gradsolve API reference¶
gradsolve is a library of differentiable ensemble solvers for differential equations on
GPUs, written in JAX. This page documents its public interface: the two entry points
(solve, grad_closure), the Problem protocol your model must satisfy, the SolveResult
it returns, and the engine registry the routing layer dispatches to. For the project
overview and installation instructions see the README and the
quickstart.
import gradsolve
Importing gradsolve enables jax_enable_x64 (accuracy work assumes float64) unless
GRADSOLVE_X64=0 is set in the environment before import.
With
GRADSOLVE_X64=0,tsit5_replay,vern7_replay,rodas5p_replayand the fixed-step scans run in float32 without raising, even thoughprecision="float64"is the only value they accept and the result is still labelled float64. Only the Warp engines raise in this case. Keep x64 on (the default) for accuracy work.
gradsolve.solve¶
def solve(
problem,
y0,
params,
*,
saveat=None, # dense/time-grid output; see the saveat section below
saveat_dense: bool = False,
engine: str = "auto",
fused: str | bool = "auto",
rtol: float = 1e-6,
atol: float = 1e-9,
device: str = "cpu",
batch_n: int | None = None,
accuracy_target: float | None = None,
) -> SolveResult
Runs one batched, forward ensemble ODE solve and returns a SolveResult. problem is any
object satisfying the Problem protocol below; y0 is the initial state, shape (n, dim);
params is the per-trajectory parameter array, shape (n, P).
With engine="auto" (the default), solve resolves an engine name via
gradsolve.dispatch.choose_engine(dim=problem.dim, stiff=problem.is_stiff, need_grad=False, ...)
and dispatches to it. Pass a canonical registry name (e.g. engine="fixed_step_tsit5",
"warp_ode", "diffrax") to force a specific engine; an unrecognized name raises
ValueError listing the known keys. If the resolved engine’s supports(problem) is
False — e.g. warp_ode/cuda_tsit5 require NVIDIA Warp/nvcc, absent on a machine
without an NVIDIA GPU — solve falls back to diffrax (when the diffrax extra is installed) for a
forward-only solve, and otherwise to the general record-and-replay engine of the problem’s
class (rodas5p_replay if stiff, tsit5_replay otherwise) rather than raising; result.route
records the reroute and its reason. rtol/atol are honoured by every auto-routed engine;
the fixed-step scans (explicit engine= only) accept them nominally. batch_n and
accuracy_target are accepted routing hints not yet used by
the routing policy. The returned SolveResult.solver is stamped with the routing
name that ran (a registry key, or "warp_replay" on the saveat path), not the engine’s
internal descriptive string.
With problem, y0 and params defined as in the quickstart (the
user_lorenz model, 16 trajectories):
import gradsolve # problem, y0, params defined as in the quickstart
result = gradsolve.solve(problem, y0, params, device="cpu")
print(result.solver, result.y_final.shape) # diffrax (16, 3): user_lorenz has no registered fused field, so the general engine runs (tsit5_replay without the diffrax extra)
gradsolve.grad_closure¶
def grad_closure(
problem,
y0,
params,
*,
wrt="params", # 'params' | 'y0' | ('y0', 'params')
saveat=None, # dense/time-grid output; see the saveat section below
saveat_dense: bool = False,
precision: str = "float64", # see the precision section below
engine: str = "auto",
fused: str | bool = "auto",
rtol: float = 1e-6,
atol: float = 1e-9,
device: str = "cpu",
batch_n: int | None = None,
accuracy_target: float | None = None,
remat: bool | None = None,
)
Returns a jax.grad-able closure over the initial state, the parameters, or both. By
default (wrt="params") that closure is params -> y_final[n, dim]. Calling jax.grad on a
scalar function of the closure’s output gives exact reverse-mode gradients with respect to
params. With engine="auto", routing runs the same choose_engine as solve but with
need_grad=True, which can additionally resolve to "warp_replay" (the record-and-replay
adjoint: a fused forward records the accepted step mesh once, and the gradient is a
fixed-length lax.scan replay over it — reverse-mode-differentiable by construction, no
custom kernel backward pass). For a right-hand side with no registered Warp field,
grad_closure falls back to the general record-and-replay engine for the problem’s stiffness
class (gradsolve.solvers.tsit5_replay for non-stiff, gradsolve.solvers.rodas5p_replay for
stiff), so it works on any f_jax, not only the library’s own registered problems (Lorenz,
Van der Pol, the linear ladder). The mesh for that general engine is recorded by a batched
jax.lax.while_loop on the device (gradsolve.solvers.record_jax) on a GPU or for ensembles
of 32 or more trajectories, and by a host loop otherwise; the choice is automatic, and setting
GRADSOLVE_RECORDER=host or GRADSOLVE_RECORDER=jax in the environment forces the host or
device recorder. Both recorders produce the same accepted step mesh up to floating-point
rounding.
Every record-and-replay engine (warp_replay, warp_rosenbrock, and the general-RHS
fallback) shares one property, stated in each module’s docstring: the step-size
controller is frozen at the recording call. The closure differentiates the realized
trajectory — dt is data, not a differentiable function of the parameters — so the gradient
is the exact discrete adjoint of that replayed, fixed-step integration, valid in a
neighborhood of the params passed to grad_closure. Re-record (call grad_closure
again) if you move far from that point.
import gradsolve, jax, jax.numpy as jnp
final_states = gradsolve.grad_closure(problem, y0, params, device="cpu")
loss = lambda p: jnp.sum(final_states(p) ** 2)
gradient = jax.grad(loss)(jnp.asarray(params)) # gradient w.r.t. params, per trajectory
wrt: gradients w.r.t. the initial state¶
wrt selects which inputs the returned closure takes and is differentiable in. Return
shapes mirror the corresponding input shapes.
|
closure signature |
closed over |
|---|---|---|
|
|
|
|
|
|
|
|
— |
The mesh is recorded once, at the (y0, params) passed to grad_closure, and frozen for
the closure’s lifetime. A y0 gradient therefore carries exactly the same frozen-controller
caveat as a parameter gradient — perturbing y0 replays the recorded mesh rather than
re-running the step-size controller — and is valid in a neighborhood of the recording
point.
# Fit an initial condition instead of a rate constant.
final_states = gradsolve.grad_closure(problem, y0, params, wrt="y0")
loss = lambda z: jnp.sum((final_states(z) - y_target) ** 2)
gradient_y0 = jax.grad(loss)(jnp.asarray(y0)) # shape == y0.shape
One exclusion: fused_rosenbrock_backward’s custom_vjp differentiates parameters by
construction, so it is params-only. Requesting a y0 gradient from it does not fail and does
not silently ignore the request — it reroutes to the stiff replay engine and records why on
the closure’s .route (below).
saveat: dense output (states at requested times)¶
saveat=<sorted times within [t0, t1]> makes the closure return the states at those times,
(n, k, dim), instead of the final state — so a time-series loss differentiates through the
solve. solve(..., saveat=ts) correspondingly fills SolveResult.y_saved (n, k, dim) and
SolveResult.ts_saved (k,) (host NumPy). saveat=None (default) returns the final state
only.
ts = np.linspace(0.1, 1.0, 12)
final_states = gradsolve.grad_closure(problem, y0, params, saveat=ts)
loss = lambda p: jnp.mean((final_states(p) - observations) ** 2)
gradient = jax.grad(loss)(jnp.asarray(params))
Saved states are integrated, not interpolated, by default. The engine records which step brackets each requested time, then takes one genuine step of its own method to exactly that time. So a saved state has the same accuracy as the final state; no interpolant is involved, which matters because an adaptive controller takes wide steps where the problem is easy.
saveat_dense=True (requires saveat) evaluates the method’s own continuous extension at
each save time instead of taking an extra step to it, which saves one solver step per
requested time at some cost in accuracy; it is implemented on rodas5p_replay only and
raises ValueError on any other engine.
Dense output adds memory independent of the step count: beyond the mesh and tape, the scan
carries only the active bracket and the k outputs, about k*n*dim*8 bytes. The full
(S, dim) state history is never stored.
saveat composes with wrt — a time-series loss differentiated w.r.t. y0 is
grad_closure(..., wrt="y0", saveat=ts). The mesh is still recorded once, at the passed
(y0, params); a fit that travels far from that point should re-record (see
examples/07_saveat_timeseries_fit.py, which does exactly that).
Scope — dense output exists on the JAX scan and replay engines only:
engine |
|
|---|---|
|
yes |
|
yes |
|
yes |
|
yes |
|
yes |
|
yes |
fused Warp kernels, |
no — |
fixed_step_imex is first order, so saved states carry its lower accuracy. The fused kernels
and the cuda_tsit5 engine hold the integration in registers and emit a final state; diffrax
has its own SaveAt which this API deliberately does not wire. Naming one of them explicitly
with saveat set raises ValueError rather than silently ignoring the request.
engine="auto" routes to a dense-capable engine. Unsorted or out-of-domain times raise
ValueError; y_saved[:, -1] == y_final exactly when t1 is in saveat.
precision: float32 record and replay¶
precision="float32" records the step mesh with the f32 Warp kernel and replays in f32.
The closure casts its inputs to the run precision, so the arithmetic is the one you asked
for rather than whatever your dtypes happened to promote to.
With a registered field and Warp installed — here the built-in Lorenz field, reached by giving
the quickstart’s one-parameter Lorenz class the name "diffeqgpu_lorenz" ("lorenz" is the
same field with a three-column [sigma, beta, rho] parameter layout; user fields register via
register_jax_field):
class Lorenz: # as in the quickstart, but named for the registered field
name = "diffeqgpu_lorenz"; dim = 3; t0 = 0.0; t1 = 1.0; is_stiff = False
def f_jax(self, t, y, p):
rho = p[0]
return jnp.stack([10.0 * (y[1] - y[0]), rho * y[0] - y[1] - y[0] * y[2], y[0] * y[1] - (8.0 / 3.0) * y[2]])
final_states = gradsolve.grad_closure(Lorenz(), y0, params, engine="warp_replay", precision="float32")
loss = lambda p: jnp.sum(final_states(p) ** 2)
gradient = jax.grad(loss)(jnp.asarray(params, jnp.float32)) # float32
Scope — the registered Warp field route only (warp_replay nonstiff, warp_rosenbrock
stiff). The general-RHS recorders (tsit5_replay, vern7_replay, rodas5p_replay) are
float64 throughout, as are the fixed scans; none of them can honour an f32 request, so they
raise ValueError rather than return a float64 result under an f32 label.
On the general-RHS and fixed-step engines pass float64 y0 and params; float32 inputs are
used as given and are not cast (the run-precision cast belongs to the Warp float32 route). In
an x64 process a plain solve promotes them through its float64 arithmetic, a closure returns
a gradient at the dtype given, and saveat on a record-and-replay engine raises a lax.scan
carry-type TypeError.
In float32 the gradients agree with a float64 replay of the same f32-recorded mesh to a relative L2 error of at most 1e-3 with direction cosine at least 1 - 1e-6 (the bound the test suite enforces); replaying the same mesh isolates arithmetic precision from the choice of mesh.
The record buffer halves per step (4 bytes vs 8). The total is about half rather than exactly half, because f32 is a different controller, not just different arithmetic: its roundoff changes which steps the error estimate accepts, so it records a slightly different mesh. That is also why the comparison above replays the f32 mesh rather than comparing against an f64-recorded run.
f64 needs an x64 process. On the Warp route, precision="float64" raises in a non-x64
process rather than replaying in float32 under a float64 label; the general-RHS recorders and
fixed scans do not check (see the note at the top of this page). Ask for f32 explicitly and it
works in any process.
.route: which engine actually ran¶
Every closure grad_closure returns carries a .route with three fields — requested
(the engine= you passed), actual (the engine that built the closure), and reason
(None when the request was honoured; otherwise why it was not). Read it instead of
assuming: an engine is rerouted when it does not supports() the problem, when the problem
matches no registered Warp field, or when the request exceeds what the engine can do.
final_states = gradsolve.grad_closure(problem, y0, params, wrt="y0", engine="fused_rosenbrock_backward")
final_states.route.requested # 'fused_rosenbrock_backward'
final_states.route.actual # 'tsit5_replay' — the general-RHS replay engine
final_states.route.reason # 'y0-unsupported; engine-does-not-support-problem'
# (problem here is the nonstiff, unregistered user_lorenz from the quickstart, so the params-only
# fused backward first reroutes off the y0 request and then off the nonstiff problem. On a
# registered stiff field with Warp available, actual reads 'warp_rosenbrock' and reason
# reads 'y0-unsupported'.)
actual is a registry key except for "warp_replay", the Warp record-and-replay path, which
has no registry entry. A nonstiff problem with no registered field falls back to the pure-JAX
recorder and reports actual="tsit5_replay" with reason="no-registered-field", which is how
you tell it apart from a genuine Warp record-and-replay.
Passing an unregistered engine= name (not a key in the registry and not "warp_replay")
raises ValueError; an engine with no reverse path implemented (e.g. engine="cuda_tsit5",
which is forward-only by design) raises ValueError("engine ... has no reverse closure implemented") when that engine supports the problem (otherwise the request is rerouted and
.route records why).
remat: checkpointing the replay scan¶
remat (default None → auto via gradsolve.dispatch.choose_remat(dim, stiff)) controls
whether the replay scan is wrapped in jax.checkpoint. It affects the warp
record-and-replay paths and rodas5p_replay (the plain scan engines and tsit5_replay
accept and ignore it); the default policy
is: always remat for the non-stiff Tsit5 replay (faster at low dimension, always less
memory), remat only at or above dispatch.STIFF_REMAT_DIM
(16) for the stiff Rosenbrock replay (a modest speed cost that buys the memory headroom
needed to avoid OOM at higher dimension).
gradsolve.register_jax_field¶
def register_jax_field(name, f_jax, dim, n_params, *, stiff=False) -> None
Translates a user right-hand side f_jax(t, y, p) into the same fused Warp field the
built-in problems (Lorenz, Van der Pol; Robertson, HIRES) use, and registers it under
name. A Problem whose name matches then routes to the fused engines (warp_ode /
warp_replay nonstiff, warp_rosenbrock stiff) exactly as a built-in does — no
hand-written kernel. register_jax_field walks jax.make_jaxpr(f_jax) (and, for
stiff=True, jax.jacfwd(f_jax) for the analytic Jacobian) and emits a @wp.func; the
codegen is lazy (it runs when the fused kernel is first built for a given precision) and
idempotent (a name already registered is left untouched). import gradsolve stays warp-less —
the Warp import happens only when you call this.
The translator covers a fixed subset of JAX primitives: arithmetic (add/sub/mul/div/neg/ pow/integer_pow), comparisons (lt/le/gt/ge/eq/ne), abs/sign/max/min, the elementwise
transcendentals (exp/log/sin/cos/tan/tanh/sqrt), convert_element_type, and static-shape
ops (reshape/squeeze/expand_dims/broadcast_in_dim/transpose/slice/split/concatenate/stack/ iota), with jit-wrapped sub-functions inlined. An RHS using anything else (e.g.
jax.lax.cond) raises
gradsolve.warp.jax_field.UnsupportedRHS, naming the offending primitive. For a stiff field
n_params must be <= dim (the stiff kernel hands a length-dim param vector).
fused: the codegen switch on solve / grad_closure¶
solve and grad_closure take fused="auto" | True | False (default "auto"):
"auto"— a registered generated field (or a built-in) routes to the fused engine as usual; an unregistered problem takes the general path, identical tofused=False."auto"checks fused-eligibility (it translates the RHS to see whether it could be fused, priming the translator cache) but does not auto-adopt the fused engine — adoption is opt-in, viaregister_jax_fieldorfused=True. AnUnsupportedRHSduring that check is swallowed and the reason is appended toSolveResult.route.reason("fused-unsupported:<primitive>; fell back to the general path").True— demand the fused kernel: translate and register the RHS on this call so the problem routes to the fused engine, and letUnsupportedRHSpropagate (no silent fallback).False— never call the translator; the general path runs even for a translatable RHS.
n_params for this translation is read from the batched params the caller already
passes (params is (n, P)), not from the Problem — the library Problem protocol has
no n_params.
The Problem protocol¶
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: ...
Structural (duck-typed), defined in gradsolve/base.py. gradsolve reads only these six
members off a problem object — dim is the state dimension (drives the register-limit
routing decision), t0/t1 the integration horizon, is_stiff selects explicit vs.
implicit engines, and f_jax(t, y, params) is the JAX-traceable right-hand side mapping one
trajectory’s state y of shape (dim,) and its parameters params of shape (P,) to dy/dt
(gradsolve vmaps it over the ensemble). Any object exposing these — a plain class, as in the
quickstart, or a richer dataclass that additionally carries reference solutions, batch
constructors or a right-hand side for another framework — satisfies the protocol with no
inheritance required. Everything beyond the six members is the caller’s concern, not the
library’s.
SolveResult¶
@dataclass
class SolveResult:
y_final: np.ndarray # (n, dim)
accepted_steps: np.ndarray = ... # (n,), int64
rejected_steps: np.ndarray = ... # (n,), int64
solver: str = ""
y_saved: np.ndarray | None = None # (n, k, dim); None unless saveat was passed
ts_saved: np.ndarray | None = None # (k,), the echoed output times
route: Route | None = None # the Route record, set by gradsolve.solve
Defined in gradsolve/base.py. y_final is the final state per trajectory. accepted_steps
/ rejected_steps carry the per-trajectory step counts needed for warp-divergence metrics
(the step-count dispersion across a batch): adaptive engines (diffrax, the Warp kernels)
report the true measured counts; fixed-step scan engines (fixed_step_tsit5,
fixed_step_imex) report the constant configured step count repeated n times for
accepted_steps and zeros for rejected_steps — by contract, never left empty. The
length-0 default (np.empty(0)) is reserved for a backend whose API genuinely exposes no
step counts. solver is the canonical engine key that solve resolved to (see above).
y_saved holds the states at the requested times and ts_saved echoes those times; both are
None unless saveat was passed (see the saveat section). route is the Route record
(requested, actual, reason) described under .route above; gradsolve.solve sets it,
and it is None on a result built by an engine’s own solve.
The engine registry¶
gradsolve.api.ENGINE_REGISTRY maps each canonical engine name to an EngineSpec(name, supports, solve, reverse). supports(problem) -> bool and solve(problem, y0, params, *, rtol, atol, device) -> SolveResult are required; reverse is the optional closure factory
grad_closure calls (None means the engine is forward-only). The registered keys:
Engine |
Module |
Stiff? |
Reverse? |
What it is |
|---|---|---|---|---|
|
|
non-stiff only |
yes (native |
Classical 7-stage Tsit5 (Tsitouras 2011), run as a fixed number of steps ( |
|
|
stiff and non-stiff |
yes (native |
Fixed-step, order-1 linearly-implicit (Rosenbrock-)Euler: per step solves |
|
|
routes automatically ( |
yes ( |
Universal catch-all wrapping |
|
|
non-stiff |
yes (record-and-replay |
The reference record-and-replay reverse-mode engine for an arbitrary non-stiff right-hand side. It records the accepted adaptive Tsit5 step mesh once, at the current parameters (numpy host loop on a small CPU ensemble, or a batched JAX |
|
|
stiff |
yes (record-and-replay |
The stiff high-order sibling of |
|
|
non-stiff |
yes (record-and-replay |
The high-order sibling of |
|
|
non-stiff |
via |
Fused adaptive Tsit5 as one NVIDIA Warp CUDA kernel launch, one thread per trajectory (forward + record only, float32/float64). Needs |
|
|
stiff |
via record-and-replay ( |
The stiff sibling of |
|
|
non-stiff |
no (forward-only by design) |
A hand-written CUDA Tsit5 kernel exposed via |
|
|
stiff |
no (forward-only by design) |
The stiff analogue of |
|
|
stiff |
yes (genuine fused-kernel |
Override-only (never an |
warp_replay appears in the tables above and in choose_engine’s return values but is
not a key in ENGINE_REGISTRY — by design. It has no independent forward solve; it is
the record-and-replay reverse routing target whose forward is warp_ode and whose helpers
(make_replay_closure, make_rosenbrock_replay_closure, replay_solve_jax) live in
gradsolve/warp/warp_replay.py. grad_closure special-cases it directly.
How routing decides¶
def choose_engine(
dim: int,
stiff: bool,
need_grad: bool,
*,
batch_n: int | None = None,
accuracy_target: float | None = None,
stiff_fused_enabled: bool | None = None,
cuda_tsit5_enabled: bool | None = None,
cuda_rosenbrock23_enabled: bool | None = None,
) -> str
gradsolve.dispatch.choose_engine is a pure function (no side effects, never raises) mapping
the workload’s three salient axes — state dimension, stiffness, and whether a gradient is
needed — to the engine that is faster for that case. Both solve and
grad_closure call it under engine="auto". The auditable list of rows it implements is
gradsolve.dispatch.DECISION_MAP, one dict per (dim, stiff, need_grad) case with an
evidence string giving the rationale for that routing; a test asserts choose_engine agrees
with DECISION_MAP on every row. Three module constants control whether a fused GPU engine
is actually routed to: STIFF_FUSED_ENABLED (gates warp_rosenbrock, True by default),
CUDA_TSIT5_ENABLED (gates cuda_tsit5, True) and CUDA_ROSENBROCK23_ENABLED (gates
cuda_rosenbrock23, False); each is overridable per call via stiff_fused_enabled=,
cuda_tsit5_enabled= and cuda_rosenbrock23_enabled= for testing without mutating module
state.
from gradsolve.dispatch import choose_engine
choose_engine(dim=3, stiff=False, need_grad=False) # -> "cuda_tsit5" (dim <= 16, forward-only)
choose_engine(dim=3, stiff=False, need_grad=True) # -> "warp_replay" (needs a gradient)
choose_engine(dim=1000, stiff=True, need_grad=True) # -> "rodas5p_replay" (dim > 64)
A second pure function, choose_remat(dim, stiff, *, batch_n=None) -> bool, decides
whether grad_closure wraps the replay scan in jax.checkpoint (see the remat
discussion above); grad_closure’s default remat=None calls it automatically.
Runnable examples¶
examples/00_standalone.py and examples/03_engine_routing.py depend only on gradsolve
and print OK on a clean CPU checkout (install as in the
install section):
python examples/00_standalone.py # your own RHS, no extra dependencies: forward + reverse (FD-checked)
python examples/03_engine_routing.py # inspect choose_engine's routing table across (dim, stiff, grad)
The full set of tutorials (examples/00–09) build on the same two entry points against a range
of registered problems and the fused reverse path; examples/README.md is the full index.