TAI BUI
Back to curriculum
BuildPython

Introduction to JAX

PyTorch mutates tensors. TensorFlow builds graphs. JAX compiles pure functions. That last one changes how you think about deep learning.

TIME: ~90 minutesPREREQUISITES: Phase 03 Lessons 01-11, basic NumPy

Learning Objectives

  • Write pure-function neural network code using JAX's functional API (jax.numpy, jax.grad, jax.jit, jax.vmap)
  • Explain the key design difference between PyTorch's eager mutation and JAX's functional compilation model
  • Apply jit compilation and vmap vectorization to accelerate training loops compared to naive Python
  • Train a simple network in JAX and contrast the explicit state management with PyTorch's object-oriented approach

The Problem

You know how to build neural networks in PyTorch. Define an nn.Module, call .backward(), step the optimizer. It works, and millions of people use it every day.

But PyTorch has a real constraint: it traces operations eagerly, one at a time, in Python. Every tensor + tensor is a separate kernel launch, and every training step re-interprets the same Python code. That's fine at normal scale. It stops being fine the moment you need to train a 540-billion-parameter model across 2,048 TPUs, where the overhead compounds into real time and real money.

JAX is NumPy with three superpowers: automatic differentiation, JIT compilation to XLA, and automatic vectorization. Write a function that processes one example, and JAX hands you back a function that processes a batch, computes gradients, compiles to machine code, and runs across multiple devices, all without touching the original function.

Google DeepMind trains Gemini on JAX. Anthropic trained Claude on JAX. These aren't small operations: they're the largest neural network training runs on Earth, and both labs chose JAX because it treats a training loop as a compilable program rather than a sequence of Python calls.

Warm-up0/3
1. In general, why does compiled machine code typically run faster than code executed by an interpreter like Python, for the same numerical computation?Warm-up
Two listings of the same sum-of-squares computation over 10 million numbers, one Python script run with python3 and one C program built with gcc -O2, each printing 129999966, with the measured wall time of each run.
Real runs on this machine, best of two. The C source is reformatted for display; both programs build the list or array first, then sum.
2. In a PyTorch training loop, what does calling loss.backward() do?Warm-up
A five-line PyTorch training loop with the loss.backward() line highlighted, next to the state of a one-weight model just before that line: x 3.0, target 1.0, w 2.0, loss 25.0 and w.grad None.
Values from a real torch run, captured before backward() is called.
3. Why does a standard PyTorch training loop call optimizer.zero_grad() on every step?Warm-up
A PyTorch loop with the optimizer.zero_grad() line highlighted, next to a table of three batches (x and target values) with the loss and the gradient on w that each batch gives by itself: 6.0, -2.0 and 8.0.
Per-batch gradients from a real torch run with w = 2.0.

The Concept

The JAX Philosophy

JAX is a functional framework: no classes, no mutable state, no .backward() method. Instead:

PyTorchJAX
nn.Module class with statePure function: f(params, x) -> y
loss.backward()jax.grad(loss_fn)(params, x, y)
Eager executionJIT compilation via XLA
for x in batch: manual loopjax.vmap(f) auto-vectorization
DataParallel / FSDP (PyTorch's multi-GPU wrappers)jax.pmap(f) auto-parallelism
Mutable model.parameters()Immutable pytree of arrays

This isn't a style preference, it's a compiler constraint. JIT compilation requires pure functions: the same inputs always produce the same outputs, with no side effects. That restriction is exactly what makes 100x speedups possible.

jax.numpy: The Familiar Surface

JAX reimplements the NumPy API on accelerators:

python
import jax.numpy as jnp
 
a = jnp.array([1.0, 2.0, 3.0])
b = jnp.array([4.0, 5.0, 6.0])
c = jnp.dot(a, b)

Same function names, same broadcasting rules, same slicing semantics. The difference is that these arrays live on GPU or TPU, and every operation is traceable by the compiler.

One critical difference: JAX arrays are immutable. There's no a[0] = 5, only a = a.at[0].set(5). It feels awkward for about a week. Then it clicks: immutability is exactly what makes transformations like grad, jit, and vmap composable.

jax.grad: Functional Autodiff

jax.grad takes a function and returns a new function that computes its gradient. PyTorch attaches gradients to tensors (.grad); JAX attaches them to functions instead:

python
import jax
 
def f(x):
    return x ** 2
 
df = jax.grad(f)
df(3.0)

There's no .backward() call and no computation graph stored on tensors. The gradient is just another function you can call, compose, or JIT-compile.

This composes arbitrarily:

python
d2f = jax.grad(jax.grad(f))
d2f(3.0)

Second derivatives, third derivatives, Jacobians, Hessians: all of it by composing grad. PyTorch can do this too, through torch.autograd.functional.hessian, but it's bolted on. In JAX, composable grad is the foundation.

The constraint: grad only works on pure functions. No print statements inside, since they run during tracing rather than execution, no mutation of external state, and no random number generation without explicit key management.

jit: Compile to XLA

jax.jit traces a function once, hands that trace to XLA, and swaps in the compiled version for every call after:

python
@jax.jit
def train_step(params, x, y):
    loss = loss_fn(params, x, y)
    return loss
 
fast_step = jax.jit(train_step)

On the first call, JAX traces the function: it records which operations happen without executing them. Then it hands that trace to XLA (Accelerated Linear Algebra), Google's compiler for TPUs and GPUs. XLA fuses operations, eliminates redundant memory copies, and generates optimized machine code.

Subsequent calls skip Python entirely. The compiled code runs on the accelerator at C++ speed.

When JIT helps:

  • Training steps (same computation repeated thousands of times)
  • Inference (same model, different inputs)
  • Any function called more than once with similar-shaped inputs

When JIT hurts:

  • Functions with Python control flow that depends on values (if x > 0 where x is a traced array)
  • One-shot computations, where compilation overhead exceeds runtime
  • Debugging, since tracing hides the actual execution

The control flow restriction is real. jax.lax.cond replaces if/else, and jax.lax.scan replaces for loops. Neither is optional. They're the price of compilation.

vmap: Automatic Vectorization

Write a function that processes one example:

python
def predict(params, x):
    return jnp.dot(params['w'], x) + params['b']

vmap lifts it to process a batch:

python
batch_predict = jax.vmap(predict, in_axes=(None, 0))

in_axes=(None, 0) means don't batch over params, since it's shared, but do batch over axis 0 of x. There's no manual for loop, no reshaping, no batch dimension threading by hand. JAX figures out the batch dimension itself and vectorizes the entire computation.

This isn't syntactic sugar. vmap generates fused vectorized code that runs 10-100x faster than a Python loop, and it composes with jit and grad:

python
per_example_grads = jax.vmap(jax.grad(loss_fn), in_axes=(None, 0, 0))

Per-example gradients, in one line, something that's nearly impossible in PyTorch without hacks.

pmap: Data Parallelism Across Devices

pmap replicates a function across every available device, GPUs or TPUs, and splits the batch between them:

python
parallel_step = jax.pmap(train_step, axis_name='devices')

Inside the function, jax.lax.pmean and jax.lax.psum synchronize gradients across devices. Google trains Gemini across thousands of TPU v5e chips this way, using pmap and its successor shard_map. The whole programming model comes down to: write the single-device version, wrap it with pmap, done.

Pytrees: The Universal Data Structure

JAX operates on "pytrees": nested combinations of lists, tuples, dicts, and arrays. Your model parameters are a pytree:

python
params = {
    'layer1': {'w': jnp.zeros((784, 256)), 'b': jnp.zeros(256)},
    'layer2': {'w': jnp.zeros((256, 128)), 'b': jnp.zeros(128)},
    'layer3': {'w': jnp.zeros((128, 10)),  'b': jnp.zeros(10)},
}

Every JAX transformation, grad, jit, vmap, knows how to traverse pytrees. jax.tree.map(f, tree) applies f to every leaf, which is how optimizers update all parameters at once:

python
params = jax.tree.map(lambda p, g: p - lr * g, params, grads)

There's no .parameters() method and no parameter registration. The tree structure is the model.

Functional vs Object-Oriented

PyTorch stores state inside objects:

python
class Model(nn.Module):
    def __init__(self):
        self.linear = nn.Linear(784, 10)
 
    def forward(self, x):
        return self.linear(x)

JAX uses pure functions with explicit state instead:

python
def predict(params, x):
    return jnp.dot(x, params['w']) + params['b']

The params are passed in, nothing is stored, and nothing is mutated. That's what makes every function testable, composable, and compilable. It also means you're on the hook for managing the params yourself, or you reach for a library like Flax or Equinox.

The JAX Ecosystem

JAX gives you the primitives; libraries give you the ergonomics:

LibraryRoleStyle
Flax (Google)Neural network layersnn.Module with explicit state
Equinox (Patrick Kidger)Neural network layersPytree-based, Pythonic
Optax (DeepMind)Optimizers + LR schedulesComposable gradient transforms
Orbax (Google)CheckpointingSave/restore pytrees
CLU (Google)Metrics + loggingTraining loop utilities

Optax is the standard optimizer library. It separates the gradient transformation (Adam, SGD, clipping) from the parameter update, which makes the two trivial to compose:

python
optimizer = optax.chain(
    optax.clip_by_global_norm(1.0),
    optax.adam(learning_rate=1e-3),
)

When to Use JAX vs PyTorch

FactorJAXPyTorch
TPU supportFirst-class (Google built both)Community-maintained (torch_xla)
GPU supportGood (CUDA via XLA)Best-in-class (native CUDA)
DebuggingHard (tracing + compilation)Easy (eager, line-by-line)
EcosystemResearch-focused (Flax, Equinox)Massive (HuggingFace, torchvision, etc.)
HiringNiche (Google/DeepMind/Anthropic)Mainstream (everywhere)
Large-scale trainingSuperior (XLA, pmap, mesh)Good (FSDP, DeepSpeed)
Prototyping speedSlower (functional overhead)Faster (mutate and go)
Production inferenceTensorFlow Serving, Vertex AITorchServe, Triton, ONNX
Who uses itDeepMind (Gemini), Anthropic (Claude)Meta (Llama), OpenAI (GPT), Stability AI

The honest answer: use PyTorch unless you have a specific reason to reach for JAX instead. Those reasons are narrow: TPU access, a real need for per-example gradients, multi-device training at massive scale, or working somewhere like Google, DeepMind, or Anthropic.

Random Numbers in JAX

JAX has no global random state. Every random operation requires an explicit PRNG key:

python
key = jax.random.PRNGKey(42)
key1, key2 = jax.random.split(key)
w = jax.random.normal(key1, shape=(784, 256))

It's annoying at first, but it buys you something real: reproducibility across devices and compilations, a guarantee PyTorch's torch.manual_seed can't make in multi-GPU settings.

Check: The Concept0/27
1. What is XLA?Define
A jax.numpy function tanh(x @ w + b) with its shapes, its jaxpr listing a dot_general, a broadcast_in_dim, an add and a tanh, and the StableHLO text of the same five operations that jax.jit lowers it to.
Real output from JAX 0.11.2 on CPU; attributes trimmed and the jaxpr printed in a shortened form.
2. What does jax.jit do?Define
A function slow_fn decorated with @jax.jit that runs ten rounds of a matrix product and normalisation, and a line saying it is called four times in a row on an input of shape (256, 256).
Code typed for this question; the timings are in the solved view.
3. What is a pure function in JAX's sense?Define
Two small Python functions, f(x, w) returning w * x + 1.0 and g(x) that increments a global calls counter and returns x * calls, each called twice with the same arguments: f gives 7.0 and 7.0, g gives 2.0 and 4.0.
Output of actually running the code.
4. What does jax.vmap do?Define
Code for a single-example predict(params, x) and a jax.vmap(predict, in_axes=(None, 0)) of it, with real output: predict on one row of X gives shape () and X has shape (4, 3).
Real JAX 0.11.2 run; X holds the numbers 0.0 to 1.1 in steps of 0.1.
5. What does jax.pmap do?Define
A jax.pmap(lambda a: a * 2) call on a 4 by 3 array x with four CPU devices cpu:0 to cpu:3 listed, and the four rows of x written out.
Real JAX 0.11.2 setup with four forced host devices.
6. What is a pytree in JAX, and what does jax.tree.map let you do with one?Define
The lesson's three-layer params dictionary drawn as a tree with leaves w and b under layer1, layer2 and layer3 and their shapes.
The dictionary is written with zeros arrays; shapes are the lesson's init_params shapes.
7. What is tracing in JAX?Define
A jax.jit function g that prints its argument and returns x * 2, called twice with jnp.ones(3).
Code typed for this question; the printed output is in the solved view.
8. What does 'functional autodiff' mean for jax.grad?Define
Two code listings computing the derivative of x ** 2 at 3.0: PyTorch with requires_grad, y.backward() and x.grad, and JAX with df = jax.grad(f) and df(3.0), both giving 6.
Both real runs (torch 2.14, JAX 0.11.2); the JAX result prints as Array(6., dtype=float32).
9. Which pairing of JAX library and role is correct?Define
A box for JAX with its primitives jit, grad, vmap, pmap and jax.numpy, and four boxes joined to it for Flax (Google), Equinox (Patrick Kidger), Optax (DeepMind) and Orbax (Google), each with its module name, and no roles written on them.
Maintainers as listed in the lesson; module names are the import names.
10. What is Optax?Define
An Optax optimizer built with optax.chain of clip_by_global_norm(1.0) and adam(1e-3), the update and apply_updates calls, and a table of real numbers: params [1.0, 2.0], grads [3.0, 4.0], updates [-0.001, -0.001], new params [0.999, 1.999].
Real Optax 0.2.8 run on a one-array example.
11. What is the fundamental design difference between PyTorch and JAX?Explain
Two code excerpts of one training step: PyTorch calling loss.backward(), optimizer.step() and optimizer.zero_grad(), and JAX defining a jitted train_step(params, opt_state, x, y) and calling it as params, opt_state, loss = train_step(...).
12. Why does jax.jit require pure functions?Explain
A function step that increments a global calls counter and returns x * 2, run eagerly three times: results [2.0, 2.0, 2.0] and calls equal to 3, with a note that the next step wraps it in jax.jit.
Real JAX 0.11.2 run.
13. Why can't you write `a[0] = 5` on a JAX array the way you would on a NumPy array?Explain
Two listings of the assignment a[0] = 5: on a NumPy array it works and prints [5. 2. 3.], on a JAX array it raises a TypeError whose message is not shown.
Real runs; the full JAX error text is left out of this picture.
14. How does jax.grad's composability compare with PyTorch's approach to higher-order derivatives?Explain
Code for the second derivative of x ** 2 at 3.0 in two libraries: torch.autograd.functional.hessian(f, x) printing tensor(2.) and jax.grad(jax.grad(f))(3.0) printing Array(2., dtype=float32).
Real runs (torch 2.14, JAX 0.11.2).
15. In `jax.vmap(predict, in_axes=(None, 0))`, why is the first entry None?Explain
The lesson's predict function and its jax.vmap call with in_axes=(None, 0), with a table of shapes: params['w'] (3,), params['b'] (), batch_x (5, 3) and results (5,).
Shapes from the lesson's demo_vmap, run for real.
16. Why does a vmap'd function typically beat a Python loop over the examples?Explain
Code for predicting 1000 examples with a Python loop over a jitted single-example function and with jax.vmap, and log-scale bars of their measured times: about 153 ms for the loop and 0.62 ms for vmap.
Real run, JAX 0.11.2 on CPU, average of 5 runs after warm-up. Absolute times vary by machine.
17. How does JAX handle random number generation differently from PyTorch, and why?Explain
Two code listings drawing two random vectors of length 2 from seed 42: PyTorch with torch.manual_seed(42) and two torch.randn(2) calls, JAX with PRNGKey(42), a split into ka and kb and random.normal on each, with the real values printed.
Real runs (torch 2.14, JAX 0.11.2).
18. For `f(x) = x ** 2` and `df = jax.grad(f)`, what does `jax.grad(df)(3.0)` return?Apply
Code defining f(x) = x ** 2 and df = jax.grad(f) with the query jax.grad(df)(3.0), above plotted curves of f (reaching 9.0 at x = 3.0) and df (reaching 6.0 at x = 3.0) for x from 0 to 5.
Curves of f and of its derivative df computed here; df(3.0) = 6.0 matches a real JAX run.
19. Given `params = jax.tree.map(lambda p, g: p - lr * g, params, grads)`, what is the new value of a leaf with p = 1.0 and g = 0.5 when lr = 0.1?Apply
The jax.tree.map update line with lr = 0.1, and two pytrees of the same shape, params and grads, whose matching highlighted leaves hold 1.0 and 0.5.
20. With `per_example_grads = jax.vmap(jax.grad(loss_fn), in_axes=(None, 0, 0))` called on a batch of 32 examples, what do you get back?Apply
The per_example_grads vmap(grad(loss_fn), in_axes=(None, 0, 0)) code with the argument shapes: the lesson's MLP params, x of shape (32, 784) and y of shape (32,).
Shapes from the lesson's init_params and loss_fn, run for real with random inputs.
21. Your `@jax.jit`-decorated function contains `if x > 0: ... else: ...` where `x` is a traced array whose value changes on every call. Calling it raises a tracing error. What's going on?Analyze
A jax.jit function f with an if x > 0 branch on its array argument, called with jnp.array(1.0), which raises an error.
Code typed for this question; the error message is in the solved view.
22. A teammate writes `in_axes=(0, 0)` for `predict(params, x)` and the call fails. What wrong assumption did they make?Analyze
A predict function and a jax.vmap call with in_axes=(0, 0) marked as raising an error, with the argument shapes: params['w'] (3,), params['b'] (), batch_x (5, 3).
23. A function that increments a counter in an outer variable works in eager mode but misbehaves under @jax.jit. What is the underlying problem?Analyze
A function f that increments a global counter and returns x + counter, with the result of three eager calls: [2.0, 3.0, 4.0] and counter 3.
Real JAX 0.11.2 run.
24. Why is debugging a JAX function usually harder than debugging the same PyTorch code?Analyze
Two versions of the same small forward function with print lines after each step, one plain PyTorch and one decorated with @jax.jit.
25. A team has TPU pod access, needs per-example gradients, and plans multi-device training at very large scale. Which framework fits?Design
Three requirement boxes (TPU pod access, per-example gradients, very large multi-device training), a grid of TPU chips marked illustrative and four examples x1 to x4 each with its own gradient g1 to g4.
Chip count is illustrative.
26. You add dropout layers to the JAX forward pass. How should the randomness be supplied?Design
The lesson's three-layer forward function with two comment lines marking where dropout goes after each ReLU, and a table of the activation shapes there for a batch of 128: (128, 256) and (128, 128).
27. You have a working single-device train_step and want to run it across all available GPUs. Which design fits?Design
A single-device jitted train_step with value_and_grad, above four empty GPU boxes labelled GPU 0 to GPU 3, with the goal of running the step on all available GPUs.
The number of GPUs is illustrative.

Build It

Step 1: Setup and Data

We'll train a 3-layer MLP on MNIST using JAX and Optax: 784 inputs, two hidden layers of 256 and 128 neurons, 10 output classes.

python
import jax
import jax.numpy as jnp
from jax import random
import optax
 
def get_mnist_data():
    from sklearn.datasets import fetch_openml
    mnist = fetch_openml('mnist_784', version=1, as_frame=False, parser='auto')
    X = mnist.data.astype('float32') / 255.0
    y = mnist.target.astype('int')
    X_train, X_test = X[:60000], X[60000:]
    y_train, y_test = y[:60000], y[60000:]
    return X_train, y_train, X_test, y_test

Step 2: Initialize Parameters

No class, just a function that returns a pytree:

python
def init_params(key):
    k1, k2, k3 = random.split(key, 3)
    scale1 = jnp.sqrt(2.0 / 784)
    scale2 = jnp.sqrt(2.0 / 256)
    scale3 = jnp.sqrt(2.0 / 128)
    params = {
        'layer1': {
            'w': scale1 * random.normal(k1, (784, 256)),
            'b': jnp.zeros(256),
        },
        'layer2': {
            'w': scale2 * random.normal(k2, (256, 128)),
            'b': jnp.zeros(128),
        },
        'layer3': {
            'w': scale3 * random.normal(k3, (128, 10)),
            'b': jnp.zeros(10),
        },
    }
    return params

This is He-initialization done by hand: three PRNG keys split from one seed, and every weight stored as an immutable array in a nested dict.

Step 3: Forward Pass

python
def forward(params, x):
    x = jnp.dot(x, params['layer1']['w']) + params['layer1']['b']
    x = jax.nn.relu(x)
    x = jnp.dot(x, params['layer2']['w']) + params['layer2']['b']
    x = jax.nn.relu(x)
    x = jnp.dot(x, params['layer3']['w']) + params['layer3']['b']
    return x
 
def loss_fn(params, x, y):
    logits = forward(params, x)
    one_hot = jax.nn.one_hot(y, 10)
    return -jnp.mean(jnp.sum(jax.nn.log_softmax(logits) * one_hot, axis=-1))

These are pure functions: params in, prediction out, no self, no stored state. loss_fn computes cross-entropy from scratch: softmax, log, negative mean.

Step 4: JIT-Compiled Training Step

python
@jax.jit
def train_step(params, opt_state, x, y):
    loss, grads = jax.value_and_grad(loss_fn)(params, x, y)
    updates, opt_state = optimizer.update(grads, opt_state, params)
    params = optax.apply_updates(params, updates)
    return params, opt_state, loss
 
@jax.jit
def accuracy(params, x, y):
    logits = forward(params, x)
    preds = jnp.argmax(logits, axis=-1)
    return jnp.mean(preds == y)

jax.value_and_grad returns the loss value and the gradients in a single pass. The @jax.jit decorator compiles both functions to XLA, so after the first call, every training step runs without touching Python again.

Step 5: Training Loop

python
optimizer = optax.adam(learning_rate=1e-3)
 
X_train, y_train, X_test, y_test = get_mnist_data()
X_train, X_test = jnp.array(X_train), jnp.array(X_test)
y_train, y_test = jnp.array(y_train), jnp.array(y_test)
 
key = random.PRNGKey(0)
params = init_params(key)
opt_state = optimizer.init(params)
 
batch_size = 128
n_epochs = 10
 
for epoch in range(n_epochs):
    key, subkey = random.split(key)
    perm = random.permutation(subkey, len(X_train))
    X_shuffled = X_train[perm]
    y_shuffled = y_train[perm]
 
    epoch_loss = 0.0
    n_batches = len(X_train) // batch_size
    for i in range(n_batches):
        start = i * batch_size
        xb = X_shuffled[start:start + batch_size]
        yb = y_shuffled[start:start + batch_size]
        params, opt_state, loss = train_step(params, opt_state, xb, yb)
        epoch_loss += loss
 
    train_acc = accuracy(params, X_train[:5000], y_train[:5000])
    test_acc = accuracy(params, X_test, y_test)
    print(f"Epoch {epoch + 1:2d} | Loss: {epoch_loss / n_batches:.4f} | "
          f"Train Acc: {train_acc:.4f} | Test Acc: {test_acc:.4f}")

Ten epochs gets you roughly 97% test accuracy. The first epoch is slow because of JIT compilation; epochs 2 through 10 run fast.

Notice what's missing: no .zero_grad(), no .backward(), no .step(). The entire update is one composed function call, with gradients computed, transformed by Adam, and applied to parameters, all inside train_step.

Check: Build It0/7
1. In the training loop, `params` is reassigned every step (`params, opt_state, loss = train_step(...)`) instead of being mutated through a `self.parameters()` call like in PyTorch. What does that reflect?Explain
A PyTorch loop that builds Adam from model.parameters() and calls optimizer.step(), above the lesson's JAX loop that initializes params and opt_state and reassigns params, opt_state, loss = train_step(params, opt_state, xb, yb) with that line highlighted.
2. Why is the first epoch of the JAX training loop slower than epochs 2 through 10?Explain
The lesson's training loop of 468 train_step calls per epoch and a bar chart of the measured seconds for each of 10 epochs, the first about 2.9 and the rest between 0.9 and 1.2.
Real run: the lesson's init_params and train_step on random data shaped like MNIST (60000 by 784), CPU, one run. Timings vary by machine and run.
3. In jax_intro.py, `f(x) = x ** 3`, `df = jax.grad(f)`, `d2f = jax.grad(df)`. What do df(2.0) and d2f(2.0) return?Apply
The demo_grad function from jax_intro.py defining f(x) = x ** 3, df = jax.grad(f) and d2f = jax.grad(df) with three print lines, and its output where only f(2.0) = 8.0 is shown.
Real run of the lesson's demo_grad; the two derivative values are hidden here.
4. In demo_vmap, `batch_x` has shape (5, 3), and predict_single returns `jnp.dot(params['w'], x) + params['b']` for a 3-element x. What is `results.shape` from `jax.vmap(predict_single, in_axes=(None, 0))`?Apply
The lesson's demo_vmap: predict_single with its vmap call, the weight vector w of three numbers with b = 0.0, and the 5 by 3 table of batch_x values, with the shape of results left as a question.
Real values from the lesson's random keys (PRNGKey(42) split in two).
5. With 60,000 training images and batch_size = 128, how many batches does `n_batches = len(X_train) // batch_size` give per epoch?Apply
The lesson's batching code with batch_size = 128 and n_batches = len(X_train) // batch_size, a table of the first three batch slices X[0:128], X[128:256] and X[256:384], and len(X_train) = 60000 with 60000 / 128 = 468.75.
Slice bounds follow from start = i * 128.
6. In init_params, what is `scale3 = jnp.sqrt(2.0 / 128)`?Apply
A table of the three layers of the lesson's init_params with their weight shapes and scale expressions sqrt(2.0 / 784), sqrt(2.0 / 256) and sqrt(2.0 / 128), the first two values 0.0505 and 0.0884 and the third left as a question.
Values of the first two scales computed with Python; the lesson's code is quoted.
7. What does `loss, grads = jax.value_and_grad(loss_fn)(params, x, y)` return?Apply
The call loss, grads = jax.value_and_grad(loss_fn)(params, x, y) with its arguments: the lesson's three-layer MLP params, x of shape (32, 784) and y of shape (32,).
Arguments of the real run shown in the solved view: the lesson's init_params, loss_fn, random inputs.

Use It

Flax: The Google Standard

Flax is the most common JAX neural network library. It adds nn.Module back, but with explicit state management:

python
import flax.linen as nn
 
class MLP(nn.Module):
    @nn.compact
    def __call__(self, x):
        x = nn.Dense(256)(x)
        x = nn.relu(x)
        x = nn.Dense(128)(x)
        x = nn.relu(x)
        x = nn.Dense(10)(x)
        return x
 
model = MLP()
params = model.init(jax.random.PRNGKey(0), jnp.ones((1, 784)))
logits = model.apply(params, x_batch)

The structure looks like PyTorch, but params lives separately from the model: model.init() creates the params, model.apply(params, x) runs the forward pass, and the model object itself holds no state.

Equinox: The Pythonic Alternative

Equinox (by Patrick Kidger) represents models as pytrees:

python
import equinox as eqx
 
model = eqx.nn.MLP(
    in_size=784, out_size=10, width_size=256, depth=2,
    activation=jax.nn.relu, key=jax.random.PRNGKey(0)
)
logits = model(x)

The model itself is a pytree, so there's no .apply() needed. Parameters are just the model's leaves, which is closer to how JAX actually thinks.

Optax: Composable Optimizers

Optax decouples the gradient transformation from the update:

python
schedule = optax.warmup_cosine_decay_schedule(
    init_value=0.0, peak_value=1e-3,
    warmup_steps=1000, decay_steps=50000
)
 
optimizer = optax.chain(
    optax.clip_by_global_norm(1.0),
    optax.adamw(learning_rate=schedule, weight_decay=0.01),
)

Gradient clipping, learning rate warmup, and weight decay all compose as a chain of transforms: each one sees the gradients, modifies them, and passes them to the next, with no monolithic optimizer class in sight.

Check: Use It0/5
1. What is Flax?Define
The lesson's Flax MLP class with its init and apply calls, and real output: the model prints as MLP(), params hold bias and kernel arrays for Dense_0 to Dense_2 with their shapes, and logits have shape (32, 10).
Real Flax 0.12.10 output; the params tree is condensed to one line per Dense layer.
2. In Flax, why does the forward pass take params as an argument, as in `model.apply(params, x)`?Explain
The call a PyTorch module uses, model(x_batch), next to the Flax calls model.init(PRNGKey(0), jnp.ones((1, 784))) and model.apply(params, x_batch), with real output: the model prints as MLP() and params holds Dense_0, Dense_1 and Dense_2.
Real Flax 0.12.10 output.
3. What does Optax gain by keeping the gradient transformation separate from the parameter update?Explain
Code for optax.sgd(0.1) with separate opt.update and optax.apply_updates calls, drawn as a chain grads [3.0, 4.0], opt.update, updates [-0.3, -0.4], apply_updates, new params [0.7, 1.6] starting from params [1.0, 2.0].
Real Optax 0.2.8 run for the updates; new params follow from 1.0 - 0.3 and 2.0 - 0.4.
4. In Flax you call `model.apply(params, x)`, but in Equinox you call `model(x)`. What design difference explains this?Analyze
The lesson's Flax calls model.init and model.apply(params, x_batch) next to the Equinox constructor eqx.nn.MLP(784 in, 10 out, width 256, depth 2) and the call logits = model(x).
5. You need gradient clipping, a warmup-then-decay schedule, and weight decay in a JAX training step with minimal custom code. Which design fits?Design
A table of three needed training features, gradient clipping (acts on the gradients, norm at most 1.0), warmup then decay (acts on the learning rate) and weight decay 0.01 (acts on the parameters), above a plotted learning rate that rises to 1e-3 over 1,000 steps and decays to 0 by 50,000 steps.
The numbers are the lesson's Optax example settings; the curve is a cosine decay computed here.

Ship It

Installation:

bash
pip install jax jaxlib optax flax

For GPU support:

bash
pip install jax[cuda12]

For TPU (Google Cloud):

bash
pip install jax[tpu] -f https://storage.googleapis.com/jax-releases/libtpu_releases.html

Performance gotchas:

  • First JIT call is slow (compilation). Warm up before benchmarking.
  • Avoid Python loops over JAX arrays inside JIT. Use jax.lax.scan or jax.lax.fori_loop.
  • jax.debug.print() works inside JIT. Regular print() does not.
  • Profile with jax.profiler or TensorBoard. XLA compilation can hide bottlenecks.
  • JAX pre-allocates 75% of GPU memory by default. Set XLA_PYTHON_CLIENT_PREALLOCATE=false to disable.

Checkpointing:

python
import orbax.checkpoint as ocp
checkpointer = ocp.PyTreeCheckpointer()
checkpointer.save('/tmp/model', params)
restored = checkpointer.restore('/tmp/model')

This lesson produces:

  • outputs/prompt-jax-optimizer.md: a prompt for choosing the right JAX optimizer configuration
  • outputs/skill-jax-patterns.md: a skill covering functional patterns in JAX
Check: Ship It0/3
1. You want to see a value while a jitted function runs. Which tool do you use?Apply
A jax.jit function f that computes y = x * 2 and returns y + 1, with a comment marking where the value of y should be shown, called with 1.0, 2.0 and 3.0.
2. You benchmark the same JAX function 1,000 times, with and without `@jax.jit`. What pattern should you expect?Apply
Two benchmark loops over the same function f that run it 1000 times with block_until_ready and time each call, A calling f directly and B calling jax.jit(f).
3. A JAX job starts on a GPU shared with another process, and the other process immediately fails with an out-of-memory error even though your model is small. What is the likely cause and fix?Analyze
A diagram of one GPU shared by an existing job (process A) and a JAX job (process B) with a small 235,146-parameter model of about 0.94 MB, where starting B makes process A fail with an out-of-memory error.
Sizes follow from the lesson's MLP with float32 weights; the GPU itself is not measured.
Author's Note

Most people who work through this lesson will never touch JAX again day to day, and that's fine. Unless you're training at Google scale or need per-example gradients for something like differential privacy, PyTorch is still the right default. I said that above and I mean it.

What's worth keeping even if you never import jax again is the discipline underneath it: write your training step as a pure function first, figure out what state it actually needs, then decide how to manage that state, explicitly and on purpose, instead of letting a base class hand it to you. That habit alone makes PyTorch code easier to debug, because you stop discovering mutations three functions away that you never meant to happen.

One more thing: don't judge JAX by how it feels in week one. The immutable arrays and the explicit PRNG keys read like friction right up until the moment you need to reproduce a bug across two GPUs and torch.manual_seed won't get you there. That's usually the week it clicks.

Exercises

  1. Add dropout to the MLP. In JAX, dropout requires a PRNG key: thread a key through the forward pass and split it for each dropout layer, then compare test accuracy with and without.

  2. Use jax.vmap to compute per-example gradients for a batch of 32 MNIST images. Compute the gradient norm for each example. Which examples have the largest gradients, and why?

  3. Replace the manual forward function with a generic mlp_forward(params, x) that works for any number of layers. Use jax.tree.leaves to determine the depth automatically.

  4. Benchmark the training step with and without @jax.jit. Time 100 steps of each. How large is the speedup on your hardware? What is the compilation overhead on the first call?

  5. Implement gradient clipping by composing optax.chain(optax.clip_by_global_norm(1.0), optax.adam(1e-3)). Train with and without clipping. Plot the gradient norm over training to see the effect.

Key Terms

TermWhat people sayWhat it actually means
XLA"The thing that makes JAX fast"Accelerated Linear Algebra: a compiler that fuses operations and generates optimized GPU/TPU kernels from a computation graph
JIT"Just-in-time compilation"JAX traces the function on first call, compiles to XLA, then runs the compiled version on subsequent calls
Pure function"No side effects"A function where the output depends only on inputs: no global state, no mutation, no randomness without explicit keys
vmap"Auto-batching"Transforms a function that processes one example into one that processes a batch, without rewriting
pmap"Auto-parallelism"Replicates a function across multiple devices and splits the input batch
Pytree"Nested dict of arrays"Any nested structure of lists, tuples, dicts, and arrays that JAX can traverse and transform
Tracing"Recording the computation"JAX executes the function with abstract values to build a computation graph, without computing real results
Functional autodiff"grad of a function"Computing derivatives by transforming functions, not by attaching gradient storage to tensors
Optax"JAX's optimizer library"A composable library of gradient transformations (Adam, SGD, clipping, scheduling) that chain together
Flax"JAX's nn.Module"Google's neural network library for JAX, adding layer abstractions while keeping state explicit

Further Reading

  • JAX documentation: https://jax.readthedocs.io/, the official docs, with excellent tutorials on grad, jit, and vmap
  • "JAX: composable transformations of Python+NumPy programs" (Bradbury et al., 2018), the original paper explaining the design philosophy
  • Flax documentation: https://flax.readthedocs.io/, Google's neural network library for JAX
  • Patrick Kidger, "Equinox: neural networks in JAX via callable PyTrees and filtered transformations" (2021), the Pythonic alternative to Flax
  • DeepMind, "Optax: composable gradient transformation and optimisation", the standard optimizer library
  • "You Don't Know JAX" (Colin Raffel, 2020), a practical guide to JAX gotchas and patterns, from one of the T5 authors