Hello! Let's continue our journey into JAX's control flow primitives.
In our last session, we tackled conditional logic, learning how jax.lax.cond allows us to express if/else branching within JIT-compiled functions, overcoming the limitations of native Python conditionals. Today, we move from branching to iteration. We'll explore jax.lax.scan, the primary JAX primitive for implementing loops. You'll see that scan is far more than a simple replacement for a for loop; it is a powerful, parallelizable, and differentiable construct that is fundamental to implementing recurrent patterns like RNNs and stacking model layers efficiently.
This lesson will equip you to write complex, stateful computations that can be compiled and differentiated end-to-end, a crucial skill for implementing the advanced models you're aiming for.
1. The Trouble with Python for Loops
Just as a standard Python if statement can cause issues with jit, so can a standard for loop. The JAX tracer attempts to handle for loops by "unrolling" them. If a loop runs a fixed number of times, say 10, the tracer will inline the body of the loop 10 times into the jaxpr.
@jax.jit
def f(x):
# This loop will be unrolled by the JAX tracer
for _ in range(10):
x = jnp.sin(x)
return x
While this works, it's often inefficient. Unrolling leads to larger compiled code and longer compile times. More importantly, if the number of iterations depends on a traced value, the tracer cannot determine the loop bounds and will fail, much like the if statement we saw before.
The JAX solution is to provide a primitive that represents the entire loop as a single, compilable operation: jax.lax.scan. This allows XLA to compile the loop body once and apply it iteratively, and in many cases, to apply powerful parallelization strategies that are impossible with an unrolled Python loop.
2. The Anatomy of jax.lax.scan
At its core, jax.lax.scan is designed for computations that iterate over a sequence of data while carrying along a state. Its signature is jax.lax.scan(f, init, xs).
The key is the function f, which must have the signature: (carry, x) -> (carry, y). Let's break this down:
carry: This is the state of the loop. It is passed from one iteration to the next, allowing you to maintain and update information over time. Think of it as the hidden state in an RNN.x: This is a single "slice" from the input sequencexs. At each step,scanfeeds one element fromxsinto your function.- The function
freturns a tuple(new_carry, y).new_carry: The updated state that will be passed as thecarryto the next iteration.y: A per-iteration output.scancollects all theyvalues from each step and stacks them into a single output array.
The main jax.lax.scan call itself returns a tuple (final_carry, ys).
final_carry: The final value of thecarrystate after the last iteration.ys: The stacked array of all the per-iterationyoutputs.
This structure is best understood with a visual example. The following diagram illustrates the components of scan using a simple cumulative sum calculation.

Let's trace the cumsum example from the image:
init_sum = 0is our initialcarry.xs = jnp.array([1, 2, 3, ...])is our input sequence.- Iteration 1:
fis called withcarry=0andx=1. It calculatescumsum = 0 + 1 = 1. It returns(1, 1).- The new
carryfor the next iteration is1. - The first accumulated output
yis1.
- The new
- Iteration 2:
fis called withcarry=1andx=2. It calculatescumsum = 1 + 2 = 3. It returns(3, 3).- The new
carryis3. - The second accumulated output
yis3.
- The new
- ...and so on.
The final result of lax.scan will be (final_sum, [1, 3, 6, 11, ...]), where final_sum is the total sum of all elements, and the second element is the array of cumulative sums at each step.
3. Recurrent Patterns, Differentiation, and Vectorization
The true power of lax.scan becomes apparent when implementing recurrent models, a task central to your work in deep learning. The carry state maps perfectly to the concept of a hidden state in an RNN.
This is where scan's composability with grad and vmap shines. The following resource provides an excellent, compact explanation and a code example for implementing a simple RNN, and then vectorizing it across a batch.
Combine JAX Control Flow & Transformations
This text explains how lax.scan interacts with JAX's core transformations. It is crucial for understanding how to build and train recurrent models idiomatically in JAX.
Please read the sections titled Interaction with grad and vmap. Focus on these key points: grad and lax.scan: Absorb how jax.grad applied to a function containing lax.scan automatically performs the equivalent of Backpropagation Through Time (BPTT). Note the structure of the simple_rnn_step function. vmap and lax.scan: Understand how vmap is used to process a batch of sequences simultaneously, where each sequence in the batch gets its own independent scan.
Let's consolidate what you've just read.
Differentiating Through Recurrence
The simple_rnn_step function you saw is the core of the recurrence:new_hidden = jnp.tanh(W * x_t + U * prev_hidden + b)
When you wrap this step function in lax.scan, you are defining the entire forward pass of the RNN over a sequence.final_hidden, outputs = lax.scan(simple_rnn_step, initial_hidden, inputs)
The remarkable part is what happens when you apply jax.grad.grad_run_rnn = jax.grad(run_rnn, argnums=0)
JAX traces the computation through the unrolled steps of the scan and applies the chain rule automatically. You get the gradient of the final loss with respect to the initial hidden state (or any other parameter) without ever manually implementing BPTT. The scan primitive provides the necessary structure for JAX to handle this complex dependency tracking.
Vectorizing Recurrence for Batching
In practice, you never process a single sequence at a time. The combination vmap(scan(...)) is the standard JAX idiom for a batched RNN forward pass.
batched_run_rnn = jax.vmap(run_rnn, in_axes=(0, 0))
Here, in_axes=(0, 0) tells vmap that both the initial hidden state and the input sequence are batched along their first axis. JAX is smart enough to push the vmap transformation inside the scan, so that each of the batch_size sequences is processed by its own independent recurrent loop, all in parallel on the accelerator.
This composition—jit(grad(vmap(scan(...))))—is a powerful pattern for building and training sequence models in JAX.
4. scan vs. fori_loop
JAX provides another, simpler loop primitive: jax.lax.fori_loop(lower, upper, body_fun, init_val). This is closer to a standard for i in range(...) loop.
- It iterates a fixed number of times (
upper - lower). - The
body_funhas the signature(i, val) -> val, whereiis the loop index andvalis the loop-carried state. - It does not scan over an input array
xs. - It does not accumulate per-iteration outputs
ys. It only returns the final value of the state.
You should use fori_loop when you simply need to apply a function a fixed number of times, like in an optimization loop. Use scan when you need to iterate over a data sequence, accumulate outputs, or both—which is the common case for neural network layers (e.g., stacking Transformer blocks, which we'll see in a future module).
Conclusion
You now have the primary tool for iteration in your JAX arsenal. jax.lax.scan is not just a jit-compatible for loop; it is a high-performance, differentiable primitive for expressing sequential and recurrent computations. Mastering its (carry, x) -> (carry, y) pattern is key to implementing sophisticated models.
Key Takeaways:
- Python
forloops are often inefficient or incompatible withjitbecause they are unrolled. jax.lax.scanprovides a compilable, parallelizable primitive for iteration.- The core of
scanis a function with the signature(carry, x) -> (carry, y)that updates a state (carry) while iterating over an input sequence (xs). scancomposes seamlessly withgradto enable automatic differentiation through recurrence (BPTT).- The combination
vmap(scan(...))is the canonical pattern for processing batches of sequences. jax.lax.fori_loopis a simpler alternative for fixed-iteration loops that don't need to process an input sequence or collect intermediate results.
In our examples, the carry and xs have been simple JAX arrays. However, in real models, the state you carry can be much more complex—a dictionary of parameters, a tuple of hidden and cell states for an LSTM, etc. JAX handles these complex, nested data structures through a system called pytrees. In our next lesson, we will dive into what pytrees are and how to manipulate them, unlocking the ability to build and train full-scale neural networks.