3. 梯度检查点 (Gradient Checkpointing)

好,咱们来聊聊梯度检查点。说实话,这玩意儿是我在训练一个 7B 模型时被逼着深入研究的技术。当时显存爆得我怀疑人生,恨不得把显卡拆了手动扩容。后来发现,其实有个更优雅的解法——用时间换空间。

3.1 核心原理:为什么它能省显存?

先想想看,训练一个模型时,显存都花在哪了?

  • 模型参数(权重)
  • 优化器状态(Adam 的动量、方差)
  • 激活值(中间层的输出)

其中,激活值占了大头。尤其是 Transformer 这种结构,序列越长,激活值越恐怖。我见过一个 13B 模型,光激活值就吃了 40GB 显存。

梯度检查点的思路很简单:不保存所有中间激活值,而是在反向传播时重新计算它们。

具体来说,我们把网络分成若干段(checkpoint segments)。前向传播时,只保存每个段的输入和输出,中间的激活值全部扔掉。反向传播时,从保存的输入开始,重新跑一遍前向,把中间激活值算出来,再算梯度。

关键点: 这本质上是用计算量换显存。前向多算一次,显存省一半以上。

3.2 实现方式:PyTorch 里怎么搞?

PyTorch 官方提供了 torch.utils.checkpoint 模块,用起来非常直接。我个人习惯把它包装成一个函数,方便复用。

import torch
import torch.utils.checkpoint as checkpoint

def checkpoint_wrapper(module, *args):
    """对任意模块应用梯度检查点"""
    return checkpoint.checkpoint(module, *args, use_reentrant=False)

# 使用示例
class MyModel(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.block1 = torch.nn.Linear(1024, 1024)
        self.block2 = torch.nn.Linear(1024, 1024)
        self.block3 = torch.nn.Linear(1024, 1024)
    
    def forward(self, x):
        # 只对中间层使用检查点
        x = self.block1(x)
        x = checkpoint_wrapper(self.block2, x)  # 这里省显存
        x = self.block3(x)
        return x

注意 use_reentrant=False 这个参数。我刚开始用的时候没加,结果在某个自定义层上出了 bug,排查了半天。后来发现这是 PyTorch 1.10 之后推荐的做法,能避免一些诡异的梯度问题。

小技巧: 不是所有层都需要加检查点。通常只对计算量大、激活值大的层(比如 Transformer 的 attention 和 FFN)使用。像 embedding 层这种,加了反而得不偿失。

3.3 时间与空间的权衡:到底值不值?

说白了,这就是个 trade-off。你省了显存,但多花了时间。具体多少呢?我拿一个实际案例给你看。

假设我们有一个 12 层 Transformer,每层 hidden size 为 4096,序列长度 2048,batch size 为 8。

配置 显存占用 训练时间(每步) 备注
无检查点 ~48 GB ~1.2 秒 全部激活值都保存
每层都加检查点 ~22 GB ~2.1 秒 显存省了 54%,时间多了 75%
隔层加检查点 ~32 GB ~1.5 秒 折中方案,显存省 33%,时间多 25%

看到没?不是加得越多越好。我曾经在一个项目里,为了省显存,给所有层都加了检查点。结果训练时间翻了一倍,显存只省了 60%。后来改成每隔一层加一个,效果反而更好。

避坑指南: 我曾经在 batch size 很小的时候强行加检查点,结果显存没省多少,时间却暴涨。原因很简单——检查点本身也有开销。当 batch size 小于 4 时,我建议你慎重考虑是否真的需要它。

3.4 知识体系:一张图看懂

下面这张 SVG 图,把梯度检查点的核心逻辑串起来了。我画的时候特意把「时间换空间」这个 trade-off 放在中心,因为这是理解它的关键。

梯度检查点核心逻辑 前向传播 1. 正常计算每一层 2. 只保存 checkpoint 点的输入 3. 中间激活值全部丢弃 4. 保存每个段的输出 时间换空间 显存节省:50% - 70% 时间开销:30% - 100% 适用场景:显存瓶颈时 反向传播 1. 从保存的输入开始 2. 重新计算中间激活值 3. 计算梯度并更新权重 4. 释放临时激活值 重复每个 checkpoint 段 图:梯度检查点的前向与反向传播流程

3.5 实战建议:怎么选 checkpoint 点?

根据我的经验,有几点可以帮你快速决策:

  1. 优先加在计算密集的层:比如 Transformer 的 attention 和 FFN,它们产生的激活值最大。
  2. 不要加在输入输出层:embedding 和最后的 linear 层,激活值很小,加了反而增加开销。
  3. 隔层加比全加好:比如 12 层 Transformer,每 2 层加一个检查点,效果往往最优。
  4. 结合 batch size 调整:batch size 越大,检查点的收益越明显。batch size 小的时候,不如直接减 batch size。

一句话总结: 梯度检查点不是银弹,但在显存不够时,它是性价比最高的解法之一。用 30% 的时间开销,换 50% 的显存节省,这笔账怎么算都划算。

嗯,关于梯度检查点,核心就是这些。下次你训练大模型爆显存时,不妨先试试这个方案,比换显卡便宜多了。