gradsolve: how the pieces fit together¶
gradsolve solves ensembles of differential equations in JAX and can return a reverse-mode
gradient through the solve. This page explains the mechanics behind its two entry points: the
duck-typed Problem you hand it, the difference between solve() and grad_closure(), how
routing picks an engine, what stiffness changes, when the record-and-replay adjoint is (and
isn’t) the thing running under the hood, and the remat memory/speed knob.
For the full API surface see docs/api.md; for a one-minute start see
docs/quickstart.md; for the project overview see the
README. Nothing on this page needs a GPU to execute.
1. The Problem protocol¶
gradsolve doesn’t ask you to subclass anything. gradsolve/base.py defines a structural
(Protocol) contract, and the library reads exactly six members off whatever object you pass:
class Problem(Protocol):
name: str
dim: int
t0: float
t1: float
@property
def is_stiff(self) -> bool: ...
def f_jax(self, t: float, y: Any, params: Any) -> Any: ...
dim— the state dimension (this is what the register-limit routing decision below keys on).t0,t1— the integration horizon.is_stiff— a bool that steers explicit vs. implicit engines (Section 4).f_jax(t, y, params)— the JAX-traceable right-hand side for one trajectory: a stateyof shape(dim,)and its parametersparamsof shape(P,)in,dy/dtout; gradsolve vmaps it over the ensemble.
A richer problem object may carry more members; gradsolve ignores them. A plain class with
those six members is enough — docs/quickstart.md has the canonical Lorenz
example (name = "user_lorenz", dim = 3, nonstiff, sixteen trajectories with one parameter
each) and is the problem, y0, params that the snippets below reuse.
"user_lorenz" is deliberately not one of gradsolve’s registered field names (nonstiff:
lorenz, vdp, lorenz96_<d>, linear_ladder_<D>; stiff: robertson, hires; plus any
field you register with register_jax_field or register_linstiff). A user’s own right-hand
side therefore gets no fused kernel, only the general-purpose engines that differentiate
through f_jax directly (Section 5).
2. solve() vs. grad_closure()¶
Both live in gradsolve/api.py and share the same keyword arguments (engine, fused,
rtol, atol, device, batch_n, accuracy_target, saveat, saveat_dense; see
docs/api.md):
|
|
|
|---|---|---|
Returns |
a |
a closure, by default |
Routes via |
|
|
Extra kwargs |
— |
|
Unsupported engine |
falls back (diffrax if installed, else |
falls back to the same replay lanes (Section 5) |
Unknown |
raises |
same, plus accepts the non-registry name |
Engine has no reverse path |
n/a |
raises |
The asymmetry that matters in practice: for every record-and-replay engine, grad_closure
records the accepted step sequence eagerly at call time, at the concrete params you pass in
(a Warp kernel launch for a registered field, a JAX while_loop or host loop otherwise),
before handing you back a pure-JAX closure. solve() never does this — it just runs the
chosen engine forward.
That’s why grad_closure takes longer to construct than solve takes to run, and why the
closure it returns is only valid near the params you built it with (Section 5).
Two input-handling facts worth knowing up front:
solve()promotes float32 inputs;grad_closure()does not cast.solve()on any general engine (diffrax,tsit5_replay,fixed_step_tsit5, …) accepts float32y0/paramsand promotes them to float64 without raising or warning.grad_closure()on the general path does not cast its arguments: withoutsaveata float32paramsgives a float32 gradient, and withsaveatset float32y0/paramsraise alax.scancarry-typeTypeError, so pass float64. The only genuine float32 path is the registered-field replay (precision="float32"onwarp_replay/warp_rosenbrock).saveat(dense output) exists on the JAX scan/replay lanes only. The fused Warp kernels,cuda_tsit5,cuda_rosenbrock23, anddiffraxreturn a final state only and raiseValueErrorif you name them explicitly withsaveatset insolve(); ingrad_closure()the nameswarp_ode/warp_rosenbrockrefer to the replay lanes and acceptsaveat.engine="auto"routes to a dense-capable lane in both.
You can always sidestep routing with an explicit engine=. engine="diffrax" is the
universal fallback (jax.vmap(diffrax.diffeqsolve(...)), Kvaerno5 if stiff else Tsit5,
RecursiveCheckpointAdjoint for the reverse pass):
result = gradsolve.solve(problem, y0, params, engine="diffrax", device="cpu")
print(result.solver, result.y_final.shape) # -> diffrax (16, 3) (problem/y0/params as in the quickstart)
final_states = gradsolve.grad_closure(problem, y0, params, engine="diffrax", device="cpu")
loss = lambda p: jnp.sum(final_states(p) ** 2)
gradient = jax.grad(loss)(jnp.asarray(params))
print(bool(jnp.all(jnp.isfinite(gradient)))) # -> True
3. Engine routing: choose_engine / DECISION_MAP¶
gradsolve.dispatch.choose_engine(dim, stiff, need_grad, ...) is a pure function — no side
effects, never raises — mapping the workload’s three salient axes to the engine that is faster
for that corner:
from gradsolve.dispatch import choose_engine
for dim, stiff, grad in [(3, False, False), (3, False, True),
(3, True, False), (3, True, True),
(1000, False, False), (1000, True, True)]:
print(dim, stiff, grad, "->", choose_engine(dim=dim, stiff=stiff, need_grad=grad))
3 False False -> cuda_tsit5
3 False True -> warp_replay
3 True False -> warp_rosenbrock
3 True True -> warp_rosenbrock
1000 False False -> diffrax
1000 True True -> rodas5p_replay
The auditable version of this table is gradsolve.dispatch.DECISION_MAP — a list of dict rows,
one per (dim-side, stiff, need_grad) case, each carrying an evidence string giving the
rationale for that row’s routing. A test in the suite asserts choose_engine agrees with
DECISION_MAP on every row, so the table can’t drift from the code. Three module-level
constants gate whether a fused engine is actually reachable, each overridable per-call
(stiff_fused_enabled=, cuda_tsit5_enabled=, cuda_rosenbrock23_enabled=) for testing
without mutating module state:
STIFF_FUSED_ENABLED— gateswarp_rosenbrock(Truebecause the fused engine is both faster and more accurate than the fixed-step IMEX scan engine (fixed_step_imex) on Robertson).CUDA_TSIT5_ENABLED— gatescuda_tsit5(Truebecause the CUDA kernel stays register-resident up todim=64with no spill).CUDA_ROSENBROCK23_ENABLED— gatescuda_rosenbrock23, a hand-written CUDA forward-only stiff Rosenbrock23 kernel fordim ≤ CUDA_ROSEN_NVAR_CEILING(16). CurrentlyFalse, so it is reachable only by explicitengine="cuda_rosenbrock23".
Two constants set the dimension crossovers. NVAR_CEILING = 64 is the register limit: above
it the fused kernels’ per-thread register vector exceeds the GPU register budget, so
choose_engine routes to diffrax (forward) / tsit5_replay or rodas5p_replay (gradient)
instead; the fixed-step lax.scan engines (fixed_step_tsit5, fixed_step_imex) still exist
but accept and ignore rtol/atol, and are reachable only by explicit engine=.
CUDA_NVAR_CEILING = 16 bounds the sub-band of the
low-dimension forward case where the hand-written CUDA cuda_tsit5 kernel is the faster
choice; 16 < dim ≤ 64 stays on warp_ode.
If the chosen engine’s supports(problem) is False — no nvcc/Warp available, or the
problem has no registered field — both solve and grad_closure fall back rather than raise:
gradients (and saveat) go to the record-and-replay engine of the problem’s class
(rodas5p_replay if stiff, tsit5_replay otherwise); a forward-only solve goes to diffrax
when the extra is installed, else that same replay engine (api._fallback). That’s why the
quickstart’s Lorenz example (an unregistered field, run on a machine without CUDA nvcc)
lands on diffrax rather than cuda_tsit5, even though choose_engine(3, False, False) says
cuda_tsit5.
4. Stiff vs. nonstiff¶
is_stiff is the second routing axis, and it changes the family of engine, not just which
one: explicit Runge-Kutta (Tsit5) for nonstiff, implicit/linearly-implicit for stiff.
Regime |
Forward engines |
Why |
|---|---|---|
nonstiff |
|
explicit 7-stage Tsit5 (Tsitouras 2011); |
stiff |
|
per-step linear solve against the Jacobian — L-stable, survives problems explicit steppers can’t |
either |
|
picks |
fixed_step_imex.supports() actually returns True unconditionally (its linearly-implicit
Euler-like step, (I - h J) dy = h f(t, y), survives both regimes) — it’s choose_engine’s
decision to prefer other engines everywhere that keeps it off the auto-routed path entirely:
cheaper explicit engines on nonstiff problems, and — at high dimension + stiff, where the
fused kernel is out of register budget — diffrax (forward) / rodas5p_replay (gradient)
instead. fixed_step_imex stays reachable by explicit engine= on any stiff problem at any
dimension.
Robertson (dim=3, stiff) as an inline duck-typed problem. Because this is an unregistered
field on a machine with no Warp/nvcc, choose_engine still names the fused warp_rosenbrock
it would route to on a GPU, but solve falls back to diffrax (or rodas5p_replay, the
general stiff record-and-replay engine, if the diffrax extra isn’t installed):
import gradsolve, jax.numpy as jnp, numpy as np
from gradsolve.dispatch import choose_engine, choose_remat
class Robertson: # classic 3-species stiff kinetics
name = "user_robertson"; dim = 3; t0 = 0.0; t1 = 1.0; is_stiff = True
def f_jax(self, t, y, p):
k1, k2, k3 = p[0], p[1], p[2]
return jnp.stack([-k1 * y[0] + k2 * y[1] * y[2],
k1 * y[0] - k2 * y[1] * y[2] - k3 * y[1] ** 2,
k3 * y[1] ** 2])
problem = Robertson()
y0 = np.tile([1.0, 0.0, 0.0], (4, 1))
params = np.tile([0.04, 1e4, 3e7], (4, 1))
print(choose_engine(dim=problem.dim, stiff=problem.is_stiff, need_grad=False)) # -> warp_rosenbrock
print(choose_engine(dim=problem.dim, stiff=problem.is_stiff, need_grad=True)) # -> warp_rosenbrock
print(choose_remat(dim=problem.dim, stiff=problem.is_stiff)) # -> False (dim=3 < 16)
result = gradsolve.solve(problem, y0, params, device="cpu")
print(result.solver, result.y_final.shape) # -> diffrax (4, 3) (unregistered field -> fallback; rodas5p_replay without diffrax)
Why stiff routing distinguishes between engines rather than always using fixed_step_imex:
the fixed_step_imex scan (a fixed 10,000 first-order steps, explicit engine= only) accepts
rtol/atol but ignores them, so it over-resolves a problem like Robertson. The fused
warp_rosenbrock record-and-replay reverse (Section 5) is an adaptive second-order replay that
honours the tolerances, which is why choose_engine prefers it at low-dimension stiff instead
of leaving fixed_step_imex to serve both forward and grad there. For tight tolerances,
request the fifth-order rodas5p_replay (Steinebach 2023) explicitly with
engine="rodas5p_replay"; choose_engine does not branch on accuracy and selects
rodas5p_replay automatically only for stiff gradients above dim=64, while api._fallback
selects it for gradients when the problem has no registered stiff field, and for forward
solves only when diffrax is not installed.
5. When the record-and-replay adjoint applies¶
This is gradsolve’s core mechanism (gradsolve/warp/warp_replay.py), and it’s what
warp_replay and warp_rosenbrock’s reverse path both are: the fused Warp kernel runs the
forward integration once, at concrete params, and records the sequence of accepted step
sizes (dt) it took. The gradient is then computed by re-running that exact same per-step
arithmetic (tsit5_step / rosenbrock23_step) as a fixed-length jax.lax.scan over the
recorded dts — a scan is natively reverse-mode differentiable, so no custom adjoint or
kernel backward pass is needed. Trajectories that finish early are zero-padded; a dt=0 step
is exactly an identity map (y_next = y, dy_next/dy = I, dy_next/dp = 0), so both values
and gradients flow correctly through the padded tail.
The frozen-controller caveat, stated in the module docstring and worth repeating: this
differentiates the realized trajectory with the step-size controller frozen — dt is
data, not a differentiable function of the parameters. The gradient is the exact discrete
adjoint of the replayed, fixed-step-sequence integration, not of the adaptive solver as a
parameter-dependent algorithm. grad_closure records once at the params you call it with;
the closure it returns is valid in a neighborhood of that point. If you move far enough that
the controller would have taken a different step sequence, call grad_closure again to
re-record.
Where it applies — three variants, all in gradsolve/warp/warp_replay.py unless noted:
warp_replay(nonstiff) — needs NVIDIA Warp and a registered nonstiff field:lorenz,vdp,lorenz96_<d>,linear_ladder_<D>, or one added withregister_jax_field(warp_ode._field_for). This is whatchoose_enginereturns for low-dimension + nonstiff +need_grad=True.warp_rosenbrock’s reverse (stiff) — same mechanism,rosenbrock23_stepin place oftsit5_step; needs a registered stiff field:robertson,hires, or one added withregister_linstifforregister_jax_field(..., stiff=True)(warp_rosenbrock._field_for).The general-RHS pure-JAX fallback (
gradsolve/solvers/tsit5_replay.py,make_tsit5_frozen_mesh_closure) — applies to any nonstifff_jax, registered field or not; instead of a Warp kernel it records the mesh with a plain NumPy host loop for small CPU ensembles (fewer than 32 trajectories) and otherwise with a batchedjax.lax.while_loopon the device (gradsolve/solvers/record_jax.py);recorder="host"or"jax"forces either. This is what actually runs under the quickstart’s Lorenz example (n=16 on a CPU, so the host loop): that problem carries no registered Warp field, sowarp_ode.supports()returnsFalse, the router never enters the Warp branch, and the returned route readsactual="tsit5_replay",reason="no-registered-field"— which is why that closure works on a machine with no Warp/CUDA at all. (A field that matches by name but has a different param layout is a separate path: there the Warp build is attempted, itsIndexError/KeyError/ValueError/TypeErroris caught, and the reason readswarp-build-failed:<ExceptionName>.)
One more named engine exists outside this mechanism, reachable only by an explicit
engine="fused_rosenbrock_backward" override (never an "auto"/choose_engine target): a
genuine single-kernel fused Warp backward (gradsolve/warp/fused_backward.py, via
jax.custom_vjp), rather than record-and-replay. It is opt-in because it is sensitive to
conditioning and no faster than the record-and-replay path.
What doesn’t get record-and-replay at all: cuda_tsit5 is forward-only by construction —
it has no _api_reverse, so grad_closure(engine="cuda_tsit5") raises ValueError once
supports(problem) is True; on a problem it doesn’t
support it reroutes instead, same as any other engine (Section 2). And at high dimension
(dim > 64) there is no fused kernel to record from in the first place — the auto-routed
default there is tsit5_replay / rodas5p_replay, which do record and replay; an explicit
engine="fixed_step_tsit5" / "fixed_step_imex" request instead gets their own native
lax.scan reverse-diff — no recording step, no frozen-controller caveat, because there the
entire fixed-step schedule is already parameter-independent by construction.
The saving comes from differentiating a fixed-length replay of recorded steps rather than an
adaptive loop. When the right-hand side is nearly free to evaluate (for example
linear_ladder), fixed per-gradient costs dominate and the advantage over diffrax disappears.
6. The remat policy¶
grad_closure(..., remat=None) (the default) calls gradsolve.dispatch.choose_remat(dim, stiff, batch_n=None) -> bool to decide whether the record-and-replay scan wraps each step in
jax.checkpoint — recompute the forward step during the backward sweep instead of storing it.
This is grad-transparent (same value, same gradient) and trades compute for memory. The
warp record-and-replay paths (warp_replay, warp_rosenbrock’s replay) and rodas5p_replay
consume it; the plain scan engines (fixed_step_tsit5, fixed_step_imex) and tsit5_replay
accept and ignore the kwarg; remat is not implemented for those engines.
from gradsolve.dispatch import choose_remat
print(choose_remat(dim=3, stiff=True)) # -> False
print(choose_remat(dim=32, stiff=True)) # -> True
The policy (per gradsolve/dispatch.py):
Nonstiff (Tsit5 replay): always remat. At low dimension it is an outright speed advantage as well as a large memory saving. At mid/high dimension the speed is unchanged but memory still drops sharply — so it’s never a net loss, and it raises the batch size you can reach before running out of memory.
Stiff (Rosenbrock23 replay): remat only at
dim ≥ STIFF_REMAT_DIM(16). Below that threshold the per-step tape (jacfwdover aD×DJacobian plus aD³linear-solve residual) fits in memory anyway, so remat there is a pure speed cost with no benefit — off. At/above 16 the tape grows too large without it (atdim=64the replay runs out of memory without remat), so the modest speed cost is what makes those problems fit at all.
choose_remat also accepts batch_n (a larger batch makes remat more valuable), but the
policy currently branches on dim and stiff only; batch_n does not yet change the
decision.
7. Fused kernels from your own JAX RHS¶
The fused engines (warp_ode, warp_replay, warp_rosenbrock) run only on a registered
analytic field; the library provides Lorenz, Van der Pol, Lorenz-96, the linear ladder,
Robertson and HIRES as built-in fields. Any other
f_jax takes the general record-and-replay path (Section 5), which is correct but not the fused
kernel. gradsolve.register_jax_field lets you get the fused kernel for your own RHS: it walks
jax.make_jaxpr(f_jax) (and jax.jacfwd(f_jax) for a stiff Jacobian) and emits the same
@wp.func field the built-ins use, so a Problem whose name matches then routes to the
fused engines exactly as a built-in does.
import gradsolve, jax.numpy as jnp, numpy as np
class DiffusiveRing: # a fresh nonstiff system, params (a, b)
name = "user_diffusive_ring"; dim = 20; t0 = 0.0; t1 = 1.0; is_stiff = False
def f_jax(self, t, y, p):
a, b = p[..., 0], p[..., 1]
lap = jnp.roll(y, -1, axis=-1) - 2.0 * y + jnp.roll(y, 1, axis=-1)
return a[..., None] * lap - y * y * y + b[..., None]
problem = DiffusiveRing()
gradsolve.register_jax_field(problem.name, problem.f_jax, problem.dim, n_params=2, stiff=False)
y0 = np.random.default_rng(0).uniform(-1, 1, size=(8, 20))
params = np.column_stack([np.linspace(0.5, 1.5, 8), np.linspace(-0.5, 0.5, 8)])
result = gradsolve.solve(problem, y0, params) # -> route.actual == "warp_ode"
final_states = gradsolve.grad_closure(problem, y0, params) # -> route.actual == "warp_replay"
This example needs pip install "gradsolve[warp]"; it runs on Warp’s CPU device, so no GPU is
required.
solve/grad_closure take fused="auto" | True | False (default "auto"). On "auto", a
registered field routes to the fused engine and an unregistered problem takes the general
path unchanged; the RHS is translated only to check whether it could be fused (adoption
stays opt-in via register_jax_field/fused=True). fused=True demands the fused kernel
and re-raises if the RHS is untranslatable; fused=False skips translation entirely. The
translator covers a fixed primitive subset (arithmetic, comparisons, the elementwise
transcendentals, and static-index reshapes, slices and concatenations); anything outside it —
jax.lax.cond, say — raises UnsupportedRHS, and fused="auto" then records the reason on
SolveResult.route.reason and falls back to the general path. See
docs/api.md for the full primitive list and
examples/08_fused_kernel_from_jax.py for the runnable version.
Where to look next¶
docs/api.md— the full public surface: everySolveResultfield, everyENGINE_REGISTRYentry.docs/quickstart.md— the shortest path from zero to a forward solve and a gradient.README.md— the project overview.gradsolve/base.py,gradsolve/api.py,gradsolve/dispatch.py,gradsolve/warp/warp_replay.py,gradsolve/solvers/tsit5_replay.py— the source this guide describes.