Chapter 1: Advanced Transformations and Control Flow
Course: "Advanced JAX: Performance, Optimization, and Scale", Chapter 1 (course home) Environment: JAX / jaxlib 0.12.0.1, Python 3.14, CPU only. Every conclusion in this tutorial was run and verified on this version.
How to read this: This tutorial follows the 8 pages of Chapter 1 of the course. The explanations and code were rewritten from scratch, with extra material added. Outdated or error-prone patterns go in # ❌ cells, followed right away by the # ✅ recommended version.
Learning goals¶
After this chapter you should be able to:
- Explain the tracing model behind
jit/grad/vmapand what they mean when composed; - Use
lax.scanfluently (includingreverse,unroll,length,xs=None) and read the error you get when carry types don't match; - Pick between
lax.cond/lax.switch/lax.select_n/jnp.wherefor a given situation, and know thatcondturns intoselectundervmap; - Write loops with a dynamic stopping condition using
lax.while_loop, know that it does not support reverse-mode differentiation, and know what to use instead; - Understand how control flow behaves, and what it costs, when combined with
jit/grad/vmap/jax.checkpoint; - Handle variable-length data with padding + mask and
jax.ops.segment_sum; - Understand closures and staging: how constants get embedded in the program, what
constvarsinmake_jaxprare, and why you shouldn't capture large arrays in closures; - Implement a GRU, an LSTM and an ODE integrator with
lax.scan, and cross-check them value by value against Python-loop versions.
Sections¶
| Section | Course page |
|---|---|
| 1.1 Core transformations review: jit, grad, vmap | review-core-transformations |
| 1.2 Mastering lax.scan for sequential operations | mastering-lax-scan |
| 1.3 Conditional execution with lax.cond | conditional-execution-lax-cond |
| 1.4 Looping with lax.while_loop | looping-lax-while-loop |
| 1.5 Combining control flow with transformations | combining-control-flow-transformations |
| 1.6 Advanced masking techniques | advanced-masking-techniques |
| 1.7 Understanding closures and JAX staging | closures-jax-staging |
| 1.8 Hands-on: implementing complex recurrent logic | practical-complex-recurrent-logic |
| Extra exercises / Quiz / Version notes / Summary / Cheat sheet | — |
How this connects to the Basics part¶
- Tracing, recompilation, static arguments: Basics 02 JIT compilation. Section 2.4 there already shows minimal examples of
fori_loop,lax.switchandwhile_loop; this chapter goes much deeper. - Reverse mode,
jvp/vjp, NaN gradients fromjnp.whereand the "double where" trick: Basics 03 Automatic differentiation with grad in_axes/out_axesand batching semantics: Basics 04 Automatic vectorization with vmap- Managing random keys, purely functional state: Basics 01 Introduction to JAX, Basics 06 State management
⚠️ A heads-up about this environment: the CPU backend of jaxlib 0.12.0.1 has a bug that reproduces reliably. If a jitted function captures a constant matrix through a closure, uses it as the left operand of a matmul (
W @ x) and then reduces, the result silently comes out wrong. The trigger threshold depends on the shape; 128×128 already triggers it. So in this tutorial weights and data are always passed in as function arguments, and key numbers are cross-checked against NumPy float64. For the symptoms, the trigger boundary and workarounds, see "Known issues in this environment" in Basics 00 Overview; for the root-cause analysis, see Advanced Chapter 2 §2.3.
# Shared imports and helper functions
import time, io, contextlib, functools
import numpy as np
import jax
import jax.numpy as jnp
from jax import lax
jax.config.update("jax_pprint_use_color", False) # print jaxprs without ANSI color codes, easier to read and to string-match
print("jax", jax.__version__, "| devices:", jax.devices())
N_ASSERT = 0 # counts the asserts that passed in this notebook
def check(cond, msg=""):
"""Wraps assert: raises on failure, counts on success."""
global N_ASSERT
assert cond, msg
N_ASSERT += 1
def bench(f, *args, n=20):
"""Timing helper: warm up first (including compilation), then take the median of n runs, in milliseconds."""
jax.block_until_ready(f(*args))
ts = []
for _ in range(n):
t0 = time.perf_counter()
jax.block_until_ready(f(*args))
ts.append(time.perf_counter() - t0)
return 1e3 * float(np.median(ts))
1.1 Core transformations review: jit, grad, vmap¶
jax.jit, jax.grad and jax.vmap are JAX's three most basic function transformations: they take a Python function and return a new function. The lax.scan, lax.cond and lax.while_loop covered in this chapter are all built on top of their tracing machinery, so let's review that first.
jax.jit: tracing + XLA compilation. The first time a function is called with a given input shape and dtype, JAX runs the Python code once with abstract tracers in place of real arrays, records the primitive operations it hits as an intermediate representation called a jaxpr, and hands that to XLA to compile into machine code for CPU, GPU or TPU. After that, as long as the shapes and dtypes match, the cached compiled program runs directly and the Python function body does not run again. That means:
- The function must be a pure function. Side effects such as
printor modifying globals happen only once, during tracing; - A change in input shape or dtype, or in the value of a static argument (
static_argnums), triggers retracing and recompilation.
⚠️ Note: It's easy to assume that changing a Python constant captured in a closure triggers recompilation. In fact, values captured by a closure or read as globals are not part of jit's cache key. When they change, jit does not recompile; it keeps using the old value from tracing time. This is the stale-value problem. Only creating a new function object (for example a new closure) causes retracing. Section 1.7 proves this with
asserts.
jax.grad: reverse-mode automatic differentiation. The function being differentiated must return a scalar. grad returns a new function that computes the gradient, by default with respect to the first argument; use argnums to pick other arguments. Internally it is built on VJPs (vector-Jacobian products), and nesting it gives higher-order derivatives. If the output isn't a scalar, reduce it to a scalar first, or use jax.jacrev / jax.jacfwd; you can also combine with vmap to compute Jacobians or Hessians.
jax.vmap: automatic vectorization. It turns a function that handles one example into a function that handles a batch of examples, without writing a Python loop. The signature is jax.vmap(fun, in_axes=0, out_axes=0). An integer in in_axes means "map along this axis"; None means this argument isn't mapped and the whole batch shares one copy (like broadcasting). out_axes says where the mapped axis goes in the output.
⚠️ Note: The "vectorization" in vmap is vectorization. Some materials mistranslate it as quantization, which is a completely different thing.
Composition. The three transformations nest freely, e.g. jit(grad(f)), vmap(jit(f)), jit(vmap(grad(f))). The nesting order determines the meaning. For example, vmap(grad(f)) gives per-example gradients, while grad(lambda p: mean(vmap(f)(p))) gives the gradient of the mean loss.
Course code 1: speeding up with jit¶
def slow_f(x):
return jnp.sin(x) * x + jnp.log(x + 1)
fast_f = jax.jit(slow_f)
key = jax.random.key(0) # recommended in 0.12: typed key
x = jax.random.uniform(key, (1000, 1000)) # values in [0,1), so log(x+1) is defined
y_eager = slow_f(x).block_until_ready()
y_jit = fast_f(x).block_until_ready() # first call: tracing + compilation
print("results match:", bool(jnp.allclose(y_eager, y_jit)))
check(jnp.allclose(y_eager, y_jit))
print(f"op-by-op (eager): {bench(slow_f, x):.3f} ms | jit: {bench(fast_f, x):.3f} ms")
Random keys: old style vs recommended¶
# ❌ Old style (outdated): jax.random.PRNGKey creates an old-style key as a "raw" uint32 array
try:
old_key = jax.random.PRNGKey(0)
print("PRNGKey still works → dtype =", old_key.dtype, ", shape =", old_key.shape)
except Exception as e:
print("error:", type(e).__name__, e)
# ✅ Recommended: jax.random.key creates a typed key (dtype key<fry>, shape ())
new_key = jax.random.key(0)
print("dtype =", new_key.dtype, ", shape =", new_key.shape)
# Both hold the same underlying bits; the new-style key just can't be mistaken for a plain integer array in arithmetic
check(np.array_equal(jax.random.key_data(new_key), jax.random.PRNGKey(0)))
🔍 Extra: choosing data for the jit example. ⚪ Optional: read this when you write your own eager-vs-jit numerical cross-checks.
If you generate the data with the normal distribution
random.normal, about 16% of the elements are below −1, andlog(x+1)gives NaN there.jnp.allclosedefaults toequal_nan=False, so it returns False as soon as either array contains a NaN. Even if you switch toequal_nan=Trueto set the NaNs aside, there's another problem: at some positions the two terms cancel and the function value is close to 0 (this data has one such spot, x≈3.58). Therertoldoes almost nothing, and the normal rounding difference between eager and jit can exceed the defaultatol=1e-8. So when cross-checking, keep the inputs inside the function's domain. That's why the code above usesuniform.
Course code 2: gradient of a linear model with grad¶
The model and loss are
$$\hat y = \mathbf w^\top \mathbf x + b,\qquad L(\mathbf w,b)=(\hat y-y)^2,$$
and the analytic gradients are $\partial L/\partial \mathbf w = 2(\hat y-y)\,\mathbf x$, $\partial L/\partial b = 2(\hat y-y)$.
def predict(params, x):
w, b = params
return jnp.dot(w, x) + b
def loss_fn(params, x, y_true):
y_pred = predict(params, x)
return jnp.mean((y_pred - y_true) ** 2)
w_init = jnp.array([1.5, -0.5]); b_init = 0.3
params_init = (w_init, b_init)
x_data = jnp.array([0.2, 0.8]); y_target = 2.5
grad_loss_fn = jax.grad(loss_fn, argnums=0)
gw, gb = grad_loss_fn(params_init, x_data, y_target)
print("gradient w.r.t. w:", gw)
print("gradient w.r.t. b:", gb)
# Cross-check against the analytic formula in NumPy float64
w64, x64 = np.array([1.5, -0.5]), np.array([0.2, 0.8])
r = w64 @ x64 + 0.3 - 2.5 # residual = 0.2 - 2.5 = -2.3
check(np.allclose(gw, 2 * r * x64) and np.isclose(gb, 2 * r))
check(np.allclose(gw, [-0.92, -3.68]) and np.isclose(gb, -4.6))
⚠️ Note: With this data, $\hat y = 1.5\times0.2-0.5\times0.8+0.3 = 0.2$ and the residual is $-2.3$, so $\partial L/\partial\mathbf w=[-0.92,\,-3.68]$ and $\partial L/\partial b=-4.6$. If you see elsewhere a w-gradient of
[-0.392 -1.568]and a b-gradient of-1.96, that's a miscalculation. The cell above already verifies the result against the analytic formula withassert.
Course code 3: batched affine transform with vmap¶
def simple_affine(w, b, x):
return jnp.dot(w, x) + b
w = jnp.array([[1., 2.], [3., 4.], [5., 6.]]) # (3, 2)
b = jnp.array([0.1, 0.2, 0.3]) # (3,)
x_batch = jnp.arange(8.).reshape(4, 2) # (4, 2)
batched_affine = jax.vmap(simple_affine, in_axes=(None, None, 0))
out = batched_affine(w, b, x_batch)
print("output shape:", out.shape)
check(out.shape == (4, 3))
check(np.allclose(out, np.asarray(x_batch) @ np.asarray(w).T + np.asarray(b)))
Course code 4: per-example gradients with jit(vmap(grad))¶
key = jax.random.key(0)
key, kx, ky = jax.random.split(key, 3) # split off all the subkeys you need at once; never reuse
x_batch_data = jax.random.normal(kx, (16, 2))
y_batch_target = jax.random.normal(ky, (16,))
batched_grad_loss_fn = jax.jit(jax.vmap(grad_loss_fn, in_axes=(None, 0, 0)))
gw_b, gb_b = batched_grad_loss_fn(params_init, x_batch_data, y_batch_target)
print("per-example gradient shape of w:", gw_b.shape, "| per-example gradient shape of b:", gb_b.shape)
check(gw_b.shape == (16, 2) and gb_b.shape == (16,))
🔍 Extra: composition order = mathematical meaning. ⚪ Optional: skip this if you've done the "per-example gradients" topic in Basics Chapter 4.
vmap(grad(loss))gives the gradient for each example (shape(B, ...)), whilegrad(lambda p: mean(vmap(loss)(p)))gives the gradient of the mean loss. Differentiation is linear, so the latter equals the former averaged over the batch dimension; the cell below verifies this. For the principle and uses (differential privacy, per-example clipping, etc.), see the per-example gradients topic in §4.6 of Basics 04 Automatic vectorization with vmap.
def mean_loss(params, xs, ys):
return jnp.mean(jax.vmap(loss_fn, in_axes=(None, 0, 0))(params, xs, ys))
gw_mean, gb_mean = jax.jit(jax.grad(mean_loss))(params_init, x_batch_data, y_batch_target)
print("gradient of the mean loss:", gw_mean, gb_mean)
check(np.allclose(gw_mean, gw_b.mean(0), atol=1e-6) and np.allclose(gb_mean, gb_b.mean(), atol=1e-6))
1.2 Mastering lax.scan for sequential operations¶
jit, grad and vmap are great at processing data "as a whole batch". But many algorithms have dependencies between time steps, such as RNNs, running sums, ODE integration and discounted returns in reinforcement learning: step $t$ needs the result of step $t-1$. jax.lax.scan is the primitive designed for exactly this kind of computation.
Why not just write a Python for loop inside jit? During tracing, a Python loop runs as-is. If the trip count is a Python integer (a static value), every iteration's operations get unrolled into the jaxpr: loop 1000 times and the graph contains 1000 copies of the loop body, which means slow compilation and high memory use. If the trip count is a traced value, Python's range() can't run at all and you get an error. lax.scan instead traces the loop body only once and compiles it into a single XLA loop primitive.
⚠️ Note: People often say "if you use a Python for loop inside jit and its length depends on a runtime value, JAX unrolls the loop". In fact it only unrolls when the length is a static Python integer. When the length depends on a runtime (traced) value,
range(n)immediately raisesTracerIntegerConversionError, so it never gets as far as unrolling. The cell below verifies both cases.
def py_loop_sum(x, n):
s = 0.0
for _ in range(n): # n must be a Python integer
s = s + x
return s
# Case 1: n is a traced value → error
try:
jax.jit(py_loop_sum)(1.0, 3)
raised = None
except Exception as e:
raised = type(e).__name__
print("n is a traced value:", raised)
check(raised == "TracerIntegerConversionError")
# Case 2: n is static → unrolled; the number of adds in the jaxpr grows linearly with n
n_eqns = {n: len(jax.make_jaxpr(py_loop_sum, static_argnums=1)(1.0, n).eqns) for n in (3, 30)}
print("number of jaxpr equations with static n:", n_eqns)
check(n_eqns[30] > n_eqns[3] * 5)
Core idea and signature¶
final_carry, ys = jax.lax.scan(f, init, xs=None, length=None, reverse=False, unroll=1)
f(carry, x) -> (new_carry, y): the loop body.carryis the state passed from step to step,xis the current slice ofxsalong axis 0, andyis this step's output;init: the initial carry. Its pytree structure, and the shape and dtype of every leaf, must exactly match thenew_carryreturned byf;xs: optional, can be any pytree; all leaves are iterated in lockstep along axis 0. When it'sNone, the loop body receivesxasNone;length: number of iterations. Usually inferred fromxs; required whenxs=None. If given, it must equal the size ofxsalong axis 0;reverse: whenTrue, scans from the last element backward; the outputsysare still stored at their original index positions;unroll: a performance tuning knob, the number of copies of the loop body placed in one XLA loop iteration. It can be a positive integer orTrue(fully unrolled);- Return values:
final_carryandys.ysstacks each step'syalong a new axis 0 and can be a pytree; ifyisNone,ysisNonetoo.
In plain Python, the semantics are:
def scan(f, init, xs, length=None, reverse=False):
if xs is None:
xs = [None] * length
carry, ys = init, []
for x in (reversed(xs) if reverse else xs):
carry, y = f(carry, x)
ys.append(y)
if reverse:
ys = ys[::-1]
return carry, np.stack(ys)
As a recurrence: $c_t = f_c(c_{t-1}, x_t)$, $y_t = f_y(c_{t-1}, x_t)$, $t=1,\dots,T$.
Course code 1: running sum¶
def cumulative_sum_step(carry, x):
new_carry = carry + x
return new_carry, new_carry # output the running sum "so far"
xs = jnp.array([1, 2, 3, 4, 5])
final, ys = lax.scan(cumulative_sum_step, 0, xs)
print("final carry:", final, "| sequence:", ys)
check(int(final) == 15 and ys.tolist() == [1, 3, 6, 10, 15])
Course code 2: a simple RNN with scan¶
$$h_t = \tanh\left(W_{hh}\,h_{t-1} + W_{xh}\,x_t + b_h\right)$$
The carry is the hidden state $h$ and xs is the input sequence. Each step returns the new hidden state as both the carry and the output. There are two easy-to-hit pitfalls with keys here, so let's compare first:
# ❌ Old style (wrong): W_hh and W_xh share the same w_key; x_key = random.split(key) gives a key array
from jax import random
key = random.PRNGKey(0)
key, w_key, b_key = random.split(key, 3)
W_hh = random.normal(w_key, (5, 5)) * 0.1
W_xh = random.normal(w_key, (5, 3)) * 0.1 # same key as W_hh!
# Run it and you'll see: the 15 elements of W_xh are exactly the first 15 elements of flattened W_hh; they aren't independent at all
same = bool(np.allclose(np.ravel(W_xh), np.ravel(W_hh)[:15]))
print("W_xh == first 15 elements of flattened W_hh:", same)
check(same)
try:
x_key = random.split(key) # a key array of shape (2, 2), not a single key
input_sequence = random.normal(x_key, (4, 3))
print("no error")
except Exception as e:
print("error:", type(e).__name__, "-", str(e)[:90])
# ✅ Recommended: jax.random.key + one independent subkey per use; parameters passed as function arguments (not via closure)
input_features, hidden_features, sequence_length = 3, 5, 4
k_hh, k_xh, k_b, k_x = jax.random.split(jax.random.key(0), 4)
rnn_params = {
"W_hh": jax.random.normal(k_hh, (hidden_features, hidden_features)) * 0.1,
"W_xh": jax.random.normal(k_xh, (hidden_features, input_features)) * 0.1,
"b_h": jax.random.normal(k_b, (hidden_features,)) * 0.1,
}
input_sequence = jax.random.normal(k_x, (sequence_length, input_features))
h_initial = jnp.zeros((hidden_features,))
def rnn_step(params, h_prev, x_t):
h_new = jnp.tanh(params["W_hh"] @ h_prev + params["W_xh"] @ x_t + params["b_h"])
return h_new, h_new # (new carry, output of this step)
@jax.jit
def run_rnn_scan(params, h0, xs):
# Inside jit, params is already a tracer, so capturing it in the lambda doesn't create embedded constants
return lax.scan(lambda h, x: rnn_step(params, h, x), h0, xs)
h_final, h_seq = run_rnn_scan(rnn_params, h_initial, input_sequence)
print("input:", input_sequence.shape, "initial:", h_initial.shape, "final:", h_final.shape, "sequence:", h_seq.shape)
check(h_final.shape == (5,) and h_seq.shape == (4, 5))
# Cross-check against a Python loop in NumPy float64
P = {k: np.asarray(v, np.float64) for k, v in rnn_params.items()}
h = np.zeros(5); ref = []
for x_t in np.asarray(input_sequence, np.float64):
h = np.tanh(P["W_hh"] @ h + P["W_xh"] @ x_t + P["b_h"]); ref.append(h)
check(np.allclose(h_seq, np.stack(ref), atol=1e-6) and np.allclose(h_final, h, atol=1e-6))
print("matches the Python loop ✓")
⚠️ Note: If you use the same
w_keyto generate bothW_hhandW_xh,W_xhcomes out exactly equal to the first 15 elements of flattenedW_hh, so the two matrices are fully correlated. Also,x_key = random.split(key)gives a key array, and passing it torandom.normalraisesValueError: normal accepts a single key.... The right way iskey, x_key = split(key), or onesplit(key, n)followed by unpacking.Another common pattern: define the parameters outside
rnn_stepand let JAX pick them up through the closure. That runs, but the weights get embedded as constants in the compiled program (see Section 1.7 for why). On this machine's jaxlib 0.12.0.1, capturing a large matrix in a closure can also silently give wrong results. So the recommended version passes the parameters in as arguments of the jitted function.
Benefits of scan over a Python loop (course key points):
- No unrolling: the loop body is traced once and compiled into an XLA While loop, so compile time and program size are basically independent of the number of steps;
- Memory efficiency: XLA can reuse buffers across iterations;
- Loop-body optimization: XLA can apply optimizations such as operator fusion inside the loop body.
Relationship with other transformations: it works best inside jit. grad can differentiate with respect to init, xs and parameters captured by the loop body, which amounts to BPTT (backpropagation through time). vmap can process a batch of sequences in parallel, e.g. jax.vmap(lambda seq: lax.scan(step, h0, seq))(batch). If each sequence also has its own initial state, add h0 to in_axes too.
Course code 3: xs=None and length¶
def generate_powers_of_2(carry, _): # the second argument receives None
return carry * 2, carry # double the carry, output the current value
final_val, powers = lax.scan(generate_powers_of_2, 1, xs=None, length=10)
print("final value:", final_val, "| sequence:", powers)
check(int(final_val) == 1024 and powers.tolist() == [2 ** i for i in range(10)])
# ❌ Wrong: assuming that with xs=None the loop body can be just f(carry)
try:
lax.scan(lambda c: (c * 2, c), 1, xs=None, length=3)
print("no error")
except Exception as e:
print("error:", type(e).__name__, "-", e)
# ✅ Recommended: the loop body always takes two arguments; name the unused one _
final_val, _ = lax.scan(lambda c, _: (c * 2, None), 1, xs=None, length=3) # when y is None, ys is None too
check(int(final_val) == 8)
try:
lax.scan(lambda c: (c * 2, c), 1, xs=None, length=3); ok = False
except TypeError:
ok = True
check(ok)
print("the f(carry) form raises TypeError; f(carry, _) works ✓")
⚠️ Note: Even when
xsisNone, the loop body can't be written as justf(carry). scan always calls the loop body with two arguments,f(carry, x); withxs=None,xis simplyNone. Writing only one parameter raisesTypeError: <lambda>() takes 1 positional argument but 2 were given.
🔍 Extra: details of reverse, unroll, length and pytrees¶
⚪ Optional: read this when you use
reverse,unroll, or pytree-shaped xs / ys.
reverse=True scans from back to front, but ys[t] still corresponds to xs[t]; the output is not flipped. It's equivalent to "flip xs, scan forward, then flip ys back". Typical uses are discounted returns in RL, $G_t = r_t + \gamma G_{t+1}$, the backward branch of a bidirectional RNN, and suffix sums.
f = lambda c, x: (c + x, c + x)
xs = jnp.arange(1, 6)
_, ys_rev = lax.scan(f, 0, xs, reverse=True)
_, ys_flip = lax.scan(f, 0, xs[::-1])
print("reverse=True:", ys_rev, "(suffix sums, ys[t] corresponds to xs[t])")
check(ys_rev.tolist() == [15, 14, 12, 9, 5])
check(np.array_equal(ys_rev, ys_flip[::-1]))
unroll doesn't change the result, only the structure of the compiled program. unroll=k means each XLA loop iteration runs k time steps; True means fully unrolled. When the loop body is tiny (scalar ops or small matrix ops), the fixed per-iteration loop overhead is a large fraction, and a moderate unroll (2, 4, 8) may be faster. When the loop body is large, unrolling only bloats compile time and code size. Always measure it yourself. Below is one set of numbers from this machine's CPU (for comparison only, no assertions). If you run it a few times, you'll see that in this example the different unroll settings take about the same time, basically within timing noise, and full unrolling is sometimes even slower. That tells you the bottleneck here isn't loop overhead:
def make_scan(unroll):
@jax.jit
def run(x0, xs):
return lax.scan(lambda c, x: (jnp.sin(c) * 0.9 + x, None), x0, xs, unroll=unroll)[0]
return run
xs_long = jnp.linspace(0., 1., 10_000)
results = {}
for u in (1, 4, 16, True):
fn = make_scan(u)
results[u] = fn(0.0, xs_long)
print(f"unroll={str(u):>4}: {bench(fn, 0.0, xs_long):7.3f} ms")
check(all(np.allclose(results[1], v, atol=1e-5) for v in results.values())) # same semantics
# The jaxpr records the unroll parameter
print([l.strip() for l in str(jax.make_jaxpr(make_scan(4))(0.0, xs_long)).splitlines() if "unroll" in l])
Compile time of scan vs a Python loop (same 500 steps, printed only, no assertions):
def py_loop(x):
for _ in range(500):
x = jnp.sin(x) + 0.1
return x
def scan_loop(x):
return lax.scan(lambda c, _: (jnp.sin(c) + 0.1, None), x, None, length=500)[0]
for fn in (py_loop, scan_loop):
t0 = time.perf_counter(); jax.jit(fn).lower(1.0).compile()
print(f"{fn.__name__:>9}: trace+compile {1e3 * (time.perf_counter() - t0):7.1f} ms, "
f"jaxpr equations = {len(jax.make_jaxpr(fn)(1.0).eqns)}")
check(np.allclose(jax.jit(py_loop)(1.0), jax.jit(scan_loop)(1.0), atol=1e-5))
A length that disagrees with the length of xs raises an error at trace time. Pytree-shaped xs and ys are very common: you can pack several sequences into a dict or tuple and iterate over them in lockstep.
try:
lax.scan(lambda c, x: (c + x, None), 0, jnp.arange(5), length=4); msg = None
except ValueError as e:
msg = str(e)
print("length mismatch:", msg)
check(msg is not None and "disagrees" in msg)
# pytree xs / ys: dict input, tuple output
xs_tree = {"a": jnp.arange(4.), "b": jnp.ones((4, 2))}
def body(c, x):
c = c + x["a"] * x["b"] # carry shape (2,)
return c, (c.sum(), x["a"] ** 2)
c_final, (sums, sq) = lax.scan(body, jnp.zeros(2), xs_tree)
print("final:", c_final, "| sums:", sums, "| sq:", sq)
check(np.allclose(c_final, [6., 6.]) and np.allclose(sq, [0, 1, 4, 9]))
🔍 Extra: reading carry-type mismatch errors¶
🟢 Must-read
scan requires init and the new_carry returned by the loop body to have exactly the same type: pytree structure, shape and dtype. This is the most common scan error, and JAX 0.12 tells you precisely which part doesn't match:
| What doesn't match | Key phrase in the error |
|---|---|
dtype (e.g. int32 → float32) | carry input and carry output must have equal types … so the dtypes do not match |
shape (e.g. () → (2,)) | … so the shapes do not match |
| pytree structure (e.g. array → tuple) | must have the same pytree structure … their Python types differ |
One easy trap: init=0 (a Python int, weakly typed) with floating-point xs does not raise; JAX promotes the weakly typed carry to float32. But if you write jnp.int32(0) (strongly typed), you get a dtype mismatch. So the safest habit is to spell out the dtype of init, e.g. jnp.zeros((), xs.dtype).
def scan_err(init, body, xs):
try:
lax.scan(body, init, xs); return "(no error)"
except TypeError as e:
return str(e).split("\n\n")[1] # grab the specific explanation in the middle
xs_f = jnp.arange(5.)
m_dtype = scan_err(jnp.int32(0), lambda c, x: (c + x, c), xs_f)
m_shape = scan_err(jnp.float32(0), lambda c, x: (jnp.stack([c, c]), c), xs_f)
m_tree = scan_err(jnp.float32(0), lambda c, x: ((c, c), c), xs_f)
for name, m in [("dtype", m_dtype), ("shape", m_shape), ("structure", m_tree)]:
print(f"[{name}] {m}")
check("dtypes do not match" in m_dtype and "shapes do not match" in m_shape and "Python types differ" in m_tree)
# A weakly typed init doesn't raise; the carry is promoted to float32
c_weak, _ = lax.scan(lambda c, x: (c + x, None), 0, xs_f)
print("init=0 (weakly typed) → final carry dtype:", c_weak.dtype)
check(c_weak.dtype == jnp.float32)
🔍 Extra:
lax.associative_scan. ⚪ Optional: read this when you work on long associative recurrences such as linear RNNs or SSMs.If the recurrence is associative, e.g. sums, products, or the linear recurrence $h_t = a_t h_{t-1} + b_t$, you can use
lax.associative_scanfor a parallel prefix scan. Its depth is $O(\log T)$, and on GPU/TPU it's usually much faster than a sequentialscanfor long sequences (this machine is CPU-only, so the speedup wasn't measured). Linear RNNs and SSMs (such as the parallel mode of S5 and Mamba) rely on exactly this. Ordinary nonlinear RNNs aren't associative and generally needscan.
x = jnp.arange(1., 9.)
check(np.allclose(lax.associative_scan(jnp.add, x), jnp.cumsum(x)))
# Linear recurrence h_t = a_t * h_{t-1} + b_t, binary combine operator (a1,b1)∘(a2,b2) = (a1*a2, a2*b1 + b2)
a = jnp.full(8, 0.9); bb = jnp.arange(8.)
comb = lambda l, r: (l[0] * r[0], r[0] * l[1] + r[1])
_, h_par = lax.associative_scan(comb, (a, bb))
_, h_seq = lax.scan(lambda h, ab: (ab[0] * h + ab[1],) * 2, 0.0, (a, bb))
print("parallel prefix:", h_par)
check(np.allclose(h_par, h_seq, atol=1e-5))
1.3 Conditional execution with lax.cond¶
During jit tracing, a Python if can only choose a branch based on concrete Python-level values. If the condition depends on the runtime value of a JAX array (a traced value), if has to convert the tracer to bool, and tracing fails. lax.cond instead puts the conditional logic into the computation graph:
jax.lax.cond(pred, true_fun, false_fun, *operands)
pred: a scalar boolean with shape(). It can be a traced value or a Python bool;true_fun/false_fun: the two branch functions, both taking*operands;*operands: inputs passed to the branches; can be several arrays or any pytree.
Key constraint: the outputs of the two branches must have exactly the same pytree structure, shape and dtype, because the output type of the compiled program has to be fixed at compile time.
Under the hood: during tracing both branches are traced, to check and fix the output type. The compiled program contains a real runtime branch, and when pred has no vmap batch dimension only the selected branch runs (for the batched case, see the Extra in Section 1.3).
Course code: Python if vs lax.cond¶
@jax.jit
def python_conditional_process(x):
if jnp.sum(x) > 0: # needs to convert the tracer to bool
return x * x
return x * x * x
for data in (jnp.array([1., 2., 3.]), jnp.array([-1., -2., -3.])):
try:
python_conditional_process(data); err = None
except Exception as e:
err = e
print(type(err).__name__, "| subclass of ConcretizationTypeError:",
isinstance(err, jax.errors.ConcretizationTypeError))
check(isinstance(err, jax.errors.TracerBoolConversionError))
⚠️ Note: Many materials say you get a
ConcretizationTypeErrorhere. What JAX 0.12 actually raises is the more specificTracerBoolConversionError. It's a subclass ofConcretizationTypeError, soexcept ConcretizationTypeErrorstill catches it.
def lax_conditional_process(x):
def true_branch(operand):
print(" [tracing] true_branch")
return operand * operand
def false_branch(operand):
print(" [tracing] false_branch")
return operand * operand * operand
pred = jnp.sum(x) > 0
return lax.cond(pred, true_branch, false_branch, x)
jit_cond = jax.jit(lax_conditional_process)
print("First call (positive):")
r_pos = jit_cond(jnp.array([1., 2., 3.]))
print(" result:", r_pos)
print("Second call (negative, same shape → reuses the compiled program, no tracing messages):")
r_neg = jit_cond(jnp.array([-1., -2., -3.]))
print(" result:", r_neg)
check(np.allclose(r_pos, [1, 4, 9]) and np.allclose(r_neg, [-1, -8, -27]))
Multiple operands are simply passed in one after another. Branch outputs with different types and a non-scalar pred both raise errors at trace time:
print("multiple operands:", lax.cond(True, lambda a, b: a + b, lambda a, b: a - b, 1., 2.))
def cond_err(*args):
try:
lax.cond(*args); return None
except TypeError as e:
return str(e).splitlines()[0]
m1 = cond_err(True, lambda x: x, lambda x: x.astype(jnp.int32), 1.)
m2 = cond_err(jnp.array([True, False]), lambda x: x, lambda x: -x, 1.)
print("branch dtypes differ:", m1)
print("pred is not a scalar:", m2)
check(m1.startswith("cond branches must have equal output types") and m2.startswith("Pred must be a scalar"))
Relationship with other transformations:
vmap: judging by the result, each element of the batch "picks its own branch" based on its own condition. But the implementation isn't what you'd intuitively expect; see the Extra below;grad: gradients flow only through the branch that actually ran.
⚠️ Note: It's easy to think that when pred depends on a differentiable variable, gradients flow through it too. In fact
predcomes from a comparison (>,>=, etc.), and comparisons output booleans, which are not differentiable, so gradients do not flow throughpred. The derivative with respect to a threshold parameter that decides the branch is always 0. The real issue is that the function may be discontinuous at the switch point, and the gradient can't see the change caused by "switching branches". If you need a derivative with respect to the threshold, use a smooth relaxation such assigmoid. Section 1.5 proves withassertthat the threshold gradient is 0.
lax.cond vs jnp.where:
lax.cond | jnp.where(c, x, y) | |
|---|---|---|
| Condition | a single scalar | elementwise boolean array, broadcastable |
| Compute | runs only one branch (when pred has no vmap batch dimension) | computes x and y in full, then picks elementwise |
| Good for | two fundamentally different, expensive computation paths | selecting values elementwise by a mask |
🔍 Extra: lax.switch and lax.select_n¶
🟢 Must-read
lax.switch(index, branches, *operands): the multi-branch version of cond;indexis an integer scalar. An out-of-range index is clamped to[0, len(branches)-1]instead of raising. This behavior can easily hide bugs, so add your own check when needed;lax.select_n(which, *cases): elementwise multi-way selection, the primitive underneathjnp.where. It doesn't broadcast; all cases must have the same shape. Whenwhichis bool, False picks case 0 and True picks case 1, which is the opposite order ofjnp.where(c, x, y). Whenwhichis an integer, it picks case numberwhich.
branches = [lambda x: x + 1, lambda x: x * 2, lambda x: -x]
out = [float(lax.switch(i, branches, 3.0)) for i in (0, 1, 2, 5, -3)]
print("switch index 0,1,2,5,-3 →", out)
check(out == [4.0, 6.0, -3.0, -3.0, 4.0]) # 5 is clamped to 2, -3 is clamped to 0
z, o, t = jnp.zeros(4), jnp.ones(4), 2 * jnp.ones(4)
s_int = lax.select_n(jnp.array([0, 1, 2, 1]), z, o, t)
s_bool = lax.select_n(jnp.array([True, False, True, False]), z, o)
print("select_n(int) :", s_int)
print("select_n(bool):", s_bool, "← True picks case 1 (ones here)")
check(np.allclose(s_int, [0, 1, 2, 1]) and np.allclose(s_bool, [1, 0, 1, 0]))
# Order compared with jnp.where: where(c, x, y) == select_n(c, y, x)
c = jnp.array([True, False, True, False])
check(np.allclose(jnp.where(c, o, z), lax.select_n(c, z, o)))
🔍 Extra: cond turns into select under vmap¶
🟢 Must-read
vmap treats the whole batch as one array and processes it with a single program, so there's no way for different elements of the batch to take different branches. This is a vmap transformation rule and has nothing to do with the backend; the jaxpr we see below on CPU shows exactly this. So if the vmap batch dimension reaches pred, JAX rewrites cond as: run both branches on the whole batch, then pick elementwise with select_n. At that point cond's advantage of "computing only one branch" is gone, and the cost is the sum of both branches. Be especially careful when the branches have side effects (such as jax.debug.print) or are expensive.
If pred is not batched, for example when only the operand has a batch dimension, cond is kept and still runs only one branch.
def g(x):
return lax.cond(x > 0, lambda v: v * 2, lambda v: -v, x)
jp_scalar = str(jax.make_jaxpr(g)(1.0))
jp_batched_pred = str(jax.make_jaxpr(jax.vmap(g))(jnp.arange(3.)))
print("—— jaxpr after vmap (pred is batched) ——\n", jp_batched_pred)
check("cond[" in jp_scalar)
check("cond[" not in jp_batched_pred and "select_n" in jp_batched_pred)
def g2(p, x):
return lax.cond(p > 0, lambda v: v * 2, lambda v: -v, x)
jp_unbatched_pred = str(jax.make_jaxpr(jax.vmap(g2, in_axes=(None, 0)))(1.0, jnp.arange(3.)))
check("cond[" in jp_unbatched_pred)
print("when pred has no batch dimension, the cond primitive is kept ✓")
🔍 Extra:
jnp.whereand NaN in gradients. ⚪ Optional: skip this if you already know the "double where" trick from Basics Chapter 3.
jnp.wherecomputes both sides, so a NaN/Inf on the side that isn't selected still pollutes the gradient; for example, the gradient ofwhere(x>0, sqrt(x), 0)atx=0is NaN. The fix is the "double where": first replace invalid inputs with safe values, then compute. For the principle and a demo, see §3.8 of Basics 03 Automatic differentiation with grad; for a more advanced discussion, see topic 2 in Basics 07 Practical topics.
1.4 Looping with lax.while_loop¶
The number of steps of lax.scan is fixed at compile time. Some iterations need to decide when to stop based on runtime intermediate results, such as Newton's method converging, binary search, or the Collatz sequence. That's what lax.while_loop is for:
final_val = jax.lax.while_loop(cond_fun, body_fun, init_val)
cond_fun(carry) -> scalar bool: keep looping while it's True;body_fun(carry) -> new_carry: the returned state must have exactly the same structure, shape and dtype as the input;init_val: the initial state; can be a scalar, an array or a pytree.
The execution order is cond(init) → body(init) → cond(next) → … until cond returns False, and then it returns the state that made cond False. It's equivalent to this Python code:
val = init_val
while cond_fun(val):
val = body_fun(val)
return val
The state is functional: body doesn't modify its input; it returns a new state. cond_fun and body_fun are each traced only once. The carry's shape and dtype must be static, but its values can be dynamic, and the whole loop compiles into a single XLA While op.
Compared with scan: scan has a fixed number of steps and can collect each step's output ys; while_loop has a variable number of steps and returns only the final state.
Caveats: cond_fun must eventually return False, or the compiled program loops forever and the process hangs. And Ctrl-C usually can't interrupt it either, because control is inside the compiled XLA loop and never returns to Python (we tried sending SIGINT to the process, and it was still running 10 seconds later). The only way out is to restart the kernel or kill the process. In real code, add an iteration counter to the carry and set a maximum number of steps as a safety net.
Course code: the smallest power of 2 that is at least n¶
def find_power_of_two(n):
def cond_fun(current_power):
return current_power < n
def body_fun(current_power):
return current_power * 2
init_val = 1
return lax.while_loop(cond_fun, body_fun, init_val)
target_number = 100
r1 = find_power_of_two(target_number)
r2 = jax.jit(find_power_of_two)(target_number)
print("smallest power of 2 >= 100:", r1, "| JIT result:", r2)
check(int(r1) == 128 and int(r2) == 128)
# Loop trace: cond(1)…cond(64) are true, body(64)=128, cond(128) is false → returns 128
while_loop can't do reverse-mode differentiation¶
⚠️ Note: You'll often see claims like "
while_loopsupportsjax.grad; backprop unrolls the iterations that ran", but that's not true. Reverse mode (grad/vjp) onlax.while_loopraises an error right away:ValueError: Reverse-mode differentiation does not work for lax.while_loop or lax.fori_loop with dynamic start/stop values. The reason is that backprop needs to save each step's intermediate values (residuals), and since the number of while_loop steps isn't known at compile time, XLA can't preallocate buffers. Forward mode (jvp/jacfwd) doesn't need to keep history and works fine.
def power_loop(x):
# Compute x**5: counter i goes from 0 to 5
return lax.while_loop(lambda c: c[0] < 5, lambda c: (c[0] + 1, c[1] * x), (0, 1.0))[1]
val, tangent = jax.jvp(power_loop, (2.0,), (1.0,))
print("x^5 at 2 =", val, "| forward-mode derivative 5x^4 =", tangent)
check(np.isclose(val, 32.) and np.isclose(tangent, 80.))
try:
jax.grad(power_loop)(2.0); msg = None
except ValueError as e:
msg = str(e)
print("grad(while_loop):", msg)
check(msg is not None and "Reverse-mode differentiation does not work" in msg)
# jacfwd (Jacobian built with forward mode) works
check(np.isclose(jax.jacfwd(power_loop)(2.0), 80.))
What to use when the number of steps is fixed?
| Situation | Recommended | Reverse-mode differentiable? |
|---|---|---|
| The number of steps is a Python constant | lax.fori_loop(0, N, ...) or lax.scan | ✅ When fori_loop's bounds are static, it is rewritten as a scan |
| The number of steps is a traced value, but has an upper bound | run lax.scan up to the bound and freeze finished steps with a mask; see Extra exercise 2 | ✅ |
| The number of steps is determined entirely by a convergence condition | lax.while_loop | ❌ forward mode only; or define the gradient yourself with the implicit function theorem / custom_vjp |
You can see fori_loop's "dual identity" directly in the jaxpr:
def fl_static(x):
return lax.fori_loop(0, 5, lambda i, c: c * x, 1.0)
def fl_dynamic(x, n):
return lax.fori_loop(0, n, lambda i, c: c * x, 1.0)
jp_s = str(jax.make_jaxpr(fl_static)(2.0))
jp_d = str(jax.make_jaxpr(fl_dynamic)(2.0, 5))
print("static bounds → primitive:", "scan" if "scan[" in jp_s else "while")
print("dynamic bounds → primitive:", "scan" if "scan[" in jp_d else "while")
check("scan[" in jp_s and "while[" in jp_d)
check(np.isclose(jax.grad(fl_static)(2.0), 80.)) # static → reverse-mode works
try:
jax.jit(jax.grad(fl_dynamic))(2.0, 5); ok = False # under jit, n is a traced value → dynamic
except ValueError:
ok = True
check(ok)
print("fori_loop: static bounds can grad, traced bounds can't ✓")
🔍 Extra: one detail. ⚪ Optional: useful when you write library code and need fori_loop to be reverse-mode differentiable.
Calling
jax.grad(fl_dynamic)(2.0, 5)outsidejitdoes succeed, because theren=5is a concrete Python integer andfori_looptreats it as static. The same code fails as soon as you addjit. So in library code, don't rely on behavior that depends on a value "happening to be concrete": either declare the number of steps instatic_argnums, or just usescan.
🔍 Extra: under vmap, while_loop runs until "the slowest element" finishes¶
⚪ Optional: read this when you run a while_loop over a batch and the elements need very different numbers of iterations.
If the vmap batch dimension reaches cond_fun, different elements may need different numbers of iterations. JAX's approach: as long as any element still needs to continue, the whole batch keeps looping. In the lowered HLO, this shows up as a reduce or over the predicate. Finished elements have their state frozen with select; they still take part in the computation, but the results are thrown away. So when iteration counts vary a lot within a batch, a lot of compute is wasted, and you may want to bucket elements by their expected number of steps.
def collatz_steps(n):
def body(c):
v, k = c
return jnp.where(v % 2 == 0, v // 2, 3 * v + 1), k + 1
return lax.while_loop(lambda c: c[0] > 1, body, (n, 0))[1]
ns = jnp.array([1, 2, 3, 6, 7, 27])
steps = jax.vmap(collatz_steps)(ns)
print("Collatz steps per element:", steps, "→ the whole batch loops", int(steps.max()), "times")
def collatz_py(n):
k = 0
while n > 1:
n = n // 2 if n % 2 == 0 else 3 * n + 1; k += 1
return k
check(steps.tolist() == [collatz_py(int(n)) for n in ns])
hlo = jax.jit(jax.vmap(collatz_steps)).lower(ns).as_text()
check("stablehlo.or" in hlo and "stablehlo.select" in hlo)
print("The HLO shows reduce-or (\"some element isn't done\") and select (freezing finished elements) ✓")
1.5 Combining control flow with transformations¶
What makes JAX powerful is that the lax control-flow primitives compose freely with jit / grad / vmap. This section goes through the behavior and cost of each combination.
With jit¶
jit traces the scan / while loop body, and each branch of a cond, into jaxprs, which XLA then compiles into HLO. The loop body is compiled only once; both branches of a cond are compiled, and at runtime one of them runs based on the predicate.
def conditional_computation(x, threshold=5.0):
total = jnp.sum(x)
return lax.cond(total >= threshold, lambda o: o * 2.0, lambda o: o / 2.0, x)
jit_cc = jax.jit(conditional_computation)
r1 = jit_cc(jnp.array([1., 2., 3.])) # sum is 6 ≥ 5 → ×2
r2 = jit_cc(jnp.array([1., 1., 1.])) # sum is 3 < 5 → ÷2
print("Result 1:", r1, "| Result 2:", r2)
check(np.allclose(r1, [2, 4, 6]) and np.allclose(r2, [0.5, 0.5, 0.5]))
With grad¶
scan: backprop applies the chain rule in reverse time order, i.e. BPTT. The intermediate values of every forward step must be saved for the backward pass, so memory grows linearly with sequence length. For long sequences you can ease this with gradient checkpointing (jax.checkpoint); see the Extra later in this section.
Course example: a scalar RNN, $h_t = \tanh(W x_t + U h_{t-1} + b)$ with $W=0.5,\ U=0.8,\ b=0.1$, loss $L=\sum_t h_t$, differentiated with respect to the initial hidden state $h_0$. Deriving by hand gives:
$$\frac{\partial L}{\partial h_0}=\sum_{t=1}^{T}\prod_{k=1}^{t} U\,(1-h_k^2).$$
def simple_rnn_step(carry, x_t):
W, U, b = 0.5, 0.8, 0.1 # Python scalar constants: embedded as literals, no problem
new_hidden = jnp.tanh(W * x_t + U * carry + b)
return new_hidden, new_hidden
def run_rnn(initial_hidden, inputs):
_, outputs = lax.scan(simple_rnn_step, initial_hidden, inputs)
return jnp.sum(outputs)
grad_run_rnn = jax.grad(run_rnn, argnums=0)
input_sequence_s = jnp.array([1.0, -0.5, 2.0])
g_h0 = grad_run_rnn(0.0, input_sequence_s)
print("dL/dh0 =", g_h0)
# NumPy float64 cross-check: hand-derived formula + central difference
xs64 = np.array([1.0, -0.5, 2.0])
def rnn_np(h0):
h, hs = h0, []
for x_t in xs64:
h = np.tanh(0.5 * x_t + 0.8 * h + 0.1); hs.append(h)
return np.array(hs)
hs = rnn_np(0.0)
analytic = sum(np.prod(0.8 * (1 - hs[:t + 1] ** 2)) for t in range(3))
fd = (rnn_np(1e-6).sum() - rnn_np(-1e-6).sum()) / 2e-6
print(f"hand-derived formula: {analytic:.7f} | central difference: {fd:.7f}")
check(np.isclose(g_h0, analytic, atol=1e-5) and np.isclose(analytic, fd, atol=1e-6))
check(abs(float(g_h0) - 0.313) > 0.5) # clearly different from the commonly seen wrong value 0.313
⚠️ Note: Here
dL/dh0≈ 1.0749918, in full agreement with the hand-derived formula and the float64 central difference. Some materials give about0.313..., which is wrong.
cond: gradients pass only through the branch that actually ran. In the course example, when sum(x)>0 the loss is $\sum p_i x_i$ and the gradient is $x$; otherwise the loss is $\sum p_i/(x_i+10^{-5})$ and the gradient is $1/(x+10^{-5})$.
def conditional_loss(params, x):
pred = jnp.sum(x) > 0
def loss1(p): return jnp.sum(p * x)
def loss2(p): return jnp.sum(p / (x + 1e-5))
return lax.cond(pred, loss1, loss2, params)
params = jnp.array([0.5, -0.5])
g_pos = jax.grad(conditional_loss)(params, jnp.array([1.0, 1.0]))
g_neg = jax.grad(conditional_loss)(params, jnp.array([-1.0, -1.0]))
print("positive-case gradient:", g_pos, "| negative-case gradient:", g_neg, "| theory 1/(-1+1e-5) =", 1 / (-1 + 1e-5))
check(np.allclose(g_pos, [1., 1.]))
check(np.allclose(g_neg, 1 / (-1 + 1e-5), rtol=1e-6) and float(g_neg[0]) < -1.0)
⚠️ Note: The negative-case gradient is $1/(-1+10^{-5}) = -1/0.99999 \approx$
-1.00001, whose absolute value is greater than 1. Don't write it as[-0.99999 -0.99999]by mistake.
while_loop: no reverse mode; see Section 1.4.
With vmap¶
scan: each sequence in the batch is scanned independently. vmap adds a batch dimension to both carry and xs; there's still just one loop, and each step processes the whole batch.
batched_run_rnn = jax.vmap(run_rnn, in_axes=(0, 0))
batch_hidden_init = jnp.zeros(4)
batch_input_sequence = jnp.arange(12, dtype=jnp.float32).reshape((4, 3))
out_b = batched_run_rnn(batch_hidden_init, batch_input_sequence)
print("batched output shape:", out_b.shape)
check(out_b.shape == (4,))
check(np.allclose(out_b, [run_rnn(h, s) for h, s in zip(batch_hidden_init, batch_input_sequence)]))
cond: when the predicate depends on the mapped axis, JAX computes both branches over the whole batch and then picks results by each element's predicate (that's the select_n from the Extra in Section 1.3). The cost is the sum of both branches.
vmapped_conditional = jax.vmap(conditional_computation, in_axes=(0, None))
batch = jnp.array([[1., 2., 3.], [1., 1., 1.], [10., 1., 1.]])
out_v = vmapped_conditional(batch, 5.0)
print(out_v)
check(np.allclose(out_v, [[2, 4, 6], [0.5, 0.5, 0.5], [20, 2, 2]]))
while_loop: when different elements need different numbers of iterations, the whole batch keeps running until the largest iteration count in the batch, with finished elements frozen by a mask; see the Collatz example in Section 1.4.
Combining several transformations¶
You can write multi-level combinations such as jax.grad(jax.vmap(...)) or jax.jit(jax.grad(conditional_loss)). The example below has two things to watch out for, though:
# ❌ Old style (wrong): grad wraps vmap directly, but vmap's output has shape (2, 3), not a scalar
def threshold_from_params(params, x):
threshold = jnp.mean(params)
return conditional_computation(x, threshold)
batch_data = jnp.array([[1., 2., 3.], [1., 1., 1.]])
params_t = jnp.array([4.0, 6.0]) # mean is 5.0
grad_vmap_fn = jax.jit(jax.grad(jax.vmap(threshold_from_params, in_axes=(None, 0)), argnums=0))
try:
print(grad_vmap_fn(params_t, batch_data))
except Exception as e:
print("error:", type(e).__name__, "-", str(e)[:110])
# ✅ Recommended: reduce vmap's output to a scalar first, then take grad
def total_output(params, xs):
return jnp.sum(jax.vmap(threshold_from_params, in_axes=(None, 0))(params, xs))
g_thr = jax.jit(jax.grad(total_output))(params_t, batch_data)
print("gradient w.r.t. the threshold parameters:", g_thr)
check(np.allclose(g_thr, 0.0)) # the threshold enters pred only through a comparison → gradient is always 0
try:
jax.grad(jax.vmap(threshold_from_params, in_axes=(None, 0)))(params_t, batch_data); ok = False
except TypeError as e:
ok = "scalar-output" in str(e)
check(ok)
⚠️ Note: Writing
jax.grad(jax.vmap(threshold_from_params, ...))directly doesn't run; it raisesTypeError: Gradient only defined for scalar-output functions. Output had shape: (2, 3). If you sum first and then take grad, you get a result, but the gradient with respect toparamsis always 0. That's because the threshold affects branch selection only through the>=comparison, and comparisons aren't differentiable. This confirms the conclusion from Section 1.3.
🔍 Extra: jax.checkpoint with scan: trading compute for memory¶
⚪ Optional: read this when backprop over long sequences runs out of memory; the full treatment is in Advanced Chapter 6 §6.6.
When computing reverse-mode gradients through scan, JAX by default saves all intermediate values (residuals) of every step's loop body. If you write lax.scan(jax.checkpoint(step), init, xs), the forward pass saves only each step's carry, and the backward pass recomputes that step's intermediates. The gradients are the same; the price is roughly one extra forward pass (the LSTM in hands-on A of Section 1.8 verifies with assert that gradients match with and without checkpointing). For inspecting residuals with print_saved_residuals, policy options, measured memory comparisons, and the standard "scan over stacked layers + checkpointing" structure, see Advanced Chapter 6 §6.6.
1.6 Advanced masking techniques¶
XLA requires static shapes, but real data often comes in different lengths: sentences of different lengths, episodes that end early, graphs of different sizes. The standard approach is to pad to a common length and pair it with a boolean mask that marks which positions are valid. Mask operations are elementwise with no branches, so they compile efficiently under jit, and vmap and grad handle them directly.
Why do we need masks?
- When computing means or attention weights, padding positions must be excluded, or the result gets "diluted" by the padding values;
- In
scan, once a shorter sequence has ended, its hidden state and outputs shouldn't be updated anymore.
The core API is jnp.where(condition, x, y): take x where condition is True, otherwise y; all three can broadcast.
Course code: padding and sequence masks¶
Pad [1,2,3], [4,5], [6,7,8,9] with 0 to length 4:
| Sequence | Padded | Mask |
|---|---|---|
[1,2,3] | [1,2,3,0] | T T T F |
[4,5] | [4,5,0,0] | T T F F |
[6,7,8,9] | [6,7,8,9] | T T T T |
The masked mean is $\bar x_i = \dfrac{\sum_j m_{ij}x_{ij}}{\max(1,\sum_j m_{ij})}$. The $\max(1,\cdot)$ in the denominator prevents division by zero for completely empty rows.
padded_sequences = jnp.array([[1, 2, 3, 0], [4, 5, 0, 0], [6, 7, 8, 9]], dtype=jnp.float32)
mask = padded_sequences != 0
print(mask)
# Example 1: masked sum
masked_sum = jnp.sum(jnp.where(mask, padded_sequences, 0), axis=-1)
# Example 2: masked mean
num_valid_elements = jnp.sum(mask, axis=-1)
masked_avg = masked_sum / jnp.maximum(num_valid_elements, 1)
print("sum:", masked_sum, "| mean:", masked_avg)
check(np.allclose(masked_sum, [6, 9, 30]) and np.allclose(masked_avg, [2.0, 4.5, 7.5]))
🔍 Extra: don't build masks from "value != 0". 🟢 Must-read
Building the mask with
padded != 0above is just for the demo. In real data, 0 can be a legitimate value, such as token id 0 or a feature that happens to be 0. In real projects, build the mask from a lengths array:mask = jnp.arange(T)[None, :] < lengths[:, None].
lengths = jnp.array([3, 2, 4])
mask_from_len = jnp.arange(4)[None, :] < lengths[:, None]
check(np.array_equal(mask_from_len, mask))
print(mask_from_len)
Course code: masking attention scores¶
Set the scores at padding positions to a very large negative number, and after softmax their weights will be close to 0.
# ❌ Old style (wrong): jnp.randn is not a JAX API (JAX random numbers require an explicit key)
try:
scores = jnp.randn(3, 4)
except AttributeError as e:
print("error:", type(e).__name__, "-", e)
# ✅ Recommended: jax.random.normal + an explicit key
scores = jax.random.normal(jax.random.key(0), (3, 4))
masked_scores = jnp.where(mask, scores, -1e9)
attention_weights = jax.nn.softmax(masked_scores, axis=-1)
print(attention_weights)
check(np.allclose(np.asarray(attention_weights)[~np.asarray(mask)], 0.0))
check(np.allclose(attention_weights.sum(-1), 1.0, atol=1e-6))
# In 0.12, softmax has a built-in where= argument; same effect, and masked positions output exactly 0
aw2 = jax.nn.softmax(scores, axis=-1, where=mask)
check(np.allclose(aw2, attention_weights, atol=1e-6))
🔍 Extra: when "the whole row is masked",
-1e9and-infbehave differently. 🟢 Must-readWith a causal mask on top of padding, or an empty sequence in the batch, an entire row can be False:
- Fill with
-1e9: softmax gives a uniform distribution[0.25, 0.25, ...]. No error, but these weights are meaningless, and you usually need to multiply by the mask again afterward;- Fill with
-inf: mathematically this is 0/0.jax.nn.softmaxfirst subtracts the row maximum, giving $-\infty-(-\infty)$, so the result is also NaN, and the NaN keeps propagating into the gradients;- Use
jax.nn.softmax(..., where=mask): fully masked rows output all zeros, with no NaN (verified in the cell below).So in practice people use a finite large negative number, such as
jnp.finfo(dtype).minor-1e9. At low precision, check that the value itself doesn't overflow: for example,-1e9cast to float16 becomes-inf(float16 tops out around 65504), while bfloat16 has the same exponent range as float32 and doesn't overflow.
mask_empty = mask.at[1].set(False) # row 1 fully masked
row_big = jax.nn.softmax(jnp.where(mask_empty, scores, -1e9), axis=-1)[1]
row_inf = jax.nn.softmax(jnp.where(mask_empty, scores, -jnp.inf), axis=-1)[1]
print("-1e9 →", row_big, "| -inf →", row_inf)
check(np.allclose(row_big, 0.25) and np.isnan(row_inf).all())
row_where = jax.nn.softmax(scores, axis=-1, where=mask_empty)[1]
print("where= →", row_where)
check(np.allclose(row_where, 0.0)) # with where=, fully masked rows output all zeros
Course code: arithmetic masks¶
Convert the mask to floats and multiply it with the data, and you get masked sums and means as well. The course specifically warns that multiplying by 0 isn't always safe. If a masked position holds inf or NaN, $0\times\infty=\text{NaN}$ and the result is polluted anyway. In log space, log(0) causes trouble too. So prefer jnp.where.
mask_float = mask.astype(padded_sequences.dtype)
mult_sum = jnp.sum(padded_sequences * mask_float, axis=-1)
mult_avg = mult_sum / jnp.maximum(jnp.sum(mask_float, axis=-1), 1e-9)
print("multiplicative sum:", mult_sum, "| multiplicative mean:", mult_avg)
check(np.allclose(mult_sum, [6, 9, 30]) and np.allclose(mult_avg, [2, 4.5, 7.5]))
# The trap with multiplicative masks: when a masked position is inf
v = jnp.array([1.0, jnp.inf]); m = jnp.array([1.0, 0.0])
print("v*m =", v * m, "| where(m>0, v, 0) =", jnp.where(m > 0, v, 0.0))
check(np.isnan((v * m)[1]) and float(jnp.where(m > 0, v, 0.0)[1]) == 0.0)
Masks with control flow and transformations¶
jit: mask operations are ordinary elementwise ops, so compilation has no trouble at all;vmap: mask operations are vectorized automatically;grad: gradients flow only through the side thatjnp.whereselects; the unselected side gets gradient 0. But watch out for NaN; see the "double where" in §3.8 of Basics 03 Automatic differentiation with grad;scan: at each time step, the mask decides whether to "update the state" or "keep the old state".
There's one easy mistake when using masks in scan: the mask changes with the time step, so it should go into xs together with the inputs and be read step by step, not into the carry, because the carry is just passed along unchanged from step to step. Below is the full version, which also verifies that "pad, then scan with a mask" gives exactly the same final state as "scan only over the real length".
def masked_scan_body(hidden_state, inp):
input_element, current_mask = inp # the mask is read step by step from xs
potential_new_state = hidden_state * 0.9 + input_element * 0.1
new_state = jnp.where(current_mask, potential_new_state, hidden_state) # invalid step: keep unchanged
output = jnp.where(current_mask, new_state * 2.0, 0.0) # invalid step: output 0
return new_state, output
@jax.jit
def masked_run(inputs, masks):
# vmap handles the batch dimension; scan handles the time dimension
return jax.vmap(lambda xs, ms: lax.scan(masked_scan_body, 0.0, (xs, ms)))(inputs, masks)
finals, outs = masked_run(padded_sequences, mask)
print("final states:", finals)
# Cross-check: run a Python loop over each sequence's real length only (float64)
seqs = [[1, 2, 3], [4, 5], [6, 7, 8, 9]]
ref = []
for s in seqs:
h = 0.0
for x_t in s:
h = h * 0.9 + x_t * 0.1
ref.append(h)
check(np.allclose(finals, ref, atol=1e-6))
check(np.allclose(np.asarray(outs)[~np.asarray(mask)], 0.0))
print("padding + mask matches running each sequence at its real length ✓")
⚠️ Note: A common broken version puts
current_maskin the carry, saysxs=(inputs, masks)in a comment, and usesxas a single input in the loop body. Those three things contradict each other, and the code doesn't run. The right approach is to read the mask step by step as part ofxs, i.e.xs=(inputs, masks), and unpack it in the loop body as(input_element, current_mask). Written that way, the result has been cross-checked against a per-sequence Python loop.
Performance trade-offs (course key points): masking computes every position and then picks results. On GPU/TPU this is usually faster than conditional branching because it doesn't cause thread divergence. The cost is that the excluded elements are computed for nothing. If one branch is expensive and often skipped, and the predicate is a scalar (not on a vmap batch dimension), lax.cond may be faster. Let profiling decide.
🔍 Extra: segment operations with jax.ops.segment_sum¶
⚪ Optional: read this for graph neural networks, variable-length data with huge length differences, or before Exercise 3.
The compute wasted by padding + mask is proportional to "max length − mean length". If lengths vary hugely, such as node degrees in a graph neural network or the number of user actions in a recommender system, you can take a different approach and not pad at all: concatenate all elements into one long array, tag each element with the "segment id" it belongs to, and reduce per segment:
$$\text{out}[k] = \sum_{i:\ \text{seg}[i]=k} \text{data}[i].$$
jax.ops provides four functions, segment_sum / segment_max / segment_min / segment_prod. Keep these points in mind when using them:
num_segmentsmust be static under jit. If you don't pass it, the default ismax(segment_ids)+1, which needs a concrete value, so under jit it raisesConcretizationTypeError;- ids in
segment_idsthat are ≥ num_segments are silently dropped, and so are negative ids; - An empty segment gives the identity of the reduction: 0 for sum, 1 for prod,
-inffor max,+inffor min (for floats; with integer dtypes, the empty-segment result of max / min is that type's minimum / maximum value); - If you know the ids are sorted, pass
indices_are_sorted=True; some backends are faster that way.
data = jnp.array([1., 2., 3., 4., 5., 6.])
seg = jnp.array([0, 0, 1, 1, 1, 2])
ssum = jax.ops.segment_sum(data, seg, num_segments=3)
smax = jax.ops.segment_max(data, seg, num_segments=4) # segment 3 is empty
print("segment_sum:", ssum, "| segment_max (with an empty segment):", smax)
check(np.allclose(ssum, [3, 12, 6]))
check(np.allclose(smax[:3], [2, 5, 6]) and np.isneginf(smax[3]))
# Out-of-range id: silently dropped (6.0 is gone)
dropped = jax.ops.segment_sum(data, jnp.array([0, 0, 1, 1, 1, 5]), num_segments=3)
print("out-of-range id dropped:", dropped)
check(np.allclose(dropped, [3, 12, 0]))
# Under jit, omitting num_segments → error; fix it with static_argnames or functools.partial
try:
jax.jit(lambda d, s: jax.ops.segment_sum(d, s))(data, seg); err = None
except jax.errors.ConcretizationTypeError as e:
err = type(e).__name__
print("omitting num_segments under jit:", err)
check(err == "ConcretizationTypeError")
seg_mean = jax.jit(lambda d, s: jax.ops.segment_sum(d, s, num_segments=3)
/ jnp.maximum(jax.ops.segment_sum(jnp.ones_like(d), s, num_segments=3), 1))
print("segment mean:", seg_mean(data, seg))
check(np.allclose(seg_mean(data, seg), [1.5, 4.0, 6.0]))
segment_sum is essentially a scatter-add, and it works with grad: the gradient with respect to data is the upstream gradient gathered back by id. Applied to this section's variable-length sequences, the same "sum" problem now has two solutions:
flat = jnp.concatenate([jnp.array(s, jnp.float32) for s in seqs]) # no padding, length 9
ids = jnp.concatenate([jnp.full(len(s), i) for i, s in enumerate(seqs)])
check(np.allclose(jax.ops.segment_sum(flat, ids, num_segments=3), masked_sum))
# Gradient: d(sum_k w_k * segsum_k) / d data_i = w_{seg[i]}
wts = jnp.array([1., 10., 100.])
g = jax.grad(lambda d: jnp.dot(wts, jax.ops.segment_sum(d, ids, num_segments=3)))(flat)
print("gradient:", g)
check(np.allclose(g, np.asarray(wts)[np.asarray(ids)]))
1.7 Understanding closures and JAX staging¶
A quick review of Python closures¶
When a nested function refers to variables from an enclosing scope, you get a closure: the inner function "captures" those variables and can still access them after the outer function returns. Each call to the factory function creates a new closure, each holding its own captured values.
def create_multiplier(factor):
def multiplier(x):
return x * factor
return multiplier
multiply_by_2 = create_multiplier(2)
multiply_by_10 = create_multiplier(10)
print(f"multiply_by_2(5) = {multiply_by_2(5)}", f"| multiply_by_10(5) = {multiply_by_10(5)}")
check(multiply_by_2(5) == 10 and multiply_by_10(5) == 50)
Staging and value capture¶
jit, vmap and grad don't simply run your Python code each time. They first stage it: run the function once with tracers, record the primitive operations as a jaxpr, and then compile or transform that. In this process the function's arguments become the jaxpr's input variables. Values captured by a closure or read as globals are not tracers; they are read and frozen at the moment of tracing and embedded into the program. They get embedded in one of two ways:
- Python scalars (int / float): written directly into the equation as literals, e.g.
mul 2.0:f32[] a; - Arrays (
jax.Array/np.ndarray): appear asconstvars, i.e. the variables before the semicolon at the start of the jaxpr{ lambda a:f32[3,3]; b:f32[3]. ...}. Their values are stored in.constsof the object returned bymake_jaxpr, and they're embedded in the HLO as constants at compile time. (In 0.12, the oldClosedJaxprhas been merged intoJaxpr:make_jaxprreturns aJaxprdirectly, its.jaxpris itself, andjax.extend.core.ClosedJaxpris just an alias.)
Let's look at the jaxprs directly:
W_small = jnp.ones((3, 3))
jx_const = jax.make_jaxpr(lambda x: W_small @ x)(jnp.ones(3)) # array captured by a closure
jx_arg = jax.make_jaxpr(lambda W, x: W @ x)(W_small, jnp.ones(3)) # passed as an argument
jx_lit = jax.make_jaxpr(lambda x: 2.0 * x)(jnp.ones(3)) # Python scalar
print("array captured by a closure:\n", jx_const)
print("passed as an argument:\n", jx_arg)
print("Python scalar:\n", jx_lit)
check(len(jx_const.jaxpr.constvars) == 1 and len(jx_const.consts) == 1)
check(type(jx_const).__name__ == "Jaxpr" and jx_const.jaxpr is jx_const) # 0.12: ClosedJaxpr has been merged into Jaxpr
check(len(jx_arg.jaxpr.constvars) == 0 and len(jx_arg.jaxpr.invars) == 2)
check(len(jx_lit.jaxpr.constvars) == 0 and "2.0" in str(jx_lit))
Three effects on JIT (course key points)¶
- Constant specialization: captured values are constants in the compiled code, and the compiled result is specialized to the value seen at tracing time;
- Stale values: if you modify a captured variable after jit, the compiled function doesn't see the change;
- Recompilation: each distinct closure (e.g.
create_multiplier(5)andcreate_multiplier(10)) is a different function object, traced and compiled separately. If you keep creating new closures in a loop and jit them, every one recompiles, and the benefit of jit is completely wiped out.
Course code: stale-value demo¶
scale_factor = 2.0
def apply_scale(x):
return x * scale_factor # reads a global variable
jitted_apply_scale = jax.jit(apply_scale)
input_array = jnp.arange(3.)
r_first = jitted_apply_scale(input_array)
print("first call:", r_first)
print("changing scale_factor to 100.0")
scale_factor = 100.0
r_stale = jitted_apply_scale(input_array)
print("same jitted function:", r_stale, "(not [0, 100, 200])")
check(np.allclose(r_first, [0, 2, 4]) and np.allclose(r_stale, [0, 2, 4]))
# ❌ Common misconception: thinking that running jax.jit(apply_scale) again gives the new value [0, 100, 200]
jitted_apply_scale_new = jax.jit(apply_scale)
r_new = jitted_apply_scale_new(input_array)
print("wrapping jax.jit(apply_scale) again:", r_new)
try:
assert np.allclose(r_new, [0, 100, 200]), "still the old value"
print("as expected")
except AssertionError as e:
print("not as expected:", e)
# ✅ Recommended: pass values that change as explicit arguments (a change in value doesn't trigger recompilation; only a change in shape or dtype does)
@jax.jit
def apply_scale_arg(x, factor):
return x * factor
r_a, r_b = apply_scale_arg(input_array, 2.0), apply_scale_arg(input_array, 100.0)
print(r_a, r_b, "| number of compilations:", apply_scale_arg._cache_size())
check(np.allclose(r_a, [0, 2, 4]) and np.allclose(r_b, [0, 100, 200]))
check(apply_scale_arg._cache_size() == 1)
# Proof: a fresh jax.jit(f) wrapper still hits the same tracing cache (the cache key is the underlying function object, not the values of globals)
check(np.allclose(jax.jit(apply_scale)(input_array), [0, 2, 4]))
jax.clear_caches() # only after clearing the caches does retracing read the new value
check(np.allclose(jax.jit(apply_scale)(input_array), [0, 100, 200]))
print("[0, 100, 200] appears only after clear_caches() ✓")
⚠️ Note: Running
jitted_apply_scale_new = jax.jit(apply_scale)again and calling it still gives[0. 2. 4.], not[0. 100. 200.]. JAX's tracing cache is keyed on the underlying Python function object (plus argument shapes, dtypes and so on). Wrapping the same function injax.jitagain hits the existing cache, and the values of globals aren't part of the cache key. Only callingjax.clear_caches(), or switching to a new function object (e.g. a new lambda or closure), causes retracing. So don't count on "re-jitting" to refresh a global; pass it in as an argument.
Best practices (course key points)¶
- Use explicit arguments for dynamic values: dependencies are obvious, there are no stale values, and value changes don't require recompilation;
- Use closures only for static configuration: layer sizes, activation functions, fixed hyperparameters, and other values that don't change during the compiled function's lifetime;
- Be careful with mutable objects: if you modify the contents of a captured list or dict after jit, the compiled function won't see it. Treat captured values as immutable.
# Course code: use a closure for static configuration (jnp was imported at the top)
def create_dense_layer(output_size):
def apply_layer(params, x):
assert params["W"].shape[1] == output_size # shapes are static, so this can be checked at trace time
y = jnp.dot(x, params["W"]) + params["b"]
return jax.nn.relu(y) # the activation function is captured implicitly too
return apply_layer
layer10 = jax.jit(create_dense_layer(10))
kW, _ = jax.random.split(jax.random.key(1))
dense_params = {"W": jax.random.normal(kW, (4, 10)), "b": jnp.zeros(10)} # weights passed as arguments
out = layer10(dense_params, jnp.ones((2, 4)))
check(out.shape == (2, 10) and bool((out >= 0).all()))
# Mutable objects: modifying the contents of a captured dict isn't seen by the jitted function
cfg = {"scale": 2.0}
jh = jax.jit(lambda x: x * cfg["scale"])
before = jh(input_array); cfg["scale"] = 5.0; after = jh(input_array)
print("before and after modifying the dict:", before, after)
check(np.allclose(before, after))
⚠️ Note: This Dense layer code uses
jnp.dot. If you run it on its own, you needimport jax.numpy as jnpin addition toimport jaxandimport jax.nn as nn, or you'll get aNameError. The cell above uses thejnpimported at the top.
🔍 Extra: four problems with capturing large arrays in closures¶
🟢 Must-read
Putting a large array in a closure ("it doesn't change anyway") looks natural, but in practice it causes four problems:
- Program size and compile time blow up: constants are written into the HLO verbatim. As you'll see below, one 300×300 random matrix turns the lowered text from a few hundred bytes into about 700 KB. With parameters in the GB range, compilation becomes extremely slow or even fails;
- Swapping weights either doesn't take effect or forces recompilation: during training the weights change every step. If the weights are closure constants, reusing the same jitted function keeps using the old values (stale values, see above); creating a new closure each step picks up the new values, but then every step retraces and recompiles;
gradcan't see it: a constant isn't an input to the function, so you can't differentiate with respect to it. During training, weights must be passed in as arguments;- The CPU bug in this machine's jaxlib 0.12.0.1: a constant matrix captured by a closure, used in
W @ xfollowed by a reduce, silently gives wrong results. For the symptoms, trigger boundary and workarounds, see "Known issues in this environment" in Basics 00 Overview; for the root-cause analysis, see Advanced Chapter 2 §2.3.
The last cell shows a different closure mistake: saving a tracer to an outside variable during tracing (tracer leak). Using it later raises UnexpectedTracerError.
rng = np.random.default_rng(0)
B_np = rng.standard_normal((300, 300)).astype(np.float32)
B = jnp.asarray(B_np)
txt_closure = jax.jit(lambda x: B @ x).lower(jnp.ones(300)).as_text()
txt_arg = jax.jit(lambda b, x: b @ x).lower(B, jnp.ones(300)).as_text()
print(f"lowered text length: closure {len(txt_closure):,} chars | argument {len(txt_arg):,} chars")
check(len(txt_closure) > 100 * len(txt_arg))
check("stablehlo.constant dense<" in txt_closure)
# Tracer leak: saving a tracer outside the function during tracing
leaked = []
@jax.jit
def leaky(x):
leaked.append(x) # ❌ puts the tracer outside the function
return x + 1
leaky(1.0)
try:
leaked[0] + 1; err = None
except Exception as e:
err = type(e).__name__
print("using the leaked tracer:", err)
check(err == "UnexpectedTracerError")
1.8 Hands-on: implementing complex recurrent logic¶
This section uses lax.scan to implement a simplified GRU (gated recurrent unit), showing how to write more involved computation inside a loop body. After that come two extra hands-on pieces: an LSTM with variable-length masks and an RK4 ODE integrator. All results are cross-checked value by value against Python-loop versions.
Equations of the simplified GRU¶
Let $x_t$ be the input at step $t$, $h_{t-1}$ the previous hidden state, $\sigma$ the sigmoid, and $\odot$ elementwise multiplication:
$$ \begin{aligned} z_t &= \sigma(W_z x_t + U_z h_{t-1} + b_z) &&\text{update gate: how much of the candidate goes into the new state}\\ r_t &= \sigma(W_r x_t + U_r h_{t-1} + b_r) &&\text{reset gate: how much of the old state feeds into the candidate}\\ \tilde h_t &= \tanh\big(W_h x_t + U_h (r_t\odot h_{t-1}) + b_h\big) &&\text{candidate hidden state}\\ h_t &= (1-z_t)\odot h_{t-1} + z_t\odot \tilde h_t &&\text{interpolate between the old state and the candidate} \end{aligned} $$
Note: the convention here is that $z_t\to 1$ takes the candidate state. PyTorch's
nn.GRUis the opposite: $h_t=(1-z_t)\odot\tilde h_t + z_t\odot h_{t-1}$. The two forms are equally expressive, but watch out when porting weights.
Design for the scan implementation: the carry holds $h_t$ and xs is the input sequence $(x_1,\dots,x_T)$. The single-step function gru_step implements the four equations above and returns (h_t, h_t): the first becomes the next step's carry, the second is collected as output.
Course code: GRU (updated to the 0.12 recommended style)¶
sigmoid = jax.nn.sigmoid
tanh = jnp.tanh
def initialize_gru_params(key, input_dim, hidden_dim):
"""Initialize the parameters of a simplified GRU cell."""
keys = jax.random.split(key, 6) # 3 pairs of (W, U) need 6 keys; biases are all zeros
s = 0.01
return {
"Wz": jax.random.normal(keys[0], (hidden_dim, input_dim)) * s,
"Uz": jax.random.normal(keys[1], (hidden_dim, hidden_dim)) * s,
"bz": jnp.zeros((hidden_dim,)),
"Wr": jax.random.normal(keys[2], (hidden_dim, input_dim)) * s,
"Ur": jax.random.normal(keys[3], (hidden_dim, hidden_dim)) * s,
"br": jnp.zeros((hidden_dim,)),
"Wh": jax.random.normal(keys[4], (hidden_dim, input_dim)) * s,
"Uh": jax.random.normal(keys[5], (hidden_dim, hidden_dim)) * s,
"bh": jnp.zeros((hidden_dim,)),
}
def gru_step(params, h_prev, x_t):
"""One step of the simplified GRU."""
z_t = sigmoid(jnp.dot(params["Wz"], x_t) + jnp.dot(params["Uz"], h_prev) + params["bz"])
r_t = sigmoid(jnp.dot(params["Wr"], x_t) + jnp.dot(params["Ur"], h_prev) + params["br"])
h_tilde_t = tanh(jnp.dot(params["Wh"], x_t) + jnp.dot(params["Uh"], r_t * h_prev) + params["bh"])
h_t = (1 - z_t) * h_prev + z_t * h_tilde_t
return h_t, h_t # (carry, output)
def gru_sequence(params, initial_h, inputs):
"""Apply the GRU cell to a whole sequence with lax.scan."""
scan_fn = lambda carry, x: gru_step(params, carry, x)
final_h, outputs_h = lax.scan(scan_fn, initial_h, inputs)
return final_h, outputs_h # final hidden state, and [h_1, ..., h_T]
key = jax.random.key(0) # the old style was random.PRNGKey(0)
seq_len, input_dim, hidden_dim = 10, 5, 8
key, k_param, k_in = jax.random.split(key, 3)
gru_params = initialize_gru_params(k_param, input_dim, hidden_dim)
input_sequence = jax.random.normal(k_in, (seq_len, input_dim))
initial_hidden_state = jnp.zeros((hidden_dim,))
final_state, hidden_states_sequence = gru_sequence(gru_params, initial_hidden_state, input_sequence)
print("input sequence shape:", input_sequence.shape)
print("initial hidden state shape:", initial_hidden_state.shape)
print("final hidden state shape:", final_state.shape)
print("hidden state output sequence shape:", hidden_states_sequence.shape)
check(final_state.shape == (8,) and hidden_states_sequence.shape == (10, 8))
Cross-check against a Python loop (NumPy float64): this is the standard way to check that a scan implementation is correct.
def gru_numpy(params, h0, xs):
P = {k: np.asarray(v, np.float64) for k, v in params.items()}
sig = lambda a: 1 / (1 + np.exp(-a))
h, hs = np.asarray(h0, np.float64), []
for x_t in np.asarray(xs, np.float64):
z = sig(P["Wz"] @ x_t + P["Uz"] @ h + P["bz"])
r = sig(P["Wr"] @ x_t + P["Ur"] @ h + P["br"])
ht = np.tanh(P["Wh"] @ x_t + P["Uh"] @ (r * h) + P["bh"])
h = (1 - z) * h + z * ht
hs.append(h)
return h, np.stack(hs)
h_ref, hs_ref = gru_numpy(gru_params, initial_hidden_state, input_sequence)
check(np.allclose(final_state, h_ref, atol=1e-6) and np.allclose(hidden_states_sequence, hs_ref, atol=1e-6))
print("scan GRU matches the Python-loop GRU ✓ max error:", float(np.abs(hidden_states_sequence - hs_ref).max()))
Integrating with JAX transformations¶
The scan-based gru_sequence composes directly with jit, vmap and grad.
🔍 Extra: ⚪ Optional: read this if you're unsure whether "a closure capturing parameters inside jit" is safe.
The
scan_fninsidegru_sequencecapturesparamsthrough a closure. Insidejit(gru_sequence),paramsis an argument of the jitted function and is already a tracer. The closure captures a tracer, which doesn't embed the weights as constants, so this is safe. What you really want to avoid is holding a concrete weight array outside the jitted function and referring to it inside the jitted function body.
jit_gru_sequence = jax.jit(gru_sequence)
key, sk = jax.random.split(key)
input_sequence_2 = jax.random.normal(sk, (seq_len, input_dim))
final_state_jit, hidden_states_sequence_jit = jit_gru_sequence(gru_params, initial_hidden_state, input_sequence_2)
print("running the JIT-compiled version:")
print("final hidden state shape (JIT):", final_state_jit.shape, "| output sequence shape (JIT):", hidden_states_sequence_jit.shape)
check(np.allclose(final_state_jit, gru_numpy(gru_params, initial_hidden_state, input_sequence_2)[0], atol=1e-6))
# vmap: split off a new key before generating batch data; don't reuse the key directly
batch_size = 32
key, kb = jax.random.split(key)
batched_inputs = jax.random.normal(kb, (batch_size, seq_len, input_dim))
batched_initial_h = jnp.zeros((batch_size, hidden_dim))
batched_gru = jax.jit(jax.vmap(gru_sequence, in_axes=(None, 0, 0))) # parameters shared across the batch
final_states_batch, hidden_sequences_batch = batched_gru(gru_params, batched_initial_h, batched_inputs)
print("batched final states:", final_states_batch.shape, "| batched sequences:", hidden_sequences_batch.shape)
check(final_states_batch.shape == (32, 8) and hidden_sequences_batch.shape == (32, 10, 8))
check(np.allclose(final_states_batch[7], gru_numpy(gru_params, batched_initial_h[7], batched_inputs[7])[0], atol=1e-6))
You can also take gradients with respect to gru_params using jax.grad. Below we differentiate a simple loss and then spot-check one parameter element with a float64 central difference.
def gru_loss(params, h0, xs):
final_h, _ = gru_sequence(params, h0, xs)
return jnp.sum(final_h ** 2)
grads = jax.jit(jax.grad(gru_loss))(gru_params, initial_hidden_state, input_sequence)
print({k: v.shape for k, v in grads.items()})
check(jax.tree.structure(grads) == jax.tree.structure(gru_params))
# Spot-check Wz[0, 0] with a float64 central difference
eps = 1e-5
def loss_np(delta):
p = {k: np.asarray(v, np.float64).copy() for k, v in gru_params.items()}
p["Wz"][0, 0] += delta
return float(np.sum(gru_numpy(p, initial_hidden_state, input_sequence)[0] ** 2))
fd = (loss_np(eps) - loss_np(-eps)) / (2 * eps)
print(f"autodiff: {float(grads['Wz'][0, 0]):.6e} | central difference: {fd:.6e}")
check(np.isclose(float(grads["Wz"][0, 0]), fd, rtol=1e-2, atol=1e-8))
Hands-on A: an LSTM with variable-length masks (scan + vmap + grad + checkpoint)¶
One LSTM step (stack the weights of the four gates into $W\in\mathbb R^{4H\times D}$ and $U\in\mathbb R^{4H\times H}$, so one matmul computes them all):
$$ \begin{aligned} [i_t; f_t; o_t; g_t] &= W x_t + U h_{t-1} + b,\\ c_t &= \sigma(f_t)\odot c_{t-1} + \sigma(i_t)\odot \tanh(g_t),\\ h_t &= \sigma(o_t)\odot \tanh(c_t). \end{aligned} $$
Requirements:
- the carry is the pytree
(h, c); - sequences in a batch have different lengths; handle them with padding + mask, and keep
(h, c)unchanged on padding steps; - weights are passed in as arguments;
- cross-check the results against "a NumPy float64 loop that runs each sequence only over its real length";
- compare the gradients with and without
jax.checkpoint.
def init_lstm(key, D, H):
k1, k2 = jax.random.split(key)
return {"W": jax.random.normal(k1, (4 * H, D)) / np.sqrt(D),
"U": jax.random.normal(k2, (4 * H, H)) / np.sqrt(H),
"b": jnp.zeros(4 * H).at[H:2 * H].set(1.0)} # initialize the forget-gate bias to 1 (a common trick)
def lstm_cell(params, carry, x_t):
h, c = carry
i, f, o, g = jnp.split(params["W"] @ x_t + params["U"] @ h + params["b"], 4)
c_new = jax.nn.sigmoid(f) * c + jax.nn.sigmoid(i) * jnp.tanh(g)
h_new = jax.nn.sigmoid(o) * jnp.tanh(c_new)
return h_new, c_new
def lstm_masked(params, xs, mask, use_ckpt=False):
"""One sequence: xs (T, D), mask (T,) bool. Returns the final h and h at every step (padding steps output 0)."""
H = params["U"].shape[1]
def step(carry, inp):
x_t, m_t = inp
new = lstm_cell(params, carry, x_t)
carry = jax.tree.map(lambda n, o: jnp.where(m_t, n, o), new, carry) # padding steps keep the state unchanged
return carry, jnp.where(m_t, carry[0], 0.0)
body = jax.checkpoint(step) if use_ckpt else step
(h, c), hs = lax.scan(body, (jnp.zeros(H), jnp.zeros(H)), (xs, mask))
return h, hs
D, H, T = 3, 6, 7
key, kp, kx = jax.random.split(jax.random.key(42), 3)
lstm_params = init_lstm(kp, D, H)
lens = jnp.array([7, 4, 1, 5])
xs_b = jax.random.normal(kx, (4, T, D))
mask_b = jnp.arange(T)[None, :] < lens[:, None]
xs_b = jnp.where(mask_b[..., None], xs_b, 0.0) # fill padding positions with 0
run_batch = jax.jit(jax.vmap(lstm_masked, in_axes=(None, 0, 0)))
h_final_b, hs_b = run_batch(lstm_params, xs_b, mask_b)
def lstm_numpy(params, xs):
P = {k: np.asarray(v, np.float64) for k, v in params.items()}
sig = lambda a: 1 / (1 + np.exp(-a))
h = np.zeros(P["U"].shape[1]); c = np.zeros_like(h)
for x_t in xs:
i, f, o, g = np.split(P["W"] @ x_t + P["U"] @ h + P["b"], 4)
c = sig(f) * c + sig(i) * np.tanh(g)
h = sig(o) * np.tanh(c)
return h
for bi, L in enumerate(np.asarray(lens)):
ref_h = lstm_numpy(lstm_params, np.asarray(xs_b[bi, :L], np.float64))
check(np.allclose(h_final_b[bi], ref_h, atol=1e-5), f"sequence {bi} mismatch")
check(np.allclose(np.asarray(hs_b)[~np.asarray(mask_b)], 0.0))
print("final hidden states of all 4 variable-length sequences match the per-sequence NumPy loop ✓")
# Gradients: padding steps shouldn't affect the result; checkpointing doesn't change the gradients
def lstm_loss(params, xs, mask, use_ckpt):
h, _ = jax.vmap(lambda x, m: lstm_masked(params, x, m, use_ckpt))(xs, mask)
return jnp.mean(h ** 2)
grad_lstm = jax.jit(jax.grad(lstm_loss), static_argnums=3)
g_plain = grad_lstm(lstm_params, xs_b, mask_b, False)
g_ckpt = grad_lstm(lstm_params, xs_b, mask_b, True)
check(all(np.allclose(a, b, atol=1e-6) for a, b in zip(jax.tree.leaves(g_plain), jax.tree.leaves(g_ckpt))))
# Gradient w.r.t. the inputs: gradients at padding positions must be exactly 0
g_x = jax.grad(lambda x: lstm_loss(lstm_params, x, mask_b, False))(xs_b)
check(np.allclose(np.asarray(g_x)[~np.asarray(mask_b)], 0.0))
print("gradients match with and without checkpoint; input gradients at padding positions are 0 ✓")
t_plain = bench(grad_lstm, lstm_params, xs_b, mask_b, False)
t_ckpt = bench(grad_lstm, lstm_params, xs_b, mask_b, True)
print(f"timing (comparison only, no assertion): grad without checkpoint {t_plain:.3f} ms | with checkpoint {t_ckpt:.3f} ms")
Hands-on B: an RK4 ODE integrator¶
The classic fourth-order Runge–Kutta method advances $\dot y = f(t, y)$ in time:
$$ \begin{aligned} k_1 &= f(t_n, y_n), & k_2 &= f(t_n+\tfrac h2,\ y_n+\tfrac h2 k_1),\\ k_3 &= f(t_n+\tfrac h2,\ y_n+\tfrac h2 k_2), & k_4 &= f(t_n+h,\ y_n+h k_3),\\ y_{n+1} &= y_n + \tfrac h6 (k_1+2k_2+2k_3+k_4). \end{aligned} $$
Take the harmonic oscillator $\ddot x = -\omega^2 x$ as an example, with $y=(x, v)$. The analytic solution is $x(t)=x_0\cos\omega t + \frac{v_0}{\omega}\sin\omega t$. We verify the scan-based integrator three ways: cross-check against a Python loop (float64), compare with the analytic solution, and differentiate with respect to the initial value. The analytic sensitivity is $\partial x(T)/\partial v_0 = \sin(\omega T)/\omega$.
def rk4_step(f, t, y, h, args):
k1 = f(t, y, args)
k2 = f(t + h / 2, y + h / 2 * k1, args)
k3 = f(t + h / 2, y + h / 2 * k2, args)
k4 = f(t + h, y + h * k3, args)
return y + h / 6 * (k1 + 2 * k2 + 2 * k3 + k4)
def odeint_rk4(f, y0, t0, h, n_steps, args):
"""Fixed-step RK4. args (e.g. physical parameters) are passed as arguments, not captured in a closure. Returns (y_T, trajectory)."""
def body(carry, _):
t, y = carry
y_new = rk4_step(f, t, y, h, args)
return (t + h, y_new), y_new
(tT, yT), traj = lax.scan(body, (t0, y0), None, length=n_steps)
return yT, traj
def oscillator(t, y, omega):
x, v = y
return jnp.array([v, -omega ** 2 * x])
@functools.partial(jax.jit, static_argnums=(3,))
def solve(y0, omega, h, n_steps):
return odeint_rk4(oscillator, y0, 0.0, h, n_steps, omega)
y0 = jnp.array([1.0, 0.5]); omega = 2.0; h = 0.01; n = 500 # integrate to T = 5
yT, traj = solve(y0, omega, h, n)
# 1) Cross-check against a Python loop (NumPy float64)
def rk4_numpy(y, omega, h, n):
f = lambda y: np.array([y[1], -omega ** 2 * y[0]])
out = []
for _ in range(n):
k1 = f(y); k2 = f(y + h / 2 * k1); k3 = f(y + h / 2 * k2); k4 = f(y + h * k3)
y = y + h / 6 * (k1 + 2 * k2 + 2 * k3 + k4); out.append(y)
return y, np.stack(out)
yT_np, traj_np = rk4_numpy(np.array([1.0, 0.5]), 2.0, 0.01, 500)
check(np.allclose(traj, traj_np, atol=1e-5))
# 2) Analytic solution
T = h * n
x_exact = 1.0 * np.cos(omega * T) + 0.5 / omega * np.sin(omega * T)
print(f"x(T): scan={float(yT[0]):.6f} numpy={yT_np[0]:.6f} analytic={x_exact:.6f}")
check(abs(float(yT[0]) - x_exact) < 1e-4)
# 3) Derivative w.r.t. the initial value (BPTT through scan)
dx_dv0 = jax.grad(lambda y0: solve(y0, omega, h, n)[0][0])(y0)[1]
print(f"∂x(T)/∂v0: autodiff={float(dx_dv0):.6f} analytic={np.sin(omega * T) / omega:.6f}")
check(abs(float(dx_dv0) - np.sin(omega * T) / omega) < 1e-4)
# 4) vmap: integrate a batch of different initial values in parallel
y0s = jnp.stack([jnp.array([a, 0.0]) for a in (0.5, 1.0, 2.0)])
xT_b = jax.vmap(lambda y: solve(y, omega, h, n)[0][0])(y0s)
check(np.allclose(xT_b, np.array([0.5, 1.0, 2.0]) * np.cos(omega * T), atol=1e-4))
print("RK4: Python-loop cross-check, analytic solution, gradient and vmap all pass ✓")
Extra exercises¶
Each of the 4 exercises below comes with a reference solution and assert checks. Try writing it yourself first, then compare with the reference solution.
Exercise 1: discounted returns with termination flags via a reverse scan (reinforcement learning)¶
Given rewards $r_t$, termination flags $d_t\in\{0,1\}$ and a discount factor $\gamma$, compute
$$G_t = r_t + \gamma\,(1-d_t)\,G_{t+1},\qquad G_{T}=0.$$
Requirements: implement it with lax.scan(..., reverse=True), support vmap over multiple trajectories, and cross-check the result against a Python loop.
Self-check interface: define discounted_returns(rewards, dones, gamma): it takes 1-D rewards and dones of length T and a scalar gamma, and returns $G$ of length T; it must work directly under jax.vmap(..., in_axes=(0, 0, None)) and jax.jit.
# ✍️ Your answer: Exercise 1
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 1
try:
discounted_returns
except NameError as e:
print(f"⏳ Exercise 1: not answered yet: {e}")
else:
def _returns_loop(r, d, gamma): # step-by-step reference implementation (float64)
G, g = np.zeros(len(r)), 0.0
for t in reversed(range(len(r))):
g = r[t] + gamma * (1 - d[t]) * g
G[t] = g
return G
_rng = np.random.default_rng(2024)
for _T, _gamma in [(1, 0.9), (13, 0.99), (40, 0.5)]:
_r = _rng.uniform(-1, 1, _T).astype(np.float32)
_d = (_rng.uniform(size=_T) < 0.2).astype(np.float32)
_out = np.asarray(discounted_returns(jnp.asarray(_r), jnp.asarray(_d), _gamma))
assert _out.shape == (_T,), f"output shape should be ({_T},), got {_out.shape}"
assert np.allclose(_out, _returns_loop(_r, _d, _gamma), atol=1e-5), f"T={_T}, gamma={_gamma}: doesn't match the step-by-step recurrence"
_out = np.asarray(discounted_returns(jnp.array([1., 2., 3., 4.]), jnp.array([0., 1., 0., 1.]), 0.5))
assert np.allclose(_out, [2., 2., 5., 4.]), f"when d_t=1 the later returns should be cut off; expected [2, 2, 5, 4], got {_out}"
_jp = str(jax.make_jaxpr(lambda r, d: discounted_returns(r, d, 0.9))(jnp.ones(50), jnp.zeros(50)))
assert "scan[" in _jp, "no scan in the jaxpr: the exercise asks for lax.scan, not a Python loop (which unrolls over T)"
_R = jnp.asarray(_rng.uniform(size=(3, 9)), jnp.float32)
_G = jax.jit(jax.vmap(discounted_returns, in_axes=(0, 0, None)))(_R, jnp.zeros((3, 9)), 0.9)
for _i in range(3):
assert np.allclose(_G[_i], _returns_loop(np.asarray(_R[_i]), np.zeros(9), 0.9), atol=1e-5), f"after vmap, trajectory {_i} is wrong"
print("✅ Exercise 1: self-check passed")
Exercise 2: a reverse-mode differentiable "bounded while": Newton's method for square roots¶
lax.while_loop doesn't work with grad. Use lax.scan with a fixed maximum number of steps (e.g. 30) and a mask that stops "already converged" elements from updating, to implement Newton's iteration for $\sqrt a$, $x\leftarrow \tfrac12(x + a/x)$, with the convergence condition $|x^2-a|<10^{-6}a$. Requirements:
- The result matches
jnp.sqrt; - Also return the actual number of iterations, and it must match the iteration count of a
while_loopversion; jax.gradruns, and the gradient equals $1/(2\sqrt a)$.
Self-check interface: define newton_sqrt_scan(a) and newton_sqrt_while(a). Both take a scalar a and return (x, k): x approximates $\sqrt a$ and k is the actual number of iterations (an integer).
# ✍️ Your answer: Exercise 2
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 2
try:
newton_sqrt_scan, newton_sqrt_while
except NameError as e:
print(f"⏳ Exercise 2: not answered yet: {e}")
else:
_a = jnp.array([3.0, 0.5, 123.4, 7e3, 42.0])
_xs, _ks = jax.vmap(newton_sqrt_scan)(_a)
_xw, _kw = jax.vmap(newton_sqrt_while)(_a)
assert np.allclose(_xs, np.sqrt(np.asarray(_a, np.float64)), rtol=1e-6), f"scan version doesn't match √a: {_xs}"
assert np.allclose(_xw, np.sqrt(np.asarray(_a, np.float64)), rtol=1e-6), f"while version doesn't match √a: {_xw}"
assert jnp.issubdtype(jnp.asarray(_ks).dtype, jnp.integer), f"the iteration count should be an integer, got dtype {jnp.asarray(_ks).dtype}"
assert np.array_equal(_ks, _kw), f"scan iteration counts {_ks} don't match the while version {_kw}: are converged elements still being counted?"
assert bool(((np.asarray(_ks) > 0) & (np.asarray(_ks) < 30)).all()), f"iteration counts should be between 1 and 29 (no longer increasing after convergence), got {_ks}"
_g = jax.vmap(jax.grad(lambda a: newton_sqrt_scan(a)[0]))(_a)
assert bool(jnp.isfinite(_g).all()), "the scan version's gradient contains nan/inf"
assert np.allclose(_g, 0.5 / np.sqrt(np.asarray(_a, np.float64)), rtol=1e-4), f"the gradient should be 1/(2√a), got {_g}"
print("✅ Exercise 2: self-check passed")
Answer notes for Exercise 2
💡 The iteration counts match exactly because once the scan version has converged it just "idles" and
kstops increasing. The price is that it always runs all 30 steps. A more advanced approach uses the implicit function theorem at the fixed point and supplies the gradient $\partial x^*/\partial a$ directly withjax.custom_vjp. That keeps the early exit of while_loop and still allows reverse-mode differentiation.
Exercise 3: segment softmax (a basic operation in graph attention)¶
Given concatenated scores scores (length N) and segment ids seg (K segments in total), apply softmax to each segment separately:
$$\text{out}_i = \frac{\exp(s_i - m_{k})}{\sum_{j:\,\text{seg}_j=k}\exp(s_j - m_k)},\quad k=\text{seg}_i,\ m_k=\max_{j:\,\text{seg}_j=k}s_j.$$
Requirements: use only segment_max, segment_sum and indexing, subtract the per-segment maximum for numerical stability, and compile with jit. Cross-check against a NumPy result that "slices by segment and computes softmax segment by segment".
Self-check interface: define segment_softmax(scores, seg, num_segments), with num_segments as a static argument (passed as a keyword), returning a result with the same shape as scores.
# ✍️ Your answer: Exercise 3
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 3
try:
segment_softmax
except NameError as e:
print(f"⏳ Exercise 3: not answered yet: {e}")
else:
def _seg_softmax_np(s, ids, k):
s = np.asarray(s, np.float64); ids = np.asarray(ids); out = np.empty_like(s)
for j in range(k):
m = ids == j
e = np.exp(s[m] - s[m].max()); out[m] = e / e.sum()
return out
_rng = np.random.default_rng(7)
for _ids, _k in [([0, 1, 1, 2, 2, 2, 3, 4, 4, 4, 4, 4], 5), ([0, 0, 0, 1, 1], 2)]:
_ids = jnp.array(_ids)
_s = jnp.asarray(_rng.normal(size=len(_ids)) * 50 + 200, jnp.float32) # large scores: overflow unless you subtract the max
_out = segment_softmax(_s, _ids, num_segments=_k)
assert _out.shape == _s.shape, f"output shape should be {_s.shape}, got {_out.shape}"
assert bool(jnp.isfinite(_out).all()), "the result contains nan/inf: did you subtract the per-segment max before exponentiating?"
assert np.allclose(_out, _seg_softmax_np(_s, _ids, _k), atol=1e-6), "doesn't match the segment-by-segment softmax"
_sums = np.asarray(jax.ops.segment_sum(_out, _ids, num_segments=_k))
assert np.allclose(_sums, 1.0, atol=1e-6), f"each segment should sum to 1, got {_sums}"
print("✅ Exercise 3: self-check passed")
Exercise 4: write a piecewise function with lax.switch and explain its cost under vmap¶
Implement
$$f(x)=\begin{cases}x^2 & x<0\\ \sin x & 0\le x<\pi\\ x-\pi & x\ge\pi\end{cases}$$
Requirements: (a) handle scalars with lax.switch; (b) handle vectors with vmap, and confirm via the jaxpr that switch has been rewritten as select_n and all three branches are computed; (c) write an elementwise version with jnp.select and check that both agree; (d) differentiate the vmap version and compare with the analytic derivative.
Self-check interface: define piecewise(x) (scalar input, implemented with lax.switch) and piecewise_select(x) (elementwise version).
# ✍️ Your answer: Exercise 4
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 4
try:
piecewise, piecewise_select
except NameError as e:
print(f"⏳ Exercise 4: not answered yet: {e}")
else:
_x = jnp.array([-3.0, -1e-3, 0.0, 1.0, 3.0, jnp.pi, 4.2, 10.0]) # includes both breakpoints 0 and π
_x64 = np.asarray(_x, np.float64)
_f_ref = np.where(_x64 < 0, _x64 ** 2, np.where(_x64 < np.pi, np.sin(_x64), _x64 - np.pi))
_df_ref = np.where(_x64 < 0, 2 * _x64, np.where(_x64 < np.pi, np.cos(_x64), 1.0))
_y1 = jax.vmap(piecewise)(_x)
assert np.allclose(_y1, _f_ref, atol=1e-6), f"vmap(piecewise) gives wrong results: {_y1}"
assert np.allclose(piecewise_select(_x), _f_ref, atol=1e-6), "piecewise_select gives wrong results"
assert "cond[" in str(jax.make_jaxpr(piecewise)(1.0)), "no cond in the scalar jaxpr: the exercise asks for lax.switch"
_jp = str(jax.make_jaxpr(jax.vmap(piecewise))(_x))
assert "cond[" not in _jp and "select_n" in _jp, "after vmap, switch should be rewritten as select_n (all three branches computed)"
_dy = jax.vmap(jax.grad(piecewise))(_x)
assert np.allclose(_dy, _df_ref, atol=1e-6), f"the gradient doesn't match the analytic derivative: {_dy}"
print("✅ Exercise 4: self-check passed")
Chapter 1 Quiz¶
Every answer to the 8 questions below is backed by results that ran earlier in this chapter.
1. lax.scan(f, 0, jnp.arange(5.)) runs, but lax.scan(f, jnp.int32(0), jnp.arange(5.)) reports a carry type mismatch, where f = lambda c, x: (c + x, c). Why?
Answer and explanation
The Python integer 0 is weakly typed, so adding it to the float32 x promotes it to float32, and JAX allows a weakly typed carry to be promoted this way. jnp.int32(0) is a strongly typed int32, while the loop body returns float32. The dtypes don't match, hence the error carry input and carry output must have equal types ... dtypes do not match. The safest habit is to spell out the dtype of init.
2. How do the ys returned by lax.scan(f, 0, xs, reverse=True) relate to the ys returned by lax.scan(f, 0, xs[::-1])?
Answer and explanation
The former equals the latter flipped, i.e. ys_rev == ys_flip[::-1]. reverse=True computes from back to front, but ys[t] still corresponds to the position of xs[t] (see the Extra in Section 1.2).
3. Which of these can be reverse-mode differentiated by jax.grad inside jit? (a) lax.while_loop; (b) lax.fori_loop(0, 10, ...); (c) lax.fori_loop(0, n, ...), where n is a regular argument of the jitted function; (d) lax.scan(..., length=10).
Answer and explanation
(b) and (d). When fori_loop's bounds are static it is rewritten as a scan, so it can be differentiated. (a) and (c) both become an XLA while, and reverse mode raises Reverse-mode differentiation does not work for lax.while_loop.... They can only be differentiated in forward mode with jvp / jacfwd.
4. For jax.vmap(lambda x: lax.cond(x > 0, expensive_a, expensive_b, x)), how many branches run at runtime? What if pred comes from an argument that is not mapped?
Answer and explanation
When pred has a batch dimension, cond is rewritten as select_n: both branches are computed for the whole batch, then picked elementwise. When pred has no batch dimension, the cond primitive is kept and only one branch runs (see the jaxpr in the Extra of Section 1.3).
5. Some code does jf = jax.jit(f), where f reads a global s. After calling jf, it changes s, then runs jf2 = jax.jit(f) and calls jf2. In JAX 0.12, does jf2 use the new value of s or the old one?
Answer and explanation
The old value. The tracing cache is keyed on the underlying function object, so a fresh jax.jit(f) wrapper still hits the cache. To get the new value you need jax.clear_caches() or a new function object. The right fix is to pass s in as an argument (see Section 1.7).
6. What does jax.nn.softmax(jnp.where(mask, s, -jnp.inf)) give for a row where mask is all False? And with -1e9 instead?
Answer and explanation
-inf gives a row of all NaN, because of 0/0; -1e9 gives a uniform distribution (each element 1/n). Both need extra handling: usually multiply by the mask again, or exclude fully empty rows in later computation.
7. What happens if you call jax.ops.segment_sum(data, ids) inside jit without passing num_segments? And what if ids contains values >= num_segments?
Answer and explanation
Without it, the default has to compute max(ids)+1, which requires a concrete value, so inside jit it raises ConcretizationTypeError; that's why num_segments must be static. Out-of-range ids are silently dropped without an error, which can easily hide bugs.
8. Why does using lax.scan(jax.checkpoint(step), ...) on a very long scan reduce backprop memory? What does it cost? Do the gradients change?
Answer and explanation
Without checkpointing, all intermediate values (residuals) of every step are saved for the backward pass. With it, each step saves only inputs such as the carry, and the backward pass recomputes that step's intermediates. The cost is roughly one extra forward pass. The gradients are mathematically identical; see the gradient assert in hands-on A of Section 1.8. For measured residual and memory comparisons, see Advanced Chapter 6 §6.6.
Version notes¶
Below is a summary of the patterns and conclusions in this chapter that matter under JAX 0.12. Each one is verified with assert at the corresponding place in the text.
| No. | Section | Key point | Where |
|---|---|---|---|
| 1 | 1.1 | Create random keys with jax.random.key(0) (typed key) instead of the old jax.random.PRNGKey(0) | 1.1 "Random keys" comparison cells |
| 2 | 1.1 | The correct result of the linear-model grad example is a w-gradient of [-0.92, -3.68] and a b-gradient of -4.6 (residual −2.3), not [-0.392 -1.568] and -1.96 | 1.1 Course code 2 |
| 3 | 1.1 | Closure or global values aren't part of jit's cache key; when they change, jit does not recompile and keeps the old value | 1.1 explanation; proved in 1.7 |
| 4 | 1.1/1.5 | The "vectorization" in vmap is vectorization; quantization is something else entirely | 1.1 explanation |
| 5 | 1.2 | A Python for loop inside jit is unrolled only when its length is static; with a traced length, range(n) raises TracerIntegerConversionError | start of 1.2 |
| 6 | 1.2 | When W_hh and W_xh share one w_key, W_xh equals the first 15 elements of W_hh; passing x_key = random.split(key) straight to normal raises ValueError. Use an independent subkey for each use | 1.2 Course code 2 |
| 7 | 1.2 | With xs=None, scan still calls the loop body with two arguments; writing just f(carry) raises TypeError | 1.2 Course code 3 |
| 8 | 1.2/1.8 | Pass weights as arguments of the jitted function rather than capturing them in a closure (avoids constant embedding and sidesteps this machine's CPU bug) | 1.2, 1.7, 1.8 |
| 9 | 1.3 | Using a Python if on a tracer inside jit actually raises TracerBoolConversionError (a subclass of ConcretizationTypeError) | 1.3 Course code |
| 10 | 1.3 | Comparisons aren't differentiable; gradients don't flow through cond's pred, and the gradient with respect to a threshold is always 0 | 1.3 explanation; proved in 1.5 |
| 11 | 1.4/1.5 | while_loop doesn't support reverse mode (raises ValueError), only jvp/jacfwd; for a fixed number of steps use fori_loop (static bounds) or scan | 1.4 "while_loop can't do reverse-mode differentiation" |
| 12 | 1.5 | In the scan RNN example, dL/dh0 ≈ 1.0749918 (matches the hand-derived formula and the central difference), not 0.313 | 1.5 "With grad" |
| 13 | 1.5 | The negative-case gradient of conditional_loss is [-1.00001 -1.00001] (= 1/(−1+1e−5)) | 1.5 "With grad" |
| 14 | 1.5 | In jax.grad(jax.vmap(threshold_from_params)) the vmap output is (2,3), so grad raises TypeError; sum first, and the gradient is always 0 | 1.5 "Combining several transformations" |
| 15 | 1.6 | JAX has no jnp.randn; generate random scores with jax.random.normal(jax.random.key(0), (3, 4)) | 1.6 attention masks |
| 16 | 1.6 | In scan, read the mask step by step as part of xs, not from the carry; written that way, results match running each sequence at its real length | 1.6 masks with control flow |
| 17 | 1.7 | Running jax.jit(apply_scale) again still gives [0. 2. 4.] (hits the same tracing cache); use clear_caches() or pass the value as an argument | 1.7 stale-value comparison cells |
| 18 | 1.7 | The Dense layer snippet uses jnp.dot and needs import jax.numpy as jnp | 1.7 best practices |
| 19 | 1.8 | The GRU example uses jax.random.key; split before generating vmap batch data instead of reusing key directly | 1.8 Course code |
| 20 | 1.8 | Added code that takes grad with respect to the GRU parameters, a float64 central-difference check, and a numerical cross-check against a Python loop | 1.8 |
| 21 | 1.4 | When cond_fun never becomes False, Ctrl-C usually can't interrupt the compiled XLA loop either (after sending SIGINT the process kept running; not asserted in the notebook); the only way out is restarting the kernel or killing the process. Add a maximum step count to the carry | 1.4 caveats |
| 22 | 1.7 | In 0.12, ClosedJaxpr has been merged into Jaxpr: make_jaxpr returns a Jaxpr directly, with constants in .consts | 1.7 staging and value capture |
Chapter summary¶
- The tracing model underlies everything: jit traces a Python function into a jaxpr. Arguments become input variables; closure or global values are frozen at tracing time and become literals or
constvars. The cache key includes the function object, the abstract information of the arguments (shape, dtype, etc.) and the values of static arguments, but not closure values. lax.scanis the workhorse for sequential computation: the loop body is traced once and compiled into an XLA loop; you can reverse-mode differentiate it (BPTT) and batch it with vmap. Remember that carry types must match,reversedoesn't flip the output, andunrollis a performance knob you have to measure.- Pick conditionals by situation: use
lax.cond/lax.switchfor scalar predicates with expensive branches; usejnp.where/lax.select_nfor elementwise selection. Once a vmap batch dimension reaches a cond, it becomes a select and both branches are computed. Gradients don't flow through the predicate. while_loopsupports forward mode only: when you need reverse mode, switch to afori_loopwith static bounds, or "scan up to the bound + mask". Under vmap, while_loop runs until the slowest element in the batch is done.- Two approaches to variable-length data: padding + mask (regular, easy to parallelize, but the padded compute is wasted), and
segment_*(no waste, butnum_segmentsmust be static and out-of-range ids are silently dropped). For masks, preferwhereover multiplication, and handle fully empty rows specially. - Memory for backprop over long sequences: wrap the scan loop body in
jax.checkpointto trade recomputation for memory; gradients don't change. - Engineering discipline: pass weights and data as arguments; cross-check scan implementations against a Python loop (float64); spot-check gradients with finite differences.
Cheat sheet¶
| API | What it does |
|---|---|
lax.scan(f, init, xs, length, reverse, unroll) | loop with a carry, collecting each step's output; f(c, x) -> (c', y) |
lax.associative_scan(op, xs) | parallel prefix scan for associative ops, depth O(log T) |
lax.fori_loop(lo, hi, body, init) | counting loop; rewritten as scan when bounds are static (reverse-mode differentiable), otherwise a while |
lax.while_loop(cond, body, init) | loop with dynamic termination; forward mode only (jvp/jacfwd) |
lax.cond(pred, t, f, *ops) | pick one of two with a scalar predicate; becomes a select when a vmap batch dimension reaches pred |
lax.switch(i, branches, *ops) | multi-branch; an out-of-range index is clamped to the valid range |
lax.select_n(which, *cases) | elementwise multi-way selection; for bool, False→case0, True→case1 |
jnp.where(c, x, y) | elementwise selection, computes both sides; watch out for NaN gradients, use the "double where" |
jnp.select(conds, choices, default) | elementwise selection with multiple conditions |
jax.nn.softmax(x, where=mask) | masked softmax |
jax.ops.segment_sum/max/min/prod(data, ids, num_segments) | per-segment reduction; under jit num_segments must be static |
jax.checkpoint(f, policy=...) / jax.remat | gradient checkpointing, trading recomputation for memory (see Advanced Chapter 6 §6.6) |
jax.ad_checkpoint.print_saved_residuals(f, *args) | shows which residuals backprop saves |
jax.checkpoint_policies.* | checkpointing policies such as dots_saveable, nothing_saveable |
jax.make_jaxpr(f)(*args) | view the jaxpr; .jaxpr.constvars / .consts are the embedded constants |
jax.jit(f).lower(*args).as_text() | view the StableHLO to check constant embedding and loop structure |
jitted._cache_size() | how many compiled versions a jitted function has cached |
jax.clear_caches() | clear the tracing and compilation caches |
jax.random.key / jax.random.split | the random key API recommended in 0.12 |
jax.tree.map / jax.tree.leaves / jax.tree.structure | pytree utilities (replacing the old jax.tree_util.tree_map; migration table in Basics 06 State management) |
print(f"This notebook passed {N_ASSERT} asserts ✓")
Appendix: reference solutions¶
Write your own version in the "✍️ Your answer" cells first and check it with "🧪 Self-check"; come here to compare once the self-check passes or you're truly stuck. Running the solutions below in order overwrites any functions with the same names that you defined in the answer cells.
Exercise 1¶
For the problem, see "Exercise 1: discounted returns with termination flags via a reverse scan (reinforcement learning)" in the main text.
# Reference solution
def discounted_returns(rewards, dones, gamma):
def body(g_next, inp):
r, d = inp
g = r + gamma * (1.0 - d) * g_next
return g, g
_, G = lax.scan(body, jnp.zeros((), rewards.dtype), (rewards, dones), reverse=True)
return G
def returns_py(r, d, gamma):
G, g = np.zeros(len(r)), 0.0
for t in reversed(range(len(r))):
g = r[t] + gamma * (1 - d[t]) * g; G[t] = g
return G
k1, k2 = jax.random.split(jax.random.key(7))
R = jax.random.uniform(k1, (5, 20))
Dn = (jax.random.uniform(k2, (5, 20)) < 0.15).astype(jnp.float32)
G = jax.jit(jax.vmap(discounted_returns, in_axes=(0, 0, None)))(R, Dn, 0.95)
for i in range(5):
check(np.allclose(G[i], returns_py(np.asarray(R[i], np.float64), np.asarray(Dn[i]), 0.95), atol=1e-5))
print("discounted returns match the Python loop ✓")
Exercise 2¶
For the problem, see "Exercise 2: a reverse-mode differentiable "bounded while": Newton's method for square roots" in the main text.
# Reference solution
def newton_sqrt_while(a):
def cond(c):
x, k = c
return (jnp.abs(x * x - a) >= 1e-6 * a) & (k < 30)
return lax.while_loop(cond, lambda c: (0.5 * (c[0] + a / c[0]), c[1] + 1), (a, 0))
def newton_sqrt_scan(a, max_iter=30):
def body(c, _):
x, k = c
active = jnp.abs(x * x - a) >= 1e-6 * a # same as the while version's cond
x_new = jnp.where(active, 0.5 * (x + a / x), x) # freeze once converged
return (x_new, k + active.astype(jnp.int32)), None
(x, k), _ = lax.scan(body, (a, jnp.int32(0)), None, length=max_iter)
return x, k
a_vals = jnp.array([2.0, 9.0, 1e4, 0.25])
x_s, k_s = jax.vmap(newton_sqrt_scan)(a_vals)
x_w, k_w = jax.vmap(newton_sqrt_while)(a_vals)
print("sqrt:", x_s, "| iterations scan:", k_s, "while:", k_w)
check(np.allclose(x_s, jnp.sqrt(a_vals), rtol=1e-6) and np.array_equal(k_s, k_w))
g = jax.vmap(jax.grad(lambda a: newton_sqrt_scan(a)[0]))(a_vals)
check(np.allclose(g, 0.5 / np.sqrt(np.asarray(a_vals)), rtol=1e-4))
try:
jax.grad(lambda a: newton_sqrt_while(a)[0])(2.0); ok = False
except ValueError:
ok = True
check(ok)
print("the scan version is differentiable, gradient = 1/(2√a) ✓; grad on the while version raises ✓")
Exercise 3¶
For the problem, see "Exercise 3: segment softmax (a basic operation in graph attention)" in the main text.
# Reference solution
@functools.partial(jax.jit, static_argnames="num_segments")
def segment_softmax(scores, seg, num_segments):
m = jax.ops.segment_max(scores, seg, num_segments=num_segments)
e = jnp.exp(scores - m[seg]) # gather back to each element
z = jax.ops.segment_sum(e, seg, num_segments=num_segments)
return e / z[seg]
k1 = jax.random.key(3)
seg_ids = jnp.array([0, 0, 0, 1, 2, 2, 2, 2, 3, 3])
sc = jax.random.normal(k1, (10,)) * 30.0 # large values, to test numerical stability
out = segment_softmax(sc, seg_ids, num_segments=4)
sc64, ids64 = np.asarray(sc, np.float64), np.asarray(seg_ids)
ref = np.empty_like(sc64)
for k in range(4):
s = sc64[ids64 == k]; e = np.exp(s - s.max()); ref[ids64 == k] = e / e.sum()
check(np.allclose(out, ref, atol=1e-6))
check(np.allclose(jax.ops.segment_sum(out, seg_ids, num_segments=4), 1.0, atol=1e-6))
print("segment softmax is correct, and each segment sums to 1 ✓")
Exercise 4¶
For the problem, see "Exercise 4: write a piecewise function with lax.switch and explain its cost under vmap" in the main text.
# Reference solution
def piecewise(x):
idx = jnp.where(x < 0, 0, jnp.where(x < jnp.pi, 1, 2))
return lax.switch(idx, [lambda v: v ** 2, jnp.sin, lambda v: v - jnp.pi], x)
def piecewise_select(x):
return jnp.select([x < 0, x < jnp.pi], [x ** 2, jnp.sin(x)], x - jnp.pi)
xv = jnp.array([-2.0, -0.5, 0.3, 2.0, 3.5, 6.0])
y1 = jax.vmap(piecewise)(xv); y2 = piecewise_select(xv)
check(np.allclose(y1, y2))
jp = str(jax.make_jaxpr(jax.vmap(piecewise))(xv))
check("cond[" not in jp and "select_n" in jp and "sin" in jp) # all three branches are computed over the batch
dy = jax.vmap(jax.grad(piecewise))(xv)
xv64 = np.asarray(xv, np.float64)
dy_ref = np.where(xv64 < 0, 2 * xv64, np.where(xv64 < np.pi, np.cos(xv64), 1.0))
check(np.allclose(dy, dy_ref, atol=1e-6))
print("f:", y1, "\nf':", dy)