Add NVIDIA cuOpt as a GPU-accelerated solver - #909
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add test/test_cuopt.py with the cuOpt differential tests against live HiGHS (dual sign matrix, presolve maximisation, status mapping, options, refusals, device probe and fork safety) and test/test_cuopt_interrupt.py for the KeyboardInterrupt helper. Register cuOpt's time limit option and feature matrix rows, and skip the warmstart test for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Trim test/test_cuopt.py to the tests that guard linopy's compensation code for cuOpt quirks that fail silently (always-minimise duals, 0.5*Q Hessian, pad-row dual slicing, empty-primal handling, MIQP pre-check) plus the process-safety machinery. All expectations are now baked analytic values (confirmed once against HiGHS 1.15.1) instead of live differential solves. Fold test_cuopt_interrupt.py into test_cuopt.py, add cuOpt cases to test_semi_continuous.py, document the remaining quirks in doc/gpu-acceleration.rst and condense the cuOpt comments and docstrings in solvers.py to the guidance future devs need. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes from two adversarial review passes over the cuOpt implementation: - Widen the pad row for constraint-free models to include 0 so a semi-continuous variable keeps its off state (was silently forced on, reported Optimal); guarded by a new constraint-free SC test. - Hand DataModel ownership to the solve worker via a box list so the worker's clear is deterministically the final decref for the solver-owned reference; docstrings reworded to the actual thread contract (solves and unpredictable-thread teardown, not all natives). - refresh() clears the CUDA probe's own cache; probe failures now log the exception and stderr so a broken install is distinguishable from a missing GPU. - Guard a short LP dual like the primal instead of an opaque IndexError; MIQP refusal names semi-continuous variables too. - Default io_api=None fallback logs at info, only an explicitly requested file io_api warns; docs updated to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Guard cuOpt against silent int32 CSR truncation beyond 2**31-1 nonzeros - Trim cuOpt helper docstrings to state failures without inline evidence - Simplify square_equality_model to a fixed one-row fixture with baked values - Assert cuOpt's GPU_ACCELERATION capability in the feature matrix - Mark the repeated-solves test `slow` (intrinsic ~135 s GPU cost) and register the marker so it can be deselected during development - Drop the non-discriminating semi-continuous cuOpt test, loosen the unbounded-QP status pin to membership, and source the warmstart skip reason - Slim the GPU documentation Co-Authored-By: mal84emma <info@materialdifference.earth> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Merging this PR will degrade performance by 18.24%
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ❌ | Memory | test_to_lp[knapsack-n=10000] |
1.6 MB | 2.7 MB | -40.25% |
| ❌ | Memory | test_to_lp[masked-n=100] |
2.1 MB | 2.6 MB | -20.71% |
| ❌ | Memory | test_to_lp[storage-n=250] |
28.5 MB | 35.8 MB | -20.49% |
| ❌ | Memory | test_to_lp[storage-n=10] |
2.3 MB | 2.9 MB | -20.33% |
| ❌ | Memory | test_to_lp[merge_balance-severity=0] |
2.6 MB | 3.2 MB | -17.89% |
| ⚡ | Memory | test_to_lp[rolling-severity=50] |
429.7 MB | 354.6 MB | +21.19% |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing mal84emma:feat/cuopt-solver (bb0581b) with master (a64b657)
Footnotes
-
175 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. ↩
The single-worker thread plumbing (queue, submit, interrupt wrapper, disposal) and the CUDA device probe were module-level free functions prefixed with _cuopt_, sitting far from their only caller. Fold them into the cuOpt class as static/classmethods and class constants; the fork hook stays a one-time import-time call. Behavior unchanged.
FabianHofmann
left a comment
There was a problem hiding this comment.
great work @mal84emma ; I refactored the code a little and move cuopt helpers to the Cuopt solver class. I was confused about the minimization/maximization logic. but it turns out to be stable.
|
Awesome, thank you @FabianHofmann |
… round-trip test name Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes #515
Bringing Nvidia speed to linopy.
Why?
Changes proposed in this Pull Request
First-class support for NVIDIA cuOpt as a linopy solver: LP, MILP and convex QP on the GPU through cuOpt's direct (in-memory) API, with duals for LP/QP, MIP gap and dual bound for MILP, semi-continuous variables, and a
Model.to_cuopt()bridge. Installable withpip install "linopy[gpu]"(Linux, CUDA 12 driver ≥ 525.60.13, compute capability ≥ 7.0).Note
The following content was generated by AI (a supervised multi-agent run; every commit on this branch is marked with its authoring model).
Design points a reviewer will care about
io_apis transparently fall back to the direct build with one warning. The rationale (unverified solution-file parsing, an inverted Q convention between.lpand.mps) is documented at the code site.method=3(Barrier) is linopy's default, not cuOpt's own concurrent default, which segfaults on repeated solves of models ≳1300 variables (reproduced and documented). An escape hatch to PDLP (method=1) is documented for very large sparse LPs.0.5 · M.Q(cuOpt symmetrises internally); a committed deliberate-failure test proves the wrong convention changes the answer by 50% relative, so the guard cannot rot silently.NotImplementedErrorup front (cuOpt silently mis-solves it), warm start is refused (usable PDLP warm start needs three simultaneous non-default settings), reduced costs are not populated (upstream sign defect for maximised models with<=rows), SOS/indicator constraints are absent upstream. Each refusal carries a 1–3-line rationale at its enforcement site.available_solverson a GPU-less machine simply omits cuOpt, and the probe cannot poison lateros.fork()children.Performance
Indicative benchmark: LOPF of the PyPSA
scigrid-deexample network (585 buses, 1423 generators, 852 lines), its 24-hour day tiled to scale the LP; wall time ofm.solve(), solvers run sequentially on an otherwise idle Azure VM (8-core EPYC 7V12, Tesla T4 16 GB). HiGHS was giventhreads=8, parallel=on(measured: no gain over its defaults); cuOpt ran at this branch's defaults (direct API, barrier). Objectives agree to ≤8·10⁻⁴ relative at both solvers' default tolerances.cuOpt times are first-solve-in-process (cold): they include one-off CUDA context initialisation, which repeat solves in the same process avoid, running ~2 s faster. The gap widens with size (HiGHS scales ~n^1.9 here, cuOpt near-linearly). A T4 is the smallest supported datacenter GPU; newer hardware should do better.
Note
To be fair this is a bit of a trust-me-bro benchmark. HiGHS can solve much faster than its default config and even faster with better hardware (test was on a GPU optimized VM, though with not a very good GPU).
Regardless, cuOpt is fast and has been winning competitions.
Particularly now that cards with serious VRAM are available I think it could make solving large ESOMs more accessible without a commercial solver license.
Known upstream issue reviewers should weigh
cuOpt 26.08.00 can destabilise a long-lived host process after any in-process solve: its bundled OpenMP runtime intermittently corrupts glibc malloc metadata, crashing the process later in unrelated code. This was diagnosed to root cause (gdb backtrace into
_int_malloc, linopy exonerated by a source audit and by mitigation experiments) and reported with full evidence on NVIDIA/cuopt#1768. Every completed full test-suite run on this branch has a failure set byte-identical to master's baseline — correctness is unaffected — but roughly two thirds of full--run-gpusuite runs abort mid-run from this corruption. The feature is opt-in (install extra + explicit solver choice); whether to ship ahead of an upstream fix is a maintainer decision this PR intentionally surfaces rather than hides.Verification summary
minandmax.Solutiontime-limit path.pytest test/test_cuopt.py test/test_semi_continuous.py --run-gpu: 102 passed); CPU-only machines skip all of them; full CPU suite and every completed full GPU suite run are failure-set-identical to master's baseline (74 pre-existing failures/errors, all intest/remoteand unrelated areas).Implementation Approach
This PR was a bit of an experiment at getting a Claude agent system to one-shot new features. If you're interested in the approach I took you can read the agent docs and my thoughts on how it went here.
Checklist
AGENTS.md).doc.doc/release_notes.rstof the upcoming release is included.