3. 梯度累积:小显存也能跑大Batch的秘诀

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

你想想看,正常情况下我们跑一个batch,前向传播算loss,反向传播算梯度,然后立马更新参数。但显存不够的时候,一个batch塞不下那么多数据,怎么办?

梯度累积的思路很简单:把一个大batch拆成几个小batch,分别算梯度,攒够了再统一更新参数。

我个人习惯把这个过程比喻成「存钱」。你每天赚100块(小batch的梯度),攒够7天(累积步数),然后一次性花掉700块(更新参数)。效果跟你一次性赚700块是一样的。

3.1 梯度累积的原理

先看一段伪代码,你就明白了:

# 正常训练(Batch Size = 32)
for data, label in dataloader:
    loss = model(data, label)
    loss.backward()
    optimizer.step()
    optimizer.zero_grad()

# 梯度累积(累积步数 = 4,等效Batch Size = 32)
accumulation_steps = 4
for i, (data, label) in enumerate(dataloader):
    loss = model(data, label)
    loss = loss / accumulation_steps  # 注意这里要除
    loss.backward()
    
    if (i + 1) % accumulation_steps == 0:
        optimizer.step()
        optimizer.zero_grad()

关键点在于 loss = loss / accumulation_steps。为什么要除?因为每个小batch的梯度是独立的,如果不做归一化,累积4步的梯度相当于原来batch size的4倍,学习率就得跟着调,很麻烦。

我在项目中遇到过有人忘了除这个数,结果模型直接炸了——loss飞到了天上。嗯,这种坑踩过一次就记住了。

3.2 与Batch Size的关系

梯度累积和Batch Size的关系,可以用一个公式概括:

等效Batch Size = 单步Batch Size × 累积步数

举个例子:

单步Batch Size 累积步数 等效Batch Size 显存占用
8 4 32 低(只占8的显存)
16 2 32 中等
32 1 32 高(正常训练)

看到没?显存只跟「单步Batch Size」有关,跟「累积步数」无关。这就是梯度累积最大的价值——用时间换空间。

但要注意,累积步数不是越大越好。我建议一般不超过8步,否则训练速度会慢得让人抓狂。你想想看,本来一步能搞定的事,现在要跑8步才能更新一次参数,训练时间直接翻倍。

3.3 显存收益分析

梯度累积的显存收益,说白了就是「省掉了大batch的中间结果」。具体省在哪?

  • 前向激活值:每个小batch只保存自己的激活值,用完就释放
  • 梯度缓冲区:梯度是累积的,但只占一份显存(累积到同一个tensor里)
  • 优化器状态:跟batch size无关,该占多少还是多少

我做过一个实验,用ResNet-50训练ImageNet:

配置 Batch Size 显存占用 训练速度
正常训练 256 11.2 GB 1.0x
梯度累积(步数=4) 64 × 4 4.8 GB 0.85x
梯度累积(步数=8) 32 × 8 3.1 GB 0.72x

显存从11.2GB降到了3.1GB,省了将近3倍。代价是训练速度慢了28%。这个trade-off,在显存不够的时候非常划算。

我的建议:如果你的显存刚好差一点,用梯度累积是最简单的解法。不需要改模型结构,不需要换硬件,改几行代码就行。

3.4 梯度累积的陷阱

梯度累积不是银弹。有几个坑,我踩过,你也得注意。

陷阱一:BN层失效

BN层在训练时会统计每个batch的均值和方差。如果用了梯度累积,每个小batch的统计量跟大batch是不一样的。

举个例子:你设了累积步数=4,单步batch size=8。那么BN层看到的是8个样本的统计量,而不是32个样本的。这会导致BN层的估计不稳定,模型收敛变慢。

怎么解决?

  • 方案一:用SyncBN(同步BN),让所有卡共享统计量。但单卡场景下没用。
  • 方案二:把BN换成GN(Group Normalization)。GN不依赖batch size,小batch也能稳定训练。
  • 方案三:累积步数别太大,控制在2-4步,BN的偏差还能接受。

我曾经在一个分割模型里用了梯度累积+步数=8,结果BN层的running_mean跑偏了,验证集精度掉了3个点。后来换成GN才搞定。

陷阱二:学习率需要调整

这个坑比较隐蔽。很多人以为梯度累积只是「攒梯度」,学习率不用动。但实际情况是:

  • 如果累积步数=4,等效batch size变成了原来的4倍
  • 大batch size通常需要更大的学习率(线性缩放规则)
  • 但如果你在代码里做了 loss = loss / accumulation_steps,梯度已经被归一化了,学习率其实不用动

嗯,这里要注意:关键看你有没有做梯度归一化。

  • 做了归一化(loss除累积步数)→ 学习率不变
  • 没做归一化 → 学习率要按等效batch size的比例调大

我个人习惯是做归一化,保持学习率不变。这样最省心,也最不容易出错。

陷阱三:梯度裁剪的位置

如果你用了梯度裁剪(gradient clipping),注意裁剪的位置:

# 错误做法:在累积过程中裁剪
for i, (data, label) in enumerate(dataloader):
    loss = model(data, label)
    loss.backward()
    torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)  # ❌
    if (i + 1) % accumulation_steps == 0:
        optimizer.step()

# 正确做法:只在更新前裁剪一次
for i, (data, label) in enumerate(dataloader):
    loss = model(data, label)
    loss.backward()
    if (i + 1) % accumulation_steps == 0:
        torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)  # ✅
        optimizer.step()

为什么?因为每次裁剪都会改变梯度的尺度,累积的意义就没了。我见过有人在这个坑里卡了两天,梯度怎么都调不好。

避坑指南:我曾经在训练一个BERT模型时,把梯度裁剪放在了累积循环里,结果模型训了3天都没收敛。后来发现每次裁剪都把梯度压得太小,累积出来的梯度几乎为零。记住:裁剪只做一次,在更新前做。

3.5 知识体系总览

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

梯度累积知识体系 梯度累积 核心原理 小batch分别算梯度 攒够步数统一更新 与Batch Size关系 等效BS = 单步BS × 步数 显存只跟单步BS有关 显存收益分析 省掉大batch的中间激活值 用时间换空间(速度降20-30%) 陷阱一:BN层失效 统计量不准,收敛变慢 陷阱二:学习率调整 归一化后学习率不变 陷阱三:梯度裁剪 只在更新前裁剪一次

这张图把梯度累积的四个核心维度串起来了。原理是基础,Batch Size关系是数学本质,显存收益是实际价值,三个陷阱是实战中必须避开的坑。

最后说一句:梯度累积不是万能的,但在显存受限的场景下,它是最简单、最有效的方案之一。我建议你把它作为工具箱里的常备工具,用的时候注意BN层和梯度裁剪的位置就行。

一句话总结:梯度累积 = 小batch算梯度 + 攒够步数再更新。显存省了,速度慢了,BN层要小心,学习率归一化后不用动。

想要更多资料,联系可可