Skip to content

perf: close the build-time gap, and measure the mask the ladder never measured - #153

Closed
FBumann wants to merge 4 commits into
worktree-bench-harnessfrom
claude/investigate-build-throughput
Closed

perf: close the build-time gap, and measure the mask the ladder never measured#153
FBumann wants to merge 4 commits into
worktree-bench-harnessfrom
claude/investigate-build-throughput

Conversation

@FBumann

@FBumann FBumann commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Stacked on #143 (the harness) — retarget to main when that lands.

Overlaps #152. That PR does a narrower version of the same idea and the two are complementary, not competing. Details and a per-case table at the bottom — worth reading before merging either.

The docs blamed the wrong phase

docs/benchmarks.md attributed the ~2× to "fixed work — re-scanning the vars table per section, ~100M printf calls, a 2.6 GB CSV write". Those are all emit-side. The committed phase timings say otherwise:

case/size build: fk build: linopy emit: fk emit: linopy
dispatch/l 3.89s 0.40s 9.7× 2.78s 2.79s 1.0×
transport/l 5.49s 0.90s 6.1× 3.76s 3.07s 1.2×

Emit was already at parity with the writer we're compared against. The whole gap was build. (The phases don't do the same work — linopy defers coefficient materialisation to emit — so the ratio isn't apples-to-apples, but it localises our cost: build was 58% of our wall.)

bench/profile_build.py narrows it further: _build_variable was 40% of all SQL time, nearly all of it the chunked INSERT INTO var_p that assigns labels, and the cost was the sort in ROW_NUMBER() OVER (ORDER BY t_snapshot.ord, t_generator.ord). Building the same 10M-row frame four ways: 8.3s as written, 2.4s without the ORDER BY, 4.4s storing ordinals instead of values. Packing both sort keys into one BIGINT saves 15% — so it's the sort, not the key width.

The fix keeps the labels

Dropping the ORDER BY changes which coordinate gets which solver index, and tests/test_walkthrough.py pins that as a golden file. So instead: when the mask doesn't read the leading dim, the surviving trailing coordinates are identical for every leading value — rank them once over a tiny table and the label is (lead.ord - lo) * width + rank. No window, no sort.

Verified rather than assumed: built both frames and diffed them — 0 differing rows at 1M and at 10M, and the same on the walkthrough model.

case/size build before build after vs linopy
dispatch/m 1.27s 0.88s 2.20× → 1.67×
dispatch/l 8.66s 5.01s 1.35× → 0.90×
transport/m 1.56s 1.10s 2.03× → 1.65×
transport/l 10.67s 7.27s 1.65× → 1.31×

The sparse case is the bigger result

dispatch masks on p_max > 0 against a p_max that is always positive — so its where removes nothing and the ladder only ever measured a fully dense coord product: the eager lane's best case and ours worst. transport has no where at all.

bench/models/sparse.yaml keeps ~20% of a 2-D product. On the regenerated ladder:

case, top rung wall peak
dispatch, 10M 1.00× 0.28×
transport, 9.8M 1.21× 0.48×
sparse, 2.1M live 0.57× 0.20×

docs/benchmarks.md listed a where-density sweep under "not measured yet" and predicted the gap would be "qualitative rather than 2×". It is — in our favour. The case also reads the leading dim, so it exercises the general sorted path rather than the new arithmetic one; the ladder now covers both.

Two smaller items

  • vtype → ENUM. 'continuous' was written as VARCHAR once per column, the widest thing on the row. 0.87s → 0.48s at 10M, and no sink changed because vtype = '...' comparisons still work.
  • The objective's GROUP BY, skipped only where provably dead: one fragment, one variable, fragment dims == variable dims. 0.60s → 0.22s.

pyrefly caught a real bug in the walker that decides the second one: plan.Divide spells its operands numerator/divisor, not left/right, so an objective containing a division would have raised AttributeError out of the match statement. There's a regression case for it now.

Docs

bench/results/latest.jsonl and the Results tables are a fresh full ladder on Linux x86-64, replacing a macOS/M-series set the label change had invalidated; the doc says explicitly not to compare them. Two stale claims corrected: the emit-side attribution above, and the transport 256 MB OOM — which did not reproduce here, recorded as the failure being marginal rather than fixed, since both the machine and the label frame changed.

Relationship to #152

#152 ("unmasked label assignment needs no sort") computes the row-major label in closed form when where is None. Mine ranks survivors into a small table when the mask doesn't read the leading dim. Measured, per label frame in the ladder:

frame this PR #152
dispatch var_p — 2-D, mask on the trailing dim arithmetic no (has a where)
dispatch con_power_balance — 1-D, unmasked sorted window fires
sparse var_p — 2-D, mask reads the leading dim sorted window no
sparse con_power_balance — 1-D, unmasked sorted window fires
transport var_p / var_f / con_balance — unmasked arithmetic fires

Neither subsumes the other. #152 doesn't fire on dispatch at all — its where is a ParameterComparison, not None — which is the case that produced the headline "2× slower". This PR doesn't fire on 1-D frames, where #152 does. Where both fire, #152's closed form should win: it needs no join.

The best end state is all three tiers — closed form when unmasked, ranked survivors when the mask factors, window otherwise — but that is a merge of someone else's open PR, so I've left it. Whichever lands second will conflict in _label_frame; happy to do the combining.

Verification

uv run pytest — 316 passed, 1 xfailed · ruff check + format clean · pyrefly 0 errors · parity gate green on all three cases (bit-identical objectives).

🤖 Generated with Claude Code

https://claude.ai/code/session_0145rKirxsCRGodhkKozjaYW


Generated by Claude Code

claude added 3 commits July 26, 2026 20:44
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
The label frame was the single most expensive statement in build, and the cost
was the sort inside

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

When the mask does not read the leading dim, that sort is avoidable without
changing a single label. `where: "p_max > 0"` depends only on generator, so
the surviving trailing coordinates are identical for every snapshot: rank them
once over a table of size prod(rest), and the label is
`(lead.ord - lo) * width + rank` — no window function, no per-chunk sort. The
general path stays for masks that do read the leading dim.

Measured with bench/run.py, best of the repeats, on this machine:

  case/size    build before  build after          vs linopy
  dispatch/m       1.27s        0.88s        2.20x -> 1.67x
  dispatch/l       8.66s        5.01s        1.35x -> 0.90x
  transport/m      1.56s        1.10s        2.03x -> 1.65x
  transport/l     10.67s        7.27s        1.65x -> 1.31x

dispatch/l crosses over: farkas is now faster than linopy end to end at 10M
variables. The absolute ratios are machine-specific and differ from the
committed macOS numbers, so the delta is the reliable part; docs/benchmarks.md
is left for a run on the machine its ladder was measured on.

Labels are unchanged, and that is checked rather than assumed: building the
same 10M-row frame both ways and diffing gives 0 differing rows, and the same
holds on the walkthrough model.

One test moved. examples/walkthrough.py printed `sol.primal('p').head(6)`
unsorted, so its golden file pinned the physical storage order of var_p —
which this change alters even though the labels do not. primal() is a label
join and a join has no inherent row order, so the example now sorts
explicitly. That makes the golden stable under any future storage change
(chunking, parallelism, a duckdb upgrade), which it was not before.

Left alone deliberately: `primal()` itself still returns rows in whatever
order the join produces. Adding an ORDER BY there would also hit
_solution_to_parquet, which streams, and a global sort is exactly what that
path exists to avoid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145rKirxsCRGodhkKozjaYW
…s missing

Three things, all following from the build-time investigation.

**vtype as an ENUM.** `cols.vtype` held one of three literals as VARCHAR, so
'continuous' was written once per column — the widest thing on the row. An
ENUM stores the tag and every `vtype = '...'` comparison in the sinks keeps
working untouched, which is why no sink changed. Measured alone at 10M rows:
0.87s -> 0.48s.

**The objective's GROUP BY, where it is provably dead.** `INSERT INTO obj
SELECT col, SUM(coeff) ... GROUP BY col` exists because several terms can land
on one column. In the common shape — `p * cost`, one fragment over one
variable — every group holds exactly one row, and a hash aggregate over ten
million singleton groups is not free. It is skipped exactly when there is one
fragment, it mentions one variable, and its dims are that variable's: rows are
then keyed by (variable dims, var_label), and var_label already determines the
coordinates. Anything else keeps the aggregate. Measured alone: 0.60s -> 0.22s.

pyrefly caught a real bug in the walker that decides this: `plan.Divide`
spells its operands numerator/divisor, not left/right, so an objective
containing a division would have raised AttributeError out of the match
statement rather than matching. The tests did not have one; they do now.

**A sparse case.** `dispatch` masks on `p_max > 0` and the generated p_max is
always positive, so its `where` removes nothing: the ladder only ever measured
a fully dense coord product, which is the eager lane's best case and ours
worst. `bench/models/sparse.yaml` keeps ~20% of a 2-D product, and the shape
inverts — 0.57x wall and 0.20x peak at 2.1M live variables. docs/benchmarks.md
listed a where-density sweep under "not measured yet" and predicted the gap
would be "qualitative rather than 2x"; it is, in our favour. Its mask also
reads the leading dim, so it exercises the general sorted label path rather
than the arithmetic one, and the ladder now covers both.

**Numbers regenerated.** bench/results/latest.jsonl and the Results tables are
a fresh full ladder on this machine (Linux x86-64), replacing a macOS/M-series
set the label-frame change had invalidated anyway; the doc says not to compare
them. dispatch is now 1.00x at 10M and transport 1.21x.

Two stale claims corrected while there. The old runtime attribution blamed
emit-side work (printf calls, the CSV write) when emit was already at parity
and the whole gap was build — the conclusion it supported, that a bigger budget
does not help, survives for a different reason. And the transport OOM at 256 MB
did not reproduce here; that is recorded as the failure being marginal rather
than fixed, since both the machine and the label frame changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145rKirxsCRGodhkKozjaYW
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8d5f6ccf-441f-4365-a9dd-fb3db70bd733

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/investigate-build-throughput

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.

…ects

The ladder measures `write_lp`. The shipped path is `solver_direct`, and it
had never been timed — docs/benchmarks.md lists it first under "not measured
yet". Timed with HiGHS's solver stubbed out so only the model load counts, at
1M variables, best of three: LP text 0.38s, solver_direct 3.99s.

That is backwards for a sink whose stated reason to exist is "no float->text->
parse round trip", and the cause was neither duckdb nor HiGHS. Profiling put
92% of it outside both: `batch.to_pydict()` turns an Arrow record batch into
python lists — one float object per cell, five million of them per million
columns — which `np.asarray` then parses straight back into the buffer Arrow
already had. Reading the column instead is 26x faster in isolation:

    to_pydict + np.asarray   2.62s
    column().to_numpy()      0.10s

Integrality moves out of the batch loop while we are here. It was a per-batch
string compare against 'continuous' over every column; it is now its own
streamed pass over `WHERE vtype != 'continuous'`, which is empty for a pure LP
and never allocates. That also keeps the ENUM off the hot path.

dispatch/m handoff: 3.99s -> 0.43s, a 9.4x speedup, and now at parity with
writing the LP file (0.38s) rather than 11x worse than it.

What is left is balanced and mostly not ours. cProfile at 10M variables:
addCols and addRows are 3.1s of 7.5s and belong to HiGHS, fetchnumpy and the
Arrow reader another 3.8s. `batch_rows` is a real tunable — 100k/500k/2M/10M
give 7.2/5.4/6.3/6.5s — but the sweet spot is shallow and buys memory for
time, so the default stays where it is and the curve is recorded here instead.

Verified against the oracle rather than only by the suite: solver_direct and
linopy agree to every printed digit on the dispatch case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145rKirxsCRGodhkKozjaYW
@FBumann

FBumann commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Closing in favour of one PR per change. Nothing here was wrong — the opposite, the measurements are the best evidence we have on build cost — but five independent changes in one PR means one changelog line for five perf: entries, no way to attribute the end-to-end numbers to any single change, and no revert boundary between them.

The pieces now have issues, each carrying this PR's measurement:

commit issue
387c5c1d stream columns through Arrow buffers #171
07ed9a87 vtype ENUM #170
07ed9a87 objective GROUP BY col #172
ef3aa375 labels when the mask factors #173
86849dc5 bench: attribute build time to the SQL folds into #143
the sparse rung + ladder regeneration #147

Two findings from this PR that were not code and should not be lost:

  • The ladder never measured a mask that bites. dispatch guards on p_max > 0 against a p_max that is always positive, and transport has no where at all — so every published number is the fully dense coordinate product, our worst case and the eager lane's best. bench/models/sparse.yaml at ~20% density came out at 0.57x wall / 0.20x peak, i.e. the gap is in our favour once a mask does anything. Recorded on Benchmark coverage: solver_direct, storage/MILP cases, sparsity sweep, speed-of-light floor #147.
  • docs/benchmarks.md blamed the wrong phase. It attributed the ~2x to emit-side work (printf, the CSV write); the phase split says emit was already at parity and the whole gap was build. That correction belongs with whichever PR lands the ladder regeneration.

The branch stays; #173 is where its label commit gets re-cut, on top of #152 rather than beside it.

@FBumann FBumann closed this Jul 27, 2026
FBumann added a commit that referenced this pull request Jul 27, 2026
`vtype` is one of three literals written once per column, which as VARCHAR
made it the widest thing on the row — and the row count is the model's
column count. An ENUM stores the tag instead.

Nothing downstream moves: `vtype = '...'` comparisons in both sinks keep
working against an ENUM unchanged.

Measured on the branch this was split out of (#153): 0.87s -> 0.48s writing
`cols` at 10M columns.

The members come off `plan.VariableType` rather than being typed out, so the
Literal stays the single declaration. The test pins that: an ENUM rejects a
value it does not know, so a fourth variable type added to the plan and not
here would otherwise fail at insert with a duckdb cast error a long way from
its cause.

Closes #170.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FBumann added a commit that referenced this pull request Jul 27, 2026
`vtype` is one of three literals written once per column, which as VARCHAR
made it the widest thing on the row — and the row count is the model's
column count. An ENUM stores the tag instead.

Nothing downstream moves: `vtype = '...'` comparisons in both sinks keep
working against an ENUM unchanged.

Measured on the branch this was split out of (#153): 0.87s -> 0.48s writing
`cols` at 10M columns.

The members come off `plan.VariableType` rather than being typed out, so the
Literal stays the single declaration. The test pins that: an ENUM rejects a
value it does not know, so a fourth variable type added to the plan and not
here would otherwise fail at insert with a duckdb cast error a long way from
its cause.

Closes #170.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@FBumann

FBumann commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto main (#143 is merged) and dropped sparse.yaml#143 landed nodal instead, which is the same axis with a model that occurs in the wild: dispatch over (snapshot, node, tech) where a technology only generates at a node it is installed at. It also sweeps density (12 / 6 / 3 / 1 technologies per node) rather than fixing one value. Your executor and sink commits are unchanged; only the bench case and its docs were dropped.

Two conflicts worth knowing about, both resolved in favour of main:

  • examples/walkthrough.pyfeat!: a solve result tells you whether it has one #148 renamed Solution to Result and split status / termination_condition. Kept main's naming plus your explicit sort_values on primal(), since the reason for it (a label join has no inherent row order) is unaffected by the rename.
  • tests/test_relational.py — your objective-aggregate test asserted status == 'Optimal', which is now termination_condition == 'optimal'.

That second one was not only yours: the parity gate on main has the same bug and would abort every run. Fixed here in fix(bench): the parity gate reads termination_condition, not status.

The 256 MB OOM is a coin flip, not a regression

I regenerated the ladder on this branch and initially read it as a regression — dispatch/10M and nodal/3M now OOM at 256 MB where they used to build. Bisecting across your four commits gave a non-monotonic answer (commit 2 OOMs, commit 3 succeeds, commit 4 OOMs), which is the signature of marginality rather than a cause.

Confirmed by repetition on identical code, dispatch/10M at 256 MB:

2 of 6 runs completed, 4 OOMed.

So that budget cell is non-deterministic, and has been all along. Three consequences:

  1. It explains why you could not reproduce the transport OOM — it is a coin flip, not a platform difference. Constraint assembly OOMs instead of spilling when the group_sum fan-in is wide #145 should be re-characterised: the build at 10⁷ variables under 256 MB fails non-deterministically, which is worse than a deterministic failure, because it is a flaky failure in production rather than an honest error.
  2. Your changes are not the cause and are not blamed for it.
  3. Your changes do help at generous budgets — peak drops on every case at 1 GB (dispatch 0.88 → 0.74 GB, nodal 0.58 → 0.51, transport 1.35 → 1.19) — but not enough to clear the cliff.

The harness should report a failure rate for tight-budget cells rather than a single cell that happens to be whichever way the last run fell. That is a follow-up on the harness, not on this PR.

Verification here: 341 passed, 1 xfailed · ruff + format clean · parity gate bit-identical on all three cases.

@FBumann

FBumann commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Correction to the comment above. It said the pieces here were fine and needed only splitting. That was true of the two I had looked at; it is not true of all four.

Re-deriving 07ed9a87's objective change for #172 (now PR #179) turned up a silent wrong answer in the condition it used to decide when the GROUP BY col can be skipped:

one fragment, one variable, and the fragment's dims equal the variable's declared dims

That reads a fragment's dim tuple as if it described its row count. It does not. sum(…, over=d) in this engine is not a SQL aggregate — _sum_fragment drops d from the select list and leaves the rows alone, deferring the actual addition to the terminal aggregate this condition is trying to remove. So a fragment can report exactly the variable's dims while still holding many rows per var_label.

Reproduced against the shipped compiler, with q indexed by snapshot alone and price by (snapshot, generator):

objective      : sum(q * price, over=generator)
fragment dims  : ('snapshot',)
declared dims  : ('snapshot',)
condition fires: True
obj rows       : [(0,1.0), (0,2.0), (0,3.0), (1,1.0), (1,2.0), (1,3.0)]
expected       : [(0,6.0), (1,6.0)]

Downstream this splits: lp_file writes the duplicates as separate text terms and the LP format re-sums them, so it is accidentally right; solver_direct does cols LEFT JOIN obj USING (col), and a join against duplicate keys multiplies rows, so addCols appends more columns than the model has and every later column index shifts. The solve runs and returns a meaningless number.

#179 keeps the optimisation with a condition that holds, and pins this case as a test — one of its mutations is reverting to the condition above.

Status of the other three pieces, so nobody assumes more verification than happened:

piece issue state
vtype ENUM #170 re-derived and tested (PR #174)
objective GROUP BY #172 re-derived — bug found, fixed in #179
Arrow column ingest #171 not re-derived
labels when the mask factors #173 not re-derived — and it changes label assignment, so it is the one where a wrong condition costs most

Nothing here reflects badly on the measurements, which were good and are the reason these numbers exist at all. The lesson is narrower: a condition that decides when to skip a correctness-preserving step needs its counterexample hunted deliberately, not just a passing test suite — the differential tests did not catch this, because no model in them has a sum over a dim its variable lacks.

FBumann added a commit that referenced this pull request Jul 27, 2026
`vtype` is one of three literals written once per column, which as VARCHAR
made it the widest thing on the row — and the row count is the model's
column count. An ENUM stores the tag instead.

Nothing downstream moves: `vtype = '...'` comparisons in both sinks keep
working against an ENUM unchanged.

Measured on the branch this was split out of (#153): 0.87s -> 0.48s writing
`cols` at 10M columns.

The members come off `plan.VariableType` rather than being typed out, so the
Literal stays the single declaration. The test pins that: an ENUM rejects a
value it does not know, so a fourth variable type added to the plan and not
here would otherwise fail at insert with a duckdb cast error a long way from
its cause.

Closes #170.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FBumann added a commit that referenced this pull request Jul 28, 2026
`vtype` is one of three literals written once per column, which as VARCHAR
made it the widest thing on the row — and the row count is the model's
column count. An ENUM stores the tag instead.

Nothing downstream moves: `vtype = '...'` comparisons in both sinks keep
working against an ENUM unchanged.

Measured on the branch this was split out of (#153): 0.87s -> 0.48s writing
`cols` at 10M columns.

The members come off `plan.VariableType` rather than being typed out, so the
Literal stays the single declaration. The test pins that: an ENUM rejects a
value it does not know, so a fourth variable type added to the plan and not
here would otherwise fail at insert with a duckdb cast error a long way from
its cause.

Closes #170.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
FBumann added a commit that referenced this pull request Jul 28, 2026
…sult (#231)

* bench: a case where I/O is not noise, and a sink axis to measure it on (#205)

Two changes to the harness, both about measuring what users actually do.

**`profiled`, the case whose input is the same size as its model.** Every
existing case builds its coordinate product from parameters far smaller than
the product they explode into — 2-15% of it — so reading the parquet is 2-9%
of the build and the read path is lost in the margin. `profiled` is `nodal`'s
cardinalities with the mask removed and `availability` dense over
`(snapshot, node, tech)`: at the `l` rung, a 12M-row table against a 12M
coordinate product. That makes the read path, the join, and the
in-memory-vs-parquet question measurable rather than noise.

It is also the shape the eager lane handles best — a parameter already dense
over the variable product is the array xarray wants, no broadcast and no
alignment — while we join a full-size frame against a full-size coordinate
product. `nodal` is the mirror image and our clearest win. Carrying both is
what keeps the ladder from holding only shapes that suit one engine. Closes #202.

**Both sinks, not just the LP file.** `--sinks lp highs`, defaulting to both.
The LP file is the artifact fewest callers want, and it is not the same
comparison: at `profiled/m` the peak ratio is 0.60x through the LP writer and
0.94x through the handoff, because HiGHS's own dense model is resident in both
arms and swamps the difference between them.

The `highs` sink stops at the handoff — `run()` is never called. The simplex is
the same work whoever filled the model, so including it would swamp the phase
this harness exists to measure. `solve_direct` is split into `build_highs` +
the solve so there is a seam to stop at; linopy's `Model.to_highspy()` is the
same seam on the other side, which is what makes the two arms comparable.
A rung the sink is capped out of is written to the JSONL as a `skipped` record
and footnoted in the report, so a coverage hole cannot read as a result.

Measured, `profiled` at the `l` rung (12M variables): LP file, farkas 8.42 s /
1.49 GB against linopy 2.44 s / 3.26 GB; HiGHS handoff, farkas 7.24 s / 2.60 GB
against linopy 2.93 s / 3.23 GB. The parity gate passes on the new case.

No published numbers change here: `bench/results/latest.jsonl` is left as it
was, since re-running the ladder is its own act.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: refuse a parameter source that carries a coordinate twice (#201)

A parameter is a function of its dims, so two rows for one coordinate has no
defined answer. The eager lane already refuses such a source — `xarray` will
not lay out a duplicated index — while the relational lane quietly resolved it
into a sum. Two lanes that accept the same language were accepting different
data, and the one that accepted more gave an answer nobody asked for.

Now it raises a `DataError` naming the parameter and up to three offending
coordinates. One `GROUP BY … HAVING count(*) > 1` per parameter, over a source
orders of magnitude smaller than the model built from it.

`test_a_coordinate_must_be_single_valued` had to be narrowed: it doubled the
whole generator frame, which feeds both the coordinate index *and* two
parameters, so the new check now fires first and correctly. It doubles only the
index; the parameter case is its own test beside it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(main): release 0.0.0-alpha.27 (#210)

Co-authored-by: fluxopt-release[bot] <267260463+fluxopt-release[bot]@users.noreply.github.com>

* fix: three ways an objective or a scalar parameter answered quietly (#223)

* fix: an objective sums the terms it names, once each

Two ways an objective quietly answered a question nobody asked.

`x * a + y * b`, with `x, a` on `i` and `y, b` on `j`, is `|i| + |j|` summands.
The relational lane knew that — an expression there is a set of term
fragments, each keeping its own dims until the objective sums it. The eager
lane added the two operands first, which is linopy's `+`, which broadcasts:
every term counted once per coordinate of its sibling, `|i| * |j|` summands,
8.8x apart on the mixed-density case that found it (#197). Hard rule 3 says
the lanes mean the same thing, and here the relational one was right.

So the eager lane distributes the sum over addition and sums each product on
its own. The distribution has to survive an operator applied to the group —
`(x * a + y * b) * c` was wrong by the same factor — so `*`, `/` and unary
minus are walked too, and everything else stays one opaque term.

Separately, `objectives.<name>.equations` is a list of which only the first
entry was ever read. It reads as "several terms in one objective", because
that is what the key means under a constraint, and the answer was wrong by
whatever the ignored entries were worth with nothing to see (#198). It is now
a load error naming the rewrite, spelled from what was written.

SPEC said "only equations[0] is used" in §2 and "the last defined wins" for
multi-objective in §11 — the second was already false, since a second
objective has been a load error for a while.

Closes #197
Closes #198

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a dimensionless parameter is one row, and now has to be

`_check_one_row_per_coordinate` returned early when a parameter had no dims,
on the reading that there was no coordinate to group by. But a parameter with
no dims has exactly one coordinate — the empty one — so the rule it was
skipping is the rule that matters most there.

The join that broadcasts a dimensionless parameter is `ON TRUE`, which is
correct broadcast semantics for one row and a silent row multiplication for
two: duplicate `cols` entries for one variable in a bound, duplicate mask rows
in a where. Both built and solved without a word. A user who gets here has
usually passed a column they thought was indexed, so the message says what a
dimensionless parameter means rather than only what is wrong with the source.

Zero rows is caught by the same count, and was the other half of the hole.

One `count(*)` per scalar parameter, at metadata level.

Closes #166

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(main): release 0.0.0-alpha.28 (#226)

Co-authored-by: fluxopt-release[bot] <267260463+fluxopt-release[bot]@users.noreply.github.com>

* perf: compute variable labels arithmetically when the mask factors (#178)

The label frame was the single most expensive statement in build, and the cost
was the sort inside

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

When the mask does not read the leading dim, that sort is avoidable without
changing a single label. `where: "p_max > 0"` depends only on generator, so
the surviving trailing coordinates are identical for every snapshot: rank them
once over a table of size prod(rest), and the label is
`(lead.ord - lo) * width + rank` — no window function, no per-chunk sort. The
general path stays for masks that do read the leading dim.

Measured with bench/run.py, best of the repeats, on this machine:

  case/size    build before  build after          vs linopy
  dispatch/m       1.27s        0.88s        2.20x -> 1.67x
  dispatch/l       8.66s        5.01s        1.35x -> 0.90x
  transport/m      1.56s        1.10s        2.03x -> 1.65x
  transport/l     10.67s        7.27s        1.65x -> 1.31x

dispatch/l crosses over: farkas is now faster than linopy end to end at 10M
variables. The absolute ratios are machine-specific and differ from the
committed macOS numbers, so the delta is the reliable part; docs/benchmarks.md
is left for a run on the machine its ladder was measured on.

Labels are unchanged, and that is checked rather than assumed: building the
same 10M-row frame both ways and diffing gives 0 differing rows, and the same
holds on the walkthrough model.

One test moved. examples/walkthrough.py printed `sol.primal('p').head(6)`
unsorted, so its golden file pinned the physical storage order of var_p —
which this change alters even though the labels do not. primal() is a label
join and a join has no inherent row order, so the example now sorts
explicitly. That makes the golden stable under any future storage change
(chunking, parallelism, a duckdb upgrade), which it was not before.

Left alone deliberately: `primal()` itself still returns rows in whatever
order the join produces. Adding an ORDER BY there would also hit
_solution_to_parquet, which streams, and a global sort is exactly what that
path exists to avoid.


Claude-Session: https://claude.ai/code/session_0145rKirxsCRGodhkKozjaYW

Co-authored-by: Claude <noreply@anthropic.com>

* perf: cols.vtype is an ENUM, not a VARCHAR per column (#174)

`vtype` is one of three literals written once per column, which as VARCHAR
made it the widest thing on the row — and the row count is the model's
column count. An ENUM stores the tag instead.

Nothing downstream moves: `vtype = '...'` comparisons in both sinks keep
working against an ENUM unchanged.

Measured on the branch this was split out of (#153): 0.87s -> 0.48s writing
`cols` at 10M columns.

The members come off `plan.VariableType` rather than being typed out, so the
Literal stays the single declaration. The test pins that: an ENUM rejects a
value it does not know, so a fourth variable type added to the plan and not
here would otherwise fail at insert with a duckdb cast error a long way from
its cause.

Closes #170.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(main): release 0.0.0-alpha.29 (#227)

Co-authored-by: fluxopt-release[bot] <267260463+fluxopt-release[bot]@users.noreply.github.com>

* perf: unmasked label assignment needs no sort (#152)

`_label_frame` numbered every coordinate with
`ROW_NUMBER() OVER (ORDER BY ord, ...)`, a full sort of every chunk. It was
71% of build at 10M variables (#146), and the sort is the whole cost: the
same statement without the ORDER BY runs at the join floor.

When nothing is masked out, that rank has a closed form -- it is the
mixed-radix value of the `ord` tuple -- so the label can be computed instead
of sorted for. Isolated at 10M rows: 4.37s -> 0.56s. End to end, interleaved
best-of-three, `transport` at 9.8M variables: build 10.19s -> 6.52s (-36%).

The labels are *the same integers*, not merely valid ones, which is what
makes this a pure optimisation rather than a decision. #151 weighed dropping
the ordering guarantee instead and had to leave three questions open --
`solver_direct`'s batching, reproducibility (#109), and the locality of `A`.
None of them arise here: nothing downstream can tell the two apart, and the
walkthrough golden that a renumbering would have forced to regenerate is
untouched.

Masked frames keep the window. Denseness there depends on which rows survive
the predicate, and no closed form knows that -- so `dispatch`, whose variable
is masked by `where: p_max > 0`, is unchanged by this commit.

Verified: HiGHS `addRows` is insensitive to column-index order within a row
(bit-identical objective and primal under reversed and shuffled indices), so
the `ORDER BY` was not load-bearing for the shipped path either.

332 passed; ruff and pyrefly clean.


Claude-Session: https://claude.ai/code/session_013DtGqM3z2DTtT556178iXi

Co-authored-by: Claude <noreply@anthropic.com>

* chore(main): release 0.0.0-alpha.30 (#228)

Co-authored-by: fluxopt-release[bot] <267260463+fluxopt-release[bot]@users.noreply.github.com>

* docs(bench): re-run the ladder on the merged tree

Full ladder after merging main: five cases, two sinks, both arms, best of
three, parity gate green on all five. 288 records, no failures.

**Every ratio is unchanged within noise, and that is the expected result.**
main's three duckdb perf PRs cannot move this comparison: neither arm here is
duckdb. The farkas arm is the polars engine and the linopy arm is
`farkas.linopy.build`, so the work in #152, #174 and #178 has nothing to act
on — their ideas already arrived on this branch as `_positional`, #211 and
#212, and it is those that the numbers already reflected.

`transport` reads 1.59x / 0.81x where it read 1.62x / 0.78x, `sector` 0.31x
where it read 0.32x, and so on. The tables and every figure quoted in the
prose are refreshed off the new file so the doc and its provenance agree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: route the objective tests' pandas through the oracle guard

`test_objective.py` arrived from main with a bare `import pandas as pd`. It
merged without a conflict, so nothing flagged it — and on this branch pandas
is not a core dependency, so the bare-install CI job died collecting it.

Every other test module on this branch takes pandas from `tests.oracle`, which
is a `pytest.importorskip` behind the `[linopy]` extra. `test_duals.py` even
carries the comment this file needed: "through the guard: a bare import would
beat it".

Reproduced against an install with no pandas, linopy or xarray: 11 skipped
and a collection error before, 214 passed and 21 skipped after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: fluxopt-release[bot] <267260463+fluxopt-release[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants