Gradient Accumulation › Code Implementation

Michael BrenndoerferJanuary 27, 202610 min read
Language AI HandbookAll chapters
Part 32 of 67Training Optimization
Chapter 236Gradient Accumulation › Code Implementation
Reading section 9 of 14 · Code Implementation · 10 minSection 9 of 14 · 10 min

Book contents

Section 9 of 14 · 10 min read

Gradient Accumulation › Code ImplementationOne section · Sections in this chapter9 / 14 ↓
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

We will build a complete gradient accumulation training loop in PyTorch that demonstrates the correct implementation, including the loss scaling, optimizer synchronization, and gradient clipping interaction.

Setting Up the Training Environment

Let us start by defining the model and data:

In[8]:
Code
import random

import numpy as np
import torch
import torch.nn as nn

# Reproducibility
torch.manual_seed(42)
np.random.seed(42)
random.seed(42)


# Simple transformer-like language model for demonstration
class SmallTransformer(nn.Module):
    def __init__(
        self, vocab_size=1000, d_model=128, nhead=4, num_layers=2, seq_len=32
    ):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, d_model)
        encoder_layer = nn.TransformerEncoderLayer(
            d_model=d_model,
            nhead=nhead,
            dim_feedforward=256,
            dropout=0.0,
            batch_first=True,
        )
        self.transformer = nn.TransformerEncoder(
            encoder_layer, num_layers=num_layers
        )
        self.head = nn.Linear(d_model, vocab_size)
        self.d_model = d_model

    def forward(self, x):
        emb = self.embedding(x) * (self.d_model**0.5)
        out = self.transformer(emb)
        return self.head(out)


# Synthetic token dataset
VOCAB_SIZE = 1000
SEQ_LEN = 32
DATASET_SIZE = 512

tokens = torch.randint(0, VOCAB_SIZE, (DATASET_SIZE, SEQ_LEN + 1))
inputs = tokens[:, :-1]  # shape: (512, 32)
targets = tokens[:, 1:]  # shape: (512, 32)

dataset = torch.utils.data.TensorDataset(inputs, targets)

Baseline: Standard Training Loop

First, let us implement the standard training loop with a batch size of 32, to establish the baseline gradient behavior. The demonstration model sets dropout to zero so that both paths compute deterministic gradients; the earlier dropout discussion still applies to real training runs.

In[9]:
Code
from torch.utils.data import DataLoader

BATCH_SIZE = 32
MICRO_BATCH = 8
ACCUM_STEPS = BATCH_SIZE // MICRO_BATCH  # = 4

model_baseline = SmallTransformer(vocab_size=VOCAB_SIZE)
optimizer_baseline = torch.optim.Adam(model_baseline.parameters(), lr=1e-3)
loss_fn = nn.CrossEntropyLoss()

loader_baseline = DataLoader(dataset, batch_size=BATCH_SIZE, shuffle=False)

# Run one step to capture gradient norms
model_baseline.train()
optimizer_baseline.zero_grad()

x_full, y_full = next(iter(loader_baseline))
logits = model_baseline(x_full)  # (32, 32, 1000)
loss = loss_fn(logits.reshape(-1, VOCAB_SIZE), y_full.reshape(-1))
loss.backward()

# Record gradient norms before optimizer step
grad_norms_baseline = {}
for name, param in model_baseline.named_parameters():
    if param.grad is not None:
        grad_norms_baseline[name] = param.grad.norm().item()

total_grad_norm_baseline = (
    sum(v**2 for v in grad_norms_baseline.values()) ** 0.5
)
Out[10]:
Console
Baseline batch size: 32
Baseline loss: 7.0769
Total gradient norm (baseline): 1.075834
Number of parameter tensors: 27

The baseline run processes 32 samples simultaneously and computes the mean loss across all of them. The gradient norm reflects the average gradient direction over the full batch.

Gradient Accumulation Loop

Now let us implement the equivalent training step using gradient accumulation with micro-batches of size 8 and K=4K = 4 accumulation steps:

In[11]:
Code
# Create a fresh model with the same initial weights as baseline
model_accum = SmallTransformer(vocab_size=VOCAB_SIZE)
model_accum.load_state_dict(model_baseline.state_dict())
optimizer_accum = torch.optim.Adam(model_accum.parameters(), lr=1e-3)

loader_micro = DataLoader(dataset, batch_size=MICRO_BATCH, shuffle=False)
micro_iter = iter(loader_micro)

model_accum.train()
optimizer_accum.zero_grad()  # Zero ONCE per logical batch

accum_loss = 0.0
for step in range(ACCUM_STEPS):
    x_micro, y_micro = next(micro_iter)
    logits_micro = model_accum(x_micro)  # (8, 32, 1000)

    # Compute mean loss over micro-batch, then scale by 1/K
    loss_micro = loss_fn(
        logits_micro.reshape(-1, VOCAB_SIZE), y_micro.reshape(-1)
    )
    scaled_loss = loss_micro / ACCUM_STEPS  # CRITICAL: divide by K

    scaled_loss.backward()  # Accumulates into gradient buffers
    accum_loss += loss_micro.item()

# Record accumulated gradient norms (before optimizer step)
grad_norms_accum = {}
for name, param in model_accum.named_parameters():
    if param.grad is not None:
        grad_norms_accum[name] = param.grad.norm().item()

total_grad_norm_accum = sum(v**2 for v in grad_norms_accum.values()) ** 0.5
mean_loss_accum = accum_loss / ACCUM_STEPS
Out[12]:
Console
Accumulation steps (K): 4
Micro-batch size: 8
Effective batch size: 32
Mean loss (accumulated): 7.0769
Total gradient norm (accumulated): 1.075834

With identical initial weights and the same data ordering, the accumulated gradient norm should match the baseline gradient norm closely. The small numerical differences arise from floating-point rounding order, which varies when summing gradients in a different sequence.

Comparing Gradient Equivalence

Let us verify the gradient equivalence numerically by comparing per-parameter gradient norms:

In[13]:
Code
# Collect per-layer gradient differences
param_names = list(grad_norms_baseline.keys())
grad_diffs = {}
for name in param_names:
    if name in grad_norms_accum:
        diff = abs(grad_norms_baseline[name] - grad_norms_accum[name])
        rel_diff = diff / (grad_norms_baseline[name] + 1e-12)
        grad_diffs[name] = {"abs_diff": diff, "rel_diff": rel_diff}

max_rel_diff = max(v["rel_diff"] for v in grad_diffs.values())
mean_rel_diff = sum(v["rel_diff"] for v in grad_diffs.values()) / len(
    grad_diffs
)
Out[14]:
Console
Gradient norm comparison (baseline vs accumulated):
  Total norm baseline:    1.07583426
  Total norm accumulated: 1.07583422
  Absolute difference:    4.22e-08
  Max relative diff (per layer): 1.16e-07
  Mean relative diff:            2.82e-08

The relative differences should be on the order of 10−610^{-6} or smaller, which corresponds to floating-point rounding errors rather than any systematic bias. This confirms the mathematical equivalence between full-batch training and gradient accumulation.

Out[15]:
Visualization
Bar chart showing maximum relative gradient-norm differences for embedding, attention, feed-forward, layer-normalization, and output-head parameters.
Maximum relative gradient-norm difference within five parameter groups for a deterministic full-batch step and gradient accumulation with K=4 and micro-batch size 8. Every group remains below the 1e-4 reference line, confirming equivalence up to floating-point summation order when stochastic layers are disabled. An exact zero for the embedding group is drawn at the 1e-12 display floor.

Correct Gradient Clipping Integration

When gradient clipping is part of the training recipe, it must be applied after accumulation but before the optimizer step:

In[16]:
Code
model_clip = SmallTransformer(vocab_size=VOCAB_SIZE)
model_clip.load_state_dict(model_baseline.state_dict())
optimizer_clip = torch.optim.Adam(model_clip.parameters(), lr=1e-3)

MAX_GRAD_NORM = 1.0
loader_clip = DataLoader(dataset, batch_size=MICRO_BATCH, shuffle=False)
clip_iter = iter(loader_clip)

model_clip.train()
optimizer_clip.zero_grad()

for step in range(ACCUM_STEPS):
    x_c, y_c = next(clip_iter)
    logits_c = model_clip(x_c)
    loss_c = loss_fn(logits_c.reshape(-1, VOCAB_SIZE), y_c.reshape(-1))
    (loss_c / ACCUM_STEPS).backward()

# Clip AFTER accumulation, BEFORE optimizer step
grad_norm_before_clip = nn.utils.clip_grad_norm_(
    model_clip.parameters(), max_norm=MAX_GRAD_NORM
)
optimizer_clip.step()  # Now apply the clipped accumulated gradients
Out[17]:
Console
Gradient norm before clipping: 1.0758
Max gradient norm allowed:     1.0000
Clipping applied: True
Gradients were scaled by: 0.9295

The clip_grad_norm_ function returns the gradient norm before clipping, which is useful for logging. If the norm exceeds MAX_GRAD_NORM, it scales all gradients down proportionally. This also is a health monitor: persistently high pre-clip norms can signal the training instabilities discussed in the Training Stability chapter.

Distributed Training with no_sync

In multi-GPU training with PyTorch's DistributedDataParallel, you should suppress gradient all-reduce during the accumulation micro-batches and allow it only on the final step:

In[18]:
Code
# Pattern for DDP + gradient accumulation (not executed: requires DDP setup)

# model must be wrapped: model = DDP(base_model, ...)
# optimizer and loss_fn as before

optimizer.zero_grad()

for step in range(ACCUM_STEPS):
    x_micro, y_micro = next(micro_iter)

    # Suppress all-reduce for all but the last micro-batch
    if step < ACCUM_STEPS - 1:
        with model.no_sync():
            logits = model(x_micro)
            loss = loss_fn(logits.reshape(-1, VOCAB_SIZE), y_micro.reshape(-1))
            (loss / ACCUM_STEPS).backward()
    else:
        # Final micro-batch: allow the all-reduce to fire
        logits = model(x_micro)
        loss = loss_fn(logits.reshape(-1, VOCAB_SIZE), y_micro.reshape(-1))
        (loss / ACCUM_STEPS).backward()

torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()

Without no_sync(), each of the KK backward passes would trigger an all-reduce across all GPUs, performing KK times as many communication rounds as necessary. For a large model with K=16K = 16, this would add 15 unnecessary all-reduce operations per optimizer step.

Memory Footprint Comparison

Let us measure the actual memory savings from reducing the micro-batch size:

In[18]:
Code
import gc


def measure_peak_memory(
    batch_size, model_fn, vocab_size=VOCAB_SIZE, seq_len=SEQ_LEN
):
    """Measure peak GPU memory (or estimate via tensor sizes) for one forward-backward."""
    gc.collect()
    torch.cuda.empty_cache() if torch.cuda.is_available() else None

    model = model_fn()
    optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

    x = torch.randint(0, vocab_size, (batch_size, seq_len))
    y = torch.randint(0, vocab_size, (batch_size, seq_len))

    # Estimate activation memory: each layer stores O(batch * seq * d_model) floats
    d_model = model.d_model
    num_layers = 2  # from SmallTransformer definition
    bytes_per_float = 4  # float32

    # Rough activation estimate: 12 tensors per layer (QKV, attention, FFN intermediates)
    activation_bytes = (
        batch_size * seq_len * d_model * num_layers * 12 * bytes_per_float
    )
    return activation_bytes / (1024**2)  # MB


batch_sizes = [1, 2, 4, 8, 16, 32]
memory_estimates = []
for bs in batch_sizes:
    mem_mb = measure_peak_memory(
        bs, lambda: SmallTransformer(vocab_size=VOCAB_SIZE)
    )
    memory_estimates.append(mem_mb)
Out[19]:
Console
Estimated activation memory by micro-batch size:
Micro-batch    Activation (MB)    Effective batch (K=32/bs) 
----------------------------------------------------------
1              0.4                32                        
2              0.8                32                        
4              1.5                32                        
8              3.0                32                        
16             6.0                32                        
32             12.0               32

The activation memory scales linearly with micro-batch size. A micro-batch of 1 uses 1/32 the activation memory of a micro-batch of 32, while gradient accumulation with K=32K = 32 recovers the same effective batch size of 32. Parameters, gradients, and optimizer states (not shown) remain constant across all rows.

Out[20]:
Visualization
Line plot showing activation memory scaling linearly with micro-batch size.
Activation memory vs micro-batch size for the SmallTransformer model (128-dim, 2 layers, seq len 32), with gradient accumulation steps K set to maintain a constant effective batch size of 32. The linear relationship between micro-batch size and activation memory confirms that gradient accumulation directly reduces the memory-intensive component while keeping the effective batch size constant.

Key Parameters

The key parameters for gradient accumulation are:

  • accumulation_steps (KK): The number of micro-batches per optimizer step. Set to effective_batch_size // micro_batch_size. Higher values reduce activation memory but increase the number of forward-backward passes per update.
  • micro_batch_size: The number of samples processed per forward-backward pass. Limited by GPU memory capacity. Smaller values consume less activation memory.
  • loss_scaling: Always divide each micro-batch loss by KK before calling .backward(). Failure to do this scales the effective learning rate by KK.
  • gradient_clipping: Apply clip_grad_norm_() after the final backward pass and before optimizer.step(). Never clip inside the accumulation loop.
  • no_sync(): In distributed training, wrap the first K−1K-1 micro-batch backward passes in model.no_sync() to suppress unnecessary all-reduce communication.