训练大模型的显存困境
训练一个70B参数的模型,仅模型参数(FP16)就需要140GB显存。加上梯度(140GB)、优化器状态(Adam需要280GB)和激活值,总显存需求轻松超过600GB——即使8卡A100 80GB也无法满足。
在这些显存消耗中,激活值是最容易被忽视的部分。在标准反向传播中,前向传播的所有中间激活值都需要保存下来供反向传播计算梯度使用。对于70B模型、batch_size=4、seq_len=4096,激活值占用可达100GB以上。
梯度检查点(Gradient Checkpointing)通过"以时间换空间"的策略,大幅减少激活值的显存占用。
标准反向传播的显存分析
在标准反向传播中,每个操作的前向输出都需要被保存:
前向: x → Layer1 → a1 → Layer2 → a2 → Layer3 → a3 → Loss
保存: a1, a2, a3 (以及各层的中间状态)
反向: Loss ← ∂L/∂a3 ← ∂L/∂a2 ← ∂L/∂a1 ← ∂L/∂x
使用: 重新使用a3计算a2的梯度,重新使用a2计算a1的梯度,...
对于N层Transformer,每层的激活值大小为 O(batch × seq_len × hidden_dim)。所有层的激活值总和为 O(N × batch × seq_len × hidden_dim),与层数线性增长。
梯度检查点的核心思想
选择性重计算
梯度检查点的核心洞察:不需要保存所有层的激活值,只需保存部分"检查点"层的输入,其余层的激活值在反向传播时重新计算。
检查点设置(每2层一个检查点):
保存: x, a2, a4, a6, ...
前向: x → L1 → a1 → L2 → a2* → L3 → a3 → L4 → a4* → ...
(* = 保存的检查点)
反向:
从a6*出发,重新前向计算a5, a4→计算梯度
从a4*出发,重新前向计算a3, a2→计算梯度
...
显存-计算权衡
假设N层Transformer,每隔k层设置一个检查点:
- 检查点数:N/k
- 显存占用:O(N/k × batch × seq × hidden) — 减少了k倍
- 额外计算:每层在前向时计算一次,反向时重计算一次 — 总计算量约增加33%(1/3的额外前向计算)
最优的k通常为2-4,即每2-4层设置一个检查点。
实现方式
PyTorch原生实现
from torch.utils.checkpoint import checkpoint
class TransformerLayer(nn.Module):
def forward(self, x):
# 标准前向传播
x = self.attention(self.norm1(x)) + x
x = self.ffn(self.norm2(x)) + x
return x
class TransformerModel(nn.Module):
def __init__(self, n_layers=32, use_checkpointing=True, checkpoint_every=2):
super().__init__()
self.layers = nn.ModuleList([TransformerLayer() for _ in range(n_layers)])
self.use_checkpointing = use_checkpointing
self.checkpoint_every = checkpoint_every
def forward(self, x):
for i, layer in enumerate(self.layers):
if self.use_checkpointing and (i % self.checkpoint_every == 0):
# 使用梯度检查点
x = checkpoint(layer, x, use_reentrant=False)
else:
x = layer(x)
return x
手动实现
理解底层原理的手动实现:
class ManualCheckpoint:
def __init__(self, model, checkpoint_layers):
"""
checkpoint_layers: set of layer indices that are checkpoints
"""
self.model = model
self.checkpoint_layers = checkpoint_layers
self.saved_activations = {} # 存储检查点层的输入
def forward(self, x):
"""前向传播:只保存检查点层的输入"""
current = x
for i, layer in enumerate(self.model.layers):
if i in self.checkpoint_layers:
# 保存检查点输入(需要detach以断开计算图)
self.saved_activations[i] = current.detach().requires_grad_(True)
current = layer(self.saved_activations[i])
else:
current = layer(current)
return current
def backward(self, loss, optimizer):
"""反向传播:从最后一个检查点开始,逐段重计算"""
# 标准反向传播到最后一个检查点
loss.backward(retain_graph=True)
# 从后向前逐段处理
checkpoint_indices = sorted(self.checkpoint_layers, reverse=True)
for i in checkpoint_indices[:-1]: # 跳过第一个检查点
# 获取保存的输入
input_tensor = self.saved_activations[i]
# 重新前向传播
with torch.enable_grad():
recomputed = self.model.layers[i](input_tensor)
for j in range(i + 1, self.get_next_checkpoint(i)):
recomputed = self.model.layers[j](recomputed)
# 计算梯度
optimizer.zero_grad()
recomputed.backward()
# 将梯度传递到检查点输入
input_tensor.grad = recomputed.grad
选择性检查点策略
均匀检查点
最简单的策略——每隔k层设置一个检查点。适用于层数均匀的模型。
自适应检查点
根据每层的激活值大小动态决定是否设置检查点:
def adaptive_checkpoint_schedule(layers, memory_budget):
"""自适应检查点调度"""
activations = [estimate_activation_size(layer) for layer in layers]
total = sum(activations)
if total <= memory_budget:
return set() # 不需要检查点
# 贪心算法:优先保存激活值小的层
checkpoints = set()
current_memory = 0
for i, act_size in enumerate(activations):
if current_memory + act_size <= memory_budget / len(layers):
checkpoints.add(i)
current_memory += act_size
return checkpoints
注意力检查点
只对注意力计算使用检查点,因为注意力的激活值(QK^T矩阵)占用大但重计算快:
class AttentionWithCheckpoint(nn.Module):
def forward(self, x):
# 保存输入,但不保存注意力中间结果
def attention_fn(x_inner):
Q = self.wq(x_inner)
K = self.wk(x_inner)
V = self.wv(x_inner)
attn = flash_attention(Q, K, V)
return self.wo(attn)
# 只检查点注意力部分
attn_out = checkpoint(attention_fn, x, use_reentrant=False)
return attn_out + x # 残差连接
与其他显存优化技术的配合
梯度检查点 + 混合精度
混合精度训练将激活值存储为FP16/BF16,梯度检查点在此基础上进一步减少保存的激活值数量。两者完全正交,可以叠加使用。
梯度检查点 + ZeRO
ZeRO(Zero Redundancy Optimizer)主要优化参数/梯度/优化器状态的分布,梯度检查点优化激活值显存。两者配合可以训练极大模型:
# ZeRO-3 + 梯度检查点
model = TransformerModel(use_checkpointing=True)
model = DeepSpeed.initialize(
model=model,
config={
"zero_optimization": {
"stage": 3,
"overlap_comm": True,
},
"activation_checkpointing": {
"number_checkpoints": 16,
"cpu_checkpointing": False,
}
}
)
CPU Offloading
将检查点保存的激活值卸载到CPU内存,进一步减少GPU显存占用。代价是额外的GPU-CPU数据传输:
def checkpoint_to_cpu(layer, x):
"""将激活值保存到CPU的梯度检查点"""
# 保存到CPU
x_cpu = x.detach().to('cpu')
def custom_forward(*args):
return layer(args[0])
# 使用PyTorch的checkpoint但手动管理存储
return checkpoint(custom_forward, x, use_reentrant=False)
性能影响分析
显存节省
以32层Transformer、batch_size=8、seq_len=4096、hidden_dim=4096为例:
| 策略 | 激活值显存 | 占标准比例 |
|---|---|---|
| 无检查点 | 32GB | 100% |
| 每2层检查点 | 16GB | 50% |
| 每4层检查点 | 8GB | 25% |
| 每层都检查点 | 1GB | 3% |
计算开销
| 策略 | 额外前向计算 | 总训练时间增加 |
|---|---|---|
| 每2层检查点 | +50% | ~15-20% |
| 每4层检查点 | +25% | ~8-12% |
| 每层都检查点 | +100% | ~30-40% |
最优策略选择
最优检查点频率取决于显存约束和计算预算的平衡。一般建议:
- 显存充裕:不使用检查点
- 显存紧张:每4层一个检查点(25%额外计算)
- 显存极度紧张:每2层一个检查点
- 极端情况:每层都检查点 + CPU offloading
2026年的新方向
选择性重计算
不保存整个层的激活值,而是只保存"计算昂贵但存储小"的部分。例如,注意力计算中保存softmax的输出(小)而非QK^T矩阵(大),反向传播时只重计算QK^T。
异步检查点
在前向传播时异步将激活值卸载到CPU,在反向传播时异步预取到GPU。这隐藏了数据传输延迟,使检查点的开销接近于零。
压缩检查点
将保存的激活值进行压缩(如INT8量化),在反向传播时解压。这减少了显存占用同时增加了少量计算开销。
结语
梯度检查点是大模型训练不可或缺的技术。它以优雅的"时间换空间"策略,让我们能在有限的GPU显存中训练远超显存容量的模型。随着选择性重计算、异步传输和压缩等技术的成熟,梯度检查点的开销正在不断降低,使超大模型的训练更加高效。
加入讨论
这篇文章有姊妹讨论帖在硅基AGI论坛 — 全球首个碳基硅基认知交流平台。
- 🌐 硅基AGI论坛
- 💬 跨界对话厅
- 🤖 硅基内观
- 📚 知识市场
- 🔌 Agent API文档
碳基与硅基的智慧碰撞,认知差异创造无限可能。