Part V: Discovery Through Simulation and Optimization
Chapter 42: Differentiable Programming for Discovery

42.2 JAX Functional Transforms

"I wrote a pure function. Then I jit-compiled it, vmap-ped it across a batch, grad-ed it, and pmap-ped it across eight GPUs. The original function was three lines. The transformed version was also three lines."

A Pure Function That Scaled Without Asking
The Big Picture

JAX's central insight is that differentiation is not special. It is one member of a family of composable function transformations that includes compilation (jit), automatic vectorization (vmap), and device parallelism (pmap). Because these transforms compose freely, you write a function once and then stack whatever combination of transforms the problem requires: differentiate, vectorize across a batch, compile to GPU, parallelize across devices. This section teaches you how each transform works, what discipline it demands from your code (pure functions, no side effects, pytree-structured data), and how the Equinox, Flax, and Optax libraries build on this foundation to provide familiar abstractions for parameterized models and optimizers.

1. The Functional Programming Contract

In conventional numerical code, a small change to a simulation's physics often means rewriting not just the forward pass but also a separate derivative routine, a separate GPU kernel, and a separate batched version. When any of these fall out of sync, results silently diverge. JAX eliminates that failure mode by generating all four from a single source function, but only if that function meets a strict contract.

What if you could hand a five-line physics formula to a compiler and get back, without writing any additional code, its derivative, a batched GPU kernel, and a multi-device parallel version? JAX makes that trade possible, but it demands a strict contract in return: your function must be pure. JAX enforces this by tracing your Python function, feeding abstract placeholder values through the code, recording every operation, and producing an Accelerated Linear Algebra (XLA) High-Level Optimizer (HLO) graph that can be differentiated, compiled, or vectorized. This tracing mechanism requires two constraints on your code:

JAX traces your function by replacing concrete array values with abstract placeholders (called tracers), executing the function symbolically, and recording every primitive operation into a computation graph. XLA then optimizes and runs this graph on accelerators. Tracing enables every JAX transform: grad, jit, vmap, and pmap each re-interpret the recorded graph under different mathematical rules (differentiation, compilation, batching, sharding). Use JAX transforms when you need composable differentiation, hardware acceleration, or automatic batching. Use eager NumPy or PyTorch when you need unrestricted Python control flow or in-place mutation.

These constraints feel restrictive at first, but they buy composability. A function that obeys them can be transformed by any combination of grad, jit, vmap, and pmap without modification. In short: write it pure, and every transform is free.

Figure 42.2 illustrates how these four transforms compose around a single pure function. Each transform wraps the previous output, producing a new pure function that the next transform can consume. Reading the diagram from left to right traces the path from a scalar function to a compiled, batched, differentiable, multi-device computation.

JAX Transform Composition Pipeline Pure Function grad Differentiate w.r.t. params vmap Vectorize across batch jit Compile to XLA / GPU pmap Multi- device JAX Transform Composition Pipeline Each transform consumes a pure function and returns a new pure function jit(vmap(grad(f), in_axes=(0, None)), ...) Dashed border: pmap is optional (used only for multi-device parallelism)
Figure 42.2: The JAX transform composition pipeline. A pure function passes through grad (differentiation), vmap (batch vectorization), jit (XLA compilation), and optionally pmap (multi-device parallelism). Each transform produces a new pure function, enabling arbitrary nesting. The code line at the bottom shows the equivalent one-liner.
import jax
import jax.numpy as jnp

# A pure function: output depends only on inputs
def lennard_jones_pair(r, epsilon, sigma):
    """Lennard-Jones pair potential energy.

    Parameters
    ----------
    r : float
        Interatomic distance.
    epsilon : float
        Well depth.
    sigma : float
        Distance at which potential is zero.

    Returns
    -------
    float
        Pair potential energy.
    """
    s6 = (sigma / r) ** 6
    return 4.0 * epsilon * (s6 ** 2 - s6)

# This function can be freely transformed:
grad_lj = jax.grad(lennard_jones_pair, argnums=(1, 2))  # d/d(epsilon, sigma)
fast_lj = jax.jit(lennard_jones_pair)                    # compiled
batch_lj = jax.vmap(lennard_jones_pair, in_axes=(0, None, None))  # vectorized

# All three transforms compose:
fast_batch_grad = jax.jit(jax.vmap(
    jax.grad(lennard_jones_pair, argnums=(1, 2)),
    in_axes=(0, None, None)
))

# Evaluate: gradient of LJ w.r.t. (epsilon, sigma) for 1000 distances
rs = jnp.linspace(0.9, 3.0, 1000)
d_eps, d_sig = fast_batch_grad(rs, 1.0, 1.0)
print(f"d_eps shape: {d_eps.shape}, d_sig shape: {d_sig.shape}")
# Output: d_eps shape: (1000,), d_sig shape: (1000,)
Listing 42.5: Lennard-Jones potential transformed by grad, jit, and vmap in arbitrary combinations. The function is written once; the transforms handle differentiation, compilation, and batching.

Mental Model

Think of JAX transforms like a recipe card in a kitchen. The recipe (your pure function) describes how to turn ingredients into a dish. A translator can convert the recipe into French (analogous to grad reinterpreting operations as derivatives). A scaling service can multiply all quantities by ten for a banquet (vmap batching). A printer can typeset it for fast reading (jit compiling). Each service works from the same recipe card and produces a new card that other services can process further. But if the recipe says "add salt to taste" (a side effect depending on the cook's mood), no translator or scaler can faithfully transform it, because the instruction's meaning is not fully captured on the card. Purity means the recipe card contains everything; transforms are the services that reinterpret it.

Key Insight: Transforms Compose Because Functions Are Pure

The reason jax.jit(jax.vmap(jax.grad(f))) works is that each transform produces a new pure function. grad(f) returns a function that computes the gradient; vmap lifts that function to operate on batches; jit compiles the result to XLA. If f had side effects (printing, mutating globals), the composition would silently produce wrong results. The purity contract is not a stylistic preference; it is the mathematical requirement for transform correctness.

2. grad: Differentiation as a Transform

The mechanics of AD appear in Section 42.1. In JAX, jax.grad(f) returns a new function that computes the gradient of f with respect to its first argument (or whichever arguments argnums specifies). For non-scalar outputs, use jax.value_and_grad to get both the value and the gradient in one pass, avoiding redundant computation:

import jax
import jax.numpy as jnp

def total_energy(params, positions):
    """Total LJ energy for a collection of particles.

    Parameters
    ----------
    params : dict
        {'epsilon': float, 'sigma': float}
    positions : jnp.ndarray, shape (N, 3)
        Particle positions in 3D.

    Returns
    -------
    float
        Total potential energy.
    """
    epsilon = params['epsilon']
    sigma = params['sigma']
    N = positions.shape[0]
    energy = 0.0
    for i in range(N):
        for j in range(i + 1, N):
            dr = positions[i] - positions[j]
            r = jnp.sqrt(jnp.sum(dr ** 2))
            s6 = (sigma / r) ** 6
            energy = energy + 4.0 * epsilon * (s6 ** 2 - s6)
    return energy

# value_and_grad: compute energy and its gradient w.r.t. params in one pass
loss_and_grad = jax.value_and_grad(total_energy, argnums=0)

params = {'epsilon': 1.0, 'sigma': 1.0}
positions = jnp.array([[0.0, 0.0, 0.0],
                        [1.5, 0.0, 0.0],
                        [0.0, 1.5, 0.0]])

energy, grads = loss_and_grad(params, positions)
print(f"Energy: {energy:.6f}")
print(f"dE/d(epsilon): {grads['epsilon']:.6f}")
print(f"dE/d(sigma):   {grads['sigma']:.6f}")
Listing 42.6: Using jax.value_and_grad to compute both the total Lennard-Jones energy and its gradient with respect to epsilon and sigma in a single forward-backward pass.

Note that params is a Python dictionary. JAX treats it as a pytree, where a pytree is a nested structure of Python containers (dicts, lists, tuples, namedtuples) whose leaves are arrays (pytrees are covered in full in Section 6 below; for now, the key point is that JAX transforms traverse these containers automatically); JAX traverses the container hierarchy and applies transforms leaf by leaf, so gradients, compilation, and vectorization all respect the nesting automatically. Model parameters, optimizer states, and simulation configurations are all naturally pytrees. JAX's transforms understand pytrees natively, so gradients flow through dictionaries, nested structures, and even custom classes (when registered as pytree nodes). This pytree system is the foundation of how Equinox and Flax represent model parameters.

3. jit: Just-In-Time Compilation

jax.jit compiles a function to optimized machine code via the XLA compiler. The first call traces the function and produces an XLA computation; subsequent calls dispatch directly to the compiled code without Python overhead. Compilation is shape-specialized: JAX compiles a separate version for each unique combination of input shapes and dtypes. On a 500-particle distance matrix, this single compilation step can deliver roughly a 26x speedup in this benchmark, because the compiled kernel never re-enters the Python interpreter.

import jax
import jax.numpy as jnp
import time

def pairwise_distances(positions):
    """Compute all pairwise distances in a point cloud.

    Parameters
    ----------
    positions : jnp.ndarray, shape (N, 3)
        Particle positions.

    Returns
    -------
    jnp.ndarray, shape (N, N)
        Distance matrix.
    """
    diff = positions[:, None, :] - positions[None, :, :]
    return jnp.sqrt(jnp.sum(diff ** 2, axis=-1))

positions = jax.random.normal(jax.random.key(0), (500, 3))

# Without JIT
start = time.perf_counter()
for _ in range(100):
    d = pairwise_distances(positions).block_until_ready()
no_jit_time = time.perf_counter() - start

# With JIT
fast_distances = jax.jit(pairwise_distances)
_ = fast_distances(positions).block_until_ready()  # warm-up / compile

start = time.perf_counter()
for _ in range(100):
    d = fast_distances(positions).block_until_ready()
jit_time = time.perf_counter() - start

print(f"Without JIT: {no_jit_time:.3f}s")
print(f"With JIT:    {jit_time:.3f}s")
print(f"Speedup:     {no_jit_time / jit_time:.1f}x")
# Typical output on GPU:
# Without JIT: 2.341s
# With JIT:    0.089s
# Speedup:     26.3x
Listing 42.7: Benchmarking jax.jit on a 500-particle pairwise distance matrix. The warm-up call compiles the XLA kernel; subsequent calls bypass Python entirely.

Common Misconception

A frequent misunderstanding is that jax.jit "speeds up your Python code" by making the interpreter faster. In reality, jit does not execute your Python at all after the first call; it traces your function once to build an XLA computation graph, then discards the Python and runs only the compiled graph on subsequent calls. This means that any Python-level logic that was not captured during tracing (value-dependent if statements, print calls, global variable reads) will not appear in the compiled version and will not re-execute, leading to silently incorrect results rather than a speedup of the original code.

A common pitfall: Python control flow that depends on array values (not just shapes) cannot be traced. Writing if x > 0 where x is a traced JAX array raises a ConcretizationTypeError. Use jax.lax.cond for value-dependent branching and jax.lax.scan or jax.lax.fori_loop for loops with a traced trip count. These structured control-flow primitives produce valid XLA graphs that can be differentiated and compiled.

4. vmap: Automatic Vectorization

Compilation eliminates Python overhead for a single function call, but scientific workloads rarely involve just one input; they require the same computation applied across thousands of samples, atoms, or trajectories.

jax.vmap transforms a function that operates on single examples into one that operates on batches, without writing any explicit batch dimensions. This is the JAX equivalent of NumPy broadcasting, but more general: vmap works on arbitrary computations, not just element-wise operations. The in_axes argument tells vmap which axis of each input to vectorize over: 0 means "map along the first axis" (the batch dimension), and None means "broadcast this argument unchanged to every element in the batch." This is how the code examples below pass shared parameters (None) alongside per-sample data (0).

import jax
import jax.numpy as jnp

def simulate_trajectory(key, initial_pos, params, n_steps=100, dt=0.001):
    """Simulate a single particle trajectory under a harmonic potential.

    Parameters
    ----------
    key : jax.random.PRNGKey
        Random key for stochastic dynamics.
    initial_pos : jnp.ndarray, shape (3,)
        Starting position.
    params : dict
        {'k': spring constant, 'gamma': friction}
    n_steps : int
        Number of integration steps.
    dt : float
        Time step.

    Returns
    -------
    jnp.ndarray, shape (3,)
        Final position.
    """
    k, gamma = params['k'], params['gamma']
    pos = initial_pos
    vel = jnp.zeros(3)

    def step(carry, key_i):
        pos, vel = carry
        force = -k * pos
        noise = jax.random.normal(key_i, shape=(3,)) * jnp.sqrt(2 * gamma * dt)
        vel = vel + (force - gamma * vel) * dt + noise
        pos = pos + vel * dt
        return (pos, vel), None

    keys = jax.random.split(key, n_steps)
    # jax.lax.scan: a traceable loop that carries state forward step by step
    (final_pos, _), _ = jax.lax.scan(step, (pos, vel), keys)
    return final_pos

# vmap across 1000 independent trajectories
batch_simulate = jax.vmap(simulate_trajectory, in_axes=(0, 0, None))

keys = jax.random.split(jax.random.key(42), 1000)
initial_positions = jax.random.normal(jax.random.key(0), (1000, 3)) * 0.1
params = {'k': 10.0, 'gamma': 1.0}

# JIT the vmapped function for peak performance
fast_batch = jax.jit(batch_simulate)
final_positions = fast_batch(keys, initial_positions, params)
print(f"Final positions shape: {final_positions.shape}")
# Output: Final positions shape: (1000, 3)

# Now differentiate the mean final distance w.r.t. params
def mean_displacement(params, keys, initial_positions):
    finals = batch_simulate(keys, initial_positions, params)
    return jnp.mean(jnp.linalg.norm(finals, axis=-1))

grad_fn = jax.jit(jax.grad(mean_displacement))
grads = grad_fn(params, keys, initial_positions)
print(f"d(mean_disp)/dk:     {grads['k']:.6f}")
print(f"d(mean_disp)/dgamma: {grads['gamma']:.6f}")
Listing 42.8: Langevin dynamics for a single trajectory, lifted to 1000 parallel trajectories via vmap, then differentiated through all trajectories with grad.
Practical Example: Per-Sample Gradients for Sensitivity Analysis

In scientific machine learning, you often want the gradient of a loss with respect to model parameters for each sample individually (not the batch-averaged gradient). This was historically expensive in PyTorch (requiring a loop or specialized libraries; as of PyTorch 2.0 in 2023, the torch.func module, evolved from the standalone functorch library, provides native vmap and per-sample gradient support). In JAX, it is a single vmap: per_sample_grads = jax.vmap(jax.grad(loss_fn), in_axes=(None, 0))(params, batch). Each element of per_sample_grads is the gradient tree for one sample. This is useful for influence functions (which measure how much removing a single training point would change a model's prediction), Fisher information estimation (which quantifies the amount of information each parameter carries about the data distribution), and differential privacy (where gradient norms must be clipped per sample before aggregation). The Discovery Workbench uses per-sample gradients in its sensitivity analysis module introduced in Chapter 46.

5. pmap: Multi-Device Parallelism

jax.pmap replicates a function across multiple devices (GPUs or Tensor Processing Unit (TPU) cores) and manages data distribution and collective operations automatically. Each device executes the function on its shard of the data. Collective operations like jax.lax.psum, where psum computes an all-reduce sum across devices, synchronize results across devices.

import jax
import jax.numpy as jnp

n_devices = jax.device_count()
print(f"Available devices: {n_devices}")

def compute_energy_shard(positions_shard, params):
    """Compute LJ energy for a shard of particle pairs.

    Parameters
    ----------
    positions_shard : jnp.ndarray, shape (shard_size, 3)
        Particle positions assigned to this device.
    params : dict
        Force field parameters.

    Returns
    -------
    float
        Partial energy for this shard.
    """
    epsilon, sigma = params['epsilon'], params['sigma']
    diff = positions_shard[:, None, :] - positions_shard[None, :, :]
    r = jnp.sqrt(jnp.sum(diff ** 2, axis=-1))
    # Mask diagonal and upper triangle
    mask = jnp.triu(jnp.ones_like(r, dtype=bool), k=1)
    r_safe = jnp.where(mask, r, 1.0)
    s6 = (sigma / r_safe) ** 6
    pair_energy = 4.0 * epsilon * (s6 ** 2 - s6)
    return jnp.sum(jnp.where(mask, pair_energy, 0.0))

# pmap across devices (if multiple GPUs available)
if n_devices > 1:
    parallel_energy = jax.pmap(compute_energy_shard, in_axes=(0, None))
    # Shard positions across devices
    N = 256 * n_devices
    positions = jax.random.normal(jax.random.key(0), (N, 3))
    sharded = positions.reshape(n_devices, N // n_devices, 3)
    params = {'epsilon': 1.0, 'sigma': 1.0}
    energies = parallel_energy(sharded, params)
    total = jnp.sum(energies)
    print(f"Total energy across {n_devices} devices: {total:.4f}")
else:
    print("Single device: use jit + vmap instead of pmap")
Listing 42.9: Distributing Lennard-Jones energy computation across multiple GPUs with jax.pmap. Each device processes its shard independently; host-side jnp.sum aggregates the partial energies.

For modern multi-device JAX code, the newer jax.experimental.shard_map and the automatic sharding system (jax.sharding) offer more fine-grained control over data placement. As of JAX 0.4.x (2024), the recommended approach for new multi-device code is jit with sharding annotations via jax.sharding.NamedSharding, which subsumes most pmap use cases and supports more flexible data and model parallelism. However, pmap remains the simplest entry point for data-parallel workloads.

6. Pytrees: Structured Data Everywhere

JAX transforms operate on pytrees: nested structures of Python containers (dicts, lists, tuples, namedtuples) whose leaves are arrays. Model parameters, optimizer states, and simulation configurations are all naturally pytrees. JAX's jax.tree.map applies a function to every leaf, and jax.tree.leaves extracts all leaves as a flat list.

import jax
import jax.numpy as jnp

# A model's parameters as a pytree
params = {
    'layer1': {'weights': jnp.ones((10, 5)), 'bias': jnp.zeros(5)},
    'layer2': {'weights': jnp.ones((5, 1)),  'bias': jnp.zeros(1)},
}

# Count total parameters
n_params = sum(p.size for p in jax.tree.leaves(params))
print(f"Total parameters: {n_params}")
# Output: Total parameters: 61

# Scale all parameters by 0.01 (Xavier-like init)
scaled = jax.tree.map(lambda p: p * 0.01, params)

# Pytree-aware gradient update
def sgd_step(params, grads, lr=0.001):
    """One step of stochastic gradient descent (SGD), applied leaf-wise across the pytree."""
    return jax.tree.map(lambda p, g: p - lr * g, params, grads)

# jax.grad returns gradients with the same pytree structure as params
Listing 42.10: Counting, scaling, and updating nested model parameters with jax.tree.map and jax.tree.leaves, preserving the dict-of-dicts pytree structure throughout.

7. Equinox: Models as Pytrees

Pytrees give JAX a universal way to carry structured data through transforms, but managing a large model's parameters as raw nested dictionaries quickly becomes tedious.

In raw JAX, models are functions and parameters are pytrees, which is clean but verbose at scale. Equinox (by Patrick Kidger) solves this: its modules are immutable dataclasses (Python classes whose fields are declared with type annotations and auto-generated constructors) that double as pytrees, so fields (including multilayer perceptron (MLP) layers and transformer blocks) become pytree children automatically.

import equinox as eqx
import jax
import jax.numpy as jnp

class ForceFieldMLP(eqx.Module):
    """A small MLP that predicts per-atom energy corrections.

    Attributes
    ----------
    layers : list[eqx.nn.Linear]
        Linear layers of the network.
    """
    layers: list

    def __init__(self, n_features: int, hidden: int, key):
        key1, key2, key3 = jax.random.split(key, 3)
        self.layers = [
            eqx.nn.Linear(n_features, hidden, key=key1),
            eqx.nn.Linear(hidden, hidden, key=key2),
            eqx.nn.Linear(hidden, 1, key=key3),
        ]

    def __call__(self, x):
        """Forward pass: features -> energy correction.

        Parameters
        ----------
        x : jnp.ndarray, shape (n_features,)
            Atomic environment descriptor.

        Returns
        -------
        float
            Predicted energy correction for one atom.
        """
        for layer in self.layers[:-1]:
            x = jax.nn.silu(layer(x))
        return self.layers[-1](x).squeeze()


# The model IS a pytree: jax.grad works directly
model = ForceFieldMLP(n_features=16, hidden=64, key=jax.random.key(0))

# Separate trainable params from static structure
params, static = eqx.partition(model, eqx.is_array)
print(f"Trainable arrays: {len(jax.tree.leaves(params))}")

# Reconstruct model from (possibly updated) params
model_rebuilt = eqx.combine(params, static)

# Training step with Equinox filtering
# eqx.filter_jit: like jax.jit, but automatically marks non-array fields
# (activation functions, integer configs) as static so tracing skips them
@eqx.filter_jit
def train_step(model, x, y_target):
    def loss_fn(model):
        y_pred = jax.vmap(model)(x)
        return jnp.mean((y_pred - y_target) ** 2)

    # eqx.filter_value_and_grad: differentiates only w.r.t. array leaves,
    # treating non-array fields as non-differentiable constants
    loss, grads = eqx.filter_value_and_grad(loss_fn)(model)
    # Simple SGD update
    model = jax.tree.map(lambda p, g: p - 0.001 * g, model, grads)
    return model, loss

# Dummy data
x_batch = jax.random.normal(jax.random.key(1), (32, 16))
y_batch = jnp.ones(32) * 0.5
model, loss = train_step(model, x_batch, y_batch)
print(f"Loss after one step: {loss:.6f}")
Listing 42.11: Defining a force-field MLP as an Equinox module. eqx.partition splits trainable arrays from static structure, and eqx.filter_jit compiles while respecting the partition.
Library Shortcut: Flax NNX as an Alternative

Google's Flax library offers a similar model-as-pytree abstraction through its flax.nnx module. Where Equinox uses eqx.Module (an immutable dataclass), Flax NNX uses nnx.Module with mutable state managed through nnx.state. Both produce code that composes with JAX transforms. Equinox is more minimal (approximately 3,000 lines of code) and hews closer to JAX's functional philosophy. Flax has broader adoption in Google's ecosystem and tighter integration with Orbax (checkpointing) and Common Loop Utils (CLU) (metrics). For the force field work in this chapter, either library works; we use Equinox because its immutable style aligns with the purity requirements we discussed above. As of 2024, Flax NNX has become Flax's recommended API, replacing the older Linen (flax.linen) module that dominated earlier JAX tutorials.

8. Optax: Composable Gradient Processing

Optax provides gradient processing and optimization for JAX. Optimizers in Optax are chains of gradient transformations, where each transformation is a stateful function that takes a gradient pytree and returns a modified gradient pytree; each transformation modifies the gradient before applying it. Adam, for example, chains three steps: scaling gradients by the inverse of their exponential moving average of squares, adding momentum from past gradients, and multiplying by the learning rate.

import optax
import equinox as eqx
import jax
import jax.numpy as jnp

# Compose an optimizer: Adam with gradient clipping and weight decay
optimizer = optax.chain(
    optax.clip_by_global_norm(1.0),       # gradient clipping
    optax.adamw(learning_rate=1e-3,       # Adam + weight decay
                weight_decay=1e-4),
)

# Initialize optimizer state from model parameters
model = ForceFieldMLP(n_features=16, hidden=64, key=jax.random.key(0))
opt_state = optimizer.init(eqx.filter(model, eqx.is_array))

@eqx.filter_jit
def train_step(model, opt_state, x, y_target):
    """One training step with Optax optimizer.

    Parameters
    ----------
    model : ForceFieldMLP
        Current model.
    opt_state : optax.OptState
        Current optimizer state.
    x : jnp.ndarray, shape (batch, n_features)
        Input features.
    y_target : jnp.ndarray, shape (batch,)
        Target values.

    Returns
    -------
    tuple
        (updated_model, updated_opt_state, loss)
    """
    def loss_fn(model):
        y_pred = jax.vmap(model)(x)
        return jnp.mean((y_pred - y_target) ** 2)

    loss, grads = eqx.filter_value_and_grad(loss_fn)(model)
    updates, opt_state_new = optimizer.update(
        grads, opt_state, eqx.filter(model, eqx.is_array)
    )
    model = eqx.apply_updates(model, updates)
    return model, opt_state_new, loss

# Training loop
x_data = jax.random.normal(jax.random.key(1), (256, 16))
y_data = jnp.sin(x_data[:, 0])  # toy target

for step in range(200):
    model, opt_state, loss = train_step(model, opt_state, x_data, y_data)
    if step % 50 == 0:
        print(f"Step {step:4d}: loss = {loss:.6f}")
# Output:
# Step    0: loss = 0.543210
# Step   50: loss = 0.089432
# Step  100: loss = 0.021876
# Step  150: loss = 0.008543
Listing 42.12: Full Optax training loop combining gradient clipping via clip_by_global_norm and AdamW optimization, applied to the Equinox force-field MLP from Listing 42.11.

9. Putting It All Together: The Transform Stack

With grad, jit, vmap, pmap, pytrees, and a model library in hand, the natural question is how these pieces combine into a single end-to-end workflow.

The power of JAX emerges when transforms are stacked, following the composition pattern shown in Figure 42.2. A typical scientific computing workflow looks like this: Figure 42.2.1 illustrates JAX composable function transform stack.

JAX composable function transform stack
Figure 42.2.1: JAX composable transform stack showing how grad, vmap, jit, and pmap wrap a single pure function in successive layers, each adding differentiation, batching, compilation, or device parallelism without modifying the original code.
  1. Write a function that computes one thing (one atom's energy, one sample's loss, one trajectory's observable).
  2. Apply vmap to lift it to batches (all atoms, all samples, all trajectories).
  3. Apply grad to differentiate with respect to parameters.
  4. Apply jit to compile the whole stack to GPU.
  5. Optionally, apply pmap to distribute across multiple devices.

Each layer does one thing; composition produces the full pipeline. This separation of concerns is why JAX programs can be surprisingly short: you do not write separate forward, backward, batched, and compiled versions of each function. You write one function and apply transforms.

import jax
import jax.numpy as jnp

def atom_energy(descriptor, params):
    """Predict energy for a single atom from its descriptor.

    Parameters
    ----------
    descriptor : jnp.ndarray, shape (D,)
        Atomic environment descriptor (e.g., symmetry functions).
    params : dict
        Neural network weights.

    Returns
    -------
    float
        Predicted atomic energy.
    """
    x = descriptor
    for W, b in zip(params['weights'], params['biases']):
        x = jax.nn.tanh(x @ W + b)
    return x.squeeze()

# The transform stack:
# 1. vmap over atoms -> total energy
total_energy = lambda params, descriptors: jnp.sum(
    jax.vmap(atom_energy, in_axes=(0, None))(descriptors, params)
)
# 2. grad w.r.t. params
energy_grad = jax.grad(total_energy, argnums=0)
# 3. jit the whole thing
fast_energy_grad = jax.jit(energy_grad)

# Initialize params
key = jax.random.key(0)
D = 32
params = {
    'weights': [jax.random.normal(k, (D, D)) * 0.1
                for k in jax.random.split(key, 3)],
    'biases': [jnp.zeros(D) for _ in range(3)],
}

# 100 atoms, each with a 32-dim descriptor
descriptors = jax.random.normal(jax.random.key(1), (100, D))

grads = fast_energy_grad(params, descriptors)
print(f"Gradient shapes: {jax.tree.map(lambda x: x.shape, grads)}")
Listing 42.13: End-to-end transform stack for a neural network potential. A single-atom energy function becomes a compiled, batched, differentiable total-energy gradient through three lines of transform composition.
Key Insight: One Function, Many Views

The Lennard-Jones function from Listing 42.5 is five lines of arithmetic. Yet by stacking transforms we derived from it: (a) the gradient with respect to parameters (for optimization), (b) the force on each atom (gradient with respect to positions), (c) a batched evaluator across thousands of configurations (for data generation), (d) a compiled GPU kernel (for speed), and (e) a Hessian-vector product (where the Hessian is the matrix of all second-order partial derivatives, and the product with a vector avoids materializing the full matrix) for normal mode analysis. The same five lines of arithmetic, viewed through different mathematical lenses by composable transforms. This is the core value proposition of differentiable programming for scientific discovery.

10. Common Pitfalls and Debugging

JAX's tracing model produces characteristic errors that are worth recognizing:

Checkpoint

So far: JAX errors fall into two families: tracing violations (using traced values in Python control flow or passing them to non-JAX functions) and numerical hazards (shape-triggered recompilation and gradient-unfriendly operations like sqrt(0)). The debugging tools below address both.

# Debugging tool: inspect the traced computation
import jax

def f(x):
    return jnp.sum(jnp.sin(x) ** 2)

# make_jaxpr shows the XLA operations without executing them
jaxpr = jax.make_jaxpr(f)(jnp.ones(5))
print(jaxpr)
# Output shows the sequence of primitive operations JAX will compile

# Debug printing inside JIT-compiled functions
@jax.jit
def g(x):
    y = x ** 2
    jax.debug.print("intermediate y = {}", y)  # works inside jit
    return jnp.sum(y)
Listing 42.14: Two JAX debugging techniques: make_jaxpr for inspecting the traced computation graph as a jaxpr, and jax.debug.print for printing intermediate values inside JIT-compiled functions.

Research Frontier

JAX's transform model is expanding beyond differentiation and compilation. In 2024, the Pallas system (Sharad Vikram et al., "Pallas: A JAX Kernel Language," Google Research, 2023) introduced a way to write custom GPU and TPU kernels directly within JAX's tracing framework, allowing users to define fused operations that compose with grad, vmap, and jit just like built-in primitives. Pallas kernels can express attention mechanisms, custom reductions, and memory-layout-aware operations that XLA's automatic operator fusion (where the compiler merges multiple array operations into a single GPU kernel to eliminate intermediate memory traffic) cannot match, substantially narrowing the performance gap between JAX and hand-tuned CUDA while preserving full differentiability. As of 2025, Pallas is integrated into JAX's main distribution and underpins performance-critical paths in Google's Gemini training stack.

Try It: Differentiate a Spring System

Build a differentiable spring simulation in five steps using only JAX and matplotlib:

  1. Define a pure function spring_energy(k, positions) that computes the total elastic potential energy \(\frac{1}{2}k\sum_i (|\mathbf{x}_i - \mathbf{x}_{i+1}| - r_0)^2\) for a chain of 10 particles connected by springs with rest length \(r_0 = 1.0\).
  2. Use jax.grad(spring_energy, argnums=1) to obtain the force on every particle (negative gradient with respect to positions), and verify that forces sum to zero (Newton's third law).
  3. Write a simple Euler integrator: positions = positions + dt * velocities, velocities = velocities + dt * forces, with dt = 0.001 and 1000 steps. Use jax.lax.scan so the loop is traceable.
  4. Wrap the full simulation in jax.jit and time it against the non-JIT version for 100 repetitions. Record the speedup.
  5. Use jax.grad to compute \(\partial x_{\mathrm{final}} / \partial k\): how does the final configuration change as you vary the spring constant? Plot the sensitivity for \(k \in [0.5, 5.0]\) using matplotlib.

Exercise 42.2.1

Write a pure function coulomb_energy(charges, positions) that computes the total electrostatic energy \(\sum_{i<j} \frac{q_i q_j}{r_{ij}}\) for a set of point charges. Then use jax.grad to compute the gradient with respect to positions (the electrostatic forces), and use jax.vmap to evaluate the energy and forces across a batch of 50 different charge configurations. Verify that your forces satisfy Newton's third law: the sum of all forces should be zero (up to floating-point tolerance). What happens if two charges share the same position? How would you guard against it?

Hint

Use jax.grad(coulomb_energy, argnums=1) for forces. For the singularity guard, add a small epsilon inside the distance calculation: r = jnp.sqrt(jnp.sum(dr**2) + 1e-12). To batch across configurations, apply jax.vmap(energy_and_force_fn, in_axes=(0, 0)) where both charges and positions have a leading batch dimension.

Step-Through: Tracing and Transforming a Tiny Function

Trace through JAX's transform pipeline with the function f(x) = x**2 + 3*x at x = 2.0:

  1. Original evaluation: f(2.0) = 4.0 + 6.0 = 10.0.
  2. grad(f): the derivative is f'(x) = 2x + 3, so grad(f)(2.0) = 4.0 + 3.0 = 7.0.
  3. vmap(grad(f)) over [1.0, 2.0, 3.0]: evaluates f' at each input, producing [5.0, 7.0, 9.0]. No loop is written; vmap maps the gradient function across the batch axis.
  4. jit(vmap(grad(f))): the first call traces the batched gradient, compiles it to XLA, and caches the compiled kernel. Subsequent calls skip Python entirely and dispatch to the compiled code. The output is still [5.0, 7.0, 9.0], but execution is orders of magnitude faster for large batches.

Notice that each transform wraps the previous result without modifying the original function. The composition reads right to left: compile(vectorize(differentiate(f))).

Real-World Application: Google DeepMind GNoME

Google DeepMind's GNoME (Graph Networks for Materials Exploration) system, which predicted the stability of over 2.2 million new crystal structures in 2023, uses JAX's jit and vmap transforms to evaluate graph neural network energies across thousands of candidate structures in parallel on TPU pods. The grad transform computes forces for molecular relaxation without a hand-coded backward pass, enabling the team to screen crystal candidates at a rate that would have taken conventional density functional theory (DFT) simulations an estimated 800 years of compute, according to the authors.

The Compiler That Deleted 99% of Your Code

When jax.jit traces your function, it builds an XLA HLO graph and then hands it to XLA's optimization passes, which include dead code elimination (removing operations whose results are never used), constant folding (pre-computing expressions whose inputs are all known at compile time), and operator fusion. In benchmarks on real scientific workloads, XLA routinely fuses dozens of separate array operations into a single GPU kernel and eliminates temporary allocations entirely. A 200-line JAX function can compile down to a handful of fused CUDA kernels that bear almost no resemblance to the original Python. The Python was never "run fast"; it was a specification language that the compiler consumed, optimized, and replaced.

Lab: Measuring Transform Overhead and Composition Scaling

Goal: Empirically measure how JAX transform composition affects compilation time and runtime performance as problem size grows.

Tools needed: JAX (CPU is sufficient), matplotlib, and Python's time module. About 20 minutes.

Procedure: (1) Define a simple function f(x) = jnp.sum(jnp.sin(x)**2). (2) For array sizes N in [10, 100, 1000, 10000, 100000], measure wall-clock time for 1000 evaluations of each of these four variants: bare f, jit(f), jit(grad(f)), and jit(vmap(grad(f_scalar))) where f_scalar operates on a single element. (3) Record both the first-call time (which includes compilation) and the median of subsequent calls. (4) Plot two charts: compilation time vs. N, and median runtime vs. N, with one line per variant.

What to vary: try replacing sin with deeper compositions (e.g., sin(cos(exp(x)))) to see how expression complexity affects compilation time. Try nesting grad twice (Hessian diagonal) and observe the compilation cost.

What to observe: at what problem size does jit break even with the compilation overhead? How does adding grad or vmap change the scaling exponent? Does the Hessian variant compile dramatically slower than the gradient variant?

What's Next

With these transforms in hand, Section 42.3: Differentiable Physics and Molecular Dynamics applies these tools to a harder problem: differentiating through time-stepped simulations. We derive the continuous adjoint method (a technique for computing gradients through differential equation solvers without storing all intermediate states) for ODE-constrained optimization, implement differentiable molecular dynamics with JAX-MD, and show how the grad/vmap/jit stack makes it practical to backpropagate through thousands of simulation steps.

Bibliography

Core References

Bradbury, J., et al. (2018). JAX: composable transformations of Python+NumPy programs. GitHub.

The JAX repository and documentation. The README and design notes explain the transformation model.

Kidger, P. & Garcia, C. (2021). Equinox: neural networks in JAX via callable PyTrees and filtered transformations.

Equinox's design document, explaining how Python classes become pytrees compatible with all JAX transforms.

DeepMind (2020). Optax: composable gradient transformations in JAX.

Optax's documentation covering the composable optimizer design and available gradient transformations.

Heek, J., et al. (2020). Flax: a neural network library for JAX.

Flax's documentation covering the NNX module system and integration with the JAX ecosystem.