Linear solvers¶
Every Newton step of the perfect-foresight solve reduces to one sparse
linear system, \(J\,\Delta X = -G\), where \(J\) is the Jacobian
of the stacked collocation residual. That linear core is pluggable:
continuo ships several backends and chooses one automatically, but you can
pin a specific backend from the API, the simulate directive, or the
command line.
The pattern of \(J\) is constant across Newton iterations — and, at a fixed grid, across the segments of a multi-segment run — so the backends that can separate the symbolic analysis (fill-reducing ordering, block structure) from the numeric factorisation analyse the pattern once and refresh only the numbers thereafter. continuo exploits this: the analysis is hoisted out of the Newton loop and reused across segments, and each segment warm-starts its first step from the previous factorisation.
Choosing a backend¶
Three entry points, from the most global to the most local — later ones override earlier ones (CLI > directive > default):
- Python API
Pass
solver=tocontinuo.Model.simul()(or tocontinuo.solve_pf):model.simul(solver="klu") model.simul(solver="auto") # the default
- The
simulatedirective Pin the backend with the model (see Commands):
simulate(T = 50, N = 250, solver = klu);
- Command line
Override the directive at the call site:
$ continuo model.mod --solver klu
The value is a preset name (below), or — from the Python API only — a
constructed solver instance for fine control (see Fine control). The
default, auto, picks a backend from the scheme’s coupling structure.
Presets¶
Preset |
Dependency |
Exploits |
Default? |
|---|---|---|---|
|
SciPy (always present) |
sparsity (COLAMD) |
fallback |
|
|
reused symbolic factorisation; BTF |
yes (one-step schemes) |
|
|
reused symbolic factorisation (no BTF) |
no |
|
|
sparsity |
no |
|
|
sparsity, multithreading |
no |
A preset is only offered when its backend can actually run: superlu is
always available (SciPy is a hard dependency); klu requires
libklu.so at run time; umfpack and pardiso require their
optional packages. Naming an unavailable preset raises a SolveError.
auto routing¶
auto (the default) routes by the discretisation scheme’s coupling
stencil:
one-step schemes (Crank–Nicolson — the only scheme implemented today) are solved fastest by
klu: it amortises the symbolic factorisation across Newton steps (and segments), which dominates the per-iteration cost.autopickskluwhen it is available, and falls back tosuperluotherwise (warning once).
So out of the box, a run uses KLU when libklu.so is installed and
SuperLU otherwise — with no change in results, only in speed.
The backends¶
superlu— the robust fallbackSciPy’s SuperLU (
scipy.sparse.linalg.splu()). Always present, so it guarantees the solver works even with no external library. Used as the validation reference and the last-resort fallback.klu— the recommended one-step backendA
ctypeswrapper over SuiteSparse KLU. KLU separates the symbolic analysis from the numeric factorisation and reuses it across refactorisations, so each Newton step is a cheaprefactorrather than a full factorisation — the main reason it is ~7× faster than SuperLU here. It also pre-orders into block triangular form (BTF), which on these models only peels the algebraic and boundary rows as 1×1 blocks (the dynamic states/jumps couple forward and backward into one large irreducible block), so BTF is a secondary, sometimes neutral, refinement;klu-nobtfturns it off.libklu.sois a system library, not a pip package; install it with Debian’slibsuitesparse-dev(or conda’ssuitesparse). When it is absent, continuo falls back to SuperLU automatically.umfpack— optional, for completenessSuiteSparse UMFPACK via
scikit-umfpack. It separates the symbolic and numeric phases cleanly and reports a condition estimate, but its numeric phase is slower than SuperLU/KLU, so it tends only to match SuperLU in practice. Install with theumfpackextra.pardiso— optional, for large / multi-core runsIntel MKL PARDISO via
pypardiso— multithreaded and competitive on large problems, but its analysis is expensive and it has no BTF, so it is best for big models or long simulations rather than the small systems continuo usually produces. Install with thepardisoextra (pulls in MKL). Install both optional backends withsolvers.Note
On the small systems here PARDISO is far slower than KLU (see the benchmarks below), and that is not mainly an AMD-vs-Intel MKL effect: forcing
MKL_ENABLE_INSTRUCTIONS=AVX512gives no consistent speed-up, whereasMKL_NUM_THREADS=1roughly halves the time — MKL oversubscribes threads on a problem far too small to parallelise, and even single-threaded it stays an order of magnitude behind KLU. Reserve PARDISO for large models, and capMKL_NUM_THREADSwhen the systems are small.
See Installation for the extras.
Fine control¶
From the Python API, solver= also accepts a constructed
LinearSolver instance, which lets you set backend-specific options
that the presets fix. For KLU these include btf (the BTF on/off switch
the klu / klu-nobtf presets toggle) and ordering (the
per-block fill-reducing ordering, "amd" or "colamd"):
from continuo.solve import KluSolver
model.simul(solver=KluSolver(btf=False, ordering="colamd"))
Guard-rails¶
Conditioning. Backends that report a reciprocal-condition estimate feed a guard-rail: once a reused factorisation degrades, the driver falls back to a full re-factorisation (a re-pivot).
Stale pivots. If a cheap refactorisation fails outright (e.g. a KLU zero pivot), the driver redoes a full factorisation and records the event.
Structural singularity. KLU reports the structural rank at analysis time, so a structurally singular Jacobian is flagged with a clear
SolveErrorrather than producing garbage.
Diagnostics¶
Each run records what the linear solver did in Solution.diagnostics
(see Python API): the backend used, the counts of full factorisations
versus cheap refactorisations, the number of re-pivot fallbacks, the
worst reciprocal-condition estimate over the run, and the factorisation
fill. These make the cross-segment warm-start observable (a two-segment
surprise shows one factorisation and one refactorisation) and surface a
loss of conditioning before it becomes a failure.
Benchmarks¶
The tables below compare the available backends across the example models
(end-to-end Model.simul() wall-clock and peak resident memory).
Regenerate them with python examples/benchmark_solvers.py --write. The
PARDISO figures are at MKL’s default threading; on these small systems that
oversubscribes and inflates its numbers (see the note above).
Model |
n |
superlu |
klu |
klu-nobtf |
umfpack |
pardiso |
|---|---|---|---|---|---|---|
cagan |
201 |
17.7 |
17.0 |
23.5 |
16.7 |
337.5 |
dornbusch |
903 |
23.2 |
23.6 |
24.7 |
24.2 |
259.2 |
goodwin |
4802 |
136.0 |
134.5 |
131.8 |
139.2 |
389.5 |
nk |
1503 |
81.3 |
80.2 |
87.8 |
78.9 |
314.9 |
nk-nonlinear |
3005 |
123.1 |
118.6 |
120.7 |
121.9 |
365.0 |
rbc |
1004 |
29.7 |
28.2 |
30.0 |
28.1 |
274.4 |
solow |
602 |
26.8 |
26.7 |
25.5 |
26.3 |
260.1 |
tobinq |
903 |
31.9 |
30.1 |
30.6 |
30.4 |
263.8 |
Model |
n |
superlu |
klu |
klu-nobtf |
umfpack |
pardiso |
|---|---|---|---|---|---|---|
cagan |
201 |
81 |
79 |
79 |
79 |
119 |
dornbusch |
903 |
82 |
80 |
80 |
82 |
124 |
goodwin |
4802 |
96 |
92 |
92 |
95 |
151 |
nk |
1503 |
84 |
81 |
82 |
83 |
129 |
nk-nonlinear |
3005 |
91 |
88 |
88 |
90 |
141 |
rbc |
1004 |
83 |
82 |
81 |
82 |
126 |
solow |
602 |
81 |
80 |
80 |
81 |
121 |
tobinq |
903 |
83 |
82 |
81 |
83 |
124 |
Median of 5 runs of end-to-end Model.simul() (includes the CasADi build). Wall-clock in milliseconds; peak resident memory in MiB (whole process — the Python/CasADi/SciPy baseline dominates, and PARDISO loads MKL). Measured 2026-06-15 on AMD Ryzen AI 9 HX 370 w/ Radeon 890M, 24 cores, Python 3.13.12.
Isolated linear solve¶
End-to-end timings are diluted by the (solver-independent) CasADi build and
evaluation. The tables below isolate the linear solve on each model’s
real stacked Jacobian: factor + solve (a cold Newton step, factorising
from scratch) and refactor + solve (the warm per-iteration cost, reusing
the symbolic analysis and pivot order — what dominates a Newton solve once
the analysis is amortised). This warm refactor is where KLU pulls ahead: it
reuses the factorisation continuo analysed once, whereas SuperLU re-factorises
in full. Regenerate with python examples/benchmark_solvers.py --micro --write.
Model |
n |
superlu |
klu |
klu-nobtf |
umfpack |
pardiso |
|---|---|---|---|---|---|---|
cagan |
201 |
58.1 |
13.9 |
20.8 |
18.0 |
366.0 |
dornbusch |
903 |
158.9 |
67.3 |
151.0 |
324.3 |
1701 |
goodwin |
4802 |
843.8 |
338.5 |
190.7 |
1265 |
2952 |
nk |
1503 |
275.5 |
109.9 |
181.5 |
429.9 |
2704 |
nk-nonlinear |
3005 |
510.3 |
164.0 |
503.2 |
909.2 |
2136 |
rbc |
1004 |
256.5 |
73.7 |
160.1 |
272.2 |
1418 |
solow |
602 |
171.8 |
51.6 |
37.4 |
231.3 |
702.9 |
tobinq |
903 |
134.8 |
60.0 |
58.7 |
182.8 |
657.1 |
Model |
n |
superlu |
klu |
klu-nobtf |
umfpack |
pardiso |
|---|---|---|---|---|---|---|
cagan |
201 |
57.2 |
14.8 |
14.7 |
17.7 |
359.6 |
dornbusch |
903 |
158.6 |
34.4 |
52.2 |
318.1 |
1359 |
goodwin |
4802 |
829.5 |
107.3 |
98.3 |
1272 |
2991 |
nk |
1503 |
268.8 |
41.6 |
44.9 |
428.8 |
2383 |
nk-nonlinear |
3005 |
463.4 |
61.5 |
121.0 |
908.6 |
3038 |
rbc |
1004 |
280.8 |
39.3 |
47.9 |
272.2 |
1386 |
solow |
602 |
116.2 |
19.9 |
18.5 |
231.5 |
541.8 |
tobinq |
903 |
135.3 |
33.6 |
25.7 |
183.1 |
662.5 |
Median of 100 repetitions, timing only the linear-solve phases on each model’s real stacked Jacobian (the CasADi build is excluded). refactor + solve is the dominant per-iteration cost once the analysis is amortised. Measured 2026-06-15 on AMD Ryzen AI 9 HX 370 w/ Radeon 890M, 24 cores, Python 3.13.12.