概念科普 📅 2026-10-31 ✍️ MagicNetWorld 编辑部

同样是 16 位,fp16 与 bf16 的溢出风险为什么不同:从表示范围、舍入间距到 fp8 的分块缩放

主题:同样是 16 位,fp16 与 bf16 把比特切给了不同的用途——一个优先细刻度,一个优先大范围。核心区分:fp16 的主要风险是表示范围不足,梯度很小的场景通常要配 fp32 主权重与 loss scaling;bf16 的主要限制是尾数位数少、刻度较粗。证据边界:文中含一个 CPU 上的 numpy 顺序点积演示,未做 GPU 训练、量化或端到端精度实测;引用的一手论文与厂商文档按其自身口径转述。

数值精度fp16bf16FP8混合精度训练浮点数

先说人话(打个比方):你要在一个盒子里放刻度,盒子有 16 个格子:可以选「格子大一点、但能覆盖从很小到很大的范围」,也可以选「格子小一点、但只覆盖中间一段」。选前者,量很大的数不会装不下,代价是同一段范围内的刻度很粗;选后者,刻度细,但超出那一段的数直接装不进盒子。

浮点数的位宽分配就是这件事。两个具体例子:fp16 装不下 1.0×1051.0\times10^{5} 这样的中间值(超过 65504 就溢出);bf16 在 1.0 附近相邻两数的间隔约 0.0078,同一个数只能记到约 2 到 3 位十进制有效数字——前者是范围不够,后者是间距太粗。一个浮点格式把位分成三份:符号位(正负)、指数位、尾数位。指数位决定这个格式能表示多大、多小的数(下文称表示范围),尾数位决定同一个量级里两个相邻数隔多远(下文称舍入间距)。16 位一共只有 216=655362^{16}=65536 个位模式(下文称码点;码点是位模式,包含零与特殊值编码,不等于最大可表示数),fp16 给指数 5 位、尾数 10 位;bf16 给指数 8 位、尾数 7 位。位数一样,取舍相反。

值得读下去的理由很具体:在训练脚本里把 dtype(数据类型)从 float16 换成 bfloat16,同一份代码可能从「跑一段就出现 NaN(非数)」变成「能跑完」;而到了 fp8,同样一句「用 8 位算」,论文里却要配上分块缩放和额外的累加路径。这些差异不是经验玄学,大部分可以从位宽布局算出来。

需要先说清本文的边界:格式的位宽与极值是静态定义,取自一手论文与厂商文档;硬件与工程现状截至 2026-10-31;论文与厂商的实测结果按其自身口径转述,不当作本刊验证过的结论。文中只有一个在 CPU 上跑的 numpy 顺序点积演示,没有 GPU 训练、量化对比或端到端精度实验(文末「来源与核验记录」是附录性质的核验记录,不属于教学章节)。

术语与符号约定

先约定记号与术语。后面每个术语第一次出现时还会再给一句定义,这张表只是方便回查。

记号 / 术语含义备注
ss符号位0 为正、1 为负(本文只用它表示符号)
EE指数域位模式存的是「指数 + 偏置」的无符号整数
e=E−biase=E-\text{bias}实际指数偏置由格式固定,fp16 为 15,fp32 为 127
mm尾数位数决定同一量级内能分多细
ε\varepsilon相邻可表示数的相对间距等于 2−m2^{-m} 的量级,衡量「刻度粒度」
ulp最低有效位对应的间隔同一量级内相邻两数的绝对间隔
正规数normal number指数域既非全 0 也非全 1 的数,尾数带隐含前导 1
次正规数subnormal指数域全 0,隐含前导 0,用于接近零的一小段
表示范围——本文指最大正正规数与最小正正规数构成的区间
动态范围dynamic range最大正正规数 ÷ 最小正正规数,是比值不是幅值
loss scaling损失缩放反向传播前把损失乘一个常数,避免梯度落入过小的区间
分块缩放blockwise scaling每个小块单独算缩放因子,而非整张张量共用一个

一处必须提前说明的例外:上面对正规数、次正规数的判别规则,描述的是 fp16、fp32、bf16 这类带无穷编码的 IEEE 风格格式。fp8 的 E4M3 为了扩大表示范围,按 FP8 论文的约定不表示无穷,NaN 也只保留单一编码,因此它的指数域全 1 可以当作普通指数使用——这条规则不能直接套用到 E4M3 上。另外,正文里的 e(⋅)e^{(\cdot)} 指自然指数函数,与指数域的记号 EE 及实际指数 ee 无关。

一个浮点数由什么决定

对正规数,浮点格式的取值是:

x=(−1)s×(1+M2m)×2E−biasx = (-1)^{s}\times \left(1 + \frac{M}{2^{m}}\right)\times 2^{E-\text{bias}}

其中 MM 是尾数域里的整数值,0≤M<2m0\le M<2^{m}。式子把两件事分开了:指数决定量级(2e2^{e} 那一段),尾数决定在量级内部的位置(1+M2m1+\frac{M}{2^{m}} 恒落在 [1,2)\left[1,2\right))。

同一量级 [2e,2e+1)[2^{e},2^{e+1}) 内,相邻两个可表示数的间隔是:

ulp=2e−m\text{ulp}=2^{e-m}

于是相对间距的最大值出现在区间下端:

ε=2e−m2e=2−m\varepsilon=\frac{2^{e-m}}{2^{e}}=2^{-m}

mm 每多一位,同一区间里能表示的数翻倍,相对间距减半。后面判断「刻度够不够细」用的都是这个量。

这一节想让你记住的一件事:尾数位数直接给出相对间距 2−m2^{-m},指数位数直接给出可覆盖的指数区间;位宽固定时,这两者此消彼长。

表示范围:fp16 的上限为什么是 65504

fp16 是 IEEE 754 半精度:1 位符号、5 位指数(偏置 15)、10 位尾数。5 位指数域里全 0 留给零和次正规数、全 1 留给无穷与 NaN,所以最大正正规数取指数域 30,即 e=30−15=15e=30-15=15:

xmax⁡=(1+10231024)×215=65504x_{\max}=\left(1+\frac{1023}{1024}\right)\times 2^{15}=65504

最小正正规数取指数域 1,即 e=1−15=−14e=1-15=-14:

xmin⁡=1.0×2−14≈6.10×10−5x_{\min}=1.0\times 2^{-14}\approx 6.10\times 10^{-5}

这两者的比值约为 1.07×1091.07\times 10^{9},也就是最大与最小正正规数相差约 30 个 2 倍区间(因为 log⁡2(1.07×109)≈30\log_2(1.07\times10^9)\approx30)。

bf16 的切法不同:它保留 fp32 的 8 位指数(偏置 127),因此可覆盖的指数区间与 fp32 相同,最大正正规数约 3.39×10383.39\times10^{38}、最小正正规数约 1.18×10−381.18\times10^{-38},比值约 2.87×10762.87\times10^{76}。这里说「相同」指的是指数位宽与偏置相同(因此能覆盖的指数区间相同),不是说两者的最大可表示值相等——两者尾数位数不同,同量级内的刻度粗细也不同。

下面这张表把五种格式放在一起。极值均指正正规数或最大正有限值,比例是本刊按上式算的:

格式符号/指数/尾数最大正有限值最小正正规数相对间距 ε=2−m\varepsilon=2^{-m}
fp321/8/23约 3.40×10383.40\times10^{38}约 1.18×10−381.18\times10^{-38}约 1.19×10−71.19\times10^{-7}
fp161/5/1065504约 6.10×10−56.10\times10^{-5}约 9.77×10−49.77\times10^{-4}
bf161/8/7约 3.39×10383.39\times10^{38}约 1.18×10−381.18\times10^{-38}约 7.81×10−37.81\times10^{-3}
fp8 E4M31/4/34482−6=0.0156252^{-6}=0.015625约 1.25×10−11.25\times10^{-1}
fp8 E5M21/5/2573442−14≈6.10×10−52^{-14}\approx6.10\times10^{-5}约 2.50×10−12.50\times10^{-1}

特殊的取值约定另记:fp32 与 fp16 有 ±inf 与多个 NaN;bf16 的指数位宽与 fp32 相同;E4M3 不表示无穷、NaN 只有单一编码;E5M2 带 inf/NaN,跟随 IEEE 风格约定。

flowchart TB
  subgraph P32["fp32:32 位"]
    A["符号 1 位"] --- B["指数 8 位"] --- C["尾数 23 位"]
  end
  subgraph P16["fp16:16 位"]
    D["符号 1 位"] --- E["指数 5 位"] --- F["尾数 10 位"]
  end
  subgraph B16["bf16:16 位"]
    G["符号 1 位"] --- H["指数 8 位"] --- I["尾数 7 位"]
  end
  subgraph E43["fp8 E4M3:8 位"]
    J["符号 1 位"] --- K["指数 4 位"] --- L["尾数 3 位"]
  end
  subgraph E52["fp8 E5M2:8 位"]
    M["符号 1 位"] --- N["指数 5 位"] --- O["尾数 2 位"]
  end

这张图说明了什么:它只做一件事——把五种格式的位宽分配并排摆出来,方便对照「同样的位数,切给了谁」。fp16 与 bf16 都是 16 位,但一个把 10 位给了尾数、一个把 8 位给了指数;两者不是派生关系,而是同一总位宽下的两种取舍。

这一节想让你记住的一件事:fp16 的最大正有限值是 65504,任何接近或超出有限表示范围的中间结果都可能溢出,具体取决于运算与舍入规则;bf16 的可用范围与 fp32 接近,所以它常见的失败方式不是「装不下」,而是「记不细」。

舍入间距:相邻两个数到底隔多远

把 fp32 的 23 位尾数砍到 7 位,损失有多大?用「一个浮点数由什么决定」一节的相对间距公式:

εbf16=2−7≈7.81×10−3\varepsilon_{\text{bf16}}=2^{-7}\approx7.81\times10^{-3}

这表示在同一量级内,相邻两个 bf16 数的相对间隔最坏约 0.78%;按「就近舍入」折算到单次舍入,误差约半个间距,即约 0.39%。所以 bf16 大约相当于 2 到 3 位十进制有效数字;fp16 约相当于 3 到 4 位(2−10≈9.77×10−42^{-10}\approx9.77\times10^{-4},半个间距约 0.049%)。

这里有三个容易混的百分比,需要就地分开:相邻间距(ε=2−m\varepsilon=2^{-m},结构量)、单次舍入的误差上界(约半个间距)、一次计算或一个模型的误差指标(取决于算法与数据)。本文用 ε\varepsilon 表示相邻间距,用 δ\delta 表示「按就近舍入时单次舍入的误差上界」(约 ε/2\varepsilon/2,且不涉及溢出与次正规区);模型的损失类指标另称「损失误差」,不与前两者直接比较。后文出现具体百分比时,会写明属于哪一类。

对 bf16 还有一个连带差异:按 Google Cloud 的 bf16 文档描述,Cloud TPU 上的 bf16 会把次正规数刷成零(flush to zero)。这是该文档描述的硬件行为,不等于所有 bf16 实现都这样处理;但在「接近零的一小段」上,bf16 与 fp16 的行为差异确实需要单独确认。

这一节想让你记住的一件事:「间距」是格式的结构量(2−m2^{-m}),「误差」还取决于舍入规则与算法;说某个格式相当于多少位十进制有效数字,指的是间距,不是任何一次计算的误差。

零附近的次正规区,为什么梯度会掉到那里

正规数的间距公式在零附近不成立,那里还有一段特殊区域:指数域全 0 时,隐含前导位从 1 变成 0,取值变成

xsub=(−1)s×(M2m)×21−biasx_{\text{sub}}=(-1)^{s}\times\left(\frac{M}{2^{m}}\right)\times 2^{1-\text{bias}}

这段区域的作用是「贴地缓降」:让格式在最小正规数以下还能表示一小段更小的数,而不是直接跳到 0。fp16 的最小正次正规数是 2−24≈5.96×10−82^{-24}\approx5.96\times10^{-8},;它的最小正正规数是 6.10×10−56.10\times10^{-5}。

问题的关键在于有效位数:次正规区里尾数能用的位数是递减的。以 fp16 为例,越接近最小值,可用于区分相邻数的有效位越少;到最小次正规数时只剩最低的那一位。这不是「精度略降」,而是「刻度越来越少」。

这个区间恰好落在反向传播常见梯度的量级附近(约 10−510^{-5} 到 10−810^{-8}),于是常见两类现象——注意,以下是可能的原因,需要与高精度参考对照后才能确认:

  • 某些参数的梯度贡献变得极小或变成 0,表现为该参数几乎不再更新;但这不等于整层停止更新,零梯度也可能来自掩码、结构设计或数据本身;
  • 若实现把次正规数刷成零,那么「很小但非零」的梯度会直接消失,而不是缓慢降精度;
  • 换卡或换框架后同一份代码的表现变了,可能来自不同实现对次正规数的处理差异,需要逐项核对,不能一概归因于格式。

这一节想让你记住的一件事:次正规区的位置由指数位宽决定;fp16 恰好把它放在梯度的常见量级附近,bf16 则把它推到了几乎用不到的极小值附近——这是 fp16 训练要额外处理梯度、而 bf16 通常不必的原因之一。

梯度太小时怎么办:fp32 主权重与 loss scaling

混合精度训练的原始论文(Micikevicius 等,2017 年首次提交,后有修订版)给了两个到本文写作时(2026-10-31)仍在用的做法。

第一件是保留一份 fp32 的权重主副本(master copy)。低精度副本负责前向与反向的计算,优化器在 fp32 副本上计算并应用更新,再把结果舍入回低精度供下一轮使用。它解决的是「更新量太小被低精度舍入吞掉」的问题——注意它不能恢复此前已经丢失的信息。

第二件是 loss scaling。反向传播前把损失乘一个常数 SS,让梯度整体抬高到低精度格式比较合适的区间,更新前再除回去。缩放因子的取法有上下两个约束。设未缩放梯度的最大绝对值为 gmax⁡g_{\max}、最小非零绝对值为 gmin⁡g_{\min}:

S⋅gmax⁡ ≲ 655042,S⋅gmin⁡ ≳ 2−14S\cdot g_{\max}\ \lesssim\ \frac{65504}{2},\qquad S\cdot g_{\min}\ \gtrsim\ 2^{-14}

上面的「除以 2」只是本文为了留余量取的假设,不是格式本身的规则;不同实现会取不同余量,也有实现按窗口内的实测最大值动态调整 SS。

两个约束能不能同时满足,取决于梯度跨度的比值 gmax⁡/gmin⁡g_{\max}/g_{\min} 是否落在 fp16 能覆盖的指数区间内。如果某一步里不同层的梯度跨度本身就超过这个区间,那么一个全局的 SS 无法同时照顾所有层——这也是动态 loss scaling 常见的局限。

flowchart TD
  FWD["前向:低精度权重与激活"] --> LOSS["算出损失 L"]
  LOSS --> SCALE["乘缩放因子 S,得到 S 乘 L"]
  SCALE --> BWD["反向:梯度在低精度里计算"]
  BWD --> CHECK{"出现 inf 或 NaN"}
  CHECK -->|"是"| HALVE["S 减半,跳过这一步"]
  CHECK -->|"否"| UNSCALE["梯度除以 S 还原"]
  UNSCALE --> MASTER["优化器在 fp32 主副本上计算并应用更新"]
  MASTER --> ROUND["舍入回低精度,进入下一轮"]
  ROUND --> FWD
  HALVE --> FWD

这张图说明了什么:loss scaling 不是提高格式本身的精度,而是把小梯度临时搬到低精度格式比较合适的表示区间里算完再搬回来;fp32 主副本则负责让更新量不被低精度的粗刻度抹掉。两者解决的是不同问题,不能相互替代。

一个带数字的算例(假设值)

下面的梯度分布是假设值,只为演示量级关系,不是任何实测:中位数约 3.5×10−73.5\times10^{-7},最大值约 8.2×10−48.2\times10^{-4},最小值约 1.4×10−91.4\times10^{-9}。

如果 S=1S=1:中位数已经落在次正规区附近,最小值远低于最小次正规数。按上面的两条约束挑 SS:

S≤327528.2×10−4≈4.0×107S\le\frac{32752}{8.2\times10^{-4}}\approx4.0\times10^{7}

取 S=225≈3.36×107S=2^{25}\approx3.36\times10^{7} 检验两端:

S⋅gmax⁡≈3.36×107×8.2×10−4≈2.76×104<65504S\cdot g_{\max}\approx3.36\times10^{7}\times8.2\times10^{-4}\approx2.76\times10^{4}<65504 S⋅gmin⁡≈3.36×107×1.4×10−9≈4.7×10−2≫6.10×10−5S\cdot g_{\min}\approx3.36\times10^{7}\times1.4\times10^{-9}\approx4.7\times10^{-2}\gg6.10\times10^{-5}

两端都能落在正规区。需要强调的是:两端满足约束,不代表中间运算(归一化、矩阵乘内部累加、采样等)也安全。之所以一次就能选中,是因为这组梯度的跨度只有

8.2×10−41.4×10−9≈5.9×105≈219.2\frac{8.2\times10^{-4}}{1.4\times10^{-9}}\approx5.9\times10^{5}\approx2^{19.2}

明显小于 fp16 正规区间的约 2302^{30} 跨度。跨度一旦超出,固定 SS 就不再是「调不好」的问题,而是这一步里同时存在两个无法兼顾的量级,需要换格式或改算法。

这一节想让你记住的一件事:loss scaling 处理的是「梯度表示区间」问题,fp32 主副本处理的是「更新量舍入」问题;两者都不能提升格式本身的尾数位数。

累加误差:为什么点积的误差界里有项数

单次舍入的相对误差约半个间距,看起来微不足道。但点积不是一次运算,它是一串加法。设点积结果为 S=∑i=1naibiS=\sum_{i=1}^{n}a_i b_i(这个记号与前面表示符号位的位用记号不同),在「每个乘积带相对误差、累加也在同一精度下进行」这类简化模型下,一个常用的误差界是:

∣S^−S∣ ≲ n ε∑i=1n∣aibi∣\left|\hat{S}-S\right|\ \lesssim\ n\,\varepsilon\sum_{i=1}^{n}\left|a_i b_i\right|

换成相对误差需要除以 ∣S∣\left|S\right|:

∣S^−S∣∣S∣ ≲ n ε⋅κ,κ=∑i∣aibi∣∣∑iaibi∣\frac{\left|\hat{S}-S\right|}{\left|S\right|}\ \lesssim\ n\,\varepsilon\cdot\kappa,\qquad \kappa=\frac{\sum_i\left|a_i b_i\right|}{\left|\sum_i a_i b_i\right|}

κ\kappa 是条件数。这个界有三条必须交代的前提:它针对顺序累加的一种简化模型;假设各项误差不会同向叠加(最坏情况会更粗糙);并且在 SS 接近 0 时相对误差没有意义(那时只能讨论绝对误差)。另外,实际的最坏情况可能比这个界更大——它不是严格的保守界。

它的用处是看量级,不是预测实际误差。比如把 n=8192n=8192(即一次点积要累加 8192 个乘积,与数值范围无关)、bf16 的 ε≈7.81×10−3\varepsilon\approx7.81\times10^{-3} 代进去,nε≈64n\varepsilon\approx64:这只是一个定性提示:如果把 8192 个乘积全部放在 bf16 里累加,误差界会随项数线性变差,方向上是不可接受的,所以这类做法通常要把累加器提升精度。这里的代入结果不是对实际误差的预测,也不构成已成立的「最坏情况」结论——实际误差取决于舍入规则、累加器实现与数据分布。工程上的应对是众所周知的——乘法可以用低精度,累加器要提升到更高精度。Google Cloud 的 bf16 文档在描述 Cloud TPU 的 MXU(矩阵乘单元)时给出的也是这个结构:乘法在 bf16 里做,累加用完整 FP32。

这一节想让你记住的一件事:低精度的风险不在单次乘法的 0.4%,而在误差界里那个随项数增长的因子,以及相消把绝对误差放大成相对误差;所以格式选择的落点在累加器宽度。

注意力的数值陷阱:先减最大值与在线递推

softmax 是把一组分数归一化成权重:给定一行的 NN 个分数 x1,…,xNx_1,\dots,x_N,

softmax(xi)=exi∑j=1Nexj\text{softmax}(x_i)=\frac{e^{x_i}}{\sum_{j=1}^{N}e^{x_j}}

如果某个 xix_i 是 100,e100≈2.7×1043e^{100}\approx2.7\times10^{43},在任何 16 位格式里都会溢出成 inf,接着 inf/inf 变成 NaN。标准解法是先减这一行的最大值 mmax⁡m_{\max}:

softmax(xi)=exi−mmax⁡∑j=1Nexj−mmax⁡,mmax⁡=max⁡jxj\text{softmax}(x_i)=\frac{e^{x_i-m_{\max}}}{\sum_{j=1}^{N}e^{x_j-m_{\max}}},\qquad m_{\max}=\max_j x_j

这个变换在数学上恒等(分子分母同乘 e−mmax⁡e^{-m_{\max}}),在数值上把最大的一项压成 e0=1e^{0}=1,其余项落在 (0,1](0,1],不会因为单个大分数而溢出。

但注意力还有第二个限制:为了拿到 mmax⁡m_{\max},朴素实现要么先把整行扫一遍,要么把整个 N×NN\times N 的分数矩阵写回显存(一行只有 NN 个分数,完整自注意力的分数矩阵有 N×NN\times N 个元素,两者不是一个对象)。在线归一化(Milakov 与 Gimelshein,2018 年首次提交)与 FlashAttention(Dao 等,2022 年首次提交)用的是同一套递推:边算边维护当前最大值与累计分母,遇到更大的值就把历史结果整体缩放。设第 ii 个位置的值向量为 viv_i(注意力里被加权的那个向量)、未归一化的加权和为 OiO_i、累计分母为 ℓi\ell_i,初值取 m0=−∞m_0=-\infty、ℓ0=0\ell_0=0、O0=0O_0=0;首个分数到达时递推直接给出 m1=x1m_1=x_1、ℓ1=1\ell_1=1、O1=v1O_1=v_1,从第二个分数起进入下面的通用形式:

mi=max⁡(mi−1,xi)m_i=\max\left(m_{i-1},x_i\right) ℓi=emi−1−mi ℓi−1+exi−mi\ell_i=e^{m_{i-1}-m_i}\,\ell_{i-1}+e^{x_i-m_i} Oi=emi−1−mi Oi−1+exi−mi viO_i=e^{m_{i-1}-m_i}\,O_{i-1}+e^{x_i-m_i}\,v_i

最后输出 ON/ℓNO_N/\ell_N。注意缩放因子 emi−1−mi≤1e^{m_{i-1}-m_i}\le 1:新最大值只增不减,指数永远非正,这一步不会放大数值。这段递推展示的是数值与流式处理的思路,不是 FlashAttention 的完整实现(后者还包含分块、显存层级与 kernel 融合等细节)。

这一节想让你记住的一件事:数值稳定的关键是把可能溢出的数在进入指数函数之前整体平移掉;在线 softmax 的价值在于把这次平移做成可增量、可流式的。

fp8:两种编码与为什么要分块缩放

fp8 只有 8 位,共 256 个码点。按 FP8 论文(Micikevicius 等,2022 年首次提交、同年 v2)的定义,它有 E4M3 与 E5M2 两种编码:前者尾数多一位、范围小(最大 448),后者指数多一位、范围大(最大 57344)但刻度更粗。这里的 448 与 57344 都是最大正有限值,与前面 fp16 的 65504 是同一类量,不是码点数。

先看最朴素的量化方式——整个张量共用一个缩放因子(per-tensor scaling):

xq=QuantE4M3(xΔ),Δ=max⁡∣x∣448x_q=\text{Quant}_{E4M3}\left(\frac{x}{\Delta}\right),\qquad \Delta=\frac{\max\left|x\right|}{448}

这里 Δ\Delta 是缩放因子,不是「固定间距的刻度」:fp8 内部的间距仍随指数变化,与定点格式的等间距刻度不同。

问题在于神经网络张量里的离群值(outlier,指幅度远大于其余元素的少数值)可以是其余元素的几十倍。Δ\Delta 由最大值决定,于是绝大多数元素除以 Δ\Delta 之后落到 fp8 表示区间的低端,可用刻度很少,部分小值可能直接归零。信息不是被「轻微舍入」,而是在低端被压缩掉。

下面把分块后的一小块称为 tile(小块)。DeepSeek-V3 的训练报告(arXiv:2412.19437;v1 提交于 2024-12-27、v2 于 2025-02-18,本文按 v2 页面核对)给出的做法是(该文 §3.3.2):

  • 激活:按 1×1281\times128 的分块(每个 token 即每个被处理的词元;通道指特征维度上的一列,每 128 个通道)单独算缩放因子;
  • 权重:按 128×128128\times128 的块(128 个输入通道 × 128 个输出通道)单独算缩放因子;
  • 乘法在 FP8 里做,但每 128 个 FP8 乘积的结果在 CUDA Core(通用计算单元)里用 FP32 累加一次,以缓解 Hopper 架构 Tensor Core(矩阵乘单元)在 FP8 GEMM(通用矩阵乘法)累加精度上的限制(论文把这称为硬件限制下的妥协);
  • 其余对数值敏感的操作保持 BF16 或 FP32(该文 §3.3.1:GEMM 走 FP8,输出为 BF16 或 FP32)。

该文明确指出:与先前工作「Fprop 用 E4M3、Dgrad(激活反向)与 Wgrad(权重反向)用 E5M2」的混合做法不同,它在所有张量上都采用 E4M3 以换取更高精度,并把这归因于自己的细粒度分块缩放。也就是说这个方案的梯度链路用的是 E4M3,而不是 E5M2;GEMM 的输出仍为 BF16 或 FP32。该文报告,在这套框架下其 FP8 训练的损失相对误差保持在一个很低的范围(原文给了一个具体百分比,但该句没有交代比较基准与指标定义,本文因此撤下该数值,只保留「该文报告其误差很低」这一层意思,不作外推)。这是该论文在其自身模型规模、数据与超参下报告的结果,不能读成「fp8 训练没有精度代价」。

flowchart LR
  A["激活张量(bf16)"] --> B["按 1x128 切 tile"]
  B --> C["求该 tile 的最大绝对值"]
  C --> D["缩放因子 = 最大绝对值 / 448"]
  D --> E["x 除以缩放因子,量化成 E4M3"]
  W["权重按 128x128 切块量化成 E4M3,各块自带缩放因子"] --> G["Tensor Core:FP8 乘加"]
  E --> G
  G --> H["每 128 个乘积在 CUDA Core 里以 FP32 累加"]
  H --> I["每个同尺度部分和先按自己的缩放因子还原,再合并"]
  I --> J["输出 BF16 或 FP32"]

这张图说明了什么:分块缩放的关键不是「量化得更准一点」,而是把「一个张量一个量程」改成「每个小块一个量程」——离群值只影响它自己那一块;还原时每个同尺度的部分和要先按自己的缩放因子还原,再合并。图是概念流程,不是精确的指令时序。

还有一点需要单独说明:E4M3 与 E5M2 的一般分工是「E5M2 范围大、间距粗,适合放跨度极大的量;E4M3 范围小、间距细,适合放权重与激活」。这是一般的格式取舍;而上面介绍的 DeepSeek-V3 实际是全部张量都用 E4M3(含 Dgrad 与 Wgrad),不要把它和这种一般分工混为一谈。该文还讨论了把激活也按块缩放时训练不稳定的情况,说明分块方向与粒度必须按张量分布逐项验证——那一条结果并不支持「任意改换块形状」。

这一节想让你记住的一件事:8 位格式能否用,很大程度上取决于缩放粒度与累加精度;per-tensor 缩放在离群值面前会损失大量低端刻度,而分块缩放加高精度累加,是把「精度不够」换算成「量程切得细」的工程解法。

一次 CPU 顺序点积数值演示

前面给的是误差界,界容易让人误判严重程度。为了给一个具体量级,本文在 CPU 上用 numpy 跑了一个小型演示:把「逐项顺序累加」在 float32 与 float16 下的结果,与 float64 参考值比较。

这个演示只覆盖一种误差来源(顺序累加的舍入),不代表任何真实算子,也不代表 GPU 上的行为。

import numpy as np

rng = np.random.default_rng(20261031)
n = 8192
a = rng.uniform(-1, 1, n); b = rng.uniform(-1, 1, n)
idx = rng.choice(n, size=n // 1000, replace=False)
a[idx] *= 50; b[idx] *= 50          # 0.1% 的元素放大 50 倍,模拟离群值

def naive(x, y, dt):
    acc = dt(0)
    for i in range(len(x)):
        acc = dt(acc + dt(dt(x[i]) * dt(y[i])))
    return float(acc)

def to_bf16(v):
    u = np.float32(v).view(np.uint32)
    u = (u + 0x8000) & 0xFFFF0000   # 就近舍入到 7 位尾数
    return u.view(np.float32)

ref = float(np.dot(a.astype(np.float64), b.astype(np.float64)))
f32 = naive(a, b, np.float32)
f16 = naive(a, b, np.float16)

c = rng.uniform(-1, 1, n); d = rng.uniform(-1, 1, n)   # 无离群值、正负相消的一组
ref2 = float(np.dot(c.astype(np.float64), d.astype(np.float64)))
f16_2 = naive(c, d, np.float16)

for name, v, r in [("float32", f32, ref), ("float16", f16, ref),
                   ("float16 无离群值组", f16_2, ref2)]:
    print(f"{name}: {v:.6f} 相对误差 {abs(v - r) / abs(r):.3e}")

print("float64 参考值:", ref, " 无离群值组参考值:", ref2)
print("bf16 模拟 1/3 =", float(to_bf16(1 / 3)),
      "相对误差", abs(float(to_bf16(1 / 3)) - 1 / 3) * 3)
print("float16 极值:", np.finfo(np.float16))
print("float16 里 exp(100) =", np.exp(np.float16(100)))

本机输出(Windows;numpy 2.4.6、Python 3.11.4;把上面这段存成 demo.py 后执行 python demo.py;2026-10-31):

累加格式结果相对误差
float64(参考)2102.754162——
float32 顺序累加2102.741699约 5.9×10−65.9\times10^{-6}
float16 顺序累加2100.000000约 1.3×10−31.3\times10^{-3}
float16 无离群值组——约 1.5×10−21.5\times10^{-2}

可以观察到的现象有三条,都只就该演示成立:

  • float16 的结果恰好落在 2100.000000 这个值上,与该量级上 float16 的 ulp 是 2 相符(最终值被吸附到刻度上);这不能说明所有中间结果都被同样吸附。
  • 去掉离群值、改成正负相消的一组后,float16 的相对误差反而更大(约 1.5e-2)。这与「相消会放大相对误差」的方向一致,但本演示没有单独打印条件数,因此只能作为线索,不能作为归因结论。
  • 用 7 位尾数就近舍入模拟 bf16 时,1/31/3 变成 0.333984375,相对误差约 1.95×10−31.95\times10^{-3},与半个 bf16 间距的量级相符。

要判断自己的链路掉多少精度,正确做法是用真实算子与代表性输入做对照(含参考实现、边界输入与极端 batch);CPU 上的顺序点积只演示了其中一种误差来源。

这一节想让你记住的一件事:误差界的量级是对的,但真实误差依赖求和顺序、数据分布与条件数;演示能给量级感,不能替代用真实算子做的验证。

三笔账:容量、带宽、算力

低精度的收益来自三个不同地方,混在一起谈容易得出错误结论。以下每条都只在其适用条件下成立。

第一笔是容量账:参数量乘以每个参数的字节数。同一份权重数据载荷从 bf16 换成 fp8,权重字节数减半——这不等于「静态显存整体减半」,因为还要算缩放元数据、主权重副本、优化器状态、激活与 KV cache(键值缓存,用来避免重复计算的中间结果)。

第二笔是带宽账:解码阶段每生成一个 token 都要把参与计算的权重读一遍(MoE 即混合专家模型,只读被激活的专家),时间近似等于「读的字节数 ÷ 有效带宽」。这时字节数减半给出的是一个上限,实际还要看量化与反量化的额外算子开销、kernel(计算核函数)是否融合、是否被 KV 读取、采样或通信卡住。

第三笔是算力账:prefill(预填充,指一次性处理整段输入的阶段)这类计算密集阶段受矩阵乘单元吞吐限制,8 位乘法在同样面积下可以塞进更多运算单元;但同样受累加精度与数据搬运限制,不会线性兑现。

判断自己的场景属于哪一笔账,方法还是本文反复用的那条:先看瓶颈在哪一段(读字节、做乘加、还是搬运中间结果),再问这个格式能不能把那一段的成本压下去。

这一节想让你记住的一件事:说「快了多少倍」之前先说清省的是哪一笔账;容量、带宽、算力三笔账的上限各不相同,而且每一笔都还有自己的附加开销。

常见组合示意

下表是常见组合的示意,不是通用选型规则;每一行都依赖具体的模型规模、硬件代际与框架支持。

场景常见组合主要收益需要额外关注
通用训练,硬件支持 bf16bf16 计算 + fp32 累加 + fp32 主权重省显存与带宽,可用范围接近 fp32尾数 7 位,精度敏感指标要单独看
硬件只有 fp16fp16 计算 + 动态 loss scaling + fp32 主权重尾数 10 位,刻度更细需监控 inf/NaN 与梯度下溢
支持 FP8 的加速器上做大模型预训练FP8 矩阵乘 + 分块缩放 + 高精度累加低精度乘法与权重载荷减半的潜在收益(仍含高精度累加、缩放与转换开销)依赖框架级分块缩放实现,需按任务验证
长序列注意力分块流式计算 + 在线 softmax不必把 N×NN\times N 分数矩阵落盘实现复杂度上升,需核对该实现是否真的在线

三条判断可以带走,但都要带上本文的限定:

  1. 先看表示范围,再看间距。 范围不够往往表现为 inf/NaN 或梯度归零;间距不够往往表现为指标缓慢变化。前者更容易及早暴露,所以通常排在第一位。
  2. 乘法的格式与累加的格式可以不同。 本文引用的实践(Cloud TPU 的 MXU、DeepSeek-V3 的 FP8 GEMM)都在利用这一点。
  3. 缩放粒度是可调的杠杆。 从 per-tensor 改到 per-block 往往比继续降低位宽更可控,但改动块形状必须按张量分布验证,不能照搬。

这一节想让你记住的一件事:格式选择是「乘法格式、累加格式、缩放粒度」三件事的组合;只把 dtype 改小而不动后两件,等于只做了一半的改造。

三个排查假设

遇到「精度掉了 / 训练不稳」时,下面三条可以按顺序当作假设去验证,不要当成判据。每条都给出验证方法。

  • 假设一:问题在表示范围。 现象是出现 inf/NaN,或某些参数长期不更新。验证方法:记录张量的最大绝对值、最小非零绝对值、零值比例与非有限值的比例,与格式的极值表对照;再用 fp32 参考实现跑同一批数据,看差异出现在哪一层。
  • 假设二:问题在累加精度。 现象是短序列正常、长序列或大 K 变差(K 指矩阵乘法里每个输出元素要累加的乘积个数)。验证方法:只替换累加器精度(乘法精度不动),看误差是否随之缩小;如果是,说明该环节的累加是瓶颈。
  • 假设三:问题在实现差异。 现象是换卡或换框架后表现变化。验证方法:固定输入,逐算子与参考实现对比;这类差异(例如次正规数处理、算子内部累加宽度)需要单独排查,不应直接归因于格式。

记录指标时有三个易错点:min() 可能取到最负的值或 0,要看最小非零绝对值;相对误差在参考值接近 0 时没有意义,此时只能看绝对误差;零值比例与非有限值比例要分开统计。

这一节想让你记住的一件事:排查顺序通常是「先范围、后累加、再实现」,而且每一条都要有对照实验支撑;只凭现象下结论,容易把实现差异误判成格式选错。

边界与未解

  • 误差界只是粗量级,不是预期值、也不是严格上界。 nεn\varepsilon 与条件数 κ\kappa 只用于排序比较;真实误差通常更小,也可能因同向叠加更大;反过来,「这次没掉点」也不能证明极端 batch 不会掉点。
  • 「损失误差很低」是 DeepSeek-V3 在其自身配置下报告的结果(原文数值未交代比较基准与指标定义,本文不引用该数值),依赖该文的模型规模、数据分布与超参,不能外推成「fp8 训练没有精度代价」。该文还指出,若把激活也按块缩放(与权重同样处理),训练反而不稳定——这说明缩放粒度与方向需要按张量分别验证。
  • 次正规数的处理是实现行为,不是格式定义。 本文只在「Google Cloud 的 bf16 文档描述 Cloud TPU 把次正规数刷零」这个意义上引用它;其它实现是否这样做,需要单独确认。
  • FP8 的硬件累加精度仍是开放问题。 DeepSeek-V3 用 CUDA Core 每 128 个乘积补一次 FP32 累加,属于在软件层绕过硬件限制;该文也把「Tensor Core 提供更高精度累加」列为对未来芯片的诉求。
  • 未解项:不同框架对 E4M3 的偏置、NaN 编码与舍入模式实现是否逐位一致,本文未做跨实现核对;这会影响「同一段权重在两个框架间互转是否一致」,需要单独验证。

这一节想让你记住的一件事:把「格式定义」「厂商硬件行为」「论文自身结果」「本文推导」四类信息分开看,比记住任何一个具体百分比都重要。

小结

回到开头的盒子比喻:16 位只有 65536 个码点,fp16 把刻度堆在「细」这一侧,bf16 把刻度堆在「宽」这一侧。由此得到的三条结论,覆盖了从 fp16 到 fp8 的常见工程做法:

  • fp16 的 65504 上限与 6.10×10−56.10\times10^{-5} 下限,使它在梯度很小的场景下通常需要 fp32 主权重与 loss scaling 这套组合;
  • bf16 用 7 位尾数换来了与 fp32 相同的指数位宽,因此在本文举例的梯度量级上通常不会因范围不足而下溢,代价是整间距约 0.78%(单次舍入约半个间距);
  • fp8 的 448 与约 12.5% 的间距,使得 per-tensor 缩放常常不够用,工程上通常走到分块缩放并把累加提升到 FP32。

这一节想让你记住的一件事:在本文讨论的这些条件下,低精度训练的胜负手通常不在乘法器有多小,而在量程切得够不够细、累加器抬得够不够高。

📋 来源与核验记录

本节是核验记录(附录性质),不属于教学章节。以下来源均为本轮实际打开核对,按其性质分四类。

一、格式定义与极值

  1. Micikevicius 等,《FP8 Formats for Deep Learning》,arXiv:2209.05433(v1 2022-09-12、v2 2022-09-29;本文格式表与编码约定按 v2 核对)。E4M3 最大正有限值 1.75×28=4481.75\times2^{8}=448、E5M2 最大 1.75×215=573441.75\times2^{15}=57344、E4M3 最小正正规数 2−62^{-6}、E5M2 最小正正规数 2−142^{-14}、E4M3 不表示无穷且 NaN 只有单一编码,均取自该文。https://arxiv.org/abs/2209.05433
  2. Google Cloud Blog,《BFloat16: The secret to high performance on Cloud TPUs》(本文查阅日期 2026-10-31)。bf16 为 1 符号 + 8 指数 + 7 尾数、指数范围与 FP32 相同、把次正规数刷零、MXU 内乘法用 bf16 而累加用 FP32,均取自该文对该硬件与格式的描述。https://cloud.google.com/blog/products/ai-machine-learning/bfloat16-the-secret-to-high-performance-on-cloud-tpus

二、指定版本论文的结果(按其自身口径转述)

  1. Micikevicius 等,《Mixed Precision Training》,arXiv:1710.03740(v1 2017-10-10)。fp32 主权重副本与 loss scaling 两项技术的出处。https://arxiv.org/abs/1710.03740
  2. DeepSeek-AI,《DeepSeek-V3 Technical Report》,arXiv:2412.19437(v1 2024-12-27、v2 2025-02-18,本文按 v2 核对)。§3.3.1 混合精度框架(GEMM 走 FP8、输出 BF16/FP32)、§3.3.2 细粒度量化(激活 1x128、权重 128x128)、每 128 个 FP8 乘积在 CUDA Core 内以 FP32 累加、以及该文对其 FP8 训练损失相对误差的定性报告(具体数值本文未引用,理由见正文)均取自该文。https://arxiv.org/abs/2412.19437
  3. Milakov 与 Gimelshein,《Online normalizer calculation for softmax》,arXiv:1805.02867(v1 2018-05-08)。在线 softmax 的递推形式出处。https://arxiv.org/abs/1805.02867
  4. Dao 等,《FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness》,arXiv:2205.14135(v1 2022-05-27)。分块与在线 softmax 的注意力实现出处。https://arxiv.org/abs/2205.14135

三、本文自行推导

  • 相对间距 ε=2−m\varepsilon=2^{-m}(bf16 整间距约 0.78%、半个间距约 0.39%;fp16 整间距约 0.098%、半个间距约 0.049%;fp8 E4M3 约 12.5%)、ulp 与表示范围比值,按 IEEE 754 与 FP8 论文的格式定义计算;
  • 点积误差界 nεκn\varepsilon\kappa 为通用数值分析结论的量级示意,不是任何硬件上的实测误差;
  • loss scaling 的两条约束(含「除以 2」的余量假设)由 fp16 极值与本文假设的梯度分布推出。

四、CPU 演示(本文自测,非 GPU 实测)

  • 「一次 CPU 顺序点积数值演示」一节的结果(float32 相对误差约 5.9e-6、float16 约 1.3e-3、无离群值组约 1.5e-2、bf16 模拟 1/3 约 1.95e-3)由本文在 Windows + numpy 上以 CPU 顺序累加器跑出,随机种子 20261031,完整脚本已随文给出。

未做端到端实测:本文没有在 GPU 上运行训练、量化或精度对比实验;bf16 / fp8 相关的厂商与论文数字均为其自身口径的转述。上文那个 8192 维点积演示是 CPU 上的数值示意,不能用来推断任何真实模型在真实硬件上的精度表现。