Eager banded and matrix-free SciPy solves¶
scipy_banded_adapter() and scipy_gmres_adapter() build opt-in adapters for
the existing registry. Both accept NumPy float32/float64 arrays and preserve
L @ y = R @ x, including matrix RHS columns and the core operator-axis layout.
They create no engine preparation, factorization, preconditioner or warm-start
cache. Every solve uses the original-residual contract.
No native-device transfer, automatic differentiation or default selection is
provided by these eager adapters.
Banded direct solve¶
BandedOperator(bands, lower, upper) copies SciPy diagonal-ordered storage:
bands[upper + i - j, j] is entry (i, j). Valid entries must be finite; unused
padding is canonicalized to zero. The retained array is read-only. Construct a
new operator when geometry, coefficients or stage data change. Forward and
Euclidean-transpose application visit valid bands without assembling a dense
matrix. Storage is O(n * (lower + upper + 1)); a wide band can still require
quadratic storage. A transpose is not a weighted adjoint or a symmetry claim.
The adapter uses SciPy's banded direct solve and rejects a supplied
preconditioner. A zero-bandwidth diagonal uses direct division, including n=1
where older SciPy banded routines have a singleton indexing defect. Singular
systems return failure evidence and the stage refuses their candidate.
Direct solves report zero iterative updates. They remain subject to the
requested original-residual tolerance, including finite-precision roundoff.
General matrix-free GMRES¶
ScipyGmresSolver(restart=20) is also usable directly with solve_linear.
Its input operator and optional approximate-inverse preconditioner remain
application callbacks. The optional preconditioner acts on the left;
SciPy minimizes a preconditioned residual while the engine independently checks
the original one. No Euclidean symmetry or positive definiteness is inferred.
This is appropriate for general nonsymmetric linear maps, including the tested
nonuniform finite-volume diffusion example, whose self-adjointness is weighted.
max_iterations counts inner iterations per RHS column. Each column starts
at zero and is solved separately. The reported iterations is the maximum
across columns; total work is their sum. The diagnostic message retains all
counts and SciPy status codes. Any unsuccessful column makes the overall
candidate unsuccessful; per-column original residual norms remain available.
restart bounds the Krylov cycle length; workspace scales with
n * min(restart, n) in addition to vectors. A matrix-free representation does
not eliminate this workspace or imply an arithmetic speedup.
The solver specifies callback_type="legacy" to keep that inner-iteration
budget consistent across supported SciPy APIs, and supplies explicit atol.
It bridges old tol and current rtol signatures. Required CI checks the oldest
NumPy 2-compatible SciPy series using SciPy 1.13.1, NumPy 2.0.2 and array-api-compat
1.12, plus current dependencies. SciPy introduced NumPy 2 support in its
1.13 release.
The older declared SciPy lower bound does not produce a compatible NumPy 2
environment by itself; dependency metadata still governs resolution. A
tol-only compatibility test forwards into the real current solver; it is not
a claim that the full engine is qualified on an incompatible dependency pair.
Explicit registration example¶
Both adapters can coexist because their left predicates recognize different
operator types. Mixed right operands support NumPy arrays, SciPy CSR matrices,
banded operators and ActionOperator. Register during setup and unregister
during teardown; do not mutate registry bindings per stage in concurrent runs.
import numpy as np
from op_engine import (
ActionOperator, BandedOperator, LinearSolveOptions, array_namespace,
implicit_solve, register_sparse_adapter, unregister_sparse_adapter,
scipy_banded_adapter, scipy_gmres_adapter,
)
bands = np.asarray([[0.0, -1.0, -1.0], [3.0, 3.0, 3.0], [-0.5, -0.5, 0.0]])
left = BandedOperator(bands, lower=1, upper=1)
rhs = np.asarray([2.0, 1.5, 2.5])
xp = array_namespace(rhs)
action = ActionOperator(left.shape, left.dtype, xp, left.__matmul__, left.T.__matmul__)
inverse = ActionOperator(left.shape, left.dtype, xp, lambda v: v / 3.0)
options = LinearSolveOptions(rtol=1e-10, atol=1e-12)
register_sparse_adapter(scipy_banded_adapter(options=options))
register_sparse_adapter(scipy_gmres_adapter(options=options, preconditioner=inverse))
try:
banded_result = implicit_solve(left, np.eye(3), rhs)
matrix_free_result = implicit_solve(action, np.eye(3), rhs)
finally:
unregister_sparse_adapter("scipy-banded")
unregister_sparse_adapter("scipy-gmres")
np.testing.assert_allclose(banded_result, np.ones(3))
np.testing.assert_allclose(matrix_free_result, np.ones(3))
Lifecycle and derivatives¶
There is no reuse keyed only by an operator's shape, object identity or stage label in these adapters. New band objects own their copied coefficients; action callbacks own their fixed linearization. An externally supplied preconditioner must match that map. The provider owns mesh/coefficient/stage/preconditioner generation tracking if it introduces any cache. Adapter options/context and preconditioner bindings remain adapter-wide, as described in the contract guide.
Compiler-native JAX/Torch state actions may supply native application and transpose callbacks, but these eager NumPy solvers deliberately refuse their native inputs. Do not add a hidden host conversion to make them match. Native implicit-stage derivatives and nonlinear Newton--Krylov solves retain separate qualification. Nonlocal tiled callbacks remain possible without claiming subquadratic all-pairs arithmetic.
CPU measurements¶
The reproducible worker runner is python -m scripts.benchmark_linear_operators.
It compares dense, cached SciPy CSR, uncached banded direct, and matrix-free
GMRES with/without Jacobi preconditioning. Fixtures use a nonuniform
reaction-diffusion operator at a fixed spectral scale, with a known synthetic
solution and an independently computed cell-flux RHS. They are a solve/storage
experiment, not spatial-refinement or application evidence.
The initial workers retain all
180 attempts: 156 successes and 24 pointwise-accuracy failures. Those failed
GMRES candidates had passed the adapter's normwise residual gate. Thus residual
success alone did not establish the separately requested solution accuracy.
The initial runner and defaults are retained in commit
2d2094a.
Those initial failures retain their worker stderr; they do not contain complete
worker-level timing/RSS diagnostics.
The tolerance check passed 180/180 with the same pointwise gates and tighter solve tolerances. The separate final timing run repeated all 180 cases on CPU 0 after the other local qualification jobs finished; all passed again. This shared workstation is an Intel Core Ultra 7 265U running Python 3.12.14, NumPy 2.5.4 and SciPy 1.18.1. CPU affinity and source hashes were checked for every final worker. Its command is:
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 \
taskset -c 0 python -m scripts.benchmark_linear_operators \
--sizes 64 512 2048 --rhs 1 4 --dtypes float32 float64 \
--workers-per-case 3 --repeats 7 --timeout 120 \
--rtol-float32 1e-6 --rtol-float64 1e-12 \
--require-success --output /tmp/eager-operators-refined.jsonl
Refined worker rows record CPU affinity/model, versions, thread settings, source hashes, fixture/representation preparation, first solve, seven warm solves and whole-worker peak RSS. Preparation includes synthetic fixture creation. CSR warm timings reuse the existing LU cache; the new adapters are uncached and include original-residual verification. Logical representation bytes exclude cached factors, Krylov workspace, other working arrays and allocator overhead; peak RSS includes the whole process. First-solve time is not process startup time. These distinctions must accompany comparisons and any default decision.
The following values are medians across three fresh workers for n=2048,
one RHS, float64 (each warm value is itself the median of seven solves).
Full size/RHS/precision results and worker ranges are in the
machine-readable summary.
| Path | First solve, ms | Warm solve, ms | Whole-worker peak RSS, MiB | Logical operator buffers |
|---|---|---|---|---|
| Dense | 198.38 | 191.62 | 140.24 | 64 MiB |
| Cached CSR | 0.931 | 0.0422 | 62.73 | 112 KiB |
| Banded direct | 0.464 | 0.166 | 61.81 | 48 KiB |
| Matrix-free GMRES | 4.368 | 4.030 | 61.90 | 48 KiB |
| GMRES + Jacobi | 2.267 | 1.776 | 61.92 | 64 KiB |
Cached CSR is the fastest warm path for this fixed tridiagonal fixture.
Banded direct avoids dense storage when narrow bands are available, and its
first solve is cheaper here than CSR factorization. GMRES supports an action-only
map but is slower than either structured direct path in this experiment.
The roughly one-MiB differences among the structured/action paths are not a
reliable memory ranking. Dense logical bytes include both L and dense identity
R; CSR includes both sparse operators, while the new paths use an identity
action for R. Factor/workspace and whole-process RSS must remain separate.
These scoped results do not establish an application speedup or justify changing
defaults. Choose a solver and tolerance against independently checked solution
or measured-output accuracy; a residual gate alone is insufficient.