Chapter 2: Accelerating Functions with JIT Compilation

Course: "Getting Started with JAX" (apxml.com), Chapter 2 · personal study notes Original chapter page: https://apxml.com/courses/getting-started-with-jax/chapter-2-accelerating-functions-jit Environment: JAX 0.12.0.1 · Python 3.14 · CPU only. Every conclusion and output here is based on what actually ran in this environment.

Learning goals

  1. Understand why running JAX code line by line (eagerly) isn't fast enough, and what compilation buys you;
  2. Know both ways to use jax.jit (as a decorator and as a higher-order function);
  3. Understand JIT's internal pipeline: tracing → Jaxpr → lowering → XLA compilation → caching;
  4. Understand the limits tracing places on Python control flow, and learn to write compilable control flow with lax.cond / switch / fori_loop / while_loop / scan;
  5. Tell static values apart from traced values, use static_argnums / static_argnames, and know what triggers recompilation;
  6. Recognize common jit pitfalls: side effects, global variables, dynamic shapes, buffer donation, and more;
  7. Benchmark correctly: warm up first, then time with block_until_ready().

Sections

SectionOriginal course link
2.1 Speeding up: why compile?why-compile-jax
2.2 Introducing jax.jitintroducing-jax-jit
2.3 How JIT works: tracing and compilationhow-jit-works
2.4 Python control flow and jitjit-control-flow
2.5 Static vs. traced valuesjit-static-vs-traced
2.6 Common jit pitfallsjit-common-pitfalls
2.7 Hands-on: applying jithands-on-applying-jit
Extra exercises · Quiz · Summary · Cheat sheetquiz

How to read this: regular paragraphs explain the course material. Outdated patterns go in ❌ cells, each followed right away by a ✅ recommended version and checked with assert. Things to watch out for are marked "⚠️ Note". 🔍 Extra cells are optional deep dives. The "Version notes" section at the end of the chapter collects everything.

The explanations are reorganized around the course's key points. The example code keeps the course's variable names and values where possible, and all of it has been run for real.

# Shared imports and helper functions (every later cell depends on these)
import time
import timeit
import logging
import re
import contextlib
import warnings
from functools import partial

import numpy as np
import jax
import jax.numpy as jnp
from jax import lax

print("JAX version:", jax.__version__)
print("Devices:", jax.devices())
print("Device the array is on:", jnp.ones(1).device)   # 0.12 recommends the x.device attribute


def bench(f, *args, warmup=2, runs=20, **kwargs):
    """Timing helper: warm up first (triggering compilation), then run several times and average.

    Uses jax.block_until_ready to wait until the result is really computed -- it handles any PyTree,
    and also non-JAX return values (returned as is). Returns (mean seconds, standard deviation).
    """
    for _ in range(warmup):
        jax.block_until_ready(f(*args, **kwargs))
    ts = []
    for _ in range(runs):
        t0 = time.perf_counter()
        jax.block_until_ready(f(*args, **kwargs))
        ts.append(time.perf_counter() - t0)
    return float(np.mean(ts)), float(np.std(ts))


def assert_faster(fast, slow, factor=1.0, tries=3):
    """Timing assertion: time of fast() × factor < time of slow() (both arguments are no-arg functions that return a time).
    Timings depend on machine load (e.g. several notebooks running in parallel on CI), so if it fails, measure again, up to tries times."""
    for _ in range(tries):
        tf, ts = fast(), slow()
        if tf * factor < ts:
            return tf, ts
    raise AssertionError(f"Still not satisfied after {tries} tries: {tf:.3g} × {factor} ≥ {ts:.3g}")

def expect_error(fn, exc_type=Exception):
    """Assert that fn() raises exc_type: print the exception type and first line of its message, and return the exception.
    If nothing is raised, raise AssertionError -- used in the notebook to prove that some code really does fail."""
    try:
        out = fn()
    except exc_type as e:
        first = str(e).strip().splitlines()[0]
        print(f"❌ {type(e).__name__}: {first[:160]}")
        return e
    raise AssertionError(f"Expected {exc_type.__name__} to be raised, but there was no error; result: {out}")


class _CompileLog(logging.Handler):
    """Collects the "Compiling jit(<function name>)" logs produced by jax_log_compiles."""

    def __init__(self, echo):
        super().__init__()
        self.names, self.echo = [], echo

    def emit(self, record):
        m = re.match(r"Compiling jit\((.*?)\)", record.getMessage())
        if m:
            self.names.append(m.group(1))
            if self.echo:
                print(f"    [compile] jit({m.group(1)})", flush=True)

    def count(self, name=None):
        return sum(1 for n in self.names if name is None or n == name)


@contextlib.contextmanager
def count_compiles(echo=True):
    """Context manager: count the XLA compilations that actually happen inside the block (optionally filtered by function name).

    How it works: temporarily turn on jax_log_compiles and attach a collector to the "jax" logger.
    Usage: with count_compiles() as log: ...;  assert log.count("function_name") == k
    """
    h = _CompileLog(echo)
    logger = logging.getLogger("jax")
    logger.addHandler(h)
    old = jax.config.jax_log_compiles
    jax.config.update("jax_log_compiles", True)
    try:
        yield h
    finally:
        jax.config.update("jax_log_compiles", old)
        logger.removeHandler(h)
JAX version: 0.12.0.1
Devices: [CpuDevice(id=0)]
Device the array is on: cpu:0

2.1 Speeding up: why compile?

Original: https://apxml.com/courses/getting-started-with-jax/chapter-2-accelerating-functions-jit/why-compile-jax

The problem: running JAX directly isn't necessarily fast. In Chapter 1 we called jnp functions line by line, just like NumPy. This is called eager execution. In this mode JAX is usually about as fast as NumPy, and sometimes slower. There are two reasons:

  1. Interpreter and dispatch overhead. The Python interpreter runs code one line at a time, and every jnp operation goes through its own Python → JAX → backend dispatch. The smaller the operation, the bigger the share of this fixed overhead.
  2. Accelerator launch latency. On a GPU/TPU, the CPU has to send each small operation to the accelerator one by one. Every "command" has latency, so the accelerator often sits waiting for instructions instead of computing.

The fix: compilation. Instead of interpreting line by line, convert the whole function (or its key parts) in one go into a low-level representation optimized for the target hardware (CPU/GPU/TPU). In JAX, that means handing it to the XLA compiler to generate code (for example via XLA's HLO intermediate representation). This compilation happens just in time (JIT): a function is compiled the first time it's called with a given input type and shape.

A compiled function has three main benefits:

  • Less interpreter overhead: the whole computation graph runs as one unit, with no trip back to Python for each operation.
  • Operation fusion: the compiler merges several operations into one kernel, so intermediate results don't have to be written to memory and read back. For example, the element-wise computation $$y = a\cdot x + b$$ is "one kernel for the multiply + one kernel for the add" without compilation, and the intermediate array $a\cdot x$ has to land in memory; after fusion it takes a single kernel launch and one read/write pass.
  • Hardware parallelism: the compiler can arrange vectorized and parallel execution for the target hardware.

An analogy: interpreting line by line is like directing one worker through one action at a time with a megaphone. Compiling is like handing the complete blueprint to the whole crew up front so everyone can work together efficiently.

The original course page has a diagram here: the relative speedup of the same numerical task under different execution modes (log-scale vertical axis), with a reminder that the actual speedup depends heavily on the task and the hardware.

Takeaway: JAX doesn't compile your functions automatically. If you want performance, you have to explicitly ask for compilation, mainly with jax.jit. Think of it as a "blueprint generator".

🔍 Extra: Verify "dispatch overhead" and "operation fusion" yourself

⚪ Optional: Read this if you want to see for yourself why jit is fast (dispatch overhead, operation fusion).

Two bits of code to verify this firsthand:

  1. Write a function made of 50 tiny element-wise operations and run it on a small array both directly and with jit. Nearly all of the difference comes from dispatch overhead.
  2. Print the compiled HLO, and you can see directly that XLA fused a*x+b and tanh into a single kernel.

One more thing to know: on the CPU, a single large operation (such as a big matrix multiplication) already calls a highly optimized library without jit, so jit doesn't help much. Where jit really saves time is long chains of small operations.

def many_small_ops(x):
    # 50 tiny element-wise operations: each one is dispatched separately
    for _ in range(50):
        x = x * 1.0001 + 0.001
    return x

x_small = jnp.ones(100)
many_small_ops_jit = jax.jit(many_small_ops)

t_eager, _ = bench(many_small_ops, x_small)
t_jit, _ = bench(many_small_ops_jit, x_small)
print(f"eager: {t_eager*1e6:8.1f} µs")
print(f"jit  : {t_jit*1e6:8.1f} µs   about {t_eager/t_jit:.0f}x faster")
# The jit version must be faster (what it mainly saves is 50 dispatches); timing depends on load, so assert_faster re-measures if it fails
assert_faster(lambda: bench(many_small_ops_jit, x_small)[0], lambda: bench(many_small_ops, x_small)[0])
eager:    451.2 µs
jit  :     17.4 µs   about 26x faster
(4.0118550168699584e-05, 0.0004499333503190428)
# Look at the HLO after XLA compilation and find the fusion (fused kernel)
fused = jax.jit(lambda a, x, b: jnp.tanh(a * x + b))
hlo = fused.lower(2.0, jnp.ones(1000), 1.0).compile().as_text()
fusion_lines = [line.strip() for line in hlo.splitlines() if "fusion(" in line]
for line in fusion_lines:
    print(line[:160])
assert len(fusion_lines) == 1   # multiply, add, and tanh leave just one fusion instruction
# Only one fusion instruction: multiply, add, and tanh are merged into a single loop kernel (kind=kLoop)
ROOT %add_tanh_fusion = f32[1000]{0} fusion(%b.1, %x.1, %a.1), kind=kLoop, calls=%fused_computation, metadata={op_name="jit(<lambda>)/tanh" stack_frame_id=19}

2.2 Introducing jax.jit

Original: https://apxml.com/courses/getting-started-with-jax/chapter-2-accelerating-functions-jit/introducing-jax-jit

jax.jit is JAX's main compilation transformation. You give it an ordinary Python function that works on JAX arrays, and it returns a compiled version of that function. The compiled version is turned into a fused sequence of operations optimized for CPU/GPU/TPU.

There are two ways to use it:

  1. As a decorator: write @jax.jit before the function definition. This is the most common and the most readable;
  2. As a higher-order function: compiled = jax.jit(fn) returns the compiled version. Good when "you didn't define the function" (say, it comes from a library), or when you want to keep the original function around too.

The two are exactly equivalent; only the style differs.

Here is the course example: a matrix multiplication plus a bias, passed through tanh, then averaged.

# Course example: a "slightly complex" computation
def complex_computation(x, weight, bias):
    y = jnp.dot(x, weight) + bias
    z = jnp.tanh(y)
    return jnp.mean(z)

# Generate data: use jax.random.key and split off an independent subkey for each array (old vs. new style is compared later in this section)
key = jax.random.key(0)
kx, kw, kb = jax.random.split(key, 3)
x_data = jax.random.normal(kx, (1000, 500))
weight_data = jax.random.normal(kw, (500, 200))
bias_data = jax.random.normal(kb, (200,))

# Usage 1: decorator
@jax.jit
def compiled_computation_decorator(x, weight, bias):
    y = jnp.dot(x, weight) + bias
    z = jnp.tanh(y)
    return jnp.mean(z)

# Usage 2: higher-order function
compiled_computation_functional = jax.jit(complex_computation)
# Timing procedure (uses the more precise time.perf_counter and always calls block_until_ready)

# Original function: warm up once, then time it
_ = complex_computation(x_data, weight_data, bias_data).block_until_ready()
t0 = time.perf_counter()
result_original = complex_computation(x_data, weight_data, bias_data).block_until_ready()
print(f"Original function time:                         {time.perf_counter() - t0:.6f} s")

# Decorator version: the first call includes tracing + compilation
t0 = time.perf_counter()
result_compiled_decorator = compiled_computation_decorator(x_data, weight_data, bias_data).block_until_ready()
print(f"Compiled (decorator), first call (with compile): {time.perf_counter() - t0:.6f} s")

# Second call: hits the cache
t0 = time.perf_counter()
result_compiled_decorator = compiled_computation_decorator(x_data, weight_data, bias_data).block_until_ready()
print(f"Compiled (decorator), second call (cached):      {time.perf_counter() - t0:.6f} s")

print("Results match:", jnp.allclose(result_original, result_compiled_decorator))
assert jnp.allclose(result_original, result_compiled_decorator)

# Functional version: call once to trigger compilation, then time it
_ = compiled_computation_functional(x_data, weight_data, bias_data).block_until_ready()
t0 = time.perf_counter()
_ = compiled_computation_functional(x_data, weight_data, bias_data).block_until_ready()
print(f"Compiled (functional), later call:               {time.perf_counter() - t0:.6f} s")
Original function time:                         0.000733 s
Compiled (decorator), first call (with compile): 0.015754 s
Compiled (decorator), second call (cached):      0.001092 s
Results match: True
Compiled (functional), later call:               0.000445 s

Timing tip: JAX dispatches asynchronously. When you call a JAX function, Python moves on as soon as it gets the returned array object, while the real computation may still be running in the background. Without .block_until_ready(), you only measure the time it takes to "put the work in the queue".

When you run it, you'll see:

  • The original function runs operation by operation in Python every time;
  • The compiled function's first call is usually slower than the original, because it includes tracing and XLA compilation;
  • Later calls with the same shapes and dtypes are clearly faster, because they reuse the cached compiled result directly.

(The bulk of this example is one big matrix multiplication, which is already fast on the CPU without jit, so the jit speedup isn't dramatic. That's exactly what the 2.1 Extra said.)

⚠️ Note: Older code often creates keys with jax.random.PRNGKey(0), and uses the same key for all three arrays x_data, weight_data, and bias_data. PRNGKey still works in JAX 0.12, but it's a legacy API that returns a raw uint32[2] key with no type information. Reusing the same key is the bigger problem: different arrays share the same stream of random bits, and the generated sequences have identical prefixes. This tutorial always uses jax.random.key + jax.random.split. For the full comparison of key and PRNGKey, see 01 §1.5.

# ❌ Old style (outdated): PRNGKey returns an untyped raw uint32 key, and the same key is reused for several arrays
try:
    key_old = jax.random.PRNGKey(0)
    a_old = jax.random.normal(key_old, (6,))
    b_old = jax.random.normal(key_old, (3,))
    print("key =", key_old, " dtype =", key_old.dtype)
    print("a[:3] =", a_old[:3])
    print("b     =", b_old)
    print("The two arrays have the same prefix:", bool(jnp.allclose(a_old[:3], b_old)))
except Exception as e:
    print("Error:", type(e).__name__, e)
key = [0 0]  dtype = uint32
a[:3] = [ 1.6226422   2.0252647  -0.43359444]
b     = [ 1.6226422   2.0252647  -0.43359444]
The two arrays have the same prefix: True
# ✅ Recommended: jax.random.key returns a typed key; split off an independent subkey for each use
key = jax.random.key(0)
k_a, k_b = jax.random.split(key)
a_new = jax.random.normal(k_a, (6,))
b_new = jax.random.normal(k_b, (3,))
print("key dtype =", key.dtype)
print("a[:3] =", a_new[:3])
print("b     =", b_new)

assert jnp.issubdtype(key.dtype, jax.dtypes.prng_key)     # a new-style key has a dedicated key dtype
assert not jnp.allclose(a_new[:3], b_new)                 # independent subkeys -> the sequences are no longer the same
# Counter-check: reusing the same key always gives the same prefix (exactly what we want to avoid)
assert jnp.allclose(jax.random.normal(key, (6,))[:3], jax.random.normal(key, (3,)))
print("✅ Assertions passed")
key dtype = key<fry>
a[:3] = [ 1.0040143 -0.9063372 -0.7481722]
b     = [-2.4424558  -2.0356805   0.20554423]
✅ Assertions passed

🔍 Extra: Ways to write jit, and a very common trap

🟢 Must-read

  • When you need to pass arguments to jit (such as static_argnames), the decorator is usually written with functools.partial: @partial(jax.jit, static_argnames=["n"]).
  • The compilation cache is attached to the "wrapped Python function object". Calling jax.jit(f) on the same function several times shares the cache. But if you write jax.jit(lambda x: ...) on every iteration of a loop, each lambda is a new object and recompiles every time. This is a very common performance problem in practice. The fix is to jit once outside the loop (item E7 in the summary table in 2.3 checks this again with a compile count).
def double(x):
    return x * 2

# Same function object -> shared compilation cache
a_jit = jax.jit(double)
b_jit = jax.jit(double)
a_jit(1.0).block_until_ready()
t0 = time.perf_counter(); b_jit(1.0).block_until_ready()
print(f"First call of another jax.jit(double): {(time.perf_counter()-t0)*1e3:.3f} ms (hits the shared cache)")

# A new lambda on every loop iteration -> recompiles every time
for i in range(3):
    t0 = time.perf_counter()
    jax.jit(lambda x: x * 2)(1.0).block_until_ready()
    print(f"New lambda #{i}: {(time.perf_counter()-t0)*1e3:.3f} ms (compiling every time)")

# Passing jit arguments with partial
@partial(jax.jit, static_argnames=["n"])
def power(x, n):
    return x ** n

print("power(2.0, n=3) =", power(2.0, n=3))
assert power(2.0, n=3) == 8.0
First call of another jax.jit(double): 0.057 ms (hits the shared cache)
New lambda #0: 7.999 ms (compiling every time)
New lambda #1: 7.998 ms (compiling every time)
New lambda #2: 7.691 ms (compiling every time)
power(2.0, n=3) = 8.0

2.3 How JIT works: tracing and compilation

Original: https://apxml.com/courses/getting-started-with-jax/chapter-2-accelerating-functions-jit/how-jit-works

jit works in two stages: trace first, then compile.

① Tracing The first time you call a jit function, JAX doesn't run your Python code the normal way. It replaces the real arrays with special tracer objects and "walks through" the function. Tracers carry only shape and dtype, no concrete values. JAX records every JAX operation applied to the tracers, which gives it the structure of the whole computation.

An analogy: asking someone to write down the steps for baking a cake, instead of actually baking one.

Because tracers have no concrete values, using ordinary Python operations or control flow on them can cause problems (discussed in detail in 2.4–2.6).

The output of tracing is called a Jaxpr (JAX Program Representation), a simple, purely functional, explicitly typed intermediate language. jax.make_jaxpr shows it to you directly:

def my_simple_func(x, y):
    a = jnp.sin(x)
    b = jnp.cos(y)
    return a + b

x_example = jnp.ones(3)
y_example = jnp.zeros(3)

print(jax.make_jaxpr(my_simple_func)(x_example, y_example))

# Input values don't appear in the Jaxpr; constants hard-coded in the code appear as literals
jp = str(jax.make_jaxpr(lambda x: x * 2.0)(jnp.array([7.5, 8.5])))
assert "2.0" in jp and "7.5" not in jp and "8.5" not in jp
{ lambda ; a:f32[3] b:f32[3]. let
    c:f32[3] = sin a
    d:f32[3] = cos b
    e:f32[3] = add c d
  in (e,) }

How to read it: lambda ; a:f32[3] b:f32[3] are the two inputs (type float32, shape [3]); between let ... in (e,) are the three operations sin, cos, and add, and finally e is returned. Notice that the Jaxpr has no concrete input values, only types and operations. Constants hard-coded in the code, though (like the 2.0 in x * 2.0), do show up in the Jaxpr as literals (verified in the cell above).

② Compilation The Jaxpr is handed to the XLA (Accelerated Linear Algebra) compiler, which generates machine code optimized for the target hardware. XLA's optimizations include:

  • Operation fusion: merging several operations into one kernel;
  • Kernel generation: generating specialized, efficient kernels for the target hardware;
  • Memory layout optimization: arranging how data is laid out in memory to make memory access more efficient.

Compilation is usually the most expensive part of the first call.

③ Caching Once a given "input shapes + dtypes (+ static argument values)" combination has been traced and compiled, the machine code is cached. Later calls with a matching signature run the cached code directly, skipping Python interpretation, tracing, and XLA compilation.

Keep these apart: the speedup itself comes from the compiled code (no per-operation dispatch overhead, operation fusion; see 2.1); caching makes sure that code is compiled only once and then reused. If every call had to recompile, the compile time would eat up the gains.

The whole JIT pipeline looks like this:

First call with a signature ──► trace (tracer) ──► Jaxpr ──► XLA compile ──► machine code ──► store in cache ──► run
Later call, same signature ────────────────────────────────────────────────► cache hit ──► run

This course example shows the timings of four cases: "first call / cached call / new shape / back to the old shape":

@jax.jit
def slow_function(x):
    # Loop 5 times (the Python loop is unrolled during tracing)
    for _ in range(5):
        if x.shape[0] == x.shape[1]:
            # Square matrix: matrix multiplication
            x = jnp.dot(x, x.T) + 0.5 * x
        else:
            # Non-square matrix: element-wise operations
            x = x * x + 0.5 * x
    return x

key = jax.random.key(0)
data = jax.random.normal(key, (100, 100))

t0 = time.perf_counter(); result1 = slow_function(data).block_until_ready()
print(f"First call (compile) time:          {time.perf_counter() - t0:.4f} s")

t0 = time.perf_counter(); result2 = slow_function(data).block_until_ready()
print(f"Second call (cached) time:          {time.perf_counter() - t0:.4f} s")

data_different_shape = jax.random.normal(key, (150, 150))
t0 = time.perf_counter(); result3 = slow_function(data_different_shape).block_until_ready()
print(f"Call with a new shape:              {time.perf_counter() - t0:.4f} s")

t0 = time.perf_counter(); result4 = slow_function(data).block_until_ready()
print(f"Call with the original shape again: {time.perf_counter() - t0:.4f} s")
First call (compile) time:          0.0208 s
Second call (cached) time:          0.0003 s
Call with a new shape:              0.0208 s
Call with the original shape again: 0.0002 s

When does it retrace/recompile?

There are four common cases:

  1. The input shape changes, for example (100, 100) → (150, 150);
  2. The input dtype changes, for example float32 → int32. float32 → float64 only counts as a dtype change once 64-bit mode is on (jax_enable_x64=True);
  3. The input PyTree structure changes, for example adding a key to an input dict;
  4. When you use static_argnums / static_argnames, the value of a static argument changes.

⚠️ Note: Don't use "float32 to float64" as the example of a dtype change. With the default configuration (JAX 0.12), 64-bit is off, and asking for float64 gets truncated to float32 (with a warning), so it does not recompile; it only does once x64 is on. See the assert for item E2 in the summary table below.

Each distinct combination is traced + compiled once on its own and gets its own cache entry. Beyond these four, a few other factors also affect the cache in practice (or look like they would but don't); the summary table below puts them all together.

🔍 Extra: Details worth noticing in the example above

⚪ Optional: Read this if you want to see what the shape branch and loop unrolling look like in the Jaxpr; the key points come up again in 2.5 and the summary table.

  • if x.shape[0] == x.shape[1] works fine inside jit. Shapes are known Python integers during tracing (static information), so Python branches based on shape / ndim / dtype are perfectly legal. This matters: it's the classic example of "static information can use Python control flow".
  • In this example both inputs are square, so the else branch never actually runs and doesn't appear in the compiled result at all. Tracing records only the branches actually taken.
  • for _ in range(5) is unrolled into 5 copies of the operations (count how many times dot_general appears in the Jaxpr below to check).
jaxpr = jax.make_jaxpr(slow_function.__wrapped__)(data)  # __wrapped__ gets back the original, un-jitted function
print("Number of dot_general occurrences:", str(jaxpr).count("dot_general"))
assert str(jaxpr).count("dot_general") == 5   # range(5) is unrolled into 5 copies
Number of dot_general occurrences: 5

Summary table: what triggers recompilation

Cache key ≈ the wrapped Python function object + each traced argument's shape / dtype / weak-type flag + PyTree structure + static argument values + how arguments are passed (positional / keyword / default omitted). Each row of the table has a verification cell (E1–E8): it counts the actual compilations with the count_compiles() helper defined at the top (based on jax_log_compiles), then runs assert.

#FactorExampleRecompiles?Key point
E1Shapef(ones(3)) → f(ones(4))✅ YesChanging only values doesn't; changing the shape always does
E2dtypefloat32 → int32; float32 → float64✅ Yes / ⚠️ Not by defaultWith x64 off, float64 is truncated to float32 and nothing recompiles; only with x64 on does it
E3Weak typef(1.0) vs f(jnp.float32(1.0)) vs f(1)✅ YesPython scalars, jnp.array(5.0), and jnp.full(3, 7.0) are all weakly typed
E4Static argument valuen=2 → n=3 → n=2✅ Once per new valueValues already seen hit the cache
E5PyTree structure{"a": x} → {"a": x, "b": x}; (x,) → [x]✅ YesKeys, nesting, container type, and length are all part of the structure
E6Positional vs. keyword argumentsg(x, 2.0) vs g(x, t=2.0); omitting a default argument✅ YesAn omitted default value is baked into the compiled result as a constant
E7A new jit object every timejax.jit(lambda ...) inside a loop✅ Every timeCalling jax.jit(f) several times on the same function object shares the cache
E8Global variablesChanging a global the function reads❌ NoNo recompilation, so it keeps using the old value (silently returns wrong results)

Note: f._cache_size() is a private API, and it counts the total entries for the same Python function across all its jit wrappers, so here we always count compile logs instead.

First, a quick look at what the raw compile log looks like (with jax_log_compiles on, every real compilation prints Compiling ... to stderr):

# Raw compile log: create the inputs first -- jnp.ones itself compiles small kernels in eager mode, which would get mixed into the log
a3, b3, a4 = jnp.ones(3), jnp.ones(3) * 7, jnp.ones(4)
g = jax.jit(lambda x: x * 2)
jax.config.update("jax_log_compiles", True)
print("Call 1: f32[3]", flush=True); g(a3)               # compiles
print("Call 2: f32[3], different values", flush=True); g(b3)      # cache hit, no log
print("Call 3: f32[4]", flush=True); g(a4)               # new shape -> compiles
jax.config.update("jax_log_compiles", False)
Call 1: f32[3]
Finished tracing <lambda> for jit in 0.000133753 sec
Compiling jit(<lambda>) with global shapes and types (ShapedArray(float32[3]),). Argument mapping: (UnspecifiedValue,).
Finished jaxpr to MLIR module conversion jit(<lambda>) in 0.000353098 sec
Finished XLA compilation of jit(<lambda>) in 0.007091999 sec
Call 2: f32[3], different values
Call 3: f32[4]
Finished tracing <lambda> for jit in 0.000386715 sec
Compiling jit(<lambda>) with global shapes and types (ShapedArray(float32[4]),). Argument mapping: (UnspecifiedValue,).
Finished jaxpr to MLIR module conversion jit(<lambda>) in 0.000374079 sec
Finished XLA compilation of jit(<lambda>) in 0.006958961 sec
# E1 shape: changing only values doesn't recompile; changing the shape does
def e1(x):
    return x * 2

x3a, x3b, x4 = jnp.ones(3), jnp.arange(3.0), jnp.ones(4)   # create the inputs first
e1_jit = jax.jit(e1)
with count_compiles() as log:
    e1_jit(x3a)
    e1_jit(x3b)                 # same shape, different values
    n_same_shape = log.count("e1")
    e1_jit(x4)                  # new shape
assert n_same_shape == 1
assert log.count("e1") == 2
print("E1 ✅ Same shape compiles only once; after changing the shape it has compiled", log.count("e1"), "times in total")
    [compile] jit(e1)
    [compile] jit(e1)
E1 ✅ Same shape compiles only once; after changing the shape it has compiled 2 times in total
# E2 dtype: int32 recompiles; with the default config float64 is truncated to float32 and doesn't recompile
def e2(x):
    return x + 1

xf32 = jnp.ones(3, dtype=jnp.float32)
xi32 = jnp.ones(3, dtype=jnp.int32)
with warnings.catch_warnings(record=True) as w:
    warnings.simplefilter("always")
    x64_req = jnp.ones(3, dtype=jnp.float64)     # x64 is off by default: truncated
assert x64_req.dtype == jnp.float32 and len(w) >= 1
print("Requested float64, actually got:", x64_req.dtype, "| warning:", str(w[0].message)[:60], "...")

e2_jit = jax.jit(e2)
with count_compiles() as log:
    e2_jit(xf32)
    e2_jit(x64_req)
    n_after_f64 = log.count("e2")
    e2_jit(xi32)
assert n_after_f64 == 1        # float32 and "float64" share one compiled result
assert log.count("e2") == 2    # only int32 triggers a second compile

# For comparison: temporarily turn on x64 with a context manager, and float64 becomes a genuinely different dtype (doesn't affect later cells)
def e2_x64(x):
    return x + 1

with jax.enable_x64(True):
    y32 = jnp.ones(3, dtype=jnp.float32)
    y64 = jnp.ones(3, dtype=jnp.float64)
    assert y64.dtype == jnp.float64
    e2_x64_jit = jax.jit(e2_x64)
    with count_compiles() as log64:
        e2_x64_jit(y32)
        e2_x64_jit(y64)
assert log64.count("e2_x64") == 2
print("E2 ✅ Default config: float32/\"float64\" compile 1 time in total, 2 with int32; with x64 on, float32 and float64 each compile once")
Requested float64, actually got: float32 | warning: Explicitly requested dtype float64 requested in ones is not  ...
    [compile] jit(e2)
    [compile] jit(e2)
    [compile] jit(e2_x64)
    [compile] jit(e2_x64)
E2 ✅ Default config: float32/"float64" compile 1 time in total, 2 with int32; with x64 on, float32 and float64 each compile once
# E3 weak types: Python scalars are "weakly typed" and have a different signature from strongly typed arrays of the same dtype
def e3(x):
    return x + 1

e3_jit = jax.jit(e3)
x_strong = jnp.float32(1.0)
with count_compiles() as log:
    e3_jit(1.0)
    e3_jit(2.0)          # Python float: weak f32[], only the value changed -> hit
    e3_jit(x_strong)     # strong f32[] -> new signature
    e3_jit(1)            # Python int: weak i32[] -> new signature
assert log.count("e3") == 3

# Arrays "derived" from Python scalars are often weakly typed too
for name, arr in [("jnp.array(5.0)", jnp.array(5.0)), ("jnp.full(3, 7.0)", jnp.full(3, 7.0)),
                  ("jnp.ones(3)", jnp.ones(3)), ("jnp.float32(1.0)", jnp.float32(1.0))]:
    print(f"{name:18s} weak_type={arr.weak_type}")
assert jnp.array(5.0).weak_type and jnp.full(3, 7.0).weak_type
assert not jnp.ones(3).weak_type and not jnp.float32(1.0).weak_type
print("E3 ✅ Weak/strong types and float/int scalars compiled", log.count("e3"), "times in total")
    [compile] jit(e3)
    [compile] jit(e3)
    [compile] jit(e3)
jnp.array(5.0)     weak_type=True
jnp.full(3, 7.0)   weak_type=True
jnp.ones(3)        weak_type=False
jnp.float32(1.0)   weak_type=False
E3 ✅ Weak/strong types and float/int scalars compiled 3 times in total
# E4 static argument values: each new value compiles once; values already seen hit the cache
def e4(x, n):
    for _ in range(n):
        x = jnp.sin(x)
    return x

e4_jit = jax.jit(e4, static_argnames="n")
xe4 = jnp.ones(3)
with count_compiles() as log:
    for n in [2, 3, 2, 3]:
        e4_jit(xe4, n=n)
assert log.count("e4") == 2
print("E4 ✅ n = 2,3,2,3 compiled", log.count("e4"), "times in total")
    [compile] jit(e4)
    [compile] jit(e4)
E4 ✅ n = 2,3,2,3 compiled 2 times in total
# E5 PyTree structure: keys, container type, and length are all part of the structure
def e5(d):
    return d["a"] * 2

def e5_seq(t):
    return t[0] * 2

d1 = {"a": jnp.ones(3)}
d2 = {"a": jnp.zeros(3)}                     # same structure, different values
d3 = {"a": jnp.ones(3), "b": jnp.ones(3)}    # one extra key
xs5 = jnp.ones(3)
e5_jit, e5_seq_jit = jax.jit(e5), jax.jit(e5_seq)
with count_compiles() as log:
    e5_jit(d1)
    e5_jit(d2)
    n_same_tree = log.count("e5")
    e5_jit(d3)
    e5_seq_jit((xs5,))       # tuple
    e5_seq_jit([xs5])        # list: different container type -> new structure
assert n_same_tree == 1 and log.count("e5") == 2
assert log.count("e5_seq") == 2
print("E5 ✅ Adding a dict key -> recompiles; tuple and list -> compile once each")
    [compile] jit(e5)
    [compile] jit(e5)
    [compile] jit(e5_seq)
    [compile] jit(e5_seq)
E5 ✅ Adding a dict key -> recompiles; tuple and list -> compile once each
# E6 positional vs. keyword arguments, and omitting default arguments
def e6(x, t):
    return x * t

e6_jit = jax.jit(e6, static_argnums=(1,))
x6 = jnp.ones(3)
with count_compiles() as log:
    e6_jit(x6, 2.0)          # passed positionally
    e6_jit(x6, t=2.0)        # passed by keyword: also static, but a different cache key
assert log.count("e6") == 2


def e6_default(x, tol=1e-6):
    return x + tol

e6d_jit = jax.jit(e6_default)
print(jax.make_jaxpr(e6_default)(x6))     # when omitted: the default is written into the Jaxpr as a constant (the float32 approximation of 1e-6 shows as 9.99…e-07)
with count_compiles() as log:
    e6d_jit(x6)              # omitted: the default is baked in as a constant
    e6d_jit(x6, 1e-3)        # passed positionally: tol becomes a traced argument -> new entry
    e6d_jit(x6, 1e-4)        # passed positionally with a new value -> hit
    e6d_jit(x6, tol=1e-4)    # passed by keyword -> new entry
assert log.count("e6_default") == 3
print("E6 ✅ Positional and keyword each get their own entry; omitted/positional/keyword compile 3 times in total")
    [compile] jit(e6)
    [compile] jit(e6)
{ lambda ; a:f32[3]. let b:f32[3] = add a 9.999999974752427e-07:f32[] in (b,) }
    [compile] jit(e6_default)
    [compile] jit(e6_default)
    [compile] jit(e6_default)
E6 ✅ Positional and keyword each get their own entry; omitted/positional/keyword compile 3 times in total
# E7 a new jit object every time: the same function object shares the cache; a new lambda each time compiles every time
def e7(x):
    return x * 2

x7 = jnp.ones(3)
with count_compiles() as log:
    for _ in range(3):
        jax.jit(e7)(x7)                  # same function object e7 -> shared cache
    n_same_fn = log.count("e7")
    for _ in range(3):
        jax.jit(lambda x: x * 2)(x7)     # a new lambda object each time
assert n_same_fn == 1
assert log.count("<lambda>") == 3
print("E7 ✅ jax.jit(e7) ×3 compiles only once; a new lambda ×3 compiles 3 times -> jit once outside the loop")
    [compile] jit(e7)
    [compile] jit(<lambda>)
    [compile] jit(<lambda>)
    [compile] jit(<lambda>)
E7 ✅ jax.jit(e7) ×3 compiles only once; a new lambda ×3 compiles 3 times -> jit once outside the loop
# E8 global variables: changing one doesn't trigger recompilation -> the old value keeps being used
scale_e8 = 1.0

def e8(x):
    return x * scale_e8

e8_jit = jax.jit(e8)
x8 = jnp.ones(2)
with count_compiles() as log:
    r1 = e8_jit(x8)
    scale_e8 = 5.0               # change the global variable
    r2 = e8_jit(x8)
assert log.count("e8") == 1                 # no recompilation...
assert jnp.allclose(r2, 1.0)                # ...so the result is still computed with the old value 1.0
print("E8 ✅ After changing the global, the result is still", r2, "(for the sneakier case where old and new values coexist, see Problem 5 in 2.6)")
    [compile] jit(e8)
E8 ✅ After changing the global, the result is still [1. 1.] (for the sneakier case where old and new values coexist, see Problem 5 in 2.6)

🔍 Extra: The AOT (ahead-of-time) API -- looking at jit's stages one by one

⚪ Optional: Read this if you need to measure compile time on its own, compile ahead of time for deployment, or inspect what XLA optimized.

So far we've described jit as two steps, "tracing + compilation". JAX actually splits it into finer stages that you can observe and control one at a time:

jax.jit(f).trace(*args)   → Traced     (.jaxpr shows the Jaxpr)
         .lower(*args)    → Lowered    (.as_text() shows the StableHLO, the IR before XLA)
         .compile()       → Compiled   (.as_text() shows the optimized HLO, .cost_analysis() shows a FLOPs estimate)

Uses: measuring compile time on its own, compiling ahead of time before deployment, and checking which optimizations XLA applied. Note that a Compiled object only accepts exactly the same shapes/dtypes it was compiled for.

def affine_sin(x):
    return jnp.sin(x) * 2

jf = jax.jit(affine_sin)
xs = jnp.ones(3)

print("== trace → Jaxpr ==")
print(jf.trace(xs).jaxpr)

lowered = jf.lower(xs)
print("== lower → StableHLO ==")
print(lowered.as_text())

t0 = time.perf_counter()
compiled = lowered.compile()
print(f"== compile took {(time.perf_counter()-t0)*1e3:.2f} ms ==")
print("Result:", compiled(xs))
assert jnp.allclose(compiled(xs), jnp.sin(xs) * 2)
ca = compiled.cost_analysis()
print("cost_analysis:", {k: ca[k] for k in ("flops", "transcendentals", "bytes accessed")})

# A Compiled object can't handle a different shape
expect_error(lambda: compiled(jnp.ones(4)), TypeError)
== trace → Jaxpr ==
{ lambda ; a:f32[3]. let b:f32[3] = sin a; c:f32[3] = mul b 2.0:f32[] in (c,) }
== lower → StableHLO ==
module @jit_affine_sin attributes {mhlo.num_partitions = 1 : i32, mhlo.num_replicas = 1 : i32} {
  func.func public @main(%arg0: tensor<3xf32>) -> (tensor<3xf32> {jax.result_info = "result"}) {
    %0 = stablehlo.sine %arg0 : tensor<3xf32>
    %cst = stablehlo.constant dense<2.000000e+00> : tensor<f32>
    %1 = stablehlo.broadcast_in_dim %cst, dims = [] : (tensor<f32>) -> tensor<3xf32>
    %2 = stablehlo.multiply %0, %1 : tensor<3xf32>
    return %2 : tensor<3xf32>
  }
}

== compile took 8.68 ms ==
Result: [1.6829419 1.6829419 1.6829419]
cost_analysis: {'flops': 3.0, 'transcendentals': 3.0, 'bytes accessed': 24.0}
❌ TypeError: Argument types differ from the types for which this computation was compiled. Perhaps you are calling the compiled executable with a different enable_x64 mode t
TypeError("Argument types differ from the types for which this computation was compiled. Perhaps you are calling the compiled executable with a different enable_x64 mode than when it was AOT compiled? The mismatches are:\nArgument 'x' compiled with float32[3] and called with float32[4]")

2.4 Python control flow and jit

Original: https://apxml.com/courses/getting-started-with-jax/chapter-2-accelerating-functions-jit/jit-control-flow

jax.jit traces a function with abstract values (tracers) and then compiles the resulting Jaxpr, usually tracing only once per function signature. But Python's native if, for, and while need concrete values to decide which path to take and how many times to loop. Tracers have no concrete values, so using Python control flow on traced values fails.

Conditional execution

Start with an ordinary Python function:

def conditional_func_py(x):
    if x > 0:
        return x * 2
    else:
        return x / 2

print(conditional_func_py(5.0))    # 10.0
print(conditional_func_py(-4.0))   # -2.0

# Try jit: x is replaced by a tracer, so `if x > 0` can't get True/False
jitted_conditional_py = jax.jit(conditional_func_py)
expect_error(lambda: jitted_conditional_py(jnp.array(5.0)), jax.errors.ConcretizationTypeError)
10.0
-2.0
❌ TracerBoolConversionError: Attempted boolean conversion of traced array with shape bool[].
jax.errors.TracerBoolConversionError('Attempted boolean conversion of traced array with shape bool[].\nThe error occurred while tracing the function conditional_func_py at /tmp/ipykernel_12624/2774902486.py:1 for jit. This concrete value was not available in Python because it depends on the value of the argument x.\nSee https://docs.jax.dev/en/latest/101/errors.html#jax.errors.TracerBoolConversionError')

The fix: jax.lax.cond. It compiles both branches into the computation graph and picks which one to run at runtime based on the predicate:

lax.cond(pred, true_fun, false_fun, *operands)
  • pred: a scalar boolean (can be a traced value);
  • true_fun / false_fun: the two branch functions, which must accept inputs with the same structure and return outputs with the same shape, dtype, and structure;
  • operands: the operands passed to the branch functions.
def conditional_func_lax(x):
    def true_branch(val):
        return val * 2

    def false_branch(val):
        return val / 2

    return lax.cond(x > 0, true_branch, false_branch, x)

jitted_conditional_lax = jax.jit(conditional_func_lax)
print(jitted_conditional_lax(jnp.array(5.0)))    # 10.0
print(jitted_conditional_lax(jnp.array(-4.0)))   # -2.0
assert jitted_conditional_lax(jnp.array(5.0)) == 10.0 and jitted_conditional_lax(jnp.array(-4.0)) == -2.0
10.0
-2.0

Note: if a Python if depends only on static values (such as Python constants known at compile time, shapes, or arguments marked static), it compiles just fine. But whenever a static value changes, it triggers a recompilation. So whenever the condition depends on the values in a JAX array, use lax.cond.

Loops

Case 1: the number of iterations is static. A Python for loop is unrolled during tracing, meaning the loop body is copied n times as is. Below we mark n as static with static_argnums=1:

def sum_first_n_py(arr, n):
    total = 0.0
    for i in range(n):
        total = total + arr[i]
    return total

jitted_sum_first_3 = jax.jit(sum_first_n_py, static_argnums=1)
my_array = jnp.arange(10.0)
print(jitted_sum_first_3(my_array, 3))   # 3.0 = 0 + 1 + 2
assert jitted_sum_first_3(my_array, 3) == 3.0

# See what unrolling looks like: with n=3 there are 3 element reads + 3 additions
print(jax.make_jaxpr(sum_first_n_py, static_argnums=1)(my_array, 3))
3.0
{ lambda ; a:f32[10]. let
    b:f32[1] = slice[limit_indices=(1,) start_indices=(0,) strides=None] a
    c:f32[] = squeeze[dimensions=(0,)] b
    d:f32[] = add 0.0:f32[] c
    e:f32[1] = slice[limit_indices=(2,) start_indices=(1,) strides=None] a
    f:f32[] = squeeze[dimensions=(0,)] e
    g:f32[] = add d f
    h:f32[1] = slice[limit_indices=(3,) start_indices=(2,) strides=None] a
    i:f32[] = squeeze[dimensions=(0,)] h
    j:f32[] = add g i
  in (j,) }

Unrolling has a cost: when n is large, the computation graph gets huge, and compile time grows with n (see the measurements in the Extra below).

Case 2: the number of iterations or the continue condition depends on a traced value. Here a Python loop fails too (range(n) needs a concrete integer).

The fix: use the structured loop primitives in jax.lax:

PrimitiveSignatureWhen to use
lax.fori_loopfori_loop(lower, upper, body_fun, init_val), body_fun(i, val) -> valCounting loops over a known range
lax.while_loopwhile_loop(cond_fun, body_fun, init_val)The stop condition depends on runtime values
lax.scanscan(f, init, xs), f(carry, x) -> (carry, y)Stepping through a sequence with state and collecting each step's output (covered in detail in Chapter 6 on state)

Rewrite the sum with fori_loop, and n can now be a JAX array (a traced value):

def sum_first_n_lax(arr, n):
    def body_fun(i, current_total):
        return current_total + arr[i]

    initial_val = 0.0
    final_total = lax.fori_loop(0, n, body_fun, initial_val)
    return final_total

jitted_sum_lax = jax.jit(sum_first_n_lax)
my_array = jnp.arange(10.0)
print(jitted_sum_lax(my_array, jnp.array(3)))   # 3.0  = 0+1+2
print(jitted_sum_lax(my_array, jnp.array(5)))   # 10.0 = 0+1+2+3+4
assert jitted_sum_lax(my_array, jnp.array(3)) == 3.0 and jitted_sum_lax(my_array, jnp.array(5)) == 10.0
3.0
10.0

Here's how the two kinds of loops compare: one is a Python loop unrolled inside jit, which needs the number of iterations to be fixed and known at trace time; the other is a lax loop primitive, which is just one loop node in the computation graph and works even when the number of iterations depends on a traced value. (The original course page has a comparison diagram here.)

Summary

  • Conditions that depend on traced values: lax.cond (lax.switch for multiple branches);
  • Loops that depend on traced values: lax.fori_loop / lax.while_loop / lax.scan;
  • Plain Python control flow is only appropriate when the control flow depends only on static values (Python constants, shapes, arguments marked with static_argnums/static_argnames).

🔍 Extra: The names of the error types (as observed in JAX 0.12)

⚪ Optional: Read this if you need to catch tracing-related errors with except in your code.

These errors are often lumped together as ConcretizationTypeError. But in JAX 0.12, what's actually raised is one of several more specific exceptions, and they don't all inherit from ConcretizationTypeError:

SituationActual exceptionDirect parent class
if tracer: / bool(tracer)TracerBoolConversionErrorConcretizationTypeError
range(tracer) / Python indexing with a traced valueTracerIntegerConversionErrorJAXTypeError
float(tracer)ConcretizationTypeErrorJAXTypeError
np.asarray(tracer)TracerArrayConversionErrorJAXTypeError

⚠️ Note: Don't assume they all inherit from ConcretizationTypeError. Only TracerBoolConversionError is its subclass; TracerIntegerConversionError and TracerArrayConversionError inherit directly from JAXTypeError (see Advanced 08: Debugging toolchain).

So except jax.errors.ConcretizationTypeError won't catch integer-conversion and array-conversion errors. To catch them all, use their common parent class jax.errors.JAXTypeError (which is also a subclass of TypeError). The next cell verifies this with assert.

try:
    jax.jit(conditional_func_py)(jnp.array(5.0))
except jax.errors.ConcretizationTypeError as e:
    print("Type:", type(e).__name__)
    print("Inheritance chain:", [c.__name__ for c in type(e).__mro__[:3]])

def loop_n(x, n):
    for _ in range(n):
        x = x + 1
    return x

expect_error(lambda: jax.jit(loop_n)(1.0, 3), jax.errors.TracerIntegerConversionError)

# Check the inheritance: only Bool is a subclass of ConcretizationTypeError; the common parent of all four is JAXTypeError
E = jax.errors
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.TracerBoolConversionError,
            E.TracerIntegerConversionError, E.TracerArrayConversionError))
print("Inheritance verified: to catch them all, use jax.errors.JAXTypeError")
Type: TracerBoolConversionError
Inheritance chain: ['TracerBoolConversionError', 'ConcretizationTypeError', 'JAXTypeError']
❌ TracerIntegerConversionError: The __index__() method was called on traced array with shape int32[]
Inheritance verified: to catch them all, use jax.errors.JAXTypeError

🔍 Extra: The two "identities" of fori_loop and what they mean for differentiation

⚪ Optional: Read this if you need to differentiate through a loop; Chapter 3, section 3.7 covers it again with a table.

fori_loop lowers to different primitives depending on whether its bounds are static:

  • Both bounds are Python integers (static) → lowers to scan: supports reverse-mode differentiation (jax.grad);
  • A bound is a traced value → lowers to while_loop: it runs inside jit, but doesn't support reverse-mode differentiation.

(while_loop itself doesn't support reverse-mode differentiation either, because backpropagation needs to know the number of iterations ahead of time to save the intermediate results. When you need gradients, use a fixed-length scan.)

body = lambda i, s: s + i
print("Static bound  → ", "scan" if "scan[" in str(jax.make_jaxpr(lambda s: lax.fori_loop(0, 3, body, s))(0.0)) else "while")
print("Traced bound  → ", "while" if "while[" in str(jax.make_jaxpr(lambda n: lax.fori_loop(0, n, body, 0.0))(3)) else "scan")

# Static bound: differentiable. d/dx x^3 at x=2 = 12
g_static = jax.grad(lambda x: lax.fori_loop(0, 3, lambda i, s: s * x, 1.0))(2.0)
print("grad(static fori):", g_static)
assert g_static == 12.0

# while_loop: reverse-mode differentiation raises an error
expect_error(lambda: jax.grad(lambda x: lax.while_loop(lambda s: s < 10, lambda s: s * x, 1.0))(2.0), ValueError)
Static bound  →  scan
Traced bound  →  while
grad(static fori): 12.0
❌ ValueError: Reverse-mode differentiation does not work for lax.while_loop or lax.fori_loop with dynamic start/stop values. Try using lax.scan, or using fori_loop with stati
ValueError('Reverse-mode differentiation does not work for lax.while_loop or lax.fori_loop with dynamic start/stop values. Try using lax.scan, or using fori_loop with static start/stop.')

🔍 Extra: Minimal examples of lax.switch, while_loop, and scan

⚪ Optional: Read this the first time you write multiple branches or a loop inside jit and want a runnable example to adapt.

Below are examples you can run directly. Key points:

  • lax.switch(index, branches, *operands): an out-of-range index is clamped into [0, len-1] with no error;
  • The state (carry) of a while_loop must keep the same structure/shape/dtype before and after the loop;
  • scan returns (final carry, the stacked outputs from every step).
branches = [lambda x: x, lambda x: x * 10, lambda x: -x]
sw = jax.jit(lambda i, x: lax.switch(i, branches, x))
print("switch(1):", sw(1, 3.0), "  switch(99) is clamped to the last branch:", sw(99, 3.0))

# while_loop: start at 1 and keep doubling until >= 10, counting the steps
final, steps = lax.while_loop(lambda s: s[0] < 10, lambda s: (s[0] * 2, s[1] + 1), (1, 0))
print("while_loop:", final, "steps", steps)

# scan: running sum
carry, ys = lax.scan(lambda c, x: (c + x, c + x), 0.0, jnp.arange(1.0, 5.0))
print("scan: final carry =", carry, " per-step outputs =", ys)

# Branch outputs with mismatched types raise an error
expect_error(lambda: lax.cond(True, lambda x: x, lambda x: jnp.int32(1), 1.0), TypeError)
switch(1): 30.0   switch(99) is clamped to the last branch: -3.0
while_loop: 16 steps 4
scan: final carry = 10.0  per-step outputs = [ 1.  3.  6. 10.]
❌ TypeError: cond branches must have equal output types but they differ.
TypeError('cond branches must have equal output types but they differ.\n\ntrue_fun is <lambda> at /tmp/ipykernel_12624/1350883323.py:14\nfalse_fun is <lambda> at /tmp/ipykernel_12624/1350883323.py:14\n\nThe output of true_fun has type float32[] but the corresponding output of false_fun has type int32[], so the dtypes do not match.\n\nRevise true_fun and/or false_fun so that all output types match.')

🔍 Extra: Prefer jnp.where for element-wise conditions, but watch out for NaN in gradients

🟢 Must-read

  • The predicate of lax.cond must be a scalar. For "element-wise" conditional selection on arrays, use jnp.where(cond, a, b);
  • jnp.where computes both sides and then picks. If the masked-out side has an infinite derivative at that position (like sqrt at 0), the gradient becomes NaN, and you fix it with the "double where" trick. For how it works, common scenarios, and the fix, see the NaN topic in 03 §3.8; for more advanced material see 07 Topic 2.
  • Also, under vmap, a lax.cond with a batched predicate is converted into a select that computes both sides, so don't count on cond saving computation when batching.
relu2 = jax.jit(lambda x: jnp.where(x > 0, x * 2, x))
print("jnp.where element-wise:", relu2(jnp.array([-1.0, 2.0])))
assert jnp.array_equal(relu2(jnp.array([-1.0, 2.0])), jnp.array([-1.0, 4.0]))

# Under vmap, a lax.cond with a batched predicate becomes select_n: both branches are computed
jaxpr_v = str(jax.make_jaxpr(jax.vmap(lambda x: lax.cond(x > 0, lambda v: v * 2, lambda v: -v, x)))(jnp.ones(3)))
print("select_n in the Jaxpr of vmap(cond):", "select_n" in jaxpr_v, "| cond still there:", "cond[" in jaxpr_v)
assert "select_n" in jaxpr_v and "cond[" not in jaxpr_v
jnp.where element-wise: [-1.  4.]
select_n in the Jaxpr of vmap(cond): True | cond still there: False

🔍 Extra: Measured compile time, unrolled Python loop vs. fori_loop

⚪ Optional: Read this when you have many loop iterations and compilation gets noticeably slow.

Below we compare the compile time of the two styles for loops of 10 / 100 / 1000 iterations (using the AOT API to time compilation only). First subtract the fixed overhead every compilation has, that is, the time both styles spend at n=10. Of what's left, the unrolled version's compile time grows roughly linearly with n, while fori_loop stays about the same.

Rule of thumb: when a loop runs more than a few dozen times, or the loop body is fairly large, prefer the lax loop primitives. For very short loops, unrolling can actually let XLA optimize more (scan also offers an unroll= argument as a middle ground).

def unrolled(x, n):
    for _ in range(n):
        x = jnp.sin(x) + 1
    return x

def rolled(x, n):
    return lax.fori_loop(0, n, lambda i, x: jnp.sin(x) + 1, x)

for n in [10, 100, 1000]:
    t0 = time.perf_counter(); jax.jit(unrolled, static_argnums=1).lower(1.0, n).compile(); t_u = time.perf_counter() - t0
    t0 = time.perf_counter(); jax.jit(rolled, static_argnums=1).lower(1.0, n).compile(); t_r = time.perf_counter() - t0
    print(f"n={n:5d}  unrolled: {t_u*1e3:7.1f} ms   fori_loop: {t_r*1e3:6.1f} ms")
n=   10  unrolled:    10.3 ms   fori_loop:   11.8 ms
n=  100  unrolled:    22.5 ms   fori_loop:    8.5 ms
n= 1000  unrolled:   145.9 ms   fori_loop:   10.0 ms

2.5 Static vs. traced values

Original: https://apxml.com/courses/getting-started-with-jax/chapter-2-accelerating-functions-jit/jit-static-vs-traced

jit runs the function once with placeholder objects (tracers), records the operations, builds a Jaxpr, and hands it to XLA to compile. During this process, the values in the function fall into two groups:

  • Traced values: usually the JAX array arguments (and every argument not marked static). During tracing, only their shape and dtype are known, not their concrete values. An analogy: a tracer is like a "blueprint" that lists the dimensions and materials but has no actual contents.
  • Static values: values known at compile time, such as Python integers, booleans, and string constants inside the function, plus arguments explicitly marked as static. Their concrete values are available during tracing, so they can be used in Python control flow.

An example of the problem

def conditional_double(x, threshold):
    if x > threshold:
        return x * 2
    else:
        return x

# Without marking static arguments: both x and threshold are traced, so the if can't get a concrete True/False
jitted_conditional_double = jax.jit(conditional_double)
expect_error(lambda: jitted_conditional_double(jnp.array(5.0), threshold=0.0), jax.errors.ConcretizationTypeError)
❌ TracerBoolConversionError: Attempted boolean conversion of traced array with shape bool[].
jax.errors.TracerBoolConversionError('Attempted boolean conversion of traced array with shape bool[].\nThe error occurred while tracing the function conditional_double at /tmp/ipykernel_12624/3470043619.py:1 for jit. This concrete value was not available in Python because it depends on the values of the arguments x and threshold.\nSee https://docs.jax.dev/en/latest/101/errors.html#jax.errors.TracerBoolConversionError')

Does marking threshold as static fix it?

You can mark threshold as static with static_argnums=(1,) (by position) or static_argnames=('threshold',) (by name), so it's a concrete Python float during tracing. But that doesn't fix the problem in this example: x is still a traced value, the result of x > threshold is still a tracer, and the if still can't turn it into a Python bool. Making only one side of the comparison static doesn't help at all.

⚠️ Note: It's easy to think that adding static_argnums=(1,) or static_argnames=('threshold',) makes it run and print 10.0 and -2.0 respectively. In fact (JAX 0.12), both versions raise TracerBoolConversionError (a subclass of ConcretizationTypeError).

The right fix depends on the situation:

  1. The condition depends on a traced value (this example) → use lax.cond or jnp.where; threshold can be static or not;
  2. The condition depends only on static arguments (such as if mode == "train" or if double:) → this is where static_argnums / static_argnames actually helps.
# ❌ Marking only threshold as static: instead of printing 10.0 and -2.0, it raises an error
print("static_argnums=(1,)      →", end=" ")
expect_error(lambda: jax.jit(conditional_double, static_argnums=(1,))(jnp.array(5.0), threshold=0.0),
             jax.errors.ConcretizationTypeError)
print("static_argnames=threshold →", end=" ")
expect_error(lambda: jax.jit(conditional_double, static_argnames=("threshold",))(jnp.array(-2.0), threshold=0.0),
             jax.errors.ConcretizationTypeError)
static_argnums=(1,)      → ❌ TracerBoolConversionError: Attempted boolean conversion of traced array with shape bool[].
static_argnames=threshold → ❌ TracerBoolConversionError: Attempted boolean conversion of traced array with shape bool[].
jax.errors.TracerBoolConversionError('Attempted boolean conversion of traced array with shape bool[].\nThe error occurred while tracing the function conditional_double at /tmp/ipykernel_12624/3470043619.py:1 for jit. This concrete value was not available in Python because it depends on the value of the argument x.\nSee https://docs.jax.dev/en/latest/101/errors.html#jax.errors.TracerBoolConversionError')
# ✅ Fix 1: the condition depends on the value of x -> lax.cond or jnp.where (threshold can be traced, and changing it doesn't recompile)
@jax.jit
def conditional_double_cond(x, threshold):
    return lax.cond(x > threshold, lambda v: v * 2, lambda v: v, x)

@jax.jit
def conditional_double_where(x, threshold):
    return jnp.where(x > threshold, x * 2, x)

for fn in (conditional_double_cond, conditional_double_where):
    assert fn(jnp.array(5.0), 0.0) == 10.0
    assert fn(jnp.array(-2.0), 0.0) == -2.0
print("Fix 1 ✅ lax.cond:", conditional_double_cond(jnp.array(5.0), 0.0), conditional_double_cond(jnp.array(-2.0), 0.0))
Fix 1 ✅ lax.cond: 10.0 -2.0
# ✅ Fix 2: static_argnames only really works when the if condition depends entirely on static arguments
@partial(jax.jit, static_argnames=["double"])
def maybe_double(x, double):
    if double:            # double is a Python bool, known at trace time
        return x * 2
    return x

assert maybe_double(jnp.array(5.0), double=True) == 10.0
assert maybe_double(jnp.array(5.0), double=False) == 5.0
print("Fix 2 ✅ static switch:", maybe_double(jnp.array(5.0), double=True), maybe_double(jnp.array(5.0), double=False))
Fix 2 ✅ static switch: 10.0 5.0

The recompilation trade-off

  • For traced arguments: as long as shape and dtype stay the same, any values reuse the same compiled result;
  • For static arguments: every different value triggers a retrace and a recompile.

You can think of the cache key as: the identity of the Python function object + the values of static arguments + the shapes/dtypes of traced arguments (and the PyTree structure). For the full list, see the "Summary table: what triggers recompilation" in 2.3.

Four calls show this below (threshold is static): call 1 compiles for threshold=0.0; call 2 only changes the value of x and reuses it; call 3 uses threshold=10.0 and recompiles; call 4 reuses the threshold=10.0 result.

⚠️ Note: We can't reuse the if version above here, since it raises an error. Below, the function body is swapped for a compilable jnp.where, the calls stay the same, and a compile count verifies it.

def conditional_double_static(x, threshold):
    return jnp.where(x > threshold, x * 2, x)

jitted_func = jax.jit(conditional_double_static, static_argnames=("threshold",))
x5, x1 = jnp.array(5.0), jnp.array(1.0)      # create the inputs first
with count_compiles() as log:
    print("Call 1 (x=5, threshold=0.0)", flush=True);  r_a = jitted_func(x5, threshold=0.0)    # compiles
    print("Call 2 (x=1, threshold=0.0)", flush=True);  r_b = jitted_func(x1, threshold=0.0)    # reuses
    print("Call 3 (x=5, threshold=10.0)", flush=True); r_c = jitted_func(x5, threshold=10.0)   # recompiles
    print("Call 4 (x=1, threshold=10.0)", flush=True); r_d = jitted_func(x1, threshold=10.0)   # reuses
assert log.count("conditional_double_static") == 2
assert [float(r) for r in (r_a, r_b, r_c, r_d)] == [10.0, 2.0, 5.0, 1.0]
print("✅ 4 calls compiled", log.count("conditional_double_static"), "times in total")
Call 1 (x=5, threshold=0.0)
    [compile] jit(conditional_double_static)
Call 2 (x=1, threshold=0.0)
Call 3 (x=5, threshold=10.0)
    [compile] jit(conditional_double_static)
Call 4 (x=1, threshold=10.0)
✅ 4 calls compiled 2 times in total

When to use static arguments

  1. Python control flow depends on the argument's value (such as if mode == "train" or for i in range(num_layers));
  2. It determines the function's behavior, such as the number of layers, the type of activation function, or a configuration string;
  3. Places that need constants, such as slice start/stop positions or the target shape of reshape, which must be fixed at compile time.

General rule: numerical data should always be traced arguments. Only values that determine the structure of the computation and rarely change between calls should be made static, and carefully. For dynamic control flow, use compilable forms like lax.cond, lax.switch, and lax.scan.

🔍 Extra: Hard requirements and details for static arguments

🟢 Must-read

  1. Static arguments must be hashable (because they're used as cache keys). list, dict, and JAX/NumPy arrays won't work; use tuple, int, str, frozenset, hashable custom objects, and so on.
  2. If a static argument is a custom class, whether the cache hits is decided by its __hash__ / __eq__. Ordinary classes hash by object identity by default, so every new instance recompiles (even if the contents are identical). With @dataclass(frozen=True) it hashes by value. Conversely, an incomplete __hash__ can cause a sneaky bug where "it didn't recompile when it should have".
  3. In JAX 0.12, static_argnums=(1,) is automatically matched to that positional parameter's name, so passing it by keyword is also treated as static. But positional and keyword passing each get their own cache entry. An omitted default argument is baked into the Jaxpr as a constant (see item E6 in the 2.3 summary table).
  4. Shapes are static information in themselves: x.shape, x.ndim, and x.dtype can be used directly in a Python if / range without any marking.
from dataclasses import dataclass

# 1) Arrays and lists can't be static arguments; tuples can
expect_error(lambda: jax.jit(lambda x, t: x * t, static_argnums=1)(1.0, jnp.array(2.0)), ValueError)
expect_error(lambda: jax.jit(lambda x, shape: x.reshape(shape), static_argnums=1)(jnp.arange(6.0), [2, 3]), ValueError)
reshaped = jax.jit(lambda x, shape: x.reshape(shape), static_argnums=1)(jnp.arange(6.0), (2, 3))
assert reshaped.shape == (2, 3)

# 2) Custom classes as static arguments: hashing by identity vs. by value
class CfgById:                     # default __hash__/__eq__: by object identity
    def __init__(self, k):
        self.k = k

@dataclass(frozen=True)
class CfgByValue:                  # frozen dataclass: hashes by field values
    k: int

def scale_by_id(x, cfg):
    return x * cfg.k

def scale_by_value(x, cfg):
    return x * cfg.k

xc = jnp.ones(3)
j_id = jax.jit(scale_by_id, static_argnums=1)
j_val = jax.jit(scale_by_value, static_argnums=1)
with count_compiles(echo=False) as log:
    j_id(xc, CfgById(2)); j_id(xc, CfgById(2))          # same contents, but two objects
    j_val(xc, CfgByValue(2)); j_val(xc, CfgByValue(2))  # equal values -> cache hit
assert log.count("scale_by_id") == 2
assert log.count("scale_by_value") == 1
print("Config class hashed by identity compiled", log.count("scale_by_id"), "times; frozen dataclass compiled", log.count("scale_by_value"), "time(s)")

# 4) Shape information is static
@jax.jit
def sum_rows_or_all(x):
    return x.sum() if x.ndim == 1 else x.sum(axis=0)

assert sum_rows_or_all(jnp.ones(3)) == 3.0
assert jnp.array_equal(sum_rows_or_all(jnp.ones((2, 3))), jnp.full(3, 2.0))
print("Python if based on ndim ✅", sum_rows_or_all(jnp.ones(3)), sum_rows_or_all(jnp.ones((2, 3))))
❌ ValueError: Non-hashable static arguments are not supported. An error occurred while trying to hash an object of type <class 'jaxlib._jax.ArrayImpl'>, 2.0. The error was:
❌ ValueError: Non-hashable static arguments are not supported. An error occurred while trying to hash an object of type <class 'list'>, [2, 3]. The error was:
Config class hashed by identity compiled 2 times; frozen dataclass compiled 1 time(s)
Python if based on ndim ✅ 3.0 [2. 2. 2.]

2.6 Common jit pitfalls

Original: https://apxml.com/courses/getting-started-with-jax/chapter-2-accelerating-functions-jit/jit-common-pitfalls

The starting point: JAX runs your function with abstract tracers (only shape and dtype, no concrete values). The five kinds of problems below all come from this, and each one gets "an example of the problem + the fix".

Problem 1: a Python if that depends on a traced value

@jax.jit
def condition_on_value(x):
    if x > 0:
        return x * 2
    else:
        return x / 2

expect_error(lambda: condition_on_value(jnp.array(5.0)), jax.errors.ConcretizationTypeError)

# Fix: lax.cond(predicate, true branch, false branch, operand)
@jax.jit
def condition_on_value_lax(x):
    def true_fun(operand):
        return operand * 2

    def false_fun(operand):
        return operand / 2

    return lax.cond(x > 0, true_fun, false_fun, x)

print(condition_on_value_lax(jnp.array(5.0)))    # 10.0
print(condition_on_value_lax(jnp.array(-4.0)))   # -2.0
assert condition_on_value_lax(jnp.array(5.0)) == 10.0 and condition_on_value_lax(jnp.array(-4.0)) == -2.0
❌ TracerBoolConversionError: Attempted boolean conversion of traced array with shape bool[].
10.0
-2.0

Note: an if based on static values is fine.

Problem 2: loops with a variable number of iterations

def variable_loop(x, n):
    total = x
    for i in range(n):
        total = total + i
    return total

# n is a traced value (a JAX array), so range(n) can't determine the number of iterations
expect_error(lambda: jax.jit(variable_loop)(10.0, jnp.array(3)), jax.errors.TracerIntegerConversionError)

# Fix 1: lax.fori_loop (here combined with static_argnums to make n static)
def fixed_loop_fori(x, n_static):
    def body_fun(i, current_total):
        return current_total + i
    return lax.fori_loop(0, n_static, body_fun, x)

jitted_fori = jax.jit(fixed_loop_fori, static_argnums=(1,))
print(jitted_fori(jnp.array(10.0), 3))   # 13.0 = 10 + 0 + 1 + 2
assert jitted_fori(jnp.array(10.0), 3) == 13.0

# Fix 2: lax.scan (a loop with state; here a running sum)
@jax.jit
def cumulative_sum_scan(xs):
    def scan_op(carry, x):
        new_carry = carry + x
        return new_carry, new_carry   # (carry for the next step, output of this step)
    _, ys = lax.scan(scan_op, 0.0, xs)
    return ys

print(cumulative_sum_scan(jnp.array([1.0, 2.0, 3.0, 4.0])))   # [ 1.  3.  6. 10.]
assert jnp.array_equal(cumulative_sum_scan(jnp.array([1.0, 2.0, 3.0, 4.0])), jnp.array([1.0, 3.0, 6.0, 10.0]))
❌ TracerIntegerConversionError: The __index__() method was called on traced array with shape int32[]
13.0
[ 1.  3.  6. 10.]

About fori_loop bounds: the example above marks n_static as static. That's fine, but not required. The bounds of fori_loop can be traced values (that's how sum_first_n_lax in 2.4 uses it); in that case it lowers to while_loop internally, and changing n doesn't recompile. The only cost: when the bounds are traced values, reverse-mode differentiation doesn't work (jax.grad). When you need gradients, use a static upper bound or scan.

⚠️ Note: The upper bound of fori_loop doesn't have to be a compile-time constant; a traced value works too. The asserts below verify that the result is correct and that several different values of n compile only once.

# ✅ Pass n as a traced value: the result is correct, and different n share one compiled result
def loop_add(x, n):
    return lax.fori_loop(0, n, lambda i, t: t + i, x)

loop_add_jit = jax.jit(loop_add)
x10 = jnp.array(10.0)
with count_compiles() as log:
    results = [float(loop_add_jit(x10, n)) for n in (3, 5, 100)]
print("n=3, 5, 100 →", results)
assert results == [13.0, 20.0, 10.0 + sum(range(100))]
assert log.count("loop_add") == 1
print("✅ Three different values of n compiled only", log.count("loop_add"), "time")
    [compile] jit(loop_add)
n=3, 5, 100 → [13.0, 20.0, 4960.0]
✅ Three different values of n compiled only 1 time

Problem 3: side effects

Python side effects like print, writing files, modifying a global list, and time.sleep run only during tracing. Each input signature is traced once; when something like a new shape triggers a retrace, they run again. Later calls run the compiled code directly, and these Python statements never execute again.

@jax.jit
def function_with_side_effect(x):
    print(f"TRACE: Running Python code for x={x}")   # runs only during tracing
    time.sleep(0.1)                                    # also runs only during tracing
    return x * x

print(function_with_side_effect(jnp.array(3.0)))   # prints TRACE (x is a tracer)
print(function_with_side_effect(jnp.array(4.0)))   # no TRACE, and no sleep
print(function_with_side_effect(jnp.array(5.0)))
TRACE: Running Python code for x=JitTracer(~float32[])
9.0
16.0
25.0

Advice: don't put side effects in jit functions; manage state by passing it in and returning it explicitly (covered in Chapter 6, "State management"); when debugging, you can turn jit off temporarily.

🔍 Extra: Use jax.debug.print for debug printing and jax.disable_jit() for debugging logic

🟢 Must-read

  • print prints the tracer (JitTracer(~float32[])), and only once;
  • jax.debug.print("{x}", x=x) is compiled into the computation graph and prints the actual value on every run (use {} placeholders, not an f-string);
  • with jax.disable_jit(): makes jitted functions run as ordinary Python. Then you can use print, breakpoints, and even a Python if. It's good for tracking down logic errors, but don't leave it in production code.
@jax.jit
def debug_demo(x):
    print("Plain print (tracing only):", x)
    jax.debug.print("jax.debug.print (every run): x = {x}", x=x)
    return x * x

debug_demo(jnp.array(3.0)).block_until_ready()
debug_demo(jnp.array(4.0)).block_until_ready()

# disable_jit: x is a concrete value inside the function, so a Python if works too
with jax.disable_jit():
    print("Under disable_jit:", condition_on_value(jnp.array(5.0)))
Plain print (tracing only): JitTracer(~float32[])
jax.debug.print (every run): x = 3.0
jax.debug.print (every run): x = 4.0
Under disable_jit: 10.0

Problem 4: dynamic shapes and dtypes

A change in input shape triggers recompilation. The reference timings in the course example are: shape (3,) takes about 0.15 s on the first call and about 0.0001 s after that; shape (4,) takes about 0.09 s on its first call (recompilation) and is fast again afterward. Here's a run on this machine:

@jax.jit
def process_data(x):
    return jnp.sum(x * 2.0)

inputs = [jnp.ones(s) for s in [(3,), (3,), (4,), (4,)]]   # create the inputs first so only process_data itself is timed
with count_compiles(echo=False) as log:
    for x in inputs:
        t0 = time.perf_counter()
        process_data(x).block_until_ready()
        print(f"shape={x.shape}: {time.perf_counter() - t0:.4f} s")
assert log.count("process_data") == 2     # once for (3,), once for (4,)
print("✅ Compiled", log.count("process_data"), "times in total")
shape=(3,): 0.0084 s
shape=(3,): 0.0001 s
shape=(4,): 0.0089 s
shape=(4,): 0.0000 s
✅ Compiled 2 times in total

Ways to deal with it:

  1. Pad to a uniform shape;
  2. Use static_argnums / static_argnames for arguments that determine shapes (only suitable when they take few values);
  3. Process in batches so every batch has the same shape;
  4. Reconsider whether this code really suits jit.

🔍 Extra: Bucketed padding + masking in practice

⚪ Optional: Read this when frequently changing input lengths (variable-length sequences or batches) cause repeated recompilation.

In real settings (variable-length sequences, variable-length batches), a common approach is bucketing: round lengths up to a few buckets (such as powers of 2), and use a mask to keep results correct. However the input length changes, it compiles at most "number of buckets" times.

@jax.jit
def masked_mean(x_padded, length):
    # length is a traced value: use a mask instead of the slice x[:length] (slice lengths must be static)
    mask = jnp.arange(x_padded.shape[0]) < length
    return jnp.sum(jnp.where(mask, x_padded, 0.0)) / length

def bucket_size(n):
    return max(8, 1 << (n - 1).bit_length())   # round up to a power of 2, minimum 8

def mean_any_length(x):
    n = x.shape[0]
    padded = jnp.pad(x, (0, bucket_size(n) - n))
    return masked_mean(padded, n)

lengths = [3, 5, 7, 9, 12, 16, 20]
data_by_len = [jnp.arange(1.0, n + 1) for n in lengths]    # create the inputs first
with count_compiles(echo=False) as log:
    for x in data_by_len:
        assert jnp.allclose(mean_any_length(x), x.mean())
assert log.count("masked_mean") == 3
print("All 7 lengths give correct results; masked_mean compiled only", log.count("masked_mean"), "times (buckets: 8, 16, 32)")
All 7 lengths give correct results; masked_mean compiled only 3 times (buckets: 8, 16, 32)

Problem 5: global variables

A jit function reads global variables during tracing and "bakes" their values at that moment into the compiled result as constants. Changing a global afterward doesn't trigger recompilation (item E8 in the 2.3 summary table), and the cached compiled result still uses the old value.

learning_rate = 0.01

@jax.jit
def update_weights(params, grads):
    return params - learning_rate * grads   # learning_rate is frozen in during tracing

params = jnp.array([1.0, 2.0])
grads = jnp.array([0.5, -0.1])

r1 = update_weights(params, grads)
print(r1)                              # [0.995 2.001]
learning_rate = 1000.0                 # change the global variable
r2 = update_weights(params, grads)
print(r2)                              # still [0.995 2.001]: it uses the old value 0.01
assert np.allclose(r1, [0.995, 2.001]) and np.allclose(r2, r1)
[0.995 2.001]
[0.995 2.001]

The fix: pass hyperparameters in explicitly as arguments.

⚠️ Note: The output of update_weights_explicit(params, grads, 1000.0) is [-499. 102.]. The second component is $2 - 1000 \times (-0.1) = 102$. Some material prints it as [-499. 120.], which is a typo.

# ✅ Pass hyperparameters in as arguments
@jax.jit
def update_weights_explicit(params, grads, lr):
    return params - lr * grads

r_small = update_weights_explicit(params, grads, 0.01)
r_big = update_weights_explicit(params, grads, 1000.0)
print(r_small)    # [0.995 2.001]
print(r_big)      # [-499.  102.]

course_printed = np.array([-499.0, 120.0])      # ❌ the easily mis-copied value (120 is a typo)
assert np.allclose(r_small, [0.995, 2.001])
assert np.allclose(r_big, [-499.0, 102.0])      # ✅ the correct value
assert not np.allclose(r_big, course_printed)
print("✅ Assertions passed: the correct result is [-499. 102.]")
[0.995 2.001]
[-499.  102.]
✅ Assertions passed: the correct result is [-499. 102.]

🔍 Extra: Old and new values of a global coexisting, and passing large arrays as arguments too

🟢 Must-read

  1. Old and new values coexisting: the global value is read only during tracing. If a new shape later triggers a retrace, the new compiled result reads the new value, while the cache entry for the old shape still uses the old value. The result: the same function uses different hyperparameters for inputs of different shapes. Bugs like this are extremely hard to track down. So jit functions should be pure functions, with everything that can change passed in through arguments.
  2. Don't capture large arrays (such as model weights) in a closure either; pass them in as arguments. Arrays captured by a closure are embedded in the compiled result as constants, which increases compile time and memory, and changing them later has no effect. Also, this environment (jaxlib 0.12.0.1 CPU) has a known bug: when a jit closure captures a large array and then does a matmul followed by a reduction, it silently returns wrong results. For a reproduction and workaround see 00 · Known issues in this environment; for the root-cause analysis see Advanced 02 §2.3. Every example and benchmark in this tutorial passes weights in as arguments.
scale = 1.0

@jax.jit
def scaled(x):
    return x * scale

s_old = scaled(jnp.ones(2))   # traces and freezes scale=1.0
scale = 5.0
s_new = scaled(jnp.ones(3))   # new shape -> retraces and reads scale=5.0
s_again = scaled(jnp.ones(2)) # old cache entry, still scale=1.0!
print("shape (2,):", s_old, " shape (3,):", s_new, " shape (2,) again:", s_again)
assert jnp.allclose(s_old, 1.0) and jnp.allclose(s_new, 5.0) and jnp.allclose(s_again, 1.0)
shape (2,): [1. 1.]  shape (3,): [5. 5. 5.]  shape (2,) again: [1. 1.]

🔍 Extra: donate_argnums -- letting XLA reuse input buffers

⚪ Optional: Read this when GPU or host memory is tight and you want parameter updates to reuse the input buffers.

Training loops often have params = update(params, ...): the old params is thrown away once used. donate_argnums tells XLA "this input won't be used again", so it can write the output straight into the input's memory and lower peak memory use.

  • A donated array can't be used afterward, or you get RuntimeError: Array has been deleted;
  • The CPU backend in JAX 0.12 also supports donation (older versions warned that "donation is not available" on CPU and ignored it);
  • Donation only actually takes effect when the input matches some output in shape/dtype.
@partial(jax.jit, donate_argnums=0)
def sgd_step(params, grads, lr):
    return params - lr * grads

p = jnp.ones(4)
new_p = sgd_step(p, jnp.full(4, 0.5), 0.1)
print("New parameters:", new_p)
print("Has the old p been deleted:", p.is_deleted())
expect_error(lambda: p + 1, RuntimeError)   # using a donated array again -> error
assert p.is_deleted() and jnp.allclose(new_p, 0.95)
New parameters: [0.95 0.95 0.95 0.95]
Has the old p been deleted: True
❌ RuntimeError: Array has been deleted with shape=float32[4].

2.7 Hands-on: applying jit

Original: https://apxml.com/courses/getting-started-with-jax/chapter-2-accelerating-functions-jit/hands-on-applying-jit

This section ties everything together: write a timing helper, compare original functions with their jit versions, and practice control flow and static arguments.

One more reminder: JAX runs asynchronously, so you must wait for the result to really finish before timing; the first call includes compile overhead, so warm up before timing.

Setup: timing helper and test data

print("Available devices:", jax.devices())

def time_func(func, *args, num_runs=10, warmup_runs=2):
    """A common way to time things: warm up + time several runs, returning (mean, standard deviation)."""
    # Warm up (including the first compilation)
    for _ in range(warmup_runs):
        result = func(*args)
        if isinstance(result, jax.Array):
            result.block_until_ready()
    times = []
    for _ in range(num_runs):
        start = timeit.default_timer()
        result = func(*args)
        if isinstance(result, jax.Array):
            result.block_until_ready()
        times.append(timeit.default_timer() - start)
    return np.mean(times), np.std(times)

key = jax.random.key(0)
kx, ky = jax.random.split(key)
x_jnp = jax.random.normal(kx, (1000, 1000))
y_jnp = jax.random.normal(ky, (1000, 1000))
Available devices: [CpuDevice(id=0)]

⚠️ Note: The time_func above only calls block_until_ready() when the return value is a single jax.Array. If the function returns a tuple or dict (a PyTree), it never waits for the computation to finish, and what it measures is just the asynchronous queueing time. The fix is jax.block_until_ready(result), which handles any PyTree (that's how the bench at the top of this notebook is written). Below is a side-by-side comparison.

# ❌ Using time_func on a function that returns a tuple: a tuple isn't a jax.Array, so the blocking is skipped
@jax.jit
def heavy_tuple(a):
    c = a @ a
    for _ in range(3):
        c = jnp.tanh(c @ a)
    return c, c.sum()          # returns a tuple (a PyTree)

A_big = jax.random.normal(jax.random.key(7), (1500, 1500))
t_course, _ = time_func(heavy_tuple, A_big)
print(f"Original time_func: {t_course*1e3:8.3f} ms   ← just the queueing time")
Original time_func:    0.015 ms   ← just the queueing time
# ✅ Fix: jax.block_until_ready can wait on any PyTree
def time_func_fixed(func, *args, num_runs=10, warmup_runs=2):
    for _ in range(warmup_runs):
        jax.block_until_ready(func(*args))
    times = []
    for _ in range(num_runs):
        start = timeit.default_timer()
        jax.block_until_ready(func(*args))
        times.append(timeit.default_timer() - start)
    return np.mean(times), np.std(times)

t_fixed, _ = time_func_fixed(heavy_tuple, A_big)
print(f"Fixed version:      {t_fixed*1e3:8.3f} ms   ← the real compute time (about {t_fixed/t_course:.0f}x the original)")
assert t_fixed > 10 * t_course
Fixed version:        19.738 ms   ← the real compute time (about 1348x the original)

Example 1: a "compute-heavy" function

def compute_heavy(a, b):
    c = jnp.dot(a, b)
    d = jnp.sin(c)
    e = jnp.log(jnp.abs(d) + 1e-6)
    f = jnp.sum(e)
    return f

mean_time_orig, std_time_orig = time_func(compute_heavy, x_jnp, y_jnp)
print(f"Original function: {mean_time_orig*1e3:.3f} ± {std_time_orig*1e3:.3f} ms")

compute_heavy_jit = jax.jit(compute_heavy)
mean_time_jit, std_time_jit = time_func(compute_heavy_jit, x_jnp, y_jnp)
print(f"JIT version:       {mean_time_jit*1e3:.3f} ± {std_time_jit*1e3:.3f} ms")
print(f"Speedup:           {mean_time_orig / mean_time_jit:.2f}x")
assert jnp.allclose(compute_heavy(x_jnp, y_jnp), compute_heavy_jit(x_jnp, y_jnp), rtol=1e-4)   # results agree
Original function: 3.825 ± 0.264 ms
JIT version:       1.513 ± 0.047 ms
Speedup:           2.53x

How much faster it gets depends on the hardware, the array sizes, and how complex the function is. The bulk of this example is a 1000×1000 matrix multiplication (already handled by an optimized library on the CPU), so what jit mainly saves is the reads and writes of intermediate arrays in the later sin/abs/log/sum steps (fusion).

Below the results are drawn as a bar chart (error bars are standard deviations). The vertical axis uses a linear scale starting at 0: a bar chart on a log axis has no real zero baseline, so the ratio of bar heights would mislead. A log axis is only worth it when the two differ by several orders of magnitude.

import matplotlib.pyplot as plt

labels = ["Original Function", "JIT Compiled"]
mean_times = [mean_time_orig, mean_time_jit]
std_devs = [std_time_orig, std_time_jit]

fig, ax = plt.subplots(figsize=(5, 3.2))
ax.bar(labels, [t * 1e3 for t in mean_times], yerr=[s * 1e3 for s in std_devs],
       width=0.45, color="#2a78d6", capsize=4, error_kw={"elinewidth": 1})
ax.set_ylabel("Mean time per call (ms)")
ax.set_ylim(0, max(t + sd for t, sd in zip(mean_times, std_devs)) * 1e3 * 1.25)   # leave room at the top for labels
ax.set_title("compute_heavy: original vs jax.jit (CPU)")
for i, (t, sd) in enumerate(zip(mean_times, std_devs)):
    # Put the label above the top of the error bar so they don't overlap
    ax.annotate(f"{t*1e3:.2f} ms", (i, (t + sd) * 1e3), textcoords="offset points",
                xytext=(0, 6), ha="center", fontsize=9)
ax.spines[["top", "right"]].set_visible(False)
ax.grid(axis="y", which="major", alpha=0.3)
plt.tight_layout()
plt.show()
No description has been provided for this image

Example 2: JIT and Python control flow

First, a function written with a Python if whose condition depends on jnp.sum(x). Since the condition depends on a traced value, the jit version always raises TracerBoolConversionError during tracing. The right way to write it is lax.cond.

⚠️ Note: One claim is that the Python if version "may compile only the branch it hits first, may recompile, or may fail, depending on the version". That's wrong. In JAX 0.12, as long as the condition depends on a traced value, it always raises an error; "compiling only one branch" never happens.

# ❌ Old style: a Python if whose condition depends on a traced value
def conditional_computation(x, threshold):
    if jnp.sum(x) > threshold:
        return jnp.dot(x, x.T) * 2
    else:
        return jnp.dot(x, x.T) / 2

conditional_computation_jit = jax.jit(conditional_computation)
x_small = jnp.array([1.0, 2.0, 3.0])

expect_error(lambda: conditional_computation_jit(x_small, 5.0), jax.errors.ConcretizationTypeError)
expect_error(lambda: conditional_computation_jit(x_small, 10.0), jax.errors.ConcretizationTypeError)
❌ TracerBoolConversionError: Attempted boolean conversion of traced array with shape bool[].
❌ TracerBoolConversionError: Attempted boolean conversion of traced array with shape bool[].
jax.errors.TracerBoolConversionError('Attempted boolean conversion of traced array with shape bool[].\nThe error occurred while tracing the function conditional_computation at /tmp/ipykernel_12624/129658921.py:2 for jit. This concrete value was not available in Python because it depends on the values of the arguments x and threshold.\nSee https://docs.jax.dev/en/latest/101/errors.html#jax.errors.TracerBoolConversionError')
# ✅ Use lax.cond (x is 1D, so x.T doesn't change it, and jnp.dot(x, x.T) is the inner product 14.0)
@jax.jit
def conditional_computation_lax(x, threshold):
    return lax.cond(
        jnp.sum(x) > threshold,
        lambda op: op * 2,
        lambda op: op / 2,
        jnp.dot(x, x.T),
    )

r5 = conditional_computation_lax(x_small, 5.0)     # sum=6 > 5  → 14 * 2
r10 = conditional_computation_lax(x_small, 10.0)   # sum=6 < 10 → 14 / 2
print("threshold=5.0 :", r5)
print("threshold=10.0:", r10)
assert r5 == 28.0 and r10 == 7.0
threshold=5.0 : 28.0
threshold=10.0: 7.0

Example 3: static arguments

apply_n_times(x, n) does n matrix multiplications with a Python for loop. range(n) needs a concrete integer, so:

  • Not marked static: n is a traced value, and range(n) immediately raises TracerIntegerConversionError;
  • static_argnums=(1,): each different n compiles once (once each for n=2 and 3; n=2 again hits the cache);
  • If you want no recompilation at all when n changes: rewrite the loop as lax.fori_loop(0, n, ...) and pass n as a traced value.

⚠️ Note: Without marking it static, it is not the case that "the loop is unrolled for the n seen at trace time and recompiles when n changes". In JAX 0.12, without marking it static it can't be traced at all and raises TracerIntegerConversionError right away.

# ❌ Old style: not marked static, so n is a traced value
def apply_n_times(x, n):
    y = x
    for _ in range(n):
        y = jnp.dot(y, x)
    return y

x_matrix = jax.random.normal(jax.random.key(1), (50, 50))
apply_n_times_jit = jax.jit(apply_n_times)
expect_error(lambda: time_func(apply_n_times_jit, x_matrix, 2), jax.errors.TracerIntegerConversionError)
❌ TracerIntegerConversionError: The __index__() method was called on traced array with shape int32[]
jax.errors.TracerIntegerConversionError('The __index__() method was called on traced array with shape int32[]\nThe error occurred while tracing the function apply_n_times at /tmp/ipykernel_12624/3568859178.py:2 for jit. This concrete value was not available in Python because it depends on the value of the argument n.\nSee https://docs.jax.dev/en/latest/101/errors.html#jax.errors.TracerIntegerConversionError')
# ✅ Fix 1: static n -- each new value compiles once
apply_n_times_static_jit = jax.jit(apply_n_times, static_argnums=(1,))
with count_compiles() as log:
    for n in [2, 3, 2]:
        m, s = time_func(apply_n_times_static_jit, x_matrix, n)
        print(f"static n={n}: {m*1e6:.1f} µs", flush=True)
assert log.count("apply_n_times") == 2     # once each for n=2 and n=3; the second n=2 hits the cache
    [compile] jit(apply_n_times)
static n=2: 56.8 µs
    [compile] jit(apply_n_times)
static n=3: 32.7 µs
static n=2: 80.0 µs
# ✅ Fix 2: fori_loop + traced n -- changing n never recompiles
@jax.jit
def apply_n_times_fori(x, n):
    return lax.fori_loop(0, n, lambda i, y: jnp.dot(y, x), x)

with count_compiles() as log:
    outs = {n: apply_n_times_fori(x_matrix, n) for n in [2, 3, 5]}
assert log.count("apply_n_times_fori") == 1
for n in [2, 3]:   # matches the static unrolled version
    assert jnp.allclose(outs[n], apply_n_times_static_jit(x_matrix, n), rtol=1e-3)
print("✅ fori_loop version: n=2,3,5 compiles only once, and the results match the static version")
    [compile] jit(apply_n_times_fori)
✅ fori_loop version: n=2,3,5 compiles only once, and the results match the static version

Section summary: always wait for results to finish when timing (use block_until_ready(); for PyTree return values, use jax.block_until_ready), and keep the first-call compile overhead in mind. jit is the foundation of JAX performance work, but you need to pair it with lax control flow and static arguments to write code that is both correct and efficient.


Extra exercises

The 4 extra exercises below cover control-flow primitives, static arguments, recompilation analysis, and pure-function design. Try them yourself first, then look at the reference solutions.

Exercise 1: a piecewise function (lax.switch / jnp.select)

Implement a jit-compatible personal income tax function tax(income) (scalar) with progressive rates:

Income bracketRate
[0, 10000)0%
[10000, 40000)10%
[40000, 100000)20%
≥ 10000030%

Requirements: (a) write a scalar version with lax.switch; (b) write another version with jnp.select or jnp.where that handles arrays directly; (c) check that both agree with a NumPy reference implementation.

Self-check interface: define a scalar version tax_switch(income) and an array version tax_vectorized(income).

# ✍️ 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:
    tax_switch, tax_vectorized
except NameError as e:
    print(f"⏳ Exercise 1: not answered yet: {e}")
else:
    def _tax_ref(inc):                 # bracket by bracket: each bracket taxes only the part of income that falls inside it
        edges, r = [0.0, 10000.0, 40000.0, 100000.0, np.inf], [0.0, 0.10, 0.20, 0.30]
        return sum(max(0.0, min(inc, hi) - lo) * rr for lo, hi, rr in zip(edges[:-1], edges[1:], r))
    _inc = [0.0, 5000.0, 9999.5, 10000.0, 10001.0, 39999.0, 40000.0, 55555.0, 99999.0, 100000.0, 123456.0, 1e6]
    _ref = np.array([_tax_ref(v) for v in _inc])
    _sw = np.array([float(tax_switch(jnp.float32(v))) for v in _inc])
    assert np.allclose(_sw, _ref, rtol=1e-5, atol=1e-2), f"tax_switch gives wrong results:\n  expected {_ref}\n  got {_sw}"
    assert "cond" in str(jax.make_jaxpr(tax_switch)(1.0)), "tax_switch should use lax.switch (its jaxpr should contain cond)"
    _vec = np.asarray(jax.jit(tax_vectorized)(jnp.array(_inc)))
    assert _vec.shape == (len(_inc),), f"tax_vectorized should work element-wise, so the output shape should be {(len(_inc),)}, got {_vec.shape}"
    assert np.allclose(_vec, _ref, rtol=1e-5, atol=1e-2), f"tax_vectorized gives wrong results:\n  expected {_ref}\n  got {_vec}"
    assert jax.jit(tax_vectorized)(jnp.array(_inc).reshape(3, 4)).shape == (3, 4), "tax_vectorized should keep the input's shape"
    print("✅ Exercise 1: self-check passed")
⏳ Exercise 1: not answered yet: name 'tax_switch' is not defined

Exercise 2: Newton's method for square roots with lax.while_loop

Implement newton_sqrt(a, tol=1e-6): starting from $x_0 = a$ ($x_0=1$ if $a<1$), iterate $$x_{k+1} = \tfrac12\left(x_k + \frac{a}{x_k}\right)$$ until $|x_{k+1}-x_k| < \text{tol}$, and return (result, number of iterations). The whole function must be jitted, tol must be passed in as a traced argument (changing tol doesn't recompile), and compare against jnp.sqrt. Think about it: why must this use while_loop instead of a Python while?

Self-check interface: define newton_sqrt(a, tol) -> (result, number of iterations) and wrap it with jax.jit.

# ✍️ Your answer: Exercise 2
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 2
try:
    newton_sqrt
except NameError as e:
    print(f"⏳ Exercise 2: not answered yet: {e}")
else:
    assert hasattr(newton_sqrt, "lower"), "newton_sqrt should be wrapped with jax.jit"
    for _a in [0.01, 0.5, 3.0, 50.0]:
        _r, _k = newton_sqrt(_a, 1e-6)
        assert np.isclose(float(_r), np.sqrt(_a), rtol=1e-5), f"newton_sqrt({_a}) = {float(_r)}, should be about {np.sqrt(_a):.6f}"
        assert int(_k) >= 1, "There should be at least 1 iteration"
    _, _k_loose = newton_sqrt(5.0, 1e-1)
    _, _k_tight = newton_sqrt(5.0, 1e-6)
    assert int(_k_tight) > int(_k_loose), f"A smaller tol should take more iterations, but tol=1e-1 took {int(_k_loose)} iterations and 1e-6 took {int(_k_tight)} iterations"
    with count_compiles(echo=False) as _log:
        for _a, _tol in [(2.0, 1e-2), (7.0, 1e-4), (11.0, 1e-5)]:
            newton_sqrt(_a, _tol)
    assert _log.count("newton_sqrt") <= 1, f"Changing a and tol shouldn't recompile, but it compiled {_log.count('newton_sqrt')} times (did tol become a static argument?)"
    print("✅ Exercise 2: self-check passed")
⏳ Exercise 2: not answered yet: name 'newton_sqrt' is not defined

Exercise 3: recompilation detective

Without running the code, predict how many compilations the 8 calls below trigger in total, and say for each call whether it triggers a compilation and why. Then run it to check (count with the count_compiles helper defined at the top).

@partial(jax.jit, static_argnames=["mode"])
def detective(x, mode="a"):
    return x * 2 if mode == "a" else x + 1

detective(jnp.ones(3))                       # ①
detective(jnp.ones(3) * 5)                   # ②
detective(jnp.ones(4))                       # ③
detective(jnp.ones(3), mode="b")             # ④
detective(jnp.ones(3, dtype=jnp.int32))      # ⑤
detective(1.0)                               # ⑥
detective(jnp.float32(1.0))                  # ⑦
detective(jnp.ones(3), mode="a")             # ⑧

Self-check interface: write your prediction as predictions: a list of 8 bools, where element k says whether call k triggers a compilation (True means it compiles).

# ✍️ 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:
    predictions
except NameError as e:
    print(f"⏳ Exercise 3: not answered yet: {e}")
else:
    assert len(predictions) == 8, f"predictions should have 8 elements, got {len(predictions)} elements"

    @partial(jax.jit, static_argnames=["mode"])
    def _detective_check(x, mode="a"):     # identical to detective in the exercise, renamed so it can be counted separately
        return x * 2 if mode == "a" else x + 1

    _args = [((jnp.ones(3),), {}), ((jnp.ones(3) * 5,), {}), ((jnp.ones(4),), {}),
             ((jnp.ones(3),), {"mode": "b"}), ((jnp.ones(3, dtype=jnp.int32),), {}),
             ((1.0,), {}), ((jnp.float32(1.0),), {}), ((jnp.ones(3),), {"mode": "a"})]
    _actual = []
    with count_compiles(echo=False) as _log:
        for _a, _kw in _args:
            _before = _log.count("_detective_check")
            _detective_check(*_a, **_kw)
            _actual.append(_log.count("_detective_check") > _before)
    _wrong = ["①②③④⑤⑥⑦⑧"[k] for k in range(8) if bool(predictions[k]) != _actual[k]]
    assert not _wrong, f"Your predictions for these calls are wrong: {', '.join(_wrong)}. Compare against the recompilation summary table in 2.3 and think again"
    print("✅ Exercise 3: self-check passed")
⏳ Exercise 3: not answered yet: name 'predictions' is not defined
Exercise 3: answer and explanation

It compiles 7 times in total; only ② hits the cache.

  • ② only has different values, so it hits ①'s entry;
  • ③ has a different shape; ④ a different static argument value; ⑤ a different dtype;
  • ⑥ and ⑦ are both float32 scalars, but Python 1.0 is weakly typed and jnp.float32(1.0) is strongly typed, so their signatures differ (item E3 in the 2.3 summary table);
  • ⑧ is the easiest to get wrong: mode="a" is the same as the default, but the cache key distinguishes "omitting the default argument" from "explicitly passing the same value" (item E6), so ⑧ compiles once more. When you're tracking down "why did my function compile twice", this is a cause few people mention. Calling it consistently avoids it.

Exercise 4: rewrite a "training loop with global state" as a pure function + fori_loop

The code below trains 1D linear regression $y = wx + b$ (mean squared error, hand-written gradients) with a global variable and a Python loop; every step calls the jit function separately and depends on a global learning rate:

lr = 0.1
@jax.jit
def step(w, b, x, y):
    err = w * x + b - y
    return w - lr * jnp.mean(2 * err * x), b - lr * jnp.mean(2 * err)

for _ in range(200):
    w, b = step(w, b, x, y)

Rewrite it so that: (a) lr and the number of steps num_steps are both arguments; (b) the whole 200-step training runs in one jit function using lax.fori_loop; (c) data is generated with jax.random.key (true $w=3, b=-1$, plus noise), and you verify the learned parameters; (d) you use bench to compare the time of "a Python loop calling step 200 times" with "one fori_loop training run".

Self-check interface: define a single step step(w, b, x, y, lr), a Python loop version train_python_loop(w, b, x, y, lr, num_steps), and a jit + fori_loop version train_fori(w, b, x, y, lr, num_steps); the last two return (w, b).

# ✍️ 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:
    step, train_python_loop, train_fori
except NameError as e:
    print(f"⏳ Exercise 4: not answered yet: {e}")
else:
    _k1, _k2 = jax.random.split(jax.random.key(7))
    _x = jax.random.uniform(_k1, (300,), minval=-1.0, maxval=1.0)
    _y = -2.0 * _x + 0.5 + 0.05 * jax.random.normal(_k2, (300,))
    _w0 = _b0 = jnp.float32(0.0)

    _xn, _yn = np.asarray(_x, np.float64), np.asarray(_y, np.float64)
    _e = -_yn                                              # the residual when w=b=0
    _w1, _b1 = step(_w0, _b0, _x, _y, 0.3)
    assert np.isclose(float(_w1), -0.3 * np.mean(2 * _e * _xn), atol=1e-5) and np.isclose(float(_b1), -0.3 * np.mean(2 * _e), atol=1e-5), \
        "One step of step doesn't match the gradient descent formula given in the exercise"

    _wf, _bf = train_fori(_w0, _b0, _x, _y, 0.5, 300)
    assert abs(float(_wf) + 2.0) < 0.05 and abs(float(_bf) - 0.5) < 0.05, f"train_fori didn't learn the true parameters (-2, 0.5); got ({float(_wf):.3f}, {float(_bf):.3f})"
    _wp, _bp = train_python_loop(_w0, _b0, _x, _y, 0.5, 300)
    assert np.allclose([float(_wf), float(_bf)], [float(_wp), float(_bp)], atol=1e-4), "train_fori and train_python_loop should give the same result"
    _wz, _bz = train_fori(jnp.float32(1.0), jnp.float32(2.0), _x, _y, 0.0, 50)
    assert np.allclose([float(_wz), float(_bz)], [1.0, 2.0]), "With lr=0 the parameters shouldn't change: lr must actually be used as an argument, not taken from a global variable"
    assert not np.isclose(float(train_fori(_w0, _b0, _x, _y, 0.5, 5)[0]), float(train_fori(_w0, _b0, _x, _y, 0.5, 6)[0])), \
        "Different num_steps should give different results"
    assert hasattr(train_fori, "lower"), "train_fori should be a jit function"
    assert "while" in train_fori.lower(_w0, _b0, _x, _y, 0.5, 300).as_text(), "train_fori should use lax.fori_loop, not a Python loop unrolled during tracing"
    print("✅ Exercise 4: self-check passed")
⏳ Exercise 4: not answered yet: name 'step' is not defined

Chapter 2 quiz

These 8 questions help you check how well you've got this chapter. The answer and explanation sit under each question (collapsible).

1. Which statement about JAX in eager mode is most accurate? A. JAX automatically compiles every function into XLA code B. Every jnp operation is dispatched separately, so the overhead adds up with many small operations C. Eager mode is always faster than jit on a GPU D. Eager mode can't run on the CPU

Answer and explanation

B. Without jit, JAX dispatches operations one at a time, and the fixed overhead is a large share for small operations. JAX doesn't automatically compile whole functions (A is wrong), and the whole point of jit is to be faster than eager (C is wrong).

2. A @jax.jit function is called first with f32[100,100] and then again with different values of f32[100,100]. The second call will: A. Retrace and recompile B. Only retrace C. Use the cached compiled result directly D. Raise an error

Answer and explanation

C. The cache key is made up of the traced arguments' shapes, dtypes, weak-type flags, PyTree structure, static argument values, and so on (see the 2.3 summary table for the full list); it has nothing to do with the concrete values.

3. What happens when the function below is wrapped in jax.jit and called as f(jnp.array(3.0))?

def f(x):
    if x.ndim == 0:
        return x + 1
    return x.sum()

A. It raises TracerBoolConversionError B. It returns 4.0 normally C. It recompiles on every call D. It returns a tracer object

Answer and explanation

B. x.ndim is shape information, a known Python integer during tracing, so an if based on it is perfectly legal. Only conditions that depend on values raise errors.

4. A function contains if x > threshold:, where x is a JAX array. Which approach makes it work correctly under jit? A. static_argnames=("threshold",) B. static_argnums=(0,) C. Rewrite it as lax.cond(x > threshold, ...) or jnp.where D. Make threshold a Python int

Answer and explanation

C. As long as one side of the comparison is a traced value, the result is a tracer and the if can't use it (neither A nor D works; see section 2.5). B makes an array static, which raises an error because arrays aren't hashable.

5. Which is correct about print and jax.debug.print? A. Inside a jit function, both print the actual values on every call B. print runs once during tracing and prints the tracer; jax.debug.print prints the actual value on every run C. jax.debug.print can only be used under disable_jit D. print makes jit raise an error

Answer and explanation

B. print is a Python side effect that runs only during tracing; jax.debug.print is compiled into the computation graph as an operation.

6. Inside a jit function you need to loop n times, n is different on every call, and you only need the forward computation. What's the best way to write it? A. A Python for i in range(n) with n made static B. lax.fori_loop(0, n, body, init), with n passed in as a traced value C. A Python while loop D. Re-run jax.jit before every call

Answer and explanation

B. A fori_loop with a traced upper bound compiles only once. A recompiles for every new n and also unrolls the computation graph; C raises an error; D compiles every time. (Note: if you need reverse-mode differentiation, a fori_loop with a traced upper bound doesn't support it; switch to a static upper bound or scan.)

7. Which of these can't be the value of an argument specified in static_argnums? A. 3 B. "relu" C. (2, 3) D. jnp.array([2, 3])

Answer and explanation

D. Static arguments must be hashable. JAX arrays aren't, so you get ValueError: Non-hashable static arguments are not supported.

8. Which is the right way to time a jit function? A. Use the time of the first call as the function's running time B. You don't need block_until_ready, because JAX is synchronous C. Call it once to warm up (triggering compilation), then time it and call block_until_ready() on the result D. Use time.sleep to wait for the computation to finish

Answer and explanation

C. The first call includes tracing and compilation; JAX dispatches asynchronously, so without blocking you only measure the queueing time.


Chapter summary

  • Why compile: eager mode dispatches operations one by one, with interpreter/dispatch overhead; compilation removes that overhead, fuses operations, and uses hardware parallelism. On the CPU, jit helps most with "lots of small operations".
  • Using jax.jit: the decorator @jax.jit or jax.jit(f); with arguments, use @partial(jax.jit, ...). The cache is attached to the function object -- don't keep jitting new lambdas inside a loop.
  • How it works: tracing (tracers have only shape/dtype) → Jaxpr → lowering (StableHLO) → XLA compilation → caching. You can watch each stage with make_jaxpr, .trace(), .lower().as_text(), and .compile().
  • What triggers recompilation: shape, dtype, weak type, static argument values, PyTree structure, how arguments are passed (positional/keyword/default omitted), and creating a new jit object each time; changing a global variable does not (so the old value is silently used). With x64 off by default, float64 is truncated to float32, so it doesn't cause a recompile. Watch it with jax_log_compiles (see the 2.3 summary table).
  • Control flow: an if/for/while that depends on traced values raises TracerBoolConversionError / TracerIntegerConversionError (their common parent is jax.errors.JAXTypeError; only the former is a subclass of ConcretizationTypeError). Use lax.cond/lax.switch/jnp.where for conditions and lax.fori_loop/while_loop/scan for loops. Python control flow that depends on shapes or static arguments is legal.
  • Static arguments: must be hashable; every new value compiles once; only make values static that "determine the structure of the computation and rarely change". Making only one side of a comparison static doesn't fix if x > t.
  • Common pitfalls: side effects run only during tracing (debug with jax.debug.print / jax.disable_jit()); globals are frozen in during tracing, and after a retrace old and new values may coexist; pass large arrays/weights in as arguments rather than capturing them in closures; dynamic shapes cause frequent recompilation (use bucketed padding + masking); donate_argnums can reuse buffers, but a donated array can't be used again.
  • Measuring performance: warm up first, then time, and wait for results with block_until_ready() / jax.block_until_ready() (you must use the latter when a PyTree is returned).
  • fori_loop and differentiation: static bounds → scan, reverse-mode differentiable; traced bounds → while_loop, not reverse-mode differentiable.

Cheat sheet

APIWhat it does
jax.jit(f) / @jax.jitJust-in-time compile a function, cached by input signature
jax.jit(f, static_argnums=..., static_argnames=...)Specify static arguments (must be hashable; a changed value recompiles)
jax.jit(f, donate_argnums=...)Donate input buffers to reduce memory use
@partial(jax.jit, ...)Decorator form with arguments
jax.make_jaxpr(f)(*args)See the Jaxpr from tracing
jax.jit(f).trace(*args).jaxprAOT: the tracing stage
jax.jit(f).lower(*args).as_text()AOT: see the lowered StableHLO
.lower(...).compile() / .as_text() / .cost_analysis()AOT: compile, see the optimized HLO, estimate cost
x.block_until_ready() / jax.block_until_ready(tree)Wait for asynchronous computation to finish (a must for timing)
jax.config.update("jax_log_compiles", True)Log every compilation
jax.config.update("jax_enable_x64", True) / with jax.enable_x64(True):Enable 64-bit types globally / temporarily
jax.disable_jit()Context manager: temporarily disable jit for debugging
jax.debug.print("{x}", x=x)Print runtime values from compiled code
lax.cond(pred, true_fun, false_fun, *ops)Two-branch conditional with a scalar predicate
lax.switch(index, branches, *ops)Multi-branch conditional (an out-of-range index is clamped)
jnp.where(c, a, b) / jnp.select(conds, choices)Element-wise conditional selection (both sides are computed)
lax.fori_loop(lo, hi, body, init)Counting loop; bounds can be traced values; reverse-mode differentiable only with static bounds
lax.while_loop(cond, body, init)Conditional loop; no reverse-mode differentiation
lax.scan(f, init, xs)Step through a sequence with state; returns (final carry, stacked outputs)
jax.random.key(seed) / jax.random.split(key, n)New-style random keys and splitting (replaces PRNGKey)
x.device / x.weak_typeWhich device the array is on / whether it's weakly typed
jax.errors.ConcretizationTypeError / jax.errors.JAXTypeErrorErrors when a traced value is used as a concrete value; the former is not the base class of all such errors (see the 2.4 Extra), so catch the latter to get them all

Version notes

Below is a summary of the patterns and conclusions in this chapter to watch for under JAX 0.12. Each one is verified with assert at the matching place in the text.

No.SectionKey pointWhere
12.2Create keys with jax.random.key(0) (typed keys), no longer jax.random.PRNGKey(0)2.2 Introducing jax.jit → Random keys: old style vs. recommended style
22.2 / 2.3 / 2.7Don't reuse the same key for several random arrays (reuse gives identical sequence prefixes); use jax.random.split to make an independent subkey for each array2.2 Introducing jax.jit → Random keys: old style vs. recommended style
32.3With x64 off by default, float32 → float64 doesn't trigger recompilation (float64 is truncated to float32); only with x64 on does it2.3 How JIT works: tracing and compilation → Summary table: what triggers recompilation (E2)
42.3Besides shape / dtype / PyTree / static arguments, recompilation is also triggered by weak types, positional vs. keyword arguments, omitted default arguments, and creating a new jit object each time; changing a global variable doesn't trigger it. Each is verified with a compile count2.3 How JIT works: tracing and compilation → Summary table: what triggers recompilation (E1–E8)
52.4When a traced value is misused in Python control flow, JAX 0.12 raises TracerBoolConversionError (a subclass of ConcretizationTypeError) or TracerIntegerConversionError (not a subclass); their common parent is JAXTypeError2.4 Python control flow and jit → The names of the error types
62.4fori_loop with static bounds lowers to scan and is reverse-mode differentiable; with traced bounds it lowers to while_loop and isn't2.4 Python control flow and jit → The two "identities" of fori_loop
72.5Making only threshold static in conditional_double still raises TracerBoolConversionError; the right fix is lax.cond / jnp.where, or making the condition depend only on static arguments2.5 Static vs. traced values → Does marking threshold as static fix it?
82.5The 4 calls demonstrating static-argument recompilation need a jnp.where function body (the if version raises an error), and compile 2 times in total2.5 Static vs. traced values → The recompilation trade-off
92.5Static arguments must be hashable: arrays and lists raise errors; a class hashed by identity recompiles for every instance, while a frozen dataclass hits the cache by value2.5 Static vs. traced values → Hard requirements and details for static arguments
102.6The upper bound of fori_loop can be a traced value (it lowers to while_loop); changing n doesn't recompile, it just isn't reverse-mode differentiable2.6 Common jit pitfalls → Problem 2: loops with a variable number of iterations
112.6The output of update_weights_explicit(params, grads, 1000.0) is [-499. 102.]2.6 Common jit pitfalls → Problem 5: global variables
122.6After a retrace, old and new values of a global can coexist; large arrays/weights should also be passed in as arguments, not captured in closures2.6 Common jit pitfalls → Old and new values of a global coexisting
132.7Timing needs jax.block_until_ready(result) to wait for any PyTree; a time_func that only blocks on a single jax.Array measures just the queueing time when given a tuple2.7 Hands-on: applying jit → Setup: timing helper and test data
142.7A Python if whose condition depends on a traced value always raises TracerBoolConversionError under jit; switch to lax.cond2.7 Hands-on: applying jit → Example 2: JIT and Python control flow
152.7If the n in range(n) isn't marked static, it raises TracerIntegerConversionError right away; either use static_argnums or switch to fori_loop with a traced n2.7 Hands-on: applying jit → Example 3: static arguments
162.7The timing bar chart uses a linear vertical axis starting at 0 (on a log axis the ratio of bar heights misleads)2.7 Hands-on: applying jit → Example 1: a "compute-heavy" function
172.3What's missing from a Jaxpr is the concrete values of the inputs; constants hard-coded in the code appear as literals (such as mul a 2.0:f32[])2.3 How JIT works: tracing and compilation
182.3jit's speedup comes from the compiled code (no dispatch overhead, operation fusion); caching makes sure it compiles only once and is reused; you need both2.3 How JIT works: tracing and compilation

Appendix: reference solutions

Write your own answer in the "✍️ Your answer" cell first and check it with the "🧪 Self-check". Once it passes, or if you're truly stuck, come here to compare. Running the solutions below in order overwrites any functions with the same names you defined in the answer cells.

Exercise 1

See "Exercise 1: a piecewise function (lax.switch / jnp.select)" in the main text for the problem.

# Reference solution
brackets = jnp.array([0.0, 10000.0, 40000.0, 100000.0])
rates = jnp.array([0.0, 0.10, 0.20, 0.30])
# Tax already accumulated before the start of each bracket: 0, 0, 3000, 15000
base_tax = jnp.concatenate([jnp.zeros(1), jnp.cumsum(jnp.diff(brackets) * rates[:-1])])

@jax.jit
def tax_switch(income):
    idx = jnp.searchsorted(brackets, income, side="right") - 1    # which bracket, 0..3 (a traced value)
    branches = [
        (lambda inc, k=k: base_tax[k] + (inc - brackets[k]) * rates[k]) for k in range(4)
    ]
    return lax.switch(idx, branches, income)

@jax.jit
def tax_vectorized(income):
    conds = [income < 10000, income < 40000, income < 100000]
    choices = [jnp.zeros_like(income),
               (income - 10000) * 0.10,
               3000 + (income - 40000) * 0.20]
    return jnp.select(conds, choices, default=15000 + (income - 100000) * 0.30)

def tax_numpy(inc):
    if inc < 10000: return 0.0
    if inc < 40000: return (inc - 10000) * 0.10
    if inc < 100000: return 3000 + (inc - 40000) * 0.20
    return 15000 + (inc - 100000) * 0.30

incomes = jnp.array([0.0, 9999.0, 10000.0, 25000.0, 40000.0, 70000.0, 100000.0, 250000.0])
ref = np.array([tax_numpy(float(v)) for v in incomes])
sw = np.array([float(tax_switch(v)) for v in incomes])
vec = np.asarray(tax_vectorized(incomes))
print("switch :", sw)
print("select :", vec)
print("Match:", np.allclose(sw, ref), np.allclose(vec, ref))
assert np.allclose(sw, ref) and np.allclose(vec, ref)
switch : [    0.     0.     0.  1500.  3000.  9000. 15000. 60000.]
select : [    0.     0.     0.  1500.  3000.  9000. 15000. 60000.]
Match: True True

Exercise 2

See "Exercise 2: Newton's method for square roots with lax.while_loop" in the main text for the problem.

# Reference solution
@jax.jit
def newton_sqrt(a, tol):
    x0 = jnp.where(a < 1.0, 1.0, a)

    def cond_fun(state):
        x, x_prev, k = state
        return (jnp.abs(x - x_prev) >= tol) & (k < 100)   # add a maximum step count to prevent an infinite loop

    def body_fun(state):
        x, _, k = state
        return 0.5 * (x + a / x), x, k + 1

    # Set the initial x_prev to x0 + 1 to guarantee at least one iteration
    x, _, k = lax.while_loop(cond_fun, body_fun, (x0, x0 + 1.0, 0))
    return x, k

with count_compiles(echo=False) as log:
    for a in [2.0, 9.0, 0.25, 12345.0]:
        r, k = newton_sqrt(a, 1e-6)
        print(f"a={a:>8}: newton={float(r):.6f}  jnp.sqrt={float(jnp.sqrt(a)):.6f}  {int(k)} iterations")
        assert jnp.allclose(r, jnp.sqrt(a), rtol=1e-6)
    newton_sqrt(2.0, 1e-3)
    newton_sqrt(2.0, 1e-9)
assert log.count("newton_sqrt") == 1
print("✅ 4 values of a and 3 values of tol compiled only once (both a and tol are traced values)")
# Answer: whether the loop continues depends on the value of x (a traced value); a Python while needs a concrete bool and would raise TracerBoolConversionError.
a=     2.0: newton=1.414214  jnp.sqrt=1.414214  5 iterations
a=     9.0: newton=3.000000  jnp.sqrt=3.000000  6 iterations
a=    0.25: newton=0.500000  jnp.sqrt=0.500000  5 iterations
a= 12345.0: newton=111.108055  jnp.sqrt=111.108055  12 iterations
✅ 4 values of a and 3 values of tol compiled only once (both a and tol are traced values)

Exercise 3

See "Exercise 3: recompilation detective" in the main text for the problem.

# Reference solution (run to verify)
@partial(jax.jit, static_argnames=["mode"])
def detective(x, mode="a"):
    return x * 2 if mode == "a" else x + 1

ones3, ones3x5, ones4 = jnp.ones(3), jnp.ones(3) * 5, jnp.ones(4)
ones3_i32, f32_scalar = jnp.ones(3, dtype=jnp.int32), jnp.float32(1.0)
calls = [
    ("① f32[3], default mode",               lambda: detective(ones3),            True),
    ("② same shape, different values",       lambda: detective(ones3x5),          False),
    ("③ f32[4], new shape",                  lambda: detective(ones4),            True),
    ("④ mode='b', static value changed",     lambda: detective(ones3, mode="b"),  True),
    ("⑤ i32[3], new dtype",                  lambda: detective(ones3_i32),        True),
    ("⑥ Python float (weakly typed scalar)", lambda: detective(1.0),              True),
    ("⑦ jnp.float32 strongly typed scalar",  lambda: detective(f32_scalar),       True),
    ("⑧ explicit mode='a'",                  lambda: detective(ones3, mode="a"),  True),
]
predictions = [expect for _, _, expect in calls]   # self-check interface: whether each of the 8 calls triggers a compilation
with count_compiles(echo=False) as log:
    for name, call, expect_compile in calls:
        before = log.count("detective")
        call()
        compiled = log.count("detective") > before
        print(f"{name:38s} → {'compile' if compiled else 'cache hit'}")
        assert compiled == expect_compile, name
assert log.count("detective") == 7
print("✅ Compiled", log.count("detective"), "times in total")
① f32[3], default mode                 → compile
② same shape, different values         → cache hit
③ f32[4], new shape                    → compile
④ mode='b', static value changed       → compile
⑤ i32[3], new dtype                    → compile
⑥ Python float (weakly typed scalar)   → compile
⑦ jnp.float32 strongly typed scalar    → compile
⑧ explicit mode='a'                    → compile
✅ Compiled 7 times in total

Exercise 4

See "Exercise 4: rewrite a training loop with global state as a pure function + fori_loop" in the main text for the problem.

# Reference solution
k1, k2 = jax.random.split(jax.random.key(42))
x_lr = jax.random.uniform(k1, (256,), minval=-1.0, maxval=1.0)
y_lr = 3.0 * x_lr - 1.0 + 0.1 * jax.random.normal(k2, (256,))

@jax.jit
def step(w, b, x, y, lr):
    err = w * x + b - y
    return w - lr * jnp.mean(2 * err * x), b - lr * jnp.mean(2 * err)

def train_python_loop(w, b, x, y, lr, num_steps):
    for _ in range(num_steps):
        w, b = step(w, b, x, y, lr)
    return w, b

@jax.jit
def train_fori(w, b, x, y, lr, num_steps):
    def body(i, wb):
        return step(wb[0], wb[1], x, y, lr)   # calling a jit function inside a jit function: it gets inlined
    return lax.fori_loop(0, num_steps, body, (w, b))

w0, b0 = jnp.float32(0.0), jnp.float32(0.0)
w_fit, b_fit = train_fori(w0, b0, x_lr, y_lr, 0.5, 200)
print(f"fori_loop training result: w={float(w_fit):.3f}, b={float(b_fit):.3f}  (true values 3, -1)")

w_py, b_py = train_python_loop(w0, b0, x_lr, y_lr, 0.5, 200)
print("Both approaches agree:", bool(jnp.allclose(w_fit, w_py, atol=1e-5) & jnp.allclose(b_fit, b_py, atol=1e-5)))
assert jnp.allclose(w_fit, w_py, atol=1e-5) and jnp.allclose(b_fit, b_py, atol=1e-5)
assert abs(float(w_fit) - 3.0) < 0.05 and abs(float(b_fit) + 1.0) < 0.05

t_py, _ = bench(train_python_loop, w0, b0, x_lr, y_lr, 0.5, 200, runs=5)
t_fo, _ = bench(train_fori, w0, b0, x_lr, y_lr, 0.5, 200, runs=5)
print(f"Python loop, 200 calls:   {t_py*1e3:.2f} ms")
print(f"Single fori_loop training: {t_fo*1e3:.2f} ms  (about {t_py/t_fo:.0f}x)")
assert_faster(lambda: bench(train_fori, w0, b0, x_lr, y_lr, 0.5, 200, runs=5)[0],
              lambda: bench(train_python_loop, w0, b0, x_lr, y_lr, 0.5, 200, runs=5)[0])
fori_loop training result: w=2.997, b=-1.006  (true values 3, -1)
Both approaches agree: True
Python loop, 200 calls:   2.87 ms
Single fori_loop training: 0.11 ms  (about 26x)
(8.460859971819446e-05, 0.003052591602317989)