梯度检查点技术:用时间换显存的经典套路
说实话,我第一次接触梯度检查点时,心里是拒绝的。
你想啊,本来训练一个模型就够慢了,还要在 forward 的时候故意不存中间结果,backward 的时候再重新算一遍——这不是自己给自己找麻烦吗?
但后来我在一个 80GB 显存的 A100 上跑一个 7B 的 LLaMA 微调任务,batch size 只能设到 2。换了梯度检查点之后,batch size 直接翻到了 8。嗯,真香。
什么是梯度检查点?
梯度检查点(Gradient Checkpointing),也叫「激活重计算」。说白了就是:forward 的时候只存一小部分关键激活值,backward 的时候需要哪些中间结果,就现场重新算一遍。
我习惯把它理解成「显存版的虚拟内存」——把不常用的数据暂时扔掉,用的时候再从磁盘(这里是计算)换回来。
核心思想:用额外的计算开销,换取显存空间的释放。
原理:到底省了哪部分显存?
我们先看看一个普通的训练流程里,显存都花在哪了:
- 模型参数:权重本身,这个省不了
- 优化器状态:Adam 的 momentum 和 variance,也省不了
- 激活值(Activations):每一层的中间输出——这才是大头
- 梯度:反向传播时产生的中间梯度
激活值占了多少?我举个例子。一个 24 层的 Transformer,序列长度 1024,隐藏维度 1024,光激活值就能吃掉 3-4GB 显存。层数越多、序列越长,这个数字越恐怖。
梯度检查点就是针对「激活值」下手的。
实现方式:怎么在代码里用?
如果你用 PyTorch,其实就一行代码的事:
from torch.utils.checkpoint import checkpoint
# 原始 forward
def forward(self, x):
return self.ffn(self.attention(x))
# 加了检查点
def forward(self, x):
return checkpoint(self.ffn, self.attention(x))
等等,别急着复制。这里有个坑——checkpoint 函数要求输入不能有 in-place 操作。我曾经在一个 LayerNorm 里用了 x += residual,结果 backward 的时候梯度死活不对。排查了半天,才发现是 in-place 惹的祸。
避坑指南:使用 checkpoint 时,确保模块内部没有 in-place 操作(如 +=、.add_())。否则梯度会算错,而且不会报错——这才是最坑的。
如果你用 HuggingFace Transformers,更简单:
from transformers import TrainingArguments
training_args = TrainingArguments(
output_dir="./output",
gradient_checkpointing=True, # 就这一行
per_device_train_batch_size=4,
)
嗯,HuggingFace 已经帮你封装好了。但我建议你还是理解一下底层原理,不然出了问题都不知道怎么调。
时间与空间的权衡:到底值不值?
这是最核心的问题。我们来看一组实测数据(以 GPT-2 1.5B 为例,batch size=8,序列长度=1024):
| 配置 | 显存占用 | 训练时间(每步) | 加速比 |
|---|---|---|---|
| 无检查点 | 24.3 GB | 1.2s | 1.0x |
| 全层检查点 | 12.1 GB | 1.9s | 0.63x |
| 选择性检查点(每2层) | 16.8 GB | 1.4s | 0.86x |
看到没?全层检查点省了 50% 的显存,但慢了 58%。而选择性检查点只省了 30% 的显存,但只慢了 16%。
我个人习惯的做法是:先看显存瓶颈在哪。如果就差那么 2-3GB,用选择性检查点就够了。如果差得很多,那就全层检查点,配合梯度累积一起用。
什么时候该用?什么时候不该用?
我总结了几条经验:
- 该用:
- 模型太大,batch size 上不去
- 序列长度很长(比如 4096 以上)
- 显存刚好差那么一点,换卡又太贵
- 不该用:
- 模型本身很小,显存绰绰有余
- 训练速度是瓶颈,显存不是
- 你的模型里有大量 in-place 操作(改起来太麻烦)
小技巧:如果你用 PyTorch 的 torch.utils.checkpoint,可以配合 torch.no_grad() 一起用。在不需要梯度的推理阶段,直接关掉检查点,省掉不必要的重计算开销。
核心逻辑流程图
下面这张图展示了梯度检查点的工作流程,我画了个 SVG 方便你理解:
一些实战中的坑
最后分享几个我踩过的坑:
- 不要对输入做检查点——如果你把 embedding 层也包进 checkpoint,反而会浪费显存,因为 embedding 的激活值本来就不大。
- 注意梯度累积的配合——用了梯度检查点之后,batch size 上去了,但梯度累积步数要相应减少。我见过有人两个一起开,结果显存是省了,但训练效果变差了。
- 检查点粒度不是越细越好——每层都做检查点,重计算开销会爆炸。我一般建议每 2-4 层设一个检查点。
- 混合精度训练下要小心——fp16 的梯度检查点有时会引入数值误差。我习惯在关键层(比如 attention 的 softmax)保留 fp32 精度。
嗯,梯度检查点就是这么个东西。说白了就是「用时间换空间」——在显存不够用的时候,这是一个性价比很高的选择。但别盲目用,先算清楚你的显存瓶颈到底在哪,再决定要不要上。
想要更多资料,联系可可