Skip to content

perf(highs): handing a basis to HiGHS takes milliseconds rather than a second at a million columns - #1885

Merged
FBumann merged 2 commits into
claude/wizardly-lamport-e3g6omfrom
claude/wizardly-lamport-e3g6om-highs-basis
Oct 8, 2026
Merged

FBumann merged 2 commits into
claude/wizardly-lamport-e3g6omfrom
claude/wizardly-lamport-e3g6om-highs-basis

Conversation

@FBumann

@FBumann FBumann commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Prompt: yes, stack both PRs on 1882

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 scale load by 1 + 0.02·sin(k), each with start= the previous answer and outputs={'basis'}. Each re-solve so reads, matches and writes a basis. The time is the wall time of the 6 solve() 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.

case base (s) this PR (s) change simplex iterations, both
dispatch s (100 × 1000) 2.668, 2.287, 2.222 (median 2.287) 1.474, 1.786, 1.694 (median 1.694) −26% 3700
storage s 5.013, 5.375, 4.843 (median 5.013) 4.092, 4.270, 3.967 (median 4.092) −18% 10639
dispatch m (100 × 10000) 29.011, 30.076, 29.997 (median 29.997) 21.252, 20.712, 21.241 (median 21.241) −29% 37123

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:

step before after
statuses into HighsBasis.col_status (_warm) 1.04 s (a HighsBasisStatus(int) call each) 0.064 s (numpy index into the five members)
integers out of the returned list (_basis) 0.44 s (int() each) 0.19 s (np.fromiter over .value)
the highspy getter that makes the list 0.9 to 2.0 s unchanged: highspy has no numpy path

On 200k codes, building the BASIS series by gather instead of from strings took 0.003 s against 0.12 s.

A profile of dispatch s at 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
  • Full suite (pytest -n 4, with HiGHS, Gurobi and Xpress): 5301 passed, 566 skipped, 1 xfailed.
  • ruff check ., ruff format --check . and pyrefly check: 0 errors.
  • Not run: test-bench and docs-test. This PR changes no file under bench/ or docs/, and only private docstrings.
Deliberately not done

🤖 Generated with Claude Code

https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz

claude added 2 commits October 7, 2026 18:17
…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
@read-the-docs-community

Copy link
Copy Markdown

@codspeed

codspeed Bot commented Oct 7, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 67 untouched benchmarks
⏩ 307 skipped benchmarks1


Comparing claude/wizardly-lamport-e3g6om-highs-basis (333d1b5) with claude/wizardly-lamport-e3g6om (9b046ce)

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

@FBumann
FBumann added this pull request to stack #1874 October 8, 2026 08:01
@FBumann
FBumann removed this pull request from stack #1874 October 8, 2026 08:06
@FBumann
FBumann merged commit 0ff3c53 into claude/wizardly-lamport-e3g6om Oct 8, 2026
14 of 15 checks passed
@FBumann
FBumann deleted the claude/wizardly-lamport-e3g6om-highs-basis branch October 8, 2026 08:06
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>
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