Lab 09 — Parallelism, simulated¶
Build: a step-by-step ring all-reduce, Megatron column- and row-parallel layers, the ZeRO memory model, the pipeline-bubble formula and data-parallel gradient averaging, all in NumPy. Then real DDP across two processes on your laptop CPU.
Time: 2–3 h for the tests, 2–4 h for the experiments · Reads first: pretraining §5
Run: pytest labs/09_parallelism (your code) · pytest labs/09_parallelism --impl=solution (reference). The tests are NumPy only and finish in about a second.
Every frontier model is trained on more GPUs than fit in one server, so large-scale jobs and research-engineer interviews both assume you can size a run on paper: which collective moves how many bytes, what each GPU holds in memory, and how much time idle pipeline stages waste. None of this needs a cluster. The arithmetic is exact, and this lab makes you derive it by building each piece.
1. Ring all-reduce¶
All-reduce: N ranks each hold a vector of S bytes; afterwards every rank holds the elementwise sum. The ring algorithm splits each vector into N chunks and runs two phases of N − 1 steps. In every step each rank sends one chunk to its right neighbour, all at the same time:
N = 3; rank r starts with chunks (a_r, b_r, c_r) and sends to rank r+1 (mod 3)
rank 0 rank 1 rank 2
reduce-scatter 0 c += c2 a += a0 b += b1
reduce-scatter 1 b += b1+b2 c += c0+c2 a += a0+a1
owns sum(b) owns sum(c) owns sum(a)
all-gather 0 a = sum (from 2) b = sum (from 0) c = sum (from 1)
all-gather 1 c = sum (from 2) a = sum (from 0) b = sum (from 1)
Each step sends S/N bytes per rank, so over 2(N − 1) steps each rank sends (and receives)
Why this is bandwidth-optimal. Follow one element. Its N contributions start on N ranks, and a one-way send can merge at most one partial sum into another, so the first complete sum exists only after at least N − 1 sends. At that moment the other N − 1 ranks lack it, and each must receive at least one more message: N − 1 more sends. So any algorithm needs at least 2(N − 1)S bytes in total, which is at least 2(N − 1)S/N on the busiest rank. The ring meets that bound on every rank at once.
Compare gathering on rank 0 and broadcasting back: rank 0 receives (N − 1)S, which grows with N. For the test's buffer (64 float64 values, S = 512 bytes) the ring sends 512, 768 and 896 bytes per rank for N = 2, 4, 8; rank 0 of the naive scheme would receive 512, 1,536 and 3,584.
The ring's weakness is latency: 2(N − 1) sequential steps, each with a fixed start-up cost, so time ≈ 2(N − 1)·latency + 2(N − 1)/N · S / bandwidth. For small messages and very large N, NCCL switches to tree algorithms.
Worked: all-reducing the bf16 gradients of a 7B model (S = 14 GB) over 8 GPUs sends 2 · 7/8 · 14 = 24.5 GB per GPU: 54 ms at 450 GB/s (NVLink 4, one direction), 0.49 s at 50 GB/s (400 Gb/s InfiniBand).
2. Data parallelism: why averaging is exact¶
Each rank holds the whole model and 1/N of the batch. For a loss that is a mean over examples and N equal shards \(B_k\) of the batch \(B\),
so one all-reduce (sum, then divide by N) gives every replica the full-batch gradient, and all replicas take the identical step. data_parallel_grad checks this on least squares, where the gradient of ½·mean((Xw − y)²) is Xᵀ(Xw − y)/n.
- Equal shards matter. With 65 rows over 8 ranks, the plain average missed the full-batch gradient by 1.4% of its largest component in one random draw.
DistributedSamplerpads or drops the remainder. With variable-length sequences, weight by tokens: sum the per-token losses and divide by the global token count. - Overlap. PyTorch DDP groups gradients into buckets (25 MB by default) and all-reduces each bucket as soon as backward has produced it, hiding communication behind the rest of backward.
3. Tensor parallelism: the Megatron MLP¶
The MLP computes Z = GeLU(XA)B with A of shape (d, 4d) and B of shape (4d, d). Megatron-LM (Shoeybi et al., 1909.08053) splits it over n GPUs with no communication in the middle:
X (replicated) ──┬── X A_1 ── GeLU ── Y_1 B_1 ──┐
├── X A_2 ── GeLU ── Y_2 B_2 ──┼── all-reduce (sum) ──► Z (replicated)
└── ... ┘
A = [A_1 | A_2 | ...] split by columns B = [B_1 ; B_2 ; ...] split by rows
- Column-parallel first: rank i computes X A_i, a column block of XA. GeLU is elementwise, so GeLU(X A_i) is exactly the i-th column block of GeLU(XA).
- Row-parallel second: that block is exactly the input slice that multiplies row block B_i, and YB = Σᵢ Yᵢ Bᵢ. One all-reduce sums the partial products.
The order matters. Split A by rows and you hold partial sums of XA, and GeLU does not distribute over a sum: with the lab's GeLU, GeLU(1) + GeLU(1) = 1.68 but GeLU(2) = 1.95. You would need an extra all-reduce before the nonlinearity.
Megatron writes this with two operators: f before the block (identity forward, all-reduce backward, which sums the partial gradients with respect to X) and g after it (all-reduce forward, identity backward). So an MLP block costs one all-reduce in forward and one in backward. Attention splits the same way (Q, K, V column-parallel by heads, output projection row-parallel), giving 2 + 2 all-reduces per layer per micro-batch.
Worked: each all-reduce carries the activation, b·s·h·2 bytes: 33.5 MB for s = h = 4096, b = 1. With TP = 8 each GPU sends 2 · 7/8 · 33.5 = 58.7 MB per all-reduce; 4 per layer over 32 layers is 7.5 GB per micro-batch: 17 ms over NVLink, 150 ms over InfiniBand. This communication sits on the critical path, which is why TP stays inside a node.
4. ZeRO: where 16 bytes per parameter go¶
Mixed-precision Adam holds 2 bytes of bf16 weights, 2 of bf16 gradients and 12 of fp32 optimizer state (master weights, momentum, variance) per parameter: \(2\Psi + 2\Psi + 12\Psi = 16\Psi\). ZeRO (Rajbhandari et al., 1910.02054) shards these across the N data-parallel ranks:
| stage | shards | bytes per GPU | 7.5B, N = 64 (paper and test) | 7B, N = 8 |
|---|---|---|---|---|
| 0 (plain DP) | nothing | 16Ψ | 120 GB | 112 GB |
| 1 | optimizer state | 4Ψ + 12Ψ/N | 31.4 GB | 28 + 10.5 = 38.5 GB |
| 2 | + gradients | 2Ψ + 14Ψ/N | 16.6 GB | 14 + 12.25 = 26.25 GB |
| 3 | + weights | 16Ψ/N | 1.9 GB | 14 GB |
Read the 7B column against 40 GB cards: plain DP is hopeless, stage 1 leaves 1.5 GB for activations (not enough in practice), stage 2 leaves 13.75 GB and stage 3 leaves 26 GB. These are model states only; activations, temporary buffers and fragmentation come on top.
Stages 1 and 2 move the same bytes as plain DP, because an all-reduce already is a reduce-scatter plus an all-gather: reduce-scatter the gradients so each rank gets the slice it owns, update that slice, all-gather the new weights. Stage 3 all-gathers the weights again in backward: 1.5× DP traffic. PyTorch FSDP implements the same idea (full sharding is roughly stage 3; see Zhao et al., 2304.11277). The k argument is the optimizer's bytes per parameter: 12 for Adam with fp32 master weights, 8 for SGD with momentum.
5. Pipeline parallelism and the bubble¶
Split the layers into p stages and the batch into m micro-batches. In a GPipe schedule (Huang et al., 1811.06965) every stage runs all forwards, then all backwards:
time 0 1 2 3 4 5 6 7 8 9 10 11 12 13
stage 0 F1 F2 F3 F4 . . . . . . B4 B3 B2 B1
stage 1 . F1 F2 F3 F4 . . . . B4 B3 B2 B1 .
stage 2 . . F1 F2 F3 F4 . . B4 B3 B2 B1 . .
stage 3 . . . F1 F2 F3 F4 B4 B3 B2 B1 . . .
The schedule lasts (m + p − 1)(t_F + t_B) and each stage works m(t_F + t_B) of it, so the idle fraction is
| idle fraction | m = 1 | 4 | 8 | 16 | 32 | 64 |
|---|---|---|---|---|---|---|
| p = 2 | 50% | 20% | 11.1% | 5.9% | 3.0% | 1.5% |
| p = 4 | 75% | 42.9% | 27.3% | 15.8% | 8.6% | 4.5% |
| p = 8 | 87.5% | 63.6% | 46.7% | 30.4% | 18.0% | 9.9% |
1F1B schedules alternate one forward and one backward in steady state: same bubble, but at most p micro-batches of activations alive instead of m. Interleaved 1F1B (Narayanan et al., 2104.04473) gives each GPU v chunks of layers and divides the bubble by v, at the cost of more point-to-point messages. That paper defines the bubble as idle time over ideal time, (p − 1)/m; this lab uses idle over total. They agree when m ≫ p; say which one you mean.
6. Choosing a layout¶
| strategy | splits | memory per GPU | communication | use when |
|---|---|---|---|---|
| DP (DDP) | batch | full 16Ψ + activations | all-reduce grads once per step, overlapped | model and optimizer fit on one GPU |
| FSDP / ZeRO-3 | batch; states sharded | 16Ψ/N + activations + gathered layers | all-gather weights, reduce-scatter grads: 1.5× DP | model does not fit; links are fast |
| TP | each matmul | weights and activation work ÷ n | 2 all-reduces per layer per pass, on the critical path | layers too big, or latency matters; within NVLink |
| PP | layers | layers ÷ p | small point-to-point activations | across nodes with slow links; costs the bubble |
Typical large runs combine them: TP inside a node, PP across nodes when needed, DP or FSDP outermost. The curriculum's design order and its 7B planning exercise apply this.
What to implement¶
| # | function | test | what the test pins down |
|---|---|---|---|
| 1 | ring_all_reduce(arrays) → (results, sent) |
test_ring_all_reduce_sums_and_moves_2_n_minus_1_over_n |
for N = 2, 4, 8 every rank ends with the elementwise sum, and sent[r] == 2(N−1)/N · 64 · 8 bytes for every rank |
| 2 | column_parallel(x, w_shards) |
test_tensor_parallel_linear_layers |
concatenating x @ W_i over 4 column shards of a 12 × 8 W equals x @ W |
| 3 | row_parallel(x_shards, w_shards) |
same | summing x_i @ W_i over 3 input slices and matching row blocks equals x @ W |
| 4 | megatron_mlp(x, A, B, n) |
test_megatron_mlp_equals_unsharded |
with n = 4, A 16 × 64 and B 64 × 16, equals gelu(x @ A) @ B |
| 5 | zero_memory_bytes(n_params, n_gpus, stage, k=12) |
test_zero_paper_numbers |
7.5B parameters on 64 GPUs: 120, 31.4, 16.6, 1.875 GB (1 GB = 10⁹ bytes) |
| 6 | pipeline_bubble(stages, micro_batches) |
test_pipeline_bubble_and_data_parallel |
0.75 for p = 4, m = 1 and 3/32 for p = 4, m = 29 |
| 7 | data_parallel_grad(X, y, w, n) |
same | the mean of 8 shard gradients equals Xᵀ(Xw − y)/64 |
Given: gelu (tanh form), and in ring_all_reduce the float64 chunk lists, the sent counters and the return line.
Tips¶
Tip
In each ring step, first build every rank's message (sender, chunk index, a copy of the data), then deliver them all. That is what "simultaneous" means on real hardware, and it keeps the simulation honest whatever schedule you choose.
- Index bookkeeping: in reduce-scatter step t, rank r sends chunk (r − t) mod N; afterwards rank r owns the finished chunk (r + 1) mod N, so in all-gather step t it sends chunk (r + 1 − t) mod N. Trace N = 3 against the diagram in §1 first, and count bytes with
data.nbyteson what you send. megatron_mlpis twonp.array_splitcalls (axis=1for A,axis=0for B) and onerow_parallelcall.zero_memory_bytes: start from 2Ψ, 2Ψ and kΨ and divide one more term by N at each stage.
Common bugs¶
- Starting the all-gather from the wrong chunk (chunk r instead of the finished (r + 1) mod N): receivers overwrite correct sums with partial ones.
- Counting bytes per step for the whole array, or counting received and sent bytes together:
sentcomes out N× or 2× too large. - GeLU after the sum, or A split by rows:
test_megatron_mlp_equals_unshardedfails because GeLU is not additive. - Sharding the wrong term per stage (weights at stage 2), or using GiB (2³⁰) where the paper and the test use 10⁹.
- Using (p − 1)/m, the other bubble convention: 3/29 instead of 3/32.
- Summing shard gradients instead of averaging (N× too large), or dividing each shard gradient by the full batch size and then averaging (N× too small).
CPU experiments (no GPU needed)¶
Real DDP on two processes. Save this as ddp_demo.py and run torchrun --nproc_per_node=2 ddp_demo.py:
import time
import torch
import torch.distributed as dist
import torch.nn as nn
dist.init_process_group("gloo")
rank, world = dist.get_rank(), dist.get_world_size()
torch.manual_seed(0) # same data and init on every rank
X, y = torch.randn(64, 32), torch.randn(64, 1)
model = nn.Linear(32, 1)
ddp = nn.parallel.DistributedDataParallel(model)
shard = slice(rank * 64 // world, (rank + 1) * 64 // world)
loss = ((ddp(X[shard]) - y[shard]) ** 2).mean()
loss.backward() # DDP all-reduces (averages) grads here
ref = nn.Linear(32, 1)
ref.load_state_dict(model.state_dict())
((ref(X) - y) ** 2).mean().backward()
assert torch.allclose(model.weight.grad, ref.weight.grad, atol=1e-6)
buf = torch.zeros(25_000_000) # 100 MB of fp32
dist.barrier(); t = time.time()
for _ in range(5):
dist.all_reduce(buf)
dt = (time.time() - t) / 5
sent = 2 * (world - 1) / world * buf.numel() * 4 # ring all-reduce bytes per rank
if rank == 0:
print(f"grads match full batch; all-reduce 100 MB: {dt * 1e3:.0f} ms, ~{sent / dt / 1e9:.1f} GB/s per rank")
dist.destroy_process_group()
Rank 0 prints one line. On one cloud CPU container it printed 83 ms and about 1.2 GB/s per rank; your laptop will differ. The GB/s figure is what nccl-tests calls bus bandwidth: bytes actually sent per rank, not buffer size over time. Warnings about OMP_NUM_THREADS or IPv6 sockets are harmless. On native Windows, gloo is the only backend; if torchrun fails with an error that mentions libuv, set USE_LIBUV=0 in the environment, or run it in WSL2.
Three follow-ups:
- Sweep the message size from 4 KB to 400 MB and fit time = a + bytes/B. The intercept a is the latency term; find the size below which it dominates. Repeat with
--nproc_per_node=4and predict the change first (per-rank bytes go from 1.0S to 1.5S). - Break the equal-shard assumption: call
data_parallel_gradwith 65 rows, then fix it by weighting each shard's gradient by its size. - Draw the schedule: write a 20-line simulator that prints the GPipe grid above for any p and m, counts idle slots, and checks
pipeline_bubble. Add 1F1B and count the peak number of micro-batches whose activations are alive.
GPU scale-up (8 GB)¶
One GPU cannot show communication, but it can check the memory model, which decides whether a run fits. A free Colab or Kaggle T4 (16 GB) works too; T4 has no bf16 tensor cores, so use fp16 autocast with a GradScaler, or fp32.
- Predict, then measure, model-state memory. Train your lab 05 GPT for a few steps with AdamW under bf16 autocast. Autocast keeps parameters in fp32, so model states are 4 + 4 + 8 = 16 bytes per parameter, the same total as 2 + 2 + 12. Compare
torch.cuda.max_memory_allocated()with 16Ψ plus your activation estimate, then enable activation checkpointing and see which term moved. - Why full fine-tuning a 0.5B model does not fit. Qwen2.5-0.5B has 494 M parameters: 7.9 GB of model state before any activation. Confirm it, then see lab 10 for what does fit.
- FSDP on one GPU. On Linux, WSL2 or Colab (native Windows builds of PyTorch have no NCCL), launch with
torchrun --nproc_per_node=1, applyfully_shardfromtorch.distributed.fsdpto each block and then the model, and readtorch.cuda.memory_summary(). At N = 1 there is nothing to shard, so memory matches the plain model: 16Ψ/N at N = 1. Then passoffload_policy=CPUOffloadPolicy()and measure the memory saved and the step time lost. - Two real GPUs. Kaggle has offered a two-T4 accelerator option (check the current menu). Run
ddp_demo.pywith"nccl", tensors oncuda:{LOCAL_RANK}, and compare the bandwidth with gloo; then run the Megatron MLP across the two GPUs withdist.all_reduceand check it against the unsharded result.
Check yourself¶
- Ring all-reduce of a 1 GB buffer: bytes sent per GPU with 8 GPUs, and with 64? What does grow with N?
- Why is the first Megatron MLP layer column-parallel and the second row-parallel, and not the other way round?
- A 7B model on 8 GPUs with mixed-precision Adam: model-state memory per GPU under ZeRO-2 and ZeRO-3, and what ZeRO-3 costs you.
- p = 8, m = 16: what fraction of time is idle, and how can you reduce it without changing the global batch?
- Why does TP usually stay inside a node while DP and FSDP span nodes?
- Each DDP rank computes the mean loss over its own non-padding tokens, and ranks have different token counts. What is wrong with averaging those gradients, and what is the fix?
Answers
- 2 · 7/8 · 1 GB = 1.75 GB with 8 GPUs; 2 · 63/64 = 1.97 GB with 64. The number of sequential steps grows, from 14 to 126, so the latency term grows with N while the bandwidth term barely changes.
- Column-parallel output is already split along the hidden dimension, and GeLU is elementwise, so each rank applies it locally and the result is exactly the input slice the row-parallel layer needs. The block then needs one all-reduce. The other order produces partial sums before the nonlinearity, which needs an extra all-reduce because GeLU(a + b) ≠ GeLU(a) + GeLU(b).
- ZeRO-2: 2Ψ + 14Ψ/8 = 14 + 12.25 = 26.25 GB. ZeRO-3: 16Ψ/8 = 14 GB. ZeRO-3 all-gathers the weights in forward and again in backward: 1.5× the communication of plain DP, and the gathers must be prefetched to hide them.
- 7/23 = 30.4%. Use an interleaved schedule (the bubble falls by the number of chunks per GPU), use more, smaller micro-batches if the GPU stays efficient at the smaller size, or use fewer pipeline stages by sharding with FSDP or TP instead.
- TP puts two all-reduces per layer per pass on the critical path: 7.5 GB per micro-batch in the §3 example, 17 ms over NVLink but 150 ms over InfiniBand, against ~50 ms of compute. DP's single gradient all-reduce per step overlaps with backward, so slower links are acceptable.
- Each rank's gradient is a mean over a different number of tokens, so averaging ranks weights tokens on short-sequence ranks more heavily than the full-batch gradient would. Sum the per-token losses on each rank, all-reduce the global token count, and divide by it.
Stretch¶
- Ring all-reduce for real: implement it with
dist.isendanddist.irecvunder gloo and compare its time withdist.all_reduce. - Tensor-parallel autograd: write f and g as
torch.autograd.Functions and check that the gradients of a 2-process Megatron MLP match the unsharded MLP. - Hierarchical all-reduce: reduce-scatter inside each node, all-reduce across nodes, all-gather inside the node. Compute the inter-node bytes and compare with a flat ring (the idea behind HSDP).
- Sequence parallelism (Korthikanti et al., 2205.05198): replace each TP all-reduce by a reduce-scatter and an all-gather, shard LayerNorm and dropout activations along the sequence, and show the communication volume is unchanged while activation memory falls.