TAI BUI
Back to curriculum
CapstonePython

Collective Ops From Scratch

Allreduce, broadcast, allgather, and reduce_scatter are the four collectives that carry distributed training, and most of what a training framework offers is built from them. Build each one over a `multiprocessing.Queue` mesh, check it against a reference implementation, and the rest of the track is plumbing.

TIME: ~90 minPREREQUISITES: Phase 19 Track C lessons 42-49

Learning Objectives

  • Implement ring allreduce in two passes (reduce-scatter then allgather) and prove the per-rank communication volume is 2(N-1)/N bytes per element.
  • Build broadcast, allgather, and reduce_scatter on top of point-to-point sends over `multiprocessing.Queue`.
  • Verify every primitive against a `torch.distributed` gloo reference for the same input.
  • Defend the choice of ring versus tree on cluster shape, latency floor, and bandwidth ceiling.

The Problem

Four collectives do the communication in distributed training. Allreduce leaves every rank holding the same reduced tensor. Broadcast copies one rank's tensor to the others. Allgather gives every rank every other rank's shard. Reduce_scatter leaves each rank with the sum of one chunk only.

Allreduce is the one that hurts when it's done naively. A naive version over N ranks sends N times the tensor to a root and broadcasts N times back. Bandwidth scales as O(N) per rank, the root becomes a bottleneck, and the wall-clock floor is the slowest link times N. Ring allreduce flattens that into 2(N-1) chunks of size T/N, so per-rank bytes drop to 2T(N-1)/N independent of cluster size. Tree allreduce wins on small N and high-latency links because depth is log2(N) hops instead of 2(N-1). Pick the wrong topology for the cluster shape and the slowest GPU dictates step time.

The frameworks you'll read in this track lean on these four. PyTorch DDP synchronises gradients with one allreduce per parameter bucket. ZeRO shards optimiser state by reduce_scatter and broadcasts updated parameters by allgather. FSDP turns the full forward into allgather plus reduce_scatter. Pipeline parallel needs broadcast for activations across stage groups. Without the four collectives in your head, it's hard to say why training stalls, why a gradient mismatch shows up at rank 3, or why the pipeline bubble doubles when you swap topologies.

Warm-up0/3
1. Four workers each hold a number and all need the total. Everyone sends their number to worker 0, which adds them and sends the total back to everyone. As the worker count grows into the hundreds, what limits this scheme?Warm-up
2. Two Python processes need to pass tensors in order, one process writing and the other reading. Which standard-library tool fits?Warm-up
3. In Python, summing the same list of floats in two different orders can give results that differ in the last digits. What is the usual reason?Warm-up
Check: The Problem0/5
1. What is the defining outcome of an allreduce across N ranks?Define
2. What does broadcast do in a group of ranks?Define
3. What does each rank hold after an allgather?Define
4. What does each rank hold after reduce_scatter?Define
5. Why does ring allreduce keep per-rank traffic roughly constant as N grows, while the naive send-to-root scheme grows with N?Explain

The Concept

Mermaid diagram

Ring allreduce in two passes

Ring allreduce computes the elementwise sum of every rank's tensor and leaves the result on all ranks, using only sends between neighbours on a cycle.

Split the tensor into N equal chunks indexed 0..N-1. Rank r starts by sending the chunk whose index equals its rank. If the element count does not divide by N, ring_allreduce zero-pads the tensor up to the next multiple of N and trims the result back at the end. Pass 1, reduce-scatter, runs N-1 steps. At step s, rank r sends chunk (r - s) mod N to rank (r + 1) mod N and receives chunk (r - s - 1) mod N from rank (r - 1) mod N, accumulating the received chunk into its local copy. After N-1 steps, rank r owns the full sum for chunk (r + 1) mod N. Pass 2, allgather, runs another N-1 steps and rotates the finished chunks around the ring until each rank holds the full sum for every chunk.

PrimitivePer-rank bytesStepsWhen to use
Ring allreduce2T(N-1)/N2(N-1)Large T, fat-pipe homogeneous cluster
Tree allreduceT log2(N)2 log2(N)Small T or high-latency links
BroadcastTlog2(N) treeParameter init, scalar config
AllgatherT(N-1)/NN-1Sharded forward, ZeRO unshard
Reduce_scatterT(N-1)/NN-1ZeRO gradient sharding

The table lists tree allreduce for comparison only. code/main.py implements ring allreduce, and its broadcast is a doubling tree, but there is no tree allreduce.

Queue mesh as a stand-in for NCCL

NCCL runs over PCIe and NVLink with hardware-offloaded reductions, and a CPU has no equivalent. Here every ordered pair of ranks gets its own multiprocessing.Queue (a full NxN grid), and the ring algorithms use only the neighbour edges. Each queue gives ordered point-to-point delivery with a single producer and a single consumer. The reduction happens in user space, so you pay Python overhead, but the wire pattern matches NCCL's ring allreduce. Check correctness on the queue version and the schedule carries over to a cluster. The timings don't.

Verify against gloo

verify_against_gloo runs each primitive on the queue mesh and through torch.distributed initialised with the gloo backend, on the same tensors and world size, and main() calls it for all four. The check passes when the max absolute difference is below 1e-5. It is a tolerance rather than byte equality because float addition is not associative, so two implementations that sum in a different order can differ in the last bits. If your ring allreduce diverges from gloo by more than that, the check fails. A reference implementation catches the bugs that make a primitive only look correct. Without it, a bug can survive until step 10000 of a real training run.

Check: The Concept0/9
1. In a ring allreduce, what does the word 'ring' describe?Define
2. What is a tree allreduce?Define
3. Ring allreduce moves 2T(N-1)/N bytes per rank, while allgather alone moves T(N-1)/N. Why is allreduce twice as costly?Explain
4. Why does ring allreduce need an allgather pass after the reduce-scatter pass?Explain
5. How does `Mesh` wire the ranks together, and which queues does ring allreduce actually use?Explain
6. Why use a tree broadcast instead of N-1 direct sends from the source?Explain
7. Why does `verify_against_gloo` compare with a max-absolute-difference tolerance instead of requiring identical bytes?Explain
8. When does tree allreduce win over ring allreduce?Explain
9. In this implementation, after the reduce-scatter pass of `ring_allreduce` on N ranks, which chunk holds the full sum on rank r?Apply

Build It

code/main.py implements:

  • Mesh dataclass, one rank's view of a full NxN grid of multiprocessing.Queues. It holds rank and world_size and exposes send(dst, tensor) and recv(src). recv times out after RECV_TIMEOUT_S = 30.0. The ring algorithms use only the neighbour edges.
  • ring_allreduce(mesh, tensor) running the two-pass algorithm, zero-padding to a multiple of world_size.
  • broadcast(mesh, tensor, src) over a doubling tree.
  • allgather(mesh, tensor) using N-1 rotations.
  • reduce_scatter(mesh, tensor) as the first half of allreduce. It pre-rotates the chunks so rank r returns the sum of chunk r, matching the torch.distributed contract, and raises ValueError when the element count is not divisible by world_size.
  • gloo_reference(op, world_size, per_rank_tensors) that runs the same inputs through torch.distributed with gloo, and verify_against_gloo(op, world_size, per_rank_tensors) that returns (match, max_abs_diff) with a 1e-5 threshold.

Run it:

bash
python3 code/main.py

The output is a per-primitive verification table comparing queue-mesh and gloo outputs, followed by a per-rank byte counter that checks the 2T(N-1)/N scaling.

Check: Build It0/9
1. You run `ring_allreduce` on 4 ranks with a 10-element tensor on each. What happens?Apply
2. In `main()`, 4 ranks each hold 64 float32 elements (T = 256 bytes). What per-rank byte count should the allreduce counter report?Apply
3. `broadcast` runs on 8 ranks with source 0, and the value starts on rank 0 only. After round 0 ranks 0 and 1 hold it. Which sends happen in round 1, where each holder h sends to h + 2?Apply
4. Three ranks call `allgather` with shards [0, 1], [10, 11] and [20, 21] on ranks 0, 1 and 2. What does rank 1 return?Apply
5. Three ranks call `reduce_scatter`, and rank r holds [0, 1, 2, 3, 4, 5] multiplied by (r + 1). Chunk r goes to rank r. What does rank 1 return?Apply
6. You call `reduce_scatter` on 4 ranks with a 10-element tensor on each. What happens?Apply
7. A new allreduce matches gloo within 1e-5, but its byte counter reports about three times the 2T(N-1)/N the ring should move. What does that say?Analyze
8. A refactor deletes the pre-rotation line in `reduce_scatter`, so the ring runs on the original chunk order. Each rank still returns a fully summed chunk, yet the gloo check fails on 4 ranks. Why?Analyze
9. During a ring allreduce built from this lesson's `Mesh`, one rank crashes. What do the ranks waiting on its data observe with the shipped code?Analyze

Production patterns in the wild

Three patterns harden the primitives enough to ship.

Bucket gradients before allreduce. A 1B-parameter model has tens of thousands of gradient tensors. One allreduce per tensor pays the latency floor N times. DDP buckets gradients into ~25 MB chunks and issues one allreduce per bucket, so the small tensors ride along with the big ones. Skip bucketing and the latency overhead dominates the step.

Overlap communication with computation. Backward computes gradients layer by layer in reverse order. Once the last layer's gradient is ready, start its allreduce while the next layer keeps computing. PyTorch DDP wires this with bucket-ready hooks. When the network has slack, the overlap can halve visible communication time.

Pick ring or tree by message size. NCCL ships a topology detector that picks ring for messages above ~1 MB and tree below. The crossover is bandwidth versus latency: above 1 MB, the bandwidth term 2T(N-1)/N dominates and ring wins, and below 1 MB the log2(N) hop count wins. Hard-coding one topology costs throughput at the wrong message size.

Check: Production patterns in the wild0/5
1. In DDP-style gradient synchronisation, what is a bucket?Define
2. Why does DDP start a bucket's allreduce while backward is still computing the remaining layers' gradients?Explain
3. A team hard-codes ring allreduce and sees poor throughput when synchronising 64 KB messages on a large cluster. Which explanation fits NCCL's ring-versus-tree rule of thumb?Analyze
4. Allreduce time on a cluster is dominated by per-call latency because the model has tens of thousands of small gradient tensors. What is the standard fix?Analyze
5. You must synchronise gradients for a 1B-parameter model with tens of thousands of tensors on a homogeneous cluster with fast links. Which design fits?Design

Use It

Where the primitives show up:

  • PyTorch DDP. Calls dist.all_reduce on bucketed gradients after backward. The bucket size is tunable, and the 25 MB default is reasonable for 100Gbit Ethernet.
  • DeepSpeed ZeRO. Issues reduce_scatter to shard gradients and allgather to reconstruct full parameters before forward. Those are the same primitives you build here.
  • FSDP. Forward begins with allgather to unshard the layer, computes, then reduces with reduce_scatter and discards the unshard. Same primitives, different schedule.
Check: Use It0/3
1. A ZeRO-style trainer wants each rank to keep only the summed gradient for the shard it will update. Which collective fits?Explain
2. Each rank of an FSDP layer holds only a shard of its weights. Which collective gathers the full weights before the layer computes its forward?Explain
3. You want each rank to update only 1/N of the parameters and store only that shard's optimiser state, yet every rank needs the full updated parameters for the next forward. Which sequence fits?Design

Ship It

Lessons 77-81 build on these queue-mesh primitives. Lesson 77 wires allreduce into DDP, lesson 78 wires reduce_scatter into ZeRO, and lesson 79 wires broadcast into pipeline activations. Lesson 81 composes all four into the end-to-end demo.

Exercises

  1. Add a tree allreduce variant and switch between ring and tree by message size. Measure the crossover.
  2. Mesh.recv already hardcodes RECV_TIMEOUT_S = 30.0. Make it configurable with a recv_timeout_ms argument so a stalled rank surfaces a deadline error you choose instead of waiting a fixed 30 seconds.
  3. Replace multiprocessing.Queue with TCP sockets for the four primitives. Keep verify_against_gloo and use a real wire.
  4. Add a bandwidth instrumentation hook so the per-rank byte counter logs to JSONL.
  5. Compare wall-clock time of ring versus tree on 4 ranks for tensors of size 1KB, 1MB, 16MB. Defend the crossover empirically.

Key Terms

TermWhat people sayWhat it actually means
Allreduce"Sum across ranks"After the call every rank holds the same reduced tensor
Ring"The fast topology"N-1 chunks of size T/N flow around the cycle twice
Tree"The log topology"Reduction follows a binary tree; depth is log2(N) hops
Allgather"Concatenate shards"Every rank ends with every other rank's shard
Reduce_scatter"Split the sum"Each rank ends with the sum of one chunk only
Bucket"Fuse small tensors"Coalesce N small allreduces into one large one
Author's Note

My bet is that the byte-counter line deserves a closer read than the match table. A gloo match says the queue mesh agrees with a reference on the tensors it was given. The measured-versus-expected comparison against 2T(N-1)/N says the ring allreduce is moving the traffic the formula predicts, and I'd want both before trusting it.

I'd treat ring versus tree as a measurement, not a rule. The ~1 MB crossover is the figure NCCL uses, and code/main.py implements ring allreduce but no tree allreduce, so that side is left to exercises 1 and 5. Run them before you defend a topology.

If something breaks, I'd start with reduce_scatter. It runs the same N-1 step send and receive loop as the first pass of ring_allreduce, so it is the smaller place to check that schedule.

Further Reading

Code