Data parallelism and collectives
Suppose your model fits on one GPU, but one GPU would take a month to train it. You have eight GPUs. What is the simplest way to make training eight times faster?
Give each GPU a full copy of the model and a different eighth of every batch. Each GPU runs the forward and backward pass on its own slice, the GPUs average their gradients, and every GPU applies the same averaged update to its own copy. The copies start identical and receive identical updates, so they stay identical forever. This is data parallelism, and it is how almost every large model is trained, usually combined with the other strategies of this chapter.
The whole cost of data parallelism is in one word of that description, average. Eight GPUs each hold a gradient as large as the model, and they must combine them into one, every step. How to do that fast, with a pattern of messages called all-reduce, is the subject of this section. Along the way we meet the handful of collective operations that every distributed training system is built from, and derive what each one costs.
Why averaging gradients is exactly large-batch training
Data parallelism is not an approximation. Take a global batch of examples with index set and split it into shards of examples each, one per GPU. Write for the loss on example . The loss is the mean over the batch, so its gradient is
In plain words, the gradient of the whole batch is the average of the GPUs' gradients, as long as every GPU averages over the same number of examples.
That condition is not a technicality. In language modeling the natural unit is the token, not the sequence, and different GPUs can see different numbers of non-padding tokens. If each GPU averages its loss over its own tokens and the GPUs then average those means, a GPU with 100 tokens gets the same say as one with 10,000. The fix is to sum the loss over tokens on each GPU, sum the token counts across GPUs, and divide once. The cell below checks both claims on a linear model.
Data parallelism changes the global batch size, not the algorithm. Eight GPUs with 32 sequences each train exactly like one GPU with 256 sequences. That is both its strength (no change to the math) and its limit. Beyond the critical batch size, larger batches stop reducing the number of steps needed (§8.7), so you cannot add GPUs forever just by growing the batch. We return to this limit at the end of the section.
The collective operations
When many GPUs work on one job, they talk in a small vocabulary of group operations called collectives, named in the MPI standard decades before deep learning and implemented for GPUs by libraries such as NVIDIA's NCCL. Every rank (one process, usually one GPU) calls the same collective at the same time, each with its own buffer. Picture ranks, each holding a buffer split into chunks.
| collective | before | after | used for |
|---|---|---|---|
| broadcast | rank 0 has | every rank has | initial weights |
| reduce | rank has | rank 0 has | metrics, losses |
| all-reduce | rank has | every rank has | gradients in data parallelism, tensor parallelism |
| reduce-scatter | rank has | rank has chunk of | sharded gradients (ZeRO, FSDP) |
| all-gather | rank has chunk | every rank has all chunks | sharded weights (ZeRO-3, FSDP), sequence parallelism |
| all-to-all | rank has chunks | rank has | expert parallelism, Ulysses |
The reductions usually sum, though maximum and others exist. Two identities are worth seeing right away. An all-reduce is a reduce-scatter followed by an all-gather (reduce each chunk on its owner, then hand the owners' chunks to everyone). And an all-to-all is a transpose of a grid of chunks, every rank sending a different piece to every other rank. Here are all of them as plain functions on lists of arrays, one array per simulated rank.
Ring all-reduce, derived
Now the central question. Each of ranks holds a gradient of bytes. How long does it take to give every rank the sum?
The obvious algorithm sends everything to one rank, adds, and sends the result back. Rank 0 then receives bytes and sends bytes, so with links of bandwidth (bytes per second) the time is at least . It grows linearly with the number of GPUs, and every other rank's link sits idle while rank 0 works. With 64 GPUs and a 2 GB gradient over 50 GB/s links, that is 5 seconds per step, far longer than the computation.
The ring all-reduce removes the bottleneck by making every rank do an equal share of the sending, adding, and receiving. Arrange the ranks in a ring, each sending only to its right neighbor (all indices modulo ), and split every buffer into chunks of bytes. The algorithm has two phases of steps each.
- Reduce-scatter. At step , rank sends its current copy of chunk to rank , which adds it into its own copy of that chunk. A chunk travels around the ring collecting one more contribution at every hop. After steps, it has visited every rank. Working through the indices, the chunk that arrives at rank in the last step is , so rank now holds the complete sum of chunk .
- All-gather. At step , rank sends chunk to rank , which overwrites its copy. At step 0 each rank passes on the finished chunk it owns, and at every later step it passes on the finished chunk it just received. After steps every rank has every finished chunk.
The widget below runs it. Each square is one chunk on one rank, and the dots show which ranks' contributions it contains.
Now count. In each of the steps, every rank sends one chunk of bytes and receives one. All links work at once, so a step takes , where is the fixed latency of sending any message at all (launching the transfer, synchronizing, crossing the switch). Writing for the time per byte (this latency-plus-bandwidth cost model, per message, is Hockney's model, the standard way to reason about collectives), the total is
In plain words, each rank sends and receives just under twice its buffer, however many ranks there are, but it pays the latency times.
The bandwidth term is the remarkable part. As grows, approaches 2 from below, so the data each GPU moves is essentially constant. Adding GPUs to a ring does not make the all-reduce slower, except through latency. And no algorithm can do better on bandwidth. Patarasuk and Yuan (2009) proved that some rank must send at least bytes in any all-reduce (assuming the data are not compressed). The argument is a count of messages. Summing one number held by ranks and delivering the sum to all of them takes at least messages, each the size of one number. The elements of a buffer are independent, so an -byte all-reduce needs at least bytes of messages in total, and spread over senders, at least one must send . The ring meets that bound exactly. This is a theorem, and it is why rings are the default for large messages.
The two halves are themselves the collectives of the table. A reduce-scatter alone is phase 1, steps moving bytes per rank. An all-gather alone is phase 2, with the same cost, where is the size of the full gathered result. So each of them costs half an all-reduce, which will matter when ZeRO splits an all-reduce into its halves (§10.3).
Bytes on the wire for each collective
Implement collective_bytes(op, M, p) returning the number of bytes sent by the busiest rank for the bandwidth-optimal algorithm of each collective among p ranks. M is the size in bytes of the full, unsharded buffer. For "all_reduce" it is . For "reduce_scatter" and "all_gather" it is (for all-gather, M is the size of the gathered result). For "all_to_all", where M is the size of each rank's send buffer, a rank keeps its own and sends the rest. For "broadcast" and "reduce" with a pipelined chain, the busiest rank sends M. With p == 1, nothing is sent. Raise ValueError for any other name.
Ring all-reduce with exact chunk bookkeeping
Implement ring_allreduce(bufs) for a list of p one-dimensional NumPy arrays of equal length n (not necessarily divisible by p). Split each buffer into p chunks with np.array_split, and follow the schedule in the text exactly. In reduce-scatter step k (for k from 0 to p - 2) rank r sends chunk (r - k) % p to rank (r + 1) % p, which adds it to its own copy. In all-gather step k rank r sends chunk (r + 1 - k) % p, which the receiver stores in place of its copy. All sends of a step happen simultaneously. Return (out, log), where out[r] is rank r's final buffer and log lists every message as a tuple (step, src, dst, chunk), with steps numbered 0 to 2p - 3 (all-gather steps come after the p - 1 reduce-scatter steps). Do not modify the inputs.
Latency, trees, and choosing an algorithm
The ring's weakness is its latency terms. For a large gradient bucket the bandwidth term dominates and the ring is ideal, but for a small message, say a few kilobytes of loss values or a tensor-parallel activation of one token, the latency term dominates, and it grows linearly with the number of ranks. Algorithms built on trees pay latency only logarithmically.
Recursive doubling is the simplest. In step , rank exchanges its whole current buffer with rank (the rank whose index differs in bit ) and both add. After steps everyone has the sum, so
It has the fewest steps possible, but it sends the whole buffer every step, bytes instead of the ring's . Recursive halving and doubling (Rabenseifner's algorithm) fixes that by exchanging half of the remaining data at each step, which gives latency terms and the optimal bandwidth when is a power of two. NCCL uses rings and double binary trees, two complementary trees that each carry half of the data so that every rank does useful work in one of them, which gives logarithmic latency at close to full bandwidth for large clusters.
Setting gives the message size where the ring starts to win,
With s and 50 GB/s links, KB. For eight ranks MB, and for 512 ranks about 72 MB. Small messages want trees, large ones want rings, and the crossover moves up as the cluster grows. Libraries pick the algorithm per call from the message size and the topology. Play with the trade-off below.
When does the ring win?
Implement allreduce_time(M, p, alpha, beta, algo) for algo in "ring", "recursive_doubling", and "naive", using the formulas of the text. The naive algorithm reduces to rank 0 and broadcasts back, one message at a time through rank 0's link, so it takes . Then implement ring_crossover(p, alpha, beta) returning the message size above which the ring is faster than recursive doubling. Assume p is a power of two of at least 4.
The hardware the messages run on
The formulas need two numbers, latency and bandwidth, and the hardware gives very different ones depending on whether the two GPUs share a server.
| link | per GPU, each direction | typical use |
|---|---|---|
| NVLink 3 (A100) | 300 GB/s (600 GB/s both ways) | within an 8-GPU node |
| NVLink 4 with NVSwitch (H100) | 450 GB/s (900 GB/s both ways) | within an 8-GPU node |
| NVLink 5 (B200, GB200 NVL72) | 900 GB/s (1.8 TB/s both ways) | within a node, or a 72-GPU rack |
| PCIe Gen5 x16 | about 64 GB/s | GPU to CPU, or GPUs without NVLink |
| InfiniBand NDR, one 400 Gb/s NIC per GPU | 50 GB/s | between nodes |
| InfiniBand HDR, one 200 Gb/s NIC per GPU | 25 GB/s | between nodes (A100 era) |
The gap is the most important fact in this chapter. Inside a DGX H100, each GPU can send 450 GB/s to the others through NVSwitch. Between nodes, each GPU gets its own 400 Gb/s InfiniBand NIC, 50 GB/s, nine times less, and latency across the network is higher too. Almost every layout decision in §10.7 comes down to keeping the chattiest communication on NVLink. Measured numbers are lower than these nominal rates, so for real planning run NVIDIA's nccl-tests on your cluster.
A note on reading those benchmarks. nccl-tests reports an algorithm bandwidth, divided by the time, and a bus bandwidth, the algorithm bandwidth times for an all-reduce. The bus bandwidth is the rate at which each link actually carried data in the ring analysis above, so it can be compared directly with the link's rated speed, whatever is.
A hierarchical all-reduce exploits the gap. Across nodes of GPUs, first reduce-scatter within each node over NVLink, so each GPU holds the node's sum of of the buffer. Then all-reduce those pieces across nodes, each GPU talking to its counterparts in other nodes through its own NIC. Then all-gather within each node. Each GPU's InfiniBand traffic is now instead of .
Real libraries build rings and trees that use every NIC in parallel, in the spirit of this calculation, so you rarely write it yourself. What matters is the conclusion. Traffic that crosses nodes should be as small as possible, and every GPU's NIC should carry its share.
Overlapping communication with the backward pass
Now put the all-reduce into a training step. The naive version runs the full backward pass, then all-reduces every gradient, then steps the optimizer. The GPUs compute while the network idles, then the network works while the GPUs idle.
The fix is to notice that gradients do not appear all at once. The backward pass produces them from the last layer to the first, so the gradients of the last layer are final while the first layers are still being differentiated. Their all-reduce can start right away, on a separate CUDA stream, while computation continues. Only the communication for the first layers, which finish last, is left exposed at the end.
Launching one all-reduce per parameter tensor would pay the latency hundreds of times per step, so gradients are grouped into buckets. Li et al. (2020) describe how PyTorch's DistributedDataParallel (DDP) does it. It assigns parameters to buckets of about 25 MB (bucket_cap_mb) in roughly the reverse of their order in the model, which approximates the order in which their gradients become ready. An autograd hook on each parameter marks its gradient ready, and when every gradient in a bucket is ready, DDP launches an asynchronous all-reduce of that bucket. Before the optimizer step it waits for all of them. The bucket size is a real trade-off. Tiny buckets pay latency many times, and huge buckets wait too long to start, so the last bucket's communication sticks out past the end of the backward pass.
DDP has a few more details worth knowing. At construction it broadcasts rank 0's parameters and buffers to every rank, so all replicas start identical. It divides the summed gradients by the number of ranks, giving the average. Inside with model.no_sync(): it skips the all-reduce, which is how gradient accumulation avoids communicating on every micro-batch. And it needs find_unused_parameters=True when some parameters receive no gradient in a step, because otherwise it would wait forever for a bucket that never fills.
Simulate DDP's bucketed overlap
Implement ddp_step_time(grads, bucket_cap, p, alpha, beta). grads is a list of (nbytes, ready_time) pairs in the order the backward pass produces them (non-decreasing ready_time; the backward pass ends at the last ready_time). Fill buckets in that order. Add each gradient to the current bucket, and close the bucket as soon as its size reaches at least bucket_cap. The last bucket closes with the last gradient. A bucket is ready when its last gradient is ready. Buckets are all-reduced one at a time, in order, on one communication stream, each with the ring cost , starting at the later of its ready time and the end of the previous bucket's all-reduce. Return (end_time, exposed), where end_time is when the last all-reduce finishes and exposed is how far that is past the end of the backward pass (at least 0).
When data parallelism stops scaling
Can the all-reduce always hide behind the backward pass? Count both sides for one step. Each GPU processes tokens. The backward pass of a model with parameters costs about FLOPs per token (two-thirds of the training cost of §9.2), so at FLOP/s it takes seconds. The all-reduce moves about bytes of bfloat16 gradients per GPU, taking seconds. The communication fits inside the backward pass when
In plain words, each GPU must chew through at least tokens per step, whatever the model size, to give the network time to deliver the gradients.
For an H100 at its peak of 989 TFLOP/s in bfloat16, over a 50 GB/s InfiniBand NIC, tokens per GPU per step. At a realistic 40% of peak it is about 7,900. Within a node, over NVLink, it is only about 2,200. These are generous estimates, since they assume perfect overlap and ignore latency.
Now combine this with the batch size limit. If each of data-parallel GPUs needs at least 8,000 tokens per step, the global batch is at least tokens. Pretraining batches are typically a few million tokens, bounded by the critical batch size (§8.7). A 4M-token batch therefore allows only about 500 data-parallel GPUs before communication is exposed or each GPU gets too few tokens to be efficient. To use 16,000 GPUs, as Llama 3 405B did, you have to split the model itself, so that a group of GPUs works on the same tokens. That is the job of the next four sections.
In PyTorch
A DDP training script is launched with one process per GPU, for example torchrun --nproc_per_node=8 train.py, which sets the environment variables RANK, LOCAL_RANK, and WORLD_SIZE that the script reads. This code cannot run in a single-process notebook, so it is shown as reference.
The collectives are also available directly, and writing data parallelism by hand shows what DDP automates. A hook on each parameter fires once its gradient is fully accumulated and launches an asynchronous all-reduce, and the step waits for all of them.
This version launches one all-reduce per tensor and pays latency for each, which is exactly the problem DDP's buckets solve.
| strategy | memory per GPU | communication per step | limit |
|---|---|---|---|
| single GPU | all model states and activations | none | the model must fit, and time |
| data parallel (DDP) | all model states, activations for of the batch | all-reduce of gradients, about the gradient bytes per GPU | model states must fit; batch size caps |
| ZeRO and FSDP | model states divided by | about to the DDP volume | same batch size cap |
Read more
- Li et al. (2020). PyTorch Distributed: Experiences on Accelerating Data Parallel Training. How DDP buckets, overlaps, and synchronizes, with measurements.
- Patarasuk and Yuan (2009). Bandwidth optimal all-reduce algorithms for clusters of workstations. The lower bound on all-reduce traffic and the ring that meets it.
- Thakur, Rabenseifner, and Gropp (2005). Optimization of Collective Communication Operations in MPICH. Rings, recursive doubling, and recursive halving with their cost models.
- Sergeev and Del Balso (2018). Horovod: fast and easy distributed deep learning in TensorFlow. The paper that brought ring all-reduce from HPC into deep learning.
- Goyal et al. (2017). Accurate, Large Minibatch SGD: Training ImageNet in 1 Hour. Data parallelism on 256 GPUs, and the learning-rate scaling that makes large batches work.
- NVIDIA. NCCL Tests, performance notes. Algorithm bandwidth versus bus bandwidth, explained.
- Douillard et al. (2023). DiLoCo: Distributed Low-Communication Training of Language Models. Data parallelism that synchronizes every few hundred steps, with an inner and an outer optimizer.
- Austin et al. (2025). How to Scale Your Model. The chapters on networking and on training derive when data parallelism is compute-bound, for TPUs and GPUs.