Theory · 01
Compilation pipeline¶
Flows: symbolic → C → cached .so¶
When you first integrate a ContinuousSystem, this happens once:
_equations(y, t, **params) your one symbolic method
│ evaluated with SymEngine symbols
▼
SymEngine expression list dim expressions, params as symbols
│ JiTCODE
▼
generated C source RHS (+ variational eqs for Lyapunov runs)
│ your C compiler
▼
compiled .so saved to the cache directory
│
▼
~/.cache/tsdynamics/tsdyn_<Class>_<dim>[_<hash>].<ext>
Every later call — same session or a fresh process — finds the .so on
disk and loads it in milliseconds. The cache directory is
~/.cache/tsdynamics/, overridable with the TSDYNAMICS_CACHE
environment variable.
Structural vs. control parameters¶
The key design decision: ordinary parameters are not baked into the binary. They are passed to JiTCODE as control parameters — runtime arguments of the compiled module — so this costs zero recompiles:
lor = ts.Lorenz()
for rho in np.linspace(0, 50, 200):
lor.with_params(rho=rho).integrate(final_time=50.0) # one .so, 200 runs
The exception is parameters that change the symbolic structure of the
equations — integer loop bounds like Lorenz-96's N, which determine how
many expressions _equations returns. These are declared in
_structural_params and baked in at compile time:
class Lorenz96(ContinuousSystem):
params = {"f": 8.0, "N": 20}
_structural_params = frozenset({"N"}) # part of the cache key
Cache keys¶
| Family | Disk cache key | Recompiles when |
|---|---|---|
| ODE | tsdyn_<Class>_<dim> |
never (param changes are free) |
| ODE, structural | tsdyn_<Class>_<dim>_<md5-16 of structural params> |
a structural param changes |
| DDE | tsdyn_dde_<Class>_<md5-16 of all params> |
any param changes |
| ODE Lyapunov | the above + _lyap<n> |
a different n_exp is requested |
DDEs hash all parameters because delay values shape the history-buffer structure of the compiled module — they cannot be control parameters. This is why DDE parameter sweeps recompile per value.
When to wipe the cache¶
The cache key does not include the body of _equations. If you edit
the equations of an existing class (or upgrade JiTCODE/JiTCDDE across a
major version), stale binaries will keep loading:
Maps: Numba¶
DiscreteMap needs no C toolchain. @staticjit applies Numba's njit to
_step/_jacobian, and on the first iterate a compiled loop is built
with the current parameter values inlined, cached in-process per
(class, params-hash). A parameter change costs one re-JIT
(milliseconds); without Numba installed everything still runs, just
slower, through a pure-Python fallback.
Jacobians for free¶
Because the ODE right-hand side exists symbolically, the Jacobian does too — no hand-derivation, no finite differences:
lor = ts.Lorenz()
lor.jacobian_sym() # dim × dim SymEngine expressions, ∂f_i/∂y_j
lor.jacobian([1.0, 1.0, 1.0]) # ndarray (3, 3), evaluated at a state
jacobian_sym differentiates _equations with SymEngine; jacobian
evaluates a cached numeric form. The same symbolic differentiation is how
JiTCODE builds the variational equations for
Lyapunov runs. Hand-written _jacobian methods on system
classes are never used at runtime — the autogenerated form is the single
source of truth, and the test suite cross-checks any hand-written ones
against it.
A related helper, _rhs_numeric(), exposes a fast numeric f(u, t) used
by the Poincaré crossing refinement — the
compiled JiTCODE path remains the integrator of record.
The diffsol backend (experimental)¶
integrate(backend="diffsol") skips the C toolchain entirely: the same
symbolic equations are translated to a small solver DSL, JIT-compiled
through LLVM at runtime, and integrated by Rust solver kernels (Tsit45 for
non-stiff work; BDF / TR-BDF2 / ESDIRK34 for stiff problems). Initial
conditions and parameters are solve-time inputs, so one compiled module per
system serves every run.
Indicative numbers (Lorenz, 1000 time units, rtol=1e-9, 100 000 output
points, warm caches, one machine):
| backend | wall time | cold start |
|---|---|---|
jitcode (dopri5) |
1.72 s | seconds (C compiler) |
diffsol (tsit45) |
0.16 s | 0.07 s (LLVM JIT) |
Cross-validation against the JiTCODE path is part of the test suite
(tests/test_diffsol_backend.py). The backend is experimental: ODEs only,
and the right-hand side must use functions the DSL provides — unsupported
constructs raise a clear DiffSLTranslationError.
See also¶
- Install — the C toolchain requirement
- Conventions — what else is keyed on parameter hashes