Jaxprs, lowering and XLA

JAX · Expert · 14 min

Reading the intermediate program explains recompiles, unexpected ops and performance at the compiler level.

Every transformation works on an intermediate language called jaxpr. jax.make_jaxpr(f)(x) shows it: typed input variables, a list of primitive equations (mul, sin, dot_general, pjit, scan...) and outputs. Python control flow is gone; only primitives on abstract values remain. Reading jaxprs is the quickest way to see what tracing captured, e.g. whether a loop was unrolled or a constant was baked in.

Transformations are interpreters over jaxprs: grad linearizes and transposes them, vmap rewrites each primitive with a batching rule, jit lowers them.

Ahead-of-time stages make the pipeline explicit:

  • jax.jit(f).trace(x) gives the traced stage, .lower(x) lowers to StableHLO (MLIR); lowered.as_text() prints it.
  • .compile() runs XLA and returns a Compiled object; compiled.as_text() shows the optimized HLO after fusion, and compiled.cost_analysis() estimates flops and bytes.
  • jax.export serializes a lowered function so it can be loaded and run later or elsewhere.

Every primitive has rules for evaluation, abstract evaluation (shape inference), lowering, JVP, transpose and batching. That is why adding new low-level ops is done through jax.extend or custom calls (FFI), and why the custom-kernel language Pallas plugs in as a primitive too.

Captured constants matter: a large NumPy array closed over by a jitted function is embedded in the program, inflating compile time and memory. Pass it as an argument instead.

Example

import jax
import jax.numpy as jnp

def f(x, y):
    return jnp.sin(x) * y + 1.0

print(jax.make_jaxpr(f)(1.0, 2.0))
# { lambda ; a:f32[] b:f32[]. let
#     c:f32[] = sin a
#     d:f32[] = mul c b
#     e:f32[] = add d 1.0:f32[]
#   in (e,) }

lowered = jax.jit(f).lower(1.0, 2.0)
print("stablehlo.sine" in lowered.as_text())   # True: StableHLO module
compiled = lowered.compile()
print(compiled(1.0, 2.0))                       # 2.682942
print(jax.make_jaxpr(jax.grad(f))(1.0, 2.0).eqns[0].primitive)  # sin

Remember

  • A jaxpr is the primitive-level program every transformation operates on
  • .lower() gives StableHLO; .compile() runs XLA and exposes optimized HLO and cost analysis
  • Closed-over large constants get baked into the program; pass them as arguments

Watch out for

  • Closing over a big NumPy array in a jitted function, which embeds it as a constant in every compile.

Check yourself

  1. What does jax.make_jaxpr(f)(x) produce?

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

Official docs: docs.jax.dev/en/latest/601/jaxpr.html