全换成 FP16,为什么会丢梯度?
本课一问:上一课的 16 字节里,有 12 字节是 FP32。把权重、梯度、优化器状态全换成 16 位的 FP16,账本直接砍半——为什么没人这么训?
这一课先不碰:BF16(第 3 课)、8 位格式(第 4 课)、激活(第 5 课)。这一课只看 FP16 一种格式在训练里出的三种事,以及混合精度怎么把它们一一堵上。
从已有出发
上一课的账本按「混合精度」记:16 位的权重和梯度,加上 FP32 的主权重、m、v。现在问的是:为什么不干脆全用 16 位?先看同一步训练换成 FP16 之后,每个环节存成什么:
- 解决了范围到 ±3.4 × 10³⁸,1 附近的间距 2⁻²³ ≈ 1.2 × 10⁻⁷,数值上几乎不用操心。卡在每个参数 4 + 4 + 4 + 4 = 16 字节(权重、梯度、Adam 的 m、v):锚点 27,781,427,952 个参数就是 444.5 GB,是 80 GB 单卡的 5.6 倍,还没算激活。
- 解决了矩阵乘和激活用 16 位,带宽与激活显存减半;FP32 主权重接住小更新,loss 乘 S 把小梯度推出下溢区,更新前再除回来。卡在指数只有 5 位:fp16(10⁻⁸) = 0,fp16(1 + 10⁻⁴) = 1,fp16(70,000) = inf;缩放倍数要一直调,溢出就得跳过这一步。
- 解决了8 位指数,范围与 FP32 相同:不再下溢,不需要 loss scaling。2026 年的默认训练精度。卡在1 附近间距粗到 1/128:bf16(257) = 256,bf16(1 + 10⁻³) = 1——主权重和优化器状态仍要 FP32,账本还是 16 字节/参数。
- 解决了激活每 1 × 128、权重每 128 × 128 一个缩放:张量 [1000, 0.01, 0.02, 0.5] 里,0.01 的误差从整张量缩放的 12.8% 降到 0.45%;DeepSeek-V3 相对 BF16 的 loss 相对误差 < 0.25%(官方自报)。卡在只有 256 个位型,E4M3 最大 448;块的形状要跟着离群值走——激活梯度改按 128 × 128 块量化,16B MoE 训到约 300B token 发散(附录 B.2);主权重和梯度仍是 FP32。
- 解决了MXFP4 每 32 个数共用一个 2 的幂(4.25 位/参数),NVFP4 每 16 个数一个 E4M3 缩放;K3、DeepSeek-V4 在后训练阶段对 MoE 专家权重做 QAT。卡在E2M1 只有 16 个码点:块 [0.3, −1.2, 2.7, 0.05] 用 MX 缩放后,0.05 → 0;全 FP4 预训练仍是前沿。
- 解决了不把 s × s 的注意力分数存下来:锚点一条 4,096 token 的序列,激活从 162.5 GiB 降到 42.5 GiB(选择性重计算 / FlashAttention);全量重计算每层只存输入,只剩 2.5 GiB(标准块公式近似)。卡在重计算用多算一遍前向换显存;可 444.5 GB 的模型状态一张 80 GB 卡仍然放不下 → C10 并行。
为什么这里换例子:浮点格式讨论的是「一个数存进去会变成什么」,贯穿例句「小明把苹果给了小王」不会进到这一层。这一课和后面两课追踪三个数:一个权重 w = 1、它的一次更新 Δw = 10⁻⁴、一个很小的梯度 g = 10⁻⁸。它们是送进格式里测试的输入(R4 调研的实算用例),不是从哪个模型量出来的;真实的梯度分布,本课后半用我们自己模型的探针数据来看。
先自己设计:全部换成 FP16
最直接的做法:权重、梯度、优化器状态、更新,统统存成 FP16。下面每一格都是浏览器按 IEEE 754 真舍入出来的:
- ① 朴素方案
- ② 它在哪失败
- ③ 改进方案
口径:浏览器现算。FP32 用 DataView 取位;其余格式先存成 FP32,再按 RNE 舍入(与 PyTorch 的 dtype 转换逐值一致,开发时用 torch 2.6 对照过)。E4M3 是 OCP 规范的 FN 变体(无 inf,最大 448);E5M2 有 inf(最大 57,344)。
三种失败各有一个补丁,合起来叫混合精度训练(Micikevicius et al. 2017):
- 更新被吞 → 在 FP32 的主权重上做更新,再转一份 FP16 给下一步前向;
- 小梯度下溢 → 反向前把 loss 乘上一个 2 的幂 S(loss scaling),更新前在 FP32 里除回来;
- 矩阵乘里的累加误差 → FP16 相乘,FP32 累加。
下面三节逐个拆开。
为什么会这样:把 16 个位拆开看
FP16 一共 16 位:1 位符号、5 位指数、10 位尾数。一个数的值由下面这个公式决定,逐项点开:
在下面的实验台里逐位拨一拨:
口径:浏览器现算。FP32 用 DataView 取位;其余格式先存成 FP32,再按 RNE 舍入(与 PyTorch 的 dtype 转换逐值一致,开发时用 torch 2.6 对照过)。E4M3 是 OCP 规范的 FN 变体(无 inf,最大 448);E5M2 有 inf(最大 57,344)。
试三件事:① 点预设 1×10⁻⁸:FP16 里是「零」,再切到 FP32,它是一个正规数;② 点 1.0001,看「相邻可表示值」:1 的下一个 FP16 数是 1.0009765625,1.0001 离 1 更近,于是舍入回 1;③ 在 FP16 里把指数位全拨成 1、尾数全拨成 0,看它变成 +inf——那就是 70,000 的归宿。
三种失败,对应公式里的三处边界:
- 下溢:10⁻⁸ 比最小次正规数的一半(2⁻²⁵ ≈ 3 × 10⁻⁸)还小,舍入成 0。梯度一旦变成 0,这个参数这一步就不会动。
- 更新被吞:w = 1 时,只有 Δw 超过 1 附近间距的一半(2⁻¹¹ ≈ 4.9 × 10⁻⁴)才加得上去。一般地,权重比更新大 2¹¹ = 2,048 倍以上时,这次更新就可能被整个吞掉 证据等级 · 数学可证明 在写明的前提下可以严格推导出来,不依赖实验。只要前提成立,换数据、换规模仍然成立。 本条10 位尾数:对齐二进制小数点时更新右移 11 位以上就落在尾数之外;原文写作权重是更新的 2,048 倍以上时更新可能变成 0。 出处Micikevicius et al. 2017 §3.1(需联网) ↗需联网 。
- 上溢:超过 65,504 的数变成 inf;一个 inf 进了梯度,这一步的所有梯度都不能用了。
改进一:在 FP32 主权重上做更新
更新被吞的问题,在任何 16 位格式里都躲不掉。解法是把「累积小更新」这件事交给 FP32:优化器在一份 FP32 的主权重上做 w ← w + Δw,做完再转一份 16 位的给下一步前向。同一个 w = 1,每步加 10⁻⁴,加 1,000 步:
口径:浏览器现算。FP32 用 DataView 取位;其余格式先存成 FP32,再按 RNE 舍入(与 PyTorch 的 dtype 转换逐值一致,开发时用 torch 2.6 对照过)。E4M3 是 OCP 规范的 FN 变体(无 inf,最大 448);E5M2 有 inf(最大 57,344)。
FP16 那一行 1,000 步一步都没加上;FP32 主权重走到 1.1000166(和精确值 1.1 的差,来自 FP32 自己的舍入),每一步再转成 FP16 给前向用。这就是上一课账本里那 4 字节「FP32 主权重」的来历。原论文用一个普通话语音模型(DeepSpeech2)的直方图说明了这件事的规模:约 5% 的权重梯度指数小于 −24,存成 FP16 就是 0 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条模型、比例都是原文;原文用它论证保留 FP32 主权重的必要。核实 agent 在 PDF 原文核对。 出处Micikevicius et al. 2017 §3.1 图 2b(需联网) ↗需联网 。
试两件事:① 点预设「1 + 10⁻³ × 1000」:FP16 这回每一步都加上了——更新超过了 2⁻¹¹,但仍有舍入,1,000 步后停在 1.9765625,不是 2;② 点「256 + 1 × 10」,看 FP16 在 256 附近的间距(0.25)下,每步加 1 没有问题——被吞的条件永远是「更新和权重的比例」,不是更新的绝对大小。
改进二:loss scaling——把整批梯度推出下溢区
小梯度下溢,靠主权重救不回来:梯度在反向传播时就已经是 FP16 了,等不到优化器。办法是在反向之前把 loss 乘一个数 S:
在数轴上看:拖 loss scale,整批梯度向右平移。
试三件事:① 保持 S = 2⁰,看单个梯度 10⁻⁸:直接存 FP16 是 0;② 点「S = 65,536」:10⁻⁸ × 65,536 = 6.55 × 10⁻⁴ 存进 FP16,再在 FP32 里除回来,相对误差约 0.03%;③ 把单个梯度改成 0.01,再把 S 拖到 2²⁴:0.01 × 2²⁴ ≈ 1.68 × 10⁵,超过 65,504,「× S」那一行变成 inf——S 不是越大越好。(上面的直方图是我们模型训练结束时一整批梯度的真实分布,数轴下方的比例随 S 实时重算。)
S 取多大,原论文按模型手工选:SSD 用 8,bigLSTM 用 128 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条原文数字;它还说明 ImageNet CNN、DCGAN 不需要缩放。核实 agent 在原文核对。 出处Micikevicius et al. 2017 §3.2、§4.2、§4.5(需联网) ↗需联网 。后来的工具把它做成了动态的:先取一个大值,一旦梯度里出现 inf 或 NaN,就跳过这一步、把 S 减半;连续若干步不溢出,再把 S 翻倍。原论文只建议「溢出时跳过这一步」,动态调 S 写在它的「未来工作」里。我们项目的 DynamicLossScaler 就是这样:初值 65,536,溢出减半并跳步,连续 200 步不溢出翻倍(C5 第 6 课提过的「FP16 下梯度溢出成 inf」,在这里被接住)。
改进三:FP16 相乘,FP32 累加
一次矩阵乘里,每个输出是几千个乘积的和。如果累加器也是 FP16,加到后面,新加的小乘积就会像上面的 Δw 一样被吞掉。所以矩阵乘的输入是 FP16,乘积在 FP32 里累加,最后再存回 FP16 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条原文的第三条技术;硬件(Tensor Core)支持 FP16 输入、FP32 累加。 出处Micikevicius et al. 2017 §3.3(需联网) ↗需联网 。第 4 课会看到,到了 8 位这件事变得更要紧:DeepSeek-V3 发现 H800 上 FP8 矩阵乘的累加器只保留约 14 位。
我们自己的模型:到底有多少梯度会下溢
以上三个数是送进格式的测试输入。真实训练里有多少梯度会掉进下溢区,要看真实分布——实验脚本在我们的基线模型上取一批数据,算一次 FP32 梯度,统计存成 FP16 会怎样;再真在 FP16 里训练两遍(缩放 / 不缩放)对照:
读数(实验 2026-09-26 导出,单种子):把训练结束时那批 FP32 梯度直接存成 FP16,1.27% 落在 2⁻²⁴ 以下、26.73% 落进次正规区;乘上 65,536 之后,前者几乎为 0(0.00003%)。真在 FP16 里反向一次,不缩放时梯度相对 FP32 的误差是 8.9%,缩放后 0.12%。训练过程中,fp16 不缩放那一组「恰为 0 的梯度」从第 5 步的 0.048% 涨到最后的 1.32%(最高 3.03%):梯度随训练变小,掉进下溢区的越来越多。fp16 + 动态缩放一组跳过 0 步,缩放倍数一次都没减半。
常见误解一:「loss scaling 改变了训练结果,相当于把学习率调大了。」
不会。梯度在更新之前已经在 FP32 里除回了 S,交给优化器的就是原本的梯度;S 又是 2 的幂,乘除都不引入舍入。只要没有溢出,它和「不缩放但梯度不下溢」的结果一样。它只改变梯度在 FP16 里存着的那一刻落在数轴的哪一段。
常见误解二:「FP16 不行,是因为它不够精确。」
FP16 有 10 位尾数,1 附近的间距 2⁻¹⁰,比下一课的 BF16(2⁻⁷)细 8 倍。让它丢梯度的主要是只有 5 位的指数:范围太窄,小梯度下溢、大数上溢。「更新被吞」确实是精度问题,可下一课会看到 BF16 更躲不掉——所以两种 16 位都需要 FP32 主权重。
常见误解三:「我们的小模型上 fp16 不缩放也训得好,所以下溢无所谓。」
我们的实验里,fp16 不缩放的最终 val loss 是 4.6166,fp32 是 4.6405(参照 run ref_baseline;fp32 同配置重跑 5 次的终值在 4.6243–4.6405 之间,见 C14 实验课)——不缩放反而略低,差值和重跑波动同一量级,看不出伤害。可同一组数据也说明了为什么不能据此下结论:恰为 0 的梯度比例在随训练上涨(上面的读数),梯度的相对误差到训练结束已达 8.9%;3.7M 参数、400 步掩盖得住,放大、训久了就不一定。原论文里,SSD 检测模型不缩放直接发散(表 2);bigLSTM 语言模型不缩放,perplexity 在约 30 万步之后才开始发散(图 5) 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条SSD:FP32 基线 76.9%,FP16 不缩放发散,缩放后 77.1%;bigLSTM:不缩放的 FP16 在约 300K 次迭代后相对 FP32 发散。原文数字,在 PDF 原文核对。 出处Micikevicius et al. 2017 表 2、图 5(需联网) ↗需联网 ——下溢的伤害常常要训得够久才显出来。何况反向传播是逐层相乘传下去的:某一层的激活梯度下溢成 0,它下面所有层从这条路径拿到的梯度也都是 0。
证据三问
| 问 | 本课的回答 |
|---|---|
| ① 它解决什么问题? | 让矩阵乘和激活用 16 位,同时不丢小梯度、不吞小更新 |
| ② 数学上为什么可能有效? | 下溢、上溢、更新被吞都是 IEEE 754 位宽的算术后果 证据等级 · 数学可证明 在写明的前提下可以严格推导出来,不依赖实验。只要前提成立,换数据、换规模仍然成立。 本条FP16:5 位指数、10 位尾数;最小次正规数 2⁻²⁴,最大 65,504;1 附近间距 2⁻¹⁰。全部可在上面的实验台逐位核对。 出处待补 ;乘除 2 的幂不改尾数,所以 loss scaling 本身无损 证据等级 · 数学可证明 在写明的前提下可以严格推导出来,不依赖实验。只要前提成立,换数据、换规模仍然成立。 本条只要不溢出、不下溢,x × 2ᵏ ÷ 2ᵏ = x 精确成立。 出处待补 |
| ③ 实验上为什么被选中? | 原论文在图像分类、检测、语音、翻译、语言模型、GAN 上都达到了 FP32 的精度 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条原文实验;各网络的缩放倍数不同,有的不需要缩放。 出处Micikevicius et al. 2017 §4(需联网) ↗需联网 ;今天 FP16 训练已基本被下一课的 BF16 取代 |
一句话
FP16 丢梯度,是因为指数只有 5 位:比 2⁻²⁵ 还小的梯度直接变 0,比权重小 2¹¹ 倍以上的更新被吞;修法是 FP32 主权重 + 把 loss 乘大、更新前再除回来。
先停在这里:记住「范围不够 → loss scaling;精度不够 → FP32 主权重」两条就可以进下一课。
再深一层:在位实验台里亲手构造 2⁻²⁴、65,504 这两个边界值,再去读原论文 §3。
下一个缺口
loss scale 要一直调,溢出就跳步——能不能换一种 16 位,干脆不缩放? → 第 3 课:能不要 loss scaling 吗?
你可能会问
- 「为什么 S 常取 65,536?」 它是 2¹⁶:把梯度整体右移 16 个 2 的幂,又离 FP16 的最大值 65,504 足够远(梯度本身很少大于 1)。PyTorch 的 GradScaler 默认也从这个值起步;真正合适的值由动态缩放在训练中找。
- 「推理还用 FP16 吗?」 推理没有反向,也就没有梯度下溢的问题,FP16 仍常见;可激活偶尔超过 65,504 会上溢。C13 讲推理时的量化。
- 「为什么不直接做梯度的缩放,而要缩放 loss?」 下溢发生在反向传播的途中,激活梯度一层层往下传时就可能变 0;等算出参数梯度再缩放已经晚了。缩放 loss 让整条反向链上的每一个梯度都一起放大。