Repository navigation
perf(highs): handing a basis to HiGHS takes milliseconds rather than a second at a million columns - #1885
Merged
FBumann merged 2 commits intoOct 8, 2026
Conversation
…a second at a million columns highspy takes and returns a basis only as a list of HighsBasisStatus members. The sink now picks each status from the five members by numpy index instead of constructing one per call, reads the integers back with np.fromiter, and the status table is gathered from the BASIS series by code rather than built from strings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
…han a second at a million columns Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
Merging this PR will not alter performance
Comparing Footnotes
|
FBumann
added this pull request to stack #1874
October 8, 2026 08:01
FBumann
removed this pull request from stack #1874
October 8, 2026 08:06
FBumann
merged commit Oct 8, 2026
0ff3c53
into
claude/wizardly-lamport-e3g6om
14 of 15 checks passed
FBumann
added a commit
that referenced
this pull request
Oct 8, 2026
…e last solve ended unless start=None says otherwise (#1884) > **Prompt:** Lets do the follow up removeing keep, and also bacnhmarking potential differences! > **Prompt:** Users should be able to opt out of start. We want the api to make that possible. But per default do the best thing. > [!NOTE] > The following content was generated by AI. `keep=` and `Result.kept` are removed. `start='previous'` is now the default of `Model.solve` and `solve_over`: a re-solve carries on in the loaded solver, as `keep='progress'` did, at the same speed. `start=None` begins from nothing. An answer given is matched by coordinate. <details><summary>What a solve begins from</summary> | `start=` | solver still loaded | update loaded the solver again, or a sweep slice under an executor | |---|---|---| | `'previous'` (default) | carries on; nothing read or matched | from nothing; nothing read or matched | | `None` | from nothing (`forget()`) | from nothing | | an answer, a `Start`, an earlier sweep | `forget()`, then matched by coordinate | matched by coordinate | - A word other than `'previous'` is refused by `Model.solve`, and by `solve_over` before a slice is built. - A start from an answer with no values (infeasible, or no incumbent) raises `NoSolutionError`, whether or not it is the model's last answer. </details> <details><summary>Why carrying on is the default</summary> It won in every LP case measured here, on HiGHS and Gurobi: 1.5× to 5.7× less wall time over six re-solves (tables below). Mixed-integer `commitment s` showed no difference. It lost 18× in #815: 76.6 s against 4.3 s on HiGHS, on `bench/expansion/sub.yaml` (10k generators × 24 snapshots). Presolve solves that model in 0 iterations, and a solver that carries on skips its presolve. On Gurobi the same model took 231 iterations against 120. The how-to names this case and the opt-out. </details> <details><summary>Gurobi: old <code>keep='progress'</code> against carrying on, and the cost of a matched basis</summary> The user's Mac with a full Gurobi licence. `keep_vs_start.py` (cases from `bench/cases.py`, 6 updates scaling `load` by `1 + 0.02·sin(k)`, median of 3 rounds with the arm order rotated). Old is 9b046ce. New is this branch at 2dede07/817b012, `start=` the previous answer, which takes the same carry-on path as today's default. Load average not recorded. Wall seconds over the 6 re-solves. | case | cold (old / new) | old `keep='progress'` | new carry-on | old `start=` with basis read and matched | warm iterations | |---|---|---|---|---|---| | dispatch `s` | 0.599 / 0.609 | 0.390 | 0.389 | 0.617 | 3700 | | storage `s` | 1.611 / 1.663 | 0.857 | 0.875 | 1.143 | 11159 | | dispatch `m` | 9.469 / 9.775 | 4.323 | 4.435 | 6.579 | 37123 | | storage `m` | 31.779 / 31.604 | 20.811 | 21.056 | 23.488 | 202182 | Cold iterations: 6000, 299811, 60000, 1288980. `commitment s` (new tree only): 1.261 cold against 1.252, 256 against 254 iterations, 6 nodes each. At dispatch `m`, a matched basis costs about 0.38 s per solve: about 0.11 s inside the solve phase (reset, set and read `VBasis`/`CBasis`), the rest outside it. One profile of the matching on HiGHS, on a cloud container: 0.17–0.21 s for `matched_basis` per solve at dispatch `m`. </details> <details><summary>HiGHS: old <code>keep='progress'</code> against new <code>start=</code> previous</summary> Same updates. One process per run, 3 rounds, order alternating. Load average below 0.7. Old is 9b046ce with `keep='progress'`; new is 9c816af with `start=` the previous answer. | case | old `keep='progress'` (s) | new (s) | simplex iterations, both | |---|---|---|---| | dispatch `s` (100 × 1000) | median 0.711 (0.696, 0.711, 0.865) | median 0.712 (0.662, 0.712, 0.759) | 3700 | | storage `s` | median 2.581 (2.688, 2.581, 2.229) | median 2.254 (2.502, 2.254, 2.165) | 11514 | | dispatch `m` (100 × 10000) | median 12.068 (12.314, 12.068, 11.876) | median 12.257 (12.449, 12.257, 12.073) | 37123 | Cold against carry-on at 558a2c3, wall seconds / iterations: dispatch `s` 2.833 / 6000 against 0.723 / 3700; storage `s` 13.279 / 217137 against 2.342 / 11514; dispatch `m` 34.925 / 60000 against 12.339 / 37123. Neither table was re-run after the default changed (dc4889b). The carry-on path it takes is the same one: no `forget()`, no read, no match. </details> <details><summary>What changed</summary> - `keep=` is removed from `solve`, `Model.solve` and `solve_over`, and `Result.kept` with it. `Diagnostics.loads` still counts the solves that loaded a solver. - `Model.solve(start='previous')` is the default. `solve_over(start='previous')` is the default too, and is no longer refused under an executor; there every slice begins from nothing. - A sweep no longer reads each slice's basis, and a slice read back from a spill no longer starts the next one. The solve mark on `Result` is gone: an answer given is always matched. - `bench/warm_payoff.py` passes `start=None` for its cold arm. The bench `window` arm still runs against a checkout that takes `keep`. - Docs: the warm-start how-to is rewritten around the default, with #815's loss and this PR's numbers. `reference/sweeps.md`, the glossary's **start** · **previous** entry, `debug.md`, the README, the sinks README and the docstrings follow. </details> <details><summary>Mutation table</summary> `python -m tools.mutate` for the deletions, scoped to `tests/test_start.py`, `tests/test_warm_start.py` and `tests/test_strategy.py`. The hand mutation was made with `sed`, `__pycache__` was dropped on both sides, and it was restored with `git checkout --`. The tree was clean after every run. | guard | result | |---|---| | `forget()` unless the solve carries on (`engine.py:202-203`) | **caught** | | the start word refused by `Model.solve` (`api.py:367`) | **caught** | | the start word refused before a slice is built (`strategy.py:356`) | **caught**, by a probe added for it; green before the probe | | by hand: `carry_on = False` | **caught** (5 failed) | </details> <details><summary>Verification</summary> - On 23f5630, after merging the updated base: `pixi run check` gave 5323 passed, 566 skipped, 1 xfailed, with `ruff`, format and `pyrefly` clean. `docs-test` gave 112 passed. - On dc4889b: `docs-build` reported no issues. `test-bench` gave 263 passed, run with `--i-know-another-is-running` because the suite had just loaded the box. - The reported bug that an infeasible last answer carried on silently was reproduced as a strict `xfail` first. </details> <details><summary>Deliberately not done, and coverage that moved</summary> - **No replacement for `keep='nothing'`.** On Xpress, `forget()` only sets `keepbasis=0`. For a fully fresh solver, close the model and build again, as the how-to says. - **After an update that loads the solver again, the default starts cold**, not from the previous answer matched by coordinate. That match needs a basis read on every solve. Pass the answer as `start=` to get it. - **Gurobi's matched-basis path is not made faster.** Only HiGHS's conversion was (#1885). - Tests moved: the `keep` tests became tests of the default and of `start=None`. `test_update`'s walk and the resumed-carry test pass `start=None`, since carrying on can end on another vertex of a degenerate optimum. The spill-read-back start test is deleted with the feature. The test for refusing `'previous'` under an executor became one where every slice begins from nothing. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_018pGzyDMmp1WD4B65wFR8op --------- Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
The following content was generated by AI.
The HiGHS sink converted each basis status with a Python call. Now numpy picks each status. On dispatch
m, 6 re-solves that start from a basis and read one back take 21.2 s instead of 30.0 s, with the same iterations.Benchmark
HiGHS, base 9b046ce (the head of #1882) against 6ecab89. Each process builds the case and solves it with
outputs={'basis'}. It then re-solves it after 6 updates that scaleloadby1 + 0.02·sin(k), each withstart=the previous answer andoutputs={'basis'}. Each re-solve so reads, matches and writes a basis. The time is the wall time of the 6solve()calls. One process per run, 3 rounds, the order alternating each round. Load average 0.4 to 1.1. Both sides took the same iterations and reached the same objectives in every round.s(100 × 1000)sm(100 × 10000)A first run started at load average 8.5, just after the test suite, and is not counted. Its medians were within 0.2 s of these.
Microbenchmark on highspy 1.15.1, 1M statuses, one run:
HighsBasis.col_status(_warm)HighsBasisStatus(int)call each)_basis)int()each)np.fromiterover.value)On 200k codes, building the
BASISseries bygatherinstead of from strings took 0.003 s against 0.12 s.A profile of dispatch
sat the base, per re-solve: write 0.158 s, read 0.093 s, match 0.034 s, status table 0.027 s. With this change, the write falls to 0.009 s. The read stays at 0.092 s, which is mostly the highspy getter.Verification
pytest -n 4, with HiGHS, Gurobi and Xpress): 5301 passed, 566 skipped, 1 xfailed.ruff check .,ruff format --check .andpyrefly check: 0 errors.test-benchanddocs-test. This PR changes no file underbench/ordocs/, and only private docstrings.Deliberately not done
writeBasisto a file took 0.165 s for 200k columns before any parsing, so it does not pay. A numpy accessor in highspy would remove the rest.test_basis,test_start,test_warm_start) cover the round trip.CHANGELOG.mdline after feat(api): a solve or a sweep can start from tables of values and basis statuses, and a sweep from an earlier sweep or from the slice before it #1882's, so the second to merge has a one-line conflict there.🤖 Generated with Claude Code
https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz