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

能不要 loss scaling 吗?

BF16:用尾数换指数
loss scaling 只能把梯度整体平移,改变不了 FP16 只有 40 个 2 的幂宽的窗口。BF16 把 3 位尾数挪给指数,范围和 FP32 一样宽,于是不再下溢、不用缩放;代价是 1 附近间距粗到 1/128——bf16(257) = 256,主权重和优化器状态仍要 FP32。
❓

本课一问:上一课的 FP16 靠 loss scaling 才活下来,可缩放倍数要一直调,溢出了还得跳过一步。能不能换一种 16 位格式,干脆不缩放?

这一课先不碰:8 位格式(第 4 课)、激活(第 5 课)。

从已有出发

同一步训练,把 FP16 换成 BF16:格式标签只改了一个字母,loss 那一格的「× S」没了。

从已有出发 · 训练一步在显存里留下什么(BF16 混合精度) 本课点任一环节看它在 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⁻⁸),原因同上一课:格式讨论的是单个数,贯穿例句不进到这一层。

先自己设计:留在 FP16,把缩放调好

最省事的想法:FP16 的问题既然有解,就把动态 loss scaling 调好,继续用 FP16。它卡在一个缩放改变不了的数上:

  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案留在 FP16,只靠动态 loss scaling
FP16、BF16、FP32 的可表示范围(同一条 log₂ 轴)

窗口宽度这件事,原论文的数字也在说同一件事:不同网络需要的缩放倍数从 8 到 32K 不等,很多网络根本不需要缩放 。一个要按模型、按训练阶段去找的超参,本身就是负担。公平地说,在我们的小模型上它运行得很好:fp16 + 动态缩放一组一步都没跳(0 步),缩放倍数从 65,536 翻到了 2¹⁸——对这个规模,FP16 + 缩放完全够用。换成 BF16 的理由不是「这次失败了」,而是上面那点余量:BF16 把缩放这套机关和余量问题一起拿掉。

项目实测 · 恰为 0 的梯度与 loss scale(fp16 的跳步)

改进:BF16——把尾数挪给指数

BF16(brain floating point)由 Google Brain 为 TPU 设计:还是 16 位,但分法变了——1 位符号、8 位指数、7 位尾数。8 位指数和 FP32 一模一样,所以它能表示的范围和 FP32 一样宽:

BF16 的值(对照 FP16 与 FP32)
× ×
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
BF16 就是 FP32 的高 16 位:把 FP32 的 23 位尾数舍入到 7 位,指数原样保留。所以 FP32 与 BF16 互转几乎不花成本。

「就是 FP32 的高 16 位」可以直接在位上核对:

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

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

🎯

试三件事:① 在 BF16 与 FP32 之间切换 0.1:FP32 的位型是 0x3DCCCCCD,BF16 是 0x3DCD——前 16 位 0x3DCC,再按舍去的低 16 位 0xCCCD(大于一半)进一,就是 0x3DCD;② 点 1×10⁻⁸,切 FP16 与 BF16:一个是零,一个是正规数;③ 点 257,看 BF16 的「相邻可表示值」:256 的下一个数是 258,257 正好在中间,平局取偶数,落回 256。

BF16 的格式出自 Google Brain(Google Cloud 博客 2019-08-23,Shibo Wang 与 Pankaj Kanwar)。同年 Kalamkar 等人的研究在多个领域的模型上检验它:和 FP32 范围相同,不改超参就能达到 FP32 的效果 。今天它是大模型训练的默认格式:锚点 Qwen3.8 的权重就以 BF16 发布。

新的失败:精度粗了 8 倍

范围换来了,代价是尾数。上面表格后三行就是:

更新被吞的问题在 BF16 里更严重。还是那个 w = 1,这次把 BF16 也放进去累加:

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

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

🎯

试两件事:① 默认输入下 FP16 和 BF16 都一步没加上,只有 FP32 主权重在走;② 点预设「1 + 10⁻³ × 1000」:FP16 走到了 1.9765625,BF16 还停在 1——BF16 的门槛是 w 的 2⁻⁸ ≈ 1/256,比 FP16 的 2⁻¹¹ 高 8 倍。

所以换成 BF16 之后,FP32 主权重、FP32 的 m 和 v 一样也不能少,上一课的账本不变,还是 16 字节/参数:

换成 BF16 之后的账:还是 16 字节/参数

BF16 省掉的是 loss scaling 这套机关,不是显存。

回扣 C8:logit 漂到上千时,BF16 还分得清吗?

C8 讲 z-loss、QK-Norm 时留了一个问题:logit 漂到上千,BF16 还分得清吗?现在可以算了。在 512 到 1,024 之间,BF16 的相邻两个数相差 2⁹⁻⁷ = 4:

四个相邻的 logit,存进 FP16 与 BF16

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

1,000、1,001、1,002 在 BF16 里是同一个数 1,000,1,003 变成 1,004。两个 logit 1,001 与 1,003,真实的 softmax 概率之比是 e² ≈ 7.39;存成 BF16 以后变成 e⁴ ≈ 54.6——差了 7 倍多。FP16 在这里反而分得清(间距 0.5),但它的最大值只有 65,504。这就是 C8 要把 logit 按住的另一个理由:logit 越大,BF16 能分辨的差别越粗;实际实现也会把 softmax 这类敏感算子放在更高的精度里算(下面的 Dig)。

🔍 BF16 也不是处处都行:Switch Transformer 的路由器

Switch Transformer 说自己「首次」用 bfloat16 这样的低精度训练大型稀疏模型;但它同时报告,纯 bfloat16 训练会发散——路由器里的 softmax 对精度敏感。解法是只把路由器的输入转成 FP32,其余照旧用 bfloat16:训练稳定,速度与纯 bfloat16 相同(§2.4「Selective precision」、表 2)。这就是后来各家的共同做法:大部分算子用低精度,少数敏感的(softmax、归一化、路由)留在高精度。DeepSeek-V3 也写明,归一化、注意力、MoE 门控等算子保持 BF16 或 FP32(第 4 课)。出处:Fedus et al. 2021(arXiv 2101.03961),核实 agent 在原文核对;本地论文库有 S6-10-Switch-Transformer.pdf。

回扣 C4:ε = 10⁻⁶ 在 16 位里

C4 第 3 课讲 RMSNorm 时留了一句:ε 在低精度下会遇到问题。锚点的 rms_norm_eps = 0.000001。把它存进两种 16 位:

归一化里的 ε,存进 FP16 与 BF16

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

在 FP16 里,10⁻⁶ 小于最小正规数 6.1 × 10⁻⁵,是次正规数,只剩 5 位有效二进制位,相对误差 1.3%;在 BF16 里它是正规数。更重要的是,真实实现通常把归一化整个放在高精度里算(DeepSeek-V3 就把归一化算子保留在 BF16 / FP32 ),ε 不会以 16 位出现在分母里。

我们自己的模型:bf16 和 fp32 差多少

项目实测 · fp32 / bf16 / fp16 的 loss 曲线

⚠

常见误解一:「BF16 比 FP16 更精确。」

正相反。BF16 的尾数只有 7 位,1 附近间距 2⁻⁷,比 FP16 粗 8 倍;它多出来的是范围。训练选它,是因为梯度与激活跨越的数量级比 FP16 的 40 个 2 的幂还宽,而舍入误差可以交给 FP32 主权重兜底。

常见误解二:「用了 BF16,就不需要 FP32 了。」

更新被吞在 BF16 里更严重(门槛从 FP16 的 1/2048 提到 1/256),所以主权重和优化器状态仍然是 FP32,账本还是 16 字节/参数;矩阵乘的累加也仍在 FP32 里做。

常见误解三:「BF16 是 IEEE 754 的标准格式。」

IEEE 754 定义了 16 位的 binary16(就是 FP16),没有 bfloat16。bfloat16 是 Google Brain 为 TPU 定的格式,后来被 GPU 等硬件广泛支持。

证据三问

问本课的回答
① 它解决什么问题?不要 loss scaling:让 16 位格式的范围和 FP32 一样宽,梯度不再下溢
② 数学上为什么可能有效?8 位指数与 FP32 相同,范围算术上就和 FP32 一致 ;精度的损失由 FP32 主权重接住(上一课的累加实验)
③ 实验上为什么被选中?多领域不改超参就能匹配 FP32 ;大模型训练普遍采用,Switch Transformer 用它训大型稀疏模型时需要把路由器留在 FP32

一句话

📌

BF16 把 FP16 的 3 位尾数挪给指数,范围和 FP32 一样宽,于是不下溢、不用 loss scaling;代价是 1 附近间距粗到 1/128,主权重和优化器状态仍要 FP32。

🚦

先停在这里:记住「FP16 缺范围、BF16 缺精度,两者都要 FP32 主权重;BF16 不用缩放」就可以进下一课。
再深一层:展开 Switch Transformer 的 Dig,想一想为什么偏偏是 softmax 和路由对精度敏感。

下一个缺口

16 位已经稳了。矩阵乘能不能再砍一半,用 8 位算? → 第 4 课:8 位还能训吗?

你可能会问