Chapter 6: Managing State in JAX
Managing state in JAX with functional patterns
At its core, JAX is about pure functions plus function transformations like jit, grad and vmap. But real computations are full of "state": model parameters keep getting updated during training, optimizers have to remember momentum, loops carry intermediate results forward, and random number generators have to advance their internal state. These needs naturally clash with a pure-function style. This chapter covers JAX's answer: treat state as ordinary data, pass it into functions explicitly and get it back out as a return value, and use PyTrees to organize complex state.
Learning goals
- Explain the two conditions for a pure function (determinism, no side effects), and why
jit/grad/vmapdepend on them - Recognize the typical problems mutable state causes in JAX: code that runs only once during tracing, stale results, wrong gradients, and side effects that don't happen per example
- Get comfortable with the "explicit state passing" pattern:
new_state, out = f(state, inputs) - Organize PyTree state with dict / list / tuple / NamedTuple / dataclass, and manipulate it with the
jax.tree.*tools - Implement stateful computations such as a counter, SGD, momentum SGD and Adam, and combine them with
jit,grad,vmapandlax.scan - See how ecosystem libraries like Optax and Flax wrap state on top of this foundation
Sections
Original course page: https://apxml.com/courses/getting-started-with-jax/chapter-6-managing-state-in-jax
Environment: Python 3.14, JAX 0.12.0.1, CPU. All conclusions here are based on what actually ran in this environment.
How to read this: the explanations reorganize the course's key points, and all code has actually been run. Problematic patterns sit in ❌ cells, each followed right away by the ✅ recommended version; points that need extra care are marked "⚠️ Note", and the "Version notes" section at the end of the chapter collects them.
import sys
import warnings
from functools import partial
from typing import NamedTuple
from dataclasses import dataclass, field
import jax
import jax.numpy as jnp
from jax import lax
print("Python", sys.version.split()[0])
print("JAX ", jax.__version__)
print("Devices", jax.devices())
6.1 Functional purity and side effects¶
JAX transformations such as jit, grad, vmap and pmap want the functions they transform to be pure functions as far as possible. A pure function meets two conditions:
- Deterministic: the same inputs always produce the same outputs.
- No side effects: apart from computing its return value, the function has no observable effect on the outside world. Specifically, it does not:
- modify variables outside the function's local scope (global variables, for example);
- modify its arguments in place (for example,
appendto a list that was passed in); - do I/O (printing, reading or writing files, network requests);
- interact with external systems in any other implicit way.
Let's look at a few plain Python examples to tell pure from impure.
# Pure function: the output depends only on the inputs, and nothing outside is affected
def pure_add(a, b):
return a + b
# Impure: printing is an I/O side effect
def impure_add_and_print(a, b):
result = a + b
print(f"The result is {result}") # side effect
return result
# Impure: modifies a global variable
call_count = 0
def impure_increment_counter(x):
global call_count
call_count += 1 # side effect: modifies global state
return x + 1
# Impure: modifies an input argument in place
def impure_append_to_list(data_list, value):
data_list.append(value) # side effect: modifies the caller's list
return data_list
print(pure_add(2, 3))
impure_add_and_print(2, 3)
impure_increment_counter(1); impure_increment_counter(1)
print("call_count =", call_count)
lst = [1, 2]
impure_append_to_list(lst, 3)
print("The caller's list was changed:", lst)
Why does purity matter so much to JAX?
- Tracing: the first time
jax.jitis called, it doesn't run your Python code on concrete numbers. It runs the function once with abstract "traced values" (tracers), records the array operations inside as an intermediate representation, the Jaxpr, and hands that to XLA to compile. Side effects like printing or modifying a global variable happen only once, during tracing. Later calls that hit the cache run the compiled program directly, and the Python-level side effects never show up again. That's why aprintinside a jitted function usually prints only once. - Caching and optimization: JAX caches compiled results by input shape, dtype and so on, on the assumption that "the output is determined only by the inputs". If a function depends on global state, the cached program may use a stale value.
- Automatic differentiation:
gradneeds a clear "inputs → outputs" data flow. Side effects and in-place modification scramble the computation structure the chain rule relies on. - vmap / pmap: the mapped function is also traced only once; there are no separate Python "instances" each running on their own. A modification to shared state inside the function happens only once, and it modifies the tracer that stands for the whole batch, not once per example. If you want each example (or device) to have its own state, you have to pass the state explicitly as an input and output with a batch dimension (demonstrated in 6.2).
The state management problem
Training a model means updating weights, keeping momentum and counting steps. In ordinary Python or object-oriented code, this is usually written as "modify an object's attributes", like the counter class below. That collides head-on with purity. JAX's answer is explicit state passing: pass the current state in as an argument, and return the new state as part of the output (see 6.3).
# A typical object-oriented stateful style: the state hides inside the object, and count() modifies self.n every time
class Counter:
def __init__(self):
self.n = 0
def count(self):
self.n += 1
return self.n
def reset(self):
self.n = 0
my_counter = Counter()
print(my_counter.count()) # 1
print(my_counter.count()) # 2 (same call, different result: not a pure function)
🔍 Extra: the "runs only once during tracing" trap when modifying/reading globals inside jit¶
🟢 Must-read
We said earlier that "side effects happen only during tracing". Let's actually run the three most common traps:
- Writing a global variable: this happens only during tracing, and later calls don't change it again. Only when the input shape or dtype changes and triggers a retrace does it "occasionally" run again, so the count looks completely random.
- Reading a global variable: the value read during tracing is baked into the compiled result as a constant. If you change the global later, the cached version doesn't notice; only when a new shape triggers recompilation does it pick up the new value. The result is that the same function gives "a mix of old and new" results for inputs of different shapes.
- Debug printing: if you want to print values on every run, use
jax.debug.print. It's compiled into the program and outputs on every execution.
counter = 0
@jax.jit
def double(x):
global counter
counter += 1 # side effect: runs only during tracing
return x * 2
for i in range(3):
double(jnp.float32(i))
print("After 3 calls, counter =", counter, "(incremented only during the first trace)")
double(jnp.ones(3)) # new shape → retrace
print("After a call with a different shape, counter =", counter)
assert counter == 2
# Reading a global variable: the value is frozen at trace time
scale = 2.0
@jax.jit
def scaled(x):
return x * scale
print("With scale=2:", scaled(1.0))
scale = 10.0
print("After changing to scale=10, scalar input (cache hit):", scaled(1.0))
print("After changing to scale=10, new-shape input (recompiled):", scaled(jnp.ones(2)))
assert float(scaled(1.0)) == 2.0 and float(scaled(jnp.ones(2))[0]) == 10.0 # same function, old and new values side by side
# The right way to print for debugging
@jax.jit
def show(x):
print("Python print: appears only during tracing, x =", x)
jax.debug.print("jax.debug.print: appears on every run, x = {}", x)
return x + 1
show(1.0)
_ = show(2.0)
Conclusion: if you want a function's behavior to depend on some value, pass that value in as an argument (marked as a static argument if needed) instead of relying on a global variable. That's exactly where "explicit state passing" starts.
6.2 The challenge of state in functional code¶
JAX's jit and grad work by symbolically tracing pure functions. In standard Python, though, we're used to maintaining state through "in-place modification": append to a list, change a dict key, change an instance attribute. For example:
# Standard Python: modifying a dict in place
my_data = {'values': [10, 20]}
def update_data(data_dict, new_value):
data_dict['values'].append(new_value) # modify the list in place
data_dict['last_added'] = new_value # add a key in place
# no return needed: the caller's object has already been changed
update_data(my_data, 30)
print(my_data) # {'values': [10, 20, 30], 'last_added': 30}
Under JAX transformations, this style causes three kinds of problems:
- It breaks
jit: tracing captures the external state at that moment, and the compiled version may keep reusing that old value, so results come out wrong or not as expected. - It interferes with
grad: in-place modification blurs the data flow. You may get an error outright, or you may silently get mathematically wrong gradients, which sets a trap for training. - It gets in the way of
vmap/pmap: the mapped function is traced only once, so a modification to external mutable state inside it also happens only once, and what gets written is a tracer, not "one modification per example". Any independent per-example state has to be passed in as an argument with a batch dimension and passed back out as a return value.
Below is the course's example of "using JAX arrays but modifying an object's attribute in place". Without jit everything works; with jit, in JAX 0.12 it always raises a TypeError. The reason isn't the "attribute modification" itself: StateHolder is a plain Python object that is neither an array nor a registered PyTree, so jit doesn't know how to turn it into a traced value.
⚠️ Note: this code's behavior under jit isn't ambiguous, and it doesn't work in some versions and fail in others: it raises
TypeError: Error interpreting argument ... as an abstract arrayevery time. The case that really "goes wrong silently" is when you register the class as a PyTree (see the second half of this section).
⚠️ Note: the problem under vmap / pmap is not "multiple parallel instances reading and writing the same mutable state, causing conflicts and race conditions". vmap traces only once: the side effect of modifying an external list in the function body runs once for 5 examples, and what it gets is a
BatchTracer(verified with anassertin the second half of this section). The real problem is that the side effect doesn't happen per example at all, and it may leak a tracer out of the function.
# ❌ Wrong: jit a function that modifies an object's attribute in place
class StateHolder:
def __init__(self, value):
self.value = jnp.array(value)
def update_state_impurely(state_obj, increment):
# Impure: modifies the object's attribute in place
state_obj.value = state_obj.value + increment
return state_obj.value
# Without JIT: runs as ordinary Python and looks fine
my_state = StateHolder(10.0)
print("Non-JIT call 1:", update_state_impurely(my_state, 5.0)) # 15.0
print("Non-JIT call 2:", update_state_impurely(my_state, 3.0)) # 18.0
# With JIT: always raises TypeError
my_state_jit = StateHolder(10.0)
jitted_update = jax.jit(update_state_impurely)
stateholder_error = None
try:
print("JIT call 1:", jitted_update(my_state_jit, 5.0), my_state_jit.value)
except Exception as e:
stateholder_error = e
print(f"Actual result → {type(e).__name__}: {str(e)[:200]}")
assert isinstance(stateholder_error, TypeError), "StateHolder should reliably raise TypeError under jit"
# ✅ Fix: explicit state passing. The state is a plain array, the function returns the new state, and no object is modified
def update_state_pure(value, increment):
return value + increment
jitted_pure = jax.jit(update_state_pure)
v = jnp.array(10.0)
v = jitted_pure(v, 5.0); print("JIT call 1:", v)
v = jitted_pure(v, 3.0); print("JIT call 2:", v)
assert float(v) == 18.0
Why it works: jit only understands arrays and PyTrees. Turning the state into an array (or a PyTree) and passing it out through the return value solves both the "can't be traced" problem and the "side effect" problem at once.
A sneakier kind of "silent failure": if you register the class as a PyTree (see 6.4 for how), jit runs fine, but what it gets inside jit is a copy rebuilt from the leaves. Changing an attribute on the copy has no effect on the caller's object, so there's no error, but the result is wrong: in the example below, the outside s.value stays at 10 the whole time, and the second return value is 13 instead of 18.
Also, JAX arrays themselves are immutable: x[0] = 1 raises an error right away; use the functional x = x.at[0].set(1) instead. It returns a new array (inside jit, XLA can usually optimize it into an in-place update).
The end of the cell below also shows how side effects actually behave under vmap (this goes with the note about "race conditions" above).
# Demo: once registered as a PyTree, in-place modification "fails silently"
@jax.tree_util.register_pytree_node_class
class StateHolderPytree:
def __init__(self, value):
self.value = value
def tree_flatten(self):
return (self.value,), None # (children, auxiliary data)
@classmethod
def tree_unflatten(cls, aux, children):
return cls(*children)
s = StateHolderPytree(jnp.array(10.0))
f = jax.jit(update_state_impurely)
r1 = f(s, 5.0); print("Return value:", r1, " outside s.value:", s.value)
r2 = f(s, 3.0); print("Return value:", r2, " outside s.value:", s.value, " ← not accumulated! A silent error")
assert float(s.value) == 10.0 and float(r2) == 13.0 # the outside object was never modified
# JAX arrays are immutable
x = jnp.zeros(3)
try:
x[0] = 1.0
except TypeError as e:
print("TypeError:", str(e)[:110], "...")
print("Functional update x.at[0].set(1.0) →", x.at[0].set(1.0), "; the original array is still", x)
# Side effects under vmap: run only once during tracing and get a BatchTracer standing for the whole batch; there are no "multiple instances racing"
side_effect_log = []
def append_and_double(v):
side_effect_log.append(type(v).__name__) # side effect: modifies an external list
return v * 2
jax.vmap(append_and_double)(jnp.arange(5.0))
print("After vmap processed 5 examples, the external list was appended to", len(side_effect_log), "time(s):", side_effect_log)
assert side_effect_log == ["BatchTracer"]
Takeaway for this section: parameter updates, momentum, RNN hidden states and the like in training all need a different pattern: a function that updates state takes the current state as input and returns the new state as output, leaving the original state unchanged.
6.3 Pattern: explicit state passing¶
JAX's main way of handling state is explicit state passing, in three steps:
- Take the current state as an input argument;
- Compute with the state and the other inputs;
- Return the new, updated state (usually together with other results).
The state is passed around as ordinary data, and the original object is never modified. Model parameters, optimizer momentum and RNN hidden states can all be handled this way.
┌─────────────────────┐
current state ─▶│ │──▶ new state ──┐
│ pure function f │ │ (the caller keeps it and passes it in next time)
other inputs ──▶│ │──▶ other results
└─────────────────────┘ │
▲ │
└─────────────────────────────────┘
As a formula: $(s_{t+1},\ y_t) = f(s_t,\ x_t)$.
First, compare with the imperative style:
# Imperative: the state hides inside an object
class Counter:
def __init__(self):
self.count = 0
def increment(self):
self.count += 1
return self.count
c = Counter()
print(c.increment()) # 1
print(c.increment()) # 2
print("Final count:", c.count) # 2
# Functional: the state is passed in and returned explicitly
def init_counter_state():
"""Initialize the state."""
return 0
def increment(current_state):
"""Take the current state and return the new state."""
print(f"Running increment function with state: {current_state}") # for demonstration
new_state = current_state + 1
return new_state
state = init_counter_state()
print("Initial state:", state)
state = increment(state)
print("State after first increment:", state)
state = increment(state)
print("State after second increment:", state)
Key points:
- The state is passed to
incrementexplicitly as an argument; incrementreturns a new value instead of modifying anything;- The caller is responsible for keeping the returned new state and passing it back in on the next call;
incrementitself is a pure function (apart from theprintused for demonstration).
Because it's pure, it can be compiled with jit directly:
jitted_increment = jax.jit(increment)
state = init_counter_state()
# The first call triggers tracing and compilation, which runs the Python code (including the print)
state = jitted_increment(state)
print("State after JIT call 1:", state)
# Later calls use the compiled version, so the print inside no longer runs
state = jitted_increment(state)
print("State after JIT call 2:", state)
state = jitted_increment(state)
print("State after JIT call 3:", state)
The first time jit is called with state of a given type and shape, it traces the function and compiles it with XLA; later calls with compatible shapes/types use the cache directly. The flow of state from input to output is itself part of the traced logic, so the compiled update stays correct. The same pattern works for grad too.
In practice, state is often a nested structure, such as a dict of parameters or optimizer statistics (means, variances). That's exactly the problem PyTrees solve in the next section.
6.4 Using PyTrees for structured state¶
Neural network parameters usually consist of weight matrices and bias vectors for many layers, organized by layer; optimizer state may also keep momentum or an adaptive learning rate for every parameter. Passing dozens of arrays in and out by hand, one by one, is tedious and error-prone.
A PyTree isn't a type you can import from JAX. It's "a tree structure made of nested Python containers":
- Internal nodes: containers, most commonly
list,tupleanddict(plusNone,NamedTupleand others, see the Extra below); - Leaves: usually JAX arrays, or other non-container objects (Python scalars and so on).
For example, the parameters of a two-layer linear model:
params (dict)
├── layer1 (dict)
│ ├── weights : Array(3, 2) ← leaf
│ └── bias : Array(2,) ← leaf
└── layer2 (dict)
├── weights : Array(2, 1) ← leaf
└── bias : Array(1,) ← leaf
params = {
'layer1': {'weights': jnp.ones((3, 2)), 'bias': jnp.zeros((2,))},
'layer2': {'weights': jnp.ones((2, 1)), 'bias': jnp.zeros((1,))},
}
print(jax.tree.map(lambda x: x.shape, params))
JAX transformations support PyTrees out of the box. When jax.jit, jax.grad, jax.vmap or jax.pmap take or return a PyTree, they walk the tree structure automatically and apply their logic to the leaves, while keeping the container structure unchanged:
- the gradient returned by
jax.gradhas the same PyTree structure asparams; jax.vmaphandles the batch dimension on each leaf of the input PyTree;jithandles the container structure automatically when compiling, so you don't have to flatten, update and rebuild the dict by hand.
To update parameters, use jax.tree.map to apply a function leaf by leaf across one or more PyTrees with the same structure; it returns a new PyTree with the same structure:
⚠️ Note: older code commonly uses
jax.tree_util.tree_map. It still works in 0.12 and gives exactly the same result, but the officially recommended form isjax.tree.map, and that's what we use from here on. The full old-vs-new comparison is in the migration table later in this section.
def update_params(params, grads, learning_rate):
"""Apply one SGD update to every leaf of the PyTree."""
def sgd_update(param, grad):
return param - learning_rate * grad
return jax.tree.map(sgd_update, params, grads)
# Placeholder gradients (same structure as params)
grads = {
'layer1': {'weights': jnp.full((3, 2), 0.1), 'bias': jnp.full((2,), 0.01)},
'layer2': {'weights': jnp.full((2, 1), 0.2), 'bias': jnp.full((1,), 0.02)},
}
learning_rate = 0.01
updated_params = update_params(params, grads, learning_rate)
print("layer1 weights:\n", updated_params['layer1']['weights'])
print("layer2 bias:", updated_params['layer2']['bias'])
# jit update_params directly; structure and values match the uncompiled version
jit_update_params = jax.jit(update_params)
updated_jit = jit_update_params(params, grads, learning_rate)
same = jax.tree.all(jax.tree.map(jnp.allclose, updated_params, updated_jit))
print("jit result matches the non-jit result:", bool(same))
assert same
Common functions in the jax.tree module (jax.tree_util has functions with the same names, and they're equivalent):
jax.tree.map(f, tree, *rest): apply a function leaf by leaf;jax.tree.leaves(tree): pull out all the leaves into a flat list;jax.tree.unflatten(treedef, leaves): rebuild a tree from a structure definition and leaves.
You usually only use these directly when your own function needs to work with the leaves explicitly (like update_params above); the rest of the time, JAX transformations quietly take care of the structure.
Summary: organize complex state with standard dicts, lists and tuples, and JAX handles the structure automatically inside transformations. That lets you write concise functional code while getting compilation, automatic differentiation and vectorization (vectorization, not quantization, which is something else).
🔍 Extra: PyTree API migration table (old → new, as tested on 0.12)¶
⚪ Optional: look here when migrating old code or checking the old and new forms of a tree API.
JAX now recommends the jax.tree module. jax.tree_util.* is still there and fully usable (some features, like registering custom nodes and keystr, exist only in tree_util). What was actually removed are the old aliases on the top-level jax namespace (jax.tree_map and friends): they were deprecated first, and by 0.12 they're gone, so calling them raises AttributeError. Section 6.7 runs into old code that uses jax.tree_map.
| Old style | Status in 0.12 | New style (recommended) |
|---|---|---|
jax.tree_map(f, t, *rest) | ❌ Removed | jax.tree.map |
jax.tree_leaves(t) | ❌ Removed | jax.tree.leaves |
jax.tree_structure(t) | ❌ Removed | jax.tree.structure |
jax.tree_flatten(t) / jax.tree_unflatten(d, l) | ❌ Removed | jax.tree.flatten / jax.tree.unflatten |
jax.tree_transpose(o, i, t) | ❌ Removed | jax.tree.transpose |
jax.tree_reduce(f, t) | ❌ Removed | jax.tree.reduce |
jax.tree_util.tree_multimap(f, t, *rest) | ❌ Removed (earlier) | jax.tree.map with several trees |
jax.tree_util.tree_map | ✅ Available | jax.tree.map |
jax.tree_util.tree_leaves / tree_structure | ✅ Available | jax.tree.leaves / jax.tree.structure |
jax.tree_util.tree_flatten / tree_unflatten | ✅ Available | jax.tree.flatten / jax.tree.unflatten |
jax.tree_util.tree_all / tree_reduce | ✅ Available | jax.tree.all / jax.tree.reduce |
jax.tree_util.tree_transpose | ✅ Available | jax.tree.transpose |
jax.tree_util.tree_map_with_path / tree_leaves_with_path | ✅ Available | jax.tree.map_with_path / jax.tree.leaves_with_path |
jax.tree_util.tree_broadcast | ✅ Available | jax.tree.broadcast |
The cell below first verifies the "status" column of this table with assert, then demonstrates the new APIs one by one.
# Self-verifying the migration table: none of the top-level old aliases exist, and both the tree_util and jax.tree versions do
removed_top_level = ["tree_map", "tree_leaves", "tree_structure", "tree_flatten",
"tree_unflatten", "tree_transpose", "tree_reduce"]
for name in removed_top_level:
assert not hasattr(jax, name), f"jax.{name} should have been removed"
assert not hasattr(jax.tree_util, "tree_multimap")
still_in_tree_util = ["tree_map", "tree_leaves", "tree_structure", "tree_flatten", "tree_unflatten",
"tree_all", "tree_reduce", "tree_transpose", "tree_map_with_path",
"tree_leaves_with_path", "tree_broadcast"]
for name in still_in_tree_util:
assert hasattr(jax.tree_util, name), f"jax.tree_util.{name} should still be available"
new_api = ["map", "leaves", "structure", "flatten", "unflatten", "all", "reduce",
"transpose", "map_with_path", "leaves_with_path", "broadcast"]
for name in new_api:
assert hasattr(jax.tree, name), f"jax.tree.{name} should exist"
print("Migration table verified; the jax.tree module provides:", [n for n in dir(jax.tree) if not n.startswith("_")])
# ❌ Old style (outdated): the top-level alias jax.tree_map was removed in 0.12
try:
jax.tree_map(lambda x: x * 2, {'a': 1.0})
except AttributeError as e:
print("Actual result → AttributeError:", e)
# ✅ Recommended: jax.tree.*, one function at a time
import operator
tree = {'b': jnp.arange(3.), 'a': (1.0, None, [2.0, 3.0])}
# (1) map: one tree, or several trees (several trees must have the same structure)
print("map, one tree:", jax.tree.map(lambda x: x * 2, {'w': 1.0, 'b': (2.0, 3.0)}))
summed = jax.tree.map(lambda a, b, c: a + b + c, {'w': 1.0, 'b': 2.0}, {'w': 10.0, 'b': 20.0}, {'w': 100.0, 'b': 200.0})
print("map, three trees:", summed)
assert summed == {'w': 111.0, 'b': 222.0}
try:
jax.tree.map(lambda a, b: a + b, {'w': 1.0}, {'w': 1.0, 'b': 2.0})
except ValueError as e:
print("Mismatched structures → ValueError:", str(e).splitlines()[0])
# (2) leaves: None is an "empty subtree", not a leaf; dicts are flattened in sorted key order ('a' before 'b')
lv = jax.tree.leaves(tree)
print("leaves:", lv)
assert len(lv) == 4 and lv[0] == 1.0
# (3) structure
print("structure:", jax.tree.structure(tree))
# (4) flatten / unflatten: a round trip
leaves, treedef = jax.tree.flatten(tree)
rebuilt = jax.tree.unflatten(treedef, [x * 10 for x in leaves])
print("unflatten:", rebuilt)
assert jax.tree.structure(rebuilt) == treedef
# (5) map_with_path: get the key path of each leaf, e.g. to apply weight decay to weights only
decayed = jax.tree.map_with_path(
lambda path, x: x * 0.9 if 'weights' in jax.tree_util.keystr(path) else x, params)
print("map_with_path paths:", jax.tree.map_with_path(lambda p, x: jax.tree_util.keystr(p), params))
assert jnp.allclose(decayed['layer1']['weights'], 0.9) and jnp.allclose(decayed['layer1']['bias'], 0.0)
# (6) reduce: reduce all leaves to a single value, e.g. count parameters or compute the global gradient norm
n_params = jax.tree.reduce(operator.add, jax.tree.map(lambda x: x.size, params))
global_norm = jnp.sqrt(jax.tree.reduce(operator.add, jax.tree.map(lambda g: jnp.sum(g ** 2), grads)))
print("reduce, parameter count:", n_params, " global gradient norm:", global_norm)
assert n_params == 11
# (7) all: true only if every leaf is true
print("all:", jax.tree.all({'x': True, 'y': [True, True]}), jax.tree.all({'x': True, 'y': [True, False]}))
assert jax.tree.all({'x': True, 'y': [True, True]}) and not jax.tree.all({'x': True, 'y': [True, False]})
# (8) transpose: "list of dicts" ↔ "dict of lists", handy for regrouping per-step metrics by name
records = [{'loss': 1.0, 'acc': 0.5}, {'loss': 0.8, 'acc': 0.6}, {'loss': 0.5, 'acc': 0.7}]
outer = jax.tree.structure([0] * len(records))
inner = jax.tree.structure({'loss': 0, 'acc': 0})
by_key = jax.tree.transpose(outer, inner, records)
print("transpose:", by_key)
assert by_key == {'acc': [0.5, 0.6, 0.7], 'loss': [1.0, 0.8, 0.5]}
🔍 Extra: custom state containers: NamedTuple, register_dataclass, register_pytree_node_class¶
⚪ Optional: read this if you want to use a container with named fields (a TrainState and so on) instead of a dict.
Dicts are convenient to write, but a mistyped key only shows up at run time. In practice, "containers with named fields" are more common for representing state:
typing.NamedTuple: a PyTree out of the box (JAX recognizes it automatically). It's immutable, and._replace()gives you a modified copy. Section 6.6 uses it.jax.tree_util.register_dataclass: registers a@dataclassas a PyTree. You can specifydata_fields/meta_fieldsexplicitly, or just use it as a decorator and let JAX infer from the fields. A field withfield(metadata=dict(static=True))becomes static metadata: it isn't traced, and a change in its value triggers recompilation. It's a good idea to pair it withfrozen=True, which rules out in-place modification at the syntax level.jax.tree_util.register_pytree_node_class: the most flexible; you writetree_flatten/tree_unflattenyourself. Good for classes with an unusual structure.
# 1) NamedTuple
class TrainState(NamedTuple):
step: jax.Array
params: dict
ts = TrainState(step=jnp.array(0), params={'w': jnp.ones(2)})
ts2 = ts._replace(step=ts.step + 1) # creates a new object; the original is unchanged
print("NamedTuple structure:", jax.tree.structure(ts))
print("ts.step =", ts.step, " ts2.step =", ts2.step)
# 2) register_dataclass: specify the data fields and metadata fields explicitly
@partial(jax.tree_util.register_dataclass, data_fields=['w', 'b'], meta_fields=['name'])
@dataclass(frozen=True)
class Linear:
w: jax.Array
b: jax.Array
name: str # static metadata, not a leaf
lin = Linear(jnp.ones(2), jnp.zeros(2), 'fc1')
print("dataclass leaves:", jax.tree.leaves(lin))
print("dataclass structure:", jax.tree.structure(lin))
# You can also use it directly as a decorator and let the field metadata decide what's static
@jax.tree_util.register_dataclass
@dataclass(frozen=True)
class AdamConfig:
lr: jax.Array
b1: float = field(default=0.9, metadata=dict(static=True))
print("Inferred:", jax.tree.structure(AdamConfig(jnp.array(1e-3))))
# frozen=True: an attempt to modify in place errors immediately
try:
lin.w = jnp.zeros(2)
except Exception as e:
print(f"{type(e).__name__}: {e}")
# All of these containers pass straight through jit / grad
@jax.jit
def forward(layer, x):
return x @ layer.w + layer.b.sum()
print("jit forward:", forward(lin, jnp.array([1.0, 2.0])))
print("Gradient with respect to the dataclass:", jax.grad(lambda l: forward(l, jnp.array([1.0, 2.0])))(lin))
6.5 Example: a stateful counter¶
First, recall the standard Python counter class. It relies on mutable state (a side effect) and isn't compatible with JAX: if you apply jit to increment directly, JAX can't trace the in-place change to self.count.
class Counter:
def __init__(self):
self.count = 0
def increment(self):
self.count += 1 # in-place modification: a side effect
return self.count
counter_obj = Counter()
print(counter_obj.increment(), counter_obj.increment())
A functional implementation: the state is just an ordinary value. The update function takes the old state and returns the new state. The output of one call is the input to the next, and that's how the state "flows" through a chain of pure function calls.
initial_state = 0
def update_counter(current_state):
"""Increment the input state by 1."""
print("Tracing update_counter...") # shows up only during tracing (for demonstration)
return current_state + 1
state1 = update_counter(initial_state)
state2 = update_counter(state1)
state3 = update_counter(state2)
print("Final state:", state3)
Compatible with JIT: after jax.jit(update_counter), calling it three times in a row prints "Tracing update_counter..." only once; the later calls use the cached compiled version. This shows that jit handles state managed in a functional way correctly. It only traces again when the input's abstract signature (shape, dtype, weak-type flag) changes.
⚠️ Note: you'll sometimes hear that "when the input type changes, say from a Python int to a JAX tracer, you may get multiple traces". That's not accurate: a tracer is jit's internal traced value, not a type the caller passes in. When you start calling from the Python
int0, what comes back is a weakly typedint32array whose signature is the same as a Pythonint's, so it traces only once (verified withassertbelow). The case that really causes an extra trace is a weak type turning into a strong type; see Exercise 2 in 6.8.
counter_traces = []
def update_counter_counted(current_state):
counter_traces.append(1) # runs only during tracing; used to count traces
return update_counter(current_state)
jitted_update_counter = jax.jit(update_counter_counted)
s = initial_state
for i in range(3):
s = jitted_update_counter(s)
print(f"After call {i + 1}: {s}")
assert len(counter_traces) == 1 and int(s) == 3
Iterating with lax.scan
Calling a jitted function over and over in a Python loop costs Python dispatch overhead every time. jax.lax.scan hands the whole job of "repeatedly applying a body function to an accumulated state" to the compiler:
- Body function signature:
body(carry, x) -> (new_carry, y)carry: the state passed from step to step;x: this step's input slice (fromxs; can beNone);y: this step's output. Theyvalues from all steps are stacked along a new axis 0 and returned.
- Call:
final_carry, ys = lax.scan(body, init_carry, xs, length=...)
Its semantics are equivalent to this Python code:
def scan(f, init, xs, length=None):
carry, ys = init, []
for x in xs: # if xs is None, loop length times with x = None
carry, y = f(carry, x)
ys.append(y)
return carry, stack(ys)
def scan_body(carry_state, _):
next_state = update_counter(carry_state)
return next_state, None # no extra output per step
initial_scan_state = 0
num_steps = 10
final_state, accumulated_outputs = jax.lax.scan(
scan_body, initial_scan_state, xs=None, length=num_steps)
print("Final state after scan:", final_state)
print("Per-step outputs:", accumulated_outputs) # each step outputs None, so the whole thing is None too
Put lax.scan inside jax.jit and the whole loop compiles into one optimized program, which is much faster than calling a jitted function over and over in a Python loop. One thing to watch: scan's length must be a Python integer known at compile time, because it determines the shape of the output ys. So the number of iterations must either be passed to jit as a static argument, or you switch to lax.fori_loop, which allows a dynamic count.
⚠️ Note: if, as in
run_scan_jitted(init_state, steps), you passstepsas an ordinary jit argument straight intolength, you getConcretizationTypeError: The length argument to scan expects a concrete int value. Below we first run that version, then show two fixes.
# ❌ Wrong: steps is passed into jit as an ordinary argument and then into scan's length
@jax.jit
def run_scan_jitted(init_state, steps):
final, _ = jax.lax.scan(scan_body, init_state, xs=None, length=steps)
return final
scan_error = None
try:
print(run_scan_jitted(initial_scan_state, num_steps))
except Exception as e:
scan_error = e
print(f"Actual result → {type(e).__name__}:\n{str(e)[:250]}")
assert isinstance(scan_error, jax.errors.ConcretizationTypeError)
# ✅ Fix 1: mark steps as static with static_argnames (each distinct value of steps compiles once)
@partial(jax.jit, static_argnames="steps")
def run_scan_jitted(init_state, steps):
final, _ = jax.lax.scan(scan_body, init_state, xs=None, length=steps)
return final
r_static = run_scan_jitted(initial_scan_state, steps=num_steps)
print("Fix 1 (static_argnames):", r_static)
assert int(r_static) == 10
# ✅ Fix 2: when the loop count needs to change at run time and you don't need per-step outputs, use fori_loop
@jax.jit
def run_fori(init_state, steps):
return lax.fori_loop(0, steps, lambda i, c: update_counter(c), init_state)
r_fori_10, r_fori_25 = run_fori(initial_scan_state, 10), run_fori(initial_scan_state, 25)
print("Fix 2 (fori_loop):", r_fori_10, r_fori_25)
assert int(r_fori_10) == 10 and int(r_fori_25) == 25
Why: ordinary jit arguments are abstract tracers during tracing, with only a shape and a dtype and no concrete value; scan needs a concrete step count to determine the output shape. static_argnames lets steps take part in compilation as a Python integer (at the cost of recompiling for every new value); when fori_loop's upper bound is a dynamic value, it becomes a while_loop underneath, so the count can change at run time, but reverse-mode differentiation isn't supported.
Scaling up to complex state: the same pattern works for nested dicts or lists of JAX arrays (model parameters, optimizer statistics and so on). As long as the update function takes the whole PyTree and returns the whole updated PyTree, JAX transformations handle it correctly.
Summary: treat state as an immutable value and pass it into and out of pure functions explicitly. That keeps you compatible with transformations like jit, and lets you use efficient execution tools like lax.scan.
🔍 Extra: lax.scan's carry is the state; record a trajectory with per-step outputs¶
🟢 Must-read
The example above outputs None at every step, so it doesn't show off the most useful side of scan: the carry is the state, and y is "whatever you want to record at each step". Below, a PyTree state with two fields (running sum, step count) goes into the carry, and the mean at each step is collected as ys. A few rules:
- The carry's PyTree structure, shapes and dtypes must stay the same at every step, or you get an error (a common cause: the initial value is
int32, but the body function returnsfloat32). There's one exception: if the initial value is weakly typed (a Python scalar, orjnp.array(0)without a dtype),scanfirst promotes it to the output's type and doesn't error; xscan be a PyTree, andscanslices each leaf along axis 0;scanitself can be transformed further bygradandvmap.
def running_mean_body(carry, x):
new_carry = {'sum': carry['sum'] + x, 'n': carry['n'] + 1}
y = new_carry['sum'] / new_carry['n'] # per-step output: the current mean
return new_carry, y
xs = jnp.array([2.0, 4.0, 6.0, 8.0, 10.0])
init = {'sum': jnp.array(0.0), 'n': jnp.array(0)}
final, means = lax.scan(running_mean_body, init, xs)
print("Final state:", final)
print("Mean at each step:", means)
assert jnp.allclose(means, jnp.array([2.0, 3.0, 4.0, 5.0, 6.0]))
# A carry whose dtype changes raises an error
def add_body(carry, x):
return carry + x, None # int state + float input → float
try:
lax.scan(add_body, jnp.array(0, dtype=jnp.int32), xs) # explicit int32 initial state
except TypeError as e:
print("dtype mismatch → TypeError:", next(l for l in str(e).splitlines() if "input carry" in l)[:150])
# Exception: weak types (Python scalars, values like jnp.array(0) with no dtype given) are allowed to be promoted
print("Weakly typed initial value jnp.array(0):", lax.scan(add_body, jnp.array(0), xs)[0], "(no error)")
# A shape mismatch raises an error too
try:
lax.scan(lambda c, x: (jnp.stack([c, c]), None), jnp.array(0.0), xs)
except TypeError as e:
print("shape mismatch → TypeError:", next(l for l in str(e).splitlines() if "input carry" in l)[:150])
🔍 Extra: a random key is itself a piece of state that must be passed explicitly¶
🟢 Must-read
JAX's PRNG is stateless: a random function depends only on the key passed in, and the same key always gives the same result. So a key has to be passed in and out explicitly like a parameter, and each key is used only once: key, subkey = jax.random.split(key), sample with subkey, and keep passing key along; inside lax.scan, put the key in the carry. For the difference between jax.random.key and the old PRNGKey, see Chapter 1; for a full demonstration of management patterns such as split / fold_in / scan carry, plus common pitfalls, see Advanced 10, "Random numbers and sharding", §5–§6.
6.6 Example: simple optimizer state¶
Optimizers in machine learning (SGD, Adam and so on) update parameters step by step based on gradients. Many optimizers also keep internal state, such as a momentum vector or a separate adaptive learning rate for each parameter.
The most basic gradient descent:
$$ \theta_{\text{new}} = \theta_{\text{old}} - \eta\, \nabla f(\theta_{\text{old}}) $$
where $\eta$ is the learning rate. Take minimizing $f(x) = (x-3)^2$ as an example: the minimum is at $x=3$, and the gradient is $f'(x) = 2(x-3)$.
def objective_function(x):
return (x - 3.0) ** 2
grad_fn = jax.grad(objective_function)
def sgd_update(params, gradients, learning_rate):
"""Pure function: return new parameters based on the gradients."""
return params - learning_rate * gradients
current_params = jnp.array(0.0)
learning_rate = 0.1
num_steps = 20
print(f"Initial parameter: {current_params:.4f}")
for step in range(num_steps):
gradients = grad_fn(current_params)
next_params = sgd_update(current_params, gradients, learning_rate)
current_params = next_params # explicitly catch the new state
if (step + 1) % 5 == 0:
print(f"Step {step + 1:2d}: parameter = {current_params:.4f}, gradient = {gradients:.4f}")
print(f"Final optimized parameter: {current_params:.4f}")
sgd_final = float(current_params) # keep it to compare with momentum later
sgd_update is a pure function: the "parameters" state is passed in explicitly and returned again.
Adding optimizer state: SGD with momentum
Momentum keeps a "velocity" $v$. It accumulates past gradients, which smooths the updates and speeds things up when the gradient keeps pointing the same way:
$$ v_{\text{new}} = \mu\, v_{\text{old}} + \eta\, \nabla f(\theta_{\text{old}}), \qquad \theta_{\text{new}} = \theta_{\text{old}} - v_{\text{new}} $$
where $\mu$ is the momentum coefficient. The state now has two parts (parameters and velocity), so we bundle them with typing.NamedTuple (a dict {'params': ..., 'velocity': ...} would work too). A NamedTuple is a valid PyTree on its own.
Momentum isn't faster on every problem. Its advantage is clearest on ill-conditioned problems (where the curvature differs a lot between directions). On a 1-D quadratic like $f(x)=(x-3)^2$ with curvature 2, with $\eta=0.1$ and momentum set to 0.9, the system is underdamped and oscillates back and forth around the optimum.
⚠️ Note: demonstrating momentum with $\eta=0.1,\ \mu=0.9$ actually does worse than plain SGD: after 20 steps the parameter sits at 4.0478, further from the optimum 3 than plain SGD's 2.9654. Setting $\mu$ to 0.5 converges to 2.9992 in 20 steps. Below we first run the $\mu=0.9$ version, then the tuned one.
# ❌ Poor hyperparameter: momentum=0.9 is underdamped on this problem and doesn't converge within 20 steps
class OptimizerState(NamedTuple):
params: jax.Array
velocity: jax.Array
def momentum_update(state, gradients, learning_rate, momentum):
"""Pure function: take the old optimizer state and return the new optimizer state."""
new_velocity = momentum * state.velocity + learning_rate * gradients
new_params = state.params - new_velocity
return OptimizerState(params=new_params, velocity=new_velocity)
initial_params = jnp.array(0.0)
initial_velocity = jnp.array(0.0)
current_state = OptimizerState(params=initial_params, velocity=initial_velocity)
learning_rate = 0.1
momentum_coeff = 0.9
num_steps = 20
print(f"Initial state: parameter = {current_state.params:.4f}, velocity = {current_state.velocity:.4f}")
for step in range(num_steps):
gradients = grad_fn(current_state.params)
current_state = momentum_update(current_state, gradients, learning_rate, momentum_coeff)
if (step + 1) % 5 == 0:
print(f"Step {step + 1:2d}: parameter = {current_state.params:.4f}, "
f"velocity = {current_state.velocity:.4f}, gradient = {gradients:.4f}")
print(f"Final optimized parameter: {current_state.params:.4f}")
momentum09_final = float(current_state.params)
print(f"Measured: |momentum μ=0.9 − 3| = {abs(momentum09_final - 3):.4f} > |plain SGD − 3| = {abs(sgd_final - 3):.4f}")
assert abs(momentum09_final - 3) > abs(sgd_final - 3) # momentum with μ=0.9 actually does worse than plain SGD
# ✅ Fix: the same momentum_update, with only the momentum coefficient changed to 0.5 (learning rate unchanged)
def run_momentum(mu, lr=0.1, steps=20):
st = OptimizerState(params=jnp.array(0.0), velocity=jnp.array(0.0))
for _ in range(steps):
st = momentum_update(st, grad_fn(st.params), lr, mu)
return float(st.params)
for mu_ in [0.0, 0.3, 0.5, 0.9]:
print(f"momentum={mu_}: parameter after 20 steps = {run_momentum(mu_):.4f}")
momentum05_final = run_momentum(0.5)
assert abs(momentum05_final - 3) < 1e-2 # converges after tuning
assert abs(momentum05_final - 3) < abs(sgd_final - 3) # and beats plain SGD
Why: on a quadratic, momentum behaves like a second-order linear system. When $\mu$ is too large there isn't enough damping, so the parameter overshoots the optimum and swings back; a moderate $\mu$ converges fastest. In practice 0.9 is the usual default momentum, but it often needs a smaller learning rate to go with it.
Compatibility with JAX transformations: momentum_update is a pure function whose output depends only on its inputs, so jax.jit can trace and compile it without trouble. Numeric hyperparameters like the learning rate and momentum coefficient should be passed in as ordinary (dynamic) arguments: they're just scalars and don't change the program's structure, and as dynamic arguments every value shares the same compiled result. Save static arguments for things that really affect the program structure (shapes, loop counts, boolean switches, the choice of activation function, and so on). For the full table of what triggers recompilation, see Chapter 2, section 2.3; for how to deal with it, see Chapter 7, topic 1.
⚠️ Note: a common pattern is
jax.jit(momentum_update, static_argnums=(2, 3)), makinglearning_rateandmomentumstatic arguments, on the grounds that "marking them static avoids unnecessary recompilation". It's actually the opposite: the values of static arguments are part of the compilation cache key, so every new value triggers a recompile. Try it with a 5-value learning rate schedule: the static version traces 5 times, the dynamic version only once. With a constant learning rate both compile only once and the code itself runs fine; but as soon as this habit meets a learning rate schedule, it recompiles on every step.
# ❌ Wrong: learning rate and momentum as static arguments (recompiles every step with a learning rate schedule)
momentum_traces = []
def momentum_update_logged(state, gradients, learning_rate, momentum):
momentum_traces.append(learning_rate) # runs only during tracing; used to count traces
return momentum_update(state, gradients, learning_rate, momentum)
jitted_momentum_update = jax.jit(momentum_update_logged, static_argnums=(2, 3))
schedule = [0.1, 0.09, 0.08, 0.07, 0.06] # simulated learning rate decay
current_state = OptimizerState(params=jnp.array(0.0), velocity=jnp.array(0.0))
print("--- Running the JIT-compiled update (static learning rate) ---")
for lr_t in schedule:
current_state = jitted_momentum_update(current_state, grad_fn(current_state.params), lr_t, 0.5)
print(f"parameter = {current_state.params:.4f}; traces = {len(momentum_traces)}")
assert len(momentum_traces) == 5
# ✅ Fix: pass the learning rate and momentum as ordinary (dynamic) arguments
momentum_traces.clear()
jitted_momentum_update = jax.jit(momentum_update_logged)
current_state = OptimizerState(params=jnp.array(0.0), velocity=jnp.array(0.0))
print("--- Running the JIT-compiled update (dynamic learning rate) ---")
for lr_t in schedule:
current_state = jitted_momentum_update(current_state, grad_fn(current_state.params), lr_t, 0.5)
print(f"parameter = {current_state.params:.4f}; traces = {len(momentum_traces)}")
assert len(momentum_traces) == 1
# Constant learning rate, full 20 steps: matches the uncompiled version
current_state = OptimizerState(params=jnp.array(0.0), velocity=jnp.array(0.0))
for step in range(num_steps):
current_state = jitted_momentum_update(current_state, grad_fn(current_state.params), 0.1, 0.5)
print(f"Final optimized parameter (JIT-compiled, μ=0.5): {current_state.params:.4f}")
assert jnp.allclose(current_state.params, momentum05_final)
Summary: explicit state passing plus a PyTree structure (here a NamedTuple) gives you training loops that are clear, easy to manage, and compatible with both jit and grad. It's the foundation for building more complex models and training pipelines.
🔍 Extra: two ways to write the momentum formula¶
⚪ Optional: read this when comparing against the momentum formula in PyTorch / Optax or a paper.
The formulation of Sutskever et al. (the one used above) puts the learning rate inside the velocity: $v \leftarrow \mu v + \eta g,\ \theta \leftarrow \theta - v$. PyTorch and Optax's sgd(momentum=...) use $v \leftarrow \mu v + g,\ \theta \leftarrow \theta - \eta v$. With a constant learning rate the two are exactly equivalent ($v_{\text{Sutskever}} = \eta\, v_{\text{PyTorch}}$); when the learning rate changes over time, they behave differently. Keep this in mind when porting code or comparing with a paper.
# Check that the two momentum formulas are equivalent with a constant learning rate
g_fn = jax.grad(lambda x: (x - 3.0) ** 2)
eta, mu = 0.1, 0.9
x1, v1 = 0.0, 0.0 # Sutskever form
x2, v2 = 0.0, 0.0 # PyTorch / Optax form
for _ in range(20):
g1 = g_fn(x1); v1 = mu * v1 + eta * g1; x1 = x1 - v1
g2 = g_fn(x2); v2 = mu * v2 + g2; x2 = x2 - eta * v2
print(f"Sutskever form: {float(x1):.6f} PyTorch/Optax form: {float(x2):.6f}")
assert jnp.allclose(x1, x2, atol=1e-5)
🔍 Extra: generic PyTree optimizers (momentum SGD and Adam) + a full training loop¶
⚪ Optional: read this if you want to implement optimizers that work on any PyTree yourself, or to understand Optax's interface; Extra exercises 3 and 4 use the code here.
The optimizers so far handle a single scalar parameter. In real use, the parameters are a PyTree, and the optimizer state should be a PyTree whose structure matches it. Below, following Optax's interface, we write two optimizers that work on any PyTree:
init(params) -> opt_state: create the initial state from the parameter structure;update(grads, opt_state, params) -> (updates, new_opt_state): return the parameter increments and the new state;apply_updates(params, updates): add the increments to the parameters.
The state of momentum SGD (Optax/PyTorch form) is the velocity $v$ (called trace in Optax):
$$ v_t = \mu\, v_{t-1} + g_t, \qquad \Delta\theta_t = -\eta\, v_t, \qquad v_0 = 0 . $$
The state of Adam consists of the step count $t$, the first moment $m$ and the second moment $v$:
$$ \begin{aligned} m_t &= \beta_1 m_{t-1} + (1-\beta_1)\, g_t, & v_t &= \beta_2 v_{t-1} + (1-\beta_2)\, g_t^2, & m_0 = v_0 &= 0,\\ \hat m_t &= \frac{m_t}{1-\beta_1^{\,t}}, & \hat v_t &= \frac{v_t}{1-\beta_2^{\,t}}, & t &= 1, 2, \dots\\ \Delta\theta_t &= -\eta\,\frac{\hat m_t}{\sqrt{\hat v_t + \epsilon_{\text{root}}}+\epsilon}. & & & & \end{aligned} $$
What bias correction is for: $m_0=v_0=0$ biases the early moment estimates toward 0 (at step 1, $m_1=(1-\beta_1)g_1$, only 10% of the true gradient). Dividing by $1-\beta^t$ cancels exactly this bias: at step 1, $\hat m_1 = g_1$ and $\hat v_1 = g_1^2$, so the first update has a size of about $\eta$, regardless of the gradient's magnitude.
These two optimizers correspond to Optax's trace + scale_by_learning_rate and scale_by_adam + scale_by_learning_rate. For a term-by-term comparison with Optax, the composable chain style, and an actual cross-check against optax (optax 0.2.9 is installed in this environment), see Chapter 7, topic 5, "Hand-written optimizers". Below, we first verify that the implementation here is correct against a reference computed by hand with NumPy.
Finally, we fit $y=\sin(x)$ with a small MLP, using "parameters + optimizer state" as the carry of lax.scan so the whole training run compiles into a single program.
# ---------- Generic optimizers: interface modeled on Optax ----------
class Optimizer(NamedTuple):
init: callable
update: callable
def apply_updates(params, updates):
return jax.tree.map(lambda p, u: p + u, params, updates)
class MomentumState(NamedTuple):
velocity: dict
def sgd_momentum(lr, momentum=0.9):
def init(params):
return MomentumState(velocity=jax.tree.map(jnp.zeros_like, params))
def update(grads, state, params=None):
v = jax.tree.map(lambda v, g: momentum * v + g, state.velocity, grads)
updates = jax.tree.map(lambda v: -lr * v, v)
return updates, MomentumState(velocity=v)
return Optimizer(init, update)
class AdamState(NamedTuple):
count: jax.Array
mu: dict
nu: dict
def adam(lr, b1=0.9, b2=0.999, eps=1e-8):
def init(params):
# Note: mu and nu must be two independent sets of arrays; they can't share the same zeros object (see the donate_argnums Extra)
return AdamState(count=jnp.zeros([], jnp.int32),
mu=jax.tree.map(jnp.zeros_like, params),
nu=jax.tree.map(jnp.zeros_like, params))
def update(grads, state, params=None):
t = state.count + 1
mu = jax.tree.map(lambda m, g: b1 * m + (1 - b1) * g, state.mu, grads)
nu = jax.tree.map(lambda v, g: b2 * v + (1 - b2) * g ** 2, state.nu, grads)
bc1 = 1 - b1 ** t # bias correction
bc2 = 1 - b2 ** t
updates = jax.tree.map(lambda m, v: -lr * (m / bc1) / (jnp.sqrt(v / bc2) + eps), mu, nu)
return updates, AdamState(count=t, mu=mu, nu=nu)
return Optimizer(init, update)
# ---------- Model and data ----------
def init_mlp(key, sizes=(1, 32, 32, 1)):
params = []
keys = jax.random.split(key, len(sizes) - 1)
for k, (n_in, n_out) in zip(keys, zip(sizes[:-1], sizes[1:])):
params.append({'w': jax.random.normal(k, (n_in, n_out)) * jnp.sqrt(2.0 / n_in),
'b': jnp.zeros(n_out)})
return params
def mlp(params, x):
for layer in params[:-1]:
x = jnp.tanh(x @ layer['w'] + layer['b'])
return x @ params[-1]['w'] + params[-1]['b']
def loss_fn(params, x, y):
return jnp.mean((mlp(params, x) - y) ** 2)
key = jax.random.key(0)
key, k_data, k_init = jax.random.split(key, 3)
X = jax.random.uniform(k_data, (256, 1), minval=-3.0, maxval=3.0)
Y = jnp.sin(X)
params0 = init_mlp(k_init)
print("Parameter structure:", jax.tree.map(jnp.shape, params0))
import numpy as np
# Self-check on a quadratic: the hand-written Adam / momentum SGD match a NumPy reference computed by hand, step by step
A = np.array([1.0, 4.0, 0.25], dtype=np.float32) # different curvature in each dimension
C = np.array([3.0, -1.0, 2.0], dtype=np.float32) # the optimum
def quad_loss(p): # f(x) = 1/2 Σ a_i (x_i - c_i)^2
return 0.5 * jnp.sum(A * (p['x'] - C) ** 2)
def numpy_adam_reference(x, steps, lr=0.1, b1=0.9, b2=0.999, eps=1e-8):
m = np.zeros_like(x); v = np.zeros_like(x); traj = []
for t in range(1, steps + 1):
g = A * (x - C) # gradient computed by hand
m = b1 * m + (1 - b1) * g
v = b2 * v + (1 - b2) * g ** 2
m_hat = m / (1 - b1 ** t); v_hat = v / (1 - b2 ** t)
x = x - lr * m_hat / (np.sqrt(v_hat) + eps)
traj.append(x.copy())
return traj
def numpy_momentum_reference(x, steps, lr=0.1, mu=0.9):
v = np.zeros_like(x); traj = []
for _ in range(steps):
g = A * (x - C)
v = mu * v + g
x = x - lr * v
traj.append(x.copy())
return traj
def run_jax_optimizer(opt, steps):
p = {'x': jnp.zeros(3)}
state = opt.init(p); traj = []
for _ in range(steps):
g = jax.grad(quad_loss)(p)
updates, state = opt.update(g, state, p)
p = apply_updates(p, updates)
traj.append(np.asarray(p['x']))
return traj, state
x0 = np.zeros(3, dtype=np.float32)
adam_traj, adam_state = run_jax_optimizer(adam(0.1), 3)
for t, (ours, ref) in enumerate(zip(adam_traj, numpy_adam_reference(x0, 3)), start=1):
print(f"Adam step {t} JAX: {ours} NumPy: {ref}")
assert np.allclose(ours, ref, rtol=1e-5, atol=1e-6) # differences of about 1e-6 come from float32 rounding in 1-β2^t
assert int(adam_state.count) == 3
# The effect of bias correction: at step 1 every coordinate moves by about lr=0.1, regardless of the gradient size
assert np.allclose(np.abs(adam_traj[0]), 0.1, atol=1e-6)
mom_traj, _ = run_jax_optimizer(sgd_momentum(0.1, momentum=0.9), 3)
for ours, ref in zip(mom_traj, numpy_momentum_reference(x0, 3)):
assert np.allclose(ours, ref, rtol=1e-5, atol=1e-6)
print("The first 3 steps of Adam and of momentum SGD are allclose to the NumPy reference ✓")
def make_train(optimizer, num_steps):
"""Return a jit-compiled training function. Parameters and data are passed in as function arguments (no large arrays captured by closures);
(params, opt_state) is the scan carry."""
@jax.jit
def train(params, x, y):
def train_step(carry, _):
params, opt_state = carry
loss, grads = jax.value_and_grad(loss_fn)(params, x, y) # x, y are arguments of train (tracers)
updates, opt_state = optimizer.update(grads, opt_state, params)
params = apply_updates(params, updates)
return (params, opt_state), loss # carry = state; y = per-step loss
opt_state = optimizer.init(params)
(params, opt_state), losses = lax.scan(train_step, (params, opt_state), None, length=num_steps)
return params, opt_state, losses
return train
results = {}
for name, opt in [("SGD", sgd_momentum(0.01, momentum=0.0)),
("SGD+momentum", sgd_momentum(0.01, momentum=0.9)),
("Adam", adam(0.01))]:
p, s, losses = make_train(opt, 1000)(params0, X, Y)
results[name] = losses
print(f"{name:12s} initial loss {losses[0]:.4f} → final loss {losses[-1]:.5f}")
print("Step count in the Adam state:", s.count, "; first-moment structure matches the parameters:",
jax.tree.structure(s.mu) == jax.tree.structure(p))
import matplotlib.pyplot as plt
plt.rcParams['font.sans-serif'] = ['PingFang SC', 'Heiti SC', 'Arial Unicode MS', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
fig, ax = plt.subplots(figsize=(6, 3.5))
for name, losses in results.items():
ax.plot(losses, label=name)
ax.set_yscale('log'); ax.set_xlabel('step'); ax.set_ylabel('MSE loss (log scale)')
ax.set_title('Same model, three kinds of optimizer state'); ax.legend()
plt.tight_layout(); plt.show()
🔍 Extra: donate_argnums, letting the new state reuse the old state's memory¶
⚪ Optional: read this when memory is tight (training large models on GPU/TPU); the gains on CPU are small.
How it works. Explicit state passing has a hidden cost: when you run new_state = step(state), the old state and the new state take up memory at the same time. JAX arrays are immutable, so by default XLA has to allocate separate buffers for the outputs. jax.jit(f, donate_argnums=...) (or donate_argnames=) tells XLA: "I won't need these inputs after the call." XLA can then reuse the input buffers directly as output buffers with the same shape and dtype (input/output aliasing), which amounts to an in-place update inside the compiled program.
When it helps. Mainly for saving GPU/TPU memory. When training a large model, the parameters plus Adam's two moments take about 3 times the parameter count. Without donation, the peak memory of a training step needs a whole extra copy of the "new state" on top of that, which can push you straight into OOM. With donation, the old state's buffers are reused by the new state, peak memory drops noticeably, and you also skip the allocation and copying. On CPU, memory is usually plentiful, so the gain is small.
What actually happens on CPU (JAX 0.12).
- Donation takes effect: after the call, the donated array's
is_deleted()isTrue, and accessing it again raisesRuntimeError: Array has been deleted; - If a donation can't be used (no output array with a matching shape and dtype), there's no error, just a
UserWarning: Some donated buffers were not usable.
The pitfall of donating the same buffer twice. If the same array object appears twice in the donated PyTree, donation raises INVALID_ARGUMENT: Attempt to donate the same buffer twice. In the training step below, the exception type is ValueError; in simpler calls we've also seen JaxRuntimeError, so don't catch just one type. The easiest place to hit this is an optimizer's init: taking a shortcut like zeros = jax.tree.map(jnp.zeros_like, params); AdamState(mu=zeros, nu=zeros) works fine without donation and errors as soon as you donate. The fix is to call jax.tree.map(jnp.zeros_like, params) separately for mu and nu, which is exactly how the adam above is written. We actually ran into this pitfall while writing this tutorial.
Rules of thumb: write the training step as
state = step(state, batch), donatestate(parameters + optimizer state), and overwrite the old reference with the return value right after the call; don't donate data you reuse every step; create every state buffer separately ininit.
opt = adam(1e-2)
@partial(jax.jit, donate_argnums=(0, 1)) # donate params and opt_state; don't donate the data x, y
def train_step_donated(params, opt_state, x, y):
loss, grads = jax.value_and_grad(loss_fn)(params, x, y)
updates, opt_state = opt.update(grads, opt_state, params)
return apply_updates(params, updates), opt_state, loss
params = jax.tree.map(jnp.copy, params0)
opt_state = opt.init(params)
old_leaf = params[0]['w']
params, opt_state, loss = train_step_donated(params, opt_state, X, Y) # correct: overwrite the old references
print("Old parameter buffer has been deleted:", old_leaf.is_deleted())
assert old_leaf.is_deleted()
deleted_error = None
try:
print(old_leaf + 1)
except RuntimeError as e:
deleted_error = e
print("Accessing the old array again → RuntimeError:", e)
assert deleted_error is not None
assert not X.is_deleted() # data that wasn't donated is still usable
for _ in range(200):
params, opt_state, loss = train_step_donated(params, opt_state, X, Y)
print(f"Loss after 200 steps: {loss:.5f}")
# A donation that can't be used: no output array of the same shape → a warning, not an error
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
jax.jit(lambda a: a.sum(), donate_argnums=0)(jnp.ones(10))
msgs = [str(m.message) for m in w]
print("Warnings:", [m[:60] for m in msgs])
assert any("donated buffers were not usable" in m for m in msgs)
# ❌ Wrong (same buffer donated twice): Adam's mu and nu share the same zeros object
def adam_init_shared(params):
zeros = jax.tree.map(jnp.zeros_like, params)
return AdamState(count=jnp.zeros([], jnp.int32), mu=zeros, nu=zeros) # mu and nu are the very same arrays
params = jax.tree.map(jnp.copy, params0)
bad_opt_state = adam_init_shared(params)
double_donate_error = None
try:
train_step_donated(params, bad_opt_state, X, Y)
except Exception as e:
double_donate_error = e
print(f"Actual result → {type(e).__name__}: {str(e)[:120]}")
assert double_donate_error is not None and "donate the same buffer twice" in str(double_donate_error)
# (XLA also prints a C++ log line with the same content to stderr; you can ignore it)
# ✅ Fix: create mu and nu separately (the way adam(...).init above does it)
params = jax.tree.map(jnp.copy, params0)
good_opt_state = opt.init(params)
assert good_opt_state.mu[0]['w'] is not good_opt_state.nu[0]['w']
params, good_opt_state, loss = train_step_donated(params, good_opt_state, X, Y)
print(f"Independent buffers + donation: runs fine, loss = {loss:.5f}")
6.7 Combining state management with transformations¶
JAX manages state functionally: pass the state in as an argument and return the updated state, so the function stays pure. That's the key to whether it works with transformations. This section shows, one at a time, how the pattern works with jit, grad, vmap and pmap.
State management and JIT compilation¶
The counter's state is a dict {'count': ...} (which is itself a PyTree). Calling it twice by hand and calling it twice after jit should give exactly the same result.
To compare two PyTrees leaf by leaf for equality, use jax.tree.all(jax.tree.map(jnp.array_equal, a, b)).
⚠️ Note: older code often compares with
jax.tree_util.tree_all(jax.tree_map(...)). In JAX 0.12, the top-level aliasjax.tree_maphas been removed and raisesAttributeError: module 'jax' has no attribute 'tree_map'. Usejax.tree.mapinstead (see the migration table in 6.4).
def stateful_counter(count_state, increment):
"""Take the counter state and an increment, and return the new state."""
new_count = count_state['count'] + increment
return {'count': new_count}
initial_state = {'count': jnp.array(0)}
# Without jit
state = stateful_counter(initial_state, 1)
state = stateful_counter(state, 2)
print("Result of manual calls:", state)
# With jit
jit_counter = jax.jit(stateful_counter)
jit_state = jit_counter(initial_state, 1)
jit_state = jit_counter(jit_state, 2)
print("Result of JIT calls:", jit_state)
# ❌ Old style (outdated): the top-level alias jax.tree_map was removed in JAX 0.12
tree_map_error = None
try:
assert jax.tree_util.tree_all(jax.tree_map(lambda x, y: jnp.array_equal(x, y), state, jit_state))
except AttributeError as e:
tree_map_error = e
print("Actual result → AttributeError:", e)
assert tree_map_error is not None
# ✅ Recommended
assert jax.tree.all(jax.tree.map(lambda x, y: jnp.array_equal(x, y), state, jit_state))
print("jax.tree.map assertion passed: both results match")
Key point: what jit traces depends on the structure of the state, plus the types and shapes of the arguments. As long as the structure stays the same, the compilation cache can be reused.
Tip: if the structure of the state changes between two calls (say you add a key to the dict), it triggers recompilation.
traces = []
@jax.jit
def bump(state):
traces.append(jax.tree.structure(state)) # recorded during tracing
return jax.tree.map(lambda x: x + 1, state)
bump({'count': jnp.array(0)})
bump({'count': jnp.array(5)}) # same structure: cache hit
bump({'count': jnp.array(0), 'steps': jnp.array(0)}) # new key: retrace
print("Number of traces:", len(traces))
for t in traces: print(" ", t)
assert len(traces) == 2
State management and automatic differentiation¶
Take a linear model $\hat y = w x + b$ as an example; the parameter dict params is the state.
predict_and_update(params, x): returns the predictions plus the (unmodified) parameter state, to show the "state in, state out" function signature;loss_fn(params, x, y_target): mean squared error $L = \frac{1}{n}\sum_i (\hat y_i - y_i)^2$; it returns only the scalar loss, so thatgradcan differentiate it;jax.grad(loss_fn, argnums=0): the gradient with respect to argument 0 (the wholeparamsPyTree), with the same structure asparams;jax.value_and_grad: gets the loss value and the gradient in one go.
def predict_and_update(params, x):
"""Forward prediction; the parameter state is returned as is (not modified here)."""
pred = params['w'] * x + params['b']
return pred, params
def loss_fn_linear(params, x, y_target):
pred, _ = predict_and_update(params, x)
return jnp.mean((pred - y_target) ** 2)
# Data: the ideal parameters are w=2.5, b=1.0
x_data = jnp.array([1.0, 2.0, 3.0])
y_data = jnp.array([3.5, 5.5, 7.5])
params = {'w': jnp.array(2.0), 'b': jnp.array(1.0)}
grad_fn_linear = jax.grad(loss_fn_linear, argnums=0)
grads = grad_fn_linear(params, x_data, y_data)
print("Gradient (same structure as params):", grads)
loss_val, grads = jax.value_and_grad(loss_fn_linear)(params, x_data, y_data)
print("Loss:", loss_val, " gradient:", grads)
Combine "compute the gradient + update the parameters" into one training step: old parameters in, loss and new parameters out. That's one purely functional state update. Use jax.tree.map for the leaf-by-leaf update.
⚠️ Note: if
training_stepupdated the parameters withjax.tree_map, it would raiseAttributeErrorin JAX 0.12 (the same problem as the comparison code above), so here it's written directly asjax.tree.map.
def training_step(params, x, y_target, learning_rate):
loss, grads = jax.value_and_grad(loss_fn_linear)(params, x, y_target)
updated_params = jax.tree.map(lambda p, g: p - learning_rate * g, params, grads)
return loss, updated_params
learning_rate = 0.1
current_params = {'w': jnp.array(2.0), 'b': jnp.array(1.0)}
for i in range(2):
loss, current_params = training_step(current_params, x_data, y_data, learning_rate)
print(f"Step {i + 1}: loss = {loss:.4f}, parameters = {jax.tree.map(float, current_params)}")
# Check by hand: the step-1 gradient is w:-2, b:-1, so with learning rate 0.1 → w=2.2, b=1.1
assert jnp.allclose(current_params['w'], 2.1733334) and jnp.allclose(current_params['b'], 1.1)
State management and vmap¶
vmap can run the same stateful function in parallel over "a batch of independent states". Below we maintain 3 counters at once (all starting at 0, with increments 1, 5 and 10), then do a second round of increments [2, 3, 4].
What in_axes=({'count': 0}, 0) means: the 'count' leaf of the state dict is mapped along axis 0, and the increments are mapped along axis 0 too. in_axes itself can be written as a PyTree with the same structure as the inputs.
batched_counter = jax.vmap(stateful_counter, in_axes=({'count': 0}, 0))
batch_state = {'count': jnp.array([0, 0, 0])}
batch_state = batched_counter(batch_state, jnp.array([1, 5, 10]))
print("After round 1:", batch_state)
batch_state = batched_counter(batch_state, jnp.array([2, 3, 4]))
print("After round 2:", batch_state)
Another common case is parameters shared across the batch, with only the data having a batch dimension. in_axes=(None, 0) means the first argument (params) isn't mapped, and x is mapped along axis 0. out_axes defaults to 0, so the returned parameter state gets "copied" into a version with a batch dimension.
batched_predict = jax.vmap(predict_and_update, in_axes=(None, 0))
preds, out_params = batched_predict(current_params, x_data)
print("Batched predictions:", preds)
print("Output parameter state (copied along axis 0):", out_params)
🔍 Extra: use out_axes to avoid needlessly copying shared state¶
🟢 Must-read
Look at the output above: the unbatched params were broadcast to length-3 arrays under the default out_axes=0, meaning one copy per example. With many parameters, this wastes memory, and it can also mislead later code into thinking these are "different parameters for each example". For outputs that don't vary with the batch, specifying out_axes=(0, None) returns them as they are (provided the output really doesn't depend on the batch dimension; otherwise it raises an error).
Also, if a loss function needs to return both a scalar loss and an updated state (such as BatchNorm's running statistics), use jax.value_and_grad(f, has_aux=True): f returns (loss, aux), only loss is differentiated, and aux is carried out as is. This is the standard way to "carry state through grad".
batched_predict2 = jax.vmap(predict_and_update, in_axes=(None, 0), out_axes=(0, None))
preds2, out_params2 = batched_predict2(current_params, x_data)
print("Parameter output with out_axes=(0, None):", out_params2)
# has_aux: carry a piece of "state" out alongside the loss
def loss_with_stats(params, x, y, stats):
pred = params['w'] * x + params['b']
new_stats = {'pred_mean': 0.9 * stats['pred_mean'] + 0.1 * pred.mean(), # running-average statistic
'calls': stats['calls'] + 1}
return jnp.mean((pred - y) ** 2), new_stats
stats = {'pred_mean': jnp.array(0.0), 'calls': jnp.array(0)}
(loss, stats), grads = jax.value_and_grad(loss_with_stats, has_aux=True)(current_params, x_data, y_data, stats)
print("Loss:", loss, "\nNew statistics:", stats, "\nGradient:", grads)
State management and pmap¶
With multiple devices, there are two common ways to place state:
- Replicated state: things like model parameters get a full copy on every device (corresponding to
in_axes=None, or stacking copies along the device dimension when passing them in); - Sharded state: state tied to the data is split across devices (
in_axes=0).
After computing gradients in parallel, use collective operations like lax.psum and lax.pmean to aggregate across devices, so the parameters on every device stay identical. See the pmap chapter for details.
🔍 Extra:
pmapstill works in current JAX, but the officially recommended new approach isjax.jitwithjax.sharding(NamedSharding+PartitionSpec) for automatic parallelism, orjax.shard_mapfor explicit per-device programming. For state, "replicated / sharded" corresponds toPartitionSpec()(fully replicated) andPartitionSpec('data')(split along the data axis). This machine has only 1 CPU device, so we only cover the concepts here; for a full demonstration with 8 simulated devices and the key points of migrating from pmap to shard_map, see Chapter 5.
Combining several transformations¶
training_step is a pure function, so it can be jitted directly. The second call uses the compilation cache, so it's faster. You can then wrap it further with vmap (say, to train several sets of hyperparameters at once), or plug it into pmap / sharded parallelism.
Summary: explicit state passing + PyTrees let stateful computations work smoothly with jit, grad, vmap and pmap.
import time
jit_training_step = jax.jit(training_step)
current_params = {'w': jnp.array(2.0), 'b': jnp.array(1.0)}
for i in range(2):
t0 = time.perf_counter()
loss, current_params = jit_training_step(current_params, x_data, y_data, learning_rate)
loss.block_until_ready() # wait for the asynchronous computation to finish before timing
dt = (time.perf_counter() - t0) * 1e3
print(f"Step {i + 1}: loss = {loss:.4f}, parameters = {jax.tree.map(float, current_params)}, time {dt:.2f} ms")
print("(the first call includes tracing + compilation; the second uses the cache directly)")
# Combining further: vmap trains one step with each of 4 different learning rates at once
lrs = jnp.array([0.01, 0.05, 0.1, 0.2])
init = {'w': jnp.array(2.0), 'b': jnp.array(1.0)}
losses, new_params = jax.jit(jax.vmap(training_step, in_axes=(None, None, None, 0)))(init, x_data, y_data, lrs)
print("w after one step with each of the 4 learning rates:", new_params['w'])
6.8 Practice: implementing stateful functions¶
The three exercises below all follow the same pattern: a pure function takes the current state and returns the new state (plus optional auxiliary outputs).
Exercise 1: a stateful counter with JIT¶
Define update_counter(count, increment) that returns (new_count, increment), that is, the new state plus an auxiliary output. Wrap it with jax.jit and loop 5 times, passing in the current count and the loop index i as the increment each time.
Expected output: the initial count is 0; at steps 1–5 the new count is 1–5, and the returned increment is 0–4. In other words, the count here always goes up by 1, and the increment i you pass in is just returned unchanged as an auxiliary output, to demonstrate the "new state + auxiliary output" return form.
def update_counter(count, increment):
"""Add 1 to the count, and return the increment passed in this time as an auxiliary output."""
new_count = count + 1
return new_count, increment
jit_update_counter = jax.jit(update_counter)
count = 0
print("Initial count:", count)
for i in range(5):
count, returned_inc = jit_update_counter(count, i)
print(f"Step {i + 1}: new count = {count}, returned increment = {returned_inc}")
Exercise 2: computing a simple moving average¶
Use the tuple (current_sum, count) as the state. update_moving_average(state, new_value) returns (new_state, current_average).
Expected output: for the data [2, 4, 6, 8, 10], the averages are 2.00, 3.00, 4.00, 5.00, 6.00 in turn, and the states are (2.0, 1), (6.0, 2), (12.0, 3), (20.0, 4), (30.0, 5). The JIT version is optional.
Use arrays with a fixed dtype for the initial state, such as (jnp.array(0.0, jnp.float32), jnp.array(0, jnp.int32)), so that it has exactly the same type as the updated state. If you use the Python scalars (0.0, 0), JAX treats them as weakly typed. After the first call, new_sum = 0.0 + a float32 array becomes a strongly typed float32, so the input signature changes on the second call and jit traces and compiles again. In a large model, that means tens of seconds or even minutes of extra compilation, with no error, so it's hard to notice.
⚠️ Note: if you use
(0.0, 0)as the initial state and run the JIT version directly, processing 5 values traces 2 times; with fixed-dtype arrays it traces only once. Both versions give the same numbers.
def update_moving_average(state, new_value):
current_sum, count = state
new_sum = current_sum + new_value
new_count = count + 1
current_average = new_sum / new_count
return (new_sum, new_count), current_average
data = jnp.array([2.0, 4.0, 6.0, 8.0, 10.0])
state = (0.0, 0) # without jit, a Python-scalar initial state is fine
averages = []
for v in data:
state, avg = update_moving_average(state, v)
averages.append(float(avg))
print(f"new value {float(v):5.1f} → average {float(avg):.2f}, state = ({float(state[0])}, {int(state[1])})")
assert averages == [2.0, 3.0, 4.0, 5.0, 6.0]
# ❌ Not recommended: the JIT version with Python scalars (0.0, 0) as the initial state compiles one extra time
ma_traces = []
def update_moving_average_counted(state, v):
ma_traces.append(1) # runs only during tracing; used to count traces
return update_moving_average(state, v)
jit_update_moving_average = jax.jit(update_moving_average_counted)
state = (0.0, 0)
for v in data:
state, avg = jit_update_moving_average(state, v)
print("Final average, JIT version:", avg, "| traces =", len(ma_traces))
assert len(ma_traces) == 2 and float(avg) == 6.0
# ✅ Fix: give the initial state the same dtypes as the updated state
ma_traces.clear()
jit_update_moving_average = jax.jit(update_moving_average_counted)
state = (jnp.array(0.0, jnp.float32), jnp.array(0, jnp.int32))
for v in data:
state, avg = jit_update_moving_average(state, v)
print("Final average, JIT version:", avg, "| traces =", len(ma_traces))
assert len(ma_traces) == 1 and float(avg) == 6.0
Why: jit's cache key includes each input's shape, dtype and weak-type flag. Python scalars are weakly typed, which counts as a different signature from a strongly typed float32 array. lax.scan is stricter here: a strongly typed carry whose dtype changes from one step to the next is an error right away (see the Extra in 6.5 on lax.scan's carry).
Exercise 3: a single gradient descent step¶
The objective is $f(x) = x^2$ with gradient $f'(x) = 2x$. gradient_descent_step(params, learning_rate) returns params - learning_rate * gradient. Start from the parameter 5.0 with learning rate 0.1, and loop for 5 steps.
Expected output: each step multiplies by $1 - 2\times0.1 = 0.8$, so 5.0 → 4.0000, 3.2000, 2.5600, 2.0480, 1.6384. In the JIT version, the learning rate should be passed in as an ordinary (dynamic) argument.
⚠️ Note: you'll often hear that "the learning rate is usually marked as a static argument (
static_argnums=(1,)/static_argnames)". That runs fine while the learning rate stays fixed, but as soon as it changes, every new value triggers recompilation (shown in 6.6: 5 values, 5 recompiles). The learning rate is just a scalar that doesn't affect the program's structure, so it should be passed in as a dynamic argument.
def loss_fn_sq(x):
return x ** 2
grad_fn_sq = jax.grad(loss_fn_sq)
def gradient_descent_step(params, learning_rate):
gradient = grad_fn_sq(params)
return params - learning_rate * gradient
params = jnp.array(5.0)
lr = 0.1
print(f"Initial parameter: {params}")
for step in range(5):
params = gradient_descent_step(params, lr)
print(f"Step {step + 1}: {params:.4f}")
assert jnp.allclose(params, 1.6384)
# JIT version: the learning rate as a dynamic argument
jit_gd_step = jax.jit(gradient_descent_step)
p = jnp.array(5.0)
for _ in range(5):
p = jit_gd_step(p, lr)
print("JIT version after 5 steps:", p)
assert jnp.allclose(p, 1.6384)
Exercise summary: pure functions + "state in, state out". With PyTrees, this pattern scales all the way from a simple counter to nested model parameters and optimizer state.
🏋️ Extra exercises (with reference solutions)¶
The exercises below get progressively harder. Try them yourself first, then look at the solutions.
Extra exercise 1: an exponential moving average (EMA) of the parameters¶
Training often keeps an EMA copy of the parameters (used for evaluation, since it's more stable): $\bar\theta \leftarrow d\,\bar\theta + (1-d)\,\theta$.
Requirements:
- Define
TrainState(NamedTuple)with three fields:step,paramsandema_params; - Write a jit-compiled
ema_train_step(state, x, y, lr, decay)that does one SGD step onparams, updatesema_paramsat the same time, and adds 1 tostep; - Train for 100 steps on the linear regression data from 6.7, and print the final
paramsandema_params.
Self-check interface: TrainState (a NamedTuple with fields step, params, ema_params); ema_train_step(state, x, y, lr, decay): jit-compiled, uses loss_fn_linear from 6.7 as the loss, and returns a new TrainState.
# ✍️ Your answer: Extra 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: Extra exercise 1
try:
TrainState, ema_train_step
except NameError as e:
print(f"⏳ Extra exercise 1: not answered yet: {e}")
else:
def _selfcheck():
import numpy as np
assert {"step", "params", "ema_params"} <= set(getattr(TrainState, "_fields", ())), \
"TrainState should be a NamedTuple with the three fields step, params and ema_params"
assert hasattr(ema_train_step, "lower"), "ema_train_step should be a function compiled with jax.jit"
x = jnp.array([0.5, -1.0, 2.0, 3.0]); y = jnp.array([1.0, 0.0, 4.0, 7.5])
w0, b0, ew, eb, lr, d = 1.5, -0.5, 0.2, 0.3, 0.1, 0.8
st = TrainState(step=jnp.array(3), params={"w": jnp.array(w0), "b": jnp.array(b0)},
ema_params={"w": jnp.array(ew), "b": jnp.array(eb)})
new = ema_train_step(st, x, y, lr, d)
assert isinstance(new, TrainState), "ema_train_step should return a new TrainState"
assert int(new.step) == 4, f"step should go from 3 to 4, got {new.step}"
# Closed-form gradient of the linear model's MSE
xn, yn = np.asarray(x, np.float64), np.asarray(y, np.float64)
r = w0 * xn + b0 - yn
pw, pb = w0 - lr * 2 * np.mean(r * xn), b0 - lr * 2 * np.mean(r)
got_p = [float(new.params["w"]), float(new.params["b"])]
assert np.allclose(got_p, [pw, pb], atol=1e-5), f"params is not the result of one SGD step: expected {[pw, pb]}, got {got_p}"
got_e = [float(new.ema_params["w"]), float(new.ema_params["b"])]
ema_new = [d * ew + (1 - d) * pw, d * eb + (1 - d) * pb] # using the updated parameters
ema_old = [d * ew + (1 - d) * w0, d * eb + (1 - d) * b0] # using the parameters before the update (both conventions are accepted)
assert np.allclose(got_e, ema_new, atol=1e-5) or np.allclose(got_e, ema_old, atol=1e-5), \
f"ema_params does not follow ē ← d·ē + (1−d)·θ: got {got_e}"
_selfcheck(); del _selfcheck
print("✅ Extra exercise 1: self-check passed")
Extra exercise 2: Welford's online variance with lax.scan + vmap¶
Welford's algorithm computes the mean and variance online with the state $(n, \mu, M_2)$, and it's numerically more stable than "sum the squares first, then subtract":
$$ n \leftarrow n+1,\quad \delta = x-\mu,\quad \mu \leftarrow \mu + \delta/n,\quad M_2 \leftarrow M_2 + \delta\,(x-\mu),\quad \sigma^2 = M_2/n $$
Requirements:
- Write
welford_step(state, x)as the body function oflax.scan, outputting the current variance at each step; - Write
online_var(xs)to process one data stream and return the final mean, the variance, and the variance trace; - Use
jax.vmapto process 4 random data streams of length 1000 at once (generated withjax.random.key), and compare againstjnp.mean/jnp.var.
Self-check interface: welford_step(state, x) (the body function of lax.scan); online_var(xs): takes a 1-D array and returns (mean, variance, variance trace), where the variance trace has the same shape as xs.
# ✍️ Your answer: Extra 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: Extra exercise 2
try:
welford_step, online_var
except NameError as e:
print(f"⏳ Extra exercise 2: not answered yet: {e}")
else:
def _selfcheck():
import numpy as np
rng = np.random.default_rng(2)
xs = (1000.0 + 0.5 * rng.standard_normal(300)).astype(np.float32) # large offset, small variance: a test of numerical stability
assert "scan" in str(jax.make_jaxpr(online_var)(jnp.asarray(xs))), "online_var should be implemented with lax.scan"
m, v, tr = jax.jit(online_var)(jnp.asarray(xs))
x64 = xs.astype(np.float64)
assert np.shape(tr) == (300,), f"The variance trace should have shape (300,), got {np.shape(tr)}"
assert np.isclose(float(m), x64.mean(), rtol=1e-6), f"Wrong mean: {float(m)} vs {x64.mean()}"
assert np.isclose(float(v), x64.var(), rtol=2e-3), \
f"Variance {float(v):.6f} is too far from the float64 reference {x64.var():.6f} (did you use \"sum of squares minus squared mean\"?)"
for k in (0, 1, 9, 99, 299):
assert np.isclose(float(tr[k]), x64[:k + 1].var(), rtol=2e-3, atol=1e-6), \
f"Entry {k} of the variance trace should be the variance of the first {k + 1} values"
streams = (np.arange(1, 4)[:, None] * rng.standard_normal((3, 200)) + 50.0).astype(np.float32)
mb, vb, tb = jax.vmap(online_var)(jnp.asarray(streams))
assert np.shape(mb) == (3,) and np.shape(vb) == (3,) and np.shape(tb) == (3, 200), "Wrong output shapes after vmap"
assert np.allclose(vb, streams.astype(np.float64).var(axis=1), rtol=2e-3), "Wrong per-stream variance after vmap"
_selfcheck(); del _selfcheck
print("✅ Extra exercise 2: self-check passed")
Extra exercise 3: implement RMSProp and plug it into the generic training framework above¶
RMSProp's state is a running average $s$ of the squared gradients:
$$ s \leftarrow \rho\, s + (1-\rho)\, g^2,\qquad \Delta\theta = -\eta\, \frac{g}{\sqrt{s}+\epsilon} $$
Requirements: following sgd_momentum / adam above, write rmsprop(lr, rho=0.9, eps=1e-8) that returns an Optimizer with init / update; then use make_train to train for 1000 steps on the $\sin(x)$ fitting task and compare the final loss with Adam's.
Self-check interface: rmsprop(lr, rho=0.9, eps=1e-8) returns Optimizer(init, update), where update(grads, state, params=None) returns (updates, new_state) and updates is the amount to add to the parameters; name the per-step losses from training with make_train rms_losses.
# ✍️ Your answer: Extra 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: Extra exercise 3
try:
rmsprop, rms_losses
except NameError as e:
print(f"⏳ Extra exercise 3: not answered yet: {e}")
else:
def _selfcheck():
import numpy as np
lr, rho, eps = 0.05, 0.8, 1e-6
params = {"a": jnp.array([1.0, -2.0, 3.0]), "b": [jnp.ones((2, 2))]}
g1 = {"a": jnp.array([0.5, -1.0, 2.0]), "b": [jnp.array([[1.0, -3.0], [0.1, 0.0]])]}
g2 = {"a": jnp.array([-0.5, 0.2, 1.0]), "b": [jnp.array([[2.0, 1.0], [-0.1, 4.0]])]}
opt = rmsprop(lr, rho=rho, eps=eps)
st = opt.init(params)
u1, st = opt.update(g1, st, params)
u2, st = jax.jit(opt.update)(g2, st, params) # it must also work inside jit
assert jax.tree.structure(u1) == jax.tree.structure(params), "updates should have the same structure as the parameters"
s1 = jax.tree.map(lambda g: (1 - rho) * np.asarray(g, np.float64) ** 2, g1)
s2 = jax.tree.map(lambda s, g: rho * s + (1 - rho) * np.asarray(g, np.float64) ** 2, s1, g2)
e1 = jax.tree.map(lambda g, s: -lr * np.asarray(g, np.float64) / (np.sqrt(s) + eps), g1, s1)
e2 = jax.tree.map(lambda g, s: -lr * np.asarray(g, np.float64) / (np.sqrt(s) + eps), g2, s2)
for name, got, exp in (("Step 1", u1, e1), ("Step 2", u2, e2)):
ok = jax.tree.leaves(jax.tree.map(lambda a, b: bool(np.allclose(a, b, rtol=1e-5, atol=1e-6)), got, exp))
assert all(ok), f"{name}: updates don't match the RMSProp formula (updates should carry a minus sign, and s must accumulate across steps)"
rl = np.asarray(rms_losses)
assert rl.shape == (1000,) and np.all(np.isfinite(rl)), "rms_losses should be 1000 steps of finite losses"
assert rl[-1] < 0.5 * rl[0], f"The loss didn't drop noticeably after training: {rl[0]:.4f} → {rl[-1]:.4f}"
_selfcheck(); del _selfcheck
print("✅ Extra exercise 3: self-check passed")
Extra exercise 4: an RNN's hidden state, and taking gradients through lax.scan¶
A recurrent neural network's hidden state $h_t = \tanh(W_h h_{t-1} + W_x x_t + b)$ is the textbook example of "state carried through a loop".
Requirements:
- Initialize the parameters
{'Wh': (H,H), 'Wx': (D,H), 'b': (H,), 'Wo': (H,1)}(H=16, D=1) withjax.random.key; - Write
rnn_forward(params, xs): uselax.scanwith the hidden state as the carry, outputting $h_t W_o$ at each step; - Task: given a sequence of length 20, predict "the running sum up to the current position". Train for 300 steps with Adam (the one implemented above), and print the initial and final loss;
- Observe:
jax.gradpasses straight throughlax.scan(which amounts to getting BPTT, backpropagation through time, for free).
Self-check interface: rnn_forward(params, xs), where params contains Wh (16,16), Wx (1,16), b (16,), Wo (16,1) and xs has shape (T, 1), returns shape (T, 1); name the per-step losses from 300 training steps rnn_losses.
# ✍️ Your answer: Extra 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: Extra exercise 4
try:
rnn_forward, rnn_losses
except NameError as e:
print(f"⏳ Extra exercise 4: not answered yet: {e}")
else:
def _selfcheck():
import numpy as np
from jax.test_util import check_grads
rng = np.random.default_rng(4)
P = {"Wh": 0.3 * rng.standard_normal((16, 16)), "Wx": rng.standard_normal((1, 16)),
"b": 0.1 * rng.standard_normal(16), "Wo": 0.3 * rng.standard_normal((16, 1))}
xs = rng.uniform(-1, 1, (7, 1))
params = jax.tree.map(lambda a: jnp.asarray(a, jnp.float32), P)
xs_j = jnp.asarray(xs, jnp.float32)
assert "scan" in str(jax.make_jaxpr(rnn_forward)(params, xs_j)), "rnn_forward should be implemented with lax.scan"
out = np.asarray(jax.jit(rnn_forward)(params, xs_j))
assert out.shape == (7, 1), f"The output shape should be (T, 1) = (7, 1), got {out.shape}"
def ref(row_vec): # NumPy float64, unrolled step by step; both matrix-multiplication conventions are accepted
h, ys = np.zeros(16), []
for x in xs:
pre = (h @ P["Wh"] if row_vec else P["Wh"] @ h) + x @ P["Wx"] + P["b"]
h = np.tanh(pre)
ys.append(h @ P["Wo"])
return np.array(ys)
assert np.allclose(out, ref(True), atol=1e-4) or np.allclose(out, ref(False), atol=1e-4), \
"The output of rnn_forward doesn't match h_t = tanh(W_h h_{t-1} + W_x x_t + b), y_t = h_t W_o (h_0 should be 0)"
batched = jax.vmap(rnn_forward, in_axes=(None, 0))(params, jnp.stack([xs_j, -xs_j, 2 * xs_j]))
assert batched.shape == (3, 7, 1), "rnn_forward should be vmappable over a batch of sequences"
f = lambda p: jnp.sum(rnn_forward(p, xs_j) ** 2)
g = jax.grad(f)(params)
assert jax.tree.structure(g) == jax.tree.structure(params), "The gradient should have the same structure as params"
check_grads(f, (params,), order=1, modes=["rev"], atol=5e-2, rtol=5e-2) # backprop through scan agrees with finite differences
rl = np.asarray(rnn_losses)
assert rl.shape == (300,) and np.all(np.isfinite(rl)), "rnn_losses should be 300 steps of finite losses"
assert rl[-1] < 0.1 * rl[0], f"After 300 training steps the loss should drop below 1/10 of its initial value: {rl[0]:.4f} → {rl[-1]:.4f}"
_selfcheck(); del _selfcheck
print("✅ Extra exercise 4: self-check passed")
🔍 Extra: how ecosystem libraries manage state: Optax and Flax¶
⚪ Optional: read this when you're about to start using Optax / Flax.
The pattern we've written by hand in this chapter is exactly what the JAX ecosystem libraries are designed around. You can follow the comparison below without installing them (the code below is illustrative and wasn't run in this environment; check each library's current docs for exact signatures). As it happens, this environment does have optax 0.2.9 and flax 0.12.12 installed; for an actual cross-check of the hand-written optimizers against optax, see Chapter 7, topic 5.
Optax: optimizers as pure init / update functions¶
An Optax optimizer is just a pair of pure functions, with exactly the same structure as this chapter's Optimizer(init, update):
import optax
optimizer = optax.adam(learning_rate=1e-3) # GradientTransformation(init, update)
opt_state = optimizer.init(params) # the state is a PyTree
@jax.jit
def train_step(params, opt_state, batch):
loss, grads = jax.value_and_grad(loss_fn)(params, batch)
updates, opt_state = optimizer.update(grads, opt_state, params) # passed in explicitly, returned explicitly
params = optax.apply_updates(params, updates)
return params, opt_state, loss
What Optax adds is composability: optax.chain(optax.clip_by_global_norm(1.0), optax.adamw(schedule)) strings together gradient clipping, weight decay and a learning rate schedule, and the combined state is a tuple of the parts' states (a PyTree again). The learning rate schedule is computed internally from the count state, which neatly avoids the "static learning rate causes recompilation" problem from 6.6.
Flax Linen: stateless modules, variables passed in and out explicitly¶
A Linen Module only describes the computation and holds no data. Parameters and other mutable collections (such as BatchNorm's batch_stats) live in a variables dict that is passed to apply explicitly:
variables = model.init(key, x) # {'params': ..., 'batch_stats': ...}
# Training mode: declare batch_stats mutable, and apply returns the updated state as its second return value
y, updates = model.apply(variables, x, train=True, mutable=['batch_stats'])
variables = {**variables, **updates}
# When randomness is needed (e.g. Dropout), the key is passed in explicitly too
y = model.apply(variables, x, rngs={'dropout': dropout_key})
This is a direct expression of this chapter's "state in, state out" pattern, and mutable=[...] plays the role of has_aux from the Extra in 6.7.
Flax NNX: object-oriented on the surface, pure functions underneath¶
NNX lets you write mutable objects PyTorch-style (self.count.value += 1), which feels more natural. It works with JAX transformations because wrappers like nnx.jit and nnx.grad use nnx.split(model) before entering the transformation to break the object into a static structure graphdef + a state PyTree state, rebuild it with nnx.merge inside the pure function, and write the new state back to the object afterward. In other words, "mutable" is just surface syntax; at the transformation boundary, it's still explicit state passing:
graphdef, state = nnx.split(model) # structure + state PyTree
# ... inside the pure function: model = nnx.merge(graphdef, state); compute; _, state = nnx.split(model)
nnx.update(model, state) # write the new state back to the object
| Approach | Where the state lives | How it's updated |
|---|---|---|
| Hand-written (this chapter) | Your own dict / NamedTuple | new_state = f(state, ...) |
| Optax | The opt_state PyTree | updates, opt_state = tx.update(...) |
| Flax Linen | The variables dict (grouped into collections) | apply(..., mutable=[...]) returns the updates |
| Flax NNX | Variable attributes on the module object | Wrapped transformations split / merge automatically |
📝 Chapter 6 quiz¶
About this quiz: the course's official quiz is on the quiz page (login required). The 8 questions below focus on design trade-offs and common failure modes.
1. Which of these functions behaves differently under jax.jit than without jit?
- A.
def f(x): return jnp.sin(x) * 2 - B.
def f(x): global n; n += 1; return x + n - C.
def f(x, s): return x * s - D.
def f(p, g): return jax.tree.map(lambda a, b: a - 0.1 * b, p, g)
2. In JAX 0.12, which of these calls raises AttributeError?
- A.
jax.tree.map(f, t)· B.jax.tree_util.tree_map(f, t)· C.jax.tree_map(f, t)· D.jax.tree.leaves(t)
3. What is the result of jax.tree.leaves({'w': 1.0, 'b': None, 'c': [2.0, 3.0]})?
- A.
[None, 1.0, 2.0, 3.0]· B.[2.0, 3.0, 1.0]· C.[1.0, None, [2.0, 3.0]]· D.[1.0, 2.0, 3.0]
4. Inside a jitted function you call lax.scan(body, init, None, length=steps), where steps is an ordinary jit argument. What happens?
- A. It runs normally · B. It raises
ConcretizationTypeError· C. It quietly runs only 1 step · D. It automatically becomes awhile_loop
5. You train with a cosine-decay learning rate and pass the learning rate to a jit-compiled training step via static_argnums. What's the main problem?
- A. The gradients come out wrong · B. The learning rate doesn't take effect · C. Every step recompiles · D. It raises a type error
6. Which of these is the right way to handle random keys?
- A. Reuse the same
keythroughout training to keep things reproducible - B. Keep the key in a global variable, split it inside the jitted function, and write it back to the global
- C. Before each use,
key, sub = jax.random.split(key), sample withsub, and keep passingkeyalong as state - D. Create a fresh key each step with
jax.random.key(step); this is exactly equivalent to split, with no downsides
7. Given step = jax.jit(update, donate_argnums=0), which usage causes an error?
- A.
state = step(state, g) - B.
new = step(state, g); print(state) - C.
state, aux = step(state, g)(updatereturns a pair) - D. Running
state = step(state, g)repeatedly in a loop
8. A function needs to return both a scalar loss and updated BatchNorm statistics, and you want gradients of the loss only. What should you use?
- A.
jax.grad(f), withfreturning a tuple · B.jax.value_and_grad(f, has_aux=True)· C.jax.vmap(jax.grad(f))· D. Store the statistics in a global variable
Answer and explanation (click to expand)
- B. Modifying a global variable is a side effect that runs only once, during tracing, so
nstops increasing on later cache hits; and thenread during tracing gets baked in as a constant. A and D are pure functions; in C,sis an argument, so there's no problem. - C. The top-level alias
jax.tree_maphas been removed; bothjax.tree.*andjax.tree_util.*work (verified in 6.4). - B. This question tests two details: ①
Noneis an empty PyTree node with no children, not a leaf; ② dicts are flattened in sorted key order, not insertion order. Sorted, the keys areb, c, w:'b'isNoneand contributes no leaves;'c'contributes2.0, 3.0;'w'contributes1.0, so the result is[2.0, 3.0, 1.0]. Picking D means overlooking the key sorting. (The code cell below verifies this.) - B.
scan's length determines the output shape, so it must be known at compile time. The fix isstatic_argnames, or switching tofori_loop(shown in 6.5). - C. The values of static arguments are part of the compilation cache key, and every new value triggers recompilation (shown in 6.6). Pass the learning rate in as a dynamic array argument instead, or compute it inside the function from the step count, the way Optax does.
- C. A makes all the "random numbers" the same; B's write to a global inside jit only takes effect during tracing; D uses consecutive integers as seeds, which isn't recommended statistically; the right ways to derive keys are
splitorjax.random.fold_in(key, step). - B. After
stateis donated, its buffer has been deleted, and accessing it again raisesRuntimeError: Array has been deleted. - B.
has_aux=Trueletsfreturn(loss, aux); onlylossis differentiated, andauxis carried out as is (the Extra in 6.7).
# Verify quiz questions 2, 3 and 4
print("Q2:", "jax.tree_map exists" if hasattr(jax, "tree_map") else "jax.tree_map does not exist → AttributeError")
print("Q3:", jax.tree.leaves({'w': 1.0, 'b': None, 'c': [2.0, 3.0]}))
@jax.jit
def q4(init, steps):
return lax.scan(lambda c, _: (c + 1, None), init, None, length=steps)[0]
try:
q4(0, 5)
except Exception as e:
print("Q4:", type(e).__name__)
Version notes¶
Below is a summary of the patterns and conclusions in this chapter that need attention under JAX 0.12. Each one is verified with an assert at the corresponding place in the text (environment: JAX 0.12.0.1, CPU).
| No. | Section | Key point | Where |
|---|---|---|---|
| 1 | 6.2 | Jitting a plain object that modifies its attributes in place (like StateHolder) always raises TypeError (the object is neither an array nor a PyTree); the case that silently goes wrong is after registering it as a PyTree | 6.2 The challenge of state in functional code |
| 2 | 6.4 | For leaf-by-leaf updates, jax.tree.map(...) is recommended; jax.tree_util.tree_map still works but is no longer recommended | 6.4 Using PyTrees for structured state |
| 3 | 6.4 | vmap stands for vectorization; quantization is a different thing | 6.4 Using PyTrees for structured state |
| 4 | 6.4 | A full old → new migration table for the PyTree API (marking the top-level aliases removed in 0.12), with demos of map / leaves / structure / flatten / unflatten / map_with_path / reduce / all / transpose | 6.4 Using PyTrees for structured state (Extra: migration table) |
| 5 | 6.4 | Besides dict / list / tuple, state containers can be NamedTuple, register_dataclass or register_pytree_node_class | 6.4 Using PyTrees for structured state (Extra: custom containers) |
| 6 | 6.5 | A tracer is not a type the caller passes in; a jitted counter called starting from a Python int traces only once, and an extra trace happens only when a weak type turns into a strong type | 6.5 Example: a stateful counter |
| 7 | 6.5 | Passing an ordinary jit argument to scan(length=steps) raises ConcretizationTypeError; fix: static_argnames="steps", or switch to lax.fori_loop | 6.5 Example: a stateful counter |
| 8 | 6.5 | A random key is state that must be passed explicitly: use each key only once and split first; use jax.random.key instead of PRNGKey (demos and asserts in Chapter 1 and Advanced 10) | 6.5 Example: a stateful counter (Extra: random keys) |
| 9 | 6.6 | On $f(x)=(x-3)^2$, momentum with $\eta=0.1,\ \mu=0.9$ ends at 4.0478 after 20 steps, worse than plain SGD's 2.9654; with $\mu=0.5$ it reaches 2.9992 | 6.6 Example: simple optimizer state |
| 10 | 6.6 | Making the learning rate and momentum static_argnums triggers a recompile for every new value: 5 learning rate values, 5 recompiles; as dynamic arguments it compiles only once | 6.6 Example: simple optimizer state |
| 11 | 6.6 | Generic PyTree momentum SGD and Adam (with bias correction), allclose to a NumPy reference; plus a full training loop (term-by-term comparison and actual cross-check with Optax in Chapter 7, topic 5) | 6.6 Example: simple optimizer state (Extra: generic optimizers) |
| 12 | 6.6 | Reducing the memory cost of state updates with donate_argnums: how it works, actual behavior on CPU, and the pitfall of donating the same buffer twice (Adam's mu and nu must not share zeros) | 6.6 Example: simple optimizer state (Extra: donate_argnums) |
| 13 | 6.7 | Compare state before and after jit with jax.tree.all(jax.tree.map(...)); the top-level alias jax.tree_map has been removed (AttributeError) | 6.7 Combining state management with transformations |
| 14 | 6.7 | Use jax.tree.map for leaf-by-leaf parameter updates in training_step (jax.tree_map has been removed) | 6.7 Combining state management with transformations |
| 15 | 6.7 | When vmap shares parameters, the default out_axes=0 copies them along the batch dimension; avoid that with out_axes=(0, None); use has_aux when you need to carry state out | 6.7 Combining state management with transformations |
| 16 | 6.7 | The new way to manage state across multiple devices is jit + jax.sharding / jax.shard_map (pmap still works) | 6.7 Combining state management with transformations |
| 17 | 6.8 | If the JIT version of Exercise 2 uses the Python scalars (0.0, 0) as the initial state, weak typing makes it trace 2 times; with fixed-dtype arrays it traces only once | 6.8 Practice: implementing stateful functions |
| 18 | 6.8 | In Exercise 3 the learning rate should be passed in as a dynamic argument, not a static one (same reason as item 10) | 6.8 Practice: implementing stateful functions |
| 19 | 6.1 | Writing a global variable inside jit happens only during tracing, and reading one freezes it as a constant; use jax.debug.print for run-time printing | 6.1 Functional purity and side effects |
| 20 | Quiz | 8 quiz questions for this chapter, focused on design trade-offs and common failures, with answers and explanations | Chapter 6 quiz |
| 21 | 6.1 / 6.2 | vmap traces only once, so a side effect that modifies external state in the function runs once for the whole batch and sees a BatchTracer; the problem isn't race conditions but that side effects don't happen per example (assert) | 6.2 The challenge of state in functional code |
📌 Summary¶
- Pure function = deterministic + no side effects. JAX's
jit/grad/vmap/pmapare all built on this assumption. - Side effects inside jit (
print, writing globals) run only once, during tracing; globals that are read get frozen as constants. Usejax.debug.printfor debugging. - Modifying object attributes in place under jit either errors (the object isn't a PyTree) or silently goes wrong (the object is a PyTree, and only an internal copy gets modified). JAX arrays themselves are immutable; use
.at[].set(). - Explicit state passing:
new_state, out = f(state, inputs); the caller is responsible for catching the new state and passing it to the next call. - Organize complex state with PyTrees (dict / list / tuple / NamedTuple / registered dataclass), and transformations keep the structure automatically; the result of
gradhas the same structure as the parameters. - Use
jax.tree.*(recommended) orjax.tree_util.*; top-level aliases likejax.tree_maphave been removed. - In
lax.scan, the carry is the state andyscollects per-step outputs;lengthmust be static; the carry's structure, shapes and dtypes must stay the same at every step. - A random key is state too: use each key only once; after a
split, pass one along and use the other. - Optimizer state (momentum, Adam's moments) is a PyTree with the same structure as the parameters; the
init/updateinterface is the core design of Optax. - Don't make changing scalars like the learning rate static arguments, or every new value recompiles; give the initial state the same dtypes as the updated state to avoid an extra compile.
donate_argnumslets the new state reuse the old state's buffers, lowering peak memory; after donating, you can't access the old arrays again.vmapcan batch over independent states (in_axescan be a PyTree); for shared state usein_axes=None,out_axes=None. Usehas_aux=Truewhen you need to carry extra state out.
🗂️ Cheat sheet¶
| API | What it does |
|---|---|
jax.tree.map(f, t, *rest) | Apply a function leaf by leaf and return a new tree with the same structure |
jax.tree.leaves(t) | Pull out all the leaves (a flat list) |
jax.tree.structure(t) | Get the tree structure, a PyTreeDef |
jax.tree.flatten(t) / jax.tree.unflatten(d, l) | Flatten into (leaves, structure) / rebuild from structure and leaves |
jax.tree.all(t) / jax.tree.reduce(f, t) | Logical AND over the leaves / reduce the leaves |
jax.tree.map_with_path(f, t) | map with key paths; jax.tree_util.keystr turns a path into a string |
jax.tree_util.register_dataclass | Register a dataclass as a PyTree (distinguishing data fields from static fields) |
jax.tree_util.register_pytree_node_class | Flatten / rebuild rules for a custom class |
typing.NamedTuple | A ready-to-use PyTree state container with named fields; ._replace() creates a new copy |
x.at[idx].set(v) | Functional "modification" of an array; returns a new array |
jax.lax.scan(body, init, xs, length) | A loop with state, body(carry, x) -> (carry, y) |
jax.lax.fori_loop(lo, hi, body, init) | A loop whose count can be decided at run time (returns only the final state) |
jax.random.key(seed) / jax.random.split(key, n) | Create a key / derive new keys (keys are explicit state) |
jax.random.fold_in(key, i) | Derive a key from an integer index |
jax.value_and_grad(f, has_aux=True) | Get the loss, auxiliary state and gradients together |
jax.jit(f, static_argnums/static_argnames) | Make arguments that affect the program structure static (every new value recompiles) |
jax.jit(f, donate_argnums=...) | Donate input buffers to the outputs, saving memory on state updates |
jax.vmap(f, in_axes, out_axes) | Batch over independent states; None means shared |
jax.debug.print(fmt, *args) | A print that runs on every execution of the compiled function |
🧭 Course summary and where to go next¶
After the 6 chapters of this course, you have the core mental model of JAX:
jax.Arrayandjax.numpy: immutable arrays, a NumPy-like API, running the same way on CPU / GPU / TPU;jit: tracing → Jaxpr → XLA compilation, static vs. dynamic arguments, and what triggers recompilation;grad: functional automatic differentiation,value_and_grad,has_aux, higher-order derivatives;vmap: automatic vectorization,in_axes/out_axes;pmapand parallelism: multi-device data parallelism and collective communication;- State management: pure functions + explicit state passing + PyTrees, which combine all the transformations above into a complete training pipeline.
Suggested next steps
| Stage | Content | Notes |
|---|---|---|
| Must-read | JAX - The Sharp Bits | The official "gotchas" collection: pure functions, in-place updates, out-of-bounds indexing, random numbers, control flow, NaN debugging and more; many of this chapter's Extras come from here |
| Official tutorials | Stateful computations, Pytrees, Control flow and Pseudorandom numbers in the JAX docs | The authoritative version of this chapter's content |
| Optimizers | Optax | Composable gradient transformations: Adam / AdamW / Lion, gradient clipping, learning rate schedules |
| Neural networks | Flax (NNX recommended for new projects) | Modular modeling; understand how NNX's split / merge relates to this chapter |
| Checkpoints | Orbax | Save / restore PyTrees of parameters and optimizer state, with async and sharding support |
| Parallelism | The official Distributed arrays and automatic parallelization, jax.sharding, shard_map | The new generation of parallelism, gradually replacing pmap |
| Performance | jax.profiler, jax.make_jaxpr, donate_argnums, jax.checkpoint (rematerialization) | Find compilation and memory bottlenecks |
| Hands-on | Write a complete MNIST / CIFAR training script with Flax + Optax + Orbax | Bundle the parameters, optimizer state, random key and step count into one TrainState PyTree |
Appendix: reference solutions¶
Write your own answer in the "✍️ Your answer" cell in the main text first, and check it with the "🧪 Self-check". Come here to compare once the self-check passes or you're really stuck. Running the solutions below in order overwrites any functions with the same names that you defined in the answer cells.
Extra exercise 1¶
See "Extra exercise 1: an exponential moving average (EMA) of the parameters" in the main text for the problem.
# Reference solution
class TrainState(NamedTuple):
step: jax.Array
params: dict
ema_params: dict
@jax.jit
def ema_train_step(state, x, y, lr, decay):
grads = jax.grad(loss_fn_linear)(state.params, x, y)
new_params = jax.tree.map(lambda p, g: p - lr * g, state.params, grads)
new_ema = jax.tree.map(lambda e, p: decay * e + (1 - decay) * p, state.ema_params, new_params)
return TrainState(step=state.step + 1, params=new_params, ema_params=new_ema)
p0 = {'w': jnp.array(0.0), 'b': jnp.array(0.0)}
state = TrainState(step=jnp.array(0), params=p0, ema_params=p0)
for _ in range(100):
state = ema_train_step(state, x_data, y_data, 0.05, 0.9)
print("step :", state.step)
print("params :", jax.tree.map(lambda a: round(float(a), 4), state.params))
print("ema_params:", jax.tree.map(lambda a: round(float(a), 4), state.ema_params))
Extra exercise 2¶
See "Extra exercise 2: Welford's online variance with lax.scan + vmap" in the main text for the problem.
# Reference solution
class WelfordState(NamedTuple):
n: jax.Array
mean: jax.Array
m2: jax.Array
def welford_step(state, x):
n = state.n + 1
delta = x - state.mean
mean = state.mean + delta / n
m2 = state.m2 + delta * (x - mean)
new_state = WelfordState(n, mean, m2)
return new_state, m2 / n # output the current variance at each step
def online_var(xs):
init = WelfordState(jnp.array(0.0), jnp.array(0.0), jnp.array(0.0))
final, var_trace = lax.scan(welford_step, init, xs)
return final.mean, final.m2 / final.n, var_trace
key = jax.random.key(7)
streams = jax.random.normal(key, (4, 1000)) * jnp.array([[1.0], [2.0], [0.5], [3.0]]) + 100.0
means, variances, traces = jax.jit(jax.vmap(online_var))(streams)
print("Welford mean :", means)
print("jnp.mean :", streams.mean(axis=1))
print("Welford variance:", variances)
print("jnp.var :", streams.var(axis=1))
print("Variance trace shape:", traces.shape)
Extra exercise 3¶
See "Extra exercise 3: implement RMSProp and plug it into the generic training framework above" in the main text for the problem.
# Reference solution
class RMSPropState(NamedTuple):
sq_avg: dict
def rmsprop(lr, rho=0.9, eps=1e-8):
def init(params):
return RMSPropState(sq_avg=jax.tree.map(jnp.zeros_like, params))
def update(grads, state, params=None):
s = jax.tree.map(lambda s, g: rho * s + (1 - rho) * g ** 2, state.sq_avg, grads)
updates = jax.tree.map(lambda g, s: -lr * g / (jnp.sqrt(s) + eps), grads, s)
return updates, RMSPropState(sq_avg=s)
return Optimizer(init, update)
_, _, rms_losses = make_train(rmsprop(0.003), 1000)(params0, X, Y)
print(f"RMSProp final loss: {rms_losses[-1]:.5f}")
print(f"Adam final loss: {results['Adam'][-1]:.5f}")
Extra exercise 4¶
See "Extra exercise 4: an RNN's hidden state, and taking gradients through lax.scan" in the main text for the problem.
# Reference solution
H, D, T = 16, 1, 20
def init_rnn(key):
k1, k2, k3 = jax.random.split(key, 3)
return {'Wh': jax.random.normal(k1, (H, H)) * 0.1,
'Wx': jax.random.normal(k2, (D, H)) * 0.5,
'b': jnp.zeros(H),
'Wo': jax.random.normal(k3, (H, 1)) * 0.1}
def rnn_forward(params, xs): # xs: (T, D)
def cell(h, x):
h = jnp.tanh(h @ params['Wh'] + x @ params['Wx'] + params['b'])
return h, h @ params['Wo'] # carry = hidden state, y = this step's output
h0 = jnp.zeros(H)
_, ys = lax.scan(cell, h0, xs)
return ys # (T, 1)
def rnn_loss(params, xs_batch, ys_batch):
preds = jax.vmap(rnn_forward, in_axes=(None, 0))(params, xs_batch)
return jnp.mean((preds - ys_batch) ** 2)
key = jax.random.key(123)
key, k_data, k_init = jax.random.split(key, 3)
xs_batch = jax.random.uniform(k_data, (64, T, D), minval=-0.5, maxval=0.5)
ys_batch = jnp.cumsum(xs_batch, axis=1) # target: the running sum
rnn_params = init_rnn(k_init)
rnn_opt = adam(0.01)
@jax.jit
def train_rnn(params, xs, ys):
"""Parameters and data are both passed in as jit arguments, not captured by closures."""
def rnn_step(carry, _):
params, opt_state = carry
loss, grads = jax.value_and_grad(rnn_loss)(params, xs, ys)
updates, opt_state = rnn_opt.update(grads, opt_state, params)
return (apply_updates(params, updates), opt_state), loss
(params, _), losses = lax.scan(rnn_step, (params, rnn_opt.init(params)), None, length=300)
return params, losses
rnn_params, rnn_losses = train_rnn(rnn_params, xs_batch, ys_batch)
print(f"initial loss {rnn_losses[0]:.5f} → final loss {rnn_losses[-1]:.5f}")
assert rnn_losses[-1] < 0.1 * rnn_losses[0]
print("Gradient structure matches the parameters:", jax.tree.map(jnp.shape, jax.grad(rnn_loss)(rnn_params, xs_batch, ys_batch)))