技术博客

梯度累积与有效 batch size:显存换批量的等价性边界

第 137 / 147 篇 · 2026-03-15 · ai · 训练工程显存优化数据并行

想把 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 上求平均梯度再做一次更新,在数学上是同一个对象——梯度算子是线性的,和的梯度等于梯度的和。

梯度累积的数据流:k 个 micro-batch 共享一份梯度缓冲,攒够才走一步优化器 micro-batch 1 forward + backward micro-batch 2 forward + backward ... 梯度缓冲 .grad k 次 backward 累加 ÷ k optimizer.step 参数更新一次 下一组 k 个 batch 时间轴:显存峰值只等于 1 个 micro-batch,更新频率降 1/k

等价性的三个条件

线性性保证了"梯度的平均相同",但完整的等价还要补三个条件,缺一个结果就悄悄偏离大 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"的训练。