Skip to content

perf(engine): an update that gives a bound, a right-hand side or a cost new numbers at the coordinates it had skips most of the build - #2070

Open
FBumann wants to merge 5 commits into
perf/update-reads-what-changedfrom
perf/update-numbers-only
Open

FBumann wants to merge 5 commits into
perf/update-reads-what-changedfrom
perf/update-numbers-only

Conversation

@FBumann

@FBumann FBumann commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Prompt: Build it properly

Note

The following content was generated by AI.

An update whose parameters feed only bounds, constant sides and the objective, with the coordinates they had, keeps the labels and matrix and re-reads only what they feed. On top of #2069, such an update takes 0.17–0.33× the time; any other update builds in full, unchanged.

What changed

Engine.build takes the names an update replaced (Model.update already passes them, #2069). Where assembly.numbers_only holds, assembly.refilled replaces the build:

  • numbers_only: every changed name is a parameter; none is structural; each is attached at exactly the coordinates the last build attached it at (equal height and no new coordinate, the door refusing duplicates); and no constraint it feeds dropped a row with no terms.
  • Structural (_structural_parameters) is a parameter a build reads for which columns, rows or matrix entries exist: a variable's or a constraint's where, a cases: region's condition, a shift's or a window's named amount, and, inside a constraint, a factor of a variable-carrying product or a divisor of a variable-carrying numerator. A power of a variable is refused at load, so it has no branch. In the objective every use is read again, so none there is structural.
  • The dropped-row condition: refuse_null_constants and the gap check of constant_side read the rows a constraint labelled; the stored frame holds only the rows that kept a term. A constraint that dropped one builds in full, so a new divisor of zero on that row is refused as a fresh build refuses it.
  • refilled: on the stored label frames, reads again each variable's bounds that name a changed parameter (_columns), each constraint's constant side that does (_compiled + _constant_side), and the whole objective if it reads one, and splices each share over the run of labels its declaration owns. Labels, matrix, quadratic rows and sets are the last build's. Measured is the last build's with those entries replaced, and its bound and right-hand-side ranges put back in build order (_in_build_order), so diagnostics() lists each block where a fresh build lists it.
  • _build_variable and _build_constraint call the same _columns, _compiled and _constant_side, so the two paths share one copy of each.

The held solver is untouched: it still compares the digest and the coefficients and takes the new numbers by value, or loads again where a quadratic constraint's right-hand side moved.

Docs: docs/howto/faster.md ("Keep the model loaded") and the labels paragraph of docs/development/architecture.md say what such an update skips.

Coverage moved. test_a_rebuild_leaves_the_held_solver_pinning_none_of_the_old_model held a weak reference to the old matrix, which the new model now shares by design. It holds one to the old rows instead, which a new load replaces.

Probes

tests/test_update.py:

  • test_a_change_of_numbers_alone_hands_over_what_a_fresh_build_does, every port but tsp_mtz: each real-valued parameter in turn ×1.25 + 0.5, the model's hand-off and diagnostics() compared with a fresh build of that data (frames to 1e-12, obj by column; shape, omissions, sparse parameters and every range). Over 264 such updates in 42 ports, 177 take the numbers-only path, and every port has at least one. The diagnostics comparison came after a first version put a re-read block's range at the end of its frame: 29 of the 42 ports failed it before the fix.
  • test_new_numbers_are_read_again_on_the_old_labels_only_where_nothing_else_can_move, one parameter per role: a bound, a constant side and a cost take the path and stay loaded; a variable where, a constraint where, a coefficient, a divisor, fewer coordinates and a new index build in full. Each also compares the hand-off and the optimum with a fresh build.
  • test_a_parameter_that_picks_which_entries_exist_is_built_again: a cases: condition over variables, and a shift's named offset.
  • test_as_many_coordinates_but_other_ones_build_in_full: a missing: absent bound moved from one generator to another.
  • test_new_numbers_on_a_row_the_build_dropped_are_refused_as_a_fresh_build_refuses_them: a sparse coefficient leaves a row with no term, and a divisor of zero there is refused in a fresh build's words.

Outside the suite, on the bench models at s and xs: storage load and p_max and network load and p_max take the path, sector demand and nodal installed do not, and all six hand-offs equal a fresh build's.

Mutation table

Each guard broken by one text substitution, the full suite run (pytest -q -n 4), the file restored with git checkout --, __pycache__ dropped on both sides, the tree clean after each. At 07241eb; the "coordinates" row again at c4d2848 with its probe; the last two rows at c70193d.

mutation result
a changed name that is not a parameter allowed caught, 296 failed, 60 errors
no parameter structural caught, 46 failed
the dropped-row fallback removed caught, 1 failed
the same-coordinates check removed caught, 1 failed
a where not read caught, 9 failed
a cases: condition not read caught, 1 failed
a shift or window amount not read caught, 1 failed
a coefficient not read caught, 34 failed
a divisor of a variable not read caught, 3 failed
heights not compared caught, 1 failed
coordinates not compared at equal height still green at 07241eb, then caught by test_as_many_coordinates_but_other_ones_build_in_full, added for it
ranges left where a refill put them caught, 29 failed
right-hand-side ranges not read again caught, 39 failed
A/B of the update, this container (4 cores, not the publishing machine)

Base #2069's head fdeee15, head c4d2848. c70193d since only reorders two small dicts of ranges after the refill. One fresh process per measurement, base and head alternated ABBA for three rounds. Each process builds once, then times 12 calls of model.update({param: …}), each with the values ×0.9–1.1 at the same coordinates, and reports the median; the table gives the median over the three processes. Only the update is timed: no solve, no hand-off. Load average 0.95 before, 1.50 after.

model, what changes base ms head ms head ÷ base head faster in
storage 2xs (1k columns), load 36.9 12.1 0.33 3/3
storage xs (10k columns), load 38.5 10.9 0.28 3/3
network s (100k columns), load 107.6 31.2 0.29 3/3
network m (1M columns), load 389.1 66.8 0.17 3/3
network m (1M columns), p_max 384.5 120.2 0.31 3/3
nodal m (300k columns), installed, control: a where reads it 68.4 68.1 1.00 2/3

Per-process medians, ms: storage 2xs base 36.9 / 34.9 / 38.5, head 13.1 / 11.5 / 12.1; storage xs base 37.7 / 39.8 / 38.5, head 10.9 / 11.3 / 10.5; network s base 107.6 / 102.2 / 109.6, head 31.2 / 32.3 / 29.7; network m load base 386.0 / 389.1 / 392.4, head 61.1 / 66.8 / 71.7; network m p_max base 407.4 / 384.5 / 380.2, head 120.2 / 127.4 / 106.1; nodal m base 68.4 / 62.3 / 71.9, head 68.1 / 65.5 / 68.8.

Gates at c70193d, on mathspec 0.4.0

No pixi in this container, so each gate ran in a uv environment synced from #2065's uv.lock (--group dev --group docs --all-extras, Python 3.12, polars 2.0.0):

  • ruff check ., ruff format --check . (0.16.9): clean.
  • pyrefly check on the package (1.3.1): 0 errors.
  • pytest -q -n 4: 5942 passed, 459 skipped, 4 xfailed.
  • pytest tests/test_docs_math.py tests/test_docstring_links.py (docs-test): 116 passed at c4d2848; no docstring link changed since.

Not run: docs-build, the floors environment, test-via-pyarrow.

Not done. The door still re-reads dimensions, relations and assumptions on every update (#2069), about 10 ms of what is left on the storage model. _structural_parameters walks the program on every update; it is not cached, because a Program is not hashable and no measured update spends time there. #2022 restructures the same build methods, so whichever of the two merges second needs a merge of the other. Stacked on #2069.

🤖 Generated with Claude Code

https://claude.ai/code/session_018B3nA4oeSor7jjZ4vwux1G

…st new numbers at the coordinates it had skips most of the build

Where every name an update replaces is a parameter that no build reads for
which columns, rows or matrix entries exist, attached at the coordinates it
had, the engine keeps the labels and the matrix and reads again only the
bounds, constant sides and objective those parameters feed. Anything else
builds in full.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018B3nA4oeSor7jjZ4vwux1G
…ilds in full

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

coderabbitai Bot commented Oct 28, 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
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: e3f8cce1-f894-48f9-81c0-d30c43111c73

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
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018B3nA4oeSor7jjZ4vwux1G
@read-the-docs-community

read-the-docs-community Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

@codspeed

codspeed Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by ×2.5

1 warning

⚠️ Base and head ran on different runner types

Some benchmarks with significant performance changes were compared across different runner types, so the changes might not come from the code. Run base and head on the same runner type, or use CodSpeed Macro Runners for more stable results.

Learn more about Macro Runners

Open the report in CodSpeed to investigate

⚡ 5 improved benchmarks
✅ 99 untouched benchmarks
⏩ 307 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ test_window[transport-s-specsolve-highs-one] 12.6 MB 3.9 MB ×3.3
⚡ test_window[storage-s-specsolve-highs-one] 10.5 MB 3.4 MB ×3.1
⚡ test_read[commitment-s-specsolve-parquet] 684.3 KB 220.7 KB ×3.1
⚡ test_window[fleet-s-specsolve-highs-one] 11.3 MB 5.9 MB +90.65%
⚡ test_window[milp-s-specsolve-highs-one] 17.9 MB 10.6 MB +68.42%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing perf/update-numbers-only (1de322f) with perf/update-reads-what-changed (fdeee15)

Open in CodSpeed

Footnotes

  1. 307 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. ↩

…ch block where a fresh build lists it

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

This branch has not been deployed

No deployments
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