perf: close the build-time gap, and measure the mask the ladder never measured - #153
perf: close the build-time gap, and measure the mask the ladder never measured#153FBumann wants to merge 4 commits into
Conversation
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
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ 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 |
…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
|
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 The pieces now have issues, each carrying this PR's measurement:
Two findings from this PR that were not code and should not be lost:
The branch stays; #173 is where its label commit gets re-cut, on top of #152 rather than beside it. |
`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>
`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>
|
Rebased onto Two conflicts worth knowing about, both resolved in favour of
That second one was not only yours: the parity gate on The 256 MB OOM is a coin flip, not a regressionI regenerated the ladder on this branch and initially read it as a regression — Confirmed by repetition on identical code,
So that budget cell is non-deterministic, and has been all along. Three consequences:
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. |
|
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
That reads a fragment's dim tuple as if it described its row count. It does not. Reproduced against the shipped compiler, with Downstream this splits: #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:
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 |
`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>
`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>
…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>
Stacked on #143 (the harness) — retarget to
mainwhen that lands.The docs blamed the wrong phase
docs/benchmarks.mdattributed the ~2× to "fixed work — re-scanning the vars table per section, ~100Mprintfcalls, a 2.6 GB CSV write". Those are all emit-side. The committed phase timings say otherwise: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.pynarrows it further:_build_variablewas 40% of all SQL time, nearly all of it the chunkedINSERT INTO var_pthat assigns labels, and the cost was the sort inROW_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 theORDER 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 BYchanges which coordinate gets which solver index, andtests/test_walkthrough.pypins 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.
The sparse case is the bigger result
dispatchmasks onp_max > 0against a p_max that is always positive — so itswhereremoves nothing and the ladder only ever measured a fully dense coord product: the eager lane's best case and ours worst.transporthas nowhereat all.bench/models/sparse.yamlkeeps ~20% of a 2-D product. On the regenerated ladder:docs/benchmarks.mdlisted 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 becausevtype = '...'comparisons still work.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.Dividespells its operandsnumerator/divisor, notleft/right, so an objective containing a division would have raisedAttributeErrorout of the match statement. There's a regression case for it now.Docs
bench/results/latest.jsonland 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:var_p— 2-D, mask on the trailing dimwhere)con_power_balance— 1-D, unmaskedvar_p— 2-D, mask reads the leading dimcon_power_balance— 1-D, unmaskedvar_p/var_f/con_balance— unmaskedNeither subsumes the other. #152 doesn't fire on
dispatchat all — itswhereis aParameterComparison, notNone— 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