深蓝黑背景封面。左侧是一根 FP16 数轴:左段紫色虚线框标注「下溢区:< 6×10⁻⁵,FP16 直接舍成 0」,右段青色框标注「可表示区(最小正规格化数 6.1×10⁻⁵)」;下溢区内一个紫点标注「梯度 10⁻⁷ → 0」,一条青色弧线箭头标注「loss × 2¹⁰,梯度同步放大」,把数值送入可表示区,落点青点标注「10⁻⁴ ✓ 可表示」。下方深色面板写三行:「反向传播在放大后的数值上进行,小梯度保住」「更新前梯度 ÷ 2¹⁰ 还原,方向与相对大小不变」「权重主副本用 FP32 保存,累加更新不吃精度亏」;再下两行灰字注「不缩放的后果:大片梯度变 0,模型学不动」「缩放过大:梯度溢出 inf——所以 scale 要动态调」。右侧大标题「混合精度训练为什么要给 loss 做缩放」,副标题「先放大 loss 让梯度逃出下溢区,更新前再缩回来」,小注「动态缩放:溢出就降档,平稳就升档」。左上角标注「面试经典 · 训练精度」,右上角圆角胶囊写「Day 09 · 12-06」。 图 1:本文封面,FP16 数轴上的下溢区与缩放逃生路线。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料整理,与任何公司的当前招聘无关。「混合精度训练为什么要做 loss scaling」这道题,表面上问的是一个技巧,实际考你能不能把 FP16 的数值范围、反向传播的链式放大、动态调档策略三件事串成一条逻辑链。

一句话结论:FP16 的最小正规格化数约为 6.1×10⁻⁵,而训练后期的梯度大量落在 10⁻⁶ 到 10⁻⁸ 量级,不缩放会被舍入成 0;把 loss 乘一个大系数 S,梯度沿链式法则同步放大逃出下溢区,更新权重前再除以 S 还原。

问题出在 FP16 的表示范围。

IEEE 754 半精度浮点(FP16)用 1 位符号、5 位指数、10 位尾数,能表示的最小正规格化数约 6.1×10⁻⁵,最大 65504;作为对照,FP32 的最小正数约 1.2×10⁻³⁸,差了 33 个数量级。神经网络里激活值大多在可表示区内,但梯度不一样——训练到中后期,大量梯度落在 10⁻⁶ 到 10⁻⁸ 量级,直接塞进 FP16 会被舍入成 0,对应参数这一轮的更新信息就丢了。这就是梯度下溢(underflow)。2017 年发表的混合精度训练论文(arXiv:1710.03740)提出两招应对:一是保留一份 FP32 的权重主副本,每轮把 FP16 梯度累加到主副本上;二就是损失缩放。

为什么缩放 loss 就能缩放全部梯度?

反向传播是链式法则:每个参数的梯度都是 loss 对输出求导后一路乘回来的结果。loss 乘 S,链路上的每一个梯度都精确地乘上同一个 S——缩放源头一处,全链路同步放大。这比分层逐点缩放梯度干净得多:只改一个标量,不碰任何中间张量。放大后的梯度逃出下溢区完成反向传播,优化器更新前统一除以 S 还原真实量级,再把增量累加到 FP32 主副本。整套流程:FP16 前向算 loss → loss 乘 S → 反向传播(梯度同乘 S)→ 梯度除 S 还原 → FP32 主副本更新权重。

用代码把下溢与还原算一遍(Node.js 22.15.0,手写 IEEE 754 半精度舍入模拟,node fp16.js):

const g = 1e-8, S = 2 ** 10;
const scaled = f16(g * S);          // 放大后反传(FP16)
const restored = scaled / S;        // 更新前 unscale(FP32)
// f16(v) 表示「把 v 按 FP16 精度舍入」

实测输出:

FP16 最小正规格化数 2^-14 = 0.00006103515625
FP16 最大可表示数        = 65504
FP16 表示 65520(超出)  = Infinity
梯度 1e-8 直接进 FP16    = 0
梯度 1e-7 直接进 FP16    = 1.1920928955078125e-7
梯度 1e-8 ×2^10 进 FP16 = 0.000010251998901367188
unscale 还原           = 1.0011717677116394e-8(保住了,误差 0.12%)
不缩放:41.2% 的梯度变 0;×2^10 后:0.0% 变 0

最后一行是 10 万个对数均匀分布在 10⁻⁴ 到 10⁻¹⁰ 的模拟梯度:不缩放时 41.2% 直接变 0,乘 2¹⁰ 后无一丢失。注意 65520 变成 Infinity——放大过头会撞另一堵墙。

S 不能拍死:动态调档在两面墙之间找平衡。

S 太小,梯度仍下溢;S 太大,梯度超过 65504 溢出成 inf,比下溢更糟——inf 反向传播会污染整个梯度图。而且不同模型、不同训练阶段的最优 S 不同,所以 S 必须动态调整,规则很简单:每步更新前检查梯度是否出现 inf 或 NaN,出现了就跳过本次权重更新并把 S 减半(降档);连续 N 步没有溢出,就把 S 翻倍(升档),尽量用大 scale 保住精度。PyTorch 的 GradScaler 就是这套策略的实现,默认初始 scale 65536、升档倍率 2、降档倍率 0.5、升档窗口 2000 步。另外提一句:BF16 的指数位与 FP32 相同(8 位),表示范围不吃亏,所以 BF16 混合精度通常不需要 GradScaler——loss scaling 是 FP16 路线的配套技术。

深蓝黑背景概念图。顶部小字「概念图 · 动态损失缩放」,主标题「放大 → 反传 → 还原:scale 自动在两面墙之间调档」。第一块面板是 FP32 与 FP16 可表示范围对比:FP32 横条写「最小正数 ≈ 1.2×10⁻³⁸,梯度几乎不可能下溢」;FP16 横条左段紫色虚线区标注「下溢区 < 6.1×10⁻⁵:典型梯度 10⁻⁶~10⁻⁸ 全落在这里 → 变 0」,右段青色区标注「可表示区(最大 65504,过大也会溢出 inf)」。中间是横向五步链路:① FP16 前向,激活与 loss 用半精度;② loss × S,S 如 2¹⁰,数值整体左移;③ 反向传播,梯度同乘 S,逃出下溢区;④ 梯度 ÷ S,还原真实量级,转 FP32;⑤ 更新权重,FP32 主副本累加,精度不亏。再往下两张调档卡:左卡「检测溢出:梯度出现 inf / NaN → 跳过本次更新,S ← S ÷ 2(降档)」;右卡「连续 N 步无溢出 → S ← S × 2(升档),尽量用大 scale 保精度」。底部总结条写「scale 只挪数值的量级,不改优化方向;动态调档在下溢变 0 与溢出 inf 之间自动找平衡」。 图 2:FP16 下溢区、五步缩放链路与动态调档规则。

面试现场的答题结构。

先给结论「防梯度下溢」,然后摆数值事实:FP16 最小正规格化数 6.1×10⁻⁵ 对比典型梯度 10⁻⁶~10⁻⁸,不缩放就清零。接着讲机制:链式法则让缩放 loss 等价于缩放全部梯度,反向在放大后的数值上进行,更新前 unscale 还原,FP32 主副本保证累加不亏精度。追问「S 取多少」时答动态调档:溢出减半、连续平稳翻倍,并指出两面墙——小了下溢、大了溢出成 inf。最后补一句 BF16 不需要缩放,展示你清楚这套机制是 FP16 专属的。你在实际训练里遇到过梯度 scale 频繁降档的情况吗?最后是怎么定位溢出源的?欢迎聊聊。