0%

AI 浮点数全景:从 FP32、TF32、FP16、BF16 到 FP8、FP6 与 FP4

在上一篇中,我们过了一遍(非常非常基础+终极简化版本)的浮点数计算是怎么在硬件上实际进行的。结尾部分提到,随着 AI 的兴起,硬件的浮点数数据类型已经从科学计算中常用的 double(FP64)和最常规的数据类型 FP32 开始一路下探到了 FP4。本文尝试把这些数据类型的位级表示、单次运算误差、点积误差和端到端模型精度四个层次拆开,给出一个全景式的理解。

这里先抛出来几个我拍脑袋想到的问题来做一下探讨:

  1. TF32 是怎么来的,它是不是一种真正的 32 位存储格式?
  2. FP16 和 BF16 都是 16 位,谁更“准”?
  3. 我们用 TF32 来做矩阵乘法的时候会有一种工程上的 trick:单次 TF32 乘法的精度在敏感情况下会掉的比较厉害,可以通过三次 TF32 乘法来逼近一次 FP32 乘法。那如果把 TF32 换成更快的 BF16,能做出来类似的精度逼近方案吗?
  4. 在一块只有 FP16 Tensor Core、没有 BF16 Tensor Core的硬件上,怎样尽量准确而高效地模拟 BF16 模型?

先统一一下“精度”的准确定义

先放一下几个目前在 AI 领域中用到的数据类型的二进制表示:

AI 常用浮点数据类型的位域及 E、M 浮点数计算公式

一个二进制浮点数通常写成

$$
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$ 表示最大舍入误差。

实际到真正工程落地的时候还要再区分四件事:

  1. 表示精度:把一个实数存入某格式会损失多少。
  2. 乘法输入精度:Vector Core 或者 Tensor Core 真正拿多少 bit 的 significand 相乘。
  3. 累加精度:相乘得到的乘积又是累加到 FP16、FP32?还是更宽的哪个 accumulator 类型上。
  4. 模型精度:最终 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”

  1. 它跟 FP32 有一样的 8-bit exponent,所以动态范围和 FP32 是一样的;
  2. 但是 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import numpy as np

def qbits(x, p):
"""finite normal FP32 -> p significant bits, round-to-nearest-even"""
x = np.asarray(x, np.float32)
u = x.view(np.uint32).copy()
drop = 24 - p
lsb = (u >> drop) & 1
u += ((1 << (drop - 1)) - 1) + lsb
u &= np.uint32(~((1 << drop) - 1) & 0xffffffff)
return u.view(np.float32)

def split(x, p, count):
r, parts = np.asarray(x, np.float32), []
for _ in range(count):
hi = qbits(r, p)
parts.append(hi)
r = np.float32(r - hi)
return parts

def triangular_product(a, b, p, levels):
"""levels=1/2/3 -> 1/3/6 partial products"""
aa, bb = split(a, p, levels), split(b, p, levels)
z = np.zeros_like(a, dtype=np.float32)
for level in range(levels):
for i in range(level + 1):
z = np.float32(z + np.float32(aa[i] * bb[level-i]))
return z

def full_9xbf16(a, b):
aa, bb = split(a, 8, 3), split(b, 8, 3)
z = np.zeros_like(a, dtype=np.float32)
# 从第 4 层到第 0 层,小项优先归并
for level in range(4, -1, -1):
for i in range(3):
j = level - i
if 0 <= j < 3:
z = np.float32(z + np.float32(aa[i] * bb[j]))
return z

def metrics(got, ref):
rel = np.abs(got.astype(np.float64) - ref) / np.abs(ref)
rel = rel[np.isfinite(rel) & (rel > 0)]
return np.median(rel), np.quantile(rel, .99), np.max(rel)

rng = np.random.default_rng(20260726)
n = 2_000_000
a = (rng.choice(np.float32([-1, 1]), n) *
np.exp2(rng.uniform(-60, 60, n))).astype(np.float32)
b = (rng.choice(np.float32([-1, 1]), n) *
np.exp2(rng.uniform(-60, 60, n))).astype(np.float32)
ref = a.astype(np.float64) * b.astype(np.float64)

cases = [
("FP32", np.float32(a * b)),
("1xTF32", triangular_product(a, b, 11, 1)),
("3xTF32", triangular_product(a, b, 11, 2)),
("1xBF16", triangular_product(a, b, 8, 1)),
("3xBF16", triangular_product(a, b, 8, 2)),
("6xBF16", triangular_product(a, b, 8, 3)),
("9xBF16", full_9xbf16(a, b)),
]
for name, got in cases:
print(name, *(f"{v:.4e}" for v in metrics(got, ref)))

注意这段 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 流程

  1. 从 BF16 bit pattern 提取 exponent,处理 zero、subnormal、Inf、NaN;特殊值存在时走显式传播或 FP32 fallback。
  2. 静态权重离线转换成 FP16 payload + int8 power-of-two scale,优先 per-column;缓存转换结果,不在每次 inference 重做。
  3. activation 在 producer epilogue 中顺便统计 per-row max/min exponent,并融合 BF16→scaled-FP16 packing,避免额外读写。
  4. 若 row/column exponent span 落入 FP16 normal 窗口,走一次 MMA 快路径。
  5. 否则沿 K 自适应分块;极少量 outlier 走 sparse FP32 correction。
  6. FP16 Tensor Core 始终使用 FP32 accumulator;每个 partial 在 FP32 中 rescale、归并,最后一次才 round 到 BF16 输出。
  7. layernorm、softmax、variance、长 reduction、optimizer state 保留 FP32,除非单独做过误差验证。

实现时还要核对目标 ISA 对 FP16 subnormal、乘法、accumulator 和 FTZ 的精确定义。库 API 写着 FP32 accumulation,不代表所有中间处理、特殊值和 reduction order 都与某台原生 BF16 硬件逐位一致。

怎么验证这个 BF16-on-FP16 方案

不要只跑模型 accuracy,至少做四层验证:

  1. 逐元素转换:穷举全部 65536 个 BF16 bit pattern。对 finite value 检查 scale→FP16→FP32→inverse-scale 是否还原;zero、Inf、NaN 单独分类。
  2. GEMM 数值:以软件 BF16 解码 + FP64/FP32 reference 为准,比较 direct cast、per-tensor、row/column、K-block、outlier correction;报告 Frobenius、max、ULP 与 overflow/underflow 计数。
  3. 对抗分布:固定 mantissa,扫描 BF16 全 exponent;构造 29/30/31/40 bit exponent span 的 tile,精确找到快路径失效边界;再加 cancellation 和大 condition number。
  4. 性能拆账:分别测 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 时,可以按这个顺序问:

  1. E 和 M 各多少?有没有 hidden bit、subnormal、Inf/NaN?
  2. 它是 storage type、multiply input type,还是 accumulator type?
  3. 是否附带 shared scale,scale 的粒度与格式是什么?
  4. 舍入是 RNE、toward-zero 还是 stochastic rounding?是否 FTZ?
  5. 宣称的“精度相同”指单次误差、GEMM norm,还是模型指标?
  6. 宣称的“速度”是否包含 conversion、scale、workspace 与 fallback?

低精度计算真正的主线不是“bit 越少越快”,而是把 exponent、significand、scale 与 accumulator 分工:需要范围的地方保 exponent,需要局部精度的地方保 significand,需要长 reduction 的地方用宽 accumulator。3×TF32、6×BF16、BF16-on-FP16 block scaling,看起来是三种技巧,底层其实都是同一件事——用多个便宜的低精度表示和乘法,重新拼出昂贵的高精度语义;能不能赢,最终取决于误差项的阶数是否够低,以及硬件吞吐能否覆盖额外的乘法和数据搬运。

参考资料