Distributed training
One GPU can't hold a big model, and even if it could, training would take months. Distributed training splits the work across many GPUs — but the moment you do, communication, not compute, becomes the thing you engineer. This lesson maps the parallelism strategies and when each is actually required.
Learning objectives
- Explain why one GPU isn't enough — the memory wall and the time wall.
- Distinguish data, tensor, and pipeline parallelism and what each splits.
- Describe DDP (all-reduce gradients) and the memory problem it does not solve.
- Explain ZeRO / FSDP sharding of optimizer state, gradients, and parameters.
- Use gradient accumulation to simulate a large batch on small memory.
- Reason about communication cost and why it dominates at scale.
- Choose a parallelism strategy by model size and hardware.
python3 — they model the arithmetic so you can reason about it offline. Only the PyTorch FSDP/DDP snippet needs real hardware:- 2+ GPUs (single node) or a multi-node cluster with a fast interconnect (NVLink / InfiniBand) +
pip install torch, launched withtorchrun
1 · Why one GPU isn't enough essential
You've probably felt the small version of this already. You fine-tune a model on your laptop or a single cloud GPU, and one day torch greets you with CUDA out of memory — not because your data got bigger, but because the model did. You shrink the batch size, it fits, you exhale. Now scale that frustration up by a thousand: a modern LLM doesn't just refuse to train on one card, it can't even be loaded onto one. The question this whole chapter answers is: when a single GPU can't hold or can't finish the job, how do you spread it across many without the coordination cost eating all your gains?
Here's the mental model to carry through every section. Training a model is a giant, repeated loop — read a batch, compute a forward pass, compute gradients backward, update the weights — and it runs on two finite resources: memory (does everything fit?) and time (will it finish this decade?). Distributed training exists because for large models you run out of both, and there is no single trick that fixes both cleanly. Every strategy you'll meet is really a different answer to "which resource am I short on, and what am I willing to pay to get more of it?" The price you pay is always the same currency: communication — GPUs talking to each other over a wire — and that bill is the villain of this entire chapter.
The common mistake beginners make is to reach for more GPUs the moment training feels slow. But "slow" and "too big" are different diseases with different cures. If the model fits and you just want it done sooner, you replicate it and split the work (that's data parallelism, coming in §3). If the model doesn't fit, adding replicas does nothing — you need to carve the model itself apart (sharding and model parallelism, §4–5). Diagnosing which wall you hit — memory or time — before you pick a tool is the single most valuable habit in this whole chapter, and it's why we open by naming both walls precisely.
Two independent walls push training onto many GPUs. The memory wall: a model's weights, its gradients, and the optimizer's state (Adam keeps two extra tensors per weight) must all live in GPU RAM at once — a 7B model in mixed precision needs far more than the 80 GB on a single H100. The time wall: even if it fit, one GPU doing every FLOP for trillions of tokens would take months. Distributed training attacks both — but every split adds a communication cost that becomes the real design constraint.
Read it top-down: the one goal at the top splits into the two independent questions you must ask before reaching for more GPUs.
- The left red box is the memory wall — the model's full training state (weights + gradients + optimizer, ~112 GB for 7B) is larger than a single 80 GB card. Adding replicas can't fix this.
- The right red box is the time wall — even if it fit, one GPU grinding through trillions of tokens would take months. This is the wall data parallelism attacks.
- The blue boxes below name what each wall is made of, and the bottom line is the catch: any split you make to clear a wall is paid for in communication.
In short: Ask "too big or too slow?" first — the memory wall and the time wall need different tools, and every fix costs you comms.
2 · Three ways to split the work essential
Imagine a restaurant kitchen swamped on a Friday night. There are three genuinely different ways to cope, and every distributed-training strategy is one of them in disguise. You can clone the whole kitchen — build a second identical kitchen next door and send half the orders there (that's data parallelism: more throughput, same menu). Or a single dish is so enormous — a wedding cake — that no one cook can carry it, so several cooks each take one tier and assemble at the end (that's tensor parallelism: one item split across hands). Or you keep a single line of cooks but give each a station — one chops, one fries, one plates — and dishes flow down the line (that's pipeline parallelism: split by stage). The kitchen never changes its recipes; it only changes how the work is carved up.
Now the precise version. There are exactly three orthogonal axes along which you can split training, and "orthogonal" is the load-bearing word: they're independent choices, so you can stack them. The one question that instantly tells you which axis you're looking at is "what got split?" Data parallel splits the batch (the data), keeping a full model copy per GPU. Tensor parallel splits a single layer's matrices, so every GPU holds a thin slice of the same layer. Pipeline parallel splits the stack of layers, so each GPU owns a contiguous block of the model's depth. Ask "what got split?" of any real system and you can immediately name its strategy — and because the three are orthogonal, a frontier training run typically uses all three at once, which the industry calls 3D parallelism.
The mistake to avoid: treating these as competitors where you pick "the best one." They solve different problems. Data parallelism is a speed tool and does nothing for a model that's too big; tensor and pipeline parallelism are fit tools that let an oversized model exist at all, but they add communication and complexity you don't want unless you're forced into them. Read the diagram and table below not as a ranking but as a menu keyed to your bottleneck — the right choice is dictated by what's broken, not by what sounds most powerful.
There are three orthogonal axes of parallelism. You can — and at scale you do — combine them ("3D parallelism").
This diagram lays out the three orthogonal ways to spread training across GPUs. They answer different questions — "how do I go faster?" vs "how do I fit a model that doesn't fit?" — and at large scale you combine all three.
- Data parallel (left) keeps a full copy of the model on every GPU and splits the batch — each GPU trains on different examples, then they average gradients. Great for speed; does nothing for a model that's too big.
- Tensor parallel (middle) splits a single layer's matrices across GPUs so one huge matmul is computed in slices. Very chatty, so it's kept inside one node.
- Pipeline parallel (right) splits the stack of layers across GPUs — GPU 0 runs the first layers, GPU 1 the next, and so on, like an assembly line.
In short: Ask of any strategy: what got split? Data parallel splits the batch; tensor parallel splits one layer; pipeline parallel splits the layers. That one question tells you which box you're in.
| Strategy | What is split | What each GPU holds | Main cost |
|---|---|---|---|
| Data parallel | the batch | a full copy of the model | all-reduce gradients each step |
| Tensor parallel | a single layer's matrices | a slice of every layer | all-reduce inside every layer |
| Pipeline parallel | the stack of layers | a contiguous set of layers | activations across stage boundaries + bubble |
Rule of thumb: data parallel when the model fits on one GPU and you just want to go faster; tensor and pipeline when a single copy no longer fits and you must carve the model itself across devices.
3 · Data parallelism & DDP advanced
Picture eight students grading the same enormous exam. To go faster, you split the stack of papers into eight piles, one per student, and everyone grades in parallel. But there's a catch that has nothing to do with grading: at the end, every student has only seen their pile, so before anyone can update the shared answer key they must pool what they learned and agree on one combined result. That pooling step — not the grading — is where the whole scheme lives or dies, and it's exactly the situation data parallelism creates. This is the most common way to train, and understanding why the syncing step dominates is the difference between an engineer who "uses 8 GPUs" and one who knows what those 8 GPUs are actually spending their time on.
Concretely: every GPU holds a complete copy of the model and processes a different shard of the batch. Because each copy saw different examples, after the backward pass they all hold different gradients — and if you let them each update independently, the eight copies would immediately drift apart and stop being the same model. So the gradients must be averaged into one shared result that every GPU applies identically, keeping all replicas in lockstep. That averaging is a collective operation called an all-reduce, and the phrase to burn in is "sum-then-share": it sums each gradient value across all GPUs and hands the identical total back to every one.
Why does all-reduce get its own callout and its own runnable lab below? Because it is the recurring cost of data parallelism — it happens every single step, forever, and its price is set by how fast your GPUs can talk, not by how fast they can compute. The counter-intuitive fact that trips people up: adding more GPUs barely changes how many bytes each one has to move for the all-reduce (the clever "ring" algorithm sees to that), so the communication cost is governed almost entirely by your model's gradient size and your interconnect bandwidth. That's the seed of this chapter's thesis — past a handful of GPUs, you stop optimizing compute and start optimizing the wire — and the lab makes the numbers concrete.
DistributedDataParallel (DDP) is the workhorse. Each GPU holds a full replica of the model and processes a different shard of the batch. After the backward pass, every replica has different gradients (it saw different data), so they must be averaged. That averaging is an all-reduce: a collective operation (via NVIDIA's NCCL library) that sums each gradient across all GPUs and hands the identical result back to every one, so all replicas step in lockstep and stay identical.
Follow the flow left-to-right: full model copies produce differing gradients, those funnel into one all-reduce, and one averaged gradient flows back out to all.
- The left blue column is the setup — each GPU holds a complete copy of the model and trains on a different shard of the batch.
- The amber boxes and dashed arrows are the problem: because each GPU saw different data, its gradient
g0/g1/g2differs. Left alone the copies would drift apart. - The purple all-reduce box sums those gradients and the green boxes are the payoff — every GPU receives the same averaged gradient
ḡ, so all replicas step identically and stay one model. This sum-then-share happens on every step, which is why it dominates the cost.
In short: DDP's whole trick is the all-reduce: differing gradients in, one identical average out, every step forever.
allreduce.pydef allreduce_bytes(param_count, bytes_per_grad=2, world_size=8):
"""Ring all-reduce moves ~2*(N-1)/N * gradient_size per GPU."""
grad_bytes = param_count * bytes_per_grad
factor = 2 * (world_size - 1) / world_size
per_gpu = grad_bytes * factor
return {
"grad_size_GB": round(grad_bytes / 1e9, 2),
"moved_per_gpu_GB": round(per_gpu / 1e9, 2),
"note": "approaches 2x grad size as world_size grows",
}
print(allreduce_bytes(7_000_000_000, world_size=8))
print(allreduce_bytes(7_000_000_000, world_size=64))
{'grad_size_GB': 14.0, 'moved_per_gpu_GB': 24.5, 'note': 'approaches 2x grad size as world_size grows'}
{'grad_size_GB': 14.0, 'moved_per_gpu_GB': 27.56, 'note': 'approaches 2x grad size as world_size grows'}
This models the traffic DDP generates every single step. After each backward pass the GPUs must agree on one averaged gradient, and this is how many bytes cross the wire to do it.
grad_bytes = param_count * bytes_per_gradis the size of the full gradient (2 bytes each in mixed precision) — for 7B params that's 14 GB.factor = 2 * (world_size - 1) / world_sizeis the ring all-reduce constant: each GPU sends and receives about this fraction of the gradient. As the GPU count grows it climbs toward 2×, never past it.- So the bytes moved per GPU are almost independent of how many GPUs you add — the cost is set by the model's gradient size and your interconnect speed.
What the output means: At 8 GPUs each moves ~24.5 GB; at 64 GPUs ~27.6 GB — barely more despite 8× the GPUs. That flatness is why all-reduce scales, and why the wire speed (not the GPU count) governs the per-step comms time.
Try this: Call it with bytes_per_grad=4 (fp32 gradients) and watch every number double — precision directly sets your communication bill.
4 · What DDP doesn't solve — ZeRO & FSDP advanced
Go back to the eight students, but change the problem. DDP made them grade faster — but what if the answer key itself is a thousand-page binder that no single student's desk can hold? Handing all eight students their own full copy doesn't help; the binder was the problem, and now you have eight copies of an unfittable binder. That is exactly DDP's blind spot: it hands every GPU a complete copy of the model, the gradients, and the optimizer's bookkeeping. If one copy of that pile doesn't fit on one GPU, DDP is useless no matter how many GPUs you add — it only ever multiplies replicas, it never makes one replica smaller.
The fix has an elegant name and a simple idea: ZeRO — the Zero Redundancy Optimizer, from Microsoft's DeepSpeed — and its PyTorch-native twin, FSDP (Fully Sharded Data Parallel). Instead of every GPU redundantly storing the whole binder, each GPU keeps only 1/N of it and borrows the missing pages from its neighbours only at the exact moment it needs them, then throws them away again. Back to the students: rather than eight thousand-page binders, each student keeps a hundred-and-twenty-five pages, and when the class hits a section someone else owns, that owner briefly reads their pages aloud. Nobody hoards the whole thing, so the memory each desk needs falls roughly in proportion to the number of desks.
The precise term for "keep only your slice" is sharding, and the crucial insight — the one the table and lab below drive home — is that ZeRO comes in stages that shard progressively more. ZeRO-1 shards only the optimizer state (the biggest hog, as §1 warned); ZeRO-2 adds the gradients; ZeRO-3 (identical in spirit to FSDP) shards the parameters too, so nothing is fully replicated. The trade you're making is explicit and worth saying out loud: you spend extra communication — gathering each layer's shards just in time, using them, releasing them — to buy an enormous memory saving. The common misconception is that sharding is "just a faster DDP"; it is not — it is a memory tool that costs you comms, and reaching for it when you only needed speed adds gather/scatter traffic you didn't have to pay. Read the table as a memory-vs-communication ladder, not a free lunch.
DDP makes you faster but not bigger: every GPU still stores a full copy of the parameters, gradients, and optimizer state. If one copy doesn't fit, DDP can't help. ZeRO (Zero Redundancy Optimizer, from Microsoft's DeepSpeed) and PyTorch's FSDP (Fully Sharded Data Parallel) fix this by sharding that state across the data-parallel GPUs instead of replicating it. Each GPU owns 1/N of the state and gathers the rest only when needed, then frees it.
Compare the two halves side by side — same three GPUs, same model; the only difference is how much of the training state each card must store.
- The left red row is DDP: every GPU holds the whole state
P·G·O(parameters, gradients, optimizer) — a full 112 GB copy that never shrinks no matter how many GPUs you add. - The right green row is ZeRO-3 / FSDP: each GPU keeps only
P/3 G/3 O/3— one slice — and gathers the missing pieces from neighbours only at the moment a layer needs them, then frees them. - The payoff line is the contrast in the labels: red stays pinned at 112 GB, green falls to ~1/N — which is exactly why oversized models train under sharding, not DDP.
In short: DDP hands every GPU the whole pile; sharding hands each GPU one slice and borrows the rest just in time — trading a little comms for a lot of memory.
| Stage | Shards across GPUs | Memory win |
|---|---|---|
| ZeRO-1 | optimizer state | ~4× on a typical Adam setup |
| ZeRO-2 | + gradients | ~8× |
| ZeRO-3 / FSDP | + parameters | ~N× (near-linear in GPU count) |
ZeRO-3 and FSDP are the same idea: fully shard params, grads, and optimizer state. During the forward/backward pass a layer's parameters are all-gathered just in time, used, then released — trading extra communication for a huge memory saving. This is what lets you train models far larger than any single GPU.
mem_shard.pydef memory_per_gpu(strategy, params, world_size,
bytes_param=2, bytes_grad=2, bytes_optim=12):
"""Per-GPU bytes for model state (params+grads+optimizer), excluding activations.
Mixed precision: 2B param + 2B grad + 12B optimizer (fp32 master + Adam m,v).
DDP replicates everything; ZeRO-3/FSDP shard all three across world_size GPUs."""
full = params * (bytes_param + bytes_grad + bytes_optim)
if strategy == "ddp":
per_gpu = full # every GPU holds a full copy
elif strategy == "zero3":
per_gpu = full / world_size # sharded across all GPUs
else:
raise ValueError(strategy)
return round(per_gpu / 1e9, 1) # GB
for n in (8, 64):
ddp = memory_per_gpu("ddp", 7_000_000_000, n)
z3 = memory_per_gpu("zero3", 7_000_000_000, n)
print(f"world_size={n:>3}: DDP={ddp} GB/gpu ZeRO-3={z3} GB/gpu")
world_size= 8: DDP=112.0 GB/gpu ZeRO-3=14.0 GB/gpu
world_size= 64: DDP=112.0 GB/gpu ZeRO-3=1.8 GB/gpu
This is the heart of the lesson: it computes how much training state each GPU must hold under plain DDP versus ZeRO-3/FSDP. Training state = parameters + gradients + the optimizer's extra tensors (Adam keeps a master copy and two moments).
full = params * (2 + 2 + 12)— 16 bytes per parameter of state. For 7B params that's 112 GB, more than any single GPU has.strategy == "ddp"returns the full amount on every GPU: DDP replicates all of it, so per-GPU memory never drops no matter how many GPUs you add.strategy == "zero3"divides byworld_size: ZeRO-3/FSDP shards the state so each GPU owns only 1/N of it.
What the output means: DDP stays pinned at 112 GB/GPU at both 8 and 64 GPUs — it will simply OOM. ZeRO-3 drops from 14 GB to 1.8 GB as you shard wider. This single contrast is why large-model training uses sharding, not DDP.
Try this: Bump params to 70B and re-run: DDP asks for over a terabyte per GPU (impossible), while ZeRO-3 at 64 GPUs still fits. That's the memory wall, and how sharding beats it.
5 · Tensor & pipeline parallelism expert
Here's the wall sharding alone can't climb. ZeRO/FSDP cleverly spreads the storage of the model across GPUs — but when a layer actually runs, its shards are gathered back onto one GPU and computed there. That's fine until a single layer is so large that even one layer's worth of matrix multiply won't fit in one GPU's memory or takes agonizingly long on one card. At the frontier — think the giant feed-forward and attention matrices inside a 100B+ model — you hit exactly this. When the unit of work itself is indivisible-by-storage-tricks, you have to split the computation, and that's what tensor and pipeline parallelism do. They're the top rung of the ladder precisely because they're the most invasive and the most communication-hungry.
Think of tensor parallelism as several accountants sharing one colossal spreadsheet formula. The formula is a single matrix multiply — say a [hidden × 4·hidden] weight in an MLP — too big for one accountant, so each takes a vertical band of columns, computes their partial answer, and then they must add up the partials to get the true result. That "add up the partials" is an all-reduce, and here's the sting: it happens inside every single layer, on every forward and backward pass. That is a torrent of communication, which is why tensor parallelism is kept within one node, where the ultra-fast NVLink fabric can absorb it — reach across slower node-to-node links with this and throughput collapses.
Pipeline parallelism is the assembly line from §2 made real: GPU 0 owns layers 0–7, GPU 1 owns layers 8–15, and a batch flows through the stages in sequence. It's far less chatty than tensor parallelism — you only pass activations at the seams between stages — but it has its own signature tax, the pipeline bubble. When work starts, stage 2 sits idle waiting for stage 1 to finish the first chunk; at the end, stage 1 is idle while the last chunk drains out the far end. That idle time is pure waste. The cure, and the thing the lab below quantifies, is to chop the batch into many small microbatches so the line stays full most of the time — but the bubble shrinks toward zero without ever quite reaching it. The mistake to avoid is running a deep pipeline with too few microbatches, which leaves half your expensive GPUs idle; the bubble formula tells you exactly how many you need.
Sharding state (ZeRO/FSDP) still runs a full copy of each layer's compute on one GPU. When a single layer is too big or too slow for that, you split the compute itself:
Tensor parallel splits an individual layer's matrix multiply across GPUs — e.g. each GPU computes a column-slice of a big [hidden × 4·hidden] MLP weight — then an all-reduce stitches the partial results back together within every layer. It's extremely communication-heavy, so it's kept inside one node where NVLink bandwidth is highest.
Pipeline parallel puts different layers on different GPUs (stage 1 = layers 0–7, stage 2 = layers 8–15, …). A microbatch flows through the stages like an assembly line. The catch is the pipeline bubble: while stage 1 works on the first microbatch, later stages sit idle. Splitting the batch into many microbatches shrinks the bubble but never fully removes it.
Two panels for two different compute splits. Read the left top-to-bottom (slices then a merge), and the right as a staggered timeline flowing right.
- Left — tensor parallel: the amber cells are column slices of a single layer's matrix, each on its own GPU; the arrow into the purple box is the all-reduce that stitches the partial results back together — and it fires inside every layer, so it's kept within one node on fast NVLink.
- Right — pipeline parallel: each row is a GPU owning a block of layers (stage 0/1/2). Blue cells are microbatches being worked on; the staggered layout shows work flowing down the line like an assembly line.
- The red cells are the payoff to notice — the bubble: stage 1 and 2 sit idle at the start waiting for work to reach them. Feeding more microbatches keeps the line full and shrinks that red idle time.
In short: Tensor parallel = slice one matmul + all-reduce every layer (chatty, in-node); pipeline parallel = stage the layers into an assembly line whose start-up bubble shrinks with more microbatches.
bubble.pydef bubble_fraction(stages, microbatches):
"""Idealized GPipe bubble: (stages - 1) / (microbatches + stages - 1).
More microbatches -> smaller idle fraction; more stages -> bigger bubble."""
return round((stages - 1) / (microbatches + stages - 1), 3)
for mb in (1, 4, 16, 64):
frac = bubble_fraction(stages=4, microbatches=mb)
print(f"4 stages, {mb:>2} microbatches -> {frac:.1%} of time idle in the bubble")
4 stages, 1 microbatches -> 75.0% of time idle in the bubble
4 stages, 4 microbatches -> 42.9% of time idle in the bubble
4 stages, 16 microbatches -> 15.8% of time idle in the bubble
4 stages, 64 microbatches -> 4.5% of time idle in the bubble
Pipeline parallelism has a built-in inefficiency called the bubble: while the first stage works on the very first microbatch, every later stage is sitting idle waiting for work to reach it. This computes how much of the time is wasted that way.
(stages - 1)is the number of steps it takes to fill (and drain) the pipeline — the idle stretch at the start and end.- Dividing by
(microbatches + stages - 1)spreads that fixed idle time over the whole run. More microbatches keep the pipeline full longer, so the idle fraction shrinks. - The loop sweeps 1 → 64 microbatches for a fixed 4 stages to show the effect.
What the output means: With 1 microbatch you waste 75% of the time; with 64 microbatches only 4.5%. The lesson: pipeline parallelism only pays off when you feed it many microbatches so the bubble becomes negligible.
Try this: Set stages=16 and re-run the sweep — a deeper pipeline needs even more microbatches to hide its (now larger) bubble.
6 · Gradient accumulation — a big batch on small memory expert
A recipe you found says "use a batch of 512," and your GPU OOMs at 32. This is one of the most common walls in practice, and it feels like a dead end: the training recipe that everyone reports good results with simply won't fit on your hardware. Beginners respond by shrinking the batch and quietly hoping it's fine — but batch size isn't a free knob. Small batches give noisier gradient estimates, and the loss curve you get can differ meaningfully from the recipe's. What you actually want is the statistics of a 512-batch with the memory footprint of a 32-batch. Gradient accumulation is the trick that gives you exactly that, and it's worth its own section because it's the everyday tool that lets modest hardware reproduce big-lab recipes.
The idea is almost embarrassingly simple once you see it. Remember the training loop: forward, backward (which adds gradients into each weight's .grad), then optimizer.step() to apply them and zero_grad() to wipe the slate. Normally you step after every batch. In gradient accumulation you run several small micro-batches and let their gradients pile up — you deliberately skip the step and the zeroing — then step just once after K micro-batches. Because gradients sum, the accumulated gradient over K micro-batches of size m is mathematically the same as one gradient computed on a batch of K×m. Think of it as filling a bucket one cup at a time instead of needing a bucket-sized tap: the bucket ends up equally full, you just took more trips.
So the effective batch size — the number the loss curve actually "sees" — is micro_batch × accumulation_steps × data_parallel_world_size, and the lab below makes that arithmetic concrete, including the LLM-relevant conversion to tokens per step. The trade, and the misconception to guard against, is spelled out in the callout after: accumulation buys memory with time, not for free. K micro-batches mean K forward/backward passes per optimizer update, so each step takes K× longer — you haven't dodged the work, you've just fit it through a narrower memory pipe. That's usually a trade worth making when the alternative is not being able to run the recipe at all.
Large batches stabilize training, but a big batch means big activations, which means out-of-memory. Gradient accumulation fakes a large batch on small memory: run several small micro-batches, sum their gradients without stepping the optimizer, then step once after K micro-batches. The effective batch size is micro_batch × accumulation_steps × data_parallel_world_size — you trade wall-clock time for memory headroom.
optimizer.step() (green) then applies a gradient equal to a K×-larger batch — at the memory of one micro-batch.
Read it left-to-right: several small micro-batches feed one shared gradient buffer, which finally drives a single optimizer step.
- The blue boxes are K small micro-batches run one after another — small enough to fit memory. Crucially, after each you skip the optimizer step and the
zero_grad(). - The amber buffer is where their gradients pile up: because gradients sum, K micro-batches of size
maccumulate into the same total as one batch ofK×m. - The green box is the payoff — one
optimizer.step()applies that accumulated gradient, so the loss curve sees a large effective batch while peak memory stayed at one micro-batch. The cost is the bottom line: K× the wall-clock.
In short: Sum K small gradients, step once — a big-batch update at small-batch memory, paid for in time.
grad_accum.pydef effective_batch(micro_batch, accum_steps, world_size, seq_len=None):
"""Effective batch = per-GPU micro-batch * accumulation steps * # data-parallel GPUs.
accum_steps lets you hit a target batch without more memory (just more steps)."""
eff = micro_batch * accum_steps * world_size
result = {"effective_batch": eff, "optimizer_steps_per_1M_samples": round(1_000_000 / eff)}
if seq_len:
result["effective_tokens_per_step"] = eff * seq_len
return result
print(effective_batch(micro_batch=2, accum_steps=16, world_size=8))
print(effective_batch(micro_batch=1, accum_steps=32, world_size=64, seq_len=4096))
{'effective_batch': 256, 'optimizer_steps_per_1M_samples': 3906}
{'effective_batch': 2048, 'optimizer_steps_per_1M_samples': 488, 'effective_tokens_per_step': 8388608}
Big batches train more stably, but a big batch means big activations and out-of-memory. Gradient accumulation fakes a large batch on small memory by summing gradients over several small micro-batches before stepping the optimizer once. This computes the batch you effectively trained on.
eff = micro_batch * accum_steps * world_size— the true batch is the per-GPU micro-batch, times how many you accumulate, times how many data-parallel GPUs are all contributing.optimizer_steps_per_1M_samplesshows the flip side: a bigger effective batch means fewer optimizer steps to cover the same data.- The optional
seq_lenconverts the sample batch into tokens per step — the number that actually matters for LLM training recipes.
What the output means: A tiny per-GPU micro-batch of 2, accumulated 16× over 8 GPUs, trains as if the batch were 256 — with the peak memory of just 2. The second call reaches a 2048-sample / ~8.4M-token effective batch on 64 GPUs.
Try this: Halve micro_batch to survive an OOM, then double accum_steps to keep the same effective batch — same recipe, less peak memory, slightly slower.
7 · Communication is the bottleneck expert
This is the war story every scaling team eventually tells. A team buys twice the GPUs expecting roughly twice the training speed, and instead throughput barely moves — sometimes it gets worse. The GPUs are pristine and idle-hot, sitting there waiting. What happened is the theme this whole chapter has been building toward: past a certain point, your training is no longer limited by how fast your GPUs can compute, but by how fast they can talk to each other. If you internalize one idea from this page, make it this one — it reframes every decision, from which cloud instance to rent to how often you sync.
Here's the tension, made mechanical. Split a fixed amount of compute across more GPUs and each GPU's compute time falls — that's the win you paid for. But the communication per step (the all-reduce from §3) does not fall correspondingly; it's set by your gradient size and your wire speed, and it stays stubbornly flat as you add GPUs. So step time is a race between two clocks: compute, which shrinks, and comms, which doesn't. Whichever is larger is your bound. Early on compute dominates and everything scales beautifully; eventually the two clocks cross, and after that every extra GPU is paying bandwidth for compute savings you no longer need — you're comms-bound.
The lever that decides where that crossover lands is the interconnect — the physical fabric the GPUs communicate over — and the gap between options is enormous, not marginal. NVLink inside a single node runs at hundreds of GB/s and can hide the all-reduce entirely behind compute; commodity Ethernet across nodes can be tens of times slower and turns the very same job comms-bound. The lab below runs one identical job — same model, same GPUs, same 64-way DDP — and changes only the wire, and watches the verdict flip from healthy to hopeless. That's the honest lesson for a systems engineer: at scale you engineer the network as deliberately as the GPUs, you keep the chattiest parallelism (tensor) inside the fast fabric, and you overlap comms with compute wherever the framework lets you. Buying faster GPUs for a comms-bound job is the classic expensive mistake.
Compute per GPU stays roughly fixed as you add GPUs, but communication grows and eventually dominates. Whether your training is compute-bound or comms-bound depends on the ratio of FLOPs done to bytes moved, divided by your interconnect bandwidth. This is why the interconnect (NVLink at ~900 GB/s intra-node vs InfiniBand or, worse, Ethernet across nodes) often decides your real throughput.
Scan left-to-right as you add GPUs; at each point the taller of the two curves is what bounds your step time.
- The horizontal axis is GPU count; the vertical axis is time per training step. The blue curve is per-GPU compute, the amber line is per-step communication (the all-reduce).
- The blue curve falls because splitting fixed compute across more GPUs shrinks each GPU's share. The amber line stays flat because all-reduce bytes barely change with GPU count — it's set by gradient size and wire speed.
- The red crossover is the payoff point: left of it compute is larger (healthy, compute-bound); right of it comms is larger (comms-bound), so every extra GPU buys compute savings you no longer need while paying the same bandwidth bill.
In short: Compute shrinks with GPUs, comms doesn't — past the crossover you're paying the wire, not the chips, and faster GPUs won't help.
comms_cost.pydef comms_cost(params, world_size, tflops_per_gpu, interconnect_GBps,
tokens_per_step=8192, bytes_per_grad=2, flops_per_token_per_param=6):
"""Compare per-step compute time vs all-reduce comms time.
compute ~ 6 * params FLOPs per token (fwd+bwd) over the step's tokens;
comms ~ 2x grad bytes over the wire, once per optimizer step."""
compute_flops = flops_per_token_per_param * params * tokens_per_step
compute_s = compute_flops / (tflops_per_gpu * 1e12)
grad_bytes = params * bytes_per_grad
moved = grad_bytes * 2 * (world_size - 1) / world_size
comms_s = moved / (interconnect_GBps * 1e9)
bound = "comms-bound" if comms_s > compute_s else "compute-bound"
return {"compute_s": round(compute_s, 3), "comms_s": round(comms_s, 3),
"verdict": bound}
# Same job, fast NVLink vs slow Ethernet:
print(comms_cost(7_000_000_000, 64, tflops_per_gpu=300, interconnect_GBps=600))
print(comms_cost(7_000_000_000, 64, tflops_per_gpu=300, interconnect_GBps=12.5))
{'compute_s': 1.147, 'comms_s': 0.046, 'verdict': 'compute-bound'}
{'compute_s': 1.147, 'comms_s': 2.205, 'verdict': 'comms-bound'}
This is the punchline of the whole lesson: it puts per-step compute time next to per-step communication time and declares which one you're bound by. Change only the interconnect and watch the verdict flip.
compute_flops = 6 * params * tokens_per_stepuses the standard estimate of ~6 FLOPs per parameter per token (forward + backward); dividing by the GPU's TFLOP/s gives the compute seconds.moved = grad_bytes * 2 * (world_size-1)/world_sizeis the all-reduce traffic from the earlier lab; dividing by the interconnect GB/s gives the comms seconds.verdictis simply whichever is larger. The two calls use an identical job — same GPUs, same model, same 64-way DDP — and change only the wire.
What the output means: On NVLink (600 GB/s) comms is 0.046 s against 1.147 s of compute — compute-bound, the healthy case. On 10 GbE (12.5 GB/s) comms balloons to 2.2 s and dominates — comms-bound. Same GPUs; only the wire changed.
Try this: Sweep interconnect_GBps downward and find the value where compute and comms are equal — that's your crossover. Below it, faster GPUs won't help; you need a faster network or fewer syncs.
8 · Choosing a strategy by scale tech-lead
Here's the failure mode that separates a junior from a lead. Handed a training project, the eager engineer reaches straight for the most impressive-sounding stack — "we'll do 3D parallelism with tensor and pipeline and FSDP" — for a 3B model that would have trained perfectly happily under plain DDP on eight cards. Now they've signed up for a mountain of communication overhead, brittle launch scripts, and days of debugging pipeline bubbles, all to solve a problem they never had. The skill this section teaches is the opposite instinct: pick the cheapest strategy that fits, and climb the ladder only when a concrete wall physically forces you up a rung.
Everything from this chapter collapses into a short decision procedure driven by two questions, asked in order. First: does one full copy of the model plus its training state fit on one GPU? If yes, you only have a speed problem — replicate and split the batch with DDP, the simplest and least chatty option. If the state doesn't fit but the model's weights still do, shard the state with ZeRO-3 / FSDP: you've traded some communication for the memory to exist. Second question, only if you're still stuck: does a single layer fit on one GPU? If not, you must split the compute — tensor parallelism inside a node, and once you're spanning many nodes at giant scale, you combine all three axes into 3D parallelism. Each rung up adds communication and operational cost, so each rung must be earned by a wall you actually hit.
The reason to encode this as an explicit ladder — which the lab below does in code — is that it turns an intimidating "what parallelism should we use?" into a sequence of honest yes/no questions about what fits where, each answerable with the memory arithmetic from §1 and §4. That's also why a real lead never picks a rung by reputation: they cite the specific GB-per-GPU number that justifies it. "We need FSDP because DDP asks for 112 GB/GPU and our cards have 80" is an argument; "FSDP is what the big labs use" is not. Start low, measure GPU utilization and throughput, and escalate only when the numbers demand it.
A lead picks the cheapest strategy that fits, and escalates only when a wall forces it. The decision is driven by whether one copy of the model fits on one GPU, and then by how many GPUs/nodes you have.
Read it as a decision ladder, top to bottom: at each purple question a yes branches left to a strategy, a no drops you to the next, harder question.
- First question — does the full training state fit one GPU? Yes → DDP (green, simplest, least chatty). This is where most jobs stop.
- Second — if the state doesn't fit but a single layer still does → ZeRO-3 / FSDP shards the state. Third — if even a layer is too big and you're on one node → tensor parallel inside the node (amber).
- The bottom red box is the last resort — spanning many nodes at giant scale you combine all three into 3D parallelism. Each downward step adds comms and ops cost, so each must be earned by a concrete wall.
In short: Take the first "yes" — start at DDP and climb a rung only when the GB-per-GPU number proves the current rung won't fit.
choose.pydef choose_strategy(model_fits_one_gpu, state_fits_one_gpu,
layer_fits_one_gpu, multi_node):
"""Escalate only as far as the memory walls force you."""
if model_fits_one_gpu and state_fits_one_gpu:
return "DDP (replicate; all-reduce grads) — simplest, just go faster"
if not state_fits_one_gpu and layer_fits_one_gpu:
return "ZeRO-3 / FSDP (shard params+grads+optim) — bigger than one GPU's memory"
if not layer_fits_one_gpu and not multi_node:
return "Tensor parallel within the node (+ FSDP) — a single layer is too big"
return "3D parallelism: tensor (in-node) + pipeline (across nodes) + data/FSDP"
print(choose_strategy(True, True, True, False)) # small model -> DDP
print(choose_strategy(True, False, True, False)) # state too big -> FSDP/ZeRO-3
print(choose_strategy(False, False, False, True)) # giant -> 3D parallelism
DDP (replicate; all-reduce grads) — simplest, just go faster
ZeRO-3 / FSDP (shard params+grads+optim) — bigger than one GPU's memory
3D parallelism: tensor (in-node) + pipeline (across nodes) + data/FSDP
This encodes the tech-lead decision as a ladder: start with the simplest strategy that fits, and climb a rung only when a concrete memory wall forces you to. Each argument is an honest yes/no about what fits where.
- If both the model and its full training state fit on one GPU, you only need speed → DDP. Simplest, least communication.
- If the state no longer fits but a single layer still does → ZeRO-3 / FSDP shards params, grads, and optimizer state across GPUs.
- If a single layer is too big → tensor parallel inside a node; and once you span multiple nodes at giant scale, you combine all three into 3D parallelism.
What the output means: The three calls walk up the ladder: small model → DDP; oversized state → FSDP/ZeRO-3; giant multi-node model → 3D parallelism. The rule is to escalate only as far as a wall actually pushes you.
Try this: Flip state_fits_one_gpu to False for your own model and confirm the recommendation jumps to FSDP — then justify it with the exact GB/GPU number from mem_shard.py.
The real PyTorch below shows the two most common rungs — DDP and FSDP — in the shape you'd actually launch with torchrun. It needs real hardware to run; read it for the structure, not to execute it here.
train.py# needs multi-GPU + PyTorch: launch with `torchrun --nproc_per_node=8 train.py`
# NOT runnable on a laptop; shown for structure. Requires: pip install torch
import os, torch, torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
def setup():
dist.init_process_group("nccl") # NCCL = GPU collectives (all-reduce)
local_rank = int(os.environ["LOCAL_RANK"])
torch.cuda.set_device(local_rank)
return local_rank
def wrap(model, local_rank, shard=False):
model = model.to(local_rank)
if shard:
# FSDP == ZeRO-3: shard params+grads+optimizer across the data-parallel group
return FSDP(model, device_id=local_rank)
# DDP: full replica per GPU; gradients all-reduced automatically in backward()
return DDP(model, device_ids=[local_rank])
# training step is identical either way — the wrapper handles the comms:
# loss = model(batch).loss; loss.backward() # <- all-reduce / reduce-scatter here
# optimizer.step(); optimizer.zero_grad()
# Use accumulation to grow the effective batch without more memory:
# if (step + 1) % accum_steps == 0: optimizer.step(); optimizer.zero_grad()
Your 7B model trains fine on one GPU but you have 8 and want it 8× faster. Someone proposes ZeRO-3. Is that the right first move, and what does it change vs DDP?
Show answer
You move a DDP job from a single 8-GPU node (NVLink) to 8 nodes over Ethernet and throughput collapses even though total GPU count is unchanged. Why?
Show answer
comms_cost.py. The fix is a faster interconnect (InfiniBand), keeping chatty parallelism in-node, gradient accumulation to reduce sync frequency, and overlapping comms with compute.Exercise MS2.1 — Size the state and pick a strategy
Context: Choosing a parallelism strategy is a memory calculation first: you size the per-GPU state under each option, then let the number that fits pick the rung.
Your task: For a 13B model, compute per-GPU training state under DDP vs ZeRO-3 for world sizes 8 and 64, run the strategy chooser, and justify a starting rung with a specific memory number.
Requirements:
- Compute per-GPU state under both DDP (replicated) and ZeRO-3 (sharded 1/G)
- Do it for both world sizes 8 and 64
- Run the chooser with honest answers about what fits
- Name the rung you would start on and cite the memory number that justifies it
💡 Hint: The DDP-vs-ZeRO gap is roughly a factor of the world size on the replicated state; that gap is your argument.
Exercise MS2.2 — Find your comms wall
Context: Every distributed job has a bandwidth below which it flips from compute-bound to comms-bound, and finding that wall is what tells you whether the interconnect, not the GPU, is your limit.
Your task: Holding the GPU and a 64-way DDP job fixed, sweep the interconnect from NVLink (600 GB/s) down to 10 GbE (12.5 GB/s) to find where the job becomes comms-bound, then argue how accumulation pushes that crossover back.
Requirements:
- Sweep interconnect bandwidth with GPU throughput and world size held fixed
- Identify the bandwidth where comms time overtakes compute time
- Explain that syncing less often (accumulation) reduces all-reduce frequency
- State how that moves the compute-bound/comms-bound crossover
💡 Hint: Accumulation trades sync frequency for step count; fewer all-reduces per optimizer step buys back headroom on a slow link.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Distributed training offers three orthogonal ways to split the work, and the first skill is matching a bottleneck to the axis that actually relieves it.
Your task: Write a classifier that maps a training bottleneck to the right split: data parallel (replicas), pipeline parallel (split by layer), or tensor parallel (split a layer's tensors).
Requirements:
- Batch too slow but the model fits one GPU → data parallel (DDP)
- Model too big by layers → pipeline parallel
- A single layer too big → tensor parallel
- Note that the two model splits exist only because something no longer fits one card
💡 Hint: Data parallelism is a throughput fix; the model splits are fit fixes — the constraint tells you which.
Show solution
Match the constraint to the split. Runnable:
def which_split(problem):
table = {
"batch too slow, model fits one GPU": "data parallel (DDP)",
"model too big for one GPU by layers": "pipeline parallel",
"a single layer too big for one GPU": "tensor parallel",
}
return table.get(problem, "profile first -- unknown bottleneck")
for p in ["batch too slow, model fits one GPU",
"a single layer too big for one GPU"]:
print(p, "->", which_split(p))
Data parallelism speeds up throughput when the model fits; the two model splits exist only because a model (or a layer) is too big for one card.
Context: DDP averages gradients with an all-reduce every step, and the volume moved — not the GPU count — is what interconnect bandwidth has to carry.
Your task: Model the per-GPU ring all-reduce volume as ≈ 2·(G−1)/G · grad_bytes and compute it for a 7B bf16 model across 8 GPUs.
Requirements:
- Gradient buffer is one value per parameter (× bytes-per-param)
- Per-GPU ring all-reduce moves ~2× the buffer, nearly independent of G
- Show the volume approaching 2× as G grows
- Conclude that interconnect bandwidth, not GPU count, sets step time
💡 Hint: The (G−1)/G factor saturates toward 1, which is exactly why ring all-reduce scales.
Show solution
The gradient buffer is one value per parameter; ring all-reduce is ~2× the buffer regardless of G (the (G-1)/G factor). Runnable:
def allreduce_gib(params_b, G, bytes_per=2):
grad_bytes = params_b * 1e9 * bytes_per
moved = 2 * (G - 1) / G * grad_bytes # per-GPU ring all-reduce volume
return moved / (1024 ** 3)
for G in (2, 8, 64):
print(f"G={G:>2}: per-GPU all-reduce ~= {allreduce_gib(7, G):6.2f} GiB / step")
# approaches 2x the gradient buffer (~13 GiB) as G grows -- ~26 GiB moved per step
The volume is nearly independent of G — which is exactly why ring all-reduce scales, and why interconnect bandwidth (not GPU count) sets the step time.
Context: DDP replicates the full optimizer state on every GPU; ZeRO/FSDP shards it, and that ~1/G cut is the reason big models fit at all.
Your task: Model the per-GPU memory saving of FSDP over DDP for a 7B Adam job on 8 GPUs, where replicated state becomes ~1/G under sharding.
Requirements:
- Per-param training bytes = weight + master + grad + two Adam moments
- DDP holds the full state on each GPU; FSDP holds ~1/G of it
- Report DDP per-GPU, FSDP per-GPU, and the saving
- Note FSDP pays extra comms (gather shards before each layer) for the memory cut
💡 Hint: Divide the replicated total by G for the sharded case; the trade is memory for communication.
Show solution
Adam state (18 B/param here) is replicated under DDP but sharded 1/G under FSDP. Runnable:
def per_gpu_gib(params_b, G, sharded):
bytes_per = 2 + 4 + 4 + 4 + 4 # weight + master + grad + m + v (fp32 heavy)
total = params_b * 1e9 * bytes_per / (1024 ** 3)
return total / G if sharded else total
p, G = 7, 8
ddp = per_gpu_gib(p, G, sharded=False)
fsdp = per_gpu_gib(p, G, sharded=True)
print(f"DDP per-GPU: {ddp:6.1f} GiB (replicated)")
print(f"FSDP per-GPU: {fsdp:6.1f} GiB (sharded 1/{G})")
print(f"saving: {ddp - fsdp:6.1f} GiB per GPU")
FSDP trades extra communication (gather shards before each layer) for a ~1/G cut in per-GPU state — the reason big models train at all.
Context: When your target batch does not fit in memory, gradient accumulation buys the big batch with time instead of memory by stepping the optimizer only every K micro-batches.
Your task: Compute the accumulation steps K needed to reach an effective batch of 512 from a micro-batch of 16, and show the effective batch is preserved across the GPUs.
Requirements:
- effective =
micro_batch × accum_steps × num_gpus - Solve for
accum_steps, rounding up when it does not divide evenly - Verify the achieved effective batch matches the target
- Note each step now costs K forward/backward passes
💡 Hint: It is one division; the insight is that the loss curve matches a true large batch at a fraction of the peak memory.
Show solution
effective = micro_batch × accum_steps × num_gpus. Solve for accum_steps. Runnable:
import math
def accum_steps(effective_batch, micro_batch, num_gpus):
per_step = micro_batch * num_gpus
if effective_batch % per_step:
# round up; effective batch will be the next multiple
return math.ceil(effective_batch / per_step)
return effective_batch // per_step
K = accum_steps(effective_batch=512, micro_batch=16, num_gpus=4)
achieved = 16 * K * 4
print(f"accum_steps K = {K}, achieved effective batch = {achieved}")
# 16 * 8 * 4 = 512 -- same optimizer step as a 512 batch, at 1/32 the peak memory
Accumulation buys batch size with time instead of memory: the loss curve matches a true large batch, but each step takes K forward/backward passes.
Context: Step time is compute plus communication, and as you add GPUs the compute per GPU falls while comms does not — so there is a crossover past which more GPUs make each step slower.
Your task: Model step time as compute (work/G) plus bandwidth-bound all-reduce time, and sweep G to find the point where comms overtakes compute.
Requirements:
- Compute per GPU scales as
work / G - Comms is the ring all-reduce volume divided by interconnect bandwidth
- Sweep G and flag the first G where comms > compute
- Explain that beyond the knee you pay bandwidth for compute you no longer need
💡 Hint: The crossover G is where you switch strategy; the all-reduce volume barely shrinks while compute keeps dropping.
Show solution
Model compute/GPU as work/G and comms as a bandwidth-bound constant that grows slowly; find where they cross. Runnable:
def step_ms(G, work_ms=800.0, grad_gib=13.0, bw_gib_s=300.0):
compute = work_ms / G # perfect compute scaling
moved = 2 * (G - 1) / G * grad_gib # ring all-reduce volume (GiB)
comms = moved / bw_gib_s * 1000 # ms at bw_gib_s GiB/s
return compute, comms, compute + comms
print(f"{'G':>3} {'compute':>9} {'comms':>8} {'step(ms)':>9}")
for G in (1, 2, 4, 8, 16, 32, 64):
c, m, tot = step_ms(G)
flag = " <- comms-bound" if m > c else ""
print(f"{G:>3} {c:9.1f} {m:8.1f} {tot:9.1f}{flag}")
Past the crossover, adding GPUs makes each step slower: you are paying bandwidth for compute you no longer need. That knee is where you switch strategy.
Context: Leading training across scales means encoding a decision table: models that fit one card go DDP, larger ones shard with FSDP, and the truly enormous need 3D parallelism — with a comms-risk flag as the cluster grows.
Your task: Build a planner that, given model size and GPU count, picks DDP / FSDP / 3D parallel and flags the communication risk, for cases from 1B on 8 GPUs to 400B on 1024 GPUs.
Requirements:
- If the full Adam state fits one card → DDP (replicate)
- Else if bf16 weights fit one card → FSDP/ZeRO (shard state)
- Else → 3D parallel (tensor + pipeline + data)
- Raise comms risk as GPU count grows and note a real profiler pass is still required (needs GPU)
💡 Hint: The thresholds are the serve/train footprints from ms1; the planner picks the axis but topology still decides the real knee.
Show solution
The lesson's scale ladder, as a planner. Runnable:
def strategy(model_b, gpus, card_gib=80):
serve = model_b * 1e9 * 2 / (1024 ** 3) # bf16 weights
train = model_b * 1e9 * 18 / (1024 ** 3) # Adam, per-GPU if replicated
if train <= card_gib * 0.9:
s = "DDP (replicate; simplest, fastest per step)"
elif serve <= card_gib * 0.9:
s = "FSDP/ZeRO (shard state; model weights still fit one card)"
else:
s = "3D parallel: tensor + pipeline + data (weights exceed one card)"
risk = "HIGH" if gpus >= 64 else "moderate"
return f"{model_b}B on {gpus} GPUs -> {s} | comms risk: {risk}"
for m, g in [(1, 8), (13, 32), (70, 128), (400, 1024)]:
print(strategy(m, g))
# real runs verify with a profiler (torch profiler / nsys) -- needs GPU
The planner picks the axis; the real cluster still needs a profiler pass to confirm the comms knee (needs GPU), because interconnect topology, not the model, often decides.
✓ Checkpoint — you can move on when you can…
- Explain the memory wall and the time wall that force multi-GPU training.
- Distinguish data / tensor / pipeline parallelism and what each splits.
- Explain DDP's all-reduce and the memory problem it does not solve.
- Explain how ZeRO-3 / FSDP shard state and why it scales with GPU count.
- Compute an effective batch with gradient accumulation.
- Reason about when a job is compute-bound vs comms-bound and pick a strategy.