Distributed training
Training something too big for one GPU.
A GPU has a fixed amount of memory, and a large model plus everything training needs beside it does not fit in one. Distributed training spreads the work across many GPUs, often across many machines. There are two basic moves: give every GPU a complete copy of the model and a different slice of the data, then average what each of them learned; or cut the model itself into pieces so that each GPU holds only part of it.
Without it the largest model you can train is whatever fits on one card, which today is small. The hard part is not the splitting but the talking: the GPUs must exchange updates constantly, and that traffic, rather than the arithmetic, is usually what caps how fast a run goes. Long runs also fail. A job spending three weeks across a thousand GPUs will lose a machine partway, so checkpointing and restarting is part of the design rather than an afterthought.
It sits on top of PyTorch, whose FSDP and the DeepSpeed library are how it is usually done, and on GPU fundamentals, which explain the memory ceiling that makes it necessary. Pretraining is its main consumer. It is squarely an infrastructure topic: nothing in applied AI engineering requires it, so it is worth knowing which kind of role you are aiming at before spending weeks here.
Splitting the work is the easy half; the cost lives in the communication between the pieces.
In more detailOnce a model or batch exceeds one device, training splits across many: data parallelism replicates the model and shards the batch, while tensor and pipeline parallelism shard the model itself. The hard parts are communication overhead, memory accounting, and recovering from a failure partway through a long run.
Learn first
Training something too big for one GPU
By the end: You can explain the parallelism strategies and what limits each.
- Why one device runs out
- Data, tensor and pipeline parallelism
- Communication as the bottleneck
- Failure over a long run
At some point the model, its gradients and its optimiser state stop fitting on one device, and training becomes a distributed systems problem. The mistake that costs the most is reaching for the most sophisticated answer first. There is an order to try things in, each step is more complicated and slower per device than the last, and most people never need to go past the second one. This section gives that order, the three ways of splitting work and what each costs, and the failure modes that only appear once a run lasts days.
Why one device runs out#
Training needs far more memory than inference, and the weights are the smallest part of it. Every parameter carries a gradient, and an optimiser like Adam carries two more numbers per parameter plus a full-precision master copy. Mixed-precision Adam works out at sixteen bytes per parameter, so a seven-billion-parameter model needs about 104 GiB before storing a single activation — more than an 80 GiB card. The arithmetic is worked in full in the numbers section of this topic; the consequence is that the parameter count alone never tells you whether something fits.
what you have what to do
fits on one GPU one GPU. stop here.
fits, but the batch is too small gradient accumulation
model fits, optimiser state does not ZeRO stage 1 or 2 / FSDP
model does not fit ZeRO stage 3 / FSDP full shard
one layer does not fit tensor parallelism
too many layers for the interconnect pipeline parallelismGradient accumulation is the step people skip
If the problem is that the batch is too small rather than that the model does not fit, you do not need more devices at all. Run four micro-batches, add their gradients, and step once: the optimiser sees the large batch and the memory only ever holds one micro-batch of activations. It costs nothing but wall-clock, needs no extra hardware, and it also reduces communication in a multi-GPU run, because the gradient sync happens once per accumulation cycle rather than once per micro-batch.
Three ways to split#
The three parallelisms split different things, and the distinction that matters is what each one has to send across the wire and how often.
what is split what crosses the wire when
data parallel the batch gradients, once per step
tensor parallel each layer's maths activations, twice per layer
pipeline parallel the layers activations at boundaries per stageData parallelism gives every device a full copy of the model and a different slice of the batch, then averages the gradients. It is the simplest and the only one that does not change the model code. Its limit is memory: every device holds everything, so it cannot help with a model that does not fit. Sharded data parallelism — FSDP, or ZeRO — keeps the same programming model and splits the optimiser state, gradients and optionally the weights across devices, gathering each piece back only while it is needed. That is usually the whole answer, and it is covered in the next section.
Tensor parallelism splits the arithmetic inside a layer: each device holds part of a weight matrix and computes part of the result, and the parts are combined before the layer finishes. It is what you use when a single layer is too large for one device. Because it communicates inside every layer rather than once per step, it is almost always confined to the devices inside one machine, on NVLink. Pipeline parallelism gives each device a contiguous block of layers and passes activations along the chain, which communicates little but introduces the bubble: while device one works on the first micro-batch, the others have nothing to do. Splitting the batch into many micro-batches shrinks the bubble without removing it.
Large training runs combine all three, which is where the phrase 3D parallelism comes from: tensor parallel within a machine, pipeline parallel across a few machines, data parallel across the rest. You do not need to configure that to be hired, but you should be able to say why each sits at the level it does — fastest links for the chattiest parallelism.
Communication is the limit#
The reason the ordering above is not arbitrary is that every step past the first buys memory with bandwidth. Whether that trade is affordable is arithmetic, not judgement: a seven-billion-parameter gradient sync is about 27 ms over NVLink and about 19.6 seconds over 10 Gb Ethernet, which is 6 per cent of a training step against 98 per cent of it. Same model, same eight GPUs, and the interconnect alone decides whether the cluster is useful. Doing that calculation before provisioning is the single highest-value habit in this topic, and the next section but one works it in full.
Scaling efficiency is the number to report
Eight GPUs almost never give eight times the throughput. Measure samples per second on one device, then on all of them, and divide by the ideal: above 90 per cent is healthy, 70 per cent means something is worth investigating, and below 50 per cent means you are paying for hardware that is waiting. This is the first question an interviewer asks about a multi-GPU claim, and 'we used eight GPUs' without it is not an answer.
Long runs fail#
A run measured in days meets failures a run measured in minutes never does, and none of them are exotic: a node dies, a network partition hangs a collective, a disk fills with checkpoints, a GPU develops memory errors. The distributed part makes each one worse, because collectives are synchronous — one slow or dead device stalls all of them, and the symptom is a job that is not crashed and not progressing.
- Checkpoint on time, not on epochs, and keep the optimiser state: a weights-only checkpoint cannot resume Adam correctly.
- Make resuming the normal path and test it on day one, including the data loader position, or a resumed run silently retrains on the same examples.
- Log throughput per step, not just loss. A straggler shows up as throughput decay long before anything fails.
- Set a timeout on collectives. The default in some stacks is half an hour of silence before anything is reported.
- Keep the last few checkpoints rather than only the newest, because a loss spike is usually discovered after the checkpoint that contains it.
- Record the seed, the data order and the exact configuration with the checkpoint. A run you cannot reproduce is a run you cannot debug.
The loss spike deserves its own note, because it is the characteristic failure of a long run and the response is standard: when the loss jumps and does not recover, roll back to the last good checkpoint, skip or reshuffle the batches around the spike, and lower the learning rate for a while. That sequence is what large training reports describe, and being able to say it is what the question is testing.
Where people go wrong
Assuming more GPUs means proportionally faster training. Communication overhead grows with the number of devices, so scaling efficiency falls and past some point adding hardware buys very little.
Build this: Train a small model on two GPUs with data parallelism and measure the speedup against one. It will be less than two, and understanding why is the point of the exercise.
Open this section on its own pageData parallel training in PyTorch
By the end: You can turn a single-GPU training script into a multi-GPU one with DDP, launch it, and measure whether it actually scaled.
- Processes, ranks and torchrun
- Wrapping the model
- Splitting the data
- Saving from one process
- Gradient accumulation without extra syncs
- Measuring scaling efficiency
Data parallelism is the simplest way to use more than one GPU: every device gets a complete copy of the model and a different slice of each batch, computes gradients on its slice, and the gradients are averaged so all the copies stay identical. PyTorch's implementation is DistributedDataParallel, DDP for short. Turning a single-GPU script into a multi-GPU one with it is about twenty lines, and four of them are the ones people get wrong. This section is the whole conversion: how the processes are started, what wraps the model, how the data is split so two devices do not train on the same examples, how to save without writing the same file twice, how to accumulate gradients without paying for a sync you do not need, and how to tell whether any of it actually made training faster. Every code block here was run on this machine with two processes on the gloo backend, which is the same API you use on GPUs with nccl.
Processes, ranks and torchrun#
DDP is not threads. Each GPU gets its own operating-system process running the same script, and they find each other through a rendezvous that torchrun sets up. Rank is the global index of a process, local rank is its index within one machine and is what you use to pick a device, and world size is the total. You do not set these yourself; torchrun puts them in the environment and the process group reads them.
torchrun --nproc_per_node=4 train.py # 4 processes on this machine
torchrun --nnodes=2 --nproc_per_node=8 \
--rdzv_backend=c10d --rdzv_endpoint=host:29500 train.py
rank 0..world_size-1 global index of this process
local_rank 0..gpus_per_node-1 which GPU on this machine
world_size total processes usually GPUs in totalThe conversion, in full#
import os, torch, torch.nn as nn
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.utils.data import DataLoader, TensorDataset, DistributedSampler
def main():
dist.init_process_group("gloo") # "nccl" when there are GPUs
rank, world = dist.get_rank(), dist.get_world_size()
# On GPUs: torch.cuda.set_device(int(os.environ["LOCAL_RANK"])) before anything else.
data = TensorDataset(torch.randn(64, 10), torch.randn(64, 1))
sampler = DistributedSampler(data, num_replicas=world, rank=rank, shuffle=True)
loader = DataLoader(data, batch_size=8, sampler=sampler) # batch_size is PER PROCESS
model = DDP(nn.Linear(10, 1)) # wrap AFTER moving to the device
opt = torch.optim.SGD(model.parameters(), lr=0.01)
for epoch in range(2):
sampler.set_epoch(epoch) # without this, every epoch has the same order
seen = 0
for x, y in loader:
opt.zero_grad()
nn.functional.mse_loss(model(x), y).backward() # gradients sync here
opt.step()
seen += len(x)
if rank == 0:
print(f"epoch {epoch}: rank 0 saw {seen} of {len(data)} samples")
if rank == 0: # save from one process only
torch.save(model.module.state_dict(), "model.pt") # .module, not the wrapper
dist.destroy_process_group()
if __name__ == "__main__":
main()
# torchrun --nproc_per_node=2 train.py
# epoch 0: rank 0 saw 32 of 64 samples
# epoch 1: rank 0 saw 32 of 64 samplesFour of those lines are the ones that go wrong, and none of them fail loudly. Forgetting the DistributedSampler means every process iterates the whole dataset, so an epoch trains on everything twice and the results look mysteriously worse than the single-GPU run. Forgetting set_epoch means the shuffle is identical every epoch, which quietly removes shuffling. Saving state_dict from the wrapper rather than from .module writes keys prefixed with module., which then fail to load into the plain model at inference. And saving from every rank has four processes writing one path at once.
batch_size is per process
DataLoader(batch_size=32) on eight processes is a global batch of 256, not 32. This is the single most common cause of a multi-GPU run behaving differently from the single-GPU one it was meant to reproduce: the batch size changed by a factor of eight, so the effective learning rate is wrong. Decide the global batch first and divide, and scale the learning rate when it changes — the usual starting point is linear in the global batch, with a warmup.
Gradient accumulation without extra syncs#
DDP synchronises gradients during every backward call. When you accumulate over several micro-batches, all but the last of those syncs are wasted — the gradients are not being used yet. no_sync suppresses them, so a four-step accumulation costs one all-reduce instead of four.
from contextlib import nullcontext
accum = 4
for i, (x, y) in enumerate(batches):
last = (i == accum - 1)
ctx = nullcontext() if last else model.no_sync() # sync only on the last one
with ctx:
(nn.functional.mse_loss(model(x), y) / accum).backward()
opt.step()
opt.zero_grad()
# accumulated 4 micro-batches with 1 sync instead of 4Measuring whether it scaled#
Adding GPUs is an assumption until it is measured, and the number to report is scaling efficiency: throughput on N devices divided by N times the throughput on one. Measure samples per second over a steady-state window, skipping the first few steps so compilation and warmup do not count.
devices samples/s ideal efficiency
1 412 412 100%
2 790 824 96%
4 1510 1648 92%
8 2560 3296 78% <- look here
78% on eight means roughly two of the eight are doing nothing useful.
Usual causes, in the order worth checking:
the data loader cannot feed them (workers, prefetch, decode cost)
the batch per device is too small (GPU idle between kernels)
the interconnect (see the numbers section)
one straggler (a slower card, or a busy host)The habit worth forming is running this table once when you set the job up and once more after any change to the data pipeline. Efficiency falling from 92 to 60 per cent after someone adds a heavier augmentation is a common and entirely invisible regression — the loss curve looks the same, and the bill doubles.
Where people go wrong
Assuming eight GPUs means eight times faster, then discovering the data loader was the bottleneck all along.
Build this: Convert a single-GPU training script to DDP, run it on one, two and four GPUs (a cloud machine for an afternoon is enough), and report throughput and scaling efficiency. Then add gradient accumulation with no_sync and confirm the loss curve matches the larger batch.
Open this section on its own pageSharding a model with FSDP
By the end: You can shard a model too large for plain data parallelism, choose what to wrap, and save and load sharded checkpoints.
- Sharding per block
- Mixed precision settings
- Activation checkpointing alongside
- Checkpoints for a sharded model
- When it is slow
FSDP is the API for the sharding described in the previous section: each device owns a slice of the parameters, gradients and optimiser state, and the pieces are gathered back for the moment they are used. The mental model that makes it behave predictably is that sharding happens per wrapped unit — you choose the units, and that choice sets both the memory saving and the communication cost. This section covers choosing them, the mixed-precision settings, combining it with activation checkpointing, saving a model whose weights are spread across eight machines, and what to look at when it is slower than you expected.
These blocks are not executed here
Unlike the DDP section, whose code was run on this machine with two processes, FSDP could not be: it fails on this laptop inside a Metal backend check unrelated to the code, and multi-GPU behaviour cannot be simulated honestly on CPU anyway. Every API name, argument and signature below was checked against the installed PyTorch 2.14 rather than written from memory, and the numbers quoted are the arithmetic from the previous sections, not timings. Treat the shapes as correct and the performance claims as things to measure on your own hardware.
Sharding per block#
Wrap the repeating unit of the model — a transformer block — rather than the whole thing. Wrapping only the root gathers every parameter at once, which defeats the purpose, since peak memory then includes the entire model. Wrapping each block means only one block's parameters are gathered at a time, and the next block's gather can overlap with the current block's compute.
import torch, torch.nn as nn
from torch.distributed.fsdp import fully_shard
for block in model.transformer_blocks: # the repeating unit, innermost first
fully_shard(block)
fully_shard(model) # then the root, for anything left over
# Peak memory now holds one block's worth of gathered parameters, not the model.The one tuning knob worth knowing is reshard_after_forward. Left on, parameters are freed after the forward pass and gathered again for the backward, which is the lowest-memory setting and costs one extra all-gather. Turned off, they stay resident between the two, which is faster and uses more memory. The usual configuration is to reshard everywhere except the last block, which is gathered again almost immediately.
Mixed precision settings#
from torch.distributed.fsdp import MixedPrecisionPolicy
policy = MixedPrecisionPolicy(
param_dtype=torch.bfloat16, # what the forward and backward compute in
reduce_dtype=torch.float32, # what gradients are reduced in
)
for block in model.transformer_blocks:
fully_shard(block, mp_policy=policy)That split is the answer to a common interview question about why bf16 is safe for training when fp16 needs loss scaling. bf16 has the same exponent range as fp32, so it does not underflow the way fp16 does and needs no scaler; what it has instead is fewer mantissa bits, so the place to be careful is accumulation — which is exactly what reduce_dtype controls.
Activation checkpointing alongside#
Sharding and checkpointing solve different problems and are almost always used together: sharding handles everything that scales with parameter count, checkpointing handles activations, which scale with batch and sequence length. For the 7B-shaped model in the previous section that was 16 GiB of activations reduced to 1 GiB, for 33 per cent more compute.
from torch.distributed.algorithms._checkpoint.checkpoint_wrapper import (
checkpoint_wrapper, CheckpointImpl,
)
for i, block in enumerate(model.transformer_blocks):
if i % 2 == 0: # every block, or every second one
model.transformer_blocks[i] = checkpoint_wrapper(
block, checkpoint_impl=CheckpointImpl.NO_REENTRANT,
)
# Apply checkpointing BEFORE fully_shard, so FSDP shards the wrapped module.Checkpoints for a sharded model#
This is where people lose a training run. Calling state_dict on a sharded model gives you that rank's shard, so saving it from rank zero saves an eighth of the model, and the failure is only discovered at load time. There are two correct approaches and the right one depends on what the checkpoint is for.
# For resuming: every rank writes its own shard, in parallel. Fast, and the
# only sane option at scale -- no rank ever holds the whole model.
import torch.distributed.checkpoint as dcp
from torch.distributed.checkpoint.state_dict import get_state_dict, set_state_dict
model_sd, optim_sd = get_state_dict(model, optimizer)
dcp.save({"model": model_sd, "optim": optim_sd}, checkpoint_id="step_1000")
# Resuming: load into the same shape, then push back into the live objects.
state = {"model": model_sd, "optim": optim_sd}
dcp.load(state, checkpoint_id="step_1000")
set_state_dict(model, optimizer, model_state_dict=state["model"],
optim_state_dict=state["optim"])For the other purpose — a single file to hand to inference — you need the full unsharded weights gathered onto one rank, which requires that rank to have enough memory for the whole model. Do that once at the end of training rather than every few hundred steps, and keep it separate from the resume checkpoints, which are the ones that must be fast.
When it is slow#
FSDP can run correctly and slowly, and the symptom is always the same — the memory fits, the loss falls, and the throughput is a fraction of what the hardware should give. The causes are a short list, in the order worth checking.
symptom / cause what to do
wrapped only the root wrap each repeating block
wrapped too finely, every Linear wrap blocks, not layers
stage 3 on a slow interconnect try stage 2, or accumulate gradients
reshard_after_forward on everywhere leave the last block resident
gathering full state_dict every save use dcp.save for resume checkpoints
CPU offload left on turn it off unless you truly need itMeasure it the way the previous section measures DDP: samples per second on one device against samples per second on all of them. Sharding that has taken throughput below what a smaller batch on plain data parallelism would have given is a configuration to abandon, not to tune further — and knowing that boundary is most of what the FSDP question in an interview is really asking.
Where people go wrong
Saving an FSDP model by gathering the full state dict onto rank 0 and running out of memory at the end of a two-day run.
Build this: Take a model that does not fit with DDP on your GPUs, shard it per block with mixed precision, add activation checkpointing, and record peak memory and throughput for each change. Save a sharded checkpoint and load it on a different GPU count.
Open this section on its own pageCollectives and the numbers that decide a design
By the end: You can estimate communication time, memory per GPU and model FLOPs utilisation, and use them to choose a parallelism strategy.
- All-reduce, all-gather, reduce-scatter
- How long a gradient sync takes
- Memory per GPU, worked
- Model FLOPs utilisation
Four numbers decide a distributed training design, and all four are arithmetic you can do before requesting a single GPU: how much memory each device needs, how long a gradient sync takes, what fraction of the step that sync is, and how much of the hardware you are actually using. This section works all four for a concrete model — seven billion parameters on eight GPUs — because the numbers, not the vocabulary, are what an interviewer is probing and what stops you renting a cluster that cannot help.
The three collectives#
Almost all distributed training is built from three operations, and knowing which one a framework is doing tells you what it costs. On NVIDIA hardware they are implemented by NCCL, the library PyTorch calls when you select the nccl backend, and it is NCCL that decides the ring or tree the data actually travels. All-reduce combines a value across every device and leaves every device with the combined result — this is the gradient sync in plain data parallelism. Reduce-scatter combines and then gives each device only its slice. All-gather is the reverse: each device contributes its slice and everyone ends up with the whole. The identity worth remembering is that an all-reduce is exactly a reduce-scatter followed by an all-gather, which is why sharded training can replace one with the other two and pay roughly the same total.
operation each device starts with each device ends with
all-reduce its own full gradient the summed full gradient
reduce-scatter its own full gradient the summed slice it owns
all-gather the slice it owns every slice, assembledHow long a gradient sync takes#
A ring all-reduce does not send everything to everyone. Each device passes slices around a ring, and the traffic per device works out at twice the payload times N minus one over N — approaching twice the payload however many devices you add, which is the useful property. Seven billion bf16 gradients is 14 GB, so on eight devices each one moves 24.5 GB per step.
params, N = 7e9, 8
grad_bytes = params * 2 # bf16 gradients
payload = grad_bytes * 2 * (N - 1) / N # ring all-reduce traffic per device
print(f"{grad_bytes/1e9:.0f} GB of gradients -> {payload/1e9:.1f} GB moved per device")
for link, bw in [("NVLink", 900e9), ("PCIe 4.0 x16", 25e9),
("100 Gb Ethernet", 12.5e9), ("10 Gb Ethernet", 1.25e9)]:
print(f" {link:18} {payload/bw*1000:9.1f} ms per step")
# 14 GB of gradients -> 24.5 GB moved per device
# NVLink 27.2 ms per step
# PCIe 4.0 x16 980.0 ms per step
# 100 Gb Ethernet 1960.0 ms per step
# 10 Gb Ethernet 19600.0 ms per stepA time in milliseconds means nothing until you compare it with the step it sits inside. A transformer forward and backward pass costs about six FLOPs per parameter per token, so at 4,096 tokens per device that is 1,376 TFLOPs a step, and eight H100s sustaining a realistic forty per cent of their 989 TFLOP/s bf16 peak take 435 ms of compute.
interconnect sync step sync as a share
NVLink 27 ms 462 ms 5.9%
PCIe 4.0 980 ms 1415 ms 69.3%
10 Gb Ethernet 19600 ms 20035 ms 97.8%This is the calculation that saves the money
Do it before provisioning, not after a week of poor scaling. If the sync is a large share of the step there are only three moves, and all of them are cheaper than more GPUs: accumulate gradients over several micro-batches so the sync happens once per several forward passes, shard so that reduce-scatter replaces all-reduce, or get a better interconnect. Buying more devices on a slow network makes the ratio worse, because the payload per device barely falls while the number of hops grows.
Memory per GPU, worked#
The memory question is the one people get wrong by a factor of five, because they count the parameters and stop. Mixed-precision training with Adam holds five things per parameter, and the parameters are the smallest of them.
params = 7e9
GiB = 1024 ** 3 # memory is sold and reported in binary units
costs = [("bf16 weights", 2), ("bf16 gradients", 2), ("fp32 master copy", 4),
("Adam momentum", 4), ("Adam variance", 4)]
for name, b in costs:
print(f"{name:18} {b:2d} bytes/param {params*b/GiB:6.1f} GiB")
total = params * sum(b for _, b in costs) / GiB
print(f"{'TOTAL':18} {sum(b for _,b in costs):2d} bytes/param {total:6.1f} GiB")
# bf16 weights 2 bytes/param 13.0 GiB
# bf16 gradients 2 bytes/param 13.0 GiB
# fp32 master copy 4 bytes/param 26.1 GiB
# Adam momentum 4 bytes/param 26.1 GiB
# Adam variance 4 bytes/param 26.1 GiB
# TOTAL 16 bytes/param 104.3 GiBThat total is also the rule of thumb worth memorising, because it answers the interview question directly: mixed-precision Adam costs roughly sixteen bytes per parameter, so multiply your parameter count by sixteen and compare it with your device. Plain SGD with momentum costs eight and full fp32 Adam costs twenty. Activations sit on top and scale with batch size rather than parameter count, which is why they are the part you can trade.
weights = params * 2 / GiB # bf16 copy every device keeps resident
grads = params * 2 / GiB
opt = params * 12 / GiB # fp32 master + Adam momentum + Adam variance
for name, per_gpu in [
("none (plain DDP)", weights + grads + opt),
("stage 1 (optimiser)", weights + grads + opt / N),
("stage 2 (+ gradients)", weights + grads / N + opt / N),
("stage 3 (+ parameters)", (weights + grads + opt) / N),
]:
print(f"{name:23} {per_gpu:6.1f} GiB fits an 80 GiB card: {per_gpu < 80}")
# none (plain DDP) 104.3 GiB fits an 80 GiB card: False
# stage 1 (optimiser) 35.9 GiB fits an 80 GiB card: True
# stage 2 (+ gradients) 24.4 GiB fits an 80 GiB card: True
# stage 3 (+ parameters) 13.0 GiB fits an 80 GiB card: TrueModel FLOPs utilisation#
MFU is the single number that says whether a training run is healthy: the FLOPs your model actually needed, divided by the FLOPs the hardware could have delivered in the same wall-clock. It ignores everything you cannot bill for — recomputation from activation checkpointing, padding, waiting on the network — which is exactly what makes it honest.
tokens_per_step = N * 4096
flops = 6 * params * tokens_per_step # 6 FLOPs per parameter per token
compute_s = flops / (989e12 * 0.4 * N) # 40% of bf16 peak, sustained
comm_s = payload / 900e9 # NVLink
step = compute_s + comm_s
achieved = flops / step / N
print(f"step {step*1000:.0f} ms achieved {achieved/1e12:.0f} TFLOP/s per GPU")
print(f"MFU {achieved/989e12*100:.1f}%")
# step 462 ms achieved 372 TFLOP/s per GPU
# MFU 37.6%MFU you measure what it usually means
40-50% healthy; tune elsewhere
25-35% communication or activation checkpointing overhead
10-20% the data loader, or a batch too small to fill the GPU
under 10% something is on the CPU, or synchronising every stepWhere people go wrong
Planning a multi-node run from GPU FLOPs alone, without estimating how long communication takes over the actual network.
Build this: For a model size of your choice, write a small calculator for memory per GPU under DDP and full sharding, all-reduce time over two link speeds, and the tokens per second needed for 40% MFU. Check it against a real run if you have one.
Open this section on its own pageNow actually do it
Reading a topic is not learning it. These are the hours that count, and each one tells you when you are finished.
1Measure the scaling efficiency yourself
15hTrain the same model on one and two GPUs with data parallelism. Measure the speedup. It will be less than two.
Done when: You can state the efficiency and explain where the rest went.
2Survive a failure mid-run
15hAdd checkpointing to a long run, kill a process partway, and resume. Then make the restart automatic.
Done when: A killed run resumes without manual intervention and loses at most one checkpoint interval.
30 hours of practice
Words used here
7 terms this topic teaches, each with a one-line definition if one of them stopped you.
- Gradient accumulation
- Summing gradients over several small batches before stepping the optimiser, to get the behaviour of a large batch on a GPU that cannot hold one.
- Data parallelism
- Putting a full copy of the model on every GPU, giving each a different slice of the batch, and averaging the gradients. The simplest way to scale, and the right one until the model stops fitting on one device.
- Tensor parallelism
- Splitting individual weight matrices across GPUs so each holds a slice of every layer. It needs a collective communication inside each layer, so it belongs within a fast-interconnect node rather than across nodes.
- Pipeline parallelism
- Giving each GPU a consecutive group of layers and passing activations along the chain. Communication is small, but devices sit idle waiting for work unless the batch is split into micro-batches.
- ZeRO
- Sharding optimiser state, then gradients, then parameters across data-parallel workers instead of replicating them, gathering each piece only when needed. Cuts memory per GPU by close to the number of workers.
- FSDP
- PyTorch's implementation of sharded data parallelism: parameters live split across GPUs and are gathered layer by layer for each forward and backward pass, then released.
- NCCL
- NVIDIA's library for collective operations between GPUs — all-reduce, all-gather, broadcast — using the fastest link available. Every multi-GPU training framework sits on top of it.
Interview questions
8 questions, grouped by the section that teaches the answer. An answer you have read is not an answer you can give, so use Test yourself and say it out loud first.
Training something too big for one GPU
Why does training need so much more memory than inference?
Inference needs the weights. Training also needs gradients, optimiser state which for Adam is several times the model size, and the activations retained for the backward pass.
What is the difference between data and tensor parallelism?
Data parallelism replicates the whole model on each device and splits the batch, requiring the model to fit on one device. Tensor parallelism splits individual layers across devices for models too large for one, at the cost of communication inside every layer.
What usually limits distributed training?
Communication bandwidth. Synchronising gradients every step moves a large amount of data, and across machines that frequently dominates compute time, which is why interconnect drives cluster design.
Sharded data parallelism in practice
What do the ZeRO stages shard?
Stage 1 shards optimiser state, stage 2 adds gradients, stage 3 adds the weights. Each saves more memory and adds more communication, with weights being the costliest because they must be gathered before every layer.
What is activation checkpointing?
Storing only some forward-pass activations and recomputing the rest during the backward pass. It trades roughly a third more compute for a large reduction in memory, and is often what makes a model fit without new hardware.
Data parallel training in PyTorch
What happens during a DDP training step?
Each process runs forward and backward on its own slice of the batch. During the backward pass, gradients are averaged across processes with an all-reduce, overlapped with the remaining backward computation. Every process then applies the same averaged gradient, so the model copies stay identical.
Then they push further
Going from 1 to 8 GPUs only gives 4 times the throughput. Where do you look?
Profile. Common causes are data loading that cannot keep up, gradient communication that is not overlapped or crosses a slow link, a small model or batch per GPU so communication dominates, and synchronisation points such as logging that stall every step.
What changes about the learning rate?
The global batch is now eight times larger. Many setups increase the learning rate with batch size, often with warmup, but it has to be retuned rather than assumed.
What gets marked up. Marked up for all-reduce during backward, the batch size consequence, and a method for finding the bottleneck.
Sharding a model with FSDP
When would you use FSDP instead of DDP?
When the model's weights, gradients and optimiser state do not fit on one GPU with plain replication. FSDP shards all three and gathers each layer's weights just in time, at the cost of extra communication.
Then they push further
Your FSDP job fits in memory but is much slower than expected across two machines. Why?
Parameter gathers and gradient reduce-scatters in every layer cross the slower network link. Shard within each machine and replicate across machines, increase the work per GPU, and check that communication overlaps with compute.
What gets marked up. Marked up for knowing what is sharded, the per-layer gather cost, and hybrid sharding as the fix across nodes.
Collectives and the numbers that decide a design
Estimate how much memory it takes to train a 7B model with Adam in mixed precision.
About 16 bytes per parameter: 2 for 16-bit weights, 2 for gradients, 4 for 32-bit master weights and 8 for Adam's two moments. That is about 112 GB before activations, so it needs sharding across several GPUs.
Then they push further
How long would a gradient all-reduce take across 8 GPUs over a 100 Gbit/s network?
Each GPU moves about 2 times 7/8 of 14 GB, around 24 GB. At about 12.5 GB/s that is roughly two seconds per step, which is why this is done over fast in-machine links or overlapped with computation.
What is MFU and what would a low value tell you?
The share of peak hardware FLOPs spent on model computation. A low value means time goes to communication, data loading, idle pipeline stages or inefficient kernels, and says where to look.
What gets marked up. Strong candidates do the arithmetic out loud with the 16 bytes per parameter figure and relate communication time to interconnect bandwidth.
Resources
Free to watch and read, picked for this topic.
Videos
For training something too big for one GPU
For sharded data parallelism in practice
For data parallel training in PyTorch
For sharding a model with FSDP
For collectives and the numbers that decide a design
Full courses: the whole topic, start to finish
Read and explore