What JAX Traces, and What It Refuses JAX, a numerical computing library, is fundamentally a tracing machine that converts pure Python functions into a typed intermediate representation called a jaxpr, refusing to execute code with control flow that cannot be traced, unlike PyTorch's torch.compile which falls back to a graph break. The jaxpr, which erases concrete data and preserves only types and shapes, is the core object that all JAX transformations—jit, grad, vmap, shard_map—operate on, with the library exposing 285 primitive operations as of version 0.11.0. What JAX Traces, and What It Refuses Coming from PyTorch, the first thing JAX does is offend you. You write an honest little function with an if in it, wrap it in jax.jit , call it, and instead of running it throws TracerBoolConversionError . No graceful fallback, no partial compile — a hard stop on a line of Python that would have run fine a second ago. torch.compile would have shrugged, taken a graph break, and moved on. JAX refuses. That refusal is the most important thing to understand about JAX, and once it clicks the whole system falls into place. JAX is not a framework with a compiler attached. It is a tracing machine . Every headline feature — jit , grad , vmap , shard map — is the same move applied over and over: run a pure Python function once with stand-in values, record the sequence of primitive operations into a small typed IR called a jaxpr , and then transform or compile that IR. Understanding JAX means understanding the trace boundary — what the machine can see when your function runs, and what it is structurally blind to. Everything below runs on JAX 0.11.0, CPU backend, and every jaxpr and every number is copied from a session rather than written from memory. 1 Line numbers refer to that release. This is the front end: how Python becomes a jaxpr, why control flow is special, why every transformation is a rewrite of the same object, and — the part that has changed most in the last year — what the type of a traced value has quietly grown to include. The jaxpr is the whole point Start with the object everything revolves around. jax.make jaxpr traces a function and hands you the IR without compiling it: 1 2 3 4 5 6 7 python import jax.numpy as jnp from jax import make jaxpr def f x : return jnp.maximum x 2.0 + 1.0, 0.0 relu 2x + 1 print make jaxpr f jnp.float32 3.0 1 2 3 4 5 js { lambda ; a:f32 . let b:f32 = mul a 2.0:f32 c:f32 = add b 1.0:f32 d:f32 = max c 0.0:f32 in d, } Read it like a typed lambda in A-normal form. 2 After the semicolon come the input binders — here one scalar, a:f32 . Then a let block of primitive applications, each binding a typed intermediate, and finally the returned tuple. Every value carries its type as a dtype and a shape: f32 is a float32 scalar, f32 4,8 a matrix. Notice what is not there. The number 3.0 you passed in is gone. The jaxpr is not a trace of one execution; it is the abstract computation, parameterized over the types of its inputs, with all concrete data erased.Give it real arrays and the primitives get more interesting: 1 2 3 4 5 python def layer x, W, b : return jnp.maximum x @ W + b, 0.0 x = jnp.ones 4, 8 ; W = jnp.ones 8, 16 ; b = jnp.zeros 16, print make jaxpr layer x, W, b 1 2 3 4 5 6 7 8 9 js { lambda ; a:f32 4,8 b:f32 8,16 c:f32 16 . let d:f32 4,16 = dot general dimension numbers= 1 , 0 , , preferred element type=float32 a b e:f32 1,16 = broadcast in dim broadcast dimensions= 1, c f:f32 4,16 = add d e g:f32 4,16 = max f 0.0:f32 in g, } Your @ became dot general — the one general contraction primitive that every matmul, batched matmul, and tensor contraction in JAX lowers to. The dimension numbers= 1 , 0 , , says “contract axis 1 of the left operand with axis 0 of the right, no batch axes,” which is an ordinary matrix multiply. Your + b became an explicit broadcast in dim followed by add , because the jaxpr does not hide broadcasting — it is a real operation with a real cost, so it gets a node. This is the level JAX reasons at. Import jax.lax , jax.nn , jax.random , and jax.scipy.special , then sweep the heap for live core.Primitive objects, and you find 285 of them. 3 That count includes plenty of internal machinery you will never type, but the order of magnitude is the point: a few hundred typed operations, and no Python left. Tracing is abstract interpretation, not bytecode surgery Here is where JAX diverges from torch.compile at the mechanism level, and the contrast explains everything downstream. Dynamo intercepts CPython at the frame-evaluation layer and symbolically executes your bytecode, opcode by opcode; when it meets something it cannot model, it takes a graph break and falls back to the interpreter. 4 JAX does none of that. It never looks at your bytecode. It just calls your function — with fake arguments. When you trace f , JAX does not pass in an array. It passes in a Tracer : an object carrying an abstract value , and recording every primitive applied to it. Put a print inside a traced function and you can see exactly what flows through: 1 2 3 4 5 6 python def probe x : print type x . name , "|", x.aval return x + 1 make jaxpr probe jnp.ones 2, 3 DynamicJaxprTracer | float32 2,3 The value moving through your function is a DynamicJaxprTracer whose aval is float32 2,3 . Your Python executes once, top to bottom, during tracing, and every jax.numpy call it makes appends a node to the jaxpr being built. That is why x @ W turns into dot general — the tracer’s matmul records the primitive instead of computing anything. The abstract value is the entire worldview of the compiler. It knows your array is float32 2,3 . It does not know, and never asked, what the numbers are. Hold onto that sentence; the last section of this post is about how much else the aval now knows. The trace boundary: what JAX refuses to see Now the offending if . A tracer has a type but no concrete value, so the moment your code demands a concrete boolean from it, tracing cannot continue: 1 2 3 4 5 6 7 8 python from jax import jit def bad x : if x 0: needs a real bool; x is a tracer return x return -x jit bad jnp.float32 1.0 1 2 3 4 5 jax.errors.TracerBoolConversionError: Attempted boolean conversion of traced array with shape bool . The error occurred while tracing the function bad for jit. This concrete value was not available in Python because it depends on the value of the argument x. x 0 produces another tracer — a bool — and a Python if needs to branch now , which means collapsing that tracer to True or False , which JAX cannot do without the data. So it stops. This is the exact spot where torch.compile would graph-break, and the two systems make opposite choices. Dynamo chooses “keep running, compile what you can.” JAX chooses “the trace stays pure and complete, so make the control flow part of the IR or don’t compile at all.” I have come around to preferring JAX’s choice, with a caveat. A graph break is a silent performance cliff — your model still runs, just slower, and you find out months later reading a profile. TracerBoolConversionError is loud and immediate; it fails in your face on the first call. That honesty is worth a lot. The cost is real: you cannot sprinkle data-dependent Python control flow through your model and expect it to compile. Control flow has to become a primitive The fix is to lift the branch out of Python and into the IR: 5 fn:5 1 2 3 4 5 6 7 python from jax import lax def good x : select the branch inside the IR instead of in Python return lax.cond x 0, lambda v: v, lambda v: -v, x print make jaxpr good jnp.float32 1.0 1 2 3 4 5 6 7 8 9 10 js { lambda ; a:f32 . let b:bool = gt a 0.0:f32 c:i32 = convert element type new dtype=int32 weak type=False b d:f32 = cond branches= { lambda ; e:f32 . let f:f32 = neg e in f, } { lambda ; g:f32 . let in g, } c a in d, } The branch is now a cond primitive whose two arms are themselves little jaxprs, and the predicate is a traced bool rather than a Python bool. Both arms are in the IR because JAX traced both of them, which you can watch directly: 1 2 3 4 5 6 python hits = def true fn v : hits.append "true" ; return v 10 def false fn v : hits.append "false" ; return v - 10 make jaxpr lambda x: lax.cond x 0, true fn, false fn, x jnp.float32 1.0 print hits 'true', 'false' Both branch functions ran at trace time. That is the price of making control flow data-independent, and it has a consequence people trip over: if one arm of your cond allocates a 10 GB intermediate, that allocation is in the program whether or not the predicate ever selects it. Tracing both arms is not the same as running both. An unbatched cond executes one branch. But batch the predicate — which is what happens the moment you vmap a per-example decision — and the primitive disappears: 1 2 f = lambda x: lax.cond x 0, lambda v: v 10, lambda v: v - 10, x print make jaxpr vmap f jnp.arange 4, dtype=jnp.float32 1 2 3 4 5 6 7 8 9 10 11 12 13 js { lambda ; a:f32 4 . let b:bool 4 = gt a 0.0:f32 c:i32 4 = convert element type new dtype=int32 weak type=False b d:bool 4 = eq c 0:i32 e:f32 4 = stop gradient a f:f32 4 = select n d e a g:f32 4 = sub f 10.0:f32 h:bool 4 = eq c 1:i32 i:f32 4 = stop gradient a j:f32 4 = select n h i a k:f32 4 = mul j 10.0:f32 l:f32 4 = select n c g k in l, } No cond anywhere. Both the sub and the mul run on all four lanes and a select n picks per lane, because there is no such thing as a divergent branch across a vector register. 5 Each lane pays for both arms. When one arm is an attention block and the other is return x , that is the whole cost of the expensive arm on every example, and it is invisible unless you read the jaxpr.Loops split the same way. lax.while loop is for data-dependent iteration counts. lax.scan is for a fixed count, and it is the one to internalize, because it traces the body exactly once: 1 2 3 4 5 6 7 8 python n = def body carry, x : n.append 1 return carry + x, carry print make jaxpr lambda c, xs: lax.scan body, c, xs jnp.float32 0. , jnp.arange 1000, dtype=jnp.float32 print len n , "trace s of the body" 1 2 3 4 5 6 7 8 9 js { lambda ; a:f32 b:f32 1000 . let c:f32 d:f32 1000 = scan jaxpr={ lambda ; e:f32 f:f32 . let g:f32 = add e f in g, e } length=1000 reverse=False unroll=1 a b in c, d } 1 trace s of the body One equation, length=1000 , one traced body. Write the same loop as a Python for and JAX will happily trace it — it just unrolls the whole thing into the jaxpr, and you pay for that in the compiler. Here is a 5,000-step recurrence over a 32×32 weight matrix, measured both ways on CPU: 1 2 3 top-level eqns trace compile python for 10000 0.11 s 36.92 s lax.scan 1 0.00 s 0.02 s Thirty-seven seconds versus twenty milliseconds, for a program that computes the same thing. This is the single most common self-inflicted wound in JAX code, and it does not announce itself — nothing errors, your job just sits in XLA for a minute before every run. The rule to internalize: anything that decides the shape or structure of the computation from a value must become a primitive, because the trace only knows types, never values. Purity is not optional The trace also assumes your function is pure — output determined entirely by its inputs, no side effects, no hidden state. This is not a style preference; it is load-bearing. Your Python runs while JAX traces it, so any side effect fires only during tracing, once per trace, and not on the cached calls that follow. A print inside a jitted function prints while JAX traces it, then goes silent on every cached call after — baffling if you did not expect it. Mutating a global from inside a traced function is worse: the write happens at trace time against whatever the state was then, and the compiled program has no memory of it. The trace-counting trick further down works precisely because of this. The most visible consequence is randomness, and it is the single most complained-about thing in JAX. NumPy’s np.random.randn reads and advances a global RNG state — a side effect, invisible to the tracer, impossible to reproduce or parallelize deterministically. JAX cannot trace that, so it refuses to have it. Randomness is instead an explicit, pure function of a key you pass in: 6 fn:6 1 2 3 key = jax.random.key 0 jax.random.normal key, 3, 1.6226422 2.0252647 -0.4335944 jax.random.normal key, 3, identical — same key, same draw Same key, same numbers, every time — a draw is a deterministic function of its key, which is what a pure trace demands. To get fresh randomness you advance the state yourself, by splitting a key into new independent keys: 1 2 k1, k2 = jax.random.split key jax.random.normal k1, 3, 1.0040143 -0.9063372 -0.7481722 People hate this until they need it, and then they stop. Because the randomness is threaded explicitly, a JAX computation is bit-for-bit reproducible no matter how it is parallelized — the key, not a hidden global, determines the result. The generator underneath is Threefry, a counter-based cipher-like construction designed so that the n -th draw is computable directly from the counter without stepping through the first n−1 , which is exactly what makes a split-and-scatter design work across 512 chips. 7 fn:7 And a key is a first-class typed value, not a special case bolted onto the API. Trace a draw and look at the binder: 1 print make jaxpr lambda k: jax.random.normal k, 3, key 1 2 3 4 5 6 7 8 9 10 js { lambda ; o:key