Data Parallelism › Gradient Synchronization in Depth

Michael BrenndoerferJanuary 16, 20265 min read
Language AI HandbookAll chapters
Part 31 of 67Training Infrastructure
Chapter 222Data Parallelism › Gradient Synchronization in Depth
Reading section 6 of 12 · Gradient Synchronization in Depth · 5 minSection 6 of 12 · 5 min

Book contents

Section 6 of 12 · 5 min read

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 kk forward-backward passes and only call optimizer.step() after kk passes, effectively computing a gradient over kk 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 kk times per optimizer step but only need to average them once. DDP provides the no_sync() context manager to suppress gradient synchronization:

In[16]:
Code
# 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_steps
Out[17]:
Console
Ran 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 kk 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 kk times larger.

One thing to be careful about: the loss / accumulation_steps scaling in the code above. Because gradients accumulate additively across the kk backward passes, you need to scale each loss by 1/k1/k 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 kk times the mean gradient, which would require reducing the learning rate by kk to compensate.

Gradient Averaging vs. Gradient Summing

DDP averages gradients by default, dividing the sum by NN (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:

Lr=1b∑x∈BrLx(θ)\mathcal{L}_r = \frac{1}{b} \sum_{x \in \mathcal{B}_r} \mathcal{L}_x(\theta)

where b=∣Br∣b = |\mathcal{B}_r| is the local batch size. The gradient of this loss is:

∇Lr=1b∑x∈Br∇Lx(θ)\nabla \mathcal{L}_r = \frac{1}{b} \sum_{x \in \mathcal{B}_r} \nabla \mathcal{L}_x(\theta)

After all-reduce averaging over NN ranks, the synchronized gradient is:

∇sync=1N∑r=0N−1∇Lr=1N∑r=0N−11b∑x∈Br∇Lx(θ)=1Nb∑r=0N−1∑x∈Br∇Lx(θ)\begin{aligned} \nabla_{\text{sync}} &= \frac{1}{N} \sum_{r=0}^{N-1} \nabla \mathcal{L}_r \\ &= \frac{1}{N} \sum_{r=0}^{N-1} \frac{1}{b} \sum_{x \in \mathcal{B}_r} \nabla \mathcal{L}_x(\theta) \\ &= \frac{1}{Nb} \sum_{r=0}^{N-1} \sum_{x \in \mathcal{B}_r} \nabla \mathcal{L}_x(\theta) \end{aligned}

where:

  • ∇sync\nabla_{\text{sync}} is the final synchronized gradient used for the optimizer step
  • bb is the per-rank mini-batch size
  • NN is the number of ranks (GPUs)

This expression is exactly the gradient of the mean loss over the global batch of size NbNb. 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.