Skip to content

chore(bench): a profiler, a regression suite, and the gate fix main needs today - #182

Merged
FBumann merged 3 commits into
mainfrom
bench/harness-tools
Jul 27, 2026
Merged

chore(bench): a profiler, a regression suite, and the gate fix main needs today#182
FBumann merged 3 commits into
mainfrom
bench/harness-tools

Conversation

@FBumann

@FBumann FBumann commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Split out of #178, which mixed harness work with engine changes. This half touches no src/ — it is measurement only, and one line of it is a bug fix main needs regardless of whether the perf work lands.

fix(bench): the parity gate is broken on main

#148 split the solve result into linopy's two fields — status is the solver's health ('ok'), termination_condition is what it concluded ('optimal'). It landed after the harness did, so bench/_run_case.py compares status against 'Optimal' and aborts every run on main before a single timing is taken. One line.

bench/profile_build.py: attribution, not just detection

bench/run.py says how much slower; this says where. It wraps execute and tags every statement with the build step that issued it. That is how the label sort was found — 71% of build at 10M variables in one statement — and it is what makes the next round of perf work aimable rather than speculative.

bench/regressions/: the question run.py cannot answer

run.py publishes how farkas compares to linopy, so it must measure peak RSS: that is the number a reader can check with /usr/bin/time. It is a poor regression detector, though — RSS moves with machine load, and I mis-read a coin-flip OOM as a regression twice before bisecting settled it.

This is pytest-benchmem: pytest-benchmark timing plus a memray peak pass, with isolate=True so every pass runs in a fresh process. That matters twice — duckdb would otherwise be measured with a warm buffer pool, and isolation is what makes whole-process rss available beside the memray peak, so the two can be watched for divergence.

Why memray here and not in the published ladder. Measured, dispatch/m, same runs reporting both:

arm ru_maxrss memray peak
farkas 309 MB 211 MB
linopy 604 MB 2967 MB

memray counts polars' reserved arenas as allocated and does not count the interpreter or mapped libraries at all, so the bias points in opposite directions in the two lanes: the peak ratio is 0.51x by RSS and 0.07x by memray. Publishing that would turn a defensible 2x memory win into a fictitious 14x one — false to the first reader who ran /usr/bin/time. Within one lane the same bias sits on both sides of a diff and cancels, leaving a metric that is deterministic and attributable to a call stack.

So: RSS for the comparison we publish, memray for the regressions we chase, and --benchmark-memory-compare-fail=mean:10% to make the second a gate.

uv sync --group bench
uv run pytest bench/regressions --benchmark-memory

Four workloads, ~74 s, time and both memory metrics side by side:

  Name (time in ms)                     Min        Mean   │  peak·min (MiB)   rss·min (MiB)
  test_build_and_write[dispatch-s]   143.61      145.40   │           34.58          192.75
  test_build_and_write[nodal-m]      356.37      407.45   │           69.10          230.52
  test_build_and_write[dispatch-m]   526.52      537.78   │          162.05          312.64
  test_build_and_write[transport-m]  691.52      751.12   │          196.14          393.75

Its own dependency group (--group bench), not dev — nothing in the test suite or the published ladder needs memray. testpaths stays tests, so uv run pytest does not collect it. The tests assert a column count as a cheap correctness gate: the published ladder has a parity gate against linopy, this one at least refuses to time a model it did not build.

Verification: 342 passed, 1 xfailed · ruff check + format clean · no src/ changes.

🤖 Generated with Claude Code

claude and others added 3 commits July 27, 2026 09:57
docs/benchmarks.md says the ~2x is "dominated by fixed work — re-scanning the
vars table per section, ~100M printf calls, a 2.6 GB CSV write". Those are all
emit-side costs, and the committed phase timings say emit is not the problem.

At the l rung, taking the best of each repeat:

  case/size   build fk   build lp   ratio      emit fk   emit lp   ratio
  dispatch/l     3.89s      0.40s    9.7x        2.78s     2.79s    1.0x
  transport/l    5.49s      0.90s    6.1x        3.76s     3.07s    1.2x

Emit is at parity; the whole gap is build. The phases are not doing the same
work — linopy allocates arrays cheaply and materialises coefficients at write
time, so a phase-to-phase ratio is not apples to apples — but it localises our
cost: build is 58% of our wall at dispatch/l, and emit is already as fast as
the writer we are compared against.

bench/profile_build.py tags every statement with the build step that issued
it. On dispatch/l, _build_variable is 40% of all SQL time and one statement
inside it — the chunked INSERT INTO var_p that assigns labels — is most of it.
The cost is the ORDER BY in

    ROW_NUMBER() OVER (ORDER BY t_snapshot.ord, t_generator.ord)

which sorts every chunk. Measured on the same data, building the same 10M-row
frame four ways: 8.3s as written, 2.4s with the ORDER BY dropped, and 4.4s
storing ordinals rather than coordinate values. Packing the two sort columns
into one BIGINT saves 15%, so it is the sort itself and not the key width.

Dropping the ORDER BY is not free: it changes which coordinate gets which
solver index, which tests/test_walkthrough.py pins as a golden file because
that mapping is part of the documented story.

There is a variant that keeps the mapping exactly. When the mask does not
depend on the leading dim — `where: p_max > 0` depends only on generator — the
surviving trailing coordinates are the same for every leading value, so they
can be ranked once over a tiny table and the label computed arithmetically,
with no window function and no sort. Verified by building both frames and
diffing them: 0 rows differ at 1M and at 10M, 2.2x and 3.2x faster.

Two smaller items on the same path, measured at 10M rows: the vtype column is
VARCHAR ('continuous' written ten million times) where UTINYINT is 2.5x faster
(0.87s -> 0.35s), and the objective runs SUM(coeff) GROUP BY col over groups
that each hold exactly one row when the objective has a single term, where
skipping the aggregate is 2.7x faster (0.60s -> 0.22s).

No engine change here — this commit adds the profiler and the finding. The
docs' attribution is left alone until a fix lands to replace it with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145rKirxsCRGodhkKozjaYW
`bench/run.py` publishes how farkas compares to linopy. It cannot answer "did
this PR make it worse" well: peak RSS moves with machine load, and I mis-read a
coin-flip OOM as a regression twice this week before bisecting proved otherwise.

`bench/regressions/` is the other half — pytest-benchmem, which is pytest-benchmark
timing plus a memray peak pass, with `isolate=True` so each pass gets a fresh
process. That matters twice over: duckdb would otherwise be measured with a warm
buffer pool, and isolation is what makes whole-process `rss` available beside the
memray peak, so the two can be watched for divergence.

Measured, `dispatch/m`:

| arm | ru_maxrss | memray peak |
|---|---|---|
| farkas | 309 MB | 211 MB |
| linopy | 604 MB | 2967 MB |

memray counts polars' reserved arenas as allocated and does not count the
interpreter or mapped libraries at all, so the bias points in opposite
directions in the two lanes: the peak ratio is 0.51x by RSS and 0.07x by memray.
Publishing that would turn a defensible 2x memory win into a fictitious 14x one,
false to anyone who ran /usr/bin/time. Within one lane the bias sits on both
sides of a diff and cancels.

So: RSS for the comparison we publish, memray for the regressions we chase, and
`--benchmark-memory-compare-fail` to make the second a gate.

Its own dependency group, not `dev` — nothing in the test suite or the published
ladder needs memray. `testpaths` is still `tests`, so `uv run pytest` does not
collect it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#148 split the solve result into linopy's two fields — `status` is the solver's
health ('ok'), `termination_condition` is what it concluded ('optimal'). It
landed after the harness did, so the gate has been comparing `status` against
'Optimal' and aborts every run on main before a single timing is taken.
@FBumann FBumann added chore Packaging, CI, dependencies area:engine Lowering, IR, relational executor, sinks labels Jul 27, 2026
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@FBumann, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 26 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 32e80676-0b82-432e-9588-19933ee93d20

📥 Commits

Reviewing files that changed from the base of the PR and between cd4cb02 and 165a4ec.

📒 Files selected for processing (7)
  • .gitignore
  • bench/README.md
  • bench/_run_case.py
  • bench/profile_build.py
  • bench/regressions/__init__.py
  • bench/regressions/test_build.py
  • pyproject.toml
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bench/harness-tools

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@FBumann FBumann changed the title bench: a profiler, a regression suite, and the gate fix main needs today chore(bench): a profiler, a regression suite, and the gate fix main needs today Jul 27, 2026
@FBumann
FBumann merged commit e92ba90 into main Jul 27, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:engine Lowering, IR, relational executor, sinks chore Packaging, CI, dependencies

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants