Just-in-time compilation#
jax.jit() is the transformation that makes JAX code fast: it compiles a
Python function into a single optimized computation, fused and specialized for
your hardware. This page is about what compilation buys you, when it happens
(and re-happens), and the levers you control: static arguments, caching,
in-place updates with refs and buffer donation, and asynchronous dispatch.
What compilation buys you#
Without jit, JAX executes operations (think jnp function calls) one at a
time, dispatching each to the device as it’s encountered. That’s flexible in
that it allows arbitrary Python control flow, but it limits performance in two
ways: each dispatch carries Python and runtime overhead, and a compiler never
gets to see the whole computation at once, so it can’t optimize across
operations.
jax.jit addresses both limitations. It traces the function to a jaxpr
(How transformations work: tracing) and hands the whole jaxpr to
XLA, which compiles it
into a single optimized executable, fusing operations, eliminating temporary
arrays, and specializing for your CPU, GPU, or TPU. Consider a scaled
exponential linear unit
(SELU),
an operation commonly used in deep learning:
import jax
import jax.numpy as jnp
def selu(x, alpha=1.67, lambda_=1.05):
return lambda_ * jnp.where(x > 0, x, alpha * jnp.exp(x) - alpha)
x = jnp.arange(1000000)
%timeit selu(x).block_until_ready()
6.89 ms ± 201 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
selu_jit = jax.jit(selu)
# Warm up: the first call traces and compiles.
selu_jit(x).block_until_ready()
%timeit selu_jit(x).block_until_ready()
657 μs ± 1.59 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
Here’s what just happened:
We defined
selu_jitas the compiled version ofselu.We called
selu_jitonce onx. This is where the tracing and XLA compilation happen. (We do this warm-up call outside the timing loop so that we measure execution, not compilation.)We timed the compiled version. Subsequent calls with the same input types never run the Python function
seluat all; they go straight to the compiled executable.
(Note the use of block_until_ready(): because of JAX’s
asynchronous dispatch, covered below, timing
without it would measure only how long it takes to launch the work.)
What the compiled function captures#
A jaxpr records the function as executed on the JAX types it was traced with. Python-level control flow and side effects are resolved during tracing. If a function branches on a static property like rank, the jaxpr knows only the branch that was taken:
def log2_if_rank_2(x):
if x.ndim == 2:
return jnp.log(x) / jnp.log(2.0)
else:
return x
jax.jit(log2_if_rank_2).trace(jnp.array([1, 2, 3])).jaxpr
{ lambda ; a:i32[3]. let in (a,) }
For a rank-1 input, the compiled function is just the identity. That’s fine, because the cached executable is only reused for inputs of the same JAX type; a rank-2 input triggers a fresh trace that takes the other branch.
Impure functions are dangerous under jit for exactly this reason: side
effects happen once, at trace time, and are then absent from the cached
executable, so they might appear to work on the first call and silently vanish
afterwards. JAX often can’t detect the impurity. For debug printing that
survives compilation, use jax.debug.print(); for general side effects at
a performance cost, see External callbacks. To check for leaked tracers from
side effects, use jax.check_tracer_leaks().
Why can’t we just jit everything?#
Recall from Transformations: grad and vmap that traced code can’t always
specialize on data values — and jit is the extreme case: its tracers carry
no values at all, only JAX types.
Value-dependent Python control flow therefore fails:
# Condition on the value of x.
def f(x):
if x > 0:
return x
else:
return 2 * x
jax.jit(f)(10) # Raises an error
TracerBoolConversionError: Attempted boolean conversion of traced array with shape bool[].
The error occurred while tracing the function f at /tmp/ipykernel_1614/3856269748.py:3 for jit. This concrete value was not available in Python because it depends on the value of the argument x.
See https://docs.jax.dev/en/latest/errors.html#jax.errors.TracerBoolConversionError
# While loop conditioned on x and n.
def g(x, n):
i = 0
while i < n:
i += 1
return x + i
jax.jit(g)(10, 20) # Raises an error
TracerBoolConversionError: Attempted boolean conversion of traced array with shape bool[].
The error occurred while tracing the function g at /tmp/ipykernel_1614/722961019.py:3 for jit. This concrete value was not available in Python because it depends on the value of the argument n.
See https://docs.jax.dev/en/latest/errors.html#jax.errors.TracerBoolConversionError
The problem in both cases is that we tried to condition the program’s
trace-time flow on runtime values. Traced values inside jit, like x and n
here, can only affect control flow through their JAX types, not their values.
For much more on this, including the structured control-flow operations
jax.lax.cond and jax.lax.scan that express data-dependent control flow
inside compiled code, see Control flow and logical operators with jit.
One pragmatic option is to jit only part of a function. If the expensive work is inside the loop body, compile just the body and let Python drive the loop:
@jax.jit
def loop_body(prev_i):
return prev_i + 1
def g_inner_jitted(x, n):
i = 0
while i < n:
i = loop_body(i)
return x + i
g_inner_jitted(10, 20)
Array(30, dtype=int32, weak_type=True)
This gives up cross-operation optimization for the loop itself, but keeps each iteration compiled. (If you do this, read JIT and caching below to avoid accidentally recompiling every iteration.)
Marking arguments as static#
To branch on the value of an argument, one option is to treat that argument as
static: a regular Python value fixed at trace time, rather than a traced
array. The static_argnums and static_argnames parameters to jax.jit do
this:
f_jit_correct = jax.jit(f, static_argnums=0)
print(f_jit_correct(10))
10
g_jit_correct = jax.jit(g, static_argnames=['n'])
print(g_jit_correct(10, 20))
30
To specify static arguments when using jit as a decorator, use the
decorator-factory pattern:
@jax.jit(static_argnames=['n'])
def g_jit_decorated(x, n):
i = 0
while i < n:
i += 1
return x + i
print(g_jit_decorated(10, 20))
30
The compiled executable is specialized to the value of each static argument, so JAX recompiles whenever a static argument takes a value it hasn’t seen. Marking an argument static is only a good strategy when the function will see a limited set of values for it. Static argument values must be hashable, since they become part of the cache key.
JIT and caching#
The first call to a jitted function performs tracing and compilation. Subsequent applications of the function to the same input types will instead use cached compilations. Understanding how the compilation cache works is important: several common patterns silently defeat it.
Suppose we define f = jax.jit(g). When f is first invoked, it gets
compiled and the resulting executable is cached. The cache key includes:
the JAX types of the arguments — new shapes or dtypes trigger a fresh trace and compile;
the values of any static arguments; and
the object identity of the function
gitself.
That last point has a practical consequence: avoid calling jax.jit on
temporary functions defined inside loops or other inner scopes. The cache
relies on the object identity of the function, so freshly-created lambdas and
partial objects — even ones wrapping the same underlying code — look like new
functions every time, and recompile every time:
from functools import partial
def unjitted_loop_body(prev_i):
return prev_i + 1
def g_inner_jitted_partial(x, n):
i = 0
while i < n:
# Don't do this! A fresh `partial` has a different hash each time,
# so this recompiles on every iteration.
i = jax.jit(partial(unjitted_loop_body))(i)
return x + i
def g_inner_jitted_lambda(x, n):
i = 0
while i < n:
# Don't do this either! Same problem: a fresh lambda every iteration.
i = jax.jit(lambda x: unjitted_loop_body(x))(i)
return x + i
def g_inner_jitted_normal(x, n):
i = 0
while i < n:
# This is OK: `jax.jit` sees the same function object each time,
# and finds the cached executable.
i = jax.jit(unjitted_loop_body)(i)
return x + i
print("jit called in a loop with partials:")
%timeit g_inner_jitted_partial(10, 20).block_until_ready()
print("jit called in a loop with lambdas:")
%timeit g_inner_jitted_lambda(10, 20).block_until_ready()
print("jit called in a loop with caching:")
%timeit g_inner_jitted_normal(10, 20).block_until_ready()
jit called in a loop with partials:
396 ms ± 6.17 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
jit called in a loop with lambdas:
410 ms ± 10.6 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
jit called in a loop with caching:
3.07 ms ± 17 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
When a program recompiles more than you expect, try setting the
jax_explain_cache_misses config option to make JAX print an explanation for
every tracing cache miss:
jax.config.update("jax_explain_cache_misses", True)
@jax.jit
def double(x):
return 2 * x
_ = double(jnp.arange(3)) # cache miss: never seen this function before
_ = double(jnp.arange(3)) # cache hit: silent
_ = double(jnp.arange(3.0)) # cache miss: x's JAX type changed
TRACING CACHE MISS at /tmp/ipykernel_1614/401824424.py:7:4 (<module>):
never seen function:
double id=126450749752800 defined at /tmp/ipykernel_1614/401824424.py:3
TRACING CACHE MISS at /tmp/ipykernel_1614/401824424.py:9:4 (<module>):
for double defined at /tmp/ipykernel_1614/401824424.py:3
all previously seen cache keys differ. For the closest previous key:
different input types:
* at x, now f32[3] and before i32[3]
jax.config.update("jax_explain_cache_misses", False)
For a lighter-weight signal, jax_log_compiles logs one line per compilation,
without the diagnosis. When tracing or compilation time itself becomes the
problem, Debugging slow JAX tracing and XLA compilation is a field guide to diagnosing it.
Another classic source of endless recompilation is data whose shape
genuinely varies from call to call: ragged sequence lengths, a partial final
batch at the end of an epoch. Since the cache is keyed on JAX types, every
new shape is a fresh compile. The standard fix is to pad variable-shaped
data up to a small, fixed set of bucket shapes, trading a little wasted
compute (and a masking jnp.where or two) for a bounded number of
compilations.
The persistent compilation cache (Persistent compilation cache) keeps
compiled executables across process restarts; in the other direction,
jax.clear_caches() drops every cached trace and executable.
Using jit with methods#
Most jax.jit examples decorate stand-alone functions, but sooner or later
you’ll want to jit a method:
class CustomClass:
def __init__(self, x: jax.Array, mul: bool):
self.x = x
self.mul = mul
@jax.jit # <---- How to do this correctly?
def calc(self, y):
if self.mul:
return self.x * y
return y
c = CustomClass(2, True)
c.calc(3)
TypeError: Error interpreting argument to <function CustomClass.calc at 0x73019c055080> as an abstract array. The problematic value is of type <class '__main__.CustomClass'> and was passed to the function at path self.
This typically means that a jit-wrapped function was called with a non-array argument, and this argument was not marked as static using the static_argnums or static_argnames parameters of jax.jit.
The problem is that the first argument to calc is self, of type
CustomClass, and JAX doesn’t know how to handle that type. There are three
basic strategies, in increasing order of power.
Strategy 1: a jitted helper function#
The most straightforward approach: keep jit on a stand-alone helper that
takes only JAX-compatible arguments, and have the method call it:
class CustomClass:
def __init__(self, x: jax.Array, mul: bool):
self.x = x
self.mul = mul
def calc(self, y):
return _calc(self.mul, self.x, y)
@jax.jit(static_argnums=0)
def _calc(mul, x, y):
if mul:
return x * y
return y
c = CustomClass(2, True)
print(c.calc(3))
6
This is simple and explicit, and JAX never has to learn about CustomClass
at all. The cost is that the method’s logic lives outside the class.
Strategy 2: marking self as static#
Another common pattern is to mark the self argument as static. Done
naively, it misbehaves:
class CustomClass:
def __init__(self, x: jax.Array, mul: bool):
self.x = x
self.mul = mul
# WARNING: broken, as we'll see below. Don't copy & paste!
@jax.jit(static_argnums=0)
def calc(self, y):
if self.mul:
return self.x * y
return y
c = CustomClass(2, True)
print(c.calc(3))
6
This runs, but it has a flaw. Static arguments become cache keys, so
JAX relies on their hash and equality. The default __hash__ for a custom
object is its object ID, which doesn’t change when the object mutates — so
mutating the object silently serves stale compiled code:
c.mul = False
print(c.calc(3)) # Should print 3... but the cache doesn't know mul changed
6
You can partially address this by defining __hash__ and __eq__ in terms
of the object’s contents, so that differing objects actually miss the cache:
class CustomClass:
def __init__(self, x: jax.Array, mul: bool):
self.x = x
self.mul = mul
@jax.jit(static_argnums=0)
def calc(self, y):
if self.mul:
return self.x * y
return y
def __hash__(self):
return hash((self.x, self.mul))
def __eq__(self, other):
return (isinstance(other, CustomClass) and
(self.x, self.mul) == (other.x, other.mul))
This works with jit and other transformations so long as you never
mutate the object: mutating a value that’s in use as a hash key leads to
subtle problems — which is exactly why Python’s mutable containers (dict,
list) don’t define __hash__. If your class mutates its attributes, it
isn’t really static, and there’s a better option.
Strategy 3: registering the class as a pytree#
The most flexible approach is to register the type as a custom pytree node (Custom pytree nodes), saying explicitly which parts are dynamic data and which are static metadata:
from jax import tree_util
class CustomClass:
def __init__(self, x: jax.Array, mul: bool):
self.x = x
self.mul = mul
@jax.jit
def calc(self, y):
if self.mul:
return self.x * y
return y
def _tree_flatten(self):
children = (self.x,) # arrays / dynamic values
aux_data = {'mul': self.mul} # static values
return (children, aux_data)
@classmethod
def _tree_unflatten(cls, aux_data, children):
return cls(*children, **aux_data)
tree_util.register_pytree_node(CustomClass,
CustomClass._tree_flatten,
CustomClass._tree_unflatten)
Now self flows through jit like any other pytree argument: the arrays in
children are traced, the aux_data is static, plain @jax.jit works with
no static_argnums, and the problems above solve themselves:
c = CustomClass(2, True)
print(c.calc(3))
c.mul = False # mutation is detected
print(c.calc(3))
c = CustomClass(jnp.array(2), True) # non-hashable x is supported
print(c.calc(3))
6
3
6
If your class is a dataclass, jax.tree_util.register_dataclass() gets you
the same result with much less ceremony; see Custom pytree nodes.
In-place updates with refs#
A compiled function’s inputs and outputs are immutable arrays, so at the jit
boundary every output gets a fresh buffer, even when you’re conceptually just
updating an input, as in a training step that maps parameters to new
parameters. Inside the compiled computation XLA reuses memory aggressively, but
it can’t reuse an input’s buffer for an output unless you tell it something
extra. JAX has two ways to say it: pass the data as a ref, or donate
the input buffer. Refs express the intent directly, so we cover them first;
donation, next, is the longer-standing mechanism you’ll meet throughout
existing JAX code.
A ref (Refs: mutable arrays) passed into a jitted function represents mutable memory: writes through it happen in place, and no fresh buffer is allocated. We can watch the buffer address stay fixed across the call:
@jax.jit
def sin_inplace(x_ref):
x_ref[...] = jnp.sin(x_ref[...])
x_ref = jax.new_ref(jnp.arange(3.0))
print(x_ref.unsafe_buffer_pointer(), x_ref)
sin_inplace(x_ref)
print(x_ref.unsafe_buffer_pointer(), x_ref)
104040855109504 Ref([0., 1., 2.], dtype=float32)
104040855109504 Ref([0. , 0.84147096, 0.9092974 ], dtype=float32)
sin_inplace updates the buffer backing x_ref, so its address stays the
same. In general, under a jit you should expect refs to point to fixed
buffer addresses, and indexed updates to be performed in place.
Note
Temporary caveat: dispatch from Python to impure jit-compiled functions
that take ref inputs is currently slower than dispatch to pure
jit-compiled functions, since it takes a less optimized path.
Working with refs does mean writing the function differently: mutation through indexed writes, rather than functional updates. For code already written functionally (which is most existing JAX code, and the convention across the ecosystem), buffer donation can achieve the in-place effect without changing the function.
Buffer donation#
When JAX executes a computation, it uses device buffers for all inputs and outputs. If you know an input won’t be needed after the computation, and it matches the shape and element type of an output, you can donate the input buffer to hold that output, reducing peak memory by the size of the donated buffer. The canonical case is a functional update, where the new state replaces the old:
params, state = jax.jit(update_fn, donate_argnums=(0, 1))(params, state)
Think of donation as a memory-efficient functional update on immutable
arrays. Within a compiled computation, XLA reuses buffers automatically;
at the jit boundary, though, JAX must assume you might still hold a
reference to the input unless you promise otherwise with donate_argnums
(or donate_argnames):
def add(x, y):
return x + y
x = jax.device_put(np.ones((2, 3)))
y = jax.device_put(np.ones((2, 3)))
# Execute `add` with donation of the buffer for `y`. The result has
# the same shape and type as `y`, so it will share its buffer.
z = jax.jit(add, donate_argnums=(1,))(x, y)
Donation comes with rules and sharp edges:
Donated means gone. After the call, the donated input is invalid, and using it is an error:
z = jax.jit(add, donate_argnums=(1,))(x, y) w = y + 1 # Reuses `y`, whose buffer was donated above # >> RuntimeError: Invalid argument: CopyToHostAsync() called on invalid buffer
Keyword arguments aren’t donated by
donate_argnums. This code donates nothing:params, state = jax.jit(update_fn, donate_argnums=(0, 1))(params=params, state=state)
Pytree arguments donate all their buffers. Donating an argument that’s a pytree donates every array in it:
def add_ones(xs: list[jax.Array]): return [x + 1 for x in xs] xs = [jax.device_put(np.ones((2, 3))), jax.device_put(np.ones((3, 4)))] # Donates the buffers of both arrays in `xs`. z = jax.jit(add_ones, donate_argnums=0)(xs)
Unusable donations are dropped, with a warning. If there are more donated buffers than outputs to hold them, or no output matches a donated buffer’s shape and element type, you’ll see
UserWarning: Some donated buffers were not usable.
As the sharp edges above suggest, donation is a promise layered on top of immutable semantics, and it’s possible to get subtly wrong. The promise is checked only partially, and only at runtime. When you have the freedom to restructure, refs (In-place updates with refs above) express the same intent with less to misuse. Donation remains the workhorse for functionally-written code, which is to say most JAX code today.
Asynchronous dispatch#
One more piece of the performance model, relevant with and without jit:
JAX does not wait for a computation to finish before returning control to
Python. Consider:
import numpy as np
from jax import random
x = random.uniform(random.key(0), (1000, 1000))
jnp.dot(x, x) + 3.0
Array(shape=(1000, 1000), dtype=float32)
When jnp.dot(x, x) executes, JAX returns immediately with a
jax.Array that is really a future: a value that will be produced
on the device but isn’t necessarily available yet. We can inspect its JAX
type, and pass it into further JAX computations, without waiting; only when
we actually look at the value from the host (printing it, converting it to
a NumPy array) does Python block until the computation is done. (The cell
above shows values only because rendering the result forced the wait.)
This is why asynchronous dispatch is valuable: Python “runs ahead” of the device, enqueueing work and staying off the critical path.
Asynchronous operations make timing a bit trickier. Time an operation naively and you measure only the dispatch:
%time jnp.dot(x, x)
CPU times: user 3.61 ms, sys: 0 ns, total: 3.61 ms
Wall time: 1.86 ms
Array(shape=(1000, 1000), dtype=float32)
A fraction of a millisecond would be a suspiciously good time for a
1000Ă—1000 matrix multiplication! To measure the actual computation, force
completion with block_until_ready():
%time jnp.dot(x, x).block_until_ready()
CPU times: user 56.9 ms, sys: 691 ÎĽs, total: 57.6 ms
Wall time: 29.4 ms
Array(shape=(1000, 1000), dtype=float32)
Blocking without transferring the result to the host (as block_until_ready
does) is usually faster than forcing a transfer with np.asarray(...), and is
the right tool for microbenchmarks. Benchmarking JAX code has a few more
pitfalls of this flavor — Benchmarking and profiling starts there.
Where to next#
Ahead-of-time lowering and compilation — running
jit’s stages (trace, lower, compile) ahead of time, for inspection or control.Benchmarking and profiling — measure before you optimize: benchmarking pitfalls and the JAX profiler.
Control flow and logical operators with jit — expressing conditionals and loops that live inside compiled code.
Debugging runtime values — printing and inspecting values under
jit.Debugging slow JAX tracing and XLA compilation — when tracing or compilation itself is the bottleneck.
Matmul precision — controlling the speed/accuracy tradeoff inside matmuls.
Controlling XLA from JAX — XLA compiler flags and per-operation metadata.