Repository navigation
Conversation
…t joined Implements #508; supersedes #511, whose measurement and mechanism both aged. `profiled` is the rung this engine loses to linopy on, and its own model header diagnosed why: a parameter already dense over the variable product is the array xarray wants, needing neither broadcast nor alignment, while we join a full-size frame against a full-size coordinate product. On today's main that join is the single largest query in the build (0.84 s, 23%). Position can mean the coordinate here too. The label frame is in label order by construction — not lexicographic dim order, but row-major over the product in each dimension's own index order — so a row's position is computable from its own coordinates, and the values scatter into that order with no join at all. profiled/l build 1.325 s -> 0.923 s (0.684x, 7 of 7 paired rounds) `dispatch` and `transport` are 1.005x and 1.023x: their bounds are per-node or per-generator, not dense over the product, so the gate declines and nothing changes. The fast path fires on the shape it targets and nowhere else. **#541 is why this is now worth three times what #511 measured.** That PR made every string dimension an `Enum` over its labels *in ordinal order*, so the physical code already is the ordinal: `to_physical()` where #511 collected the dim table into a Python dict and paid `replace_strict` per row per dim. Only a non-string dimension still needs the dictionary, and it is built from the small side. The gate is the whole correctness argument, since a wrong bound is a wrong model with no error: dims exactly the variable's and in order, no `where`, and a height equal to the product of the cardinalities binding cached. Duplicate coordinates would break density without changing the height and are refused earlier by `check_one_row_per_coordinate`. Three tests: the attached path against the linopy oracle, the same numbers arriving shuffled — the failure an attach that trusted source order gets silently wrong — and both refusals.
Merging this PR will degrade performance by 9.97%
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing Footnotes
|
|
Superseded by #568. #511 merged while this was open, and it turned out to already contain the mechanism — the scatter and the whole gate. The only thing this PR had that main lacks is the Enum ordinal, so #568 is that as a 15-line delta instead of a wholesale replacement, with the same measured result (profiled/l 1.240 s -> 0.901 s). |
…fused, as a sum already was math-spec moves from 0.0.0-alpha.105 to c25984e, the head of energy-models/mathspec#566. The pin is a commit rather than a tag because that PR is open and stacked on two more; it is not a pin to merge on. The refusal is the outcome a model author sees. at()'s joined-column check asked whether a joined dimension was produced, which the landing check already refuses, rather than whether it was consumed, so `at(load, by=diag, over=rep, into=generator)` loaded and landed with the joined dimension gone. sum already refused the same shape. Carrying it is a port of the relation node API, which no consumer can tell apart. Walk is now Direction, with Partition split off as its own class rather than a direction with nothing consumed or produced, and the accessors that inverted between the two operators are gone: a node's ends are read off its direction, and an At reads the direction the other way round at the four sites that care. GroupSum.walk and At.walk are .direction; .over, .into, .joined and .relation are .direction.consumed_dims, .produced_dims, .joined_dims and .name, each read in the operator's own terms. Coverage moved: test_by_takes_a_relation asserts position(by=)'s new refusals — a parameter and an undeclared name now take the message every operator's by= gives, and a dimension is told by= takes the map out of it — and test_a_call_walks_one_table_and_says_so follows "walks one table" to "reads one table". The thirteen generated pages are tools.gallery_math and tools.ladder re-run; the diff is one legend line per page, "the order shift walks" becoming "the order shift steps along". Not done here: math-spec#566 also adds ExpressionComparisonNode and ArithmeticComparisonNode, which neither lane evaluates. Four tests are red on that and land with the evaluator in the PR stacked on this one, as #566's own body says they should. pytest -q -n auto: 4016 passed, 251 skipped, 1 xfailed, 4 failed — the four above. ruff check and ruff format clean. pyrefly reports 2 errors, both assert_never on those same two nodes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NHXujoUG77G2SoDbhau5mS
…h lanes math-spec#566 lets either side of a where comparison be arithmetic over parameters — `p_max > cost`, `0.5 * p_max > 0`, `sum(p_max, over=generator) > 0`. Both lanes now build it, so what the language accepts is what this package builds. A side is compiled the way a constant side of a constraint is: the relational lane folds its constant fragments over the comparison's dims and joins the value column onto the carrier, the linopy lane evaluates it to a DataArray. A coordinate no fragment reaches stays null and the comparison reads false, which is what every other atom over a missing value does. Two architectural decisions to review rather than take on trust: Hard rule 0 moves. A mask now reads an expression, and a cases expression already read a mask, so the recursion is the language's own grammar and no ordering of a lane's two modules removes it. DELIBERATE_LAZY_IMPORTS was empty and that emptiness was the claim; it now holds two entries, one per lane, and architecture.md says so. The alternative was threading an evaluator callback through masked() at seven call sites in two modules, which buys a parameter and a detour for every future reader. NEVER_LOWERED excuses ArithmeticComparisonNode from both dispatch guards. It is in program.WhereNode but lower_program rewrites every one into an ExpressionComparisonNode, so no lowered mask holds one and neither lane can dispatch on it. The exclusion is a claim about upstream, so test_no_never_lowered_node_survives_lowering checks it rather than trusting it, and the two assert_never pragmas name the same reason. Whether the union should carry a node lowering always replaces is a question for #566. Coverage moved: 'p_max > cost' leaves the refusal sweep, where it asserted "compares two parameters", and joins ACCEPTED with the two arithmetic cases, so all three are swept for lane agreement on rows and status. pytest -q -n auto: 4023 passed, 251 skipped, 1 xfailed. ruff check, ruff format and pyrefly clean, the last with 6 suppressed rather than 4. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NHXujoUG77G2SoDbhau5mS
Replaces #511, rebased on today's
mainand re-measured. Implements #508. Close #511 in favour of this.Why it is not just a rebase
#511 measured −11% on
profiled/l. This measures −32%, on the same case, for the same idea — because two things changed under it.#541 made the ordinal free.
encode_dimensionsnow makes every string dimension anEnumover its labels in ordinal order, so the physical code already is the ordinal:to_physical(), no lookup. #511 collected the dimension table into a Python dict and paidreplace_strictper row per dim, which onprofiledis two of three dimensions. Only a non-string dimension still needs the dictionary, and it is built from the small side — one entry per label, not per row.The measurement had to be redone anyway. #511's numbers predate #541 and #550 (CSR), and
docs/benchmarks.mdstill publishes from 1 Aug. The 1.46x-vs-linopy figure quoted in #511 is three engine changes stale, so the case for the change had to be re-established rather than carried over.The change
profiled's own model header diagnosed this before either PR existed:On today's
mainthat join is the largest single query in the build —0.84 sof aprofiled/lbuild, 23% (python -m bench.profile_build profiled l).Position can mean the coordinate here too. The label frame is in label order by construction — not lexicographic dim order, which is why sorting the parameter by its dim columns does not match it, but row-major over the product in each dimension's own index order. A row's position is therefore computable from its own coordinates, and the values scatter into that order with no join at all.
Measured
Interleaved A/B, both arms in one process, the branch toggled so only the decision differs. Median of paired ratios, 7 rounds:
The fast path fires on the shape it targets and nowhere else. The two controls are within noise of 1.0 and their pairs straddle it.
The gate is the whole correctness argument
A wrong bound is a wrong model with no error, so the attach is refused unless every one of these holds — each a fact already computed elsewhere:
wherecheck_one_row_per_coordinateThree tests, carried over from #511 and adjusted to the scatter: the attached path against the linopy oracle; the same numbers arriving shuffled, which is what an attach trusting source order gets silently wrong; and both refusals.
Interaction with #520
#520 rewrites the same
boundsmethod — it dropsmaintain_order='left'and guarantees the order with a verified sort in_build_variableinstead. This PR is additive against that: the attach provides position rather than preserving it, so it is correct under either mechanism, but whichever merges second reconciles one hunk. Landing this first is the smaller reconciliation.Not taken
Keeping it lazy with
pl.concat(how='horizontal')measured slightly faster in #511, but polars deprecates that concat's height padding in favour of requiring equal heights — exactly the precondition the gate checks — so the natural spelling warns until that release, and this repo treats warnings as errors. Still worth revisiting; the floor here is 1.30 and we run 1.43.942 passed ·
ruffclean ·pyrefly0 errors.🤖 Generated with Claude Code