Solvers
Time integration
Linear methods run inside a JAX scan:
|
update |
|---|---|
|
explicit |
|
first-order diagonal IMEX: collision and hyper-diffusion damping implicit, drift and streaming explicit |
|
the same diagonal split as ARS(2,2,2), second order |
|
backward Euler solved with SOLVAX GMRES. Preconditioners: diagonal damping, or an FFT/Hermite-line inverse of the additive diagonal-plus-streaming symbol. Linked chains keep the twist-and-shift layout. |
integrate_linear defaults to rk4. A runtime deck that sets dt gets
rk2 at that fixed step; a deck without dt gets rk4 with the CFL
controller at cfl = 0.9 (gkx.config.TimeConfig).
Nonlinear runs add rk3, sspx3 and the opt-in imex-ars3
(parallel streaming and the field response implicit per twist-shift chain,
fixed dt only). Measured choice of default, one RTX A4000, steady
seconds per unit of simulated time, Cyclone kinetic electrons at
(Nl, Nm) = (4, 8):
grid |
|
|
|
|
|---|---|---|---|---|
32x32x16 |
3.6 |
4.9 |
2.4 |
0.43 (dt 0.05) |
64x64x24 |
62 |
40 |
43 |
2.8 (dt 0.035) |
With adiabatic electrons at 64x64x24 (4, 8), rk4 + CFL takes 0.35 s per
unit time against 0.77 for rk3 + CFL; on the 16x16 tutorial grids every
explicit choice is launch-bound and within noise. So the default stays
rk4 + CFL. imex-ars3 is 6-14 times faster per unit time at 32x32
and above, but is not selected automatically: its fixed step has no safe a
priori value (0.05 is stable at 32x32 and non-finite at t = 15 at 64x64;
0.1 fails at 32x32), its factor build doubles the cold start, and on the
tutorial grids it does not pay. Choose it, with dt from the kinetic
electron deck notes, for long kinetic-electron runs at 32x32 and above.
rk4-pi is RK4 with an error-controlled step (opt-in, fixed_dt = false).
The embedded solution is the explicit midpoint rule built from the RK4 stages,
so the error estimate costs no extra right-hand side. The step follows an I
controller on \(\|e\|/(10^{-2}\|G\|)\) (safety 0.9, growth <= 1.3), is
capped at twice the CFL step and at dt_max, and a step with an estimate
above three tolerances is redone once at the controller’s smaller step.
Adiabatic-electron Cyclone at 64x64x24 (4, 8), one RTX A4000, saturated window
of t = 500: 0.34 s per unit time against 0.61 for rk4 + CFL (1.8x), mean
step 1.97x larger, \(Q_i\) = 11.90 +- 0.25 against 11.79 +- 0.25. The
QHS stellarator (48x48) and the KBM deck take 1.96x and 1.77x larger steps
with the same flux. With kinetic electrons the CFL step is already the
stability limit (1.03x): use imex-ars3 there.
On CPU, XLA spreads FFTs and contractions over the visible cores (2.2x on 16
cores at 32x32x16). With 2 or 3 schedulable CPUs, import gkx sets
--xla_cpu_multi_thread_eigen=false unless XLA_FLAGS already names
that flag: on 2 and 3 CPUs XLA:CPU deadlocked on nonlinear runs (jax 0.11.2).
Shift-invert preconditioners
Shift-invert Arnoldi solves each shifted system with complex FGMRES (unitary
Givens rotations), starting from zero. It reports the physical residual
||b - A x||; tolerance, restart length, iteration limit and preconditioner
are user settings, and a failed residual gate is never turned into a finite
success value.
shift_preconditioner selects the right preconditioner:
"auto"(default) is"pr3-cm", with the inner cycle raised to at least restart 150 and maxiter 600. See “Eigensolver policy” in Methods and Decisions for why;"hermite-line"and"field-corrected"can be named directly;"pr3-cm"is the structured Peaceman–Rachford splitting described in Numerics. It is the only choice with host-side setup and structural preconditions.
pr3-cm
pr3-cm splits the shifted operator into a streaming half, inverted by the
Hermite line solve, and a z-local half, a batch of (l, m) blocks, and runs
three double sweeps between them. Setup costs Nl * Nm probes of the
z-local operator and one host factorization per shift; the factors enter the
jitted solve as an operand.
shift_precond_block_solve chooses how the z-local block is inverted:
"auto"(default)exact block-Thomas elimination in the Laguerre index plus one Sherman–Morrison field correction when the block is Laguerre-tridiagonal plus rank one; otherwise the dense batched inverse, with the reason recorded in
EigenSolveStatus.inner["preconditioner_setup"]."block-thomas"the exact solve, or an error when the structure does not hold.
"dense"the dense batched inverse.
The Schur elimination is SOLVAX block_thomas_factor_ops (hence
solvax>=0.29.0). GKX keeps the Hermite-banded coupling action, an
unrolled forward substitution pinned bitwise to SOLVAX
block_thomas_solve_ops on the same factors, and the Sherman–Morrison
correction. Against the previous in-tree factors, full applies agree to
7.1e-16–8.9e-16 and factor storage is 1.43x–2.61x smaller over
Nl*Nm = 36..768 (plan/log.md, 2026-09-20). The record supports no
speed claim.
The structural checks (z-locality, rank one, off-tridiagonal and Hermite-band
width) are floored at 64 * eps of the probe precision: inert in float64,
7.6e-6 in float32. A z-coupled block is refused, not sent to the dense
fallback, because it is not the operator the splitting describes.
Measured on the shipped Cyclone deck (examples/01_linear_tokamak/case_full.toml)
at Nz=96:
pr3-cmreaches the1e-4inner tolerance in 252 unrestarted iterations;hermite-lineleaves 0.549 after 600;with
pr3-cmthe shift-invert pair certifies (residual3.25e-07atNl=4, Nm=8,1.67e-07atNl=8, Nm=24) and agrees withadaptiveto eight significant figures in the growth rate;at the default
shift_restart=20evenpr3-cmstalls (GMRES(20) x 15 leaves 0.177). Raiseshift_restartandshift_maxitertogether with it.
Linked boundaries
On a linked boundary the eigen routes solve on the modes the linked chains
couple. Only the linked parallel derivative, its kz hypercollisions and the
chain end damping couple (ky, kx) modes, so a kx row outside the dealiased
Nakx set has no coupling to the chains and contributes only undamped drift
eigenvalues next to a physical shift. Seeds and every Krylov, power-iteration
and inner GMRES vector are projected onto the chain modes, and
sparse_shift_invert assembles only their columns (2560 of 4096 unknowns on
the Nx=8 linked Cyclone pilot). Returned eigenvectors keep the full state
shape with exact zeros on the other rows, and certification applies the
unprojected operator, so unexpected coupling fails the residual gate. Periodic
boundaries and full-cover linked grids (for example Nx=1 at one ky) are
unchanged; a seed with no chain component raises ValueError.
Sparse shift-invert
method="sparse_shift_invert" assembles the sparse operator 64 columns at a
time and factors the complete shifted operator through the optional SciPy
backend. It needs shift from a coarse solve or continuation point and
certifies every candidate with the original JAX operator. The SOLVAX bridge
reuses the same factors for the adjoint inverse in implicit eigenpair AD. It is
eager and CPU-factorized.
State intake on linked boundaries
On a linked boundary the runtime zeroes the rows the chains never reach in
every state it did not build itself: a user initial_state passed to
run_runtime_linear, and a restart or init file named by init.init_file.
This matches GX, which masks after restart_read. The runtime’s own initial
conditions seed only the dealiased 1 + 2 * ((Nx - 1) // 3) kx rows, which
the chains cover, so nothing GKX builds is changed; periodic decks and
full-cover linked grids have no mask.
The mask is applied at intake only. The chains are closed under the whole right-hand side: a state that is zero outside them has a right-hand side that is exactly zero there, in the linear operator and in the nonlinear bracket, whose output the two-thirds mask restricts to the covered rows on a full grid. No per-step operation is added.
Why the mask is needed: off-chain content is decoupled and undamped, so no
growth-rate fit or certified eigenpair reads it, but
distribution_free_energy() and the resolved ky/kx
spectra sum over every row, and a linear ky-selected grid carries an all-true
dealias mask, so the sums would count it forever. On a nonlinear grid the ExB
bracket takes the unmasked state into real space, so off-chain content also
aliases back onto the chain rows and changes the physics.
linked_chain_cover_mask() returns the
covered (ky, kx) modes for a deck, and
mask_off_chain_rows() applies them to
a state; both cost O(Nz + Nky * Nkx) and return None and the state
unchanged when every row is reachable. A caller holding a built cache (every
nonlinear entry point does) reads the same cover with
linked_cover_mask_from_cache() and applies it
with mask_supplied_state().
Supplied states below the runtime
The same rule holds at the library entry points that accept a caller-built
state: PreparedSimulation.solve(initial_state=...) on the object
gkx.prepare returns,
integrate_nonlinear_explicit_diagnostics_state, the differentiable
objective nonlinear_heat_flux_window(),
and the raw drivers
integrate_nonlinear() and
integrate_nonlinear_cached().
Each projects the state onto the linked chain cover; none rejects it. At
the raw drivers the projection runs once, in integrate_nonlinear_cached
(integrate_nonlinear builds a cache and delegates), before the scan is
built: one where whose instruction count does not grow with steps.
Projection, not rejection: these entry points are traced, so an error condition would have to read values a tracer does not have, and on a concrete array it would force a host synchronization on every call. The runtime above them already projects, so rejecting here would make two doors into the same solver disagree.
What the projection does and does not change:
it is a
whereagainst a mask fixed by the deck’s topology, never a branch on the state’s values, so it holds underjitand reverse-mode AD;the cotangent of a supplied state is exactly zero on the off-chain rows and bitwise unchanged on the chain rows;
nonlinear_heat_flux_windowkeeps its gradient contract (the saturated state is detached; onlygeomandparamscarry derivatives); the projection changes the trajectory the window starts on, not what is differentiated;periodic decks and full-cover linked grids get
Noneand the same state object back.
On a full nonlinear grid the chain cover equals the two-thirds dealias
mask, so free energy and spectra already exclude off-chain rows; what the
projection protects is the bracket. On a linked Cyclone grid at Nx8/Ny8/Nz16
with a saturated-amplitude broadband state, one right-hand side’s chain rows
move 2.37e-02 relative when off-chain content is present. The effect is
quadratic in amplitude (2.37e-04 at max|G| = 1e-2), so a linear-seed
state shows almost nothing and a saturated restart shows all of it.
Eigenpair certification
KrylovConfig() and dominant_eigenpair default to method="auto":
the Hermite-banded direct shift-invert (banded) when its block arrays fit in
memory, the residual-certified adaptive eigensolve when they do not or when
the banded pair is rejected. power_iters defaults to 40 in both. No method
returns a pair above its original-operator residual gate
\(\lVert Av-\lambda v\rVert/\max(\lVert Av\rVert,|\lambda|\lVert v\rVert)\):
adaptive,banded,shift_invertandsparse_shift_invertraise when their gates reject the pair;power,propagatorandarnoldireturn a Rayleigh or Ritz pair without a convergence test of their own. The pair is checked againstcertifiable_residual_tolerance(shift_outer_residual_tol, dtype), the same gate as shift-invert, and a failing pair raisesRuntimeErrorwith the residual and tolerance;certify=False(onKrylovConfigordominant_eigenpair) is the explicit opt-out for these three raw routes only. The pair is returned and the status callback reports it as uncertified, with its residual. It does not relax the other gates;a zero or non-finite eigenvector has infinite residual, so a breakdown that returns
(0, 0)cannot pass any gate.
KrylovConfig.method previously defaulted to "propagator" and
dominant_eigenpair(method=...) to "power"; both returned unchecked
pairs, and on the linked Cyclone deck a bare KrylovConfig() returned the
wrong branch with relative residual 0.98–1.00. Code that relies on the raw
routes must now name the method, and pass certify=False if it knowingly
accepts an unconverged pair.
Why the default is not pr3-cm shift-invert
Matrix-free shift-invert with pr3-cm certifies on the shipped Cyclone deck but is not
cheaper. Against a same-session adaptive control, every arm certified
against the original operator, in matvec-equivalents to a certified pair:
|
|
best certified |
ratio |
|---|---|---|---|
|
33,916 |
about 57,000 (dense block solve) |
1.7x more |
|
96,700 |
about 220,000 (block-Thomas) |
2.3x more |
Shift-invert costs more at every preconditioner apply cost observed over eighteen interleaved timing rounds. The gap is the preconditioner apply: the inner-iteration counts alone, 13,996 and 24,188, are below the control’s operator applications, so the route is apply-bound, not iteration-bound. An inexact-Krylov tolerance schedule is not implemented; it needs a per-Arnoldi-step original-operator residual, which the restarted loop does not compute.
Solver status on results
Runtime results carry the status of the solve that produced them:
RuntimeLinearResult.eigen_status(EigenSolveStatus) is set on Krylov runs.residualis the original-operator relative residual of the returned pair,tolerancethe gate its route applied,certifiedwhether it passed androutethe method that produced the pair; a shift-invert fallback names its fallback method.innersummarizes the inner FGMRES solves of the shift-invert build that produced the pair (converged, unconverged and total solves, maximum relative residual, iterations, tolerance and preconditioner) and isNonefor other routes.dominant_eigenpair(..., return_status=True)returns it as a third element. Every gate raises, socertifiedisFalseonly for a raw route called withcertify=False.implicit_solve(ImplicitSolveSummary) onRuntimeLinearResultformethod="implicit"and onRuntimeNonlinearResultfor IMEX runs summarizes every GMRES solve of the run: the largest SOLVAX true relative residual||b - A x|| / ||b||, the most iterations one solve used, the number of solves and the number that did not converge.summary()flattens both into scalareigen_*andimplicit_*keys, which areNonewhere no such solve ran.*.summary.json(written by a prefix run, by the--outJSON form and as the sidecar of a NetCDF bundle) records the same keys. Both readimplicit_solve_payload()andgkx.solvers_linear_krylov.eigen_status_payload, so they cannot drift apart.
The time scans carry ImplicitSolveStats, four scalars, through
lax.scan and fori_loop; no host callback runs inside a traced scan.
The IMEX diagnostics and sheared IMEX scans fold one ImplicitSolveStats
leaf per implicit solve (three per step under sspx3); the explicit sheared
methods keep their carry unchanged.
Non-convergence fails closed at the host boundary. A traced step cannot
raise, so run_runtime_linear and run_runtime_nonlinear check the
carried stats after the scan returns and raise RuntimeError with the
unconverged count, the maximum relative residual and the maximum iterations.
Neither returns a trajectory with an unconverged implicit step. The library
integrators stay traceable and do not raise: integrate_linear,
integrate_linear_diagnostics, integrate_linear_from_config,
integrate_nonlinear, integrate_nonlinear_from_config,
integrate_nonlinear_imex_cached, integrate_nonlinear_imex_diagnostics,
integrate_nonlinear_explicit_diagnostics, integrate_nonlinear_sheared
and integrate_nonlinear_sheared_transport accept
return_solve_stats=True and append the stats (None for methods without
an implicit solve). integrate_nonlinear_sheared_transport returns them as
the trace’s solve_stats field. A caller that wants the runtime’s refusal
applies require_converged_implicit_solves()
to the stats.
Cost. Callers that do not request the stats compile the same graph, because XLA removes the unused carry. Requesting them reads SOLVAX’s recomputed true residual, one operator application per implicit solve, plus about twenty instructions of scalar folds. On a 4x4x8, Nl=2, Nm=4 implicit linear scan the optimized HLO grows from 3834 to 4443 instructions (FFT 16 to 20). No jit static argument or compile key is added.
One nonlinear diagnostics graph
gkx.prepare keeps a compiled scan and runs it through simulation.solve();
integrate_nonlinear_explicit_diagnostics_state is the function entry point,
and the one run_runtime_nonlinear reaches on its fixed-window, chunked and
sharded routes. Both compile the same jitted graph and return bitwise
identical arrays for the same inputs, in float32 and under
JAX_ENABLE_X64. Compare them with exact equality;
tests/unit/nonlinear/test_nonlinear.py does, over every output array, for
RK3 and RK4 and for fixed and adaptive steps. Value and reverse-mode gradient
match bit for bit.
The one remaining difference is the state dtype. A prepared object freezes the
dtype of the G0 it was built with and casts every later state to it; the
function entry point takes the dtype it is handed. Scaling G0 by a
float64 scalar under JAX_ENABLE_X64 and feeding it to only one of them
runs the two at different precisions.
Cost. Nothing to opt into, and no step costs more. On the shipped Cyclone
deck at 32x32x24, Nl=2, Nm=4, against a scan compiled outside jit: RK3
copy ops fall from 226 to 85 and the bytes written by copies and
concatenates rise 0.47%; the graph carries its cache, parameter and policy
arrays as module literals (6,553,260 bytes). One jit replaces about sixty
small modules, so eight successive chunked calls at 16x16x24 peak at 826 MB
resident instead of 1,200 MB. profile_runtime_kernels.py
nonlinear-step-hlo reports --route diagnostics and --route runtime as
that one graph and keeps the out-of-jit placement under --route eager-scan.
One adjoint window graph
nonlinear_heat_flux_window(), the
route a transport optimization takes, runs its differentiated scan as one
jax.jit graph that is compiled once and reused for matching shapes, dtypes,
topology and static options (GKX 2.3.0; earlier releases recompiled on every
call). Host-side work stays outside the graph: the linked cache build (its
jtwist is an integer read off the shear), the quadrature weights, the
chain-cover projection of the supplied state, and the Hermitian projector’s
ky layout, which crosses the boundary as a hashable signature. Every array
is an argument, not a captured constant, so the same executable serves the
next geometry an optimizer proposes.
Shipped Cyclone deck at 16x16x12, Nl=2, Nm=4, six-step RK3 window
differentiated with jax.value_and_grad, XLA:CPU, jax 0.10.2, two runs of
three timed calls:
|
compilations per call |
wall per call |
|---|---|---|
two-sided |
13 -> 0 |
4.92-5.36 s -> 0.139-0.141 s (37x) |
half-spectrum |
13 -> 0 |
7.04-7.36 s -> 0.112-0.116 s (63x) |
The first call also falls, 11.0 s to 7.3 s and 12.4 s to 6.8 s.
Identity. Value and both adjoint components are bitwise unchanged against
the per-call route in 20 of 26 gate configurations (every one using the default
checkpoint=True except RK2 under x64), across RK2/RK3/RK4, two-sided and
half ky, a window tail, the uncompressed FFT path, exact Laguerre
quadrature and an electromagnetic KBM deck, in float32 and x64. The other
six move by 1 to 11 float32 ulps or 1 to 3 double ulps, inside the window’s
measured float32-vs-float64 roundoff of 48 ulps. They follow from
taking the grid and quadrature weights as operands. The window mean is taken
outside the graph: inside it XLA fuses the divide into the scan’s
accumulation, and four configurations that are bitwise with the divide outside
move by one ulp.
The discrete adjoint, checkpoint schedule, gradient contract and the warning at the divergence knee are described in Nonlinear automatic differentiation.
Differentiable eigenmodes
adaptive_propagator_eigenpair is the opt-in matrix-free path for linear and
quasilinear objectives. It:
estimates a stable RK4 step from broadband spectral probes;
extracts several leading-growth candidates from a long-horizon propagator;
certifies every pair against the original continuous operator;
tracks a requested branch with right or biorthogonal overlap; and
uses an implicit reverse rule for eigenvalue and eigenvector observables.
It avoids the O(n^2) dense matrix, but the explicit filter is slow at high
Hermite resolution. Details are in Differentiable matrix-free eigenmodes.
Optional damping
The linear operator supports:
Lenard–Bernstein diagonal damping with rate
nu;velocity-space hyper-collisions controlled by
nu_hyper_l,nu_hyper_m,nu_hyper_m_const,nu_hyper_lmand their exponents. The Laguerre channels reach the distribution only through the constant-coefficient branch, so they needhypercollisions_const; see Declared velocity-space regularization; andsmooth field-aligned end damping at the rate
[time] damp_ends_rateoverdamp_ends_widthfracof the chain (Parallel boundary and end damping).
Each can be disabled or refined independently in resolution studies.
Performance caching
LinearCache stores geometry-dependent gyroaverages, drift coefficients,
linked-chain maps, and masks outside the time loop. build_linear_cache
constructs it; the runtime integration entry points build and reuse it
automatically.
Growth-rate extraction
For
GKX fits \(\log|\phi|\) and unwrapped phase against time.
fit_growth_rate_auto scans for the most exponential window and is used by
the Cyclone harness when auto_window=True.