4. 梯度累积

说实话,刚入行那会儿,我特别羡慕那些有8块A100的团队。他们可以随便把batch size开到1024,而我手里只有一块2080Ti,8G显存,跑个ResNet50 batch size开到32就爆显存了。

后来我学到了梯度累积,这玩意儿简直就是我们穷人的救星。说白了,就是用时间换空间——多跑几步前向传播,攒够了梯度再更新一次参数。

梯度累积的原理

先想想正常的训练流程:

  1. 前向传播,算loss
  2. 反向传播,算梯度
  3. 优化器更新参数

梯度累积改成了这样:

  1. 前向传播,算loss
  2. 反向传播,算梯度(但不更新参数)
  3. 重复步骤1-2,把梯度累加起来
  4. 累积够N步后,用累积的梯度更新参数

嗯,这里要注意:累积的是梯度,不是loss。我见过有人直接把loss加起来,然后反向传播一次,这完全不对。loss是标量,梯度是张量,两者性质不同。

核心公式:

假设你想模拟batch size = 1024的效果,但显存只够跑batch size = 128。

那就设累积步数 = 1024 / 128 = 8。

每8步更新一次参数,等效于batch size = 1024。

为什么能等效?

你想想看,正常的batch size = 1024,是一次性算1024个样本的梯度,然后更新。

梯度累积是分8次,每次算128个样本的梯度,加起来,再更新。

数学上,梯度的线性叠加性质保证了这两者是等价的。说白了就是:

grad_total = grad_1 + grad_2 + ... + grad_8

只要每次的样本是独立采样的,结果就没区别。

我的经验:实际训练中,由于Batch Normalization层的存在,两者会有细微差别。BN的均值和方差在累积模式下是分步计算的,不是全局的。所以如果你用BN,建议把累积步数设小一点,或者用SyncBN。

代码实现

我直接给你一个完整的PyTorch实现,这是我项目里一直在用的模板:

import torch
import torch.nn as nn
from torch.utils.data import DataLoader

# 配置
accumulation_steps = 8  # 累积步数
effective_batch_size = 1024  # 目标batch size
actual_batch_size = 128  # 实际能跑的batch size

model = YourModel()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-4)
scaler = torch.cuda.amp.GradScaler()  # 混合精度配合使用

model.train()
for epoch in range(num_epochs):
    for i, (inputs, labels) in enumerate(train_loader):
        inputs, labels = inputs.cuda(), labels.cuda()
        
        # 混合精度前向
        with torch.cuda.amp.autocast():
            outputs = model(inputs)
            loss = criterion(outputs, labels)
            # 关键:loss要除以累积步数
            loss = loss / accumulation_steps
        
        # 反向传播,累积梯度
        scaler.scale(loss).backward()
        
        # 每accumulation_steps步更新一次
        if (i + 1) % accumulation_steps == 0:
            # 梯度裁剪(可选)
            scaler.unscale_(optimizer)
            torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
            
            # 更新参数
            scaler.step(optimizer)
            scaler.update()
            optimizer.zero_grad()

我曾经踩过的坑:

  • 忘记把loss除以accumulation_steps。如果不除,累积的loss会变成原来的N倍,梯度爆炸。
  • 优化器更新后忘记zero_grad。梯度会一直累积下去,永远不会清零。
  • 最后一个batch不够累积步数时,梯度没更新就结束了。建议用drop_last=True丢弃不完整的batch。

配合混合精度使用

梯度累积和混合精度是绝配。为什么?

混合精度用FP16存梯度,显存直接减半。梯度累积又让你能用小batch模拟大batch。两者一结合,我曾在4G显存的卡上跑出了batch size=512的效果。

具体做法就是上面代码里那样:用GradScaler管理梯度缩放,用autocast做自动混合精度。注意scaler.step()和scaler.update()要放在累积步数判断里面。

实际效果对比

配置 显存占用 等效Batch Size 训练速度
直接跑batch=1024 24GB 1024 1x
batch=128 + 累积8步 4GB 1024 1.1x(略慢)
batch=128 + 累积8步 + 混合精度 2.5GB 1024 0.9x(反而更快)

看到了吗?混合精度不仅省显存,因为FP16计算更快,有时候反而能提速。

什么时候不该用梯度累积?

也不是所有场景都适合。我总结了几种情况:

  • 用Batch Normalization时:累积步数别超过4,否则BN的统计量偏差太大
  • 强化学习:策略梯度方法对梯度延迟敏感,累积步数建议≤2
  • GAN训练:生成器和判别器交替更新,累积步数要一致,否则训练不稳定

我的建议:刚开始用梯度累积时,先设accumulation_steps=2或4试试。看看loss曲线和正常训练是否一致。没问题了再逐步加大。别一上来就设32,出了问题很难排查。

用SVG看梯度累积流程

下面这张图帮你理清整个流程:

梯度累积流程图 前向传播 反向传播 累积梯度 重复N次(N=累积步数) 累积够N步? 否 是 优化器更新参数 清零梯度

这张图里,红色箭头是循环路径,绿色箭头是更新路径。每次反向传播后,梯度都累加到同一个缓冲区里。直到累积够N步,才触发优化器更新。

嗯,这里有个细节:清零梯度一定要在更新之后。我见过有人把顺序搞反了,结果梯度永远都是0,模型根本不学习。

好了,梯度累积就讲这么多。说白了就是一句话:显存不够,步数来凑。配合混合精度,你甚至能在笔记本上跑出实验室级别的batch size。