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 Size关系是数学本质,显存收益是实际价值,三个陷阱是实战中必须避开的坑。
最后说一句:梯度累积不是万能的,但在显存受限的场景下,它是最简单、最有效的方案之一。我建议你把它作为工具箱里的常备工具,用的时候注意BN层和梯度裁剪的位置就行。
一句话总结:梯度累积 = 小batch算梯度 + 攒够步数再更新。显存省了,速度慢了,BN层要小心,学习率归一化后不用动。
想要更多资料,联系可可