Chapter 3: Distributed Computing with JAX
Course: "Advanced JAX: Performance, Optimization, and Scale", Chapter 3 · Original: https://apxml.com/courses/advanced-jax/chapter-3-distributed-computing-jax Environment: Python 3.14 · JAX / jaxlib 0.12.0.1 · CPU only (8 devices simulated with
XLA_FLAGS; the multi-host demo uses 2 subprocesses × 4 devices)
Learning goals
- Tell apart data parallelism, model (tensor) parallelism, pipeline parallelism, and ZeRO / FSDP, along with the memory and communication cost of each;
- Understand device objects, array placement, and the rules for committed vs. uncommitted arrays;
- Read example code written with
pmap, and rewrite each example in the style 0.12 recommends:jit + NamedShardingorjax.shard_map; - Know exactly what
psum / pmean / pmax / all_gather / psum_scatter / all_to_all / ppermutedo and how much data each one moves; - Use
PartitionSpec,AxisType,with_sharding_constraintandreshardon a 2D mesh (data × model), and inspect sharding types withjax.typeof; - Understand the multi-host programming model, and actually run a 2-process demo on this machine (a cross-process
psumand data-parallel training); - Write distributed data-parallel training that matches the single-device result bit for bit, and read the gradient-sync communication volume from the compiled output.
Sections
| Section | Original course page |
|---|---|
| 3.1 Introduction to parallelism | introduction-parallelism-concepts |
| 3.2 Device management in JAX | device-management-jax |
| 3.3 Single Program, Multiple Data (SPMD) with pmap | spmd-with-pmap |
| 3.4 Data parallelism with pmap | data-parallelism-pmap |
| 3.5 Collective communication primitives (psum, pmean, etc.) | collective-communication-primitives |
| 3.6 Handling axis names in pmap | handling-axis-names-pmap |
| 3.7 Nested pmap and advanced partitioning | nested-pmap-advanced-partitioning |
| 3.8 Overview of multi-host programming | multi-host-programming-overview |
| 3.9 Practice: distributed data-parallel training | practice-distributed-data-parallel-training |
| — | Exercises · Quiz · Version notes · Summary · Cheat sheet |
How to read this chapter
- The example code follows the structure of each course page. Variable names, shapes, hyperparameters,
in_axes/axis_nameand so on all match the course. - All code uses the style 0.12 recommends (
jax.random.key,jax.tree.map,x.device, etc.). Outdated or broken patterns go into# ❌ Old stylecells, together with what they output, followed right away by the recommended version. - The course uses
jax.pmap. In 0.12 that is a legacy API: the first sentence of its docstring is "Old way of doing parallel map. Use jax.shard_map instead." (Section 3.3 checks this with an assert). So every major pmap example is followed by a# ✅ Modern stylecell that asserts both give the same result.
Links to the Basics part (cross-references only; not repeated here)
- Basics 05: Multi-device parallelism with pmap already covers: basic pmap usage,
in_axes/out_axes, the fact that pmap is implemented with shard_map in 0.12,make_meshusing Explicit axes by default, vma (varying/invariant) types, a hand-written ring all-reduce withppermute, and the "gradients come out N times too large inside shard_map" pitfall. This chapter assumes you know all of that and builds on it with 2D partitioning,all_to_all/psum_scatter, FSDP, explicit sharding types, multi-host, and communication-volume analysis. - Basics 02: JIT compilation: tracing and compilation with
jit. Every "modern style" in this chapter is built onjit. - Basics 03: Automatic differentiation with grad: Section 3.7 uses one property: the transpose (backward pass) of
all_gatherispsum_scatter. - Basics 04: Automatic vectorization with vmap:
vmapalso acceptsaxis_name, so you can test collectives on a single device (extra in Section 3.6).
⚠️ Requirement: the first code cell must set
XLA_FLAGSbeforeimport jax, so please restart the kernel and run from the top. If jax has already been imported in this kernel, the device count will be 1 and the assert in the first cell will fail.
# Must be set before import jax: simulate 8 devices on 1 CPU (run this after restarting the kernel)
import os
os.environ["XLA_FLAGS"] = "--xla_force_host_platform_device_count=8"
import re, sys, json, subprocess, tempfile, socket, warnings
import numpy as np
import jax
import jax.numpy as jnp
from jax import lax
from jax.sharding import Mesh, NamedSharding, PartitionSpec as P, AxisType
import optax
assert jax.device_count() == 8, "Device count is not 8: restart the kernel and run from the first cell"
N = jax.device_count()
print("JAX", jax.__version__, "| devices:", N, "| platform:", jax.devices()[0].platform)
def f64(a):
"""Convert to NumPy float64, used to cross-check distributed results"""
return np.asarray(a, dtype=np.float64)
def collectives(compiled):
"""Find the collective ops used in the compiled HLO text"""
return sorted(set(re.findall(r"\b(all-reduce|reduce-scatter|all-gather|all-to-all|collective-permute)\(",
compiled.as_text())))
3.1 Introduction to parallelism¶
Modern machine learning often outgrows a single device: either the dataset is huge, or the model has billions of parameters, so the work has to be spread over several devices. Parallel processing means cutting a task into smaller, independent or semi-independent pieces and running them on several processing units at the same time. Which strategy you pick for training and inference depends on whether the bottleneck is data volume or model size.
Data parallelism¶
This is the most common way to speed up training. The course breaks it into five steps:
- Replicate the model: copy the same model structure and current parameters to every device (GPU or TPU core);
- Shard the data: split the global batch into smaller batches, one per device, with no overlap;
- Parallel forward and backward pass: each device uses its own model copy and mini-batch to compute local gradients;
- Aggregate gradients: combine the local gradients by averaging (
pmean) or summing (psum). The result equals the gradient computed on the global batch; - Update parameters: every device applies the same aggregated gradient, so all replicas stay in sync.
Why step 4 works can be written as a formula. Split the global batch $B$ evenly into $N$ parts $B_1,\dots,B_N$, each of size $|B|/N$, and take the loss as the batch mean $L_B(\theta)=\frac{1}{|B|}\sum_{x\in B}\ell(x;\theta)$. Then
$$ \nabla_\theta L_B(\theta) \;=\; \frac{1}{N}\sum_{i=1}^{N} \nabla_\theta L_{B_i}(\theta), $$
In other words, each device computes the gradient of its local mean loss, and then the devices average them (pmean), which gives the global gradient. This effectively increases the total batch per step, which usually uses the accelerators better. Data parallelism fits best when "the model fits on one device and the dataset is large". The course uses pmap to implement this kind of SPMD data parallelism.
Model parallelism¶
If the parameters, activations or intermediate state don't fit on a single accelerator, you have to split the model itself across devices. Data flows through the pieces in order: the forward pass sends activations between devices and the backward pass sends gradients, which means a lot of communication. This lets you train very large models, but partitioning and managing the communication is fairly complex. Two common approaches are tensor parallelism (splitting individual weight matrices) and pipeline parallelism.
Pipeline parallelism¶
This is a more refined form of model parallelism whose goal is to keep devices busy. In naive model parallelism, a batch flows through the devices one after another, so at any moment most devices are idle. Pipeline parallelism works like this:
- Stages: split the model into several sequential stages and give each stage to one device (or a group of devices);
- Micro-batches: split the input batch further into smaller micro-batches;
- Pipelined execution: once device 1 finishes stage 1 of micro-batch 1 and moves on to micro-batch 2, device 2 can start stage 2 of micro-batch 1. That way more devices work at the same time.
It reduces the idle "bubbles" of naive model parallelism, but dependencies, scheduling and state (such as the activations the backward pass needs) all have to be managed carefully.
Focus of this chapter¶
This chapter is mostly about data parallelism: pmap maps a function over several devices, distributes the data automatically, and provides collectives such as gradient aggregation. Model parallelism and pipeline parallelism are only background here; Chapter 6 of the course (very large models) comes back to them.
# Check the formula above: averaging "gradients of the local mean loss" over N equal shards == gradient on the global batch (cross-checked in NumPy float64)
rng = np.random.default_rng(0)
Xg = rng.normal(size=(64, 5)); yg = rng.normal(size=64); w0 = rng.normal(size=5)
def grad_mse(w, X, y): # Analytic gradient of linear-regression MSE: 2/|B| * X^T (Xw - y)
return 2.0 / len(X) * X.T @ (X @ w - y)
g_global = grad_mse(w0, Xg, yg)
g_local = [grad_mse(w0, Xs, ys) for Xs, ys in zip(np.split(Xg, 8), np.split(yg, 8))]
assert np.allclose(np.mean(g_local, axis=0), g_global) # pmean of local gradients == global gradient
assert np.allclose(np.sum(g_local, axis=0), 8 * g_global) # psum of local gradients == N × global gradient
print("pmean(local grads) == global grad ✓; psum(local grads) == 8 × global grad ✓")
🔍 Extra: memory and communication budgets for the three kinds of parallelism¶
🟢 Must-read
The above is qualitative. When you actually choose a strategy, you need two numbers: how much memory each device must hold, and how much data has to be communicated per step. Let the model have $\Phi$ parameters and let $N$ be the number of devices.
Memory: with mixed-precision Adam, each parameter takes about 16 bytes: 2 bytes for the fp16 parameter, 2 bytes for the fp16 gradient, plus 4 bytes each for the fp32 master parameter, first moment and second moment.
| Scheme | Parameters / gradients / optimizer state per device | Memory per device |
|---|---|---|
| Data parallelism (DP) | Everything replicated | $16\Phi$ |
| ZeRO-1 (shard optimizer state) | Only optimizer state is sharded | $4\Phi + 12\Phi/N$ |
| ZeRO-2 (also shard gradients) | Gradients and optimizer state sharded | $2\Phi + 14\Phi/N$ |
| ZeRO-3 / FSDP (shard everything) | Everything sharded; a layer is all-gathered temporarily when needed | $16\Phi/N$ |
Communication: with ring algorithms, each device sends/receives the following ($S$ is the tensor size in bytes):
$$ \text{all-reduce}: 2\tfrac{N-1}{N}S,\qquad \text{reduce-scatter}: \tfrac{N-1}{N}S,\qquad \text{all-gather}: \tfrac{N-1}{N}S . $$
One ring all-reduce is exactly one reduce-scatter plus one all-gather (Section 3.5 checks this with an assert). So when ZeRO-1/2 replace DP's "all-reduce the gradients" with "reduce-scatter the gradients + all-gather the updated parameters", the communication volume stays the same, while the state each device keeps around shrinks a lot.
ZeRO-3 / FSDP shard the parameters too, so the long-lived parameters, gradients and optimizer state all drop to $1/N$. But to avoid holding the full parameters between the forward and backward pass, the backward pass usually all-gathers them again, so communication is about 1.5× that of DP ($3\tfrac{N-1}{N}S$ vs. $2\tfrac{N-1}{N}S$). If the full parameters gathered in the forward pass are kept until the backward pass (as the simplified implementation in Section 3.7 does), communication is the same as DP, at the cost of temporarily holding the full parameters during compute. That is the core idea of ZeRO / FSDP. Section 3.7 actually implements it and checks it with memory_analysis.
Pipeline bubble: for a GPipe-style schedule with $p$ stages and $m$ micro-batches, the idle fraction is
$$ \text{bubble} = \frac{p-1}{m+p-1}. $$
The more micro-batches, the smaller the bubble. So the key to pipeline parallelism is making the number of micro-batches $m \gg p$.
# Pipeline bubble fraction (p-1)/(m+p-1): with p=4 stages, more micro-batches means a smaller bubble
def bubble(p, m):
return (p - 1) / (m + p - 1)
for m in [1, 4, 16, 64]:
print(f"p=4, m={m:3d}: bubble fraction = {bubble(4, m):.3f}")
assert bubble(4, 1) == 0.75 and bubble(4, 64) < 0.05 # m=1 is naive model parallelism: 3/4 of the time is spent waiting
3.2 Device management in JAX¶
Before using pmap, you need to know how JAX finds and manages accelerators. JAX automatically detects the CPUs, GPUs and TPUs in the system (or the ones a distributed environment assigns to it), and provides tools to inspect and work with them.
Listing available devices¶
jax.devices(): all devices JAX can use. In a multi-host setup this is the global device list across all processes; in a single process it is just the local devices;jax.device_count(): the global device count;jax.local_devices()/jax.local_device_count(): the devices directly attached to the current Python process, and how many there are;- Device object attributes:
platform('cpu','gpu','tpu'),id(unique within a platform),process_index(which process it belongs to),device_kind.
The course specifically points out that in a multi-host setup (such as a TPU Pod) you need to distinguish jax.devices() (global view) from jax.local_devices() (only this process's devices). On a single-host GPU or CPU setup, the two are usually the same. Section 3.8 shows the difference in a real 2-process setup.
# Course example: list devices
print("jax.devices(): ", jax.devices())
print("jax.device_count(): ", jax.device_count())
print("jax.local_devices(): ", jax.local_devices()[:3], "...")
print("jax.local_device_count():", jax.local_device_count())
d0 = jax.local_devices()[0]
print(f"First local device: ID={d0.id}, Platform={d0.platform}, process_index={d0.process_index}, kind={d0.device_kind}")
assert jax.local_devices() == jax.devices() # Single process: local == global
assert all(d.process_index == 0 for d in jax.devices())
assert [d.id for d in jax.devices()] == list(range(8))
Default device placement¶
By default, arrays are created and computed on the first device in jax.local_devices() (cpu:0, gpu:0 or tpu:0). That is convenient for a single device; in distributed settings you usually want explicit control.
⚠️ Note:
deviceis an attribute, so writex.device. Thex.device()you often see in older material raisesTypeError: 'Device' object is not callable. There are two other ways to check:x.devices()(a method that returns a set) andx.sharding.
# ❌ Old style (outdated): x.device(). In 0.12, device is an attribute, not a method
x = jnp.ones((3, 3))
try:
x.device()
raised = False
except TypeError as e:
raised = True
print("x.device() raised:", type(e).__name__, "-", e)
assert raised
# ✅ Recommended: the x.device attribute / x.devices() / x.sharding
x = jnp.ones((3, 3))
print("x.device :", x.device)
print("x.devices():", x.devices())
print("x.sharding :", x.sharding)
assert x.device == jax.devices()[0]
assert x.devices() == {jax.devices()[0]}
Explicit placement with jax.device_put¶
The signature is jax.device_put(x, device=None). x is the data to place (a NumPy array, Python scalar, list or pytree); the second argument is the target device (taken from jax.devices() or jax.local_devices()), and None means the default device. In 0.12 the second argument can also be a Sharding, which is the main way to place data on multiple devices; it is used a lot from Section 3.4 on.
The course lists three uses of device_put:
- Data transfer: it is the main way to move data from host memory to accelerator memory;
- Preparing for parallel compute: prepare shards on the host, and explicitly place initial parameters when needed;
- Controlling initial placement: to avoid unnecessary transfers later.
⚠️ Note: it is easy to think "where computation runs is decided by
jit;device_putonly affects where data starts out". In fact, the device ajitfunction runs on is decided by where its input arrays live: "computation follows data". If youdevice_putthe input tocpu:1, the jit computation and its result are both oncpu:1. The cell in the committed extra below, and the ✅ cell after thejax.jit(device=...)note in Section 3.7, both check this with asserts. Sodevice_putdoes more than decide where data starts out; it also indirectly decides where later computation runs.
# Course example: only demo explicit placement when there is more than 1 local device
num_local_devices = jax.local_device_count()
if num_local_devices > 1:
host_array = np.random.default_rng(0).random((2, 2))
print("Type before placement:", type(host_array))
target = jax.local_devices()[1]
dev_array = jax.device_put(host_array, target)
print("Type after placement:", type(dev_array).__name__, "| device:", dev_array.device)
assert dev_array.device == target
elif num_local_devices == 1:
print("Only one local device available; skipping the explicit placement example.")
else:
print("No JAX devices found.")
Device data residency¶
Once an array is on a device, JAX tries to keep the computations that use it on that device too, to reduce transfers. So if the arrays in an operation live on different devices, does JAX move data automatically, or raise an error? The rule is actually deterministic; see the extra below.
🔍 Extra: committed vs. uncommitted arrays¶
🟢 Must-read
Basics 01 §1.6 "Cross-device operations" already covered this rule; here we review it in the multi-device setting. Whether JAX "moves the data" or "raises an error" depends on whether the arrays are committed (bound to a device):
- Arrays created without a device, like
jnp.ones(...), are uncommitted; they automatically follow the other inputs; jax.device_put(x, device_or_sharding)gives you a committed array;- If the inputs of an operation have only one committed device, the computation runs on that device and uncommitted inputs are moved there;
- If two inputs are committed to different devices, you get
ValueError: Received incompatible devicesright away. JAX will not move the data for you.
u = jnp.ones(3) # uncommitted (on cpu:0 by default)
c1 = jax.device_put(jnp.ones(3), jax.devices()[1]) # committed to cpu:1
c2 = jax.device_put(jnp.ones(3), jax.devices()[2]) # committed to cpu:2
r = u + c1
print("uncommitted + committed(cpu:1) → result on", r.device)
assert r.device == jax.devices()[1]
try:
c1 + c2
raised = False
except ValueError as e:
raised = True
print("committed(cpu:1) + committed(cpu:2) raised:", str(e)[:110], "...")
assert raised
# For a cross-device operation, move the data explicitly
r2 = c1 + jax.device_put(c2, jax.devices()[1])
assert r2.device == jax.devices()[1]
Environment variables for device visibility¶
Before importing JAX, you can use environment variables to control which devices it sees:
CUDA_VISIBLE_DEVICES=1 python my_jax_script.py: inside the script,jax.local_devices()lists just 1 GPU device, with id 0. Visible devices are renumbered from 0, so this id=0 is physically GPU 1;CUDA_VISIBLE_DEVICES=""usually hides all GPUs and forces the CPU;JAX_PLATFORMS=cpu(orgpu,tpu): only initialize the backend for that platform. Use it to force one when several backends are available.
To sum up: device management is the foundation of distributed computing. You need to know which devices are available, where data lives (x.device), and how to control placement (jax.device_put). These are prerequisites for using pmap.
🔍 Extra: two ways to simulate multiple devices, and "must be set before initialization"¶
⚪ Optional: read this if you want to simulate multiple devices on your machine, or if you see "device count is 1"
There is no GPU on this machine, so this chapter uses XLA_FLAGS=--xla_force_host_platform_device_count=8 to simulate 8 CPU devices. 0.12 also has a config option jax_num_cpu_devices (used in Basics 01 §1.6) that does the same thing. But like the environment variable, it must be set before the backend is initialized (before any JAX operation runs for the first time), or you get a RuntimeError. The backend in this kernel is already initialized, so the demo below uses a subprocess.
# Demo of jax_num_cpu_devices in a clean subprocess: setting it before initialization works; setting it afterwards raises RuntimeError
probe = r"""
import jax
jax.config.update('jax_num_cpu_devices', 3) # right after import jax
print(jax.device_count())
try:
jax.config.update('jax_num_cpu_devices', 5) # the backend is initialized by now
except RuntimeError as e:
print('RuntimeError')
"""
env = {k: v for k, v in os.environ.items() if k != "XLA_FLAGS"} # drop this kernel's XLA_FLAGS
out = subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True, env=env, timeout=120)
print(out.stdout)
assert out.stdout.split() == ["3", "RuntimeError"]
assert jax.config.jax_platforms is None # None when JAX_PLATFORMS is not set (backend chosen automatically)
3.3 Single Program, Multiple Data (SPMD) with pmap¶
SPMD (Single Program, Multiple Data) is a very common parallel computing model, and it suits GPUs and TPUs especially well: the same program runs on many processors at once, and each instance handles a different part of the data. The alternative is MPMD, where different processors run different programs.
In the course, the main tool for SPMD is jax.pmap. It turns a function written for one device into one that runs in parallel on many devices, handling replication and data sharding automatically. Think of Python's built-in map: pmap "maps the function over the devices in parallel". Each device runs the same compiled function, but gets its own slice of the input.
How pmap implements SPMD (with 4 TPU cores as an example)¶
- Write single-device code: write the function as if for one device, e.g. one network layer or one loss computation;
- Wrap it in
jax.pmap; - Arrange the data shards: the leading axis of the input corresponds to devices. For example, data of shape
(128, 50)(batch size 128, 50 features) on 4 devices must be reshaped to(4, 32, 50); the 4 in dimension 0 is the device axis; - Run: JAX compiles the original function with XLA when needed, copies the compiled result to the 4 devices, and sends each device its slice (device 0 gets slice 0, and so on). All devices compute their slices at the same time, and the results are collected and stacked along a new leading axis.
So the key point of pmap is: you only write single-device logic; pmap maps the input's leading axis onto devices and handles replication and parallelism. Underneath, XLA optimizes for the target hardware.
# pmap is a legacy API in 0.12: the first sentence of its docstring tells you to use jax.shard_map instead
first_line = jax.pmap.__doc__.strip().splitlines()[0]
print(first_line)
assert first_line.startswith("Old way of doing parallel map")
# Course example: the most basic pmap SPMD example
n_local = jax.local_device_count()
print("Available devices:", n_local)
num_devices_to_use = 4 if n_local >= 4 else n_local
print(f"pmap will use {num_devices_to_use} devices.")
per_device_batch_size = 8
feature_size = 16
global_batch_size = num_devices_to_use * per_device_batch_size
# Leading axis = device axis: (num devices, per-device batch size, num features)
sharded_data = jnp.arange(global_batch_size * feature_size, dtype=jnp.float32).reshape(
(num_devices_to_use, per_device_batch_size, feature_size))
print("Input shape:", sharded_data.shape)
def simple_computation(x):
return x * 2.0 + 1.0 # scale, then add a constant
parallel_computation = jax.pmap(simple_computation)
result = parallel_computation(sharded_data)
print("Output shape:", result.shape, "| result[0, 0, 0] =", result[0, 0, 0]) # original value 0 → 0*2+1 = 1
print("Devices actually used:", sorted(d.id for d in result.sharding.device_set))
assert result.shape == sharded_data.shape and float(result[0, 0, 0]) == 1.0
assert len(result.sharding.device_set) == 4 # leading axis of length 4 < 8 devices: only the first 4 devices are used
# ✅ Modern style: jit + NamedSharding. The data keeps its global shape (32, 16); the sharding carries the partitioning, so no manual reshape into a device axis
mesh4 = jax.make_mesh((4,), ("data",), devices=jax.devices()[:4]) # like the original example, use only 4 devices
flat = sharded_data.reshape(global_batch_size, feature_size)
flat_s = jax.device_put(flat, NamedSharding(mesh4, P("data")))
res_jit = jax.jit(simple_computation)(flat_s)
print("jit version type:", jax.typeof(res_jit))
# ✅ Modern style: jax.shard_map. The function sees each device's local block (8, 16)
res_sm = jax.jit(jax.shard_map(simple_computation, mesh=mesh4, in_specs=P("data"), out_specs=P("data")))(flat_s)
assert np.array_equal(np.asarray(res_jit).reshape(result.shape), np.asarray(result))
assert np.array_equal(np.asarray(res_sm), np.asarray(res_jit))
print("pmap / jit+sharding / shard_map give the same result ✓")
🔍 Extra: three "views" of the same SPMD program¶
🟢 Must-read
All three styles compile to an SPMD program. The difference is the shape the function body sees:
| Style | Shape of x inside the function | How communication is written |
|---|---|---|
pmap | A single slice with the device axis removed, (8, 16) | Collectives written by hand, with an axis_name |
shard_map | A local block that keeps its rank, (8, 16) (global (32, 16) split 4 ways) | Collectives written by hand, using mesh axis names |
jit + sharding | The global shape (32, 16), with sharding in the type | Inserted automatically by the compiler |
Below we print jax.typeof during tracing. On an Explicit mesh, the jit view's type shows @data directly; the shard_map view shows {V:data}, meaning the value differs across devices (varying); the pmap view only sees a single slice.
views = {}
def spy(name):
def f(x):
views[name] = str(jax.typeof(x)) # record the type during tracing
return x * 2.0 + 1.0
return f
jax.pmap(spy("pmap"))(sharded_data)
jax.jit(jax.shard_map(spy("shard_map"), mesh=mesh4, in_specs=P("data"), out_specs=P("data")))(flat_s)
jax.jit(spy("jit"))(flat_s)
for k, v in views.items():
print(f"{k:10s} view: {v}")
assert views["jit"] == "float32[32@data,16]"
assert views["shard_map"].startswith("float32[8,16]") and "V:data" in views["shard_map"]
assert views["pmap"].startswith("float32[8,16]")
3.4 Data parallelism with pmap¶
The basic recipe for data parallelism: replicate the model on several devices, and have each device process a different slice of the input batch with the same parameters. The course uses a simple prediction function to show how pmap does both:
- Model replication: the pmapped function (usually the forward pass or the whole training step) runs on every device. Parameters are broadcast with
in_axes=None, so each device gets an identical copy; - Data sharding: an input whose leading axis length equals the number of devices is split along that axis; the first slice goes to the first device, and so on.
What in_axes means: None means broadcast, the same value goes to every device, which is typical for model parameters; 0 means map over axis 0, so the data is split along axis 0, which is how input data is usually handled in data parallelism.
⚠️ Note: pmap's
in_axesdefaults to 0, and arguments without a mapped leading axis are not broadcast automatically. You must writein_axes=(None, 0)explicitly, otherwise the parameters will also be split along axis 0 (the ❌ cell below fails outright). The example here writesin_axes=(None, 0), so it runs; the ❌ cell in the gradient example of Section 3.6 leaves it out, so it doesn't.
⚠️ Note:
jax.random.PRNGKeyis the legacy API; 0.12 recommendsjax.random.key. Also, JAX's convention is use each key only once: don't use the samekey(0)to generatew,band the data;splitit first. The code below does exactly that. Basic key usage is in Basics 01, and a deeper discussion is in Advanced 10.
# Course example (with jax.random.key + split): setup
num_devices = jax.local_device_count()
print("Number of devices:", num_devices)
def predict(params, inputs):
return jnp.dot(inputs, params["w"]) + params["b"] # a simple linear transform
key = jax.random.key(0)
k_w, k_b, k_x = jax.random.split(key, 3)
input_dim, output_dim = 10, 5
params = {"w": jax.random.normal(k_w, (input_dim, output_dim)),
"b": jax.random.normal(k_b, (output_dim,))}
global_batch_size = 32 * num_devices
dummy_data = jax.random.normal(k_x, (global_batch_size, input_dim))
print(f"Parameter shapes: w={params['w'].shape}, b={params['b'].shape}")
print("Global data batch shape:", dummy_data.shape)
# Reshape the data for pmap: [num devices, per-device batch size, num features]
batch_per_device = global_batch_size // num_devices
sharded_data = dummy_data.reshape((num_devices, batch_per_device, input_dim))
print("Sharded data shape:", sharded_data.shape)
# params broadcast (None), data split along axis 0 (0)
parallel_predict = jax.pmap(predict, in_axes=(None, 0))
sharded_predictions = parallel_predict(params, sharded_data)
sharded_predictions.block_until_ready()
print("Output predictions shape:", sharded_predictions.shape)
assert sharded_predictions.shape == (num_devices, batch_per_device, output_dim)
# ❌ Old style (wrong): leave out in_axes and assume the parameters are "broadcast automatically". The default is in_axes=0, so w's axis 0 (size 10) is treated as the device axis
import contextlib, io
try:
with contextlib.redirect_stdout(io.StringIO()): # JAX prints a source-location line to stdout before raising; suppress it here
jax.pmap(predict)(params, sharded_data)
raised = False
except ValueError as e:
raised = True
msg = str(e)
print("Leaving out in_axes raised:", type(e).__name__)
print(" ", msg[msg.find("implies"):msg.find("(full shape")].strip())
# b's axis 0 has size 5 and is treated as a mapped axis of "5 devices"; the data's axis 0 is 8, so it can't be divided evenly
assert raised and "partitioned 5 times" in msg
# ✅ Recommended / Modern style: replicate parameters with P(), shard data with P('data'); the function uses global shapes, no reshape needed (for the pmap version, see in_axes=(None, 0) in the cell above)
mesh = jax.make_mesh((N,), ("data",))
params_rep = jax.device_put(params, NamedSharding(mesh, P()))
data_sh = jax.device_put(dummy_data, NamedSharding(mesh, P("data")))
print("Parameter type:", jax.typeof(params_rep["w"]), "| data type:", jax.typeof(data_sh))
pred_modern = jax.jit(predict)(params_rep, data_sh)
print("Output type:", jax.typeof(pred_modern))
# Matches the pmap result, and cross-checked against NumPy float64
assert np.allclose(np.asarray(pred_modern).reshape(sharded_predictions.shape), np.asarray(sharded_predictions), atol=1e-6)
ref = f64(dummy_data) @ f64(params["w"]) + f64(params["b"])
assert np.allclose(f64(pred_modern), ref, atol=1e-5)
print("pmap == jit+sharding == NumPy float64 ✓")
To sum up the data flow: the global batch is split across devices, the parameters are copied to every device, each device computes independently, and the outputs are stacked along the device axis. sharded_predictions[i] is what device i computed from shard sharded_data[i] and the replicated parameters.
In real training, predict is just one part of a bigger train_step that also computes the loss and gradients. Before updating the parameters, the gradients from all devices have to be combined across devices (usually averaged). That needs the collective primitives in the next section.
🔍 Extra: three practical points for data parallelism¶
⚪ Optional: for reference when you actually build a data-parallel training loop, tune the learning rate, and deal with the last partial batch
- Keep parameters resident on device. In a training loop,
device_putthe parameters as replicated (P()) once. Each step outputs parameters with the same sharding, and you just pass them back in for the next step. With pmap, the common approach is to give the parameters a device axis and pass them within_axes=0(see Section 3.9), so they stay on the devices. - Global batch and learning rate. When the device count goes from 1 to $N$ with the per-device batch size fixed, the global batch grows $N$ times and gradient noise drops. A common rule of thumb is to scale the learning rate linearly ($\eta \to N\eta$) together with warmup. For very large batches linear scaling often stops working, and people usually switch to layer-wise adaptive optimizers like LARS / LAMB. This doesn't change the conclusion that "after pmean you get the global gradient"; it only affects convergence-related hyperparameters.
- The sharded dimension must divide evenly.
P('data')requires axis 0 to be divisible by the size of the mesh axis. The last incomplete batch is either dropped, or padded and used with a mask (Basics 05, extra exercise 4, covers how to average correctly over batches of unequal length).
3.5 Collective communication primitives (psum, pmean, etc.)¶
Inside pmap, each device runs the same program on its own slice of the data. But distributed algorithms can't do without communication and synchronization between devices. In data-parallel training, for example, once each device has computed gradients on its local batch, those gradients must be combined across all devices (usually averaged) before the parameters can be updated. That step relies on collective operations.
The collectives in jax.lax let arrays spread across devices (along the pmap's mapped axis) take part in one computation together, and the result is usually replicated back to every participating device. Collectives must be called inside a transformation that binds the corresponding axis name. Besides pmap, in 0.12 that can also be shard_map or a vmap with axis_name (the extra in Section 3.6 demos vmap).
Use axis_name to pick the communication axis: a collective needs axis_name to say which axis to communicate along, and the name must match the one given by the enclosing pmap(..., axis_name=...). Explicit names avoid ambiguity, which matters especially for multi-dimensional parallelism.
Core reductions: psum, pmean, pmax, pmin¶
Suppose there are $N$ devices along axis 'i', and device $j$ holds $x_j$. The formulas below are all element-wise, and every device gets the same result:
$$ \texttt{psum}: y = \sum_{j=0}^{N-1} x_j,\qquad \texttt{pmean}: y = \frac{1}{N}\sum_{j} x_j,\qquad \texttt{pmax}: y = \max_j x_j,\qquad \texttt{pmin}: y = \min_j x_j . $$
psum: commonly used to sum data-parallel gradients before the optimizer update. For example, if 4 devices hold 2, 3, 1 and 4, afterpsumevery device gets $2+3+1+4=10$;pmean: the standard way to average gradients in data parallelism;pmax/pmin: useful for syncing flags (e.g. "did any device hit an error?") or finding the largest loss in the whole batch.
⚠️ Note:
DeviceArraywas removed long ago. Example outputs in older material often look likeDeviceArray([6., 6., 6., 6.], dtype=float32). Today every array is a unifiedjax.Arrayand prints asArray([...], dtype=float32).
# Course example: psum / pmean / pmax. The example assumes 4 devices; when pmap's leading axis has length 4, only the first 4 devices are used
def sum_across_devices(local_value):
return lax.psum(local_value, axis_name="batch_axis") # 'batch_axis' must match pmap's axis_name
def average_across_devices(local_value):
return lax.pmean(local_value, axis_name="batch_axis")
def get_max_value(local_value):
return lax.pmax(local_value, axis_name="data_split")
values = jnp.arange(4.) # one value per device: [0, 1, 2, 3]
r_sum = jax.pmap(sum_across_devices, axis_name="batch_axis")(values)
r_mean = jax.pmap(average_across_devices, axis_name="batch_axis")(values)
r_max = jax.pmap(get_max_value, axis_name="data_split")(jnp.array([2., 5., 1., 4.]))
r_text = jax.pmap(sum_across_devices, axis_name="batch_axis")(jnp.array([2., 3., 1., 4.])) # the example from the text
print("psum :", repr(r_sum)) # every device gets 0+1+2+3 = 6
print("pmean:", r_mean) # (0+1+2+3)/4 = 1.5
print("pmax :", r_max)
print("psum([2,3,1,4]):", r_text)
assert type(r_sum).__name__ == "ArrayImpl" and isinstance(r_sum, jax.Array) # no longer a DeviceArray
assert np.array_equal(r_sum, [6.] * 4) and np.array_equal(r_mean, [1.5] * 4)
assert np.array_equal(r_max, [5.] * 4) and np.array_equal(r_text, [10.] * 4)
# ✅ Modern style: in shard_map, use the mesh axis name directly for collectives; out_specs=P() means the result is identical on every device (invariant)
mesh4c = jax.make_mesh((4,), ("batch_axis",), devices=jax.devices()[:4])
vals4 = jax.device_put(jnp.arange(4.), NamedSharding(mesh4c, P("batch_axis")))
def reduce_all(x): # x is each device's local block, shape (1,)
return lax.psum(x, "batch_axis"), lax.pmean(x, "batch_axis"), lax.pmax(x, "batch_axis")
s, m, mx = jax.jit(jax.shard_map(reduce_all, mesh=mesh4c, in_specs=P("batch_axis"), out_specs=P()))(vals4)
assert float(s[0]) == float(r_sum[0]) == 6.0
assert float(m[0]) == float(r_mean[0]) == 1.5
assert float(mx[0]) == 3.0 # max(0,1,2,3)
# ✅ Modern style: jit + sharding, no collectives to write at all: jnp.sum is the global sum, and the compiler inserts the all-reduce
f_sum = jax.jit(jnp.sum)
assert float(f_sum(vals4)) == 6.0
print("Communication used by the jit version:", collectives(f_sum.lower(vals4).compile()))
⚠️ Note: it's easy to assume that
pmeanis more efficient thanpsumfollowed by a manual division by the device count, because the backend can optimize it specially. In factpmeanispsumplus one division. The jaxpr below only containspsum_invariantanddiv; there is no dedicatedpmeanprimitive, and both versions compile to exactly the same communication.pmeanis recommended only because it reads better and you won't forget to divide by $N$.
mesh8 = jax.make_mesh((N,), ("i",))
v8 = jax.device_put(jnp.arange(8.), NamedSharding(mesh8, P("i")))
jaxpr_txt = str(jax.make_jaxpr(jax.shard_map(lambda x: lax.pmean(x, "i"), mesh=mesh8,
in_specs=P("i"), out_specs=P()))(v8))
print(jaxpr_txt)
assert "psum" in jaxpr_txt and "div" in jaxpr_txt and "pmean" not in jaxpr_txt
# Both versions compile to the same communication: a single all-reduce
f_pmean = jax.jit(jax.shard_map(lambda x: lax.pmean(x, "i"), mesh=mesh8, in_specs=P("i"), out_specs=P()))
f_psum = jax.jit(jax.shard_map(lambda x: lax.psum(x, "i") / N, mesh=mesh8, in_specs=P("i"), out_specs=P()))
assert collectives(f_pmean.lower(v8).compile()) == collectives(f_psum.lower(v8).compile()) == ["all-reduce"]
assert np.allclose(f_pmean(v8), f_psum(v8))
Other collectives¶
Three more are commonly used:
jax.lax.all_gather: collects the inputs from all devices and concatenates them along the mapped axis, so every device gets the full array. Keep in mind that the output size grows linearly with the device count, so it can take a lot of memory;jax.lax.ppermute: exchanges data according to the (source, destination) pairs in thepermargument. It is more general and is the building block for ring all-reduce and model-parallel communication, but the indices need careful handling (Basics 05, extra exercise 2, already used it to write a ring all-reduce by hand);jax.lax.axis_index(axis_name): strictly speaking, not a communication primitive. It returns the integer index of the current device along the given axis, so devices at different positions can run different logic.
Overall, collective primitives are the basic building blocks for coordinating devices inside pmap: gradient aggregation, sync points and data exchange all rely on them. JAX and XLA use NVLink or inter-chip interconnect (ICI) on GPUs/TPUs to optimize execution, but communication always has a cost.
🔍 Extra: exact semantics of the four "rearranging" collectives¶
🟢 Must-read
psum_scatter and all_to_all are at the heart of ZeRO/FSDP, MoE and sequence parallelism. Suppose there are $N$ devices on the axis, device $j$ holds $x_j$, and $x_j$ is split evenly along some dimension into $N$ blocks $x_j^{(0)},\dots,x_j^{(N-1)}$:
| Operation | Device $i$ gets | Communication per device (ring) | Typical use |
|---|---|---|---|
all_gather(x, tiled=True) | $[x_0; x_1; \dots; x_{N-1}]$ | $\frac{N-1}{N}S_\text{out}$ | FSDP: gather parameters before the forward pass; tensor parallelism: gather activations |
psum_scatter(x, tiled=True) | $\sum_j x_j^{(i)}$ (block $i$ of the sum) | $\frac{N-1}{N}S_\text{in}$ | Gradient reduce-scatter in FSDP / ZeRO-2 |
all_to_all(x, split_axis, concat_axis) | $[x_0^{(i)}; x_1^{(i)}; \dots; x_{N-1}^{(i)}]$ | $\frac{N-1}{N}S$ | Distributed transpose, MoE token routing, Ulysses sequence parallelism |
ppermute(x, perm) | $x_{\pi^{-1}(i)}$; 0 if nobody sends to it | $S$ | Ring algorithms, halo exchange, passing activations in a pipeline |
There is also an identity: all-reduce = reduce-scatter + all-gather. Below we verify each one on 8 devices (an 8×8 matrix split by rows, device i holds row i).
X8 = np.arange(64.).reshape(8, 8)
X8s = jax.device_put(jnp.asarray(X8), NamedSharding(mesh8, P("i"))) # device i holds row i, local shape (1, 8)
def smap(f, in_specs=P("i"), out_specs=P("i")):
return jax.jit(jax.shard_map(f, mesh=mesh8, in_specs=in_specs, out_specs=out_specs))
# all_gather: tiled=False adds a new axis of length N, local (8,1,8); tiled=True concatenates along an existing axis, local (8,8)
ag = smap(lambda x: lax.all_gather(x, "i", tiled=True))(X8s)
assert np.array_equal(np.asarray(ag), np.tile(X8, (8, 1))) # every device gets the full X
ag_shape = jax.eval_shape(jax.shard_map(lambda x: lax.all_gather(x, "i"), mesh=mesh8, in_specs=P("i"), out_specs=P("i")), X8s).shape
print("all_gather non-tiled global shape:", ag_shape, "| tiled:", ag.shape)
# psum_scatter: first sum element-wise (column sums), then split the result into 8 blocks along axis 1; device i keeps only block i
rs = smap(lambda x: lax.psum_scatter(x, "i", scatter_dimension=1, tiled=True))(X8s)
assert np.array_equal(np.asarray(rs).ravel(), X8.sum(0))
print("psum_scatter global shape:", rs.shape, "= column sums", X8.sum(0)[:4], "...")
# all_to_all: each device cuts its row into 8 blocks and sends one to each of the 8 devices → "split by rows" becomes "split by columns"
a2a = smap(lambda x: lax.all_to_all(x, "i", split_axis=1, concat_axis=0, tiled=True), out_specs=P(None, "i"))(X8s)
assert np.array_equal(np.asarray(a2a), X8)
print("all_to_all before:", jax.typeof(X8s), "→ after:", jax.typeof(a2a), "(same data, sharding moved from rows to columns)")
assert a2a.addressable_shards[3].data.shape == (8, 1)
assert np.array_equal(np.asarray(a2a.addressable_shards[3].data).ravel(), X8[:, 3]) # device 3 holds column 3
# ppermute: ring shift right, device j sends to j+1
pp = smap(lambda x: lax.ppermute(x, "i", [(j, (j + 1) % 8) for j in range(8)]))(X8s)
assert np.array_equal(np.asarray(pp), np.roll(X8, 1, axis=0))
# Identity: reduce-scatter + all-gather == all-reduce
rsag = smap(lambda x: lax.all_gather(lax.psum_scatter(x, "i", scatter_dimension=1, tiled=True), "i", axis=1, tiled=True))(X8s)
ar = smap(lambda x: lax.psum(x, "i"))(X8s)
assert np.array_equal(np.asarray(rsag), np.asarray(ar))
print("reduce-scatter + all-gather == all-reduce ✓")
🔍 Extra: halo exchange with ppermute (stencil computations)¶
⚪ Optional: for domain decomposition in stencil computations such as convolutions or finite differences
Basics 05 already used ppermute to write a ring all-reduce. Here is another equally common case: boundary exchange in stencil computations, such as 1D convolution or finite differences. Split an array of length 64 into 8 segments and compute $y_k = x_{k-1} + x_k + x_{k+1}$ (zero padding at both ends). Besides its own segment, each device needs the last element of its left neighbor and the first element of its right neighbor.
The key is that ppermute's perm doesn't have to be a full permutation: a device that receives nothing gets all zeros, which is exactly the zero-padding boundary condition.
xs1d = np.arange(64.) ** 1.5
xs1d_s = jax.device_put(jnp.asarray(xs1d), NamedSharding(mesh8, P("i")))
def stencil(x): # x: this device's 8 elements
to_right = [(j, j + 1) for j in range(7)] # device j sends its last element to j+1 (device 0 receives nothing → 0)
to_left = [(j + 1, j) for j in range(7)] # device j+1 sends its first element to j (device 7 receives nothing → 0)
left_halo = lax.ppermute(x[-1:], "i", to_right)
right_halo = lax.ppermute(x[:1], "i", to_left)
padded = jnp.concatenate([left_halo, x, right_halo])
return padded[:-2] + padded[1:-1] + padded[2:]
y_st = smap(stencil)(xs1d_s)
ref_st = np.convolve(np.pad(xs1d, 1), np.ones(3), mode="valid") # NumPy float64 reference
assert np.allclose(f64(y_st), ref_st, rtol=1e-6)
print("Halo-exchange stencil == NumPy convolution ✓; communication used:", collectives(smap(stencil).lower(xs1d_s).compile()))
🔍 Extra: values after all_gather are still varying¶
⚪ Optional: read this when
out_specs=P()in shard_map raises a replication error
shard_map defaults to check_vma=True and tracks whether each value differs across devices (Basics 05 covered this). The results of psum / pmean / pmax are invariant (identical on every device), so you can output a single copy with out_specs=P(). The trap: in 0.12's type system, the result of all_gather is still marked varying ({V:i}), so writing out_specs=P() right after an all_gather raises an error. There are two ways out: keep out_specs=P('i') (from outside you see N copies concatenated); or, if you really need a replicated output, switch to jit + sharding and let the compiler handle it.
types = {}
def probe_vma(x):
types["local"] = str(jax.typeof(x))
types["psum"] = str(jax.typeof(lax.psum(x, "i")))
types["all_gather"] = str(jax.typeof(lax.all_gather(x, "i", tiled=True)))
return x
smap(probe_vma)(X8s)
for k, v in types.items():
print(f"{k:11s}: {v}")
assert "V:i" in types["local"] and "V:i" not in types["psum"] and "V:i" in types["all_gather"]
try:
smap(lambda x: lax.all_gather(x, "i", tiled=True), out_specs=P())(X8s)
raised = False
except ValueError as e:
raised = True
print("all_gather + out_specs=P() raised:", str(e)[:90], "...")
assert raised
3.6 Handling axis names in pmap¶
When you write a function to parallelize with pmap, especially one that uses collectives like lax.psum or lax.pmean, you have to say clearly which group of devices the communication happens across. You do that by naming the axis pmap maps over with axis_name, and then referring to that name in the collective.
⚠️ Note:
axis_nameis a required argument ofpsum. It's easy to think that in simple cases you can leave it out andpsumwill implicitly sum over the mapped axis; in fact leaving it out raisesTypeError: psum() missing 1 required positional argument: 'axis_name'right away. JAX does not "infer which axis a collective runs along".
# ❌ Old style (wrong): assume psum works implicitly without axis_name
try:
jax.pmap(lambda x: lax.psum(x * 2.0))(jnp.arange(8.))
raised = False
except TypeError as e:
raised = True
print("Leaving out axis_name raised:", e)
assert raised
# ✅ Recommended / course example: name the mapped axis 'devices' and refer to it explicitly in psum
def scaled_sum(x):
scaled_x = x * 2.0
return lax.psum(scaled_x, axis_name="devices")
n_devices = jax.local_device_count()
print(f"Using {n_devices} devices.")
data = jnp.arange(n_devices, dtype=jnp.float32)
parallel_computation = jax.pmap(scaled_sum, axis_name="devices") # the name is bound to the input's axis 0
result = parallel_computation(data)
print("Input data:", data)
print("Result (same on every device):", result)
expected_sum = jnp.sum(data * 2.0)
print("Expected sum:", expected_sum)
assert jnp.allclose(result[0], expected_sum)
# Two examples: [0,1] → 2; [0,1,2,3] → 12
assert np.array_equal(jax.pmap(scaled_sum, axis_name="devices")(jnp.arange(2.)), [2., 2.])
assert np.array_equal(jax.pmap(scaled_sum, axis_name="devices")(jnp.arange(4.)), [12.] * 4)
# ✅ Modern style: the axis name 'devices' lives on the mesh; in shard_map, sum within the block first and then psum; the jit version just writes a global sum
mesh_dev = jax.make_mesh((N,), ("devices",))
data_s = jax.device_put(data, NamedSharding(mesh_dev, P("devices")))
res_sm = jax.jit(jax.shard_map(lambda x: lax.psum(jnp.sum(x * 2.0), "devices"), mesh=mesh_dev,
in_specs=P("devices"), out_specs=P()))(data_s)
res_jit = jax.jit(lambda x: jnp.sum(x * 2.0))(data_s)
assert float(res_sm) == float(res_jit) == float(result[0]) == 56.0
print("pmap / shard_map / jit all give", float(res_jit))
The course lists three benefits of named axes:
- Clarity:
lax.psum(..., axis_name='batch')tells you at a glance that the sum runs over the batch dimension; - Fits multi-dimensional parallelism: when there are several levels of parallelism, each level has its own name, and the collective uses the name to say which level it acts on. For example, with two levels
'data_shards'and'model_replicas': a collective along'data_shards'happens between devices that "hold different data shards but the same model replica"; one along'model_replicas'happens between devices that "hold different model replicas but the same data shard". In 0.12 this corresponds to the two axes of a 2D mesh (see Section 3.7); - Composability: a function that reduces along a
'spatial'axis can be reused in any parallel context that maps that name.
The second example is gradient averaging in data parallelism: each device computes gradients on its local shard, and before updating the parameters uses pmean to average across all devices.
⚠️ Note: in this example both leaves of
paramsare scalars (shape()). If you callpmap(parallel_update_step, axis_name='data_parallel_axis')withoutin_axes, it uses the defaultin_axes=0, and a scalar has no axis 0 to map over, so you getIndexError: tuple index out of range. Writein_axes=(None, 0)instead. Also, when taking device 0's parameters at the end, you can't usejax.tree_map: it has been removed in 0.12 (AttributeError). Usejax.tree.map.
# Course example: model and update step
def loss_fn(params, local_batch):
predictions = params["w"] * local_batch["x"] + params["b"]
error = predictions - local_batch["y"]
return jnp.mean(error ** 2)
def compute_gradients(params, local_batch):
return jax.grad(loss_fn)(params, local_batch)
def parallel_update_step(params, sharded_batch):
local_grads = compute_gradients(params, sharded_batch) # each device computes gradients locally
avg_grads = lax.pmean(local_grads, axis_name="data_parallel_axis") # average along the named axis
learning_rate = 0.01
return jax.tree.map(lambda p, g: p - learning_rate * g, params, avg_grads)
n_devices = jax.local_device_count()
params_s = {"w": jnp.ones(()), "b": jnp.zeros(())}
xs_b = jnp.arange(n_devices * 4, dtype=jnp.float32).reshape((n_devices, 4, 1))
ys_b = xs_b * 2.0 + 0.5 # true w=2.0, b=0.5
sharded_batch = {"x": xs_b, "y": ys_b}
# ❌ Old style (wrong): leave out in_axes (scalar params can't be mapped with the default in_axes=0), and use the removed jax.tree_map
try:
jax.pmap(parallel_update_step, axis_name="data_parallel_axis")(params_s, sharded_batch)
err1 = None
except IndexError as e:
err1 = e
print("Leaving out in_axes:", type(e).__name__, "-", e)
try:
jax.tree_map(lambda x: x, params_s)
err2 = None
except AttributeError as e:
err2 = e
print("jax.tree_map:", type(e).__name__, "-", e)
assert isinstance(err1, IndexError) and isinstance(err2, AttributeError)
# ✅ Recommended (still with pmap): broadcast params with None, map data along axis 0; use jax.tree.map to take device 0's result
p_update_step = jax.pmap(parallel_update_step, axis_name="data_parallel_axis", in_axes=(None, 0))
new_params = p_update_step(params_s, sharded_batch)
new_params_0 = jax.tree.map(lambda x: x[0], new_params)
print("Original params:", params_s)
print("Updated params (device 0):", new_params_0)
# After pmean, the parameters are identical on every device
assert all(np.all(np.asarray(v) == np.asarray(v)[0]) for v in jax.tree.leaves(new_params))
# ✅ Modern style: jit + NamedSharding. No axis names appear in the function; use the loss over the global batch, and the compiler inserts the gradient all-reduce
mesh_dp = jax.make_mesh((N,), ("data_parallel_axis",))
batch_global = jax.device_put({"x": xs_b.reshape(-1, 1), "y": ys_b.reshape(-1, 1)},
NamedSharding(mesh_dp, P("data_parallel_axis")))
params_rep_s = jax.device_put(params_s, NamedSharding(mesh_dp, P()))
@jax.jit
def update_step_global(params, batch):
grads = jax.grad(loss_fn)(params, batch) # gradient of the global mean loss == pmean of each device's local gradient
return jax.tree.map(lambda p, g: p - 0.01 * g, params, grads)
new_params_jit = update_step_global(params_rep_s, batch_global)
print("Communication:", collectives(update_step_global.lower(params_rep_s, batch_global).compile()))
# Cross-check against NumPy float64: analytic gradients dL/dw = 2·mean(err·x), dL/db = 2·mean(err)
xx, yy = f64(xs_b).ravel(), f64(ys_b).ravel()
err = 1.0 * xx + 0.0 - yy
w_ref, b_ref = 1.0 - 0.01 * 2 * np.mean(err * xx), 0.0 - 0.01 * 2 * np.mean(err)
for k, ref_v in [("w", w_ref), ("b", b_ref)]:
assert np.allclose(float(new_params_0[k]), ref_v, rtol=1e-6)
assert np.allclose(float(new_params_jit[k]), ref_v, rtol=1e-6)
print(f"pmap == jit+sharding == NumPy: w={w_ref:.6f}, b={b_ref:.6f} ✓")
So writing axis_name explicitly is more than a recommendation: as shown above, collectives can't leave out the axis name at all. Clear axis names make distributed code easier to read, less prone to subtle bugs, and easier to maintain and compose.
🔍 Extra: where axis names "live" in 0.12¶
⚪ Optional: when you need reductions over several axes, or want to test communication logic on a single device with vmap
- Axis names live on the mesh.
jax.make_mesh((4, 2), ('data', 'model'))defines the axis names;PartitionSpecuses those names to describe sharding; collectives insideshard_maprefer to them too. pmap'saxis_nameis just a "one-dimensional, temporary" mesh axis. - You can reduce over several axes at once:
lax.psum(x, ('data', 'model'))sums over the whole 2D mesh;lax.axis_index('data')returns this device's coordinate along that axis. - Test collective logic on a single device:
vmapalso acceptsaxis_name, andpsum / all_gather / ppermuteare all defined undervmap, with the batch dimension playing the role of "devices". When writing a complex communication pattern, you can unit-test it on the CPU withvmapfirst, and then switch toshard_map.
# vmap also accepts axis_name: check the semantics of collectives on a single device
vx = jnp.arange(4.)
v_psum = jax.vmap(lambda x: lax.psum(x, "b"), axis_name="b")(vx)
v_perm = jax.vmap(lambda x: lax.ppermute(x, "b", [(0, 1), (1, 2), (2, 3), (3, 0)]), axis_name="b")(vx)
v_idx = jax.vmap(lambda x: lax.axis_index("b"), axis_name="b")(vx)
print("vmap psum:", v_psum, "| ppermute:", v_perm, "| axis_index:", v_idx)
assert np.array_equal(v_psum, [6.] * 4) and np.array_equal(v_perm, [3., 0., 1., 2.]) and np.array_equal(v_idx, [0, 1, 2, 3])
# Compare with the shard_map version (Explicit mesh: the input must first be device_put with a sharding that matches in_specs)
mesh_b = jax.make_mesh((4,), ("b",), devices=jax.devices()[:4])
sm_perm = jax.jit(jax.shard_map(lambda x: lax.ppermute(x, "b", [(0, 1), (1, 2), (2, 3), (3, 0)]),
mesh=mesh_b, in_specs=P("b"), out_specs=P("b")))(
jax.device_put(vx, NamedSharding(mesh_b, P("b"))))
assert np.array_equal(np.asarray(sm_perm), np.asarray(v_perm))
print("vmap and shard_map give the same ppermute result ✓")
3.7 Nested pmap and advanced partitioning¶
A single pmap is good at spreading work over a one-dimensional row of devices. More complex parallelism (such as data parallelism + model parallelism) needs a multi-dimensional grid of accelerators. The course's approach is nested pmap: take 4 GPUs arranged in a 2×2 grid, with rows doing data parallelism and columns doing model parallelism; a function under the outer pmap(axis_name='data') calls a function under the inner pmap(axis_name='model').
Three things to watch for when nesting:
- Axis scope: an axis name defined by the inner pmap is only valid within the inner call and the devices it involves; the outer
axis_namerefers to collectives between the outer devices; - Data mapping: the input shape is
(outer size, inner size, ...); axis 0 corresponds to the outer'data'axis and axis 1 to the inner'model'axis; - Collectives must specify
axis_name, which decides which group of devices communicates.
⚠️ Note: this nested-pmap code fails in several places in JAX 0.12:
from jax.experimental import maps: the module has been removed (ModuleNotFoundError);jnp.ones(16.): shapes must be integers, so this raisesTypeError; writejnp.ones(16);jax.device_put(...).devices:devicesis a method, and without parentheses you get a bound method object. The variable namedevice_meshis also misleading: the code never builds a mesh.jnp.arange(num_devices).reshape(2, 2)only works with exactly 4 devices and fails with 8. The fallback branchjax.local_devices()[:1] * 4repeats the same device 4 times, which can't be used for parallelism either;- The most fundamental problem: nested pmap raises
ValueErroroutright in 0.12 (pmap is now implemented on top of shard_map, and the inner mesh conflicts with the outer one). Use a 2D mesh + shard_map instead.
# ❌ Old style (outdated/wrong): jax.experimental.maps, jnp.ones(16.), .devices without parentheses, reshape(2,2), nested pmap
problems = {}
try:
import jax.experimental.maps # noqa: F401
except ModuleNotFoundError as e:
problems["experimental.maps"] = f"{type(e).__name__}: {e}"
try:
jnp.ones(16.)
except TypeError as e:
problems["jnp.ones(16.)"] = f"{type(e).__name__}: {str(e)[:70]}"
try:
jnp.arange(jax.local_device_count()).reshape(2, 2)
except TypeError as e:
problems["arange(8).reshape(2,2)"] = f"{type(e).__name__}: {str(e)[:70]}"
arr = jax.device_put(jnp.arange(4).reshape(2, 2), jax.devices()[0])
problems[".devices without ()"] = f"gives a {type(arr.devices).__name__}, not a device mesh"
from functools import partial
@partial(jax.pmap, axis_name="model") # the original example writes @jax.pmap(axis_name='model'); partial is equivalent and more general
def inner_op(x_model_shard, params_model_shard):
return lax.psum(x_model_shard * params_model_shard, axis_name="model") * 2
@partial(jax.pmap, axis_name="data")
def outer_op(x_data_shard, params_data_shard):
return lax.psum(inner_op(x_data_shard, params_data_shard), axis_name="data")
data_simple = jnp.arange(4.).reshape(2, 2)
params_simple = jnp.ones(4).reshape(2, 2) * 0.5 # ones(4.) already fixed to ones(4), so we only look at the nested pmap itself
try:
outer_op(data_simple, params_simple)
except ValueError as e:
problems["nested pmap"] = f"{type(e).__name__}: {str(e)[:110]}"
for k, v in problems.items():
print(f"- {k:24s} → {v}")
assert len(problems) == 5
# ✅ Recommended / Modern style: nested pmap → 2×2 mesh + shard_map. One function refers to both axis names
mesh22 = jax.make_mesh((2, 2), ("data", "model"), devices=jax.devices()[:4])
print(mesh22)
def inner(x, p):
return lax.psum(x * p, "model") * 2 # sum along the 'model' axis (the 2 devices in the same row)
def outer(x, p):
return lax.psum(inner(x, p), "data") # sum along the 'data' axis (the 2 devices in the same column)
put22 = lambda a: jax.device_put(a, NamedSharding(mesh22, P("data", "model")))
f22 = jax.jit(jax.shard_map(outer, mesh=mesh22, in_specs=(P("data", "model"),) * 2, out_specs=P("data", "model")))
out_simple = np.asarray(f22(put22(data_simple), put22(params_simple)))
print("Simple example output:\n", out_simple)
assert np.array_equal(out_simple, np.full((2, 2), 6.0)) # not 12.0
# Where the tempting 12 comes from: psum the inner result (already replicated along model) over both axes, so the same value is counted twice
# The vma type check rejects this double counting (summing over model a value that is invariant along model); you have to turn off check_vma to reproduce 12
try:
jax.jit(jax.shard_map(lambda x, p: lax.psum(inner(x, p), ("data", "model")), mesh=mesh22,
in_specs=(P("data", "model"),) * 2, out_specs=P()))(put22(data_simple), put22(params_simple))
vma_raised = False
except ValueError as e:
vma_raised = True
print("check_vma=True rejects the double sum:", str(e)[:80], "...")
f_double = jax.jit(jax.shard_map(lambda x, p: lax.psum(inner(x, p), ("data", "model")), mesh=mesh22,
in_specs=(P("data", "model"),) * 2, out_specs=P(), check_vma=False))
twelve = float(np.asarray(f_double(put22(data_simple), put22(params_simple))).ravel()[0])
print("check_vma off, psum(1,1,5,5) over both axes =", twelve, "← 12.0 comes from double counting")
assert vma_raised and twelve == 12.0
# 4D example: data (2,2,2,2), each device gets a (1,1,2,2) block
data4 = jnp.arange(16.).reshape(2, 2, 2, 2)
params4 = jnp.ones(16).reshape(2, 2, 2, 2) * 0.5
out4 = np.asarray(f22(put22(data4), put22(params4)))
print("4D example output[0, 0]:\n", out4[0, 0])
assert np.array_equal(out4[0, 0], np.asarray(data4).sum(axis=(0, 1))) # 2·0.5·Σ_ij x_ij = Σ_ij x_ij
assert all(np.array_equal(out4[i, j], out4[0, 0]) for i in range(2) for j in range(2))
⚠️ Note: for the simple example (
data_simple = arange(4).reshape(2,2),params = 0.5), it's easy to computeoutput_simple[0]as12.0, reasoning that "the outer psum(1, 1, 5, 5) = 12". Running the equivalent 2D mesh + shard_map implementation above actually gives[6., 6.]. The reason is that the outerpsum(..., 'data')only sums along the'data'axis, which involves just the 2 devices in the same column, not all 4:
$$ \text{inner}_{i} = 2\sum_{j} x_{ij}p_{ij} = \begin{cases}2(0+0.5)=1 & i=0\\ 2(1+1.5)=5 & i=1\end{cases}, \qquad \text{outer} = \sum_i \text{inner}_i = 1+5 = 6 . $$
The "psum(1, 1, 5, 5)" reasoning sums the inner result once more along both axes. But the inner result is already replicated along 'model' (both devices in a row hold 1, or both hold 5), so summing along 'model' again counts the same value twice. 0.12's vma type check rejects this outright (verified above). The 4D example works the same way: the correct result is output[i, j] = data.sum(axis=(0, 1)), cross-checked against NumPy above.
Advanced partitioning strategies¶
pmap still follows SPMD, and the partitioning is decided implicitly by how inputs are sharded along the mapped axes. Two common patterns:
- Data parallelism + model parallelism:
psumthe gradients on the outer axis,all_gatherthe activations on the inner axis; - 2D/3D tensor parallelism: split a large matrix multiplication over a 2D or 3D device grid.
Picture a 2×2 grid: the outer pmap maps over the "data" axis (rows), the inner one over the "model" axis (columns).
To assign devices by hand, you can pre-place the inputs with jax.device_put, or restrict the devices with pmap(devices=...). For more complex cases, people used to use jax.jit(device=...), jax.experimental.shard_map, or frameworks built on jax.Array.
⚠️ Note:
jax.jit(device=...)is deprecated in 0.12 and emits aDeprecationWarning; the recommendation is todevice_putthe inputs instead.jax.experimental.shard_maphas been deprecated since v0.8.0; usejax.shard_map.
# ❌ Old style (outdated): jax.jit(device=...) and jax.experimental.shard_map both emit a DeprecationWarning
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
jax.jit(lambda x: x + 1, device=jax.devices()[1])(1.0)
from jax.experimental.shard_map import shard_map as _old_shard_map # noqa: F401
msgs = [str(x.message) for x in w if issubclass(x.category, DeprecationWarning)]
for m_ in msgs:
print("DeprecationWarning:", m_[:110])
assert any("device argument on jit is deprecated" in m_ for m_ in msgs)
assert any("jax.experimental.shard_map is deprecated" in m_ for m_ in msgs)
# ✅ Recommended: device_put the input onto the target device and the computation follows the data; use jax.shard_map instead of the experimental version
y_dev = jax.jit(lambda x: x + 1)(jax.device_put(1.0, jax.devices()[1]))
assert y_dev.device == jax.devices()[1]
print("Computation ran on", y_dev.device, "(it follows the data)")
🔍 Extra: combining a 2D mesh with PartitionSpec¶
🟢 Must-read
Here is the full toolkit for multi-dimensional partitioning in 0.12. First arrange the 8 devices into a (data=4, model=2) mesh. Entry k of a PartitionSpec says which mesh axis (or axes) dimension k of the array is split along:
PartitionSpec | Meaning | Per-device block of an (8, 4) array |
|---|---|---|
P() | Fully replicated | (8, 4) |
P('data') | Axis 0 split 4 ways along data, replicated along model | (2, 4) |
P(None, 'model') | Axis 1 split 2 ways along model | (8, 2) |
P('data', 'model') | Both axes split | (2, 2) |
P('model', 'data') | Mapping swapped | (4, 1) |
P(('data', 'model')) | Axis 0 split 8 ways along both axes combined | (1, 4) |
jax.typeof(x) shows the sharding type, e.g. float32[8@data,4@model]; jax.debug.visualize_array_sharding draws the layout.
mesh2d = jax.make_mesh((4, 2), ("data", "model"))
print(mesh2d)
A = jnp.arange(32.).reshape(8, 4)
cases = [(P(), (8, 4)), (P("data"), (2, 4)), (P(None, "model"), (8, 2)), (P("data", "model"), (2, 2)),
(P("model", "data"), (4, 1)), (P(("data", "model")), (1, 4))]
for spec, local in cases:
a = jax.device_put(A, NamedSharding(mesh2d, spec))
print(f"{str(spec):26s} local block {a.addressable_shards[0].data.shape} type {jax.typeof(a)}")
assert a.addressable_shards[0].data.shape == local
assert np.array_equal(np.asarray(a), np.asarray(A)) # sharding only changes where data lives, not its values
print("Layout of P('data', 'model'):")
jax.debug.visualize_array_sharding(jax.device_put(A, NamedSharding(mesh2d, P("data", "model"))))
🔍 Extra: AxisType — Explicit / Auto / Manual¶
🟢 Must-read
In 0.12, jax.make_mesh creates AxisType.Explicit axes by default (Basics 05 mentioned this). How the three axis types differ:
| Axis type | Is sharding part of the type? | Who decides the sharding | Where it shows up |
|---|---|---|---|
Explicit | Yes, jax.typeof shows 8@data | Propagated op by op by type rules; ambiguity is an error and you must specify | Default of make_mesh |
Auto | No | The compiler (GSPMD / Shardy) propagates it; you can hint with with_sharding_constraint | axis_types=(AxisType.Auto, ...) |
Manual | — | You write the collectives yourself | Inside a shard_map function body |
The upside of Explicit mode is that sharding is fixed at trace time, so what you see is what you get. The cost is that you must be explicit when there's ambiguity. The classic example is a matmul where the contracting dimension is sharded on both sides: should the output be all-reduced to replicated, or reduce-scattered to sharded? JAX raises ShardingTypeError and asks you to say so with out_sharding=.
xs2 = jax.device_put(jnp.ones((8, 4)), NamedSharding(mesh2d, P("data", "model")))
ws2 = jax.device_put(jnp.ones((4, 6)), NamedSharding(mesh2d, P("model", None)))
print("Mesh axis types:", mesh2d.axis_types)
assert mesh2d.axis_types == (AxisType.Explicit, AxisType.Explicit)
@jax.jit
def trace_types(x):
print(" at trace time x :", jax.typeof(x))
print(" at trace time x.T:", jax.typeof(x.T)) # after transposing, the sharding swaps too
print(" at trace time sum(0):", jax.typeof(x.sum(0))) # the reduced data dimension disappears
return x.T
trace_types(xs2)
# Contracting dim (4) sharded along model on both sides → ambiguous output sharding → ShardingTypeError
try:
xs2 @ ws2
raised = False
except Exception as e:
raised = type(e).__name__ == "ShardingTypeError"
print("Ambiguous matmul:", type(e).__name__, "-", str(e)[:95], "...")
assert raised
# Make it explicit with out_sharding: the result is split by rows along data (the compiler inserts an all-reduce along model)
r_mm = jnp.matmul(xs2, ws2, out_sharding=NamedSharding(mesh2d, P("data", None)))
print("With out_sharding:", jax.typeof(r_mm))
assert str(jax.typeof(r_mm)) == "float32[8@data,6]" and np.allclose(np.asarray(r_mm), 4.0)
# Auto mesh: sharding isn't shown in the type, the compiler decides, and the matmul doesn't raise
mesh2d_auto = jax.make_mesh((4, 2), ("data", "model"), axis_types=(AxisType.Auto,) * 2)
xa2 = jax.device_put(jnp.ones((8, 4)), NamedSharding(mesh2d_auto, P("data", "model")))
wa2 = jax.device_put(jnp.ones((4, 6)), NamedSharding(mesh2d_auto, P("model", None)))
ra = jax.jit(jnp.matmul)(xa2, wa2)
print("Auto mesh: type", jax.typeof(ra), "| sharding chosen by the compiler", ra.sharding.spec)
assert str(jax.typeof(ra)) == "float32[8,6]" and np.allclose(np.asarray(ra), 4.0)
# Manual: inside a shard_map function body, the mesh axes become Manual
seen = {}
def peek(x):
seen["types"] = jax.sharding.get_abstract_mesh().axis_types
return x
jax.jit(jax.shard_map(peek, mesh=mesh2d, in_specs=P("data", "model"), out_specs=P("data", "model")))(xs2)
print("Axis types inside shard_map:", seen["types"])
assert seen["types"] == (AxisType.Manual, AxisType.Manual)
🔍 Extra: with_sharding_constraint and reshard¶
⚪ Optional: when you need to pin or change the sharding of an intermediate value inside jit
jax.lax.with_sharding_constraint(x, sharding) constrains the sharding of an intermediate value inside jit. It behaves differently under the two axis types:
- Auto axes: it is a hint to the compiler. The compiler redistributes the data into the given sharding at that point, inserting communication if needed. It's commonly used to pin the sharding of activations so the compiler doesn't pick an inefficient plan;
- Explicit axes: it becomes an assertion. If the current sharding doesn't match the given one, you get an
AssertionErrorthat suggestsjax.sharding.reshard. In Explicit mode, the right tool for changing sharding isreshard.
# Auto mesh: with_sharding_constraint is a hint, and the intermediate value is redistributed to P(None, 'model')
@jax.jit
def f_hint(x):
y = x * 2
y = lax.with_sharding_constraint(y, NamedSharding(mesh2d_auto, P(None, "model")))
return y + 1
xa = jax.device_put(jnp.arange(32.).reshape(8, 4), NamedSharding(mesh2d_auto, P("data", "model")))
r_hint = f_hint(xa)
print("Auto: input", xa.sharding.spec, "→ output", r_hint.sharding.spec)
assert r_hint.sharding.spec == P(None, "model")
assert np.allclose(np.asarray(r_hint), np.arange(32.).reshape(8, 4) * 2 + 1)
# Explicit mesh: with a mismatched sharding, with_sharding_constraint fails as an assertion
xe = jax.device_put(jnp.arange(32.).reshape(8, 4), NamedSharding(mesh2d, P("data", "model")))
try:
with jax.set_mesh(mesh2d):
jax.jit(lambda x: lax.with_sharding_constraint(x * 2, P(None, "model")))(xe)
raised = False
except AssertionError as e:
raised = True
print("Explicit: with_sharding_constraint raised AssertionError:", str(e)[:100], "...")
assert raised
# Explicit mesh: change the sharding with reshard, and the type changes with it
with jax.set_mesh(mesh2d):
r_re = jax.jit(lambda x: jax.sharding.reshard(x * 2, P(None, "model")))(xe)
print("Explicit: type after reshard", jax.typeof(r_re))
assert str(jax.typeof(r_re)) == "float32[8,4@model]"
assert np.allclose(np.asarray(r_re), np.arange(32.).reshape(8, 4) * 2)
🔍 Extra: data × model 2D parallelism (a Megatron-style MLP block)¶
⚪ Optional: for tensor parallelism; full training is in Advanced 06 §6.8
The classic way to split for tensor parallelism comes from Megatron-LM. In a two-layer MLP $y = \mathrm{relu}(xW_1)W_2$, split $W_1$ by columns (P(None, 'model')) and $W_2$ by rows (P('model', None)). Then:
- In the first layer, each device computes some of the columns of the hidden layer, $h_{[:, k]} = \mathrm{relu}(xW_{1[:, k]})$. relu is element-wise, so no communication is needed;
- In the second layer, each device gets a partial sum $h_{[:, k]}W_{2[k, :]}$, which only needs one
psumalong'model'.
Add splitting the batch along 'data' and you have data × model 2D parallelism. The whole block's forward pass needs only one all-reduce. For full 2D-parallel training with an optimizer, see Advanced 06 §6.8.
k1, k2, k3 = jax.random.split(jax.random.key(42), 3)
Xm = jax.random.normal(k1, (16, 8))
W1 = jax.random.normal(k2, (8, 32)) / np.sqrt(8)
W2 = jax.random.normal(k3, (32, 8)) / np.sqrt(32)
put2 = lambda a, spec: jax.device_put(a, NamedSharding(mesh2d, spec))
def megatron_block(x, w1, w2): # x:(4,8) w1:(8,16) w2:(16,8) — each device's local block
h = jax.nn.relu(x @ w1) # some of the hidden layer's columns, no communication needed
return lax.psum(h @ w2, "model") # partial sums → sum along the model axis
f_meg = jax.jit(jax.shard_map(megatron_block, mesh=mesh2d,
in_specs=(P("data", None), P(None, "model"), P("model", None)),
out_specs=P("data", None)))
args_meg = (put2(Xm, P("data", None)), put2(W1, P(None, "model")), put2(W2, P("model", None)))
y_meg = f_meg(*args_meg)
ref_meg = np.maximum(f64(Xm) @ f64(W1), 0) @ f64(W2)
assert np.allclose(f64(y_meg), ref_meg, atol=1e-5)
print("shard_map version: output", jax.typeof(y_meg), "| communication", collectives(f_meg.lower(*args_meg).compile()))
# The same block written with Explicit jit: the intermediate activation has type [16@data, 32@model], and the second matmul sets its output with out_sharding
@jax.jit
def megatron_explicit(x, w1, w2):
h = jax.nn.relu(x @ w1)
print(" type of intermediate activation h:", jax.typeof(h))
return jnp.matmul(h, w2, out_sharding=NamedSharding(mesh2d, P("data", None)))
y_meg2 = megatron_explicit(*args_meg)
assert np.allclose(f64(y_meg2), ref_meg, atol=1e-5)
assert collectives(megatron_explicit.lower(*args_meg).compile()) == ["all-reduce"]
print("Explicit jit version matches the shard_map version; the forward pass has just one all-reduce ✓")
🔍 Extra: ZeRO-3 / FSDP — reduce-scatter + all-gather¶
⚪ Optional: when the parameters are too big for one device and you need FSDP
FSDP (Fully Sharded Data Parallel, equivalent to ZeRO-3) works like this: parameters, gradients and optimizer state are all sharded across devices, so each device only keeps $1/N$ long-term. One training step goes:
- all-gather before the forward pass: temporarily collect the full parameters $\theta = \mathrm{all\_gather}(\theta^{(i)})$;
- Compute the local gradient $g_i = \nabla L_{B_i}(\theta)$ with the full parameters and the local batch;
- reduce-scatter the gradients: $g^{(i)} = \frac{1}{N}\sum_j g_j^{(i)}$, so each device only gets the averaged gradient for its own shard;
- Update the local parameter shard $\theta^{(i)}$ with $g^{(i)}$.
JAX has a neat property here: the transpose of all_gather is psum_scatter. So if you put "all-gather the parameters" inside the loss function and differentiate with respect to the parameter shards, autodiff generates the reduce-scatter of step 3 on its own; you don't have to write it. Below we implement both a hand-written version and an automatic one, cross-check them against the single-device gradient, and then compare per-device parameter memory with memory_analysis.
Watch out for the pitfall from the "gradients come out N times too large in shard_map" topic in Basics 05: after differentiating the "local mean loss" inside shard_map and then going through
psum_scatter(a sum), you must divide by $N$ again to get the global mean gradient. Both versions below have/ N.
def init_mlp(key, sizes):
ks = jax.random.split(key, len(sizes) - 1)
return [{"w": jax.random.normal(k, (a, b)) / np.sqrt(a), "b": jnp.zeros(b)}
for k, a, b in zip(ks, sizes[:-1], sizes[1:])]
def mlp(p, x):
for layer in p[:-1]:
x = jnp.tanh(x @ layer["w"] + layer["b"])
return x @ p[-1]["w"] + p[-1]["b"]
def mlp_loss(p, x, y):
return jnp.mean((mlp(p, x) - y) ** 2)
# Axis 0 of every parameter is divisible by 8, so it can be split along data
mlp_params = init_mlp(jax.random.key(0), [16, 64, 64, 8])
Xf = jax.random.normal(jax.random.key(1), (256, 16))
Yf = jnp.sin(Xf @ jnp.ones((16, 8)) / 4)
g_single = jax.grad(mlp_loss)(mlp_params, Xf, Yf) # single-device reference gradient
dmesh = jax.make_mesh((N,), ("data",))
pspec = jax.tree.map(lambda a: P("data"), mlp_params)
p_fsdp = jax.device_put(mlp_params, jax.tree.map(lambda s: NamedSharding(dmesh, s), pspec))
Xd = jax.device_put(Xf, NamedSharding(dmesh, P("data")))
Yd = jax.device_put(Yf, NamedSharding(dmesh, P("data")))
print("FSDP parameter sharding types:", jax.tree.map(lambda a: str(jax.typeof(a)), p_fsdp[0]))
def gather_params(p_shard):
return jax.tree.map(lambda a: lax.all_gather(a, "data", tiled=True), p_shard)
def fsdp_grad_manual(p_shard, x, y):
g_full = jax.grad(mlp_loss)(gather_params(p_shard), x, y) # local gradient (full shape)
return jax.tree.map(lambda g: lax.psum_scatter(g, "data", scatter_dimension=0, tiled=True) / N, g_full)
def fsdp_grad_auto(p_shard, x, y):
# Differentiate with respect to the parameter shards: the transpose of all_gather automatically becomes psum_scatter
g = jax.grad(lambda q: mlp_loss(gather_params(q), x, y))(p_shard)
return jax.tree.map(lambda a: a / N, g)
results = {}
for fn in [fsdp_grad_manual, fsdp_grad_auto]:
sm = jax.jit(jax.shard_map(fn, mesh=dmesh, in_specs=(pspec, P("data"), P("data")), out_specs=pspec))
g = sm(p_fsdp, Xd, Yd)
ok = jax.tree.all(jax.tree.map(lambda a, b: bool(np.allclose(np.asarray(a), np.asarray(b), atol=1e-5)), g, g_single))
comp = sm.lower(p_fsdp, Xd, Yd).compile()
results[fn.__name__] = (comp.memory_analysis().argument_size_in_bytes, collectives(comp))
print(f"{fn.__name__:17s} matches single-device gradient: {ok} | communication: {collectives(comp)}")
assert ok and collectives(comp) == ["all-gather", "reduce-scatter"]
# For comparison: plain data parallelism (parameters fully replicated)
p_rep = jax.device_put(mlp_params, NamedSharding(dmesh, P()))
dp_comp = jax.jit(jax.grad(mlp_loss)).lower(p_rep, Xd, Yd).compile()
param_bytes = sum(a.size * a.dtype.itemsize for a in jax.tree.leaves(mlp_params))
data_bytes_local = (Xf.size + Yf.size) * 4 // N
print(f"\nTotal parameters {param_bytes} bytes; local data per device {data_bytes_local} bytes")
print(f"DP input memory per device: {dp_comp.memory_analysis().argument_size_in_bytes:6d} bytes | communication: {collectives(dp_comp)}")
print(f"FSDP input memory per device: {results['fsdp_grad_auto'][0]:6d} bytes | communication: {results['fsdp_grad_auto'][1]}")
assert dp_comp.memory_analysis().argument_size_in_bytes == param_bytes + data_bytes_local
assert results["fsdp_grad_auto"][0] == param_bytes // N + data_bytes_local # parameter memory drops to 1/N
3.8 Overview of multi-host programming¶
pmap is often used across the accelerators of one host, where data parallelism alone gives a clear speedup. But training foundation models with billions or even trillions of parameters needs more compute than one host has. That's where multi-host programming comes in: coordinating several independent machines over a network.
Problems when scaling to multiple nodes¶
- Network communication: inside a host, traffic goes over PCIe / NVLink; between hosts it relies on Ethernet, InfiniBand, or a dedicated TPU interconnect. Latency and bandwidth between hosts are usually orders of magnitude worse than inside a host, so cross-host communication easily becomes the bottleneck, and efficient collective algorithms are needed;
- Synchronization and coordination: each machine runs its own Python process. The processes have to start up, find each other, and synchronize at points such as collectives;
- Infrastructure and job launch: launching the same script on every host, configuring the network and handling failures is usually done with SLURM or cloud vendor tools (such as the tools for managing TPU Pod slices). JAX itself doesn't provide this infrastructure; it only provides the runtime programming primitives.
JAX primitives in a multi-host setting¶
- Global device view: in a correctly configured multi-host setup,
jax.devices()returns the devices on all hosts; - Cross-host collectives:
psum / pmean / all_gatheretc. handle network communication transparently, and on TPU Pods they use the dedicated high-speed inter-chip interconnect; - Process identity:
jax.process_index()is the index of the current process (0 toprocess_count-1), andjax.process_count()is the total number of processes (usually one per host). These are commonly used so each host reads a different data shard from disk.
You can think of multi-host programming as "SPMD, extended": every host runs exactly the same script, data loading is split by process_index, and collectives use axis names to exchange data among the devices of all hosts. For data parallelism: each host loads part of the global batch, computes gradients on its local devices, averages them across all devices globally with pmean, and then runs the optimizer step. Frameworks like Flax provide more convenient APIs on top of these primitives.
⚠️ Note: don't forget
jax.distributed.initialize. A multi-host JAX program must call it before running any JAX computation, so the processes can find each other and exchange topology information. On TPU, SLURM and Open MPI the arguments can be detected automatically; otherwise you must passcoordinator_address(thehost:portof process 0),num_processesandprocess_id(andlocal_device_idsif needed).
⚠️ Note: it's easy to assume pmap works over the global device list, so pmap code written for one host can be used as-is on many hosts, with data split by the total number of global devices. Running it with 2 processes × 4 devices shows otherwise: when each process calls pmap, the input's leading axis length is this process's device count (4), and the output shape is
(4,)too. Only the collectives (psumetc.) span all 8 devices. So data for pmap must be split by the local device count.
🔍 Extra: core multi-host concepts¶
⚪ Optional: when you need to run on several machines
| Concept | Explanation |
|---|---|
jax.devices() vs jax.local_devices() | Global devices vs. the devices this process can access directly; d.process_index says which process a device belongs to |
| Device ids | With multiple processes, ids aren't necessarily contiguous. Running on CPU, process 1's device ids are 2048–2051 |
| Global arrays | A jax.Array can be sharded across processes; when x.is_fully_addressable is False, this process can only access the part in x.addressable_shards |
jax.make_array_from_process_local_data(sharding, local_data) | Each process supplies only its own part of the data, and they are assembled into one global array (the standard way to load data on multiple hosts) |
jax.experimental.multihost_utils.process_allgather | Gathers every process's data into each process's host memory (often used for eval metrics and logging) |
| CPU collectives | Config option jax_cpu_collectives_implementation, one of 'gloo' / 'mpi' / 'megascale'. In 0.12 the default is already 'gloo', so the local demo needs no extra setup |
A few practical rules: all processes must call the same jit functions and collectives in the same order, or you may deadlock; only log and write checkpoint metadata when process_index() == 0; random keys should either be identical across processes (e.g. parameter initialization) or differ by process_index (e.g. data augmentation). See Advanced 10 §5.6 for details.
First, the single-process case:
print("process_index:", jax.process_index(), "| process_count:", jax.process_count())
assert jax.process_index() == 0 and jax.process_count() == 1
assert jax.local_devices() == jax.devices()
# With a single process, the "local data" for make_array_from_process_local_data is all of the data
mesh_mh = jax.make_mesh((N,), ("data",))
local_np = np.arange(16.).reshape(8, 2)
garr = jax.make_array_from_process_local_data(NamedSharding(mesh_mh, P("data")), local_np)
print("Global array type:", jax.typeof(garr), "| fully addressable:", garr.is_fully_addressable)
assert garr.is_fully_addressable and np.array_equal(np.asarray(garr), local_np)
assert jax.config.jax_cpu_collectives_implementation == "gloo" # default in 0.12
🔍 Extra: actually running a 2-process "multi-host" demo on this machine¶
⚪ Optional: if you want to verify multi-process behavior yourself (reading the output is fine too)
Below we use subprocess to launch 2 processes with the coordinator at localhost. Each process simulates 4 CPU devices, for 8 global devices in total. Each process runs the same worker script (SPMD) and does three things in order:
- Call
jax.distributed.initialize(...), then look atprocess_index / process_count / devices / local_devices; - Each process supplies only its own 4 numbers (process 0 has 1–4, process 1 has 5–8). They are assembled into a global array with
make_array_from_process_local_data, and then we do a cross-process sum withshard_map + psum,jit + jnp.sum, and the legacypmap + psum. All should give $1+\dots+8=36$; - Cross-process data-parallel training: each process loads only its own half of the data (sliced by
process_index) and trains linear regression for 30 steps. Finally, the notebook cross-checks the result against single-process gradient descent in NumPy float64.
WORKER = """
import os, sys, json
pid, nproc, port = int(sys.argv[1]), int(sys.argv[2]), sys.argv[3]
os.environ["XLA_FLAGS"] = "--xla_force_host_platform_device_count=4" # each process simulates 4 CPU devices
import jax, jax.numpy as jnp, numpy as np
from jax import lax
from jax.sharding import NamedSharding, PartitionSpec as P
# Must be called before any JAX computation
jax.distributed.initialize(coordinator_address=f"localhost:{port}", num_processes=nproc, process_id=pid)
out = {"process_index": jax.process_index(), "process_count": jax.process_count(),
"device_count": jax.device_count(), "local_device_count": jax.local_device_count(),
"global_ids": [d.id for d in jax.devices()], "local_ids": [d.id for d in jax.local_devices()],
"cpu_collectives": jax.config.jax_cpu_collectives_implementation}
mesh = jax.make_mesh((jax.device_count(),), ("data",))
# 1) Cross-process sum: each process supplies only its own 4 numbers
local = np.arange(4, dtype=np.float32) + 4 * pid + 1 # process 0: 1..4, process 1: 5..8
g = jax.make_array_from_process_local_data(NamedSharding(mesh, P("data")), local)
out["global_shape"] = list(g.shape)
out["fully_addressable"] = g.is_fully_addressable
out["addressable_ids"] = [s.device.id for s in g.addressable_shards]
psum_fn = jax.jit(jax.shard_map(lambda x: lax.psum(x, "data"), mesh=mesh, in_specs=P("data"), out_specs=P()))
out["shard_map_psum"] = float(np.asarray(psum_fn(g).addressable_shards[0].data)[0])
out["jit_sum"] = float(jax.jit(jnp.sum)(g))
pm = jax.pmap(lambda x: lax.psum(x, "i"), axis_name="i")(jnp.asarray(local)) # legacy API: leading axis = local device count
out["pmap_out_shape"] = list(pm.shape)
out["pmap_psum"] = np.asarray(pm).tolist()
# 2) Cross-process data-parallel training: each process loads only its own half of the data
rng = np.random.default_rng(0)
X_all = rng.normal(size=(64, 5)).astype(np.float32)
y_all = (X_all @ np.arange(1, 6, dtype=np.float32) + 0.5).astype(np.float32)
per = len(X_all) // jax.process_count()
sl = slice(jax.process_index() * per, (jax.process_index() + 1) * per)
dsh = NamedSharding(mesh, P("data"))
Xg = jax.make_array_from_process_local_data(dsh, X_all[sl])
yg = jax.make_array_from_process_local_data(dsh, y_all[sl])
params = jax.device_put((jnp.zeros(5), jnp.zeros(())), NamedSharding(mesh, P()))
@jax.jit
def step(params, X, y):
def loss(p):
w, b = p
return jnp.mean((X @ w + b - y) ** 2)
l, grads = jax.value_and_grad(loss)(params)
return jax.tree.map(lambda p, g: p - 0.1 * g, params, grads), l
for _ in range(30):
params, l = step(params, Xg, yg)
out["final_w"] = np.asarray(params[0]).tolist()
out["final_b"] = float(params[1])
out["final_loss"] = float(l)
print("RESULT " + json.dumps(out), flush=True)
jax.distributed.shutdown()
"""
workdir = tempfile.mkdtemp(prefix="jax_multihost_")
worker_path = os.path.join(workdir, "worker.py")
with open(worker_path, "w") as fh:
fh.write(WORKER)
with socket.socket() as s_: # find a free port for the coordinator
s_.bind(("localhost", 0))
port = s_.getsockname()[1]
procs = [subprocess.Popen([sys.executable, worker_path, str(i), "2", str(port)],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=workdir)
for i in range(2)]
mh = {}
for i, p_ in enumerate(procs):
stdout, stderr = p_.communicate(timeout=300)
assert p_.returncode == 0, stderr[-2000:]
line = [l for l in stdout.splitlines() if l.startswith("RESULT ")][0]
mh[i] = json.loads(line[len("RESULT "):])
for i, r_ in mh.items():
print(f"Process {i}: process_index={r_['process_index']}/{r_['process_count']}, {r_['device_count']} global devices, "
f"local device ids {r_['local_ids']}, CPU collectives = {r_['cpu_collectives']}")
print(f" global array shape {r_['global_shape']}, fully_addressable={r_['fully_addressable']}, "
f"shards this process can access are on devices {r_['addressable_ids']}")
print(f" shard_map psum = {r_['shard_map_psum']}, jit sum = {r_['jit_sum']}, "
f"pmap output shape {r_['pmap_out_shape']} value {r_['pmap_psum']}")
# Cross-check: cross-process sum == 36; pmap leading axis == local device count; both processes train the same parameters, equal to single-process NumPy float64 gradient descent
for i, r_ in mh.items():
assert r_["process_index"] == i and r_["process_count"] == 2
assert r_["device_count"] == 8 and r_["local_device_count"] == 4
assert len(r_["global_ids"]) == 8 and set(r_["local_ids"]) <= set(r_["global_ids"])
assert r_["global_shape"] == [8] and r_["fully_addressable"] is False
assert r_["shard_map_psum"] == 36.0 and r_["jit_sum"] == 36.0
assert r_["pmap_out_shape"] == [4] and r_["pmap_psum"] == [36.0] * 4 # each process feeds only 4 values, but psum spans 8 devices
assert mh[0]["local_ids"] != mh[1]["local_ids"]
assert mh[0]["final_w"] == mh[1]["final_w"] and mh[0]["final_b"] == mh[1]["final_b"]
rng = np.random.default_rng(0)
X_all = rng.normal(size=(64, 5)).astype(np.float32).astype(np.float64)
y_all = X_all @ np.arange(1, 6) + 0.5
w_np, b_np = np.zeros(5), 0.0
for _ in range(30):
err_ = X_all @ w_np + b_np - y_all
w_np, b_np = w_np - 0.1 * 2 * X_all.T @ err_ / 64, b_np - 0.1 * 2 * err_.mean()
print("w from 2-process training:", np.round(mh[0]["final_w"], 4))
print("w from single-process NumPy:", np.round(w_np, 4))
assert np.allclose(mh[0]["final_w"], w_np, atol=1e-4) and abs(mh[0]["final_b"] - b_np) < 1e-4
print("✓ 2-process multi-host demo: cross-process psum and data-parallel training both match the single-process reference")
3.9 Practice: distributed data-parallel training¶
This section builds a complete data-parallel training loop with pmap: each device handles part of the data, computes gradients locally, averages the gradients, and updates the parameters in sync. The model is linear regression $\hat y = Xw + b$, and the loss is mean squared error
$$ L(w, b) = \frac{1}{|B|}\sum_{(x, y)\in B}\big(x^\top w + b - y\big)^2 . $$
Setup¶
Older material sometimes uses jax.config.update("jax_cpu_device_count", 4) to simulate multiple devices, and puts it after a call to jax.local_device_count(). That has two problems; see the note below.
⚠️ Note: there is no
jax_cpu_device_countconfig option in 0.12; it raisesAttributeError: Unrecognized config option. The right way is to setXLA_FLAGS=--xla_force_host_platform_device_count=Nbefore importing jax (the first cell of this notebook does that), or to calljax.config.update('jax_num_cpu_devices', N)afterimport jaxbut before any computation (demoed in Section 3.2). Both must happen before the backend is initialized, so putting it afterlocal_device_count()has no effect anyway.
# ❌ Old style (outdated/wrong): jax_cpu_device_count is not a valid config option
try:
jax.config.update("jax_cpu_device_count", 4)
raised = False
except AttributeError as e:
raised = True
print("Raised:", type(e).__name__, "-", e)
assert raised
# ✅ Recommended: the first cell of this notebook already simulates 8 devices with XLA_FLAGS; jax_num_cpu_devices works too (must be set before backend initialization)
num_devices = jax.local_device_count()
print("Number of available devices:", num_devices)
assert num_devices == 8
Model, loss and data¶
Data: 5 features and 100 * num_devices samples (so it divides evenly by the device count). The true parameters are random, labels get 0.1× Gaussian noise added, and then everything is reshaped to (num devices, samples per device, num features).
def linear_model(params, x):
w, b = params
return jnp.dot(x, w) + b
def mean_squared_error(params, x_batched, y_batched):
predictions = linear_model(params, x_batched)
return jnp.mean((predictions - y_batched) ** 2)
feature_dim = 5
num_samples = 100 * num_devices
key = jax.random.key(0)
key, w_key, b_key, x_key, noise_key = jax.random.split(key, 5)
true_w = jax.random.normal(w_key, (feature_dim,))
true_b = jax.random.normal(b_key, ())
X = jax.random.normal(x_key, (num_samples, feature_dim))
noise = jax.random.normal(noise_key, (num_samples,)) * 0.1
y = jnp.dot(X, true_w) + true_b + noise
batch_size_per_device = num_samples // num_devices
sharded_X = X.reshape((num_devices, batch_size_per_device, feature_dim))
sharded_y = y.reshape((num_devices, batch_size_per_device))
print("Total samples:", num_samples, "| Samples per device:", batch_size_per_device)
print("Shape of sharded X:", sharded_X.shape, "| Shape of sharded y:", sharded_y.shape)
The distributed training step¶
The training step has four parts: (1) local value_and_grad; (2) average the gradients with pmean; (3) plain gradient descent with the averaged gradients; (4) average the loss with pmean so it's easy to print. pmap is called with in_axes=(0, 0, 0, None), and static_broadcasted_argnums=(3,) makes the learning rate a static argument.
⚠️ Note:
pmaphas nojitargument; passingjit=TrueraisesTypeError. pmap already compiles the function, so there's no need, and no way, to "turn on jit" again.
⚠️ Note:
in_axes=(0, 0, 0, None)means the parameters are mapped along axis 0, not replicated (None). That's correct here: thereplicated_paramswe pass in already carry a device axis, with shape(num_devices, ...), and each device takes its own copy.
⚠️ Note: another way to replicate parameters,
pmap(broadcast, in_axes=None, out_axes=0)(params), doesn't work. When every input isin_axes=None, pmap can't tell how long the mapped axis is and raisesValueError: pmap requires at least one argument with a mapped axis(addingaxis_sizedoesn't help). Writing it withjax.tree_mapdoesn't work either, because that has been removed. The recommended form isjax.tree.map(lambda x: jnp.broadcast_to(x, (n,) + x.shape), params).
compute_loss_and_grads = jax.value_and_grad(mean_squared_error)
def distributed_train_step(params, x_shard, y_shard, learning_rate):
loss, grads = compute_loss_and_grads(params, x_shard, y_shard) # 1. local gradients
avg_grads = lax.pmean(grads, axis_name="batch") # 2. average across devices
new_params = jax.tree.map(lambda p, g: p - learning_rate * g, params, avg_grads) # 3. gradient descent
avg_loss = lax.pmean(loss, axis_name="batch") # 4. average loss
return new_params, avg_loss
key, w_init_key, b_init_key = jax.random.split(key, 3)
initial_w = jax.random.normal(w_init_key, (feature_dim,))
initial_b = jax.random.normal(b_init_key, ())
params = (initial_w, initial_b)
# ❌ Old style (wrong): pmap has no jit argument; a "broadcast" pmap with all in_axes=None can't determine the device count
errs = {}
try:
jax.pmap(distributed_train_step, axis_name="batch", jit=True)
except TypeError as e:
errs["pmap(jit=True)"] = f"{type(e).__name__}: {e}"
def broadcast(x):
return x
try:
jax.pmap(broadcast, in_axes=None, out_axes=0)(params)
except ValueError as e:
errs["pmap(in_axes=None)"] = f"{type(e).__name__}: {e}"
try:
jax.pmap(broadcast, in_axes=None, out_axes=0, axis_size=num_devices)(params)
except ValueError as e:
errs["pmap(in_axes=None, axis_size=8)"] = f"{type(e).__name__}: {e}"
for k, v in errs.items():
print(f"{k:32s} → {v[:100]}")
assert len(errs) == 3
# ✅ Recommended (pmap version): add a device axis to the parameters with broadcast_to, in_axes=(0, 0, 0, None)
p_train_step = jax.pmap(distributed_train_step, axis_name="batch",
in_axes=(0, 0, 0, None), out_axes=0, static_broadcasted_argnums=(3,))
replicated_params = jax.tree.map(lambda x: jnp.broadcast_to(x, (num_devices,) + x.shape), params)
print("Shape of initial w:", initial_w.shape, "→ replicated:", replicated_params[0].shape)
print("Shape of initial b:", initial_b.shape, "→ replicated:", replicated_params[1].shape)
assert replicated_params[0].shape == (num_devices, feature_dim) and replicated_params[1].shape == (num_devices,)
learning_rate = 0.05
num_epochs = 50 # each epoch uses all the data once, i.e. full-batch gradient descent, one update per epoch
print("\nStarting distributed training...")
current_params = replicated_params
for epoch in range(num_epochs):
current_params, loss = p_train_step(current_params, sharded_X, sharded_y, learning_rate)
if (epoch + 1) % 10 == 0:
print(f"Epoch {epoch + 1}/{num_epochs}, Loss: {float(loss[0]):.4f}")
final_params_pmap = jax.tree.map(lambda x: x[0], current_params)
print("\nTraining finished.")
print("Learned w:", final_params_pmap[0], "\nTrue w: ", true_w)
print("Learned b:", final_params_pmap[1], "| True b:", true_b)
# Sanity check: w is the same on device 0 and device 1
assert jnp.allclose(current_params[0][0], current_params[0][1])
# Cross-check against single-device training and against NumPy float64 gradient descent
@jax.jit
def single_step(params, X, y, lr):
loss, grads = compute_loss_and_grads(params, X, y)
return jax.tree.map(lambda p, g: p - lr * g, params, grads), loss
p_single = params
for _ in range(num_epochs):
p_single, _ = single_step(p_single, X, y, learning_rate)
Xn, yn = f64(X), f64(y)
w_np, b_np = f64(initial_w), float(initial_b)
for _ in range(num_epochs):
e_ = Xn @ w_np + b_np - yn
w_np, b_np = w_np - learning_rate * 2 * Xn.T @ e_ / len(Xn), b_np - learning_rate * 2 * e_.mean()
assert np.allclose(final_params_pmap[0], p_single[0], atol=1e-5) and np.allclose(final_params_pmap[1], p_single[1], atol=1e-5)
assert np.allclose(f64(final_params_pmap[0]), w_np, atol=1e-4) and abs(float(final_params_pmap[1]) - b_np) < 1e-4
assert np.allclose(final_params_pmap[0], true_w, atol=0.05) # close to the true parameters after 50 steps
print("pmap version == single-device version == NumPy float64 ✓")
# ✅ Modern style: jit + NamedSharding. device_put the parameters replicated with P(), shard the data along data; the code is identical to the single-device version
mesh_dp = jax.make_mesh((num_devices,), ("data",))
params_m = jax.device_put(params, NamedSharding(mesh_dp, P()))
X_m = jax.device_put(X, NamedSharding(mesh_dp, P("data")))
y_m = jax.device_put(y, NamedSharding(mesh_dp, P("data")))
for epoch in range(num_epochs):
params_m, loss_m = single_step(params_m, X_m, y_m, learning_rate) # reuse the single-device single_step
print("Parameter type:", jax.typeof(params_m[0]), "| final loss:", float(loss_m))
print("Communication:", collectives(single_step.lower(params_m, X_m, y_m, learning_rate).compile()))
assert np.allclose(np.asarray(params_m[0]), final_params_pmap[0], atol=1e-5)
assert np.allclose(float(params_m[1]), float(final_params_pmap[1]), atol=1e-5)
print("Modern style == pmap version ✓")
The course wraps up with six points: data sharding, SPMD functions, collectives with pmean, synchronous updates, setting up pmap and in_axes, and replicating parameters explicitly. It also suggests swapping the model for a neural network and adding an optimizer such as Adam. Below we do that extension, and along the way read the gradient-sync communication volume from the compiled output.
🔍 Extra: data-parallel MLP + Adam, and the gradient-sync communication volume¶
⚪ Optional: if you want to estimate communication volume from the compiled output
With optax, the sharding of the optimizer state (Adam's first and second moments) follows the parameters: replicated parameters mean replicated state. The training-step code is exactly the same as on a single device; only the inputs carry a sharding.
How much is communicated? In this example's compiled HLO (CPU backend), the compiler merges all gradients and the loss into a single all-reduce. This is XLA's all-reduce combining optimization; on another backend or with a bigger model, it may be split into several by a size threshold. Its byte count is the total parameter bytes plus one scalar loss. For a ring all-reduce, each device sends/receives $2\frac{N-1}{N}S$ per step.
opt = optax.adam(1e-2)
mlp_p0 = init_mlp(jax.random.key(7), [16, 64, 64, 8])
Xt = jax.random.normal(jax.random.key(8), (512, 16))
Yt = jnp.sin(Xt @ jnp.ones((16, 8)) / 4)
@jax.jit
def adam_step(params, opt_state, x, y):
loss, grads = jax.value_and_grad(mlp_loss)(params, x, y)
updates, opt_state = opt.update(grads, opt_state, params)
return optax.apply_updates(params, updates), opt_state, loss
def train(params, x, y, steps=100):
state = opt.init(params)
for _ in range(steps):
params, state, loss = adam_step(params, state, x, y)
return params, state, loss
# Single-device reference
p_ref, _, loss_ref = train(mlp_p0, Xt, Yt)
# Data parallelism: parameters replicated, data sharded
p_dp, s_dp, loss_dp = train(jax.device_put(mlp_p0, NamedSharding(dmesh, P())),
jax.device_put(Xt, NamedSharding(dmesh, P("data"))),
jax.device_put(Yt, NamedSharding(dmesh, P("data"))))
print(f"Loss after 100 steps: single device {float(loss_ref):.6f} | data parallel {float(loss_dp):.6f}")
print("Type of Adam's first moment:", jax.typeof(s_dp[0].mu[0]["w"]))
max_diff = max(float(jnp.max(jnp.abs(np.asarray(a) - np.asarray(b)))) for a, b in zip(jax.tree.leaves(p_dp), jax.tree.leaves(p_ref)))
print("Max parameter difference:", max_diff)
assert max_diff < 1e-4 and abs(float(loss_dp) - float(loss_ref)) < 1e-5
# Read the all-reduce byte count from the compiled HLO
comp = adam_step.lower(jax.device_put(mlp_p0, NamedSharding(dmesh, P())),
s_dp, jax.device_put(Xt, NamedSharding(dmesh, P("data"))),
jax.device_put(Yt, NamedSharding(dmesh, P("data")))).compile()
DTYPE_BYTES = {"f32": 4, "bf16": 2, "f16": 2, "s32": 4, "f64": 8}
ar_bytes, n_ar = 0, 0
for line in comp.as_text().splitlines():
m_ = re.search(r"=\s*(\(.*\)|\S+)\s+all-reduce\(", line)
if m_:
n_ar += 1
for dt, dims in re.findall(r"(f32|bf16|f16|s32|f64)\[([\d,]*)\]", m_.group(1)):
ar_bytes += DTYPE_BYTES[dt] * int(np.prod([int(d) for d in dims.split(",") if d] or [1]))
param_bytes = sum(a.size * a.dtype.itemsize for a in jax.tree.leaves(mlp_p0))
ring = 2 * (N - 1) / N * ar_bytes
ma_ = comp.memory_analysis()
print(f"Total parameter bytes : {param_bytes}")
print(f"Number of all-reduces / total bytes: {n_ar} / {ar_bytes} (= all gradients + 1 f32 loss)")
print(f"Ring all-reduce traffic per device : 2·(N-1)/N·S = {ring:.0f} bytes/step")
print(f"memory_analysis (per device) : inputs {ma_.argument_size_in_bytes} B, outputs {ma_.output_size_in_bytes} B, temp {ma_.temp_size_in_bytes} B")
assert n_ar >= 1 and ar_bytes == param_bytes + 4
Exercises¶
The 3 exercises below cover the three core topics of this chapter: rearranging collectives, computing global quantities under FSDP, and training with 2D partitioning. Each one has a reference solution and asserts.
✍️ Exercise 1: distributed matrix transpose with all_to_all¶
X is a 16×16 matrix split by rows over 8 devices (P('i', None), each device holds 2 rows). Implement the transpose inside shard_map, with the output $X^\top$ also split by rows. At no point may you all-gather the full matrix.
Hint: device i needs columns 2i:2i+2 of the original matrix.
Self-check interface: define f_t(x): the input is a 16×16 matrix split over mesh8 with P('i', None); return its transpose, also split by rows (it may be jit-compiled).
# ✍️ Your answer: Exercise 1
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 1
try:
f_t
except NameError as e:
print(f"⏳ Exercise 1: not answered yet: {e}")
else:
_A = np.random.default_rng(1).normal(size=(16, 16)).astype(np.float32)
_A_s = jax.device_put(jnp.asarray(_A), NamedSharding(mesh8, P("i", None)))
_AT = f_t(_A_s)
assert np.array_equal(np.asarray(_AT), _A.T), "The result is not the transpose"
assert _AT.sharding.is_equivalent_to(NamedSharding(mesh8, P("i", None)), 2), f"The output should be split by rows; actual sharding is {_AT.sharding}"
_coll = collectives(jax.jit(f_t).lower(_A_s).compile())
assert "all-gather" not in _coll and "all-reduce" not in _coll, f"Should not gather the full matrix; collectives actually used: {_coll}"
print("✅ Exercise 1: self-check passed")
✍️ Exercise 2: global gradient-norm clipping under FSDP¶
Under FSDP, each device holds only one shard of the gradient. The global gradient norm $\|g\| = \sqrt{\sum_\ell \|g_\ell\|^2}$ needs all shards to take part. Implement clip_by_global_norm inside shard_map: the input is the sharded gradient from the FSDP part of Section 3.7 (sharded with pspec); return the clipped sharded gradient and the global norm, and cross-check against optax.clip_by_global_norm on the full gradient.
Common mistakes: computing the norm of only the local shard, or clipping each shard separately. Both are wrong.
Self-check interface: define f_clip(g, max_norm): g is a gradient with the same structure as mlp_params, split over dmesh with pspec, and max_norm is a scalar; return (clipped sharded gradient, global norm), where the clipped gradient is still sharded with pspec.
# ✍️ Your answer: Exercise 2
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 2
try:
f_clip
except NameError as e:
print(f"⏳ Exercise 2: not answered yet: {e}")
else:
_rng = np.random.default_rng(3)
_g = jax.tree.map(lambda a: _rng.normal(size=a.shape).astype(np.float32), mlp_params) # a fresh gradient
_g_s = jax.device_put(_g, jax.tree.map(lambda s: NamedSharding(dmesh, s), pspec))
_norm = float(np.sqrt(sum(np.sum(np.asarray(a, np.float64) ** 2) for a in jax.tree.leaves(_g))))
for _ratio in [0.3, 2.0]: # once with clipping, once without
_mx = _ratio * _norm
_gc, _n = f_clip(_g_s, jnp.float32(_mx))
assert abs(float(_n) - _norm) < 1e-4 * _norm, f"The global norm should be {_norm:.5f}, got {float(_n):.5f}: did you only use the local shard?"
_scale = min(1.0, _mx / _norm)
for _a, _b in zip(jax.tree.leaves(_gc), jax.tree.leaves(_g)):
assert np.allclose(np.asarray(_a), _b * _scale, atol=1e-5), f"Wrong clipping result for max_norm = {_ratio}× the norm"
assert _a.sharding.is_equivalent_to(NamedSharding(dmesh, P("data")), _a.ndim), f"The clipped gradient should stay sharded; actual {_a.sharding}"
print("✅ Exercise 2: self-check passed")
✍️ Exercise 3: one training step with data × model 2D parallelism¶
Using a (data=4, model=2) Auto mesh, split W1 of the Megatron MLP from Section 3.7 by columns (P(None, 'model')) and W2 by rows (P('model', None)), and split the data by rows along 'data'. Write one SGD step (learning rate 0.1, MSE loss, target Y = sin(X)) such that:
- The result matches one step of single-device training exactly (
atol=1e-5); - The updated parameters keep their original sharding. Parameter sharding must not "drift" during training, or the next step will need extra communication. In this small example the Auto-mode compiler keeps the parameter sharding even without
out_shardings, but that isn't guaranteed and can change as soon as the model gets more complex, so pin it explicitly.
Self-check interface: define step_2d(p, x, y): p = {"w1": (8, 32) with P(None, 'model'), "w2": (32, 8) with P('model', None)}, and x, y have shape (16, 8) and are split over mesh2d_auto with P('data'); return the parameter dict after one SGD step (learning rate 0.1, MSE loss).
# ✍️ Your answer: Exercise 3
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 3
try:
step_2d
except NameError as e:
print(f"⏳ Exercise 3: not answered yet: {e}")
else:
_rng = np.random.default_rng(11)
_w1 = (_rng.normal(size=(8, 32)) / np.sqrt(8)).astype(np.float32)
_w2 = (_rng.normal(size=(32, 8)) / np.sqrt(32)).astype(np.float32)
_x = _rng.normal(size=(16, 8)).astype(np.float32); _y = np.sin(_x)
_put = lambda a, spec: jax.device_put(jnp.asarray(a), NamedSharding(mesh2d_auto, spec))
_specs = {"w1": P(None, "model"), "w2": P("model", None)}
_new = step_2d({"w1": _put(_w1, _specs["w1"]), "w2": _put(_w2, _specs["w2"])},
_put(_x, P("data")), _put(_y, P("data")))
# One SGD step derived by hand in float64, as the reference
_X, _W1, _W2, _Y = (np.asarray(a, np.float64) for a in (_x, _w1, _w2, _y))
_pre = _X @ _W1; _h = np.maximum(_pre, 0); _dout = 2 * (_h @ _W2 - _Y) / _Y.size
_ref = {"w2": _W2 - 0.1 * (_h.T @ _dout), "w1": _W1 - 0.1 * (_X.T @ ((_dout @ _W2.T) * (_pre > 0)))}
for _k in ["w1", "w2"]:
assert np.allclose(np.asarray(_new[_k]), _ref[_k], atol=1e-5), f"{_k}: the updated value doesn't match the hand-derived SGD step"
assert _new[_k].sharding.is_equivalent_to(NamedSharding(mesh2d_auto, _specs[_k]), 2), (
f"{_k}: the sharding drifted, expected {_specs[_k]}, got {_new[_k].sharding.spec}")
print("✅ Exercise 3: self-check passed")
Quiz¶
The answers to all 8 questions below have been verified by running code in this notebook.
1. In jax.pmap(f)(params, data), params is {'w': (10, 5), 'b': (5,)}, data is (8, 32, 10), and there is no in_axes. What happens?
Answer and explanation
An error. in_axes defaults to 0, so the parameters are also mapped along axis 0: axis 0 of b (size 5) is taken as the device count, which doesn't match the data's 8. Parameters are only broadcast if you write in_axes=(None, 0) explicitly. The claim that "arguments without a mapped leading axis are broadcast automatically" is wrong (Section 3.4).
2. How do lax.pmean(x, 'i') and lax.psum(x, 'i') / N differ once compiled?
Answer and explanation
They don't. The jaxpr of pmean is just psum plus one div, and both compile to a single all-reduce. pmean is recommended only because it reads better, not because "the backend can implement it more efficiently" (Section 3.5).
3. On a 2×2 mesh, device $(i, j)$ holds $x_{ij}=2i+j$ and $p_{ij}=0.5$. What is psum(psum(x*p, 'model') * 2, 'data')?
Answer and explanation
6 everywhere. The inner sum along 'model' gives $2(0+0.5)=1$ and $2(1+1.5)=5$; the outer sum along 'data' only involves the 2 devices in the same column, giving $1+5=6$. If you got 12, you summed the already-replicated inner value along 'model' again, double counting it. 0.12's vma check rejects that outright (Section 3.7).
4. On a default (Explicit) mesh, x has type float32[8@data,4@model] and w is float32[4@model,6]. What happens if you write x @ w directly? How do you fix it?
Answer and explanation
It raises ShardingTypeError. The contracting dimension is sharded along model on both sides, so the output could be all-reduced to replicated or reduce-scattered to sharded: it's ambiguous. The fix is jnp.matmul(x, w, out_sharding=NamedSharding(mesh, P('data', None))), or switching to an Auto mesh and letting the compiler decide (Section 3.7).
5. Inside jit on an Explicit mesh, what happens if you call with_sharding_constraint(y, P(None,'model')) on a value sharded as P('data','model')?
Answer and explanation
It raises AssertionError. When all axes are Explicit, with_sharding_constraint is only an assertion and doesn't change the sharding. To change the sharding, use jax.sharding.reshard. Only on an Auto mesh is it a hint to the compiler (Section 3.7).
6. Compared with plain data parallelism, how do FSDP's per-device parameter memory and per-step communication change? How do you get the gradient reduce-scatter "for free" in JAX?
Answer and explanation
Parameter (and gradient and optimizer-state) memory drops to $1/N$. In this chapter's simplified implementation, communication goes from one all-reduce ($2\frac{N-1}{N}S$) to one all-gather plus one reduce-scatter ($\frac{N-1}{N}S$ each), so the total is unchanged; real FSDP all-gathers again in the backward pass to avoid holding the full parameters between forward and backward, so communication is about 1.5× that of DP (extra in Section 3.1). As for the reduce-scatter: all_gather the parameter shards inside the loss function, then differentiate with respect to the shards. Since the transpose of all_gather is psum_scatter, autodiff generates the reduce-scatter itself. Remember to divide the result by $N$ (Section 3.7; memory_analysis result: 23072 + 3072 → 5956 bytes).
7. 2 processes, 4 devices each. In process 1, what are len(jax.devices()) and len(jax.local_devices())? If you call jax.pmap(lambda x: lax.psum(x, 'i'), axis_name='i'), how long should the input's leading axis be, and how many devices does psum span?
Answer and explanation
8 and 4. The pmap input's leading axis has length equal to the local device count, 4, and the output shape is (4,) too, but psum spans all 8 devices (the run gives 36 = 1+…+8). Also, you must call jax.distributed.initialize first (Section 3.8).
8. lax.ppermute(x, 'i', perm=[(0, 1), (1, 2)]) runs on 4 devices. What do device 0 and device 3 get?
Answer and explanation
Both get all zeros, because no device sends them anything. perm doesn't have to be a full permutation, and the "receive nothing, get 0" property is exactly what you use to implement zero boundaries in stencil computations (the halo exchange in Section 3.5).
# Verify quiz question 8
mesh_q = jax.make_mesh((4,), ("i",), devices=jax.devices()[:4])
q8 = jax.jit(jax.shard_map(lambda x: lax.ppermute(x, "i", [(0, 1), (1, 2)]), mesh=mesh_q,
in_specs=P("i"), out_specs=P("i")))(jax.device_put(jnp.array([10., 20., 30., 40.]), NamedSharding(mesh_q, P("i"))))
print(q8)
assert np.array_equal(np.asarray(q8), [0., 10., 20., 0.])
Version notes¶
Below is a summary of the patterns and conclusions in this chapter that need attention under JAX 0.12. Each one is verified with an assert at the corresponding place in the text.
| No. | Section | Key point | Where |
|---|---|---|---|
| 1 | 3.2 | device is an attribute, so write x.device (x.device() raises); there are also x.devices() and x.sharding | 3.2 Default device placement |
| 2 | 3.2 | The rule for cross-device operations is deterministic: uncommitted arrays follow committed ones; two committed inputs on different devices raise ValueError | 3.2 extra |
| 3 | 3.4 | pmap's in_axes defaults to 0; arguments without a mapped leading axis are not broadcast automatically, you must write None explicitly | 3.4 |
| 4 | 3.4 | Use jax.random.key instead of jax.random.PRNGKey, split first, and use each key only once | 3.4 |
| 5 | 3.5 | DeviceArray has been removed; all arrays are jax.Array and print as Array([...]) | 3.5 |
| 6 | 3.5 | pmean is just psum + div; it compiles to the same communication as psum followed by dividing by N, and is not more efficient | 3.5 |
| 7 | 3.5 | Extra on the semantics and communication volume of four rearranging collectives such as psum_scatter and all_to_all, and the identity AR = RS + AG | 3.5 extra |
| 8 | 3.6 | axis_name is a required argument of psum; leaving it out raises TypeError, there is no implicit inference | 3.6 |
| 9 | 3.6 | With scalar parameters, pmap without in_axes raises IndexError; write in_axes=(None, 0) | 3.6 |
| 10 | 3.6 / 3.9 | jax.tree_map has been removed (AttributeError); use jax.tree.map | 3.6, 3.9 |
| 11 | 3.7 | The jax.experimental.maps module has been removed; use jax.make_mesh / jax.sharding.Mesh | 3.7 |
| 12 | 3.7 | Shapes must be integers: jnp.ones(16.) raises; write jnp.ones(16) | 3.7 |
| 13 | 3.7 | x.devices is a method and can't be used as a device mesh; arange(num_devices).reshape(2,2) only works with exactly 4 devices; local_devices()[:1]*4 repeats the same device 4 times and can't be used for parallelism. Build a mesh with jax.make_mesh((2,2), ..., devices=...) | 3.7 |
| 14 | 3.7 | Nested pmap raises ValueError in 0.12; for 2D parallelism use a 2D mesh + shard_map | 3.7 |
| 15 | 3.7 | In the simple nested-reduction example, output_simple[0] is [6., 6.], not 12; 12 comes from summing an already-replicated value again | 3.7 |
| 16 | 3.7 | jax.jit(device=...) and jax.experimental.shard_map are both deprecated (DeprecationWarning); device_put the inputs instead and use jax.shard_map | 3.7 |
| 17 | 3.7 | Extras on combining PartitionSpecs, AxisType, with_sharding_constraint / reshard, the Megatron MLP, and FSDP | 3.7 extra |
| 18 | 3.8 | A multi-host program must call jax.distributed.initialize before any computation; the arguments are explained, and a 2-process demo runs on this machine | 3.8 |
| 19 | 3.8 | With multiple hosts, each process's pmap input leading axis length = local device count (4); only collectives span the global devices | 3.8 |
| 20 | 3.9 | The jax_cpu_device_count config option doesn't exist; simulate multiple devices with XLA_FLAGS or jax_num_cpu_devices, both set before backend initialization | 3.9 |
| 21 | 3.9 | pmap has no jit argument (passing it raises TypeError); it compiles on its own | 3.9 |
| 22 | 3.9 | in_axes=(0,0,0,None) in the training step means the parameters carry a device axis and are mapped along axis 0, not replicated | 3.9 |
| 23 | 3.9 | Replicating parameters with pmap(broadcast, in_axes=None, out_axes=0)(params) raises ValueError (adding axis_size doesn't help); add a device axis with jnp.broadcast_to, or in the modern style use device_put(..., P()) | 3.9 |
| 24 | 3.9 | Training results are cross-checked against a single device and NumPy float64; extras on the modern style, MLP+Adam, and all-reduce byte analysis | 3.9 |
| 25 | 3.2 | jit computation follows the input data: wherever the input lives, the computation and result live there too; device_put affects more than the initial placement | 3.2 Explicit placement with jax.device_put |
| 26 | 3.5 | A collective works as long as an enclosing transformation binds its axis name: pmap, shard_map, and vmap with axis_name all work | 3.5, 3.6 extra |
Chapter summary¶
- Parallelism strategies: data parallelism replicates the model, shards the data, and averages gradients with
pmean; model/tensor parallelism shards the weights; pipeline parallelism splits the layers and uses micro-batches to shrink the bubble $\frac{p-1}{m+p-1}$. ZeRO/FSDP replace the all-reduce with "reduce-scatter + all-gather", cutting long-lived parameters/gradients/optimizer state to $1/N$. ZeRO-1/2 keep the same communication volume; ZeRO-3/FSDP communicate about 1.5× as much as DP if the backward pass all-gathers the parameters again. - Devices and data:
x.deviceis an attribute; committed vs. uncommitted decides where computation runs; settings that simulate multiple devices must be applied before the backend is initialized. - pmap is a legacy API:
in_axesdefaults to 0, so parameters needNone; a single pmap can only express 1D parallelism, and nested pmap raises in 0.12; with multiple hosts, pmap's leading axis is the local device count. In the modern style, preferjit + NamedSharding, and usejax.shard_mapwhen you need to write communication by hand. - Collectives:
psum/pmean/pmax/pminreduce (pmean=psum/ N);all_gather,psum_scatter,all_to_allandppermuterearrange, and AR = RS + AG. Withppermute, a device that receives nothing gets 0. The vma check rejects double summing and also treats the result ofall_gatheras varying. - Multi-dimensional partitioning: 2D mesh +
PartitionSpec. Explicit axes put sharding in the type (jax.typeof), requireout_shardingwhen ambiguous, turnwith_sharding_constraintinto an assertion, and change sharding withreshard; Auto axes leave it to the compiler; inside shard_map the axes are Manual. - Multi-host: call
jax.distributed.initializefirst; load data byprocess_indexand assemble a global array withmake_array_from_process_local_data; all processes run the same program in the same order. This chapter actually ran it with 2 processes on this machine. - Verification habits: always cross-check distributed results against a single device and NumPy float64; read communication volume from the compiled HLO and
memory_analysisinstead of guessing.
Cheat sheet¶
| API | Purpose |
|---|---|
jax.devices() / jax.local_devices() | Global devices / this process's devices |
jax.device_count() / jax.local_device_count() | Global / local device count |
jax.process_index() / jax.process_count() | Current process index / total number of processes |
x.device / x.devices() / x.sharding | The array's device (attribute) / set of devices / sharding |
jax.device_put(x, device_or_sharding) | Place or shard an array (gives a committed array) |
jax.config.update('jax_num_cpu_devices', n) | Simulate n CPU devices (before backend initialization) |
jax.make_mesh(shape, names, axis_types=, devices=) | Create a device mesh (Explicit axes by default) |
NamedSharding(mesh, P(...)) | Describe sharding in terms of mesh axes |
jax.typeof(x) | Type with sharding info, e.g. float32[8@data,4@model] |
jax.debug.visualize_array_sharding(x) | Draw the layout |
jax.shard_map(f, mesh=, in_specs=, out_specs=) | Per-device local view + hand-written collectives |
jax.pmap(f, axis_name=, in_axes=, static_broadcasted_argnums=) | Legacy 1D SPMD |
lax.psum / pmean / pmax / pmin(x, axis) | Cross-device reductions (axis can be a tuple of names) |
lax.all_gather(x, axis, tiled=) | Gather and concatenate |
lax.psum_scatter(x, axis, scatter_dimension=, tiled=) | Sum, then split (reduce-scatter) |
lax.all_to_all(x, axis, split_axis, concat_axis, tiled=) | All-to-all exchange, rearranges the sharded dimension |
lax.ppermute(x, axis, perm) | Point-to-point sends by (source, destination) pairs; 0 if nothing is received |
lax.axis_index(axis) | This device's index along the axis |
lax.with_sharding_constraint(x, s) | Sharding hint under Auto, assertion under Explicit |
jax.sharding.reshard(x, P(...)) | Change sharding under Explicit |
jnp.matmul(a, b, out_sharding=) | Specify the output sharding to resolve ambiguity |
jax.set_mesh(mesh) | Set the mesh context (after which you can write P(...) directly) |
jax.distributed.initialize(coordinator_address, num_processes, process_id) | Multi-host initialization |
jax.make_array_from_process_local_data(sharding, local) | Assemble each process's local data into a global array |
multihost_utils.process_allgather(x) | Gather every process's data to host memory |
compiled.memory_analysis() / compiled.as_text() | Per-device memory stats / compiled HLO (to inspect communication ops) |
Appendix: reference solutions¶
First write your own answer in the "✍️ Your answer" cell in the main text and check it with the "🧪 Self-check"; come here to compare once the self-check passes or if you're really stuck. Running the solutions below in order overwrites any functions with the same names you defined in the answer cells.
Exercise 1¶
See "✍️ Exercise 1: distributed matrix transpose with all_to_all" in the main text.
# Reference solution
Xt16 = np.arange(256.).reshape(16, 16)
Xt16_s = jax.device_put(jnp.asarray(Xt16), NamedSharding(mesh8, P("i", None)))
def dist_transpose(x): # x: (2, 16), this device's 2 rows
# Cut along columns into 8 blocks and send one to each of the 8 devices; concatenate the received blocks along rows → (16, 2) = columns 2i:2i+2 of the original
cols = lax.all_to_all(x, "i", split_axis=1, concat_axis=0, tiled=True)
return cols.T # (2, 16) = rows 2i:2i+2 of the transpose
f_t = jax.jit(jax.shard_map(dist_transpose, mesh=mesh8, in_specs=P("i", None), out_specs=P("i", None)))
XT = f_t(Xt16_s)
assert np.array_equal(np.asarray(XT), Xt16.T)
assert collectives(f_t.lower(Xt16_s).compile()) == ["all-to-all"]
print("Distributed transpose is correct and uses a single all-to-all ✓; output type", jax.typeof(XT))
Exercise 2¶
See "✍️ Exercise 2: global gradient-norm clipping under FSDP" in the main text.
# Reference solution
g_sharded = jax.jit(jax.shard_map(fsdp_grad_auto, mesh=dmesh, in_specs=(pspec, P("data"), P("data")),
out_specs=pspec))(p_fsdp, Xd, Yd)
true_norm = float(optax.global_norm(g_single))
max_norm = 0.5 * true_norm # half the true norm, so clipping kicks in
def clip_sharded(g, max_norm):
local_sq = sum(jnp.sum(a ** 2) for a in jax.tree.leaves(g)) # sum of squares of the local shard
norm = jnp.sqrt(lax.psum(local_sq, "data")) # global sum of squares → global norm
scale = jnp.minimum(1.0, max_norm / norm)
return jax.tree.map(lambda a: a * scale, g), norm
f_clip = jax.jit(jax.shard_map(clip_sharded, mesh=dmesh, in_specs=(pspec, P()), out_specs=(pspec, P())))
g_clipped, norm_d = f_clip(g_sharded, jnp.float32(max_norm))
ref_clipped, _ = optax.clip_by_global_norm(max_norm).update(g_single, optax.EmptyState())
print(f"Global norm: distributed {float(norm_d):.6f} | optax {true_norm:.6f}")
assert abs(float(norm_d) - true_norm) < 1e-5
assert jax.tree.all(jax.tree.map(lambda a, b: bool(np.allclose(np.asarray(a), np.asarray(b), atol=1e-6)), g_clipped, ref_clipped))
assert abs(float(optax.global_norm(ref_clipped)) - max_norm) < 1e-5
print("Global-norm clipping of FSDP-sharded gradients == optax ✓")
Exercise 3¶
See "✍️ Exercise 3: one training step with data × model 2D parallelism" in the main text.
# Reference solution
put_a = lambda a, spec: jax.device_put(a, NamedSharding(mesh2d_auto, spec))
Ym = jnp.sin(Xm)
p_meg = {"w1": W1, "w2": W2}
p_meg_sh = {"w1": put_a(W1, P(None, "model")), "w2": put_a(W2, P("model", None))}
out_sh = {k: v.sharding for k, v in p_meg_sh.items()}
def meg_loss(p, x, y):
return jnp.mean((jax.nn.relu(x @ p["w1"]) @ p["w2"] - y) ** 2)
def meg_step(p, x, y):
g = jax.grad(meg_loss)(p, x, y)
return jax.tree.map(lambda a, b: a - 0.1 * b, p, g)
step_2d = jax.jit(meg_step, out_shardings=out_sh) # pin the parameter sharding with out_shardings
new_2d = step_2d(p_meg_sh, put_a(Xm, P("data")), put_a(Ym, P("data")))
new_1 = jax.jit(meg_step)(p_meg, Xm, Ym)
for k in p_meg:
assert np.allclose(np.asarray(new_2d[k]), np.asarray(new_1[k]), atol=1e-5)
assert new_2d[k].sharding.is_equivalent_to(out_sh[k], new_2d[k].ndim)
print("One 2D-parallel training step == single device ✓; sharding preserved:", {k: v.sharding.spec for k, v in new_2d.items()})
print("Communication inserted by the compiler:", collectives(step_2d.lower(p_meg_sh, put_a(Xm, P("data")), put_a(Ym, P("data"))).compile()))