技术博客

混合精度训练:fp16 的动态损失缩放原理与实现

第 135 / 147 篇 · 2026-05-28 · ai · 混合精度训练工程数值计算

混合精度训练(Micikevicius et al. 2018)的账很诱人:Tensor Core 上 fp16 矩阵乘的吞吐是 fp32 的 2–8 倍,显存占用减半,还能开更大的 batch。但直接把 fp16 用到梯度上会立刻翻车——看一眼 IEEE 754 的半精度参数就明白为什么:

$$ \text{fp16:}\quad \text{尾数 10 位, 指数 5 位} \Rightarrow \text{最大值}\ 65504,\ \text{最小正规数}\ 6.1\times10^{-5}. $$

上下两头都危险。上头:loss 稍大或 attention logits 偏深,前向激活就可能溢出 $65504$;下头更要命——训练中的梯度幅值大多在 $10^{-6}\sim10^{-2}$ 区间,整段落在 fp16 最小正规数之下,以 fp16 存梯度等于把它们全部冲刷成零,反向传播链从中间断开。

损失缩放:一行乘法换梯度两个数量级

思路朴素:梯度太小,那就把 loss 乘一个大常数 $S$,让整条反向链的梯度同比放大 $S$ 倍。链式法则保证了这一步的合法性:

$$ \nabla_\theta (S \cdot L) = S \cdot \nabla_\theta L, $$

缩放只改变梯度的整体尺度,不改变任何方向的相对关系,也不改变优化问题的解。工程实现:前向算出 loss 后乘 $S$,反向传播自然得到 $S$ 倍的梯度,optimizer.step() 之前把梯度除回 $S$(PyTorch 的 GradScaler 在 unscale 阶段做),参数的更新方向和大小与不用缩放完全一致。被 $S$ 抬上来的梯度落回 fp16 的安全区,下溢问题解掉。

动态化:$S$ 该多大?让梯度自己投票

静态 $S$ 的问题是梯度的动态范围随训练阶段、层深、任务而漂移,今天够用的 $2^{15}$ 明天可能让某层梯度溢出。Micikevicius 论文的答案是动态调度,规则只有三条:

  1. 初始化 $S = 2^{15}$(fp16 范围的几何中点附近);
  2. 每 $N$ 步(默认 2000)做一次检查:如果这 $N$ 步没有 inf/nan,$S \gets 2S$——梯度还有上浮空间,扩大表示精度;
  3. 一旦检出 inf/nan:$S \gets S/2$,并且丢弃这一步的更新(梯度清零,optimizer 不 step)。

这套"倍增探索、减半止损"的机制让 $S$ 自动追踪梯度的真实量级。可以把它看成在梯度分布的"表示精度"和"不溢出"之间维持一个在线平衡:翻倍太早会溢出,翻倍太迟浪费表示精度,$N=2000$ 步的间隔是经验上兼顾两者的一个值。代价是一个 batch 白算——检出溢出跳步的损失约 $0.05\%$ 的吞吐,可忽略。

动态 loss scale 的锯齿:无溢出则每 N 步翻倍,溢出则减半并跳步 S 步数 稳定增长:每 N=2000 步 ×2 检出 inf/nan:S÷2 且该步跳过 fp16 上界 65504 对应的 S 上限 实现:torch.cuda.amp.GradScaler(init_scale=2^15, growth_interval=2000),unscale 在 step 之前

实现里容易踩的三个坑

unscale 必须在 clip 和 step 之前。梯度裁剪的阈值是对真实梯度范数标定的,若先裁剪后除 $S$,等效阈值被放大了 $S$ 倍;GradScaler.unscale_(optimizer) 的正确位置是 clip_grad_norm_ 之前。框架顺序错一点都不报错,只是裁剪失效——这类静默错误只能靠 update/param 比率异常发现。

溢出检测的粒度。检测 inf/nan 应在整个反向完成后、参数更新前对检查过的梯度做一次全集扫描(torch._amp_foreach_non_finite_check_and_unscale),而不是逐层 early-stop——中间层一个 inf 经链式法则污染所有上游梯度,漏检一层就等于漏检全部。代价是多一次全参数遍历,已被融合 kernel 摊薄到可忽略。

和梯度累积的组合。累积的梯度也要先 unscale 再累加,否则第 $i$ 个 micro-batch 的梯度带着 $S$ 进累加器,语义变成"各乘各的 $S$ 再求和",与"先求和再统一缩放"不等价(除非各 micro-batch 的 $S$ 一致——动态调度下恰恰不一致)。GradScaler 配合 no_sync 的标准用法处理了这一点,手写累积循环时要自己留意。

bf16 时代:这套机制可以退役了吗

bf16(Google Brain 的 brain float)把指数位扩到 8 位、尾数砍到 7 位:动态范围和 fp32 相同($\sim10^{\pm38}$),梯度天然不会下溢;上界问题也几乎不存在。于是动态 loss scale 在 bf16 训练里整体退役——PyTorch 的 bf16 autocast 根本不接受 GradScaler。这笔交换的代价是尾数从 10 位降到 7 位,相对精度从 $2^{-11}$ 恶化到 $2^{-8}\approx0.4\%$。什么时候这个代价会咬人?梯度本身需要精细分辨率的场景:小学习率微调、量化感知训练、以及前面 loss spike 篇提到的——bf16 的粗尾数让"灾难性抵消"类事故变得隐蔽,fp16 的溢出是响亮的,bf16 的精度损失是无声的。实践中大型预训练全面转向 bf16,正是因为 fp16 的动态范围问题比尾数问题致命得多。

小结

动态损失缩放是混合精度训练的钥匙发明:$S\cdot L$ 的一行乘法把梯度抬回 fp16 的表示窗口,链式法则保证它不改变优化目标;"无溢出翻倍、溢出减半跳步"让 $S$ 自适应梯度漂移。实现要点是 unscale 在裁剪之前、溢出检测做全集扫描、梯度累积先 unscale 再累加。bf16 用更宽的指数位买断了下溢问题,让这套机制成为历史,但代价是 0.4% 的尾数精度——精度与范围这道数值计算的永恒交换,在混合精度训练里表现得淋漓尽致。