Skip to content

perf(engine): a bound dense over the variable product is attached, not joined - #566

Closed
FBumann wants to merge 1 commit into
mainfrom
perf/bounds-attach-rebased
Closed

FBumann wants to merge 1 commit into
mainfrom
perf/bounds-attach-rebased

Conversation

@FBumann

@FBumann FBumann commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Replaces #511, rebased on today's main and 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_dimensions now makes every string dimension an Enum over 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 paid replace_strict per row per dim, which on profiled is 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.md still 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:

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 largest single query in the build — 0.84 s of a profiled/l build, 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:

profiled/l   1.325 s -> 0.923 s    0.684x   all [0.684, 0.694, 0.724, 0.668, 0.672, 0.682, 0.738]
dispatch/l   0.193 s -> 0.194 s    1.005x   (gate declines — bounds are per-generator)
transport/l  0.613 s -> 0.627 s    1.023x   (gate declines — bounds are per-node)

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:

dims exactly the variable's, in order fewer broadcast, more is already refused, a different order is a different row-major walk
the variable declares no where a mask makes the label frame a subset of the product, and position stops lining up
the parameter is dense height equals the product of the cardinalities binding cached
no duplicate coordinates would break density without changing the height — refused earlier by check_one_row_per_coordinate

Three 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 bounds method — it drops maintain_order='left' and guarantees the order with a verified sort in _build_variable instead. 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 · ruff clean · pyrefly 0 errors.

🤖 Generated with Claude Code

…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.
@codspeed

codspeed Bot commented Aug 11, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 9.97%

⚡ 1 improved benchmark
❌ 2 regressed benchmarks
✅ 11 untouched benchmarks
⏩ 38 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
❌ Memory test_emit[sector-s-lpspec-highs] 3.9 MB 5 MB -22.14%
❌ Memory test_emit[sector-s-lpspec-lp] 4.9 MB 5.6 MB -12.62%
⚡ Memory test_emit[profiled-s-lpspec-lp] 23.6 MB 22 MB +7.27%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing perf/bounds-attach-rebased (4948fcc) with main (ad81c35)

Open in CodSpeed

Footnotes

  1. 38 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@FBumann

FBumann commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

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).

@FBumann FBumann closed this Aug 11, 2026
FBumann pushed a commit that referenced this pull request Sep 19, 2026
…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
FBumann pushed a commit that referenced this pull request Sep 19, 2026
…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
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.

1 participant