modax-solvers 0.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. modax_solvers-0.0.3/LICENSE +21 -0
  2. modax_solvers-0.0.3/PKG-INFO +611 -0
  3. modax_solvers-0.0.3/README.md +578 -0
  4. modax_solvers-0.0.3/modax/__init__.py +5 -0
  5. modax_solvers-0.0.3/modax/_codegen.py +37 -0
  6. modax_solvers-0.0.3/modax/_jax_common.py +111 -0
  7. modax_solvers-0.0.3/modax/_jax_numba_custom_call.py +371 -0
  8. modax_solvers-0.0.3/modax/_numba_common.py +188 -0
  9. modax_solvers-0.0.3/modax/_sensitivity.py +469 -0
  10. modax_solvers-0.0.3/modax/_sparse_direct.py +639 -0
  11. modax_solvers-0.0.3/modax/_sparsity.py +230 -0
  12. modax_solvers-0.0.3/modax/rodas5P.py +1290 -0
  13. modax_solvers-0.0.3/modax/tsit5.py +713 -0
  14. modax_solvers-0.0.3/modax_solvers.egg-info/PKG-INFO +611 -0
  15. modax_solvers-0.0.3/modax_solvers.egg-info/SOURCES.txt +28 -0
  16. modax_solvers-0.0.3/modax_solvers.egg-info/dependency_links.txt +1 -0
  17. modax_solvers-0.0.3/modax_solvers.egg-info/requires.txt +16 -0
  18. modax_solvers-0.0.3/modax_solvers.egg-info/top_level.txt +1 -0
  19. modax_solvers-0.0.3/pyproject.toml +151 -0
  20. modax_solvers-0.0.3/setup.cfg +4 -0
  21. modax_solvers-0.0.3/tests/test_enzyme_jacobian.py +138 -0
  22. modax_solvers-0.0.3/tests/test_examples.py +323 -0
  23. modax_solvers-0.0.3/tests/test_nonautonomous.py +85 -0
  24. modax_solvers-0.0.3/tests/test_save_hook.py +104 -0
  25. modax_solvers-0.0.3/tests/test_sensitivity.py +521 -0
  26. modax_solvers-0.0.3/tests/test_solver_options.py +93 -0
  27. modax_solvers-0.0.3/tests/test_solver_vmap.py +154 -0
  28. modax_solvers-0.0.3/tests/test_solvers.py +90 -0
  29. modax_solvers-0.0.3/tests/test_sparse_direct.py +434 -0
  30. modax_solvers-0.0.3/tests/test_sparsity.py +144 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lawrence Berry
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,611 @@
1
+ Metadata-Version: 2.4
2
+ Name: modax-solvers
3
+ Version: 0.0.3
4
+ Summary: GPU-accelerated ODE solvers for massive ensembles of low-dimensional trajectories
5
+ Author-email: Lawrence Berry <lawrenceberry@outlook.com>
6
+ License-Expression: MIT
7
+ Project-URL: Documentation, https://lawrenceberry.github.io/modax/
8
+ Project-URL: Source, https://github.com/lawrenceberry/modax
9
+ Project-URL: Issues, https://github.com/lawrenceberry/modax/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: GPU :: NVIDIA CUDA
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
15
+ Classifier: Topic :: Scientific/Engineering :: Physics
16
+ Requires-Python: >=3.13
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: jax>=0.9.1
20
+ Requires-Dist: numba>=0.61
21
+ Requires-Dist: numba-cuda-mlir[cu13]>=0.5.1
22
+ Requires-Dist: numba-enzyme-cuda>=0.2.1
23
+ Requires-Dist: networkx>=3.4
24
+ Requires-Dist: numpy>=2.4.3
25
+ Requires-Dist: cvxopt>=1.3
26
+ Requires-Dist: scipy>=1.17.1
27
+ Provides-Extra: sparse
28
+ Provides-Extra: cuda12
29
+ Requires-Dist: jax[cuda12]>=0.9.1; extra == "cuda12"
30
+ Provides-Extra: cuda13
31
+ Requires-Dist: jax[cuda13]>=0.9.1; extra == "cuda13"
32
+ Dynamic: license-file
33
+
34
+ # modax
35
+
36
+ <!--
37
+ The coverage badge reads a gist that the `tests` workflow rewrites on every push
38
+ to master; replace GIST_ID below with that gist's id (the same value as the
39
+ repository variable COVERAGE_GIST_ID). These badges sit above the `overview`
40
+ snippet marker, so the documentation site does not repeat them.
41
+ -->
42
+ [![tests](https://github.com/lawrenceberry/modax/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/lawrenceberry/modax/actions/workflows/tests.yml)
43
+ [![coverage](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Flawrenceberry%2FGIST_ID%2Fraw%2Fmodax-coverage.json)](https://github.com/lawrenceberry/modax/actions/workflows/tests.yml)
44
+ [![lint](https://github.com/lawrenceberry/modax/actions/workflows/lint.yml/badge.svg?branch=master)](https://github.com/lawrenceberry/modax/actions/workflows/lint.yml)
45
+ [![PyPI](https://img.shields.io/pypi/v/modax-solvers)](https://pypi.org/project/modax-solvers/)
46
+ [![Downloads](https://img.shields.io/pypi/dm/modax-solvers)](https://pypi.org/project/modax-solvers/)
47
+ [![Python](https://img.shields.io/pypi/pyversions/modax-solvers)](https://pypi.org/project/modax-solvers/)
48
+ [![JAX](https://img.shields.io/badge/JAX-compatible-3f51b5)](https://docs.jax.dev/)
49
+
50
+ **[Documentation](https://lawrenceberry.github.io/modax/)**
51
+
52
+ <!-- --8<-- [start:overview] -->
53
+
54
+ GPU-accelerated ODE solvers for **massive ensembles** (1-100k) of low-dimensional (<200D) ODE trajectories, built on
55
+ JAX and Numba-CUDA-MLIR. Applications include: Bayesian parameter inference, uncertainty quantification and the integration of physically uncoupled systems.
56
+
57
+ Every solver is a hand-written **CUDA custom kernel** compiled by
58
+ Numba-CUDA-MLIR: one CUDA thread
59
+ per trajectory, hand-written step kernels with in-kernel LU factorisation,
60
+ exposed to JAX as an XLA FFI custom call. That binding makes each solver an
61
+ ordinary JAX primitive — `jit`-traceable, and `vmap` over a single solve lowers
62
+ to one native ensemble launch.
63
+
64
+ <!-- --8<-- [end:overview] -->
65
+
66
+ <!-- --8<-- [start:solvers] -->
67
+
68
+ ## Solvers (`modax/`)
69
+
70
+ | Method | Type | Use for | File |
71
+ |-------------|------------------------|-------------------|----------------|
72
+ | **Tsit5** | Explicit RK (order 5) | Non-stiff systems | `tsit5.py` |
73
+ | **Rodas5P** | Rosenbrock-W (order 5) | Stiff systems | `rodas5P.py` |
74
+
75
+ Rodas5P supports an `lu_precision` (`"fp32"`/`"fp64"`) knob: the FP32
76
+ factorisation halves shared-memory use without lowering method order, since the
77
+ Rosenbrock order conditions hold under an approximate Jacobian.
78
+
79
+ <!-- --8<-- [end:solvers] -->
80
+
81
+ <!-- --8<-- [start:sparse] -->
82
+
83
+ ## Sparse systems
84
+
85
+ Rodas5P takes a `sparsity` pattern, and that one argument is the whole
86
+ interface — there is no linear solver to write or to pass:
87
+
88
+ ```python
89
+ y = solve(ode_fn, y0, t_span, params,
90
+ sparsity=pattern) # (n_vars, n_vars) mask, scipy sparse, or (nnz, 2)
91
+ ```
92
+
93
+ A pattern buys two separate things. The Jacobian is recovered in one Enzyme
94
+ sweep per *colour* of the pattern's column intersection graph rather than one
95
+ per column, since columns sharing no row can be seeded together and the pattern
96
+ says which output component belongs to which. And the iteration matrix
97
+ `M = I/(hγ) − J` gets a **direct sparse solver compiled for that exact
98
+ structure**: an in-kernel sparse LU and a pair of sparse triangular solves, one
99
+ trajectory per thread, in place of the dense LU. The pattern must be a superset
100
+ of the true nonzeros — colouring a superset only costs sweeps, colouring a
101
+ subset silently corrupts entries — but it need *not* cover the factorisation's
102
+ fill-in, which is worked out from it. With no pattern, every column gets its own
103
+ colour and the matrix is factorised densely: the same mechanism at its
104
+ uninformative end rather than a second code path.
105
+
106
+ On DISCO-EB's 50-variable Einstein-Boltzmann system this is **22% faster** than
107
+ the hand-written Schur block-LU it replaced, and it asks nothing of the caller
108
+ but the pattern.
109
+
110
+ ### The choices behind it
111
+
112
+ All of the analysis happens once, on the host, when the kernel is built
113
+ (`modax/_sparse_direct.py`).
114
+
115
+ **AMD for the ordering, not COLAMD.** The obvious alternative, COLAMD, orders
116
+ the *columns* so that fill stays bounded whatever row permutation partial
117
+ pivoting later chooses. That is the right objective exactly when there will be
118
+ pivoting — and there will not be, because the pattern is compiled into the
119
+ kernel and cannot depend on the numbers. COLAMD's permutation is also one-sided,
120
+ so it moves the diagonal off the diagonal, and this factorisation needs the
121
+ diagonal precisely where `I/(hγ)` puts it. AMD instead minimises (approximately)
122
+ the fill of the Cholesky factor of `S + Sᵀ`, which is the standard bound on the
123
+ fill of an unpivoted `LU` of `S`, and it does so with a *symmetric* permutation
124
+ `P S Pᵀ` that leaves every diagonal entry on the diagonal. It is what UMFPACK
125
+ and SuperLU use in their "symmetric mode", for these reasons, and an iteration
126
+ matrix is about as close to structurally symmetric as an unsymmetric matrix
127
+ gets. It comes from SuiteSparse through [cvxopt](https://cvxopt.org), whose
128
+ wheel carries AMD itself, so no system library is involved;
129
+ `ordering="natural"` skips the ordering and those two are the whole of the
130
+ choice. CHOLMOD's other orderings were on offer while scikit-sparse was a
131
+ dependency, and measuring them is what retired it: `colamd`, `nesdis` and
132
+ `best` each returned AMD's own fill to the entry, and `metis` was strictly
133
+ worse where it differed — 660 nonzeros against 484 on the Einstein-Boltzmann
134
+ structure below, losing the perfect elimination order.
135
+
136
+ **No pivoting at all.** The pattern has to be fixed at compile time and the same
137
+ in every thread, so rows cannot be swapped on the numbers — which would also
138
+ reintroduce the warp divergence one-trajectory-per-thread is there to avoid. Two
139
+ things make that sound. The permutation is symmetric, so `M`'s diagonal stays on
140
+ the diagonal and `I/(hγ)` guarantees every pivot is structurally present and
141
+ grows without bound as the step shrinks. And Rodas5P is a Rosenbrock-**W**
142
+ method: order 5 survives an approximate factorisation, so a badly conditioned
143
+ pivot costs step-size control rather than correctness, and the controller is
144
+ what notices. A pivot that reaches exactly zero leaves an infinity, the error
145
+ norm goes to NaN, the step is rejected, and the smaller step puts a larger
146
+ `1/(hγ)` on that very diagonal.
147
+
148
+ **A symbolic factorisation for the footprint, not a trial numeric one.** The
149
+ `L + U` pattern comes from pattern-only Gaussian elimination, which is exact: it
150
+ is what the numeric factorisation will touch, no more and no less. Factorising a
151
+ sample matrix and counting cannot be — a coefficient that happens to vanish for
152
+ those particular numbers, or an exact cancellation, drops an entry another
153
+ right-hand side needs, and the buffer is then one slot short in a kernel with no
154
+ way to say so. It is also cheaper, needing neither a plausible matrix nor a
155
+ device. The implementation is bit-per-entry over the whole matrix, `O(n³/64)`
156
+ time and `O(n²)` bits, which for the tens-to-a-few-hundred variables these
157
+ solvers target analyses in milliseconds and buys nothing back from a sparse
158
+ symbolic algorithm.
159
+
160
+ **CSR, not CSC.** Every one of the three routines that reads the matrix reads it
161
+ *by rows*: the up-looking factorisation takes row `i` and subtracts multiples of
162
+ the rows above it, the forward substitution is a dot product of row `i` of `L`
163
+ with the solution so far, and the back substitution is the same over row `i` of
164
+ `U`. One row-major image serves all three. CSC would have to be transposed for
165
+ two of them, and a column-oriented factorisation would still leave the solves
166
+ wanting rows. `L` and `U` share that one image — `L` strictly left of the
167
+ diagonal, `U` from it rightwards — because the factorisation is in place and a
168
+ unit diagonal needs no storage, so the buffer is exactly `nnz(L + U)`, which is
169
+ per-thread local memory and the thing that bounds occupancy.
170
+
171
+ **The Jacobian is written straight into the factorisation's buffer.** Colouring
172
+ and storage are separate questions, and the AD's colour sweeps deposit `−J` at
173
+ the CSR slots the factorisation will read, with the fill-in slots simply cleared
174
+ beforehand. Nothing is staged through global memory and read back, and nothing is
175
+ expanded to a dense matrix in between. It also means the pattern may be declared
176
+ as tightly as it really is: a hand-written solver owning its own buffer had to
177
+ declare its fill-in in the pattern to have somewhere to put it, and paid colours
178
+ for that.
179
+
180
+ **Straight-line code where it fits.** Table-driven, a sparse routine spends a
181
+ broadcast load on the index of every value before it can issue the load of the
182
+ value itself, and that dependent pair is only free when enough other
183
+ trajectories are in flight to cover it. DISCO-EB's single-cosmology case is 128
184
+ trajectories — four warps on a 46-SM device — and nothing covers it. So below
185
+ `MAX_UNROLLED_SUBSTITUTIONS` / `MAX_UNROLLED_UPDATES` the routines are emitted
186
+ as straight-line code with every slot a literal, and above them they fall back
187
+ to loops over index tables in constant memory. Unrolling costs no registers,
188
+ since the kernel indexes both the matrix and the right-hand side with loop
189
+ variables of its own and neither can leave local memory whatever this does — it
190
+ trades index loads for instruction count and nothing else. Measured on DISCO-EB
191
+ at N128: **528 ms** table-driven, **419 ms** with the solves unrolled, **398 ms**
192
+ with the factorisation unrolled too, against **509 ms** for the hand-written
193
+ Schur solver. The two emissions are checked against each other and required to
194
+ agree bit for bit.
195
+
196
+ **What it finds on a real problem.** DISCO-EB's Einstein-Boltzmann Jacobian is a
197
+ densely coupled core bordered by tridiagonal free-streaming hierarchies. AMD
198
+ returns a *perfect* elimination order for it — zero fill, `nnz(L + U) = nnz(J)` —
199
+ and the order it finds is the hand-written Schur solver's: peel each hierarchy
200
+ from its truncated end inwards, where every variable has degree two, then
201
+ eliminate the dense core last.
202
+
203
+ <!-- --8<-- [end:sparse] -->
204
+
205
+ <!-- --8<-- [start:api] -->
206
+
207
+ ## API
208
+
209
+ All solvers expose a single `solve(...)` entry point that integrates an
210
+ ensemble in one call:
211
+
212
+ ```python
213
+ from modax.rodas5P import solve
214
+
215
+ # ode_fn is a CUDA-device callable: (y, t, p) -> tuple
216
+ y = solve(
217
+ ode_fn,
218
+ y0, # (n_vars,) or (N, n_vars) initial state(s)
219
+ t_span, # (n_save,) output times (shared across the ensemble)
220
+ params, # (n_params,) or (N, n_params) per-trajectory parameters
221
+ rtol=1e-8,
222
+ atol=1e-10,
223
+ first_step=None,
224
+ max_steps=100_000,
225
+ return_stats=False, # also return per-step accept/reject counts
226
+ error_weights=None, # optional per-component weights (0 = ignore)
227
+ pcoeff=0.0, icoeff=1.0, dcoeff=0.0, # PID step-controller gains
228
+ sens_error_control=True, # error-control the sensitivities too
229
+ sparsity=None, # Jacobian pattern; see "Sparse systems"
230
+ ordering="amd", # its fill-reducing permutation
231
+ )
232
+ # y has shape (N, n_save, n_vars)
233
+ ```
234
+
235
+ Calling conventions:
236
+
237
+ - The callbacks are compiled with `numba_cuda_mlir`, so they take and return fixed-size
238
+ tuples of scalars rather than arrays, and use `math` rather than `numpy`/`jax.numpy`.
239
+ Plain Python functions are jitted automatically; pre-`cuda.jit`ed ones are used as-is.
240
+ A right-hand side that must also run under `jax` — to compare against a
241
+ Diffrax baseline, say — need not be written twice: `examples/dual_backend.py`
242
+ builds both forms from one body, parameterised over the names the two
243
+ backends spell differently.
244
+ - **Rodas5P** (implicit) needs only `ode_fn`. Its Jacobian ∂f/∂y, and the ∂f/∂t
245
+ a non-autonomous system needs to retain full order, are differentiated out of
246
+ `ode_fn` with [numba-enzyme](https://github.com/Qruise-ai/numba-enzyme),
247
+ which runs Enzyme over the callback's LLVM IR.
248
+ - **Tsit5** (explicit) needs no derivatives at all.
249
+
250
+ Importing `modax` enables JAX float64.
251
+
252
+ <!-- --8<-- [end:api] -->
253
+
254
+ <!-- --8<-- [start:gradients] -->
255
+
256
+ ## Gradients
257
+
258
+ Both solvers are differentiable with respect to `y0` and `params`:
259
+
260
+ ```python
261
+ import jax
262
+ from modax.rodas5P import solve
263
+
264
+ def loss(params):
265
+ y = solve(ode_fn, y0, t_span, params)
266
+ return jnp.sum((y[:, -1, :] - observed) ** 2)
267
+
268
+ value, grad = jax.value_and_grad(loss)(params) # one joint solve
269
+ ```
270
+
271
+ `jax.jvp`, `jax.jacfwd`, `jax.grad`, `jax.jacrev` and `jax.value_and_grad` all
272
+ work, inside `jit` and `vmap` as usual. Derivatives are computed only when a
273
+ differentiation transform actually asks for them — a plain `solve(...)` runs the
274
+ same kernel it always did and pays nothing.
275
+
276
+ Asking for a derivative integrates the **continuous forward-sensitivity
277
+ system** alongside the state. Writing $S = \partial y/\partial\theta$,
278
+ differentiating $y' = f(t, y, p)$ with respect to $\theta$ gives the variational
279
+ equation
280
+
281
+ $$\frac{dS}{dt} = J_y(t)\,S(t) + J_p(t), \qquad J_y = \frac{\partial f}{\partial y},\quad J_p = \frac{\partial f}{\partial \theta}$$
282
+
283
+ which the solver integrates jointly with the state as one larger ODE
284
+
285
+ $$\frac{\partial}{\partial t}\begin{bmatrix} y \\ S \end{bmatrix} = \begin{bmatrix} f(t, y, p) \\ J_y(t)\,S + J_p(t) \end{bmatrix}$$
286
+
287
+ so `jax.value_and_grad` costs one solve rather than one for the value and
288
+ another for the derivative.
289
+
290
+ ### How the joint system is solved
291
+
292
+ There are three ways to arrange this, and they are not equally good.
293
+
294
+ **(a) Two separate solves** — integrate `y` to completion, then integrate `S`
295
+ against it. The sensitivity solve needs `y(t)` at *its own* step and stage
296
+ points, which the state solve never produces, so this means storing the whole
297
+ trajectory: at $10^5$ trajectories and $\sim\!10^3$ adaptive steps that is
298
+ hundreds of gigabytes, on a device with tens. It also runs two independent
299
+ adaptive loops per trajectory, doubling the warp-divergence penalty that
300
+ dominates this kernel's cost. Rejected.
301
+
302
+ **(b) Staggered** — advance `y` over a step, then advance `S` over the same step
303
+ using `y`'s stage values. No trajectory storage, and the sensitivity
304
+ subsystem's Jacobian with respect to its own unknown is exactly $J_y$. But for a
305
+ *linearly implicit* method this does not avoid anything: treating `y(t)` as a
306
+ known function of `t` moves the state dependence into explicit time dependence,
307
+ and Rosenbrock's $\partial F/\partial t$ term picks it straight back up by the
308
+ chain rule. It costs a second pass through the tableau and the state's stage
309
+ values kept alive, for the same derivatives.
310
+
311
+ **(c) Jointly — what modax does.** One Rosenbrock step on $[y, S]$, exploiting
312
+ the fact that the joint Jacobian is *exactly* block lower triangular, because
313
+ `f` does not depend on `S`:
314
+
315
+ $$A = \begin{bmatrix} J_y & 0 \\ L & J_y\end{bmatrix}, \qquad L = \frac{\partial}{\partial y}\left(J_y S + J_p\right)$$
316
+
317
+ "Joint" therefore does **not** mean factorising an $n_\text{aug} \times
318
+ n_\text{aug}$ matrix. The iteration matrix $M = I/(h\gamma) - A$ inherits the
319
+ structure, and every diagonal block is the *same* $M_0 = I/(h\gamma) - J_y$, so
320
+ one stage is a block forward substitution
321
+
322
+ $$M_0\,k_y = r_y, \qquad M_0\,k_{S_k} = r_{S_k} + L_k\,k_y$$
323
+
324
+ against a single factorisation. The LU stays $n_\text{vars}^3$ instead of
325
+ $n_\text{vars}^3(1+n_\text{sens})^3$, and shared memory $n_\text{vars}^2$
326
+ instead of $n_\text{vars}^2(1+n_\text{sens})^2$.
327
+
328
+ (c) was chosen because it needs exactly the same derivatives as (b) while
329
+ sequencing them in one pass, under one step-size controller with one rejection
330
+ decision — and because the triangular structure means sequencing the state
331
+ before the sensitivities is not an approximation but the shape of the exact
332
+ solve. Within a stage it *is* staggered; it simply does not pretend the
333
+ coupling is absent.
334
+
335
+ ### Second derivatives, and why they are unavoidable
336
+
337
+ The coupling block $L$ is a second derivative of the *original* right-hand side
338
+ — with respect to (state, state) and (state, parameter):
339
+
340
+ $$L_k = \frac{\partial^2 f}{\partial y\,\partial y}\!\left[\cdot,\,S_k\right] + \frac{\partial^2 f}{\partial y\,\partial p_k}$$
341
+
342
+ They appear because $S' = J_y(y)S + J_p(y)$ is a linear ODE whose *coefficients*
343
+ depend on `y`, and an implicit method has to differentiate those coefficients.
344
+ There is no arrangement that escapes them: a Newton-iterated method (BDF, SDIRK)
345
+ could treat $J_y$ as a mere preconditioner and converge regardless, but Rodas5P
346
+ is linearly implicit — its Jacobian is inside the formula, so an approximate one
347
+ lands in the answer.
348
+
349
+ modax gets them from [numba-enzyme](https://github.com/Qruise-ai/numba-enzyme),
350
+ whose `jvp` composes with itself: `jvp(jvp(f))` is a forward-over-forward
351
+ directional derivative, giving $D^2 f(x)[u,v]$. Seeding $u = (S_k, 0, e_k)$ and
352
+ $v = (k_y, 0, 0)$ returns $L_k k_y$ directly — the matrix $L_k$ is never formed.
353
+ Seeding $v = (0,1,0)$ instead returns the sensitivity rows'
354
+ $\partial F/\partial t$, the other second derivative a Rosenbrock method needs.
355
+ The same mechanism supplies the first-order right-hand side: $J_y S_k + J_p$
356
+ *is* a directional derivative, so it is one sweep per column rather than a whole
357
+ Jacobian.
358
+
359
+ Composition here is not the trivial thing it is in JAX. `jax.jvp` maps a jaxpr
360
+ to a jaxpr, so it is closed under itself; numba-enzyme's maps a Python callable
361
+ to a *compiled device symbol*, and differentiating that again would hand Enzyme
362
+ an external declaration with no body. So the fork records the chain instead of
363
+ applying it, and emits every level as a definition in one module, where a single
364
+ Enzyme pass resolves the nested markers.
365
+
366
+ This matters more than it sounds. Dropping $L$ and using the block diagonal
367
+ $\mathrm{diag}(J_y, \ldots, J_y)$ is legitimate for a W method — order 5 survives
368
+ — but the error constant does not, and the step-size controller pays for it. On
369
+ a two-species right-hand side bilinear in state and parameters:
370
+
371
+ | joint Jacobian | additive `f` ($L = 0$) | bilinear `f` ($L \neq 0$) |
372
+ |---|---|---|
373
+ | block diagonal (W approximation) | 1.0× the plain solve's steps | **201×** |
374
+ | exact, via `jvp(jvp(f))` | 1.0× | **1.2×** |
375
+
376
+ and on a forced non-autonomous problem with a closed-form sensitivity, the
377
+ gradient error at `rtol=1e-6` improves from $4.6\times10^{-3}$ to
378
+ $4.6\times10^{-8}$, converging at the method's proper order instead of crawling.
379
+
380
+ Details:
381
+
382
+ - Only the blocks you differentiate are integrated. A gradient with respect to
383
+ `params` alone carries `n_params` sensitivity columns; one with respect to
384
+ `y0` as well carries `n_vars` more.
385
+ - The sensitivities take part in step-size control by default (~20% extra steps),
386
+ so the gradient's accuracy is tied to `rtol` rather than left to luck. Pass
387
+ `sens_error_control=False` to drop them from the error norm: the joint solve
388
+ then takes exactly the steps the plain solve takes and returns the same value.
389
+ - `t_span` is not differentiable; differentiating through it raises.
390
+
391
+ ### What gradients cost
392
+
393
+ The joint system is `n_vars * (1 + n_sens)` wide, where `n_sens` is the number
394
+ of directions actually differentiated — `n_params`, plus `n_vars` more if you
395
+ differentiate `y0` as well.
396
+
397
+ **Cost is linear in `n_sens`, because the sensitivities are never factorised.**
398
+ This is the whole point of the block-triangular structure. The joint iteration
399
+ matrix has the same `M0 = I/(h*gamma) - J_y` on every diagonal block, so a step
400
+ factorises `M0` exactly **once**, at `O(n_vars^3)`, and every sensitivity column
401
+ then reuses that factorisation. What an extra column adds is a forward and back
402
+ substitution against factors that already exist — `O(n_vars^2)` — plus two
403
+ Enzyme sweeps per stage and its share of the occupancy. Per step:
404
+
405
+ ```
406
+ cost ~ O(n_vars^3) one LU, however many columns
407
+ + (1 + n_sens) * O(n_vars^2) one substitution per column per stage
408
+ + (1 + n_sens) * O(n_vars) right-hand sides and Enzyme sweeps
409
+ ```
410
+
411
+ There is no second cubic term anywhere in that. Nothing about differentiating
412
+ costs another factorisation, which is exactly why the measured overhead below
413
+ tracks `1 + n_sens` and not something steeper.
414
+
415
+ **Against parameter count**, at `n_vars = 8`, 1000 trajectories, fp32:
416
+
417
+ | `n_params` | joint width | solve | `value_and_grad` | overhead |
418
+ |---|---|---|---|---|
419
+ | 1 | 16 | 6.30 ms | 10.44 ms | **1.66×** |
420
+ | 2 | 24 | 6.42 ms | 14.32 ms | **2.23×** |
421
+ | 4 | 40 | 6.56 ms | 20.51 ms | **3.13×** |
422
+ | 8 | 72 | 6.98 ms | 48.52 ms | **6.95×** |
423
+
424
+ So cost is roughly **linear in `1 + n_params`**, with a coefficient a little
425
+ under one — about `0.7 * (1 + n_params)` here — the discount being the
426
+ factorisation that all the columns share. Budget accordingly: ten parameters is
427
+ an order of magnitude, not a rounding error, but it is an order of magnitude and
428
+ not the `n_params`-fold repetition of the cubic that differentiating the
429
+ factorisation itself would cost.
430
+
431
+ **Against state dimension**, one parameter, on the VdP lattice at 1000
432
+ trajectories, fp32:
433
+
434
+ | `n_vars` | joint width | solve | `value_and_grad` | overhead |
435
+ |---|---|---|---|---|
436
+ | 8 | 16 | 3.52 ms | 5.85 ms | **1.66×** |
437
+ | 16 | 32 | 8.61 ms | 19.84 ms | **2.31×** |
438
+ | 32 | 64 | 20.39 ms | 49.45 ms | **2.43×** |
439
+ | 48 | 96 | 31.93 ms | 84.85 ms | **2.66×** |
440
+
441
+ A single sensitivity column costs between 1.7× and 2.7× across that range —
442
+ flat enough to plan around, and creeping up rather than down, since the extra
443
+ triangular solves and Enzyme sweeps scale with `n_vars` even though the
444
+ factorisation they reuse does not. In fp64 the ratio is *lower* (2.21× at
445
+ `n_vars = 48`), because the shared LU is twice the work and so a larger share of
446
+ the step.
447
+
448
+ Two things to watch:
449
+
450
+ - **Differentiating `y0` adds `n_vars` columns**, not one, so it is only
451
+ practical at low dimension. On 3-species Robertson at `N = 20000`, a gradient
452
+ with respect to the three rate parameters costs ~9× the value; adding `y0`
453
+ takes it to six columns and ~34×. At `n_vars = 48` it is not an option at all.
454
+ - **Shared memory is the hard limit.** Rodas5P re-fits its LU batch to the
455
+ augmented footprint automatically, and raises a clear error if even one
456
+ trajectory per block will not fit. At `n_vars = 48` that leaves room for about
457
+ one parameter column.
458
+
459
+ Tsit5 is cheaper per column (it forms no Jacobian and needs no second
460
+ derivatives) and is bounded by memory traffic rather than shared memory, so it
461
+ scales further in `n_sens` — at the usual cost of needing a non-stiff problem.
462
+
463
+ ### Why continuous forward sensitivities
464
+
465
+ modax is built for **massive ensembles of low-dimensional systems with few
466
+ parameters**, and that regime picks the method. The three candidates scale
467
+ differently in the state dimension `n_vars` and the parameter count
468
+ `n_params`:
469
+
470
+ | approach | work per step | extra memory | grows with |
471
+ |---|---|---|---|
472
+ | **Continuous forward sensitivity** (modax) | $O(n_\text{vars}^3 + n_\text{params}\,n_\text{vars}^2)$ | $O(n_\text{vars}\,(1 + n_\text{params}))$ | `n_params` |
473
+ | **Continuous adjoint** (backward) | $O(n_\text{vars}^3)$ backward, plus the forward solve and its checkpoint re-solves | $O(n_\text{vars} + n_\text{params})$ plus checkpoints | number of output cotangents — *not* `n_params` |
474
+ | **Direct auto-diff through the solver** | $O(n_\text{params}\,n_\text{vars}^3)$ | $O(n_\text{vars}\,(1 + n_\text{params}))$ forward; a full tape in reverse | `n_params`, **on the cubic term** |
475
+
476
+ The decisive row is the last one. A step's cost is dominated by factorising the
477
+ iteration matrix, $O(n_\text{vars}^3)$. Forward sensitivity pays that **once**
478
+ and each parameter column then costs a substitution against factors that already
479
+ exist, so the cubic term never multiplies:
480
+
481
+ $$O(n_\text{vars}^3 + n_\text{params}\,n_\text{vars}^2) \quad\text{against}\quad O(n_\text{params}\,n_\text{vars}^3)$$
482
+
483
+ Direct auto-diff has no way to know that. Handed the kernel's hand-written LU as
484
+ ordinary scalar code, Enzyme differentiates the factorisation *itself* —
485
+ propagating a tangent through every one of its $O(n_\text{vars}^3)$ operations,
486
+ once per direction. That is a factor of `n_params` on the dominant term, and it
487
+ is structure no differentiator can recover on its own: what modax does by hand
488
+ is apply the differentiation rule for a linear solve, `M dk = dr - dM k`, which
489
+ reuses `M`'s factors. An auto-diff system that treats the solve as a primitive
490
+ *with* that rule attached would recover the same scaling; one differentiating
491
+ the scalar code beneath it would not.
492
+
493
+ Against the adjoint, the trade is the usual one: its cost is independent of
494
+ `n_params` and instead proportional to the number of outputs differentiated, so
495
+ it wins once parameters outnumber state dimensions. modax targets the opposite
496
+ corner — the BBN example fits 2 parameters to a 4-species network — and the
497
+ adjoint would additionally need either a backwards solve, which is unstable for
498
+ the stiff, dissipative systems Rodas5P exists to handle, or a checkpointed
499
+ reverse pass whose gradients are no longer consistent with the discrete solve
500
+ the forward pass actually performed.
501
+
502
+ Differentiating `y0` as well adds `n_vars` columns rather than one, so it enters
503
+ the table wherever `n_params` appears, and is only practical at low dimension.
504
+
505
+ Forward sensitivities also fit the execution model. The variational equation is
506
+ per-trajectory and couples nothing across the ensemble, so the joint system is
507
+ still one CUDA thread per trajectory with no cross-trajectory communication.
508
+
509
+ The asymptotics are not the only obstacle to differentiating the solver kernel
510
+ itself with Enzyme, the way `ode_fn` is differentiated; it is impractical here
511
+ for mechanical reasons too. The kernels are not ordinary functions: they are
512
+ hand-written CUDA with per-trajectory adaptive stepping, hand-written linear
513
+ algebra over thread-local buffers, and `syncthreads` barriers in Tsit5's shared
514
+ backend.
515
+ Reverse mode through that, and through the step controller's data-dependent
516
+ control flow, is exactly where Enzyme-GPU stops working, and a reverse pass would in any
517
+ case need a tape of every stage of every step — at $10^5$ trajectories and
518
+ $\sim\!10^3$ adaptive steps that is hundreds of gigabytes, on a device with tens.
519
+ Integrating the sensitivity equation instead keeps the whole derivative inside
520
+ the same kernel structure, at the same memory footprint, with the same
521
+ per-thread independence.
522
+
523
+ ### Why a stiff ODE has a stiff sensitivity ODE
524
+
525
+ This is why the sensitivity system goes through the *stiff* solver rather than
526
+ being handed to an explicit one: it inherits the state's stiffness exactly.
527
+
528
+ **Claim.** The joint system $z' = F(z)$ has the same Jacobian spectrum as the
529
+ state equation, so every spectral measure of stiffness is identical.
530
+
531
+ **Proof.** With $z = (y, S_1, \ldots, S_m)$ and
532
+ $F_{S_k} = J_y(y)S_k + J_{p,k}(y)$, the joint Jacobian is
533
+
534
+ $$A = \frac{\partial F}{\partial z} = \begin{bmatrix} J_y & 0 & \cdots & 0 \\ L_1 & J_y & & \\ \vdots & & \ddots & \\ L_m & & & J_y \end{bmatrix}$$
535
+
536
+ since $\partial F_y/\partial S_k = 0$ (the state equation does not involve $S$)
537
+ and $\partial F_{S_k}/\partial S_j = J_y\,\delta_{kj}$. $A$ is block lower
538
+ triangular, and the determinant of a block triangular matrix is the product of
539
+ the determinants of its diagonal blocks, so
540
+
541
+ $$\det(A - \lambda I) = \prod_{i=0}^{m} \det(J_y - \lambda I) = \big[\det(J_y - \lambda I)\big]^{m+1}.$$
542
+
543
+ Hence $\mathrm{spec}(A) = \mathrm{spec}(J_y)$, each eigenvalue with its algebraic
544
+ multiplicity multiplied by $m+1$. No new eigenvalues appear, and none are lost.
545
+ $\blacksquare$
546
+
547
+ **Consequence.** The stiffness ratio $\max_i|\mathrm{Re}\,\lambda_i| \,/\,
548
+ \min_i|\mathrm{Re}\,\lambda_i|$, the linear stability constraint
549
+ $h\lambda \in \mathcal{S}$, and any other spectral criterion take the same value
550
+ for the joint system as for the original. If the state equation is stiff, the
551
+ joint system is stiff to exactly the same degree — no more, no less.
552
+
553
+ The same fact seen without matrices: the sensitivity equation is linear in $S$
554
+ with homogeneous part $S' = J_y(t)S$, which is the *variational equation* of the
555
+ original problem. By variation of constants,
556
+
557
+ $$S(t) = \Phi(t, t_0)\,S(t_0) + \int_{t_0}^{t} \Phi(t, s)\,J_p(s)\,\mathrm{d}s,$$
558
+
559
+ where $\Phi$ is the state-transition matrix of that variational equation,
560
+ $\Phi' = J_y\Phi$, $\Phi(t_0,t_0) = I$. So sensitivities are propagated by
561
+ *precisely* the operator that governs how perturbations of the state evolve. The
562
+ violently contracting directions that make the state stiff are the same
563
+ directions in which $\Phi$ contracts, and an explicit method integrating $S$
564
+ would face exactly the step-size restriction it faces on $y$.
565
+
566
+ One honest caveat: equal spectra do not mean equal transient behaviour. $A$ is
567
+ block triangular and generally not normal, so when $L \neq 0$ the joint system
568
+ can show larger transient growth than the state alone even though its eigenvalues
569
+ are unchanged. Stiffness in the spectral sense is identical; conditioning need
570
+ not be.
571
+
572
+ <!-- --8<-- [end:gradients] -->
573
+
574
+ <!-- --8<-- [start:install] -->
575
+
576
+ ## Install & run
577
+
578
+ ```bash
579
+ uv sync # CPU
580
+ uv sync --extra cuda13 # or --extra cuda12, for GPU
581
+
582
+ uv run pytest
583
+ uv run ruff format && uv run ruff check --fix
584
+ ```
585
+
586
+ The Enzyme-derived Jacobians come from
587
+ [numba-enzyme-cuda](https://pypi.org/project/numba-enzyme-cuda/), the
588
+ CUDA-enabled fork of numba-enzyme, which is an ordinary PyPI dependency and
589
+ carries its own LLVM 15 and Enzyme binaries — nothing has to be built by hand,
590
+ and no system LLVM is involved. It provides the `numba_enzyme` import package,
591
+ so upstream `numba-enzyme` must not be installed alongside it. See
592
+ [wheels/README.md](wheels/README.md) for what is in the wheel and why.
593
+
594
+ `pip install modax-solvers` gets the same set, and there is no system library to
595
+ install first: every dependency ships wheels, the AMD ordering included. A GPU
596
+ is needed to run a solve.
597
+
598
+ <!-- --8<-- [end:install] -->
599
+
600
+ <!-- --8<-- [start:examples] -->
601
+
602
+ ## Examples
603
+
604
+ Worked end-to-end problems live in `examples/` (each with its own README):
605
+
606
+ - `bbn_estimation/` — toy Big Bang Nucleosynthesis network with nested-sampling
607
+ parameter estimation and a modax/Diffrax/scipy solver benchmark;
608
+ - `21cm_igm_evolution/` — toy global 21cm IGM thermal/ionisation history;
609
+ - `mukhanov_sasaki/` — Mukhanov–Sasaki mode evolution.
610
+
611
+ <!-- --8<-- [end:examples] -->