Skip to content

perf(gurobi): load the model in one loadModel call and read solutions in bulk - #1126

Merged
FabianHofmann merged 2 commits into
masterfrom
perf/gurobi-bulk-model
Nov 4, 2026
Merged

FabianHofmann merged 2 commits into
masterfrom
perf/gurobi-bulk-model

Conversation

@FabianHofmann

Copy link
Copy Markdown
Collaborator

Note

The following content was generated by AI.

Changes proposed in this Pull Request

This ports the approach of fluxopt/specsolve#1942 (MIT licensed) to linopy's Gurobi direct path. specsolve#1942 swapped addMVar + per-block addMConstr for one column-wise gurobipy.loadModel call. It passes senses as S1 bytes and reads results back with whole-model getAttr/setAttr instead of through MVar/MConstr handles. No code was copied. The change is reimplemented on top of linopy's Matrices.

Gurobi._build_solver_model (used by to_gurobipy and solve(io_api="direct")):

  • Makes one gurobipy.loadModel(...) call with obj, lb, ub, vtype (only when the model has discrete columns), constr_csc (M.A.tocsc()), sense (as S1 bytes), rhs, modelsense and qobj_coo. The quadratic objective 0.5 x'Qx is folded into the upper triangle of Q, with the coefficients halved.
  • When set_names=True, names are set with one whole-model setAttr("VarName"/"ConstrName", ...) call each.
  • SOS and indicator constraints, which loadModel cannot take, are still added over gm.getVars(). That logic is moved unchanged into a shared helper, _add_sos_and_indicators.
  • The transient CSC copy is dropped straight after loadModel, so it is gone before the names are allocated.
  • The previous addMVar/addMConstr build is kept as _build_solver_model_mvar. It is the fallback for gurobipy < 12 (no loadModel) and for env=None, because loadModel requires an Env.

Solution readback in Gurobi._solve: X, Pi and (in the from-file case) VarName/ConstrName are each read with one m.getAttr(attr, objs) call, instead of [v.X for v in vars_] and the like. The dual except now also catches GurobiError, because the bulk getAttr raises that on a MILP where the property read raised AttributeError.

Behaviour is unchanged. A new test checks that the loadModel build and the old addMVar build give identical Gurobi models. It compares the A matrix, the Q matrix (symmetrised), Obj, LB, UB, VType, RHS, Sense, VarName, ConstrName, ModelSense, ObjCon, SOS and general-constraint counts. It covers LP min and max, a MILP with binary, integer and semi-continuous columns, a QP (max, with a cross term), a model without constraints and a model with SOS and indicator constraints, each with names on and off. Other tests check that both builds reach the same optimum, and that Model.solve(io_api="direct") returns the same primal and dual values as io_api="lp".

Benchmarks

The build step was timed by calling Gurobi._build_solver_model on a precomputed Matrices, for origin/master and for this branch. Each variant ran in a fresh process. Peak RSS is the VmHWM delta during the build. Gurobi 13.0.3.

model master time branch time master peak RSS branch peak RSS
160k vars, 160k rows, 480k nnz, LP 0.30 s 0.065 s +106 MB +23 MB
1.0M vars, 1.0M rows, 3.0M nnz, LP 1.78 s 0.41 s +654 MB +148 MB
same, set_names=True 2.60 s 0.84 s +806 MB +333 MB
same, plus a quadratic objective (1M diagonal terms) 2.25 s 0.52 s +685 MB +210 MB

Memory: the CSR to CSC conversion that loadModel needs is a transient copy of A, about 22 MB at 3M nnz (the peak-versus-retained gap above). It is freed straight after loadModel. Even with that copy, peak memory is about 4x lower than master, because no MVar/MLinExpr/MConstr objects are built.

Solution readback could only be timed in isolation, because a size-limited license cannot solve a large model. On 500k variables, reading an attribute with [v.LB for v in vars] took 0.52 s, and m.getAttr("LB", vars) took 0.13 s.

Benchmark output
# origin/master
n=1000 names=False quad=False nvars=1001000 nnz=2999000: time 1.780s  peak +654 MB  retained +654 MB
n=1000 names=True  quad=False nvars=1001000 nnz=2999000: time 2.596s  peak +806 MB  retained +736 MB
n=1000 names=False quad=True  nvars=1001000 nnz=2999000: time 2.245s  peak +685 MB  retained +685 MB
n=400  names=False quad=False nvars=160400  nnz=479600:  time 0.301s  peak +106 MB  retained +106 MB

# this branch
n=1000 names=False quad=False nvars=1001000 nnz=2999000: time 0.410s  peak +148 MB  retained +126 MB
n=1000 names=True  quad=False nvars=1001000 nnz=2999000: time 0.839s  peak +333 MB  retained +212 MB
n=1000 names=False quad=True  nvars=1001000 nnz=2999000: time 0.518s  peak +210 MB  retained +187 MB
n=400  names=False quad=False nvars=160400  nnz=479600:  time 0.065s  peak +23 MB   retained +20 MB

# end-to-end to_gurobipy, 160k vars / 480k nnz (best of 3)
master: set_names=False 0.288s, set_names=True 0.355s
branch: set_names=False 0.090s, set_names=True 0.154s

The model is x[i,j] >= 0 (n×n) and y[i] in [-5, 5], with constraints sum_j x[i,j] + 2 y[i] >= i and x - 0.5 x.shift(dim_0=1) <= 10, and objective 1.5 Σx + Σy (plus Σy² + Σx² for the quadratic variant).

Tests

  • Full pytest test with Gurobi 13 (size-limited license): 5278 passed, 117 skipped.
  • ruff check, ruff format and mypy . are clean.

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.

Generated by Claude Code

… in bulk

The Gurobi direct build passes columns, the linear rows (CSC), the
objective (incl. the quadratic part) and the sense to a single
gurobipy.loadModel call instead of addMVar + setObjective + addMConstr.
Names are set with one whole-model setAttr each; SOS and indicator
constraints are added over the model's Var list. gurobipy < 12 or a
missing env falls back to the previous addMVar path.

The solution and duals are read back with one getAttr call each instead
of a property read per variable and constraint.

Approach ported from fluxopt/specsolve#1942.

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

codspeed Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 61.49%

2 warnings, 1 note

⚠️ 25 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

⚠️ 25 benchmarks with a significant change ran in a different software environment

Their base and head results were measured with a different OS or toolchain, such as compiler, runtime or library versions, so the changes might come from this difference rather than from the code. Open an affected benchmark to see what changed.

See affected benchmarks

ℹ️ 181 benchmarks have no flamegraph

The flamegraph wasn't generated: the symbols of the profiling trace could not be resolved, because no information about the language frames was collected.

See affected benchmarks

⚡ 25 improved benchmarks
✅ 156 untouched benchmarks
⏩ 181 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ test_to_solver[gurobi-rolling-severity=0] 3.8 MB 2 MB +85.87%
⚡ test_to_solver[gurobi-storage-n=250] 41.7 MB 22.5 MB +85.19%
⚡ test_to_solver[gurobi-merge_balance-severity=0] 3.9 MB 2.1 MB +83.36%
⚡ test_to_solver[gurobi-merge_balance-severity=100] 10.1 MB 5.5 MB +82.79%
⚡ test_to_solver[gurobi-nodal_balance-severity=50] 4.3 MB 2.4 MB +82.3%
⚡ test_to_solver[gurobi-basic-n=250] 60.5 MB 33.3 MB +82.01%
⚡ test_to_solver[gurobi-merge_balance-severity=50] 6.9 MB 3.8 MB +81.84%
⚡ test_to_solver[gurobi-nodal_balance-severity=100] 4.3 MB 2.4 MB +81.29%
⚡ test_to_solver[gurobi-nodal_balance-severity=0] 4.3 MB 2.4 MB +79.35%
⚡ test_to_solver[gurobi-expression_arithmetic-n=250] 37.5 MB 21.1 MB +78.11%
⚡ test_to_solver[gurobi-piecewise-n=1000] 4.7 MB 2.6 MB +78%
⚡ test_to_solver[gurobi-storage-n=10] 1.9 MB 1.1 MB +76.73%
⚡ test_to_solver[gurobi-knapsack-n=10000] 2.7 MB 1.6 MB +75.95%
⚡ test_to_solver[gurobi-sos-n=1000] 3 MB 1.8 MB +69.88%
⚡ test_to_solver[gurobi-milp-n=50] 1,038.2 KB 658 KB +57.78%
⚡ test_to_solver[gurobi-masked-n=100] 903.8 KB 573.3 KB +57.67%
⚡ test_to_solver[gurobi-cumsum-severity=100] 70.3 MB 46.9 MB +49.96%
⚡ test_to_solver[gurobi-kvl_cycles-severity=0] 268.2 MB 179.9 MB +49.08%
⚡ test_to_solver[gurobi-rolling-severity=50] 153.5 MB 104.8 MB +46.48%
⚡ test_to_solver[gurobi-cumsum-severity=50] 17.8 MB 12.5 MB +42.09%
... ... ... ... ...

ℹ️ 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/gurobi-bulk-model (4e09576) with master (cfaea93)

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

@FabianHofmann
FabianHofmann marked this pull request as ready for review November 4, 2026 07:02
@FabianHofmann
FabianHofmann merged commit ca4272e into master Nov 4, 2026
23 checks passed
@FabianHofmann
FabianHofmann deleted the perf/gurobi-bulk-model branch November 4, 2026 08:53
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