Data Parallelism
Replicate the model on every GPU, split the batch β from naive DP to DDP to FSDP/ZeRO, with gradient sync mechanics and scaling behavior.
Data parallelism is the simplest and most widely used distributed training strategy. Every GPU has a full copy of the model, processes a different slice of the batch, and synchronizes gradients via AllReduce. This chapter traces the evolution from naive DP to DDP to FSDP.
1. The Core Idea
GPU 0: model copy + batch shard 0 β gradients 0 βββ
GPU 1: model copy + batch shard 1 β gradients 1 βββ€ββ AllReduce (average) βββ all GPUs get averaged gradients
GPU 2: model copy + batch shard 2 β gradients 2 βββ€ β optimizer step (identical on all)
GPU 3: model copy + batch shard 3 β gradients 3 βββ
Mathematical equivalence: Training with batch size on GPUs, each processing samples, with averaged gradients is mathematically identical to training with batch on one GPU. Same convergence, throughput.
2. Evolution: DP β DDP β FSDP
DataParallel (DP) β Deprecated
PyTorchβs original nn.DataParallel: one process, multiple GPUs. GPU 0 is the βmasterβ β it scatters input, gathers output, and does all gradient reduction.
model = nn.DataParallel(model) # don't use this
Three fatal flaws:
- GIL bottleneck: Single Python process β single GIL β GPU 0 becomes the bottleneck
- Imbalanced memory: GPU 0 holds all gradients for reduction β OOM while others have spare memory
- Redundant copies: Scatter/gather through GPU 0 wastes NVLink bandwidth
Never use DP. Use DDP.
DistributedDataParallel (DDP) β Standard
One process per GPU. No master β all GPUs are equal. Gradients synchronized via AllReduce (ring algorithm through NCCL).
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
dist.init_process_group('nccl')
model = DDP(model.cuda(), device_ids=[local_rank])
# Training loop is identical to single-GPU
for batch in dataloader:
loss = model(batch)
loss.backward() # DDP hooks AllReduce into backward automatically
optimizer.step()
optimizer.zero_grad()
How DDP works:
- Forward pass: each GPU processes its batch shard independently (no communication)
- Backward pass: as gradients are computed, DDP buckets them and starts AllReduce
- Gradient bucketing: gradients are grouped into ~25 MB buckets. AllReduce starts per-bucket as soon as that bucketβs gradients are ready β overlapping communication with backward computation
- After AllReduce: all GPUs have identical averaged gradients β optimizer step produces identical parameters
FSDP / ZeRO β Memory-Efficient
DDP replicates the full model on every GPU. For a 70B model: 140 GB per GPU just for BF16 parameters β doesnβt fit on any single GPU.
FSDP (Fully Sharded Data Parallel) / ZeRO shard model state across GPUs:
| Stage | Whatβs sharded | Memory per GPU | Communication |
|---|---|---|---|
| DDP (no sharding) | Nothing | Full model + optimizer | AllReduce (gradients) |
| ZeRO Stage 1 | Optimizer states | ~60% of DDP | Same as DDP |
| ZeRO Stage 2 | + Gradients | ~40% of DDP | ReduceScatter + AllGather |
| ZeRO Stage 3 / FSDP | + Parameters | ~1/P of DDP | AllGather (fwd) + ReduceScatter (bwd) |
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
model = FSDP(model, auto_wrap_policy=transformer_auto_wrap_policy)
FSDP tradeoff: Less memory per GPU (can train larger models), but more communication (AllGather before each layerβs forward pass).
3. Communication Analysis
DDP: AllReduce Gradients
Communication per step: one AllReduce of all gradients.
For 7B model, BF16: GB per step.
On 8 GPUs with NVLink (450 GB/s): ms β but mostly hidden behind backward (gradient bucketing).
FSDP: AllGather + ReduceScatter Per Layer
For each of layers:
- Forward: AllGather parameters ( params) β needs full params for compute
- Backward: ReduceScatter gradients ( params) β each GPU keeps its gradient shard
Total per step:
Volume is the same as DDP (), but spread across smaller operations instead of one large AllReduce. The advantage: each AllGather can overlap with the previous layerβs computation.
4. Scaling Behavior
Strong Scaling (Fixed Total Batch)
GPUs, each processes samples. As increases:
- Compute per GPU: (linear speedup β )
- Communication: constant (AllReduce volume β , independent of β )
- But: Per-GPU batch shrinks β GPU utilization drops when batch is too small
Practical limit: When per-GPU batch < ~4 samples, GPU utilization drops below 50%. Solution: gradient accumulation (process multiple micro-batches before AllReduce).
Weak Scaling (Fixed Per-GPU Batch)
GPUs, each processes samples. Total batch = . As increases:
- Compute per GPU: constant β
- Communication: constant β
- Total batch grows: may need learning rate scaling ()
This is how large-scale training works: 1000 GPUs, each with batch 4, total batch 4000. Communication overhead is <5% on good networks.
For details on FSDP/ZeRO memory sharding, see the Training Systems > Memory Optimization module. For how AllReduce works, see Communication Primitives.
5. When Data Parallelism Is Not Enough
| Problem | Why DP canβt solve it | Solution |
|---|---|---|
| Model doesnβt fit on one GPU | DP replicates the full model | Tensor/Pipeline parallelism or FSDP |
| Sequence too long for one GPU | Attention memory per GPU | Context/Sequence parallelism |
| MoE experts too many for one GPU | Each expert is a separate FFN | Expert parallelism |
| Communication becomes bottleneck | Only happens at >1000 GPUs or slow networks | Gradient compression, hierarchical AllReduce |
In practice: Data parallelism is the outer dimension β you always use it. Other parallelism strategies are the inner dimensions, composed with DP.
6. Practical DDP Template
import os
import torch
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.utils.data import DataLoader, DistributedSampler
def setup():
dist.init_process_group('nccl', timeout=timedelta(seconds=300))
local_rank = int(os.environ['LOCAL_RANK'])
torch.cuda.set_device(local_rank)
return local_rank
def train():
local_rank = setup()
model = MyModel().cuda(local_rank)
model = DDP(model, device_ids=[local_rank])
sampler = DistributedSampler(dataset, shuffle=True)
dataloader = DataLoader(dataset, sampler=sampler, batch_size=32,
num_workers=4, pin_memory=True)
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4)
for epoch in range(num_epochs):
sampler.set_epoch(epoch) # ensure different shuffle per epoch
for batch in dataloader:
batch = batch.cuda(local_rank, non_blocking=True)
loss = model(batch)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
optimizer.zero_grad()
# Launch: torchrun --nproc_per_node=8 train.py
Self-Check
- Explain why DDP is mathematically equivalent to single-GPU training with larger batch
- Explain the three flaws of
nn.DataParalleland why DDP fixes them - Explain gradient bucketing and how it overlaps communication with backward
- Explain ZeRO Stage 1/2/3 and the memory-communication tradeoff
- Calculate AllReduce volume for a 7B model in BF16
- Explain strong vs weak scaling and when each hits limits
- Write a complete DDP training script with
DistributedSampler