Skip to content

Gradient Checkpointing

Overview

Gradient Checkpointing (aka Activation Checkpointing or Recomputation) reduces memory usage by selectively discarding intermediate activations during forward pass and recomputing them during backward pass. Trade-off: Save ~50% memory at cost of ~30% computation overhead.

  • Foundational Idea: "Training Deep Nets with Sublinear Memory" (Chen et al., 2016)
  • Trade-off: Memory: O(N) → O(√N), Compute: ~1.3x slower training
  • Adoption: Standard in transformers (LLaMA, BERT, GPT all use it)
  • Key Insight: Recompute is cheaper than storing large activations in GPU memory
  • Modern Implementation: PyTorch provides native support

-

The Memory Problem During Training

Where Memory Goes

GPU Memory During Training:

Model Weights: 70B parameters = 140GB (float32)
Optimizer States: 2 × 70B = 140GB (Adam: m and v buffers)
Activations (stored): Depends on depth and batch size
 - Layer 1 output: (batch, seq_len, hidden) = 2GB
 - Layer 2 output: (batch, seq_len, hidden) = 2GB
 -...
 - Layer 80 output: (batch, seq_len, hidden) = 2GB
 - Total: 80 layers × 2GB = 160GB!

Total during training:
 - Weights + Optimizer: 280GB
 - Activations: 160GB
 - Gradients: 140GB
 - Total: 580GB!

Only A100 (80GB) can't fit even one example per GPU!
Problem: Activation storage is huge!

Storage breakdown:
 - Weights/Optimizer: 70% of memory
 - Activations: 30% of memory
 - Most problematic: Activations aren't needed immediately!

Why Store Activations?

Backward pass needs activations:

Forward pass produces:
 - Layer 1: a₁ (activation)
 - Layer 2: a₂ (activation)
 -...
 - Layer 80: a₈₀ (activation)
 - Loss: L

Backward pass computes:
 - ∂L/∂a₈₀ = dL/da₈₀ (computed from forward)
 - ∂L/∂W₈₀ = dL/da₈₀ × ∂a₈₀/∂W₈₀ (needs a₈₀!)
 - ∂L/∂a₇₉ = dL/da₈₀ × ∂a₈₀/∂a₇₉ (needs both a₈₀ and a₇₉!)
 -...

To compute ∂L/∂W at layer i:
 - Need activation a_i from forward pass!

Current approach:
 - Store all activations during forward: 160GB
 - Backward pass uses stored activations
 - Problem: Huge memory for storing!

Question: Can we compute ∂L/∂W without storing activations?
Answer: Yes! Recompute them!

-

Gradient Checkpointing: The Solution

Core Idea

Instead of:
- ┌─────────────────┐ ┌──────────────────┐
 - Forward Pass │ │ Backward Pass │
 - (store a₁..a₈₀) │ → │ (use a₁..a₈₀) │
 - ┘ └──────────────────┘
Memory: 160GB

Do:
- ┌─────────────────┐ ┌──────────────────┐
 - Forward Pass │ │ Recompute │ ┌──────────────────┐
 - (store a₁..a₁₀) │ → │ Forward (a₁..a₈₀)│ → │ Backward Pass │
 - ┘ └──────────────────┘ │ (use recomputed) │
 - Memory: ~16GB Compute: extra └──────────────────┘

Savings:
 - Forward: Store only ~10 activations (1/8 of total)
 - Backward: Recompute ~70 activations (extra compute)
 - Trade: 90% memory saving for 30% compute overhead
 - Net: Better than 90GB + waiting for OOM error!

Checkpointing Strategy

Transformer Block:
- ┌──────────────────────────────┐
 - Input x │
 - ┤
 - Self-Attention │ ← Save output here (checkpoint)
 - ┤
 - Feed-Forward Network (FFN) │
 - ┤
 - Output │ ← Save output here (checkpoint)
 - ┘

Checkpoint placement strategy:
 - Option 1: Every layer (expensive backward recompute)
 - Option 2: Every 2-3 layers (balanced)
 - Option 3: Only attention outputs (reasonable)
 - Typical: Save ~20-25% of activations, recompute 75-80%

Implementation

PyTorch Native Support

import torch
import torch.nn as nn
from torch.utils.checkpoint import checkpoint

class TransformerBlock(nn.Module):
 def __init__(self, hidden_dim):
 super().__init__()
 self.self_attn = MultiHeadAttention(hidden_dim)
 self.ffn = FeedForward(hidden_dim)
 self.norm1 = nn.LayerNorm(hidden_dim)
 self.norm2 = nn.LayerNorm(hidden_dim)

 def forward(self, x):
 # Without checkpointing: stores all intermediate activations
 # x_attn = self.self_attn(self.norm1(x))
 # out = self.ffn(self.norm2(x + x_attn))
 # return x + out

 # With checkpointing: recompute during backward
 def create_attention_layer(x):
 return self.self_attn(self.norm1(x))

 def create_ffn_layer(x_attn, x):
 return self.ffn(self.norm2(x + x_attn))

 # checkpoint() recomputes during backward, doesn't store activations
 x_attn = checkpoint(create_attention_layer, x, use_reentrant=False)
 out = checkpoint(create_ffn_layer, x_attn, x, use_reentrant=False)
 return x + out

# Usage:
model = nn.Sequential(
 TransformerBlock(2048),
 TransformerBlock(2048),
 #... more blocks
)

# Training loop
x = torch.randn(32, 4096, 2048) # batch, seq_len, hidden_dim
y = model(x)
loss = criterion(y, target)
loss.backward() # Automatically recomputes activations!
optimizer.step()

Manual Checkpointing Control

class FullyShardedTransformer(nn.Module):
 def __init__(self, num_layers, hidden_dim):
 super().__init__()
 self.layers = nn.ModuleList([
 TransformerBlock(hidden_dim)
 for _ in range(num_layers)
])
 self.use_checkpoint = True

 def forward(self, x):
 for i, layer in enumerate(self.layers):
 if self.use_checkpoint:
 # Create wrapper that doesn't require self
 def layer_forward(x, layer=layer):
 return layer(x)

 x = checkpoint(layer_forward, x, use_reentrant=False)
 else:
 x = layer(x)
 return x

# Advanced
class SelectiveCheckpointTransformer(nn.Module):
 def __init__(self, num_layers, hidden_dim, checkpoint_interval=1):
 super().__init__()
 self.layers = nn.ModuleList([
 TransformerBlock(hidden_dim)
 for _ in range(num_layers)
])
 self.checkpoint_interval = checkpoint_interval

 def forward(self, x):
 for i, layer in enumerate(self.layers):
 # Checkpoint every N layers
 if (i % self.checkpoint_interval) == 0:
 def layer_forward(x, layer=layer):
 return layer(x)
 x = checkpoint(layer_forward, x, use_reentrant=False)
 else:
 x = layer(x)
 return x

-

The Trade-off Analysis

Memory Savings

Scenario: 70B model, batch_size=1, seq_len=4096

Without checkpointing:
 - Layer activations: 80 layers × 2GB = 160GB
 - Peak memory: 280GB (weights + optimizer) + 160GB = 440GB
 - Doesn't fit on A100 (80GB)!

With checkpointing (every layer):
 - Stored activations: ~1 layer (2GB)
 - Peak memory: 280GB + 2GB = 282GB (still doesn't fit with optimizer!)

With checkpointing + FSDP (8 GPUs):
 - Per GPU parameters: 140GB / 8 = 17.5GB
 - Per GPU optimizer: 140GB / 8 = 17.5GB
 - Per GPU activations: 2GB (checkpoint)
 - Total per GPU: ~37GB (fits on A100!)
 - Solution: FSDP + checkpointing enables large model training!

Compute Overhead

Computation breakdown (per layer):

Forward pass (one-time): F
Backward pass without checkpoint: B (uses stored activation)
Backward pass with checkpoint: F + B (recompute + backward)

Total compute:
 - Without checkpoint: F + B per layer = (F + B) × 80 layers
 - With checkpoint: (F + B + F) × 80 = (2F + B) × 80 layers
 - Overhead: F / (F + B) = typically 30-40%

Typical F/B ratio for transformers: F:B ≈ 1:2 (backward heavier)
 - Overhead = 1/3 ≈ 33% extra compute

Actual wall-clock time:
 - Without checkpoint: Faster per batch, but OOMs on large models
 - With checkpoint: 30-40% slower per batch, fits large models
 - Net-net: Can train 10-100x larger models with checkpoint
 - Trade is worthwhile!

Real example (GPT-3 70B):
 - Without checkpoint: ~120 hrs training, OOMs
 - With checkpoint: ~150 hrs training (1.25x), fits!
 - Can add more data, more GPUs, get to production

Advanced Techniques

Partial Checkpointing

Instead of checkpointing all layers, only checkpoint expensive ones:

Cost of operations (relative):
 - Self-attention: 1x (attention is relatively fast)
 - FFN: 2-3x (FFN is compute-intensive)
 - LayerNorm: 0.1x (super cheap)

Strategy:
 - Checkpoint FFN layers
 - Don't checkpoint attention layers
 - Save 50% activations, but keep cheap operations stored
 - Balances memory and compute better

Selective Checkpointing

class SmartCheckpointTransformer(nn.Module):
 def forward(self, x):
 for i, layer in enumerate(self.layers):
 # Only checkpoint expensive FFN layer
 if isinstance(layer.ffn, ExpensiveFFN):
 def ffn_checkpoint(x):
 return layer.ffn(x)
 ffn_out = checkpoint(ffn_checkpoint, layer.norm1(x))
 x = x + ffn_out
 else:
 # Cheap layers: no checkpoint
 x = layer(x)
 return x

Reentrant vs Non-reentrant Checkpointing

# Traditional (reentrant=True):
checkpoint(func, x, use_reentrant=True)

Pros:
Lower memory overhead
Compatible with older PyTorch versions

Cons:
Can cause issues with certain operations
Requires careful implementation

# Modern (reentrant=False):
checkpoint(func, x, use_reentrant=False)

Pros:
More stable with complex models
Better compatibility
PyTorch 1.11+

Cons:
Slightly higher memory overhead
Requires newer PyTorch

When to Use Gradient Checkpointing

Use checkpointing when

Model is large (>1B parameters)
Batch size is small (can't use more GPUs easily)
Memory is bottleneck (OOM errors)
Sequence length is long (more activations to store)
Can afford 30-40% compute overhead

Don't use checkpointing when

Memory is plentiful (small model + large GPUs)
Training speed is critical
Operations don't compose well (RNGs, special functions)

Key Takeaways

Trade 30% compute for 50-80% memory savings Essential for training LLMs on GPUs with limited memory Checkpointing enables 10-100x larger models to train Modern PyTorch makes it easy: just wrap function in checkpoint() Widely adopted: LLaMA, BERT, GPT all use it

-