Performance: dispatch, donation, caching

JAX · Advanced · 14 min

Correct JAX code can still be slow; these habits often give large speedups for free.

JAX's performance model rests on a few facts.

Asynchronous dispatch. Python enqueues work and returns futures. Anything that needs a value on the host, such as float(x), print(x), np.asarray(x) or a Python if on a result, blocks until the device catches up. Keep metrics on device and fetch them every N steps.

Buffer donation. jax.jit(step, donate_argnums=(0,)) tells XLA it may reuse the input buffers for the outputs. For a train step that maps old params to new params this roughly halves peak parameter memory. Donated inputs must not be used again afterwards.

Compilation cost. Every new shape compiles. Reduce variety (pad to buckets), and enable the persistent compilation cache so restarts reuse executables:

jax.config.update("jax_compilation_cache_dir", "/tmp/jax_cache")

Precision. On GPUs and TPUs matrix multiplies may use reduced-precision passes by default; jax.default_matmul_precision("highest") or the precision= argument trade speed for accuracy. Use bfloat16 activations for speed where accuracy allows.

Profiling. jax.profiler.trace("/tmp/trace") records a trace you can open in Perfetto or TensorBoard to see kernels, gaps and host-device transfers.

Also measure correctly: warm up to exclude compilation, then time with .block_until_ready().

Example

import time
import jax
import jax.numpy as jnp

def step(params, x):
    return jax.tree.map(lambda p: p - 0.01 * jnp.tanh(p * x.mean()), params)

fast_step = jax.jit(step, donate_argnums=(0,))  # reuse params' buffers

params = {"w": jnp.ones((2048, 2048))}
x = jnp.ones(16)
params = fast_step(params, x)                   # compile once
jax.block_until_ready(params)

t = time.perf_counter()
for _ in range(20):
    params = fast_step(params, x)               # old params are donated
jax.block_until_ready(params)                   # wait before stopping timer
print(f"{(time.perf_counter() - t) / 20 * 1e3:.2f} ms/step")

lowered = jax.jit(step).lower(params, x)
print(type(lowered.compile()).__name__)         # Compiled: AOT compile, no run

Remember

  • Host reads like float(x) force synchronization; keep values on device
  • donate_argnums lets XLA reuse input buffers for outputs
  • Avoid shape variety and enable the persistent compilation cache

Watch out for

  • Reading a donated array after the call, which raises an error because its buffer was deleted.

Check yourself

  1. Why does calling float(loss) every training step often slow a JAX loop down?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.jax.dev/en/latest/gpu_performance_tips.html