TAI BUI
Back to curriculum
CapstonePython

End-to-End Distributed Training

Lessons 76 through 80 each built one piece. This one assembles them: a tiny GPT trained across 4 simulated ranks, with DDP for the initial parameter sync, ZeRO-1 for optimiser-state sharding, and a sharded checkpoint at the halfway mark. The demo runs 20 steps and exits on its own, printing a loss curve and a memory profile and writing a checkpoint it can reload.

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

Learning Objectives

  • Compose DDP (lesson 77) plus ZeRO-1 (lesson 78) plus sharded checkpoints (lesson 80) into one training loop.
  • Train a 2-layer transformer language model on a small synthetic corpus for 20 steps across 4 simulated ranks.
  • Print a per-step rank-0 loss table, a per-rank optimiser memory profile, and the checkpoint directory, then verify that the step-10 optimiser master shards reload byte-equal on the same world size.
  • Defend the composition: each piece is independently testable in earlier lessons and this lesson demonstrates they compose.

The Problem

A capstone checks that the pieces fit together. Lesson 76 implemented collectives. Lesson 77 wrapped them into DDP. Lesson 78 sharded optimiser state with reduce_scatter. Lesson 79 analysed pipeline parallelism. Lesson 80 saved a sharded checkpoint. Each one had its own test. A real training run uses all of these primitives at once, and a wrong composition shows up as a diverging loss, a checkpoint that refuses to resume, or per-rank memory that grows when it should shrink.

The end-to-end demo is built around four invariants, but it only enforces one of them. (a) The loss table prints rank 0's loss for each of the 20 steps for you to read. Nothing checks that it falls, and it should not: the corpus is uniform random tokens, so there is nothing to learn and the demo tests wiring, not convergence. (b) Every rank should hold the same parameter norm. The script measures it once, after the last step, and prints the per-rank values with their drift. (c) Per-rank optimiser memory should follow the ZeRO-1 formula of 12P/N bytes (fp32 master copy, m and v, with P padded to a multiple of N). The script prints the measured shard bytes next to that figure and does not assert they match. (d) The checkpoint at step 10 should reload byte-equal. This is the only gate: if the reloaded optimiser master shards differ from the snapshot taken at save time, the script prints RESUME FAILED and returns 1. The demo is a single command that runs 20 steps and exits 0 when the resume check passes.

Warm-up0/3
1. Four copies of the same model train on different slices of data. What has to happen before each weight update so the copies stay identical?Warm-up
2. Two pieces each pass their own unit tests but fail when wired together. What is the most likely cause?Warm-up
3. A large file is copied between two machines and you want to know whether any byte changed on the way. What is the standard check?Warm-up
Check: The Problem0/6
1. In this lesson, what does an end-to-end run test that a separate unit test per piece does not?Define
2. Which state does ZeRO-1 shard across the four ranks in this demo?Define
3. After a run, rank 0's final parameter norm is 5.12 and rank 3's is 5.40. What does the demo do?Apply
4. The model has 29,824 parameters and runs on 4 ranks. What per-rank optimiser size does the demo print as expected?Apply
5. Suppose a model had P = 10 parameters on 4 ranks, using the demo's padding rule so P becomes a multiple of N. How many optimiser bytes does each rank hold?Apply
6. Every rank prints 357,888 optimiser bytes, while the expected figure printed beside them is 89,472. What does this point to?Analyze

The Concept

Mermaid diagram

The mini GPT

The model is 2 transformer blocks with embed dim 32, 4 attention heads, vocab 64, sequence length 16 and batch 4. It has 29,824 parameters (the script prints the count). That is large enough to exercise the wiring decisions that matter: multi-head attention runs the standard masked path, LayerNorm has weights to sync, and the LM head is a separate linear projection back to the vocab. It is small enough that 20 steps on 4 CPU ranks finish in well under a minute (about 36 seconds end to end here, including the torch import and process spawn).

The training data is torch.randint over the 64-token vocab, generated once from a seeded generator and sliced so each rank sees a different block at every step. The tokens are uniform random, so the next token cannot be predicted from the context. With random tokens there is nothing to learn, so expect the loss to stay near the uniform-guess value of ln 64 (about 4.16). In our run rank 0 printed values between 4.11 and 4.46 with no downward trend.

The composition rules

Lesson pieceWhat it ownsWhat it leaves to the loop
DDP broadcastInitial parameter syncOne dist.broadcast per parameter, in a loop at construct time
ZeRO-1 stepGradient sync (reduce_scatter sums the shards, then the step divides by world size to get the mean), master shard update, parameter all_gatherOne call per step replacing optimiser.step
Sharded checkpointPersist per-rank state, manifest with sha256Called on rank 0 with state collected via allgather
Training loopForward, backward, loss loggingCalls the three above in order

The loop knows nothing about reduce_scatter or rendezvous files. The ZeRO and checkpoint modules expose narrow interfaces, and the loop composes them.

Why a tiny GPT and not just an MLP

The MLP from lesson 77 was enough to verify gradient sync. A tiny GPT adds three things. The LM head over the vocab is separate (untied here for clarity, where a full GPT typically ties it to the token embedding). The loss is softmax plus cross-entropy, which has more numerical edge cases than MSE. And the forward pass is asymmetric: embeddings, then attention, then an MLP per layer. An MLP capstone would leave open whether the composition handles LayerNorm and the embedding layer's grad shape correctly.

A self-terminating run

The loop runs a fixed 20 steps and exits. There is no while True, no human intervention, and no resume from external state. You can leave the demo running unattended and find a complete log when it finishes, which is a decent sign the system is wired correctly. If a piece deadlocks, the demo never returns and the test rig catches it.

Check: The Concept0/13
1. What is a memory profile, and what does this demo print as one?Define
2. What makes the demo run self-terminating?Define
3. Why is the model a tiny GPT instead of an MLP?Explain
4. How does the demo combine DDP and ZeRO-1?Explain
5. A teammate expects the loss table to fall steadily over the 20 steps. Why is that not a valid expectation here?Explain
6. Why does the training loop know nothing about reduce_scatter or rendezvous files?Explain
7. What does a fixed 20-step loop with no while True give the test setup?Explain
8. What does load_sharded do with the sha256 values stored in the manifest?Explain
9. In the demo's ZeroOptimizer.step, reduce_scatter sums each chunk across ranks and the result is then divided by the world size. Why divide?Apply
10. In the demo's MiniGPT, the embed dim is 32 and there are 4 attention heads. What is the size of each head?Apply
11. In the demo, rank 2 on step 3 slices its block with offset = step * 4 * 4 * 17 + rank * 4 * 17 (world size 4, batch 4, sequence length 16 plus 1). Which token offset does it start from?Apply
12. Rank 0's loss reads 4.20 at step 5 and 4.46 at step 6, and a teammate says the wiring is broken. What is the right reading?Analyze
13. You want to export loss, grad norm and step time to JSONL, as in Exercise 4. Where does the lesson's composition suggest putting that code?Design

Build It

code/main.py implements:

  • MiniGPT: 2-layer transformer with masked self-attention and a separate LM head.
  • make_corpus(seed, total_tokens): seeded uniform random token ids from torch.randint, sliced into next-token input and target pairs. There is no pattern to learn, so the loss exercises the wiring and not the model.
  • _train_worker: spawned per rank. It broadcasts the init params, runs the loop, calls the ZeRO step, and writes the sharded checkpoint at step 10.
  • verify_resume: after the main run, reloads the step-10 checkpoint in-process and returns a bool: whether each rank's saved optimiser master shard equals the in-memory snapshot (torch.equal). It does not compare the model state or the m and v shards. main prints the result.
  • main: runs the whole demo and prints the loss table, the memory profile, and the verification result, and returns 1 if the check fails.

Run it:

bash
python3 code/main.py

Output: a 20-row loss table (rank 0 only), the final parameter norm of each rank with the drift between them, a 4-row per-rank optimiser shard size next to the expected size, the checkpoint directory path (not the manifest contents), and a "RESUME VERIFIED" line on success.

Check: Build It0/7
1. What is the resume contract in this lesson?Define
2. What does verify_resume actually compare?Define
3. With 4 ranks, batch 4, sequence length 16 and 20 steps, how many tokens does the demo ask make_corpus to generate?Apply
4. A checkpoint was saved with 4 ranks. What happens if load_sharded is called on it with expected_world_size set to 2?Apply
5. A teammate sees RESUME VERIFIED and concludes that the whole training state resumes exactly. What is wrong with that claim?Analyze
6. Reloading a checkpoint raises 'sha256 mismatch on rank 2' while the manifest's world size is right. What is the most likely cause?Analyze
7. You want a stricter resume check than the demo's. Which extension tests more of the resume contract?Design

Production patterns in the wild

Three patterns carry the composition over to real runs.

Checkpoint every K minutes, not every K steps. Step time varies with seq length and microbatch count, so a 10-minute cadence catches the same amount of compute regardless of model size. The demo checkpoints by step count for simplicity. Production runs use wall-clock time.

Detect divergence early. Production runs add a NaN guard after backward and a loss-spike detector. If loss jumps by more than 2x in one step, they roll back to the previous checkpoint instead of letting the optimiser march into a degenerate state. The demo has no such guard. Its random-token loss should stay near ln 64, so there is nothing for a detector to catch. Exercise 5 adds one.

Aggregate the memory profile across ranks. Per-rank memory differs in real runs (the rank holding the largest pipeline stage carries more activations). Production logs the max across ranks plus the mean. The demo prints per-rank optimiser shard bytes next to the expected figure so you can compare them with the formula.

Check: Production patterns in the wild0/4
1. Why does production use a wall-clock checkpoint cadence instead of a step count?Explain
2. Why does a production loss-spike detector roll back to the previous checkpoint after a jump of more than 2x in one step?Explain
3. In real runs per-rank memory differs, for example on the rank holding the largest pipeline stage. Why does production log the max across ranks and not only the mean?Explain
4. A multi-day run must survive a sudden loss spike with nobody watching. Which design follows the lesson's production patterns?Design

Use It

Where this shape shows up in production:

  • DeepSpeed. Combines DDP, ZeRO, pipeline and activation checkpointing under one config. The composition here is the DeepSpeed shape in miniature.
  • PyTorch FSDP. The native equivalent. FullyShardedDataParallel with ShardingStrategy.SHARD_GRAD_OP is ZeRO-2.
  • NeMo and Megatron-LM. Add tensor parallel for the very largest models. The rest of the composition has the same shape.
Check: Use It0/1
1. How does the lesson relate its composition to DeepSpeed?Explain

Ship It

The track ends here. The 6 lessons together make up the distributed-training subsystem a team would build before adopting DeepSpeed. The abstraction has been run against gloo, and the resume check guards the checkpoint path. Phase 17 (infrastructure and production) is where you take this to a real cluster.

Exercises

  1. Add a tensor-parallel split of the attention head and verify the loss matches the single-rank baseline. Use two ranks: half the heads per rank, with an allreduce of the attention output.
  2. Add gradient accumulation across 4 microbatches and show that the gradient equals the gradient of one big batch.
  3. Add a resume-from-step-10 path that continues training to step 20 and produces the same final loss as the original run.
  4. Add a metrics export (loss, grad norm, step time) to JSONL so the run can be visualised after the fact.
  5. Add a NaN guard that rolls back to the previous checkpoint on a loss spike, and force a spike with a one-step LR multiplier to exercise the rollback.

Key Terms

TermWhat people sayWhat it actually means
End-to-end"Wire it all up"One run composes the pieces, instead of a unit test per piece
Memory profile"GB per rank"Bytes held on each rank for params, grads, optimiser state. The demo prints only the optimiser shard bytes
Resume contract"Save and load"Saved state equals what was in memory at save time after a round-trip. The demo compares the optimiser master shards
Self-terminating"Bounded run"Fixed step count, exit 0 on completion, no human in the loop
Author's Note

My bet is that the byte-equal resume check is the line to trust most, and the memory line the one to read with the code open. verify_resume compares the reloaded master shards against the snapshot each rank took at step 10, and the reload goes through load_sharded, which checks each shard's sha256 against the manifest before it deserializes that shard. Treat the other three invariants as things the output lets you eyeball, since the script prints the loss table, the final norms with their drift, and the shard sizes next to the expected size.

I'd keep the capstone this boring on purpose. The loop calls three narrow interfaces in order and knows nothing about reduce_scatter, so when a run goes wrong you get a short list of places to look. If the checkpoint refuses to load, I'd start with the manifest and the world size, because load_sharded raises on a mismatch.

The production-patterns section describes a NaN guard and wall-clock checkpointing that code/main.py does not implement. Exercise 5 is the place to add the guard, and I'd do it before pointing this at anything you can't rerun.

Further Reading

Code