Data Parallel DDP From Scratch
DistributedDataParallel (DDP) is a thin wrapper around allreduce. Wrap a model, broadcast the initial parameters from rank 0 so every rank starts identical, and allreduce the gradients with an explicit call after backward. The rest is plain gradient descent, and the whole pattern fits in about 220 lines.
Learning Objectives
- Wire a `DistributedDataParallel`-shaped wrapper that broadcasts initial parameters and allreduces gradients after backward.
- Spawn N CPU ranks with `mp.get_context("spawn")` and `ctx.Process` over the gloo backend with file-based rendezvous.
- Prove gradient-sync correctness by training the same model on the same data sequentially and showing matching per-step losses and a matching final parameter norm.
- Defend the use of buckets (gradient fusion) and overlap (comm during backward) as the two changes that turn a working DDP into a production DDP.
The Problem
Data parallel splits the batch across N ranks. Each rank runs forward and backward on its own shard, and at each step the ranks' gradients are summed so all N copies stay identical. The optimiser steps on that summed gradient.
The reason to bother: a 1-billion-parameter model with 12 GB of activations does not fit on one consumer GPU, and even when a model fits, training can take weeks.
Skip the gradient sync and the N replicas diverge after the first update. You no longer have one model trained on more data, you have N separate models that happen to share initial weights. Do the sync badly (one allreduce per parameter, no overlap, no bucketing) and the network becomes the bottleneck while the GPUs idle waiting on the wire. The craft of DDP is making gradient sync nearly free relative to compute. Canonical PyTorch DDP gets there by bucketing gradients, overlapping allreduce with the next layer's backward, and using NCCL on NVLink. The code here is the naive version on CPU with gloo, one allreduce per parameter with no buckets and no overlap, and exercise 1 adds bucketing so you can measure the difference.
The Concept
The three operations DDP needs
| Stage | Collective | Why |
|---|---|---|
| Init | broadcast from rank 0 | Every rank starts with the same parameters |
| After backward | allreduce of each grad | The mean gradient is what the optimiser steps on |
| Sometimes | broadcast of buffers | Batchnorm running stats stay synchronised (production DDP only; main.py has none because MiniMLP has no buffers) |
Why mean and not sum
The mean gradient is allreduce-SUM divided by world_size. It keeps the same magnitude as world_size changes, so a learning rate tuned at one rank works at four. Leave out the division and you retune the learning rate every time the cluster size changes. DDP wraps the SUM and divides, and the code here does the same.
Why bucket gradients
A bucket is a group of gradient tensors reduced with a single allreduce. A transformer has thousands of parameter tensors, and one allreduce per tensor pays the gloo latency floor thousands of times. DDP groups gradients into ~25 MB buckets and issues one allreduce per bucket. The same total bytes cross the wire, but the latency is amortised over the bucket. The code here does not bucket: sync_grads issues one allreduce per parameter tensor (six for MiniMLP), and exercise 1 adds buckets.
Why pin the seed
Every rank calls torch.manual_seed(SEED) before building the model, so initial parameters start identical, and the broadcast from rank 0 guarantees it. Data is not shuffled per rank. Each rank slices its own shard out of one shared synthetic dataset by offset (step * world_size * batch + rank * batch), so ranks see different rows without needing a rank-specific seed. Seeding parameter init per rank would make the replicas start apart, and only the broadcast would repair that. Skip both the shared seed and the broadcast and the ranks start from different weights, so their step-0 losses already disagree with the reference. (Rank 0 keeps the reference seed if the others only vary theirs, so it first drifts at step 1.)
Build It
code/main.py implements five pieces:
MiniMLP: a 3-layer MLP small enough to converge in seconds, large enough to expose the wiring.DistributedDataParallel(model, world_size): a thin wrapper, not annn.Module. It broadcasts params at construct time, and itssync_grads()divides the allreduce-summed grads by world_size. The training loop callsddp.sync_grads()explicitly afterloss.backward(); there are no backward hooks._ddp_worker(rank, world_size, init_file, iface, steps, batch, lr, out_queue): the full training loop, withtorch.distributedinit over gloo, then forward, backward, sync, step.run_ddp(...): starts the ranks withmp.get_context("spawn")andctx.Process, using a file-based rendezvous, and collects each rank's losses and final parameter norm.reference_single_process(...): trains the same model on the same data sequentially in one process, walking every rank's micro-batch in rank order each step. The printed table compares its losses to the DDP run.
Run it:
python3 code/main.pyThe output is a per-step table with the reference loss, the DDP loss on rank 0, the DDP loss on rank 3, and the drift between those two ranks, followed by the final parameter norm for the reference and for rank 0. The two paths produce matching loss curves and a matching final norm to float epsilon, which shows the gradient sync is correct. The code does not compare parameters after every step.
Production patterns in the wild
Three patterns harden DDP enough to ship.
Find unused parameters. Some forward paths skip parameters conditionally (early exit, a mixture-of-experts router). Those parameters get no gradient, but DDP's bucket-ready hook still waits for them and the allreduce deadlocks. find_unused_parameters=True makes DDP check which params got gradients before reducing. The cost is a graph walk per step, so leave it off unless your forward branches.
Static graph optimisation. When the forward is stable across steps, static_graph=True lets DDP precompute the bucket schedule. Precomputing saves a few ms per step, and that compounds across 10000 steps.
Gradient accumulation needs care. Accumulating gradients over K microbatches without syncing each one is a 10x throughput win. DDP exposes no_sync() as a context manager that pauses the post-backward allreduce. Forget the manager and you allreduce K times for nothing, and throughput drops to the floor.
Use It
Where this pattern shows up in practice:
- PyTorch DDP. The canonical implementation.
torch.nn.parallel.DistributedDataParallel(model)wires up bucketing, overlap, and the no_sync context. - HuggingFace Accelerate. Adds a launcher that handles
torchrunenv vars and the model wrap. Same DDP under the hood. - Megatron-LM data parallel. Combines DDP with tensor parallel for large models. The data-parallel piece is the same allreduce-after-backward pattern.
Ship It
Lesson 78 (ZeRO sharding) swaps the per-parameter allreduce for reduce_scatter, so each rank stores only its shard of the optimiser state. Lesson 81 composes DDP with ZeRO into the end-to-end demo.
Exercises
- Add gradient buckets of configurable size and measure the speedup vs one-allreduce-per-parameter on a deeper model.
- Implement
no_sync()as a context manager and verify gradient accumulation matches a single-process baseline over K microbatches. - Add a
find_unused_parametersmode where the forward sometimes skips one of the MLP layers; without the flag the run should deadlock. - Replace gloo with
torch.distributed.barrier()-only synchronisation to feel the difference between allreduce-based and barrier-based sync. - Measure the gradient-sync overhead as a fraction of step time for batch sizes 1, 16, 256 and explain the scaling.
Key Terms
| Term | What people say | What it actually means |
|---|---|---|
| DDP | "Data parallel" | Wrapper that broadcasts params and allreduces grads each step |
| Bucket | "Fuse grads" | Group N small allreduces into one large one (not in main.py; exercise 1) |
| Overlap | "Hide comm" | Issue allreduce while later layers still computing backward (production DDP only) |
| no_sync | "Accumulate" | Skip the post-backward allreduce for gradient accumulation |
| find_unused | "Branchy forward" | Detect parameters with no grad before reducing |
My bet is that the reference loop deserves a closer read than the DDP wrapper. The wrapper is a broadcast and an allreduce-and-divide. The reference walks each rank's micro-batch in rank order on one process, and matching it is the correctness test here.
I'd also hold off on calling this a production DDP. sync_grads in code/main.py issues one allreduce per parameter, so bucketing is what exercise 1 adds, and overlap is something the problem section describes but the code does not implement. Measure that gap before you trust the claim that sync is nearly free.
If the DDP run and the reference disagree, I'd start with sync_grads. It is where the code sums gradients across ranks and divides by world_size, so that is where I'd look for a wrong divisor or a skipped parameter.
Further Reading
- PyTorch DistributedDataParallel docs
- PyTorch DDP internals tutorial
- Li et al, PyTorch Distributed: Experiences on Accelerating Data Parallel Training
- Phase 19 Lesson 76 - the collectives DDP is built on
- Phase 19 Lesson 78 - ZeRO sharding replaces the per-param allreduce with reduce_scatter
Code
- main.py (python, 223 lines)