Part I · Foundations Week 1 Published primitives.py test_primitives.py Notebook

JAX Primitives Through Dynamical Systems

Internalize five JAX primitives — jit, grad, vmap, lax.scan, lax.associative_scan — by implementing dynamical systems you already know: damped oscillator, coupled oscillator ring, EMA. The scan signature (carry, x) → (carry, y) is exactly the SSM state update.

On this page
  1. Motivation
  2. The damped oscillator as scan prototype
  3. Continuous system
  4. Euler discretization maps to lax.scan
  5. Energy analysis
  6. Gradient sensitivity
  7. fori_loop and jit: alternatives to scan
  8. Coupled oscillators and the Jacobian
  9. Ring topology with nearest-neighbor coupling
  10. Jacobian as transition matrix
  11. Batching with vmap
  12. Lyapunov exponents via QR decomposition
  13. The numerical catastrophe
  14. The QR algorithm
  15. Parallel prefix computation
  16. Warm-up: cumulative sum
  17. The EMA recurrence
  18. The associative operator
  19. Reflections
  20. What transfers directly from the PhD
  21. What requires adaptation
  22. Forward map

Motivation

The standard route into state space models begins with lax.scan tutorials that implement toy counters or fibonacci sequences. We take a different path: implement dynamical systems you have solved before, and let the JAX primitives emerge as the natural computational substrate.

This chapter builds four pieces of infrastructure that recur throughout the curriculum:

  1. A lax.scan-based time stepper (used in every SSM implementation),
  2. Automatic Jacobian computation via jax.jacfwd (stability analysis),
  3. QR-based Lyapunov exponents via lax.scan (training diagnostics, Week 18),
  4. The associative scan operator for parallel prefix computation (S5, Mamba).

The damped oscillator as scan prototype

Continuous system

Consider the damped harmonic oscillator:

x˙=v,v˙=ω2xγv,\begin{aligned} \dot{x} &= v, \\ \dot{v} &= -\omega^2 x - \gamma v, \end{aligned}

where ω\omega is the natural frequency and γ0\gamma \geq 0 is the damping coefficient. In state-space form with state h=[x,v]\statevec = [x, v]^\top:

h˙=[01ω2γ]Ah.\dot{\statevec} = \underbrace{\begin{bmatrix} 0 & 1 \\ -\omega^2 & -\gamma \end{bmatrix}}_{\statemat} \statevec. Continuous SSM h˙=Ah+Bu\dot{\statevec} = \statemat\statevec + \inputmat u with B=0\inputmat = 0 (no forcing). Full SSMs add forcing u(t)u(t) and output y=Ch+Duy = \outputmat\statevec + \feedmat u.

The eigenvalues of A\statemat are λ=γ/2±(γ/2)2ω2\lambda = -\gamma/2 \pm \sqrt{(\gamma/2)^2 - \omega^2}. For underdamped motion (γ<2ω\gamma < 2\omega), these are complex with negative real part — the system oscillates and decays. This eigenvalue structure is identical to what we will see in trained S4D layers (Week 5).

Euler discretization maps to lax.scan

Forward Euler with step size Δt\Delta t:

xn+1=xn+Δtvn,vn+1=vn+Δt(ω2xnγvn).\begin{aligned} x_{n+1} &= x_n + \Delta t \, v_n, \\ v_{n+1} &= v_n + \Delta t \, (-\omega^2 x_n - \gamma v_n). \end{aligned}

This is a recurrence of the form hn+1=f(hn)\statevec_{n+1} = f(\statevec_n) — exactly the pattern that lax.scan computes. The scan signature (carry, x) -> (carry, y) maps as:

Scan elementSSM roleOscillator
carrystate ht\statevec_t[xn,vn][x_n, v_n]
xinput utu_t(unused — autonomous system)
youtput yty_t[xn+1,vn+1][x_{n+1}, v_{n+1}]
Example 1.1 (First Euler step of the damped oscillator).

With ω=1\omega = 1, γ=0.1\gamma = 0.1, Δt=0.1\Delta t = 0.1, and initial (x0,v0)=(1,0)(x_0, v_0) = (1, 0), one Euler step gives

x1=1+0.10=1.00,v1=0+0.1(110.10)=0.10.x_1 = 1 + 0.1 \cdot 0 = 1.00, \quad v_1 = 0 + 0.1 \cdot (-1 \cdot 1 - 0.1 \cdot 0) = -0.10.

The position has not moved yet; velocity has picked up the restoring force. This is the pair that carry = (x, v) stores and hands to the next step — lax.scan automates exactly this handoff over the full trajectory.

Proposition 1.2 (Scan–loop equivalence).

The lax.scan implementation and the Python for-loop implementation of the damped oscillator compute the same recurrence and therefore agree up to floating-point roundoff under the current implementation.

Proof.

Both compute the same recurrence hn+1=f(hn)\statevec_{n+1} = f(\statevec_n) with the same initial condition. lax.scan is a compiled version of the sequential loop — it does not reorder or fuse operations across time steps. The only difference is compilation: lax.scan traces the step function once and executes the compiled XLA loop, while the Python loop re-interprets each step. In practice the trajectories match to machine precision because the same floating-point operations are applied in the same sequence.

Energy analysis

The energy E=12(v2+ω2x2)E = \frac{1}{2}(v^2 + \omega^2 x^2) satisfies E˙=γv20\dot{E} = -\gamma v^2 \leq 0. For γ>0\gamma > 0, energy decays monotonically in continuous time.

Energy decay of the damped harmonic oscillator (ω = 2, γ = 0.5, Δt = 0.01, 1000 steps). Forward Euler produces a correct decay trend with per-step fluctuations.
Energy decay of the damped harmonic oscillator (ω = 2, γ = 0.5, Δt = 0.01, 1000 steps). Forward Euler produces a correct decay trend with per-step fluctuations.

Gradient sensitivity

JAX’s jax.grad computes xfinal/γ\partial x_{\text{final}} / \partial \gamma — the sensitivity of the endpoint to the damping coefficient. This works through the entire lax.scan via reverse-mode automatic differentiation.

Remark.

The ability to differentiate through a scan is what makes SSMs trainable. When we add learnable parameters (A\statemat, B\inputmat, C\outputmat, Δ\stepsize), the same jax.grad machinery computes gradients for all of them simultaneously.

fori_loop and jit: alternatives to scan

lax.fori_loop compiles the same recurrence but returns only the final state — no trajectory stored. This is the inference pattern: when generating tokens, you need hL\statevec_L, not the intermediate h1,,hL1\statevec_1, \ldots, \statevec_{L-1}.

jax.jit wraps the entire scan into a single XLA computation, eliminating Python interpreter overhead between steps. The first call traces and compiles; subsequent calls dispatch directly to the compiled kernel.

Three-way timing: lax.scan and fori_loop run 10–100× faster than the Python loop. [Convergence]
Wall-clock time for 10,000-step oscillator simulation. Python loop includes interpreter overhead per step; lax.scan and fori_loop are XLA-compiled.
Wall-clock time for 10,000-step oscillator simulation. Python loop includes interpreter overhead per step; lax.scan and fori_loop are XLA-compiled.

Coupled oscillators and the Jacobian

Ring topology with nearest-neighbor coupling

NN oscillators on a ring, coupled via discrete Laplacian:

x¨i=ω2xi+κ(xi12xi+xi+1),i=1,,N,x0xN.\ddot{x}_i = -\omega^2 x_i + \kappa (x_{i-1} - 2x_i + x_{i+1}), \quad i = 1, \ldots, N, \quad x_0 \equiv x_N.

The state vector is h=[x1,,xN,v1,,vN]R2N\statevec = [x_1, \ldots, x_N, v_1, \ldots, v_N]^\top \in \R^{2N}.

Jacobian as transition matrix

The Jacobian of one Euler step with respect to the state, J=f/h\jacobian = \partial f / \partial \statevec, is the matrix of all first-order partial derivatives — it captures how a small perturbation in h\statevec propagates through one time step. This makes J\jacobian the discrete-time transition matrix: it describes how the state evolves locally.

For linear systems, J\jacobian is constant (independent of h\statevec) — the same transition applies everywhere. For nonlinear systems, J\jacobian varies along the trajectory. In the SSM context, this distinction maps to Linear Time-Invariant (LTI) models (S4, S4D) where parameters are fixed, versus selective models (Mamba) where parameters depend on the input. ”Selective” means the model can choose what to remember based on input content, not just position.

We compute J\jacobian via jax.jacfwd(one_step)(state0), which returns the full 2N×2N2N \times 2N Jacobian matrix.

Eigenvalue structure of the coupled oscillator Jacobian (N = 4, ω = 1, κ = 0.1, Δt = 0.01). The eigenvalues lie very close to the unit circle, but for this undamped ring system the spectral radius is slightly larger than 1 (≈ 1.00007), so forward Euler is weakly unstable at this step size.
Eigenvalue structure of the coupled oscillator Jacobian (N = 4, ω = 1, κ = 0.1, Δt = 0.01). The eigenvalues lie very close to the unit circle, but for this undamped ring system the spectral radius is slightly larger than 1 (≈ 1.00007), so forward Euler is weakly unstable at this step size.

Batching with vmap

jax.vmap vectorizes a function over a batch dimension without writing explicit loops. It transforms a function f:RnRmf: \R^n \to \R^m into f^:RB×nRB×m\hat{f}: \R^{B \times n} \to \R^{B \times m} that processes BB inputs simultaneously on GPU.

Two demonstrations:

  1. Ensemble simulation: vmap over initial conditions produces BB oscillator trajectories in parallel. This is the same pattern used to process a minibatch of sequences through an SSM layer.
  2. Batched Jacobians: vmap(jacfwd) computes the transition matrix at BB different states simultaneously. In Week 18, this enables Lyapunov analysis across minibatches of sequences.
Remark.

vmap composes with other JAX transforms. Applied to grad, it yields per-example gradients; wrapped in jit, it compiles the batched computation. This composability is why JAX fits SSM research so well: scan, vmap, and grad combine in the same pattern you would write mathematically.

Lyapunov exponents via QR decomposition

Executable companion: guides/notebooks/week01_notebook.ipynb Exercise 3 computes the full spectrum with the QR pattern described here.

The numerical catastrophe

Given a sequence of transition matrices J0,J1,,JL1\jacobian_0, \jacobian_1, \ldots, \jacobian_{L-1}, the cumulative effect is the product ML=t=0L1JtM_L = \prod_{t=0}^{L-1} \jacobian_t. Computing MLM_L directly is numerically catastrophic: if any eigenvalue has λ>1\abs{\lambda} > 1, components grow exponentially (λL\sim \abs{\lambda}^L), causing overflow. If λ<1\abs{\lambda} < 1, components decay to zero (underflow). With L=10,000L = 10{,}000 steps, even λ=1.001\abs{\lambda} = 1.001 grows to e1022,000e^{10} \approx 22{,}000.

The QR algorithm

Due to Benettin et al. (1980) . Oseledets’ theorem guarantees convergence: the time-averaged growth rates exist and are independent of the initial perturbation directions almost everywhere.

The solution is to periodically orthogonalize via QR decomposition. [Official] Benettin et al. Benettin et al. (1980) introduced the algorithm; see Anonymous (2025) for a recent application in the SSM context. At each step:

  1. Propagate the perturbation matrix: M=JtQt1M = \jacobian_t Q_{t-1}
  2. Decompose: QtRt=MQ_t R_t = M (QR factorization)
  3. Accumulate: log_sum+=logdiag(Rt)\text{log\_sum} \mathrel{+}= \log \abs{\diag(R_t)}

The Lyapunov exponents are the time-averaged growth rates:

λi=1LΔtt=1Llogdiag(Rt)i.\lyapexp_i = \frac{1}{L \cdot \Delta t} \sum_{t=1}^{L} \log \abs{\diag(R_t)_i}.

The computation fits naturally into lax.scan. Its carry stores the state, the orthogonal basis, and the running log sum:

(xt,Qt,log_sum).(x_t, Q_t, \text{log\_sum}).

This is the same carry-and-update pattern used by SSM recurrences.

Proposition 1.3 (Euler-discretized linear verification).

For the linear system h˙=Ah\dot{\statevec} = \statemat \statevec with A=diag(λ1,,λd)\statemat = \diag(\lambda_1, \ldots, \lambda_d), the Lyapunov exponents computed from the Euler-step Jacobian satisfy

λi=1Δtlog1+Δtλi.\lyapexp_i = \frac{1}{\Delta t} \log \abs{1 + \Delta t \cdot \lambda_i}.

In the continuous-time limit Δt0\Delta t \to 0, this converges to λi\lambda_i.

Proof.

For a diagonal system, the Jacobian of the Euler step is J=I+ΔtA=diag(1+Δtλi)\jacobian = I + \Delta t \cdot \statemat = \diag(1 + \Delta t \cdot \lambda_i). The QR decomposition is trivial (Q=IQ = I, R=JR = \jacobian), so

λi=1Δtlog1+Δtλi.\lyapexp_i = \frac{1}{\Delta t} \log \abs{1 + \Delta t \cdot \lambda_i}.

Expanding log(1+Δtλi)\log(1 + \Delta t \cdot \lambda_i) shows λi=λi+O(Δt)\lyapexp_i = \lambda_i + \bigO{\Delta t} as Δt0\Delta t \to 0. With Δt=0.01\Delta t = 0.01 and 10,000 steps, the error is 0.1\lesssim 0.1 in our experiments.

Proposition 1.4 (Discrete-time trace identity).

For the same Euler-discretized linear system,

iλi=1Δtlogdet(I+ΔtA).\sum_i \lyapexp_i = \frac{1}{\Delta t}\log\bigl|\det(I + \Delta t \cdot \statemat)\bigr|.

Consequently, iλitr(A)\sum_i \lyapexp_i \to \tr(\statemat) as Δt0\Delta t \to 0.

Proof.

Summing the previous formula gives

iλi=1Δtilog1+Δtλi=1Δtlogi1+Δtλi.\sum_i \lyapexp_i = \frac{1}{\Delta t}\sum_i \log \abs{1 + \Delta t \cdot \lambda_i} = \frac{1}{\Delta t}\log \prod_i \abs{1 + \Delta t \cdot \lambda_i}.

Since the eigenvalues of I+ΔtAI + \Delta t \cdot \statemat are 1+Δtλi1 + \Delta t \cdot \lambda_i, the product is det(I+ΔtA)\abs{\det(I + \Delta t \cdot \statemat)}. Taking Δt0\Delta t \to 0 recovers tr(A)\tr(\statemat) from the standard expansion logdet(I+ΔtA)=Δttr(A)+O(Δt2)\log\det(I + \Delta t \cdot \statemat) = \Delta t \cdot \tr(\statemat) + \bigO{\Delta t^2}.

Lyapunov exponent convergence for a 2D linear system with A = diag(-1, -2). The QR algorithm converges to the true exponents (-1, -2) within 0.1 tolerance after ~5,000 steps. Sum of exponents matches tr(A) = -3.
Lyapunov exponent convergence for a 2D linear system with A = diag(-1, -2). The QR algorithm converges to the true exponents (-1, -2) within 0.1 tolerance after ~5,000 steps. Sum of exponents matches tr(A) = -3.

Parallel prefix computation

Warm-up: cumulative sum

The simplest associative_scan example: cumulative sum with the operator f(a,b)=a+bf(a, b) = a + b. Addition is trivially associative, so:

def cumsum_associative(x):
    return jax.lax.associative_scan(jnp.add, x)

This produces identical results to jnp.cumsum but makes the parallel prefix structure explicit. The Blelloch algorithm Blelloch (1990) computes all LL prefix sums in O(logL)\bigO{\log \seqlen} parallel depth — a general technique for reducing an associative operator over a sequence in O(logL)\bigO{\log L} parallel steps using O(L)\bigO{L} total work. Same FLOPs as a sequential scan, but vastly more parallelism on GPU.

The EMA recurrence

The exponential moving average (EMA) is a running weighted average that gives more weight to recent values. With smoothing parameter α(0,1)\alpha \in (0, 1):

yt=αxt+(1α)yt1,y0=0.y_t = \alpha \, x_t + (1 - \alpha) \, y_{t-1}, \quad y_0 = 0.

When α\alpha is small (e.g., 0.1), the average is “slow” and remembers far into the past. When α\alpha is large (e.g., 0.9), it tracks the input closely. This is a first-order linear recurrence — the simplest possible SSM with scalar state, Aˉ=(1α)\discA = (1-\alpha), and Bˉ=α\discB = \alpha.

Sequential computation via lax.scan requires O(L)\bigO{\seqlen} sequential steps. Can we do better?

The associative operator

Define the pair representation (at,bt)=(1α,αxt)(a_t, b_t) = (1-\alpha, \, \alpha \, x_t), where ata_t is the decay factor and btb_t is the input contribution. We need a binary operator — a function that combines two such pairs into one, representing the combined effect of two consecutive time steps. The key requirement is associativity: (pq)r=p(qr)(p \scanop q) \scanop r = p \scanop (q \scanop r), which allows arbitrary grouping and therefore parallel evaluation. The operator:

(a1,b1)(a2,b2)=(a1a2,  a2b1+b2).(a_1, b_1) \scanop (a_2, b_2) = (a_1 \cdot a_2, \; a_2 \cdot b_1 + b_2).
Proposition 1.5 (Associativity of the scan operator).

The scan operator \scanop is associative.

Proof.

Compute both groupings:

((a1,b1)(a2,b2))(a3,b3)=(a1a2,a2b1+b2)(a3,b3)=(a1a2a3,  a3(a2b1+b2)+b3)=(a1a2a3,  a2a3b1+a3b2+b3).\begin{aligned} ((a_1, b_1) \scanop (a_2, b_2)) \scanop (a_3, b_3) &= (a_1 a_2, \, a_2 b_1 + b_2) \scanop (a_3, b_3) \\ &= (a_1 a_2 a_3, \; a_3(a_2 b_1 + b_2) + b_3) \\ &= (a_1 a_2 a_3, \; a_2 a_3 b_1 + a_3 b_2 + b_3). \end{aligned}(a1,b1)((a2,b2)(a3,b3))=(a1,b1)(a2a3,a3b2+b3)=(a1a2a3,  a2a3b1+a3b2+b3).\begin{aligned} (a_1, b_1) \scanop ((a_2, b_2) \scanop (a_3, b_3)) &= (a_1, b_1) \scanop (a_2 a_3, \, a_3 b_2 + b_3) \\ &= (a_1 a_2 a_3, \; a_2 a_3 b_1 + a_3 b_2 + b_3). \end{aligned}

Both expressions are identical.

Associativity is what enables jax.lax.associative_scan to compute the prefix sums in O(logL)\bigO{\log \seqlen} parallel depth using Blelloch’s algorithm.

EMA via sequential lax.scan and parallel associative scan on a random sequence (L = 100, α = 0.1). Maximum absolute difference: < 10⁻⁶.
EMA via sequential lax.scan and parallel associative scan on a random sequence (L = 100, α = 0.1). Maximum absolute difference: < 10⁻⁶.

Reflections

What transfers directly from the PhD

Three pieces of infrastructure from vortex dynamics research reappear with zero modification:

  1. QR-based Lyapunov computation. Same algorithm, same numerical considerations (transient removal, convergence diagnostics). The only adaptation: SSM dynamics are input-dependent, so the spectrum is a statistic over sequences rather than a property of the system.
  2. Discretization awareness. The intuition that “choice of integrator determines long-time accuracy” maps directly to “choice of SSM discretization determines training stability.” The stability region analysis from numerical methods applies unchanged.
  3. Eigenvalue monitoring. Tracking whether eigenvalues stay in the left half-plane (continuous) or inside the unit disk (discrete) is the same diagnostic in both domains.

What requires adaptation

  1. No natural period. Floquet theory assumes periodicity; SSM dynamics depend on the input sequence. The correct analogue is Lyapunov exponents (infinite-time average) rather than Floquet multipliers (one-period product).
  2. High dimension. The vortex quartet has 4–8 degrees of freedom. SSMs have N=16\statedim = 16256256 per layer, with L=1024L = 1024131072131072 time steps. The QR algorithm scales well (O(d2L)\bigO{d^2 L}), but visualization strategies change.
  3. Learned dynamics. In the dissertation, A\statemat is given by physics. In SSMs, A\statemat is learned — meaning the stability properties change during training. This opens the question of whether stability-aware training (constraining eigenvalues) improves optimization.

Forward map

  • Week 2: The training loop harness built in Flax will use lax.scan for the SSM layer and optax for optimization.
  • Weeks 4–6: S4, S4D, and S5 use the exact scan patterns from this chapter, with HiPPO-initialized A\statemat matrices instead of our hand-crafted oscillator systems.
  • Week 7: Mamba makes B\inputmat, C\outputmat, and Δ\stepsize input-dependent, breaking the convolutional view but preserving the parallel scan.
  • Week 18: The Lyapunov infrastructure from Exercise 3 is applied to trained SSMs to characterize gradient flow stability.
  • Week 20: The discretization analysis from the DynConnect boxes is formalized with stability region plots in Julia.