Data Parallelism › Gradient Synchronization in Depth
Language AI HandbookAll chapters Data Parallelism › Gradient Synchronization in DepthOne section · Sections in this chapter6 / 12 ↓
Reading tools Definitions & inline links
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
The gradient synchronization step is where most of the complexity in DDP lives. Let us examine several aspects that matter in practice.
When Does Synchronization Happen?
DDP synchronizes gradients during the backward pass, not after it. This is achieved through autograd hooks that DDP registers on each parameter's .grad_fn. When the autograd engine finishes computing the gradient for a parameter, the hook fires immediately. DDP accumulates gradients into the appropriate bucket, and when the bucket is full (or when the last parameter in the bucket gets its gradient), DDP launches an asynchronous NCCL all-reduce operation on that bucket.
The hooks fire in the order that autograd computes gradients, which is roughly reverse layer order for a standard sequential model. This means the gradients for output layers (which backpropagate first) are placed into buckets and communicated first, while the gradients for early layers (embeddings, first transformer blocks) are computed and communicated last. By the time the backward pass finishes, many all-reduce operations have already completed.
This design has two important benefits. First, compute and communication run simultaneously: while the CPU continues the backward pass for earlier layers, the GPU's communication unit handles gradient transfers for later layers. Second, the critical path is shortened: the wall-clock time for a training step approaches max(compute_time, communication_time) rather than their sum, as long as the communication can be fully overlapped.
The practical degree of overlap depends on the model architecture. Deep models with many layers provide more opportunity for overlap than shallow models. Attention-heavy layers with long compute times provide more hiding of communication than lightweight MLP layers. For large transformer models (where each layer has hundreds of millions of parameters), the overlap is substantial and gradient bucketing provides large speedups.
No-Sync Context Manager
Sometimes you want to accumulate gradients over multiple mini-batches before synchronizing. This is called gradient accumulation, and it is used when the target global batch size is larger than what fits in GPU memory in a single batch. The idea is to run forward-backward passes and only call optimizer.step() after passes, effectively computing a gradient over times as many samples per optimizer step.
With DDP, calling backward() triggers an all-reduce after every pass, even when you do not want to synchronize yet. This wastes communication bandwidth: you are averaging gradients times per optimizer step but only need to average them once. DDP provides the no_sync() context manager to suppress gradient synchronization:
# Gradient accumulation with DDP
accumulation_steps = 4
optimizer.zero_grad()
ddp_model.train()
sampler.set_epoch(1)
for step, (batch_X, batch_y) in enumerate(dataloader):
# Only synchronize on the last accumulation step
is_last_accumulation_step = (step + 1) % accumulation_steps == 0
if is_last_accumulation_step:
# Normal backward: triggers all-reduce
logits = ddp_model(batch_X)
loss = criterion(logits.view(-1, vocab_size), batch_y.view(-1))
loss = loss / accumulation_steps
loss.backward()
optimizer.step()
optimizer.zero_grad()
else:
# Suppressed backward: no all-reduce until no_sync exits
with ddp_model.no_sync():
logits = ddp_model(batch_X)
loss = criterion(logits.view(-1, vocab_size), batch_y.view(-1))
loss = loss / accumulation_steps
loss.backward()
if step >= 7: # Run just a few steps for demonstration
break
num_sync_steps = (step + 1) // accumulation_stepsRan 8 forward-backward passes. Triggered all-reduce 2 times. Communication savings vs naive: 6 all-reduces skipped. Gradient accumulation allows larger effective batch sizes without increasing per-step communication frequency.
Gradient accumulation is an important practical technique because many language model training recipes target specific global batch sizes (for example, 4 million tokens per step) that far exceed what fits in GPU memory in a single forward pass. By accumulating over steps with no_sync(), you achieve the target batch size while only communicating once. The communication overhead is the same as without accumulation, but the effective batch size is times larger.
One thing to be careful about: the loss / accumulation_steps scaling in the code above. Because gradients accumulate additively across the backward passes, you need to scale each loss by so that the accumulated gradient equals the gradient of the mean loss over the full accumulated batch. Without this scaling, the accumulated gradient would be times the mean gradient, which would require reducing the learning rate by to compensate.
Gradient Averaging vs. Gradient Summing
DDP averages gradients by default, dividing the sum by (the number of ranks). This normalization ensures that the gradient scale is the same regardless of how many GPUs you use, which makes learning rate tuning independent of the degree of parallelism.
There is an important subtlety here. The per-rank loss is already averaged over the local batch:
where is the local batch size. The gradient of this loss is:
After all-reduce averaging over ranks, the synchronized gradient is:
where:
- is the final synchronized gradient used for the optimizer step
- is the per-rank mini-batch size
- is the number of ranks (GPUs)
This expression is exactly the gradient of the mean loss over the global batch of size . The synchronized gradient is the true full-batch gradient for the combined mini-batch across all ranks, divided by the global batch size. This is the correct gradient for the loss function you are optimizing, which is the mean loss per token (or per example) over the global batch.