Data parallelism, ZeRO and FSDP
Before this lesson: lesson 6 (16 bytes per parameter, activation memory), lesson 7 (ring all-reduce = reduce-scatter + all-gather, and the bytes each GPU sends), stochastic gradient descent (the mini-batch gradient is a mean) and Adam and AdamW (the update is elementwise). Time: about 70 minutes, plus the notebook.
Why this matters #
Data parallelism (DP) is the outer axis of almost every training run, from a small model on 4 GPUs to Llama 3 405B, which used data parallelism of degree 128 on 16,384 H100 GPUs (with tensor and pipeline parallelism inside). This lesson gives the numbers behind the options of DDP, ZeRO and FSDP: how much memory each stage saves, how many bytes it sends, and when those bytes hide behind compute.
A 7B model trained with mixed-precision AdamW needs GB of model states (lesson 6). No 80 GB GPU holds that. ZeRO-3 over 8 GPUs needs 14 GB per GPU. The price is 1.5 times the communication of plain data parallelism. Whether you see that price in the step time depends on one ratio: FLOP/s over bandwidth.
1. Predict first #
2. The picture #
Two GPUs, one batch #
Both GPUs hold the same weights . GPU 0 takes the first half of the batch, GPU 1 the second half. Each computes the gradient of the mean loss over its half. The loss is a mean over samples and the gradient is linear, so the mean of the two half-batch gradients is the full-batch gradient (when the halves have the same size). An all-reduce gives this mean to both GPUs. Both apply the same update, and the two copies stay identical. This is DDP (distributed data parallel).
Small numbers. Fit with the loss on four samples , , at . The gradient of one sample is :
| sample | 1 | 2 | 3 | 4 |
|---|---|---|---|---|
GPU 0 (samples 1, 2) gets the mean ; GPU 1 (samples 3, 4) gets . The all-reduce averages them: . One GPU with all four samples gets . The same number.
What is redundant #
With GPUs and parameters, every GPU stores the same weights, gradients and optimizer states: copies of bytes. ZeRO removes the copies. The key fact is that AdamW is elementwise: the update of parameter needs only its gradient , its moments and , and its fp32 master weight. So GPU can own slice of the parameters. It keeps the optimizer states of that slice only, receives only that slice of the averaged gradient, updates only that slice, and sends the new values to the others.
The communication is already there #
A ring all-reduce has two halves (lesson 7). After the reduce-scatter, GPU holds slice of the sum. After the all-gather, every GPU holds every slice. ZeRO puts the optimizer step between the two halves:
| before the update | the update | after the update | |
|---|---|---|---|
| DDP | backward, then all-reduce of the gradients (= reduce-scatter + all-gather) | every GPU updates all | — |
| ZeRO-1, ZeRO-2 | backward, then reduce-scatter of the gradients | GPU updates slice | all-gather of the new weights |
| ZeRO-3 | all-gather the weights (forward), all-gather again (backward), reduce-scatter of the gradients | GPU updates slice | nothing: the next forward pass gathers |
Compare the stages. The left bars are the model states of one GPU: weights (blue), gradients (yellow) and optimizer states (violet); the line is 80 GB. The right bars are the bytes each GPU sends per step (aqua). Increase : the optimizer part shrinks first (ZeRO-1), then the gradients (ZeRO-2), then everything (ZeRO-3). Notice that the aqua bars of DDP, ZeRO-1 and ZeRO-2 are equal.
A 10B-parameter model is trained with mixed-precision AdamW () and ZeRO-3 on 16 GPUs. How much model-state memory does each GPU need?
ZeRO-3 shards everything: GB. 160 GB is plain data parallelism (no sharding). 28.75 GB is ZeRO-2, which keeps the full bf16 weights: GB. 1.25 GB counts only the bf16 weights divided by 16 and forgets the gradients and the optimizer states.
3. Definitions and main results #
Notation. : number of parameters. : number of data-parallel GPUs (lesson 7 writes ). : optimizer bytes per parameter; for mixed-precision Adam (fp32 master weight, and , 4 bytes each). Weights and gradients are bf16 (2 bytes). : tokens one GPU processes per optimizer step. : FLOP/s one GPU achieves on the matmuls. : network bandwidth per GPU and direction, in bytes/s. A ring reduce-scatter or all-gather of bytes sends bytes per GPU; a ring all-reduce sends twice that (lesson 7). 1 GB bytes.
Data parallelism is large-batch training #
Proposition (DDP is exact). Let all GPUs start from the same weights (DDP broadcasts them from GPU 0 at the start). Split the global batch into micro-batches of the same size, and let the loss be a mean over samples. Then after every step all GPUs hold the same weights, equal to the weights of one GPU that trains on the whole batch.
Proof.
- The full-batch loss is . Split the samples into groups of size .
- GPU computes , the gradient of its own mean loss.
- The averaged gradient is .
- Every GPU applies the same deterministic optimizer step to the same weights, the same gradient and the same optimizer state, so all GPUs end with the same new weights and state.
- By induction over the steps, the copies stay identical and equal to one-GPU training.
In floating point the averaged gradient can differ from the one-GPU gradient in the last bits, because the sum is done in a different order. The copies still stay identical to each other, because all GPUs receive the same all-reduce result.
Theorem (ZeRO memory and communication) #
With one micro-batch per step:
| stage | what is sharded | model states per GPU (bytes) | elements sent per GPU per step |
|---|---|---|---|
| DDP (ZeRO-0) | nothing | ||
| ZeRO-1 () | optimizer states | ||
| ZeRO-2 () | + gradients | ||
| ZeRO-3 () | + parameters |
To get bytes, multiply the volumes by the bytes per element (2 for bf16) and by .
Proof.
- Bytes per parameter. bf16 weight (2), bf16 gradient (2), optimizer states (). DDP keeps everything on every GPU: .
- ZeRO-1. AdamW is elementwise, so GPU needs the optimizer states of its slice only: . Weights and gradients stay full: .
- ZeRO-2. GPU uses only slice of the averaged gradient. So each gradient bucket is reduced to its owners as soon as it is ready (a reduce-scatter), and the rest of the bucket is freed: .
- ZeRO-3. The weights are sharded too: . Full weights exist only for the few layers that are being computed.
- DDP volume. An all-reduce of gradient elements is a reduce-scatter () plus an all-gather (): .
- ZeRO-1 and ZeRO-2 volume. Reduce-scatter the gradients (); GPU updates slice ; all-gather the new weights (). Total . This is the DDP all-reduce cut in two, with the update in the middle, so there is no extra traffic.
- ZeRO-3 volume. Before each layer's forward pass, all-gather its weights, and free them after use: over the whole model. The backward pass needs them again: a second all-gather (). Then reduce-scatter the gradients (). No all-gather follows the update, because the next forward pass does it. Total , which is 1.5 times DDP.
Two remarks.
- For large the model states go to (ZeRO-1, 4 times less than DDP), (ZeRO-2, 8 times less) and 0 (ZeRO-3).
- ZeRO shards model states only. Every GPU still runs whole layers on its own micro-batch, so the activations are not divided. For them use checkpointing (lesson 6) or tensor and sequence parallelism (lesson 9).
Gradient accumulation #
Gradient accumulation runs micro-batches one after the other and adds their gradients before one optimizer step. The global batch grows by without more activation memory, because only one micro-batch's activations are stored at a time. The communication depends on the stage:
| stage | what it does per micro-batch | elements sent per GPU per step |
|---|---|---|
| DDP | adds into its full local gradient; no communication inside no_sync() |
|
| ZeRO-1 | the same: full local gradient, one reduce-scatter and one all-gather per step | |
| ZeRO-2 | keeps no full gradient, so it reduce-scatters after every micro-batch | |
| ZeRO-3 | all-gathers the weights twice and reduce-scatters in every micro-batch |
So with accumulation, DDP and ZeRO-1 send fewer bytes per token. ZeRO-3 pays its full for every micro-batch.
ZeRO-3 (FSDP with resharding after forward) runs 8 micro-batches of gradient accumulation per optimizer step. How many times per step is each layer's weight all-gathered?
The weights are sharded and freed after use, so every micro-batch needs them twice: once before its forward pass and once before its backward pass. . This is why ZeRO-3 must hide its communication per micro-batch, not per step. DDP with no_sync() would all-reduce once per step.
How DDP overlaps the all-reduce with the backward pass #
PyTorch DDP (Li et al. 2020) works in five steps.
- Construction. GPU 0 broadcasts the parameters and buffers. DDP groups the parameters into
buckets (default 25 MiB) in the reverse order of
model.parameters(). This is a guess of the order in which the gradients become ready. After the first iteration, DDP rebuilds the buckets in the order it actually saw. - Backward. An autograd hook fires when a parameter's gradient is ready and marks it in its bucket. When a bucket is complete, an asynchronous all-reduce of the whole bucket starts on a separate communication stream. Meanwhile autograd computes the gradients of earlier layers. All GPUs launch the buckets in the same order, because collectives must match across GPUs.
- End of backward. DDP waits for all buckets and writes the averaged gradients into
.grad. The optimizer then runs as on one GPU. - Why buckets. One all-reduce per tensor sends many small messages, and each pays the latency (lesson 7). One all-reduce at the end overlaps nothing. Buckets of tens of MB make small next to the bandwidth term and still start early.
- What stays exposed. The bucket with the first layers' gradients is ready last. No compute is left to hide it.
Change the bucket size. The top lane is the backward pass, layer by layer from the last layer (blue). The bottom lane is the all-reduce of each bucket (aqua). The violet part is exposed: the GPU waits for communication. At the start (25 MB buckets, 16,384 tokens per GPU, 50 GB/s) only the last bucket is exposed. Then try very small buckets (the latency of each all-reduce adds up), one huge bucket (nothing overlaps), fewer tokens and a slower network. The model: 24 layers of 50M parameters, 8 GPUs, TFLOP/s.
In DDP with well-chosen buckets and a fast network, one part of the gradient all-reduce is still never hidden behind compute. Which part, and why?
The backward pass goes from the last layer to the first. The first layers' gradients are computed last, so their bucket's all-reduce starts when no compute is left. Smaller last buckets make this exposed time shorter, but it never becomes zero. The communication runs on a separate stream, so the other buckets can overlap.
How FSDP implements ZeRO-3 #
PyTorch FSDP (Zhao et al. 2023) works in six steps.
- Units. You wrap the model bottom-up, usually one unit per transformer block, plus the
root (
fully_shard(block)in FSDP2). The parameters of one unit form one communication group. FSDP2 shards each parameter along dimension 0 as aDTensor; FSDP1 flattens a unit's parameters into oneFlatParametersplit into equal chunks. Each GPU keeps its fp32 shard (the master weights), its gradient shard and the Adam states of its shard. With bf16 compute the shard and its gradient are both fp32: bytes per parameter, the same as . - Forward of a unit. A pre-forward hook all-gathers the unit's parameters (in bf16 if the
mixed-precision policy says so). The unit runs. A post-forward hook frees the full copy
(
reshard_after_forward=True). - Backward of a unit (in reverse order). All-gather the parameters again, compute the gradients, free the full parameters, and reduce-scatter the gradients. Each GPU keeps its gradient shard.
- Optimizer step. A normal optimizer runs on the local shards, with no communication. The next forward pass's all-gathers spread the new weights.
- Overlap and prefetch. The all-gathers run on a separate stream. Backward prefetch issues the next unit's all-gather before the current unit's reduce-scatter. Forward prefetch issues the next forward all-gather early. FSDP limits how many all-gathers are in flight, so the peak memory is the shards, plus about two units of full parameters, plus the activations.
- Variants.
reshard_after_forward=Falsekeeps the full weights from forward to backward: one all-gather less ( volume), but the full bf16 weights stay in memory during the step. HSDP (hybrid sharding; FSDP2 with a 2-D device mesh) shards inside groups of GPUs, usually one node, and replicates across the groups. The model states are . The all-gathers and reduce-scatters stay on the fast links inside the node. Only an all-reduce of each GPU's gradient shard ( elements) crosses the network.
Turn prefetch and resharding on and off. Each FSDP unit needs an all-gather (aqua) before its compute (blue); in the backward pass each unit ends with a reduce-scatter. The curve below counts the units whose full parameters are in memory. Without prefetch, compute waits for every all-gather (violet). Without resharding, the backward all-gathers disappear, but all units stay in memory at the end of the forward pass.
Spot the error: "FSDP keeps a full copy of the weights on every GPU during the whole forward pass; it saves memory only in the optimizer step."
With reshard_after_forward=True, each unit's parameters are all-gathered just before the unit runs and freed just after it. With prefetch, the next unit is gathered while the current one computes, so about two units are full at a time. A GPU cannot multiply with only its shard of a weight matrix in FSDP: it needs the full matrix for its own micro-batch. (Tensor parallelism, lesson 9, is the method that computes with weight slices.)
Proposition (tokens that hide the all-reduce) #
Use DDP with bf16 gradients and a ring all-reduce over GPUs, overlapped with the backward pass. The communication is hidden when
Proof.
- The forward pass costs about FLOPs per token and the backward pass (one matmul for the input gradient, one for the weight gradient). We ignore the attention-score FLOPs. The backward time is .
- The all-reduce of bf16 gradients ( bytes) sends bytes per GPU. Its time is .
- It is hidden if , that is, . cancels: compute and communication both grow linearly with the model.
- ZeRO-3. Forward: all-gather bytes against FLOPs. Backward: all-gather and reduce-scatter, bytes against FLOPs. The same threshold in both phases, but per micro-batch, because the gathers repeat in every micro-batch.
- fp32 gradient reduction (Llama 3 reduce-scatters gradients in fp32) doubles the bytes: .
- Numbers. Llama 3 405B achieved TFLOP/s per H100, about 40% of the 989 TFLOP/s dense bf16 peak. Across nodes with 400 Gb/s InfiniBand per GPU ( GB/s): tokens. Inside a node on NVLink ( GB/s): about 900 tokens. With one 200 Gb/s link per GPU ( GB/s): 16,000 tokens.
- Use the bus bandwidth that nccl-tests measures (lesson 7) for ; it is often below the link peak. And remember that the last bucket is always exposed.
Find the threshold. The curve is (communication time) / (backward time) against the tokens per GPU; below 1 (the line) the all-reduce hides. The bars show the two times for your settings: compute (blue) and communication (aqua). Move the model-size slider: both bars change, but the curve and the threshold do not move.
You train a 1B and a 100B model with DDP on the same GPUs (same achieved FLOP/s) and the same network. Which one needs more tokens per GPU per step to hide the gradient all-reduce?
The bytes grow like and the backward FLOPs like , so the ratio of the two times does not depend on : . The 100B model has 100 times more bytes, but also 100 times more FLOPs per token. In practice the 100B model also uses tensor or pipeline parallelism, which changes and per data-parallel GPU, not the rule.
Answers to section 1. (1) The same. A reduce-scatter plus an all-gather is exactly the all-reduce that DDP sends: elements. (2) Neither. Both need , because cancels. (3) 1.5 times: against elements, for any . The bytes per GPU of a ring collective do not grow with the number of GPUs.
4. Worked example #
A 13B model (40 layers, hidden size , sequence length ), mixed-precision AdamW (), on 4 nodes with 8 H100 80 GB each, so . NVLink gives 450 GB/s and InfiniBand 50 GB/s per GPU; the matmuls achieve TFLOP/s. One micro-batch is one sequence: 4,096 tokens.
Step 1: model states per GPU ().
| DDP | ZeRO-1 | ZeRO-2 | ZeRO-3 | HSDP () | |
|---|---|---|---|---|---|
| formula | |||||
| GB | 208 | 56.9 | 31.7 | 6.5 | 26 |
Step 2: activations (lesson 6, FlashAttention, no checkpointing): GB per layer, 28.5 GB for 40 layers. Totals: ZeRO-1 85.4 GB (does not fit), ZeRO-2 60.2 GB, ZeRO-3 35.0 GB, HSDP 54.5 GB.
Step 3: compute per micro-batch. FLOPs, so 0.80 s at 400 TFLOP/s, of which the backward pass takes s.
Step 4: communication per micro-batch (bf16).
- ZeRO-3 over all 32 GPUs: GB per GPU. The rings cross nodes, so the 50 GB/s links set the pace: 1.51 s, more than the 0.80 s of compute. 4,096 tokens is below : the step is limited by communication.
- ZeRO-2 over 32 GPUs: reduce-scatter GB per micro-batch, 0.50 s, just under the 0.53 s backward pass. Plus one all-gather of 25.2 GB (0.50 s) per step.
- HSDP, shards inside each node: GB on NVLink: 0.15 s. Across nodes, each GPU all-reduces its gradient shard ( GB) with its 3 peers in the other nodes: GB, 0.10 s.
Verdict. HSDP with : 54.5 GB per GPU plus buffers, no recomputation, and all communication well hidden. ZeRO-3 over 32 GPUs would need 2 sequences per micro-batch to reach . Then its activations double to 57 GB (63.5 GB in total), and its communication (1.51 s against 1.60 s of compute) is only just hidden. The same bytes are cheap on NVLink and expensive on the network.
5. In code #
The first block simulates ZeRO-1 on 4 "GPUs" with NumPy and checks two claims: the result equals one-GPU AdamW on the whole batch, and each GPU sends elements per step.
import numpy as np
rng = np.random.default_rng(0)
N, P, B = 4, 10, 32 # GPUs, parameters, global batch
X, y = rng.standard_normal((B, P)), rng.standard_normal(B)
grad = lambda w, Xs, ys: Xs.T @ (Xs @ w - ys) / len(ys) # gradient of the mean of (x.w - y)^2 / 2
def adamw(w, g, m, v, t, lr=1e-2, b1=0.9, b2=0.999, eps=1e-8, wd=0.1):
m, v = b1 * m + (1 - b1) * g, b2 * v + (1 - b2) * g * g
mh, vh = m / (1 - b1**t), v / (1 - b2**t)
return w - lr * (mh / (np.sqrt(vh) + eps) + wd * w), m, v
w0 = rng.standard_normal(P)
w, m, v = w0.copy(), np.zeros(P), np.zeros(P) # one GPU, whole batch
for t in range(1, 6):
w, m, v = adamw(w, grad(w, X, y), m, v, t)
sl = np.array_split(np.arange(P), N) # GPU k owns parameters sl[k]
Xs, ys = np.split(X, N), np.split(y, N) # GPU k gets micro-batch k
W = [w0.copy() for _ in range(N)] # every GPU: all weights ...
M = [np.zeros(len(s)) for s in sl] # ... but Adam states of its slice only
V = [np.zeros(len(s)) for s in sl]
sent = 0
for t in range(1, 6):
g_local = [grad(W[k], Xs[k], ys[k]) for k in range(N)]
for k in range(N): # reduce-scatter: GPU k gets the mean of slice k
gk = np.mean([g[sl[k]] for g in g_local], axis=0)
W[k][sl[k]], M[k], V[k] = adamw(W[k][sl[k]], gk, M[k], V[k], t)
for k in range(N): # all-gather: everyone gets every new slice
for j in range(N):
W[k][sl[j]] = W[j][sl[j]]
sent += 2 * (N - 1) / N * P # elements sent per GPU: reduce-scatter + all-gather
print(max(np.abs(Wk - w).max() for Wk in W) < 1e-12) # True: ZeRO-1 = one-GPU AdamW
print(sent / 5) # 15.0 = 2 * (3/4) * 10 per step
The second block is PyTorch reading code for gradient accumulation with DDP. Inside no_sync()
the hooks add the gradients locally and start no all-reduce; the last micro-batch all-reduces the
sum once.
import contextlib
from torch.nn.parallel import DistributedDataParallel as DDP
ddp = DDP(model, bucket_cap_mb=25) # gradients are grouped into 25 MiB buckets
for i, (x, y) in enumerate(micro_batches): # m micro-batches per optimizer step
sync = i == m - 1
with contextlib.nullcontext() if sync else ddp.no_sync():
(loss_fn(ddp(x), y) / m).backward() # all-reduce of each bucket only when sync
opt.step()
opt.zero_grad()
The third block runs FSDP2 (fully_shard) on the CPU with two processes and the gloo backend.
It checks three claims: the parameters are sharded, each unit needs two all-gathers and one
reduce-scatter, and the result equals one process with the whole batch.
# Save as fsdp_check.py and run: python fsdp_check.py (CPU, 2 processes)
import os
import warnings
import torch
import torch.distributed as dist
import torch.multiprocessing as mp
from torch.distributed.device_mesh import init_device_mesh
from torch.distributed.fsdp import fully_shard
from torch.distributed.tensor.debug import CommDebugMode
warnings.filterwarnings("ignore", message="FSDP2-wrapped module") # harmless under CommDebugMode
def make_model():
torch.manual_seed(0) # same initial weights on every process
return torch.nn.Sequential(torch.nn.Linear(16, 64), torch.nn.Tanh(), torch.nn.Linear(64, 4)).double()
def worker(rank, world):
os.environ.update(MASTER_ADDR="127.0.0.1", MASTER_PORT="29514")
dist.init_process_group("gloo", rank=rank, world_size=world)
g = torch.Generator().manual_seed(1)
X = torch.randn(8, 16, generator=g, dtype=torch.float64)
Y = torch.randn(8, 4, generator=g, dtype=torch.float64)
mesh = init_device_mesh("cpu", (world,)) # 1-D mesh: shard over all ranks (2-D gives HSDP)
model = make_model()
for layer in (model[0], model[2]): # one FSDP unit per Linear ...
fully_shard(layer, mesh=mesh)
fully_shard(model, mesh=mesh) # ... and the root for anything left
opt = torch.optim.AdamW(model.parameters(), lr=1e-2) # sees only the local shards
w = model[0].weight # a DTensor; rank r keeps rows 32r ... 32r + 31
if rank == 0:
print(type(w).__name__, w.placements, tuple(w.shape), tuple(w.to_local().shape))
Xr, Yr = X.chunk(world)[rank], Y.chunk(world)[rank] # each rank: its own 4 samples
with CommDebugMode() as comm:
torch.nn.functional.mse_loss(model(Xr), Yr).backward()
opt.step() # each rank updates its own shard
full = [p.full_tensor() for p in model.parameters()] # all-gather, only to compare
if rank == 0:
print({str(op).split(".")[-1]: n for op, n in comm.get_comm_counts().items()})
ref = make_model() # reference: one process, the whole batch
ref_opt = torch.optim.AdamW(ref.parameters(), lr=1e-2)
torch.nn.functional.mse_loss(ref(X), Y).backward()
ref_opt.step()
print(all(torch.allclose(a, b) for a, b in zip(full, ref.parameters())))
dist.destroy_process_group()
if __name__ == "__main__":
mp.spawn(worker, args=(2,), nprocs=2)
# DTensor (Shard(dim=0),) (64, 16) (32, 16)
# {'_allgather_base_': 4, '_reduce_scatter_base_': 2}
# True
Two units, each gathered in the forward pass and again in the backward pass (4 all-gathers), and
one reduce-scatter per unit: the of the theorem. With reshard_after_forward=False on the
second unit you would see one all-gather less. (Checked with PyTorch 2.15 on a laptop CPU.)
6. Where it shows up #
- LLM pretraining. Llama 3 405B used FSDP as its data-parallel axis (degree 128 on 16,384 H100s), with tensor and pipeline parallelism inside. It sharded optimizer states and gradients but did not reshard the weights after the forward pass (so per step, like ZeRO-2), and it reduce-scattered gradients in fp32 (Llama 3 paper, section 3.3.2). DeepSpeed implements ZeRO-1/2/3 directly.
- Fine-tuning. FSDP with LoRA or QLoRA: the frozen base model is sharded, and only the small adapters have gradients and Adam states. So the gradient and terms cover only the adapters.
- ML for science. Earth-system and weather models often have 0.1–5B parameters but long token sequences: a 0.25° global field ( points) with patches gives about 65,000 tokens per time step. ZeRO-1 or ZeRO-2 fits the model states easily (plain DDP only up to about 3B on 80 GB GPUs, because GB already fills one). One sample already gives about 4 times on a network with 25 GB/s per GPU (65,000 against 16,000 tokens), so the data-parallel communication hides well. The GPU fills with activations, and the limit is the largest useful global batch, not the bandwidth. On nodes with 4 GPUs and a slower network between nodes, shard inside the node: HSDP with .
- Checkpoints. Sharded optimizer states mean sharded checkpoint files
(
torch.distributed.checkpoint). A restart on a different needs resharding.
7. Common mistakes #
- "ZeRO-3 shards activations." ZeRO-1/2/3 shard model states only.
- "ZeRO-3 costs times the communication." It is against elements per GPU.
- Using the per-step token count for ZeRO-3. With gradient accumulation its gathers repeat in every micro-batch, so the condition must hold per micro-batch.
- "DDP traffic per GPU grows with ." The bytes per GPU stay at about for a message of bytes. What grows is the latency term , so small buckets on many GPUs become limited by latency.
- Wrapping only the root with FSDP. Then the whole model is one unit. It is all-gathered at once, the full bf16 weights and gradients (about bytes) stay in memory through the backward pass, and nothing overlaps.
- Sum instead of mean, or uneven token counts. With a per-token loss and a different number of tokens on each GPU, weight each GPU's gradient by its token count.
- Local gradient clipping. With sharded gradients, the global norm needs an all-reduce of the squared shard norms. FSDP's clipping does it; a hand-written loop must do it too.
8. Check yourself #
- A 70B model, mixed-precision AdamW, 128 GPUs of 80 GB. Model states per GPU with ZeRO-1, ZeRO-2
and ZeRO-3? Which ones fit?
Answer
ZeRO-1: GB. ZeRO-2: GB. ZeRO-3: GB. Only ZeRO-3 fits (or ZeRO combined with tensor or pipeline parallelism); the activations come on top. - Why can ZeRO-1 shard the Adam states at no extra communication cost? Which kind of optimizer
would break this?
Answer
Adam is elementwise, so slice of the update needs only slice of the averaged gradient (the reduce-scatter half of the all-reduce) and of the states; the all-gather half then spreads the new weights. An optimizer that needs whole matrices, such as Shampoo or Muon (optimization lesson 12), cannot update a flat slice alone: it must first gather each gradient matrix, which adds communication. - A cluster of A100 GPUs achieves 150 TFLOP/s per GPU and has 200 Gb/s InfiniBand per GPU. How
many tokens per GPU per step hide the DDP all-reduce? And with fp32 gradient reduction?
Answer
GB/s, so tokens. With fp32 the bytes double: 12,000 tokens. - You set
reshard_after_forward=Falseon every FSDP unit (what Llama 3 did). What changes in memory and communication?Answer
The full bf16 weights stay in memory from forward to backward, up to bytes at the end of the forward pass. The backward all-gather disappears, so the volume drops from to (one all-gather and one reduce-scatter), the same as DDP. - HSDP on 16 nodes × 4 GPUs, $\Psi = 2$B, bf16 gradients, 25 GB/s per GPU between nodes. How many
bytes does each GPU send across nodes per step, and how long does it take? Compare with DDP over
all 64 GPUs.
Answer
Each GPU holds a gradient shard of GB and all-reduces it over replicas: GB, 75 ms. DDP over 64 GPUs sends GB GB through the same 25 GB/s links: 315 ms. - In DDP, which gradients are always exposed, and why?
Answer
Those of the first layers (and the input embedding). The backward pass computes them last, so their bucket's all-reduce starts when no compute is left to overlap with.
Why does data-parallel training give the same update as one GPU with the whole batch?
The loss is a mean over samples and the gradient is linear, so the mean of equal-size micro-batch gradients is the full-batch gradient. All GPUs apply the same update to the same weights.
What are the per-GPU model-state bytes of DDP, ZeRO-1, ZeRO-2 and ZeRO-3?
, , and , with for mixed-precision Adam.
Why does ZeRO-2 send no more bytes than DDP?
A ring all-reduce is a reduce-scatter plus an all-gather. ZeRO-2 does the update between the two halves: reduce-scatter the gradients, update slice , all-gather the new weights. Both send elements.
Why does ZeRO-3 send elements per step?
It all-gathers the weights before the forward pass (), again before the backward pass (), and reduce-scatters the gradients ().
What does FSDP do before a unit's forward pass and after its backward pass?
Before forward: all-gather the unit's parameters (and free them after the forward). After backward: free the full parameters and reduce-scatter the gradients, so each GPU keeps its gradient shard.
How many tokens per GPU per step hide the DDP gradient all-reduce?
: about 8,000 for 400 TFLOP/s and 50 GB/s.
Why does the model size cancel in the overlap condition?
The all-reduce moves about bytes and the backward pass does FLOPs. Both grow linearly with , so only , and remain.
What is HSDP?
Hybrid sharding: shard the model states inside a group of GPUs (usually a node) and replicate across groups. Gathers stay on fast links; only an all-reduce of the gradient shards crosses the network.
How does gradient accumulation change the communication of DDP and of ZeRO-3?
DDP (with
no_sync) all-reduces once per step, so fewer bytes per token. ZeRO-3 must all-gather twice and reduce-scatter in every micro-batch: per micro-batch.
9. Interview angle #
- "Why does ZeRO-2 cost no more communication than DDP?" — A ring all-reduce is a reduce-scatter plus an all-gather; ZeRO-2 does the update between the two halves.
- "Why is ZeRO-3 only 1.5 times?" — Two all-gathers of the weights and one reduce-scatter: against .
- "Your FSDP run on 8 nodes is slow. What do you check?" — Tokens per micro-batch against ;
exposed all-gathers in the profiler; the unit size (too small: latency; too large: memory);
HSDP;
reshard_after_forward=False; prefetching; the measured bus bandwidth. - "Does the gradient all-reduce get harder to hide for bigger models?" — No: , the model size cancels.
- More practice questions on distributed training are in the interview track.
10. Practice #
The notebook for this lesson runs in Colab, with NumPy only. It has five exercises, each with a check cell and a folded solution:
- Simulate DDP with 4 "GPUs" on linear regression: show that the copies stay identical and match one-GPU SGD on the whole batch.
- Sharded AdamW: implement ZeRO-1, -2 and -3 on the simulator; check that the final weights equal DDP's to and count the elements each GPU sends.
model_state_bytes(psi, nd, stage, K): the memory formulas, checked against what the simulator stores.- A bucketed-overlap timeline: the exposed communication time as a function of the bucket size.
tokens_to_hide(F, W, nd, grad_bytes), and the smallest micro-batch for ZeRO-3 on a given network.
11. Watch and read #
- Rajbhandari et al. (2019), ZeRO: Memory Optimizations Toward Training Trillion Parameter Models: section 3.1 (16 bytes per parameter), section 5 (the three stages), section 7 (communication volume).
- Zhao et al. (2023), PyTorch FSDP: Experiences on Scaling Fully Sharded Data Parallel: full and hybrid sharding, overlap, prefetch, the rate limiter.
- Li et al. (2020), PyTorch Distributed: Experiences on Accelerating Data Parallel Training: how DDP builds buckets and overlaps communication.
- Hugging Face, The Ultra-Scale Playbook: data parallelism and ZeRO with measured numbers; picotron has naive and bucketed data parallelism in a few hundred lines.
- Austin et al., How to Scale Your Model, How to Parallelize a Transformer for Training: sections on data parallelism and fully sharded data parallelism.
- Llama Team (2024), The Llama 3 Herd of Models, section 3.3.2: how FSDP is used at 16,384 GPUs.
Runs in your browser with nothing to install. The notebook repeats the key demos and has exercises with checks; solutions are inside, folded.
No Google account needed: Python runs inside your browser. The first start downloads about 40 MB; your changes are saved in this browser only.