Chapter 7: Practical Topics
Chapters 1–6 covered jit, grad, vmap, pmap, and state management, and the "🔍 Extra" sections of each chapter touched on quite a few practical problems along the way. This chapter doesn't repeat that material. Instead, it picks the 7 topics that cause the most trouble in training code, goes deeper on each one, and finally ties them together with an MLP classifier written from scratch.
Every topic follows the same structure:
Motivation → how it works → ❌ common mistakes, measured → ✅ the right way →
assertas proof → 💡 a one-line practical rule
"→ Basics in Chapter N x.x" in the text points to the related basics in an earlier chapter; it's worth reviewing that first before reading on.
All conclusions were run on JAX 0.12.0.1 / Python 3.14 / CPU only. Timings depend on the machine, so look at relative sizes.
Contents¶
| # | Topic | What you'll get |
|---|---|---|
| 1 | Recompilation: triggers, diagnosis, and fixes | A measured table of 8 kinds of triggers, 4 diagnostic tools, bucketed padding, AOT compilation, a compile-budget tool |
| 2 | Numerical stability and NaN gradients | A general double-where pattern, stable logsumexp / softmax / log_softmax, how jax_debug_nans actually behaves in 0.12, fixing gradients with custom_jvp |
| 3 | Choosing between jacfwd / jacrev / jvp / vjp | A decision table, timing plots over a dimension sweep, memory and speed of three HVP implementations |
| 4 | Per-example gradients | vmap(grad) cross-checked against a loop, B×P memory and chunking, full DP-SGD (clipping + noise) |
| 5 | Hand-written optimizers | An Optax-style GradientTransformation, SGD/momentum/Nesterov/Adam/AdamW, chain, warmup + cosine |
| 6 | donate_argnums and memory | How aliasing works, CPU measurements, the donate-twice pitfall, a standard training-step template |
| 7 | The new PyTree API and custom containers | Extra tests for the migration table (full table in Chapter 6), the whole jax.tree.* family, dataclass / NamedTuple as TrainState, a parameter table |
| ★ | Capstone project: MLP classifier on spiral data | Ties everything above together; asserts accuracy > 95% and that the training function compiles only once |
| 🏋️ | Extra exercises (6, with reference solutions) | Compile-budget test, safe cosine similarity, Newton-CG, finding mislabeled examples, the Lion optimizer (cross-checked against optax.lion), frozen layers + donation |
| 📝 | Chapter 7 quiz (8 questions) | Answers folded in <details>; some questions come with code checks |
| ✔ | Practical rules checklist | 2–3 rules per topic |
| 🗂️ | Cheat sheet | Key APIs used in this chapter → what they do, grouped by topic |
Learning goals¶
After this chapter, you should be able to:
- Explain what's in the jit cache key; when "training slows down", use logs / counters /
jax.stagesto find which argument triggered recompilation, and put it into a test with a compile budget; - Write
where, norms, and softmax cross-entropy that don't produce NaN gradients, and know whatjax_debug_nanscan and can't catch; - Pick the right one of
jacfwd,jacrev,jvp,vjpbased on input/output dimensions, and do second-order computations without building the Hessian; - Compute per-example gradients efficiently, estimate their memory, and implement DP-SGD;
- Write composable optimizers and learning-rate schedules with PyTrees, without Optax, and verify them against NumPy reference values;
- Write training steps with
donate_argnamesand avoid donation-related runtime errors; - Use the new
jax.tree.*API fluently, define a TrainState with a dataclass, and print a parameter table. - Combine all of the above into a complete training project, and solve the 6 extra exercises of this chapter on your own.
0 Common setup¶
⚪ Optional: the timing, error-printing, and compile-counting helpers shared by this chapter; come back to the definitions when you need them
The cell below defines helpers used throughout the chapter:
bench: a timing helper; it warms up first (triggering compilation), then times, callingjax.block_until_readyevery time;show_error: when we deliberately demonstrate an error, prints only the exception type and the first line of the message;CompileCounter: a compile counter based onjax.monitoringthat counts the XLA compilations that really happen inside awithblock, optionally filtered by function name. Topic 1 explains it in detail. We define it here up front, and every topic afterwards uses it to prove that "there are no extra compilations".
⚠️ Known issue in this environment (→ 00 Overview · known issues in this environment; root cause in Advanced 02 · 2.3): the CPU backend of jaxlib 0.12.0.1 has a bug where a larger constant matrix captured by a jit closure silently gives wrong results after a matmul followed by a reduce, with no error. So in this chapter all weights and data are passed in as function arguments, and key numbers are cross-checked against NumPy float64.
# Common imports and helpers (every later cell depends on this one)
import time, math, logging, warnings, functools, dataclasses
from dataclasses import dataclass, field
from functools import partial
from typing import NamedTuple, Any, Callable
import numpy as np
import jax
import jax.numpy as jnp
from jax import lax
import jax.monitoring as monitoring
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
# Chart colors (categorical colors in a fixed order)
C_BLUE, C_ORANGE, C_AQUA, C_VIOLET, C_RED = "#2a78d6", "#eb6834", "#1baf7a", "#6250d6", "#e34948"
print("JAX", jax.__version__, "| backend", jax.default_backend(), "| devices", jax.devices())
def bench(f, *args, warmup=2, runs=10):
"""Timing: warm up `warmup` times (including compilation), then run `runs` times and return the median in ms."""
for _ in range(warmup):
jax.block_until_ready(f(*args))
ts = []
for _ in range(runs):
t0 = time.perf_counter()
jax.block_until_ready(f(*args))
ts.append(time.perf_counter() - t0)
return float(np.median(ts)) * 1e3
def assert_faster(fast, slow, factor=1.0, tries=3):
"""Timing assertion: time of fast() × factor < time of slow() (both arguments are no-arg functions that return a time).
Timings depend on machine load (e.g. several notebooks running in parallel on CI), so if it fails, measure again, up to `tries` times."""
for _ in range(tries):
tf, ts = fast(), slow()
if tf * factor < ts:
return tf, ts
raise AssertionError(f"Still not satisfied after {tries} tries: {tf:.3g} × {factor} ≥ {ts:.3g}")
def show_error(fn):
"""Run fn; if it raises, print only the exception type and the first line of the message (for demonstrating wrong code)."""
try:
out = fn()
print("(no error) result:", out)
except Exception as e:
first = str(e).strip().splitlines()[0] if str(e).strip() else ""
print(f"❌ {type(e).__name__}: {first[:160]}")
_COMPILE_EVENT = "/jax/core/compile/backend_compile_duration"
class CompileCounter:
"""Count the XLA backend compilations that happen inside a with block.
name=None counts all compilations; name="f" counts only compilations of jit(f).
How it works: every time JAX finishes a backend compilation, it emits a
'/jax/core/compile/backend_compile_duration' event through jax.monitoring, with fun_name attached.
"""
def __init__(self, name=None):
self.name = name
self.events = [] # [(fun_name, seconds)]
self._listener = self._on_event # keep the same bound-method object so we can find it when unregistering
def _on_event(self, event, duration, **kw):
if event != _COMPILE_EVENT:
return
fun = kw.get("fun_name", "?")
if self.name is None or fun == f"jit({self.name})":
self.events.append((fun, duration))
def __enter__(self):
monitoring.register_event_duration_secs_listener(self._listener)
return self
def __exit__(self, *exc):
monitoring.unregister_event_duration_listener(self._listener)
return False
@property
def count(self):
return len(self.events)
def wpad(s, width):
"""Left-align by display width (a CJK character takes 2 columns), for printing aligned tables."""
import unicodedata
w = sum(2 if unicodedata.east_asian_width(ch) in "WF" else 1 for ch in str(s))
return str(s) + " " * max(0, width - w)
def to64(x):
"""Convert to NumPy float64, for cross-checking against high-precision reference values."""
return np.asarray(x, dtype=np.float64)
# Self-test: the counter sees compilations and doesn't count cache hits
_x = jnp.ones(3)
def _probe(x):
return x * 2
with CompileCounter("_probe") as _c:
jax.jit(_probe)(_x); jax.jit(_probe)(_x)
assert _c.count == 1, _c.events
print("CompileCounter self-test passed: two calls, only 1 compilation")
Topic 1 Recompilation: triggers, diagnosis, and fixes¶
1.1 Motivation¶
🟢 Must-read
The typical symptom: the model isn't big, yet training gets slower and slower, or stalls every few steps. The CPU is maxed out, but little real computation gets done. The usual culprit is recompilation: every time the jit cache misses, JAX has to retrace the Python function, generate StableHLO, and hand it to XLA to optimize and compile. Compiling a medium-sized training step takes anywhere from a few hundred milliseconds to tens of seconds, while running it may take just a few milliseconds.
Let's start with a direct comparison: the same function called with 20 different batch sizes, versus called 20 times with the same batch size. How different are the times?
→ Basics in Chapter 2 2.3 (tracing and compilation, the table of recompilation triggers, AOT API), 2.5 (static arguments), 2.6 problem 4 (dynamic shapes), exercise 3 (recompilation detective).
def mlp_loss(w1, w2, x):
# Forward pass of a small MLP + mean square; weights and data are passed in as arguments
h = jnp.tanh(x @ w1)
return jnp.mean((h @ w2) ** 2)
k1, k2, k3 = jax.random.split(jax.random.key(0), 3)
W1 = jax.random.normal(k1, (64, 256)) / 8.0
W2 = jax.random.normal(k2, (256, 10)) / 16.0
X_all = jax.random.normal(k3, (600, 64))
grad_fn = jax.jit(jax.grad(mlp_loss, argnums=(0, 1)))
sizes = [100 + 7 * i for i in range(20)] # 20 different batch sizes
batches = [X_all[:n] for n in sizes] # slice ahead of time so slicing isn't timed
same = [X_all[:100]] * 20
def run(xs):
t0 = time.perf_counter()
for x in xs:
jax.block_until_ready(grad_fn(W1, W2, x))
return (time.perf_counter() - t0) * 1e3
with CompileCounter("mlp_loss") as c_var:
t_var = run(batches)
grad_fn(W1, W2, same[0]) # warm up: make sure only execution is measured below
with CompileCounter("mlp_loss") as c_same:
t_same = run(same)
print(f"20 batch sizes: {t_var:7.1f} ms, {c_var.count} compilations")
print(f"same batch size: {t_same:7.1f} ms, {c_same.count} compilations")
print(f"About {np.mean([d for _, d in c_var.events]) * 1e3:.1f} ms per compilation, "
f"about {t_same / 20:.2f} ms per execution")
assert c_var.count == 20 and c_same.count == 0
1.2 How it works: what's in the jit cache key¶
🟢 Must-read
Every time you call jax.jit(f)(*args), JAX computes a cache key. On a hit, it runs the compiled program directly; on a miss, it retraces and recompiles. The cache key is roughly made of: the wrapped function object (by object identity) + the pytree structure of the arguments (container types, dict keys, positions of None) + the shape / dtype / weak_type of each leaf (and sharding) + the values of static arguments (compared with __hash__ / __eq__) + how you call it (positional, keyword, omitted default arguments) + global config that affects tracing (such as jax_enable_x64). Examples and checks for each item are in Chapter 2, 2.3 "Table of recompilation triggers" (E1–E8), so we won't repeat them here.
Here we add two kinds of "object identity" problems that the table doesn't go into: bound methods like jax.jit(self.method), and static config classes hashed by id.
Cases that don't trigger recompilation: changing only the values with the same shape and dtype; wrapping the same module-level function with jax.jit(f) several times (they share a cache); requesting float64 under the default config (it gets truncated to float32, → see the Extra in Chapter 2, 2.3).
Below we measure the basic items of the table again together with these two kinds (later topics all use the count_compiles defined here). Each row uses a newly defined function, counts how many times it gets compiled with CompileCounter, and checks the expected count with assert.
1.3 ❌ Common triggers, measured one by one¶
⚪ Optional: read this when you want to confirm each kind of trigger (including bound methods and config classes hashed by id); count_compiles, used later, is defined here
x3, x4 = jnp.ones(3), jnp.ones(4)
x3_i = jnp.ones(3, jnp.int32)
def count_compiles(name, calls):
"""Run each no-arg function in calls in order, and return how many times jit(name) was compiled."""
with CompileCounter(name) as c:
for call in calls:
jax.block_until_ready(call())
return c.count
rows = [] # (trigger, how it's called, measured compile count, expected count)
# ① Shape
def t_shape(x): return x * 2
f = jax.jit(t_shape)
rows.append(("shape", "f32[3] → f32[4]", count_compiles("t_shape", [lambda: f(x3), lambda: f(x4)]), 2))
# ② dtype
def t_dtype(x): return x * 2
f = jax.jit(t_dtype)
rows.append(("dtype", "f32[3] → i32[3]", count_compiles("t_dtype", [lambda: f(x3), lambda: f(x3_i)]), 2))
# ③ Weak type: Python scalars are "weakly typed", arrays are "strongly typed"
def t_weak(x, lr): return x * lr
f = jax.jit(t_weak)
rows.append(("weak type", "lr=0.1 → lr=jnp.float32(0.1)",
count_compiles("t_weak", [lambda: f(x3, 0.1), lambda: f(x3, jnp.float32(0.1))]), 2))
# ④ Value of a static argument
def t_static(x, n): return x ** n
f = jax.jit(t_static, static_argnames="n")
rows.append(("static arg value", "n=2 → n=3", count_compiles("t_static", [lambda: f(x3, n=2), lambda: f(x3, n=3)]), 2))
# ⑤ Pytree structure: one more key / list replaced by tuple / leaf replaced by None
def t_tree(s): return jax.tree.map(lambda a: a + 1, s)
f = jax.jit(t_tree)
rows.append(("pytree structure", "{'a'} → {'a','b'} → [x] → (x,) → {'a', 'b':None}",
count_compiles("t_tree", [lambda: f({"a": x3}), lambda: f({"a": x3, "b": x3}),
lambda: f([x3]), lambda: f((x3,)), lambda: f({"a": x3, "b": None})]), 5))
# ⑥ New jit object: every call creates a new inner function (closure)
def make_and_call(x):
def t_inner(y): return y + 1 # every call to make_and_call creates a new function object
return jax.jit(t_inner)(x)
rows.append(("new jit obj (closure)", "called 3 times in a loop",
count_compiles("t_inner", [lambda: make_and_call(x3)] * 3), 3))
# ⑦ New jit object: jit a bound method (every access to obj.method creates a new bound-method object)
class Model:
def t_method(self, x): return x * 3
def predict(self, x): return jax.jit(self.t_method)(x)
m = Model()
rows.append(("new jit obj (bound meth)", "m.predict called 3 times",
count_compiles("t_method", [lambda: m.predict(x3)] * 3), 3))
# ⑧ Static arguments compared by id: a custom class without __eq__/__hash__ makes every new instance a new key
class Cfg:
def __init__(self, n): self.n = n
def t_cfg(x, cfg): return x * cfg.n
f = jax.jit(t_cfg, static_argnums=1)
rows.append(("static arg hashed by id", "Cfg(2) created 3 times", count_compiles("t_cfg", [lambda: f(x3, Cfg(2))] * 3), 3))
# ⑨ How you call it: omitting a default argument vs. passing the same value explicitly
def t_default(x, scale=2.0): return x * scale
f = jax.jit(t_default)
rows.append(("calling convention", "f(x) → f(x, 2.0) → f(x, scale=2.0)",
count_compiles("t_default", [lambda: f(x3), lambda: f(x3, 2.0), lambda: f(x3, scale=2.0)]), 3))
# -- Control group: these don't trigger recompilation --
def t_value(x): return x * 2
f = jax.jit(t_value)
rows.append(("(control) values only", "ones(3) → ones(3)*5", count_compiles("t_value", [lambda: f(x3), lambda: f(x3 * 5)]), 1))
def t_shared(x): return x * 2
rows.append(("(control) jax.jit(f) x3", "same module-level function wrapped 3 times",
count_compiles("t_shared", [lambda: jax.jit(t_shared)(x3)] * 3), 1))
print(wpad("Trigger", 26) + wpad("Calls", 50) + " Got Exp")
print("-" * 80)
for name, how, got, exp in rows:
print(wpad(name, 26) + wpad(how, 50) + f"{got:>4}{exp:>6}")
for name, how, got, exp in rows:
assert got == exp, (name, got, exp)
print("\nAll match the expectations ✔")
A few points that are easy to miss:
- ⑤ pytree structure:
Noneisn't a leaf but an "empty subtree", so{'a': x, 'b': None}and{'a': x, 'b': x}have different structures. If some field of your training state is sometimesNone(e.g. no momentum yet at step one), you get one extra compilation. - ⑦ bound methods: if you write
jax.jit(self.method)inside a method, every access toself.methodcreates a new bound-method object, so every call recompiles. This is the most common performance accident when putting jit into object-oriented code. - ⑧ static arguments: the cache key compares with
__hash__+__eq__. Plain classes compare byidby default, so a new instance with identical contents still misses the cache.
1.4 Diagnostic tools¶
⚪ Optional: look this up when training slows down and you need to find which argument triggered recompilation
Once you notice the problem, you also need to find which argument caused the recompilation. JAX offers 4 kinds of tools, from coarse to fine:
| Tool | How to use it | What it tells you |
|---|---|---|
| ① Compile log | jax.config.update("jax_log_compiles", True) | One line per compilation: function name + shapes/dtypes of all arguments |
| ② Cache-miss explanations | jax.config.update("jax_explain_cache_misses", True) | Why it missed: compared with the closest old cache key, which argument differs and where |
| ③ Counters | Trace counters (Python side effects), CompileCounter (jax.monitoring events) | Exact counts, which you can put in an assert / unit test |
④ jax.stages | jit(f).trace(...) / .lower(...) / .compile() | How long each stage takes, how big the program is, and ahead-of-time compilation |
The output of ① and ② goes through Python's logging (logger names start with jax, at level WARNING). To keep the output tidy, we write a small helper below that collects these logs before printing them.
class capture_jax_logs:
"""Temporarily take over the 'jax' logger and collect log messages into self.messages (no longer printed to stderr)."""
def __enter__(self):
self.logger = logging.getLogger("jax")
self.messages = []
self.handler = logging.Handler()
self.handler.emit = lambda record: self.messages.append(record.getMessage())
self._old_propagate = self.logger.propagate
self.logger.addHandler(self.handler)
self.logger.propagate = False
return self
def __exit__(self, *exc):
self.logger.removeHandler(self.handler)
self.logger.propagate = self._old_propagate
return False
# ① jax_log_compiles: prints one "Compiling jit(...)" line for every real compilation
def diag_step(params, x):
return jax.tree.map(lambda p: p * x.mean(), params)
f = jax.jit(diag_step)
params_a = {"w": jnp.ones((2, 2)), "b": jnp.zeros(2)}
xs = [jnp.ones(5), jnp.ones(5) * 2, jnp.ones(6)] # build the inputs first so jnp.ones's own compile logs don't get mixed in
jax.config.update("jax_log_compiles", True)
try:
with capture_jax_logs() as logs:
for x in xs:
f(params_a, x)
finally:
jax.config.update("jax_log_compiles", False) # finally makes sure it gets turned off
compiling = [m for m in logs.messages if m.startswith("Compiling")]
for m in compiling:
print(m[:150], "...")
assert len(compiling) == 2 # the 2nd call only has different values, so it hits the cache
# ② jax_explain_cache_misses: tells you directly "which argument changed, and where"
def diag_explain(state, lr):
return jax.tree.map(lambda p: p - lr * p, state)
f = jax.jit(diag_explain)
s1 = {"w": jnp.ones(3)}
s2 = {"w": jnp.ones(3), "m": jnp.zeros(3)} # the structure changed
f(s1, 0.1) # populate the cache first
jax.config.update("jax_explain_cache_misses", True)
try:
with capture_jax_logs() as logs:
f(s1, jnp.float32(0.1)) # weak type → strong type
f(s2, 0.1) # pytree structure change
finally:
jax.config.update("jax_explain_cache_misses", False)
misses = [m for m in logs.messages if "CACHE MISS" in m]
for m in misses:
# drop the file-path lines and keep only the "reason" part
lines = [l for l in m.splitlines() if not l.strip().startswith(("for ", "TRACING"))]
print("\n".join(lines[:6]), "\n")
assert len(misses) == 2
How to read it: the first message says lr has "different input types" and gives the old and new types (weak type f32[]{weak_type=True} became strong type f32[]). The second says the pytree structure of the arguments differs. In a real project, turning on ② and running a few training steps is usually enough to find the problem.
③ Counters: logs are for humans to read, counters are for tests. A trace counter relies on "Python side effects run only during tracing" (→ see the Extra in Chapter 6, 6.1), while CompileCounter counts real backend compilations. The two are usually equal, but tracing doesn't always mean compiling: jax.eval_shape, jax.make_jaxpr, and .lower() only trace, they don't compile.
trace_count = 0
def diag_counter(x):
global trace_count
trace_count += 1 # runs only during tracing
return jnp.sin(x) * 2
f = jax.jit(diag_counter)
with CompileCounter("diag_counter") as c:
jax.eval_shape(f, x3) # trace only, compute the output shape/dtype
f.lower(x4) # trace + lower to StableHLO, no compile
f(x3); f(x3) # 1st: trace + compile (reusing the trace from eval_shape); 2nd: cache hit
print(f"trace count = {trace_count}, backend compile count = {c.count}")
assert c.count == 1 and trace_count >= 2
④ jax.stages: splits a jit call into three stages, trace → lower → compile, and times each one. Below we use a function with a "Python loop unrolled 30 times" to see where compile time goes and how big the compiled program is. A Compiled object only accepts the shapes/dtypes it was compiled for; passing the wrong ones raises TypeError right away instead of quietly recompiling. That's what "compile ahead of time, zero compilations in production" relies on.
def deep_unrolled(ws, x):
for w in ws: # the Python loop gets unrolled into 30 copies during tracing
x = jnp.tanh(x @ w)
return x.sum()
ws = [jax.random.normal(k, (64, 64)) / 8.0 for k in jax.random.split(jax.random.key(1), 30)]
xb = jnp.ones((16, 64))
jf = jax.jit(deep_unrolled)
t0 = time.perf_counter(); traced = jf.trace(ws, xb); t1 = time.perf_counter()
lowered = traced.lower(); t2 = time.perf_counter()
compiled = lowered.compile(); t3 = time.perf_counter()
print(f"trace {1e3*(t1-t0):6.1f} ms | lower {1e3*(t2-t1):6.1f} ms | compile {1e3*(t3-t2):6.1f} ms")
print("jaxpr equations:", len(traced.jaxpr.eqns), "| StableHLO lines:", len(lowered.as_text().splitlines()))
print("Types:", type(traced).__name__, type(lowered).__name__, type(compiled).__name__,
"| all in jax.stages:", all(hasattr(jax.stages, n) for n in ("Traced", "Lowered", "Compiled")))
ref = to64(xb) # NumPy float64 reference value
for w in ws:
ref = np.tanh(ref @ to64(w))
assert np.allclose(compiled(ws, xb), ref.sum(), rtol=1e-4, atol=1e-4)
show_error(lambda: compiled(ws, jnp.ones((17, 64)))) # different shape → TypeError
1.5 ✅ Fixes¶
🟢 Must-read
(1) Variable-length inputs: bucketed padding + mask¶
The Extra in Chapter 2, 2.6 already showed how to compute a bucketed mean. Here we build a version closer to training: loss and gradient on variable-length batches. There are two key points:
- Round lengths up to a small number of "buckets", so the number of compilations ≤ the number of buckets;
- Use a mask to remove the padded rows from both the numerator and the denominator. The most common mistakes are padding without masking, or masking the numerator but still dividing by the padded length.
def masked_mse(w, X, y, mask):
r = X @ w - y
return jnp.sum(mask * r ** 2) / jnp.sum(mask) # numerator and denominator count only valid rows
def plain_mse(w, X, y):
return jnp.mean((X @ w - y) ** 2)
BUCKETS = (64, 128, 256, 512)
def pad_to_bucket(X, y):
"""Pad in NumPy (triggers no JAX compilation) and return (Xp, yp, mask)."""
n = X.shape[0]
b = next(b for b in BUCKETS if b >= n)
Xp = np.zeros((b, X.shape[1]), np.float32); Xp[:n] = X
yp = np.zeros(b, np.float32); yp[:n] = y
mask = np.zeros(b, np.float32); mask[:n] = 1.0
return Xp, yp, mask
kx, ky, kw, kn = jax.random.split(jax.random.key(2), 4)
Xbig = np.asarray(jax.random.normal(kx, (512, 8)))
ybig = np.asarray(jax.random.normal(ky, (512,)))
w0 = jax.random.normal(kw, (8,))
lengths = [int(n) for n in jax.random.randint(kn, (30,), 20, 500)] # 30 random lengths
vg_plain = jax.jit(jax.value_and_grad(plain_mse))
vg_masked = jax.jit(jax.value_and_grad(masked_mse))
wrong_mean = jax.jit(lambda w, X, y: jnp.mean((X @ w - y) ** 2)) # ❌ padded but not masked
with CompileCounter("plain_mse") as c_plain:
for n in lengths:
vg_plain(w0, Xbig[:n], ybig[:n])
max_err_masked, max_err_wrong = 0.0, 0.0
with CompileCounter("masked_mse") as c_masked:
for n in lengths:
Xp, yp, mask = pad_to_bucket(Xbig[:n], ybig[:n])
loss, g = vg_masked(w0, Xp, yp, mask)
# NumPy float64 reference values
r = Xbig[:n].astype(np.float64) @ to64(w0) - ybig[:n]
ref_loss, ref_g = np.mean(r ** 2), 2 * Xbig[:n].T.astype(np.float64) @ r / n
max_err_masked = max(max_err_masked, abs(float(loss) - ref_loss), np.abs(to64(g) - ref_g).max())
max_err_wrong = max(max_err_wrong, abs(float(wrong_mean(w0, Xp, yp)) - ref_loss))
print(f"No buckets: {len(set(lengths))} lengths → {c_plain.count} compilations")
print(f"Buckets: {len(BUCKETS)} buckets → {c_masked.count} compilations")
print(f"Max error of buckets + mask vs. NumPy float64: {max_err_masked:.2e}")
print(f"❌ Max loss error when padding without a mask: {max_err_wrong:.3f}")
assert c_plain.count == len(set(lengths)) and c_masked.count <= len(BUCKETS)
assert max_err_masked < 1e-4 and max_err_wrong > 0.1
# The cost of padding: how much computation is wasted on average
waste = np.mean([1 - n / next(b for b in BUCKETS if b >= n) for n in lengths])
print(f"Power-of-2 buckets waste {waste:.0%} of the computation on average; finer buckets waste less but compile more often")
(2) Static arguments: hashable, compared by value, few distinct values¶
- Static arguments must be hashable (lists, dicts, and arrays don't work, → Extra in Chapter 2, 2.5);
- Custom config classes should be compared by value. You can use
@dataclass(frozen=True)to generate__eq__and__hash__automatically; or usejax.tree_util.register_staticto register the class as a "pytree with no leaves", so it's treated as a static value without listing it instatic_argnames; - Static arguments should take few distinct values: the number of layers, the activation function name, or whether you're in training mode are fine; numbers that change, like the learning rate, temperature, or step count, are not (→ Chapter 6, 6.6).
@dataclass(frozen=True) # frozen=True → automatically generates value-based __eq__ and __hash__
class CfgOK:
n: int
def t_cfg_ok(x, cfg): return x * cfg.n
f = jax.jit(t_cfg_ok, static_argnums=1)
n_ok = count_compiles("t_cfg_ok", [lambda: f(x3, CfgOK(2))] * 3)
@jax.tree_util.register_static # register as a static pytree: just pass it in as a regular argument
@dataclass(frozen=True)
class Activation:
name: str
def t_act(x, act):
return jnp.tanh(x) if act.name == "tanh" else jax.nn.relu(x) # Python if: act is static
f_act = jax.jit(t_act) # note: no static_argnums
n_act = count_compiles("t_act", [lambda: f_act(x3, Activation("tanh"))] * 3 + [lambda: f_act(x3, Activation("relu"))])
# Learning rate: ❌ static (one compilation per new value) vs. ✅ dynamic (compiled once)
def t_lr_static(p, lr): return p - lr * p
def t_lr_dynamic(p, lr): return p - lr * p
f_s = jax.jit(t_lr_static, static_argnames="lr")
f_d = jax.jit(t_lr_dynamic)
lrs = [1e-3 * (1 + i) for i in range(8)] # simulate 8 steps of a learning-rate schedule
n_s = count_compiles("t_lr_static", [lambda lr=lr: f_s(x3, lr=lr) for lr in lrs])
n_d = count_compiles("t_lr_dynamic", [lambda lr=lr: f_d(x3, jnp.float32(lr)) for lr in lrs])
print(f"frozen dataclass as a static argument, 3 equal instances → {n_ok} compilation(s)")
print(f"register_static: tanh × 3 + relu × 1 → {n_act} compilation(s)")
print(f"8-step learning-rate schedule: static {n_s} | dynamic {n_d} compilations")
assert (n_ok, n_act, n_s, n_d) == (1, 2, 8, 1)
(3) Create jit outside loops; keep argument dtypes and weak types consistent¶
- Create the jit object once, at module level or in the constructor, never inside a loop body or method body. In object-oriented code, write the part to be compiled as "a pure function with explicitly passed arguments" and jit it at module level;
- Pass numbers into jit consistently as strongly typed arrays such as
jnp.asarray(v, jnp.float32), not "sometimes a Python float, sometimes an array". Note that the problem is the mixing: passing a Python float every time (even if the value changes every step) compiles only once. We verify both below.
def _forward(w, x): # pure function: weights passed explicitly
return jnp.tanh(x @ w)
forward_jit = jax.jit(_forward) # ✅ created once at module level
class ModelOK:
def __init__(self, w):
self.w = w
def predict(self, x):
return forward_jit(self.w, x) # the method only calls it, it doesn't create a new jit
mo = ModelOK(jnp.ones((3, 2)))
n_fwd = count_compiles("_forward", [lambda: mo.predict(x3)] * 5)
def t_lr_mixed(p, lr): return p - lr * p
f_mix = jax.jit(t_lr_mixed)
schedule = [0.1, jnp.float32(0.05), 0.02] # ❌ mixing Python floats and arrays
n_mix = count_compiles("t_lr_mixed", [lambda lr=lr: f_mix(x3, lr) for lr in schedule])
def t_lr_cast(p, lr): return p - lr * p
f_cast = jax.jit(t_lr_cast)
n_cast = count_compiles("t_lr_cast", [lambda lr=lr: f_cast(x3, jnp.asarray(lr, jnp.float32)) for lr in schedule]) # ✅
def t_lr_py(p, lr): return p - lr * p
f_py = jax.jit(t_lr_py)
n_py = count_compiles("t_lr_py", [lambda lr=lr: f_py(x3, lr) for lr in [0.1, 0.05, 0.02]]) # all Python floats: no recompilation even though the value changes
print(f"Module-level jit, predict called 5 times → {n_fwd} compilation(s)")
print(f"Learning rate mixing Python float / array → {n_mix} compilations; all converted to float32 arrays → {n_cast}; all Python floats → {n_py} compilation(s)")
assert (n_fwd, n_mix, n_cast, n_py) == (1, 2, 1, 1)
(4) Ahead-of-time (AOT) compilation: compile every bucket before going live¶
With buckets, the first time a bucket shows up it still has to compile, which an online service sees as "occasional long-tail latency". The fix is to lower().compile() every bucket at startup using jax.ShapeDtypeStruct (which only describes shape and dtype, no real data needed), then call the Compiled objects directly. A Compiled object never triggers another compilation; with the wrong shape, it raises an error right away instead of quietly compiling.
def serve_loss(w, X, y, mask):
return masked_mse(w, X, y, mask)
t0 = time.perf_counter()
compiled_by_bucket = {
b: jax.jit(serve_loss).lower(
jax.ShapeDtypeStruct((8,), jnp.float32), jax.ShapeDtypeStruct((b, 8), jnp.float32),
jax.ShapeDtypeStruct((b,), jnp.float32), jax.ShapeDtypeStruct((b,), jnp.float32)).compile()
for b in BUCKETS}
print(f"Precompiling {len(BUCKETS)} buckets at startup: {(time.perf_counter() - t0) * 1e3:.0f} ms")
w0_np = np.asarray(w0)
with CompileCounter() as c_serve: # name=None: count compilations of all functions
outs = []
for n in lengths:
Xp, yp, mask = pad_to_bucket(Xbig[:n], ybig[:n]) # padding done in NumPy
outs.append(compiled_by_bucket[Xp.shape[0]](w0_np, Xp, yp, mask))
jax.block_until_ready(outs)
print(f"Serving {len(lengths)} requests triggered {c_serve.count} compilations")
assert c_serve.count == 0
1.6 A compile-budget tool¶
⚪ Optional: when you want to put "compile at most N times" into a unit test or CI
The diagnostics so far are all "investigate after something goes wrong". A better approach is to write "this function compiles at most N times" as an assertion in your training script or unit tests. Then, whenever someone's code change introduces extra compilations, you get an alarm right away. Below we build two ways to use it on top of CompileCounter:
- Context manager
CompileBudget(max_compiles, name=None, on_exceed="raise"): counts compilations inside thewithblock and checks on exit; - Decorator
@compile_budget(max_compiles): wraps a jit function, accumulates its compile count over its whole lifetime, and checks after every call.
on_exceed="raise" raises CompileBudgetExceeded (good for tests), and "warn" only issues a warning (good for production). The message includes the function name and duration of each compilation.
class CompileBudgetExceeded(RuntimeError):
pass
class CompileBudget(CompileCounter):
"""Compile budget: raise (or warn) when there are more than max_compiles compilations."""
def __init__(self, max_compiles, name=None, on_exceed="raise"):
super().__init__(name)
self.max_compiles = max_compiles
self.on_exceed = on_exceed
def check(self):
if self.count > self.max_compiles:
detail = ", ".join(f"{fn}({d * 1e3:.0f}ms)" for fn, d in self.events)
msg = (f"Compile budget exceeded: {self.name or 'all functions'} allows {self.max_compiles}, "
f"got {self.count} → {detail}")
if self.on_exceed == "raise":
raise CompileBudgetExceeded(msg)
warnings.warn(msg, RuntimeWarning, stacklevel=3)
def __exit__(self, exc_type, exc, tb):
super().__exit__(exc_type, exc, tb)
if exc_type is None: # if the with block itself raised, don't pile a budget error on top
self.check()
return False
def compile_budget(max_compiles, on_exceed="raise"):
"""Decorate a jit function: accumulate its compile count and check the budget after every call."""
def deco(jitted):
budget = CompileBudget(max_compiles, jitted.__name__, on_exceed)
@functools.wraps(jitted)
def wrapper(*args, **kwargs):
monitoring.register_event_duration_secs_listener(budget._listener)
try:
out = jitted(*args, **kwargs)
finally:
monitoring.unregister_event_duration_listener(budget._listener)
budget.check()
return out
wrapper.budget = budget
return wrapper
return deco
# Usage 1: decorator. Allow 2 compilations (e.g. one batch shape each for training and evaluation)
@compile_budget(2)
@jax.jit
def budget_step(w, x):
return w - 0.1 * jnp.mean(x) * w
w_ = jnp.ones(4)
for n in [32, 32, 64, 64, 32]: # only 2 shapes: within budget
w_ = budget_step(w_, jnp.ones(n))
print("5 calls, 2 shapes:", budget_step.budget.count, "compilations, within budget")
assert budget_step.budget.count == 2
try:
budget_step(w_, jnp.ones(48)) # a 3rd shape → over budget
raise AssertionError("should have raised")
except CompileBudgetExceeded as e:
print("❌ Caught:", str(e)[:120])
# Usage 2: context manager + warn mode
def budget_loop(p, lr): return p - lr * p
f = jax.jit(budget_loop)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
with CompileBudget(1, "budget_loop", on_exceed="warn") as b:
for lr in [0.1, 0.05, jnp.float32(0.01)]: # one strongly typed value mixed in → one extra compilation
f(x3, lr)
print("⚠️ Warning:", str(caught[0].message)[:110])
assert b.count == 2 and len(caught) == 1
# Usage 3: ✅ as an assertion: once fixed, it stays within budget and passes quietly
with CompileBudget(1, "budget_loop"):
for lr in [0.1, 0.05, 0.01]:
f(x3, jnp.asarray(lr, jnp.float32)) # strongly typed f32[] was already compiled above → 0
print("After the fix: within budget ✔")
💡 Practical rule¶
🟢 Must-read
In one sentence: treat "compile count" as a metric that deserves assertions, just like the loss. Find the cause with
jax_explain_cache_misses; fix it with buckets, value-hashed static configs, module-level jit, and strongly typed scalars; and finally guard it with a compile budget.
Topic 2 Numerical stability and NaN gradients¶
2.1 Motivation¶
⚪ Optional: a demo of how a NaN wipes out a whole set of parameters in one step; skip it if you already know this
The trouble with NaN is that it's contagious, and it spreads fast. If even one example's gradient is NaN, the whole gradient becomes NaN after averaging over the batch. One parameter update later, every weight is NaN, all subsequent losses are NaN, and the training run is ruined. Worse, JAX doesn't raise an error by default, so you often only notice hundreds of steps later when the loss curve breaks off.
float32 has a narrow range. exp(x) overflows to inf once $x>88.72$. At the other end, JAX in this environment (CPU backend) flushes denormal numbers straight to 0, so exp(x) is already 0 around $x<-87.3$ (NumPy keeps denormals and only underflows to 0 at $x<-103.3$).
→ Basics in Chapter 3 3.8 (double
where,logsumexp, the softplus example withcustom_jvp,check_grads), Extra in Chapter 2, 2.4 (NaN gradients ofjnp.where), Extra in Chapter 4, 4.5 (sqrtin distance matrices).
# One NaN example wipes out the whole set of parameters in a single step
def per_sample_loss(w, x):
return jnp.sqrt(jnp.sum((w * x) ** 2)) # ‖w ⊙ x‖: when x is all zeros the gradient is 0/0
w_nan = jnp.ones(4)
X_nan = jnp.stack([jnp.ones(4), 2 * jnp.ones(4), jnp.zeros(4)]) # the 3rd example is all zeros
g = jax.grad(lambda w: jnp.mean(jax.vmap(per_sample_loss, (None, 0))(w, X_nan)))(w_nan)
w_after = w_nan - 0.1 * g
print("float32 exp limits: exp(88.7) =", jnp.exp(88.7), "| exp(89) =", jnp.exp(89.0),
"| exp(-87) =", jnp.exp(-87.0), "| exp(-88) =", jnp.exp(-88.0), "| NumPy exp(-100) =", np.exp(np.float32(-100.0)))
assert float(jnp.exp(-88.0)) == 0.0 and float(jnp.exp(-87.0)) > 0 and np.exp(np.float32(-100.0)) > 0 # JAX flushes to zero, NumPy keeps denormals
print("Batch gradient:", g)
print("Parameters after one update:", w_after)
assert bool(jnp.all(jnp.isnan(w_after)))
2.2 Double where: how it works and a general pattern¶
🟢 Must-read
How it works: see Chapter 3, Extra in 3.8, "NaN gradients and the double where trick". where only blocks the forward value. In the backward pass, the branch that wasn't selected still gets multiplied by its derivative at the dangerous point, and $0\times\infty=\text{NaN}$. The fix is to first use one where to replace dangerous inputs with a safe value, then use a second where to select the output:
x_safe = jnp.where(ok, x, SAFE) # 1st where: dangerous input → safe value
y = jnp.where(ok, f(x_safe), FILL) # 2nd where: select the output
Below we turn this into a general function, apply it to 4 dangerous functions common in practice, compare ❌ / ✅ one by one, and cross-check against analytic derivatives.
def double_where(fn, x, ok, safe_value, fill):
"""Return fill where ok is False, while keeping the gradient there finite."""
x_safe = jnp.where(ok, x, safe_value)
return jnp.where(ok, fn(x_safe), fill)
# ---- 4 dangerous functions: ❌ single-where version vs. ✅ double-where version ----
def norm_bad(v): # vector norm, 0/0 at the zero vector
return jnp.linalg.norm(v)
def norm_good(v):
sq = jnp.sum(v ** 2)
return double_where(jnp.sqrt, sq, sq > 0, 1.0, 0.0)
def sinc_bad(x): # sin(x)/x, filled with 1 at x=0
return jnp.where(x != 0, jnp.sin(x) / x, 1.0)
def sinc_good(x):
return double_where(lambda t: jnp.sin(t) / t, x, x != 0, 1.0, 1.0)
def entropy_bad(p): # -Σ p log p, defined as 0 at p=0
return -jnp.sum(jnp.where(p > 0, p * jnp.log(p), 0.0))
def entropy_good(p):
return -jnp.sum(double_where(lambda t: t * jnp.log(t), p, p > 0, 1.0, 0.0))
def logit_bad(p): # log(p/(1-p)), clipped to ±20 for p∈{0,1}
return jnp.where((p > 0) & (p < 1), jnp.log(p / (1 - p)), 20.0 * jnp.sign(p - 0.5))
def logit_good(p):
ok = (p > 0) & (p < 1)
return double_where(lambda t: jnp.log(t / (1 - t)), p, ok, 0.5, 20.0 * jnp.sign(p - 0.5))
cases = [
("norm ‖v‖ @ v=0", norm_bad, norm_good, jnp.zeros(3)),
("sinc @ x=0", sinc_bad, sinc_good, jnp.array(0.0)),
("entropy @ p=[0,.5,.5]", entropy_bad, entropy_good, jnp.array([0.0, 0.5, 0.5])),
("logit @ p=1", logit_bad, logit_good, jnp.array(1.0)),
]
print(wpad("Function @ danger pt", 24) + wpad("Forward (❌/✅)", 22) + "grad ❌ → grad ✅")
for name, bad, good, x in cases:
gb, gg = jax.grad(bad)(x), jax.grad(good)(x)
print(wpad(name, 24) + wpad(f"{float(bad(x)):.3f} / {float(good(x)):.3f}", 22) + f"{gb} → {gg}")
assert np.allclose(bad(x), good(x)) # forward values are identical
assert not bool(jnp.all(jnp.isfinite(gb))) # ❌ gradient contains NaN
assert bool(jnp.all(jnp.isfinite(gg))) # ✅ gradient is all finite
# At normal points, the gradient of the ✅ version must equal the analytic derivative (computed in NumPy float64)
v, x, p, q = np.array([3.0, 4.0, 12.0]), 0.7, np.array([0.2, 0.3, 0.5]), 0.3
assert np.allclose(jax.grad(norm_good)(jnp.array(v)), v / np.linalg.norm(v), atol=1e-6)
assert np.allclose(jax.grad(sinc_good)(x), (x * np.cos(x) - np.sin(x)) / x ** 2, atol=1e-6)
assert np.allclose(jax.grad(entropy_good)(jnp.array(p)), -(np.log(p) + 1), atol=1e-6)
assert np.allclose(jax.grad(logit_good)(q), 1 / (q * (1 - q)), atol=1e-4)
print("\nGradients at normal points match the analytic derivatives ✔")
How to choose the safe value: any value works as long as fn and its derivative are finite there (the result gets thrown away by the second where anyway). Use 1.0 for log and sqrt; the domain of logit is $(0,1)$, so use 0.5. Just pick a value inside the domain.
2.3 Stable logsumexp / softmax / log_softmax¶
🟢 Must-read
How it works: for any constant $m$, $\log\sum_i e^{x_i} = m + \log\sum_i e^{x_i - m}$. With $m=\max_i x_i$, all exponent arguments are $\le 0$, and the largest term is exactly $e^0=1$, so nothing overflows and the sum can't underflow to 0.
There are three engineering details:
- Apply
lax.stop_gradientto $m$. Mathematically the gradient contributions through $m$ cancel exactly; stopping it saves part of the backward computation and is also cleaner when there are tied maxima (jax.nn.logsumexpdoes this); - When a whole row is
-inf(fully masked), $m=-\infty$ and $x-m$ becomes-inf - (-inf) = NaN, so replace a non-finite $m$ with 0; - For cross-entropy, use
log_softmax, notlog(softmax): tiny probabilities in softmax underflow to 0, and taking the log gives-inf.
Below we hand-write these three functions and compare them with the naive versions, jax.nn, and NumPy float64 reference values.
def logsumexp_stable(x, axis=-1):
m = jnp.max(x, axis=axis, keepdims=True)
m = lax.stop_gradient(jnp.where(jnp.isfinite(m), m, 0.0)) # details 1 and 2
return jnp.squeeze(m, axis) + jnp.log(jnp.sum(jnp.exp(x - m), axis=axis))
def log_softmax_stable(x, axis=-1):
return x - jnp.expand_dims(logsumexp_stable(x, axis), axis)
def softmax_stable(x, axis=-1):
m = lax.stop_gradient(jnp.max(x, axis=axis, keepdims=True))
e = jnp.exp(x - m)
return e / jnp.sum(e, axis=axis, keepdims=True)
# ❌ Naive versions
def logsumexp_naive(x): return jnp.log(jnp.sum(jnp.exp(x), axis=-1))
def softmax_naive(x): return jnp.exp(x) / jnp.sum(jnp.exp(x), axis=-1, keepdims=True)
def log_softmax_naive(x): return jnp.log(softmax_naive(x))
def np_log_softmax(x): # NumPy float64 reference
x = np.asarray(x, np.float64)
m = x.max(-1, keepdims=True)
return x - (m + np.log(np.exp(x - m).sum(-1, keepdims=True)))
logits = jnp.array([[1000.0, 0.0, -1000.0], # overflow
[-1000.0, -1001.0, -1002.0], # everything underflows
[0.0, -120.0, 3.0], # one tiny probability
[1.0, 2.0, 3.0]]) # ordinary case
ref = np_log_softmax(logits)
print("Naive log(softmax):\n", log_softmax_naive(logits))
print("Stable log_softmax:\n", log_softmax_stable(logits))
print("Naive logsumexp:", logsumexp_naive(logits), "\nStable logsumexp:", logsumexp_stable(logits))
assert np.allclose(log_softmax_stable(logits), ref, atol=1e-4)
assert np.allclose(log_softmax_stable(logits), jax.nn.log_softmax(logits), atol=1e-5)
assert np.allclose(softmax_stable(logits), np.exp(ref), atol=1e-6)
assert np.allclose(logsumexp_stable(logits), jax.nn.logsumexp(logits, axis=-1), atol=1e-4)
assert not bool(jnp.all(jnp.isfinite(log_softmax_naive(logits))))
print("The stable versions match NumPy float64 and jax.nn ✔")
Cross-entropy and its gradient. Cross-entropy is $\ell = -\log p_y = -\text{log\_softmax}(z)_y$, and its gradient with respect to the logits has a very clean analytic form: $\partial\ell/\partial z = \text{softmax}(z) - \text{onehot}(y)$. With the log(softmax) version, when the probability of the correct class underflows, the loss is inf and the gradient is NaN. The stable version gives a large but finite loss in that case, and the gradient is correct.
def ce_naive(z, y):
return -jnp.mean(jnp.take_along_axis(log_softmax_naive(z), y[:, None], axis=1))
def ce_stable(z, y):
return -jnp.mean(jnp.take_along_axis(log_softmax_stable(z), y[:, None], axis=1))
z = jnp.array([[0.0, -120.0, 3.0], [1.0, 2.0, 3.0]])
y = jnp.array([1, 2]) # the correct-class probability of the 1st example is about e^-123
for name, fn in [("❌ log(softmax)", ce_naive), ("✅ log_softmax", ce_stable)]:
loss, gz = jax.value_and_grad(fn)(z, y)
print(f"{name}: loss = {float(loss):.4f}, gradient finite = {bool(jnp.all(jnp.isfinite(gz)))}")
loss, gz = jax.value_and_grad(ce_stable)(z, y)
ref_g = (np.exp(np_log_softmax(z)) - np.eye(3)[np.asarray(y)]) / len(y) # (softmax - onehot) / B
ref_loss = -np.mean(np_log_softmax(z)[np.arange(2), np.asarray(y)])
assert np.allclose(gz, ref_g, atol=1e-6) and np.isclose(float(loss), ref_loss, rtol=1e-5)
assert not np.isfinite(float(ce_naive(z, y)))
print("Gradient = (softmax − onehot)/B, matches NumPy float64 ✔")
The mask and -inf pitfall. In attention or variable-length classification, it's common to set the logits of invalid positions to -inf. As soon as a whole row is masked (e.g. a padded example), the softmax / log_softmax of that row is -inf - (-inf) = NaN (the jax.nn implementation too). Whether it pollutes the whole batch depends on how you exclude that row:
- Multiplying by float weights (
nll * row_weight, whererow_weightis 0./1.):NaN × 0 = NaN, so the loss of the whole batch becomes NaN; jnp.where(row_mask, nll, 0): the loss happens to be fine, but the probabilities themselves in that row are still NaN. If they feed into later computations (e.g.probs @ Vin attention), the NaN keeps spreading.
✅ The real fix is to use a very large finite negative number (jnp.finfo(dtype).min) instead of -inf. Then a fully masked row gets a uniform distribution (harmless), and you exclude it from the loss with the mask.
NEG = jnp.finfo(jnp.float32).min
def masked_ce(z, y, valid_cols, row_weight, fill):
z = jnp.where(valid_cols, z, fill) # mask out invalid classes
logp = jax.nn.log_softmax(z)
nll = -jnp.take_along_axis(logp, y[:, None], axis=1)[:, 0]
return jnp.sum(nll * row_weight) / jnp.sum(row_weight), jax.nn.softmax(z)
zb = jnp.array([[2.0, 1.0, 0.5], [0.3, 0.2, 0.1]])
yb = jnp.array([0, 0])
valid_cols = jnp.array([[True, True, False], [False, False, False]]) # row 2 is padding: fully masked
row_weight = jnp.array([1.0, 0.0]) # float weights: 0 for the padding row
out = {}
for name, fill in [("❌ fill -inf", -jnp.inf), ("✅ fill finfo.min", NEG)]:
(loss, probs), gz = jax.value_and_grad(masked_ce, has_aux=True)(zb, yb, valid_cols, row_weight, fill)
out[name] = (loss, probs, gz)
print(f"{name}: loss = {float(loss)}, gradient finite = {bool(jnp.all(jnp.isfinite(gz)))}, "
f"probabilities of the padding row = {probs[1]}")
loss_ok, probs_ok, g_ok = out["✅ fill finfo.min"]
ref = -np_log_softmax(np.array([2.0, 1.0]))[0] # only row 1 and its first two classes are valid
assert np.isclose(float(loss_ok), ref, rtol=1e-6) and bool(jnp.all(jnp.isfinite(g_ok)))
assert np.allclose(probs_ok[1], 1 / 3) # fully masked row → uniform distribution, harmless
assert np.isnan(float(out["❌ fill -inf"][0])) and bool(jnp.all(jnp.isnan(out["❌ fill -inf"][1][1])))
2.4 Locating NaNs with jax_debug_nans (measured behavior in 0.12)¶
🟢 Must-read
With jax.config.update("jax_debug_nans", True), JAX checks computation results for NaNs. Here's how it actually behaves in 0.12 (each item is verified below):
| Situation | Behavior |
|---|---|
| eager (no jit) | Checks after every primitive and raises FloatingPointError right at the first primitive that produces a NaN |
| jit function output contains NaN | First prints Invalid nan value encountered in the output of a jax.jit function. Calling the de-optimized version., then reruns primitive by primitive (de-optimized) and raises at the line that produced the NaN |
| NaN produced in the backward pass | Also reruns and pinpoints the primitive in the backward computation (here, div) |
NaN inside jit that doesn't reach the output (e.g. dropped by where) | No error: jit only checks outputs. The same function in eager mode does raise |
An inf rather than a NaN | No error; you need to turn on jax_debug_infs separately |
⚠️ After vmap has been called in the process, calling jit functions / eager primitives that already ran | No longer checked; NaNs slip through silently (reproduction and triggers in Advanced 08 §3.1); restored after jax.clear_caches() |
The last item is a known issue in 0.12.0.1. This chapter has already used vmap, so locate_nan below calls jax.clear_caches() once after turning on the switch, to make sure the check really works. The traceback of a FloatingPointError contains the line in your own code, and locate_nan picks it out for you.
def locate_nan(fn, *args, user_funcs=()):
"""Run fn with jax_debug_nans on and return (error message, line in user code). finally makes sure the switch is turned off."""
import traceback
jax.config.update("jax_debug_nans", True)
jax.clear_caches() # measured in 0.12: after vmap has been used in the process, NaNs are missed unless the cache is cleared (see the next cell)
try:
jax.block_until_ready(fn(*args))
return None, None
except FloatingPointError as e:
frames = [f for f in traceback.extract_tb(e.__traceback__) if f.name in user_funcs]
where_ = f"{frames[-1].name}(): {frames[-1].line.strip()}" if frames else "(inside the backward computation generated by JAX)"
return str(e).splitlines()[0], where_
finally:
jax.config.update("jax_debug_nans", False)
def encoder(x):
h = x * 2.0
scale = jnp.log(h - 3.0) # when h < 3, log(negative) = NaN
return jnp.sum(scale * h)
def masked_log(x):
return jnp.where(x > 0, jnp.log(x), 0.0) # the forward NaN is dropped by where and never reaches the output
x_bad = jnp.array([1.0, 5.0])
tests = [
("eager", encoder, (x_bad,)),
("jit: NaN in output", jax.jit(encoder), (x_bad,)),
("grad(jit): NaN in backward", jax.grad(jax.jit(masked_log)), (jnp.array(0.0),)),
("jit: NaN not in output", jax.jit(masked_log), (jnp.array(-1.0),)),
("eager: same function", masked_log, (jnp.array(-1.0),)),
("jit: inf only", jax.jit(lambda x: 1.0 / x), (jnp.array(0.0),)),
]
results = {}
for name, fn, args in tests:
msg, loc = locate_nan(fn, *args, user_funcs={"encoder", "masked_log"})
results[name] = msg
print(f"[{name}]\n {'no error' if msg is None else 'FloatingPointError: ' + msg}" + (f"\n located at → {loc}" if loc else ""))
assert results["eager"] and results["jit: NaN in output"] and results["grad(jit): NaN in backward"]
assert results["jit: NaN not in output"] is None and results["eager: same function"] is not None
assert results["jit: inf only"] is None
assert jax.config.jax_debug_nans is False # the switch has been restored
⚠️ The missed-detection issue in 0.12.0.1: when you turn on jax_debug_nans globally with jax.config.update, once the process has called (or even just traced) vmap once, jit functions and eager primitives that already ran are no longer checked, and NaNs slip through silently. Newly compiled computations aren't affected, and calling jax.clear_caches() restores the check. Turning it on with with jax.debug_nans(True): doesn't have this problem. For a subprocess reproduction, the triggers, and workarounds, see Advanced 08 §3.1 and 00 Known issue 2.
So in 0.12.0.1, don't count on "leave jax_debug_nans on for the whole training run and it'll stop automatically when something goes wrong". A more reliable workflow is:
- In the training loop, check on the host every step that the loss is finite (or use
checkifybelow); - When you find a NaN, save that step's inputs and rerun just that step inside
with jax.debug_nans(True):(if you turned it on globally withconfig.update, calljax.clear_caches()first), and letFloatingPointErrortell you exactly which line it is.
# inf has to be caught separately with jax_debug_infs
jax.config.update("jax_debug_infs", True)
jax.clear_caches()
try:
jax.jit(lambda x: 1.0 / x)(jnp.array(0.0))
caught_inf = None
except FloatingPointError as e:
caught_inf = str(e).splitlines()[0]
finally:
jax.config.update("jax_debug_infs", False)
print("jax_debug_infs:", caught_inf)
assert caught_inf is not None
jax_debug_nans is a global switch, best turned on temporarily while debugging. If you want NaN checks on a particular function in production code, use jax.experimental.checkify. It compiles the checks into the program and returns an error object, and you decide when to raise it. It composes with jit and isn't affected by the vmap issue above (the code below runs in this same process, which has already used vmap). Below, a second call to the same compiled function still gets checked.
from jax.experimental import checkify
checked = jax.jit(checkify.checkify(encoder, errors=checkify.float_checks))
err, out = checked(x_bad)
print("checkify report:", err.get())
err_ok, _ = checked(jnp.array([3.0, 5.0]))
print("Normal input:", err_ok.get())
err2, _ = checked(x_bad) # second call, hits the cache
print("Bad input again:", err2.get())
assert err.get() is not None and err_ok.get() is None and err2.get() is not None
try:
err.throw()
except Exception as e:
print(f"err.throw() → {type(e).__name__}")
2.5 Fixing gradients with custom_jvp¶
⚪ Optional: when you need a custom derivative rule or a straight-through estimator (quantization-aware training)
Sometimes the derivative JAX computes automatically is unusable at certain points, but we know what the derivative should be. Then we tell JAX directly with custom_jvp (→ Chapter 3, 3.8 already showed the basics with softplus). Here are two practical points:
- ❌ The custom rule itself must be safe too: the most common mistake is copying the derivative formula straight into the rule. For example, the derivative of the norm, $x/\lVert x\rVert$, still gives
0/0at zero. The rule needs a doublewhereas well; - Straight-through estimator: functions like
roundandsignhave a derivative of 0 almost everywhere. Quantization-aware training needs "round in the forward pass, treat as identity in the backward pass", whichcustom_jvpexpresses in one line.
from jax.test_util import check_grads # → Chapter 3, 3.8: must be imported explicitly
# ❌ The rule copies the formula directly
@jax.custom_jvp
def norm_cj_bad(v):
return jnp.sqrt(jnp.sum(v ** 2))
@norm_cj_bad.defjvp
def _norm_bad_jvp(primals, tangents):
(v,), (t,) = primals, tangents
n = norm_cj_bad(v)
return n, jnp.dot(v / n, t) # at the zero vector, v/n = 0/0
# ✅ The rule uses a double where internally too
@jax.custom_jvp
def norm_cj(v):
return jnp.sqrt(jnp.sum(v ** 2))
@norm_cj.defjvp
def _norm_jvp(primals, tangents):
(v,), (t,) = primals, tangents
n = norm_cj(v)
ok = n > 0
unit = v / jnp.where(ok, n, 1.0) # at the zero vector, unit = 0 (subgradient 0)
return n, jnp.dot(jnp.where(ok, unit, 0.0), t)
zero = jnp.zeros(3)
print("❌ Rule copies the formula, gradient at zero:", jax.grad(norm_cj_bad)(zero))
print("✅ Rule uses double where, gradient at zero:", jax.grad(norm_cj)(zero))
assert bool(jnp.all(jnp.isnan(jax.grad(norm_cj_bad)(zero))))
assert bool(jnp.all(jax.grad(norm_cj)(zero) == 0))
# Nonzero points: finite-difference check (both forward and reverse); it also composes with jit / vmap
check_grads(norm_cj, (jnp.array([3.0, -4.0, 1.0]),), order=1, modes=["fwd", "rev"])
batch = jnp.array([[3.0, 4.0], [0.0, 0.0], [1.0, 0.0]])
gb = jax.jit(jax.vmap(jax.grad(norm_cj)))(batch)
print("Per-row gradients under vmap + jit:\n", gb)
assert np.allclose(gb, [[0.6, 0.8], [0.0, 0.0], [1.0, 0.0]])
# Straight-through estimator: round in the forward pass, identity in the backward pass
@jax.custom_jvp
def round_ste(x):
return jnp.round(x)
@round_ste.defjvp
def _round_ste_jvp(primals, tangents):
(x,), (t,) = primals, tangents
return round_ste(x), t # treat the derivative as 1
def quant_loss(w, x, target):
wq = round_ste(w * 4.0) / 4.0 # quantize to multiples of 0.25
return jnp.sum((wq * x - target) ** 2)
w_q = jnp.array([0.3, -0.7])
x_q, t_q = jnp.array([1.0, 2.0]), jnp.array([0.5, 0.5])
g_plain = jax.grad(lambda w: jnp.sum((jnp.round(w * 4) / 4 * x_q - t_q) ** 2))(w_q)
g_ste = jax.grad(quant_loss)(w_q, x_q, t_q)
wq = np.round(np.asarray(w_q, np.float64) * 4) / 4
ref = 2 * (wq * np.asarray(x_q) - np.asarray(t_q)) * np.asarray(x_q) # analytic gradient treating the derivative of round as 1
print("Gradient with plain round:", g_plain, "(zero everywhere, training can't move)")
print("Straight-through gradient:", g_ste)
assert bool(jnp.all(g_plain == 0)) and np.allclose(g_ste, ref)
💡 Practical rule¶
🟢 Must-read
In one sentence: whenever a
where/mask hides alog,sqrt, or division, use a doublewhere; always uselog_softmax+ a finite mask value for classification losses; during training, detect NaNs with a host-sideisfinitecheck orcheckify, then rerun the failing step withjax_debug_nans+jax.clear_caches()to locate it. Remember thatjax_debug_nansonly checks jit outputs, doesn't check for inf, and in 0.12 misses NaNs after vmap has been used.
Topic 3 Choosing between jacfwd / jacrev / jvp / vjp¶
3.1 Motivation¶
⚪ Optional: examples of typical use cases for the differentiation APIs
Picking the wrong differentiation API doesn't give wrong results, but it can be a hundred times slower and use $n$ times more memory. Common scenarios: sensitivities of "few parameters → many outputs" in physical simulation, Hessians or Hessian-vector products in optimization, input gradients for adversarial examples, solving linear systems in implicit layers. Which API fits each scenario depends on the input dimension $n$, the output dimension $m$, and which part of $J$ you actually need.
→ Basics in Chapter 3 Extra in 3.3 (
jvp/vjpand the two modes, the decision rule forjacfwd/jacrevand initial timings), Extra in 3.5 (jax.hessian=jacfwd(jacrev)), exercise C (two ways to write an HVP); Chapter 4 extra exercise 4 (batched Jacobians).
3.2 How it works, and a decision table¶
🟢 Must-read
Let $f:\mathbb R^n\to\mathbb R^m$, and let $C$ be the cost of one forward evaluation.
- Forward mode
jvp: gives $Jv$ (a combination of columns of $J$) in one pass, at a cost of about $2C\sim3C$, without storing intermediate values; - Reverse mode
vjp: gives $u^\top J$ (a combination of rows of $J$) in one pass, at a cost of about $2C\sim4C$, but it has to store the forward intermediates (memory grows with computation depth); jacfwd=vmap(jvp)over $n$ basis vectors,jacrev=vmap(vjp)over $m$ basis vectors.
| What you need | Use | Cost | Notes |
|---|---|---|---|
| Gradient of a scalar loss ($m=1$) | grad / value_and_grad | About 1 VJP | The default for training |
| A single directional derivative $Jv$ | jvp | About 1 JVP | Don't build $J$ first and then multiply by $v$ |
| Several $Jv_i$ at the same point | jax.linearize or vmap(jvp) | Linearize only once | linearize returns a linear function you can call repeatedly |
| A single $u^\top J$ | vjp | About 1 VJP | vjp_fn can be called repeatedly |
| Full $J$, $n \lesssim m$ (tall and thin) | jacfwd | $n$ JVPs | e.g. 4 parameters → 1000 outputs |
| Full $J$, $n \gtrsim m$ (short and wide) | jacrev | $m$ VJPs | e.g. 1000 parameters → 4 outputs |
| Full Hessian ($n$ small) | jax.hessian | $n$ HVPs | $O(n^2)$ memory |
| Hessian-vector product $Hv$ | jvp(grad(f)) (forward-over-reverse) | About 1 HVP | Newton-CG, curvature estimates, influence functions |
| One Jacobian per example | vmap(jacfwd/jacrev) | Same as above × B | → Chapter 4 extra exercise 4 |
Below we quantify the rule "choose based on whether $n$ or $m$ is bigger" with an experiment.
3.3 Timing experiment: sweeping input/output dimensions¶
⚪ Optional: when you want to see the timing curves of jacfwd / jacrev as the dimensions change
We use $f(x)=\tanh(Ax)$ with $A\in\mathbb R^{m\times n}$. Its Jacobian has the analytic form $J=\mathrm{diag}(1-\tanh^2(Ax))\,A$, which we can cross-check with NumPy float64. Following our convention, $A$ is passed in as an argument, not captured by a closure.
In theory, jacfwd batches the $n$ JVPs into one $(m\times n)\cdot(n\times n)$ matrix multiply, about $mn^2$ multiply-adds. jacrev is $(m\times m)\cdot(m\times n)$, about $m^2n$. Their ratio is $n/m$.
def tanh_layer(x, A):
return jnp.tanh(A @ x)
def jac_fwd(x, A): return jax.jacfwd(tanh_layer)(x, A) # Jacobian with respect to x only
def jac_rev(x, A): return jax.jacrev(tanh_layer)(x, A)
jac_fwd_jit, jac_rev_jit = jax.jit(jac_fwd), jax.jit(jac_rev)
def np_jac(x, A):
x, A = to64(x), to64(A)
return (1 - np.tanh(A @ x) ** 2)[:, None] * A
dims = [4, 16, 64, 256, 1024, 2048]
FIXED = 256
timing = {"vary_m": {"fwd": [], "rev": []}, "vary_n": {"fwd": [], "rev": []}}
key = jax.random.key(3)
for d in dims:
for sweep, (n, m) in [("vary_m", (FIXED, d)), ("vary_n", (d, FIXED))]:
key, ka, kx = jax.random.split(key, 3)
A = jax.random.normal(ka, (m, n)) / np.sqrt(n)
x = jax.random.normal(kx, (n,))
timing[sweep]["fwd"].append(bench(jac_fwd_jit, x, A, runs=5))
timing[sweep]["rev"].append(bench(jac_rev_jit, x, A, runs=5))
if d in (4, 256): # spot-check a few: both methods match the analytic solution
assert np.allclose(jac_fwd_jit(x, A), np_jac(x, A), atol=1e-5)
assert np.allclose(jac_rev_jit(x, A), np_jac(x, A), atol=1e-5)
print(wpad("", 16) + "".join(f"{d:>9}" for d in dims))
for sweep, label in [("vary_m", f"n={FIXED}, m=d"), ("vary_n", f"m={FIXED}, n=d")]:
for mode in ("fwd", "rev"):
print(wpad(f"{label} {mode}", 16) + "".join(f"{t:9.3f}" for t in timing[sweep][mode]))
print("(in ms)")
fig, axes = plt.subplots(1, 2, figsize=(10, 3.8), sharey=True)
for ax, sweep, xlabel, title in [
(axes[0], "vary_m", "output dimension m", f"n={FIXED} fixed, growing output dimension"),
(axes[1], "vary_n", "input dimension n", f"m={FIXED} fixed, growing input dimension")]:
ax.plot(dims, timing[sweep]["fwd"], "o-", color=C_BLUE, lw=2, ms=6, label="jacfwd")
ax.plot(dims, timing[sweep]["rev"], "s-", color=C_ORANGE, lw=2, ms=6, label="jacrev")
ax.axvline(FIXED, color="#999999", lw=1, ls=":")
ax.text(FIXED * 1.08, 0.04, "n = m", transform=ax.get_xaxis_transform(), color="#666666", fontsize=9)
ax.set_xscale("log"); ax.set_yscale("log")
ax.set_xlabel(xlabel); ax.set_title(title, fontsize=11)
ax.grid(True, which="major", color="#e5e5e5", lw=0.8)
ax.legend(frameon=False)
axes[0].set_ylabel("time (ms, log scale)")
fig.suptitle("Full Jacobian of f(x) = tanh(Ax): jacfwd vs jacrev", fontsize=12)
plt.tight_layout(); plt.show()
# At the two ends with the biggest dimension gap, the conclusion should be very clear (the theoretical gap is 2048/256 = 8x; we only assert 1.5x to tolerate machine jitter)
assert timing["vary_m"]["fwd"][-1] * 1.5 < timing["vary_m"]["rev"][-1] # n=256 ≪ m=2048: jacfwd is faster
assert timing["vary_n"]["rev"][-1] * 1.5 < timing["vary_n"]["fwd"][-1] # n=2048 ≫ m=256: jacrev is faster
Reading the plot: the two curves cross around $n=m$. On the left, the more outputs, the worse jacrev does; on the right, the more inputs, the worse jacfwd does. At small dimensions, both take around 0.1 ms, mostly fixed overhead, so the actual gap is much smaller than the theoretical ratio $n/m$ (e.g. at $n=256,\ m=4$, jacrev should in theory be 64 times faster).
3.4 ❌ Common mistake: building $J$ when you only need $Jv$¶
🟢 Must-read
The most common waste is writing jax.jacfwd(f)(x) @ v: spend $n$ JVPs building the whole $J$, then multiply by a single vector. One jvp is enough. When you need to multiply by many vectors at the same point, use jax.linearize to linearize once; each later call runs only the linear part.
n = m = 1024
ka, kx, kv = jax.random.split(jax.random.key(4), 3)
A = jax.random.normal(ka, (m, n)) / np.sqrt(n)
x = jax.random.normal(kx, (n,))
V = jax.random.normal(kv, (8, n)) # 8 directions
jv_bad = jax.jit(lambda x, A, v: jax.jacfwd(tanh_layer)(x, A) @ v) # ❌
jv_good = jax.jit(lambda x, A, v: jax.jvp(lambda x: tanh_layer(x, A), (x,), (v,))[1]) # ✅
def jv_many_linearize(x, A, V): # ✅ many directions: linearize once, then vmap
_, f_lin = jax.linearize(lambda x: tanh_layer(x, A), x)
return jax.vmap(f_lin)(V)
jv_many = jax.jit(jv_many_linearize)
t_bad, t_good = bench(jv_bad, x, A, V[0]), bench(jv_good, x, A, V[0])
t_many = bench(jv_many, x, A, V)
print(f"❌ jacfwd(f)(x) @ v : {t_bad:8.3f} ms")
print(f"✅ jvp : {t_good:8.3f} ms ({t_bad / t_good:.0f}x faster)")
print(f"✅ linearize + vmap, 8 directions: {t_many:.3f} ms")
ref = np_jac(x, A) @ to64(V).T
assert np.allclose(jv_good(x, A, V[0]), ref[:, 0], atol=1e-4)
assert np.allclose(jv_bad(x, A, V[0]), ref[:, 0], atol=1e-4)
assert np.allclose(jv_many(x, A, V), ref.T, atol=1e-4)
assert_faster(lambda: bench(jv_good, x, A, V[0]), lambda: bench(jv_bad, x, A, V[0]), factor=5) # remeasures automatically under high load
3.5 Three ways to compute Hessian-vector products (HVP)¶
⚪ Optional: for second-order methods such as Newton-CG and curvature estimation
Many second-order methods only need $Hv$, not the full $H$. $Hv$ is the directional derivative of the gradient $\nabla f$ along $v$. Combining forward and reverse mode in pairs gives three ways to write it:
| Approach | Code | Idea |
|---|---|---|
| Forward-over-reverse | jvp(grad(f), (x,), (v,)) | One JVP of the gradient function |
| Reverse-over-reverse | grad(lambda x: vdot(grad(f)(x), v)) | First compute the scalar $g\cdot v$, then take its gradient |
| Reverse-over-forward | grad(lambda x: jvp(f, (x,), (v,))[1]) | First compute the directional derivative $\nabla f\cdot v$, then take its gradient |
The official JAX docs recommend the first. Forward mode doesn't need to store extra intermediates, so it uses the least memory and is usually the fastest. Below we compare the three on results, speed, and temporary memory after compilation (memory_analysis().temp_size_in_bytes), and against "build the full Hessian first, then multiply by $v$".
The test function is a logistic-regression-style loss $f(x)=\sum_i \mathrm{softplus}(a_i^\top x) + \tfrac{\lambda}{2}\lVert x\rVert^2$. Its Hessian has the analytic form $H=A^\top\mathrm{diag}(\sigma(Ax)(1-\sigma(Ax)))A+\lambda I$, and we compute reference values with NumPy float64.
LAM = 0.1
def logistic_obj(x, A):
return jnp.sum(jax.nn.softplus(A @ x)) + 0.5 * LAM * jnp.sum(x ** 2)
def hvp_fwd_rev(x, v, A):
return jax.jvp(lambda x: jax.grad(logistic_obj)(x, A), (x,), (v,))[1]
def hvp_rev_rev(x, v, A):
return jax.grad(lambda x: jnp.vdot(jax.grad(logistic_obj)(x, A), v))(x)
def hvp_rev_fwd(x, v, A):
return jax.grad(lambda x: jax.jvp(lambda x: logistic_obj(x, A), (x,), (v,))[1])(x)
def hvp_full(x, v, A):
return jax.hessian(logistic_obj)(x, A) @ v
def np_hvp(x, v, A):
x, v, A = to64(x), to64(v), to64(A)
s = 1 / (1 + np.exp(-(A @ x)))
return A.T @ (s * (1 - s) * (A @ v)) + LAM * v
methods = {"fwd-over-rev": hvp_fwd_rev, "rev-over-rev": hvp_rev_rev, "rev-over-fwd": hvp_rev_fwd, "full Hessian @ v": hvp_full}
jitted = {k: jax.jit(f) for k, f in methods.items()}
M_ROWS = 512
ns = [128, 512, 2048]
hvp_time = {k: [] for k in methods}
hvp_mem = {k: [] for k in methods}
for n in ns:
ka, kx, kv = jax.random.split(jax.random.key(n), 3)
A = jax.random.normal(ka, (M_ROWS, n)) / np.sqrt(n)
x = jax.random.normal(kx, (n,))
v = jax.random.normal(kv, (n,))
ref = np_hvp(x, v, A)
for name, f in jitted.items():
out = f(x, v, A)
assert np.allclose(out, ref, rtol=1e-4, atol=1e-4), (name, n) # all four methods match the analytic solution
hvp_time[name].append(bench(f, x, v, A, runs=5))
hvp_mem[name].append(f.lower(x, v, A).compile().memory_analysis().temp_size_in_bytes / 1024)
print(wpad("Method", 20) + "".join(f" n={n:<6}" for n in ns) + " | temp memory KiB (same order)")
for name in methods:
print(wpad(name, 20) + "".join(f"{t:9.3f}ms" for t in hvp_time[name]) + " | "
+ " ".join(f"{m:9.1f}" for m in hvp_mem[name]))
print("The three HVPs, the full Hessian, and the NumPy float64 analytic solution all agree ✔")
fig, axes = plt.subplots(1, 2, figsize=(10, 3.8))
colors = [C_BLUE, C_ORANGE, C_AQUA, "#888888"]
markers = ["o", "s", "^", "x"]
for (name, c, mk) in zip(methods, colors, markers):
ls = ":" if name.startswith("full") else "-"
axes[0].plot(ns, hvp_time[name], marker=mk, ls=ls, color=c, lw=2, ms=6, label=name)
axes[1].plot(ns, np.maximum(hvp_mem[name], 1e-1), marker=mk, ls=ls, color=c, lw=2, ms=6, label=name)
for ax, yl, t in [(axes[0], "time (ms)", "speed"), (axes[1], "temp memory after compile (KiB)", "memory")]:
ax.set_xscale("log"); ax.set_yscale("log"); ax.set_xlabel("parameter dimension n")
ax.minorticks_off(); ax.set_xticks(ns, [str(n) for n in ns])
ax.set_ylabel(yl); ax.set_title(f"HVP {t} (A is {M_ROWS}×n)", fontsize=11)
ax.grid(True, color="#e5e5e5", lw=0.8)
axes[0].legend(frameon=False, fontsize=9)
plt.tight_layout(); plt.show()
# Memory of the full Hessian grows as n², and all three HVPs use far less
for name in ("fwd-over-rev", "rev-over-rev", "rev-over-fwd"):
assert hvp_mem[name][-1] * 10 < hvp_mem["full Hessian @ v"][-1]
How to read it: the three HVPs give exactly the same results, their speeds are in the same ballpark, and their temporary memory after compilation is identical (the three lines overlap in the right plot). That's because this function has a simple structure, and XLA can optimize all three into something very similar. On deeper networks the gap may widen, since "reverse-over-reverse" usually has to store one more layer of intermediates. The real dividing line is building the full Hessian: it takes $n$ HVPs, and memory grows as $n^2$. At $n=2048$ it's one to two orders of magnitude slower than an HVP, with three orders of magnitude more temporary memory.
💡 Practical rule¶
🟢 Must-read
In one sentence: when you need the full Jacobian, compare $n$ and $m$: use
jacfwdfor few inputs andjacrevfor few outputs. When you only need its product with a few vectors, don't build the matrix first; usejvp/vjp/linearize(building the matrix only pays off when the number of vectors approaches $n$). For Hessian-vector products, preferjvp(grad(f)).
Topic 4 Per-example gradients¶
4.1 Motivation¶
⚪ Optional: examples of what per-example gradients are used for (DP-SGD, training diagnostics, etc.)
Regular training only needs "the gradient of the batch-averaged loss", but many situations need each example's own gradient:
- Differentially private training (DP-SGD): first clip each example's gradient to norm ≤ C, then sum and add noise;
- Training diagnostics: examples with unusually large gradient norms are often hard examples, outliers, or mislabeled;
- Data valuation / influence functions, diagonal estimates of the Fisher information matrix, and so on.
In JAX, one line of vmap(grad(loss)) gives you per-example gradients, but in practice there are three things to worry about: whether it's written correctly (cross-check against a loop), whether there's enough memory ($B\times P$), and how to compute in chunks when there isn't.
→ Basics in Chapter 4 topic 4.6 (how per-example gradients work and what they're for, why
grad(vmap(f))raises an error), Extra in 4.7 (the memory trade-off betweenlax.mapand vmap), extra exercise 2 (DP-SGD-style clipping, without noise).
4.2 Cross-checking vmap(grad) against a loop¶
⚪ Optional: the standard approach was covered in Chapter 4, 4.6; this is the cross-check against a loop implementation
The model is a two-layer MLP with nested-dict parameters, and the loss uses log_softmax_stable from topic 2. The key is the single-example loss: it takes one example x: (d,) and one label y: (), and returns a scalar. Under vmap, the parameters aren't mapped (in_axes=None) and the data is mapped along axis 0.
def init_mlp(key, sizes):
params = {}
for i, (k, (n_in, n_out)) in enumerate(zip(jax.random.split(key, len(sizes) - 1), zip(sizes[:-1], sizes[1:]))):
params[f"dense_{i}"] = {"w": jax.random.normal(k, (n_in, n_out)) * np.sqrt(2.0 / n_in),
"b": jnp.zeros(n_out)}
return params
def mlp_apply(params, x):
n = len(params)
for i in range(n):
layer = params[f"dense_{i}"]
x = x @ layer["w"] + layer["b"]
if i < n - 1:
x = jax.nn.relu(x)
return x
def loss_single(params, x, y):
"""Cross-entropy of a single example: x has shape (d,), y is an integer label."""
return -log_softmax_stable(mlp_apply(params, x))[y]
def loss_batch(params, X, Y):
return jnp.mean(jax.vmap(loss_single, in_axes=(None, 0, 0))(params, X, Y))
def tree_allclose(a, b, **kw):
return all(jax.tree.leaves(jax.tree.map(lambda u, v: bool(np.allclose(u, v, **kw)), a, b)))
D_IN, N_CLS, B = 20, 10, 256
kp, kx, ky = jax.random.split(jax.random.key(10), 3)
params_ps = init_mlp(kp, (D_IN, 64, N_CLS))
X_ps = jax.random.normal(kx, (B, D_IN))
Y_ps = jax.random.randint(ky, (B,), 0, N_CLS)
P = sum(l.size for l in jax.tree.leaves(params_ps))
print(f"Parameter count P = {P}, batch B = {B}")
# ✅ vmap(grad): parameters not mapped, data mapped
per_sample_grads = jax.jit(jax.vmap(jax.grad(loss_single), in_axes=(None, 0, 0)))
g_vmap = per_sample_grads(params_ps, X_ps, Y_ps)
print("Per-example gradient shapes:", jax.tree.map(lambda l: l.shape, g_vmap))
# Reference implementation: a Python loop, computing gradients one example at a time
grad_one = jax.jit(jax.grad(loss_single))
def per_sample_loop(params, X, Y):
gs = [grad_one(params, X[i], Y[i]) for i in range(X.shape[0])]
return jax.tree.map(lambda *ls: jnp.stack(ls), *gs)
g_loop = per_sample_loop(params_ps, X_ps, Y_ps)
t_loop = bench(per_sample_loop, params_ps, X_ps, Y_ps, warmup=1, runs=3)
t_vmap = bench(per_sample_grads, params_ps, X_ps, Y_ps)
print(f"loop {t_loop:.1f} ms | vmap {t_vmap:.3f} ms ({t_loop / t_vmap:.0f}x faster)")
assert tree_allclose(g_vmap, g_loop, rtol=1e-5, atol=1e-6) # matches the loop element-wise
g_mean = jax.tree.map(lambda l: l.mean(0), g_vmap)
assert tree_allclose(g_mean, jax.grad(loss_batch)(params_ps, X_ps, Y_ps), atol=1e-6) # mean = gradient of the batch loss
print("vmap(grad) matches the loop; the mean of the per-example gradients = the gradient of the mean loss ✔")
4.3 ❌ Common mistakes¶
🟢 Must-read
- Forgetting
in_axes: the defaultin_axes=0maps the parameters too along axis 0 (→ Chapter 4, 4.4). At best you get a shape mismatch error; at worst, when the shapes happen to line up, it silently computes wrong results; - Taking
gradof the batch loss: you get one gradient averaged over the batch, not per-example gradients; there's no B dimension in the shape; - Doing cross-example computations in the single-example loss (e.g. normalizing by the batch mean): after
vmap, each example only sees itself, so the meaning changes.
Below we demonstrate the first two. For the first, we deliberately build an example where "the shapes happen to line up": the batch size equals the input dimension of the first layer.
# ❌ 1a: default in_axes=0 maps the parameters too → shape mismatch error
show_error(lambda: jax.vmap(jax.grad(loss_single))(params_ps, X_ps, Y_ps))
# ❌ 1b: when the parameters' axis 0 happens to equal the batch size (both 20), no error, but the meaning is completely wrong
Xs, Ys = X_ps[:D_IN], Y_ps[:D_IN]
p_tiny = {"w": 0.1 * jnp.ones((D_IN, D_IN))} # shape (20, 20)
def loss_tiny(p, x, y):
return jnp.sum((x * p["w"]) ** 2) * (y + 1) # element-wise product: both a row of w and the whole w broadcast
wrong = jax.vmap(jax.grad(loss_tiny))(p_tiny, Xs, Ys) # silently treats row i of w as the parameters of example i
right = jax.vmap(jax.grad(loss_tiny), in_axes=(None, 0, 0))(p_tiny, Xs, Ys)
print("❌ Default in_axes: no error, output shape", wrong["w"].shape, "| ✅ correct per-example gradient shape", right["w"].shape)
assert wrong["w"].shape == (D_IN, D_IN) and right["w"].shape == (D_IN, D_IN, D_IN)
# ❌ 2: grad of the batch loss gives just one gradient
g_batch = jax.grad(loss_batch)(params_ps, X_ps, Y_ps)
print("❌ Shape of grad(loss_batch):", g_batch["dense_0"]["w"].shape, "← no batch dimension")
assert g_batch["dense_0"]["w"].shape == (D_IN, 64)
4.4 Memory: $B \times P$, and computing in chunks¶
⚪ Optional: when the model is large and per-example gradients don't fit in memory
Per-example gradients form a $B\times P$ tensor. Here $P\approx 2000$, so there's no visible problem. But for a 100-million-parameter model with batch 256, it takes 100 GB in float32.
Many situations don't actually need to store the whole $B\times P$:
- Training diagnostics only need each example's gradient norm ($B$ numbers);
- DP-SGD only needs "the sum of the clipped gradients" ($P$ numbers).
So the right approach is to reduce inside each chunk: compute per-example gradients for only $k$ examples at a time (memory $k\times P$), immediately compute norms or clip and sum, then move on to the next chunk. Below we compare four ways of computing "per-example gradient norms" and use memory_analysis() to look at output and temporary memory after compilation (→ the Extra in Chapter 4, 4.7 used the same method).
def per_sample_norm(params, x, y):
g = jax.grad(loss_single)(params, x, y)
return jnp.sqrt(sum(jnp.sum(l ** 2) for l in jax.tree.leaves(g)))
B_BIG, CHUNK = 4096, 256
kx, ky = jax.random.split(jax.random.key(11))
X_big = jax.random.normal(kx, (B_BIG, D_IN))
Y_big = jax.random.randint(ky, (B_BIG,), 0, N_CLS)
variants = {
"① vmap: store B×P, then norm": lambda p, X, Y: jax.vmap(
lambda g: jnp.sqrt(sum(jnp.sum(l ** 2) for l in jax.tree.leaves(g))))(
jax.vmap(jax.grad(loss_single), in_axes=(None, 0, 0))(p, X, Y)),
"② vmap(per-example norm)": lambda p, X, Y: jax.vmap(per_sample_norm, in_axes=(None, 0, 0))(p, X, Y),
f"③ lax.map(batch_size={CHUNK})": lambda p, X, Y: lax.map(lambda xy: per_sample_norm(p, *xy), (X, Y), batch_size=CHUNK),
"④ lax.map (one at a time)": lambda p, X, Y: lax.map(lambda xy: per_sample_norm(p, *xy), (X, Y)),
}
ref_norms = None
print(wpad("Approach", 34) + "temp memory time")
for name, fn in variants.items():
jf = jax.jit(fn)
norms = jf(params_ps, X_big, Y_big)
if ref_norms is None:
ref_norms = norms
assert np.allclose(norms, ref_norms, rtol=1e-5, atol=1e-6), name # all four approaches agree
mem = jf.lower(params_ps, X_big, Y_big).compile().memory_analysis()
t = bench(jf, params_ps, X_big, Y_big, runs=3)
print(wpad(name, 34) + f"{mem.temp_size_in_bytes / 2**20:7.2f} MiB {t:8.2f} ms")
print(f"\nTheory: B×P×4 bytes = {B_BIG * P * 4 / 2**20:.1f} MiB; chunked k×P×4 = {CHUNK * P * 4 / 2**20:.2f} MiB")
print(f"With 100M parameters and B=256, B×P needs {256 * 1e8 * 4 / 2**30:.0f} GiB")
How to read it: the temporary memory of ① and ② is on the order of $B\times P$; XLA won't eliminate the intermediate per-example gradients for you. ③ computes in chunks, so temporary memory drops to within $k\times P$, and its speed is between vmap and one-at-a-time (parallelism within a chunk is limited on CPU; on GPU, bigger chunks get closer to vmap). ④ uses the least memory but is fully sequential and slowest. Rule of thumb: start with vmap; when memory runs short, switch to lax.map(..., batch_size=k), and reduce the per-example gradients inside each chunk.
4.5 DP-SGD: a complete implementation with clipping + noise¶
⚪ Optional: for differentially private training
DP-SGD (Abadi et al., 2016) computes each step's gradient like this:
- Per-example gradients $g_i$;
- Clipping: $\bar g_i = g_i\cdot\min\!\left(1, \dfrac{C}{\lVert g_i\rVert_2}\right)$, where the norm is the global L2 norm of all parameters concatenated;
- Sum and add noise: $\tilde g = \dfrac{1}{B}\left(\sum_i \bar g_i + \mathcal N(0, \sigma^2C^2 I)\right)$, with noise sampled independently for each parameter;
- Do a regular SGD update with $\tilde g$.
Computing the privacy budget $(\varepsilon,\delta)$ (privacy accounting) needs a dedicated accountant, which we won't go into; we only implement the gradient part. The implementation has two pieces, clip_and_sum (whole batch or in chunks) and add_dp_noise, so they can be verified separately. Random numbers use jax.random.key, with an independent key split off for each leaf.
def clip_and_sum(params, X, Y, clip_norm):
"""Whole-batch version: returns (Σ_i clipped g_i, each example's norm before clipping). Memory B×P."""
g = jax.vmap(jax.grad(loss_single), in_axes=(None, 0, 0))(params, X, Y)
sq = sum(jnp.sum(l.reshape(l.shape[0], -1) ** 2, axis=1) for l in jax.tree.leaves(g))
norms = jnp.sqrt(sq)
factor = jnp.minimum(1.0, clip_norm / (norms + 1e-12)) # per-example scaling factor (B,)
summed = jax.tree.map(lambda l: jnp.tensordot(factor, l, axes=1), g)
return summed, norms
def clip_and_sum_chunked(params, X, Y, clip_norm, chunk):
"""Chunked version: chunk examples per chunk, memory chunk×P. Requires B to be divisible by chunk."""
Xc = X.reshape(-1, chunk, X.shape[-1])
Yc = Y.reshape(-1, chunk)
def body(acc, xy):
s, norms = clip_and_sum(params, *xy, clip_norm)
return jax.tree.map(jnp.add, acc, s), norms
acc0 = jax.tree.map(jnp.zeros_like, params)
summed, norms = lax.scan(body, acc0, (Xc, Yc))
return summed, norms.reshape(-1)
def add_dp_noise(summed, key, clip_norm, noise_mult, batch_size):
leaves, treedef = jax.tree.flatten(summed)
keys = jax.random.split(key, len(leaves)) # one independent key per leaf
noisy = [(l + noise_mult * clip_norm * jax.random.normal(k, l.shape)) / batch_size
for l, k in zip(leaves, keys)]
return jax.tree.unflatten(treedef, noisy)
def dp_grad(params, X, Y, key, clip_norm, noise_mult):
summed, norms = clip_and_sum(params, X, Y, clip_norm)
return add_dp_noise(summed, key, clip_norm, noise_mult, X.shape[0]), norms
# ---------- Self-proof ----------
C = 1.0
dp_grad_jit = jax.jit(dp_grad)
# (a) Infinite clipping threshold, no noise → reduces to the ordinary mean gradient
g_nonpriv, _ = dp_grad_jit(params_ps, X_ps, Y_ps, jax.random.key(0), 1e9, 0.0)
assert tree_allclose(g_nonpriv, jax.grad(loss_batch)(params_ps, X_ps, Y_ps), atol=1e-6)
# (b) After clipping, every example's norm is ≤ C
summed, norms = jax.jit(clip_and_sum)(params_ps, X_ps, Y_ps, C)
clipped = norms * jnp.minimum(1.0, C / (norms + 1e-12))
print(f"Norms before clipping: min {norms.min():.3f}, max {norms.max():.3f}; max after clipping {clipped.max():.4f} (C = {C})")
assert float(clipped.max()) <= C * (1 + 1e-5) and float(norms.max()) > C # clipping really happened
# (c) The chunked version matches the whole-batch version
summed_c, norms_c = jax.jit(clip_and_sum_chunked, static_argnames="chunk")(params_ps, X_ps, Y_ps, C, chunk=64)
assert tree_allclose(summed, summed_c, rtol=1e-5, atol=1e-5) and np.allclose(norms, norms_c, rtol=1e-5)
# (d) Statistical properties of the noise: (g̃ - noiseless result) × B / (σC) should be standard normal
sigma = 1.1
zero_noise = add_dp_noise(summed, jax.random.key(1), C, 0.0, B)
noise_samples = jax.jit(jax.vmap(lambda k: add_dp_noise(summed, k, C, sigma, B)))(jax.random.split(jax.random.key(2), 200))
z = np.concatenate([np.asarray((n - z0[None]) * B / (sigma * C)).ravel()
for n, z0 in zip(jax.tree.leaves(noise_samples), jax.tree.leaves(zero_noise))])
print(f"Standardized noise: mean {z.mean():+.4f}, std {z.std():.4f} (theory 0 and 1, {z.size} samples)")
assert abs(z.mean()) < 0.01 and abs(z.std() - 1) < 0.01
# (e) The same key gives the same result twice, different keys give different results (randomness is fully determined by the key)
a1, _ = dp_grad_jit(params_ps, X_ps, Y_ps, jax.random.key(5), C, sigma)
a2, _ = dp_grad_jit(params_ps, X_ps, Y_ps, jax.random.key(5), C, sigma)
a3, _ = dp_grad_jit(params_ps, X_ps, Y_ps, jax.random.key(6), C, sigma)
assert tree_allclose(a1, a2) and not tree_allclose(a1, a3)
print("All 5 self-proofs of the DP-SGD gradient passed ✔")
Finally, let's actually train with DP-SGD. The data is Gaussian clusters with 20 dimensions and 10 classes. The training loop is written as a single jit program with lax.scan, with the key in the carry and split each step, and we compare it with non-private SGD.
def make_blobs(key, n, d, k, spread=1.0):
kc, ky, kn = jax.random.split(key, 3)
centers = jax.random.normal(kc, (k, d)) * 2.0
y = jax.random.randint(ky, (n,), 0, k)
X = centers[y] + spread * jax.random.normal(kn, (n, d))
return X, y
Xtr, Ytr = make_blobs(jax.random.key(20), 4096, D_IN, N_CLS)
def accuracy(params, X, Y):
return jnp.mean(jnp.argmax(mlp_apply(params, X), axis=-1) == Y)
def train_dp(params, X, Y, key, lr, clip_norm, noise_mult, steps, batch):
def step(carry, _):
params, key = carry
key, k_idx, k_noise = jax.random.split(key, 3)
idx = jax.random.choice(k_idx, X.shape[0], (batch,), replace=False)
g, _ = dp_grad(params, X[idx], Y[idx], k_noise, clip_norm, noise_mult)
params = jax.tree.map(lambda p, gi: p - lr * gi, params, g)
return (params, key), None
(params, _), _ = lax.scan(step, (params, key), None, length=steps)
return params
train_dp_jit = jax.jit(train_dp, static_argnames=("steps", "batch"))
p0 = init_mlp(jax.random.key(21), (D_IN, 64, N_CLS))
p_dp = train_dp_jit(p0, Xtr, Ytr, jax.random.key(22), 0.5, 1.0, 1.1, steps=300, batch=256)
p_sgd = train_dp_jit(p0, Xtr, Ytr, jax.random.key(22), 0.5, 1e9, 0.0, steps=300, batch=256) # no clipping, no noise
acc_dp, acc_sgd = float(accuracy(p_dp, Xtr, Ytr)), float(accuracy(p_sgd, Xtr, Ytr))
print(f"Initial accuracy {float(accuracy(p0, Xtr, Ytr)):.3f} | plain SGD {acc_sgd:.3f} | DP-SGD(C=1, σ=1.1) {acc_dp:.3f}")
assert acc_dp > 0.9 and acc_sgd > 0.9
💡 Practical rule¶
🟢 Must-read
In one sentence: write per-example gradients as "single-example loss +
vmap(grad, in_axes=(None, 0, 0))" and cross-check against a loop. Memory is $B\times P$; for large models, chunk withlax.map(batch_size=k)and reduce to norms or clipped sums inside each chunk.
Topic 5 Hand-written optimizers¶
5.1 Motivation¶
⚪ Optional: why split optimizers into small composable transformations (the design idea behind Optax)
In real projects, just use Optax (optax 0.2.9 is installed in this environment). But the best way to really understand optimizers is to implement them yourself. The implementation in this section doesn't depend on Optax; we only use Optax at the end to cross-check. Chapter 6 already wrote momentum SGD and Adam that "work on any PyTree", but each optimizer was one big block of code: to add gradient clipping, weight decay, or a learning-rate schedule, you had to copy it and modify it. Optax's idea is to split an optimizer into small transformations that compose:
adamw = chain(clip_by_global_norm(1.0), # gradient clipping
scale_by_adam(), # Adam's direction
add_decayed_weights(1e-4), # decoupled weight decay
scale_by_learning_rate(schedule)) # multiply by -lr(t)
Each transformation is a pair of pure functions: init(params) -> state and update(updates, state, params) -> (updates, state). The state is a PyTree, so it passes straight through jit / scan / donate.
→ Basics in Chapter 6 6.6 (the learning rate should be passed as a dynamic argument) and its Extra (PyTree momentum SGD and Adam, the two momentum formulas), extra exercise 3 (RMSProp); for an introduction to Optax's interface, see the ecosystem section at the end of Chapter 6.
5.2 How it works: GradientTransformation and its components¶
⚪ Optional: when you want to implement it yourself or understand Optax's internals; skip it if you just use Optax
| Component | State | What it does |
|---|---|---|
scale(s) | None | $u \leftarrow s\,u$ |
scale_by_schedule(f) | step count | $u \leftarrow f(t)\,u$ |
trace(β, nesterov) | velocity $v$ | $v \leftarrow u + \beta v$; Nesterov outputs $u + \beta v$, otherwise outputs $v$ |
scale_by_adam(β₁, β₂, ε) | count, $m$, $v$ | $\hat m/(\sqrt{\hat v}+\varepsilon)$, with bias correction |
add_decayed_weights(λ, mask) | None | $u \leftarrow u + \lambda\theta$ (only for parameters where the mask is true) |
clip_by_global_norm(c) | None | When $\lVert u\rVert > c$, scale everything down to norm $c$ |
chain(*ts) | tuple of each component's state | Apply in order |
The convention is the same as Optax: update outputs the increment to add to the parameters (already including the minus sign), and you add it at the end with apply_updates(params, updates).
class GradientTransformation(NamedTuple):
init: Callable # params -> state
update: Callable # (updates, state, params=None) -> (updates, new_state)
class EmptyState(NamedTuple):
pass
def apply_updates(params, updates):
return jax.tree.map(lambda p, u: p + u, params, updates)
def global_norm(tree):
return jnp.sqrt(sum(jnp.sum(l ** 2) for l in jax.tree.leaves(tree)))
def identity():
return GradientTransformation(lambda params: EmptyState(), lambda u, s, params=None: (u, s))
def scale(step_size):
def update(updates, state, params=None):
return jax.tree.map(lambda g: step_size * g, updates), state
return GradientTransformation(lambda params: EmptyState(), update)
class ScaleByScheduleState(NamedTuple):
count: jax.Array
def scale_by_schedule(schedule):
def init(params):
return ScaleByScheduleState(count=jnp.zeros([], jnp.int32)) # strongly typed int32: no extra compilation
def update(updates, state, params=None):
s = schedule(state.count) # use the step count before the update (count=0 at step 1)
return jax.tree.map(lambda g: s * g, updates), ScaleByScheduleState(count=state.count + 1)
return GradientTransformation(init, update)
def scale_by_learning_rate(lr):
if callable(lr):
return scale_by_schedule(lambda count: -lr(count))
return scale(-lr)
class TraceState(NamedTuple):
trace: Any
def trace(decay, nesterov=False):
def init(params):
return TraceState(trace=jax.tree.map(jnp.zeros_like, params))
def update(updates, state, params=None):
new_t = jax.tree.map(lambda g, t: g + decay * t, updates, state.trace)
out = jax.tree.map(lambda g, t: g + decay * t, updates, new_t) if nesterov else new_t
return out, TraceState(trace=new_t)
return GradientTransformation(init, update)
class ScaleByAdamState(NamedTuple):
count: jax.Array
mu: Any
nu: Any
def scale_by_adam(b1=0.9, b2=0.999, eps=1e-8):
def init(params):
return ScaleByAdamState(count=jnp.zeros([], jnp.int32),
mu=jax.tree.map(jnp.zeros_like, params), # mu and nu are created independently (see topic 6)
nu=jax.tree.map(jnp.zeros_like, params))
def update(updates, state, params=None):
mu = jax.tree.map(lambda m, g: b1 * m + (1 - b1) * g, state.mu, updates)
nu = jax.tree.map(lambda v, g: b2 * v + (1 - b2) * g * g, state.nu, updates)
count = state.count + 1
t = count.astype(jnp.float32)
bc1, bc2 = 1 - b1 ** t, 1 - b2 ** t # bias correction
out = jax.tree.map(lambda m, v: (m / bc1) / (jnp.sqrt(v / bc2) + eps), mu, nu)
return out, ScaleByAdamState(count=count, mu=mu, nu=nu)
return GradientTransformation(init, update)
def add_decayed_weights(weight_decay, mask=None):
"""Decoupled weight decay: u ← u + λθ. mask can be a bool tree with the same structure as params, or a function returning such a tree."""
def update(updates, state, params):
m = jax.tree.map(lambda _: True, params) if mask is None else (mask(params) if callable(mask) else mask)
return jax.tree.map(lambda g, p, keep: g + weight_decay * p if keep else g, updates, params, m), state
return GradientTransformation(lambda params: EmptyState(), update)
def clip_by_global_norm(max_norm):
def update(updates, state, params=None):
factor = jnp.minimum(1.0, max_norm / (global_norm(updates) + 1e-12))
return jax.tree.map(lambda g: g * factor, updates), state
return GradientTransformation(lambda params: EmptyState(), update)
def chain(*transforms):
def init(params):
return tuple(t.init(params) for t in transforms)
def update(updates, state, params=None):
new_state = []
for t, s in zip(transforms, state):
updates, s = t.update(updates, s, params)
new_state.append(s)
return updates, tuple(new_state)
return GradientTransformation(init, update)
# ---------- Common aliases ----------
def sgd(lr, momentum=None, nesterov=False):
return chain(trace(momentum, nesterov) if momentum else identity(), scale_by_learning_rate(lr))
def adam(lr, b1=0.9, b2=0.999, eps=1e-8):
return chain(scale_by_adam(b1, b2, eps), scale_by_learning_rate(lr))
def adamw(lr, weight_decay=1e-4, b1=0.9, b2=0.999, eps=1e-8, mask=None):
return chain(scale_by_adam(b1, b2, eps), add_decayed_weights(weight_decay, mask), scale_by_learning_rate(lr))
# ---------- Learning-rate schedules ----------
def warmup_cosine_decay_schedule(init_value, peak_value, warmup_steps, decay_steps, end_value=0.0):
"""Same semantics as Optax: rise linearly to peak over the first warmup_steps steps, then cosine-decay to end by step decay_steps (including warmup)."""
def schedule(count):
c = jnp.asarray(count, jnp.float32)
warm = init_value + (peak_value - init_value) * c / warmup_steps
frac = jnp.clip((c - warmup_steps) / (decay_steps - warmup_steps), 0.0, 1.0)
cos = end_value + (peak_value - end_value) * 0.5 * (1 + jnp.cos(jnp.pi * frac))
return jnp.where(c < warmup_steps, warm, cos)
return schedule
print("Components defined:", ", ".join(["scale", "scale_by_schedule", "trace", "scale_by_adam",
"add_decayed_weights", "clip_by_global_norm", "chain", "sgd", "adam", "adamw"]))
5.3 assert as proof: step-by-step cross-check against hand-computed NumPy float64 values¶
⚪ Optional: a method for cross-checking a hand-written optimizer step by step; useful when you implement your own
The test problem is a quadratic on PyTree parameters, $L(\theta)=\sum \tfrac12 a\,\theta^2 + c\,\theta$, whose gradient $a\theta + c$ is easy to compute by hand in NumPy. Each optimizer runs 5 steps, and the parameters at every step are compared with the NumPy reference.
rng_keys = jax.random.split(jax.random.key(30), 4)
theta0 = {"w": jax.random.normal(rng_keys[0], (3, 2)), "b": jax.random.normal(rng_keys[1], (2,))}
coef_a = {"w": 1.0 + jax.random.uniform(rng_keys[2], (3, 2)) * 4, "b": jnp.array([0.5, 2.0])}
coef_c = {"w": jax.random.normal(rng_keys[3], (3, 2)), "b": jnp.array([1.0, -1.0])}
def quad_loss(theta, a, c): # coefficients passed in as arguments, not captured by a closure
return sum(jnp.sum(0.5 * a[k] * theta[k] ** 2 + c[k] * theta[k]) for k in theta)
def run_jax(opt, steps=5):
@jax.jit
def step(theta, state, a, c):
g = jax.grad(quad_loss)(theta, a, c)
u, state = opt.update(g, state, theta)
return apply_updates(theta, u), state
theta, state, traj = theta0, opt.init(theta0), []
for _ in range(steps):
theta, state = step(theta, state, coef_a, coef_c)
traj.append(jax.tree.map(to64, theta))
return traj
def run_numpy(rule, steps=5):
"""rule(t, theta, g, mem) -> theta_new; t starts at 1, mem is a dict you can use freely."""
theta = jax.tree.map(to64, theta0)
a, c = jax.tree.map(to64, coef_a), jax.tree.map(to64, coef_c)
mem, traj = {}, []
for t in range(1, steps + 1):
g = {k: a[k] * theta[k] + c[k] for k in theta}
theta = {k: rule(t, k, theta[k], g[k], mem) for k in theta}
traj.append(theta)
return traj
def check(name, jax_traj, np_traj, rtol=1e-5, atol=1e-5): # float32 vs float64
for t, (tj, tn) in enumerate(zip(jax_traj, np_traj), 1):
for k in tn:
assert np.allclose(tj[k], tn[k], rtol=rtol, atol=atol), (name, t, k, np.abs(tj[k] - tn[k]).max())
err = max(np.abs(tj[k] - tn[k]).max() for tj, tn in zip(jax_traj, np_traj) for k in tn)
print(f"{wpad(name, 34)} all 5 steps match, max error {err:.2e}")
LR, B1, B2, EPS = 0.1, 0.9, 0.999, 1e-8
def np_adam(t, k, th, g, mem, wd=0.0):
m = mem.get(("m", k), 0.0) * B1 + (1 - B1) * g
v = mem.get(("v", k), 0.0) * B2 + (1 - B2) * g ** 2
mem[("m", k)], mem[("v", k)] = m, v
direction = (m / (1 - B1 ** t)) / (np.sqrt(v / (1 - B2 ** t)) + EPS)
return th - LR * (direction + wd * th) # AdamW: the decay term doesn't go through Adam's normalization
def np_momentum(t, k, th, g, mem, nesterov=False, beta=0.9):
v = beta * mem.get(("v", k), 0.0) + g
mem[("v", k)] = v
return th - LR * ((g + beta * v) if nesterov else v)
check("adam", run_jax(adam(LR)), run_numpy(np_adam))
check("adamw(wd=0.05)", run_jax(adamw(LR, weight_decay=0.05)), run_numpy(partial(np_adam, wd=0.05)))
check("sgd(momentum=0.9)", run_jax(sgd(LR, momentum=0.9)), run_numpy(np_momentum))
check("sgd(momentum=0.9, nesterov=True)", run_jax(sgd(LR, momentum=0.9, nesterov=True)),
run_numpy(partial(np_momentum, nesterov=True)))
check("sgd", run_jax(sgd(LR)), run_numpy(lambda t, k, th, g, mem: th - LR * g))
Now cross-check against the real Optax: with the same hyperparameters and the same gradient sequence, the hand-written versions and optax should agree at every step (run_jax only uses init / update, so we can pass Optax optimizers straight in).
import optax
print("optax version:", optax.__version__)
opt_pairs = [
("adam", adam(LR), optax.adam(LR)),
("adamw(wd=0.05)", adamw(LR, weight_decay=0.05), optax.adamw(LR, weight_decay=0.05)),
("nesterov", sgd(LR, momentum=0.9, nesterov=True), optax.sgd(LR, momentum=0.9, nesterov=True)),
("clip+adam+schedule", chain(clip_by_global_norm(1.0), adam(warmup_cosine_decay_schedule(0.0, LR, 2, 10, 1e-3))),
optax.chain(optax.clip_by_global_norm(1.0), optax.adam(optax.warmup_cosine_decay_schedule(0.0, LR, 2, 10, 1e-3)))),
]
for name, mine, ref in opt_pairs:
a, b = run_jax(mine), run_jax(ref)
err = max(np.abs(x[k] - y[k]).max() for x, y in zip(a, b) for k in x)
print(f"{wpad(name, 22)} hand-written vs optax, max diff over 5 steps {err:.2e}")
assert err < 1e-6, name
sched_mine = jax.vmap(warmup_cosine_decay_schedule(0.0, 1e-2, 10, 100, 1e-4))(jnp.arange(120))
sched_ref = jax.vmap(optax.warmup_cosine_decay_schedule(0.0, 1e-2, 10, 100, 1e-4))(jnp.arange(120))
assert np.allclose(sched_mine, sched_ref, rtol=1e-6, atol=1e-9)
print("The warmup-cosine schedule matches optax point by point ✔")
# Learning-rate schedule: cross-check point by point against the NumPy formula, and check the key points
INIT, PEAK, WARM, DECAY, END = 0.0, 1e-2, 10, 100, 1e-4
sched = warmup_cosine_decay_schedule(INIT, PEAK, WARM, DECAY, END)
def np_sched(c):
if c < WARM:
return INIT + (PEAK - INIT) * c / WARM
frac = min(max((c - WARM) / (DECAY - WARM), 0.0), 1.0)
return END + (PEAK - END) * 0.5 * (1 + np.cos(np.pi * frac))
steps_probe = np.arange(0, 121)
lr_curve = np.asarray(jax.vmap(sched)(jnp.asarray(steps_probe)))
assert np.allclose(lr_curve, [np_sched(c) for c in steps_probe], rtol=1e-5, atol=1e-9)
assert lr_curve[0] == INIT and np.isclose(lr_curve[WARM], PEAK) and np.isclose(lr_curve[DECAY], END)
assert np.all(np.diff(lr_curve[:WARM + 1]) > 0) and np.all(np.diff(lr_curve[WARM:DECAY + 1]) < 0)
print(f"warmup-cosine: step 0 {lr_curve[0]}, step {WARM} {lr_curve[WARM]:.4g} (peak), "
f"step {DECAY} {lr_curve[DECAY]:.4g}, then stays at {lr_curve[-1]:.4g} ✔")
# Gradient clipping: updates above the threshold are scaled down to it, those below are left alone
clip = clip_by_global_norm(1.0)
big = {"w": jnp.full((3, 2), 3.0), "b": jnp.full(2, 4.0)}
small = jax.tree.map(lambda x: x * 0.01, big)
assert np.isclose(global_norm(clip.update(big, EmptyState())[0]), 1.0, rtol=1e-6)
assert tree_allclose(clip.update(small, EmptyState())[0], small)
# chain(clipping + Adam + schedule) cross-checked step by step against NumPy
def run_numpy_chain(steps=5):
"""NumPy reference: clip by global norm first, then Adam, then multiply by -lr(t-1)."""
theta = jax.tree.map(to64, theta0)
a, c = jax.tree.map(to64, coef_a), jax.tree.map(to64, coef_c)
m = {k: 0.0 for k in theta}; v = {k: 0.0 for k in theta}; traj = []
for t in range(1, steps + 1):
g = {k: a[k] * theta[k] + c[k] for k in theta}
factor = min(1.0, 1.0 / (np.sqrt(sum(np.sum(x ** 2) for x in g.values())) + 1e-12))
new = {}
for k in theta:
gk = g[k] * factor
m[k] = B1 * m[k] + (1 - B1) * gk
v[k] = B2 * v[k] + (1 - B2) * gk ** 2
new[k] = theta[k] - np_sched(t - 1) * (m[k] / (1 - B1 ** t)) / (np.sqrt(v[k] / (1 - B2 ** t)) + EPS)
theta = new
traj.append(theta)
return traj
opt_chain = chain(clip_by_global_norm(1.0), scale_by_adam(B1, B2, EPS), scale_by_learning_rate(sched))
check("chain(clip, adam, warmup-cosine)", run_jax(opt_chain), run_numpy_chain())
5.4 ❌ Common mistakes, measured¶
🟢 Must-read
Mistake 1: forgetting Adam's bias correction. $m$ and $v$ both start at 0, so the first few steps are heavily biased toward 0. Without the correction, the first step's direction is $\dfrac{(1-\beta_1)g}{\sqrt{(1-\beta_2)g^2}}=\dfrac{1-\beta_1}{\sqrt{1-\beta_2}}\,\mathrm{sign}(g)\approx 3.16\,\mathrm{sign}(g)$. In other words, the first step is more than 3 times the step size you set, and with a large learning rate it can easily blow up training right at the start.
Mistake 2: writing AdamW as "L2 regularization + Adam". If you add $\lambda\theta$ to the gradient and feed it into Adam, the decay term gets normalized by $\sqrt{\hat v}$. In the extreme case (task gradient of 0), every step becomes "subtract a fixed lr", almost independent of λ. Decoupled AdamW instead shrinks by a factor of $(1-\eta\lambda)$.
# Mistake 1: drop the bias correction
def scale_by_adam_no_bc(b1=0.9, b2=0.999, eps=1e-8):
def update(updates, state, params=None):
mu = jax.tree.map(lambda m, g: b1 * m + (1 - b1) * g, state.mu, updates)
nu = jax.tree.map(lambda v, g: b2 * v + (1 - b2) * g * g, state.nu, updates)
out = jax.tree.map(lambda m, v: m / (jnp.sqrt(v) + eps), mu, nu) # ❌ no division by 1-β^t
return out, ScaleByAdamState(state.count + 1, mu, nu)
return GradientTransformation(scale_by_adam().init, update)
g_demo = {"w": jnp.array([0.3, -2.0, 50.0])}
p_demo = {"w": jnp.zeros(3)}
for name, tx in [("✅ with bias correction", scale_by_adam()), ("❌ without bias correction", scale_by_adam_no_bc())]:
u, _ = tx.update(g_demo, tx.init(p_demo))
print(f"{name}: first-step direction = {u['w']}")
u_ok, _ = scale_by_adam().update(g_demo, scale_by_adam().init(p_demo))
u_bad, _ = scale_by_adam_no_bc().update(g_demo, scale_by_adam().init(p_demo))
assert np.allclose(np.abs(u_ok["w"]), 1.0, atol=1e-6)
assert np.allclose(np.abs(u_bad["w"]), (1 - 0.9) / np.sqrt(1 - 0.999), rtol=1e-4)
# Mistake 2: L2 regularization + Adam vs. decoupled AdamW. The task gradient is always 0, so we only see the effect of the decay term
lr, wd, steps = 0.01, 0.1, 20
p_init = {"w": jnp.array([1.0, 10.0])}
zero_grad = {"w": jnp.zeros(2)}
opt_l2 = chain(add_decayed_weights(wd), scale_by_adam(), scale_by_learning_rate(lr)) # ❌ add λθ first, then go through Adam
opt_w = adamw(lr, weight_decay=wd) # ✅ decoupled
results_wd = {}
for name, opt in [("❌ L2 + Adam", opt_l2), ("✅ AdamW", opt_w)]:
p, s = p_init, opt.init(p_init)
for _ in range(steps):
u, s = opt.update(zero_grad, s, p)
p = apply_updates(p, u)
results_wd[name] = p["w"]
print(f"{name}: w after {steps} steps = {p['w']}")
assert np.allclose(results_wd["❌ L2 + Adam"], np.array([1.0, 10.0]) - steps * lr, atol=5e-3) # each step subtracts about lr, regardless of the size of w
assert np.allclose(results_wd["✅ AdamW"], np.array([1.0, 10.0]) * (1 - lr * wd) ** steps, rtol=1e-5) # shrinks proportionally
With L2 + Adam, both w=1 and w=10 drop by about $20\times0.01=0.2$: the amount of decay doesn't depend on the parameter size, and λ gets normalized away too. AdamW shrinks both proportionally by $(1-\eta\lambda)^{20}$.
Mistake 3: recompilation caused by the scheduler. Two ways of writing it fall into the pitfalls of topic 1: making the learning rate a static argument (one compilation per new value); or computing the learning rate in a Python loop and passing it into jit sometimes as a Python float and sometimes as an array (one compilation each for weak and strong types). Passing a Python float every step doesn't recompile by itself (verified in topic 1.5 (3)), but then the schedule logic stays outside jit. Here scale_by_schedule stores the step count in the state (an int32 array) and computes the learning rate inside jit, so 100 steps compile only once:
model_opt = chain(clip_by_global_norm(1.0), scale_by_adam(), scale_by_learning_rate(sched))
def opt_train_step(theta, state, a, c):
loss, g = jax.value_and_grad(quad_loss)(theta, a, c)
u, state = model_opt.update(g, state, theta)
return apply_updates(theta, u), state, loss
opt_train_step_jit = jax.jit(opt_train_step)
theta, state = theta0, model_opt.init(theta0)
with CompileBudget(1, "opt_train_step"):
for _ in range(100):
theta, state, loss = opt_train_step_jit(theta, state, coef_a, coef_c)
print(f"100 steps: compiled once (within budget), step counter = {int(state[2].count)}, loss = {float(loss):.4f}")
assert int(state[2].count) == 100
5.5 Comparing optimizers on the Rosenbrock function¶
⚪ Optional: an intuitive comparison of how each optimizer converges on an ill-conditioned problem
Finally, we run 3000 steps on the classic ill-conditioned Rosenbrock function $f(x,y)=(1-x)^2+100(y-x^2)^2$. Each optimizer is compiled into a single program with lax.scan, and we also plot the warmup-cosine schedule itself.
def rosenbrock(p):
return (1 - p["x"]) ** 2 + 100 * (p["y"] - p["x"] ** 2) ** 2
def run_opt(opt, p0, steps):
def body(carry, _):
p, s = carry
loss, g = jax.value_and_grad(rosenbrock)(p)
u, s = opt.update(g, s, p)
return (apply_updates(p, u), s), loss
(p, _), losses = lax.scan(body, (p0, opt.init(p0)), None, length=steps)
return p, losses
STEPS = 3000
p_start = {"x": jnp.array(-1.5), "y": jnp.array(2.0)}
cos_sched = warmup_cosine_decay_schedule(0.0, 0.05, 100, STEPS, 1e-3)
optimizers = {
"SGD (lr=5e-4)": sgd(5e-4),
"Momentum 0.9": sgd(5e-4, momentum=0.9),
"Nesterov 0.9": sgd(5e-4, momentum=0.9, nesterov=True),
"Adam (lr=0.05)": adam(0.05),
"Adam + warmup-cosine": chain(clip_by_global_norm(10.0), scale_by_adam(), scale_by_learning_rate(cos_sched)),
}
curves, finals = {}, {}
for name, opt in optimizers.items():
p_end, losses = jax.jit(partial(run_opt, opt, steps=STEPS))(p_start)
curves[name], finals[name] = np.asarray(losses), p_end
print(f"{wpad(name, 24)} final loss {float(losses[-1]):.2e}, end point ({float(p_end['x']):.4f}, {float(p_end['y']):.4f})")
fig, axes = plt.subplots(1, 2, figsize=(11, 3.8), gridspec_kw={"width_ratios": [2, 1]})
for (name, losses), c in zip(curves.items(), [C_BLUE, C_ORANGE, C_AQUA, C_VIOLET, C_RED]):
axes[0].plot(np.maximum(losses, 1e-12), color=c, lw=1.6, label=name)
axes[0].set_yscale("log"); axes[0].set_xlabel("step"); axes[0].set_ylabel("loss (log scale)")
axes[0].set_title("Convergence on Rosenbrock", fontsize=11); axes[0].legend(frameon=False, fontsize=9)
axes[0].grid(True, color="#e5e5e5", lw=0.8)
axes[1].plot(np.asarray(jax.vmap(cos_sched)(jnp.arange(STEPS))), color=C_RED, lw=2)
axes[1].set_xlabel("step"); axes[1].set_ylabel("learning rate"); axes[1].set_title("warmup(100) + cosine schedule", fontsize=11)
axes[1].grid(True, color="#e5e5e5", lw=0.8)
plt.tight_layout(); plt.show()
# Note: loss is clipped at 1e-12 for the log-scale plot; the momentum and Nesterov curves almost overlap
for name in ("Nesterov 0.9", "Adam + warmup-cosine"):
assert curves[name][-1] < 1e-3, name # both converge to near (1, 1)
assert curves["Nesterov 0.9"][-1] < curves["SGD (lr=5e-4)"][-1] # momentum-type methods beat plain SGD
💡 Practical rule¶
🟢 Must-read
In one sentence: write optimizers as
init/updatepure functions and compose them withchain; keep the state (including the step count) in a PyTree that travels through jit. Don't forget Adam's bias correction, make weight decay decoupled (AdamW), and cross-check every component step by step against hand-computed NumPy values.
Topic 6 donate_argnums and memory¶
6.1 Motivation¶
⚪ Optional: why explicit state passing adds an extra copy to peak memory
Explicit state passing has a hidden cost: when you run new_state = step(state, batch), the old state and the new state occupy memory at the same time. When training with Adam, the state is "parameters + two moments", i.e. 3 times the parameter count, and at the peak there's one more copy on top. In large-model training, that extra copy often decides between OOM and running fine.
jax.jit(f, donate_argnums=...) / donate_argnames=... tells XLA: "I won't need these inputs after the call, so their buffers can be reused for the outputs".
→ Basics in the Extra in Chapter 2, 2.6 (basic use of
donate_argnums,is_deleted) and the Extra in Chapter 6, 6.6 (donating params and opt_state, the pitfall of the same array appearing twice, the warning when donation can't be used).
6.2 How it works: input-output aliasing¶
⚪ Optional: when you want to verify at the StableHLO / memory_analysis level that donation is in effect
Donation boils down to declaring input-output aliasing at compile time: input buffer $i$ shares memory with some output $j$ of exactly the same shape and dtype. You can see it at three levels:
- In the lowered StableHLO, a donated parameter carries the attribute
tf.aliasing_output = j; - In
compiled.memory_analysis(),alias_size_in_bytesis nonzero and peak memory drops accordingly; - At runtime, the device address of the output array (
unsafe_buffer_pointer()) is the same as the input's, and the input array is marked as deleted.
def sgd_update(p, g):
return p - 0.1 * g
N_EL = 8_000_000 # a float32 array of 32 million bytes ≈ 30.5 MiB
donated = jax.jit(sgd_update, donate_argnums=0)
plain = jax.jit(sgd_update)
spec = (jax.ShapeDtypeStruct((N_EL,), jnp.float32),) * 2
hlo_line = [l for l in donated.lower(*spec).as_text().splitlines() if "func.func public @main" in l][0]
print("StableHLO signature:", hlo_line.strip()[:120], "...")
assert "tf.aliasing_output = 0" in hlo_line
assert "tf.aliasing_output" not in plain.lower(*spec).as_text()
mem = {}
for name, f in [("no donation", plain), ("donate p", donated)]:
m = f.lower(*spec).compile().memory_analysis()
mem[name] = m
print(f"{name}: arguments {m.argument_size_in_bytes / 2**20:5.1f} MiB | output {m.output_size_in_bytes / 2**20:5.1f} MiB | "
f"alias {m.alias_size_in_bytes / 2**20:5.1f} MiB | peak {m.peak_memory_in_bytes / 2**20:5.1f} MiB")
assert mem["donate p"].alias_size_in_bytes == N_EL * 4
assert mem["no donation"].peak_memory_in_bytes - mem["donate p"].peak_memory_in_bytes == N_EL * 4 # peak saves exactly one copy
6.3 Measured behavior on CPU¶
⚪ Optional: measured address reuse and timing of donation on CPU
On the CPU backend of JAX 0.12, donation really takes effect (older versions ignored donation on CPU and issued a warning): the output is written straight into the input's buffer, and the input array is deleted. Speed is basically the same. This update is a single pass of memory reads and writes anyway; what you save is memory, not time.
p = jnp.ones(N_EL)
g = jnp.ones(N_EL)
ptr_before = p.unsafe_buffer_pointer()
p_new = donated(p, g)
print("Donated: output address == input address:", p_new.unsafe_buffer_pointer() == ptr_before, "| input deleted:", p.is_deleted())
assert p_new.unsafe_buffer_pointer() == ptr_before and p.is_deleted()
q = jnp.ones(N_EL)
q_new = plain(q, g)
print("Not donated: output address == input address:", q_new.unsafe_buffer_pointer() == q.unsafe_buffer_pointer(), "| input deleted:", q.is_deleted())
assert not q.is_deleted()
def loop(f, x, n=20):
for _ in range(n):
x = f(x, g)
return x
t_don = bench(lambda: loop(donated, jnp.ones(N_EL)), runs=5)
t_pl = bench(lambda: loop(plain, jnp.ones(N_EL)), runs=5)
print(f"20 updates (including creating the initial value): donated {t_don:.1f} ms | not donated {t_pl:.1f} ms")
del p_new, q, q_new
6.4 When it helps, and when it doesn't¶
⚪ Optional: look this table up when deciding whether an argument is worth donating
| Situation | Does donation help? |
|---|---|
params and opt_state in a training step (outputs have the same shape as inputs) | ✅ The most typical use; peak memory drops by one copy of the state |
| Large arrays updated "in place", like a KV cache or a ring buffer | ✅ |
| The data batch, which changes every step | ❌ There's no output of the same shape to reuse, and it triggers a warning |
| Arrays you still need afterwards (e.g. for evaluation or a checkpoint) | ❌ Once donated, they're gone; copy them or fetch them to the host first |
| Very small arrays (scalars, counters) | Almost no benefit, but no harm either |
When there's no output that can reuse the buffer, JAX doesn't raise an error; it only gives UserWarning: Some donated buffers were not usable:
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
jax.jit(lambda x: x.sum(), donate_argnums=0)(jnp.ones(10)) # the output is a scalar, nothing to reuse
print("Warning:", str(caught[0].message).splitlines()[0])
assert any("donated buffers were not usable" in str(w.message) for w in caught)
6.5 ❌ Common mistakes, measured¶
🟢 Must-read
- Using the old array after donating it →
RuntimeError: Array has been deleted; - Donating the same buffer twice: the same array appears twice among the donated arguments (e.g. Adam's
muandnushare onezeros, orstep(state, state)) →Attempt to donate the same buffer twice; - The same buffer once donated and once not: e.g.
step(state, state.params), where the first argument is donated and the second references arrays inside it →Attempt to use a buffer that was previously donated; - Other references still point to the old state: e.g. storing each step's
paramsin a list for plotting; after donation, every array in the list is deleted.
The last three are all runtime errors that don't show up at compile time, and they only appear once donation is turned on. That's why "just add donate as an optimization" often breaks code that worked fine.
@partial(jax.jit, donate_argnums=(0,))
def upd_state(state, extra):
return jax.tree.map(lambda s: s + 1.0, state), extra * 2
# ❌ 1: using it after donation
s = {"mu": jnp.zeros(3), "nu": jnp.zeros(3)}
s2, _ = upd_state(s, jnp.ones(3))
show_error(lambda: s["mu"] + 1)
import contextlib, os
@contextlib.contextmanager
def quiet_cpp_stderr():
"""Besides raising a Python exception, XLA also dumps a large log to stderr from the C++ layer; here we temporarily point fd 2 at /dev/null."""
saved = os.dup(2)
devnull = os.open(os.devnull, os.O_WRONLY)
try:
os.dup2(devnull, 2)
yield
finally:
os.dup2(saved, 2); os.close(saved); os.close(devnull)
# ❌ 2: the same array appears twice in the donated pytree
shared = jnp.zeros(3)
bad_state = {"mu": shared, "nu": shared}
with quiet_cpp_stderr():
show_error(lambda: upd_state(bad_state, jnp.ones(3)))
# ❌ 3: the same array is donated once and not donated once
s3 = {"mu": jnp.zeros(3), "nu": jnp.zeros(3)}
with quiet_cpp_stderr():
show_error(lambda: upd_state(s3, s3["mu"]))
# ❌ 4: the history holds references to old states
history = []
s4 = {"mu": jnp.zeros(3), "nu": jnp.zeros(3)}
for _ in range(3):
history.append(s4) # stores a reference
s4, _ = upd_state(s4, jnp.ones(3))
print("❌ Are the arrays in the history deleted:", [h["mu"].is_deleted() for h in history])
assert all(h["mu"].is_deleted() for h in history)
# ✅ Fix: create each array independently; "copy" anything you want to keep to the host first
host_copy = lambda tree: jax.tree.map(lambda a: np.array(a, copy=True), tree) # a real copy
history_ok = []
s5 = {"mu": jnp.zeros(3), "nu": jnp.zeros(3)}
for _ in range(3):
history_ok.append(host_copy(s5))
s5, _ = upd_state(s5, jnp.ones(3))
print("✅ Host-side history:", [float(h["mu"][0]) for h in history_ok])
assert [float(h["mu"][0]) for h in history_ok] == [0.0, 1.0, 2.0]
⚠️ A subtle behavior on CPU (measured in 0.12): on the CPU backend, jax.device_get(x) and np.asarray(x) return a zero-copy view that shares memory with the device array. As long as that NumPy view is alive, JAX silently skips donating that buffer: no error, no warning, the input isn't deleted, and you just lose the memory savings. So:
- To keep a copy without affecting donation, use
np.array(x, copy=True)(thehost_copyabove); - Conversely, "the old array still works after donation" doesn't mean donation is fine; there may just be a view lingering somewhere. On GPU/TPU,
device_getreally copies to the host, so this doesn't happen.
f_don = jax.jit(lambda x: x + 1, donate_argnums=0)
view_results = {}
for how in ["keep nothing", "jax.device_get (view)", "np.asarray (view)", "np.array(copy=True)"]:
x = jnp.ones(3)
keep = {"jax.device_get (view)": jax.device_get, "np.asarray (view)": np.asarray,
"np.array(copy=True)": lambda a: np.array(a, copy=True)}.get(how, lambda a: None)(x)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
_ = f_don(x)
view_results[how] = x.is_deleted()
print(f"{wpad(how, 26)} input deleted after donation: {x.is_deleted()!s:5} warnings: {len(caught)}")
del keep
assert view_results == {"keep nothing": True, "jax.device_get (view)": False, "np.asarray (view)": False, "np.array(copy=True)": True}
6.6 ✅ A standard template for the training step¶
🟢 Must-read
Putting together the lessons of the previous topics, a "training step with donation" should:
- Pack the state into a single PyTree (a NamedTuple here; topic 7 switches to a dataclass) and donate it by name with
donate_argnames=("state",), which works whether you pass it positionally or by keyword; - Create every array in the state independently, with fixed dtypes (the step counter is an
int32array, not a Python int); - Not donate the data batch;
- Be called as
state, metrics = train_step(state, batch), immediately overwriting the old reference; - Assert with
CompileBudgetthat it compiles only once.
class TrainStateNT(NamedTuple):
step: jax.Array
params: Any
opt_state: Any
tpl_opt = adamw(1e-2, weight_decay=1e-4)
@partial(jax.jit, donate_argnames=("state",))
def train_step_tpl(state, batch):
X, Y = batch
loss, grads = jax.value_and_grad(loss_batch)(state.params, X, Y)
updates, opt_state = tpl_opt.update(grads, state.opt_state, state.params)
new_state = TrainStateNT(step=state.step + 1,
params=apply_updates(state.params, updates),
opt_state=opt_state)
return new_state, {"loss": loss, "grad_norm": global_norm(grads)}
params_t = init_mlp(jax.random.key(40), (D_IN, 64, N_CLS))
state = TrainStateNT(step=jnp.zeros([], jnp.int32), params=params_t, opt_state=tpl_opt.init(params_t))
first_leaf = jax.tree.leaves(state)[1]
batches = [(Xtr[i:i + 256], Ytr[i:i + 256]) for i in range(0, 2048, 256)] # 8 batches of the same shape
losses_tpl = []
with CompileBudget(1, "train_step_tpl"):
for epoch in range(3):
for batch in batches:
state, metrics = train_step_tpl(state, batch) # immediately overwrite the old reference
losses_tpl.append(float(metrics["loss"]))
print(f"24 steps: loss {losses_tpl[0]:.3f} → {losses_tpl[-1]:.3f}, step = {int(state.step)}, compiled once")
print("Arrays of the initial state were donated (deleted):", first_leaf.is_deleted(), "| data batch not deleted:", not batches[0][0].is_deleted())
assert first_leaf.is_deleted() and not batches[0][0].is_deleted()
assert int(state.step) == 24 and losses_tpl[-1] < losses_tpl[0]
💡 Practical rule¶
🟢 Must-read
In one sentence: donate the whole state in the training step with
donate_argnames=("state",), don't donate the data, and overwrite right away at the call site withstate = step(state, ...). Create each array in the state independently; copy anything you want to keep withnp.array(x, copy=True)before donating (on CPU,device_getis a zero-copy view and silently disables donation).
Topic 7 The new PyTree API and custom containers¶
7.1 Motivation¶
⚪ Optional: why old APIs raise errors, and why you need a structured TrainState
JAX code online uses old forms like jax.tree_map and jax.tree_leaves all over the place, and in 0.12 they raise AttributeError right away. On top of that, once a project grows, the "parameters are a nested dict, state is a tuple" style quickly becomes hard to maintain: typos in field names only show up at runtime, and mixing static config with arrays easily causes recompilation. This section walks systematically through the new PyTree API and gives the recommended way to write a TrainState.
→ Basics in Chapter 6 Extra in 6.4 (PyTree API migration table,
Noneisn't a leaf,map_with_path) and Extra in 6.4 (custom state containers:NamedTuple/register_dataclass/register_pytree_node_class).
7.2 Old → new migration table (measured in 0.12)¶
⚪ Optional: the full migration table is in Chapter 6; here we only test 3 additional old APIs
The full old → new migration table (each row measured) is in Chapter 6, Extra in 6.4, "PyTree API migration table", so we won't repeat it here. There's only one key point: the top-level aliases like jax.tree_map were removed in 0.12, while jax.tree_util.* still works. Below we only test 3 old APIs that aren't in that table, and list all functions of the jax.tree module.
# Only test old APIs not in Chapter 6's migration table (full table in Chapter 6, 6.4)
extra_old = [
# (old style, recommended new style, (module, attribute name))
("jax.tree_all", "jax.tree.all", (jax, "tree_all")),
("jax.tree_util.register_keypaths", "register_pytree_with_keys", (jax.tree_util, "register_keypaths")),
("jax.tree_util.build_tree", "jax.tree.unflatten", (jax.tree_util, "build_tree")),
]
print(wpad("Old style", 36) + wpad("In 0.12", 12) + "Recommended")
print("-" * 84)
for old, new, (mod, attr) in extra_old:
print(wpad(old, 36) + wpad("available" if hasattr(mod, attr) else "removed", 12) + new)
assert not any(hasattr(mod, attr) for _, _, (mod, attr) in extra_old)
assert not hasattr(jax, "tree_map") and hasattr(jax.tree_util, "tree_map") # the key point of the Chapter 6 table
# All functions provided by the jax.tree module (broadcast / reduce_associative / static etc. are newer and missing from old tutorials)
print("\njax.tree module:", [n for n in dir(jax.tree) if not n.startswith("_")])
try:
jax.tree_map(lambda x: x, [1])
except AttributeError as e:
print("Old style, measured:", f"AttributeError: {str(e)[:70]}")
7.3 A tour of the whole jax.tree.* family¶
⚪ Optional: look this up when you need less common functions like transpose / broadcast / reduce
Each function gets a minimal example, with asserts that spell out its semantics.
tree = {"layer2": {"w": jnp.ones((3, 2)), "b": jnp.zeros(2)},
"layer1": {"w": jnp.ones((4, 3)), "b": jnp.zeros(3)}}
# map: can act on several trees with the same structure at once
doubled = jax.tree.map(lambda a, b: a + b, tree, tree)
assert float(doubled["layer1"]["w"][0, 0]) == 2.0
# leaves / structure / flatten / unflatten: dicts are flattened in sorted key order
leaves, treedef = jax.tree.flatten(tree)
assert [l.shape for l in leaves] == [(3,), (4, 3), (2,), (3, 2)] # layer1 first, b before w
assert jax.tree.structure(tree) == treedef and jax.tree.leaves(tree)[0].shape == (3,)
rebuilt = jax.tree.unflatten(treedef, [l + 1 for l in leaves])
assert float(rebuilt["layer2"]["w"][0, 0]) == 2.0
# reduce / reduce_associative: reduce all leaves to a single value
n_params = jax.tree.reduce(lambda acc, l: acc + l.size, tree, 0)
n_params2 = jax.tree.reduce_associative(lambda a, b: a + b, jax.tree.map(lambda l: l.size, tree))
assert n_params == n_params2 == 3 * 2 + 2 + 4 * 3 + 3
# all: every leaf is truthy
assert jax.tree.all(jax.tree.map(lambda l: bool(jnp.all(jnp.isfinite(l))), tree))
# transpose: "dict of lists" ⇄ "list of dicts" (e.g. transposing metrics recorded step by step)
metrics_per_step = [{"loss": 1.0, "acc": 0.5}, {"loss": 0.8, "acc": 0.6}, {"loss": 0.5, "acc": 0.9}]
by_key = jax.tree.transpose(jax.tree.structure([0] * 3), jax.tree.structure({"loss": 0, "acc": 0}), metrics_per_step)
assert by_key == {"loss": [1.0, 0.8, 0.5], "acc": [0.5, 0.6, 0.9]}
# broadcast: broadcast a "prefix tree" to the full structure (e.g. a different learning-rate multiplier per layer)
lr_mult = jax.tree.broadcast({"layer1": 0.1, "layer2": 1.0}, tree)
assert lr_mult["layer1"] == {"b": 0.1, "w": 0.1} and lr_mult["layer2"]["w"] == 1.0
# map_with_path / leaves_with_path / flatten_with_path: include each leaf's path
names = [jax.tree_util.keystr(p, simple=True, separator=".") for p, _ in jax.tree.leaves_with_path(tree)]
assert names == ["layer1.b", "layer1.w", "layer2.b", "layer2.w"]
decay_mask = jax.tree.map_with_path(lambda p, l: p[-1].key == "w", tree) # decay only the weights, not the biases
assert decay_mask == {"layer1": {"b": False, "w": True}, "layer2": {"b": False, "w": True}}
(paths_leaves, _) = jax.tree.flatten_with_path(tree)
assert len(paths_leaves) == 4 and jax.tree_util.keystr(paths_leaves[0][0]) == "['layer1']['b']"
print("Default keystr format:", jax.tree_util.keystr(paths_leaves[3][0]), "| simple format:", names[3])
print("Weight-decay mask generated from paths:", decay_mask)
print("All jax.tree.* demos passed ✔")
The decay_mask above can be passed straight to add_decayed_weights(wd, mask=...) from topic 5 to "decay only the weights".
7.4 ❌ Common mistakes, measured¶
🟢 Must-read
- Treating a shape tuple as a leaf:
jax.tree.map(jnp.zeros, {"w": (2, 3)})descends into the tuple, treats2and3as separate leaves, and gives you two 1-D arrays. Useis_leafto tell it "a tuple is a leaf"; Noneisn't a leaf:Noneis an empty subtree, sotree.leavesskips it and the leaf count isn't what you expect;mapover two trees with different structures: raises an error right away;- An unregistered plain dataclass: it's treated as a single opaque leaf,
tree.leavesreturns just the object itself, and passing it into jit raisesTypeError; - Assuming dict leaves come in insertion order: they're actually flattened in sorted key order, so pairing names yourself with
zip(names, leaves)misaligns them; useleaves_with_pathinstead.
# ❌ 1: a shape tuple treated as a subtree
shapes = {"w": (2, 3), "b": (3,)}
wrong = jax.tree.map(jnp.zeros, shapes)
right = jax.tree.map(jnp.zeros, shapes, is_leaf=lambda x: isinstance(x, tuple))
print("❌ Without is_leaf:", jax.tree.map(lambda a: a.shape, wrong))
print("✅ With is_leaf :", jax.tree.map(lambda a: a.shape, right))
assert right["w"].shape == (2, 3) and isinstance(wrong["w"], tuple)
# ❌ 2: None isn't a leaf
assert len(jax.tree.leaves({"a": 1.0, "b": None})) == 1
# ❌ 3: mismatched structures
show_error(lambda: jax.tree.map(jnp.add, {"w": 1.0}, {"w": 1.0, "b": 2.0}))
# ❌ 4: an unregistered dataclass is an opaque leaf
@dataclass
class PlainState:
w: jax.Array
ps = PlainState(jnp.ones(2))
print("Leaves of an unregistered dataclass:", jax.tree.leaves(ps))
assert jax.tree.leaves(ps) == [ps]
show_error(lambda: jax.jit(lambda s: s.w * 2)(ps))
# ❌ 5: pairing names in insertion order
ordered = {"z_last": jnp.ones(1), "a_first": jnp.zeros(5)}
naive_pairs = list(zip(ordered.keys(), [l.shape for l in jax.tree.leaves(ordered)]))
path_pairs = [(jax.tree_util.keystr(p, simple=True), l.shape) for p, l in jax.tree.leaves_with_path(ordered)]
print("❌ zip(keys, leaves):", naive_pairs, "\n✅ leaves_with_path :", path_pairs)
assert naive_pairs[0] == ("z_last", (5,)) and path_pairs[0] == ("a_first", (5,))
7.5 TrainState: register_dataclass vs. NamedTuple¶
🟢 Must-read
For a TrainState, the recommended form is @jax.tree_util.register_dataclass + @dataclass(frozen=True):
- Fields have names and type annotations, so a misspelled field name fails right away;
frozen=Trueforbids in-place modification at the language level; update withdataclasses.replace(state, ...);- Declare static fields with
jax.tree.static(...)(equivalent tofield(metadata=dict(static=True))). A static field isn't a leaf but part of the treedef, which suits things that rarely change, like "model config". Just remember that any change to a static field's value causes recompilation.
NamedTuple works too (no setup, built-in _replace); the downsides are that fields can be indexed by position and you can't declare static fields.
@jax.tree_util.register_dataclass
@dataclass(frozen=True)
class TrainState:
step: jax.Array
params: Any
opt_state: Any
key: jax.Array
model_name: str = jax.tree.static(default="mlp") # static field: not a leaf
ts = TrainState(step=jnp.zeros([], jnp.int32), params={"w": jnp.ones(2)}, opt_state=(),
key=jax.random.key(0))
print("Number of leaves:", len(jax.tree.leaves(ts)), "| treedef:", jax.tree.structure(ts))
assert len(jax.tree.leaves(ts)) == 3 # step, w, key; model_name isn't among them
# Updating: dataclasses.replace creates a new object and leaves the original unchanged; frozen forbids in-place modification
ts2 = dataclasses.replace(ts, step=ts.step + 1)
assert int(ts.step) == 0 and int(ts2.step) == 1
show_error(lambda: setattr(ts, "step", 5))
# It passes straight through jit; but ❌ grad over the whole TrainState fails: step is int32 and key is a PRNG key, neither differentiable
def ts_loss(state, x):
return jnp.sum((state.params["w"] * x) ** 2)
x_ts = jnp.array([1.0, 2.0])
print("jit forward:", jax.jit(ts_loss)(ts, x_ts))
show_error(lambda: jax.grad(ts_loss)(ts, x_ts))
# ✅ Differentiate only with respect to params: make params the differentiated argument and carry the other fields along unchanged
g_params = jax.jit(jax.grad(lambda p, s, x: ts_loss(dataclasses.replace(s, params=p), x)))(ts.params, ts, x_ts)
print("✅ Gradient with respect to params:", g_params)
assert np.allclose(g_params["w"], [2.0, 8.0])
# A change in a static field → recompilation
def ts_step(state):
return dataclasses.replace(state, step=state.step + 1)
f_ts = jax.jit(ts_step)
n_static = count_compiles("ts_step", [lambda: f_ts(ts), lambda: f_ts(ts2),
lambda: f_ts(dataclasses.replace(ts, model_name="cnn"))])
print("Same model_name twice + a different model_name →", n_static, "compilations")
assert n_static == 2
# A NamedTuple version for comparison
class TrainStateNT2(NamedTuple):
step: jax.Array
params: Any
nt = TrainStateNT2(jnp.zeros([], jnp.int32), {"w": jnp.ones(2)})
assert int(nt._replace(step=nt.step + 1).step) == 1 and nt[0] is nt.step # can be indexed by position
register_dataclass + frozen | NamedTuple | Plain dict | |
|---|---|---|---|
| Field name checking | ✅ | ✅ | ❌ (a typo just creates a new key) |
| Forbids in-place modification | ✅ frozen=True | ✅ | ❌ |
| Static fields | ✅ jax.tree.static | ❌ | ❌ |
| How to update | dataclasses.replace | ._replace | {**d, k: v} |
| Extra dependencies | None | None | None |
7.6 Printing a parameter table with keystr + map_with_path¶
⚪ Optional: when you want to print a PyTorch-style parameter table
Finally, a practical helper: print a "name, shape, dtype, parameter count" table for any parameter tree. Names are generated with keystr(path, simple=True, separator="."), in the same style as PyTorch's named_parameters().
def param_table(params, title="Parameter table"):
# Step 1: map_with_path replaces each leaf with (name, shape, dtype, parameter count)
info = jax.tree.map_with_path(
lambda p, l: (jax.tree_util.keystr(p, simple=True, separator="."), tuple(l.shape), str(l.dtype), int(l.size)),
params)
# Step 2: pull those tuples out as leaves (otherwise tree.leaves would descend into the tuples)
rows = jax.tree.leaves(info, is_leaf=lambda x: isinstance(x, tuple))
total = sum(r[3] for r in rows)
width = max(len(r[0]) for r in rows) + 2
print(f"{title} ({len(rows)} tensors, {total:,} parameters)")
print(wpad("Name", width) + wpad("Shape", 16) + wpad("dtype", 10) + wpad("Params", 10) + "Share")
for name, shape, dtype, size in rows:
print(f"{name:<{width}}{str(shape):<16}{dtype:<10}{size:>8,} {size / total:6.1%}")
return rows, total
rows, total = param_table(params_ps, "Parameter table of the MLP from Section 4")
assert total == P == sum(l.size for l in jax.tree.leaves(params_ps))
assert [r[0] for r in rows] == ["dense_0.b", "dense_0.w", "dense_1.b", "dense_1.w"]
assert rows[1][1] == (D_IN, 64)
💡 Practical rule¶
🟢 Must-read
In one sentence: use only
jax.tree.*(the oldjax.tree_mapand friends were removed). Useregister_dataclass+frozen=Truefor TrainState; usejax.tree.staticfor static config and remember that any change to its value recompiles. Whenever you need "names", always get them through*_with_path+keystr; don't pair them up by order yourself.
★ Capstone project: an MLP classifier for spiral data, from scratch¶
Let's tie the 7 topics together: without Optax or Flax, using only tools written in this chapter, we build a complete training project. Here's which topic each part comes from:
| Component | Implementation | Topic |
|---|---|---|
| Data | Three-arm spiral generated with jax.random, passed into jit as arguments | 0 (environment issue) |
| TrainState | register_dataclass + frozen=True, with step / params / opt_state / key | 7 |
| Parameter table | map_with_path + keystr | 7 |
| Optimizer | chain(clip_by_global_norm, scale_by_adam, add_decayed_weights(mask), scale_by_learning_rate(warmup-cosine)) | 5 |
| Loss | Cross-entropy with the stable log_softmax_stable | 2 |
| Training | train_epoch under jit(donate_argnames=("state",)), looping over shuffled mini-batches with lax.scan inside | 6, 1 |
| Compile budget | CompileBudget(1, "train_epoch"): 150 epochs, only 1 compilation allowed | 1 |
| Hard examples | Per-example gradient norms (chunked with lax.map(batch_size=)), top 5 | 4 |
| Validation | Forward pass cross-checked against NumPy float64; accuracy > 95% | All |
★.1 Data: a three-arm spiral¶
⚪ Optional: the capstone project ties the 7 topics together; read it as a whole example after the individual topics
def make_spiral(key, n_per_class, n_classes=3, noise=0.15):
"""Classic spiral data: radius r∈[0,1], the angle of class j lies in [4j, 4j+4] plus noise."""
k_r, k_n = jax.random.split(key)
r = jax.random.uniform(k_r, (n_classes, n_per_class))
cls = jnp.arange(n_classes)[:, None]
theta = 4.0 * cls + 4.0 * r + noise * jax.random.normal(k_n, (n_classes, n_per_class))
X = jnp.stack([r * jnp.sin(theta), r * jnp.cos(theta)], axis=-1).reshape(-1, 2)
y = jnp.broadcast_to(cls, (n_classes, n_per_class)).reshape(-1)
return X.astype(jnp.float32), y.astype(jnp.int32)
k_data, k_split, k_init, k_train = jax.random.split(jax.random.key(2026), 4)
X_all_s, y_all_s = make_spiral(k_data, 600)
perm = jax.random.permutation(k_split, X_all_s.shape[0])
n_test = 360
X_test, y_test = X_all_s[perm[:n_test]], y_all_s[perm[:n_test]]
X_train, y_train = X_all_s[perm[n_test:]], y_all_s[perm[n_test:]]
print(f"Training set {X_train.shape}, test set {X_test.shape}, class counts (train): {np.bincount(np.asarray(y_train))}")
assert X_train.shape == (1440, 2) and X_train.dtype == jnp.float32 and y_train.dtype == jnp.int32
★.2 Model, TrainState, and optimizer¶
⚪ Optional: capstone project: assembling the model, TrainState, and optimizer
- The model reuses
init_mlp/mlp_apply(a ReLU MLP) from topic 4, with structure 2 → 64 → 64 → 3; - The TrainState reuses the
TrainStatedataclass defined in topic 7. Every array is created independently with a fixed dtype (stepis anint32array, the key is a typed key), so the state's type signature is exactly the same before and aftertrain_epoch; - Optimizer: first clip to global norm 1.0, then Adam, then decoupled decay on the weights (
w) only, and finally multiply by the warmup + cosine learning rate. The mask is generated by path withmap_with_path.
BATCH, EPOCHS = 128, 150
STEPS_PER_EPOCH = X_train.shape[0] // BATCH
TOTAL_STEPS = EPOCHS * STEPS_PER_EPOCH
lr_schedule = warmup_cosine_decay_schedule(init_value=0.0, peak_value=1e-2, warmup_steps=100,
decay_steps=TOTAL_STEPS, end_value=1e-4)
weight_mask = lambda params: jax.tree.map_with_path(lambda p, _: p[-1].key == "w", params)
optimizer = chain(clip_by_global_norm(1.0),
scale_by_adam(),
add_decayed_weights(1e-4, mask=weight_mask),
scale_by_learning_rate(lr_schedule))
params_init = init_mlp(k_init, (2, 64, 64, 3))
state = TrainState(step=jnp.zeros([], jnp.int32), params=params_init,
opt_state=optimizer.init(params_init), key=k_train, model_name="spiral-mlp")
_ = param_table(state.params, "Spiral MLP parameter table")
print("Weight-decay mask:", weight_mask(state.params))
print("TrainState leaf count:", len(jax.tree.leaves(state)), "| static field model_name =", state.model_name)
# The state must not contain shared array objects (otherwise donation raises "donate the same buffer twice", see topic 6)
ids = [id(l) for l in jax.tree.leaves(state)]
assert len(ids) == len(set(ids))
★.3 Loss and training function¶
⚪ Optional: capstone project: compiling a whole epoch into one program with lax.scan
ce_lossuseslog_softmax_stablefrom topic 2, cross-checked againstjax.nn.log_softmaxbefore its first use;train_epoch(state, X, Y): inside jit, shuffle the data with the key from the state, cut it into fixed-shape mini-batches, then run the training steps one after another withlax.scan. The whole epoch is one compiled program;XandYare passed in as arguments (not captured by a closure, and not donated); onlystateis donated.
def ce_loss(params, X, Y):
logp = log_softmax_stable(mlp_apply(params, X))
return -jnp.mean(jnp.take_along_axis(logp, Y[:, None], axis=1))
ref_ce = -jnp.mean(jnp.take_along_axis(jax.nn.log_softmax(mlp_apply(params_init, X_train)), y_train[:, None], axis=1))
assert np.isclose(ce_loss(params_init, X_train, y_train), ref_ce, rtol=1e-6)
def train_epoch(state, X, Y):
key, k_perm = jax.random.split(state.key)
idx = jax.random.permutation(k_perm, X.shape[0])[: STEPS_PER_EPOCH * BATCH].reshape(STEPS_PER_EPOCH, BATCH)
def one_step(st, batch_idx):
loss, grads = jax.value_and_grad(ce_loss)(st.params, X[batch_idx], Y[batch_idx])
updates, opt_state = optimizer.update(grads, st.opt_state, st.params)
st = dataclasses.replace(st, step=st.step + 1, params=apply_updates(st.params, updates), opt_state=opt_state)
return st, (loss, global_norm(grads))
state = dataclasses.replace(state, key=key)
state, (losses, gnorms) = lax.scan(one_step, state, idx)
return state, losses, gnorms
train_epoch_jit = jax.jit(train_epoch, donate_argnames=("state",))
@jax.jit
def accuracy_fn(params, X, Y):
return jnp.mean(jnp.argmax(mlp_apply(params, X), axis=-1) == Y)
print(f"Each epoch has {STEPS_PER_EPOCH} steps; {EPOCHS} epochs, {TOTAL_STEPS} steps in total")
★.4 Training: compile budget = 1¶
⚪ Optional: capstone project: training under a compile budget and verifying donation
The whole training loop runs inside CompileBudget(1, "train_epoch"). Whatever the reason (weak types, dtype changes, pytree structure changes...), a single extra compilation raises CompileBudgetExceeded right here.
init_leaf = jax.tree.leaves(state.params)[0]
step_losses, step_gnorms, test_acc_curve = [], [], []
t0 = time.perf_counter()
with CompileBudget(1, "train_epoch") as budget:
for epoch in range(EPOCHS):
state, losses, gnorms = train_epoch_jit(state, X_train, y_train) # immediately overwrite the old state
step_losses.append(losses); step_gnorms.append(gnorms)
if epoch % 10 == 9 or epoch == 0:
test_acc_curve.append((epoch + 1, float(accuracy_fn(state.params, X_test, y_test))))
jax.block_until_ready(state)
train_time = time.perf_counter() - t0
step_losses = np.concatenate([np.asarray(l) for l in step_losses])
step_gnorms = np.concatenate([np.asarray(g) for g in step_gnorms])
n_compiles = budget.count
train_acc = float(accuracy_fn(state.params, X_train, y_train))
test_acc = float(accuracy_fn(state.params, X_test, y_test))
print(f"Training {EPOCHS} epochs took {train_time:.2f} s; train_epoch compile count = {n_compiles}")
print(f"loss: {step_losses[0]:.4f} → {step_losses[-1]:.4f}; step = {int(state.step)}")
print(f"Training accuracy {train_acc:.4f}, test accuracy {test_acc:.4f}")
print("Buffers of the initial parameters were donated:", init_leaf.is_deleted(), "| training data not deleted:", not X_train.is_deleted())
assert n_compiles == 1
assert int(state.step) == TOTAL_STEPS
assert init_leaf.is_deleted() and not X_train.is_deleted()
assert train_acc > 0.95 and test_acc > 0.95
★.5 Cross-checking against NumPy float64¶
⚪ Optional: capstone project: an independent numerical check of the training results
Training ran in float32; now we compute the evaluation results again independently in NumPy float64: forward logits, cross-entropy, and accuracy must all match. This step is also insurance against "the CPU backend in this environment silently computing wrong results".
P64 = jax.tree.map(to64, jax.device_get(state.params))
def np_forward(p, X):
h = np.asarray(X, np.float64)
n = len(p)
for i in range(n):
h = h @ p[f"dense_{i}"]["w"] + p[f"dense_{i}"]["b"]
if i < n - 1:
h = np.maximum(h, 0.0)
return h
logits_np = np_forward(P64, X_test)
logits_jx = np.asarray(mlp_apply(state.params, X_test))
lsm = logits_np - logits_np.max(1, keepdims=True)
lsm = lsm - np.log(np.exp(lsm).sum(1, keepdims=True))
ce_np = -np.mean(lsm[np.arange(n_test), np.asarray(y_test)])
acc_np = np.mean(logits_np.argmax(1) == np.asarray(y_test))
print(f"Max logits error {np.abs(logits_np - logits_jx).max():.2e}; "
f"test cross-entropy JAX {float(ce_loss(state.params, X_test, y_test)):.6f} / NumPy {ce_np:.6f}; NumPy accuracy {acc_np:.4f}")
assert np.allclose(logits_np, logits_jx, atol=1e-4), np.abs(logits_np - logits_jx).max()
assert np.isclose(float(ce_loss(state.params, X_test, y_test)), ce_np, rtol=1e-3, atol=1e-6), ce_np
assert np.isclose(acc_np, test_acc, atol=1e-6), (acc_np, test_acc) # predicted classes are exactly the same
★.6 Per-example gradients: finding the top 5 "hard examples"¶
⚪ Optional: capstone project: training diagnostics with per-example gradient norms
Using per_sample_norm from topic 4 (the gradient norm of the single-example loss), we compute in chunks over the whole training set with lax.map(batch_size=256), then take the 5 examples with the largest norms using lax.top_k. A large gradient norm means that example "pulls" hardest on the parameters, and it's usually also where the model is least sure.
@jax.jit
def grad_norms_all(params, X, Y):
return lax.map(lambda xy: per_sample_norm(params, *xy), (X, Y), batch_size=256)
norms_train = grad_norms_all(state.params, X_train, y_train)
top_norms, top_idx = lax.top_k(norms_train, 5)
per_loss = jax.vmap(loss_single, in_axes=(None, 0, 0))(state.params, X_train, y_train)
probs_top = jax.nn.softmax(mlp_apply(state.params, X_train[top_idx]))
print(" Rank Example coords true pred p(true) grad norm loss")
for rank, i in enumerate(np.asarray(top_idx)):
x, yv = np.asarray(X_train[i]), int(y_train[i])
pr = np.asarray(probs_top[rank])
print(f" {rank + 1} #{i:<5} ({x[0]:+.3f}, {x[1]:+.3f}) {yv} {pr.argmax()} {pr[yv]:.3f} "
f"{float(norms_train[i]):7.3f} {float(per_loss[i]):6.3f}")
# Self-proof: chunked results match full vmap; the top 5 norms really are the largest; the mean loss of hard examples is far above the overall mean
full = jax.vmap(per_sample_norm, in_axes=(None, 0, 0))(state.params, X_train, y_train)
assert np.allclose(norms_train, full, rtol=1e-4, atol=1e-5), np.abs(norms_train - full).max()
assert float(top_norms.min()) >= float(jnp.sort(norms_train)[-6]), top_norms
assert float(per_loss[top_idx].mean()) > 10 * float(per_loss.mean()), (per_loss[top_idx].mean(), per_loss.mean())
print(f"\nMean loss of hard examples {float(per_loss[top_idx].mean()):.3f}, overall mean {float(per_loss.mean()):.4f}")
r_hard = np.linalg.norm(np.asarray(X_train[top_idx]), axis=1)
wrong_test = np.asarray(jnp.argmax(mlp_apply(state.params, X_test), -1) != y_test)
r_wrong = np.linalg.norm(np.asarray(X_test)[wrong_test], axis=1)
print(f"Distance of hard examples from the origin: {np.round(r_hard, 4)}; {wrong_test.sum()} misclassified test examples, distance from the origin: {np.round(r_wrong, 3)}")
assert r_hard.max() < 0.05 and (r_wrong < 0.05).all() # hard examples and misclassified examples are all near the origin
Reading the results: all 5 hard examples are crowded near the origin ($r\approx0$). That's exactly the inherent difficulty of spiral data: all three spiral arms start from the origin, and points of different classes almost overlap there. The model gives the true class a probability of only 0.35–0.55, and the loss is two orders of magnitude above the mean. Points far out that noise pushed onto another arm are actually rare. The only misclassified test example is also right next to the origin (distance about 0.001). In other words, the remaining error on this task comes from unavoidable ambiguity near the origin, not from a lack of model capacity.
★.7 Visualization: loss curve and decision boundary¶
⚪ Optional: capstone project: visualizing the results
grid_n = 300
gx, gy = np.meshgrid(np.linspace(-1.15, 1.15, grid_n), np.linspace(-1.15, 1.15, grid_n))
grid = jnp.asarray(np.stack([gx.ravel(), gy.ravel()], axis=1), jnp.float32)
predict = jax.jit(lambda params, X: jnp.argmax(mlp_apply(params, X), axis=-1)) # params passed in as an argument
zz = np.asarray(predict(state.params, grid)).reshape(grid_n, grid_n)
fig = plt.figure(figsize=(12, 4.6))
gs = fig.add_gridspec(2, 2, width_ratios=[1.35, 1], height_ratios=[2.2, 1], hspace=0.08, wspace=0.18)
ax_loss = fig.add_subplot(gs[0, 0])
ax_lr = fig.add_subplot(gs[1, 0], sharex=ax_loss)
ax_db = fig.add_subplot(gs[:, 1])
steps_axis = np.arange(1, len(step_losses) + 1)
ax_loss.plot(steps_axis, step_losses, color=C_BLUE, lw=0.8, alpha=0.35, label="per-step loss")
epoch_mean = step_losses.reshape(EPOCHS, STEPS_PER_EPOCH).mean(1)
ax_loss.plot(np.arange(1, EPOCHS + 1) * STEPS_PER_EPOCH, epoch_mean, color=C_BLUE, lw=2, label="per-epoch mean")
ax_loss.set_yscale("log"); ax_loss.set_ylabel("training loss (log)")
ax_loss.set_title(f"Training (test accuracy {test_acc:.1%}, train_epoch compiled {n_compiles} time(s))", fontsize=11)
ax_loss.legend(frameon=False, fontsize=9); ax_loss.grid(True, color="#e5e5e5", lw=0.8)
plt.setp(ax_loss.get_xticklabels(), visible=False)
ax_lr.plot(steps_axis, np.asarray(jax.vmap(lr_schedule)(jnp.arange(len(step_losses)))), color=C_RED, lw=1.8)
ax_lr.set_ylabel("learning rate"); ax_lr.set_xlabel("step"); ax_lr.grid(True, color="#e5e5e5", lw=0.8)
from matplotlib.colors import ListedColormap
class_colors = [C_BLUE, C_ORANGE, C_AQUA]
ax_db.contourf(gx, gy, zz, levels=[-0.5, 0.5, 1.5, 2.5], cmap=ListedColormap(class_colors), alpha=0.18)
ax_db.contour(gx, gy, zz, levels=[0.5, 1.5], colors="#666666", linewidths=0.8)
Xtr_np, ytr_np = np.asarray(X_train), np.asarray(y_train)
for c, col in enumerate(class_colors):
m = ytr_np == c
ax_db.scatter(Xtr_np[m, 0], Xtr_np[m, 1], s=9, color=col, edgecolors="white", linewidths=0.3, label=f"class {c}")
hard = Xtr_np[np.asarray(top_idx)]
ax_db.scatter(hard[:, 0], hard[:, 1], s=170, facecolors="none", edgecolors="#111111", linewidths=1.6, label="top-5 hard examples")
ax_db.set_aspect("equal"); ax_db.set_xlim(-1.15, 1.15); ax_db.set_ylim(-1.15, 1.15)
# The hard examples are crowded near the origin, so add a zoomed-in inset
R = 0.012
axins = ax_db.inset_axes([0.62, 0.02, 0.36, 0.36])
lg = np.linspace(-R, R, 200)
lx, ly = np.meshgrid(lg, lg)
lz = np.asarray(predict(state.params, jnp.asarray(np.stack([lx.ravel(), ly.ravel()], 1), jnp.float32))).reshape(200, 200)
axins.contourf(lx, ly, lz, levels=[-0.5, 0.5, 1.5, 2.5], cmap=ListedColormap(class_colors), alpha=0.18)
axins.contour(lx, ly, lz, levels=[0.5, 1.5], colors="#666666", linewidths=0.8)
near = (np.abs(Xtr_np) < R).all(1)
axins.scatter(Xtr_np[near, 0], Xtr_np[near, 1], s=14, c=[class_colors[c] for c in ytr_np[near]],
edgecolors="white", linewidths=0.3)
axins.scatter(hard[:, 0], hard[:, 1], s=120, facecolors="none", edgecolors="#111111", linewidths=1.4)
for r, (hx, hy) in enumerate(hard):
axins.annotate(str(r + 1), (hx, hy), xytext=(5, 5), textcoords="offset points", fontsize=8)
axins.set_xlim(-R, R); axins.set_ylim(-R, R); axins.set_xticks([]); axins.set_yticks([])
axins.set_title("zoom near the origin", fontsize=8)
ax_db.indicate_inset_zoom(axins, edgecolor="#666666")
ax_db.set_title("Decision boundary and hard examples (training set)", fontsize=11)
ax_db.legend(frameon=False, fontsize=8, loc="lower left")
plt.show()
print("Test accuracy over training:", ", ".join(f"ep{e}={a:.3f}" for e, a in test_acc_curve))
★.8 Project summary¶
⚪ Optional: the capstone project's checklist and follow-up experiments
| Check | Result |
|---|---|
train_epoch compile count | 1 (asserted with CompileBudget) |
| Donation in effect | Initial parameter buffers deleted, training data unaffected |
| Numerics | logits / cross-entropy / accuracy match NumPy float64 |
| Accuracy | > 95% on both training and test sets (asserted) |
| Hard examples | Chunked per-example gradients match full vmap; the top 5 are all near the origin where the three spiral arms meet |
Things you can try next:
- Increase
noiseand see how the hard examples change; - Change
BATCHto a value that doesn't divide evenly and see whether the compile budget complains (it doesn't:STEPS_PER_EPOCHis static, and dropping the remainder doesn't change any shape); - Make the learning rate a static argument (
static_argnames), pass it in the loop, and watch the budget complain. If you just pass a Python float each epoch instead, it won't complain, because a change in the value of a weakly typed scalar doesn't trigger recompilation.
¶
🏋️ Extra exercises (with reference solutions)¶
The 6 exercises below cover the most important topics of this chapter, in roughly increasing difficulty. Each one is structured as "problem → (when needed) code given with the problem → reference solution", and the reference solutions prove their results with assert. Try writing your own first, then compare with the solution.
| Exercise | Topic | Related topic |
|---|---|---|
| 1 | Write a "compile-budget test" for a serving function, then fix it until it passes | 1 |
| 2 | Turn a cosine similarity that produces NaN gradients into a safe version | 2 |
| 3 | Implement Newton-CG with HVP + conjugate gradient, without building the Hessian | 3 |
| 4 | Use per-example gradients to find examples whose labels were deliberately flipped | 4 |
| 5 | Implement the Lion optimizer with an Optax-style interface and cross-check against optax.lion | 5 |
| 6 | A training step with frozen layers + a donated state | 6, 7 |
Exercise 1: a compile-budget test for a serving function¶
⚪ Optional: exercise to reinforce topic 1
The serve below row-normalizes each request. It has two problems that cause recompilation: the requests have different numbers of rows, and eps is sometimes a Python float and sometimes a jnp.float32 array.
Requirements:
- Write a test function
test_serve_compiles(serve_fn, fun_name, budget): serve the 20 requests prepared below under topic 1'sCompileBudget, and return(within budget or not, compile count); - Use it to show that the original
servefails with a budget of 3; - Write
serve_fixed: pad the row count up to the buckets(32, 64, 128)(do the padding in NumPy), and makeepsconsistentlynp.float32, so the test passes; - The output must match the NumPy float64 reference (after removing the padded rows).
Self-check interface: the first two items of the tuple returned by test_serve_compiles(serve_fn, fun_name, budget) are (within budget or not, compile count); serve_fixed(batches, eps_list) has the same signature as serve and returns a list of NumPy arrays with the padding removed. The self-check calls jax.clear_caches() first, then counts compilations with new requests.
# Code given with the problem
def normalize_rows(x, eps):
return x / (jnp.sqrt(jnp.sum(x ** 2, axis=1, keepdims=True)) + eps)
normalize_jit = jax.jit(normalize_rows)
def serve(batches, eps_list):
return [np.asarray(normalize_jit(b, e)) for b, e in zip(batches, eps_list)]
ex1_lengths = [int(n) for n in jax.random.randint(jax.random.key(70), (20,), 5, 120)]
ex1_batches = [np.asarray(jax.random.normal(k, (n, 8))) for k, n in zip(jax.random.split(jax.random.key(71), 20), ex1_lengths)]
ex1_eps = [1e-6 if i % 2 else jnp.float32(1e-6) for i in range(20)] # Python floats and arrays alternate
print("Row counts of the 20 requests:", ex1_lengths)
# ✍️ Your answer: Exercise 1
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 1
try:
test_serve_compiles, serve_fixed
except NameError as e:
print(f"⏳ Exercise 1: not answered yet: {e}")
else:
def _selfcheck():
jax.clear_caches() # clear the compilation cache so counting starts from zero
r = test_serve_compiles(serve, "normalize_rows", 3)
assert not r[0] and r[1] > 3, f"The original serve should exceed the budget of 3; your test returned {r[:2]}"
jax.clear_caches()
r_big = test_serve_compiles(serve, "normalize_rows", 1000)
assert r_big[0] and r_big[1] == r[1], "With a large enough budget the test should pass, with the same compile count"
# New requests: row counts cover the edges of the three buckets, eps takes a new value, Python float and jnp.float32 alternate
lengths = [1, 31, 32, 33, 64, 65, 100, 128, 7, 50, 127, 2]
rng = np.random.default_rng(11)
batches = [rng.standard_normal((n, 8)).astype(np.float32) for n in lengths]
eps_list = [1e-3 if i % 2 else jnp.float32(1e-3) for i in range(len(lengths))]
jax.clear_caches()
with CompileCounter() as cc: # count compilations of all functions
outs = serve_fixed(batches, eps_list)
assert cc.count <= 3, f"serve_fixed compiled {cc.count} times for 12 new requests (should be ≤ 3): {cc.events}"
assert len(outs) == len(batches), "There should be one output per request"
for b, o in zip(batches, outs):
o = np.asarray(o)
assert o.shape == b.shape, f"Output shape {o.shape} should match the input {b.shape} (remove the padded rows)"
b64 = b.astype(np.float64)
ref = b64 / (np.sqrt((b64 ** 2).sum(1, keepdims=True)) + 1e-3)
assert np.allclose(o, ref, atol=1e-6), "The output of serve_fixed doesn't match the NumPy float64 reference"
_selfcheck(); del _selfcheck
print("✅ Exercise 1: self-check passed")
Exercise 2: a safe cosine similarity¶
⚪ Optional: exercise to reinforce the double where from topic 2
As soon as one input is a zero vector, $\cos(a,b)=\dfrac{a\cdot b}{\lVert a\rVert\,\lVert b\rVert}$ is $0/0$. A lot of code fills it with 0 using jnp.where, but the gradient is still NaN.
Requirements:
- Measure it first:
cos_sim_where(the single-whereversion) is 0 in the forward pass at $a=0$, but thejax.gradwith respect to $a$ contains NaN; - Write
cos_sim_safe(a, b)with a doublewhere: return 0 when either input is a zero vector, with finite gradients with respect to both $a$ and $b$; - At normal points, the gradient must equal the analytic form $\dfrac{\partial\cos}{\partial a} = \dfrac{b}{\lVert a\rVert\lVert b\rVert} - \dfrac{(a\cdot b)\,a}{\lVert a\rVert^3\lVert b\rVert}$ (NumPy float64) and pass
check_grads; - Take gradients with
vmap(grad)over a batch containing zero-vector rows; the result must have no NaN.
Self-check interface: cos_sim_safe(a, b), where a and b are 1-D arrays; returns a scalar.
# Code given with the problem
def cos_sim_where(a, b):
na, nb = jnp.linalg.norm(a), jnp.linalg.norm(b)
ok = (na > 0) & (nb > 0)
return jnp.where(ok, jnp.dot(a, b) / (na * nb), 0.0)
# ✍️ Your answer: Exercise 2
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 2
try:
cos_sim_safe
except NameError as e:
print(f"⏳ Exercise 2: not answered yet: {e}")
else:
def _selfcheck():
rng = np.random.default_rng(22)
a64, b64 = rng.standard_normal(6), rng.standard_normal(6)
a, b = jnp.asarray(a64, jnp.float32), jnp.asarray(b64, jnp.float32)
na, nb = np.linalg.norm(a64), np.linalg.norm(b64)
c = a64 @ b64 / (na * nb)
assert np.isclose(float(cos_sim_safe(a, b)), c, atol=1e-6), "Wrong cosine value at a normal point"
ga, gb = jax.grad(cos_sim_safe, argnums=(0, 1))(a, b)
assert np.allclose(ga, b64 / (na * nb) - c * a64 / na ** 2, atol=1e-5), "Gradient with respect to a doesn't match the analytic form"
assert np.allclose(gb, a64 / (na * nb) - c * b64 / nb ** 2, atol=1e-5), "Gradient with respect to b doesn't match the analytic form"
assert np.isclose(float(cos_sim_safe(1e-3 * a, a)), 1.0, atol=1e-5), "A very small but nonzero vector shouldn't be treated as a zero vector"
z = jnp.zeros(6)
for name, (u, v) in {"a=0": (z, b), "b=0": (a, z), "a=b=0": (z, z)}.items():
assert float(cos_sim_safe(u, v)) == 0.0, f"{name}: should return 0"
gu, gv = jax.grad(cos_sim_safe, argnums=(0, 1))(u, v)
assert bool(jnp.all(jnp.isfinite(gu)) & jnp.all(jnp.isfinite(gv))), f"{name}: gradient contains NaN/inf"
A = jnp.stack([a, z, a, z]); B = jnp.stack([b, b, z, z])
gbatch = jax.jit(jax.vmap(jax.grad(cos_sim_safe)))(A, B)
assert bool(jnp.all(jnp.isfinite(gbatch))), "A batch with zero-vector rows produced NaN under vmap(grad)"
_selfcheck(); del _selfcheck
print("✅ Exercise 2: self-check passed")
Exercise 3: Newton-CG: second-order optimization with only HVPs¶
⚪ Optional: exercise to reinforce the HVP from topic 3
Each Newton step solves $H d = -\nabla f$. When the parameter dimension $n$ is large, $H$ doesn't fit in memory, but conjugate gradient (CG) only needs "matrix × vector", which is exactly what topic 3's HVP provides.
Requirements (the objective is logistic_obj(x, A) from topic 3, with $A$ passed in as an argument):
- Write
cg_solve(matvec, b, iters): iterate a fixed number of times withlax.fori_loop. Note that once the residual becomes 0, $\alpha$ and $\beta$ hit $0/0$, so guard against it using the ideas from topic 2; - Write a jit-compiled
newton_cg_step(x, A): usegradfor the gradient andhvp_fwd_revfor the matrix-vector product; - The first step's direction $d$ must match NumPy float64's
np.linalg.solve(H, -g)(relative error < 1e-3); - Iterate 6 steps, bring the gradient norm below 1e-3, and observe Newton's quadratic convergence.
Self-check interface: cg_solve(matvec, b, iters) returns an approximate solution of $Hx=b$; newton_cg_step(x, A): jit-compiled, returns (new x, gradient g, direction d).
# ✍️ Your answer: Exercise 3
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 3
try:
cg_solve, newton_cg_step
except NameError as e:
print(f"⏳ Exercise 3: not answered yet: {e}")
else:
def _selfcheck():
rng = np.random.default_rng(33)
Q, _ = np.linalg.qr(rng.standard_normal((8, 8)))
M64 = Q @ np.diag(np.linspace(1.0, 10.0, 8)) @ Q.T # symmetric positive definite, condition number 10
b64 = rng.standard_normal(8)
M, bj = jnp.asarray(M64, jnp.float32), jnp.asarray(b64, jnp.float32)
# The matrix is passed as a jit argument (not a closure constant, to avoid this environment's CPU bug)
x = jax.jit(lambda M, b: cg_solve(lambda v: M @ v, b, 8))(M, bj)
assert np.allclose(x, np.linalg.solve(M64, b64), rtol=1e-3, atol=1e-4), "cg_solve should reach the exact solution after n iterations"
x_more = jax.jit(lambda M, b: cg_solve(lambda v: M @ v, b, 40))(M, bj)
assert bool(jnp.all(jnp.isfinite(x_more))), "NaN appeared with more iterations than dimensions: guard against 0/0 once the residual is 0"
x2 = jax.jit(lambda b: cg_solve(lambda v: 2.0 * v, b, 5))(bj) # converges in step 1, after which the residual is exactly 0
assert bool(jnp.all(jnp.isfinite(x2))) and np.allclose(x2, b64 / 2, atol=1e-5), \
"With H = 2I it converges in step 1; later iterations shouldn't produce NaN"
x0 = jax.jit(lambda M, b: cg_solve(lambda v: M @ v, b, 5))(M, jnp.zeros(8))
assert bool(jnp.all(x0 == 0)), "With b = 0 the solution should be 0, with no NaN"
# Newton-CG: cross-check against the exact NumPy float64 Newton direction on a new problem
assert hasattr(newton_cg_step, "lower"), "newton_cg_step should be a jit-compiled function"
A64 = rng.standard_normal((120, 16)) / 4.0
x064 = rng.standard_normal(16)
A, xj = jnp.asarray(A64, jnp.float32), jnp.asarray(x064, jnp.float32)
x1, g, d = newton_cg_step(xj, A)
s = 1 / (1 + np.exp(-(A64 @ x064)))
g64 = A64.T @ s + LAM * x064
H64 = A64.T @ ((s * (1 - s))[:, None] * A64) + LAM * np.eye(16)
d_ref = np.linalg.solve(H64, -g64)
assert np.allclose(g, g64, atol=1e-4), "The returned gradient g is wrong"
rel = np.linalg.norm(np.asarray(d, np.float64) - d_ref) / np.linalg.norm(d_ref)
assert rel < 1e-3, f"Relative error of the Newton direction vs. np.linalg.solve(H, -g) is {rel:.2e} ≥ 1e-3"
assert np.allclose(x1, xj + d, atol=1e-6), "The new x should equal x + d"
xi = xj
for _ in range(6):
xi, _, _ = newton_cg_step(xi, A)
gn = float(jnp.linalg.norm(jax.grad(logistic_obj)(xi, A)))
assert gn < 1e-3, f"After 6 Newton-CG steps the gradient norm {gn:.2e} is still ≥ 1e-3"
_selfcheck(); del _selfcheck
print("✅ Exercise 3: self-check passed")
Exercise 4: finding mislabeled examples with per-example gradients¶
⚪ Optional: exercise to reinforce topic 4
Real datasets always contain some mislabeled examples. They "fight against" the other examples of their class, so after some training their per-example loss and gradient norm are noticeably larger.
Requirements:
- Use
make_blobsfrom topic 4 to generate 2000 examples (20 dimensions, 10 classes), and randomly pick 20 examples whose labels you change to a different class; - Train the MLP from topic 4 on the noisy labels with
adamfrom topic 5 (full batch, 150 steps, withlax.scan); - Compute each example's gradient norm in chunks with
lax.map(..., batch_size=500)and take the top 40; - Assert: at least 90% of the 20 flipped examples appear in the top 40 (recall ≥ 0.9).
Self-check interface: Xn (2000×20 examples), Yn_clean (original labels), Yn (labels after flipping), flip_idx (indices of flipped examples), p_noisy (parameters after training on Yn), norms_n (each example's gradient norm, shape (2000,)).
# ✍️ Your answer: Exercise 4
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 4
try:
Xn, Yn_clean, Yn, flip_idx, p_noisy, norms_n
except NameError as e:
print(f"⏳ Exercise 4: not answered yet: {e}")
else:
def _selfcheck():
assert Xn.shape == (2000, 20) and Yn.shape == Yn_clean.shape == (2000,), "Wrong shape for the examples or labels"
changed = set(np.flatnonzero(np.asarray(Yn) != np.asarray(Yn_clean)).tolist())
flips = set(np.asarray(flip_idx).tolist())
assert len(flips) == 20, f"Should flip 20 distinct examples, but flip_idx contains {len(flips)} distinct ones"
assert changed == flips, "The positions where labels changed should be exactly flip_idx (every flipped example must get a different class)"
assert int(jnp.min(Yn)) >= 0 and int(jnp.max(Yn)) < N_CLS, "Flipped labels are out of the class range"
nn = np.asarray(norms_n)
assert nn.shape == (2000,) and np.all(np.isfinite(nn)) and np.all(nn >= 0), "norms_n should be 2000 finite non-negative numbers"
for i in sorted(flips)[:3] + [0, 1, 1999]: # spot check: matches the per-example gradient norm
ref = float(per_sample_norm(p_noisy, Xn[i], Yn[i]))
assert np.isclose(nn[i], ref, rtol=1e-4, atol=1e-6), f"norms_n[{i}] = {nn[i]:.5f}, by definition it should be {ref:.5f}"
acc = float(accuracy(p_noisy, Xn, Yn_clean))
assert acc > 0.8, f"Accuracy of p_noisy on the true labels is only {acc:.3f}; training may not have converged"
top40 = set(np.argsort(-nn)[:40].tolist())
recall = len(top40 & flips) / 20
assert recall >= 0.9, f"The top 40 by gradient norm only recover {recall:.0%} of the flipped examples (need ≥ 90%)"
_selfcheck(); del _selfcheck
print("✅ Exercise 4: self-check passed")
Exercise 5: implement the Lion optimizer and cross-check against optax.lion¶
⚪ Optional: exercise to reinforce topic 5
Lion (Chen et al., 2023) keeps only one momentum $m$, and its update direction is a sign:
$$ u_t = \mathrm{sign}\big((1-\beta_1)\,g_t + \beta_1\, m_{t-1}\big),\qquad m_t = \beta_2\, m_{t-1} + (1-\beta_2)\, g_t $$
Then, as in AdamW, it applies decoupled weight decay and multiplies by $-\eta$.
Requirements:
- Following the interface from topic 5, write
scale_by_lion(b1=0.9, b2=0.99)(state containscountandmu), then assemblelion(lr, b1, b2, weight_decay, mask)withchain; - Use
run_jaxfrom topic 5 to run 5 steps on the same quadratic andassert allcloseagainstoptax.lionat every step; test once with a constant learning rate and once with a warmup-cosine schedule; - Verify a characteristic of Lion: without weight decay, the absolute change of every coordinate in the first step is exactly $\eta$.
Self-check interface: scale_by_lion(b1=0.9, b2=0.99) returns a GradientTransformation whose state has two fields, count and mu; lion(lr, b1=0.9, b2=0.99, weight_decay=1e-3, mask=None), where lr can be a constant or a schedule function.
# ✍️ Your answer: Exercise 5
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 5
try:
scale_by_lion, lion
except NameError as e:
print(f"⏳ Exercise 5: not answered yet: {e}")
else:
def _selfcheck():
import optax
rng = np.random.default_rng(55)
th0 = {"w": jnp.asarray(rng.standard_normal((4, 3)), jnp.float32), "b": jnp.asarray(rng.standard_normal(3), jnp.float32)}
ca = {"w": jnp.asarray(1 + 3 * rng.random((4, 3)), jnp.float32), "b": jnp.asarray(1 + 3 * rng.random(3), jnp.float32)}
cc = {"w": jnp.asarray(rng.standard_normal((4, 3)), jnp.float32), "b": jnp.asarray(rng.standard_normal(3), jnp.float32)}
grad_of = lambda th: jax.tree.map(lambda t, a, c: a * t + c, th, ca, cc) # gradient of the quadratic
def run(opt, steps=6):
th, st, traj = th0, opt.init(th0), []
for _ in range(steps):
u, st = opt.update(grad_of(th), st, th)
th = jax.tree.map(lambda p, v: p + v, th, u)
traj.append(th)
return traj
sched = lambda count: 0.02 * (count + 1) / 6.0
mask = {"w": True, "b": False}
cases = [("constant lr, b1=0.8, b2=0.95, wd=0.05", lion(0.02, b1=0.8, b2=0.95, weight_decay=0.05),
optax.lion(0.02, b1=0.8, b2=0.95, weight_decay=0.05)),
("schedule-function lr", lion(sched, weight_decay=0.01), optax.lion(sched, weight_decay=0.01)),
("weight_decay with mask", lion(0.02, weight_decay=0.1, mask=mask), optax.lion(0.02, weight_decay=0.1, mask=mask))]
for name, mine, ref in cases:
for t, (x, y) in enumerate(zip(run(mine), run(ref)), 1):
for k in x:
assert np.allclose(x[k], y[k], atol=1e-6), f"{name}: at step {t}, parameter {k} doesn't match optax.lion"
# Step-by-step cross-check against optax.scale_by_lion: the step-2 gradient sits near the sign boundary, which tells "direction first" from "momentum first"
t_mine, t_ref = scale_by_lion(), optax.scale_by_lion()
s_mine, s_ref = t_mine.init({"w": jnp.zeros(4)}), t_ref.init({"w": jnp.zeros(4)})
for t, g in enumerate(([1.0, 1.0, 1.0, 1.0], [-0.085, -0.095, -0.08, 0.5], [0.3, -0.2, 0.0, -1.0]), 1):
g = {"w": jnp.array(g)}
u_mine, s_mine = t_mine.update(g, s_mine)
u_ref, s_ref = t_ref.update(g, s_ref)
assert np.array_equal(u_mine["w"], u_ref["w"]), \
f"scale_by_lion at step {t}: direction {u_mine['w']} differs from optax's {u_ref['w']} (watch the order of updating the direction and the momentum)"
st = scale_by_lion().init(th0)
assert hasattr(st, "count") and hasattr(st, "mu"), "The state of scale_by_lion should have two fields, count and mu"
_, st1 = scale_by_lion().update(grad_of(th0), st, th0)
assert int(st1.count) == 1, "count should increase by 1 after each update"
u, _ = lion(0.03, weight_decay=0.0).update(grad_of(th0), lion(0.03, weight_decay=0.0).init(th0), th0)
assert all(np.allclose(np.abs(l), 0.03) for l in jax.tree.leaves(u)), "Without weight decay, the absolute change of every coordinate in the first step should equal lr"
_selfcheck(); del _selfcheck
print("✅ Exercise 5: self-check passed")
Exercise 6: a training step with frozen layers + a donated state¶
⚪ Optional: exercise to reinforce topics 6 and 7
When fine-tuning, you often freeze some layers and train only the rest.
Requirements:
- Write a gradient transformation
freeze(mask): for leaves where the mask is true, set the update to 0. Generate the mask by path withmap_with_path: freeze all parameters underdense_0; - Using
TrainStatefrom topic 7, write a training step withdonate_argnames=("state",)and the optimizerchain(adamw(...), freeze(mask)), train 20 steps onXtr, Ytrfrom topic 4, and assert withCompileBudgetthat it compiles only once; - Before training, save a host copy of the parameters with
np.array(x, copy=True)(why notjax.device_get? see topic 6.5); after training, assert thatdense_0is bit-identical to it and thatdense_1has changed; - Demonstrate a pitfall: if before training you only kept a reference to the device array
state.params["dense_0"]["w"], its buffer has been donated and can no longer be used, even though the layer is frozen and its values haven't changed.
Self-check interface: freeze(mask) returns a GradientTransformation (mask is a boolean tree with the same structure as the parameters, or a function that generates such a tree from the parameters); frozen_mask (the mask that freezes dense_0); ex6_opt (chain(adamw(...), freeze(...))); finetune_step(state, X, Y): donates state and returns (new state, loss).
# ✍️ Your answer: Exercise 6
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 6
try:
freeze, frozen_mask, ex6_opt, finetune_step
except NameError as e:
print(f"⏳ Exercise 6: not answered yet: {e}")
else:
def _selfcheck():
upd = {"a": jnp.ones(3), "b": {"c": 2 * jnp.ones(2), "d": -jnp.ones(4)}}
m_tree = {"a": True, "b": {"c": False, "d": True}}
try:
tr = freeze(m_tree); out, _ = tr.update(upd, tr.init(upd), upd)
except Exception: # implementations where "mask is a function" are accepted too
tr = freeze(lambda p: m_tree); out, _ = tr.update(upd, tr.init(upd), upd)
assert np.all(np.asarray(out["a"]) == 0) and np.all(np.asarray(out["b"]["d"]) == 0), "Leaves where the mask is true should have an update of 0"
assert np.array_equal(out["b"]["c"], upd["b"]["c"]), "Leaves where the mask is false should keep their update unchanged"
p = init_mlp(jax.random.key(2024), (D_IN, 64, N_CLS))
m = frozen_mask(p) if callable(frozen_mask) else frozen_mask
assert all(jax.tree.leaves(m["dense_0"])) and not any(jax.tree.leaves(m["dense_1"])), \
"frozen_mask should freeze all parameters under dense_0, and only those"
st = TrainState(step=jnp.zeros([], jnp.int32), params=p, opt_state=ex6_opt.init(p), key=jax.random.key(1))
host = jax.tree.map(lambda a: np.array(a, copy=True), st.params)
ref = st.params["dense_1"]["w"]
with CompileBudget(1, "finetune_step"):
for _ in range(3):
st, loss = finetune_step(st, Xtr[:512], Ytr[:512])
assert int(st.step) == 3 and np.isfinite(float(loss)), "After 3 steps, step should be 3 and loss should be finite"
after = jax.tree.map(lambda a: np.array(a, copy=True), st.params)
assert all(np.array_equal(after["dense_0"][k], host["dense_0"][k]) for k in ("w", "b")), \
"dense_0 should stay bit-identical (weight decay must be blocked too)"
assert not np.allclose(after["dense_1"]["w"], host["dense_1"]["w"]), "dense_1 should be training"
assert ref.is_deleted(), "finetune_step should donate state: the parameter buffers from before training should be deleted"
_selfcheck(); del _selfcheck
print("✅ Exercise 6: self-check passed")
¶
📝 Chapter 7 quiz¶
⚪ Optional: for self-testing whether you've mastered the failure modes of each topic
About: the 8 questions below all revolve around this chapter, with a focus on common failure modes and design trade-offs. The answers are folded at the end, and the code cell below verifies several of them.
1. Which of the following recompiles on every call?
- A. A module-level
f_jit = jax.jit(f), then callingf_jit(x)in a loop - B. Writing
return jax.jit(self.forward)(x)inside a class method - C. A config class defined with
@dataclass(frozen=True), passed viastatic_argnums, with a new instance of identical content created each time - D. The same module-level function
f, withjax.jit(f)(x)written in two different places
2. What is the result of jax.grad(lambda x: jnp.where(x > 0, jnp.log(x), 0.0))(0.0)?
- A.
0.0· B.inf· C.nan· D. raisesFloatingPointError
3. In JAX 0.12.0.1 with jax_debug_nans turned on, which of the following always raises FloatingPointError?
- A. A NaN produced inside a jit function but dropped by
jnp.where, never appearing in the output - B. A jit function produces
inf - C. The first execution of
jnp.log(jnp.array(-1.0))in eager code without jit - D. After
vmaphas been called in the process, calling a jit function that already ran again and producing a NaN
4. For $f:\mathbb R^3\to\mathbb R^{5000}$, what's best for computing the full Jacobian?
- A.
jax.jacrev(f)· B.jax.jacfwd(f)· C.jax.hessian(f)· D.jax.gradseparately for each of the 5000 outputs
5. For a model with 10 million parameters and batch = 512, how big is the tensor of all per-example gradients computed at once with vmap(grad) (float32)?
- A. 40 MB · B. 2 GB · C. 20 GB · D. 200 GB
6. Which statement about AdamW and "L2 regularization + Adam" is correct?
- A. They're exactly equivalent, just written differently
- B. AdamW adds $\lambda\theta$ to the gradient and then feeds it into Adam
- C. In "L2 + Adam" the decay term gets normalized by $\sqrt{\hat v}$, distorting how the decay relates to both the parameter size and λ; AdamW is a decoupled proportional shrink
- D. AdamW doesn't need bias correction
7. With step = jax.jit(update, donate_argnums=0), what happens when you call step(state, state["params"])?
- A. It runs normally; the second argument is copied automatically
- B. A runtime error: the same buffer is both donated and used in one call
- C. A compile-time error
- D. Only a "Some donated buffers were not usable" warning
8. What does jax.tree.map(jnp.zeros, {"w": (2, 3)}) return?
- A.
{"w": an all-zeros array of shape (2, 3)} - B.
{"w": (an all-zeros array of shape (2,), an all-zeros array of shape (3,))} - C. Raises
TypeError - D.
{"w": (0, 0)}
Click to expand: Answers and explanations
- B. Every access to
self.forwardcreates a new bound-method object, and the jit cache is keyed by function object, so it recompiles every time (in topic 1, 3 calls compiled 3 times). C's frozen dataclass compares by value and compiles only once; D uses the same function object and shares the cache. - C. Both branches of
whereget evaluated. The local derivative of the unselectedlogbranch at 0 isinf, multiplied by the 0 coming from upstream gives0 × inf = NaN. The fix is a doublewhere(topic 2). - C. In eager mode every primitive is checked the first time it runs. A: jit only checks outputs; B: inf needs
jax_debug_infs; D: this is the missed-detection issue in 0.12.0.1, and checking only comes back afterjax.clear_caches()(topic 2.4). - B. The input has only 3 dimensions, so
jacfwdneeds just 3 JVPs;jacrevneeds 5000 VJPs (the timing plot in topic 3). - C. $512 \times 10^7 \times 4$ bytes $\approx 2.0\times10^{10}$ bytes, about 20 GB. That's why large models need chunking with reduction inside each chunk (topic 4.4).
- C. Topic 5.4 showed it: with zero gradient, "L2 + Adam" subtracts about lr every step regardless of the size of w; AdamW shrinks proportionally by $(1-\eta\lambda)$.
- B. The error message is
Attempt to use a buffer that was previously donated in the same call to Execute(). This kind of error only shows up at runtime (topic 6.5). - B. A tuple is a pytree container, so
(2, 3)is split into two leaves2and3, giving arrays of shapes(2,)and(3,); passis_leaf=lambda x: isinstance(x, tuple)(topic 7.4).
# Verifying the quiz by measurement (questions 1, 2, 5, 8)
class _QuizModel:
def q1_forward(self, x): return x + 1
def predict(self, x): return jax.jit(self.q1_forward)(x)
_qm = _QuizModel()
assert count_compiles("q1_forward", [lambda: _qm.predict(x3)] * 3) == 3 # Question 1: B compiles every time
assert bool(jnp.isnan(jax.grad(lambda x: jnp.where(x > 0, jnp.log(x), 0.0))(0.0))) # Question 2: C
assert abs(512 * 10_000_000 * 4 / 1e9 - 20.48) < 1e-9 # Question 5: about 20 GB
q8 = jax.tree.map(jnp.zeros, {"w": (2, 3)})
assert isinstance(q8["w"], tuple) and [a.shape for a in q8["w"]] == [(2,), (3,)] # Question 8: B
print("Answers to questions 1, 2, 5, 8 verified by measurement ✔ (for questions 3, 4, 6, 7 see the measurements in the corresponding topics)")
✔ Practical rules checklist¶
🟢 Must-read
| Topic | Rules |
|---|---|
| 1 Recompilation | ① Treat "compile count" as a metric that deserves assertions: wrap key functions in CompileBudget / @compile_budget and put them into tests.② To find the cause, turn on jax_explain_cache_misses first; the three most common culprits are variable-length shapes, weakly typed scalars, and newly created jit objects (lambdas / closures in loops / jax.jit(self.method)).③ For variable-length inputs, use bucketed padding + mask (mask both numerator and denominator); static arguments must be hashable, compared by value, and take few distinct values, and numbers that change (the learning rate) are always passed in dynamically. |
| 2 Numerical stability | ① Whenever a where / mask hides a log, sqrt, or division, use a double where; the rule in a custom_jvp must be safe too.② Always use log_softmax (max-shift + stop_gradient) for classification losses, not log(softmax); mask with finfo.min instead of -inf.③ jax_debug_nans only checks jit outputs, doesn't check for inf, and in 0.12 misses NaNs after vmap has been used: during training, detect problems with isfinite/checkify, then turn on the switch + jax.clear_caches() and rerun that step to locate it. |
| 3 Choosing a derivative API | ① Full Jacobian: few inputs ($n<m$) → jacfwd, few outputs → jacrev; for a scalar loss it's always grad.② When you only need $Jv$ / $u^\top J$ / $Hv$, don't build the matrix: use jvp, vjp, linearize; prefer jvp(grad(f)) for HVPs. |
| 4 Per-example gradients | ① Write a "single-example loss" + vmap(grad, in_axes=(None, 0, 0)), and cross-check against a loop implementation; forgetting in_axes can silently go wrong.② Memory is $B\times P$: for large models, chunk with lax.map(batch_size=k) or scan, and reduce to norms / clipped sums directly inside each chunk.③ DP-SGD: clip each example by global norm → sum → add $\mathcal N(0,\sigma^2C^2)$ → divide by B; split the noise key every step, independently for each leaf. |
| 5 Hand-written optimizers | ① Write them as init/update pure functions + chain composition; the state (including the int32 step count) is a PyTree, and the learning-rate schedule is computed inside jit.② Adam must do bias correction; weight decay must be decoupled (AdamW), with a mask that excludes biases. ③ Cross-check every component step by step against hand-computed NumPy float64 values. |
| 6 donate | ① Use donate_argnames=("state",) for the training step, donate only the state and not the data, and immediately write state = step(state, ...) at the call site.② Create each array in the state independently; don't call step(state, state.params); copy anything you want to keep with np.array(x, copy=True) first (on CPU, device_get / np.asarray are zero-copy views, and donation is silently skipped while a view is alive).③ Donation saves peak memory (verifiable with memory_analysis().peak_memory_in_bytes); speed on CPU is almost unchanged. |
| 7 PyTree | ① Use only jax.tree.*; top-level aliases like jax.tree_map were removed in 0.12.② Use register_dataclass + frozen=True for TrainState and jax.tree.static for static config (any change in its value recompiles); differentiate only with respect to params.③ Get names with *_with_path + keystr; shape tuples need is_leaf. |
| Environment | jaxlib 0.12.0.1 CPU in this environment: always pass weights and data into jit as arguments, never capture them in a closure; cross-check key numbers against NumPy float64. |
¶
🗂️ Cheat sheet¶
⚪ Optional: API reference table for lookup
Topic 1 Recompilation
| API | Purpose |
|---|---|
jax.config.update("jax_log_compiles", True) | Logs Compiling jit(f) with ... for every real compilation (WARNING level) |
jax.config.update("jax_explain_cache_misses", True) | Explains cache misses: which argument's type / structure changed |
jax.monitoring.register_event_duration_secs_listener(cb) | Listens to events like /jax/core/compile/backend_compile_duration, with fun_name; the basis of this chapter's CompileCounter |
jax.monitoring.unregister_event_duration_listener(cb) | Unregisters a listener (pass the same function object) |
jax.jit(f).trace(*a) → .lower() → .compile() | The three stages of jax.stages: Traced / Lowered / Compiled |
jax.ShapeDtypeStruct(shape, dtype) | Describes only shape and dtype, for lower without real data (AOT compilation) |
compiled.cost_analysis() / .memory_analysis() | FLOPs estimate / argument, output, temporary, alias, and peak memory of the compiled program |
jax.eval_shape(f, *a) | Traces without compiling, giving the output's shape and dtype |
jax.tree_util.register_static(cls) | Registers a hashable class as a static pytree, treated as static without static_argnames |
@dataclass(frozen=True) | Generates value-based __eq__/__hash__ automatically, good for static config |
CompileBudget(n, name) / @compile_budget(n) | This chapter's tool: raise or warn when the compile count exceeds the budget |
Topic 2 Numerical stability
| API | Purpose |
|---|---|
jnp.where(ok, f(jnp.where(ok, x, safe)), fill) | Double where: keeps gradients finite at masked positions |
jax.nn.logsumexp / jax.nn.log_softmax / jax.nn.softmax | Stable implementations (max-shift + stop_gradient) |
jax.lax.stop_gradient(x) | Forward unchanged, gradient 0 in the backward pass |
jnp.finfo(jnp.float32).min | The smallest finite float32; use it instead of -inf for masking |
jax.config.update("jax_debug_nans", True) / "jax_debug_infs" | Checks for NaN / inf and pinpoints the primitive; only checks jit outputs |
with jax.debug_nans(True): | Turns on NaN checking only inside the block; not affected by the vmap missed-detection issue in 0.12.0.1 |
jax.clear_caches() | Clears the compilation cache; in 0.12, used to make a globally enabled jax_debug_nans work again |
jax.experimental.checkify.checkify(f, errors=checkify.float_checks) | Compiles NaN/inf checks into the program, returns (err, out), err.get() / err.throw() |
jax.custom_jvp + @f.defjvp | Custom derivative rules (the rule itself must be numerically safe too) |
jax.test_util.check_grads(f, args, order, modes) | Checks gradients with finite differences (needs an explicit import jax.test_util) |
Topic 3 Choosing a derivative API
| API | Purpose |
|---|---|
jax.jvp(f, (x,), (v,)) | Forward mode, returns (f(x), J v) |
jax.vjp(f, x) → vjp_fn(u) | Reverse mode, returns f(x) and a reusable u ↦ uᵀJ |
jax.linearize(f, x) | Linearizes once, returns (f(x), v ↦ J v); good for many directions at the same point |
jax.jacfwd(f) / jax.jacrev(f) | Full Jacobian: the former for few inputs, the latter for few outputs |
jax.hessian(f) | jacfwd(jacrev(f)), $O(n^2)$ memory |
jax.jvp(jax.grad(f), (x,), (v,))[1] | Hessian-vector product (forward-over-reverse, preferred) |
Topic 4 Per-example gradients
| API | Purpose |
|---|---|
jax.vmap(jax.grad(loss), in_axes=(None, 0, 0)) | Per-example gradients: shared parameters, data mapped per example |
jax.lax.map(f, xs, batch_size=k) | Runs chunks sequentially with vmap inside each chunk; memory about k examples |
jax.lax.scan(body, init, xs) | Chunked accumulation (e.g. the clip-and-sum of DP-SGD) |
jnp.tensordot(c, g, axes=1) | Per-example weighted sum $\sum_i c_i g_i$ |
jax.lax.top_k(x, k) | The k largest values and their indices (finding hard examples) |
jax.random.split(key, n) | Derive independent keys for each leaf / each step |
Topic 5 Hand-written optimizers
| API | Purpose |
|---|---|
GradientTransformation(init, update) | init(params) -> state; update(updates, state, params) -> (updates, state) |
chain(*ts) / apply_updates(p, u) | Compose transformations in order / add the increment to the parameters |
scale_by_adam / trace(β, nesterov) / scale_by_lion (Exercise 5) | Adam direction / momentum and Nesterov / Lion's sign direction |
add_decayed_weights(λ, mask) / clip_by_global_norm(c) | Decoupled weight decay / global-norm clipping |
scale_by_learning_rate(lr) / scale_by_schedule(f) | Multiply by -lr (constant or schedule function; the step count lives in the state) |
warmup_cosine_decay_schedule(init, peak, warmup, decay, end) | Linear warmup + cosine decay (same semantics as Optax) |
optax.adam / optax.adamw / optax.lion / optax.chain | optax 0.2.9 installed in this environment, used for cross-checking |
Topic 6 donate
| API | Purpose |
|---|---|
jax.jit(f, donate_argnums=(0,)) / donate_argnames=("state",) | Declares that inputs aren't used after the call, so their buffers can be reused for outputs of the same shape |
x.is_deleted() | Whether an array has been donated (deleted) |
x.unsafe_buffer_pointer() | Device buffer address, for checking in-place reuse |
jax.device_get(tree) | Fetch to the host (NumPy); on CPU it's a zero-copy view, and the buffer won't be donated while the view is alive |
np.array(x, copy=True) | A real copy to the host; use it to keep a copy before donating |
tf.aliasing_output in lowered.as_text() | Compile-time input-output alias marker |
Topic 7 PyTree
| API | Purpose |
|---|---|
jax.tree.map / leaves / structure / flatten / unflatten | Basic operations (top-level jax.tree_map and friends were removed) |
jax.tree.reduce / reduce_associative / all | Reduce over all leaves |
jax.tree.transpose(outer, inner, tree) | "dict of lists" ⇄ "list of dicts" |
jax.tree.broadcast(prefix, full) | Broadcast a prefix tree to the full structure |
jax.tree.map_with_path / leaves_with_path / flatten_with_path | Operations with key paths |
jax.tree_util.keystr(path, simple=True, separator=".") | Turns a path into a name like dense_0.w |
jax.tree_util.register_dataclass + jax.tree.static(...) | Registers a dataclass as a pytree and declares static fields |
dataclasses.replace(state, ...) / nt._replace(...) | Functional updates for a frozen dataclass / NamedTuple |
is_leaf=lambda x: isinstance(x, tuple) | Treat tuples and other containers as leaves |
# Whole-chapter check: every jax config switch has been restored to its default
for flag in ("jax_debug_nans", "jax_debug_infs", "jax_log_compiles", "jax_explain_cache_misses"):
assert getattr(jax.config, flag) is False, flag
print("All config switches restored to their defaults; every cell of Chapter 7 has run ✔")
Appendix: reference solutions¶
Write your own solution first in the "✍️ Your answer" cell in the main text and check it with "🧪 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.
Exercise 1¶
See "Exercise 1: a compile-budget test for a serving function" in the main text for the problem.
# Reference solution: Exercise 1
EX1_BUCKETS = (32, 64, 128)
def test_serve_compiles(serve_fn, fun_name, budget):
"""Compile-budget test: while serving all requests, jit(fun_name) must not compile more than budget times."""
try:
with CompileBudget(budget, fun_name) as b:
outs = serve_fn(ex1_batches, ex1_eps)
return True, b.count, outs
except CompileBudgetExceeded:
return False, b.count, None
def normalize_rows_fixed(x, eps): # same computation as the original, named separately so compilations can be counted by name
return normalize_rows(x, eps)
normalize_fixed_jit = jax.jit(normalize_rows_fixed)
def serve_fixed(batches, eps_list):
outs = []
for b, e in zip(batches, eps_list):
n = b.shape[0]
size = next(s for s in EX1_BUCKETS if s >= n)
padded = np.zeros((size, b.shape[1]), np.float32)
padded[:n] = b # padding in NumPy, triggers no JAX compilation
out = normalize_fixed_jit(padded, np.float32(e)) # eps made consistently a strongly typed float32 scalar
outs.append(np.asarray(out)[:n]) # remove the padded rows (all-zero rows stay 0 after normalization, harmless)
return outs
ok_orig, n_orig, _ = test_serve_compiles(serve, "normalize_rows", 3)
ok_fixed, n_fixed, outs = test_serve_compiles(serve_fixed, "normalize_rows_fixed", 3)
print(f"Original serve: {'pass' if ok_orig else 'fail'}, {n_orig} compilations")
print(f"Fixed serve_fixed: {'pass' if ok_fixed else 'fail'}, {n_fixed} compilations")
assert not ok_orig and ok_fixed and n_fixed <= len(EX1_BUCKETS)
for b, o in zip(ex1_batches, outs):
b64 = b.astype(np.float64)
ref = b64 / (np.sqrt((b64 ** 2).sum(1, keepdims=True)) + 1e-6)
assert np.allclose(o, ref, atol=1e-6)
print("The fixed output matches the NumPy float64 reference ✔")
Exercise 2¶
See "Exercise 2: a safe cosine similarity" in the main text for the problem.
# Reference solution: Exercise 2
zero3, b_vec = jnp.zeros(3), jnp.array([1.0, 2.0, 2.0])
g_bad = jax.grad(cos_sim_where)(zero3, b_vec)
print("❌ Single where: forward", float(cos_sim_where(zero3, b_vec)), "| gradient with respect to a", g_bad)
assert float(cos_sim_where(zero3, b_vec)) == 0.0 and bool(jnp.any(jnp.isnan(g_bad)))
def cos_sim_safe(a, b):
na2, nb2 = jnp.sum(a ** 2), jnp.sum(b ** 2)
ok = (na2 > 0) & (nb2 > 0)
na = jnp.sqrt(jnp.where(ok, na2, 1.0)) # 1st where: dangerous input → safe value
nb = jnp.sqrt(jnp.where(ok, nb2, 1.0))
return jnp.where(ok, jnp.dot(a, b) / (na * nb), 0.0) # 2nd where: select the output
ga, gb = jax.grad(cos_sim_safe, argnums=(0, 1))(zero3, b_vec)
print("✅ Double where: forward", float(cos_sim_safe(zero3, b_vec)), "| gradients", ga, gb)
assert bool(jnp.all(jnp.isfinite(ga))) and bool(jnp.all(jnp.isfinite(gb)))
ka, kb2 = jax.random.split(jax.random.key(72))
a_r, b_r = jax.random.normal(ka, (5,)), jax.random.normal(kb2, (5,))
a64, b64 = to64(a_r), to64(b_r)
na, nb = np.linalg.norm(a64), np.linalg.norm(b64)
ref_ga = b64 / (na * nb) - a64.dot(b64) * a64 / (na ** 3 * nb)
assert np.allclose(jax.grad(cos_sim_safe)(a_r, b_r), ref_ga, atol=1e-6)
assert np.isclose(float(cos_sim_safe(a_r, b_r)), a64.dot(b64) / (na * nb), atol=1e-6)
check_grads(cos_sim_safe, (a_r, b_r), order=1, modes=["fwd", "rev"])
batch_a = jnp.stack([a_r, jnp.zeros(5), -a_r])
batch_b = jnp.stack([b_r, b_r, jnp.zeros(5)])
gbatch = jax.jit(jax.vmap(jax.grad(cos_sim_safe)))(batch_a, batch_b)
assert bool(jnp.all(jnp.isfinite(gbatch))) and np.allclose(gbatch[1:], 0.0)
print("Gradient at normal points = analytic form, check_grads passes; batch gradients with zero vectors are all finite ✔")
Exercise 3¶
See "Exercise 3: Newton-CG: second-order optimization with only HVPs" in the main text for the problem.
# Reference solution: Exercise 3
def cg_solve(matvec, b, iters):
def body(_, st):
x, r, p, rs = st
Ap = matvec(p)
pAp = jnp.vdot(p, Ap)
alpha = jnp.where(pAp > 0, rs / jnp.where(pAp > 0, pAp, 1.0), 0.0) # stop updating once the residual is 0
x = x + alpha * p
r = r - alpha * Ap
rs_new = jnp.vdot(r, r)
beta = jnp.where(rs > 0, rs_new / jnp.where(rs > 0, rs, 1.0), 0.0)
return x, r, r + beta * p, rs_new
x, *_ = lax.fori_loop(0, iters, body, (jnp.zeros_like(b), b, b, jnp.vdot(b, b)))
return x
@partial(jax.jit, static_argnames="iters")
def newton_cg_step(x, A, iters=60):
g = jax.grad(logistic_obj)(x, A)
d = cg_solve(lambda v: hvp_fwd_rev(x, v, A), -g, iters)
return x + d, g, d
n_nc = 50
ka, kx = jax.random.split(jax.random.key(73))
A_nc = jax.random.normal(ka, (200, n_nc)) / np.sqrt(n_nc)
x_nc = jax.random.normal(kx, (n_nc,))
# First-step direction: cross-check against the exact NumPy float64 Newton direction
_, g0, d0 = newton_cg_step(x_nc, A_nc)
A64, x64 = to64(A_nc), to64(x_nc)
s = 1 / (1 + np.exp(-(A64 @ x64)))
H64 = A64.T @ ((s * (1 - s))[:, None] * A64) + LAM * np.eye(n_nc)
g64 = A64.T @ s + LAM * x64
d_ref = np.linalg.solve(H64, -g64)
rel = np.linalg.norm(to64(d0) - d_ref) / np.linalg.norm(d_ref)
print(f"Step 1: relative error of the CG direction vs. np.linalg.solve {rel:.2e}")
assert np.allclose(g0, g64, atol=1e-5) and rel < 1e-3
x_it = x_nc
for k in range(6):
x_it, g, _ = newton_cg_step(x_it, A_nc)
print(f" iteration {k + 1}: ‖∇f‖ before the update = {float(jnp.linalg.norm(g)):.3e}")
g_final = float(jnp.linalg.norm(jax.grad(logistic_obj)(x_it, A_nc)))
print(f"‖∇f‖ after 6 steps = {g_final:.2e}")
assert g_final < 1e-3
Exercise 4¶
See "Exercise 4: finding mislabeled examples with per-example gradients" in the main text for the problem.
# Reference solution: Exercise 4
N_NOISY, N_FLIP = 2000, 20
Xn, Yn_clean = make_blobs(jax.random.key(80), N_NOISY, D_IN, N_CLS)
flip_idx = jax.random.choice(jax.random.key(81), N_NOISY, (N_FLIP,), replace=False)
offset = jax.random.randint(jax.random.key(82), (N_FLIP,), 1, N_CLS) # 1..9: guarantees a different class
Yn = Yn_clean.at[flip_idx].set((Yn_clean[flip_idx] + offset) % N_CLS)
assert int(jnp.sum(Yn != Yn_clean)) == N_FLIP
ex4_opt = adam(1e-2)
@partial(jax.jit, static_argnames="steps")
def fit_full_batch(params, X, Y, steps):
def body(carry, _):
p, s = carry
loss, g = jax.value_and_grad(loss_batch)(p, X, Y)
u, s = ex4_opt.update(g, s, p)
return (apply_updates(p, u), s), loss
(params, _), losses = lax.scan(body, (params, ex4_opt.init(params)), None, length=steps)
return params, losses
p_noisy, ex4_losses = fit_full_batch(init_mlp(jax.random.key(83), (D_IN, 64, N_CLS)), Xn, Yn, steps=150)
norms_n = jax.jit(lambda p, X, Y: lax.map(lambda xy: per_sample_norm(p, *xy), (X, Y), batch_size=500))(p_noisy, Xn, Yn)
_, top40 = lax.top_k(norms_n, 40)
recall = len(set(np.asarray(top40).tolist()) & set(np.asarray(flip_idx).tolist())) / N_FLIP
acc_clean = float(accuracy(p_noisy, Xn, Yn_clean))
print(f"Training loss {float(ex4_losses[0]):.3f} → {float(ex4_losses[-1]):.3f}; accuracy on the true labels {acc_clean:.3f}")
print(f"The top 40 by gradient norm recovered {recall:.0%} of the flipped examples"
f" (mean norm of flipped examples {float(norms_n[flip_idx].mean()):.3f}, overall {float(norms_n.mean()):.3f})")
assert recall >= 0.9
Exercise 5¶
See "Exercise 5: implement the Lion optimizer and cross-check against optax.lion" in the main text for the problem.
# Reference solution: Exercise 5
class ScaleByLionState(NamedTuple):
count: jax.Array
mu: Any
def scale_by_lion(b1=0.9, b2=0.99):
def init(params):
return ScaleByLionState(count=jnp.zeros([], jnp.int32), mu=jax.tree.map(jnp.zeros_like, params))
def update(updates, state, params=None):
direction = jax.tree.map(lambda g, m: jnp.sign((1 - b1) * g + b1 * m), updates, state.mu)
mu = jax.tree.map(lambda m, g: b2 * m + (1 - b2) * g, state.mu, updates) # compute the direction first, then update the momentum
return direction, ScaleByLionState(count=state.count + 1, mu=mu)
return GradientTransformation(init, update)
def lion(lr, b1=0.9, b2=0.99, weight_decay=1e-3, mask=None):
return chain(scale_by_lion(b1, b2), add_decayed_weights(weight_decay, mask), scale_by_learning_rate(lr))
lion_sched = warmup_cosine_decay_schedule(0.0, 3e-2, 2, 10, 1e-3)
for name, mine, ref in [
("constant lr=0.03, wd=0.1", lion(0.03, weight_decay=0.1), optax.lion(0.03, weight_decay=0.1)),
("warmup-cosine schedule", lion(lion_sched), optax.lion(optax.warmup_cosine_decay_schedule(0.0, 3e-2, 2, 10, 1e-3)))]:
a_traj, b_traj = run_jax(mine), run_jax(ref)
err = max(np.abs(x[k] - y[k]).max() for x, y in zip(a_traj, b_traj) for k in x)
print(f"{wpad(name, 24)} hand-written Lion vs optax.lion, max diff over 5 steps {err:.2e}")
assert err < 1e-6
lion0 = lion(0.03, weight_decay=0.0)
u1, _ = lion0.update(jax.grad(quad_loss)(theta0, coef_a, coef_c), lion0.init(theta0), theta0)
assert all(np.allclose(np.abs(l), 0.03) for l in jax.tree.leaves(u1))
print("In the first step, every coordinate changes by ±lr ✔")
Exercise 6¶
See "Exercise 6: a training step with frozen layers + a donated state" in the main text for the problem.
# Reference solution: Exercise 6
def freeze(mask):
def update(updates, state, params=None):
m = mask(params) if callable(mask) else mask
return jax.tree.map(lambda u, frozen: jnp.zeros_like(u) if frozen else u, updates, m), state
return GradientTransformation(lambda params: EmptyState(), update)
frozen_mask = lambda params: jax.tree.map_with_path(
lambda p, _: jax.tree_util.keystr(p[:1], simple=True) == "dense_0", params)
ex6_opt = chain(adamw(1e-2, weight_decay=1e-4), freeze(frozen_mask)) # freeze goes last: it blocks weight decay too
@partial(jax.jit, donate_argnames=("state",))
def finetune_step(state, X, Y):
loss, g = jax.value_and_grad(loss_batch)(state.params, X, Y)
u, opt_state = ex6_opt.update(g, state.opt_state, state.params)
return dataclasses.replace(state, step=state.step + 1, params=apply_updates(state.params, u),
opt_state=opt_state), loss
p6 = init_mlp(jax.random.key(90), (D_IN, 64, N_CLS))
st6 = TrainState(step=jnp.zeros([], jnp.int32), params=p6, opt_state=ex6_opt.init(p6), key=jax.random.key(91))
print("Freeze mask:", frozen_mask(st6.params))
host_params = jax.tree.map(lambda a: np.array(a, copy=True), st6.params) # ✅ a real host copy
host_dense0, host_dense1 = host_params["dense_0"], host_params["dense_1"]
device_ref = st6.params["dense_0"]["w"] # ❌ just a reference to the device array
with CompileBudget(1, "finetune_step"):
for i in range(20):
st6, loss6 = finetune_step(st6, Xtr[:512], Ytr[:512])
print(f"After 20 steps loss = {float(loss6):.4f}, step = {int(st6.step)}")
after = jax.tree.map(lambda a: np.array(a, copy=True), st6.params)
assert all(np.array_equal(after["dense_0"][k], host_dense0[k]) for k in ("w", "b")) # frozen layer is bit-identical
assert not np.allclose(after["dense_1"]["w"], host_dense1["w"]) # the other layers are training
print("dense_0 is bit-identical to before training, dense_1 has been updated ✔")
print("❌ Has the device reference saved before training been deleted:", device_ref.is_deleted())
assert device_ref.is_deleted()
show_error(lambda: device_ref + 0)