What is JAX and Why Migrate?
JAX is a high-performance numerical computing library from Google Research that provides composable transformations of Python+NumPy programs: automatic differentiation (grad), JIT compilation (jit), automatic vectorization (vmap), and parallelization (pmap). Unlike PyTorch's eager-first imperative style, JAX is built on a functional programming paradigm where computations are pure functions over immutable arrays.
Migrating from PyTorch to JAX matters for several compelling reasons:
- Performance: JAX's XLA backend compiles entire computation graphs into highly optimized kernels, often yielding 2-5x speedups over PyTorch eager mode
- Hardware portability: JAX runs identically on CPU, GPU, and TPU with zero code changes
- Function transformations:
vmapeliminates manual batching code;pmapenables seamless multi-device parallelism - Reproducibility: Pure functional code with explicit PRNG keys makes stochastic operations deterministic by design
- Composability: Transformations like
grad(jit(vmap(f)))compose arbitrarily, enabling concise yet powerful code
Core Mental Model Shift
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Before diving into code, understand the fundamental paradigm differences:
PyTorch: Imperative, Stateful, Object-Oriented
# PyTorch - everything is an object with mutable state
model = nn.Linear(10, 5)
optimizer = torch.optim.Adam(model.parameters())
loss_fn = nn.CrossEntropyLoss()
def train_step(x, y):
optimizer.zero_grad() # mutate optimizer state
output = model(x) # model holds parameters internally
loss = loss_fn(output, y)
loss.backward() # mutate .grad attributes
optimizer.step() # mutate parameters in-place
return loss.item()
JAX: Functional, Stateless, Explicit
# JAX - everything is a pure function of explicit arguments
import jax
import jax.numpy as jnp
import optax
def forward(params, x):
w, b = params
return jnp.dot(x, w) + b # pure function, no internal state
def loss_fn(params, x, y):
pred = forward(params, x)
return -jnp.mean(jnp.log(pred) * y) # returns value, no side effects
optimizer = optax.adam(1e-3)
opt_state = optimizer.init(params)
def train_step(params, opt_state, x, y):
loss, grads = jax.value_and_grad(loss_fn)(params, x, y)
updates, new_opt_state = optimizer.update(grads, opt_state, params)
new_params = optax.apply_updates(params, updates)
return new_params, new_opt_state, loss
The key insight: everything becomes explicit arguments and return values. There are no hidden states, no in-place mutations, no object hierarchies holding internal variables.
Step-by-Step Migration Guide
Step 1: Environment Setup
Install JAX with the appropriate accelerator support:
# CPU-only version
pip install jax jaxlib
# CUDA GPU version (CUDA 12)
pip install "jax[cuda12]"
# TPU version
pip install "jax[tpu]"
Also install the ecosystem libraries you'll need:
pip install optax flax orbax-checkpoint
- optax: Gradient transformation and optimization (replaces
torch.optim) - flax: Neural network module system (replaces
torch.nn) - orbax: Checkpointing utilities (replaces
torch.save/torch.load)
Step 2: Tensors and Array Operations
Replace torch.* tensor operations with jax.numpy (which mirrors NumPy but operates on immutable JAX arrays):
# PyTorch
import torch
x = torch.randn(32, 10)
y = torch.nn.functional.relu(x)
z = torch.matmul(x, x.T)
# JAX equivalent
import jax.numpy as jnp
from jax import random
key = random.PRNGKey(42)
x = random.normal(key, (32, 10))
y = jax.nn.relu(x) # or: jnp.maximum(x, 0)
z = jnp.matmul(x, x.T) # or: x @ x.T
Critical difference: JAX arrays are immutable. You cannot do x[0, 0] = 5. Instead, use index update syntax:
# Instead of x[0, 0] = 5 (in-place mutation)
x = x.at[0, 0].set(5) # returns a NEW array
x = x.at[1:3].add(1.0) # adds 1.0 to slice, returns new array
Step 3: Random Number Generation
This is perhaps the most jarring change. PyTorch uses a global mutable RNG state; JAX requires explicit PRNG keys that you split and pass around functionally:
# PyTorch - implicit global RNG state
x = torch.randn(100, 10) # global state mutated invisibly
y = torch.randn(100, 10) # different numbers, same global mutation
# JAX - explicit PRNG key management
from jax import random
key = random.PRNGKey(42) # initial seed
key1, key2 = random.split(key) # split for independent streams
x = random.normal(key1, (100, 10))
y = random.normal(key2, (100, 10))
# Pattern for training loops: split as you go
key, subkey = random.split(key)
dropout_mask = random.bernoulli(subkey, 0.5, (64, 128))
key, subkey = random.split(key) # split again for next use
# ... continue splitting through the loop
The rule: never reuse a key. Every stochastic operation consumes a key and you split to get fresh ones. This is tedious at first but gives you exact reproducibility and parallel-safe randomness.
Step 4: Model Definition — Replacing nn.Module
PyTorch models are classes holding state. JAX models are typically either pure functions or Flax modules. Here's a side-by-side comparison of a simple MLP:
# ===== PYTHORCH =====
class MLP(nn.Module):
def __init__(self, in_dim=784, hidden=256, out_dim=10):
super().__init__()
self.fc1 = nn.Linear(in_dim, hidden)
self.fc2 = nn.Linear(hidden, hidden)
self.fc3 = nn.Linear(hidden, out_dim)
self.dropout = nn.Dropout(0.1)
def forward(self, x):
x = F.relu(self.fc1(x))
x = self.dropout(x)
x = F.relu(self.fc2(x))
x = self.fc3(x)
return x
model = MLP()
# Parameters are implicitly owned by model.parameters()
# ===== JAX WITH FLAX =====
import flax.linen as nn
class MLP(nn.Module):
hidden: int = 256
out_dim: int = 10
dropout_rate: float = 0.1
@nn.compact
def __call__(self, x, deterministic=False):
x = nn.Dense(self.hidden)(x)
x = nn.relu(x)
x = nn.Dropout(self.dropout_rate)(
x, deterministic=deterministic)
x = nn.Dense(self.hidden)(x)
x = nn.relu(x)
x = nn.Dense(self.out_dim)(x)
return x
model = MLP()
# Parameters are initialized separately and passed explicitly
key = random.PRNGKey(0)
dummy_input = jnp.ones((1, 784))
variables = model.init(key, dummy_input)
params = variables['params'] # pure pytree of arrays
Key differences in Flax:
@nn.compactenables inline submodule definition (alternative: explicit setup)- Parameters are returned from
init()as a dictionary-like "pytree" - Dropout and batch norm take an explicit
deterministic=flag (no global training/eval mode) - The
__call__method is a pure function of inputs + parameters
Step 5: Loss Functions and Automatic Differentiation
Replace loss.backward() and torch.no_grad() with JAX's functional gradient transforms:
# PyTorch
def train_step(model, optimizer, x, y):
optimizer.zero_grad()
loss = F.cross_entropy(model(x), y)
loss.backward()
optimizer.step()
return loss.item()
# JAX
def compute_loss(params, x, y):
logits = model.apply({'params': params}, x)
return optax.softmax_cross_entropy_with_integer_labels(
logits, y).mean()
# jax.grad returns a function that computes gradients
grad_fn = jax.grad(compute_loss)
# jax.value_and_grad returns both value and gradients
value_and_grad_fn = jax.value_and_grad(compute_loss)
def train_step(params, opt_state, x, y):
loss, grads = value_and_grad_fn(params, x, y)
updates, new_opt_state = optimizer.update(grads, opt_state, params)
new_params = optax.apply_updates(params, updates)
return new_params, new_opt_state, loss
JAX provides jax.grad for scalar-output functions, jax.jacfwd/jax.jacrev for full Jacobians, and jax.vjp/jax.jvp for vector-Jacobian products. These compose with other transformations:
# Compute per-example gradients efficiently with vmap+grad
per_example_grads = jax.vmap(
jax.grad(compute_loss, argnums=0),
in_axes=(None, 0, 0)
)(params, x_batch, y_batch)
Step 6: Optimizers with Optax
Optax replaces torch.optim. It treats optimizers as pure functions that transform gradients and maintain state explicitly:
# PyTorch
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
# optimizer holds internal state, mutates parameters in-place
# JAX with Optax
import optax
optimizer = optax.adam(learning_rate=1e-3)
# Or chain transformations:
optimizer = optax.chain(
optax.clip_by_global_norm(1.0),
optax.adamw(learning_rate=1e-3, weight_decay=1e-4),
)
# Initialize optimizer state
opt_state = optimizer.init(params)
# In training loop:
grads = jax.grad(loss_fn)(params, x, y)
updates, new_opt_state = optimizer.update(grads, opt_state, params)
new_params = optax.apply_updates(params, updates)
# params, opt_state are now updated for the next iteration
Optax's chaining API is powerful — you can stack gradient clipping, weight decay, learning rate schedules, and the core optimizer into a single pipeline.
Step 7: Training Loop — Putting It All Together
Here's a complete training loop that demonstrates the full JAX pattern:
import jax
import jax.numpy as jnp
import optax
import flax.linen as nn
from jax import random
# --- Model definition ---
class CNN(nn.Module):
num_classes: int = 10
@nn.compact
def __call__(self, x, deterministic=False):
x = nn.Conv(features=32, kernel_size=(3, 3))(x)
x = nn.relu(x)
x = nn.max_pool(x, window_shape=(2, 2), strides=(2, 2))
x = nn.Conv(features=64, kernel_size=(3, 3))(x)
x = nn.relu(x)
x = x.reshape(x.shape[0], -1) # flatten
x = nn.Dense(256)(x)
x = nn.relu(x)
x = nn.Dense(self.num_classes)(x)
return x
# --- Loss function ---
def loss_fn(params, batch, deterministic=False):
images, labels = batch
logits = model.apply({'params': params}, images,
deterministic=deterministic)
loss = optax.softmax_cross_entropy_with_integer_labels(
logits, labels).mean()
return loss
# --- Metrics ---
def compute_accuracy(params, batch):
images, labels = batch
logits = model.apply({'params': params}, images, deterministic=True)
preds = jnp.argmax(logits, axis=-1)
return jnp.mean(preds == labels)
# --- Training step (to be JIT-compiled) ---
@jax.jit
def train_step(params, opt_state, batch):
loss, grads = jax.value_and_grad(loss_fn)(params, batch)
updates, new_opt_state = optimizer.update(grads, opt_state, params)
new_params = optax.apply_updates(params, updates)
return new_params, new_opt_state, loss
# --- Initialization ---
key = random.PRNGKey(42)
model = CNN(num_classes=10)
# Initialize parameters
key, init_key = random.split(key)
dummy_input = jnp.ones((1, 28, 28, 1)) # MNIST-like
variables = model.init(init_key, dummy_input)
params = variables['params']
# Initialize optimizer
optimizer = optax.chain(
optax.clip_by_global_norm(1.0),
optax.adamw(learning_rate=1e-3, weight_decay=1e-4)
)
opt_state = optimizer.init(params)
# --- Training loop ---
num_epochs = 10
for epoch in range(num_epochs):
# Dummy data loader
for batch_idx in range(len(train_images) // 32):
batch_images = train_images[batch_idx*32:(batch_idx+1)*32]
batch_labels = train_labels[batch_idx*32:(batch_idx+1)*32]
batch = (batch_images, batch_labels)
params, opt_state, loss = train_step(params, opt_state, batch)
# Evaluation (no JIT needed if small, but you can JIT this too)
eval_batch = (test_images[:1000], test_labels[:1000])
acc = compute_accuracy(params, eval_batch)
print(f"Epoch {epoch}: loss={loss:.4f}, accuracy={acc:.4f}")
Step 8: JIT Compilation — The Performance Magic
The @jax.jit decorator compiles your function to XLA-optimized kernels. This is where massive speedups come from. Key rules for JIT-compatible code:
- No Python control flow dependent on array values — use
jax.lax.cond,jax.lax.scan,jax.lax.fori_loopinstead - Fixed array shapes — JIT traces at specific shapes; use padding or bucketing for variable-length data
- All inputs must be JAX arrays — no Python lists, dicts of varying structure (use pytrees)
- No side effects — no printing, logging, or I/O inside JIT'd functions
# This BREAKS JIT — data-dependent Python if
@jax.jit
def broken_fn(x):
if x[0] > 0: # x is an array, truth value is ambiguous
return x
return -x
# This WORKS — use jax.lax.cond for control flow
@jax.jit
def fixed_fn(x):
return jax.lax.cond(x[0] > 0, lambda: x, lambda: -x)
# This BREAKS JIT — dynamic shape
@jax.jit
def broken_dynamic(x):
return x[:random.randint(0, 10)] # JIT needs static bounds
# This WORKS — use static argnames or fixed slices
@jax.jit
def fixed_slice(x):
return x[:10] # static slice
You can debug JIT issues by calling the function once without @jit to see the Python-side logic, then adding JIT gradually.
Step 9: Vectorization with vmap
vmap automatically vectorizes a function over a batch dimension, replacing manual batching code:
# PyTorch — batch processing is implicit in nn.Module
output = model(batch_images) # automatically handles batches
# JAX — explicit vmap for per-sample operations
# Say you have a function that processes ONE image
def process_single(params, image):
return model.apply({'params': params}, image[None, ...])[0]
# vmap creates a batched version automatically
process_batch = jax.vmap(process_single, in_axes=(None, 0))
batch_output = process_batch(params, batch_images)
# Even more powerful: vmap over multiple axes
# Compute per-example gradients for an entire batch
per_example_grad_fn = jax.vmap(
jax.value_and_grad(loss_fn, argnums=0),
in_axes=(None, (0, 0)) # params fixed, batch split
)
losses, per_example_grads = per_example_grad_fn(params, batch)
Step 10: Handling State — BatchNorm, Dropout, Running Stats
PyTorch uses model.train() / model.eval() and internal buffers. JAX requires explicit state passing:
# PyTorch — state is implicit
model.train()
out = model(x) # updates running_mean internally
model.eval()
out = model(x) # uses stored running_mean
# JAX with Flax — explicit state variables
class StatefulModule(nn.Module):
@nn.compact
def __call__(self, x, deterministic=False):
x = nn.Dense(256)(x)
x = nn.BatchNorm(use_running_average=not deterministic)(x)
x = nn.relu(x)
x = nn.Dense(10)(x)
return x
# Initialize returns both params and batch_stats
variables = model.init(key, x)
params = variables['params']
batch_stats = variables['batch_stats']
# Training: pass mutable state, update it
def train_step(params, batch_stats, opt_state, batch):
# mutable=['batch_stats'] tells Flax to update them
(loss, logits), updated_vars = model.apply(
{'params': params, 'batch_stats': batch_stats},
batch['images'], deterministic=False,
mutable=['batch_stats']
)
new_batch_stats = updated_vars['batch_stats']
# Compute gradients on loss...
return new_params, new_batch_stats, new_opt_state, loss
# Evaluation: use stored stats, don't update
def eval_step(params, batch_stats, batch):
logits = model.apply(
{'params': params, 'batch_stats': batch_stats},
batch['images'], deterministic=True
)
return logits
This explicit state threading is more verbose but gives you total control — you can easily checkpoint, restore, or manipulate running statistics.
Step 11: Data Loading
JAX doesn't have a direct replacement for torch.utils.data.DataLoader. Common approaches:
- Use PyTorch DataLoader + convert: Load with PyTorch, convert tensors to NumPy/JAX arrays
- Use TensorFlow's
tf.data: Excellent performance, natural fit with JAX - Use Grain / custom iterators: Google's Grain library or simple NumPy-based loaders
# Option 1: PyTorch DataLoader bridge
from torch.utils.data import DataLoader
import numpy as np
def numpy_collate(batch):
"""Convert PyTorch tensors to numpy arrays"""
return {k: v.numpy() if isinstance(v, torch.Tensor) else v
for k, v in batch.items()}
dataloader = DataLoader(dataset, batch_size=32,
collate_fn=numpy_collate)
for batch in dataloader:
images = jnp.asarray(batch['images']) # zero-copy if possible
labels = jnp.asarray(batch['labels'])
# ... training step
# Option 2: Pure NumPy loader (simple datasets)
def numpy_data_stream(x_path, y_path, batch_size, key):
x_data = np.load(x_path)
y_data = np.load(y_path)
num_samples = len(x_data)
while True:
key, subkey = random.split(key)
indices = random.permutation(subkey, num_samples)
for i in range(0, num_samples, batch_size):
batch_idx = indices[i:i+batch_size]
yield jnp.asarray(x_data[batch_idx]), jnp.asarray(y_data[batch_idx])
Step 12: Checkpointing and Model Persistence
Replace torch.save / torch.load with Orbax or simple pickling of pytrees:
# PyTorch
torch.save({
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'epoch': epoch,
}, 'checkpoint.pt')
checkpoint = torch.load('checkpoint.pt')
model.load_state_dict(checkpoint['model_state_dict'])
# JAX with Orbax
import orbax.checkpoint as ocp
checkpointer = ocp.StandardCheckpointer()
checkpointer.save('checkpoint_dir/',
args=ocp.args.StandardSave(
{'params': params, 'opt_state': opt_state, 'epoch': epoch}
))
# Restore
restored = checkpointer.restore('checkpoint_dir/',
args=ocp.args.StandardRestore(
{'params': params, 'opt_state': opt_state, 'epoch': 0}
))
params = restored['params']
opt_state = restored['opt_state']
# Simple alternative: pickle pytrees
import pickle
with open('checkpoint.pkl', 'wb') as f:
pickle.dump({'params': params, 'opt_state': opt_state}, f)
with open('checkpoint.pkl', 'rb') as f:
restored = pickle.load(f)
Common Migration Pitfalls and Best Practices
Pitfall 1: In-Place Mutation Habits
# WRONG: Trying to mutate JAX arrays
params['Dense_0']['kernel'][0, :] = 0.0 # ERROR!
# CORRECT: Use .at[] updates
params['Dense_0']['kernel'] = params['Dense_0']['kernel'].at[0, :].set(0.0)
Pitfall 2: Forgetting to Split PRNG Keys
# WRONG: Reusing the same key
key = random.PRNGKey(42)
x = random.normal(key, (100,))
y = random.normal(key, (100,)) # y will be IDENTICAL to x!
# CORRECT: Split before each use
key = random.PRNGKey(42)
key1, key2 = random.split(key)
x = random.normal(key1, (100,))
y = random.normal(key2, (100,)) # independent random values
Pitfall 3: Python Control Flow in JIT Functions
# WRONG: Data-dependent if/for inside @jit
@jax.jit
def bad_loop(x):
for i in range(x.shape[0]): # x.shape[0] is a traced value
x = x.at[i].set(x[i] * 2)
return x
# CORRECT: Use jax.lax.scan or static iteration
@jax.jit
def good_loop(x):
return jax.lax.fori_loop(0, x.shape[0],
lambda i, arr: arr.at[i].set(arr[i] * 2), x)
Pitfall 4: Ignoring JIT Warm-Up
The first call to a @jax.jit function compiles it, which can take seconds. Always "warm up" JIT functions before benchmarking:
# Warm up JIT
_ = train_step(params, opt_state, dummy_batch)
# Now benchmark the real run
start = time.time()
for _ in range(100):
params, opt_state, loss = train_step(params, opt_state, real_batch)
elapsed = time.time() - start
Pitfall 5: Expecting PyTorch-Style Debugging
JIT-compiled code doesn't support print() or pdb inside the function. Use:
jax.debug.print(x)inside JIT for traced values- Remove
@jax.jittemporarily to debug in eager mode jax.debug.breakpoint()for interactive debugging
Best Practice: Gradual JIT Adoption
Start with eager mode (no JIT) to verify correctness, then add JIT incrementally:
# Phase 1: Write and debug eagerly
def train_step(params, opt_state, batch):
...
# Phase 2: Add JIT once logic is correct
train_step = jax.jit(train_step, static_argnames=['deterministic'])
Best Practice: Use Pytrees Effectively
JAX's pytree abstraction (nested dicts/lists/tuples of arrays) is how you manage structured parameters. Libraries like Flax produce pytrees naturally:
# Flatten and unflatten for custom manipulation
from jax.tree_util import tree_map, tree_flatten
# Apply learning rate scaling to all parameters
def scale_lr(param):
return param * 0.1
scaled_params = tree_map(scale_lr, params)
# Compute global norm across all parameters
leaves, _ = tree_flatten(params)
global_norm = jnp.sqrt(sum(jnp.sum(jnp.square(leaf)) for leaf in leaves))
Best Practice: Profile with jax.profiler
# Profile JIT-compiled code
with jax.profiler.trace('logs/'):
for step in range(100):
params, opt_state, loss = train_step(params, opt_state, batch)
# View with TensorBoard: tensorboard --logdir logs/
Full End-to-End Migration Example
Below is a complete, runnable training script that mirrors a typical PyTorch workflow — data loading, model definition, training loop, evaluation, and checkpointing — all in JAX:
import jax
import jax.numpy as jnp
import optax
import flax.linen as nn
from flax.training import train_state, checkpoints
from jax import random
import numpy as np
# ============================================================
# 1. Model Definition (replaces nn.Module)
# ============================================================
class ResBlock(nn.Module):
features: int
@nn.compact
def __call__(self, x, deterministic=False):
residual = x
x = nn.Conv(self.features, kernel_size=(3, 3), padding='SAME')(x)
x = nn.BatchNorm(use_running_average=not deterministic)(x)
x = nn.relu(x)
x = nn.Conv(self.features, kernel_size=(3, 3), padding='SAME')(x)
x = nn.BatchNorm(use_running_average=not deterministic)(x)
if residual.shape != x.shape:
residual = nn.Conv(self.features, kernel_size=(1, 1))(residual)
return nn.relu(x + residual)
class ResNet(nn.Module):
num_classes: int = 10
@nn.compact
def __call__(self, x, deterministic=False):
x = nn.Conv(64, kernel_size=(7, 7), strides=(2, 2), padding='SAME')(x)
x = nn.BatchNorm(use_running_average=not deterministic)(x)
x = nn.relu(x)
x = nn.max_pool(x, window_shape=(3, 3), strides=(2, 2), padding='SAME')
x = ResBlock(64)(x, deterministic=deterministic)
x = ResBlock(128)(x, deterministic=deterministic)
x = jnp.mean(x, axis=(1, 2)) # global average pooling
x = nn.Dense(self.num_classes)(x)
return x
# ============================================================
# 2. Training State (replaces model+optimizer combo)
# ============================================================
def create_train_state(key, model_cls, learning_rate=1e-3):
# Initialize model variables
dummy_input = jnp.ones((1, 32, 32, 3))
variables = model_cls().init(key, dummy_input)
params = variables['params']
batch_stats = variables.get('batch_stats', {})
# Setup optimizer
tx = optax.chain(
optax.clip_by_global_norm(1.0),
optax.adamw(learning_rate, weight_decay=1e-4)
)
return {
'params': params,
'batch_stats': batch_stats,
'opt_state': tx.init(params),
'tx': tx,
'step': 0,
'key': key,
}
# ============================================================
# 3. Loss and Metrics (pure functions)
# ============================================================
def compute_loss_and_accuracy(state, batch):
images, labels = batch
variables = {
'params': state['params'],
'batch_stats': state['batch_stats']
}
# Forward pass (with mutable batch_stats for training)
logits, updated_vars = ResNet(num_classes=10).apply(
variables, images,
mutable=['batch_stats'],
deterministic=False # training mode
)
loss = optax.softmax_cross_entropy_with_integer_labels(
logits, labels).mean()
accuracy = jnp.mean(jnp.argmax(logits, axis=-1) == labels)
return loss, accuracy, updated_vars['batch_stats']
# ============================================================
# 4. Training Step (JIT-compiled)
# ============================================================
@jax.jit
def train_step(state, batch):
(loss, accuracy, new_batch_stats), grads = jax.value_and_grad(
compute_loss_and_accuracy, argnums=0, has_aux=True
)(state, batch)
# Update parameters
updates, new_opt_state = state['tx'].update(
grads['params'], state['opt_state'], state['params']
)
new_params = optax.apply_updates(state['params'], updates)
# Update state
new_state = {
'params': new_params,
'batch_stats': new_batch_stats,
'opt_state': new_opt_state,
'tx': state['tx'],
'step': state['step'] + 1,
'key': state['key'],
}
return new_state, loss, accuracy
# ============================================================
# 5. Evaluation Step
# ============================================================
@jax.jit
def eval_step(state, batch):
images, labels = batch
variables = {
'params': state['params'],
'batch_stats': state['