Skip to content

perf(highs): build every model with one row-wise passModel call - #1128

Merged
FabianHofmann merged 3 commits into
masterfrom
perf/highs-passmodel-lp-tail
Nov 4, 2026
Merged

FabianHofmann merged 3 commits into
masterfrom
perf/highs-passmodel-lp-tail

Conversation

@FabianHofmann

@FabianHofmann FabianHofmann commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Alternative to #1118.

Changes proposed in this Pull Request

Note

The following content was generated by AI.

Like #1118, this builds the HiGHS model in bulk, but it uses one row-wise passModel call for every model, LP or MILP. The cached CSR matrix is passed as is (no .tocsc() copy). HiGHS converts it to column-wise storage inside passModel.

passModel needs a full-length integrality array, even for a pure LP. Because of that array, HiGHS's LP writer emits empty bin / gen / semi section headers, and CBC, GLPK and COPT misread them. This broke test_set_files[*-mps-*] in #1118. Those tests call solve(io_api="mps", problem_fn="problem.lp"). io_api="mps" makes to_file write through HiGHS (h.writeModel(fn)), and HiGHS picks the file format from the extension, so the "MPS" export was in fact an LP file. Without problem_fn, linopy names the temp file .mps, so normal solves never hit this.

#1118 avoids the array for LPs by using addCols + addRows. This PR keeps passModel and removes the mismatch instead:

  • Breaking: to_file (and therefore solve) raises ValueError when the file extension doesn't match io_api: .lp for "lp" / "lp-polars", .mps for "mps". Before, a mismatched name silently produced the other format.
  • test_set_files and test_set_files_and_keep_files now name the problem file after the io_api.
  • With that, linopy never asks HiGHS to write an LP file, so the empty headers don't reach other solvers. A user who calls to_highspy(m).writeModel("x.lp") directly still gets them; HiGHS reads that file back fine, and the proper fix is upstream in the HiGHS LP writer.

Row-wise vs column-wise input: passing CSC builds about 20% faster on the largest LP, but roughly doubles peak memory (rolling: 86 MB vs 40 MB growth), so this PR passes the matrix row-wise.

Results on the test_to_solver HiGHS benchmarks, measured locally, best of ≥7 rounds:

  • Time: geometric mean 0.80× master, vs 0.82× for perf(highs): build the model in bulk from the row-wise matrix #1118 (6b8cc1a). The densest LP (rolling, 4M nonzeros) is 5–9% slower than master. That's probably because passModel converts to column-wise up front, while addRows defers it to solve time.
  • Peak memory while building: about half of master's on the large LPs (rolling 44 MB vs 90 MB, cumsum 14 MB vs 29 MB), about the same on MILPs.
Benchmark and test details

test_to_solver[highs-*], best-of-7 wall time per case, on one cloud VM, all three commits run back to back:

case master (ms) other PR 6b8cc1a (ms) this PR (ms) other / master this / master
kvl_cycles-severity=0 388.06 387.39 372.33 1.00 0.96
rolling-severity=100 331.42 332.32 361.77 1.00 1.09
kvl_cycles-severity=50 287.81 291.15 275.96 1.01 0.96
rolling-severity=50 206.51 207.01 216.89 1.00 1.05
cumsum-severity=100 84.37 82.92 69.23 0.98 0.82
expression_arithmetic-n=250 60.81 52.30 49.06 0.86 0.81
basic-n=250 59.83 48.63 44.10 0.81 0.74
storage-n=250 39.56 18.49 19.38 0.47 0.49
kvl_cycles-severity=100 34.39 32.55 27.36 0.95 0.80
cumsum-severity=50 24.87 15.51 15.56 0.62 0.63
merge_balance-severity=100 15.58 10.00 10.83 0.64 0.70
sparse_network-n=250 11.76 8.57 9.23 0.73 0.79
qp-n=1000 10.76 10.00 9.54 0.93 0.89
merge_balance-severity=50 10.21 7.54 6.88 0.74 0.67
qp-n=10 8.99 9.40 9.01 1.05 1.00
nodal_balance-severity=100 6.87 5.72 5.42 0.83 0.79
knapsack-n=10000 6.73 2.64 2.45 0.39 0.36
piecewise-n=1000 6.72 4.45 4.16 0.66 0.62
nodal_balance-severity=50 5.89 3.79 4.23 0.64 0.72
merge_balance-severity=0 5.55 4.53 4.54 0.82 0.82
nodal_balance-severity=0 3.33 2.11 2.06 0.63 0.62
rolling-severity=0 2.40 1.75 1.68 0.73 0.70
piecewise-n=10 2.39 2.29 2.22 0.96 0.93
milp-n=50 2.26 1.84 1.80 0.82 0.80
storage-n=10 1.88 1.51 1.47 0.80 0.78
masked-n=100 1.81 1.63 1.62 0.90 0.89
milp-n=10 1.61 1.55 1.48 0.96 0.92
knapsack-n=100 1.39 1.27 1.22 0.91 0.87
expression_arithmetic-n=10 1.13 1.12 1.11 0.99 0.98
basic-n=10 1.02 1.00 1.01 0.98 0.99
masked-n=10 0.87 0.86 0.86 0.99 0.99
sparse_network-n=10 0.83 0.81 0.80 0.97 0.96
cumsum-severity=0 0.74 0.73 0.72 0.98 0.97
geomean (33 cases) 0.82 0.80

Peak RSS growth while building the HiGHS model (MB above the model already in memory, fresh process per cell):

case master #1118 6b8cc1a this PR
rolling 90 90 44
cumsum 29 29 14
storage 16 16 12
knapsack 6 5 5
kvl_cycles 0 0 0

Tests:

  • New test_to_file_rejects_mismatched_extension (mps + .lp, lp + .mps, lp-polars + .mps).
  • Full pytest test -q: 6389 passed, 87 skipped. ruff check and ruff format are clean. mypy linopy shows the same error count as master in this environment.

Checklist

  • AI-generated content is marked (see AGENTS.md).
  • Code changes are sufficiently documented; i.e. new functions contain docstrings and further explanations may be given in doc.
  • Unit tests for new features were added (if applicable).
  • A note for the release notes doc/release_notes.rst of the upcoming release is included.
  • I consent to the release of this PR's code under the MIT license.

https://claude.ai/code/session_01F4nx8JFY4SxxVLJEByWw8z


Generated by Claude Code

Pass the cached CSR matrix to a single passModel call for LPs and MILPs
alike. HiGHS stores it column-wise; converting it in Python would hold a
second copy of the matrix.

passModel always stores an integrality array, for which the HiGHS LP
writer emits empty bin/gen/semi sections that CBC, GLPK and COPT misread.
to_file(io_api="mps") now strips the empty trailing sections after
HiGHS writes an .lp file, rewriting only the file tail.

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

codspeed Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 25.48%

1 warning

⚠️ 22 benchmarks with a significant change ran on different hardware

Their base and head results were measured on machines with a different CPU, core count or memory, so the changes might not come from the code. Run base and head on the same runner type.

Learn more about Macro Runners

See affected benchmarks

⚡ 22 improved benchmarks
✅ 159 untouched benchmarks
⏩ 181 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ test_to_solver[highs-knapsack-n=100] 100.3 KB 70.3 KB +42.74%
⚡ test_to_solver[highs-milp-n=10] 101.6 KB 75.1 KB +35.24%
⚡ test_to_solver[highs-rolling-severity=100] 184.6 MB 138.6 MB +33.23%
⚡ test_to_solver[highs-rolling-severity=50] 138.7 MB 104.2 MB +33.19%
⚡ test_to_solver[highs-kvl_cycles-severity=0] 236.1 MB 177.6 MB +32.94%
⚡ test_to_solver[highs-cumsum-severity=100] 61 MB 45.9 MB +32.84%
⚡ test_to_solver[highs-kvl_cycles-severity=50] 122.2 MB 92.2 MB +32.58%
⚡ test_to_solver[highs-cumsum-severity=50] 15.9 MB 12 MB +32.31%
⚡ test_to_solver[highs-piecewise-n=10] 101.1 KB 77 KB +31.29%
⚡ test_to_solver[highs-piecewise-n=1000] 2.5 MB 2 MB +27.33%
⚡ test_to_solver[highs-knapsack-n=10000] 1.4 MB 1.1 MB +25.68%
⚡ test_to_solver[highs-basic-n=250] 31.3 MB 25.2 MB +24.11%
⚡ test_to_solver[highs-storage-n=250] 20.9 MB 17.1 MB +22.07%
⚡ test_to_solver[highs-storage-n=10] 917.8 KB 763.5 KB +20.21%
⚡ test_to_solver[highs-milp-n=50] 512.2 KB 426.2 KB +20.17%
⚡ test_to_solver[highs-masked-n=100] 427.3 KB 358.7 KB +19.13%
⚡ test_to_solver[highs-merge_balance-severity=100] 4.9 MB 4.2 MB +17.7%
⚡ test_to_solver[highs-merge_balance-severity=50] 3.4 MB 2.9 MB +17.47%
⚡ test_to_solver[highs-nodal_balance-severity=0] 2.1 MB 1.8 MB +17.46%
⚡ test_to_solver[highs-nodal_balance-severity=50] 2.1 MB 1.8 MB +17.46%
... ... ... ... ...

ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.

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/highs-passmodel-lp-tail (486a868) with master (22a1882)

Open in CodSpeed

Footnotes

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

to_file(io_api="mps") writes through HiGHS, which picks the format from
the extension, so a .lp path silently produced an LP file. Raise a
ValueError on any mismatch instead and drop the LP tail cleanup, which
was only needed for that case. The set_files tests now name the problem
file after the io_api.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4nx8JFY4SxxVLJEByWw8z
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4nx8JFY4SxxVLJEByWw8z
@FabianHofmann
FabianHofmann marked this pull request as ready for review November 4, 2026 07:01
@FabianHofmann
FabianHofmann merged commit efb871f into master Nov 4, 2026
19 of 20 checks passed
@FabianHofmann
FabianHofmann deleted the perf/highs-passmodel-lp-tail branch November 4, 2026 07:01
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