Chapter 8: The Debugging Toolkit
In earlier chapters, debugging tools were spread across several places: Basics Chapter 2 covered jax_log_compiles and jax.debug.print, Basics Chapter 7 covered jax_debug_nans, checkify and compile budgets, and Advanced Chapter 2 covered jax_explain_cache_misses and the profiler. This chapter reorganizes them as "symptom → tool → usage" and adds other commonly used tools: debug.callback, debug.breakpoint, the different kinds of checkify checks, tracer leak checking, traceback filtering, live_arrays, transfer_guard, jax_debug_key_reuse, and more. At the end we walk through the whole process on "a training script with 3 hidden bugs".
Environment: JAX / jaxlib 0.12.0.1, Python 3.14, CPU only. Every conclusion in this chapter was run in this environment and verified with
assert. Timings are only printed, not asserted. Things already covered in depth in earlier chapters get just a conclusion and a link.
Learning goals¶
After this chapter you should be able to:
- Look at a symptom (NaN, wrong numbers, repeated compilation, Tracer errors, growing memory, slowdowns, inconsistent results across devices) and know right away which tool to reach for first;
- See runtime values inside jit / vmap / grad with
jax.debug.print,jax.debug.callback,jax.debug.breakpointandjax.disable_jit(), and know how each behaves under the various transformations; - See the program itself: jaxpr, StableHLO, optimized HLO, and use
jax.named_scopeto find your own code in the HLO; - Catch numerical problems with
jax_debug_nans,checkifyandcheck_grads, and know how to work around the casejax_debug_nansmisses in 0.12.0.1; - Recognize every kind of Tracer error and apply the standard fix; catch tracer leaks with the leak checker;
- Read JAX error tracebacks, switch
JAX_TRACEBACK_FILTERING, and inspect the current configuration; - Investigate memory with
memory_analysis,live_arrays,deleteandclear_caches; investigate multi-device problems withjax.typeofand the sharding inspection tools.
Contents¶
| # | Section | Main tools |
|---|---|---|
| 0 | Symptom lookup table · Common setup | — |
| 1 | Seeing values | jax.debug.print / callback / breakpoint, jax.disable_jit() |
| 2 | Seeing the program | make_jaxpr, lower().as_text(), compile().as_text(), jax.stages, jax.named_scope |
| 3 | Numerical problems | jax_debug_nans / jax_debug_infs, checkify, check_grads |
| 4 | A catalog of Tracer errors | jax.errors.*, jax.checking_leaks() |
| 5 | Compilation and speed | jax_log_compiles, jax_explain_cache_misses, _cache_size, compile counting, transfer_guard |
| 6 | Error messages and configuration | JAX_TRACEBACK_FILTERING, jax.config, jax.print_environment_info() |
| 7 | Memory | memory_analysis(), save_device_memory_profile, jax.live_arrays(), x.delete(), jax.clear_caches() |
| 8 | Multiple devices | jax.typeof, visualize_array_sharding and its replacements, inspect_array_sharding |
| 9 | Hands-on: a training script with 3 hidden bugs | All of the above |
| — | Exercises · Quiz · Summary and cheat sheet | — |
0 Symptom lookup table¶
🟢 Must-read
Start from the symptom, then find the tool. The right-hand column points to parts already covered in depth in earlier chapters, which aren't repeated here.
| Symptom | Do this first | Then use | This chapter | Existing notes |
|---|---|---|---|---|
| Output contains NaN / inf | Check on the host at every step that the loss is finite | checkify (float_checks) to find the primitive; or rerun the failing step inside with jax.debug_nans(True): (if you turn it on globally with config.update, call jax.clear_caches() first) | §3, §9 | Basics 07 Topic 2 |
| Wrong numbers (no error) | Cross-check against NumPy float64 | Bisect with jax.disable_jit(): right in eager, wrong under jit → a compilation-level problem; then look at the jaxpr / HLO | §1.4, §2 | 00 Known issue 1, Advanced 02 · 2.3 |
| Slow compilation / repeated compilation | Count compilations (CompileCounter) | jax_log_compiles for shapes, jax_explain_cache_misses for the reason, _cache_size() for cache entries | §5 | 02 · 2.3 overview table, Basics 07 Topic 1 |
| Tracer error | Read the line "The error occurred while tracing … argument x" in the error | Rewrite following the table in §4: jnp.where / lax.cond / static argument / size= | §4 | 02 · 2.4–2.6 |
| UnexpectedTracerError | Find where the function stores an intermediate value somewhere outside | jax.checking_leaks() reports the reference chain right when tracing ends | §4.6 | Advanced 01 |
| Out of memory | Estimate with compile().memory_analysis() | jax.live_arrays() to find what wasn't freed; x.delete(), jax.clear_caches(); save_device_memory_profile to see where allocations happen | §7 | Advanced 02 · 2.1, Basics 07 Topic 6 |
| Slow | First rule out repeated compilation; remember block_until_ready when timing | jax.stages to split compile / run time; transfer_guard to catch implicit transfers; profiler | §5.5 | Advanced 02 · 2.1, 2.7 |
| Inconsistent results across devices | Check the sharding type with jax.typeof(x) | Check whether the replicas (addressable_shards) agree; inside shard_map, use debug.print to see each device's local block | §8 | 05 · 5.7, Advanced 03 |
| Randomness is wrong (same dropout every step) | Print each step's key with key_data | jax_debug_key_reuse reports the reused key directly | §9 | 01 · 1.5 Random numbers, Advanced 10 Random numbers and sharding |
⚠️ Known issue in this environment (→ 00 Overview): the CPU backend of jaxlib 0.12.0.1 silently returns wrong results for "jit closure captures a constant matrix →
W @ x→ sum". So all weights and data in this chapter are always passed in as function arguments. The bug itself is a textbook case of "wrong numbers", and §1.4 uses it to demonstrate bisecting.
Common setup¶
⚪ Optional: small helpers shared across the chapter (subprocesses, log capture, compile counting); come back to the definitions when you need them
The cell below defines helpers used throughout the chapter:
show_error(fn): runsfn, prints the exception type and the first line of the message, and returns the exception object so we canassert isinstance(...)afterwards;capture_stdout(fn): collects whatfnprints to stdout while it runs (includingjax.debug.printoutput), for assertions;capture_jax_logs: temporarily takes over thejaxlogger and collects compile logs (same approach as Basics 07 Topic 1.4);CompileCounter: a compile counter based onjax.monitoringevents (see Basics 07 Topic 1.4 for how it works; this is a trimmed-down version);run_py(code, ...): runs a script in a fresh subprocess.XLA_FLAGS,JAX_*environment variables and some global state can only be demonstrated cleanly in a subprocess.
import os, sys, io, re, gc, time, json, logging, subprocess, textwrap, traceback, contextlib, functools, warnings
from functools import partial
from collections import Counter
import numpy as np
import jax
import jax.numpy as jnp
from jax import lax
import jax.monitoring as monitoring
from jax.experimental import checkify
import jax.test_util # must be imported explicitly (see Chapter 3); with only `import jax`, accessing jax.test_util raises AttributeError
from jax.test_util import check_grads
print("JAX", jax.__version__, "| backend", jax.default_backend(), "| devices", jax.devices())
CONFIG_AT_START = dict(jax.config.values) # record the full config at the start, to check at the end that all debug switches were restored
def to64(a):
"""Convert to NumPy float64 for cross-checking."""
return np.asarray(a, dtype=np.float64)
def show_error(fn):
"""Run fn; on error, print "type: first line of message" and return the exception object; return None if there's no error."""
try:
out = fn()
except Exception as e:
first = str(e).strip().splitlines()[0] if str(e).strip() else ""
print(f" → {type(e).__name__}: {first[:150]}")
return e
print(" → no error, result:", out)
return None
def capture_stdout(fn):
"""Run fn and collect stdout. jax.debug.print is an asynchronous callback, so call effects_barrier() at the end to wait for it to finish printing."""
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
jax.block_until_ready(fn())
jax.effects_barrier()
return buf.getvalue()
class capture_jax_logs:
"""Temporarily take over the 'jax' logger and collect log messages in self.messages (instead of printing 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
class CompileCounter:
"""Count XLA backend compilations inside the with block; with name="f", only count jit(f)."""
EVENT = "/jax/core/compile/backend_compile_duration"
def __init__(self, name=None):
self.name, self.events = name, []
self._listener = self._on_event # keep the same bound-method object so it can be found when unregistering
def _on_event(self, event, duration, **kw):
fun = kw.get("fun_name", "?")
if event == self.EVENT and (self.name is None or fun == f"jit({self.name})"):
self.events.append(fun)
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 run_py(code, *args, env=None, stdin=None, timeout=300):
"""Run code in a fresh Python subprocess and return the CompletedProcess. Keys in env override the current environment variables."""
full_env = {k: v for k, v in os.environ.items() if not k.startswith(("XLA_FLAGS", "JAX_"))}
full_env["PYTHON_COLORS"] = "0" # Python 3.14's colored tracebacks mix ANSI escape codes into the output
full_env.update(env or {})
return subprocess.run([sys.executable, "-c", textwrap.dedent(code), *args], input=stdin,
capture_output=True, text=True, env=full_env, timeout=timeout)
def result_lines(proc):
"""Extract what follows 'RESULT ' in the subprocess stdout (it may come right after the debugger prompt '(jdb) '); everything else is treated as noise."""
return [l.split("RESULT ", 1)[1] for l in proc.stdout.splitlines() if "RESULT " in l]
1 Seeing values: runtime numbers inside compiled code¶
A regular print inside jit runs only once, at trace time, and prints a tracer (demonstrated in the 02 · 2.6 extra). To see runtime values, there are four tools:
| Tool | Good for | Cost |
|---|---|---|
jax.debug.print | Quickly printing a few intermediate values | One host callback; doesn't change the numbers in the compiled result |
jax.debug.callback | Handing values to any Python function (collecting statistics, logging, assertions) | Same as above; the callback must return None |
jax.debug.breakpoint | Stopping somewhere and inspecting variables interactively | Needs stdin (an interactive terminal) |
jax.disable_jit() | Running the whole thing as plain Python, so print, pdb and Python if work | Very slow; vmap still traces |
1.1 jax.debug.print¶
🟢 Must-read
For the basics ({} placeholders, don't use f-strings, prints on every run), see 02 · 2.6. Here we add four more things: format specifiers, the recommended two-step style, ordered=True, and behavior under vmap / grad / cond.
Formatting: the format string follows the rules of str.format, so {m:.3f}, positional {} and keyword {name} all work. Formatting happens on the host at runtime, with concrete values.
@jax.jit
def stats(x):
jax.debug.print("mean={m:.3f} max={mx:.2f} size={n}", m=x.mean(), mx=x.max(), n=x.size)
jax.debug.print("positional: first={} last={}", x[0], x[-1])
return x * 2
out = capture_stdout(lambda: stats(jnp.array([1.0, 2.0, 4.5])))
print(out)
assert "mean=2.500" in out and "max=4.50" in out and "size=3" in out
assert "positional: first=1.0 last=4.5" in out
# ❌ Wrong: the f-string is evaluated at trace time, so it prints a tracer, and every later run prints that same line
@jax.jit
def fstring_print(x):
jax.debug.print(f"x = {x}")
return x
try:
out_bad = capture_stdout(lambda: fstring_print(jnp.float32(3.0)))
print("Actual output:", out_bad.strip())
except Exception as e: # if the tracer's string happens to contain braces, you also get a format error
out_bad = f"{type(e).__name__}: {e}"
print("Actual error:", out_bad)
assert "Tracer" in out_bad and "3.0" not in out_bad
# ✅ Correct: the format string is a plain string and the values are passed as arguments
@jax.jit
def good_print(x):
jax.debug.print("x = {x}", x=x)
return x
out_good = capture_stdout(lambda: good_print(jnp.float32(3.0)))
print("Actual output:", out_good.strip())
assert out_good.strip() == "x = 3.0"
The two-step style and ordered=True. The 0.12 docs recommend writing the options and the format arguments separately, the "two-step" style: jax.debug.print(ordered=True)("step {i}", i=i). Mixing ordered= with format arguments in a single call is marked soft deprecated (for now it doesn't emit a warning).
By default, different debug.print calls have no guaranteed order. XLA can reorder operations that have no data dependency between them, especially on accelerators. Even on this machine, the two output lines of the first example above could come out in reverse order. ordered=True adds an implicit "token" dependency chain to these prints, so they come out in program order, even across multiple jit calls. The cost is an extra serial dependency, so use it only when order really matters.
log_order = jax.debug.print(ordered=True) # step one: pass only the options, get back a print function
@jax.jit
def stage_a(x):
for i in range(3): # the Python loop unrolls into 3 prints at trace time
log_order("A{i}", i=i)
return x + 1
@jax.jit
def stage_b(x):
log_order("B x={x}", x=x)
return x * 2
out = capture_stdout(lambda: stage_b(stage_a(jnp.float32(1.0))))
print(out)
assert out.split() == ["A0", "A1", "A2", "B", "x=2.0"]
jax.debug.log: same signature as debug.print, but the output goes to Python logging (logger name jax._src.debugging, INFO level) instead of stdout. When your training script already has logging set up, this puts debug output in the same place as your other logs, with timestamps, and makes it easy to turn off by level.
dbg_logger = logging.getLogger("jax._src.debugging")
old_level = dbg_logger.level
dbg_logger.setLevel(logging.INFO) # at the default level, INFO isn't emitted
try:
with capture_jax_logs() as logs:
jax.block_until_ready(jax.jit(lambda x: (jax.debug.log("loss = {x:.2f}", x=x), x * 2)[1])(jnp.float32(0.25)))
jax.effects_barrier()
finally:
dbg_logger.setLevel(old_level)
print("logging received:", logs.messages)
assert logs.messages == ["loss = 0.25"]
Behavior under transformations (the next cell checks each row):
| Scenario | Behavior |
|---|---|
vmap | Prints once per example, showing that single example's value |
grad / jit(grad) | Runs only in the forward pass and shows the primal value; gradients in the backward pass aren't visible (to see gradients, see the extra below) |
lax.cond | Prints only for the branch that actually runs |
lax.cond inside vmap | cond is rewritten into select, so both branches run, and each example prints once from each branch |
vmap + ordered=True | Works fine in 0.12 |
def body(x):
jax.debug.print("saw x = {x}", x=x)
return jnp.sin(x) * x
def branchy(x):
return lax.cond(x > 0,
lambda x: (jax.debug.print("positive branch {x}", x=x), x)[1],
lambda x: (jax.debug.print("negative branch {x}", x=x), -x)[1], x)
out_v = capture_stdout(lambda: jax.vmap(body)(jnp.array([1.0, 2.0, 3.0])))
out_g = capture_stdout(lambda: jax.grad(body)(2.0))
out_jg = capture_stdout(lambda: jax.jit(jax.grad(body))(2.0))
out_c = capture_stdout(lambda: jax.jit(branchy)(jnp.float32(1.0)))
out_vc = capture_stdout(lambda: jax.vmap(branchy)(jnp.array([1.0, -1.0])))
out_vo = capture_stdout(lambda: jax.vmap(lambda x: (jax.debug.print(ordered=True)("ordered {x}", x=x), x)[1])(jnp.arange(2.0)))
for name, o in [("vmap", out_v), ("grad", out_g), ("jit(grad)", out_jg), ("cond", out_c),
("vmap(cond)", out_vc), ("vmap + ordered", out_vo)]:
print(f"{name:15s}", o.strip().replace("\n", " | "))
assert out_v.splitlines() == ["saw x = 1.0", "saw x = 2.0", "saw x = 3.0"]
assert out_g.splitlines() == ["saw x = 2.0"] and out_jg.splitlines() == ["saw x = 2.0"]
assert out_c.splitlines() == ["positive branch 1.0"]
assert sorted(out_vc.splitlines()) == sorted(["positive branch 1.0", "positive branch -1.0", "negative branch 1.0", "negative branch -1.0"])
assert out_vo.splitlines() == ["ordered 0.0", "ordered 1.0"]
🔍 Extra: seeing gradients in the backward pass with a custom_vjp "gradient probe"¶
⚪ Optional: when you need to see the gradient that some intermediate value receives
debug.print in forward code only shows primal values. To see the gradient an intermediate value receives, write an identity function and use custom_vjp to print (or call back with) the cotangent in its backward rule. It changes neither the forward pass nor the gradient; it just "listens" on the side. Below we collect the gradient with jax.debug.callback and cross-check it against the analytic $\partial L/\partial h = 2h$.
GRAD_TAPS = []
@partial(jax.custom_vjp, nondiff_argnums=(1,))
def grad_tap(x, name):
"""Identity function; when backprop passes through here, hand the incoming gradient to the host to record."""
return x
def _grad_tap_fwd(x, name):
return x, None
def _grad_tap_bwd(name, _, g):
jax.debug.callback(lambda g: GRAD_TAPS.append((name, np.asarray(g))), g)
return (g,)
grad_tap.defvjp(_grad_tap_fwd, _grad_tap_bwd)
def tap_loss(w, x):
h = grad_tap(jnp.tanh(w * x), "h")
return jnp.sum(h ** 2)
w_t, x_t = jnp.array([1.0, 2.0]), jnp.array([0.5, 0.1])
g_w = jax.jit(jax.grad(tap_loss))(w_t, x_t)
jax.effects_barrier()
name, g_h = GRAD_TAPS[-1]
print(f"{name}'s incoming gradient:", g_h, "| gradient w.r.t. w:", g_w)
h64 = np.tanh(to64(w_t) * to64(x_t))
assert name == "h" and np.allclose(g_h, 2 * h64, atol=1e-6) # ∂L/∂h = 2h
assert np.allclose(g_w, 2 * h64 * (1 - h64 ** 2) * to64(x_t), atol=1e-6) # the probe doesn't change the gradient
1.2 jax.debug.callback¶
⚪ Optional: when you want to hand runtime values to any Python function (statistics, logging, host-side assertions)
debug.print is really just debug.callback plus formatting. Using callback directly lets you hand values to any Python function: collect statistics, write logs, assert on the host. The callback receives NumPy arrays and must return None. To send a result back to the device you need jax.pure_callback; see Advanced 05 · 5.3, which has a full comparison table of the three callbacks under jit / vmap / grad / DCE. Like debug.print, it supports the two-step style jax.debug.callback(ordered=True)(fn, *args).
Below we write an "activation statistics recorder" that records the mean and standard deviation of each layer's output inside a jit-compiled forward pass. When you're debugging a diverging training run, this is much more useful than printing whole arrays.
class ActivationLog:
"""Record per-layer activation statistics inside jit using jax.debug.callback."""
def __init__(self):
self.records = []
def record(self, name, x):
def _host(m, s, frac_dead): # runs on the host; the arguments are NumPy arrays
self.records.append({"layer": name, "mean": float(m), "std": float(s), "dead": float(frac_dead)})
jax.debug.callback(_host, x.mean(), x.std(), jnp.mean(x <= 0))
return x
act_log = ActivationLog()
def mlp_forward(params, x):
for i, (w, b) in enumerate(params):
x = x @ w + b
if i < len(params) - 1:
x = act_log.record(f"relu{i}", jax.nn.relu(x))
return x
k1, k2, k3 = jax.random.split(jax.random.key(0), 3)
params_cb = [(jax.random.normal(k1, (8, 16)) / jnp.sqrt(8.0), jnp.zeros(16)),
(jax.random.normal(k2, (16, 16)) / 4.0, jnp.zeros(16)),
(jax.random.normal(k3, (16, 1)) / 4.0, jnp.zeros(1))]
x_cb = jax.random.normal(jax.random.key(1), (32, 8))
jax.block_until_ready(jax.jit(mlp_forward)(params_cb, x_cb))
jax.effects_barrier()
for r in act_log.records:
print(f" {r['layer']}: mean={r['mean']:.3f} std={r['std']:.3f} dead fraction={r['dead']:.2f}")
# Cross-check: recompute the first layer's statistics with NumPy in eager mode
h0 = np.maximum(to64(x_cb) @ to64(params_cb[0][0]), 0)
assert [r["layer"] for r in act_log.records] == ["relu0", "relu1"]
assert abs(act_log.records[0]["mean"] - h0.mean()) < 1e-5 and abs(act_log.records[0]["std"] - h0.std()) < 1e-5
# Under vmap the callback runs once per example (4 examples × 2 layers = 8 records)
act_log.records.clear()
jax.block_until_ready(jax.vmap(mlp_forward, in_axes=(None, 0))(params_cb, x_cb[:4]))
jax.effects_barrier()
print("Number of records under vmap:", len(act_log.records))
assert len(act_log.records) == 8
1.3 jax.debug.breakpoint¶
⚪ Optional: when you need to inspect variables interactively inside jit
jax.debug.breakpoint() stops at runtime and drops you into a debugger called jdb, where you can inspect variables in the current frame (they're already concrete values). Common commands:
| Command | What it does |
|---|---|
p expr / pp expr | Print / pretty-print |
u (up) / d (down) | Move up and down the call stack |
l (list) / w (where) | Show source / show the call stack |
c (continue) | Continue running |
q (quit) | Quit (raises an exception and aborts the program) |
When you run it directly in a notebook, it waits for you to type commands into an input box:
# Needs interaction, not executed (there's no interactive stdin when nbclient runs the notebook automatically)
@jax.jit
def f(x):
y = jnp.sin(x) * 2
jax.debug.breakpoint() # stops here: (jdb) p y
return y + 1
f(jnp.arange(3.0))
So that it can also be demonstrated in an automatically executed notebook, below we put the same code in a subprocess and feed it three commands through stdin: p y, p x, c.
BP_SCRIPT = """
import jax, jax.numpy as jnp
@jax.jit
def f(x):
y = jnp.sin(x) * 2
jax.debug.breakpoint()
return y + 1
print("RESULT", f(jnp.arange(3.0)).tolist())
"""
proc = run_py(BP_SCRIPT, stdin="p y\np x\nc\n")
print(proc.stdout)
assert "Entering jdb" in proc.stdout and "(jdb)" in proc.stdout
assert "1.6829419" in proc.stdout # p y: sin(1)*2, already a concrete value
assert "Array([0., 1., 2.], dtype=float32)" in proc.stdout # p x
res = json.loads(result_lines(proc)[0]) # after typing c, the program runs to completion
assert np.allclose(res, np.sin(np.arange(3.0)) * 2 + 1, atol=1e-6)
Conditional breakpoints: an unconditional breakpoint stops at every step of a training loop. In practice you more often want "stop only when a NaN shows up": put the breakpoint in one branch of a lax.cond. Note that you must pass the variables you want to inspect as arguments to the branch function; otherwise they aren't visible in the frame jdb stops in (the branch function). For example, if a lambda captures them as a closure, p y raises NameError.
COND_BP_SCRIPT = """
import jax, jax.numpy as jnp
from jax import lax
def stop_if_nan(x, y): # pass in the variables you want to inspect as arguments
jax.debug.breakpoint()
@jax.jit
def f(x):
y = jnp.log(x)
lax.cond(jnp.any(jnp.isnan(y)), stop_if_nan, lambda x, y: None, x, y)
return y
print("RESULT ok-run", f(jnp.array([1.0, 2.0])).tolist()) # doesn't stop
print("RESULT nan-run", f(jnp.array([1.0, -2.0])).tolist()) # stops; continue after inspecting
"""
proc = run_py(COND_BP_SCRIPT, stdin="p y\np x\nc\n")
print(proc.stdout)
assert proc.stdout.count("Entering jdb") == 1 # only the run with a NaN stops
assert "Array([ 0., nan], dtype=float32)" in proc.stdout and "Array([ 1., -2.], dtype=float32)" in proc.stdout
assert [l.split()[0] for l in result_lines(proc)] == ["ok-run", "nan-run"]
1.4 jax.disable_jit(): a debugging aid and a bisection tool¶
🟢 Must-read
For the basics, see 02 · 2.6. Here is exactly what it does (checked in the next cell):
- Functions wrapped in
jax.jitrun as plain Python, with concrete arrays as arguments, so Pythonif,printandpdball work; lax.scan/lax.fori_loop/lax.condalso fall back to Python implementations: the loop body runs step by step and sees concrete values, and thefori_loopindex is even a Pythonint;- But
vmapis not affected: the function still sees aBatchTracer, so a value-dependent Pythonifinside vmap still raises an error.
seen = []
with jax.disable_jit():
@jax.jit
def jitted_branch(x):
seen.append(("argument type inside jit", type(x).__name__))
return x if x > 0 else -x # Python if: only legal under disable_jit
r1 = jitted_branch(jnp.array(-3.0))
lax.scan(lambda c, x: (seen.append(("carry inside scan body", type(c).__name__)) or (c + x, c)), jnp.float32(0), jnp.arange(2.0))
lax.fori_loop(0, 2, lambda i, v: (seen.append(("fori index", type(i).__name__)) or v + i), 0)
jax.vmap(lambda x: seen.append(("argument inside vmap", type(x).__name__)) or x)(jnp.arange(2.0))
for s in seen:
print(" ", *s)
kinds = dict(seen)
assert float(r1) == 3.0 and kinds["argument type inside jit"] == "ArrayImpl"
assert kinds["fori index"] == "int" and kinds["argument inside vmap"] == "BatchTracer"
Bisecting "wrong numbers" with disable_jit. When the numbers look suspicious, compute the same function three ways: the jit version, the disable_jit version, and a NumPy float64 reference.
- jit wrong,
disable_jitright → the problem is in compilation (fusion, constant folding, a backend bug); look at the HLO next (§2); - both wrong → the problem is in your algorithm or data;
- both right but slightly off from the reference → most likely float32 precision; consider a different algorithm or local float64.
Below we demonstrate with the known CPU bug in this environment (→ 00 Overview; root-cause analysis in Advanced 02 · 2.3): with the same formula, the jit result is way off when the weights are captured by a closure, but disable_jit is right, which points straight at the compiler.
def bisect_numeric(fn, *args, ref):
"""Run the same function under jit and under disable_jit, and compare the max error against the reference."""
err_jit = float(np.abs(to64(jax.jit(fn)(*args)) - ref).max())
with jax.disable_jit():
err_eager = float(np.abs(to64(fn(*args)) - ref).max())
print(f" jit error = {err_jit:.3g} | disable_jit error = {err_eager:.3g}")
return err_jit, err_eager
W_bug = jax.random.normal(jax.random.key(1), (128, 128)) / jnp.sqrt(128.0)
X_bug = jax.random.normal(jax.random.key(2), (50, 128))
ref_bug = (to64(W_bug) @ to64(X_bug).T).sum(0) # NumPy float64 reference
# ❌ Wrong: the weights are captured as a constant by the jit closure (jaxlib 0.12's CPU backend on macOS / Apple Silicon silently returns wrong results)
def closure_version(X):
return jax.vmap(lambda x: jnp.sum(W_bug @ x))(X)
try:
err_jit, err_eager = bisect_numeric(closure_version, X_bug, ref=ref_bug)
except Exception as e:
print("Actual error:", type(e).__name__, e)
raise
assert err_eager < 1e-3
import platform, sys
if sys.platform == "darwin" and platform.machine() == "arm64": # on Apple Silicon it must reproduce; if not, it may have been fixed
assert err_jit > 1.0, "Not reproduced on macOS arm64: it may be fixed in a newer jaxlib, so update the notes"
if err_jit > 1.0:
print("jit wrong, eager right → the problem is in the compilation layer")
else:
print("This machine doesn't trigger the bug, so jit is correct too (it reproduces on macOS / Apple Silicon and did not show up in our Linux x86 test). The bisection idea is the same.")
# ✅ Correct: pass the weights in as a function argument
def param_version(W, X):
return jax.vmap(lambda x: jnp.sum(W @ x))(X)
err_jit, err_eager = bisect_numeric(param_version, W_bug, X_bug, ref=ref_bug)
assert err_jit < 1e-3 and err_eager < 1e-3
2 Seeing the program: what JAX actually traced and compiled¶
When the numbers don't reveal anything, look at the program itself. A jit call goes through three representations, and each one can be printed:
| Level | How to get it | What it answers | Existing notes |
|---|---|---|---|
| jaxpr | jax.make_jaxpr(f)(*args), jax.jit(f).trace(*args).jaxpr | What JAX thinks it needs to compute: which values got baked in as constants, whether control flow was unrolled, what the transformations inserted | Advanced 02 · 2.2 (walking the jaxpr, counting primitives) |
| StableHLO | jax.jit(f).lower(*args).as_text() | The program before it's handed to XLA; debug_info=True adds source locations | 02 · AOT extra |
| Optimized HLO | ….lower(*args).compile().as_text() | What XLA actually runs: fusion, layouts, constants; metadata={op_name=...} traces back to the source | Advanced 02 · 2.3 (counting fusions, locating the CPU bug) |
This section covers only the two moves you'll use most when debugging: spotting "baked-in constants" in the jaxpr, and using jax.named_scope to find your own code in all three representations.
2.1 jaxpr: spotting values that were treated as constants¶
🟢 Must-read
"I changed the config but the result didn't change" is a sneaky kind of bug: a global variable read by a jit function gets baked in as a literal at trace time, and changing it later neither triggers recompilation nor takes effect (the behavior was measured in 02 · 2.6 problem 5). The jaxpr lets you see this directly: the literal shows up in an equation as 2.0:f32[] instead of as an input variable.
SCALE = 2.0 # module-level "config"
def scaled(x):
return x * SCALE
jaxpr_txt = str(jax.make_jaxpr(scaled)(1.0))
print(jaxpr_txt)
assert "mul a 2.0:f32[]" in jaxpr_txt # SCALE shows up as a literal: it was baked in
# ❌ Wrong: keep the config in a global variable and change it after jit
scaled_jit = jax.jit(scaled)
before = float(scaled_jit(1.0))
SCALE = 10.0
try:
after = float(scaled_jit(1.0))
print(f"before changing SCALE = {before}, after = {after} (no error, but it still uses the old value)")
except Exception as e:
print("Actual error:", type(e).__name__, e)
raise
assert before == after == 2.0
# ✅ Correct: pass the config in as an argument; in the jaxpr it becomes the input variable b instead of a literal
def scaled_ok(x, scale):
return x * scale
print(jax.make_jaxpr(scaled_ok)(1.0, SCALE))
scaled_ok_jit = jax.jit(scaled_ok)
assert float(scaled_ok_jit(1.0, 2.0)) == 2.0 and float(scaled_ok_jit(1.0, 10.0)) == 10.0
SCALE = 2.0
2.2 jax.named_scope: labeling your own code¶
⚪ Optional: when you need to find your own code in the HLO or a profiler trace
The HLO of a large model easily runs to thousands of lines, and you can't tell which instructions belong to "my layer". with jax.named_scope("name"): adds the name to the name stack of every operation inside it, and it propagates all the way to:
- the
source_info.name_stackof each jaxpr equation; - the location info in StableHLO (only printed with
as_text(debug_info=True)); - the
metadata={op_name="jit(f)/name/..."}of each instruction in the optimized HLO; - operation names in profiler traces (visible on GPU/TPU; see the Advanced 02 · 2.1 extra).
Below we label each layer of a two-layer network and then find it in each of the three representations.
def scoped_mlp(params, x):
with jax.named_scope("encoder"):
h = jax.nn.relu(x @ params["w1"])
with jax.named_scope("head"):
out = h @ params["w2"]
loss = jnp.mean(out ** 2)
return loss
p_ns = {"w1": jnp.ones((4, 8)) * 0.1, "w2": jnp.ones((8, 2)) * 0.1}
x_ns = jnp.ones((3, 4))
jf_ns = jax.jit(scoped_mlp)
# ① jaxpr: the name stack of each equation
traced = jf_ns.trace(p_ns, x_ns)
stacks = [str(e.source_info.name_stack) for e in traced.jaxpr.jaxpr.eqns]
print("① jaxpr name stacks:", stacks)
# ② StableHLO: the default text has no names; only debug_info=True does
lowered = traced.lower()
plain, dbg = lowered.as_text(), lowered.as_text(debug_info=True)
print("② Default StableHLO text contains 'encoder'?", "encoder" in plain, "| with debug_info=True:",
[l.strip() for l in dbg.splitlines() if "encoder/" in l][:2])
# ③ Optimized HLO: op_name in the metadata
compiled = lowered.compile()
def hlo_ops_in_scope(hlo_text, scope):
"""Find instructions in the optimized HLO whose op_name contains /scope/; return (instruction name, op_name)."""
pat = re.compile(r"%([\w.\-]+) = .*?op_name=\"([^\"]*/" + re.escape(scope) + r"/[^\"]*)\"")
return [m.groups() for m in pat.finditer(hlo_text)]
enc_ops = hlo_ops_in_scope(compiled.as_text(), "encoder")
print("③ Instructions in the optimized HLO that belong to encoder:")
for name, op in enc_ops:
print(f" %{name:20s} ← {op}")
assert stacks.count("encoder") >= 2 and stacks.count("head") >= 3
assert "encoder" not in plain and "jit(scoped_mlp)/encoder/dot_general" in dbg
assert any(op.endswith("encoder/dot_general") for _, op in enc_ops)
assert np.isclose(float(compiled(p_ns, x_ns)), float(np.mean((np.maximum(to64(x_ns) @ to64(p_ns["w1"]), 0) @ to64(p_ns["w2"])) ** 2)))
2.3 jax.stages: the three stage objects¶
⚪ Optional: a quick reference of the Traced / Lowered / Compiled attributes commonly used in debugging
jit(f).trace(...) → Traced, .lower() → Lowered, .compile() → Compiled; all three classes live in jax.stages. What each is for (timing stages separately, AOT precompilation, Compiled accepting only the shapes it was compiled for) was measured in the 02 · AOT extra and Basics 07 Topic 1.4. The attributes you'll use most when debugging:
| Object | Attribute / method | Debugging use |
|---|---|---|
Traced | .jaxpr, .out_info, .in_avals | Get the jaxpr and output shapes without compiling |
Lowered | .as_text(debug_info=True), .cost_analysis() | See the program handed to XLA and its source locations |
Compiled | .as_text(), .memory_analysis(), .cost_analysis(), .input_shardings / .output_shardings | See the optimized result, memory (§7), sharding (§8) |
print("Types:", type(traced).__name__, "→", type(lowered).__name__, "→", type(compiled).__name__)
print("Traced.out_info:", traced.out_info, "| keys of Compiled.input_shardings[0]:", sorted(compiled.input_shardings[0][0]))
assert isinstance(traced, jax.stages.Traced) and isinstance(lowered, jax.stages.Lowered)
assert isinstance(compiled, jax.stages.Compiled)
assert traced.out_info.shape == () and traced.out_info.dtype == jnp.float32
3 Numerical problems: NaN, inf, out-of-bounds, division by zero and wrong gradients¶
| Tool | Mechanism | Good for |
|---|---|---|
jax_debug_nans / jax_debug_infs | Global switch: checks after every output; when it finds one, it reruns primitive by primitive and raises FloatingPointError at the primitive that produced the NaN | Turning on temporarily while debugging, and rerunning just the failing step |
checkify | Compiles the checks into the program and returns an error value; you decide when to raise it | Staying in the code long-term; composes with jit / vmap / grad; also catches out-of-bounds indexing and division by zero |
jax.test_util.check_grads | Compares jvp / vjp against finite differences | After writing a custom_jvp / custom_vjp or a hand-written gradient |
3.1 jax_debug_nans / jax_debug_infs¶
🟢 Must-read
What it catches and misses in each scenario (eager, jit outputs, backprop, NaNs discarded by where, inf only) was tested case by case in Basics 07 Topic 2.4, so we won't repeat it. This section adds three things: the scoped form, an exact reproduction of the missed-detection issue in this environment, and why a NaN in backprop can't be traced to a source line.
The scoped form: jax.debug_nans and jax.debug_infs are themselves context managers that restore the setting when the with block ends. That's harder to forget than config.update + finally.
with jax.debug_nans(True): # the with form isn't affected by the vmap missed-detection issue below, so no need to clear caches
print("inside the block:", jax.config.jax_debug_nans)
e_nan = show_error(lambda: jax.jit(lambda x: jnp.log(x))(jnp.array(-1.0)))
with jax.debug_infs(True):
e_inf = show_error(lambda: jax.jit(lambda x: 1.0 / x)(jnp.array(0.0)))
print("outside the block:", jax.config.jax_debug_nans, jax.config.jax_debug_infs)
assert isinstance(e_nan, FloatingPointError) and "nan" in str(e_nan)
assert isinstance(e_inf, FloatingPointError) and "inf" in str(e_inf)
assert jax.config.jax_debug_nans is False and jax.config.jax_debug_infs is False
⚠️ Known issue in this environment: when turned on globally, checks are missed after vmap has been used (→ 00 Known issue 2; this section is the authoritative reproduction and trigger conditions). Below we reproduce it exactly in fresh subprocesses. The switch is on from the start; we first run one jit function and one eager primitive (log) normally once, then split into four cases, and then feed inputs that produce NaN:
none:vmapwas never used in the process;vmap: an unrelatedvmapwas called once in between;clear:vmapwas called, followed byjax.clear_caches();with: the switch is turned on with thejax.debug_nans(True)context manager instead ofconfig.update(equivalent to putting the whole script inside awithblock), andvmapis also called in between.
The first three turn it on globally with jax.config.update("jax_debug_nans", True).
NAN_PROBE = """
import sys, jax, jax.numpy as jnp
mode = sys.argv[1]
if mode == "with":
jax.debug_nans(True).__enter__() # equivalent to putting everything below in a with block
else:
jax.config.update("jax_debug_nans", True)
f_old = jax.jit(lambda x: jnp.log(x))
f_old(jnp.array(2.0)).block_until_ready() # run it normally once first
jnp.log(jnp.array(2.0)) # also run the eager log once first
if mode in ("vmap", "clear", "with"):
jax.vmap(lambda x: x * 2)(jnp.ones(3)) # one unrelated vmap
if mode == "clear":
jax.clear_caches() # the workaround
cases = {
"jit fn already run": lambda: f_old(jnp.array(-1.0)),
"eager log already run": lambda: jnp.log(jnp.array(-1.0)),
"eager sqrt never run": lambda: jnp.sqrt(jnp.array(-1.0)),
"newly created jit fn": lambda: jax.jit(lambda x: jnp.arccos(x))(jnp.array(3.0)),
}
for name, fn in cases.items():
try:
fn(); print("RESULT", name, "missed")
except FloatingPointError:
print("RESULT", name, "caught")
"""
nan_table = {}
for mode in ["none", "vmap", "clear", "with"]:
for line in result_lines(run_py(NAN_PROBE, mode)):
name, verdict = line.rsplit(" ", 1)
nan_table.setdefault(name, {})[mode] = verdict
print(f"{'Case':24s} {'no vmap':10s} {'vmap used':10s} {'vmap+clear':10s} {'with + vmap':10s}")
for name, row in nan_table.items():
print(f"{name:22s} {row['none']:12s} {row['vmap']:12s} {row['clear']:12s} {row['with']}")
assert all(row["none"] == row["clear"] == row["with"] == "caught" for row in nan_table.values())
assert nan_table["jit fn already run"]["vmap"] == "missed" and nan_table["eager log already run"]["vmap"] == "missed"
assert nan_table["eager sqrt never run"]["vmap"] == "caught" and nan_table["newly created jit fn"]["vmap"] == "caught"
Conclusion: when you turn it on globally with config.update, once vmap has been used, computations that already ran are no longer checked. Eager primitives count too, because internally they also have cached jits. Newly compiled computations are not affected. jax.clear_caches() clears the caches and forces recompilation, which brings the checks back. Turning it on with with jax.debug_nans(True): is not affected at all: all four cases are caught.
So the workarounds, in order of preference: ① wrap the step you want to check in with jax.debug_nans(True): (what this chapter does); ② if you must turn it on globally, call jax.clear_caches() right after; ③ for checks that stay in the code long-term, use checkify instead (§3.2).
🔍 Extra: what triggers the missed detection is "tracing vmap", not "running vmap"¶
⚪ Optional: if you want to know which operations trigger it; otherwise just remember "when it's on globally, clear the caches once vmap has been traced"
Which operations exactly trigger the missed detection? Below we try them one by one in subprocesses. A single make_jaxpr(vmap(...)) (tracing only, no execution) is enough to trigger it; grad, scan, checkify, plain make_jaxpr and repeated config.update don't. This shows the problem lies in how some global state that vmap changes during tracing interacts with the jit cache, not in running vmap itself. Practical upshot: if anything in the process (including libraries you use) has traced vmap, assume jax_debug_nans no longer works for old cache entries.
TRIGGER_PROBE = """
import sys, jax, jax.numpy as jnp
from jax.experimental import checkify
jax.config.update("jax_debug_nans", True)
f = jax.jit(lambda x: jnp.log(x))
f(jnp.array(2.0)).block_until_ready()
m = sys.argv[1]
if m == "grad": jax.grad(lambda x: x * 2)(1.0)
if m == "scan": jax.lax.scan(lambda c, x: (c + x, x), 0.0, jnp.ones(3))
if m == "make_jaxpr": jax.make_jaxpr(lambda x: x * 2)(1.0)
if m == "checkify": checkify.checkify(lambda x: x * 2)(1.0)
if m == "config.update": jax.config.update("jax_debug_nans", True)
if m == "make_jaxpr(vmap)": jax.make_jaxpr(jax.vmap(lambda x: x * 2))(jnp.ones(3))
if m == "jit(vmap)": jax.jit(jax.vmap(lambda x: x * 2))(jnp.ones(3))
try:
f(jnp.array(-1.0)); print("RESULT missed")
except FloatingPointError:
print("RESULT caught")
"""
trig = {m: result_lines(run_py(TRIGGER_PROBE, m))[0]
for m in ["grad", "scan", "make_jaxpr", "checkify", "config.update", "make_jaxpr(vmap)", "jit(vmap)"]}
for m, v in trig.items():
print(f" after {m:18s} → NaN in the old jit function: {v}")
assert [m for m, v in trig.items() if v == "missed"] == ["make_jaxpr(vmap)", "jit(vmap)"]
NaN in backprop: jax_debug_nans can only tell you the primitive name. When locate_nan from 07 hits a NaN in the backward pass, it returns "in the backward computation generated by JAX". Below is one of the most common sources: the gradient of jnp.linalg.norm at an all-zero vector ($\nabla\lVert b\rVert = b/\lVert b\rVert = 0/0$). The error only says invalid value (nan) encountered in mul. Apart from the call site (and this chapter's show_error wrapper), no frame in the traceback points to the function at fault, even with JAX_TRACEBACK_FILTERING=off. What works better here is checking the gradient leaf by leaf: first find which parameter's gradient is broken, then narrow it down (the hands-on in §9 walks through this in full).
def reg_loss(p, x):
return jnp.sum((x @ p["w"] + p["b"]) ** 2) + 1e-3 * jnp.linalg.norm(p["b"]) # b is initialized to 0
p_reg = {"w": jnp.ones((3, 2)), "b": jnp.zeros(2)}
x_reg = jnp.ones((4, 3))
with jax.debug_nans(True):
jax.clear_caches()
err = show_error(lambda: jax.grad(reg_loss)(p_reg, x_reg))
user_frames = [f for f in traceback.extract_tb(err.__traceback__) if "site-packages" not in f.filename]
print("User code frames in the traceback:", [(f.name, f.line) for f in user_frames])
# Check leaf by leaf: which parameter's gradient contains NaN
grads = jax.grad(reg_loss)(p_reg, x_reg)
bad_leaves = [jax.tree_util.keystr(path) for path, g in jax.tree_util.tree_leaves_with_path(grads)
if not bool(jnp.all(jnp.isfinite(g)))]
print("Leaves whose gradient contains NaN:", bad_leaves)
assert isinstance(err, FloatingPointError) and "mul" in str(err)
assert "reg_loss" not in [f.name for f in user_frames] # no frame lands in reg_loss
assert bad_leaves == ["['b']"]
3.2 jax.experimental.checkify¶
🟢 Must-read
07 showed the basic use of checkify(f, errors=float_checks) and that it is not affected by the vmap missed-detection issue. Here we go through its four kinds of checks systematically, plus custom assertions with checkify.check:
| Error set | Contains | What it catches |
|---|---|---|
checkify.user_checks (default) | FailedCheckError | Your own checkify.check(pred, msg, ...) |
checkify.nan_checks | NaNError | Any primitive producing a NaN (reports the primitive name) |
checkify.div_checks | DivisionByZeroError | A divisor of 0 (both integer and floating-point division) |
checkify.index_checks | OOBError | Out-of-bounds indexing (by default JAX silently clamps the index!) |
checkify.float_checks | NaN + division by zero | The usual combination |
checkify.all_checks | All of the above | The least hassle while debugging |
The message in checkify.check supports {name} placeholders; pass arrays as keyword arguments and they're formatted at runtime. Below we use one function and four inputs, turning on a different check set each time:
def risky(x, i):
checkify.check(jnp.all(x > 0), "x must be positive, min = {m}", m=jnp.min(x))
y = jnp.log(x) # x ≤ 0 → NaN
z = x[i] # i out of bounds → JAX clamps the index by default, no error
w = 7 // i # i == 0 → integer division by zero
return y.sum() + z + w
x_ok = jnp.array([1.0, 2.0, 3.0])
inputs = {"normal": (x_ok, 1), "x negative": (jnp.array([-1.0, 2.0, 3.0]), 1), "index OOB": (x_ok, 5), "div by 0": (x_ok, 0)}
sets = {"user": checkify.user_checks, "nan": checkify.nan_checks, "index": checkify.index_checks,
"div": checkify.div_checks, "all": checkify.all_checks}
table = {}
for sname, errs in sets.items():
checked = jax.jit(checkify.checkify(risky, errors=errs))
for iname, args in inputs.items():
err, _ = checked(*args)
table[(sname, iname)] = err.get()
print(f"{'Check set':8s}" + "".join(f"{n:>10s}" for n in inputs))
for sname in sets:
print(f"{sname:10s}" + "".join(f"{'✔ caught' if table[(sname, n)] else '—':>10s}" for n in inputs))
print("\nSample messages:")
for key in [("user", "x negative"), ("nan", "x negative"), ("index", "index OOB"), ("div", "div by 0")]:
print(f" {key}: {table[key].strip()[:90]}")
assert all(table[(s, "normal")] is None for s in sets)
assert "x must be positive, min = -1.0" in table[("user", "x negative")]
assert "nan generated by primitive: log" in table[("nan", "x negative")]
assert "out-of-bounds" in table[("index", "index OOB")] and table[("user", "index OOB")] is None
assert "division by zero" in table[("div", "div by 0")]
assert all(table[("all", n)] is not None for n in inputs if n != "normal")
clamped = float(jax.jit(lambda x, i: x[i])(x_ok, 5))
print("\nActual result of x[5] without checks:", clamped, "(the index is clamped to 2, a silent error)")
assert clamped == 3.0
# ❌ Wrong: using checkify.check directly inside jit without wrapping with checkify.checkify
def check_only(x):
checkify.check(x > 0, "x must be positive")
return jnp.log(x)
try:
jax.jit(check_only)(1.0)
raised = None
except Exception as e:
raised = e
print(f"Actual error: {type(e).__name__}: {str(e).splitlines()[0][:120]}")
assert isinstance(raised, ValueError) and "not functionalized" in str(raised)
# ✅ Correct: checkify first, then jit; once you have err, inspect it with err.get() or raise it with err.throw()
checked_only = jax.jit(checkify.checkify(check_only))
err_ok, _ = checked_only(1.0)
err_bad, _ = checked_only(-1.0)
print("Good input:", err_ok.get(), "| bad input:", err_bad.get())
try:
err_bad.throw()
except Exception as e:
thrown = e
print(f"err.throw() → {type(e).__name__} (inheritance chain {[c.__name__ for c in type(e).__mro__[:3]]})")
assert err_ok.get() is None and "x must be positive" in err_bad.get()
assert isinstance(thrown, checkify.JaxRuntimeError) and isinstance(thrown, ValueError)
Composing with jit / vmap / grad (the next cell checks each row):
| Combination | Behavior |
|---|---|
jit(checkify(f)) vs checkify(jit(f)) | Same result; usually write the former |
vmap(checkify(f)) | Returns a batched error; get() lists the index of every failing example (at mapped index 1: ...) |
checkify(vmap(f)) | Reports only one failing example and doesn't tell you which one |
checkify(grad(f), errors=nan_checks) | Catches NaNs produced in backprop (for example the mul when you forget one of the two wheres) |
grad(lambda x: checkify(f)(x)[1]) | Differentiates fine, but the error value is thrown away; the check has to sit outside grad |
check inside a lax.scan body | Works normally and reports the value from the first failing iteration |
float_checks and inf | Doesn't catch inf (exp(1000.) raises nothing); for inf, write your own check(jnp.isfinite(...)) |
checkify.debug_check | A no-op when not wrapped by checkify (no error, no check); when wrapped, same as check |
def pos_log(x):
checkify.check(x > 0, "x must be positive, got {v}", v=x)
return jnp.log(x)
xs_mixed = jnp.array([1.0, -2.0, 3.0, -4.0])
r = {}
r["jit(checkify)"] = jax.jit(checkify.checkify(pos_log))(jnp.float32(-1.0))[0].get()
r["checkify(jit)"] = checkify.checkify(jax.jit(pos_log))(jnp.float32(-1.0))[0].get()
r["vmap(checkify)"] = jax.vmap(checkify.checkify(pos_log))(xs_mixed)[0].get()
r["checkify(vmap)"] = checkify.checkify(jax.vmap(pos_log))(xs_mixed)[0].get()
bad_where = lambda x: jnp.sum(jnp.where(x > 0, jnp.sqrt(x), 0.0)) # single where: the backward pass produces NaN
r["checkify(grad)"] = checkify.checkify(jax.grad(bad_where), errors=checkify.nan_checks)(jnp.array([0.0, 1.0]))[0].get()
g_inside = jax.grad(lambda x: checkify.checkify(pos_log)(x)[1])(jnp.float32(-1.0))
def scan_body(c, x):
checkify.check(x < 10, "value at step {i} is too large: {x}", i=c[1], x=x)
return (c[0] + x, c[1] + 1), None
r["scan"] = jax.jit(checkify.checkify(lambda xs: lax.scan(scan_body, (0.0, 0), xs)[0]))(jnp.array([1.0, 20.0, 30.0]))[0].get()
r["float_checks and inf"] = checkify.checkify(jnp.exp, errors=checkify.float_checks)(jnp.float32(1000.0))[0].get()
@jax.jit
def dbg_fn(x):
checkify.debug_check(x > 0, "debug_check failed: {x}", x=x)
return x * 2
dbg_plain = float(dbg_fn(-1.0))
r["debug_check + checkify"] = checkify.checkify(dbg_fn)(-1.0)[0].get()
for k, v in r.items():
print(f" {k:22s} → {v.strip() if v else None}".replace("\n", " ⏎ "))
print(" gradient of grad(checkify(f)[1]):", g_inside, "| result of debug_check when not wrapped:", dbg_plain)
assert r["jit(checkify)"] == r["checkify(jit)"] and "-1.0" in r["jit(checkify)"]
assert "at mapped index 1" in r["vmap(checkify)"] and "at mapped index 3" in r["vmap(checkify)"]
assert "mapped index" not in r["checkify(vmap)"] and r["checkify(vmap)"].count("must be positive") == 1
assert "nan generated by primitive: mul" in r["checkify(grad)"]
assert float(g_inside) == -1.0 # it differentiates, but the check is thrown away
assert "value at step 1 is too large: 20.0" in r["scan"]
assert r["float_checks and inf"] is None
assert dbg_plain == -2.0 and "debug_check failed" in r["debug_check + checkify"]
🔍 Extra: format arguments to checkify.check must be arrays¶
⚪ Optional: when checkify.check raises TypeError in eager mode
Inside jit, the x in checkify.check(..., v=x) is a tracer, and that's fine. But in eager mode, passing a Python scalar directly raises TypeError: Formatting arguments to checkify.check need to be PyTrees of arrays. Writing v=jnp.asarray(x) works in both cases.
e_py = show_error(lambda: checkify.checkify(pos_log)(-1.0))
e_arr, _ = checkify.checkify(pos_log)(jnp.asarray(-1.0))
assert isinstance(e_py, TypeError) and "PyTrees of arrays" in str(e_py)
assert "got -1.0" in e_arr.get()
3.3 Gradient checking: jax.test_util.check_grads¶
⚪ Optional: when you've written a custom_vjp or a hand-written gradient and need to verify it
For the basics (you must import jax.test_util explicitly, order=2 checks second order, hand-written central differences), see 03 · Checking gradients with finite differences, which used custom_jvp to show it catching a wrong derivative. Here we add the custom_vjp case:
- Only
modes=["rev"]works;modes=["fwd"]raisesTypeError: can't apply forward-mode autodiff (jvp) to a custom_vjp function; - On failure it raises
AssertionError, withVJP cotangent projectionand the actual / expected values in the message; - Under float32 the default tolerance is about
2e-3; adjust withatol/rtol/epsif needed, or check with float64 inputs insidewith jax.enable_x64(True):.
# ❌ Wrong: the backward rule of the custom_vjp has the wrong sign (the derivative of softplus should be sigmoid(x))
@jax.custom_vjp
def softplus_bad(x):
return jnp.logaddexp(x, 0.0)
softplus_bad.defvjp(lambda x: (softplus_bad(x), x), lambda x, g: (g * jax.nn.sigmoid(-x),))
x_cg = jnp.array([-1.0, 0.5, 2.0])
try:
check_grads(softplus_bad, (x_cg,), order=1, modes=["rev"])
cg_err = None
except AssertionError as e:
cg_err = e
print("check_grads found an error:", " | ".join(str(e).strip().splitlines()[:2]))
e_fwd = show_error(lambda: check_grads(softplus_bad, (x_cg,), order=1, modes=["fwd"]))
assert cg_err is not None and "VJP" in str(cg_err)
assert isinstance(e_fwd, TypeError) and "custom_vjp" in str(e_fwd)
# ✅ Correct: the backward rule uses sigmoid(x), and check_grads passes quietly (it prints nothing when it passes)
@jax.custom_vjp
def softplus_ok(x):
return jnp.logaddexp(x, 0.0)
softplus_ok.defvjp(lambda x: (softplus_ok(x), x), lambda x, g: (g * jax.nn.sigmoid(x),))
check_grads(softplus_ok, (x_cg,), order=1, modes=["rev"])
assert np.allclose(jax.grad(lambda x: softplus_ok(x).sum())(x_cg), 1 / (1 + np.exp(-to64(x_cg))), atol=1e-6)
print("check_grads passed ✔")
4 A catalog of Tracer errors¶
Inside jit / vmap, a function's arguments are tracers that know only their shape and dtype. Any Python operation that needs a "concrete value" (if, range, float(), np.asarray, shapes that depend on values…) raises an error. The table below comes from running on 0.12.0.1, and each row has matching ❌ / ✅ cells further down:
| Triggering code | Actual exception | Standard fix |
|---|---|---|
if x > 0:, while x > 1:, and / or | TracerBoolConversionError | jnp.where, lax.cond, lax.while_loop |
range(n), [a, b, c][i] (__index__) | TracerIntegerConversionError | static_argnames; lax.fori_loop; jnp.asarray(list)[i] first |
np.sin(x), np.asarray(x) (__array__) | TracerArrayConversionError | Switch to jnp.*; when you really need NumPy / an external library, use jax.pure_callback |
float(x), int(x), x.item(), jnp.arange(n), jnp.nonzero(x) | ConcretizationTypeError | Static argument; fixed size + masking; jnp.nonzero(x, size=k) |
x[x > 0] (boolean mask indexing) | NonConcreteBooleanIndexError (an IndexError subclass) | jnp.where(mask, x, 0) + masked sum |
jnp.zeros(n), x.reshape(n, -1) (a tracer in the shape) | Plain TypeError: Shapes must be 1D sequences of concrete values | Make the shape argument static |
x[i:i+2] (the slice bound is a tracer) | Plain IndexError | lax.dynamic_slice(x, (i,), (2,)) |
⚠️ A common misconception: it's easy to assume these three subclasses all inherit from
ConcretizationTypeErrorand can be caught together (see also the 02 · 2.4 extra). Actually, in 0.12.0.1 (checked in the next cell), onlyTracerBoolConversionErroris a subclass of it.TracerIntegerConversionErrorandTracerArrayConversionErrorinherit directly fromJAXTypeError(which is also aTypeError). To catch them all at once, usejax.errors.JAXTypeErrororTypeError.
E = jax.errors
for cls in [E.ConcretizationTypeError, E.TracerBoolConversionError, E.TracerIntegerConversionError,
E.TracerArrayConversionError, E.NonConcreteBooleanIndexError, E.UnexpectedTracerError]:
print(f" {cls.__name__:30s} → {[c.__name__ for c in cls.__mro__[1:4]]}")
assert issubclass(E.TracerBoolConversionError, E.ConcretizationTypeError)
assert not issubclass(E.TracerIntegerConversionError, E.ConcretizationTypeError)
assert not issubclass(E.TracerArrayConversionError, E.ConcretizationTypeError)
assert all(issubclass(c, E.JAXTypeError) and issubclass(c, TypeError) for c in
[E.ConcretizationTypeError, E.TracerIntegerConversionError, E.TracerArrayConversionError, E.UnexpectedTracerError])
assert issubclass(E.NonConcreteBooleanIndexError, IndexError)
How to read a Tracer error: the message is long, but only two sentences matter. Below we extract them:
- The first line: which conversion failed (
Attempted boolean conversion of traced array with shape bool[]); The error occurred while tracing the function <name> at <file>:<line> for jit. This concrete value was not available in Python because it depends on the value of the argument <argument name>.(with several arguments it readsthe values of the arguments x and limit). This sentence tells you directly which function and which argument caused the trouble. There are usually just two fixes: make that argument static, or replace the Python control flow that depends on its value with JAX primitives.
def clip_positive(x, limit):
if x > limit: # depends on the values of x and limit
return limit
return x
e = show_error(lambda: jax.jit(clip_positive)(jnp.float32(3.0), 1.0))
msg = " ".join(str(e).split()) # the original message has line breaks; squash it into one line before matching
m = re.search(r"The error occurred while tracing the function (\S+) at \S+ for jit\..*?"
r"depends on the values? of the arguments? (.+?)\.", msg)
print("Function at fault:", m.group(1), "| culprit argument(s):", m.group(2))
assert isinstance(e, E.TracerBoolConversionError)
assert m.group(1) == "clip_positive" and m.group(2) == "x and limit" # both arguments take part in the comparison
4.1 TracerBoolConversionError¶
🟢 Must-read
# ❌ Wrong: a Python if that depends on a traced value
@jax.jit
def relu_if(x):
if x > 0:
return x
return 0.0 * x
try:
relu_if(jnp.float32(2.0))
caught = None
except Exception as e:
caught = e
print(f"Actual error: {type(e).__name__}: {str(e).splitlines()[0]}")
assert isinstance(caught, E.TracerBoolConversionError)
# ✅ Correct: use jnp.where elementwise; use lax.cond when only one branch should run
@jax.jit
def relu_where(x):
return jnp.where(x > 0, x, 0.0)
@jax.jit
def relu_cond(x):
return lax.cond(x > 0, lambda x: x, lambda x: 0.0 * x, x)
xs = jnp.array([-2.0, 0.0, 3.0])
assert np.array_equal(relu_where(xs), [0.0, 0.0, 3.0])
assert [float(relu_cond(v)) for v in xs] == [0.0, 0.0, 3.0]
print("jnp.where:", relu_where(xs), "| lax.cond:", [float(relu_cond(v)) for v in xs])
With
gradalone (nojit), a Pythonifdoes not raise an error, because grad's tracers carry concrete values (see 00 Overview). But the same function fails as soon as you put it invmaporjit. Don't be fooled by tests that only use eager + grad.
4.2 TracerIntegerConversionError¶
⚪ Optional: look up the fix in the table when you hit this error
# ❌ Wrong: using a traced value as the range count or as a Python list index
def repeat_add(x, n):
for _ in range(n):
x = x + 1.0
return x
def pick(i):
return [10.0, 20.0, 30.0][i]
for name, fn in [("range(n)", lambda: jax.jit(repeat_add)(1.0, 3)), ("list[i]", lambda: jax.jit(pick)(1))]:
try:
fn()
caught = None
except Exception as e:
caught = e
print(f"{name:9s} actual error: {type(e).__name__}: {str(e).splitlines()[0]}")
assert isinstance(caught, E.TracerIntegerConversionError)
# ✅ Correct: the count determines the computation structure → static argument; or use fori_loop; convert the list to an array before indexing
repeat_static = jax.jit(repeat_add, static_argnames="n")
repeat_fori = jax.jit(lambda x, n: lax.fori_loop(0, n, lambda _, v: v + 1.0, x))
pick_arr = jax.jit(lambda i: jnp.asarray([10.0, 20.0, 30.0])[i])
assert float(repeat_static(1.0, n=3)) == 4.0 and float(repeat_fori(1.0, 3)) == 4.0 and float(pick_arr(1)) == 20.0
print("static:", repeat_static(1.0, n=3), "| fori_loop:", repeat_fori(1.0, 3), "| array indexing:", pick_arr(1))
4.3 TracerArrayConversionError¶
⚪ Optional: look up the fix in the table when you hit this error
# ❌ Wrong: calling a NumPy function inside jit (it tries to convert the tracer to an np.ndarray)
@jax.jit
def np_inside(x):
return np.sin(x) * 2
try:
np_inside(jnp.ones(3))
caught = None
except Exception as e:
caught = e
print(f"Actual error: {type(e).__name__}: {str(e).splitlines()[0]}")
assert isinstance(caught, E.TracerArrayConversionError)
# ✅ Correct: switch to jnp; when you must call NumPy / an external library, use pure_callback and declare the output shape (see Advanced 05)
@jax.jit
def jnp_inside(x):
return jnp.sin(x) * 2
@jax.jit
def via_callback(x):
out_type = jax.ShapeDtypeStruct(x.shape, x.dtype)
return jax.pure_callback(lambda v: (np.sin(v) * 2).astype(v.dtype), out_type, x)
ref = np.sin(np.ones(3)) * 2
assert np.allclose(jnp_inside(jnp.ones(3)), ref) and np.allclose(via_callback(jnp.ones(3)), ref)
print("jnp:", jnp_inside(jnp.ones(3)), "| pure_callback:", via_callback(jnp.ones(3)))
4.4 ConcretizationTypeError (the base class itself)¶
⚪ Optional: look up the fix in the table when you hit this error
# ❌ Wrong: float() / .item() / arange and nonzero whose size depends on values
cases_bad = {
"float(x)": lambda: jax.jit(lambda x: float(x) * 2)(jnp.float32(1.0)),
"x.item()": lambda: jax.jit(lambda x: x.item())(jnp.float32(1.0)),
"jnp.arange(n)": lambda: jax.jit(lambda n: jnp.arange(n))(3),
"jnp.nonzero(x)": lambda: jax.jit(lambda x: jnp.nonzero(x)[0])(jnp.array([1.0, 0.0, 2.0])),
}
for name, fn in cases_bad.items():
try:
fn()
caught = None
except Exception as e:
caught = e
print(f"{name:15s} actual error: {type(e).__name__}: {str(e).splitlines()[0][:80]}")
assert type(caught) is E.ConcretizationTypeError
# ✅ Correct: keep the computation in JAX; make value-dependent sizes static arguments, or give a fixed upper bound size=
f_float = jax.jit(lambda x: x * 2) # no need for float(), just compute
f_arange = jax.jit(lambda n: jnp.arange(n), static_argnames="n")
f_nonzero = jax.jit(lambda x: jnp.nonzero(x, size=3, fill_value=-1)[0]) # fixed output length, padded with -1
assert float(f_float(jnp.float32(1.0))) == 2.0
assert f_arange(n=3).tolist() == [0, 1, 2]
assert f_nonzero(jnp.array([1.0, 0.0, 2.0])).tolist() == [0, 2, -1]
print("arange(static n):", f_arange(n=3), "| nonzero(size=3):", f_nonzero(jnp.array([1.0, 0.0, 2.0])))
4.5 Tracers in boolean mask indexing, shapes and slices¶
🟢 Must-read
These three errors aren't in the ConcretizationTypeError family, but the cause is the same: the output shape depends on values. jit requires every shape to be known at trace time.
# ❌ Wrong: boolean mask indexing / a tracer as a shape argument / a tracer as a slice bound
cases_shape = {
"x[x > 0]": (lambda: jax.jit(lambda x: x[x > 0].sum())(jnp.array([1.0, -1.0, 2.0])), E.NonConcreteBooleanIndexError),
"jnp.zeros(n)": (lambda: jax.jit(lambda n: jnp.zeros(n))(3), TypeError),
"x[i:i+2]": (lambda: jax.jit(lambda x, i: x[i:i + 2])(jnp.arange(5.0), 1), IndexError),
}
for name, (fn, expected) in cases_shape.items():
try:
fn()
caught = None
except Exception as e:
caught = e
print(f"{name:13s} actual error: {type(e).__name__}: {str(e).splitlines()[0][:90]}")
assert isinstance(caught, expected)
# ✅ Correct: masked sum instead of boolean indexing; make the shape static; use lax.dynamic_slice for dynamic slices (the length must be fixed)
pos_sum = jax.jit(lambda x: jnp.sum(jnp.where(x > 0, x, 0.0)))
zeros_n = jax.jit(lambda n: jnp.zeros(n), static_argnums=0)
window = jax.jit(lambda x, i: lax.dynamic_slice(x, (i,), (2,)))
assert float(pos_sum(jnp.array([1.0, -1.0, 2.0]))) == 3.0
assert zeros_n(3).shape == (3,)
assert window(jnp.arange(5.0), 1).tolist() == [1.0, 2.0]
print("masked sum:", pos_sum(jnp.array([1.0, -1.0, 2.0])), "| dynamic_slice:", window(jnp.arange(5.0), 1))
4.6 UnexpectedTracerError: tracer leaks¶
🟢 Must-read
If you store an intermediate value (a tracer) outside the function during tracing, for example in a global list, an object attribute, or a mutable container in a closure, and use it after tracing ends, you get UnexpectedTracerError (Advanced 01 showed the simplest case). The annoying part: the error is raised where the value is "used", not where it "leaked", and the two can be far apart.
# ❌ Wrong: storing an intermediate result on an object attribute inside a jit function and using it outside later
class Model:
def __call__(self, x):
h = jnp.tanh(x)
self.last_hidden = h # leak: h is a tracer
return h * 2
m_leak = Model()
jax.jit(m_leak)(jnp.ones(3)) # this step doesn't raise an error
try:
jnp.sum(m_leak.last_hidden) # it only gets used much later
caught = None
except Exception as e:
caught = e
print(f"Actual error: {type(e).__name__}: {str(e).splitlines()[0][:110]}")
assert isinstance(caught, E.UnexpectedTracerError)
# ✅ Correct: return the intermediate values you need explicitly
class ModelOK:
def __call__(self, x):
h = jnp.tanh(x)
return h * 2, h # (output, auxiliary info)
out, hidden = jax.jit(ModelOK())(jnp.ones(3))
assert np.allclose(hidden, np.tanh(1.0)) and np.allclose(out, 2 * np.tanh(1.0))
print("hidden, returned explicitly:", hidden)
Leak checking: error out the moment the leak happens. 0.12 has three equivalent ways to write it:
| Form | Notes |
|---|---|
with jax.checking_leaks(): | functools.partial(jax.check_tracer_leaks, True), the most concise |
with jax.check_tracer_leaks(True): | Context manager for the config option jax_check_tracer_leaks |
jax.config.update("jax_check_tracer_leaks", True) | Turns it on globally |
Once it's on, every time tracing finishes it checks whether any tracer is still referenced from outside. If so, it raises an exception right away (a plain Exception), and the message gives the reference chain: which tracer, and what refers to it (<list> is referred to by __main__.leaked). It disables some caching and adds overhead, so use it only while debugging.
leaked = []
def leaky_fn(x):
leaked.append(x) # leaks into a global list
return x + 1
jax.clear_caches()
try:
with jax.checking_leaks():
jax.jit(leaky_fn)(jnp.float32(1.0))
leak_err = None
except Exception as e:
leak_err = e
msg_lines = str(leak_err).splitlines()
print("Type:", type(leak_err).__name__)
for l in msg_lines[:1] + [l for l in msg_lines if "referred to by" in l][:2]:
print(" ", l[:120])
assert type(leak_err) is Exception and msg_lines[0].startswith("Leaked trace")
assert any("is referred to by" in l and "leaked" in l for l in msg_lines)
leaked.clear()
⚠️ Leak checking happens only during "tracing". If the function has already been traced and cached, calling it again with the check on doesn't retrace, so it doesn't raise an error. So call jax.clear_caches() before investigating (or use a new input signature).
leaked2 = []
def leaky_cached(x):
leaked2.append(x)
return x * 2
leaky_cached_jit = jax.jit(leaky_cached)
leaky_cached_jit(jnp.float32(1.0)) # call it once first with the check off (now cached)
def try_with_check():
try:
with jax.checking_leaks():
leaky_cached_jit(jnp.float32(2.0))
return "no error"
except Exception as e:
return f"error: {str(e).splitlines()[0]}"
r_cached = try_with_check()
jax.clear_caches()
r_cleared = try_with_check()
print("On a cache hit:", r_cached)
print("After clear_caches:", r_cleared)
assert r_cached == "no error" and r_cleared.startswith("error: Leaked trace")
leaked2.clear()
5 Compilation problems and speed¶
The triggers for recompilation (shape, dtype, weak types, static argument values, PyTree structure, how arguments are passed, creating a new jit object every time) were tested one by one in the 02 · 2.3 overview table. The remedies (bucketing, static configs hashed by value, AOT) and the compile budget tool CompileBudget are in Basics 07 Topic 1. This section covers only the details of the diagnostic tools themselves: what each one outputs, and when it "stops working".
| Tool | Question it answers | Added in this section |
|---|---|---|
jax_log_compiles | What was compiled and how long it took | One compilation emits 4 log lines; eager operations like jnp.ones also show up in the log |
jax_explain_cache_misses | Why the cache missed | When a static argument value changes, it only says "explanation unavailable" |
f._cache_size() | How many entries are in the cache | Private API; all jit wrappers of the same Python function share the count |
CompileCounter / CompileBudget | Exact counts you can put in an assert | See Basics 07 Topic 1.6 |
5.1 jax_log_compiles: one compilation = 4 log lines¶
⚪ Optional: when you need to parse compile logs and tally compile time per function
Besides config.update, jax.log_compiles() is also a context manager. Every compilation that actually happens emits 4 WARNING log lines: tracing time, Compiling ... with global shapes and types (with the shapes of all arguments), jaxpr → MLIR time, and XLA compile time. Below we parse them into a "compile time per function" table. Note that eager calls like jnp.ones(...) also compile small functions themselves (jit(broadcast_in_dim) and so on), so filter by function name.
def mlp_step(w, x):
return jnp.tanh(x @ w).sum()
mlp_step_jit = jax.jit(mlp_step)
jax.clear_caches() # clear the caches so the eager operations below also recompile, which makes them visible
with capture_jax_logs() as logs, jax.log_compiles():
w5 = jnp.ones((64, 64)) # an eager operation, which also produces compile logs
mlp_step_jit(w5, jnp.ones((8, 64)))
mlp_step_jit(w5, jnp.ones((8, 64))) # cache hit: no logs
mlp_step_jit(w5, jnp.ones((16, 64))) # new shape: compiles again
pat_xla = re.compile(r"Finished XLA compilation of (jit\(.+?\)) in ([\d.]+) sec")
pat_shape = re.compile(r"Compiling (jit\(.+?\)) with global shapes and types (.+?)\. Argument")
xla_times = [(m.group(1), float(m.group(2))) for m in map(pat_xla.search, logs.messages) if m]
shapes = [(m.group(1), m.group(2)) for m in map(pat_shape.search, logs.messages) if m]
print("Start of each log line (first two words):", sorted({m.split(" ")[0] + " " + m.split(" ")[1] for m in logs.messages}))
for (fn, t), (_, sh) in zip(xla_times, shapes):
print(f" {fn:28s} XLA compile {t * 1e3:6.1f} ms args {sh[:70]}")
mine = [s for fn, s in shapes if fn == "jit(mlp_step)"]
assert len(mine) == 2 and "float32[16,64]" in mine[1]
assert any(fn != "jit(mlp_step)" for fn, _ in shapes) # compilations from eager operations got mixed in
assert {"Finished tracing", "Compiling jit(mlp_step)", "Finished jaxpr", "Finished XLA"} <= \
{" ".join(m.split(" ")[:2]) for m in logs.messages}
5.2 jax_explain_cache_misses: three explanations, and one case where it "goes quiet"¶
🟢 Must-read
It compares against the closest old cache key and tells you which property of which argument changed (for examples with shapes, weak types and PyTree structure, see Basics 07 Topic 1.4 and Advanced 02 · 2.5). In 0.12, its output comes in three kinds:
| Situation | Output |
|---|---|
| First call | never seen function: <name> id=... defined at <file:line> |
| Input types / PyTree structure changed | different input types: * at x, now i32[5] and before f32[5], or different input pytree: ... |
| A static argument's value changed | Only cache miss explanation unavailable |
The last one is the most confusing, because the tool "says nothing". Rule of thumb from practice: when you see explanation unavailable, go check the static arguments (and things that aren't in the argument list, like closures and global config).
@partial(jax.jit, static_argnames=("step",))
def decay_step(w, x, step):
return w - 0.1 * 0.99 ** step * x
w0, x0 = jnp.ones(3), jnp.ones(3)
with capture_jax_logs() as logs, jax.explain_cache_misses():
decay_step(w0, x0, step=0) # first call
decay_step(w0, x0, step=1) # the static argument's value changed
decay_step(w0.astype(jnp.int32), x0, step=1) # the input type changed
misses = [m for m in logs.messages if "CACHE MISS" in m]
for m in misses:
body_lines = [l.strip() for l in m.splitlines()[1:] if not l.strip().startswith(("for ", "all previously"))]
print(" •", " / ".join(body_lines)[:120])
assert len(misses) == 3
assert "never seen function" in misses[0]
assert "explanation unavailable" in misses[1]
assert "different input types" in misses[2] and "at w, now i32[3] and before f32[3]" in misses[2]
5.3 f._cache_size() (private API)¶
⚪ Optional: for a quick look at the number of cache entries; it's a private API, so keep it out of tests
jax.jit(f)._cache_size() returns the number of cache entries. It's a private API (it starts with an underscore and could be renamed or removed at any time), so it's only good for a quick look while debugging; don't put it in real tests. It has three quirks:
- The cache hangs off the underlying Python function: if you
jax.jit(f)the samefagain, the new wrapper's_cache_size()isn't 0 even at the start; - Wrappers with different jit options also share this count;
- It goes back to zero after
jax.clear_caches().
Because of point 1, it can't answer "how many times did this wrapper compile". For exact counts, use compile logs or CompileCounter (same advice as in 02 · 2.3).
def cache_demo(x):
return x * 2
j1 = jax.jit(cache_demo)
sizes = {"new": j1._cache_size()}
j1(1.0); j1(2.0); j1(jnp.ones(3))
sizes["after 3 calls (2 signatures)"] = j1._cache_size()
j2 = jax.jit(cache_demo)
sizes["second wrapper (not called yet)"] = j2._cache_size()
j2(jnp.ones(4))
sizes["seen by j1 after j2 compiles once"] = j1._cache_size()
j3 = jax.jit(cache_demo, inline=True)
sizes["wrapper with different options"] = j3._cache_size()
jax.clear_caches()
sizes["after clear_caches()"] = j1._cache_size()
for k, v in sizes.items():
print(f" {k:24s} {v}")
assert list(sizes.values()) == [0, 2, 2, 3, 3, 0]
5.4 Compile counting: put it in an assertion¶
⚪ Optional: the minimal way to put compile counts in an assertion; the full tool is in Basics 07 Topic 1.6
Logs are for people, counters are for tests. The CompileCounter in the common setup counts real backend compilation events and can filter by function name. Wrapping it as CompileBudget / @compile_budget, which raises an error when the budget is exceeded, is covered in Basics 07 Topic 1.6. Below is the minimal usage; the hands-on in §9 uses it to prove a fix works.
with CompileCounter("mlp_step") as cc:
for n in [8, 8, 16, 16, 8]:
mlp_step_jit(w5, jnp.ones((n, 64)))
print("5 calls, 2 shapes → compiled", cc.count, "times") # we called clear_caches above, so both shapes have to recompile
assert cc.count == 2
5.5 Slow code: what to check, in order¶
🟢 Must-read
- Is it recompiling over and over? Use the tools from 5.1–5.4. This is the number one cause of "it suddenly got slow" on CPU.
- Is the timing right? JAX uses asynchronous dispatch, so timing without
block_until_readyonly measures dispatch time (Advanced 02 · 2.7). - Is the time spent compiling or running? Use
jax.stagesto split one call into trace / lower / compile / run and time each part (next cell). - Which operator does the run time go to? Capture a trace with
jax.profiler.trace(Advanced 02 · 2.1) and match it up using thenamed_scopefrom §2.2. - Are there implicit host ↔ device transfers? Use
jax.transfer_guard(the extra below).
def deep(ws, x):
for w in ws: # the Python loop unrolls: more layers, slower compilation
x = jnp.tanh(x @ w)
return x.sum()
ws_deep = [jax.random.normal(k, (32, 32)) / 6.0 for k in jax.random.split(jax.random.key(5), 40)]
x_deep = jnp.ones((8, 32))
jd = jax.jit(deep)
t0 = time.perf_counter(); tr = jd.trace(ws_deep, x_deep)
t1 = time.perf_counter(); lo = tr.lower()
t2 = time.perf_counter(); co = lo.compile()
t3 = time.perf_counter(); jax.block_until_ready(co(ws_deep, x_deep))
t4 = time.perf_counter()
for _ in range(20):
jax.block_until_ready(co(ws_deep, x_deep))
t5 = time.perf_counter()
print(f"trace {1e3*(t1-t0):.1f} ms | lower {1e3*(t2-t1):.1f} ms | compile {1e3*(t3-t2):.1f} ms | "
f"first run {1e3*(t4-t3):.2f} ms | steady state {1e3*(t5-t4)/20:.3f} ms/call")
ref_deep = to64(x_deep)
for w in ws_deep:
ref_deep = np.tanh(ref_deep @ to64(w))
assert np.isclose(float(co(ws_deep, x_deep)), ref_deep.sum(), rtol=1e-4)
🔍 Extra: catching implicit data transfers with jax.transfer_guard¶
⚪ Optional: when you suspect implicit host-device transfers on GPU/TPU
Passing a NumPy array or a Python scalar straight into a jit function does an implicit host → device transfer on every call. On GPU/TPU this can be the main bottleneck, and nothing in the code shows it. with jax.transfer_guard("disallow"): makes implicit transfers raise an error ("log" only logs them, "allow" is the default); an explicit jax.device_put is not affected.
Let's try it on this machine's CPU (next cell): the host → device direction (NumPy array and Python scalar arguments) is caught; the device → host direction (float(x)) does not raise, because arrays on the CPU backend already live in host memory. According to the docs, implicit device → host transfers are blocked too on GPU, but that can't be verified on this machine.
f_tg = jax.jit(lambda x: x * 2)
x_dev = jnp.ones(3)
f_tg(x_dev)
tg = {}
with jax.transfer_guard("disallow"):
for name, fn in [("device array arg", lambda: f_tg(x_dev)),
("NumPy array arg", lambda: f_tg(np.ones(3, np.float32))),
("Python scalar arg", lambda: f_tg(1.0)),
("explicit device_put", lambda: f_tg(jax.device_put(np.ones(3, np.float32)))),
("float(result) (device→host)", lambda: float(f_tg(x_dev).sum()))]:
try:
fn(); tg[name] = "allowed"
except Exception as e:
tg[name] = f"blocked ({type(e).__name__})"
for k, v in tg.items():
print(f" {k:22s} {v}")
assert tg["device array arg"] == tg["explicit device_put"] == "allowed"
assert tg["NumPy array arg"].startswith("blocked") and tg["Python scalar arg"].startswith("blocked")
assert tg["float(result) (device→host)"] == "allowed" # CPU-specific: on GPU this would be blocked
6 Error messages and configuration¶
6.1 JAX_TRACEBACK_FILTERING: how many JAX-internal frames show up in a traceback¶
⚪ Optional: when a traceback is hard to read, or when you're filing an issue with JAX
A single jit call in JAX passes through dozens of internal functions. To keep tracebacks readable, JAX hides these internal frames by default. The behavior is controlled by the config option jax_traceback_filtering (environment variable JAX_TRACEBACK_FILTERING). The table below lists the valid values in 0.12.0.1, read from the config definition and tried one by one. If you give an invalid value, import jax fails right away with ValueError: Invalid value ... for JAX flag jax_traceback_filtering.
| Value | Behavior |
|---|---|
auto (default) | Same as tracebackhide in IPython ≥ 7.17, otherwise same as quiet_remove_frames |
tracebackhide | Keeps all frames but marks internal frames with __tracebackhide__; IPython / Jupyter collapse them into [... skipping hidden N frame] |
remove_frames | Actually removes the internal frames; the full original traceback is attached to the exception as __cause__ (UnfilteredStackTrace) |
quiet_remove_frames | Removes internal frames and only appends a short hint (For simplicity, JAX has removed its internal frames ...) |
off | No filtering, shows every frame. Use it when you suspect a bug in JAX itself and want to file an issue |
This setting is read at the moment the exception is raised, so you can switch it at any time in the process. Below we trigger the same error once under each of the 5 modes and count the frames in the traceback, the __cause__, and any attached notes:
def inner_reshape(x):
return x.reshape(3, 5) # reshape 4 elements into 3×5: error
outer_jit = jax.jit(lambda x: inner_reshape(x) * 2)
def tb_profile(mode):
jax.config.update("jax_traceback_filtering", mode)
try:
outer_jit(jnp.ones(4))
except Exception as e:
frames = traceback.extract_tb(e.__traceback__)
return {"frames": len(frames),
"jax internal frames": sum("/jax/_src/" in f.filename for f in frames),
"__cause__": type(e.__cause__).__name__ if e.__cause__ else "—",
"note": len(getattr(e, "__notes__", [])),
"user frames": [f.name for f in frames if "site-packages" not in f.filename]}
finally:
jax.config.update("jax_traceback_filtering", "auto")
tb = {mode: tb_profile(mode) for mode in ["auto", "tracebackhide", "off", "remove_frames", "quiet_remove_frames"]}
for mode, r in tb.items():
print(f" {mode:20s} frames {r['frames']:2d} | JAX internal frames {r['jax internal frames']:2d} | __cause__ {r['__cause__']:20s} | note {r['note']}")
assert tb["auto"]["frames"] == tb["tracebackhide"]["frames"] == tb["off"]["frames"] # in Jupyter, auto = tracebackhide
assert tb["remove_frames"]["frames"] < tb["off"]["frames"]
assert tb["remove_frames"]["__cause__"] == "UnfilteredStackTrace" and tb["quiet_remove_frames"]["note"] == 1
assert all("inner_reshape" in r["user frames"] for r in tb.values()) # your own frames are always kept
assert jax.config.jax_traceback_filtering == "auto"
In a plain Python process (not Jupyter), auto is the same as quiet_remove_frames: an extra hint shows up at the end of the error. Setting the environment variable JAX_TRACEBACK_FILTERING=off turns filtering off:
TB_SCRIPT = """
import jax, jax.numpy as jnp
jax.jit(lambda x: x.reshape(3, 5))(jnp.ones(4))
"""
tb_auto = run_py(TB_SCRIPT).stderr
tb_off = run_py(TB_SCRIPT, env={"JAX_TRACEBACK_FILTERING": "off"}).stderr
tb_bad = run_py("import jax", env={"JAX_TRACEBACK_FILTERING": "bogus"}).stderr
print("End of the output in auto mode:\n ", "\n ".join(tb_auto.strip().splitlines()[-3:]))
print(f"stderr line count: auto {len(tb_auto.splitlines())} lines, off {len(tb_off.splitlines())} lines")
print("Invalid value:", tb_bad.strip().splitlines()[-1])
assert "JAX has removed its internal frames" in tb_auto and "JAX has removed" not in tb_off
assert len(tb_off.splitlines()) > len(tb_auto.splitlines())
assert 'Invalid value "bogus" for JAX flag jax_traceback_filtering' in tb_bad
How to read a JAX traceback (read it bottom to top):
- The last line: the exception type and message. For Tracer errors, look first for the sentence "The error occurred while tracing the function … argument …" described in §4;
- Go up to the first frame from your own file: that's the line at fault. Under jit, user frames show up twice: once where the jit function is called, and once inside the traced function (
inner_reshapein the example); - The
jax/_src/...frames in between usually don't need reading;[... skipping hidden N frame]is the part folded away bytracebackhide; - Runtime errors (
JaxRuntimeError,XlaRuntimeError) happen inside the compiled program, so the traceback has only the call site and no lines from inside the function; narrow it down withcheckifyordebug.print; - Use
JAX_TRACEBACK_FILTERING=offonly when you suspect a problem in JAX itself and need to give the maintainers the full picture.
The small helper below picks out the frames in a traceback that belong to your own code, handy for a quick look inside an except:
def user_frames(exc, skip=("site-packages", "<frozen")):
"""Return the frames in an exception's traceback that don't belong to third-party libraries: [(function name, line number, code)]."""
return [(f.name, f.lineno, (f.line or "").strip()) for f in traceback.extract_tb(exc.__traceback__)
if not any(s in f.filename for s in skip)]
try:
outer_jit(jnp.ones(4))
except Exception as e:
uf = user_frames(e)
for name, lineno, line in uf:
print(f" {name:15s} line {lineno}: {line}")
assert uf[-1][0] == "inner_reshape" and "reshape(3, 5)" in uf[-1][2]
6.2 Checking the current values in jax.config¶
⚪ Optional: when you suspect you forgot to turn off a debug switch
Forgetting to turn off a debug switch is a common cause of "the code got slow for no reason" (jax_debug_nans and jax_check_tracer_leaks both disable some caching). There are several ways to read the config:
| Form | Notes |
|---|---|
jax.config.jax_debug_nans | The most common, attribute access |
jax.debug_nans.value | Works for any option that has a context manager of the same name |
jax.config.values | A dict with every config option (140+ in 0.12.0.1) |
jax.config.read("jax_debug_nans") | ⚠️ Raises AttributeError for options that have a context manager; don't use it |
Environment variables like JAX_DEBUG_NANS=1 | Read at process startup; same effect as config.update |
Below we write nondefault_flags(): it reads the default config in a clean subprocess, compares it with the current process, and lists every option that was changed. At the end of the chapter we use it to confirm all debug switches were restored.
_DEFAULTS_SCRIPT = """
import jax, json
print("RESULT " + json.dumps({k: repr(v) for k, v in jax.config.values.items()}))
"""
DEFAULT_FLAGS = json.loads(result_lines(run_py(_DEFAULTS_SCRIPT))[0])
def nondefault_flags():
"""Config options in the current process that differ from the defaults: {name: (default, current)}."""
return {k: (DEFAULT_FLAGS.get(k), repr(v)) for k, v in jax.config.values.items() if DEFAULT_FLAGS.get(k) != repr(v)}
print("Total number of config options:", len(jax.config.values))
print("attribute / .value:", jax.config.jax_debug_nans, jax.debug_nans.value)
e_read = show_error(lambda: jax.config.read("jax_debug_nans"))
with jax.debug_nans(True), jax.log_compiles(True):
inside = nondefault_flags()
print("Options changed inside the with block:", inside)
print("Outside the with block:", nondefault_flags())
assert len(jax.config.values) > 100 and isinstance(e_read, AttributeError)
assert set(inside) == {"jax_debug_nans", "jax_log_compiles"} and nondefault_flags() == {}
# Environment variables are equivalent to config.update
env_proc = run_py("""
import jax
print("RESULT", jax.config.jax_debug_nans, jax.config.jax_log_compiles, jax.config.jax_traceback_filtering)
""", env={"JAX_DEBUG_NANS": "1", "JAX_LOG_COMPILES": "true", "JAX_TRACEBACK_FILTERING": "off"})
print("Subprocess started with environment variables:", result_lines(env_proc))
assert result_lines(env_proc) == ["True True off"]
6.3 jax.print_environment_info()¶
⚪ Optional: when filing an issue or asking for help
When you file an issue or ask for help, the first thing to post is your environment info (introduced in the 01 · 1.4 extra). return_string=True gives you the string instead of printing it. The platform line in the output includes your hostname, which you may want to strip before posting it publicly:
env_info = jax.print_environment_info(return_string=True)
safe_info = re.sub(r"node='[^']*'", "node='<redacted>'", env_info)
print(safe_info)
assert f"jax: {jax.__version__}" in env_info and "jaxlib:" in env_info and "device info:" in env_info
assert "node='<redacted>'" in safe_info
7 Memory: estimate, find what's holding it, free it¶
Memory problems come in two kinds, with different tools:
| Problem | Tools |
|---|---|
| A single computation is too big (the compiled program needs more temporary buffers than device memory allows) | Estimate before running with compile().memory_analysis(); lower the peak with jax.checkpoint, chunking, donate_argnums (Advanced 06, Basics 07 Topic 6) |
| More and more live arrays (a leak) | jax.live_arrays() to see what's still alive; save_device_memory_profile to see which line allocated them; x.delete() to free immediately; jax.clear_caches() to free arrays the jit cache kept as constants |
On this machine's CPU,
jax.devices()[0].memory_stats()returnsNone. On GPU/TPU it returns fields likebytes_in_useandpeak_bytes_in_use, the most direct way to see live usage.
7.1 memory_analysis(): know how much memory you need before running¶
⚪ Optional: when estimating device memory before deployment or investigating an OOM
For what the fields of cost_analysis() / memory_analysis() mean and how fusion affects temporary buffers, see Advanced 02 · 2.3. Here we use it in a debugging scenario: per-example gradients blowing up memory. The output of vmap(grad) has $B\times P$ numbers (Basics 04 / 07 Topic 4 covers how to estimate this). memory_analysis doesn't allocate any real memory; describing the inputs with ShapeDtypeStruct is enough to get exact byte counts. It's just right for checking "will a bigger batch OOM?" before deployment.
def per_example_loss(w, x, y):
return (jnp.dot(x, w) - y) ** 2
P_dim = 4096
spec_w = jax.ShapeDtypeStruct((P_dim,), jnp.float32) # only describes the shape, no memory allocated
rows = []
for B in [64, 256, 1024]:
spec_x, spec_y = jax.ShapeDtypeStruct((B, P_dim), jnp.float32), jax.ShapeDtypeStruct((B,), jnp.float32)
batch_grad = jax.jit(jax.grad(lambda w, x, y: jnp.mean(jax.vmap(per_example_loss, (None, 0, 0))(w, x, y))))
per_ex_grad = jax.jit(jax.vmap(jax.grad(per_example_loss), in_axes=(None, 0, 0)))
ma_b = batch_grad.lower(spec_w, spec_x, spec_y).compile().memory_analysis()
ma_p = per_ex_grad.lower(spec_w, spec_x, spec_y).compile().memory_analysis()
rows.append((B, ma_b.output_size_in_bytes, ma_p.output_size_in_bytes, ma_p.temp_size_in_bytes))
print(f" B={B:5d} | mean-gradient output {ma_b.output_size_in_bytes / 2**10:8.1f} KiB | "
f"per-example gradient output {ma_p.output_size_in_bytes / 2**20:6.1f} MiB | temp buffers {ma_p.temp_size_in_bytes / 2**20:5.1f} MiB")
for B, out_b, out_p, _ in rows:
assert out_b == P_dim * 4 # mean gradient: P float32s, independent of B
assert out_p == B * P_dim * 4 # per-example gradients: B × P float32s
7.2 jax.profiler.save_device_memory_profile: who is using the memory¶
⚪ Optional: when you need to see memory usage by allocation site
It aggregates the currently live device buffers by the Python call stack at allocation time and writes them in pprof format (gzip-compressed protobuf); view the flame graph with pprof -http=:8080 file. jax.profiler.device_memory_profile() returns the same content as bytes without writing to disk. For usage and viewing, see Advanced 02 · 2.1; it works on CPU too. Here we only confirm one thing: the more large arrays are alive, the bigger the snapshot, and it shrinks back after they're deleted.
import gzip, tempfile
def profile_size():
return len(gzip.decompress(jax.profiler.device_memory_profile()))
s0 = profile_size()
hogs = [jnp.ones((256, 256)) * i for i in range(20)] # 20 arrays allocated from the same line
jax.block_until_ready(hogs)
s1 = profile_size()
mem_path = os.path.join(tempfile.mkdtemp(), "memory.prof")
jax.profiler.save_device_memory_profile(mem_path)
del hogs; gc.collect()
s2 = profile_size()
print(f"Snapshot size (decompressed): before {s0} bytes → after allocating 20 arrays {s1} → after deleting {s2}; file {os.path.getsize(mem_path)} bytes")
assert s1 > s0 and s2 < s1 and open(mem_path, "rb").read(2) == b"\x1f\x8b"
7.3 jax.live_arrays(): finding arrays that weren't freed¶
🟢 Must-read
jax.live_arrays() returns every live jax.Array in the current process. Summarize them by (shape, dtype), take a "snapshot" before and after the training loop, and whatever's extra is your suspect. Note: the list holding the result of live_arrays() itself keeps the arrays alive, so drop it right after use (the live_summary below returns only counts).
def live_summary():
"""Counter{(shape, dtype): count} of the currently live arrays and their total bytes (keeps no references to the arrays)."""
gc.collect()
arrs = jax.live_arrays()
summary = Counter((a.shape, str(a.dtype)) for a in arrs), sum(a.nbytes for a in arrs)
del arrs
return summary
def leak_report(before, after):
"""Arrays that appeared between two snapshots."""
return {k: v for k, v in (after[0] - before[0]).items()}
# ❌ Wrong: saving each step's intermediate activations in a Python list "to make debugging easier", and forgetting to clear it
debug_cache = []
@jax.jit
def fwd_step(w, x):
h = jnp.tanh(x @ w)
return h, (h ** 2).mean()
w7 = jax.random.normal(jax.random.key(7), (128, 512)) / 12.0
x7 = jax.random.normal(jax.random.key(8), (64, 128))
before = live_summary()
try:
for _ in range(10):
h, loss = fwd_step(w7, x7)
debug_cache.append(h) # each step leaves one more 64×512 array behind
after = live_summary()
leaked = leak_report(before, after)
print("Extra live arrays:", leaked, f"| up by {(after[1] - before[1]) / 2**20:.2f} MiB")
except Exception as e:
print("Actual error:", type(e).__name__, e)
raise
assert leaked.get(((64, 512), "float32"), 0) == 10
# ✅ Correct: save only the statistics you need, as Python numbers; arrays no longer referenced get freed automatically
debug_cache.clear()
stats_log = []
before = live_summary()
for _ in range(10):
h, loss = fwd_step(w7, x7)
stats_log.append(float(loss)) # save only the scalar
del h, loss
after = live_summary()
print("Extra live arrays:", leak_report(before, after), "| number of recorded losses:", len(stats_log))
assert leak_report(before, after) == {} and len(stats_log) == 10
7.4 x.delete(): free right away, without waiting for garbage collection¶
⚪ Optional: when you need to free a large array right away
del x only removes one Python name; if there are other references (a list, a closure, the debugger…), the buffer isn't freed. x.delete() frees the device buffer directly, no matter how many references remain. After that:
x.is_deleted()isTrue;- any use (including through an alias
y = x) raisesRuntimeError: Array has been deleted with shape=...; - but
print(x)/repr(x)don't raise; they showArray(shape=(...), dtype=...), so don't be fooled by it.
big = jnp.ones((1000, 1000))
alias = big # another reference
n_before = live_summary()[1]
big.delete()
n_after = live_summary()[1]
print(f"Live bytes before and after delete: {n_before / 2**20:.2f} MiB → {n_after / 2**20:.2f} MiB")
print("is_deleted:", alias.is_deleted(), "| repr:", repr(alias))
e_del = show_error(lambda: alias + 1)
assert n_before - n_after == 1000 * 1000 * 4
assert alias.is_deleted() and isinstance(e_del, RuntimeError) and "deleted" in str(e_del)
del big, alias
7.5 jax.clear_caches(): freeing arrays the jit cache "quietly" holds on to¶
⚪ Optional: when a large global array is kept as a constant by the jit cache and memory won't go down
Global arrays read by a jit function are kept in the compilation cache as constants (§2.1). After you reassign the global variable, the old array has no Python references left, but the executable in the cache still holds it, and you can still see it in live_arrays(). As long as that jit function is alive, the old array isn't freed; only jax.clear_caches() gets rid of it. That's one more reason to "pass large arrays in as arguments" (the other three are in Advanced 01).
before = live_summary()
TABLE = jnp.ones((512, 512)) # a global "lookup table"
def lookup(x):
return (x + TABLE).sum()
lookup_jit = jax.jit(lookup)
lookup_jit(jnp.float32(1.0)) # TABLE goes into the compilation cache as a constant
TABLE = jnp.zeros((256, 256)) # reassign: the old 512×512 has no Python references left
mid = live_summary()
jax.clear_caches()
end = live_summary()
key_old = ((512, 512), "float32")
print("Old TABLEs still alive after reassignment:", mid[0][key_old] - before[0][key_old],
"| after clear_caches:", end[0][key_old] - before[0][key_old])
assert mid[0][key_old] - before[0][key_old] == 1 and end[0][key_old] - before[0][key_old] == 0
del TABLE
8 Multiple devices: where arrays live and what each device computed¶
Note: this machine has only 1 CPU device. Simulating multiple devices requires setting
XLA_FLAGS=--xla_force_host_platform_device_count=4beforeimport jax, and this notebook's kernel initialized JAX long ago. So all the code in this section runs in subprocesses: each cell hands a script torun_py, the subprocess starts with 4 simulated devices, and the results come back throughRESULTlines and then getasserted. If you want to work interactively in your own notebook, putos.environ["XLA_FLAGS"] = "--xla_force_host_platform_device_count=4"in the first cell, beforeimport jax(that's what 05 and Advanced 03 do).
| Tool | What it does | Notes |
|---|---|---|
jax.typeof(x) | The array's abstract type; in explicit sharding mode it includes sharding info, like float32[8@data,4@model] | Also works while tracing inside jit |
x.sharding, x.sharding.spec | The sharding object and the PartitionSpec | |
x.sharding.devices_indices_map(x.shape) | Which block (slice) each device holds | The ascii_sharding below is built on it |
jax.debug.visualize_array_sharding(x) | Draws the sharding layout | Depends on rich |
jax.debug.inspect_array_sharding(x, callback=...) | Shows the sharding of an intermediate value inside jit | Calls back once at compile time |
x.addressable_shards | The actual data on each local device | Check whether the copies of a "replicated" array really agree |
DEV4 = {"XLA_FLAGS": "--xla_force_host_platform_device_count=4"}
SHARDING_TOOLS = '''
import json, sys
import numpy as np
import jax, jax.numpy as jnp
from jax.sharding import NamedSharding, PartitionSpec as P
def ascii_sharding(x):
"""A sharding diagram that doesn't need rich (1D or 2D arrays): each cell lists the IDs of the devices holding that block."""
idx = x.sharding.devices_indices_map(x.shape)
shape2 = x.shape if x.ndim == 2 else (1,) + x.shape
def norm(sl, n):
return (sl.start or 0, n if sl.stop is None else sl.stop)
blocks = {}
for dev, sl in idx.items():
sl2 = sl if x.ndim == 2 else (slice(None),) + tuple(sl)
key = tuple(norm(s, n) for s, n in zip(sl2, shape2))
blocks.setdefault(key, []).append(dev.id)
rows = sorted({k[0] for k in blocks}); cols = sorted({k[1] for k in blocks})
cells = [["CPU " + ",".join(map(str, sorted(blocks[(r, c)]))) for c in cols] for r in rows]
w = max(len(s) for row in cells for s in row) + 2
line = "+" + "+".join("-" * w for _ in cols) + "+"
out = [line]
for row in cells:
out.append("|" + "|".join(s.center(w) for s in row) + "|")
out.append(line)
return "\\n".join(out)
'''
8.1 jax.typeof, visualize_array_sharding, and a replacement that doesn't need rich¶
⚪ Optional: multiple devices: when inspecting an array's sharding layout
visualize_array_sharding depends on the rich library. In this environment rich is already installed as a dependency of flax (confirmed below with importlib.metadata: Required-by: flax), so calling it directly works, and 05 · Viewing the sharding layout also saved a properly drawn layout. To show what happens without rich, the second subprocess blocks it with sys.modules["rich"] = None before importing JAX, which gives the real error you'd get if rich weren't installed. In both cases you can use the ascii_sharding built on devices_indices_map instead.
SCRIPT_81 = SHARDING_TOOLS + '''
if sys.argv[1] == "no-rich":
sys.modules["rich"] = None # simulate rich not being installed
mesh = jax.make_mesh((2, 2), ("data", "model"))
A = jnp.arange(32.0).reshape(8, 4)
x = jax.device_put(A, NamedSharding(mesh, P("data", "model")))
r = jax.device_put(A, NamedSharding(mesh, P())) # fully replicated
c = jax.device_put(A, NamedSharding(mesh, P(None, "model"))) # split by columns only
print("RESULT " + json.dumps({
"devices": len(jax.devices()),
"typeof": [str(jax.typeof(v)) for v in (x, r, c, jnp.ones(3))],
"spec": str(x.sharding.spec),
"local_shape": list(x.addressable_shards[0].data.shape),
}))
try:
jax.debug.visualize_array_sharding(x)
print("RESULT visualize=ok")
except Exception as e:
print("RESULT visualize=" + type(e).__name__ + ": " + str(e))
print("ASCII P('data','model')\\n" + ascii_sharding(x))
print("ASCII P(None,'model')\\n" + ascii_sharding(c))
print("ASCII P()\\n" + ascii_sharding(r))
'''
import importlib.util, importlib.metadata
rich_installed = importlib.util.find_spec("rich") is not None
flax_needs_rich = any(r.split()[0].startswith("rich") for r in (importlib.metadata.requires("flax") or []))
print("rich installed?", rich_installed, f"(version {importlib.metadata.version('rich')})" if rich_installed else "",
"| is rich among flax's dependencies?", flax_needs_rich)
p_rich = run_py(SCRIPT_81, "with-rich", env=DEV4)
p_norich = run_py(SCRIPT_81, "no-rich", env=DEV4)
info = json.loads(result_lines(p_rich)[0])
vis_rich, vis_norich = result_lines(p_rich)[1], result_lines(p_norich)[1]
print("Devices:", info["devices"], "| typeof:", info["typeof"], "| spec:", info["spec"], "| local block:", info["local_shape"])
print("With rich:", vis_rich, "\nWithout rich:", vis_norich)
print("\nThe diagram drawn by visualize_array_sharding with rich:")
print("\n".join(l for l in p_rich.stdout.split("ASCII")[0].splitlines() if "RESULT" not in l))
print("The replacement function ascii_sharding without rich:")
print("\n".join(l for l in p_norich.stdout.splitlines() if "RESULT" not in l))
assert info["devices"] == 4 and info["local_shape"] == [4, 2]
assert info["typeof"] == ["float32[8@data,4@model]", "float32[8,4]", "float32[8,4@model]", "float32[3]"]
assert vis_norich == "visualize=ValueError: `visualize_sharding` requires `rich` to be installed."
assert vis_rich == ("visualize=ok" if rich_installed else vis_norich) # with rich it can draw; without it, the same error as above
assert "CPU 0,2" in p_norich.stdout and "CPU 0,1,2,3" in p_norich.stdout
Reading the diagrams: P("data", "model") cuts the 8×4 array into 2×2 blocks, one per device; P(None, "model") splits only by columns, so each column block is held as a copy by two devices (CPU 0,2); P() is fully replicated, so all 4 devices hold the whole array. The jax.typeof notation matches one to one: 8@data means dimension 0 is split along the data axis, and dimensions without @ aren't split.
8.2 Inspecting sharding inside jit¶
⚪ Optional: multiple devices: when inspecting the sharding of intermediate results
Once an array goes into jit, the compiler decides the sharding of the intermediate results. jax.typeof can read it at trace time (in explicit sharding mode), while jax.debug.inspect_array_sharding(x, callback=...) hands the sharding the compiler chose to a callback at compile time.
SCRIPT_82 = SHARDING_TOOLS + '''
mesh = jax.make_mesh((4,), ("d",))
x = jax.device_put(jnp.arange(16.0).reshape(8, 2), NamedSharding(mesh, P("d")))
seen = {}
@jax.jit
def f(a):
seen["trace_typeof"] = str(jax.typeof(a)) # read at trace time
b = jnp.sin(a) * 2
jax.debug.inspect_array_sharding(b, callback=lambda s: seen.__setitem__("inspect", str(s.spec)))
return b.sum(axis=1)
out = f(x)
seen["out_typeof"] = str(jax.typeof(out))
seen["out_spec"] = str(out.sharding.spec)
print("RESULT " + json.dumps(seen))
'''
seen82 = json.loads(result_lines(run_py(SCRIPT_82, env=DEV4))[0])
for k, v in seen82.items():
print(f" {k:13s} {v}")
assert seen82["trace_typeof"] == "float32[8@d,2]" and seen82["inspect"] == "P('d',)"
assert seen82["out_typeof"] == "float32[8@d]"
8.3 "Inconsistent results across devices": check the replicas, look at each device's local values¶
⚪ Optional: multiple devices: when shard_map results differ across devices
Inside shard_map, each device sees only its own local block. The most common mistake is forgetting the collective operation: each device computes a local sum, and it gets treated as the global result. The 0.12 default check_vma=True catches outputs at trace time that are "declared replicated but actually differ across devices". But if you turn it off (check_vma=False) to get around some other error, the program silently returns device 0's value, and the "replicas" on the different devices are actually all different. A related trap is moving pmean(grad) into shard_map unchanged, which makes the gradient N times too big; see the 05 · shard_map gradient N-times topic.
Investigation tools: ① inside shard_map, use jax.debug.print to print lax.axis_index and the local block; ② write a replicas_consistent(x) that compares the data on each of x.addressable_shards.
SCRIPT_83 = SHARDING_TOOLS + '''
from jax import lax
mesh = jax.make_mesh((4,), ("d",))
x = jax.device_put(jnp.arange(16.0), NamedSharding(mesh, P("d")))
def replicas_consistent(arr):
"""For an array declared replicated: are the copies on all devices bit-for-bit identical?"""
datas = [np.asarray(s.data) for s in arr.addressable_shards]
return all(np.array_equal(datas[0], d) for d in datas[1:]), [d.tolist() for d in datas]
def local_sum(xb):
jax.debug.print("device {i} local block {b}", i=lax.axis_index("d"), b=xb)
return xb.sum()
res = {}
try: # ❌ default check: errors out at trace time
jax.jit(jax.shard_map(local_sum, mesh=mesh, in_specs=P("d"), out_specs=P()))(x)
res["checked"] = "no error"
except Exception as e:
res["checked"] = type(e).__name__ + ": " + str(e).splitlines()[0][:80]
bad = jax.jit(jax.shard_map(local_sum, mesh=mesh, in_specs=P("d"), out_specs=P(), check_vma=False))(x)
jax.effects_barrier()
res["bad_value"] = float(bad); res["bad_consistent"], res["bad_replicas"] = replicas_consistent(bad)
good = jax.jit(jax.shard_map(lambda xb: lax.psum(xb.sum(), "d"), mesh=mesh, in_specs=P("d"), out_specs=P()))(x)
res["good_value"] = float(good); res["good_consistent"], res["good_replicas"] = replicas_consistent(good)
print("RESULT " + json.dumps(res, ensure_ascii=False))
'''
p83 = run_py(SCRIPT_83, env=DEV4)
res83 = json.loads(result_lines(p83)[0])
print("\n".join(sorted(l for l in p83.stdout.splitlines() if l.startswith("device"))))
for k, v in res83.items():
print(f" {k:16s} {v}")
assert res83["checked"].startswith("ValueError") and "out_specs" in res83["checked"]
assert res83["bad_value"] == 6.0 and res83["bad_consistent"] is False # returns device 0's local sum; the replicas differ
assert sorted(r for r in res83["bad_replicas"]) == [6.0, 22.0, 38.0, 54.0]
assert res83["good_value"] == float(np.arange(16.0).sum()) and res83["good_consistent"] is True
9 Hands-on: a training script with 3 hidden bugs¶
Below is a training script that looks perfectly ordinary: binary classification data, a 3-layer MLP with dropout, a bit of L2-norm regularization on the biases, and a learning rate that decays exponentially with the step count. It has 3 hidden bugs. We'll pretend we don't know where they are and, going only by the symptoms, use the tools from earlier to find and fix them one by one, proving each fix with an assert.
Following this chapter's convention, parameters and data are passed into jit functions as arguments (avoiding this environment's CPU bug).
# ===================== Training script v0 (kept as is; don't go looking for the bugs yet) =====================
def make_data(key, n=512, d=16):
kx, kw = jax.random.split(key)
X = jax.random.normal(kx, (n, d))
w_true = jax.random.normal(kw, (d,))
return X, (X @ w_true > 0).astype(jnp.int32)
def init_params(key, sizes):
keys = jax.random.split(key, len(sizes) - 1)
return [{"w": jax.random.normal(k, (m, n)) * jnp.sqrt(2.0 / m), "b": jnp.zeros(n)}
for k, m, n in zip(keys, sizes[:-1], sizes[1:])]
def forward(params, x, key=None, rate=0.2):
for i, layer in enumerate(params):
x = x @ layer["w"] + layer["b"]
if i < len(params) - 1:
x = jax.nn.relu(x)
if key is not None: # dropout during training
keep = jax.random.bernoulli(jax.random.fold_in(key, i), 1 - rate, x.shape)
x = jnp.where(keep, x / (1 - rate), 0.0)
return x
def cross_entropy(params, x, y, key):
logits = forward(params, x, key)
return -jnp.mean(jnp.take_along_axis(jax.nn.log_softmax(logits), y[:, None], axis=1))
def loss_v0(params, x, y, key):
reg = sum(jnp.linalg.norm(layer["b"]) for layer in params) # L2-norm regularization on the biases
return cross_entropy(params, x, y, key) + 1e-3 * reg
def make_train_step_v0(loss_fn):
@partial(jax.jit, static_argnames=("step",))
def train_step(params, x, y, key, step):
lr = 0.5 * 0.98 ** step # learning rate decays with the step count
loss, grads = jax.value_and_grad(loss_fn)(params, x, y, key)
return jax.tree.map(lambda p, g: p - lr * g, params, grads), loss
return train_step
def train_v0(step_fn, params, X, Y, n_steps, seed=0):
key = jax.random.key(seed)
losses = []
for step in range(n_steps):
step_key = jax.random.split(key)[0] # take a subkey each step for dropout
params, loss = step_fn(params, X, Y, step_key, step)
losses.append(float(loss))
return params, losses
def accuracy(params, X, Y):
return float(jnp.mean(jnp.argmax(forward(params, X), axis=-1) == Y))
X_tr, Y_tr = make_data(jax.random.key(42))
params0 = init_params(jax.random.key(1), [16, 64, 64, 2])
print("Data:", X_tr.shape, "| positive fraction:", float(Y_tr.mean()), "| params:", [jax.tree.map(jnp.shape, l) for l in params0])
9.0 Run it once and record the symptoms¶
⚪ Optional: hands-on: the full process of locating 3 hidden bugs from their symptoms
N_DRILL = 8
step_v0 = make_train_step_v0(loss_v0)
t0 = time.perf_counter()
with CompileCounter("train_step") as cc_v0:
_, losses_v0 = train_v0(step_v0, params0, X_tr, Y_tr, N_DRILL)
print(f"loss: {[round(l, 4) for l in losses_v0]}")
print(f"{N_DRILL} steps took {time.perf_counter() - t0:.2f} s, train_step compiled {cc_v0.count} times")
assert np.isfinite(losses_v0[0]) and all(np.isnan(l) for l in losses_v0[1:]) # symptom 1: loss becomes NaN from step 2 on
assert cc_v0.count == N_DRILL # symptom 2: it compiles on every step
Two symptoms are already obvious: the loss turns NaN from step 2 on, and it compiles once per step. The third bug produces no obvious symptom (training "runs"), so we'll catch it at the end with a dedicated tool.
9.1 Symptom 1: NaN, locating it¶
⚪ Optional: hands-on: locating a NaN in backprop
Following the lookup table: the loss is still finite at the first step and NaN at the second, which means the first parameter update introduced the NaN, and the suspect is the gradient.
- Use
checkify(not affected by the vmap missed-detection issue) to check the "forward" and the "gradient" at step 0, and see which side the NaN shows up on; - Check the gradient leaf by leaf to see which parameters' gradients are broken;
- Split the loss into its two terms, take the gradient of each, and pin down the culprit.
k_dbg = jax.random.key(123)
err_fwd, loss_at0 = jax.jit(checkify.checkify(loss_v0, errors=checkify.nan_checks))(params0, X_tr, Y_tr, k_dbg)
err_bwd, grads_at0 = jax.jit(checkify.checkify(jax.grad(loss_v0), errors=checkify.nan_checks))(params0, X_tr, Y_tr, k_dbg)
print("① forward:", err_fwd.get(), "| gradient:", err_bwd.get())
bad = [jax.tree_util.keystr(p) for p, g in jax.tree_util.tree_leaves_with_path(grads_at0) if not bool(jnp.all(jnp.isfinite(g)))]
print("② Leaves whose gradient contains NaN:", bad)
g_ce = jax.grad(cross_entropy)(params0, X_tr, Y_tr, k_dbg)
g_reg = jax.grad(lambda p: sum(jnp.linalg.norm(l["b"]) for l in p))(params0)
finite = lambda t: all(bool(jnp.all(jnp.isfinite(g))) for g in jax.tree.leaves(t))
print("③ Cross-entropy gradient finite?", finite(g_ce), "| regularizer gradient finite?", finite(g_reg), "| initial biases:", params0[0]["b"][:4])
assert err_fwd.get() is None and "nan generated by primitive" in err_bwd.get()
assert bad == ["[0]['b']", "[1]['b']", "[2]['b']"]
assert finite(g_ce) and not finite(g_reg)
Diagnosis: the forward pass is fine; the NaN shows up only in the gradient, only on the biases, and it comes from the regularizer. The biases are initialized to all zeros, and $\nabla_b \lVert b\rVert = b / \lVert b\rVert$ is $0/0$ at $b=0$. This is exactly the case from §3.1 where jax_debug_nans can only report mul and can't find the source line; "leaf-by-leaf check + splitting the loss" is faster than flipping the global switch.
Fix: write a safe norm whose gradient is 0 at 0, using the double-where trick (how it works: Basics 07 Topic 2.2).
def safe_norm(v):
"""‖v‖; when v = 0 the value is 0 and the gradient is 0 (double where)."""
ss = jnp.sum(v ** 2)
ok = ss > 0
return jnp.where(ok, jnp.sqrt(jnp.where(ok, ss, 1.0)), 0.0)
def loss_v1(params, x, y, key):
reg = sum(safe_norm(layer["b"]) for layer in params)
return cross_entropy(params, x, y, key) + 1e-3 * reg
# Check 1: gradient is 0 at 0; away from 0 it matches the gradient of jnp.linalg.norm; the gradient of the whole loss is finite
v_test = jnp.array([3.0, -4.0])
assert np.array_equal(jax.grad(safe_norm)(jnp.zeros(3)), np.zeros(3))
assert np.allclose(jax.grad(safe_norm)(v_test), jax.grad(jnp.linalg.norm)(v_test)) and float(safe_norm(v_test)) == 5.0
err_v1, g_v1 = jax.jit(checkify.checkify(jax.grad(loss_v1), errors=checkify.nan_checks))(params0, X_tr, Y_tr, k_dbg)
assert err_v1.get() is None and finite(g_v1)
# Check 2: training no longer produces NaN (the other two bugs are still there)
step_v1 = make_train_step_v0(loss_v1)
with CompileCounter("train_step") as cc_v1:
_, losses_v1 = train_v0(step_v1, params0, X_tr, Y_tr, N_DRILL)
print("Loss after fixing the NaN:", [round(l, 4) for l in losses_v1], "| compile count:", cc_v1.count)
assert all(np.isfinite(losses_v1)) and losses_v1[-1] < losses_v1[0]
assert cc_v1.count == N_DRILL # symptom 2 is still there
9.2 Symptom 2: recompiling every step, locating it¶
⚪ Optional: hands-on: locating repeated compilation caused by a static argument
Following the order in §5: first check whether the shapes in the compile log changed, then look at the reason from explain_cache_misses.
step_dbg = make_train_step_v0(loss_v1)
with capture_jax_logs() as logs, jax.log_compiles(), jax.explain_cache_misses():
p_ = params0
for s in range(3):
p_, _ = step_dbg(p_, X_tr, Y_tr, jax.random.key(s), s)
compile_lines = [m for m in logs.messages if m.startswith("Compiling jit(train_step)")]
miss_lines = [m for m in logs.messages if "CACHE MISS" in m and "train_step" in m]
shapes_seen = {m.split("with global shapes and types")[1].split(". Argument")[0] for m in compile_lines}
print("Compile count:", len(compile_lines), "| distinct argument shapes:", len(shapes_seen))
for m in miss_lines:
reason = [l.strip() for l in m.splitlines()[1:] if not l.strip().startswith(("for ", "all previously", "train_step id="))]
print(" •", " / ".join(reason))
print("_cache_size() (private API):", step_dbg._cache_size())
assert len(compile_lines) == 3 and len(shapes_seen) == 1 # shapes didn't change at all, yet it compiled 3 times
assert "never seen function" in miss_lines[0]
assert all("explanation unavailable" in m for m in miss_lines[1:]) # → suspect: a static argument
assert step_dbg._cache_size() == 3
Diagnosis: the argument shapes are identical across the three compilations, and explain_cache_misses only says explanation unavailable. From the rule of thumb in §5.2, this almost always means a static argument's value is changing. Looking back at the code: step was declared in static_argnames, and it has a new value every step.
Fix: step is only used to compute the learning rate and doesn't determine the computation structure, so it should be passed as a regular (traced) argument. And it should always be a strongly typed int32 array, so that switching between weak and strong types doesn't trigger yet another compilation (02 · E3).
def make_train_step_v2(loss_fn):
@jax.jit
def train_step(params, x, y, key, step): # step is no longer a static argument
lr = 0.5 * 0.98 ** step
loss, grads = jax.value_and_grad(loss_fn)(params, x, y, key)
return jax.tree.map(lambda p, g: p - lr * g, params, grads), loss
return train_step
def train_v2(step_fn, params, X, Y, n_steps, seed=0):
key = jax.random.key(seed)
losses = []
for step in range(n_steps):
step_key = jax.random.split(key)[0]
params, loss = step_fn(params, X, Y, step_key, jnp.int32(step)) # always an int32 array
losses.append(float(loss))
return params, losses
step_v2 = make_train_step_v2(loss_v1)
N_LONG = 60
with CompileCounter("train_step") as cc_v2:
params_v2, losses_v2 = train_v2(step_v2, params0, X_tr, Y_tr, N_LONG)
_, losses_v1_long = train_v0(make_train_step_v0(loss_v1), params0, X_tr, Y_tr, N_LONG)
print(f"{N_LONG} steps compiled {cc_v2.count} times | max loss difference vs the static-step version {np.abs(np.array(losses_v2) - losses_v1_long).max():.2e}")
assert cc_v2.count == 1 # the fix works: compiles only once
assert np.allclose(losses_v2, losses_v1_long, atol=1e-5) # mathematically identical to before the fix
9.3 Hidden bug 3: reusing a random key¶
⚪ Optional: hands-on: catching key reuse with jax_debug_key_reuse
With the first two bugs fixed, training "looks normal". But does dropout really use a new mask every step? This kind of bug never raises an error; it just weakens the regularization. Tools:
jax_debug_key_reuse(a config option available in 0.12): once it's on, a typed key (created byjax.random.key) is marked every time it's "consumed", and using the same key again immediately raisesjax.errors.KeyReuseError, telling you which argument of which function it was. Rawuint32keys from the old-stylePRNGKeyaren't checked. One more reason to usejax.random.key.- Eavesdropping: wrap
step_fnand recordjax.random.key_dataof the key it receives each step, to see whether they're all the same.
def try_train(train_fn, n=4):
try:
with jax.debug_key_reuse(True):
train_fn(step_v2, params0, X_tr, Y_tr, n)
return None
except jax.errors.KeyReuseError as e:
return e
e_reuse = try_train(train_v2)
print("jax_debug_key_reuse:", type(e_reuse).__name__, "→", str(e_reuse).splitlines()[0])
seen_keys = []
def spy(params, x, y, key, step):
seen_keys.append(tuple(np.asarray(jax.random.key_data(key)).tolist()))
return step_v2(params, x, y, key, step)
train_v2(spy, params0, X_tr, Y_tr, 5)
print("Distinct dropout keys over 5 steps:", len(set(seen_keys)))
assert isinstance(e_reuse, jax.errors.KeyReuseError) and "random_split" in str(e_reuse)
assert len(set(seen_keys)) == 1 # every step uses the same key
# The same reuse with an old-style PRNGKey is invisible to the checker
with jax.debug_key_reuse(True):
raw = jax.random.PRNGKey(0)
same = bool(jnp.all(jax.random.normal(raw, (3,)) == jax.random.normal(raw, (3,))))
print("PRNGKey reused twice: no error, the two results are identical =", same)
assert same
Diagnosis: in train_v2, every step does split(key)[0] on the same key, and key itself is never updated, so every step gets exactly the same subkey and exactly the same dropout mask. Fix: do key, step_key = jax.random.split(key) each step and carry the new key into the next step.
def train_v3(step_fn, params, X, Y, n_steps, seed=0):
key = jax.random.key(seed)
losses = []
for step in range(n_steps):
key, step_key = jax.random.split(key) # advance the key every step
params, loss = step_fn(params, X, Y, step_key, jnp.int32(step))
losses.append(float(loss))
return params, losses
assert try_train(train_v3) is None # no error even with the check on
seen_keys.clear()
train_v3(spy, params0, X_tr, Y_tr, 5)
print("Distinct dropout keys over 5 steps after the fix:", len(set(seen_keys)))
assert len(set(seen_keys)) == 5
with CompileCounter("train_step") as cc_v3:
params_v3, losses_v3 = train_v3(step_v2, params0, X_tr, Y_tr, N_LONG)
acc_v2, acc_v3 = accuracy(params_v2, X_tr, Y_tr), accuracy(params_v3, X_tr, Y_tr)
print(f"Final version: {N_LONG} steps, compiled {cc_v3.count} times (step_v2 was already compiled) | loss {losses_v3[0]:.3f} → {losses_v3[-1]:.3f}")
print(f"Training accuracy: fixed mask {acc_v2:.3f} | new mask each step {acc_v3:.3f}")
assert cc_v3.count == 0 and all(np.isfinite(losses_v3)) and losses_v3[-1] < 0.5 * losses_v3[0]
assert acc_v3 > 0.9
9.4 Hands-on recap¶
🟢 Must-read
| Bug | Symptom | Locating tools | Fix | Verification |
|---|---|---|---|---|
| Differentiating the norm of a zero vector | Loss is NaN from step 2 on | checkify(grad) → leaf-by-leaf check → split the loss | safe_norm with double where | Gradient is 0 at 0; matches the jnp.linalg.norm gradient; finite throughout training |
step is a static argument | Compiles once per step | CompileCounter, log_compiles (shapes unchanged), explain_cache_misses (explanation unavailable) | Pass step as a traced int32 argument | 60 steps compile only once; loss matches the pre-fix run step by step |
| The key isn't advanced | No obvious symptom (weaker regularization) | jax_debug_key_reuse → KeyReuseError; record key_data | key, sub = split(key) | The check no longer errors; 5 different keys over 5 steps |
Mind the order: fix the NaN first (it makes every other symptom meaningless), then the performance, and finally hunt for logic problems that "don't error but aren't right". Lock in each fix with an assert right away, so fixing a later bug doesn't break an earlier fix.
¶
🏋️ Exercises (with reference solutions)¶
| Exercise | Topic | Section |
|---|---|---|
| 1 | Four Tracer-error fixes: first see what error you actually get, then rewrite until it jits | §4 |
| 2 | Write a nan_guard decorator with checkify | §3.2, §5.4 |
| 3 | Write a leak detector with jax.live_arrays() | §7.3 |
| 4 | Automatically find "the first step where a NaN appears" and the primitive that produced it | §3.1, §9 |
Exercise 1: Four Tracer-error fixes¶
⚪ Optional: exercise: practice for §4
The exercise code below gives 4 functions. They all work fine in eager mode, but each one raises an error once wrapped in jax.jit.
Tasks:
- For each function, find out the exception type that
jax.jit(f)(*args)actually raises, put them in a dict{function name: exception class name}, and assert that they are, in order:TypeError(modeis a string, not a valid JAX type),IndexError,NonConcreteBooleanIndexError,TracerBoolConversionError; - Write a version of each function that jit can compile (you may change the body or use
static_argnames), keeping the same calling convention; - Assert that the results are correct against a NumPy float64 reference.
Self-check interface: ex1_errors: {function name: exception class name string}; fixed: {function name: the fixed function wrapped in jax.jit}, called the same way as the original (for example fixed["ex1_topk_mean"](x, 2)).
# Exercise 1 code
def ex1_normalize(x, mode): # mode is "l1" or "l2"
if mode == "l1":
n = jnp.sum(jnp.abs(x))
else:
n = jnp.sqrt(jnp.sum(x ** 2))
return x / n
def ex1_topk_mean(x, k): # mean of the k largest values
return jnp.mean(jnp.sort(x)[::-1][:k])
def ex1_count_pos(x): # number of positive values
return x[x > 0].shape[0]
def ex1_clip_by_norm(x, max_norm): # scale down proportionally when the norm exceeds max_norm
n = jnp.linalg.norm(x)
if n > max_norm:
x = x * max_norm / n
return x
ex1_x = jnp.array([3.0, -1.0, 4.0, -1.5, 5.0])
ex1_calls = {"ex1_normalize": (ex1_normalize, (ex1_x, "l1")), "ex1_topk_mean": (ex1_topk_mean, (ex1_x, 2)),
"ex1_count_pos": (ex1_count_pos, (ex1_x,)), "ex1_clip_by_norm": (ex1_clip_by_norm, (ex1_x, 2.0))}
# ✍️ 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:
ex1_errors, fixed
except NameError as e:
print(f"⏳ Exercise 1: not answered yet: {e}")
else:
assert ex1_errors == {"ex1_normalize": "TypeError", "ex1_topk_mean": "IndexError",
"ex1_count_pos": "NonConcreteBooleanIndexError",
"ex1_clip_by_norm": "TracerBoolConversionError"}, f"The measured exception types are wrong: {ex1_errors}"
_missing = set(ex1_calls) - set(fixed)
assert not _missing, f"missing from fixed: {sorted(_missing)}"
for _name in ex1_calls:
assert hasattr(fixed[_name], "lower"), f"fixed['{_name}'] should be a function wrapped in jax.jit"
_x = jnp.array([-2.0, 0.5, 7.0, 1.0, -3.0, 2.5]) # a new set of inputs
_x64 = to64(_x)
_cases = [
("ex1_normalize", (_x, "l1"), _x64 / np.abs(_x64).sum()),
("ex1_normalize", (_x, "l2"), _x64 / np.linalg.norm(_x64)),
("ex1_topk_mean", (_x, 3), np.sort(_x64)[::-1][:3].mean()),
("ex1_count_pos", (_x,), 4),
("ex1_count_pos", (-jnp.abs(_x),), 0),
("ex1_clip_by_norm", (_x, 1.0), _x64 / np.linalg.norm(_x64)),
("ex1_clip_by_norm", (_x, 100.0), _x64),
]
for _name, _args, _ref in _cases:
_out = fixed[_name](*_args)
assert np.allclose(to64(_out), _ref, atol=1e-5), \
f"{_name}{tuple(a if isinstance(a, (str, int, float)) else 'x' for a in _args)} gave the wrong result: {np.asarray(_out)}, expected {_ref}"
print("✅ Exercise 1: self-check passed")
Exercise 2: a nan_guard decorator¶
⚪ Optional: exercise: practice for §3.2, §5.4
Write a decorator nan_guard(fn):
- Internally, compile with
jax.jit(checkify.checkify(fn, errors=...)), checking for NaN, division by zero, and anycheckify.checkwritten infn; - When called, if a check fails, raise
FloatingPointErrorwith a message that starts with"<function name>: "followed by the checkify error message; otherwise returnfn's output; - Test it with
ex2_layerbelow: normal input returns the right result; input with a negative number triggers a NaN fromlog;scale=0triggers the custom check; - Assert with
CompileCounterthat 5 calls with the same shape (including the failing calls) compile only once.
Self-check interface: nan_guard(fn): returns the wrapped function, called the same way as fn; when a check fails it raises FloatingPointError with a message starting with "<function name>: ".
# Exercise 2 code
def ex2_layer(x, scale):
checkify.check(scale != 0, "scale must not be 0")
return jnp.log(x) * scale
# ✍️ 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:
nan_guard
except NameError as e:
print(f"⏳ Exercise 2: not answered yet: {e}")
else:
def _probe(x, s): # test with a new function
checkify.check(s > 0, "s must be positive")
return jnp.sqrt(x) * s
_guarded = nan_guard(_probe)
_good, _bad = jnp.array([1.0, 4.0, 9.0]), jnp.array([1.0, -4.0, 9.0])
_results = []
with CompileCounter() as _cc:
for _xx, _s in [(_good, 2.0), (_bad, 2.0), (_good, -1.0), (_good, 3.0), (_bad, 3.0)]:
try:
_results.append(("ok", _guarded(_xx, jnp.float32(_s))))
except FloatingPointError as e:
_results.append(("err", str(e)))
_kinds = [k for k, _ in _results]
assert _kinds == ["ok", "err", "err", "ok", "err"], f"The wrong calls raised errors: {_kinds} (expected ok/err/err/ok/err)"
assert np.allclose(to64(_results[0][1]), [2.0, 4.0, 6.0]) and np.allclose(to64(_results[3][1]), [3.0, 6.0, 9.0]), \
"With normal input it should return fn's output"
assert _results[1][1].startswith("_probe: ") and "nan" in _results[1][1] and "sqrt" in _results[1][1], \
f"The NaN error message should start with '_probe: ' and name sqrt: {_results[1][1]!r}"
assert _results[2][1].startswith("_probe: ") and "s must be positive" in _results[2][1], \
f"Wrong error message for the custom check: {_results[2][1]!r}"
assert _cc.count == 1, f"5 calls with the same shape should compile only once, but it compiled {_cc.count} times"
print("✅ Exercise 2: self-check passed")
Exercise 3: a leak detector¶
⚪ Optional: exercise: practice for §7.3
Write track_new_arrays(fn, *args): run fn(*args) and return (fn's return value, Counter), where the Counter counts arrays that are still alive after the call and were newly created by this call, keyed by (shape, dtype) (don't count fn's return value itself). Hint: record the ids of the arrays from jax.live_arrays() before and after the call, and be careful that your own snapshot list doesn't keep the arrays alive.
Then:
- For a
run(5)call onex3_Trainerbelow, assert that it detects 5 leaked(32, 16)arrays (fromself.history); - Write a fixed
ex3_TrainerFixed(that records only the gradient norm as a Python float), and assert that the detection result is empty and that both versions end with the same parameters.
Self-check interface: track_new_arrays(fn, *args): returns (fn's return value, Counter), where the Counter's keys are (shape tuple, dtype); ex3_TrainerFixed(w, x): constructed and run(n) the same way as ex3_Trainer, with Python floats stored in history.
# Exercise 3 code
@jax.jit
def ex3_step(w, x):
g = jax.grad(lambda w: jnp.mean(jnp.tanh(x @ w) ** 2))(w)
return w - 0.1 * g, g
class ex3_Trainer:
def __init__(self, w, x):
self.w, self.x, self.history = w, x, []
def run(self, n):
for _ in range(n):
self.w, g = ex3_step(self.w, self.x)
self.history.append(g) # record the gradient "for later analysis"
return self.w
ex3_w0 = jax.random.normal(jax.random.key(30), (32, 16)) / 6.0
ex3_x = jax.random.normal(jax.random.key(31), (8, 32))
# ✍️ 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:
track_new_arrays, ex3_TrainerFixed
except NameError as e:
print(f"⏳ Exercise 3: not answered yet: {e}")
else:
_norm = lambda c: Counter({(tuple(k[0]), str(k[1])): v for k, v in c.items()})
_keep = []
def _leaky(n): # test with a new "leaky" function
for i in range(n):
_keep.append(jnp.ones((3, i + 1))) # stored in an outside list: still alive after the call
_tmp = jnp.zeros((5,)) + i # temporary array: freed when the call ends
return jnp.arange(7.0)
_out, _cnt = track_new_arrays(_leaky, 3)
assert np.array_equal(np.asarray(_out), np.arange(7.0)), "The first return value should be fn's return value"
assert _norm(_cnt) == Counter({((3, 1), "float32"): 1, ((3, 2), "float32"): 1, ((3, 3), "float32"): 1}), \
f"Wrong leak count: {dict(_cnt)} (expected one each of (3,1), (3,2), (3,3); neither the return value nor temporaries count)"
_, _cnt2 = track_new_arrays(lambda: jnp.ones((4, 4)) * 2)
assert not _cnt2, f"With no leak it should return an empty Counter (the return value itself doesn't count), got {dict(_cnt2)}"
del _keep
_w0 = jax.random.normal(jax.random.key(9030), (32, 16)) / 6.0
_xx = jax.random.normal(jax.random.key(9031), (8, 32))
_t = ex3_TrainerFixed(_w0, _xx)
_wf, _leak = track_new_arrays(_t.run, 3)
assert not _leak, f"ex3_TrainerFixed still leaks: {dict(_leak)}"
assert len(_t.history) == 3 and all(isinstance(v, float) for v in _t.history), \
f"history should hold 3 Python floats, got {[type(v).__name__ for v in _t.history]}"
assert np.array_equal(np.asarray(_wf), np.asarray(ex3_Trainer(_w0, _xx).run(3))), "The fixed version should end with exactly the same parameters as the original"
print("✅ Exercise 3: self-check passed")
Exercise 4: automatically locating the first NaN¶
⚪ Optional: exercise: practice for §3.1, §9
ex4_step below is a jit-compiled training step. In the data stream ex4_batches, the 5th batch (index 5) has a negative number mixed in, which makes log produce a NaN. Write first_nan_step(step_fn, w, batches):
- Train normally (do not keep
jax_debug_nanson the whole time), checking on the host at every step whether the loss is finite; - As soon as you see a non-finite value, stop and rerun just that step inside
with jax.debug_nans(True):, using the parameters from before things went wrong and this step's batch. Thewithform isn't affected by the vmap missed-detection issue; if you turn it on globally withconfig.updateinstead, this process has already usedvmap, so you must calljax.clear_caches()first (§3.1); - Return
(step, primitive name), parsing the primitive name from theFloatingPointErrormessageinvalid value (nan) encountered in <primitive>; returnNoneif there's no NaN at all; - Assert that the result is
(5, "log"), and thatjax_debug_nansis back toFalseafter the function returns.
Self-check interface: first_nan_step(step_fn, w, batches): step_fn(w, batch) returns (new params, loss); it returns (step, primitive name), or None when there's no NaN.
# Exercise 4 code
@jax.jit
def ex4_step(w, batch):
loss = jnp.mean(jnp.log(batch) * w)
return w - 0.01 * jax.grad(lambda w: jnp.mean(jnp.log(batch) * w))(w), loss
ex4_batches = [jnp.full(4, 1.0 + i) for i in range(8)]
ex4_batches[5] = jnp.array([1.0, 2.0, -3.0, 4.0])
# ✍️ 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:
first_nan_step
except NameError as e:
print(f"⏳ Exercise 4: not answered yet: {e}")
else:
@jax.jit
def _probe_step(w, b): # a new training step: the NaN comes from sqrt
loss = jnp.mean(jnp.sqrt(b) * w)
return w * 0.9 + 0.1, loss
_bs = [jnp.full(3, 1.0 + i) for i in range(6)]
_bs[3] = jnp.array([4.0, -1.0, 9.0])
_log = [] # record the debug_nans switch and the parameter passed in on each call
def _spy(w, b):
_log.append((bool(jax.config.jax_debug_nans), float(w)))
return _probe_step(w, b)
_r = first_nan_step(_spy, jnp.float32(2.0), _bs)
assert _r == (3, "sqrt"), f"Should return (3, 'sqrt'), got {_r} (if the primitive name is None, check whether you cleared the caches before rerunning)"
assert jax.config.jax_debug_nans is False, "jax_debug_nans should be back to False after returning"
_flags = [f for f, _ in _log]
assert _flags[:4] == [False] * 4 and len(_flags) > 4 and all(_flags[4:]), \
f"The first 4 steps should run with debug_nans off, and it should be turned on only for the rerun after a NaN is found; actual switch log: {_flags}"
assert _log[-1][1] == _log[3][1], f"The rerun should use the parameters from before things went wrong, {_log[3][1]}, but used {_log[-1][1]}"
assert first_nan_step(_spy, jnp.float32(2.0), _bs[:3]) is None, "Should return None when there's no NaN at all"
print("✅ Exercise 4: self-check passed")
¶
📝 Quiz (6 questions)¶
⚪ Optional: for checking yourself
1. In JAX 0.12.0.1, which of these exception classes is a subclass of jax.errors.ConcretizationTypeError?
- A.
TracerIntegerConversionErrorB.TracerArrayConversionErrorC.TracerBoolConversionErrorD. All three
2. In jax.vmap(f)(jnp.array([1.0, -1.0])), f contains lax.cond(x > 0, branch_A, branch_B, x), and each branch has one jax.debug.print. How many lines are printed in total?
- A. 2 B. 4 C. 1 D. 0 (you can't use debug.print inside vmap)
3. In this environment, the process traced a vmap earlier. Then you turn on checking globally with jax.config.update("jax_debug_nans", True) and call a jit function that had already run before, and it produces a NaN. What happens, and what should you do?
- A. It raises
FloatingPointError; nothing to do - B. It silently returns NaN; call
jax.clear_caches()first and rerun, or usewith jax.debug_nans(True):orcheckifyinstead - C. It raises
UnexpectedTracerError - D. The process crashes
4. With jax_explain_cache_misses on, the log only says cache miss explanation unavailable, and jax_log_compiles shows the same argument shapes for every compilation. What's the most likely cause?
- A. The input dtype is changing B. The value of some static argument is changing C. The PyTree structure is changing D.
jax_debug_nansis on
5. You suspect a jit function leaks a tracer, so you wrap a call in with jax.checking_leaks():, and no error is raised. Which explanation is most likely?
- A.
jax.checking_leakswas removed in 0.12 and does nothing - B. The function was already called with the same input signature, so this call hit the cache and didn't retrace, and leak checking only happens when tracing finishes
- C. Leak checking only works for
vmap - D. Leak checking only works on GPU
6. Which is true about auto, the default value of JAX_TRACEBACK_FILTERING?
- A. In Jupyter (IPython ≥ 7.17) it equals
tracebackhide; in a plain Python script it equalsquiet_remove_frames - B. It always equals
off - C. It always equals
remove_frames, with the original traceback in__cause__ - D. It only affects errors from
jax.grad
Answers and explanations (click to expand)
- C. In 0.12.0.1, only
TracerBoolConversionErrorinherits fromConcretizationTypeError; the other two inherit directly fromJAXTypeError. To catch them all at once, usejax.errors.JAXTypeErrororTypeError(§4). - B. vmap rewrites
condintoselect, so both branches run for every example: 2 examples × 2 branches = 4 lines (§1.1). - B. This is a known issue in 0.12.0.1: when it's on globally, once vmap has been traced, cached computations are no longer checked.
jax.clear_caches()makes them recompile, which brings the checks back; thewith jax.debug_nans(True):form andcheckifyare not affected (§3.1). - B. In 0.12, when a static argument's value changes,
explain_cache_missescan't give a specific reason; repeated compilation with unchanged shapes almost always means a static argument (or a closure, or global config) is changing (§5.2, §9.2). - B. Leak checking happens when tracing finishes; a cache hit means no tracing. Call
jax.clear_caches()before investigating (§4.6). - A. In a notebook the internal frames are marked and shown collapsed; in a script they're removed and a hint "For simplicity, JAX has removed its internal frames" is appended (§6.1).
¶
Chapter summary¶
🟢 Must-read
- Start from the symptom, then pick the tool: for NaN, first check the loss on the host, then rerun the failing step with
checkifyorwith jax.debug_nans(True):; for wrong numbers, first cross-check against float64, then bisect withdisable_jit; for repeated compilation, first count compilations, then look at the logs and the cache-miss explanations. - Seeing values:
jax.debug.printusesstr.formatsyntax, and the two-stepjax.debug.print(ordered=True)(fmt, ...)is recommended; under vmap it prints per example, under grad it only shows primal values (use acustom_vjpprobe to see gradients), and inside vmap both branches of acondprint.jax.debug.callbackhands NumPy arrays to any Python function.jax.debug.breakpointneeds stdin, and for a conditional breakpoint you pass the variables in as branch arguments.disable_jitturns jit / scan / fori_loop / cond into Python, but vmap still traces. - Seeing the program: a literal in the jaxpr means a value was baked in as a constant;
jax.named_scopenames show up in the jaxpr name stack, in StableHLO (debug_info=True), and inop_namein the optimized HLO. - Numerics:
jax.debug_nans/jax.debug_infscan be used as context managers; in 0.12.0.1, when turned on globally withconfig.update, cached computations are missed once vmap has been traced (thewithform isn't affected); for a NaN in backprop you only get the primitive name. The four kinds ofcheckifychecks catch custom assertions, NaN, division by zero, and out-of-bounds indexing (JAX silently clamps out-of-bounds indices by default);vmap(checkify)reports every failing example,checkify(vmap)reports only one;float_checksdoesn't catch inf. For acustom_vjp,check_gradsonly works withmodes=["rev"]. - Tracer errors: only
TracerBoolConversionErroris a subclass ofConcretizationTypeError; the "depends on the value of the argument …" part of the error names the argument directly. Leak checking (jax.checking_leaks()) only happens at trace time, so clear the caches first. - Compilation and speed: one compilation emits 4 log lines; when a static argument's value changes, the cache-miss explanation is explanation unavailable;
_cache_size()is a private API, and all jit wrappers of the same Python function share the count;transfer_guard("disallow")catches implicit host → device transfers. - Errors and config:
JAX_TRACEBACK_FILTERINGhas 5 values;autoistracebackhidein Jupyter andquiet_remove_framesin scripts; read the config withjax.config.<name>orjax.config.values, notconfig.read. - Memory:
memory_analysis()withShapeDtypeStructestimates memory without allocating any; before/after snapshots oflive_arrays()find leaks;x.delete()frees right away (later use raisesRuntimeError, butreprdoesn't); old global arrays kept as constants by the jit cache can only be freed withclear_caches(). - Multiple devices:
jax.typeofshows sharding types likefloat32[8@data,4@model];visualize_array_shardingneedsrich(installed here as a dependency of flax), and without it you can use a replacement built ondevices_indices_map; ashard_mapwithcheck_vma=Falsemay return "replicas" that differ across devices, so compare them one by one withaddressable_shards. - Random numbers:
jax_debug_key_reusecatches reuse of typed keys; old-stylePRNGKeys aren't checked.
Cheat sheet¶
⚪ Optional: an API reference table for lookup
| API | What it does |
|---|---|
jax.debug.print("x={x:.3f}", x=x) | Print at runtime (str.format syntax, don't use f-strings) |
jax.debug.print(ordered=True)(fmt, ...) | The recommended two-step form; ordered=True keeps multiple prints in order |
jax.debug.log(fmt, ...) | Same as debug.print, but outputs to Python logging (logger jax._src.debugging, INFO level) |
jax.debug.callback(fn, *args) | Hand runtime values (NumPy arrays) to a host-side Python function; fn returns None |
jax.debug.breakpoint() | Stop at runtime and enter jdb (p / pp / u / d / l / w / c / q) |
jax.effects_barrier() | Wait for all dispatched callbacks (prints) to finish |
with jax.disable_jit(): | Run jit / scan / fori_loop / cond as Python (except vmap) |
jax.make_jaxpr(f)(*a) / jit(f).trace(*a).jaxpr | View the jaxpr; eqn.source_info.name_stack is the name stack |
jit(f).lower(*a).as_text(debug_info=True) | StableHLO, with source locations and named_scope |
jit(f).lower(*a).compile().as_text() | Optimized HLO, metadata={op_name=...} |
jax.stages.Traced / Lowered / Compiled | The three stage objects |
with jax.named_scope("name"): | Prefix the operations inside with a name, to find them in HLO / traces |
with jax.debug_nans(True): / jax.debug_infs(True) | Check for NaN / inf within the scope, raising FloatingPointError |
jax.clear_caches() | Clear the compilation cache: work around the missed detection of global debug_nans, make leak checking retrace, free cached constants |
checkify.checkify(f, errors=...) | Functionalized checks, returns (err, out); err.get() / err.throw() |
checkify.check(pred, "msg {v}", v=x) | Custom runtime assertion (must be wrapped by checkify) |
checkify.user_checks / nan_checks / div_checks / index_checks / float_checks / all_checks | Error sets; combine them with the bitwise-or operator (for example float_checks with user_checks) |
checkify.debug_check(...) | A no-op when not wrapped by checkify |
import jax.test_util; check_grads(f, args, order, modes) | Check gradients with finite differences; custom_vjp only supports modes=["rev"] |
jax.errors.TracerBoolConversionError etc. | Tracer error classes; catch them all with jax.errors.JAXTypeError |
with jax.checking_leaks(): / jax.check_tracer_leaks(True) | Check for tracer leaks when tracing finishes, reporting the reference chain |
with jax.log_compiles(): | Emit 4 log lines per compilation (with shapes and timings) |
with jax.explain_cache_misses(): | Explain why the cache missed |
jit_f._cache_size() | Private API: number of cache entries (shared by all wrappers of the same Python function) |
jax.monitoring.register_event_duration_secs_listener | The basis of this chapter's CompileCounter (/jax/core/compile/backend_compile_duration) |
with jax.transfer_guard("disallow"): | Make implicit host ↔ device transfers raise an error |
JAX_TRACEBACK_FILTERING=off (also tracebackhide / remove_frames / quiet_remove_frames / auto) | Control traceback filtering; also jax.config.update("jax_traceback_filtering", ...) |
jax.config.jax_debug_nans / jax.debug_nans.value / jax.config.values | Read the config |
jax.print_environment_info(return_string=True) | Version and device info (for issues) |
compiled.memory_analysis() | Argument / output / temporary buffer / peak byte counts |
jax.profiler.save_device_memory_profile(path) / device_memory_profile() | Live buffers aggregated by allocation site (pprof format) |
jax.live_arrays() | All currently live jax.Arrays |
x.delete() / x.is_deleted() | Free the device buffer right away / whether it's been freed |
device.memory_stats() | Live device memory stats on GPU/TPU (None on CPU) |
jax.typeof(x) | Abstract type, including sharding info (like float32[8@data,4@model]) |
x.sharding.devices_indices_map(x.shape) | The slice each device holds |
jax.debug.visualize_array_sharding(x) | Draw the sharding layout (needs rich) |
jax.debug.inspect_array_sharding(x, callback=...) | See the sharding the compiler chose, inside jit |
x.addressable_shards | The actual data on each local device (check whether replicas agree) |
with jax.debug_key_reuse(True): | Raise jax.errors.KeyReuseError when a typed key is reused |
jax.random.key_data(key) | Get a key's raw data (to compare whether two keys are the same) |
A final check: every debug switch turned on in this chapter has been restored (compared item by item with the default config from a clean subprocess).
leftover = nondefault_flags()
print("Config options that differ from the defaults:", leftover or "none")
assert leftover == {}
assert jax.config.jax_debug_nans is False and jax.config.jax_traceback_filtering == "auto"
Appendix: reference solutions¶
Write your own answer in the "✍️ Your answer" cell in the main text first and check it with the "🧪 Self-check". Come here to compare once the self-check passes, or if 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: Four Tracer-error fixes" in the main text for the task.
# Reference solution: Exercise 1
ex1_errors = {}
for name, (fn, args) in ex1_calls.items():
try:
jax.jit(fn)(*args)
ex1_errors[name] = None
except Exception as e:
ex1_errors[name] = type(e).__name__
print("Measured exceptions:", ex1_errors)
assert ex1_errors == {"ex1_normalize": "TypeError", "ex1_topk_mean": "IndexError",
"ex1_count_pos": "NonConcreteBooleanIndexError", "ex1_clip_by_norm": "TracerBoolConversionError"}
# Fixes: strings / integers that determine shapes → static arguments; boolean indexing → masked sum; value-dependent if → jnp.where
fixed = {
"ex1_normalize": jax.jit(ex1_normalize, static_argnames="mode"),
"ex1_topk_mean": jax.jit(ex1_topk_mean, static_argnames="k"),
"ex1_count_pos": jax.jit(lambda x: jnp.sum(x > 0)),
"ex1_clip_by_norm": jax.jit(lambda x, max_norm: x * jnp.minimum(1.0, max_norm / jnp.linalg.norm(x))),
}
x64 = to64(ex1_x)
refs = {"ex1_normalize": x64 / np.abs(x64).sum(), "ex1_topk_mean": np.sort(x64)[::-1][:2].mean(),
"ex1_count_pos": (x64 > 0).sum(), "ex1_clip_by_norm": x64 * min(1.0, 2.0 / np.linalg.norm(x64))}
for name, (_, args) in ex1_calls.items():
out = fixed[name](*args)
print(f" {name:17s} → {np.round(np.asarray(out), 4)}")
assert np.allclose(out, refs[name], atol=1e-6)
assert np.allclose(fixed["ex1_normalize"](ex1_x, "l2"), x64 / np.linalg.norm(x64), atol=1e-6) # the other branch
assert np.allclose(fixed["ex1_clip_by_norm"](ex1_x, 100.0), x64) # returned unchanged when no scaling is needed
Exercise 2¶
See "Exercise 2: a nan_guard decorator" in the main text for the task.
# Reference solution: Exercise 2
def nan_guard(fn):
checked = jax.jit(checkify.checkify(fn, errors=checkify.float_checks | checkify.user_checks))
@functools.wraps(fn)
def wrapper(*args, **kwargs):
err, out = checked(*args, **kwargs)
msg = err.get()
if msg is not None:
raise FloatingPointError(f"{fn.__name__}: {msg.strip()}")
return out
wrapper.checked = checked
return wrapper
guarded = nan_guard(ex2_layer)
good_x, bad_x = jnp.array([1.0, 2.0, 4.0]), jnp.array([1.0, -2.0, 4.0])
s1, s0 = jnp.float32(2.0), jnp.float32(0.0)
outcomes = []
with CompileCounter() as cc_ex2:
for x, s in [(good_x, s1), (bad_x, s1), (good_x, s0), (good_x, s1), (bad_x, s1)]:
try:
outcomes.append(("ok", guarded(x, s)))
except FloatingPointError as e:
outcomes.append(("err", str(e)))
for kind, v in outcomes:
print(f" {kind:3s} {v}")
print("Compile count:", cc_ex2.count)
assert [k for k, _ in outcomes] == ["ok", "err", "err", "ok", "err"]
assert np.allclose(outcomes[0][1], np.log(to64(good_x)) * 2.0)
assert outcomes[1][1].startswith("ex2_layer: nan generated by primitive: log")
assert outcomes[2][1].startswith("ex2_layer: scale must not be 0")
assert cc_ex2.count == 1
Exercise 3¶
See "Exercise 3: a leak detector" in the main text for the task.
# Reference solution: Exercise 3
def track_new_arrays(fn, *args):
gc.collect()
before_ids = {id(a) for a in jax.live_arrays()} # keep only the ids, not the arrays themselves
out = fn(*args)
jax.block_until_ready(out)
gc.collect()
out_ids = {id(a) for a in jax.tree.leaves(out)}
new = Counter((a.shape, str(a.dtype)) for a in jax.live_arrays()
if id(a) not in before_ids and id(a) not in out_ids)
return out, new
class ex3_TrainerFixed(ex3_Trainer):
def run(self, n):
for _ in range(n):
self.w, g = ex3_step(self.w, self.x)
self.history.append(float(jnp.linalg.norm(g))) # keep only a Python float
return self.w
t_bad, t_ok = ex3_Trainer(ex3_w0, ex3_x), ex3_TrainerFixed(ex3_w0, ex3_x)
w_bad, leaks_bad = track_new_arrays(t_bad.run, 5)
w_ok, leaks_ok = track_new_arrays(t_ok.run, 5)
print("Leaky version:", dict(leaks_bad))
print("Fixed version:", dict(leaks_ok), "| recorded gradient norms:", [round(v, 4) for v in t_ok.history])
assert leaks_bad == Counter({((32, 16), "float32"): 5})
assert leaks_ok == Counter() and len(t_ok.history) == 5
assert np.array_equal(w_bad, w_ok)
Exercise 4¶
See "Exercise 4: automatically locating the first NaN" in the main text for the task.
# Reference solution: Exercise 4
def first_nan_step(step_fn, w, batches):
for i, batch in enumerate(batches):
w_new, loss = step_fn(w, batch)
if not np.isfinite(float(loss)):
with jax.debug_nans(True): # the with form isn't affected by the vmap missed-detection issue (§3.1)
try:
jax.block_until_ready(step_fn(w, batch)) # rerun this step with the parameters from before things went wrong
return i, None
except FloatingPointError as e:
m = re.search(r"encountered in (\w+)", str(e))
return i, m.group(1) if m else str(e)
w = w_new
return None
result = first_nan_step(ex4_step, jnp.float32(1.0), ex4_batches)
print("First NaN:", result)
assert result == (5, "log") and jax.config.jax_debug_nans is False
assert first_nan_step(ex4_step, jnp.float32(1.0), ex4_batches[:5]) is None # no NaN in the first 5 steps