TAI BUI
Back to curriculum
BuildPython

Scaling: Distributed Training, FSDP, DeepSpeed

Your 124M model trained on one GPU. At 7 billion parameters the model no longer fits in memory, and the data would take weeks on a single machine. Past that point you split the work across GPUs or you don't train.

TIME: ~120 minutesPREREQUISITES: Phase 10, Lesson 04 (Pre-Training a Mini GPT)

Learning Objectives

  • Explain the three types of parallelism (data, tensor, pipeline) and when each is necessary based on model and cluster size
  • Simulate data-parallel gradient averaging and a tensor-parallel column split in numpy, and check both against a single-device result
  • Calculate the memory budget for a given model size (weights + optimizer states + gradients + activations) to determine the minimum hardware
  • Estimate the pipeline bubble fraction for a given stage and micro-batch count and show how more micro-batches shrink it
  • Compare DDP, FSDP, and DeepSpeed ZeRO by per-GPU memory to decide how many GPUs a model needs

The Problem

A 7B parameter model in FP16 needs 14GB just for the weights. Adam optimizer stores two additional copies of every parameter (first and second moment estimates). That is another 28GB. Gradients during backpropagation add 14GB more. You are at 56GB before a single activation is stored.

An NVIDIA A100 has 80GB of memory.

That is 56GB of 80GB used, which leaves 24GB for activations, the intermediate values computed during the forward pass that must be kept alive for backpropagation. For a 2048-token sequence with a 4096-dimensional model, a single layer's activations use about 64MB. With 32 layers, you need 2GB per sample. A batch size of 8 requires 16GB. You have 24GB. A batch size of 12 blows up.

Now try 70B parameters. Weights alone: 140GB in FP16. Does not fit on one GPU. You need at least 2 A100s (2 x 80GB = 160GB) just to hold the weights. Add optimizer states and gradients and you need far more: 3+ GPUs minimum, and realistically 8-16 depending on sharding strategy.

Llama 3 405B was trained on 16,384 NVIDIA H100 GPUs. The training run cost an estimated $100 million in compute. DeepSeek V3 trained a comparable model for roughly $5.6 million by changing the architecture (Mixture of Experts means only a fraction of parameters activate per token) and by training more efficiently.

This lesson covers the four strategies that make large-scale training possible: data parallelism, tensor parallelism, pipeline parallelism, and fully sharded data parallelism. You will simulate data, tensor, and pipeline parallelism in numpy and model FSDP's memory savings in a calculator, so the mechanics are clear before you touch a distributed training framework.

Warm-up0/3
1. A 7B parameter model in FP16 needs how much VRAM just for weights?Warm-up
A box reading parameter count 7,000,000,000 next to a box reading bytes per parameter, FP16, 2. Below them a line asks for the total weight memory in GB, marked with a question mark.
Setup numbers from the stem.
2. In a single-GPU training loop, what does Adam store for every parameter besides the weight and its gradient?Warm-up
A weight box and a gradient box, each holding one value for a single parameter on a single GPU. A line below asks what Adam also keeps per parameter, marked with a question mark.
Setup only; what Adam adds is the question.
3. A batch of 64 examples is split into two equal halves of 32, and each half's mean gradient is computed separately. How do you get the full-batch mean gradient?Warm-up
Two halves of a 64-example batch, each 32 examples, producing its own mean gradient g1 and g2. Arrows lead down to a dashed box for the full-batch mean gradient, with a question mark on how g1 and g2 combine.
Setup only; the combining rule is the question.

The Concept

Why Distribution is Required

Here is the memory math for real models. Each number comes straight from parameter count times bytes per parameter.

ModelParamsWeights (FP16)Adam StatesGradients (FP16)Total (no activations)
GPT-2 Small124M248 MB992 MB248 MB1.5 GB
Llama 3 8B8B16 GB64 GB16 GB96 GB
Llama 3 70B70B140 GB560 GB140 GB840 GB
Llama 3 405B405B810 GB3,240 GB810 GB4,860 GB

The "Adam States" column is the killer. Adam stores a running mean (m) and a running variance (v) for every parameter, both in FP32. For a 70B model, that is 70B x 4 bytes x 2 = 560GB. The optimizer alone needs seven A100s.

A single H100 has 80GB. Llama 3 405B needs at least 61 H100s to hold the weights, optimizer, and gradients. Add activations and the number grows further. Meta used 16,384 GPUs because the job needed them.

Data Parallelism

The simplest distributed strategy. Copy the entire model to N GPUs. Split each training batch into N equal parts. Each GPU runs a forward and backward pass on its shard of the data. After the backward pass, average the gradients across all GPUs. Every GPU updates its copy of the weights with the same averaged gradients, keeping all copies in sync.

The good: Throughput scales close to linearly. N GPUs process N times more data per step. Communication is limited to gradient averaging, which overlaps with computation.

The bad: Every GPU holds a complete copy of the model, optimizer states, and gradients. For a 70B model, each GPU needs 840GB. Data parallelism does nothing to reduce per-GPU memory. It only reduces training time.

The math: Effective batch size = per_gpu_batch_size x N. For N=64 GPUs with per-GPU batch of 16, the effective batch is 1,024. Llama 3 used an effective batch size of 16 million tokens per step.

Mermaid diagram

Tensor Parallelism

Split individual layers across GPUs. A single matrix multiplication is divided among GPUs, each computing part of the result.

Consider a weight matrix of shape (8192, 8192) in a feedforward layer. With 4-way tensor parallelism, each GPU holds a (8192, 2048) shard. Each GPU multiplies the input by its shard, producing a partial result. The partial results are combined (via all-reduce or all-gather) to produce the full output.

The good: Reduces per-GPU memory for model weights. A 70B model split across 8 GPUs means each GPU holds ~8.75B parameters worth of weights.

The bad: Requires fast inter-GPU communication after every layer. The all-reduce after each matmul adds latency. This works well with NVLink (900 GB/s between GPUs on the same node) but poorly across nodes connected by InfiniBand (400 Gb/s, about 50 GB/s). In practice tensor parallelism stays within a single node (8 GPUs).

Real usage: Megatron-LM pioneered tensor parallelism. Llama 3 405B uses 8-way tensor parallelism within each node.

Pipeline Parallelism

Split the model by layers. GPU 1 runs layers 1-8. GPU 2 runs layers 9-16. GPU 3 runs layers 17-24. GPU 4 runs layers 25-32. Data flows through the pipeline: GPU 1 computes its layers and sends activations to GPU 2, which computes its layers and sends to GPU 3, and so on.

The good: Little communication between GPUs: just the activations at layer boundaries, which are small compared to gradients or weights. Works across nodes because bandwidth requirements are low.

The bad: Pipeline bubbles. When GPU 4 is computing the forward pass on micro-batch 1, GPUs 1, 2, and 3 are idle (they have already forwarded their portion). During backward pass, the pattern reverses. With naive pipelining, GPU utilization is only 1/N for N pipeline stages.

GPipe and PipeDream shrink the bubble by splitting the batch into micro-batches. GPU 1 starts on micro-batch 2 as soon as it finishes forwarding micro-batch 1. This overlaps computation across pipeline stages. With M micro-batches and N stages, the bubble fraction drops to (N-1)/M. Use M=16 micro-batches with N=4 stages and the bubble is 3/16 = 18.75% idle time.

FSDP: Fully Sharded Data Parallel

FSDP combines the scalability of data parallelism with the memory efficiency of sharding. Instead of each GPU holding a complete copy of the model, each GPU holds only 1/N of the parameters, gradients, and optimizer states.

Before a layer's forward pass, FSDP runs an all-gather to collect the full parameters from all GPUs into each GPU's memory. After the forward pass, each GPU discards the non-local parameters. During backward, the all-gather runs again to reconstruct parameters for gradient computation. After the backward pass, a reduce-scatter distributes gradient shards so each GPU only stores 1/N of the gradients.

The math for a 70B model on 8 GPUs:

ComponentWithout FSDPWith FSDP
Weights (FP16)140 GB per GPU17.5 GB per GPU
Adam States (FP32)560 GB per GPU70 GB per GPU
Gradients (FP16)140 GB per GPU17.5 GB per GPU
Total840 GB per GPU105 GB per GPU

Without FSDP, you cannot fit a 70B model on a single 80GB GPU. With FSDP on 8 GPUs, each GPU still needs 105GB, which does not fit. Going to 16 GPUs gets you to 52.5GB before activations, and you can also combine FSDP with activation checkpointing (recompute activations during backward instead of storing them).

The communication cost is higher than vanilla data parallelism because of the all-gather before each layer. But the memory savings are what make the larger runs feasible.

Mermaid diagram

DeepSpeed ZeRO

DeepSpeed's ZeRO (Zero Redundancy Optimizer) is conceptually close to FSDP and was developed independently by Microsoft. It defines three stages, each sharding more aggressively:

StageShardsMemory SavingsCommunication
ZeRO-1Optimizer states only~4x reductionSame as data parallel
ZeRO-2+ Gradients~8x reductionSlightly more
ZeRO-3+ Parameters~Nx reduction (N GPUs)All-gather per layer

ZeRO-3 is equivalent to FSDP. The naming is different, the mechanism is the same. PyTorch added FSDP as a native implementation after DeepSpeed proved the concept.

DeepSpeed also introduced ZeRO-Offload (offload optimizer states to CPU RAM, which is cheaper and larger) and ZeRO-Infinity (offload to NVMe SSDs). These trade compute speed for memory capacity: the offloaded operations are slower but free up GPU memory.

Mixed Precision Training

Modern training uses multiple floating-point formats simultaneously:

  • Forward pass: FP16 or BF16 (16-bit). Half the memory of FP32. Matmuls run 2x faster on tensor cores.
  • Master weights: FP32 (32-bit). Maintained by the optimizer for numerical precision during weight updates.
  • Loss scaling: Multiply the loss by a large constant before backward pass to prevent FP16 gradients from underflowing to zero. Divide by the same constant before the optimizer step.

BF16 (Brain Float 16) has the same exponent range as FP32 (8 exponent bits) but reduced precision (7 mantissa bits vs FP32's 23). It rarely needs loss scaling because it can represent the same range of values. FP16 has 5 exponent bits and 10 mantissa bits, so it can represent fine-grained values but overflows or underflows at extreme magnitudes.

Google's TPUs use BF16 natively. NVIDIA's A100 and H100 support both FP16 and BF16. The industry has largely moved to BF16 because it removes most of the loss scaling hassle.

Memory comparison for a 7B model:

PrecisionWeightsOptimizerGradientsTotal
FP32 everywhere28 GB56 GB28 GB112 GB
Mixed (BF16 + FP32 master)14 GB56 GB14 GB84 GB

Mixed precision saves 28GB on this model. The optimizer states stay in FP32 regardless of precision, and that is where most of the memory goes.

Megatron-LM and 3D Parallelism

Large-scale training typically combines all three parallelisms:

  • Data parallelism across groups of nodes (scale batch size)
  • Tensor parallelism within a node (split layers across 8 GPUs)
  • Pipeline parallelism across nodes (split layer groups across machines)

Llama 3 405B on 16,384 H100s:

  • 8-way tensor parallelism within each node (8 GPUs per node)
  • 16-way pipeline parallelism across nodes (16 pipeline stages)
  • 128-way data parallelism across the remaining dimension (16,384 / 8 / 16 = 128)

This 3D decomposition (8 x 16 x 128 = 16,384) is how you scale to thousands of GPUs. Each GPU sees a different data shard (data parallel), holds one slice of each layer (tensor parallel), and computes a different set of layers (pipeline parallel).

DeepSeek V3 took a different approach. Their Mixture of Experts architecture activates only 37B out of 671B parameters per token. This means each GPU only needs to compute (and store activations for) the active parameters. They trained on 2,048 H800 GPUs (less than 1/8 of Meta's GPU count) for $5.6M vs Meta's estimated $100M.

Mermaid diagram

Check: The Concept0/29
1. What are the three types of parallelism used in distributed training?Define
Three unlabeled strategy boxes, A, B and C, each with a different generic placeholder mark, under the heading a GPU cluster training one large model, three ways. A question asks what this collection of three approaches is called.
The marks are placeholders; naming the three strategies is the question.
2. What is data parallelism?Define
A term card reading Data Parallelism, from The Concept section, with a note that the context is a large model trained across several GPUs, followed by a question asking what the strategy does.
The term as the lesson names it; its mechanism is the question.
3. What is tensor parallelism?Define
A term card reading Tensor Parallelism, from The Concept section, with the same context note about several GPUs, followed by a question asking what the strategy does.
The term as the lesson names it; its mechanism is the question.
4. What is pipeline parallelism?Define
A term card reading Pipeline Parallelism, from The Concept section, with the same context note about several GPUs, followed by a question asking what the strategy does.
The term as the lesson names it; its mechanism is the question.
5. What is FSDP (Fully Sharded Data Parallel)?Define
A term card reading FSDP, from the Fully Sharded Data Parallel section, with the same context note about several GPUs, followed by a question asking what the strategy does.
The term as the lesson names it; its mechanism is the question.
6. What is ZeRO (Zero Redundancy Optimizer)?Define
A term card reading ZeRO, from the DeepSpeed ZeRO section, with a note that it is DeepSpeed's approach to the same scaling problem, followed by a question asking what the strategy does.
The term as the lesson names it; its mechanism is the question.
7. What does an all-gather do?Define
Four GPUs, GPU 0 through GPU 3, each starting with a different chunk, A, B, C or D. An arrow leads down to a box named all-gather, and a question asks what each GPU holds afterward.
Starting chunks are placeholder labels, not real data.
8. What does a reduce-scatter do?Define
Four GPUs, GPU 0 through GPU 3, each starting with a different chunk, A, B, C or D. An arrow leads down to a box named reduce-scatter, and a question asks what each GPU holds afterward.
Starting chunks are placeholder labels, not real data.
9. What is mixed precision training?Define
A term card reading Mixed Precision Training, from The Concept section, noting that training uses more than one floating-point format at once, followed by a question about which formats apply to which part of training.
The term as the lesson names it; the format assignment is the question.
10. What is a pipeline bubble?Define
Four pipeline stages in a row, connected by arrows, under the heading a model's layers split into stages across GPUs. The term pipeline bubble is named, and a question asks what it refers to.
The term as the lesson names it; its meaning is the question.
11. What does FSDP (Fully Sharded Data Parallel) do that standard DDP does not?Explain
Two horizontal rows on one scale: no sharding at 840GB per GPU, and FSDP on 8 GPUs shown as a dashed box with a question mark instead of a number. A question asks what FSDP changes and how.
The 840GB figure is the lesson's own Without-FSDP total for a 70B model.
12. Why is tensor parallelism almost always kept within a single node?Explain
A bar chart of two interconnect speeds: NVLink inside a node at 900 GB/s and InfiniBand between nodes at about 50 GB/s. A question asks why tensor parallelism, which all-reduces after every layer, stays within one node.
Bandwidth figures are quoted from the lesson's Tensor Parallelism section.
13. Why does mixed precision training not cut a 7B model's memory in half?Explain
A bar chart of total memory for a 7B model: FP32 everywhere at 112 GB and mixed BF16 plus FP32 master at 84 GB. A question asks why the saving is only 25%, not 50%.
Totals from the lesson's Mixed Precision Training comparison table, matching a real run of mixed_precision_comparison(7).
14. Why must gradients be synchronized in data-parallel training?Explain
Four GPUs, each with a different batch slice, with arrows pointing down to a dashed sync step box marked with a question mark. A question asks why this sync step is necessary.
Each GPU's batch slice is different, per the Data Parallelism section.
15. How did DeepSeek V3 train for about $5.6M when Meta's Llama 3 405B run cost an estimated $100M?Explain
A bar chart of training cost in millions of dollars: Llama 3 405B at 100 and DeepSeek V3 at 5.6. A question asks what explains the gap.
Cost figures are the ones stated in the lesson's Problem section.
16. In the memory table, why is the Adam States column four times the FP16 weights column?Explain
A table of four models with their FP16 weight memory and Adam optimizer state memory: GPT-2 Small 248MB and 992MB, Llama 3 8B 16GB and 64GB, Llama 3 70B 140GB and 560GB, Llama 3 405B 810GB and 3,240GB. A question asks why the Adam States column is always 4 times the weights column.
Figures from the lesson's Why Distribution is Required table.
17. Why does splitting a batch into micro-batches shrink the pipeline bubble?Explain
A formula card showing N equals 4 stages and bubble fraction equals N minus 1 over M. A question asks why the bubble shrinks as M grows.
Formula quoted from the lesson's Pipeline Parallelism section.
18. Why does FSDP use more communication than vanilla data parallelism?Explain
Two boxes: vanilla data parallelism, which all-reduces gradients once per step, and FSDP, with its communication left as a question mark. A question asks what extra communication FSDP adds and when.
The data-parallel baseline is stated directly in the Build It section.
19. With 4 pipeline stages, what is the idle fraction with 1 micro-batch, and what does it become with 16 micro-batches?Apply
A table with N fixed at 4 stages, listing micro-batch counts 1 and 16, both with their bubble fraction left as a question mark.
N = 4 is given; the bubble fraction at each M is the question.
20. How is Llama 3 405B's 16,384 H100s split across the three kinds of parallelism?Apply
Three dashed boxes, each marked with a question mark, multiplied together to equal 16,384. A question asks which factor goes with which kind of parallelism.
16,384 is the lesson's stated Llama 3 405B GPU count; the three factors are the question.
21. A naive pipeline has 8 stages and uses 32 micro-batches. Using the (N-1)/M formula, what is the bubble fraction?Apply
A formula card: N equals 8 stages, M equals 32 micro-batches, bubble fraction equals N minus 1 over M, which is 7 over 32, left unresolved.
Formula quoted from the lesson's Pipeline Parallelism section; N and M are from the stem.
22. The memory table lists 4,860GB for Llama 3 405B (weights, Adam states, gradients). What is the minimum number of 80GB GPUs just to hold that?Apply
A formula card: 4,860 GB total before activations, 80 GB per GPU, and minimum GPUs equals 4,860 divided by 80, left unresolved.
4,860GB is the lesson's stated Llama 3 405B total; 80GB per GPU is the stated A100/H100 memory.
23. A team moves a 70B Adam run from 8 to 64 GPUs with plain data parallelism and each GPU still runs out of memory. What went wrong?Analyze
Two boxes: 8 GPUs under plain data parallelism, out of memory, and 64 GPUs under plain data parallelism, still out of memory. Two lines below ask for the per-GPU memory in each case, both left as question marks.
Both GPU counts and the out-of-memory outcome are given in the stem.
24. A tensor-parallel job spans two nodes joined by InfiniBand and the GPUs spend much of each step waiting. What is the most likely cause?Analyze
Two nodes, each with 8 GPUs and NVLink inside, connected to each other by a line labelled InfiniBand. A question asks the most likely cause of the GPUs spending much of each step waiting, for a tensor-parallel job spanning the two nodes.
Per-node GPU count and the InfiniBand link are consistent with the lesson's Tensor Parallelism section.
25. An FP16 run shows many gradients that are exactly zero, while the same run in BF16 does not. What explains the difference?Analyze
A table of three number formats: FP16 with 5 exponent bits and 10 mantissa bits, BF16 with 8 exponent bits and 7 mantissa bits, and FP32 with 8 exponent bits and 23 mantissa bits. A question asks why many FP16 gradients become exactly zero while BF16's don't.
Bit counts quoted from the lesson's Mixed Precision Training section.
26. Your team has 8 A100 80GB GPUs and wants to train a 70B model with the Adam setup from the FSDP section (840GB of model states). What is the smallest change that makes the states fit?Design
An arithmetic panel: 840GB divided by 8 GPUs is 105GB per GPU, which exceeds each A100's 80GB, so it does not fit. A question asks for the smallest change that makes the states fit.
840GB and 80GB per A100 are both stated directly in the stem.
27. You have a cluster of 8-GPU nodes and a model too big for one node's GPUs. Which layout follows the lesson's guidance?Design
Two dashed boxes, within one node and across nodes, each with its role left as a question mark, plus a line for the remaining scale across the rest of the cluster. A question asks which kind of parallelism goes in each slot.
Slots are deliberately unlabeled.
28. Each 80GB GPU holds your model's weights and gradients comfortably, but Adam states overflow. You want the least extra communication over plain data parallelism. Which approach fits?Design
Three bars for one GPU: weights and gradients both fitting under a dashed GPU-limit line, and Adam states rising above it. A question asks which approach fixes this with the least extra communication over plain data parallelism.
Which component overflows is given in the stem; bar heights are illustrative.
29. You must train a 70B model on only 4 GPUs of 80GB each, and the host has large CPU RAM. Which idea helps, at a cost in speed?Design
Two boxes: 4 GPUs of 80GB each, 320GB total and not enough, and the host's CPU RAM, described as large and mostly idle, with a dashed arrow and a question mark between them. A question asks what can move from GPU memory to CPU RAM, at a cost in speed.
GPU count, per-GPU size, and the large CPU RAM are given in the stem.

Build It

Step 1: Simulate Data Parallelism

Split a batch across simulated GPUs. Each GPU computes a forward pass on its shard. Average the gradients each shard produces.

python
import numpy as np
 
def simulate_data_parallelism(data, num_gpus, model_fn):
    batch_size = len(data)
    shard_size = batch_size // num_gpus
    remainder = batch_size % num_gpus
 
    gpu_losses = []
    gpu_gradients = []
 
    offset = 0
    for gpu_id in range(num_gpus):
        extra = 1 if gpu_id < remainder else 0
        shard = data[offset:offset + shard_size + extra]
        offset += shard_size + extra
 
        loss, grad = model_fn(shard)
        gpu_losses.append(loss)
        gpu_gradients.append(grad)
 
    avg_loss = np.mean(gpu_losses)
    avg_gradient = np.mean(gpu_gradients, axis=0)
 
    return avg_loss, avg_gradient

The all-reduce operation (averaging gradients) is the only communication in data parallelism. In practice, this uses the NCCL library on NVIDIA GPUs, which implements ring all-reduce: each GPU sends 1/N of its gradients to its neighbor, receives 1/N from the other neighbor, and after N-1 steps every GPU has the complete average. Total communication volume: 2 x gradient_size x (N-1)/N, approaching 2x the gradient size for large N.

Step 2: Simulate Tensor Parallelism

Split a weight matrix across GPUs. Each GPU computes a partial matrix multiplication. Combine the results.

python
def simulate_tensor_parallelism(input_data, weight_matrix, num_gpus):
    d_in, d_out = weight_matrix.shape
    assert d_out % num_gpus == 0, f"d_out {d_out} not divisible by num_gpus {num_gpus}"
    shard_size = d_out // num_gpus
 
    partial_results = []
    for gpu_id in range(num_gpus):
        start = gpu_id * shard_size
        end = start + shard_size
        weight_shard = weight_matrix[:, start:end]
 
        partial = input_data @ weight_shard
        partial_results.append(partial)
 
    full_output = np.concatenate(partial_results, axis=-1)
 
    direct_output = input_data @ weight_matrix
    error = np.abs(full_output - direct_output).max()
 
    return full_output, error

The error should be zero or within machine epsilon. The split is along the output dimension, so each GPU produces a different chunk of columns, and concatenation reconstructs the same result as the full matmul on one GPU.

For column-parallel linear layers (splitting the output dimension), you concatenate. For row-parallel (splitting the input dimension), you sum. In a transformer FFN, the first linear (expand) uses column-parallel and the second linear (contract) uses row-parallel. This avoids an all-reduce between the two layers.

Step 3: Simulate Pipeline Parallelism

Split a model's layers across virtual GPUs. Show the bubble problem where early stages sit idle while later stages compute.

python
def simulate_pipeline_parallelism(num_layers, num_stages, num_microbatches):
    layers_per_stage = num_layers // num_stages
 
    timeline = {}
    clock = 0
 
    for mb in range(num_microbatches):
        for stage in range(num_stages):
            start_time = max(
                timeline.get((stage, mb - 1, "fwd"), (0, 0))[1] if mb > 0 else 0,
                timeline.get((stage - 1, mb, "fwd"), (0, 0))[1] if stage > 0 else 0,
            )
            end_time = start_time + layers_per_stage
            timeline[(stage, mb, "fwd")] = (start_time, end_time)
 
    last_fwd_end = max(v[1] for v in timeline.values())
 
    for mb in range(num_microbatches - 1, -1, -1):
        for stage in range(num_stages - 1, -1, -1):
            deps = [last_fwd_end]
            if mb < num_microbatches - 1 and (stage, mb + 1, "bwd") in timeline:
                deps.append(timeline[(stage, mb + 1, "bwd")][1])
            if stage < num_stages - 1 and (stage + 1, mb, "bwd") in timeline:
                deps.append(timeline[(stage + 1, mb, "bwd")][1])
            start_time = max(deps)
            end_time = start_time + layers_per_stage
            timeline[(stage, mb, "bwd")] = (start_time, end_time)
 
    total_time = max(v[1] for v in timeline.values())
    compute_time = num_microbatches * num_stages * layers_per_stage * 2
    bubble_fraction = 1.0 - compute_time / (total_time * num_stages)
 
    return timeline, total_time, bubble_fraction

With 4 stages and 1 micro-batch, the bubble fraction is 75%, meaning three out of four GPUs are idle at any time. With 16 micro-batches, the (N-1)/M formula gives about 19%. The simulation counts the backward pass too and reports 15.8%, which matches (N-1)/(M+N-1). The cost of eliminating bubbles is memory: you must store activations for all in-flight micro-batches simultaneously.

Step 4: Memory Calculator

Compute the exact memory requirements for training any model size.

python
def memory_calculator(
    params_billions,
    precision_bytes=2,
    optimizer="adam",
    num_gpus=1,
    sharding="none",
    sequence_length=2048,
    batch_size_per_gpu=1,
    hidden_dim=None,
    num_layers=None,
):
    params = params_billions * 1e9
 
    weight_memory = params * precision_bytes
 
    if optimizer == "adam":
        optimizer_memory = params * 4 * 2
    elif optimizer == "sgd":
        optimizer_memory = params * 4
    else:
        optimizer_memory = 0
 
    gradient_memory = params * precision_bytes
 
    total_no_activation = weight_memory + optimizer_memory + gradient_memory
 
    if hidden_dim and num_layers:
        activation_per_layer = (
            sequence_length * batch_size_per_gpu * hidden_dim * precision_bytes * 4
        )
        activation_memory = activation_per_layer * num_layers
    else:
        activation_memory = params * precision_bytes * 0.5
 
    if sharding == "fsdp" or sharding == "zero3":
        weight_memory /= num_gpus
        optimizer_memory /= num_gpus
        gradient_memory /= num_gpus
    elif sharding == "zero2":
        optimizer_memory /= num_gpus
        gradient_memory /= num_gpus
    elif sharding == "zero1":
        optimizer_memory /= num_gpus
 
    per_gpu_total = weight_memory + optimizer_memory + gradient_memory + activation_memory
 
    return {
        "params_billions": params_billions,
        "weights_gb": weight_memory / 1e9,
        "optimizer_gb": optimizer_memory / 1e9,
        "gradients_gb": gradient_memory / 1e9,
        "activations_gb": activation_memory / 1e9,
        "per_gpu_total_gb": per_gpu_total / 1e9,
        "total_across_gpus_gb": per_gpu_total * num_gpus / 1e9,
        "fits_on_80gb": per_gpu_total / 1e9 <= 80,
        "num_gpus": num_gpus,
        "sharding": sharding,
    }

This calculator answers the question every ML engineer asks: "How many GPUs do I need?" Feed it the model size and see whether it fits. Adjust sharding strategy until the per-GPU total drops below 80GB.

Step 5: Mixed Precision Simulation

Compare memory usage between FP32, FP16, and mixed precision training.

python
def mixed_precision_comparison(params_billions):
    params = params_billions * 1e9
 
    fp32_weights = params * 4
    fp32_optimizer = params * 4 * 2
    fp32_gradients = params * 4
    fp32_total = fp32_weights + fp32_optimizer + fp32_gradients
 
    fp16_weights = params * 2
    fp16_master = params * 4
    fp16_optimizer = params * 4 * 2
    fp16_gradients = params * 2
    fp16_total = fp16_weights + fp16_master + fp16_optimizer + fp16_gradients
 
    mixed_weights = params * 2
    mixed_optimizer = params * 4 * 2
    mixed_gradients = params * 2
    mixed_total = mixed_weights + mixed_optimizer + mixed_gradients
 
    return {
        "fp32_total_gb": fp32_total / 1e9,
        "fp16_with_master_gb": fp16_total / 1e9,
        "mixed_bf16_gb": mixed_total / 1e9,
        "savings_vs_fp32": 1 - mixed_total / fp32_total,
    }

The surprise for most people is that mixed precision does not halve the memory. The optimizer states (Adam's m and v) stay in FP32 regardless of precision. For a 7B model, FP32 training uses 112GB. Mixed precision uses 84GB. That is a 25% reduction, not 50%. The optimizer dominates.

Check: Build It0/9
1. What does an all-reduce do?Define
Four GPUs, GPU 0 through GPU 3, each starting with a different chunk, A, B, C or D. An arrow leads down to a box named all-reduce, and a question asks what each GPU holds afterward.
Starting chunks are placeholder labels, not real data.
2. In a transformer FFN, why pair a column-parallel first linear with a row-parallel second linear?Explain
Two linear layers in a row: linear 1, column-parallel, splitting the output dimension, feeding linear 2, row-parallel, splitting the input dimension. A question asks what communication is needed between them.
The column/row split roles are stated directly in the Build It section.
3. simulate_tensor_parallelism splits an (8192, 8192) weight matrix over 4 GPUs. What shape is each GPU's weight_shard?Apply
An excerpt of simulate_tensor_parallelism: it reads d_in and d_out from the weight matrix shape, computes shard_size as d_out floor-divided by num_gpus, and slices weight_matrix by columns into weight_shard. A question asks for weight_shard's shape with an 8192 by 8192 matrix and 4 GPUs.
Code excerpted from the lesson's main.py.
4. What happens if simulate_tensor_parallelism is called with an (8192, 8192) weight matrix and num_gpus=3?Apply
An excerpt of simulate_tensor_parallelism showing its assert line: d_out modulo num_gpus must equal zero, with a message naming d_out and num_gpus. A question asks what happens when d_out is 8192 and num_gpus is 3.
Code excerpted from the lesson's main.py.
5. simulate_data_parallelism receives 10 examples and num_gpus=4. How many examples does each GPU get, in order?Apply
An excerpt of simulate_data_parallelism's sharding math: shard_size is 10 floor-divided by 4, which is 2, remainder is 10 mod 4, which is 2, and gpu_id below the remainder gets one extra example. A question asks for the examples-per-GPU sequence across GPUs 0 to 3, shown as four blanks.
Code excerpted from the lesson's main.py; shard_size and remainder are computed values.
6. Ring all-reduce moves 2 x gradient_size x (N-1)/N per GPU. For N=4 GPUs and 10GB of gradients, what is that volume?Apply
A formula card: communication volume equals 2 times gradient size times N minus 1 over N, worked to 2 times 10GB times 3 over 4, left unresolved.
Formula quoted from the lesson's Build It section.
7. What savings_vs_fp32 does mixed_precision_comparison(70) return?Apply
An excerpt showing fp32_total as 16 bytes per parameter and mixed_total as 12 bytes per parameter. A question asks for savings_vs_fp32, 1 minus mixed over fp32, left unresolved.
Byte breakdown matches mixed_precision_comparison in the lesson's main.py.
8. simulate_data_parallelism runs on 10 examples with 4 GPUs, and each shard's gradient is already divided by its own shard size. The averaged gradient differs slightly from the single-GPU full-batch gradient. Why?Analyze
Four bars of uneven height over GPU 0 through GPU 3, two taller and two shorter, representing shard sizes that are not all equal. A question asks how the averaged multi-GPU gradient compares to a single-GPU full-batch gradient.
Bar heights are illustrative; exact shard sizes are not shown.
9. A pipeline run with 64 micro-batches shows a tiny bubble but now runs out of memory, which it did not with 4 micro-batches. Why?Analyze
Two boxes: 4 micro-batches, which fits in memory but has a bigger bubble, and 64 micro-batches, which has a tiny bubble but now runs out of memory. A question asks what grows as the micro-batch count goes up.
Both outcomes (fits vs OOM) are given in the stem.

Use It

Run All Simulations

python
def run_all_demos():
    print("=" * 70)
    print("DATA PARALLELISM SIMULATION")
    print("=" * 70)
 
    np.random.seed(42)
    data = np.random.randn(64, 32)
    weight = np.random.randn(32, 16)
 
    def model_fn(batch):
        output = batch @ weight
        loss = np.mean(output ** 2)
        grad = 2 * batch.T @ (batch @ weight) / len(batch)
        return loss, grad
 
    for n_gpus in [1, 2, 4, 8]:
        loss, grad = simulate_data_parallelism(data, n_gpus, model_fn)
        print(f"  {n_gpus} GPUs: loss={loss:.4f}, grad_norm={np.linalg.norm(grad):.4f}")
 
    print()
    print("=" * 70)
    print("TENSOR PARALLELISM SIMULATION")
    print("=" * 70)
 
    x = np.random.randn(4, 8192)
    W = np.random.randn(8192, 8192)
 
    for n_gpus in [1, 2, 4, 8]:
        output, error = simulate_tensor_parallelism(x, W, n_gpus)
        print(f"  {n_gpus} GPUs: output_shape={output.shape}, max_error={error:.2e}")
 
    print()
    print("=" * 70)
    print("PIPELINE PARALLELISM SIMULATION")
    print("=" * 70)
 
    for n_mb in [1, 4, 8, 16, 32]:
        _, total_t, bubble = simulate_pipeline_parallelism(32, 4, n_mb)
        print(f"  {n_mb:2d} micro-batches: total_time={total_t:4d}, bubble={bubble:.1%}")
 
    print()
    print("=" * 70)
    print("MEMORY CALCULATOR")
    print("=" * 70)
 
    configs = [
        (7, "none", 1),
        (7, "fsdp", 8),
        (70, "none", 1),
        (70, "fsdp", 8),
        (70, "fsdp", 16),
        (405, "fsdp", 64),
        (405, "fsdp", 128),
    ]
 
    print(f"  {'Model':>8} {'Sharding':>8} {'GPUs':>5} {'Per-GPU':>10} {'Fits 80GB':>10}")
    print("  " + "-" * 50)
    for params, shard, gpus in configs:
        result = memory_calculator(params, num_gpus=gpus, sharding=shard)
        fits = "Yes" if result["fits_on_80gb"] else "No"
        print(f"  {params:>6}B {shard:>8} {gpus:>5} {result['per_gpu_total_gb']:>8.1f}GB {fits:>10}")
 
    print()
    print("=" * 70)
    print("MIXED PRECISION COMPARISON")
    print("=" * 70)
 
    for params_b in [7, 13, 70, 405]:
        result = mixed_precision_comparison(params_b)
        print(f"  {params_b}B: FP32={result['fp32_total_gb']:.0f}GB, "
              f"Mixed BF16={result['mixed_bf16_gb']:.0f}GB, "
              f"Savings={result['savings_vs_fp32']:.0%}")
Check: Use It0/3
1. With the default activation estimate, what per-GPU total does memory_calculator(70, num_gpus=8, sharding="fsdp") report?Apply
An excerpt of memory_calculator: under FSDP, weight, optimizer, and gradient memory are divided by num_gpus, but the default activation memory, params times precision bytes times 0.5, is not divided. Inputs are 70 billion params, 2 precision bytes, 8 GPUs, and the per-GPU total is left as a question mark.
Code excerpted from the lesson's main.py.
2. For 4 stages and 16 micro-batches, what bubble does simulate_pipeline_parallelism report, and why does it differ from 18.75%?Apply
Two boxes: the naive formula giving 18.75%, and the function's actually reported bubble left as a question mark. A question asks why the two might differ.
18.75% is the naive (N-1)/M figure quoted in the lesson; the function's own output is the question.
3. The calculator prints 175.0GB per GPU for 70B on 8 GPUs with FSDP, but the FSDP table says 105GB. What explains the gap?Analyze
Two boxes: the calculator's output of 175.0GB per GPU and the lesson's FSDP table figure of 105GB per GPU. A question asks where the 70GB gap between them comes from.
Both figures are stated directly in the stem; 175 - 105 = 70.

Ship It

This lesson produces outputs/prompt-distributed-training-planner.md, a prompt that takes a model size and available hardware, then produces a complete distributed training plan: parallelism strategy, memory budget, communication overhead, and expected throughput.

Author's Note

I'd run main.py and read the memory table before anything else. The calculator's default activation term is a rough guess, half the weight bytes and not divided across GPUs, so 70B on 8 GPUs prints 175.0GB per GPU where the FSDP table above says 105GB. The gap is the activations, which is a good reminder that sharding the model states does not settle the question by itself. Pass hidden_dim and num_layers if you want an activation number tied to a real shape.

The pipeline simulation is the part I'd poke at. At 16 micro-batches it reports a 15.8% bubble, not the 18.75% from (N-1)/M, because it schedules the backward pass as well and lands on 3/19. Change the stage and micro-batch counts and watch how fast the bubble shrinks.

Everything here is numpy on one CPU. The data-parallel check splits a batch into 1, 2, 4, and 8 shards of a toy loss, and the tensor-parallel split is a column slice, so no GPU communication happens and nothing here tells you what NVLink or InfiniBand would cost. Treat it as a way to check the arithmetic before you pay for hardware.

Exercises

  1. Modify the memory calculator to include activation checkpointing. With checkpointing, only store activations at every K-th layer (typical K=1, meaning recompute all). Show the memory-compute tradeoff: how much memory does checkpointing save, and how much does it slow down training (roughly 33% more compute for full checkpointing)?

  2. Extend the pipeline parallelism simulation to implement the 1F1B (one forward, one backward) schedule used by PipeDream. Compare the bubble fraction against the naive schedule for 4 stages and 8 micro-batches. The 1F1B schedule should have a smaller peak memory because it starts backward passes earlier.

  3. Implement a gradient accumulation simulator. Instead of all-reducing after every micro-batch, accumulate gradients locally for K steps, then all-reduce. Show how this reduces communication by K times but produces identical final gradients (and thus identical training).

  4. Build a cost estimator. Given a model size, target token count, GPU type (A100 at $2/hr, H100 at $3.50/hr), and parallelism strategy, estimate the total training cost in dollars. Validate against known costs: Llama 3 405B reportedly cost ~$100M, DeepSeek V3 cost ~$5.6M.

  5. Add ZeRO-Offload to the memory calculator. Assume CPU RAM is 512GB per node and NVMe is 2TB. Show how offloading optimizer states to CPU allows a 70B model to train on 4 GPUs instead of 16, at the cost of 30-50% slower optimizer steps.

Key Terms

TermWhat people sayWhat it actually means
Data parallelism"Copy the model to every GPU"Each GPU processes a different data shard; gradients are averaged via all-reduce after each step
Tensor parallelism"Split a layer across GPUs"Partition weight matrices so each GPU computes part of the matmul; requires fast NVLink interconnect
Pipeline parallelism"Split layers across GPUs"Each GPU runs a different group of layers; data flows through the pipeline with micro-batches to reduce bubbles
FSDP"Shard everything"Fully Sharded Data Parallel -- each GPU holds 1/N of weights, gradients, and optimizer states; all-gather before compute
ZeRO"DeepSpeed's version of FSDP"Zero Redundancy Optimizer with 3 stages: shard optimizer (Stage 1), + gradients (Stage 2), + parameters (Stage 3)
All-reduce"Average across GPUs"Collective operation where every GPU ends with the sum (or average) of all GPUs' inputs -- typically implemented as ring all-reduce
All-gather"Collect from all GPUs"Collective operation where every GPU ends with the concatenation of all GPUs' data -- used in FSDP to reconstruct full parameters
Reduce-scatter"Sum and distribute"Collective operation that reduces (sums) data and scatters different chunks to different GPUs -- used in FSDP for gradient sharding
Mixed precision"Train in half precision"Use FP16/BF16 for forward/backward and FP32 for optimizer states -- saves ~25% memory, not 50%, because the optimizer dominates
Pipeline bubble"Idle time in the pipeline"Fraction of time GPUs sit idle waiting for data from the previous stage -- reduced by using more micro-batches

Further Reading

Code

Ships With