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:

  1. 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;
  2. See runtime values inside jit / vmap / grad with jax.debug.print, jax.debug.callback, jax.debug.breakpoint and jax.disable_jit(), and know how each behaves under the various transformations;
  3. See the program itself: jaxpr, StableHLO, optimized HLO, and use jax.named_scope to find your own code in the HLO;
  4. Catch numerical problems with jax_debug_nans, checkify and check_grads, and know how to work around the case jax_debug_nans misses in 0.12.0.1;
  5. Recognize every kind of Tracer error and apply the standard fix; catch tracer leaks with the leak checker;
  6. Read JAX error tracebacks, switch JAX_TRACEBACK_FILTERING, and inspect the current configuration;
  7. Investigate memory with memory_analysis, live_arrays, delete and clear_caches; investigate multi-device problems with jax.typeof and the sharding inspection tools.

Contents

#SectionMain tools
0Symptom lookup table · Common setup—
1Seeing valuesjax.debug.print / callback / breakpoint, jax.disable_jit()
2Seeing the programmake_jaxpr, lower().as_text(), compile().as_text(), jax.stages, jax.named_scope
3Numerical problemsjax_debug_nans / jax_debug_infs, checkify, check_grads
4A catalog of Tracer errorsjax.errors.*, jax.checking_leaks()
5Compilation and speedjax_log_compiles, jax_explain_cache_misses, _cache_size, compile counting, transfer_guard
6Error messages and configurationJAX_TRACEBACK_FILTERING, jax.config, jax.print_environment_info()
7Memorymemory_analysis(), save_device_memory_profile, jax.live_arrays(), x.delete(), jax.clear_caches()
8Multiple devicesjax.typeof, visualize_array_sharding and its replacements, inspect_array_sharding
9Hands-on: a training script with 3 hidden bugsAll 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.

SymptomDo this firstThen useThis chapterExisting notes
Output contains NaN / infCheck on the host at every step that the loss is finitecheckify (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, §9Basics 07 Topic 2
Wrong numbers (no error)Cross-check against NumPy float64Bisect with jax.disable_jit(): right in eager, wrong under jit → a compilation-level problem; then look at the jaxpr / HLO§1.4, §200 Known issue 1, Advanced 02 · 2.3
Slow compilation / repeated compilationCount compilations (CompileCounter)jax_log_compiles for shapes, jax_explain_cache_misses for the reason, _cache_size() for cache entries§502 · 2.3 overview table, Basics 07 Topic 1
Tracer errorRead the line "The error occurred while tracing … argument x" in the errorRewrite following the table in §4: jnp.where / lax.cond / static argument / size=§402 · 2.4–2.6
UnexpectedTracerErrorFind where the function stores an intermediate value somewhere outsidejax.checking_leaks() reports the reference chain right when tracing ends§4.6Advanced 01
Out of memoryEstimate 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§7Advanced 02 · 2.1, Basics 07 Topic 6
SlowFirst rule out repeated compilation; remember block_until_ready when timingjax.stages to split compile / run time; transfer_guard to catch implicit transfers; profiler§5.5Advanced 02 · 2.1, 2.7
Inconsistent results across devicesCheck 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§805 · 5.7, Advanced 03
Randomness is wrong (same dropout every step)Print each step's key with key_datajax_debug_key_reuse reports the reused key directly§901 · 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): runs fn, prints the exception type and the first line of the message, and returns the exception object so we can assert isinstance(...) afterwards;
  • capture_stdout(fn): collects what fn prints to stdout while it runs (including jax.debug.print output), for assertions;
  • capture_jax_logs: temporarily takes over the jax logger and collects compile logs (same approach as Basics 07 Topic 1.4);
  • CompileCounter: a compile counter based on jax.monitoring events (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]
JAX 0.12.0.1 | backend cpu | devices [CpuDevice(id=0)]

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:

ToolGood forCost
jax.debug.printQuickly printing a few intermediate valuesOne host callback; doesn't change the numbers in the compiled result
jax.debug.callbackHanding values to any Python function (collecting statistics, logging, assertions)Same as above; the callback must return None
jax.debug.breakpointStopping somewhere and inspecting variables interactivelyNeeds stdin (an interactive terminal)
jax.disable_jit()Running the whole thing as plain Python, so print, pdb and Python if workVery 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
positional: first=1.0 last=4.5
mean=2.500  max=4.50  size=3

# ❌ 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
Actual output: x = JitTracer(float32[])
# ✅ 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"
Actual output: 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"]
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"]
logging received: ['loss = 0.25']

Behavior under transformations (the next cell checks each row):

ScenarioBehavior
vmapPrints 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.condPrints only for the branch that actually runs
lax.cond inside vmapcond is rewritten into select, so both branches run, and each example prints once from each branch
vmap + ordered=TrueWorks 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"]
vmap            saw x = 1.0 | saw x = 2.0 | saw x = 3.0
grad            saw x = 2.0
jit(grad)       saw x = 2.0
cond            positive branch 1.0
vmap(cond)      negative branch 1.0 | negative branch -1.0 | positive branch 1.0 | positive branch -1.0
vmap + ordered  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
h's incoming gradient: [0.92423433 0.39475057] | gradient w.r.t. w: [0.36343098 0.03793723]

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
  relu0: mean=0.483 std=0.742 dead fraction=0.52
  relu1: mean=0.236 std=0.447 dead fraction=0.60
Number of records under vmap: 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:

CommandWhat it does
p expr / pp exprPrint / 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)
Entering jdb:
(jdb) Array([0.       , 1.6829419, 1.8185948], dtype=float32)
(jdb) Array([0., 1., 2.], dtype=float32)
(jdb) RESULT [1.0, 2.6829419136047363, 2.8185949325561523]

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"]
RESULT ok-run [0.0, 0.6931471824645996]
Entering jdb:
(jdb) Array([ 0., nan], dtype=float32)
(jdb) Array([ 1., -2.], dtype=float32)
(jdb) RESULT nan-run [0.0, nan]

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.jit run as plain Python, with concrete arrays as arguments, so Python if, print and pdb all work;
  • lax.scan / lax.fori_loop / lax.cond also fall back to Python implementations: the loop body runs step by step and sees concrete values, and the fori_loop index is even a Python int;
  • But vmap is not affected: the function still sees a BatchTracer, so a value-dependent Python if inside 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"
   argument type inside jit ArrayImpl
   carry inside scan body ArrayImpl
   carry inside scan body ArrayImpl
   fori index int
   fori index int
   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_jit right → 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.")
    jit error = 107 | disable_jit error = 5.91e-06
jit wrong, eager right → the problem is in the compilation layer
# ✅ 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
    jit error = 5.91e-06 | disable_jit error = 5.91e-06

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:

LevelHow to get itWhat it answersExisting notes
jaxprjax.make_jaxpr(f)(*args), jax.jit(f).trace(*args).jaxprWhat JAX thinks it needs to compute: which values got baked in as constants, whether control flow was unrolled, what the transformations insertedAdvanced 02 · 2.2 (walking the jaxpr, counting primitives)
StableHLOjax.jit(f).lower(*args).as_text()The program before it's handed to XLA; debug_info=True adds source locations02 · AOT extra
Optimized HLO….lower(*args).compile().as_text()What XLA actually runs: fusion, layouts, constants; metadata={op_name=...} traces back to the sourceAdvanced 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
{ lambda ; a:f32[]. let b:f32[] = mul a 2.0:f32[] in (b,) }
# ❌ 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
before changing SCALE = 2.0, after = 2.0 (no error, but it still uses the old value)
# ✅ 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
{ lambda ; a:f32[] b:f32[]. let c:f32[] = mul a b in (c,) }

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:

  1. the source_info.name_stack of each jaxpr equation;
  2. the location info in StableHLO (only printed with as_text(debug_info=True));
  3. the metadata={op_name="jit(f)/name/..."} of each instruction in the optimized HLO;
  4. 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)))
① jaxpr name stacks: ['encoder', 'encoder', 'head', 'head', 'head', 'head']
② Default StableHLO text contains 'encoder'? False | with debug_info=True: ['#loc73 = loc("jit(scoped_mlp)/encoder/dot_general"(#loc67))', '#loc74 = loc("jit(scoped_mlp)/encoder/jit(relu)"(#loc68))']
③ Instructions in the optimized HLO that belong to encoder:
     %dot_general.1        ← jit(scoped_mlp)/encoder/dot_general
     %broadcast.2          ← jit(scoped_mlp)/encoder/jit(relu)
     %max.2                ← jit(scoped_mlp)/encoder/jit(relu)/max
     %ynn_fusion.1         ← jit(scoped_mlp)/encoder/dot_general
     %broadcast_maximum_fusion ← jit(scoped_mlp)/encoder/jit(relu)/max

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:

ObjectAttribute / methodDebugging use
Traced.jaxpr, .out_info, .in_avalsGet 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_shardingsSee 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
Types: Traced → Lowered → Compiled
Traced.out_info: ShapeDtypeStruct(shape=(), dtype=float32) | keys of Compiled.input_shardings[0]: ['w1', 'w2']

3 Numerical problems: NaN, inf, out-of-bounds, division by zero and wrong gradients

ToolMechanismGood for
jax_debug_nans / jax_debug_infsGlobal switch: checks after every output; when it finds one, it reruns primitive by primitive and raises FloatingPointError at the primitive that produced the NaNTurning on temporarily while debugging, and rerunning just the failing step
checkifyCompiles the checks into the program and returns an error value; you decide when to raise itStaying in the code long-term; composes with jit / vmap / grad; also catches out-of-bounds indexing and division by zero
jax.test_util.check_gradsCompares jvp / vjp against finite differencesAfter 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
inside the block: True
Invalid nan value encountered in the output of a jax.jit function. Calling the de-optimized version.
Invalid nan value encountered in the output of a jax.jit function. Calling the de-optimized version.
  → FloatingPointError: invalid value (nan) encountered in log
Invalid nan value encountered in the output of a jax.jit function. Calling the de-optimized version.
Invalid nan value encountered in the output of a jax.jit function. Calling the de-optimized version.
  → FloatingPointError: invalid value (inf) encountered in div
outside the block: False 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: vmap was never used in the process;
  • vmap: an unrelated vmap was called once in between;
  • clear: vmap was called, followed by jax.clear_caches();
  • with: the switch is turned on with the jax.debug_nans(True) context manager instead of config.update (equivalent to putting the whole script inside a with block), and vmap is 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"
Case                     no vmap    vmap used  vmap+clear with + vmap
jit fn already run     caught       missed       caught       caught
eager log already run  caught       missed       caught       caught
eager sqrt never run   caught       caught       caught       caught
newly created jit fn   caught       caught       caught       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)"]
  after grad               → NaN in the old jit function: caught
  after scan               → NaN in the old jit function: caught
  after make_jaxpr         → NaN in the old jit function: caught
  after checkify           → NaN in the old jit function: caught
  after config.update      → NaN in the old jit function: caught
  after make_jaxpr(vmap)   → NaN in the old jit function: missed
  after jit(vmap)          → NaN in the old jit function: missed

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']"]
Invalid nan value encountered in the backward pass of a jax.jit function. Calling the de-optimized backward pass.
  → FloatingPointError: invalid value (nan) encountered in mul
User code frames in the traceback: [('show_error', 'out = fn()'), ('<lambda>', 'err = show_error(lambda: jax.grad(reg_loss)(p_reg, x_reg))')]
Leaves whose gradient contains NaN: ["['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 setContainsWhat it catches
checkify.user_checks (default)FailedCheckErrorYour own checkify.check(pred, msg, ...)
checkify.nan_checksNaNErrorAny primitive producing a NaN (reports the primitive name)
checkify.div_checksDivisionByZeroErrorA divisor of 0 (both integer and floating-point division)
checkify.index_checksOOBErrorOut-of-bounds indexing (by default JAX silently clamps the index!)
checkify.float_checksNaN + division by zeroThe usual combination
checkify.all_checksAll of the aboveThe 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
Check set    normalx negative index OOB  div by 0
user               —  ✔ caught         —         —
nan                —  ✔ caught         —         —
index              —         —  ✔ caught         —
div                —         —         —  ✔ caught
all                —  ✔ caught  ✔ caught  ✔ caught

Sample messages:
  ('user', 'x negative'): x must be positive, min = -1.0 (`check` failed)
  ('nan', 'x negative'): nan generated by primitive: log.
  ('index', 'index OOB'): out-of-bounds indexing for array of shape (3,): index 5 is out of bounds for axis 0 with s
  ('div', 'div by 0'): division by zero

Actual result of x[5] without checks: 3.0 (the index is clamped to 2, a silent error)
# ❌ 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)
Actual error: ValueError: Cannot abstractly evaluate a checkify.check which was not functionalized. This probably means you tried to stage (jit/sc
# ✅ 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)
Good input: None | bad input: x must be positive (`check` failed)
err.throw() → JaxRuntimeError (inheritance chain ['JaxRuntimeError', 'ValueError', 'Exception'])

Composing with jit / vmap / grad (the next cell checks each row):

CombinationBehavior
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 bodyWorks normally and reports the value from the first failing iteration
float_checks and infDoesn't catch inf (exp(1000.) raises nothing); for inf, write your own check(jnp.isfinite(...))
checkify.debug_checkA 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"]
  jit(checkify)          → x must be positive, got -1.0 (`check` failed)
  checkify(jit)          → x must be positive, got -1.0 (`check` failed)
  vmap(checkify)         → at mapped index 1: x must be positive, got -2.0 (`check` failed) ⏎ at mapped index 3: x must be positive, got -4.0 (`check` failed)
  checkify(vmap)         → x must be positive, got -4.0 (`check` failed)
  checkify(grad)         → nan generated by primitive: mul.
  scan                   → value at step 1 is too large: 20.0 (`check` failed)
  float_checks and inf   → None
  debug_check + checkify → debug_check failed: -1.0 (`check` failed)
  gradient of grad(checkify(f)[1]): -1.0 | result of debug_check when not wrapped: -2.0

🔍 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()
  → TypeError: Formatting arguments to checkify.check need to be PyTrees of arrays, but got -1.0 of type <class 'float'>.

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"] raises TypeError: can't apply forward-mode autodiff (jvp) to a custom_vjp function;
  • On failure it raises AssertionError, with VJP cotangent projection and the actual / expected values in the message;
  • Under float32 the default tolerance is about 2e-3; adjust with atol / rtol / eps if needed, or check with float64 inputs inside with 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)
check_grads found an error: Not equal to tolerance rtol=0.002, atol=0.002 | VJP cotangent projection
  → TypeError: can't apply forward-mode autodiff (jvp) to a custom_vjp function.
# ✅ 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 ✔")
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 codeActual exceptionStandard fix
if x > 0:, while x > 1:, and / orTracerBoolConversionErrorjnp.where, lax.cond, lax.while_loop
range(n), [a, b, c][i] (__index__)TracerIntegerConversionErrorstatic_argnames; lax.fori_loop; jnp.asarray(list)[i] first
np.sin(x), np.asarray(x) (__array__)TracerArrayConversionErrorSwitch 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)ConcretizationTypeErrorStatic 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 valuesMake the shape argument static
x[i:i+2] (the slice bound is a tracer)Plain IndexErrorlax.dynamic_slice(x, (i,), (2,))

⚠️ A common misconception: it's easy to assume these three subclasses all inherit from ConcretizationTypeError and can be caught together (see also the 02 · 2.4 extra). Actually, in 0.12.0.1 (checked in the next cell), only TracerBoolConversionError is a subclass of it. TracerIntegerConversionError and TracerArrayConversionError inherit directly from JAXTypeError (which is also a TypeError). To catch them all at once, use jax.errors.JAXTypeError or TypeError.

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)
  ConcretizationTypeError        → ['JAXTypeError', '_JAXErrorMixin', 'TypeError']
  TracerBoolConversionError      → ['ConcretizationTypeError', 'JAXTypeError', '_JAXErrorMixin']
  TracerIntegerConversionError   → ['JAXTypeError', '_JAXErrorMixin', 'TypeError']
  TracerArrayConversionError     → ['JAXTypeError', '_JAXErrorMixin', 'TypeError']
  NonConcreteBooleanIndexError   → ['JAXIndexError', '_JAXErrorMixin', 'IndexError']
  UnexpectedTracerError          → ['JAXTypeError', '_JAXErrorMixin', 'TypeError']

How to read a Tracer error: the message is long, but only two sentences matter. Below we extract them:

  1. The first line: which conversion failed (Attempted boolean conversion of traced array with shape bool[]);
  2. 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 reads the 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
  → TracerBoolConversionError: Attempted boolean conversion of traced array with shape bool[].
Function at fault: clip_positive | culprit argument(s): x and limit

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)
Actual error: TracerBoolConversionError: Attempted boolean conversion of traced array with shape bool[].
# ✅ 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])
jnp.where: [0. 0. 3.] | lax.cond: [-0.0, 0.0, 3.0]

With grad alone (no jit), a Python if does 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 in vmap or jit. 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)
range(n)  actual error: TracerIntegerConversionError: The __index__() method was called on traced array with shape int32[]
list[i]   actual error: TracerIntegerConversionError: The __index__() method was called on traced array with shape int32[]
# ✅ 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))
static: 4.0 | fori_loop: 4.0 | array indexing: 20.0

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)
Actual error: TracerArrayConversionError: The numpy.ndarray conversion method __array__() was called on traced array with shape float32[3]
# ✅ 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)))
jnp: [1.6829419 1.6829419 1.6829419] | pure_callback: [1.682942 1.682942 1.682942]

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
float(x)        actual error: ConcretizationTypeError: Abstract tracer value encountered where concrete value is expected: traced array
x.item()        actual error: ConcretizationTypeError: Abstract tracer value encountered where concrete value is expected: traced array
jnp.arange(n)   actual error: ConcretizationTypeError: Abstract tracer value encountered where concrete value is expected: traced array
jnp.nonzero(x)  actual error: ConcretizationTypeError: Abstract tracer value encountered where concrete value is expected: traced array
# ✅ 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])))
arange(static n): [0 1 2] | nonzero(size=3): [ 0  2 -1]

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)
x[x > 0]      actual error: NonConcreteBooleanIndexError: Array boolean indices must be concrete; got bool[3]
jnp.zeros(n)  actual error: TypeError: Shapes must be 1D sequences of concrete values of integer type, got (JitTracer(~int32[]),)
x[i:i+2]      actual error: IndexError: Array slice indices must have static start/stop/step to be used with NumPy indexing syntax
# ✅ 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))
masked sum: 3.0 | dynamic_slice: [1. 2.]

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)
Actual error: UnexpectedTracerError: Encountered an unexpected tracer. A function transformed by JAX had a side effect, allowing for a reference to
# ✅ 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)
hidden, returned explicitly: [0.7615942 0.7615942 0.7615942]

Leak checking: error out the moment the leak happens. 0.12 has three equivalent ways to write it:

FormNotes
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()
Type: Exception
    Leaked trace DynamicJaxprTrace. Leaked tracer(s):
    <DynamicJaxprTracer 4672637456> is referred to by <list 4665117376>[0]
    <list 4665117376> is referred to by __main__.leaked

⚠️ 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()
On a cache hit: no error
After clear_caches: error: Leaked trace DynamicJaxprTrace. Leaked tracer(s):

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".

ToolQuestion it answersAdded in this section
jax_log_compilesWhat was compiled and how long it tookOne compilation emits 4 log lines; eager operations like jnp.ones also show up in the log
jax_explain_cache_missesWhy the cache missedWhen a static argument value changes, it only says "explanation unavailable"
f._cache_size()How many entries are in the cachePrivate API; all jit wrappers of the same Python function share the count
CompileCounter / CompileBudgetExact counts you can put in an assertSee 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}
Start of each log line (first two words): ['Compiling jit(broadcast_in_dim)', 'Compiling jit(convert_element_type)', 'Compiling jit(mlp_step)', 'Finished XLA', 'Finished jaxpr', 'Finished tracing']
  jit(convert_element_type)    XLA compile    2.5 ms  args (ShapedArray(float32[]),)
  jit(broadcast_in_dim)        XLA compile   13.5 ms  args (ShapedArray(float32[]),)
  jit(broadcast_in_dim)        XLA compile   12.4 ms  args (ShapedArray(float32[]),)
  jit(mlp_step)                XLA compile   13.3 ms  args (ShapedArray(float32[64,64]), ShapedArray(float32[8,64]))
  jit(broadcast_in_dim)        XLA compile   12.9 ms  args (ShapedArray(float32[]),)
  jit(mlp_step)                XLA compile   14.0 ms  args (ShapedArray(float32[64,64]), ShapedArray(float32[16,64]))

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:

SituationOutput
First callnever seen function: <name> id=... defined at <file:line>
Input types / PyTree structure changeddifferent input types: * at x, now i32[5] and before f32[5], or different input pytree: ...
A static argument's value changedOnly 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]
  • never seen function: / decay_step id=4658219312 defined at /tmp/ipykernel_13
  • cache miss explanation unavailable
  • different input types: / * at w, now i32[3] and before f32[3]

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:

  1. The cache hangs off the underlying Python function: if you jax.jit(f) the same f again, the new wrapper's _cache_size() isn't 0 even at the start;
  2. Wrappers with different jit options also share this count;
  3. 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]
  new                      0
  after 3 calls (2 signatures) 2
  second wrapper (not called yet) 2
  seen by j1 after j2 compiles once 3
  wrapper with different options 3
  after clear_caches()     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 calls, 2 shapes → compiled 2 times

5.5 Slow code: what to check, in order

🟢 Must-read

  1. 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.
  2. Is the timing right? JAX uses asynchronous dispatch, so timing without block_until_ready only measures dispatch time (Advanced 02 · 2.7).
  3. Is the time spent compiling or running? Use jax.stages to split one call into trace / lower / compile / run and time each part (next cell).
  4. Which operator does the run time go to? Capture a trace with jax.profiler.trace (Advanced 02 · 2.1) and match it up using the named_scope from §2.2.
  5. 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)
trace 2.2 ms | lower 1.5 ms | compile 28.3 ms | first run 7.38 ms | steady state 0.082 ms/call

🔍 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
  device array arg       allowed
  NumPy array arg        blocked (JaxRuntimeError)
  Python scalar arg      blocked (JaxRuntimeError)
  explicit device_put    allowed
  float(result) (device→host) allowed

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.

ValueBehavior
auto (default)Same as tracebackhide in IPython ≥ 7.17, otherwise same as quiet_remove_frames
tracebackhideKeeps all frames but marks internal frames with __tracebackhide__; IPython / Jupyter collapse them into [... skipping hidden N frame]
remove_framesActually removes the internal frames; the full original traceback is attached to the exception as __cause__ (UnfilteredStackTrace)
quiet_remove_framesRemoves internal frames and only appends a short hint (For simplicity, JAX has removed its internal frames ...)
offNo 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"
  auto                 frames 11 | JAX internal frames  8 | __cause__ —                    | note 0
  tracebackhide        frames 11 | JAX internal frames  8 | __cause__ —                    | note 0
  off                  frames 11 | JAX internal frames  8 | __cause__ —                    | note 0
  remove_frames        frames  6 | JAX internal frames  3 | __cause__ UnfilteredStackTrace | note 0
  quiet_remove_frames  frames  6 | JAX internal frames  3 | __cause__ —                    | note 1

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
End of the output in auto mode:
    TypeError: cannot reshape array of shape (4,) (size 4) into shape (3, 5) (size 15)
    --------------------
    For simplicity, JAX has removed its internal frames from the traceback of the following exception. Set JAX_TRACEBACK_FILTERING=off to include these.
stderr line count: auto 18 lines, off 28 lines
Invalid value: ValueError: Invalid value "bogus" for JAX flag jax_traceback_filtering

How to read a JAX traceback (read it bottom to top):

  1. 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;
  2. 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_reshape in the example);
  3. The jax/_src/... frames in between usually don't need reading; [... skipping hidden N frame] is the part folded away by tracebackhide;
  4. 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 with checkify or debug.print;
  5. Use JAX_TRACEBACK_FILTERING=off only 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]
  <module>        line 7: outer_jit(jnp.ones(4))
  <lambda>        line 4: outer_jit = jax.jit(lambda x: inner_reshape(x) * 2)
  inner_reshape   line 2: return x.reshape(3, 5)            # reshape 4 elements into 3×5: error

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:

FormNotes
jax.config.jax_debug_nansThe most common, attribute access
jax.debug_nans.valueWorks for any option that has a context manager of the same name
jax.config.valuesA 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=1Read 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"]
Total number of config options: 144
attribute / .value: False False
  → AttributeError: For flags with a corresponding contextmanager, read their value via e.g. `config.jax_debug_nans` rather than `config.FLAGS.jax_debug_nans`.
Options changed inside the with block: {'jax_debug_nans': ('False', 'True'), 'jax_log_compiles': ('False', 'True')}
Outside the with block: {}
Subprocess started with environment variables: ['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
jax:    0.12.0.1
jaxlib: 0.12.0.1
numpy:  2.5.4
python: 3.14.7 (main, Aug  5 2026, 10:29:49) [Clang 21.0.0 (clang-2100.1.1.101)]
device info: cpu-1, 1 local devices"
process_count: 1
platform: uname_result(system='Darwin', node='<redacted>', release='27.0.0', version='Darwin Kernel Version 27.0.0: Tue Aug 11 21:06:40 PDT 2026; root:xnu-13432.1.9~1/RELEASE_ARM64_T6050', machine='arm64')

7 Memory: estimate, find what's holding it, free it

Memory problems come in two kinds, with different tools:

ProblemTools
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() returns None. On GPU/TPU it returns fields like bytes_in_use and peak_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
  B=   64 | mean-gradient output     16.0 KiB | per-example gradient output    1.0 MiB | temp buffers   0.0 MiB
  B=  256 | mean-gradient output     16.0 KiB | per-example gradient output    4.0 MiB | temp buffers   0.0 MiB
  B= 1024 | mean-gradient output     16.0 KiB | per-example gradient output   16.0 MiB | temp buffers   0.0 MiB

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"
Snapshot size (decompressed): before 17538 bytes → after allocating 20 arrays 17838 → after deleting 17726; file 6208 bytes

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
Extra live arrays: {((), 'float32'): 1, ((64, 512), 'float32'): 10} | up by 1.25 MiB
# ✅ 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
Extra live arrays: {} | number of recorded losses: 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() is True;
  • any use (including through an alias y = x) raises RuntimeError: Array has been deleted with shape=...;
  • but print(x) / repr(x) don't raise; they show Array(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
Live bytes before and after delete: 4.36 MiB → 0.54 MiB
is_deleted: True | repr: Array(shape=(1000, 1000), dtype=float32)
  → RuntimeError: Array has been deleted with shape=float32[1000,1000].

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
Old TABLEs still alive after reassignment: 1 | after clear_caches: 0

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=4 before import 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 to run_py, the subprocess starts with 4 simulated devices, and the results come back through RESULT lines and then get asserted. If you want to work interactively in your own notebook, put os.environ["XLA_FLAGS"] = "--xla_force_host_platform_device_count=4" in the first cell, before import jax (that's what 05 and Advanced 03 do).

ToolWhat it doesNotes
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.specThe sharding object and the PartitionSpec
x.sharding.devices_indices_map(x.shape)Which block (slice) each device holdsThe ascii_sharding below is built on it
jax.debug.visualize_array_sharding(x)Draws the sharding layoutDepends on rich
jax.debug.inspect_array_sharding(x, callback=...)Shows the sharding of an intermediate value inside jitCalls back once at compile time
x.addressable_shardsThe actual data on each local deviceCheck 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
rich installed? True (version 15.0.0) | is rich among flax's dependencies? True
Devices: 4 | typeof: ['float32[8@data,4@model]', 'float32[8,4]', 'float32[8,4@model]', 'float32[3]'] | spec: P('data', 'model') | local block: [4, 2]
With rich: visualize=ok 
Without rich: visualize=ValueError: `visualize_sharding` requires `rich` to be installed.

The diagram drawn by visualize_array_sharding with rich:
                  
                  
  CPU 0    CPU 1  
                  
                  
                  
                  
                  
  CPU 2    CPU 3  
                  
                  
                  
The replacement function ascii_sharding without rich:
ASCII P('data','model')
+-------+-------+
| CPU 0 | CPU 1 |
+-------+-------+
| CPU 2 | CPU 3 |
+-------+-------+
ASCII P(None,'model')
+---------+---------+
| CPU 0,2 | CPU 1,3 |
+---------+---------+
ASCII P()
+-------------+
| CPU 0,1,2,3 |
+-------------+

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]"
  trace_typeof  float32[8@d,2]
  inspect       P('d',)
  out_typeof    float32[8@d]
  out_spec      P('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
device 0 local block [0. 1. 2. 3.]
device 1 local block [4. 5. 6. 7.]
device 2 local block [ 8.  9. 10. 11.]
device 3 local block [12. 13. 14. 15.]
  checked          ValueError: shard_map applied to the function 'local_sum' was given out_specs which require 
  bad_value        6.0
  bad_consistent   False
  bad_replicas     [6.0, 22.0, 38.0, 54.0]
  good_value       120.0
  good_consistent  True
  good_replicas    [120.0, 120.0, 120.0, 120.0]

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])
Data: (512, 16) | positive fraction: 0.55078125 | params: [{'b': (64,), 'w': (16, 64)}, {'b': (64,), 'w': (64, 64)}, {'b': (2,), 'w': (64, 2)}]

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
loss: [0.9846, nan, nan, nan, nan, nan, nan, nan]
8 steps took 2.13 s, train_step compiled 8 times

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.

  1. 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;
  2. Check the gradient leaf by leaf to see which parameters' gradients are broken;
  3. 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)
① forward: None | gradient: nan generated by primitive: mul.
② Leaves whose gradient contains NaN: ["[0]['b']", "[1]['b']", "[2]['b']"]
③ Cross-entropy gradient finite? True | regularizer gradient finite? False | initial biases: [0. 0. 0. 0.]

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
Loss after fixing the NaN: [0.9846, 1.2764, 6.8963, 1.0985, 0.5132, 0.4795, 0.4538, 0.4297] | compile count: 8

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
Compile count: 3 | distinct argument shapes: 1
  • never seen function:
  • cache miss explanation unavailable
  • cache miss explanation unavailable
_cache_size() (private API): 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
60 steps compiled 1 times | max loss difference vs the static-step version 1.19e-07

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:

  1. jax_debug_key_reuse (a config option available in 0.12): once it's on, a typed key (created by jax.random.key) is marked every time it's "consumed", and using the same key again immediately raises jax.errors.KeyReuseError, telling you which argument of which function it was. Raw uint32 keys from the old-style PRNGKey aren't checked. One more reason to use jax.random.key.
  2. Eavesdropping: wrap step_fn and record jax.random.key_data of 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
jax_debug_key_reuse: KeyReuseError → Previously-consumed key passed to random_split at index 0
Distinct dropout keys over 5 steps: 1
PRNGKey reused twice: no error, the two results are identical = True

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
Distinct dropout keys over 5 steps after the fix: 5
Final version: 60 steps, compiled 0 times (step_v2 was already compiled) | loss 0.971 → 0.205
Training accuracy: fixed mask 0.955 | new mask each step 0.973

9.4 Hands-on recap

🟢 Must-read

BugSymptomLocating toolsFixVerification
Differentiating the norm of a zero vectorLoss is NaN from step 2 oncheckify(grad) → leaf-by-leaf check → split the losssafe_norm with double whereGradient is 0 at 0; matches the jnp.linalg.norm gradient; finite throughout training
step is a static argumentCompiles once per stepCompileCounter, log_compiles (shapes unchanged), explain_cache_misses (explanation unavailable)Pass step as a traced int32 argument60 steps compile only once; loss matches the pre-fix run step by step
The key isn't advancedNo obvious symptom (weaker regularization)jax_debug_key_reuse → KeyReuseError; record key_datakey, 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)

ExerciseTopicSection
1Four Tracer-error fixes: first see what error you actually get, then rewrite until it jits§4
2Write a nan_guard decorator with checkify§3.2, §5.4
3Write a leak detector with jax.live_arrays()§7.3
4Automatically 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:

  1. 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 (mode is a string, not a valid JAX type), IndexError, NonConcreteBooleanIndexError, TracerBoolConversionError;
  2. Write a version of each function that jit can compile (you may change the body or use static_argnames), keeping the same calling convention;
  3. 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 1: not answered yet: name 'ex1_errors' is not defined

Exercise 2: a nan_guard decorator

⚪ Optional: exercise: practice for §3.2, §5.4

Write a decorator nan_guard(fn):

  1. Internally, compile with jax.jit(checkify.checkify(fn, errors=...)), checking for NaN, division by zero, and any checkify.check written in fn;
  2. When called, if a check fails, raise FloatingPointError with a message that starts with "<function name>: " followed by the checkify error message; otherwise return fn's output;
  3. Test it with ex2_layer below: normal input returns the right result; input with a negative number triggers a NaN from log; scale=0 triggers the custom check;
  4. Assert with CompileCounter that 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 2: not answered yet: name 'nan_guard' is not defined

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:

  1. For a run(5) call on ex3_Trainer below, assert that it detects 5 leaked (32, 16) arrays (from self.history);
  2. 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 3: not answered yet: name 'track_new_arrays' is not defined

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):

  1. Train normally (do not keep jax_debug_nans on the whole time), checking on the host at every step whether the loss is finite;
  2. 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. The with form isn't affected by the vmap missed-detection issue; if you turn it on globally with config.update instead, this process has already used vmap, so you must call jax.clear_caches() first (§3.1);
  3. Return (step, primitive name), parsing the primitive name from the FloatingPointError message invalid value (nan) encountered in <primitive>; return None if there's no NaN at all;
  4. Assert that the result is (5, "log"), and that jax_debug_nans is back to False after 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")
⏳ Exercise 4: not answered yet: name 'first_nan_step' is not defined

📝 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. TracerIntegerConversionError B. TracerArrayConversionError C. TracerBoolConversionError D. 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 use with jax.debug_nans(True): or checkify instead
  • 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_nans is 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_leaks was 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 equals quiet_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)
  1. C. In 0.12.0.1, only TracerBoolConversionError inherits from ConcretizationTypeError; the other two inherit directly from JAXTypeError. To catch them all at once, use jax.errors.JAXTypeError or TypeError (§4).
  2. B. vmap rewrites cond into select, so both branches run for every example: 2 examples × 2 branches = 4 lines (§1.1).
  3. 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; the with jax.debug_nans(True): form and checkify are not affected (§3.1).
  4. B. In 0.12, when a static argument's value changes, explain_cache_misses can'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).
  5. B. Leak checking happens when tracing finishes; a cache hit means no tracing. Call jax.clear_caches() before investigating (§4.6).
  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

  1. Start from the symptom, then pick the tool: for NaN, first check the loss on the host, then rerun the failing step with checkify or with jax.debug_nans(True):; for wrong numbers, first cross-check against float64, then bisect with disable_jit; for repeated compilation, first count compilations, then look at the logs and the cache-miss explanations.
  2. Seeing values: jax.debug.print uses str.format syntax, and the two-step jax.debug.print(ordered=True)(fmt, ...) is recommended; under vmap it prints per example, under grad it only shows primal values (use a custom_vjp probe to see gradients), and inside vmap both branches of a cond print. jax.debug.callback hands NumPy arrays to any Python function. jax.debug.breakpoint needs stdin, and for a conditional breakpoint you pass the variables in as branch arguments. disable_jit turns jit / scan / fori_loop / cond into Python, but vmap still traces.
  3. Seeing the program: a literal in the jaxpr means a value was baked in as a constant; jax.named_scope names show up in the jaxpr name stack, in StableHLO (debug_info=True), and in op_name in the optimized HLO.
  4. Numerics: jax.debug_nans / jax.debug_infs can be used as context managers; in 0.12.0.1, when turned on globally with config.update, cached computations are missed once vmap has been traced (the with form isn't affected); for a NaN in backprop you only get the primitive name. The four kinds of checkify checks 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_checks doesn't catch inf. For a custom_vjp, check_grads only works with modes=["rev"].
  5. Tracer errors: only TracerBoolConversionError is a subclass of ConcretizationTypeError; 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.
  6. 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.
  7. Errors and config: JAX_TRACEBACK_FILTERING has 5 values; auto is tracebackhide in Jupyter and quiet_remove_frames in scripts; read the config with jax.config.<name> or jax.config.values, not config.read.
  8. Memory: memory_analysis() with ShapeDtypeStruct estimates memory without allocating any; before/after snapshots of live_arrays() find leaks; x.delete() frees right away (later use raises RuntimeError, but repr doesn't); old global arrays kept as constants by the jit cache can only be freed with clear_caches().
  9. Multiple devices: jax.typeof shows sharding types like float32[8@data,4@model]; visualize_array_sharding needs rich (installed here as a dependency of flax), and without it you can use a replacement built on devices_indices_map; a shard_map with check_vma=False may return "replicas" that differ across devices, so compare them one by one with addressable_shards.
  10. Random numbers: jax_debug_key_reuse catches reuse of typed keys; old-style PRNGKeys aren't checked.

Cheat sheet

⚪ Optional: an API reference table for lookup

APIWhat 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).jaxprView 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 / CompiledThe 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_checksError 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_listenerThe 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.valuesRead 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_shardsThe 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"
Config options that differ from the defaults: none

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
Measured exceptions: {'ex1_normalize': 'TypeError', 'ex1_topk_mean': 'IndexError', 'ex1_count_pos': 'NonConcreteBooleanIndexError', 'ex1_clip_by_norm': 'TracerBoolConversionError'}
  ex1_normalize     → [ 0.2069 -0.069   0.2759 -0.1034  0.3448]
  ex1_topk_mean     → 4.5
  ex1_count_pos     → 3
  ex1_clip_by_norm  → [ 0.8222 -0.2741  1.0963 -0.4111  1.3704]

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
  ok  [0.        1.3862944 2.7725887]
  err ex2_layer: nan generated by primitive: log.
  err ex2_layer: scale must not be 0 (`check` failed)
  ok  [0.        1.3862944 2.7725887]
  err ex2_layer: nan generated by primitive: log.
Compile 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)
Leaky version: {((32, 16), 'float32'): 5}
Fixed version: {} | recorded gradient norms: [0.281, 0.2793, 0.2773, 0.2751, 0.2728]

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
Invalid nan value encountered in the output of a jax.jit function. Calling the de-optimized version.
Invalid nan value encountered in the output of a jax.jit function. Calling the de-optimized version.
First NaN: (5, 'log')