从0训练LLM · 现代大模型体系 阅读 + 实验 ≈ 35 分钟 页内实验 ×5
C9 精度与显存:一个参数要几字节 第 2 课

全换成 FP16,为什么会丢梯度?

FP16 为什么丢梯度
把权重、梯度、优化器状态全换成 FP16,三个真数字当场出事:10⁻⁸ 变成 0、1 + 10⁻⁴ 还是 1、70,000 变成 inf。修法是混合精度:更新在 FP32 主权重上做,loss 乘一个 2 的幂把小梯度推出下溢区,更新前再除回来。
❓

本课一问:上一课的 16 字节里,有 12 字节是 FP32。把权重、梯度、优化器状态全换成 16 位的 FP16,账本直接砍半——为什么没人这么训?

这一课先不碰:BF16(第 3 课)、8 位格式(第 4 课)、激活(第 5 课)。这一课只看 FP16 一种格式在训练里出的三种事,以及混合精度怎么把它们一一堵上。

从已有出发

上一课的账本按「混合精度」记:16 位的权重和梯度,加上 FP32 的主权重、m、v。现在问的是:为什么不干脆全用 16 位?先看同一步训练换成 FP16 之后,每个环节存成什么:

从已有出发 · 训练一步在显存里留下什么(FP16 混合精度) 本课点任一环节看它在 Qwen/Qwen3.8-27B 上占多少
精度与显存的演进:每砍一次位宽,都在一个真数字上失败一次
  1. 起点 FP32 全精度 + AdamW 深度学习早期的默认做法
    解决了范围到 ±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 倍,还没算激活。
  2. 第一代 FP16 混合精度:FP32 主权重 + loss scaling Micikevicius et al. 2017 你在这里
    解决了矩阵乘和激活用 16 位,带宽与激活显存减半;FP32 主权重接住小更新,loss 乘 S 把小梯度推出下溢区,更新前再除回来。
    卡在指数只有 5 位:fp16(10⁻⁸) = 0,fp16(1 + 10⁻⁴) = 1,fp16(70,000) = inf;缩放倍数要一直调,溢出就得跳过这一步。
  3. 第二代 BF16:用尾数换指数 Google Brain 2019 · Kalamkar et al. 2019
    解决了8 位指数,范围与 FP32 相同:不再下溢,不需要 loss scaling。2026 年的默认训练精度。
    卡在1 附近间距粗到 1/128:bf16(257) = 256,bf16(1 + 10⁻³) = 1——主权重和优化器状态仍要 FP32,账本还是 16 字节/参数。
  4. 第三代 FP8 + 细粒度块缩放 Micikevicius et al. 2022(格式)· DeepSeek-V3 2024(超大规模训练)
    解决了激活每 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。
  5. 岔路 · 2025–2026 4 位:MX / NVFP4 块缩放 + QAT OCP MX 2023 · NVFP4 2025 · Kimi K3 / DeepSeek-V4 2026
    解决了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 预训练仍是前沿。
  6. 另一半账 激活:重计算与 FlashAttention Chen et al. 2016 · Korthikanti et al. 2022 · Dao et al. 2022
    解决了不把 s × s 的注意力分数存下来:锚点一条 4,096 token 的序列,激活从 162.5 GiB 降到 42.5 GiB(选择性重计算 / FlashAttention);全量重计算每层只存输入,只剩 2.5 GiB(标准块公式近似)。
    卡在重计算用多算一遍前向换显存;可 444.5 GB 的模型状态一张 80 GB 卡仍然放不下 → C10 并行。
数字口径:舍入结果在构建时按 IEEE 754 / OCP 规则现算(先存 FP32,再就近舍入、平局取偶);显存由 anchor-model.json 的 param_total 现算,1 GB = 10⁹ 字节;激活按 Korthikanti et al. 2022 式 1(标准块近似,s = 4,096、b = 1);DeepSeek-V3 的数字出自其技术报告 §3.3 与附录 B(本地 PDF,官方自报)。

为什么这里换例子:浮点格式讨论的是「一个数存进去会变成什么」,贯穿例句「小明把苹果给了小王」不会进到这一层。这一课和后面两课追踪三个数:一个权重 w = 1、它的一次更新 Δw = 10⁻⁴、一个很小的梯度 g = 10⁻⁸。它们是送进格式里测试的输入(R4 调研的实算用例),不是从哪个模型量出来的;真实的梯度分布,本课后半用我们自己模型的探针数据来看。

先自己设计:全部换成 FP16

最直接的做法:权重、梯度、优化器状态、更新,统统存成 FP16。下面每一格都是浏览器按 IEEE 754 真舍入出来的:

  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案权重、梯度、优化器状态全部存 FP16
四个数,存进 FP32 与 FP16

口径:浏览器现算。FP32 用 DataView 取位;其余格式先存成 FP32,再按 RNE 舍入(与 PyTorch 的 dtype 转换逐值一致,开发时用 torch 2.6 对照过)。E4M3 是 OCP 规范的 FN 变体(无 inf,最大 448);E5M2 有 inf(最大 57,344)。

为什么会这样:把 16 个位拆开看

FP16 一共 16 位:1 位符号、5 位指数、10 位尾数。一个数的值由下面这个公式决定,逐项点开:

FP16(IEEE 754 binary16)的值
× ×
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
e = 0 时没有藏着的 1,值是 2⁻¹⁴ × (m ÷ 1024),叫次正规数:最小的是 2⁻²⁴ ≈ 5.96 × 10⁻⁸。比它的一半 2⁻²⁵ 还小的数,舍入后就是 0。

在下面的实验台里逐位拨一拨:

浮点位实验台 · 一个数到底存成了什么

口径:浏览器现算。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 的归宿。

三种失败,对应公式里的三处边界:

改进一:在 FP32 主权重上做更新

更新被吞的问题,在任何 16 位格式里都躲不掉。解法是把「累积小更新」这件事交给 FP32:优化器在一份 FP32 的主权重上做 w ← w + Δw,做完再转一份 16 位的给下一步前向。同一个 w = 1,每步加 10⁻⁴,加 1,000 步:

累加被吞:同一个参数每步加一点,加 N 步

口径:浏览器现算。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 。

🎯

试两件事:① 点预设「1 + 10⁻³ × 1000」:FP16 这回每一步都加上了——更新超过了 2⁻¹¹,但仍有舍入,1,000 步后停在 1.9765625,不是 2;② 点「256 + 1 × 10」,看 FP16 在 256 附近的间距(0.25)下,每步加 1 没有问题——被吞的条件永远是「更新和权重的比例」,不是更新的绝对大小。

改进二:loss scaling——把整批梯度推出下溢区

小梯度下溢,靠主权重救不回来:梯度在反向传播时就已经是 FP16 了,等不到优化器。办法是在反向之前把 loss 乘一个数 S:

loss scaling:放大、存进 FP16、再除回来
= →
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
S 太大会让大梯度上溢成 inf。原论文里 SSD 检测模型用 8 倍就够,bigLSTM 语言模型用 128 倍;不同网络用过 8 到 32K,也有很多网络根本不需要缩放。

在数轴上看:拖 loss scale,整批梯度向右平移。

FP16 的可表示范围 · 梯度掉进哪个区

🎯

试三件事:① 保持 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 。后来的工具把它做成了动态的:先取一个大值,一旦梯度里出现 inf 或 NaN,就跳过这一步、把 S 减半;连续若干步不溢出,再把 S 翻倍。原论文只建议「溢出时跳过这一步」,动态调 S 写在它的「未来工作」里。我们项目的 DynamicLossScaler 就是这样:初值 65,536,溢出减半并跳步,连续 200 步不溢出翻倍(C5 第 6 课提过的「FP16 下梯度溢出成 inf」,在这里被接住)。

改进三:FP16 相乘,FP32 累加

一次矩阵乘里,每个输出是几千个乘积的和。如果累加器也是 FP16,加到后面,新加的小乘积就会像上面的 Δw 一样被吞掉。所以矩阵乘的输入是 FP16,乘积在 FP32 里累加,最后再存回 FP16 。第 4 课会看到,到了 8 位这件事变得更要紧:DeepSeek-V3 发现 H800 上 FP8 矩阵乘的累加器只保留约 14 位。

我们自己的模型:到底有多少梯度会下溢

以上三个数是送进格式的测试输入。真实训练里有多少梯度会掉进下溢区,要看真实分布——实验脚本在我们的基线模型上取一批数据,算一次 FP32 梯度,统计存成 FP16 会怎样;再真在 FP16 里训练两遍(缩放 / 不缩放)对照:

项目实测 · 下溢探针:同一批梯度存成 FP16 会怎样

项目实测 · 恰好为 0 的梯度比例与 loss scale

读数(实验 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) ——下溢的伤害常常要训得够久才显出来。何况反向传播是逐层相乘传下去的:某一层的激活梯度下溢成 0,它下面所有层从这条路径拿到的梯度也都是 0。

证据三问

问本课的回答
① 它解决什么问题?让矩阵乘和激活用 16 位,同时不丢小梯度、不吞小更新
② 数学上为什么可能有效?下溢、上溢、更新被吞都是 IEEE 754 位宽的算术后果 ;乘除 2 的幂不改尾数,所以 loss scaling 本身无损
③ 实验上为什么被选中?原论文在图像分类、检测、语音、翻译、语言模型、GAN 上都达到了 FP32 的精度 ;今天 FP16 训练已基本被下一课的 BF16 取代

一句话

📌

FP16 丢梯度,是因为指数只有 5 位:比 2⁻²⁵ 还小的梯度直接变 0,比权重小 2¹¹ 倍以上的更新被吞;修法是 FP32 主权重 + 把 loss 乘大、更新前再除回来。

🚦

先停在这里:记住「范围不够 → loss scaling;精度不够 → FP32 主权重」两条就可以进下一课。
再深一层:在位实验台里亲手构造 2⁻²⁴、65,504 这两个边界值,再去读原论文 §3。

下一个缺口

loss scale 要一直调,溢出就跳步——能不能换一种 16 位,干脆不缩放? → 第 3 课:能不要 loss scaling 吗?

你可能会问