第三节:梯度累积与显存优化

梯度累积,说白了就是「攒一波再更新」。

我刚开始接触大模型训练时,最头疼的就是显存不够用。明明想跑个 32 的 batch size,结果 8 就爆显存了。后来发现,梯度累积这个技巧,简直就是显存不够时的救命稻草。

梯度累积的原理

先说说常规训练是怎么做的。你喂一个 batch 的数据,前向传播算 loss,反向传播算梯度,然后优化器立刻更新参数。这个过程,每个 batch 都要存一份完整的中间结果。

梯度累积的思路完全不同:

  • 先跑几个小 batch,只算梯度,不更新参数
  • 把梯度累加起来
  • 攒够了,一次性更新参数

这样做的好处很明显——显存占用只取决于单个小 batch 的大小,而不是累积后的总 batch size。

核心公式:

有效 batch size = 单步 batch size × accumulation_steps

显存占用 ≈ 单步 batch size 的显存

举个例子。你想跑有效 batch size = 64,但单步只能塞下 8。那设置 accumulation_steps = 8 就行了。显存占用还是 8 的水平,但效果相当于 64。

accumulation_steps 怎么设?

这个参数其实很好理解。它告诉程序:「你攒够多少步,再更新一次参数」。

我个人习惯这样算:

# 假设目标有效 batch size = 64
# 单步最大 batch size = 8
accumulation_steps = 64 // 8  # 结果是 8

嗯,这里要注意一点。accumulation_steps 必须是整数,而且最好能被有效 batch size 整除。不然最后一个累积步数会少,影响训练稳定性。

我的经验:

accumulation_steps 一般设 2、4、8 比较常见。超过 16 的话,训练速度会明显变慢,因为参数更新频率太低了。

与 Batch Size 的关系

很多人搞混一个概念:梯度累积 ≠ 增大 batch size。

为什么?

你想想看,真正的 batch size 增大,是所有样本的梯度同时算出来的。而梯度累积是分步算的,每一步的梯度基于不同的数据子集。虽然数学上等价,但实际训练中,由于 BN 层的统计量不同,效果会有细微差别。

我曾经踩过一个坑:把 accumulation_steps 设得特别大,比如 64,结果模型收敛变慢了。后来发现是 BN 层的 running mean 和 running var 更新频率太低,导致统计量不准。

配置 显存占用 训练速度 模型效果
batch_size=64, 无累积 高(可能爆显存) 快 好
batch_size=8, accum=8 低 中等 接近
batch_size=4, accum=16 很低 慢 略差

实战代码示例

下面是我在实际项目中用过的梯度累积代码。以 PyTorch 为例:

# 梯度累积训练循环
accumulation_steps = 8
optimizer.zero_grad()

for i, (inputs, labels) in enumerate(train_loader):
    outputs = model(inputs)
    loss = criterion(outputs, labels)
    
    # 关键:除以 accumulation_steps,保持 loss 尺度一致
    loss = loss / accumulation_steps
    loss.backward()
    
    # 每 accumulation_steps 步更新一次
    if (i + 1) % accumulation_steps == 0:
        optimizer.step()
        optimizer.zero_grad()

# 别忘了处理最后不足一个累积步的情况
if (i + 1) % accumulation_steps != 0:
    optimizer.step()
    optimizer.zero_grad()

避坑指南:

我曾经忘记把 loss 除以 accumulation_steps,结果 loss 直接炸了。因为累积了 8 步的梯度,更新时的梯度大小是正常的 8 倍,参数一步就飞出去了。

另外,如果你用分布式训练,梯度累积要配合梯度同步。我一般这样处理:

# 分布式 + 梯度累积
from torch.nn.parallel import DistributedDataParallel as DDP

model = DDP(model)
accumulation_steps = 8

for i, (inputs, labels) in enumerate(train_loader):
    outputs = model(inputs)
    loss = criterion(outputs, labels)
    loss = loss / accumulation_steps
    loss.backward()
    
    if (i + 1) % accumulation_steps == 0:
        # 只在更新时同步梯度
        for param in model.parameters():
            if param.grad is not None:
                # 手动触发梯度同步
                pass
        optimizer.step()
        optimizer.zero_grad()

知识体系图

下面这张图展示了梯度累积的核心逻辑:

梯度累积核心流程 小 Batch 数据 前向 + 反向 梯度累积 参数更新 重复 accumulation_steps 次 无梯度累积 显存占用 = batch_size × 模型大小 容易爆显存 有梯度累积 显存占用 = 单步 batch_size × 模型大小 显存友好

总结一下:

梯度累积不是银弹。它解决的是显存瓶颈,不是计算瓶颈。如果你的显存够用,直接跑大 batch 更快。但如果你像我一样,经常在 24G 显存的卡上跑大模型,那梯度累积就是必备技能。

最后提醒一句:别忘了调学习率。有效 batch size 变了,学习率也要跟着调。我一般按比例缩放:batch size 翻倍,学习率也翻倍。但这个不是绝对的,具体还得看你的任务。