feat(bench): a performance harness the published numbers come from - #143
Conversation
docs/benchmarks.md published a table whose harness had been deleted, so the one quantitative claim the project makes could not be re-run. This is the replacement: `bench/`, plus regenerated numbers read straight off a committed results file. Two arms, same YAML, same output format — farkas `build` + `write_lp` against linopy `to_file(io_api='lp-polars')`. One process per measurement, because peak RSS is a property of a process; `ru_maxrss` rather than a tracker, per the memray pitfall already recorded in the doc. A parity gate solves the smallest rung on both arms and aborts on disagreement, so a timing never describes two different models. Two cases, chosen for the SQL shape they stress rather than for coverage: `dispatch` (pointwise + one sum) and `transport` (three group_sum joins). What the run found, all of it now in docs/benchmarks.md: - the memory win holds and widens with size (0.40x at 10M variables), while build is ~2x slower above ~1e6 and faster below ~1e5; - peak RSS does *not* track `memory_limit`: over a 16x budget range it moves under 20%, at 2-3x the budget. Hard rule 4 stands in its comparative form, not its literal one; - `transport` at 9.8M variables under 256MB raises OutOfMemoryException in the unchunked constraint assembly, exactly where a comment asserts that plain hash aggregates spill on their own. `dispatch` at 10M does not, so the trigger is the group_sum fan-in. The last two want issues; neither is fixed here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 47 seconds Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (14)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
Two changes to what the harness measures, both prompted by the first ladder having quietly answered easier questions than it looked like it was asking. ## A real sparse model, not a random mask `dispatch` declares `where: p_max > 0` against a p_max that is always positive, so its mask removes nothing: the ladder only ever measured a fully dense coordinate product — the eager lane's best case and ours worst. The report now carries a `live` column so that reads as `100%` instead of being a trap, and the vacuous mask is kept deliberately, because paying for a mask that removes nothing is itself a measurement. The new `nodal` case is where sparsity actually comes from: dispatch over (snapshot, node, tech) where a technology only generates at a node it is installed at — no offshore wind inland, no hydro without a river. PyPSA attaches generators to buses, Calliope declares techs at nodes; in YAML it is a `where` over the capacity table. 50 nodes x 12 technologies, 3 installed per node. `Shape.density` sweeps 12 / 6 / 3 / 1 technologies per node at one model size, which is the axis the size ladder cannot show: we cost what survives, the eager lane costs the product. Ratio 3.10x -> 1.25x wall and 0.77x -> 0.59x peak as the portfolio thins. ## Timing boundaries A phase now counts only if both arms could pay it: - linopy's `to_file` defaults `progress` to `m._xCounter > 10_000`, so every rung above the smallest had been rendering tqdm bars the farkas arm has no equivalent of — ~7% of the write at 10M variables, plus stderr noise in a harness that parses stdout. Now `progress=False`. - farkas is charged for `ex.close()`, which releases the scratch database. It used to get that for free. - the parameter/dimension split is harness bookkeeping and moved out of the clock; only farkas was paying it. - `workdir_bytes` is recorded, because a peak-RSS win should not hide a multi-gigabyte temp file. 0.25 GB at 9.8M variables. Both corrections move against us: `dispatch` at 10M goes 2.09x -> 2.96x. The boundaries are now a table in bench/README.md rather than an accident of where the marks happened to sit. The 256MB OOM on `transport` still reproduces here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Pushed two changes: a real sparse case, and a fix to what the clock was actually measuring.
|
| live | variables | wall: farkas | wall: linopy | wall | peak: farkas | peak: linopy | peak |
|---|---|---|---|---|---|---|---|
| 100% (12 techs/node) | 1.2M | 1.22 s | 0.39 s | 3.10x | 0.48 GB | 0.63 GB | 0.77x |
| 50% (6) | 600k | 0.73 s | 0.32 s | 2.28x | 0.35 GB | 0.60 GB | 0.58x |
| 25% (3) | 300k | 0.44 s | 0.28 s | 1.56x | 0.25 GB | 0.45 GB | 0.55x |
| 8% (1) | 100k | 0.30 s | 0.24 s | 1.25x | 0.20 GB | 0.35 GB | 0.59x |
We cost what survives; the eager lane costs the product. Thinning the portfolio cuts our wall 4x and linopy's 1.6x. At nodal/3M the peak ratio is 0.38x, the best in the ladder.
The clock was measuring things only one arm does
A phase now counts only if both arms could pay it:
- linopy was being penalised.
to_filedefaultsprogresstom._xCounter > 10_000, so every rung above the smallest had been rendering tqdm progress bars that the farkas arm has no equivalent of — ~7% of the write at 10M variables, and stderr noise in a harness that parses stdout. Nowprogress=False. - farkas was getting teardown for free.
ex.close()releases the scratch database; a user pays for that, so it is timed and reported as its own phase. - The parameter/dimension split re-parses the YAML purely because the runner decides which parquet file is which. Only farkas was paying it; it moved outside the clock.
workdir_bytesis recorded, because a peak-RSS win should not quietly hide a multi-gigabyte temp file. 0.25 GB at 9.8M variables — small beside the LP file, but not zero.
Both corrections move against us. dispatch at 10M went 2.09x -> 2.96x, and almost none of that is the engine. The boundaries are now a table in bench/README.md instead of an accident of where the marks happened to sit.
Also
--sizes allruns every rung a case has, since the density rungs only exist on masked cases.- Parity gate passes on all three cases with bit-identical objectives.
- The 256MB OOM on
transportstill reproduces here (relevant to Constraint assembly OOMs instead of spilling when the group_sum fan-in is wide #145 and to the claim in perf: close the build-time gap, and measure the mask the ladder never measured #153 that it did not).
Overlap note: #153 is stacked on this branch and adds its own sparse.yaml with a fixed 20% Bernoulli mask. nodal supersedes it on realism and adds the density axis, but they will conflict in bench/cases.py — happy to reconcile in whichever lands second.
docs/benchmarks.mdpublished a table whose harness had been deleted — the file said so itself. The one quantitative claim the project makes could not be re-run. This addsbench/and regenerates the numbers from a committed results file.The harness
Two arms, same YAML, same output format:
fk.build+write_lpagainstModel.to_file(io_api='lp-polars'). That is the only comparison worth making first — the linopy lane accepts exactly the same language (hard rule 3), so it is an oracle rather than a rival dialect.Design, all of it forced by something already recorded in the doc:
bench/run.pynever imports farkas or linopy.ru_maxrss, not a tracker — operational finding 4 says memray slows duckdb ~8x and overcounts polars.wall_seconds— at the smallest rung linopy's import alone exceeds farkas's entire build.Two cases, chosen for the SQL shape they stress rather than for coverage:
dispatch(pointwise + onesum) andtransport(threegroup_sumjoins per row).What the run found
Parity gate passed with bit-identical objectives on both cases, so the timings describe the same model.
The memory win is real and widens with model size; the sub-10^5 win is just linopy's fixed overhead, not an engine property.
Two findings worth more than the table, both written up in
docs/benchmarks.mdand neither fixed here:memory_limit(Peak RSS does not track memory_limit: hard rule 4 holds comparatively, not literally #144). Over a 16x budget range peak at 10M variables moves 0.77 -> 0.90 GB, at 2-3x the budget. Hard rule 4 holds comparatively, not literally.transportat 9.8M variables under 256MB OOMs rather than spilling (Constraint assembly OOMs instead of spilling when the group_sum fan-in is wide #145), in the unchunked constraint assembly — directly under the comment asserting that plain hash aggregates spill on their own.dispatchat 10M is fine, so the trigger is thegroup_sumfan-in.Docs
docs/benchmarks.mdnow leads with generated results and a "what the numbers say" section that states the 2x build cost plainly. The earlier 107M-variable numbers are kept in a clearly labelled not reproducible section — nothing in the new ladder replaces that capability proof. Operational finding 6 records the OOM.ARCHITECTURE.mdgains one line pointing atbench/as how a claim here gets falsified.bench/.cache/is gitignored (regenerated deterministically);bench/results/is tracked, because the provenance is the point.🤖 Generated with Claude Code
Issues raised from this run
memory_limitgroup_sumfan-insolver_direct,storage/MILP, sparsity sweep, speed-of-light floor