
1. Why JAX? A Different Mental Model
PyTorch is built around imperative, object-oriented, stateful programming. A nn.Module holds its weights as attributes, .backward() mutates .grad fields on tensors in place, and optimizer.step() mutates parameters in place. This feels natural because it mirrors ordinary Python programming.
JAX is built around functional programming. There is no nn.Module base class in core JAX, no hidden state, and no in-place mutation of arrays. Every transformation JAX gives you — jit (compile), grad (differentiate), vmap (vectorize), pmap/sharding (parallelize) — is a function that takes a function and returns a new function. They compose freely because they all operate on the same contract: pure functions in, pure functions out.
# The shape of almost everything in JAX:
transformed_fn = jax.jit(jax.grad(jax.vmap(some_pure_function)))This is the single biggest mindset shift coming from PyTorch. Once it clicks, most of JAX’s API stops looking strange and starts looking like a small set of orthogonal, composable tools. This guide leans on that comparison throughout — for the concepts, code, and terminology used here, see the official docs at docs.jax.dev.
Why would you accept this extra rigidity? In exchange, JAX gives you:
jit-compiled code that runs as fast as hand-written XLA, often faster than eager PyTorch, without writing CUDA.- Composable transformations — you can nest
jit,grad, andvmapin any order and it just works, because they all share the same functional contract. - First-class, deterministic, and parallel-safe randomness via explicit PRNG keys instead of a hidden global RNG state.
- A very small, orthogonal core API —
jax.numpy(liketorch), plus four or five transformations, versus the very large surface area oftorch.nn,torch.autograd, andtorch.optimcombined.
The tradeoff: things PyTorch gives you “for free” — mutable module state, a built-in nn.Module/optim ecosystem, dynamic Python control flow inside a compiled graph — you have to either do explicitly (state-as-argument) or reach for a companion library (Flax for neural net modules, Optax for optimizers — covered in Section 13).
2. Installation
# CPU only
pip install -U jax
# NVIDIA GPU (CUDA 12)
pip install -U "jax[cuda12]"
# Neural-net ecosystem used later in this guide
pip install -U flax optaxVerify the install and check what devices JAX sees (the equivalent of torch.cuda.is_available()):
import jax
print(jax.__version__)
print(jax.devices()) # e.g. [CudaDevice(id=0)] or [CpuDevice(id=0)]
print(jax.default_backend()) # 'gpu', 'tpu', or 'cpu'See Installation in the official docs for TPU and other platform-specific instructions.
3. Arrays: jax.numpy vs torch
jax.numpy (conventionally imported as jnp) is JAX’s near drop-in replacement for NumPy, and it plays the same role torch plays as PyTorch’s tensor library.
import jax.numpy as jnp
import numpy as np
x = jnp.array([1.0, 2.0, 3.0])
y = jnp.ones((3, 4))
z = x @ x # dot product, same syntax as torch/numpy
w = jnp.sin(x) * 2.0# The PyTorch equivalent
import torch
x = torch.tensor([1.0, 2.0, 3.0])
y = torch.ones((3, 4))
z = x @ x
w = torch.sin(x) * 2.0The array API (indexing, broadcasting, reductions, most of jnp.* mirroring np.*) will feel immediately familiar if you know numpy or torch. A few differences matter a lot in practice:
| Behavior | PyTorch | JAX |
|---|---|---|
| Mutability | Tensors are mutable (x[0] = 1.0) |
Arrays are immutable. Use x.at[0].set(1.0) (returns a new array) |
| Default float dtype | float32 (configurable) |
float32 always, even if you pass Python float64 literals, unless you opt into 64-bit mode |
| Device placement | .to("cuda"), tensors “remember” their device |
jax.device_put(x, device); JAX will also auto-place based on sharding |
| Gradient tracking | requires_grad=True flag on tensors |
No flag at all — differentiability is a property of the function you pass to jax.grad, not the array |
In-place ops (add_, mul_) |
Common and encouraged for memory efficiency | Do not exist. Everything returns a new array (XLA’s compiler fuses/optimizes this away for you) |
The immutability point deserves its own example, because x[i] = v is instinctive muscle memory from both NumPy and PyTorch and it will raise an error in JAX:
x = jnp.zeros((3, 3))
# x[1, :] = 1.0 # <-- TypeError: JAX arrays are immutable
x = x.at[1, :].set(1.0) # correct: functional "update", returns a new array
x = x.at[0, 0].add(5.0) # also available: add, multiply, min, max, divide, power, getjax.Array.at[...] supports .set(), .add(), .multiply(), .divide(), .power(), .min(), .max(), and .get() — think of it as the functional counterpart to every in-place PyTorch tensor op you’re used to.
4. Pure Functions: The Rule Everything Else Depends On
Every JAX transformation (jit, grad, vmap, sharding) assumes the function you hand it is pure:
- All inputs come in through function arguments (no reading from globals or object attributes that can change).
- All outputs go out through the return value (no mutating an argument or a global in place).
- Calling the function twice with the same inputs always produces the same outputs (no reliance on iterator state, wall-clock time, hidden RNG state, I/O, etc).
This is a real constraint, not a style suggestion — violating it produces confusing, silent bugs once jit is involved, because tracing only records JAX operations, not arbitrary Python side effects:
def impure_print_side_effect(x):
print("Executing function") # a Python side effect, NOT a JAX op
return x
f = jax.jit(impure_print_side_effect)
f(4.0) # prints "Executing function" (first call: traces + compiles)
f(5.0) # prints NOTHING — reuses the cached compiled code, doesn't re-run the printThe print only happened during the tracing pass that builds the compiled program; it is not part of the compiled program itself. The same applies to appending to a Python list, incrementing a global counter, or mutating self.something inside a class method — none of that is captured by jit, grad, or vmap.
Compare to PyTorch: an nn.Module.forward freely mutates self.running_mean (as BatchNorm does) or appends to a Python list for debugging, and eager-mode PyTorch just runs it every call. JAX’s tracing/compilation model is what makes this dangerous — see Section 12 for the idiomatic fix (thread state through arguments and return values instead of mutating).
5. jax.jit: Compilation via Tracing
jax.jit is the closest thing to torch.compile, but it’s foundational to JAX rather than an opt-in speed knob bolted onto an eager system — most real JAX code is written assuming it will run under jit.
How it works
When you call a jit-wrapped function for the first time with a given combination of input shapes/dtypes, JAX:
- Runs your Python function once with tracers (abstract stand-ins that only carry shape/dtype, not concrete values) instead of real arrays.
- Records every JAX operation performed into an intermediate representation called a jaxpr.
- Hands the jaxpr to XLA, which compiles it into optimized machine code for your target device (CPU/GPU/TPU).
- Caches the compiled artifact, keyed by the function identity and the input shapes/dtypes.
Subsequent calls with the same shapes/dtypes skip tracing and compilation entirely and just execute the cached compiled program — this is where the speedup comes from.
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(1_000_000.0)
selu_jit = jax.jit(selu)
selu_jit(x).block_until_ready() # first call: traces + compiles (slow)
selu_jit(x).block_until_ready() # subsequent calls: just runs (fast, ~8x here)block_until_ready() exists because JAX dispatches work asynchronously — a call returns a future-like array immediately while the device keeps computing, similar in spirit to CUDA kernel launches being asynchronous in PyTorch, except JAX makes this explicit for benchmarking rather than hiding it behind a torch.cuda.synchronize() you have to remember.
The decorator form is more common in practice:
@jax.jit
def selu(x, alpha=1.67, lambda_=1.05):
return lambda_ * jnp.where(x > 0, x, alpha * jnp.exp(x) - alpha)Why Python control flow breaks under jit
Because tracing replaces your inputs with abstract tracers that only know shape/dtype (not concrete values), any Python control flow that needs a concrete value — if x > 0:, while i < n: — cannot be resolved during tracing:
def f(x):
if x > 0: # x is a Tracer here, not a real number
return x
else:
return 2 * x
jax.jit(f)(10) # TracerBoolConversionErrordef g(x, n):
i = 0
while i < n: # n is also a Tracer
i += 1
return x + i
jax.jit(g)(10, 20) # TracerBoolConversionErrorThere are two ways to fix this, and picking the right one matters:
Option A — mark the offending argument as static. static_argnums/static_argnames tell jit to treat that argument as a literal Python value baked into the compiled program (a new compilation happens for each distinct value), rather than a traced array:
f_jit = jax.jit(f, static_argnums=0)
f_jit(10) # works: 10
g_jit = jax.jit(g, static_argnames=['n'])
g_jit(10, 20) # works: 30Use this sparingly — every distinct static value triggers a fresh recompilation, so this only scales to a small, bounded set of values (e.g. a training: bool flag, a small integer config), never something like a batch loss value.
Option B — use a structured control-flow primitive (lax.cond, lax.while_loop, lax.fori_loop, lax.scan) so the branch/loop itself becomes part of the compiled program instead of something Python needs to resolve eagerly. See Section 11.
A caching gotcha
jit caches by function identity. Defining a new closure inside a loop defeats the cache and forces a recompile every iteration — a subtle performance bug that has no PyTorch analogue, since PyTorch doesn’t cache compiled graphs by function object identity in the same way:
# BAD: a fresh function object every iteration -> recompiles every time
def g_bad(x, n):
i = 0
while i < n:
i = jax.jit(lambda i: i + 1)(i) # new lambda -> new cache entry -> recompile
return x + i
# GOOD: reuse one compiled function
_step = jax.jit(lambda i: i + 1)
def g_good(x, n):
i = 0
while i < n:
i = _step(i)
return x + iCompare to PyTorch: torch.compile(model) gives you similar shape-specialized caching under the hood, but eager PyTorch (no compile) never has this failure mode at all, because every op just runs immediately with concrete tensors. The tradeoff is that eager PyTorch pays Python dispatch overhead on every single op, every single call.
6. jax.grad: Automatic Differentiation
This is JAX’s .backward() — except instead of calling a method on a tensor that populates .grad attributes as a side effect, jax.grad is a transformation: it takes a scalar-valued function and returns a new function that computes its gradient.
import jax
f = lambda x: x**3 + 2*x**2 - 3*x + 1
dfdx = jax.grad(f)
print(dfdx(1.0)) # 4.0 (an ordinary function call, not `.backward()`)# The PyTorch equivalent
import torch
x = torch.tensor(1.0, requires_grad=True)
f = x**3 + 2*x**2 - 3*x + 1
f.backward()
print(x.grad) # tensor(4.)Notice what’s missing in the JAX version: no requires_grad=True flag, no .backward() call, no reading .grad off the input afterward. grad(f) is the gradient function; you call it like any other function and it directly returns the gradient value(s).
Higher-order derivatives
Because jax.grad returns an ordinary function, and ordinary functions can be differentiated again, higher-order derivatives are just repeated application:
f = lambda x: x**3 + 2*x**2 - 3*x + 1
dfdx = jax.grad(f)
d2fdx = jax.grad(dfdx)
d3fdx = jax.grad(d2fdx)
print(dfdx(1.0)) # 4.0
print(d2fdx(1.0)) # 10.0
print(d3fdx(1.0)) # 6.0In PyTorch this requires torch.autograd.grad(..., create_graph=True) chained manually — a noticeably more verbose pattern for the same idea.
Gradients with respect to specific / multiple arguments
Use argnums to choose which positional argument(s) to differentiate with respect to — this replaces the PyTorch pattern of selectively setting requires_grad=True on some tensors and not others:
def loss(W, b, x, y):
preds = jnp.dot(x, W) + b
return jnp.mean((preds - y) ** 2)
W_grad = jax.grad(loss)(W, b, x, y) # grad wrt W only (argnums=0 is default)
W_grad, b_grad = jax.grad(loss, argnums=(0, 1))(W, b, x, y) # grad wrt bothvalue_and_grad: getting the loss and the gradient together
Training loops always need both the loss value (to log) and the gradient (to step the optimizer). Calling grad alone throws away the forward value; jax.value_and_grad computes both in a single pass — this is the direct equivalent of PyTorch’s loss = criterion(...); loss.backward() where you already have loss sitting around for free:
loss_value, (W_grad, b_grad) = jax.value_and_grad(loss, argnums=(0, 1))(W, b, x, y)Gradients of nested structures (dicts, NamedTuples, …)
jax.grad works transparently over pytrees (see Section 8) — you don’t need every parameter to be a flat positional argument:
params = {'W': W, 'b': b}
def loss2(params, x, y):
preds = jnp.dot(x, params['W']) + params['b']
return jnp.mean((preds - y) ** 2)
grads = jax.grad(loss2)(params, x, y)
# grads == {'W': ..., 'b': ...}, same structure as paramsThis is exactly how gradients of full neural-net parameter pytrees work in Section 13 — no .parameters() iterator needed, because the “parameters” are just a regular (if deeply nested) Python data structure.
Jacobians, Hessians, and stop_gradient
For vector-valued functions, jax.grad alone isn’t enough (it requires a scalar output). Use jax.jacrev / jax.jacfwd for full Jacobians, and compose them for Hessians:
def f(x):
return jnp.array([jnp.sin(x[0]) * x[1], x[0] * x[1] ** 2])
jac = jax.jacrev(f)(jnp.array([1.0, 2.0])) # reverse-mode: efficient for few inputs, many outputs
jac = jax.jacfwd(f)(jnp.array([1.0, 2.0])) # forward-mode: efficient for many inputs, few outputs
hessian = jax.jacfwd(jax.jacrev(f)) # standard JAX idiom for a HessianTo exclude part of a computation from the gradient (PyTorch’s .detach()), use jax.lax.stop_gradient:
def loss_fn(params, x, target):
pred = model(params, x)
target = jax.lax.stop_gradient(target) # equivalent to target.detach() in PyTorch
return jnp.mean((pred - target) ** 2)Per-example gradients: where grad + vmap shine
A pattern that’s awkward in PyTorch (historically requiring functorch/torch.func.vmap) is trivial by composing two JAX transformations:
# grad of a single example's loss, then vmap it over a batch
per_example_grads = jax.vmap(jax.grad(loss_fn), in_axes=(None, 0, 0))(params, x_batch, y_batch)params is broadcast (in_axes=None), while x_batch/y_batch are mapped over their leading axis — giving you one gradient per example instead of one gradient averaged over the batch. This is the same vmap covered next, just composed with grad.
7. jax.vmap: Automatic Vectorization
PyTorch code is usually already “batched” by hand — you write a forward that assumes a leading batch dimension and rely on broadcasting. jax.vmap flips this: you write the function for a single example, and vmap mechanically transforms it into a batched version, without you touching the implementation or writing a Python loop.
def convolve(x, w):
output = []
for i in range(1, len(x) - 1):
output.append(jnp.dot(x[i - 1 : i + 2], w))
return jnp.array(output)
x = jnp.arange(5.0)
w = jnp.array([1.0, 2.0, 1.0])
convolve(x, w) # works on a single (x, w) pairNaively batching this requires a Python loop (slow, and not jit-friendly if the batch size varies):
def manually_batched_convolve(xs, ws):
output = []
for i in range(xs.shape[0]):
output.append(convolve(xs[i], ws[i]))
return jnp.stack(output)vmap does this automatically and compiles to a single vectorized/batched XLA op instead of a Python-level loop:
auto_batch_convolve = jax.vmap(convolve)
auto_batch_convolve(xs, ws) # xs, ws now have a leading batch dimensionin_axes and out_axes
in_axes tells vmap which axis of each argument is the batch axis (None means “this argument is not batched, broadcast it as-is”):
# batch dimension is axis 1 instead of axis 0, for both inputs and the output
auto_batch_convolve_v2 = jax.vmap(convolve, in_axes=1, out_axes=1)
auto_batch_convolve_v2(xst, wst)
# only `xs` is batched; the same `w` is reused for every example
batch_convolve_v3 = jax.vmap(convolve, in_axes=[0, None])
batch_convolve_v3(xs, w)Compare to PyTorch: this replaces two different habits at once — (1) manually rewriting a function to insert a batch dimension everywhere, and (2) torch.func.vmap (formerly functorch.vmap), which is a bolt-on, less central feature in PyTorch. In JAX, vmap is a first-class, everyday transformation, and it composes with jit and grad freely: jax.jit(jax.vmap(jax.grad(f))) is completely ordinary JAX code.
8. Pytrees: JAX’s Answer to Nested Parameters
A pytree is any nested Python structure built out of containers (lists, tuples, dicts, NamedTuples, and registered custom classes) with arrays (or other non-container values) at the leaves. JAX treats pytrees as a first-class citizen: jit, grad, vmap, and jax.tree.* utilities all operate on entire pytrees, not just single arrays.
import jax
example_tree = [1, {'k1': 2, 'k2': (3, 4)}, 5]
jax.tree.leaves(example_tree)
# [1, 2, 3, 4, 5]
jax.tree.map(lambda x: x * 2, example_tree)
# [2, {'k1': 4, 'k2': (6, 8)}, 10]This is what makes the “model parameters” pattern from Section 12 work: a dict of dicts of arrays (your entire model’s weights) is just a pytree, so jax.grad(loss)(params) returns a gradient pytree with the exact same nested structure as params, and you can update every leaf at once:
new_params = jax.tree.map(lambda p, g: p - learning_rate * g, params, grads)Compare to PyTorch: this replaces model.parameters() (a flat iterator) and the implicit tree of nn.Module submodules. In JAX there’s no framework-level module tree walking your object graph for you — the “tree” is just an explicit, ordinary Python/NumPy-like nested data structure, and jax.tree.* are the generic utilities for mapping/flattening/reducing over it. This is also why Flax NNX modules (Section 13) are designed to convert cleanly to and from pytrees under the hood.
You can register your own classes as pytrees (so jax.tree.map knows how to flatten/unflatten them) via jax.tree_util.register_pytree_node — most users won’t need this directly since NamedTuple, dataclasses (via Flax), dicts, lists, and tuples already cover the common cases. See Custom pytree nodes if you need it.
9. Random Numbers: Explicit PRNG Keys
PyTorch (like NumPy) uses hidden global RNG state: calling torch.randn(3) twice gives different results because it silently advances a global generator. JAX deliberately has no global random state — every random function takes an explicit key as its first argument, and the same key always produces the same output:
from jax import random
key = random.key(42)
print(random.normal(key)) # -0.02830462
print(random.normal(key)) # -0.02830462 <-- same key, same output, every timeThis is a deliberate design choice, not an oversight — a hidden mutable global generator is fundamentally hostile to the rest of JAX’s model:
- Reproducibility under
jit/vmap: the compiler is free to reorder, parallelize, or cache calls; a hidden global counter that has to advance in “the order Python wrote it” is exactly the kind of implicit sequential dependencyjitandvmapare designed to eliminate. - Multi-device parallelism: a global generator would need to be synchronized across every device, which is either a serialization bottleneck or a correctness nightmare.
Splitting keys
Since reusing a key gives you the same “random” numbers again, and there’s no global state to “advance,” you explicitly split a key into fresh, independent subkeys whenever you need new randomness — treat every key as single-use:
key = random.key(0)
key, subkey = random.split(key)
val1 = random.normal(subkey) # use subkey once, then discard it
key, subkey = random.split(key)
val2 = random.normal(subkey) # `key` keeps getting split for each new draw
# need several keys at once (e.g. one per layer during init)?
key, *subkeys = random.split(key, num=5)A useful mental model: key is like a seed you’re not allowed to reuse. split is how you turn one seed into two (or more) independent seeds — one to consume right now, and one (conventionally the first one returned) to keep threading through the rest of your program.
Non-obvious detail: individually sampling from split subkeys does not generally match a single vectorized call with a shape argument — don’t expect [random.normal(k) for k in random.split(key, 3)] to equal random.normal(key, shape=(3,)). Both are valid, reproducible random streams; they’re just different streams.
Compare to PyTorch: this replaces torch.manual_seed(0) plus relying on the global generator advancing correctly across your whole program (which quietly breaks under DataLoader multiprocessing unless you’re careful with worker_init_fn, and is not something you can trivially reproduce on TPU pods). In JAX, correct/reproducible parallel randomness is what you get by construction, at the cost of manually threading key/subkey everywhere you’d have called a torch.rand* function.
10. The Sharp Bits: Common Gotchas Coming from PyTorch
This section is JAX’s own “Common Gotchas” guide, curated and annotated for the specific things that surprise PyTorch users.
10.1 In-place updates don’t exist
Already covered in Section 3 — x[i] = v raises an error; use x.at[i].set(v) (and .add(), .multiply(), .min(), .max(), .get()).
10.2 Out-of-bounds indexing is clamped, not an error
jnp.arange(10)[11] # returns 9 (index clamped to the last valid position) — no IndexError!This is deliberate: on accelerators, raising exceptions from out-of-bounds indices inside a compiled kernel isn’t well defined, so JAX defines it as clamping instead of erroring. If you need NaN-on-out-of-bounds behavior for debugging, use .at[].get(mode='fill', fill_value=jnp.nan):
jnp.arange(10.0).at[11].get(mode='fill', fill_value=jnp.nan) # nanPyTorch equivalent: torch.tensor([...])[11] raises IndexError. Coming from PyTorch, silently-wrong-instead-of-crashing is the dangerous direction to be surprised in — add your own bounds assertions during debugging if this matters for your code.
10.3 Lists and tuples of numbers aren’t arrays
jnp.sum([1, 2, 3]) # TypeError in strict contexts / silently slow in others — don't do this
jnp.sum(jnp.array([1, 2, 3])) # correctNumPy silently converts Python lists to arrays for you; JAX would rather you be explicit, since implicit conversion inside a traced function can quietly turn into a very slow, unrolled Python-level operation.
10.4 Dynamic shapes are not allowed under jit
Every array’s shape must be static (known at trace time) — a shape cannot depend on the value of an array, only on its shape/dtype:
def nansum(x):
mask = ~jnp.isnan(x)
x_without_nans = x[mask] # shape of the result depends on how many NaNs are in x -> fails under jit
return x_without_nans.sum()The fix is a very common JAX idiom: mask with jnp.where instead of shrinking the array, so the shape stays fixed and only the values change:
@jax.jit
def nansum_fixed(x):
mask = ~jnp.isnan(x)
return jnp.where(mask, x, 0).sum() # same shape in and out, NaNs replaced by 0 before summingPyTorch comparison: boolean masking (x[mask]) works fine in eager PyTorch because nothing needs a static shape ahead of time. Under torch.compile, you’ll hit a close analogue of this same restriction (torch._dynamo guards / graph breaks on data-dependent shapes).
10.5 float32 by default, even for “double precision” literals
x = jnp.array([1.0, 2.0], dtype=jnp.float64)
x.dtype # dtype('float32') <-- silently downcast!JAX defaults to 32-bit floats everywhere (matching typical accelerator throughput/memory tradeoffs) and silently truncates 64-bit inputs unless you opt in explicitly, at process startup, before any JAX arrays are created:
import jax
jax.config.update("jax_enable_x64", True)
# or set the environment variable JAX_ENABLE_X64=1 before importing jaxPyTorch comparison: torch.get_default_dtype() is also float32 by default, but PyTorch does not silently downcast an explicitly-constructed float64 tensor — torch.tensor([1.0], dtype=torch.float64).dtype stays float64. This is one of the sharper surprises for newcomers; if a metric or loss looks suspiciously imprecise, check whether it silently landed in float32.
10.6 Type promotion and casting rules differ from NumPy
np.arange(254.0, 258.0).astype('uint8') # [254, 255, 0, 1] (numpy wraps around)
jnp.arange(254.0, 258.0).astype('uint8') # [254, 255, 255, 255] (jax clips, and this is backend-dependent)Unsafe casts near dtype boundaries are not guaranteed to match NumPy exactly, and can vary between CPU/GPU/TPU. Don’t rely on overflow-wraparound behavior being portable.
10.7 JIT + class methods
Wrapping a bound method (self.foo) with jax.jit naively re-traces every time self changes identity (e.g. a new instance each call), and — per the pure-functions rule — mutating self inside the method silently doesn’t “stick” the way you’d expect from PyTorch’s nn.Module. Three common fixes: (1) pull the logic into a free function and jit that instead, (2) mark self as static via static_argnums if it’s hashable and small, or (3) register the class itself as a pytree. In practice, Flax NNX (Section 13) handles this for you, which is the main reason to reach for it instead of hand-rolling classes.
10.8 Debugging NaNs/Infs
jax.config.update("jax_debug_nans", True) # raise as soon as a NaN is produced, with a traceback to the op
jax.config.update("jax_debug_infs", True)This is the closest JAX equivalent to torch.autograd.detect_anomaly() — turn it on only while debugging, since it disables some optimizations and slows execution.
11. Structured Control Flow: lax.cond, while_loop, fori_loop, scan
Section 5 showed that plain Python if/while break under jit when the condition depends on a traced (runtime) value. jax.lax provides structured control-flow primitives that compile the branch/loop itself into the program, instead of requiring Python to resolve it during tracing.
| Construct | Purpose | Autodiff |
|---|---|---|
lax.cond(pred, true_fn, false_fn, operand) |
Branch on a traced boolean | Full |
lax.while_loop(cond_fn, body_fn, init) |
Loop until a traced condition is false | Forward-mode only |
lax.fori_loop(lo, hi, body_fn, init) |
Loop a (possibly traced) fixed number of times | Full when bounds are static |
lax.scan(body_fn, init, xs) |
Loop while carrying state and collecting outputs | Full |
from jax import lax
import jax.numpy as jnp
# lax.cond — like `x + 1 if pred else x - 1`, but works when `pred` is traced
operand = jnp.array([0.0])
lax.cond(True, lambda x: x + 1, lambda x: x - 1, operand) # array([1.])
# lax.while_loop — like `while x < 10: x += 1`
lax.while_loop(lambda x: x < 10, lambda x: x + 1, 0) # 10
# lax.fori_loop — like `for i in range(10): x += i`
lax.fori_loop(0, 10, lambda i, x: x + i, 0) # 45lax.scan is the workhorse for anything sequential — RNNs, cumulative sums, unrolled training steps — because it avoids Python-level unrolling (which bloats compile time) while still supporting full autodiff:
def step(carry, x):
total = carry + x
return total, total # (new_carry, per-step output to collect)
final_total, running_totals = lax.scan(step, 0.0, jnp.arange(5.0))
# final_total == 10.0
# running_totals == [0., 1., 3., 6., 10.]Compare to PyTorch: a plain Python for/while loop over tensors works fine in eager PyTorch (and is how most people write RNN steps or custom training loops) because nothing needs to be traced ahead of time. lax.scan is closer in spirit to torch.jit.script’s handling of loops, or to manually unrolling — except in JAX it’s the idiomatic default for any loop you want compiled, not a fallback compilation mode.
A practical rule of thumb: plain Python for i in range(N) is fine under jit as long as N is a static Python integer (known at trace time, not a traced array) — it just gets unrolled into the compiled program. Reach for lax.fori_loop/lax.scan when N is large (to avoid huge compile times from unrolling) or when the loop bound is itself a traced value.
12. State Without Mutation: The Functional Training-Step Pattern
Section 4 showed why mutation is dangerous under jit. Here’s the idiomatic fix, which is the single most important pattern for translating any stateful PyTorch class (a model, an optimizer, a running counter) into JAX.
The broken, PyTorch-habit version:
class Counter:
def __init__(self):
self.n = 0
def count(self) -> int:
self.n += 1 # mutates self as a side effect — invisible to jit's tracer
return self.n
counter = Counter()
fast_count = jax.jit(counter.count)
for _ in range(3):
print(fast_count()) # prints 1, 1, 1 (WRONG — the mutation only "happened" during tracing)The fix: make the state an explicit argument and an explicit return value.
CounterState = int
class CounterV2:
def count(self, n: CounterState) -> tuple[int, CounterState]:
return n + 1, n + 1 # (output, new_state) — both returned, nothing mutated
def reset(self) -> CounterState:
return 0
counter = CounterV2()
state = counter.reset()
fast_count = jax.jit(counter.count)
for _ in range(3):
value, state = fast_count(state) # caller re-threads state back in on the next call
print(value) # prints 1, 2, 3 (correct)The general transformation is always the same:
stateful_method(self, *args) -> output # mutates self.state (PyTorch habit)
↓
pure_function(state, *args) -> (output, new_state) # state is data, not identity (JAX idiom)
Worked example: linear regression parameters as state
This is precisely the pattern a training loop uses, with model parameters standing in for self.n:
from typing import NamedTuple
import jax
import jax.numpy as jnp
class Params(NamedTuple):
weight: jnp.ndarray
bias: jnp.ndarray
def loss(params: Params, x: jnp.ndarray, y: jnp.ndarray) -> jnp.ndarray:
pred = params.weight * x + params.bias
return jnp.mean((pred - y) ** 2)
LEARNING_RATE = 0.005
@jax.jit
def update(params: Params, x: jnp.ndarray, y: jnp.ndarray) -> Params:
grad = jax.grad(loss)(params, x, y)
# jax.tree.map applies the SGD update to every leaf (weight AND bias) at once
new_params = jax.tree.map(lambda p, g: p - g * LEARNING_RATE, params, grad)
return new_params
params = Params(weight=jnp.zeros(()), bias=jnp.zeros(()))
for _ in range(1000):
params = update(params, xs, ys) # params is reassigned, never mutated in place# The PyTorch equivalent — mutation is implicit and hidden inside .step()
model = torch.nn.Linear(1, 1)
optimizer = torch.optim.SGD(model.parameters(), lr=0.005)
for _ in range(1000):
optimizer.zero_grad()
pred = model(xs)
loss = ((pred - ys) ** 2).mean()
loss.backward()
optimizer.step() # mutates model.parameters() in place, invisiblyNeither version is “more correct” — but notice that the JAX version makes every state transition visible in the return value and the reassignment (params = update(...)), while PyTorch’s happens as a side effect buried inside .step(). This explicitness is exactly what lets jit, grad, and vmap reason about — and safely transform — the whole computation. Flax’s nnx.Optimizer (next section) gives you back something that feels like optimizer.step(), while still being built on this explicit-state foundation underneath.
13. Neural Networks with Flax NNX + Optax
Core JAX deliberately has no nn.Module, no optim.SGD, and no DataLoader. Those are layered on top by companion libraries:
- Flax (specifically its current NNX API) — the
torch.nnequivalent: layers, parameter initialization, module composition. - Optax — the
torch.optimequivalent: SGD, Adam, learning-rate schedules, gradient clipping, and utilities for composing them.
Why NNX and not “plain JAX classes”?
Flax NNX modules hold their parameters as ordinary Python attributes (self.w = nnx.Param(...)) — which looks exactly like the PyTorch self.weight = nn.Parameter(...) habit — while internally staying compatible with JAX’s pytree/functional model, so nnx.jit and nnx.grad can still trace through them safely. It removes the two sharp edges from Section 10.7 and Section 12 without requiring you to manually thread every parameter through function arguments by hand.
Defining a layer
from flax import nnx
import jax.numpy as jnp
import jax
class Linear(nnx.Module):
def __init__(self, din: int, dout: int, *, rngs: nnx.Rngs):
self.w = nnx.Param(rngs.params.uniform((din, dout)))
self.b = nnx.Param(jnp.zeros((dout,)))
self.din, self.dout = din, dout
def __call__(self, x: jax.Array):
return x @ self.w + self.b[None]
model = Linear(din=2, dout=5, rngs=nnx.Rngs(params=0))
y = model(jnp.ones((1, 2)))# The PyTorch equivalent
import torch.nn as nn
class Linear(nn.Module):
def __init__(self, din, dout):
super().__init__()
self.w = nn.Parameter(torch.rand(din, dout))
self.b = nn.Parameter(torch.zeros(dout))
def forward(self, x):
return x @ self.w + self.b
model = Linear(2, 5)
y = model(torch.ones(1, 2))The differences to flag for a PyTorch reader: NNX requires you to pass an explicit rngs: nnx.Rngs for parameter initialization (there’s no hidden global RNG — see Section 9), and shapes (din, dout) must be given up front rather than lazily inferred on first call (there’s no nn.LazyLinear equivalent).
Composing modules (an MLP)
class MLP(nnx.Module):
def __init__(self, din: int, dmid: int, dout: int, *, rngs: nnx.Rngs):
self.linear1 = Linear(din, dmid, rngs=rngs)
self.dropout = nnx.Dropout(rate=0.1, deterministic=False)
self.bn = nnx.BatchNorm(dmid, use_running_average=False, rngs=rngs)
self.linear2 = Linear(dmid, dout, rngs=rngs)
def __call__(self, x: jax.Array, rngs: nnx.Rngs):
x = self.linear1(x)
x = self.bn(x)
x = self.dropout(x, rngs=rngs)
x = nnx.gelu(x)
return self.linear2(x)
model = MLP(din=2, dmid=16, dout=5, rngs=nnx.Rngs(0))
y = model(jnp.ones((3, 2)), rngs=nnx.Rngs(1))Submodules are just attributes, composed exactly the way nn.Sequential/nested nn.Modules are in PyTorch — this part of the mental model transfers almost directly.
Optimizers with Optax
Optax optimizers are stateless transformations of gradients, not stateful objects that own your parameters. The core pattern (used with or without Flax) is:
import optax
optimizer = optax.adam(learning_rate=1e-3)
opt_state = optimizer.init(params) # params: any pytree of arrays
grads = jax.grad(loss_fn)(params, x, y)
updates, opt_state = optimizer.update(grads, opt_state) # opt_state is explicit, threaded state
params = optax.apply_updates(params, updates) # apply the updates functionally# The PyTorch equivalent
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
optimizer.zero_grad()
loss = loss_fn(model, x, y)
loss.backward()
optimizer.step() # optimizer owns and mutates model.parameters() in placeoptax.adam(...) replaces torch.optim.Adam(...), and Optax has direct equivalents of nearly everything in torch.optim: optax.sgd, optax.adamw, optax.rmsprop, learning-rate schedules (optax.cosine_decay_schedule, optax.warmup_cosine_decay_schedule), and gradient transformations you compose with optax.chain (e.g. gradient clipping via optax.clip_by_global_norm) — the Optax equivalent of stacking multiple torch.optim tricks by hand.
nnx.Optimizer: wiring model + optimizer together
Flax NNX wraps this pattern so it feels like optimizer.step(), while still being pure functional Optax underneath:
model = MLP(din=2, dmid=16, dout=10, rngs=nnx.Rngs(0))
optimizer = nnx.Optimizer(model, optax.adam(1e-3), wrt=nnx.Param)
@nnx.jit
def train_step(model, optimizer, x, y, rngs):
def loss_fn(model: MLP, rngs: nnx.Rngs):
y_pred = model(x, rngs)
return jnp.mean((y_pred - y) ** 2)
loss, grads = nnx.value_and_grad(loss_fn)(model, rngs)
optimizer.update(model, grads) # updates model's params in place (NNX allows this internally)
return loss
x, y = jnp.ones((5, 2)), jnp.ones((5, 10))
loss = train_step(model, optimizer, x, y, nnx.Rngs(2))nnx.jit and nnx.grad/nnx.value_and_grad are NNX-aware wrappers around jax.jit/jax.grad — they know how to split an NNX module into its underlying pytree of parameters before tracing and reassemble it afterward, which is what makes optimizer.update(model, grads) safe to call and have it “stick,” unlike the raw self.n += 1 example from Section 12. Under the hood it is still following the pure state-in/state-out contract; NNX is just doing the threading for you.
14. Full Walkthrough: Training an MLP Classifier
This section strings every preceding concept together into one realistic, end-to-end script: load data, define a model, define a loss, train with nnx.jit, and evaluate — the JAX equivalent of a typical PyTorch “hello world” training script (e.g. an MNIST MLP).
import jax
import jax.numpy as jnp
from flax import nnx
import optax
from sklearn.datasets import load_digits # 8x8 handwritten digits, 1797 samples, 10 classes
from sklearn.model_selection import train_test_split
# --- 1. Data -----------------------------------------------------------
digits = load_digits()
images, labels = jnp.array(digits.images), jnp.array(digits.target)
images_train, images_test, labels_train, labels_test = train_test_split(
images, labels, test_size=0.2, random_state=0
)
# --- 2. Model ------------------------------------------------------------
class SimpleNN(nnx.Module):
def __init__(self, n_features: int = 64, n_hidden: int = 100, n_targets: int = 10,
*, rngs: nnx.Rngs):
self.n_features = n_features
self.layer1 = nnx.Linear(n_features, n_hidden, rngs=rngs)
self.layer2 = nnx.Linear(n_hidden, n_hidden, rngs=rngs)
self.layer3 = nnx.Linear(n_hidden, n_targets, rngs=rngs)
def __call__(self, x):
x = x.reshape(x.shape[0], self.n_features) # flatten 8x8 images, like `x.view(B, -1)` in PyTorch
x = nnx.selu(self.layer1(x))
x = nnx.selu(self.layer2(x))
return self.layer3(x)
model = SimpleNN(rngs=nnx.Rngs(0))
# --- 3. Optimizer ----------------------------------------------------------
optimizer = nnx.Optimizer(model, optax.sgd(learning_rate=0.05), wrt=nnx.Param)
# --- 4. Loss ---------------------------------------------------------------
def loss_fn(model: nnx.Module, data: jax.Array, labels: jax.Array):
logits = model(data)
loss = optax.softmax_cross_entropy_with_integer_labels(
logits=logits, labels=labels
).mean()
return loss, logits # (scalar loss, auxiliary output) — see has_aux below
# --- 5. Training step (jit-compiled) ----------------------------------------
@nnx.jit
def train_step(model: nnx.Module, optimizer: nnx.Optimizer, data: jax.Array, labels: jax.Array):
grad_fn = nnx.grad(loss_fn, has_aux=True) # has_aux=True: loss_fn returns (loss, extras)
grads, logits = grad_fn(model, data, labels)
optimizer.update(model, grads)
return logits
# --- 6. Training loop --------------------------------------------------------
for step in range(301):
train_step(model, optimizer, images_train, labels_train)
if step % 50 == 0:
test_loss, _ = loss_fn(model, images_test, labels_test)
preds = jnp.argmax(model(images_test), axis=-1)
accuracy = jnp.mean(preds == labels_test)
print(f"step {step:>4} test_loss={test_loss:.4f} test_acc={accuracy:.3f}")# The PyTorch equivalent, side by side
import torch
import torch.nn as nn
import torch.nn.functional as F
class SimpleNN(nn.Module):
def __init__(self, n_features=64, n_hidden=100, n_targets=10):
super().__init__()
self.layer1 = nn.Linear(n_features, n_hidden)
self.layer2 = nn.Linear(n_hidden, n_hidden)
self.layer3 = nn.Linear(n_hidden, n_targets)
def forward(self, x):
x = x.view(x.shape[0], -1)
x = F.selu(self.layer1(x))
x = F.selu(self.layer2(x))
return self.layer3(x)
model = SimpleNN()
optimizer = torch.optim.SGD(model.parameters(), lr=0.05)
for step in range(301):
optimizer.zero_grad()
logits = model(images_train)
loss = F.cross_entropy(logits, labels_train)
loss.backward()
optimizer.step()
if step % 50 == 0:
with torch.no_grad():
test_logits = model(images_test)
test_loss = F.cross_entropy(test_logits, labels_test)
accuracy = (test_logits.argmax(-1) == labels_test).float().mean()
print(f"step {step:>4} test_loss={test_loss:.4f} test_acc={accuracy:.3f}")Reading the two side by side, nearly every JAX line has a direct PyTorch counterpart — nnx.jit in place of nothing-needed-because-eager-anyway, nnx.grad(..., has_aux=True) in place of .backward(), optimizer.update(model, grads) in place of optimizer.step(). The training loop shapes are the same; what changed is that every “hidden” mutation in the PyTorch version (gradients accumulating into .grad, the optimizer mutating parameters, model.eval()/torch.no_grad() context managers changing global state) is either explicit (grad_fn, optimizer.update) or handled by the NNX wrapper on your behalf under a functional contract, rather than left as ambient global state.
Trained this way (SGD, lr=0.05, ~300 steps) this reaches roughly 97-98% test accuracy on the digits dataset — comparable to what you’d get from the equivalent PyTorch script.
15. Multi-Device Parallelism: Sharding, jit, and shard_map
PyTorch’s multi-GPU story usually means DistributedDataParallel (data parallel, one full model replica per GPU, gradients all-reduced) or, for very large models, FSDP/tensor-parallel libraries layered on top. JAX takes a different approach: a jax.Array itself knows how it’s distributed across devices, via an explicit Sharding, and jax.jit will automatically insert the necessary communication (all-reduce, all-gather, etc.) based on those shardings — without you writing NCCL calls or wrapping your model in a parallelism-specific class.
This is the area of JAX that changes fastest across versions — treat the exact API surface below as illustrative of the concepts (mesh,
PartitionSpec, shardedjit), and check the current Distributed arrays and automatic parallelization docs for the exact API in your installed JAX version. Older code and tutorials you’ll find online often usejax.pmap, which predates this sharding API and is being superseded by it for new code.
Meshes and PartitionSpec
A mesh names your physical devices along one or more logical axes; a PartitionSpec (jax.P) then describes, per array axis, which mesh axis (if any) it’s split across:
import jax
import jax.numpy as jnp
jax.set_mesh(jax.make_mesh((4, 2), ('X', 'Y'))) # 8 devices arranged as a 4x2 logical grid
x = jnp.arange(32.0).reshape(8, 4)
x = jax.device_put(x, jax.P('X', 'Y')) # axis 0 split across mesh axis 'X', axis 1 across 'Y'
print(jax.typeof(x)) # float32[8@X,4@Y]y = jax.device_put(x, jax.P('Y', 'X')) # a different split
z = jax.device_put(x, jax.P('X', None)) # split over 'X', replicated over 'Y'jax.jit auto-parallelizes based on sharding
Once inputs carry a sharding, ordinary jit-compiled code is automatically partitioned across devices — reductions like .sum() transparently become all-reduces:
@jax.jit
def compute(x):
y = x.sum(0) # the compiler inserts an all-reduce across the sharded axis automatically
return jnp.sin(y)
x = jax.device_put(jnp.arange(8.0), jax.P('X'))
result = compute(x)Data parallelism vs. model parallelism, in one line each
# Data parallelism: shard the batch dimension across devices (like DistributedDataParallel)
x = jax.device_put(jnp.ones((batch_size, seq_len)), jax.P('X', None))
# Model/tensor parallelism: shard a weight matrix across devices (like tensor-parallel FSDP-style sharding)
params = jax.device_put(jnp.ones((model_dim, model_dim)), jax.P('Y', None))Because both are just Shardings on ordinary jax.Arrays, you can mix data and model parallelism on the same mesh (e.g. ('X', 'Y') = (data axis, model axis)) without separate parallelism frameworks for each.
Manual control: shard_map
When you want to write per-device code explicitly (and issue explicit collectives yourself, rather than letting the compiler infer them), jax.shard_map gives you that — each device runs your function on just its local shard:
mesh = jax.make_mesh((4, 2), ('X', 'Y'))
jax.set_mesh(mesh)
@jax.shard_map(out_specs=jax.P('X', None))
def manual_matmul(x_shard, y_shard):
z_partial = jnp.dot(x_shard, y_shard)
return jax.lax.psum_scatter(z_partial, 'X', tiled=True) # explicit collective
result = manual_matmul(x, y)Compare to PyTorch: this is roughly the JAX analogue of choosing between torch.compile + DTensor (automatic, sharding-annotation-driven parallelism, closest to jit + Sharding) versus hand-writing torch.distributed collectives yourself inside a custom parallel module (closest to shard_map). If you only need standard data parallelism across a handful of GPUs, jax.device_put(..., jax.P('X', ...)) plus ordinary jit gets you most of the way with far less code than either PyTorch approach.
16. PyTorch → JAX Cheat Sheet
| PyTorch | JAX / ecosystem | Notes |
|---|---|---|
torch.tensor(...) |
jnp.array(...) |
JAX arrays are immutable |
x[i] = v |
x = x.at[i].set(v) |
Functional update, returns a new array |
x.requires_grad_() + .backward() |
jax.grad(f)(x) |
Differentiability lives on the function, not the array |
loss.backward(); opt.step() |
grads = jax.grad(loss_fn)(params); updates, opt_state = optimizer.update(grads, opt_state); params = optax.apply_updates(params, updates) |
Optax optimizers are pure functions over explicit state |
nn.Module |
flax.nnx.Module |
NNX modules hold params as attributes but stay pytree-compatible |
nn.Parameter |
nnx.Param |
|
model.parameters() |
nnx.state(model, nnx.Param) / the model’s pytree of nnx.Params |
|
torch.optim.Adam(...) |
optax.adam(...) |
Wrap with nnx.Optimizer(model, tx, wrt=nnx.Param) for the NNX-friendly version |
torch.compile(model) |
jax.jit(fn) / nnx.jit(fn) |
jit is the default way to run JAX code, not an opt-in |
torch.func.vmap |
jax.vmap |
First-class and idiomatic in JAX, not a bolt-on |
torch.manual_seed(0) + global RNG |
key = jax.random.key(0), then key, subkey = jax.random.split(key) per use |
No global/hidden RNG state, ever |
tensor.detach() |
jax.lax.stop_gradient(x) |
|
with torch.no_grad(): ... |
Just don’t call jax.grad on that code path |
Purity means there’s no “mode” to toggle |
model.eval() / model.train() |
Explicit deterministic=True/False (or similar) args threaded through __call__ |
No hidden global mode flag |
DistributedDataParallel |
jax.device_put(x, jax.P('data_axis', ...)) + jit |
Sharding is a property of the array, not a wrapper class |
if x > 0: ... inside a compiled region |
jax.lax.cond(pred, true_fn, false_fn, x) |
Needed only when x is a traced value under jit |
Python while/for loop over tensors |
jax.lax.while_loop / jax.lax.scan |
Needed when the trip count is itself traced, or to avoid huge unrolled compiles |
torch.autograd.detect_anomaly() |
jax.config.update("jax_debug_nans", True) |
|
tensor.to("cuda") |
jax.device_put(x, device) |
JAX dispatch is asynchronous; use .block_until_ready() when timing |
17. Further Reading
- JAX documentation home
- Key Concepts
- Thinking in JAX
- Common Gotchas in JAX (“The Sharp Bits”)
- Just-in-time compilation
- Automatic differentiation
- Automatic vectorization
- Pytrees
- Pseudorandom numbers
- Control flow
- Stateful computations
- Distributed arrays and automatic parallelization
- Manual parallelism with shard_map
- Flax NNX basics
- Optax getting started
- JAX AI Stack: neural net basics


