tinydiffeq
tinydiffeq is a deliberately tiny set of differentiable ODE/SDE integrators
for JAX: fixed-step Euler and RK4, adaptive Tsit5 with an integral step-size
controller, and fixed-step Euler–Maruyama for Itô SDEs. Everything runs
inside one bounded lax.scan with static shapes, and every solve is
differentiable in both forward and reverse mode — including
reverse-over-forward, the pattern a Levenberg–Marquardt optimizer with
geodesic acceleration needs when it differentiates through a rollout.
It is a jvp/vjp-friendly subset of diffrax. Use diffrax instead if you need any of:
- pytree states (tinydiffeq states are single arrays, scalar or vector)
- stiff or implicit solvers
- PID step-size control
- events, root-finding, or backward-time integration
- dense output / continuous interpolation objects
- checkpointed or backsolve adjoints for long horizons
tinydiffeq ships only the O(max_steps)-memory bounded-scan approach, because
that is the one that composes cleanly with jax.jvp, jax.vjp, jax.vmap,
and reverse-over-forward without custom adjoint machinery.
Install
For accelerator use, install the JAX build matching your hardware alongside it, for example:
Vector-field interface
The vector field may take one to four positional arguments — always in this order:
xis the array state (scalar or vector).tis time.argsis pass-through data. By convention it is not an AD target — nothing stops you differentiating with respect to it, but the library's contracts and tests treat it as constants.pholds differentiable parameters — any pytree, e.g. neural-network weights. jvp/vjp with respect topandx0are first-class and tested.
The arity is inspected once and the function is wrapped into the canonical
four-argument form, so the compiled code is identical for all four. There is
no special autonomous code path: an unused t is dead-code-eliminated.
drift and diffusion in solve_sde follow the same convention.
Minimal example
import jax
import jax.numpy as jnp
from tinydiffeq import solve_ode, Tsit5, IController, SaveAt
jax.config.update("jax_enable_x64", True) # your call, not the library's
def f(x, t, args, p):
return -p * x
sol = solve_ode(
f, Tsit5(), 0.0, 2.0, jnp.asarray(1.0),
p=jnp.asarray(1.3),
dt0=0.1,
controller=IController(rtol=1e-8, atol=1e-10),
max_steps=512,
saveat=SaveAt(ts=jnp.linspace(0.0, 2.0, 21)),
)
sol.xs # (21,) states on the grid, however many internal steps were taken
sol.ok # False if max_steps ran out before t1
Gradients go straight through the solve:
def endpoint(p):
return solve_ode(
f, Tsit5(), 0.0, 2.0, jnp.asarray(1.0), p=p,
dt0=0.1, controller=IController(rtol=1e-10, atol=1e-12),
max_steps=512,
).xs
jax.grad(endpoint)(jnp.asarray(1.3)) # reverse mode
jax.jvp(endpoint, (jnp.asarray(1.3),), (jnp.asarray(1.0),)) # forward mode
jax.grad(lambda p: jax.jvp(endpoint, (p,), (jnp.asarray(1.0),))[1])(
jnp.asarray(1.3)
) # reverse-over-forward
Design contracts at a glance
dt0is required. There is no initial-step heuristic.- Forward time only:
t1 > t0. - Never poisons.
sol.okreports whethert1was reached; callers that want diverging residuals dojnp.where(sol.ok, sol.xs, jnp.inf). project(an idempotent clamp, e.g. positivity) is applied at every point where the vector field is evaluated and to every accepted state.- Never sets
jax_enable_x64. The time dtype followsjnp.result_type(x0, float); float32 problems stay float32 even under x64. - Solvers, controllers,
SaveAt, andSolutionare frozen dataclasses registered as pytrees: numeric fields (tolerances, grids,dt0,x0) are data leaves, so changing them never recompiles.
Read next: Static Shapes for the bounded-scan design and
SaveAt, Adaptive Stepping and AD for what is and is not
differentiated, SDEs, and
Migration if you are replacing hand-rolled RK4/Tsit5 loops.