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
- Understand why running JAX code line by line (eagerly) isn't fast enough, and what compilation buys you;
- Know both ways to use
jax.jit(as a decorator and as a higher-order function); - Understand JIT's internal pipeline: tracing → Jaxpr → lowering → XLA compilation → caching;
- 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; - Tell static values apart from traced values, use
static_argnums/static_argnames, and know what triggers recompilation; - Recognize common
jitpitfalls: side effects, global variables, dynamic shapes, buffer donation, and more; - Benchmark correctly: warm up first, then time with
block_until_ready().
Sections
| Section | Original course link |
|---|---|
| 2.1 Speeding up: why compile? | why-compile-jax |
2.2 Introducing jax.jit | introducing-jax-jit |
| 2.3 How JIT works: tracing and compilation | how-jit-works |
2.4 Python control flow and jit | jit-control-flow |
| 2.5 Static vs. traced values | jit-static-vs-traced |
2.6 Common jit pitfalls | jit-common-pitfalls |
2.7 Hands-on: applying jit | hands-on-applying-jit |
| Extra exercises · Quiz · Summary · Cheat sheet | quiz |
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)
2.1 Speeding up: why compile?¶
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:
- Interpreter and dispatch overhead. The Python interpreter runs code one line at a time, and every
jnpoperation goes through its own Python → JAX → backend dispatch. The smaller the operation, the bigger the share of this fixed overhead. - 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:
- 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. - Print the compiled HLO, and you can see directly that XLA fused
a*x+bandtanhinto 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])
# 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)
2.2 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:
- As a decorator: write
@jax.jitbefore the function definition. This is the most common and the most readable; - 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")
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.)
Random keys: old style vs. recommended style¶
⚠️ Note: Older code often creates keys with
jax.random.PRNGKey(0), and uses the same key for all three arraysx_data,weight_data, andbias_data.PRNGKeystill works in JAX 0.12, but it's a legacy API that returns a rawuint32[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 usesjax.random.key+jax.random.split. For the full comparison ofkeyandPRNGKey, 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)
# ✅ 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")
🔍 Extra: Ways to write jit, and a very common trap¶
🟢 Must-read
- When you need to pass arguments to
jit(such asstatic_argnames), the decorator is usually written withfunctools.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 writejax.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
2.3 How JIT works: tracing and compilation¶
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
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")
When does it retrace/recompile?
There are four common cases:
- The input shape changes, for example
(100, 100)→(150, 150); - The input dtype changes, for example
float32→int32.float32→float64only counts as a dtype change once 64-bit mode is on (jax_enable_x64=True); - The input PyTree structure changes, for example adding a key to an input dict;
- When you use
static_argnums/static_argnames, the value of a static argument changes.
⚠️ Note: Don't use "
float32tofloat64" as the example of a dtype change. With the default configuration (JAX 0.12), 64-bit is off, and asking forfloat64gets truncated tofloat32(with a warning), so it does not recompile; it only does once x64 is on. See theassertfor 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 onshape / ndim / dtypeare 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
elsebranch 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 timesdot_generalappears 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
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.
| # | Factor | Example | Recompiles? | Key point |
|---|---|---|---|---|
| E1 | Shape | f(ones(3)) → f(ones(4)) | ✅ Yes | Changing only values doesn't; changing the shape always does |
| E2 | dtype | float32 → int32; float32 → float64 | ✅ Yes / ⚠️ Not by default | With x64 off, float64 is truncated to float32 and nothing recompiles; only with x64 on does it |
| E3 | Weak type | f(1.0) vs f(jnp.float32(1.0)) vs f(1) | ✅ Yes | Python scalars, jnp.array(5.0), and jnp.full(3, 7.0) are all weakly typed |
| E4 | Static argument value | n=2 → n=3 → n=2 | ✅ Once per new value | Values already seen hit the cache |
| E5 | PyTree structure | {"a": x} → {"a": x, "b": x}; (x,) → [x] | ✅ Yes | Keys, nesting, container type, and length are all part of the structure |
| E6 | Positional vs. keyword arguments | g(x, 2.0) vs g(x, t=2.0); omitting a default argument | ✅ Yes | An omitted default value is baked into the compiled result as a constant |
| E7 | A new jit object every time | jax.jit(lambda ...) inside a loop | ✅ Every time | Calling jax.jit(f) several times on the same function object shares the cache |
| E8 | Global variables | Changing a global the function reads | ❌ No | No 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)
# 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")
# 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")
# 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")
# 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")
# 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")
# 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")
# 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")
# 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)")
🔍 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)
2.4 Python control flow and jit¶
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)
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
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))
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:
| Primitive | Signature | When to use |
|---|---|---|
lax.fori_loop | fori_loop(lower, upper, body_fun, init_val), body_fun(i, val) -> val | Counting loops over a known range |
lax.while_loop | while_loop(cond_fun, body_fun, init_val) | The stop condition depends on runtime values |
lax.scan | scan(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
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.switchfor 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
exceptin 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:
| Situation | Actual exception | Direct parent class |
|---|---|---|
if tracer: / bool(tracer) | TracerBoolConversionError | ConcretizationTypeError |
range(tracer) / Python indexing with a traced value | TracerIntegerConversionError | JAXTypeError |
float(tracer) | ConcretizationTypeError | JAXTypeError |
np.asarray(tracer) | TracerArrayConversionError | JAXTypeError |
⚠️ Note: Don't assume they all inherit from
ConcretizationTypeError. OnlyTracerBoolConversionErroris its subclass;TracerIntegerConversionErrorandTracerArrayConversionErrorinherit directly fromJAXTypeError(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")
🔍 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)
🔍 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-rangeindexis clamped into[0, len-1]with no error;- The state (carry) of a
while_loopmust keep the same structure/shape/dtype before and after the loop; scanreturns(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)
🔍 Extra: Prefer jnp.where for element-wise conditions, but watch out for NaN in gradients¶
🟢 Must-read
- The predicate of
lax.condmust be a scalar. For "element-wise" conditional selection on arrays, usejnp.where(cond, a, b); jnp.wherecomputes both sides and then picks. If the masked-out side has an infinite derivative at that position (likesqrtat 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, alax.condwith a batched predicate is converted into aselectthat computes both sides, so don't count oncondsaving 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
🔍 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")
2.5 Static vs. traced values¶
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)
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,)orstatic_argnames=('threshold',)makes it run and print10.0and-2.0respectively. In fact (JAX 0.12), both versions raiseTracerBoolConversionError(a subclass ofConcretizationTypeError).
The right fix depends on the situation:
- The condition depends on a traced value (this example) → use
lax.condorjnp.where;thresholdcan be static or not; - The condition depends only on static arguments (such as
if mode == "train"orif double:) → this is wherestatic_argnums/static_argnamesactually 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)
# ✅ 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 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))
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
ifversion above here, since it raises an error. Below, the function body is swapped for a compilablejnp.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")
When to use static arguments¶
- Python control flow depends on the argument's value (such as
if mode == "train"orfor i in range(num_layers)); - It determines the function's behavior, such as the number of layers, the type of activation function, or a configuration string;
- 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
- Static arguments must be hashable (because they're used as cache keys).
list,dict, and JAX/NumPy arrays won't work; usetuple,int,str,frozenset, hashable custom objects, and so on. - 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". - 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). - Shapes are static information in themselves:
x.shape,x.ndim, andx.dtypecan be used directly in a Pythonif/rangewithout 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))))
@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
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]))
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_loopdoesn't have to be a compile-time constant; a traced value works too. Theasserts below verify that the result is correct and that several different values ofncompile 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")
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)))
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
printprints 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 useprint, breakpoints, and even a Pythonif. 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)))
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")
Ways to deal with it:
- Pad to a uniform shape;
- Use
static_argnums/static_argnamesfor arguments that determine shapes (only suitable when they take few values); - Process in batches so every batch has the same shape;
- 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)")
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)
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.]")
🔍 Extra: Old and new values of a global coexisting, and passing large arrays as arguments too¶
🟢 Must-read
- 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.
- 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)
🔍 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)
2.7 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))
⚠️ Note: The
time_funcabove only callsblock_until_ready()when the return value is a singlejax.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 isjax.block_until_ready(result), which handles any PyTree (that's how thebenchat 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")
# ✅ 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
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
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()
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
ifversion "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)
# ✅ 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
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:
nis a traced value, andrange(n)immediately raisesTracerIntegerConversionError; static_argnums=(1,): each differentncompiles once (once each for n=2 and 3; n=2 again hits the cache);- If you want no recompilation at all when
nchanges: rewrite the loop aslax.fori_loop(0, n, ...)and passnas 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
TracerIntegerConversionErrorright 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)
# ✅ 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
# ✅ 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")
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 bracket | Rate |
|---|---|
| [0, 10000) | 0% |
| [10000, 40000) | 10% |
| [40000, 100000) | 20% |
| ≥ 100000 | 30% |
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 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 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: 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.0is weakly typed andjnp.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")
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.jitorjax.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/whilethat depends on traced values raisesTracerBoolConversionError/TracerIntegerConversionError(their common parent isjax.errors.JAXTypeError; only the former is a subclass ofConcretizationTypeError). Uselax.cond/lax.switch/jnp.wherefor conditions andlax.fori_loop/while_loop/scanfor 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_argnumscan 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_loopand differentiation: static bounds →scan, reverse-mode differentiable; traced bounds →while_loop, not reverse-mode differentiable.
Cheat sheet¶
| API | What it does |
|---|---|
jax.jit(f) / @jax.jit | Just-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).jaxpr | AOT: 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_type | Which device the array is on / whether it's weakly typed |
jax.errors.ConcretizationTypeError / jax.errors.JAXTypeError | Errors 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. | Section | Key point | Where |
|---|---|---|---|
| 1 | 2.2 | Create 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 |
| 2 | 2.2 / 2.3 / 2.7 | Don'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 array | 2.2 Introducing jax.jit → Random keys: old style vs. recommended style |
| 3 | 2.3 | With x64 off by default, float32 → float64 doesn't trigger recompilation (float64 is truncated to float32); only with x64 on does it | 2.3 How JIT works: tracing and compilation → Summary table: what triggers recompilation (E2) |
| 4 | 2.3 | Besides 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 count | 2.3 How JIT works: tracing and compilation → Summary table: what triggers recompilation (E1–E8) |
| 5 | 2.4 | When 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 JAXTypeError | 2.4 Python control flow and jit → The names of the error types |
| 6 | 2.4 | fori_loop with static bounds lowers to scan and is reverse-mode differentiable; with traced bounds it lowers to while_loop and isn't | 2.4 Python control flow and jit → The two "identities" of fori_loop |
| 7 | 2.5 | Making 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 arguments | 2.5 Static vs. traced values → Does marking threshold as static fix it? |
| 8 | 2.5 | The 4 calls demonstrating static-argument recompilation need a jnp.where function body (the if version raises an error), and compile 2 times in total | 2.5 Static vs. traced values → The recompilation trade-off |
| 9 | 2.5 | Static 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 value | 2.5 Static vs. traced values → Hard requirements and details for static arguments |
| 10 | 2.6 | The 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 differentiable | 2.6 Common jit pitfalls → Problem 2: loops with a variable number of iterations |
| 11 | 2.6 | The output of update_weights_explicit(params, grads, 1000.0) is [-499. 102.] | 2.6 Common jit pitfalls → Problem 5: global variables |
| 12 | 2.6 | After a retrace, old and new values of a global can coexist; large arrays/weights should also be passed in as arguments, not captured in closures | 2.6 Common jit pitfalls → Old and new values of a global coexisting |
| 13 | 2.7 | Timing 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 tuple | 2.7 Hands-on: applying jit → Setup: timing helper and test data |
| 14 | 2.7 | A Python if whose condition depends on a traced value always raises TracerBoolConversionError under jit; switch to lax.cond | 2.7 Hands-on: applying jit → Example 2: JIT and Python control flow |
| 15 | 2.7 | If 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 n | 2.7 Hands-on: applying jit → Example 3: static arguments |
| 16 | 2.7 | The 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 |
| 17 | 2.3 | What'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 |
| 18 | 2.3 | jit's speedup comes from the compiled code (no dispatch overhead, operation fusion); caching makes sure it compiles only once and is reused; you need both | 2.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)
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.
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")
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])