Solver selection: measurements and rationale¶
This page holds the measurements behind the default linear-solver choices in
noodl.solvers.select, noodl.layers.transport and noodl.operators.graph. The code keeps
the rule and the reason; the numbers live here, with how to reproduce them. Every number is a
CPU measurement (torch 2.14 CPU, 14 threads, Windows 11, Python 3.12, SciPy SuperLU) on the
reference composed model of benchmarks/composed_model.py: 1028 unknowns, about 5300 nonzeros,
float64. Re-measure when the platform or the problem size changes.
Sparse-direct versus PCG¶
method="auto" sends a certified-SPD operator that offers a sparse form to sparse-direct
(SciPy SuperLU, one factorisation per instance), and otherwise to Jacobi-preconditioned CG.
Median of 3 warm runs, in process:
| Direction | Ensemble | pcg | sparse_direct | Ratio |
|---|---|---|---|---|
| forward | 1 | 128.4 ms | 28.0 ms | 4.59x faster |
| forward | 100 | 2399 ms | 2182 ms | 1.10x (a tie) |
| forward | 1000 | 29489 ms | 19263 ms | 1.53x faster |
| backward | 1 | 44.9 ms | 13.8 ms | 3.25x faster |
| backward | 100 | 1741 ms | 476 ms | 3.66x faster |
At ensemble 100 the forward result is a tie: repeated, interleaved runs gave pcg 2068-2434 ms against sparse_direct 2113-2182 ms. PCG needs 168-180 iterations per Newton step at this conditioning; the factorisation needs one. The cost of sparse-direct is the per-instance Python loop (SuperLU has no batched entry point), about 21 ms per instance on this model.
Reproduce: python -m benchmarks.profile_forward --compare-solvers (add --forward-only for
the forward rows).
The ensemble threshold¶
_SPARSE_DIRECT_MAX_BATCH = 32 caps the flat batch for which auto still factorises. Ratio
sparse_direct / cg for the forward solve (below 1.0 sparse-direct wins):
| Batch | 1 | 2 | 4 | 8 | 16 | 32 | 64 |
|---|---|---|---|---|---|---|---|
| ratio | 0.24 | 0.14 | 0.26 | 0.30 | 0.54 | 0.68 | 1.00 |
In the child-process acceptance gate (benchmarks/composed_scaling_report.json, whole forward
pass) cg is already ahead of an unthresholded auto at ensemble 100 (1.593 s against 1.686 s),
100x24 (39.4 s against 48.5 s) and 1000 (12.9 s against 15.2 s), while auto wins 2.2x at
ensemble 1 (0.097 s against 0.210 s). 32 is the last measured point at which the factorisation
is clearly ahead and 64 is the tie. The whole 32-64 band is within 1.0-1.5x, so choosing 32
rather than 64 costs little.
Reproduce: python -m benchmarks.report_composed_scaling regenerates the acceptance-gate
report.
Transport solver choice¶
TransportLayer(linear_solver="auto") resolves to sparse_direct at or under
_SPARSE_DIRECT_MAX_BATCH instances and to plain gmres above it. gmres_jacobi and
gmres_ilu are never chosen by auto. The decision rests on the interleaved benchmark
benchmarks/transport_solver_bench.py, which cycles round-robin over the solvers within each
round so that ambient load cancels out of the ratios. It reports median, min and max rather
than the mean. Forward and backward medians in ms, from
benchmarks/transport_solver_bench.json (2026-09-24, 5 rounds per cell):
| Ensemble | Solver | Forward | Backward |
|---|---|---|---|
| 1 | gmres | 230 | 278 |
| 1 | gmres_jacobi | 272 | 316 |
| 1 | gmres_ilu | 288 | 285 |
| 1 | sparse_direct | 12 | 14 |
| 32 | gmres | 556 | 460 |
| 32 | gmres_jacobi | 365 | 540 |
| 32 | gmres_ilu | 712 | 937 |
| 32 | sparse_direct | 252 | 254 |
| 100 | gmres | 805 | 1093 |
| 100 | gmres_jacobi | 898 | 789 |
| 100 | gmres_ilu | 2048 | 1839 |
sparse_direct is not attempted at ensemble 100 (above the threshold). Reading the table:
- sparse_direct wins at ensembles 1 and 32 by a margin larger than the spread.
gmres_iluis the clear loser at every ensemble: a differentiable step callsgmresfrom both_LinearSolve.forwardand.backward, so the per-instance ILU factorisation is paid twice per step and never reused, and GMRES still iterates afterwards.gmresagainstgmres_jacobiis ambiguous within the spread (each wins some cells), so there is no evidence to prefer a preconditioner as the fallback.
The JSON file carries a note: its run straddled a commit that made the reported iterations
of preconditioned gmres cycle-granular, so the iterations field of the gmres_jacobi and
gmres_ilu rows is stale. Timings are unaffected.
Reproduce: PYTHONPATH=src python -m benchmarks.transport_solver_bench (add --quick for a
short run). It writes the JSON file and prints a markdown table.
Operator caches¶
GraphLaplacianOperator caches the broadcast batch shape and the source/target index tensors
expanded to it, per key. At the composed reference size, recomputing them on every matvec cost
about 11% of pcg's time (820 broadcast_shapes calls per solve). Only index tensors are
cached, never an expanded view of slopes: slopes can require grad, and a cached view would
carry one autograd graph's ExpandBackward into the next forward pass.
Reproduce: profile pcg on the composed model with python -m benchmarks.profile_forward.
Other empirical facts kept in the code¶
These are small and stay next to the code that depends on them; they are listed here so that they can be found.
solvers/iterative.py: the Givens breakdown thresholdeps ** 0.75sits about 3 orders above every measured breakdown ratio (2e-16 to 1e-14) and about 2 orders below the smallest legitimate ratio over 1000 random dense systems of size 4 to 64 (2.6e-10).solvers/iterative.py: an ill-conditioned but nonsingular system (cond 1e8, m = 4, 8, 16) routinely ends its last Arnoldi step under the relative threshold, which is whySINGULARis only reported for a breakdown that truncates the cycle.solvers/newton.py: the iterative solvers' own floor on the 2x2 float32 test system is 3e-8, which is why an explicit dense Jacobian keepsmethod="direct".