在上一篇中,我们过了一遍(非常非常基础+终极简化版本)的浮点数计算是怎么在硬件上实际进行的。结尾部分提到,随着 AI 的兴起,硬件的浮点数数据类型已经从科学计算中常用的 double(FP64)和最常规的数据类型 FP32 开始一路下探到了 FP4。本文尝试把这些数据类型的位级表示、单次运算误差、点积误差和端到端模型精度四个层次拆开,给出一个全景式的理解。
这里先抛出来几个我拍脑袋想到的问题来做一下探讨:
- TF32 是怎么来的,它是不是一种真正的 32 位存储格式?
- FP16 和 BF16 都是 16 位,谁更“准”?
- 我们用 TF32 来做矩阵乘法的时候会有一种工程上的 trick:单次 TF32 乘法的精度在敏感情况下会掉的比较厉害,可以通过三次 TF32 乘法来逼近一次 FP32 乘法。那如果把 TF32 换成更快的 BF16,能做出来类似的精度逼近方案吗?
- 在一块只有 FP16 Tensor Core、没有 BF16 Tensor Core的硬件上,怎样尽量准确而高效地模拟 BF16 模型?
先统一一下“精度”的准确定义
先放一下几个目前在 AI 领域中用到的数据类型的二进制表示:
一个二进制浮点数通常写成
$$
x=(-1)^s\times (1.f)_2\times 2^e.
$$
其中 exponent 决定动态范围,fraction 决定相邻可表示数的间距。对普通 normal number,还要加上没有存储的 leading 1,所以有效数字位数是
$$p=m+1.$$
在 $[1,2)$ 内,相邻两个数的间距(常被叫作 machine epsilon)是 $2^{-m}$;round-to-nearest 的最大相对舍入误差,即 unit roundoff,是
$$u=2^{-p}=2^{-(m+1)}.$$
这两个量经常都被写成 $\epsilon$,差一个 2。本文统一用 $u$ 表示最大舍入误差。
实际到真正工程落地的时候还要再区分四件事:
- 表示精度:把一个实数存入某格式会损失多少。
- 乘法输入精度:Vector Core 或者 Tensor Core 真正拿多少 bit 的 significand 相乘。
- 累加精度:相乘得到的乘积又是累加到 FP16、FP32?还是更宽的哪个 accumulator 类型上。
- 模型精度:最终 perplexity、top-1 或收敛曲线。它是算法、缩放、舍入、累加、优化器和数据等等一系列因素共同作用的结果,实际上没办法简单就反推得到单次运算有多少有效位。
例如我们经常在某某模型或者某某硬件 PR 上看到的“FP8 训练达到 BF16 baseline”说的就是这里的第 4 层,是各种因素综合的结果,这句话并不等于“FP8 乘法就能够有 BF16 精度”。
常见浮点格式编码详解
- 表格中 IEEE FP16/FP32 遵循 IEEE 754;
- TF32 的位数和执行语义见 NVIDIA 的说明;
- FP8 采用 E4M3/E5M2 联合规范,FP6/FP4 则按当前 NVIDIA PTX 与 OCP microscaling 中常见的有限数编码讨论。
- 另外注意,不同厂商的实际硬件单元设计虽然都会按照 IEEE 的标准来,但是最终落地都会有各自的做法,不同厂商若使用不同 bias、特殊值或 scale,范围也会改变。
| 格式 | 编码(S/E/M) | 有效位 $p$ | unit roundoff $u$ | 最大有限值 | 最小 normal | 最小正非零值 | Inf / NaN |
|---|---|---|---|---|---|---|---|
| FP32 | 1/8/23 | 24 | $2^{-24}\approx5.96\times10^{-8}$ | $3.403\times10^{38}$ | $2^{-126}$ | $2^{-149}$ | 有 / 有 |
| TF32 | 1/8/10 | 11 | $2^{-11}\approx4.88\times10^{-4}$ | 约 $3.40\times10^{38}$ | $2^{-126}$ | 实现相关 | 有 / 有 |
| FP16 | 1/5/10 | 11 | $2^{-11}\approx4.88\times10^{-4}$ | 65504 | $2^{-14}$ | $2^{-24}$ | 有 / 有 |
| BF16 | 1/8/7 | 8 | $2^{-8}=3.906\times10^{-3}$ | $3.390\times10^{38}$ | $2^{-126}$ | $2^{-133}$ | 有 / 有 |
| FP8 E4M3 | 1/4/3 | 4 | $2^{-4}=6.25\%$ | 448 | $2^{-6}$ | $2^{-9}$ | 无 / 有 |
| FP8 E5M2 | 1/5/2 | 3 | $2^{-3}=12.5\%$ | 57344 | $2^{-14}$ | $2^{-16}$ | 有 / 有 |
| FP6 E2M3 | 1/2/3 | 4 | $2^{-4}=6.25\%$ | 7.5 | 1 | 0.125 | 无 / 无 |
| FP6 E3M2 | 1/3/2 | 3 | $2^{-3}=12.5\%$ | 28 | 0.25 | 0.0625 | 无 / 无 |
| FP4 E2M1 | 1/2/1 | 2 | $2^{-2}=25\%$ | 6 | 1 | 0.5 | 无 / 无 |
这里就能够看出来几个有意思的点了:
第一,TF32 并不是一个“有 32 bit 精度的 float”。
- 它跟 FP32 有一样的 8-bit exponent,所以动态范围和 FP32 是一样的;
- 但是 mantissa 却跟 FP16 一样只有 10 bit,所以在 $[1,2)$ 内的相邻数间距是 $2^{-10}$,最大舍入误差是 $2^{-11}$,比 FP32 的 $2^{-24}$ 大了 13 个数量级。
CUDA 中矩阵输入一般仍是 FP32,加载到 Tensor Core 路径时才舍入成 TF32,乘法结果累加到 FP32。NVIDIA 也明确说明 TF32 不改变张量存储。PTX 中 .tf32 放在 32-bit register 里也不代表 32 bit 都是数值信息。这个可以说是一个为了 Tensor Core 性能而做出来的一个折中方案:保留 FP32 的 exponent 以维持动态范围,但舍弃了大部分的 fraction bit 来换取更高的吞吐。
第二,低于 8 bit 后,“FP6”“FP4”本身不再是一个完整定义。E2M3 与 E3M2 都叫 FP6,却一个偏精度、一个偏范围;scale 是 per-tensor、per-channel、per-block 还是 microscaling,也会比 element 格式本身更影响效果。
PTX ISA 明确列出了 E2M1、E2M3、E3M2,且这些编码不支持 Inf/NaN;OCP Microscaling Formats 则把低位 element 与共享 scale 一起定义。
第三,低精度 AI 很少把整个网络“裸压”进表中的范围。典型表示其实是
$$
x_i \approx s_g q_i,
$$
其中一组元素共享 scale $s_g$,$q_i$ 才是 FP8/FP6/FP4。为了增强精度,在 LLM 大模型中 FP8 比较常用的是配合 [128, 128] per-block 粒度做 scaling。block 越小,局部动态范围越容易容纳,scale 元数据和量化开销越大。FP4 的原生范围只有 $[-6,6]$,不配 scale 几乎没有讨论模型数值范围的意义。
FP32 vs TF32:范围相同,乘法精度差 13 bit
FP32 有 24 bit significand,TF32 只有 11 bit。对同一个接近 1 的数,FP32 舍入误差上界约 $6\times10^{-8}$,TF32 约 $4.9\times10^{-4}$,相差 $2^{13}=8192$ 倍。
那为什么很多模型仍能直接使用一次 TF32 GEMM 就能得到可接受的结果?因为深度学习对单次乘法误差通常有足够的容忍度,并且 TF32 在 Tensor Core 上也是把乘积累加到 FP32 的。累加器虽然不会把输入被截掉的 13 bit 变回来,但能避免长度为 K 的点积继续在舍入时掉精度。
NVIDIA 公布的 ResNet、Transformer 等训练曲线表明 TF32 可以匹配其 FP32 训练结果,实际上这个都是经验性的端到端结论,并不是所有矩阵问题的数值保证。
有些数值比较敏感或者结构设计的不太好的模型用 TF32 训练就可能会出现不收敛或者 loss 爆炸的情况了,推理的时候也是。
例如病态线性方程、强 cancellation 的 reduction、几何谓词以及对小差值敏感的科学计算等等,仍可能显著放大 TF32 输入误差。点积的相对误差不仅由 $u$ 决定,还要乘上条件数
$$
\kappa_{dot}=\frac{\sum_i|a_i b_i|}{|\sum_i a_i b_i|}.
$$
分母因抵消而接近 0 时,任何“有多少有效位”的口号都不再可靠。
FP16 vs BF16:一个保尾数,一个保指数
再把 BF16 这个数据类型拿出来对比的时候,我都会想说 TF32 实际上应该叫 FP19 才合适啊 …… 或者 BF16 应该直接叫 XX32?BF16 的 exponent 直接沿用 FP32/TF32 的 8 bit,dynamic range 几乎相同,但 mantissa 只有 7 bit,单次舍入误差是 FP16/TF32 的 8 倍。FP16 则是 5 bit exponent、10 bit mantissa,dynamic range 小得多,但在 $[1,2)$ 内相邻数更密。
所以“BF16 比 FP16 精度高”并不准确。若只看 $[1,2)$ 中相邻数,FP16 更密;若看一个跨越几十个数量级的模型是否能表示,BF16 更稳。Google TPU 的 BF16 文档 也把这种设计概括为 FP32 的范围配上较低精度,并使用 FP32 累加。
还有一个对后文非常重要的事实:处在 FP16 normal 范围内的 BF16 normal 数,可以被 FP16 精确表示。 BF16 只有 8 个有效 bit,FP16 有 11 个;两者共同覆盖的 exponent 上,BF16 的 fraction bit 只是 FP16 fraction 的高 7 bit,后面补 0 即可。把 BF16 转成 FP16时真正丢数据的主要原因是范围溢出/下溢,而不是 mantissa 不够。
FP8、FP6、FP4:格式只是起点,scale 才是另一半
FP8 Formats for Deep Learning 提出的组合很有代表性:E4M3 多留一位 fraction,适合权重和 activation;E5M2 多留一位 exponent,适合动态范围更大的 gradient。论文在多种 CNN、RNN、Transformer 以及最大 175B 模型上匹配了 16-bit baseline,但依赖缩放、更宽累加和训练配方,不能理解成单个 E4M3 数有 16-bit 精度。
FP6/FP4 更依赖 block scaling。以 MXFP4 E2M1 为例,一个 block 内的 element 只有 0、0.5、1、1.5、2、3、4、6 及其负数;共享 scale 把这套小码本搬到目标数量级。此时误差来自三层:
$$
\text{总误差}=\text{scale 量化/选择误差}+\text{element 舍入误差}+\text{累加误差}.
$$
把 block 从 128 缩到 32,通常会减少 outlier 对同组其他值的压缩;但 scale 数量增多,带宽、layout 和 kernel 实现更复杂。训练还常用 stochastic rounding、higher-precision master weight、FP32 optimizer state,以及对 softmax、normalization、reduction 保留更高精度。这里不存在“全模型统一换一个 dtype”这么简单的开关。
接下来开始看拍脑袋问题的第三个:
由三次 TF32 逼近 FP32 引申出来的想法
CUTLASS 的官方 3×TF32 示例 使用两片 TF32 表示一个 FP32 输入:
$$
\begin{aligned}
a_h &= Q_{TF32}(a), & a_l &= Q_{TF32}(a-a_h),\\
b_h &= Q_{TF32}(b), & b_l &= Q_{TF32}(b-b_h).
\end{aligned}
$$
完整乘积为
$$
ab=a_hb_h+a_hb_l+a_lb_h+a_lb_l.
$$
所谓 3×TF32 就是用三次 Tensor Core MMA 计算前三项,丢掉 $a_lb_l$。若每片相对上一片小约 $2^{-p}$,TF32 的 $p=11$,则
$$
|a_l b_l|\sim 2^{-22}|a_hb_h|.
$$
这已接近 FP32 的 24-bit significand。与此同时,两个 11-bit significand 的精确乘积最多约 22 bit,放进 FP32 accumulator 足够容纳。它因此往往非常接近原生 FP32 GEMM。
但“恢复 FP32”最好理解为FP32-like accuracy:
- 丢掉的低低项可能达到 $2^{-22}$ 量级,比 FP32 的 $u=2^{-24}$ 大;
- 三个 partial GEMM 的累加顺序不同;
- Tensor Core 的 reduction tree、rounding/FTZ 规则未必与 CUDA Core FP32 FMA 相同;
- cancellation 会放大差异。
所以不能承诺与某个 FP32 GEMM bitwise identical。CUTLASS 实现甚至允许在 high/low 转换中选择 toward-zero 与截断策略,在速度和误差之间继续取舍。
如果换成 BF16 呢?
我们看到 BF16 跟 FP32/TF32 的 exponent 一样,dynamic range 也一样,如果我们把 FP32 拆成 BF16 片:
$$
\begin{aligned}
a_0 &= Q_{BF16}(a),\\
a_1 &= Q_{BF16}(a-a_0),\\
a_2 &= Q_{BF16}(a-a_0-a_1),
\end{aligned}
$$
$b$ 同理。
因为 BF16 的 $p=8$,若把 BF16 当作“尾数更差的 TF32”,三片 BF16 的乘积就可以覆盖 FP32 significand 的高、中、低 8 bit。乘积按量级分成五层:
| 层级 | partial products | 相对主项量级 |
|---|---|---|
| 0 | $a_0b_0$ | $1$ |
| 1 | $a_0b_1+a_1b_0$ | $2^{-8}$ |
| 2 | $a_0b_2+a_1b_1+a_2b_0$ | $2^{-16}$ |
| 3 | $a_1b_2+a_2b_1$ | $2^{-24}$ |
| 4 | $a_2b_2$ | $2^{-32}$ |
现在答案很清楚:
- 1×BF16:只算第 0 层,输入就是普通 BF16 精度。
- 3×BF16:算第 0、1 层,丢项从 $2^{-16}$ 开始;约等于 16 bit significand,明显优于 BF16,但不是 FP32。
- 6×BF16:再算第 2 层,丢项从 $2^{-24}$ 开始;通常达到 FP32-like accuracy。
- 9×BF16:五层全算,完整重建三片 BF16 乘积;输入分解本身可精确覆盖普通 FP32 significand,但最终 reduction 顺序仍可能与原生 FP32 不同。
查资料的时候我才发现这个想法其实早就有人研究过,并不是我第一个拍脑袋就想到了的(哼哼,这么简单的问题必然早就有人想过了):【Leveraging the bfloat16 Artificial Intelligence Datatype For Higher-Precision Computations】 对 BF16 分片点积给出了误差分析:三片、六个最显著乘积可获得接近 FP32 的 GEMM 精度,并预测在 BF16 吞吐为 FP32 的 8/16/32 倍时,计算部分上限分别约为 1.3/2.7/5.2 倍。当前 cuBLAS 文档中的 BF16x9 选择完整九项,并明确指出只有 BF16 峰值吞吐超过 FP32 的九倍才可能有性能优势,还需要硬件高效处理额外 scale。
一个细节是,三片 BF16 对非常接近 FP32 underflow 边界的值未必能完整拆开:第二、第三个 residual 可能进入 BF16 subnormal 甚至变成 0。论文指出 exponent 不小于约 $-110$ 时三片更安全。对普通模型值问题不大,但做“全 FP32 域”的数值库时必须处理极小值、Inf 与 NaN。
一个可复现的标量实验
先故意避开 GEMM 的 reduction tree,只验证“乘法展开本身能恢复多少”。生成 200 万对 exponent 在 $[-60,60]$ 均匀分布的 FP32 normal number,以 FP64 乘积为参考。量化使用 round-to-nearest-even,partial product 与归并使用 FP32;9×BF16 从小项向大项相加。
下面是一次固定 seed 的结果。三列依次是非零相对误差的 median、P99 和 max:
| 方法 | 低精度乘法数 | median | P99 | max |
|---|---|---|---|---|
| FP32 multiply | 1 | $2.07\times10^{-8}$ | $5.29\times10^{-8}$ | $5.96\times10^{-8}$ |
| 1×TF32 | 1 | $2.08\times10^{-4}$ | $6.84\times10^{-4}$ | $9.55\times10^{-4}$ |
| 3×TF32 | 3 | $4.73\times10^{-8}$ | $2.49\times10^{-7}$ | $5.18\times10^{-7}$ |
| 1×BF16 | 1 | $1.67\times10^{-3}$ | $5.47\times10^{-3}$ | $7.57\times10^{-3}$ |
| 3×BF16 | 3 | $2.48\times10^{-6}$ | $1.35\times10^{-5}$ | $2.77\times10^{-5}$ |
| 6×BF16 | 6 | $3.38\times10^{-8}$ | $1.44\times10^{-7}$ | $2.92\times10^{-7}$ |
| 9×BF16 | 9 | $2.07\times10^{-8}$ | $5.29\times10^{-8}$ | $5.99\times10^{-8}$ |
这个实验支持位级推导:3×BF16 相对误差落在 $10^{-5}$ 左右,6×BF16 进入 $10^{-7}$,9×BF16 几乎贴住单次 FP32 舍入。3×TF32 也接近 FP32,但尾部误差并不等同于 FP32。
核心仿真代码如下,直接用 uint32 mask 模拟任意 $p$ 位 significand:
1 | import numpy as np |
注意这段 bit trick 只用于本实验选择的 finite normal 区间;若做通用转换器,必须单独处理 carry 到 exponent、subnormal、Inf、NaN、signed zero 和目标格式是否 finite-only。
还需要三组 GEMM 实验
标量实验只证明了截断项量级,不能替代矩阵实验。建议把 benchmark 做成下面三个正交维度。
实验 A:正常分布与 K scaling
取 $M=N=1024$,$K\in\{64,256,1024,4096,16384\}$,输入分别用 uniform $[-1,1]$ 和 normal $N(0,1)$。以 FP64 GEMM 为 reference,同时报告:
$$
E_F=\frac{\lVert C-C_{64}\rVert_F}{\lVert C_{64}\rVert_F},\qquad
E_{max}=\frac{\lVert C-C_{64}\rVert_\infty}{\lVert C_{64}\rVert_\infty},
$$
以及 ULP distance、吞吐、workspace 和转换时间。比较 FP32 FMA、1×TF32、3×TF32、1/3/6/9×BF16。随着 K 增大,可以看到输入量化误差和 FP32 accumulation error 如何叠加。
实验 B:相同 mantissa,不同 exponent 分布
分别生成:窄范围 $[-1,1]$、exponent 高斯分布、exponent 在 $[-110,110]$ 均匀分布、以及靠近 underflow/overflow 的边界数据。这对应 BF16 分片论文的实验设计,能暴露“平均随机输入很好,但全 FP32 动态范围并不安全”的区别。
实验 C:可控 cancellation / 条件数
构造 $a^Tb$ 的正负项几乎抵消,逐步提高 $\kappa_{dot}$;矩阵版本可用给定奇异值谱生成 condition number 从 $10^0$ 到 $10^8$ 的矩阵。图的横轴画 $\kappa$,纵轴画 forward error。若只测随机高斯矩阵,3×TF32 与 6×BF16 很可能显得“永远等同 FP32”,这组实验负责打破这个错觉。
GPU benchmark 还必须固定并记录:硬件、CUDA/cuBLAS/CUTLASS 版本、是否允许 TF32、accumulator 类型、split 是否计时、warm-up、clock、矩阵 layout、稀疏模式,以及特殊值/denormal 处理。只报库 API 的 dtype 不够,因为库可能静默选择不同 kernel。
最终的性能门槛结论:不能只用“低精度 TOPS”做除法
设原生 FP32 GEMM 的有效吞吐是 $P_{32}$,低精度 Tensor Core 的有效吞吐是 $P_L$,需要 $m$ 个 partial GEMM,额外分片、缩放和归并时间为 $T_o$。同一个 GEMM 的工作量记为 $W$:
$$
T_{emu}=\frac{mW}{P_L}+T_o,\qquad T_{32}=\frac{W}{P_{32}}.
$$
要有收益,必须满足
$$
\frac{P_L}{P_{32}}>\frac{m}{1-T_o/T_{32}}.
$$
当 $T_o=0$,门槛才退化为简单的 $P_L/P_{32}>m$。因此:
| 方案 | 目标精度 | 理想计算门槛 | 工程判断 |
|---|---|---|---|
| 3×TF32 | FP32-like | TF32 $>3\times$ FP32 | 大 GEMM 可行,小 GEMM 易被转换/launch 吃掉 |
| 3×BF16 | 约 16-bit significand | BF16 $>3\times$ FP32 | 只在应用接受非 FP32 精度时成立 |
| 6×BF16 | FP32-like | BF16 $>6\times$ FP32 | 精度/速度较合理的研究点 |
| 9×BF16 | 完整三片展开 | BF16 $>9\times$ FP32 | 还依赖高效 scaling 与融合支持 |
例如额外开销已经占原生 FP32 时间的 10%,6×BF16 的门槛就从 6 倍升到 $6/0.9=6.67$ 倍。若分片结果要写回显存,三片 A 与三片 B 还会显著增加 memory traffic;只有把 split 融进 GEMM prologue、把 partial accumulation 留在寄存器,并复用预分片的静态权重,才可能接近峰值模型。
这里也解释了为什么厂商文档说“峰值超过九倍”仍只是必要条件:shape、occupancy、指令发射比例、scale 指令、寄存器压力和 epilogue 都会让有效比值低于规格表。
下一个拍脑袋问题:
如果硬件只支持 FP16 Tensor Core,怎么才能跑 BF16 模型呢
目标语义假定为 BF16 输入乘法、FP32 accumulation;softmax、normalization、optimizer 等敏感算子另行保留 FP32。硬件可以高速做 FP16×FP16→FP32 MMA,但不能做 BF16 MMA。
错误的第一反应:直接 cast 到 FP16
BF16 的 normal exponent 约覆盖 $[-126,127]$,FP16 只覆盖 $[-14,15]$。直接 cast 有三种结果:
- 共同 normal 范围内:BF16 → FP16 是精确的;
- 大于 65504:变成 Inf 或饱和;
- 小于 FP16 可表示范围:进入 subnormal、被 FTZ,或变成 0。
因此精度风险集中在 range。把 BF16 当作“尾数更差的 FP16”来模拟,会错过最关键的问题。
快路径:二次幂缩放后精确装入 FP16
对 GEMM $C=AB$,选整数 $s_A,s_B$:
$$
\widetilde A=2^{-s_A}A,\qquad \widetilde B=2^{-s_B}B.
$$
只要 $\widetilde A,\widetilde B$ 都落在 FP16 normal 范围,转换不损失任何 BF16 significand。然后 Tensor Core 计算
$$
\widetilde C=\widetilde A\widetilde B,\qquad C=2^{s_A+s_B}\widetilde C.
$$
scale 是二的整数次幂,所以缩放本身只改 exponent,在不溢出/下溢时没有舍入误差。这条路径能复现 BF16 输入乘积的有效位,并保留 FP32 accumulator;最终再按模型要求 round 回 BF16。
最粗的一种实现是每个 tensor 一个 scale。它只要一次 max-exponent reduction,权重 scale 还能离线预计算。缺点是一个 outlier 会决定全 tensor scale,小值可能掉出 FP16 范围。
更好的单次 GEMM:A 按行、B 按列缩放
对 $A\in\mathbb{R}^{M\times K}$ 每行一个二次幂 scale,对 $B\in\mathbb{R}^{K\times N}$ 每列一个:
$$
\widetilde A=D_A^{-1}A,\qquad \widetilde B=BD_B^{-1},\qquad
C=D_A(\widetilde A\widetilde B)D_B.
$$
这样所有 K 项仍能进入同一次 FP16 Tensor Core GEMM,输出元素 $(i,j)$ 在 epilogue 乘 $2^{s_{A,i}+s_{B,j}}$。scale 只需存 exponent,权重列 scale 与 FP16 packed weight 可离线缓存。它比 per-tensor 更能适应通道间量级差异,又没有拆成多个 GEMM。
若要求所有值都保持 FP16 normal,一个 row/column 内可同时容纳的 exponent span 大约只有 30 bit。允许 FP16 subnormal 可扩大到约 40 bit,但很多硬件路径会 FTZ,性能和语义也可能不同,所以高性能实现应优先把有效值放进 normal range。
如果行/列内部跨度仍太大:则可以沿 K 分块
当一个 row 或 column 内同时存在 $10^{20}$ 和 $10^{-20}$,一个 scale 无法两头兼顾。把 reduction 维 K 切成 block:
$$
C=\sum_q A_qB_q
=\sum_q D_{A,q}\left(\widetilde A_q\widetilde B_q\right)D_{B,q}.
$$
每个 K-block 单独求 row/column scale,FP16 MMA 得到 FP32 partial C,立刻按二次幂 rescale 后累加到 FP32 C。block 越小,越容易无损覆盖 BF16 range;代价是更多 scale、更多 partial accumulator,以及较差的数据复用。工程上可以先尝试 64/128 个 K 元素一组,再根据 exponent span 自适应合并。
这本质上是 block floating point:FP16 负责 significand,额外的共享 exponent 补回 BF16 的动态范围。
outlier 不要拖累整个 tile:主路径加稀疏修正
如果 99.9% 的值落在窄范围,少数 outlier 让 block 不可缩放,可以拆成
$$
A=A_m+\Delta A,\qquad B=B_m+\Delta B,
$$
主项 $A_mB_m$ 走 scaled-FP16 Tensor Core;含 $\Delta$ 的交叉项走稀疏 FP32 kernel 或更小的独立 scale group:
$$
AB=A_mB_m+\Delta A B_m+A_m\Delta B+\Delta A\Delta B.
$$
若目标是数值等价,修正项不能直接丢;若目标只是保持模型 quality,则可以通过 calibration 决定 clip threshold,把它变成可控近似。两种目标必须在接口上分开,不能把“测试集没掉点”写成 bit-exact emulation。
一条实际可落地的 dispatch 流程
- 从 BF16 bit pattern 提取 exponent,处理 zero、subnormal、Inf、NaN;特殊值存在时走显式传播或 FP32 fallback。
- 静态权重离线转换成
FP16 payload + int8 power-of-two scale,优先 per-column;缓存转换结果,不在每次 inference 重做。 - activation 在 producer epilogue 中顺便统计 per-row max/min exponent,并融合 BF16→scaled-FP16 packing,避免额外读写。
- 若 row/column exponent span 落入 FP16 normal 窗口,走一次 MMA 快路径。
- 否则沿 K 自适应分块;极少量 outlier 走 sparse FP32 correction。
- FP16 Tensor Core 始终使用 FP32 accumulator;每个 partial 在 FP32 中 rescale、归并,最后一次才 round 到 BF16 输出。
- layernorm、softmax、variance、长 reduction、optimizer state 保留 FP32,除非单独做过误差验证。
实现时还要核对目标 ISA 对 FP16 subnormal、乘法、accumulator 和 FTZ 的精确定义。库 API 写着 FP32 accumulation,不代表所有中间处理、特殊值和 reduction order 都与某台原生 BF16 硬件逐位一致。
怎么验证这个 BF16-on-FP16 方案
不要只跑模型 accuracy,至少做四层验证:
- 逐元素转换:穷举全部 65536 个 BF16 bit pattern。对 finite value 检查 scale→FP16→FP32→inverse-scale 是否还原;zero、Inf、NaN 单独分类。
- GEMM 数值:以软件 BF16 解码 + FP64/FP32 reference 为准,比较 direct cast、per-tensor、row/column、K-block、outlier correction;报告 Frobenius、max、ULP 与 overflow/underflow 计数。
- 对抗分布:固定 mantissa,扫描 BF16 全 exponent;构造 29/30/31/40 bit exponent span 的 tile,精确找到快路径失效边界;再加 cancellation 和大 condition number。
- 性能拆账:分别测 exponent reduction、pack、MMA、rescale epilogue、fallback。报告 end-to-end latency,而不是只报 Tensor Core kernel。
正确性可以分三级:
- Level 0:模型质量等价,任务指标落在事先定义的容差内;
- Level 1:数值近似原生 BF16,每层误差与参考 BF16 kernel 接近;
- Level 2:bitwise 等价,需要固定 accumulation/reduction 顺序、rounding、FTZ 和特殊值语义。
scaled-FP16 快路径可以精确保住输入 BF16 值,却不自动保证 Level 2,因为原生 BF16 Tensor Core 的内部 reduction tree 可能不同。绝大多数 inference 部署追求 Level 0/1;数值库和回归测试才常要求 Level 2。
最后的判断框架
看到一种新 dtype 时,可以按这个顺序问:
- E 和 M 各多少?有没有 hidden bit、subnormal、Inf/NaN?
- 它是 storage type、multiply input type,还是 accumulator type?
- 是否附带 shared scale,scale 的粒度与格式是什么?
- 舍入是 RNE、toward-zero 还是 stochastic rounding?是否 FTZ?
- 宣称的“精度相同”指单次误差、GEMM norm,还是模型指标?
- 宣称的“速度”是否包含 conversion、scale、workspace 与 fallback?
低精度计算真正的主线不是“bit 越少越快”,而是把 exponent、significand、scale 与 accumulator 分工:需要范围的地方保 exponent,需要局部精度的地方保 significand,需要长 reduction 的地方用宽 accumulator。3×TF32、6×BF16、BF16-on-FP16 block scaling,看起来是三种技巧,底层其实都是同一件事——用多个便宜的低精度表示和乘法,重新拼出昂贵的高精度语义;能不能赢,最终取决于误差项的阶数是否够低,以及硬件吞吐能否覆盖额外的乘法和数据搬运。
参考资料
- IEEE Standard for Floating-Point Arithmetic (IEEE 754-2019)
- NVIDIA: Accelerating AI Training with TF32 Tensor Cores
- NVIDIA CUTLASS: 3×TF32 fast accurate TensorOp GEMM
- Greg Henry et al.: Leveraging the BF16 Datatype for Higher-Precision Computations
- NVIDIA cuBLAS: Floating Point Emulation / BF16x9
- FP8 Formats for Deep Learning
- NVIDIA PTX ISA: Alternate Floating-Point Data Formats
- OCP Microscaling Formats (MX) Specification
- Google Cloud TPU: bfloat16 floating-point format