Phase 19 · Lesson 75 of 85

Data Parallel DDP From Scratch

DistributedDataParallel is a hook on top of allreduce. Wrap a model, broadcast the initial parameters from rank 0 so every rank starts identical, install a backward hook on every parameter that issues an allreduce of the gradient, and the rest is gradient descent. The whole pattern is 200 lines.

Lesson 75 of 85Build~90 minPython

Before this: Phase 19 Track C lessons 42 to 49

On this page

Learning Objectives

  • Wire a DistributedDataParallel-shaped wrapper that broadcasts initial parameters and allreduces gradients after backward.
  • Spawn N CPU ranks with torch.multiprocessing.spawn over the gloo backend with file-based rendezvous.
  • Prove gradient-sync correctness by training the same model on the same data sequentially and showing per-step parameter equivalence.
  • 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

A 1-billion-parameter model with 12 GB of activations does not fit on one consumer GPU. Even when it fits, training takes weeks. Data parallel splits the batch across N ranks, each rank computes the forward and backward on its shard, and at every step every rank's gradients are summed so all N copies stay identical. The summed gradient is what the optimiser steps on.

Without gradient sync, the N replicas diverge by step 2. The model is not "one model trained on more data" anymore, it is N separate models that happen to share initial weights. With gradient sync done badly (one allreduce per parameter, no overlap, no bucketing) the network is the bottleneck and the GPUs idle waiting for the wire. The craft of DDP is making the gradient sync nearly free relative to compute. The canonical PyTorch DDP achieves that by bucketing gradients, overlapping allreduce with the next layer's backward, and using NCCL on NVLink. We can do all three on CPU with gloo and learn the same lessons.

The Concept

Diagram (opens full size)

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

Why mean and not sum

Allreduce-SUM divided by world_size gives the mean gradient. The mean is invariant to world_size: a learning rate tuned at one rank works at four ranks because the per-step gradient magnitude does not change. Allreduce-SUM without the division forces you to retune the learning rate every time you change cluster size. DDP wraps the SUM and divides; do the same in the lesson.

Why bucket gradients

A transformer has thousands of parameter tensors. 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 move across the wire but the latency is amortised over the bucket. For the lesson's tiny model we group everything into one bucket; the structure is what carries across.

Why pin the seed

Every rank must call torch.manual_seed(seed + rank) for shuffling but torch.manual_seed(seed) for parameter init. A single shared seed for shuffling means every rank sees the same batch order (defeating data parallel). For parameters, the rank 0 broadcast at construction is what makes the replicas identical, and it overwrites whatever each rank initialised. The shared init seed is the backup: if the broadcast is skipped or broken, rank-specific init gives every replica different starting weights, and gradient sync alone never pulls them back together. Get the seed pattern right or the test for parameter equivalence fails on step 1.

ci-ddp-grad-sync

Build It

code/main.py implements:

  • MiniMLP: a 3-layer MLP small enough to converge in seconds, large enough to expose the wiring.
  • DistributedDataParallel(model, world_size): broadcasts params at construct time, returns a wrapper whose sync_grads divides accumulated allreduce-summed grads by world_size.
  • worker(rank, world_size, ...): full training loop with torch.distributed init over gloo, forward, backward, sync, step.
  • _reference_single_process_loop(...): trains the same model on the same data sequentially on one rank, used by the test for byte-equal parameter equivalence after each step.

Run it:

python3 code/main.py

Output: a per-step training table comparing single-process loss and parameter checksum to the DDP run on 4 ranks. The two paths produce identical loss curves to float epsilon, proving the gradient sync is correct.

Production patterns in the wild

Three patterns harden DDP enough to ship.

Find unused parameters. Some forward paths skip parameters conditionally (early exit, mixture-of-experts router). The skipped parameters have no gradient, but DDP's bucket-ready hook still waits for them and the allreduce deadlocks. find_unused_parameters=True tells DDP to look at 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. The optimisation matters at scale: precomputing saves a few ms per step which compounds across 10000 steps.

Gradient accumulation needs care. Accumulating gradients over K microbatches without syncing each microbatch 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; the throughput drops to the floor.

Use It

Production patterns:

  • PyTorch DDP. The canonical implementation. torch.nn.parallel.DistributedDataParallel(model) wires bucketing, overlap, and the no_sync context.
  • HuggingFace Accelerate. Adds a launcher that handles torchrun env 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) replaces the per-parameter allreduce with reduce_scatter so each rank only stores its shard of the optimiser state. Lesson 81 composes DDP with ZeRO into the end-to-end demo.

Exercises

  1. Add gradient buckets of configurable size and measure the speedup vs one-allreduce-per-parameter on a deeper model.
  2. Implement no_sync() as a context manager and verify gradient accumulation matches a single-process baseline over K microbatches.
  3. Add a find_unused_parameters mode where the forward sometimes skips one of the MLP layers; without the flag the run should deadlock.
  4. Replace gloo with torch.distributed.barrier()-only synchronisation to feel the difference between allreduce-based and barrier-based sync.
  5. 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
Overlap "Hide comm" Issue allreduce while later layers still computing backward
no_sync "Accumulate" Skip the post-backward allreduce for gradient accumulation
find_unused "Branchy forward" Detect parameters with no grad before reducing

Further Reading

Check yourself

Quick quiz

  1. Why does DDP broadcast parameters at construction instead of relying on the same seed across ranks?

    1. Broadcast saves memory
    2. Broadcast is faster than seeding
    3. PyTorch forbids same-seed init
    4. Same-seed init drifts by float epsilon across ranks; broadcast guarantees byte-equal weights before training starts
    Show the answer

    D. Same-seed init drifts by float epsilon across ranks; broadcast guarantees byte-equal weights before training starts Float ops on different hardware (or even different process orders) can differ at the bit level. A broadcast from rank 0 is the only way to get byte-equal weights everywhere.

  2. What does DDP do after backward and before optimizer.step?

    1. Allreduce-SUM every parameter's gradient and divide by world_size to get the mean gradient
    2. Nothing
    3. Saves a checkpoint
    4. Broadcasts logits
    Show the answer

    A. Allreduce-SUM every parameter's gradient and divide by world_size to get the mean gradient The mean gradient is invariant to world_size so a learning rate tuned at one rank works at N ranks.

  3. Why divide by world_size after allreduce-SUM instead of using allreduce-MEAN?

    1. Allreduce-MEAN does not exist in gloo
    2. world_size is unknown
    3. Division by world_size on each rank is a local op; allreduce-MEAN would be implemented the same way under the hood
    4. Division produces NaN
    Show the answer

    C. Division by world_size on each rank is a local op; allreduce-MEAN would be implemented the same way under the hood Both approaches yield the same arithmetic. NCCL ships allreduce-AVG since 2.10; gloo does not, so divide locally after SUM.

  4. What does gradient bucketing in production DDP achieve?

    1. Coalesces many small allreduces into one large allreduce, amortising the per-call latency floor
    2. Frees gradient memory
    3. Quantises gradients to int8
    4. Skips backward
    Show the answer

    A. Coalesces many small allreduces into one large allreduce, amortising the per-call latency floor A 1B-parameter model has thousands of grad tensors; one allreduce per tensor pays gloo or NCCL latency thousands of times.

  5. Why does no_sync() exist in PyTorch DDP?

    1. To switch backends
    2. To turn off DDP
    3. To debug
    4. Gradient accumulation over K microbatches needs to skip the post-backward allreduce for K-1 of them; no_sync wraps that pause
    Show the answer

    D. Gradient accumulation over K microbatches needs to skip the post-backward allreduce for K-1 of them; no_sync wraps that pause Without no_sync the allreduce fires K times per accumulated step and throughput drops to the floor.

  6. Why does this lesson's reference implementation walk every rank's micro-batch sequentially in one process?

    1. It is the only way to use gloo
    2. Single-process is faster
    3. It saves memory
    4. It gives a byte-equal baseline: the sequential mean gradient must equal DDP's allreduced mean gradient at every step
    Show the answer

    D. It gives a byte-equal baseline: the sequential mean gradient must equal DDP's allreduced mean gradient at every step Equivalence to the reference loop is the load-bearing correctness test; without it gradient sync bugs hide until step 10000.

Questions people ask

How does distributed data parallel training work?

Each GPU holds a full model copy and trains on a different slice of the batch. After the backward pass, gradients are averaged across GPUs with allreduce, so every copy takes the same step. Training goes faster as more GPUs are added.

When should you use data parallel training?

When the model fits on one GPU but training is too slow. Adding GPUs with data parallelism lets you process more data per step. If the model itself does not fit, sharding methods like FSDP or ZeRO are needed.

Next lessonZeRO Optimizer State ShardingPrevious: Collective Ops From Scratch

Lessons from AI Engineering from Scratch by Rohit Ghumare and contributors, MIT license. Notes for owners by Lozo. License

Next step

Rather have it built for you?

Reading is optional. Tell me what's stuck and I'll build it into the business you already run.

  • A real conversation, not a pitch
  • You keep your accounts
  • Nothing sends without your yes