想把 batch size 从 32 提到 256,但单卡显存连 64 的激活都放不下,怎么办?最朴素的答案是梯度累积(gradient accumulation):每次前向用一个小 batch,把梯度存起来不更新,攒够 k 次再调用一次 optimizer.step()。形式上,
$$ \tilde g = \frac{1}{k}\sum_{i=1}^{k} \nabla_\theta L_i, \qquad \theta \leftarrow \theta - \eta\,\tilde g, $$
其中 $L_i$ 是第 $i$ 个 micro-batch 的平均损失。这个写法和直接在大小为 k 倍的大 batch 上求平均梯度再做一次更新,在数学上是同一个对象——梯度算子是线性的,和的梯度等于梯度的和。
等价性的三个条件
线性性保证了"梯度的平均相同",但完整的等价还要补三个条件,缺一个结果就悄悄偏离大 batch 训练。
归一化系数。每个 micro-batch 的 loss 通常是 batch 内均值,$L_i = \frac{1}{b}\sum_{j\in\text{mb}_i}\ell_j$。k 个 micro-batch 的平均损失应该是全体 $kb$ 个样本的均值:
$$ L = \frac{1}{k}\sum_{i=1}^k L_i. $$
PyTorch 的默认语义恰好就是这个:每个 micro-batch 先各自求均值,累加进 .grad,最后由 optimizer.step() 前的梯度除以 k 完成全局平均。最常见的 bug 是忘了除 k——如果每个 micro-batch 的 loss 都除以 k(比如 loss = criterion(out, y) / accum_steps 写在了循环内),梯度总量对了但 loss 的日志值会偏小 k 倍,监控曲线和 early stopping 阈值全乱;反过来如果 micro-batch 内部用了 sum 而不是 mean,大小不均匀的最后一个 micro-batch 会贡献偏大的梯度。判断标准一句话:.grad 累加的内容必须是"全体样本平均梯度的 k 分之一之和"。
随机性与正则算子。Dropout 的掩码、数据增强的随机参数,在每个 micro-batch 上都独立采样——这和大 batch 上单次采样的分布相同,没问题。真正要小心的是 BatchNorm:BN 的均值方差是 per-micro-batch 算的,k 个小 batch 各自归一化再合并梯度,不等价于在大 batch 上算一次全局统计量。小 batch 下 BN 统计量的噪声会直接进入训练动力学。LN、RMSNorm 这类样本内归一化没有这个问题,这也是 Transformer 架构对梯度累积天然友好的原因之一。
梯度裁剪和日志的时点。梯度累积把"计算梯度"和"使用梯度"拆成两个阶段,凡是以梯度为输入的操作都要挪到累积完成之后:全局范数裁剪
$$ g \leftarrow g \cdot \min\!\Big(1,\ \frac{c}{\lVert g\rVert}\Big) $$
必须对累加后的 $\tilde g$ 做,裁剪 k 个 micro-batch 各自的梯度会把大 batch 才可能出现的大梯度提前削掉,改变等价的动力学。同理,weight decay、梯度噪声注入、loss spike 检测(看更新梯度的范数)都要看累加后的值。
它和数据并行是什么关系
多卡数据并行(DDP)本质上就是"每卡一个 micro-batch 的梯度累积 + all-reduce":各卡 backward 后通过 grad 桶化通信求和平均,再走一次 optimizer.step()。梯度累积和 DDP 是正交的两层:
- 单卡:累积 $k$ 次,有效 batch = 单卡 micro × k。
- 多卡:有效 batch = 单卡 micro × 累积次数 × 卡数。DDP 的梯度同步默认在每个 micro-batch 后触发,想延后到累积结束需要
no_sync()上下文,否则通信开销翻 k 倍。
工程上还有一层容易忽略的账:累积 k 次意味着优化器的步频降 k 倍,同样 token 数下参数更新次数只有大 batch 训练的 1/k。对 Adam 这类自适应优化器,二阶矩的估计节奏也跟着变稀疏,超长累积(k ≥ 64)时收益开始偏离线性等价,需要配合调大学习率或换更大的 batch 缩放规则(下一篇展开)。
最小实现
accum_steps = 8
optimizer.zero_grad(set_to_none=True)
for i, (x, y) in enumerate(loader):
with torch.autocast("cuda", dtype=torch.bfloat16):
loss = model(x, y) / accum_steps # 归一化在循环内做,语义最不易错
loss.backward() # 梯度累加进 .grad
if (i + 1) % accum_steps == 0:
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) # 裁剪在累积后
optimizer.step()
optimizer.zero_grad(set_to_none=True)
注意 zero_grad 放在 step 之后、下一组循环开始之前,而不是每组循环开头——放错位置会多一次清零或少累加一个 micro-batch。另一个细节:set_to_none=True 让不参与的参数梯度直接是 None,跳过无谓的显存分配;DDP 里则要保持 None/张量的一致性,否则桶同步会对不上参数列表。
小结
梯度累积是"梯度算子线性"这个数学性质最直接的应用:把显存峰值压到单个 micro-batch 的水平,换取 k 倍的等效批量和 1/k 的更新频率。等价的边界条件有三条——归一化系数落在全体样本上、样本内归一化(避开 BN)、以梯度为输入的操作全部后置到累积完成之后。满足这三条,它就是大 batch 训练的免费替身;违反任何一条,你得到的只是"看起来像大 batch"的训练。