Solvers & preconditioners
jno.solve and jno.precond are slots: you hand one to fem.solve(...) or
fdm.solve(...) and it replaces the default without changing a single term of the
weak form. The same slot objects work for both front doors.
Between "accept the default" and "write a full solve_fn" sits the slot API: the solver
factorises into four orthogonal slots, each a configured callable (never a string) from the
jno.solve / jno.precond namespaces — or your own with the same contract. Every None keeps
today's default; solve_fn= stays the total override (passing both is an error).
u = fem.solve(
x0 = u_guess, # warm start — see "What a warm start buys" below
nonlinear = jno.solve.newton(), # linearization driver (nonlinear problems)
linear = jno.solve.gmres(), # inner linear solve: lu / dense / cg / bicgstab / gmres
precond = jno.precond.jacobi(), # v -> M⁻¹v spec, materialized against the assembled A
)
Choosing a linear solver
Everything shipped is pure JAX — jit/vmap-native, differentiable (Krylov on
lax.custom_linear_solve, Newton on lax.custom_root), receives the assembler's BCOO operator
directly (no densification), and composes with the periodic reduction and every parametric/inverse path.
Pick by structure:
| structure | solver | notes |
|---|---|---|
| SPD (Poisson, elasticity, mass) | cg |
cheapest per iteration |
| non-symmetric (advection, SUPG) | bicgstab / gmres |
bicgstab with jacobi() is the default |
| iterative preconditioner (inner Krylov, block/Schur) | fgmres |
flexible right preconditioning — Saad, SISSC 14(2), 1993 |
| symmetric indefinite (Stokes/Biot saddle, biharmonic) | minres |
monotone residual, O(1) memory — Paige & Saunders, SINUM 12(4), 1975 |
complex-symmetric (A = Aᵀ, not Aᴴ: time-harmonic eddy-current/A–V, Helmholtz, RCWA) |
cocg |
CG's short recurrence over the bilinear form xᵀy — O(n) memory and one matvec per step where gmres needs a (restart, n) basis. Only valid when the operator really is complex-symmetric; it can break down where CG cannot — van der Vorst & Melissen, IEEE Trans. Magn. 26(2), 1990 |
| SPD, batched/GPU-heavy | chebyshev |
inner-product free (no reductions) — Golub & Varga 1961 |
| indefinite | lu |
sparse-direct; vmaps on every backend — host/cudss/pardiso factor once for a batch against one matrix, the default device backend factors each system (jno.setup(lu_stack=k) stacks k per call) |
| cuSolver refuses it, or is slow | lu(backend="host") |
SuperLU on the host, driven from the device — same answer, same gradients. Often faster than cuSolver, and runs meshes it rejects |
| shift-invert eigs, or a constant-operator transient | lu(backend="cudss") |
NVIDIA cuDSS. Fastest per solve — caches the symbolic plan on the sparsity, so it survives a change of values. Needs the optional stack |
| a Newton loop (or no GPU / a factorization too big for device memory) | lu(backend="pardiso") |
Intel MKL PARDISO, multithreaded CPU. Fastest factorization — a Newton step reuses the analysis. x86-64 |
| a Newton march (the tangent changes every iteration) | lu(backend="host", reuse=False) |
the host factorization cache keys on the operator, so a never-repeating tangent fills it and leaks a factorization a step; reuse=False drops each one |
| small systems / coarse blocks | dense |
LAPACK, vmap-native |
jNO warns you when this applies
jno.fem detects a saddle system structurally at build time —
a field whose own test function never meets its own trial function contributes no diagonal block —
and fem.solve() warns, naming the field, when it is about to use the matrix-free default on one.
It warns rather than refuses: a 2-D saddle of moderate size does solve acceptably that way. Passing
linear= or precond= silences it, since that is the deliberate choice it asks for. The detection
is structural, so it holds in every mode with no tangent to assemble, and it fires under
jit/vmap/grad — which matters, because the runtime residual guard needs a concrete residual
and steps aside on a tracer, leaving a transformed solve otherwise unguarded.
Warm starts — what x0= actually buys
x0= is free to pass and easy to misjudge, so here is the rule, measured. A Krylov solve has to
cover the distance from its initial residual down to rtol. A warm start removes the decades it
already starts below, so the fraction of iterations saved is a ratio of logarithms — not a
ratio of errors:
with ε the relative error of the guess. At rtol = 1e-10, a guess good to 1 % removes one fifth
of the work — and a guess good to 10 % removes one tenth, which is usually not worth arranging.
Jacobi-CG, 2-D diffusion with a 10× conductivity blob, 791 DOFs, rtol = 1e-10
Cold (x0 = 0) is 85 iterations. Two initial errors of the same norm were compared: a
smooth one (a low-frequency field) and a rough one (white noise).
rel. error ε |
smooth x0 |
rough x0 |
law predicts |
|---|---|---|---|
| 3e-01 | 78 | 93 | 81 |
| 1e-01 | 74 | 90 | 76 |
| 1e-02 | 67 | 85 | 68 |
| 1e-03 | 61 | 76 | 59 |
| 1e-04 | 52 | 67 | 51 |
| 1e-06 | 35 | 53 | 34 |
The law tracks the smooth column to within 1–3 iterations across five decades. The rough column does not follow it at all: a rough guess accurate to 1 % costs exactly what a cold start costs, and a rough guess at 30 % is worse than starting from zero.
The spectrum of the initial error matters, not its norm. Krylov convergence is governed by how the error is distributed over the spectrum (Greenbaum, Iterative Methods for Solving Linear Systems, SIAM 1997, ch. 3), and a guess that is pointwise close but rough injects high-frequency error the method must then remove. This is why the previous solve in a sweep or a time step is such a good warm start — it is smooth, and it is wrong in the same modes the operator is about to correct.
A neural surrogate is a poor warm start, measured
The tempting idea — train a network to predict the solution, feed it as x0 — does not clear the
bar. On the sweep above, against a cold start: an MLP predicting DOF values directly came in at
−2 % (i.e. slower than cold, its output being spectrally rough), and predicting POD
coefficients instead — whose output is smooth by construction, being a combination of smooth
modes — recovered only +5 %. Both lost to the trivial baseline of reusing the nearest stored
snapshot (+9 %), which costs nothing to build.
The arithmetic is why, and it is not an engineering problem: at a realistic surrogate accuracy of
1e-2 to 1e-3 the law caps the prize at 20–30 %, and collecting it needs an output that is smooth as
well as accurate. Reach for x0= where a nearby solve already exists — a continuation sweep
(jno.solve.continuation, which warm-starts for you), a transient step (which warm-starts
automatically, and rejects x0= for that reason), or a Newton loop. Those are smooth by
construction and free.
A learned subspace is worth far more than a learned point
The failure above is a failure of regression, not of learning. Fit a network to predict a point and
it optimises the wrong norm; CG minimises ‖e‖_A, and an L2 fit is blind to how the error sits on
the spectrum. Give it a subspace instead and there are two much better things to do with it, both
already expressible with the slots on this page.
1. Solve in the subspace rather than regressing into it. The A-orthogonal projection
U(UᵀAU)⁻¹Uᵀb is the provably best starting point from span(U) — and it is exactly what
fem.solve(basis=U) computes, certificate included.
2. Put the subspace in the preconditioner, as a coarse-space correction
M⁻¹ = diag⁻¹ + U(UᵀAU)⁻¹Uᵀ (Nicolaides, SINUM 24(2), 1987, 355; Frank & Vuik, SISC
23(2), 2001, 442). This needs no new API — precond= takes a bare ctx -> applier:
def coarse_jacobi(U): # Jacobi + a coarse solve on span(U)
def spec(ctx):
AU = jnp.stack([ctx.A @ U[:, j] for j in range(U.shape[1])], axis=1)
Einv = jnp.linalg.inv(U.T @ AU)
dinv = 1.0 / ctx.diag()
return lambda v: dinv * v + U @ (Einv @ (U.T @ v))
return spec
u = fem.solve(linear=jno.solve.cg(), precond=coarse_jacobi(U), basis=None)
Same problem, same rank-8 POD subspace, iterations against a cold Jacobi-CG
| what the subspace is used for | saved |
|---|---|
MLP regressing DOF values → x0 |
−2.7 % |
MLP regressing POD coefficients → x0 |
+3.9 % |
nearest stored snapshot → x0 |
+8.6 % |
basis=U (A-optimal projection) → x0 |
+18.0 % |
| coarse-space preconditioner | +27.5 % |
| both together | +39.8 % |
The L2-fitted network's energy-norm error was 17–77× larger than the A-optimal projection from the very same subspace — the whole gap between the first rows and the fourth is the wrong norm, not a weak network.
And the two are different in kind — only one improves with size
h |
DOFs | cold | x0 saves |
coarse-space saves |
|---|---|---|---|---|
| 0.060 | 377 | 60 | 21.7 % | 21.7 % |
| 0.040 | 791 | 86 | 17.4 % | 25.6 % |
| 0.030 | 1394 | 113 | 17.7 % | 28.3 % |
| 0.022 | 2522 | 151 | 17.2 % | 29.8 % |
A warm start is a constant offset — it removes decades of residual and the log law fixes the fraction, so it stays flat under refinement. A coarse space removes the slow modes from the spectrum, so its benefit grows with the problem. Prefer it wherever the subspace is worth building at all.
Scope, since this was measured on one problem class: SPD + CG, a rank-8 POD subspace from
snapshots, a 10× conductivity contrast. Non-symmetric and saddle-point systems are not covered
here, the rank/cost trade-off is unexplored (each application costs two n × k products and a
k × k solve), and POD alone already delivers the table — a learned subspace has to beat POD to
earn its place, which is not shown.
Two that are not Ax = b on a square, definite operator
minres and lstsq cover the cases the table's other rows cannot, and both take a
jno.solve.LinearOperator (or a BCOO, or a dense array) directly — no FEM required:
op = jno.solve.LinearOperator(K) # K symmetric but INDEFINITE — cg cannot be used
x = jno.solve.minres(tol=1e-12)(op, b) # rel-resid 7.3e-13
xl = jno.solve.lstsq(A, b, atol=1e-12, btol=1e-12) # A RECTANGULAR (80 x 30), min ‖Ax − b‖
xd = jno.solve.lstsq(A, b, damp=0.5) # + Tikhonov damp²‖x‖² — ill-posed inverses
Checked against dense oracles
minres on a spectrum straddling zero (eigenvalues in [-3, -0.5] ∪ [0.5, 3]) reaches a
7.3e-13 relative residual, where cg has no convergence theory at all. lstsq matches
numpy.linalg.lstsq to 7.5e-13, and with damp=0.5 matches the Tikhonov normal equations
(AᵀA + damp²I)x = Aᵀb to 5.1e-12.
Direct backends
Four lu backends, and the choice is not obvious — the fastest one depends on which phase your
problem repeats.
Pick by the phase that repeats
A Newton loop re-factorizes every iteration → PARDISO (220× over SuperLU, vs cuDSS's 115×). A shift-invert eigensolve or constant-operator transient re-solves against one factorization → cuDSS, ~11× faster per solve and it takes a whole block of right-hand sides at once.
There is deliberately no auto: which wins depends on hardware jNO cannot inspect. Install both
with pip install jax-numerical-operators[fem].
backend="host" — SuperLU on the host
factors on the HOST (SuperLU) and drives it from the device; same answer and same gradients (wrapped in custom_linear_solve, transpose via SuperLU trans="T"). Measured faster where cuSolver also works — Stokes 21,839 DOFs 0.27 s vs 1.67 s, H(curl) 17,072 complex DOFs 13.3 s vs 36.4 s — and it runs meshes cuSolver rejects (Stokes 26,908, H(curl) 26,154, both of which fail on GPU). Affordable because a direct solve factorises once: the operator crosses PCIe once, not per iteration. Read the win as cuSolver's sparse LU is weak, not GPUs lose — see the row below
backend="cudss" — NVIDIA cuDSS
NVIDIA cuDSS — the fastest direct backend wherever it runs, because it separates the symbolic plan from the numeric factorization and jNO caches on the sparsity, so the plan survives a change of values. Against backend="host" on an RTX 3070 (fp64 at 1/64 rate — the unfavourable card): Stokes saddle factorization 3.4 ms vs 79.9 ms, lap3d 50³ 576 ms vs 64,856 ms, and 64.7× per Newton step at n=64,000. Also factors the Stokes saddle cuSolver calls singular, with smaller residuals. Needs the optional stack (nvmath-python, cudss, cupy); raises a clear ImportError otherwise. Fill-in still governs 3-D (69×→218× nnz growth at lap3d 20³–40³), so it moves the ceiling and makes device memory the binding constraint — it is not a substitute for a preconditioner Two things happen automatically: a block right-hand side (n, k) is solved in one call (measured 2.7× at k=4 to 5.4× at k=16 over the same factorization solved column by column — this is what the shift-invert eigensolver's block Krylov sweep needs), and an exactly symmetric operator is factored as LDLᵀ instead of general LU (1.41× faster, 1.38× less peak device memory on lap3d 40³). Symmetry is tested to within a few ulps rather than bitwise — an assembled FEM tangent for a symmetric form is symmetric only up to assembly round-off (a vector element block contracts components in a different order for (a,i),(b,j) than for (b,j),(a,i), measured 0.25 ulps), so a bitwise rule sent every vector and coupled problem down the general-LU branch. Within the gate the two triangles are averaged, a correction bounded by the asymmetry it removes and therefore no larger than the round-off already in the matrix; outside it nothing is touched. The margin is not a knife-edge: the weakest genuine asymmetry that can be constructed — an advection coefficient of 1e-12, meaningless beside the Laplacian — is already 191 ulps. Measured end-to-end on a 3-D vector tangent, general → symmetric is 1.07–1.13× at 3.4k–11.8k DOFs (growing with size) and 1.10–1.62× on lap3d 20³–50³. The rest of the rule stands: symmetry is still tested and SPD is never inferred — a matrix symmetric only to ~1e-15 would otherwise be silently factored as (A+Aᵀ)/2, and a wrong SPD guess returns NaN. A replaced pivot is first given iterative refinement against the true matrix (Wilkinson 1963 — the remedy cuDSS's own docs prescribe), so a zero-pivot-but-nonsingular operator or a marginally conditioned saddle is solved (refined to ~1e-10 per solve, one SpMV per step) rather than refused; a genuinely singular operator — one whose refinement residual will not contract — still raises: cuDSS signals it through neither an exception nor a NaN, returning a finite plausible vector instead
backend="pardiso" — Intel MKL PARDISO
Intel MKL PARDISO, multithreaded CPU. The fastest factorization of the four, and like cuDSS it splits symbolic analysis from numeric factorization, so a Newton step reuses the analysis. On lap3d 50³ (n=125,000) against single-threaded SuperLU's 65,212 ms: factorization 298 ms, Newton re-factorization 296 ms — 220×, where cuDSS reaches 115×. Its adjoint is cheaper too — Aᵀx = b comes from the same factorization rather than a second one. An exactly symmetric operator uses LDLᵀ (1.9× on lap3d 50³, 13× on a saddle), which needs the upper triangle with an explicit diagonal — without that a saddle's constraint rows come back empty and PARDISO rejects the matrix. Like cuDSS it returns finite garbage on a singular operator, so jNO checks the perturbed-pivot count and raises. pip install jax-numerical-operators[pardiso], x86-64
A direct backend can return finite garbage on a singular operator
Neither cuDSS nor PARDISO signals singularity through an exception or a NaN — both return a finite, plausible-looking vector. jNO checks (the refinement residual for cuDSS, the perturbed-pivot count for PARDISO) and raises instead.
Host SuperLU (backend="host", and the default lu() / newton(direct=True) on the CPU, which
is JAX's spsolve calling scipy) fails in three different ways, depending on pivoting luck:
- it aborts ("failed to factorize matrix at line 413 in file …/dpanel_bmod.c", after a burst
of BLAS "On entry to DTRSV parameter number 6 had an illegal value" lines printed from C, which
jNO cannot suppress).
fem.solveturns this into aRuntimeErrornaming a singular / structurally rank-deficient operator, its size, SuperLU's own message and the same hints the cuDSS error gives. Measured on a three-field u–p–s saddle with continuous P1 stress (a known-incompatible space, singular by construction) at 3334 DOFs. splu(backend="host") reports Factor is exactly singular. That is named the same way, with the exact matrix size.- JAX's
spsolveanswers an exactly singular matrix with NaN. The Newton verdict then reports a non-finite residual and points at a singular tangent, not atmax_steps.
The same form at 2582 DOFs or fewer factors without complaint and returns a finite answer
that is wrong in the null space. Nothing per-solve detects that, because detecting it would cost a
residual product on every solve. Inside a Newton solve the convergence verdict catches it only if
the wrong null-space component also stops the residual from falling. A consistent singular system
can still converge, with an arbitrary null-space component. Outside Newton, check ‖Ax − b‖
yourself, or use cuDSS or PARDISO, which check.
Naming the error costs nothing on a successful solve, because it runs only on the failure path.
Batched solves and operator storage — jno.setup(lu_stack=, matvec_format=)
Every lu backend has a vmap rule, so jax.jacrev / jax.jacfwd through a solve and a vmap over
solves work on all four. host, cudss and pardiso factor once for a batch against one matrix and
solve the whole block of right-hand sides in one call — the choice for Jacobians and sensitivities. The
default device backend (cuSolver) takes one right-hand side, so it factors every system: one per call
by default, or k per block-diagonal call with jno.setup(__file__, lu_stack=k) (or lu_stack = k
under [jno] in .jno.toml). A larger k measured faster, but cuSolver allocates for the fill-in of
all k systems, so the right k depends on the problem and the card; too large fails with a cuSolver
allocation error, never a wrong answer.
The iterative solvers apply a sparse operator as cuSPARSE CSR or as split COO, chosen by timing both on
the running device, once per operator size class (matvec_format="auto", the default).
jno.setup(__file__, matvec_format="csr") or "coo" (or [jno] matvec_format) forces one — for reproducible timings, or to
rule the choice out while debugging.
Preconditioners
Declarative specs — materialized against the assembled operator at solve time. A preconditioner never changes the converged solution, only the speed, so specs need no gradient path.
| Spec | What it is | When |
|---|---|---|
jno.precond.jacobi() |
diagonal | the default — cheapest thing that helps |
jno.precond.chebyshev(degree=…) |
fixed-degree polynomial — matvecs and AXPYs only | GPU, where reductions cost; a fixed linear map, so it legally preconditions cg/minres |
jno.precond.nystrom(rank=…) |
randomized low-rank deflation | SPD only — a spectrum with a few large outlying eigenvalues, where jacobi can be worse than nothing |
jno.precond.inner(solver) |
any jno.solve solver as the M⁻¹ application |
an inexact block/system solve — iterative inner ⇒ outer fgmres |
jno.precond.form([...terms], inner=…) |
an auxiliary operator assembled from ordinary traced terms | when you can write the preconditioner as a weak form |
jno.precond.block_diag((field, spec), …) |
per-field composition over fem.blocks (also triangular) |
multifield problems |
jno.precond.saddle(mass_weight=…, laplace_weight=…) |
the standard saddle recipe as one call | Stokes / Biot — the common case |
jno.precond.lsc() / saddle(schur="lsc") |
least-squares commutator — the convection-aware Schur factor | Navier–Stokes, where the pressure-mass recipe degrades with Re |
jno.precond.fsai(power=…) |
factored sparse approximate inverse M⁻¹ = GᵀG — two sparse products, no triangular solves |
SPD only — the rung between jacobi and multigrid, when the factor is reused (below) |
jno.precond.schwarz(parts=…) |
algebraic overlapping Schwarz (domain decomposition), two-level | SPD elliptic systems split across several devices; elasticity with nullspace="rigid" (below) |
jno.precond.amg(cycles=…) |
hybrid algebraic multigrid — host setup (pyamg), device apply |
large SPD systems; needs the optional pyamg |
jno.precond.jaxamg(symmetric=…) |
GPU AMG via NVIDIA AmgX — setup and apply both on device | large systems on a GPU; needs the optional stack |
.cached(refresh=…) |
reuse an expensive setup across solves | Newton loops and transients, where the operator barely changes between solves |
M1 + M2 / M1 @ M2 |
arithmetic on specs — applied in parallel and summed, or in sequence right-to-left | writing your own Schur approximation instead of asking for one |
Every one of them is passed the same way — fem.solve(precond=…), alongside whichever linear=
solver it is accelerating. Nothing else about the problem changes:
fem = jno.fem([ui.x * vi.x + ui.y * vi.y - 1.0 * vi, u(xb, yb) - 0.0]) # 379 DOFs, SPD
fem.solve(linear=jno.solve.cg(tol=1e-10), precond=jno.precond.jacobi())
fem.solve(linear=jno.solve.cg(tol=1e-10), precond=jno.precond.chebyshev(degree=4))
fem.solve(linear=jno.solve.cg(tol=1e-10), precond=jno.precond.nystrom(rank=20))
fem.solve(linear=jno.solve.cg(tol=1e-10), precond=jno.precond.amg(cycles=1))
# an ITERATIVE preconditioner needs a flexible outer solver — plain gmres assumes a fixed M
fem.solve(linear=jno.solve.fgmres(tol=1e-10),
precond=jno.precond.inner(jno.solve.cg(tol=1e-2, maxiter=30)))
# reuse an expensive setup across a Newton loop or a march
fem.solve(linear=jno.solve.cg(tol=1e-10), precond=jno.precond.amg(cycles=1).cached())
# three more with the same shape, each needing something the others do not:
jno.precond.gmg(n_pre=2, n_post=2) # geometric MG — needs a STRUCTURED grid
jno.precond.jaxamg(symmetric=True) # GPU AMG (AmgX) — needs the [amg] extra and a CUDA device
jno.precond.ams() # H(curl) auxiliary-space MG — for N1E edge elements
All six agree — the preconditioner is a speed choice, never an answer
Solved on the same 379-DOF Poisson operator, every spec above lands within 3.5e-10 of the
unpreconditioned reference (max|u − u_ref|). That is the contract: a preconditioner changes the
iteration count, never the converged solution — which is also why these specs need no gradient
path of their own.
A preconditioner's own inner solve is exempt from the convergence gate — inner(cg(tol=1e-2,
maxiter=30)) asks for two digits deliberately. See
the gate.
For a multifield system, compose per field over fem.blocks. The key is the trial symbol, not a
name — the same u/p you wrote the weak form with:
fem.solve(linear=jno.solve.fgmres(tol=1e-10, restart=40),
precond=jno.precond.block_diag(
(u, jno.precond.inner(jno.solve.cg(tol=1e-2, maxiter=60))), # velocity block
(p, jno.precond.form([(1.0 / mu) * pp * qq], inner=jno.solve.dense())), # pressure
))
Periodic ties, slip and hanging nodes. These make the solve run on the reduced system PᵀAP,
reduced field by field. The block preconditioners split that operator: the slices they are handed
(ctx.blocks) are each field's reduced DOFs, while fem.blocks keeps slicing the full solution that
fem.solve returns. An auxiliary form([...]) is written on the full space as usual (no ties in it) and
reduced with the same field's P. So block_diag, triangular, saddle (mass, Cahouet–Chabard,
lsc), pcd and jno.solve.staggered are written exactly as on the untied problem. The preconditioners
that build a pattern once (fsai, schwarz) build it from the reduced operator, and
schwarz(nullspace="rigid") builds its rigid-body modes on the full mesh and restricts them to the kept
DOFs (a translation along a periodic direction restricts exactly). One case is refused
by name: a complex form solved as its fused real-equivalent [Re; Im] block. There a field is not one
contiguous slice, so use a complex-native composition (a child such as ams()) or a whole-system
preconditioner.
jno.precond.chebyshev(degree=…)
fixed-degree Chebyshev polynomial preconditioner: matvecs and
AXPYs only, the GPU-era substitute for Gauss-Seidel/ILU smoothing, and a fixed linear map so it
legally preconditions cg/minres. Spectrum bounds come from lmin/lmax when you pass them, else
both ends are measured by Lanczos (Lanczos 1950, §II — the extreme Ritz values), at the same cost
as a power iteration on the top end alone. This matters because the polynomial is a contraction only
inside the interval it is fitted to: the lmin = lmax/30 guess, whenever the true ratio is smaller,
leaves the lowest modes outside that interval where the polynomial amplifies them. Without the
optional matfree package that guess is the fallback.
jno.precond.nystrom(rank=…)
randomized Nyström low-rank preconditioner (Frangella, Tropp &
Udell, SIAM J. Matrix Anal. Appl. 44(2), 2023, Alg. 2.1 and §3), the rung between jacobi and a
multilevel method. Sketches A against a random n × rank matrix — exactly rank matvecs, no
factorization, no triangular sweep — and deflates the captured top of the spectrum. That is what
jacobi cannot do: a diagonal rescales, it cannot separate a few large outlying eigenvalues, and on
such a spectrum Jacobi can be worse than no preconditioner at all (measured on a rank-15-outlier SPD
system: 124 CG iterations for jacobi, 98 unpreconditioned, 46 for nystrom(rank=20)). SPD
only — the sketch takes a Cholesky, so an indefinite operator gives NaN rather than a quiet wrong
answer.
The momentum block: Picard, not Newton
A block preconditioner needs an approximate F⁻¹ on the momentum block, and which linearisation
you pick decides whether one exists. The full Newton tangent carries the reaction term (δu·∇)u,
which is indefinite — measured on a lid-driven cavity, the smallest eigenvalue of its symmetric part
goes negative by Re = 100:
| Re = 100 | min eig of sym(F) | AMG on that block |
|---|---|---|
| Newton tangent | −6.4e-04 | rel-resid 1.2e-03, diverges by Re = 400 |
Picard / Oseen (jno.lag on the convecting velocity) |
+8.5e-04 | 6.5e-11 |
Smoothed aggregation assumes a positive diagonal, so on the Newton tangent it builds a degenerate
hierarchy — jno.precond.amg() now refuses that by name rather than returning a V-cycle that
silently does nothing. Lag the convecting velocity and the block is definite and AMG-friendly. This
is why the Navier–Stokes preconditioning literature builds on Picard.
Does it scale?
3-D Stokes, lid-driven cube, lu(backend="host") against
fgmres + triangular((u, amg()), (p, lsc())), one 8 GB card:
| DOFs | direct | iterative | |
|---|---|---|---|
| 15,468 | 2.1 s | 2.9 s | direct still wins |
| 29,114 | 11.5 s | 4.6 s | 2.5× |
| 49,072 | 53.3 s | 6.5 s | 8.2× |
| 76,542 | 166.4 s | 11.4 s | 14.6× |
| 112,724 | 431.3 s | 52.0 s | 8.3× |
The crossover is around 15–20k DOFs. Note what the direct solve is not doing: it does not run out of
memory — it scales as roughly O(N³) in time and simply loses. Below the crossover it is the right
choice and stays the default.
AMG on a march
jno.precond.amg() builds its hierarchy on the host from a concrete matrix, and a march linearises
inside lax.scan where every operator is traced — so it used to refuse every transient problem. A march
now builds it once, before the scan, from the step tangent (a state-dependent mass c(u) u_t included).
A frozen setup stays correct — a preconditioner changes convergence speed, never the answer — but goes
stale when the operator moves. cached(spec, refresh=k) spells the cadence, and the march then runs as
ceil(n/k) scans, re-composed against the carried state:
fem.solve(linear=jno.solve.fgmres(), precond=jno.precond.amg()) # frozen once
fem.solve(linear=jno.solve.fgmres(), precond=jno.precond.cached(jno.precond.amg(), refresh=25))
Measured where a melt pool's coefficients move ~1e13 between solid and molten: frozen once, the Krylov
residual stalls at 2.9e-03; at refresh=25 it reaches 1.5e-04. theta()/exponential() integrate
the march themselves and are not chunked, so a cadence beside one raises rather than never firing.
A frozen preconditioner is probed once and refused if it makes a random residual worse — but only when it stands alone, since a block preconditioner need not reduce a full residual in one application.
A steady nonlinear solve has the same problem one level down: Newton runs as a lax.while_loop, so the
tangent is traced there too. An unbuilt amg() (or ilu(), or a block holding one) is therefore
built once, before the loop, from the assembled tangent at the initial guess x0 (zero by default; a
periodic, slip or hanging-node system uses its reduced tangent). It does not follow the tangent as Newton
moves: the root is unchanged and only the Krylov speed depends on how far the tangent drifts from x0.
Specs that already materialize inside the loop (jacobi, form, lsc, pcd) are left as they were.
fem.solve(linear=jno.solve.fgmres(), precond=jno.precond.amg()) # nonlinear: built at x0
fem.solve(linear=jno.solve.fgmres(),
precond=jno.precond.triangular((u, jno.precond.amg()), (p, jno.precond.form([pb * qb / mu]))))
For Navier–Stokes, lag the convecting velocity (jno.lag, above)
so the velocity block AMG is built on is the Oseen operator. cached(spec, refresh=k) is refused
here rather than ignored: a rebuild every k Newton iterations would need the loop cut into chunks,
which the steady solve does not do. To rebuild at a better state, solve again from that state (x0=).
When the pressure mass is not enough
saddle()'s pressure-mass Schur approximation stands in for the Schur complement of a viscous
operator. Once convection dominates it degrades. Measured on the Newton tangent of a lid-driven
cavity (812 DOFs, momentum block solved exactly so the count isolates the Schur factor):
| Re | 10 | 50 | 100 | 400 | 1000 |
|---|---|---|---|---|---|
saddle() pressure mass |
26 | 38 | 82 | 212 | 105 |
lsc() |
112 | 108 | 106 | 104 | 284 |
Over Re = 10 → 400 the mass degrades 8× while LSC is flat. The robustness ends: by Re = 1000
LSC has jumped to 284 and the mass has (non-monotonically) improved, so LSC is no longer ahead. This
is a result about a range, not an unconditional one. It is built from the system's own blocks —
S⁻¹ ≈ (BM⁻¹Bᵀ)⁻¹(BM⁻¹FM⁻¹Bᵀ)(BM⁻¹Bᵀ)⁻¹ — so unlike the form()-based approximations it cannot be
written by the user as weak forms, which is why it ships as a built-in. B and M do not depend on
the solution, so P = BM⁻¹Bᵀ is assembled sparsely and factorised once, for every application
and every Newton step; only F varies, and it is only ever applied.
Measuring this at all needs args=
These counts come from fem._op.jacobian(sol, args={"nu": …}). If the viscosity is a runtime
parameter and you call jacobian(sol) without args, it evaluates at an unset parameter and
returns a matrix that is 25 % wrong — silently. The operator then does not change with Reynolds
number at all, and every preconditioner looks perfectly robust on it. An earlier version of this
table was measured that way and was wrong.
What LSC does not give you
It is not mesh-independent here. At fixed Re = 100 on a cavity, iterations grow 36 → 67 as the pressure space grows 30 → 198 (the pressure mass grows 32 → 91 over the same range, so LSC is better but neither is flat). And the Reynolds result above is resolution-dependent: at 613 DOFs the same sweep reverses the ordering, because that mesh never enters the regime where the mass approximation degrades. Read the table as a resolved-discretisation result, not an unconditional one.
PCD — and the boundary condition that decides it
PCD (Kay, Loghin & Wathen 2002) is the other convection-aware Schur approximation,
S⁻¹ ≈ A_p⁻¹ F_p M_p⁻¹. It ships as jno.precond.pcd():
xin, yin, _ = d.variable("inlet", split=True)
fem.solve(linear=jno.solve.fgmres(),
precond=jno.precond.triangular((u, jno.precond.amg()),
(p, jno.precond.pcd(viscosity=nu, inflow=(xin, yin)))))
inflow is the coordinate tuple, not a region name — domain.variable(...) mints a fresh
region on every call (inlet_3, inlet_4, …), so a name re-resolved inside the spec would point at
a different region than the one your form constrained and the Dirichlet row would quietly miss the
boundary. Passing a string is refused by name.
Unlike LSC every factor is a weak form, so the supplied spec is a convenience, not a capability — it is verified equal (to 7e-16) to the same thing written by hand, which is where to go for a variant it does not cover:
w = wv.bind(x=xi, y=yi).freeze(u_lagged) # the convecting field, as KNOWN nodal data
lap_p, conv_p = pp.x*qq.x + pp.y*qq.y, (w[0]*pp.x + w[1]*pp.y)*qq
Ap = jno.precond.form([lap_p, p(x_in, y_in) - 0.0], inner=host) # A_p⁻¹
Fp = jno.precond.form([nu*lap_p + conv_p, p(x_in, y_in) - 0.0], inner=False) # F_p, APPLIED
Mp = jno.precond.form([pp*qq], inner=host) # M_p⁻¹
schur = Ap @ Fp @ Mp
Two conditions decide whether it works at all, and both are easy to get wrong:
| Inflow/outflow problem | On an enclosed flow (a lid-driven cavity) PCD fails outright — 400 iterations, the cap, at every Reynolds number, and its eigenvalues straddle zero. The commutator argument leans on boundary behaviour an enclosed flow does not provide. Use lsc() there. |
Inflow-Dirichlet on A_p and F_p |
With Neumann pressure operators PCD also fails (400). With a Dirichlet condition on the inflow boundary it takes 77. Same problem, same mesh, same everything else. |
Channel flow, momentum block exact, per Reynolds number:
| Re | 10 | 50 | 100 | 400 | 1000 |
|---|---|---|---|---|---|
| pressure mass | 41 | 184 | 301 | 284 | 318 |
lsc() |
63 | 158 | 150 | 291 | 263 |
| PCD | 37 | 75 | 77 | 237 | 355 |
PCD is decisively best from Re = 10 to 100 — 4× the mass at Re = 100 — and loses its edge by Re = 400, where everything degrades. Neither method is Reynolds-robust at the top of that range.
Preconditioners compose like the operators they stand for
A materialized spec is a linear map v ↦ M⁻¹v, so the classical physics-based Schur
approximations are arithmetic on specs rather than a menu of built-in names:
Ap = jno.precond.form([inner(gp, gq, n_contract=2), p_sym.pin()]) # A_p⁻¹ (gauged Laplacian)
Mp = jno.precond.form([pp * qq]) # M_p⁻¹ (pressure mass)
Fp = jno.precond.form(F_terms, inner=False) # F_p APPLIED, not inverted
schur = Mp + Ap # Cahouet & Chabard (1988) — which `saddle(laplace_weight=…)` already is
schur = Ap @ Fp @ Mp # PCD — Kay, Loghin & Wathen, SIAM J. Sci. Comput. 24 (2002) 237
fem.solve(linear=jno.solve.fgmres(),
precond=jno.precond.triangular((u, jno.precond.amg()), (p, schur)))
inner=False is the piece that makes a product expressible: it applies the assembled form
(M v = A v) instead of inverting it, which is what the middle factor of PCD needs. On its own such a
factor is not a preconditioner and will make a solve worse — it exists to be composed.
A product is not symmetric
(AB)ᵀ = BᵀAᵀ, and a product of two SPD inverses is not SPD. Drive a product with
jno.solve.gmres() / fgmres(), never cg/minres — a short-recurrence method will simply
fail to reach the residual gate, loudly. (The transpose applier reverses the factor order for
you, which is what keeps the reverse pass of a differentiable solve preconditioned.)
This is why jno.precond.saddle() has no schur= argument: the approximations that would populate
such a menu are things you can write.
jno.precond.form([...terms], inner=…)
preconditioners as weak forms: assemble an auxiliary
operator from ordinary traced terms and invert it as M⁻¹ (weighted mass matrices, shifted-Laplacian
Helmholtz twins, low-order proxies — written in the PDE's language). Pass a callable
form(lambda sol: [...terms...]) for a solution-dependent auxiliary — a (1/μ(u))-weighted
Schur mass whose weight is computed from the current solution. It is re-assembled once per outer
solve from that solve's entry iterate (warm start / previous march step) — the Picard-lagged
preconditioner (Elman, Silvester & Wathen 2014, §9.2): the coefficient trails the solution by one
outer solve, which changes convergence speed only, never the answer. Eager by necessity — every
Newton loop is a lax.while_loop, so the per-step iterate is a tracer no host assembly can see; a
fully traced solve that never supplies a concrete iterate gets a loud NotImplementedError, not a
garbage preconditioner.
A coefficient from another field. An auxiliary form is written over one field, and the
coefficient it needs often lives on another — PCD's F_p carries the velocity into a pressure
form, a lagged eddy viscosity carries the previous velocity, a wall-distance field is reused
across forms. Freeze the foreign field and use it like any other coefficient:
w_frozen = w.bind(x=xi, y=yi).freeze(w_values) # another form's solved field, as data
Fp = jno.precond.form([(nu + w_frozen) * (pi.x * qi.x + pi.y * qi.y), p(xb, yb) - 0.0])
The auxiliary form never declares that field as an unknown, so it is resolved by space rather than by identity: a field of the same space and order has the same nodes and connectivity on this mesh, so the gather is exact, not an interpolation.
Matching space only — a mismatch is refused, not approximated
The foreign field's space must be one the auxiliary form already uses. A P2 field in a P1 form raises: P2 values carry edge nodes a P1 gather never indexes, and reading them through a P1 slot would produce a plausible, wrong operator rather than a visible failure. Project onto the form's own space first, or write the form over a field of the matching space.
This is what still blocks PCD on a Taylor–Hood pair, where the velocity is P2 and the
pressure P1. lsc() needs no such coefficient, which is why it exists.
jno.precond.block_diag((field, spec), …)
/ jno.precond.triangular((field, spec), …) — per-field
composition over fem.blocks. triangular is the standard saddle-point shape: last block solved
first, substituted back through the assembled off-diagonal matvecs.
jno.precond.saddle(mass_weight=…, laplace_weight=…)
that standard shape as one call, for the common case.
It finds the constraint block structurally (the field with no diagonal entry — the same detection
behind the saddle-system warning), puts amg() on the momentum block and a weighted pressure
mass matrix on the constraint block, and assembles that mass on the domain's own P1 space, so
no symbols are passed and it reads identically in 2-D and 3-D. mass_weight is explicit and never
inferred (1/μ for Stokes): digging μ out of an arbitrary weak form is fragile, and a variable
viscosity would silently take the wrong weight — which costs iterations without ever failing. A
wrong weight changes convergence speed only, never the answer. Pair with jno.solve.fgmres;
minres does not apply (block-upper-triangular is nonsymmetric). Needs pyamg, and refuses by
name on a non-saddle system or a constraint field that is not P1 — for anything outside that,
compose triangular yourself. The mass matrix alone is the pure-Stokes approximation: a strong
reaction term (Brinkman / Darcy drag, or the 1/dt mass of a small implicit step) makes the Schur
complement stop looking like a mass matrix, and the mesh-robustness degrades. laplace_weight
switches to the Cahouet–Chabard approximation, a pressure mass plus a pressure Laplacian,
S⁻¹ ≈ μ·M_p⁻¹ + α·L_p⁻¹ (Cahouet & Chabard, IJNMF 8, 869–895, 1988, §3). Both weights are
the reciprocal of the coefficient the term stands for, so mass_weight=1/μ and
laplace_weight=1/α. Measured through this spec on a 2-D Brinkman channel (μ=1, mesh_size=0.12,
preconditioned GMRES to 1e-8, restart=200): α = 0 / 1e2 / 1e3 / 1e4 → 83 / 135 / 503 / 2312
with the mass alone, 83 / 60 / 76 / 74 with Cahouet–Chabard. The point is the flatness, not the
31× at α=1e4 — the count stops tracking α. The same collapse appears with the momentum block
inverted exactly instead of by AMG (31 / 63 / 134 / 189 → 31 / 26 / 25 / 24), which is how you can
tell it is the Schur approximation doing the work and not the multigrid. At α=0 the two are the
same preconditioner — that column is one number measured twice — so leave laplace_weight at its
None default on pure Stokes and the Laplacian is never assembled. The pressure Laplacian is pure-Neumann
and therefore singular — and a sparse LU of it factors happily and then applies nonsense, so the
auxiliary carries its own gauge pin and the applier projects the constant out on both sides. It is
derived for a constant α; a spatially varying drag (topology optimisation's α(ρ)) takes a
representative scalar and degrades gracefully rather than failing.
jno.precond.amg(cycles=…)
hybrid algebraic multigrid: setup once on the host via the optional
pyamg (Vaněk, Mandel & Brezina, Computing 56, 1996; PyAMG — Bell et al., JOSS 8(87), 2023),
applied as a pure-JAX V-cycle with Chebyshev smoothing (Adams et al., JCP 188, 2003). The apply is
jit/vmap-native and exactly linear, so it preconditions cg/minres too. Mesh-independent
convergence ⇒ the choice for large elliptic blocks. Inside traced/parametric solves, pre-build
eagerly: spec = jno.precond.amg(); spec.build(fem.A). Without pyamg, amg raises a clear install hint.
jno.precond.jaxamg(symmetric=…)
GPU AMG via NVIDIA AmgX as the M⁻¹ application (setup and
apply both on the device). symmetric=False builds a second hierarchy on Aᵀ so the adjoint
(reverse-mode) solve of a non-symmetric operator is preconditioned too. Measured caveat: each AmgX
application carries ~11 ms of fixed handle overhead, so it pays only where the iteration savings
exceed it — for most problems prefer precond.amg() (whose V-cycle compiles into the solve) or
linear=jno.solve.amg() (ONE AmgX crossing per solve, with warm structure-keyed re-setup measured
~10x cheaper on repeats).
.cached(refresh=…)
on any spec — reuse an expensive setup across solves: False frozen, True
rebuild on shape/sparsity change, an int k to rebuild every k-th materialization (the cadence
for a march whose operator values drift step by step), or a ctx -> key callable.
The flagship pattern — Taylor–Hood Stokes by FGMRES with an inexact velocity block solve and the viscosity-weighted pressure-mass Schur approximation (Elman, Silvester & Wathen, 2014, §9.2):
sol = fem.solve(
linear = jno.solve.fgmres(tol=1e-10, restart=40),
precond = jno.precond.triangular(
(u, jno.precond.inner(jno.solve.cg(tol=1e-2, maxiter=60))), # Â⁻¹: inexact CG
(p, jno.precond.form([(1.0/mu) * pp * qq], inner=jno.solve.dense())), # Ŝ ≈ μ⁻¹ M_p
),
)
…and the same recipe when the defaults suit — multigrid on the momentum block, exact pressure mass:
sol = fem.solve(linear=jno.solve.fgmres(tol=1e-10, restart=150),
precond=jno.precond.saddle(mass_weight=1.0/mu))
Give FGMRES enough restart: the default restart=30 stagnates on a 3-D Taylor–Hood block
preconditioner and does so quietly — it still returns, just slower and less accurate. Measured at
4302 dofs, tol=1e-10: restart=30 → 2.30 s for 3.3e-6; restart=150 → 0.49 s for 3.3e-9. On
3-D Stokes this is what makes the recipe overtake a direct factorisation — measured crossover at
~15k dofs, and 3.4–3.9× faster (23.2 s → 5.9–6.9 s over repeat runs) at 41k, where LU's fill-in also
costs more memory.
On a reaction-dominated system — Brinkman/Darcy drag, or a small implicit time step — add the Laplacian leg, and the iteration count stops tracking the reaction coefficient:
Single precision inside a double solve — float32=True
Every jno.precond constructor takes float32=False. With True, the preconditioner is built against a
single-precision copy of the operator and applied in single precision, while the Krylov solve — its
residual, its dot products and its convergence test — stays in double. The answer meets the same
tolerance; only the preconditioner's arithmetic is cheaper.
A single-precision preconditioner is only approximately symmetric, and standard preconditioned CG
relies on exact symmetry: it lost conjugacy and stopped at a true residual of 6e-6 against a requested
1e-8 (float32 FSAI, 3-D elasticity), silently. jno.solve.cg therefore switches to flexible CG
(Notay, SIAM J. Sci. Comput. 22(4), 2000) whenever the preconditioner is float32 (or non-symmetric),
which keeps the requested accuracy.
CG to 1e-8, preconditioner in float32 vs float64 (RTX 3070)
Measured on 3-D P1 (303k), P2 (142k) and elasticity (206k) operators:
amg: 1.49×, 1.55×, 1.2–1.3×chebyshev: 1.8–3.0×fsai: 1.1–1.2×jacobi: 1.0× — a single scale, nothing to save
The gain is memory bandwidth: it is largest where the preconditioner dominates the iteration. One device, one precision pair — measure on yours.
FSAI — jno.precond.fsai()
A factored sparse approximate inverse for SPD operators: M⁻¹ = GᵀG ≈ A⁻¹, with G lower
triangular on the pattern of tril(A) (or tril(A²) with power=2) and each row a small independent
dense solve (Kolotilina & Yeremin, SIAM J. Matrix Anal. Appl. 14(1), 1993). Applying it is two sparse
products — no triangular solves and no sequential sweep, unlike ILU — so it runs well on a GPU.
The pattern is computed once on the host; the numeric factor follows the operator's current values inside a compiled solve, a Newton loop or a march. A non-symmetric operator is refused, and a changed sparsity pattern poisons the factor with NaN rather than applying a stale one.
CG to 1e-8, RTX 3070, float64
| 3-D P1, 303k | 3-D P2, 142k | 3-D elasticity, 206k | |
|---|---|---|---|
jacobi |
216 its / 76 ms | 197 its / 58 ms | 778 its / 588 ms |
fsai |
89 its / 58 ms | 82 its / 39 ms | 325 its / 387 ms |
amg |
48 ms | 52 ms |
The setup costs about one to two solves (101, 115, 530 ms there), so FSAI pays when the factor is
reused: a constant-operator march, .cached() across solves, several right-hand sides.
power=2 grows the setup steeply (7.1 s on the P2 case, for 32 iterations) — start with power=1.
Overlapping Schwarz — jno.precond.schwarz()
Domain decomposition as a preconditioner, built from the assembled operator alone: no mesh, no physics, so any FEM or FDM system qualifies. (This is not the multi-method coupling of Domain decomposition, which couples different solvers on different regions.)
- Partition. METIS splits the operator's graph into
partscompact pieces with a minimal cut (default: one per ~256 unknowns). Unknowns with no neighbours — eliminated Dirichlet rows — are left out and solved by their diagonal. - Overlap. Each piece grows by
overlaplayers of graph neighbours (default 1). - Local solves. Each subdomain's matrix is
Arestricted to its unknowns (a zero-Dirichlet problem on its grown boundary). All of them are solved exactly and together, as one batched product with their inverses. The results are added back, summing where subdomains overlap. - Coarse correction (
coarse=True, the default). One small global problem with a few unknowns per subdomain carries information across the whole domain in one step. Without it, the iteration count grows with the number of parts; with it, it stays flat.
fem.solve(linear=jno.solve.cg(tol=1e-8), precond=jno.precond.schwarz())
fem.solve(linear=jno.solve.cg(tol=1e-8), precond=jno.precond.schwarz(nullspace="rigid")) # elasticity
fem.solve(linear=jno.solve.cg(tol=1e-8), precond=jno.precond.schwarz(), shard=4) # 4 devices
On jno.fdm it composes the same way, on the operator FDM assembles for the slots
(FDM solver slots), including inside a jno.core training step
and for vector unknowns with nullspace="rigid":
| Argument | Meaning |
|---|---|
parts |
number of subdomains; default ~256 unknowns each, rounded up to a multiple of the device count |
overlap |
layers of neighbours each subdomain grows by |
coarse |
two-level (default) or one-level |
restricted |
RAS (Cai & Sarkis 1999): usually fewer iterations but non-symmetric — cg then runs flexible CG |
nullspace |
what the coarse space carries per subdomain: None one constant (Nicolaides 1987); "rigid" the rigid-body modes of a displacement field (translations + rotations), which elasticity needs; or an explicit (n, k) array |
float32 |
single-precision application |
CG to 1e-8, two-level, RTX 3070 — iterations at 64 / 256 parts
| Problem | Iterations | Solve |
|---|---|---|
| 2-D Poisson, 11.8k | 50 / 43 | 32 / 24 ms |
2-D cantilever, nullspace="rigid" |
57 / 41 | 44 / 55 ms |
| 3-D Poisson, 18.9k | 37 / 34 | 130 / 63 ms |
On the cantilever the coarse space decides the scaling: at 16 / 64 / 256 parts, one constant per part needs 154 / 207 / 256 iterations, the rigid-body modes 103 / 90 / 56.
On one GPU it is slower than amg (2-D Poisson 11.8k, 256 parts: 43 its / 24 ms against amg's
12 its / 3.6 ms). Its value is placement across devices.
Several devices (fem.solve(shard=…), a concrete or a traced operator): the subdomains are split
into one block per device. Each device builds, inverts and applies only its own, and their contributions
are combined by an all-reduce of an n-vector (three per application in the two-level form). The coarse
matrix and the vectors stay replicated. Two limits:
- The numeric setup gathers the operator's pattern and values onto every device, once per factorisation (O(nnz) there, transiently). The O(parts · m²) local matrices are what gets split.
- Verified on simulated CPU devices (per-device block counts in the compiled program, the all-reduce, answers and a gradient through the traced route on 1, 2 and 4 devices). Not timed on multi-GPU hardware.
Scope. Schwarz suits elliptic, positive-definite systems: diffusion, elasticity, implicit time steps.
Indefinite problems (Helmholtz, a saddle point taken as a whole) are outside what it handles robustly.
It needs x64 and an assembled operator, and METIS: pip install "jax-numerical-operators[fem]" (or
[fdm], or [metis]).
References: Toselli & Widlund, Domain Decomposition Methods, Springer 2005; Karypis & Kumar, SIAM J. Sci. Comput. 20(1), 1998 (METIS); Vaněk, Mandel & Brezina, Computing 56, 1996 (rigid-body near-null space).
Nonlinear drivers
The default Newton — jno.solve.newton()
A nonlinear fem.solve(), and each step of a nonlinear march, runs jno.solve.newton() unless told
otherwise. Its direct= argument picks how each linear step is solved:
direct= |
tangent | linear step |
|---|---|---|
None (default) |
assembled J(u) (a BCOO), wherever the assembler provides one |
Jacobi-BiCGStab, or the linear=/precond= slots. The BiCGStab answer is verified (one SpMV) and re-solved with Jacobi-GMRES when its relative residual is not small |
False |
matrix-free — J·v from a JVP, no tangent stored |
BiCGStab, or the linear= slot |
True |
assembled | a sparse LU each step, or the linear= slot's direct solver (see Sparse-direct Newton below) |
When each one falls back:
- The default runs matrix-free, as
direct=Falsedoes, when the problem has no assembled tangent (a residual-only operator — the matrix-free-only paths listed under the sparse-direct Newton below). - With
nonlinear=unset, a directlinear=slot (lu,dense,amg) selectsdirect=True. direct=Trueneeds the assembled tangent and raises without one; an explicit matrix-freenonlinear=beside a directlinear=raises too.
Because the default hands its linear step an assembled matrix, a precond= that needs one (jacobi)
composes with it; direct=False has no matrix and is the choice when the assembled tangent would not fit
in memory. All three modes are differentiable (implicit differentiation through lax.custom_root).
Picard / lagged coefficients — jno.lag
When a solution-dependent coefficient's Newton tangent
destroys the linearized system's structure (the classic case: a shear-thinning viscosity μ_eff(u) in
non-Newtonian Stokes flow, whose full-Newton velocity block defeats AMG/block preconditioners), freeze
it with jno.lag(...) and drive with jno.solve.picard():
fem = jno.fem([2 * jno.lag(mu_eff) * inner(eps(ui), eps(vi)) - pi * div(vi), ...])
sol = fem.solve(nonlinear=jno.solve.picard(damping=0.7), linear=jno.solve.fgmres(),
precond=jno.precond.triangular(...))
lag is stop_gradient on the traced expression, so the residual's linearization is the Picard
operator — each outer step re-solves the lagged system (linear convergence, but every inner system keeps
its symmetry/definiteness); the converged solution is identical to full Newton's. Without any lag
marker, picard(damping=…) is exactly damped Newton (jno.solve.newton(damping=…)). Caveat for inverse
problems: implicit differentiation then also uses the lagged Jacobian — drop lag when exact parameter
gradients matter more than per-step solvability.
Anderson acceleration — picard(anderson=m), staggered(anderson=m)
A fixed-point loop converges only linearly, and slowly when the lagged coupling is strong. anderson=m
mixes the last m iterates into each new one (type-II Anderson acceleration, Walker & Ni, SIAM J. Numer.
Anal. 49(4), 2011). It is safeguarded: a mixed step that does not decrease the residual retreats toward
the plain iterate, so it is never worse than anderson=0, the default. Gradients are unchanged, since
the converged point is still differentiated through custom_root.
sol = fem.solve(nonlinear=jno.solve.picard(anderson=5))
sol = fem.solve(nonlinear=jno.solve.staggered([u, d], anderson=5))
-∇·((1 + c u²)∇u) = f, lagged coefficient, Picard steps to rtol 1e-8
c |
picard() |
picard(anderson=5) |
|---|---|---|
| 5 | 33 | 11 |
| 20 | fails in 400 steps | 14 |
| 50 | fails in 400 steps | 20 |
Alternate minimization — jno.solve.staggered([u, d]) / staggered([[v, p], [T]])
Some coupled energies are non-convex in the fields jointly but convex in each separately. A monolithic Newton then has no descent guarantee and simply diverges; solving one field at a time turns the problem into a sequence of convex solves:
Variational phase-field fracture is the canonical case — (1-d)²|∇u|² is quartic in the pair, while
u alone is a linear elasticity problem and d alone a linear elliptic one. Measured on the coupled
damage form in tests/test_fem_staggered.py: newton() leaves with a residual around 1e+25 (it
raises), and the staggered sweep converges to a genuine root of the coupled system. Fixed-stress Biot
poroelasticity and thermo-mechanical staggering have the same shape.
Algorithm: Bourdin, Francfort & Marigo, Numerical experiments in revisited brittle fracture, JMPS 48 (2000), §3 — as the staggered operator split with a history field, Miehe, Welschinger & Hofacker, IJNME 83 (2010).
Groups — fields solved together inside one sweep. An entry of the field list may itself be a list:
This is not a convenience. A velocity/pressure pair cannot be swept apart: the pressure block is the
constraint block, with no diagonal of its own — the block jno.precond.saddle locates
structurally — so "solve p with v frozen" is not a well-posed sub-problem. Any flow staggered
against a solid or a temperature therefore has to group its Stokes pair. Measured on a three-field
Stokes/temperature problem (tests/test_fem_staggered_groups.py): the grouped sweep lands on the
monolithic root to 1e-7 relative, and the flat staggered([v, p, T]) does not converge at all.
A bare symbol among lists is its own group, so [[v, p], T] is legal and [u, dm] keeps meaning exactly
what it always did — one field per sweep. Every block must still appear, in exactly one group.
A group holding a constraint field is indefinite, so it wants direct=True for the reason in the
next paragraph: a matrix-free sub-solve cannot be preconditioned, and unpreconditioned BiCGStab on a
saddle block is not a solve. That is documented rather than enforced — fem.solve's own saddle warning
already fires on the shape.
direct=True factorizes each field's diagonal block rather than solving it matrix-free, and pairs
with a linear= slot:
fem.solve(nonlinear=jno.solve.staggered([u, dm], direct=True), linear=jno.solve.lu(backend="pardiso"))
It exists because the matrix-free sub-solve cannot be preconditioned. A precond= spec materializes
against an assembled operator, and a staggered sub-problem is a restriction closure
(x -> R(u with block set to x)[block]) with no matrix, so an ill-conditioned block is solved by
unpreconditioned BiCGStab — near-incompressible elasticity (ν → 0.5) being the usual victim. That
was also why a direct linear= slot used to be refused against staggered outright: there was nothing
to factorize.
The extraction avoids a data-dependent nnz (which would break the static shapes a traced Newton
needs): instead of slicing J[b][:, b] out, the complement's rows and columns are zeroed and given a
unit diagonal, so the block is solved as [[J_bb, 0], [0, I]] against [-r_b, 0]. The padding is pure
diagonal — no fill-in — so the factorization cost stays the block's. With bounds, the matrix handed to
the factorization is the min-map's semismooth Jacobian (identity rows on the active set), which
jax.linearize derives for free on the matrix-free path but an assembled tangent does not.
3-D Yeoh phase-field march — 576 DOFs, 8 load steps, CPU
Measured on a 3-D Yeoh phase-field march (576 DOFs, 8 load steps, CPU): 42.80 s → 10.78 s, 4.0×, to
the same answer. Two honest caveats: the full tangent is assembled to use one block of it (a
sparsity-caching backend — pardiso/cudss — then pays only the numeric re-factorization per sweep),
and on a well-conditioned problem the matrix-free default is cheaper. This is not a free upgrade and it
is not the default. The load-path march threads the tangent too, which it previously never did: before
this, a direct driver inside a tau= path silently fell back to the matrix-free inner solve.
Line search
line_search= globalizes each sub-solve's Newton steps, and is on by default:
| value | what it does |
|---|---|
"backtrack" (default) |
residual-norm Armijo, halving from damping. What this used to do under line_search=True. |
True |
exact line search — bisect for the root of the directional derivative R(x+λd)·d, i.e. the minimizer of the energy along the Newton direction |
False |
no line search; take damping |
The default moved from False to "backtrack" because no line search is a genuine footgun on
finite-strain forms: measured here, a 3-D Yeoh P2 march produced NaN on load step 1 without one
(the undamped step inverts an element, det F ≤ 0, and J^(-2/3) is NaN) and solved cleanly with one.
True implements Heinzmann, Vicentini, Carrara et al., Iterative convergence in phase-field brittle
fracture computations: exact line search is all you need, Computational Mechanics (2026),
arXiv:2511.23064, §3 Algorithm 2 — the same algorithm they
contributed to PETSc as SNESLineSearchBisection. Their Props. 1–2 and Remark 4 chain into a
convergence guarantee for the whole alternate-minimization scheme, provided each sub-problem is
strictly convex and coercive (jNO's bounds min-map is semismooth, so that proof does not cover it —
the same gap they note for reduced-space active sets).
It is not the default, because we have not measured it beating backtracking. On the problems
tested here — a 2-D SENT plate with and without a volumetric-deviatoric split, at several load levels —
both need the same 21–22 staggered sweeps, and the exact search costs ~15% more wall time from the
extra residual evaluations. Note the paper's own failure cases arise only where the mechanical
sub-problem is non-linear (their §: the residual "reduces to an affine form in the absence of an energy
decomposition"), at critical load steps reached along a path — a regime not reproduced here. Reach for
True when a sub-solve stalls; the theory is on its side even where our measurements are neutral.
Over-relaxation
Alternate minimization is a nonlinear block
Gauss–Seidel iteration, so over-relaxation accelerates it exactly as it does the linear one — go ω
times as far along each sub-step's own update direction, per block:
Algorithm: Farrell & Maurini, Linear and nonlinear solvers for variational phase-field models of
brittle fracture, IJNME 109 (2017) 648–667, Algorithm 2 (ORAM), §2.1. ω = 1 is plain
alternate minimization. Kahan's classical bound gives ω ∈ (0, 2) as necessary for SOR to converge, and
anything outside raises.
Whether it pays is problem-dependent, and there is no way to know in advance. The paper is explicit — they "rely on the naïve strategy of numerical experimentation on coarser problems" and defer automatic selection to future work. Their own results split cleanly: Table I (a propagating crack, where AM converges slowly) gains 58–73% fewer iterations; Table II (where AM already converges fast) gets 0% — over-relaxation hinders it, going 37 → 111 → 185 → 326 → 747 iterations as ω runs 1.0 → 1.8. On a 2-D small-strain phase-field problem that drives damage to 1, ω = 1.4 was 1.95× faster (3096 → 1587 ms, warm median of 3). So it defaults to 1, and a short ω sweep on a coarse version of the problem is the only way to know — the paper's own two tables disagree with each other.
ω cannot diverge. The extrapolation is not taken on trust: the step retreats by bisection on
[1, ω] until the trial point has a finite residual and is feasible, and ω = 1 is the
sub-solve's own converged answer, already evaluated and therefore admissible by construction. So the
worst case is that the sweep degrades to plain alternate minimization. This matters most on
finite-strain forms, where an unguarded step past a converged answer inverts an element (det F ≤ 0,
so J^(-2/3) is NaN) — measured on the 3-D Yeoh SENT march, which NaN'd on load step 1 at every ω
down to 1.1 before the guard and runs to completion with it.
The test is finiteness and feasibility, not descent. Over-relaxation is deliberately not a descent
method, so demanding a residual decrease would reject almost every ω > 1 and silently collapse the
feature back to ω = 1 while appearing to keep it. Feasibility is the paper's own rule (§2.1 backs ω off
by bisection on [1, ω] for the bound constraint); finiteness is the generalization.
Over-relaxation also acts on the free DOFs only. Farrell & Maurini's ũ lives in the constrained
space C_ū, where a prescribed DOF has δ = 0; jNO imposes essential conditions as residual rows, so
without the mask the sub-solve's exact hit on the prescribed value gets extrapolated past — measured on
one row with g = 2, ω = 1.7 gave 3.40 → 1.02 → 2.69, an oscillation decaying only as |1−ω|ᵏ, worst
on a ramped condition where g moves every load step. The mask covers every nonlinear problem — plain
or parametric, 2-D/3-D or 1-D (non-nodal families excepted) — and on a periodic, slip or hanging-node
system the prescribed DOFs are mapped into the reduced space the sweep runs in.
Cost when ω ≠ 1: one extra full residual evaluation per block per sweep.
Under bounds, over-relaxation steps past the sub-solve's answer — which is feasible by construction,
while the extrapolation need not be — so the driver takes the box projector from the bounds wrapper and
clips. That deviates from the paper, which backs the scalar ω off to the largest feasible value;
clipping is componentwise, also feasible, and keeps more of the step.
The trade is the convergence rate, and it is not small. Alternate minimization converges linearly
where Newton is quadratic, so it can need hundreds of sweeps near a propagating crack — hence the
max_sweeps=200 default. It buys robustness, not speed: where Newton converges, Newton is the better
choice (Farrell & Maurini, CMAME 312, 2017, compare the two directly). Sweeping is Gauss-Seidel, so
the order matters, and every field block must be listed — an unlisted field's equations would never
be solved, which is rejected rather than skipped. Each field is solved alone unless it is listed in a
group, which is solved together inside one sweep (see "Groups" above).
Differentiable in the ordinary way: at convergence the full residual is zero, so the sweep is just a way
of finding that root and lax.custom_root supplies the gradient from the full Jacobian — the
alternating structure is absent from the derivative by construction.
Sparse-direct Newton — jno.solve.newton(direct=True)
An iterative linear step — the default's
Jacobi-BiCGStab on the assembled tangent, or BiCGStab on the JVP with direct=False — stalls on an
indefinite / ill-conditioned tangent with no good preconditioner — a Taylor–Hood velocity/pressure
saddle, a stiff Carman–Kozeny phase-change drag in a melt pool. direct=True instead assembles and
factorizes the tangent each step with a sparse LU (the transient stepper factorizes the backward-Euler
step tangent M/dt + ∂R/∂u; the steady path ∂R/∂u).
It composes wherever the assembler provides that tangent — fem.solve(nonlinear=jno.solve.newton(direct=True))
on a native nonlinear problem, steady or the transient march — and stays differentiable: implicit
differentiation uses a direct, transposable tangent solve on the tangent assembled at the root (the adjoint
solves Jᵀ directly too, not with a stalling Krylov). damping / line_search apply unchanged. It needs the
assembled tangent, so it does not apply to the matrix-free-only paths (a coupled-residual wrapper,
complex) — those fail loud.
The tangent it factorizes is always the element-scattered sparse one, in 1-D as in 2-D/3-D. 1-D used
to build its nonlinear tangent with a global jax.jacfwd instead, on the assumption that only the
then-default matrix-free Newton would ever ask for it; direct=True does ask, from inside the Newton
loop, where a dense tangent cannot be sparsified at all (BCOO.fromdense needs a concrete nse). Besides working, the
scattered tangent is O(nnz) rather than O(N²) — which is what 1-D node counts want.
A direct linear= slot selects it. lu, dense and amg all need an assembled matrix, so pairing one
with the matrix-free Newton has nothing to factorize. fem.solve(linear=jno.solve.lu(backend="host")) on a
nonlinear or transient problem therefore routes to the direct Newton, and that slot is the solver that runs on
the assembled tangent (and on Jᵀ in the adjoint); precond= materializes against the same assembled
operator. Which factorization you pick is not cosmetic here: on a 26-step Rayleigh–Bénard march (three fields,
saddle, nonlinear) the matrix-free Newton that was the default when this was measured (direct=False
now) takes 20.1 s, linear=jno.solve.lu() 7.6 s and
linear=jno.solve.lu(backend="host") 3.1 s, all to the same 2.8e-07 per-step Newton residual. An explicit
matrix-free nonlinear= alongside a direct linear= is contradictory and raises rather than picking one
silently.
Limits, since they are not obvious. This routing only fires when the linear slot is direct. A
precond=that needs an assembled matrix (jacobi, an unbuiltamg) gets one from the default Newton; on the matrix-free routes (newton(direct=False),picard(), a problem with no assembled tangent) it raises where it is materialized.picard()has no assembled-tangent form: its linearization is the lagged matrix-free JVP, sononlinear=picard(), linear=lu()raises — moving tonewton(direct=True)there changes the algorithm, not just the solver. And the direct Newton needs the assembler to supply a tangent, so the matrix-free-only routes (a coupled-residual wrapper) fail loud either way.
Reusing the factorization — newton(direct=True, reuse=True). Lagged-Jacobian (chord / Shamanskii)
Newton: keep stepping against the last factorized tangent while each step still halves the residual,
refresh it when the contraction falls below that, and reject a stale step that does not reduce the
residual at all (Kelley, Solving Nonlinear Equations with Newton's Method, SIAM 2003, §1.5, §2.4). It
trades expensive factorizations for cheap extra steps, so it only pays where a factorization costs many
residual evaluations, and only with a backend that keeps its factorization — lu(backend="cudss"),
"pardiso" and "host" cache on the matrix values, so the same tangent again is a solve;
backend="device" refactorizes anyway. fem.stats["nonlinear"]["factorizations"] reports the count on
an eager solve.
Measured, honestly mixed. A 2-D manufactured nonlinear Poisson: 5 → 2 factorizations, same root. A 3-D rigid-plastic rolling model (22k DOFs, cuDSS, RTX 3070): the continuation ladder dropped from 137 to 75 factorizations, but the run got slower (134 s against 120 s) — there a factorization costs ~0.12 s while recompiling for each remeshed geometry costs ~6 s, so the linear solve was never the bottleneck. Measure where your time goes before reaching for it.
Writing your own
User extension is duck-typed — a linear solver is any fn(A, b, *, M=None, x0=None) -> x with A a
jno.solve.LinearOperator (.mv, .T, .diag(), .bcoo, .dense()); a preconditioner is any
ctx -> (v -> M⁻¹v):
def my_precond(ctx): # ctx.A, ctx.diag(), ctx.fem
inv = 1.0 / ctx.diag()
return lambda v: inv * v
u = fem.solve(linear=jno.solve.cg(), precond=my_precond)
Calling a solver directly (outside fem.solve) takes the preconditioner as M=, which is the
application v -> M⁻¹v. A jno.precond.* spec is accepted there too and materialized against A on
the way in — jno.solve.cg()(A, b, M=jno.precond.jacobi()). A bare callable is always the applier,
never a ctx -> applier factory: as a precond= slot it would be the factory, nothing about a callable
tells the two apart, and guessing wrong would apply a preconditioner you did not ask for. Specs that
need eager preparation (form, which assembles an auxiliary operator) require fem.solve(precond=...)
— a direct call has no owning FEM.
solve_fn= is the level below that: instead of a slot, you take the assembled system and return
the answer. jNO does nothing to it — no firewall, no convergence gate — so it is the escape hatch for
a solver that fits none of the shapes above:
u = fem.solve(solve_fn=lambda A, b: jnp.linalg.solve(A.todense(), b)) # dense LAPACK
# max|u - default| = 2.9e-10 on a 142-DOF Poisson operator
A arrives exactly as assembled — a BCOO, not a densified copy. That is deliberate: densifying is
O(n²) on an O(nnz) operator (20.5 GiB against 9.4 MiB at n=51,843), so it would make bringing a
sparse solver pointless at the sizes where you would want one. Call .todense() yourself if that is
what you need, as above.
If your callable is pure JAX it inherits jit/vmap/AD automatically. On the matrix-free nonlinear
path (newton(direct=False), picard(), or no assembled tangent) the precond spec is materialized
per Newton/Picard linearization against the JVP operator — so form, inner(...), chebyshev, a
pre-built amg, and their block_diag/triangular compositions all work; only specs that need the
assembled matrix (jacobi, an unbuilt amg) raise. The default Newton materializes the spec against
the assembled tangent instead.
Diagnostics — what the solver actually did
What a solve prints
Every build and solve logs one line each for what was built, which solver ran, and what came back:
INFO: Preprocessed mesh connectivity: 85 points, 133 triangles · h 0.0927–0.141 · worst aspect 1.38
INFO: FEM(linear, dofs=85, terms=['volume', 'dirichlet@boundary'])
INFO: solve: linear · bicgstab (matrix-free) + jacobi
INFO: solved: linear · 0.115 s · u in [0, 0.06101]
FEM(...)is the mode fixed at build and every term as jNO classified it. A boundary condition that landed nowhere is missing here.solve:is the route actually taken, including defaults you did not pass.solved:is the solution range, withALL ZERO — is the load term present?when the right-hand side was empty. A march adds its steps and the step that came closest to its tolerance:
INFO: solved: nonlinear · 0.454 s · load-path march · 40 steps over τ ∈ [0, 1] · tightest step 32 (τ=0.7949) at 82.9% of its bound · u in [0, 0.8375]
The lines cost the solve nothing it does not already pay. Nothing is added inside a traced
computation: a jitted transient march compiles to the same program with or without them. The
solved: line reduces the solution on the device and moves four numbers to the host (0.3 ms
against a 63 ms solve at 73k dofs on a GPU). It skips itself when the solve is traced
(jit/grad/jno.core). It does not compute ‖Au − b‖: that is a sparse matvec, 10 % of the
same solve, and the solve already checks its own residual and raises (below). Ask for it with
jno.info(sol, context=fem). gmsh's own progress output is suppressed; its warnings
are logged as WARNING: gmsh: ….
fem.stats
After any fem.solve(), fem.stats reports what happened
without changing the solve's return: mode, dofs, wall_s (dispatch time — JAX is async; block on
the result for compute time), the linear/precond slot reprs, nonlinear (driver, final residual
norm against its bound, converged flag, and the step count — every driver reports it), linear_iterations
(below), and amgx_cache occupancy when jaxamg served the solve. Populated on eager paths; a solve
wrapped whole in jit/vmap/grad records the slots but no residuals — reporting needs a concrete
value. The convergence check below is not so limited: it fires under a transform too.
sol = fem.solve(nonlinear=jno.solve.newton(direct=True), linear=jno.solve.lu(backend="host"))
fem.stats
# {'mode': 'nonlinear', 'dofs': 44, 'wall_s': 0.31, 'linear': 'jno.solve.lu-host(...)',
# 'precond': None, 'nonlinear': {'driver': 'newton_direct', 'residual': 1.6e-07,
# 'bound': 1.7e-06, 'steps': 3, 'converged': True}}
solve_index counts solves on the form (the first one includes tracing and compilation), and a solve
that raised still writes fem.stats, with an error entry. It used to leave the previous solve's
stats in place, and those would read as this solve's.
Krylov iterations — fem.stats["linear_iterations"]
How many iterations each linear solve took — the number that says whether a preconditioner works:
fem.solve(nonlinear=jno.solve.newton(direct=False))
fem.stats["linear_iterations"]
# {'solves': 4, 'iterations': [52, 62, 59, 54], 'total': 227, 'max': 62,
# 'by': ['the newton_krylov inner BiCGStab']} # one count per Newton step
iterations lists each forward solve's count in the order they finished, and transpose the adjoint
solves' when a gradient ran any. breakdown: True flags a BiCGStab that stopped on one. The default
solve, cg, bicgstab, minres, fgmres, cocg, chebyshev, the matrix-free Newton's inner
BiCGStab and the assembled-tangent Krylov all report. Upstream restarted gmres exposes no count and is
listed under uncounted; a direct factorisation reports None. Counts are recorded for an eager,
steady, non-adaptive solve. A march records none per step — its compiled step makes no host call, and
fem.stats["march"] is its record — and neither does a deferred node evaluated later under jno.core.
cg and bicgstab are jax.scipy's own loops, keeping the iteration count they discard: the same
arithmetic (x identical on CPU, within 1e-10 on GPU from reduction order) and the same time. The count
reaches the host through one callback per solve, a fixed ~0.08 ms (CPU) / ~0.15 ms (GPU): +7–11 % on a
790-DOF solve, unmeasurable at 46,691 DOFs.
A march records every step — fem.stats["march"]
A march (a tau= load path, an arc-length path, continuation=, a transient) records what each step
did. grid is the coordinate at each step. residual and bound are each step's final residual and
the tolerance it was judged against. step_s is each step's wall time:
| march | per-step residual | per-step time |
|---|---|---|
| load path, arc-length | ✅ | — one compiled lax.scan: only the mean wall_s / steps |
continuation= |
✅ nonlinear | ✅ every rung is materialised, so its time is real compute |
| transient, nonlinear | ✅ once evaluated | — one compiled lax.scan |
| transient, linear | — | — one compiled lax.scan |
A step inside a lax.scan is not a host-visible event, so no per-step time is reported for it rather
than an invented one. A transient solve returns a deferred node: its record says deferred=True with
the step count, dt and window until the node is evaluated eagerly (.fn()), which adds the
evaluation's wall_s and an evaluation count. Under jno.core the march runs inside a compiled
program and nothing is recorded.
A march that fails keeps its record up to and including the failing step, at the same 1-based index
the error names. jno.info(fem) renders it:
march
kind load-path march
steps 12 over τ ∈ [0, 1]
time 542 ms for the solve · mean 45.1 ms/step — includes compilation
one compiled lax.scan: a step has no wall time of its own, only the mean
convergence all 12 steps converged · tightest step 11 (τ=0.9091): residual at 45.6% of its bound
"Tightest" is the step that came closest to its bound, which is the first place to look when a finer
grid or a harder load is about to break the march. jno.info(fem, deep=True) lists every step.
A solve that did not converge raises — including the adjoint
Every linear solve is residual-checked against its own operator before it returns, and so is its
transpose — the plain fem.solve() default included. That matters because a Krylov iteration leaving on its step cap returns the last iterate
with no signal at all, so a broken solve is otherwise indistinguishable from a good one:
jax.grad(loss)(theta)
# RuntimeError: jno.solve.fgmres did not solve the system: relative residual 2.8e-02 against a
# 1e-04 gate. This is the ADJOINT (transpose) solve, not the forward one -- the solution itself is
# fine and only the GRADIENT is affected. A Krylov method can break down on A^T where it converges
# on A, so reach for a transposable-robust inner solver (jno.solve.gmres(), jno.solve.lu()) or a
# preconditioner rather than loosening the tolerance.
Where the refusal surfaces — dispatch is asynchronous
The gate is evaluated inside the traced solve, and reports through a host callback. JAX dispatch is asynchronous, so that callback has typically not run when the solve returns: the refusal appears only once something materialises the result. Measured on the same failing solve, the raise reaches the caller on the CPU backend (callbacks run inline) and is swallowed into a log line on GPU.
So the failure is recorded first and raised at a boundary that is guaranteed to exist:
| you called | when it raises |
|---|---|
fem.solve(...) |
at the call — the result is materialised and the gate drained before returning |
an eager jno.solve.cg()(A, b) |
at the call, same way |
your own jax.jit / jax.grad around a solve |
jNO has no post-execution hook there. The failure is recorded, and the raise from the callback is best-effort — it lands on some backends and not others |
The last row is a real limit, not an oversight: once the solve is inside your transform, the
library gets no callback after it executes. If you need certainty there, materialise inside the
transform-free part and let fem.solve do the checking.
Why the adjoint gets its own check
The adjoint is the half nothing used to check. lax.custom_linear_solve's transpose_solve had
no convergence test, and the eager test on the forward solve is a no-op under tracers — so the
guard was off precisely when gradients were being taken.
Measured on a 2-D strong-form Poisson operator: BiCGStab converged on A (relative residual
2.9e-05) and diverged to 5.5e+26 on Aᵀ for the same problem. The last iterate came back as
a gradient wrong by twenty orders of magnitude, and nothing anywhere raised.
Cost is one extra matvec per solve. The host round-trip that reports the failure sits in the
failing branch, so a converged solve never pays for it: 0.32 ms against 0.24 ms per call on a
small dense GMRES, and no measurable difference on the reverse pass. The fem.solve() default
got faster — 1.90 ms against 2.73 ms at 1441 DOFs — because checking from inside the trace
replaced an eager check that cost its own matvec plus a host sync.
A transient march judges every step, not just the last one
A nonlinear transient step is a Newton solve, and every one of them runs inside the march's
lax.scan — precisely where the driver's own convergence check disables itself, because it needs a
concrete residual. So the step carries its residual norms out of the scan and fem.solve() tests
them where they are concrete, against the driver's own rtol/atol:
fem.solve(nonlinear=jno.solve.newton(direct=True, rtol=1e-6, atol=1e-6))
# RuntimeError: fem.solve: the transient march did not converge at step 36 of 120 (t=0.00036):
# residual norm 3.658e+02 against the tolerance atol + rtol*||r(u_prev)|| = 1.011e-02
# (atol=1e-06, rtol=1e-06). That step is NOT a root, and every later step inherited it as its
# starting state — the whole trajectory past this point is unreliable. Globalize the per-step solve
# (jno.solve.newton(line_search=True), or damping<1), take smaller time steps (a finer
# domain(time=(...)) grid), or raise the driver's max_steps.
This is the same check the load-path march already applied, on the same
numbers; only the label and the advice differ. It covers the default θ march, jno.solve.bdf2() and
jno.solve.sdirk() (judged per stage: every stage is a Newton solve).
Why a silent transient failure is worse than it sounds
Two properties make a capped march genuinely hard to spot, both measured on a coupled melt-pool model (laser + phase change + Marangoni convection):
- It is not reproducible. Two runs of the same command reported peak melt velocities of 2.98 and 33.5 m/s — an unconverged iterate is whatever the step cap happened to leave.
- A diverged march is faster than a healthy one. Once the residual is NaN the loop
condition
||r|| > tolis False, so Newton exits on its first iteration and every remaining step is nearly free. The usual "it got slow, something is wrong" signal is inverted: the run that finished quickly was the broken one, and it returned 2e+200 K with nothing raised.
Cost is two residual evaluations per step, and only on a nonlinear block — a linear step is a linear solve with its own gate and is not judged here.
Under a transform the guard is off
Inside jax.jit/grad/vmap of a whole march the norms are themselves traced, so the test
cannot concretise and no-ops — the same trade every eager check in jNO makes. There, the
driver's max_steps is all there is.
A preconditioner is exempt — it is inexact on purpose
jno.precond.inner(jno.solve.cg(tol=1e-2, maxiter=30)) asks for two digits deliberately; that
is the whole reason the outer solver must be flexible (jno.solve.fgmres()). The gate is a
property of the call, not of the solver spec: the same cg() is checked as an outer solve
and unchecked as a preconditioner application. The convergence contract belongs to the outer
solve, and that one is still gated.
Where the time went — fem.solve(profile=True)
fem.stats says what the solver did; profile=True says how long and where. It runs the solve
inside a JAX Perfetto trace, prints the DOF count and wall time, and writes the trace to
./jno_traces for chrome://tracing or ui.perfetto.dev:
u = fem.solve(profile=True)
# [fem profile · 75 DOFs · linear] wall 82.8 ms · Perfetto trace → ./jno_traces
The same flag exists on jno.core(...).solve(profile=True) (see
Evaluation) and on jno.rcwa(...).solve(profile=True).
Profile a concrete solve
It runs eagerly, at the current parameter values. A parametric solve returns a deferred trace
node with no numeric work to time, so there is nothing to profile — and ./jno_traces is not in
the repo's .gitignore, so the trace directory will show up as untracked.
Transient problems
What a march keeps — only the frames you ask for
Evaluated eagerly (fem.solve(...).fn(), fem.solve(k=2.0)), a march keeps no past on the device.
Each step writes its state into a save slot when a frame at save_ts will read it (a save time on the
grid, or the two grid points either side of one off it) and into a scratch row otherwise, and the frames
come back as a host NumPy array. Saving every step of a march whose frames would outgrow 1/16 of the
device runs it in chunks: each chunk's frames are copied out while the next chunk is already queued, with
nothing inside the compiled step (no callback, no sync). The answer is bit-identical to the one-scan march
on CPU, under every scheme below; on a GPU it moves only by the run-to-run noise the old march had
(measured 4–7e-11, scatter-add order).
Measured (RTX 3070, 2-D linear heat, 22,801 DOFs, 5 frames saved): peak device memory 186 → 59 MiB at 500 steps and 708 → 59 MiB at 2,000 steps; wall time unchanged.
Pass save_ts= for a long march. The default saves every grid point, which keeps the whole trajectory
— on the host now, not the device. Under jit/grad (jno.core, an inverse problem) the march stays
one lax.scan holding every step: the adjoint needs the states.
What a march's Newton keeps — its tangent
A nonlinear march's default per-step Newton is jno.solve.newton(reuse=True): it keeps the assembled step
tangent while that still contracts the residual (‖r_new‖ < ‖r_old‖/2), refreshes it the moment it does
not, and rejects a step that fails to reduce the residual -- so a stale tangent costs at most one wasted
solve, never a divergence. Evaluated eagerly, the march also carries the tangent from step to step
(on the step-merge plan's fixed sparsity pattern), because a march's tangent changes little between steps.
The step's convergence record reuses the norms Newton already computed instead of evaluating the residual
twice more. And on a kept tangent the inner Krylov solve is inexact: a step contracts the residual by
the tangent's own contraction, not by the accuracy of the linear solve, so each correction is solved only to
a forcing tolerance -- half the outer target over the current residual, floored at 1e-3, capped at 0.1
(Dembo, Eisenstat & Steihaug 1982) -- instead of to 1e-10.
Measured (RTX 3070, the Taylor–Green LES of the turbulence tutorial: residual-based VMS + Vreman, P1/P1,
BDF2): at 55,296 DOFs one tangent for an 8-step march instead of two per step, 0.80 → 0.30 s per step;
at 442,368 DOFs, where the Krylov solves dominate once the tangent is kept, the inexact solves take
1,200 → 160 matvecs per step, 2.14 → 1.50 s per step -- same trajectory (energy identical to 6 digits). The answer moves only within the Newton tolerance: a reused
tangent converges linearly, so the final residual lands under the test rather than far below it. Pass
nonlinear=jno.solve.newton() for a fresh tangent at every iteration. Under jit/grad the march keeps
the tangent within a step but not across steps (the reverse pass would store one tangent per step).
Time schemes — fem.solve(time=…)
| Scheme | Order | Stability | Use |
|---|---|---|---|
jno.solve.theta(1.0) |
1 | L-stable | the default — robust, damps everything |
jno.solve.theta(0.5) |
2 | A-stable only | smooth problems; rings on stiff modes |
jno.solve.bdf2() |
2 | L-stable | stiff / saddle-point systems — flow's workhorse |
jno.solve.sdirk(order=2\|3) |
2 or 3 | L-stable, stiffly accurate | higher order where BDF2 is not enough; state-dependent mass allowed |
jno.solve.rosenbrock("ros34pw2"\|"ros2") |
3 or 2 | L-stable | nonlinear blocks without Newton: one linear solve per stage |
jno.solve.exponential() |
exact in time | unconditional | linear, autonomous blocks |
….adaptive(rtol=…) |
wraps a one-step scheme | — | multi-rate transients |
bdf2 exists because theta cannot be second order and L-stable at once. Crank–Nicolson's
amplification factor tends to -1 for a stiff mode, so the mode does not decay — it alternates in
sign. Measured on a heat problem whose initial condition is incompatible with its boundary (8 steps,
T = 0.5, so the exact field is long dead): Crank–Nicolson reaches min u = -0.70 and still carries
0.33 at the end; BDF2 reaches -0.023.
Rows with no time derivative (Dirichlet rows, a pressure, an FDM flux row) are constraints, and every θ-scheme imposes them at the new time, as backward Euler does. The θ-average would make Crank–Nicolson satisfy them only on average over the step (a boundary started off its value would flip sign every step and never settle), and would leave forward Euler with a singular step. On a Navier–Stokes saddle system, where the pressure has no time derivative at all, that ringing is exactly what you do not want.
The first BDF2 step is plain backward Euler — a multistep method has no second level to start from.
A state-dependent mass c(u)·u_t is marched in BDF2's non-conservative form
c(uⁿ⁺¹)(3uⁿ⁺¹ − 4uⁿ + uⁿ⁻¹)/(2Δt), second order in time (measured on a manufactured c(u) = 1 + u²);
it is not the conservative form an enthalpy mass H(u)_t would want. It composes with periodic ties
(the march carries the reduced state; the mass action is reduced like the residual). Refused loudly
rather than mis-integrated: a second-order-in-time (u_tt) block (assembled at θ=½ so that it is not
damped), and .adaptive() (step doubling sizes a one-step method).
Every scheme needs each transient term linear in u_t — c(u)·u_t, or u_t inside a contraction such
as τ (u·∇v)·u_t (the time derivative inside a residual-based stabilisation). A term quadratic in u_t, or
u_t inside a nonlinear function, is refused: it used to be marched with (u − u_prev)²/Δt where /Δt²
belonged. The u_t⊗u_t piece of a residual-based VMS Reynolds stress is such a term — drop that piece.
Higher order — sdirk and rosenbrock
jno.solve.sdirk(order=3) is Alexander's 3-stage, order-3 SDIRK method (order 2: 2 stages; SIAM J.
Numer. Anal. 14(6), 1977). Each stage is one backward-Euler step of size γ·dt from a shifted start
state, so everything the default step does carries over: Dirichlet and zero-mass rows, solver slots,
Newton on a nonlinear block, the complex path. All stages share one step operator, so a constant one is
built and preconditioned once. A state-dependent mass c(u)·u_t is integrated at full order in its
non-conservative form.
jno.solve.rosenbrock() is linearly implicit: every stage is one linear solve with the same matrix
W = M + γh·J, J the tangent at the step's start — no Newton loop on a nonlinear block. The default
"ros34pw2" (Rang & Angermann, BIT 45, 2005) is order 3, stiffly accurate and consistent for index-1
DAEs; "ros2" (Verwer et al., SIAM J. Sci. Comput. 20(4), 1999) is order 2. A time-varying Dirichlet
row lands exactly on g(tₙ₊₁) under the default, which is stiffly accurate; "ros2" is not, and holds it
only to its order (g = e^{-2t}: 5.3e-4 off at T = 0.5 after 16 steps, 1.4e-3 at the first step — see
the measured orders). Re-imposing g after each step would make the wall
exact, but was measured to leave an O(dt) error in the interior, so it is not done. Rosenbrock refuses a
time-, parameter- or state-dependent mass (use sdirk or bdf2).
sol = fem.solve(time=jno.solve.sdirk(order=3))
sol = fem.solve(time=jno.solve.rosenbrock()) # ros34pw2
sol = fem.solve(time=jno.solve.rosenbrock().adaptive(rtol=1e-4))
Both expose a stepper, so .adaptive() works, and both refuse second-order-in-time (u_tt) blocks.
On a nonlinear heat problem, ROS34PW2 reached a given accuracy in the least wall time of the schemes
compared (θ, BDF2, SDIRK, ROS2). Time-to-accuracy depends on the problem, so compare on yours.
The slots configure the per-step solves of the default theta-method integrator:
linear/precond see the step operator M + θ·dt·A — when it is time-independent the step matrix is
formed once and the preconditioner materialized once before the time loop — and nonlinear drives
each implicit step of a nonlinear block. Second-order-in-time (u_tt) flows through the same augmented
block. Each step warm-starts from the previous state (so x0= is rejected).
A constant step operator factorizes once, not once per step
The step matrix is formed once (above), and the host factorization is cached on the operator's content, so a march that solves against the same matrix every step pays one factorization for the whole trajectory — and the transpose solve reuses it, so the adjoint pass adds none. On a 51-step heat march that is worth 1.55× of whole-solve wall clock at 8,355 DOFs and 2.9× at 23,934. The gain grows with mesh size, because factorization cost grows faster than the per-step solve; at 513 DOFs it is 1.05×, where the factorization is sub-millisecond.
What it does not help: a nonlinear march, or any Newton loop. There the tangent's values
change every iteration, so every call legitimately misses and pays a content hash (~1–2% of a
factorization) for nothing. Reusing only the symbolic analysis — which genuinely is constant when
the sparsity pattern is fixed — is not something SuperLU exposes through scipy. It is also
host-path-only: lu() on GPU goes through cuSolver's spsolve, a single fused call with no
factorization object to keep.
Adjoint memory — the march is gradient-checkpointed
Reverse-mode through the default integrator rematerializes each step in the backward pass instead of storing every step's solver internals for the whole trajectory. Measured at 8,355 DOFs × 399 steps: peak memory 968 → 112 MB (8.6×) for a gradient cost of +60% (2.98 → 4.76 s), gradient identical to 10 digits. The trade is deliberate: a differentiable march OOMs long before it is time-walled on consumer cards. A pure forward solve is unaffected — checkpointing is the identity outside differentiation.
Complex problems
Complex problems are assembled as one real 2n system over the stacked [Re; Im] state — the
real-equivalent block [[A_r, -A_i], [A_i, A_r]] — at assembly rather than at solve time. A complex
transient is therefore an ordinary transient block (the slots configure its per-step solve as above, and
theta / adaptive / exponential all apply), and a complex steady problem is an ordinary linear
system: the linear / precond slots and x0= work on it, with a complex x0 mapped into the
block layout for you. Its default solver stays sparse-direct, not the matrix-free BiCGStab real
elliptic systems get — the real-equivalent block is indefinite for Helmholtz/PML, where Jacobi-BiCGStab
does not converge.
One exception keeps a dedicated path: a complex-native preconditioner (ams) solves A_r + i·A_i
directly rather than the block, so the Re/Im legs are retained for it.
Bloch (quasi-periodic) ties
A Bloch (quasi-periodic) tie fuses like everything else. Its complex prolongation P cannot
reduce the Re/Im legs independently, but on the fused [Re; Im] state the same tie is the real
prolongation B(P) = [[P_r, -P_i], [P_i, P_r]], and the ordinary real congruence B(P)ᵀ A B(P)
equals the Hermitian reduction P^H A_c P the Bloch space requires. Consequences: solve_fn=, the
linear/precond slots and x0= all apply to a Bloch problem (each used to be silently discarded by
a dedicated block routine); a Bloch tie composes with a complex transient (the quasi-periodic
plane-wave march, previously a dtype crash); and a real weak form with a Bloch tie is promoted to
the complex path automatically — the phase makes the field complex anyway, and the real path's
bilinear Pᵀ A P is not a Galerkin projection for a complex P (measured 8.1 rel-L2 off the
Hermitian answer on a manufactured mode, with the tie itself satisfied exactly).
A coupled (multi-field) complex steady system — coupled Helmholtz-type equations — takes the same
Re/Im split through the coupled assembler: one fused real 2n block over [Re_all; Im_all], with
fem.offsets still listing the per-field blocks of the recombined complex solution. Scope: steady and
linear (a complex nonlinear coupled form and a complex coupled transient refuse, as everywhere).
Essential values on a complex form must be real. The two legs share one Dirichlet row set, which
imposes Re u = g with Im u = 0 — right for a real g, and the usual case (the complexity lives in
the operator and the source). A complex g is not expressible there: pinning Im u = g_i would need
the imaginary leg's rows zeroed rather than set to identity, and the symmetric elimination's
known-column lift is cross-leg (the real equation needs A_r[:,j] g_r - A_i[:,j] g_i, which no per-leg
elimination produces). It raises a clear error. Carry the complex part in the operator or the source.
adapt= composes with a complex transient too: the stacked [Re; Im] halves transfer across
each remesh as a doubled field layout, the modulus |u| drives the remesh metric (refining on
Re alone would miss a rotating phase), and the saved frames come back complex. A criterion= is
refused there by name: it would be assembled against the stacked real state, whose halves it cannot
tell apart. Drop it to remesh on the modulus.
Not yet supported (clear errors): a Bloch tie on a real transient march (the phase forces a complex field — make the problem complex, or use a plain tie) or on a nonlinear form (complex nonlinear forms are not wired).