TAI BUI
Back to curriculum
CapstonePython

Distributed Data Parallel and FSDP from Scratch

Multi-rank training is two collectives and one rule. Broadcast the parameters at startup, average the gradients after backward, and keep every rank on the same step.

TIME: ~90 minutesPREREQUISITES: Phase 19 lessons 42 to 45

Learning Objectives

  • Bring up a process group across N ranks with the `gloo` backend, no special hardware.
  • Implement a minimal DDP wrapper that broadcasts parameters at construction and all-reduces gradients after backward.
  • Prove that the all-reduce of per-rank gradients matches a single-process gradient on the concatenated input.
  • Sketch FSDP parameter sharding: each rank holds a slice, the full tensor is gathered for the forward pass and dropped after.

The Problem

Data parallel training means each rank runs the same model on a different slice of the batch, then averages gradients before the optimizer step. FSDP (fully sharded data parallel) goes further: each rank holds a fraction of every parameter and reconstructs the full tensors layer by layer during the forward pass.

Here is when you reach for each. The model fits on one device, but the dataset is large and you want N times the examples per wallclock second. That is data parallel. If the model does not fit on one device either, that is FSDP.

The pain is bookkeeping. If parameters drift across ranks, the run is silently corrupt. If you average gradients but not the loss, the dashboard lies. If the collective backend cannot agree on a topology, the run hangs forever. My fix is to write the collectives by hand once, so you are not trusting a wrapper you cannot reproduce.

This lesson runs on CPU, with no CUDA assumed. The gloo backend ships with every PyTorch build and accepts torch.multiprocessing workers. The same code switches to nccl on a multi-GPU node without changing structure.

Warm-up0/2
1. Two workers each compute the mean cross-entropy loss on their own 16 examples. How do you get the mean loss over all 32 examples?Warm-up
2. Two copies of the same network are initialized from different random seeds. What do you expect before any training happens?Warm-up
Check: The Problem0/3
1. What does FSDP do that plain data parallel training does not?Define
2. Gradients are all-reduced correctly, but the dashboard plots only rank 0's local loss. What does that curve fail to show?Analyze
3. The parameters of your model do not fit on a single GPU, and you have 8 GPUs. Which setup fits?Design

The Concept

Mermaid diagram

A collective is an operation that every rank in a process group takes part in, and the DDP contract is built from two of them. The sections below say which collective runs when.

The two collectives that matter

CollectiveWhat it doesWhen
broadcastCopy a tensor from one rank to all othersParameter init, scheduler state, any one-to-all sync
all_reduceSum (or mean, or max) a tensor across all ranks, every rank gets the resultGradient averaging after backward
all_gatherEach rank contributes a tensor, every rank gets the concatenationLogits collection, FSDP parameter unshard

The DDP contract is broadcast at construction and all_reduce after backward, which MinimalDDP exposes as a sync_grads() method the training loop calls explicitly. The FSDP sketch adds all_gather before each layer's forward pass.

Gradient averaging matches single-process gradient

A model trained on a batch of B examples across N ranks must produce the same gradient as a single process training on a batch of N*B. Summing per-rank gradients and dividing by N gives the average loss gradient, which is what cross entropy with mean reduction would produce on the full batch. The code asserts this with max-abs-diff < 1e-4 between the manual all-reduce gradient and the reference single-process gradient. The looser 1e-3 threshold belongs to a different check, param_sum_spread across ranks (Exercise 1).

FSDP sketch

Mermaid diagram

The memory win is exact: per-rank memory for parameters drops to 1/N. The cost is the gather, which is paid on each forward pass. Production FSDP overlaps the gather with the previous layer's compute, so the wallclock cost is much smaller than the naive accounting predicts. The code does the round-trip on every parameter and asserts with torch.allclose that the reconstruction matches the original.

CPU and the gloo backend

CUDA is the production target, but the same code paths exist on CPU. gloo is the CPU collective backend. It is slower than nccl on GPUs by orders of magnitude, but the API surface is identical. The process group is initialized with backend="gloo" and ranks are spawned with torch.multiprocessing rather than torchrun. Both end up at the same torch.distributed calls. On a multi-GPU node, the only changes are backend="nccl", device tensors, and torchrun to launch.

Check: The Concept0/9
1. What does `dist.broadcast(tensor, src=0)` do?Define
2. What is a backend in `torch.distributed`?Define
3. After `all_reduce` with a sum op on a tensor, what does each rank hold?Define
4. What does unshard mean in the FSDP sketch?Define
5. What does the manual all-reduce check in main.py compare, and how strict is the assertion?Explain
6. Why does the all-reduce of per-rank gradients, followed by a divide by N, match the gradient from one process on N times the batch?Explain
7. What does FSDP pay for cutting per-rank parameter memory to 1/N?Explain
8. You move the CPU gloo demo to a node with 8 GPUs. Which changes does the code need?Design
9. You wrote a new gradient-sync routine and have no GPUs. How do you check that it is correct?Design

Build It

code/main.py is the runnable artifact.

Step 1: bring up the process group

python
os.environ["MASTER_ADDR"] = "127.0.0.1"
os.environ["MASTER_PORT"] = str(port)
dist.init_process_group(backend="gloo", rank=rank, world_size=world_size)

MASTER_ADDR and MASTER_PORT are the rendezvous: every rank dials the same port on the same host. The demo also points GLOO_SOCKET_IFNAME and TP_SOCKET_IFNAME at the loopback interface so the ranks talk over localhost. A bind-and-close trick picks a free port, which avoids collisions when several runs share a machine.

Step 2: broadcast at construction

MinimalDDP.__init__ walks every parameter and buffer and calls dist.broadcast(tensor, src=0). Rank 0's values become the canonical init. Skip this and each rank initializes from its own seed, so the ranks diverge from step one.

Step 3: all-reduce gradients after backward

python
def all_reduce_grads_(module, world_size):
    for p in module.parameters():
        if p.grad is None:
            p.grad = torch.zeros_like(p.data)
        dist.all_reduce(p.grad.data, op=dist.ReduceOp.SUM)
        p.grad.data.div_(world_size)

MinimalDDP.sync_grads() wraps this function, and the training loop calls it explicitly between loss.backward() and optimizer.step(). Every rank ends up with the same averaged gradient. The optimizer step then sees the same input on every rank, which is why the parameters stay in sync across the run.

Step 4: prove the equivalence

manual_all_reduce_matches_single_process builds the same model on rank 0 and compares the post-all-reduce gradient against the gradient a single process would compute on the concatenated input. The demo asserts the max-abs-diff stays well under the 1e-4 threshold.

Step 5: FSDP round trip

fsdp_round_trip_sketch flattens each parameter, pads to a multiple of world_size, slices, all-gathers, and unpads. Every rank's reconstruction matches the original under torch.allclose. That is the unshard step. The inverse (re-shard after the forward) is one slice off the gathered tensor.

Run it:

bash
python3 code/main.py

Default world size is 2. Two CPU processes spawn, talk to each other through gloo, and exit zero. The output outputs/ddp-demo.json captures parameter sums per rank, the gradient norm after all-reduce, the FSDP round-trip result, and the manual-vs-reference gradient diff.

Check: Build It0/19
1. What is the world size of a process group?Define
2. What is a rank?Define
3. Why do the ranks all use the same MASTER_ADDR and MASTER_PORT before `init_process_group`?Explain
4. In MinimalDDP, what causes gradients to be averaged across ranks after backward?Explain
5. What happens if MinimalDDP skips the broadcast at construction?Explain
6. Why does the FSDP sketch pad each flattened parameter before slicing it per rank?Explain
7. How does the demo decide the FSDP round trip worked?Explain
8. Reading the demo output, a param_sum_spread near zero across ranks tells you what?Explain
9. x has 10 rows and world_size is 3. Which slice of x does `shard_for_rank` return for rank 1?Apply
10. Default model, world_size 3. The first layer's bias has 16 elements. In `fsdp_round_trip_sketch`, what is each rank's slice length and how many zeros are padded?Apply
11. world_size is 2. A parameter has grad None on rank 0 and a gradient of 6.0 on rank 1. What do the ranks hold for it after `all_reduce_grads_`?Apply
12. A MinimalDDP is built with world_size=1 and `sync_grads()` is called after backward. What happens?Apply
13. In `test_shard_for_rank_handles_remainder`, 11 elements are split over 3 ranks. What sizes does `shard_for_rank` return for ranks 0, 1 and 2?Apply
14. In `test_grad_norm_zero_when_no_grads`, backward is never called. What does `_grad_norm` return and why?Apply
15. The test calls `write_demo({"world_size": 2}, path)` and reads the file back. Which keys does the JSON hold?Apply
16. A teammate wraps a model in MinimalDDP and trains on 2 ranks. Loss falls, but param_sum_spread grows every step. What is the most likely bug?Analyze
17. Four ranks start but the run hangs forever inside `init_process_group`, with rank 3 reading a different MASTER_PORT. What is the cause?Analyze
18. A rewrite of `all_reduce_grads_` drops the `div_(world_size)` line. With 4 ranks, how does the synced gradient compare with the full-batch single-process gradient?Analyze
19. In the FSDP sketch, per-rank memory is still the full model size after the forward pass. Which step is missing?Analyze

Use It

Production training stacks call the same primitives. PyTorch's DistributedDataParallel adds post-backward gradient hooks that overlap all-reduce with backward, bucketed all-reduce that combines several small gradients into one collective, and the no_sync context lesson 46 used.

PyTorch's FSDP adds a flat parameter view per layer, so each rank holds one contiguous buffer. It also overlaps the next layer's unshard with the current layer's compute, and offers optional CPU offload for the shards.

The shape stays the same: broadcast at startup, reduce after backward, shard parameters when they no longer fit.

Check: Use It0/2
1. Beyond broadcast and all-reduce, what does PyTorch's real DistributedDataParallel add?Explain
2. What does production FSDP add on top of the sketch's pad-slice-gather cycle?Explain

Ship It

outputs/skill-distributed-fsdp-ddp.md carries the recipe for a new training script. Spin up the process group with gloo for CPU and nccl for GPU. Wrap the model in a DDP shell that broadcasts at construction and reduces after backward. Optionally shard parameters with the all_gather pattern from the FSDP sketch.

Exercises

  1. Run with --world-size 4 and confirm the param spread stays under 1e-3 across the run.
  2. Replace the manual averaging with dist.all_reduce(op=dist.ReduceOp.AVG) and time the difference.
  3. Add a post-backward hook to the DDP wrapper so the all-reduce overlaps with the rest of the backward; measure the wallclock improvement.
  4. Implement the FSDP re-shard step: after the forward pass, replace the full tensor with the local shard again. Confirm per-rank memory drops.
  5. Switch the backend to nccl on a CUDA box. Note which environment variables change and which stay the same.

Key Terms

TermWhat people sayWhat it actually means
Backend"gloo or nccl"The library that implements the collective ops; gloo is CPU, nccl is GPU
World size"Total ranks"Number of processes in the group; the group is the unit collectives operate on
Rank"Worker id"Process identifier within the group, zero indexed
All-reduce"Sum the grads"Sum a tensor across all ranks, every rank ends with the same result
Unshard"Gather the params"Reconstruct the full tensor from per-rank slices via all_gather
Author's Note

I'd write the two collectives by hand before reaching for DistributedDataParallel. The reason is the bookkeeping failures from the problem section: drifting parameters and a lying dashboard are hard to spot when a wrapper hides them. With the broadcast and the all-reduce in front of you, you know where to look.

My bet is that the FSDP sketch is the part worth slowing down on. The gather and unpad round trip is small enough to hold in your head, and it shows where the 1/N slice comes from. Exercise 4 is where you confirm the memory drop. I'd run Exercise 1 at --world-size 4 before moving on, so you see the param spread assert hold with more than two ranks.

Further Reading

  • PyTorch torch.distributed documentation for the collective semantics this lesson relies on.
  • The gloo library's collective list, identical in shape to the CUDA-backed nccl primitives.
  • Phase 19 lesson 46 for the gradient accumulation pattern that wraps the DDP all-reduce in no_sync.
  • Phase 19 lesson 47 for the checkpoint layout that survives DDP and FSDP runs.
  • PyTorch FSDP documentation for the production implementation of the parameter sharding sketched here.

Code

Ships With