浅谈BF16/FP16/FP32三种浮点格式的数据表示与应用

一、BF16 的出现:精度与范围的博弈

1.1 深度学习训练中的"不可能三角"

2017-2018 年间,深度学习训练面临一个矛盾:

  • FP32 训练:精度足够,但显存占用大、带宽需求高,一张 V100 跑不了太大的模型
  • FP16 训练 :省显存、算得快,但范围太小------FP16 的最大值只有 65504,梯度稍微大一点就溢出到无穷大
  • 混合精度训练(FP32 主权重 + FP16 计算):能缓解精度问题,但 FP16 的范围瓶颈始终存在

问题的根源在于:FP16 为了在 16 位里塞进更多的精度(10 bit 尾数),压缩了指数位(只有 5 bit),导致量程只有 FP32 的约 1/65536。

1.2 Google Brain 的回答:BF16

2018 年,Google Brain 团队为 TPU v2 设计了一种新的 16 位浮点格式------BF16(Brain Floating Point 16)

设计思路非常直接:既然问题出在指数位不够,那把 FP32 的指数位保留,只砍尾数位

  • 保留 FP32 的 8 bit 指数 → 量程 = FP32 的量程(~3.4e38),不会溢出
  • 尾数从 23 bit 砍到 7 bit → 精度下降,但换来和 FP32 一样的动态范围

BF16 的定位很清晰:它不是 FP16 的替代品,而是"在 16 位宽度下,保留 FP32 量程"的折中方案。精度可以差一点,但不能爆炸。

1.3 BF16 的训练模式

BF16 训练的标准做法是"主权重 FP32 + 前向/反向 BF16":

  • 开始前,把 FP32 主权重临时转换为 BF16,喂给矩阵乘
  • 计算完成后,把梯度转回 FP32,累加到主权重
  • 主权重始终保持 FP32 精度,不会累积误差
  • 计算路径全部走 BF16,速度比 FP32 快数倍

今天的主流 GPU(A100 / H100 / B200)和 NPU(昇腾 910B)都原生支持 BF16。在大模型推理中,vLLM、TGI、TensorRT-LLM 等框架的默认推理精度也普遍是 BF16 而非 FP16------原因很简单:attention score 的计算中,softmax 之前的最大值可能很大,FP16 容易溢出,BF16 则不会。


二、三种浮点格式的数据表示

2.1 位分配全景

FP32、FP16、BF16 都源于 IEEE 754 标准,但它们的位预算分配完全不同。用一张表来对比:

字段 FP32 FP16 BF16
总位数 32 16 16
符号位 1 1 1
指数位 8 5 8
尾数位 23 10 7
最大值 ~3.4e38 ~65504 ~3.4e38
最小正 normal ~1.18e-38 ~6.1e-5 ~1.18e-38

从这张表可以读出三个核心信息:

第一,FP16 和 BF16 都是 16 位,但设计哲学完全相反。 FP16 牺牲范围换精度(10 bit 尾数),BF16 牺牲精度换范围(8 bit 指数)。同一个位预算,不同的取舍。

第二,BF16 的量程 ≈ FP32 的量程。 因为指数位同样是 8 位,BF16 能表示的最大值和 FP32 相同。这意味着 BF16 不会像 FP16 那样因为范围不够而溢出------这是它成为训练/推理默认精度的根本原因。

第三,BF16 的精度只有 FP32 的约 1/16。 尾数从 23 bit 砍到 7 bit,丢失了 16 倍的精度信息。对于大多数深度学习任务来说,这个精度损失是可以接受的------模型的随机初始化本身就有更大的不确定性。

2.2 三个字段的通俗解释

浮点数的三个字段各自回答一个问题:

符号位(1 bit)------最简单的字段。0 表示正数,1 表示负数。

指数位------决定这把尺子的"单位"。指数位的 bias 编码使得硬件可以直接把整个浮点数当作整数来比较大小,不需要拆开字段。指数位越宽,尺子能覆盖的范围越大。FP32 和 BF16 都是 8 bit 指数,所以量程相同;FP16 只有 5 bit 指数,所以量程窄得多。

尾数位------决定尺子的"最小刻度"。尾数位越多,相邻两个可表示的数之间越密、精度越高。FP32 的 23 bit 尾数可以区分大约 7 位有效十进制数字,BF16 的 7 bit 尾数只能区分大约 2 位。

这三个字段合在一起,决定了每种浮点格式的"精度"和"范围"。一个通俗的理解:这就像一把能自动切换单位的卷尺------指数位决定尺子的单位是毫米、厘米还是米,尾数位决定尺子上的最小刻度。FP32 是刻度细到 0.1 mm 但全长能到上千公里的尺子;BF16 是刻度只能到 8 mm 但全长也能到上千公里的尺子------粗是粗了点,但至少不会"爆表";FP16 是刻度很细但全长只有 65 米的尺子------精度不错,但稍微走远一点就测不了。

2.3 ulp

ulp(unit in the last place)------相邻两个浮点数之间的差距。在数值 1.0 附近,FP32 的 ulp 约 1.19e-7,FP16 约 9.77e-4,BF16 约 7.81e-3。注意 ulp 不是常数------数值越大,ulp 也越大。


三、FP32 如何转换为 FP16 和 BF16

理解了三种格式的位布局后,很自然会问:FP32 怎么转成 FP16 和 BF16?

答案是:两种转换走的是完全不同的路径

3.1 FP32 → FP16:带舍入的截断

FP32 转 FP16 不是简单地砍掉多余的 bit。IEEE 754 规定了一个标准方法:round-to-nearest-even(RNE)

可以这样理解:FP32 的 23 位尾数要缩减到 FP16 的 10 位。被砍掉的那 13 位里包含了"丢掉的部分还有多少"的信息。RNE 的策略是:

  • 丢掉的部分不到半个 ulp → 直接截断
  • 丢掉的部分超过半个 ulp → 向上进位
  • 恰好半个 ulp → 按"取偶"原则:看保留的最后一位,如果是奇数就 +1 变成偶数,如果是偶数就保持

RNE 保证转换误差被严格限制在 ±½ 个 ulp 以内。这个"取偶"规则是为了避免统计偏差------如果总是向上进位,系统误差会累积。

3.2 FP32 → BF16:纯截断,没有任何舍入

与 FP16 的 RNE 不同,BF16 的转换极其简单 ------就是直接砍掉低 16 位

复制代码
FP32(32 bit):s eeeeeeee mmmmmmmmmmmmmmmmmmmmmmm
                         ↓ 砍掉低 16 位
BF16(16 bit):s eeeeeeee mmmmmmm

没有 guard bit、没有 sticky bit、没有 round-to-even。就是纯截断

以一个具体的数为例:1.234567 从 FP32 转到 FP16 和 BF16,两者在数值上恰好都是 1.234375,误差 0.000192。但原因完全不同:

  • FP16:经过了完整的 RNE 流程,"碰巧"落到了这个值
  • BF16:直接截断,FP32 低 16 位里恰好没有超过半数的有效信息

3.3 两种转换的关键差异

把两种转换放在一起对比:

维度 FP32 → FP16 FP32 → BF16
保留位数 10 bit 尾数 7 bit 尾数
操作 截断 + RNE 舍入 纯截断
误差范围 ±½ ulp(有界) 0 到 1 ulp(无偏,但范围更大)
硬件复杂度 高(需要额外的舍入逻辑) 极低(只需截断)

FP16 转换的特点是"误差有界"------RNE 保证误差不超过 ±½ ulp。BF16 转换的特点是"可能无误差也可能有误差"------如果低 16 位恰好都是 0,转换是无损的;否则就是 0 到 1 ulp 之间的某个偏移。


四、实际场景:以 Qbmm 算子为例看精度转换

4.1 Qbmm 是什么

Qbmm 是 Quantized Batch MatMul 的缩写------量化批矩阵乘。输入是 INT8 的矩阵乘,输出可以是 FP32、FP16 或 BF16。应用场景是量化推理:把权重从 FP32 量化到 INT8,省显存、提速度,但累加结果需要反量化回浮点格式,可能经过激活函数(如 Gelu),最后以目标格式输出。

4.2 当前 kernel 的数据流与代码选择

一个 INT8 × INT8 的 Qbmm 算子,输出可以是 FP16(B1 分支)或 BF16(B4 分支)。两条分支走的是同一套代码------从矩阵乘到 Gelu,前几步完全共享,仅当最终输出格式不同时才分叉。

共享路径:

  1. INT8 × INT8 矩阵乘------Cube 单元执行,中间结果在 INT32 累加器中累积
  2. Fixpipe:INT32 → FP16 ------当前代码将 MatmulType 的 C-type 配置为 half,fixpipe 按此格式输出
  3. Cast:FP16 → FP32------为后续浮点计算做准备
  4. FP32 反量化------乘以 x1Scale 和 x2Scale
  5. FP32 Gelu------非线性激活在 FP32 精度下计算

Gelu 后的分叉:

B1 分支(FP16 输出)

  1. Cast:FP32 → FP16(RNE 舍入)------← 这就是输出本身
  2. 写出到 GM(FP16)

B4 分支(BF16 输出)

  1. Cast:FP32 → FP16(RNE 舍入)------← 复用 FP16 路径
  2. Cast:FP16 → FP32(zero-extend)
  3. 取高 16 位 → BF16

关键说明:B4 的 FP16 步骤不是为 BF16 特加的。

  1. 它是 B1 路径的自然终点------Gelu 后统一 Cast 回 FP16,FP16 分支直接写出,BF16 分支在此基础上做 half → float32 → 取高 16 位 的额外转换。

  2. B4分支的当前代码选 half 作为 Cube fixpipe 输出类型和 GM buffer 类型,是实现选择,不是硬件限制 ,是 Claude Code 生成该算子时为了复用 B1 代码所做的选择,不是最优,有更优的路径是从Gelu后FP32直接转换BF16。

而当前讨论的是在这种情况下,Golden应该怎么写。

Golden 的模拟路径(与 kernel 对齐):

  1. FP32 矩阵乘(跳过 fixpipe 的 FP16 中间步骤)
  2. FP32 反量化
  3. FP32 Gelu
  4. FP32 → FP16(RNE 舍入)------必须模拟的精度截断点
  5. FP16 → FP32(zero-extend)
  6. 取高 16 位 → BF16

4.3 为什么 Golden 必须对齐这条「非最优」路径

既然当前路径不是最优,Golden 为什么还要跟着绕?

理论原因:从 FP32 出发,两条路径不等价。

路径 A(kernel 实际走的):FP32 → FP16(RNE) → FP32 → BF16

路径 B(直觉写法):FP32 → BF16

差异来自两点:

第一,RNE 不是可结合的。 FP32→FP16 的 RNE 在 bit 13 处做舍入,FP32→BF16 的截断在 bit 16 处。先 round 到 bit 13 再截到 bit 16,与直接截到 bit 16,在某些 mantissa 模式下可能产生不同结果(carry propagation、取舍方向改变)。

第二,FP16 和 BF16 的指数范围不同。 FP16 最大 ~65504,最小正规数 ~6.1e-5;BF16 与 FP32 共享 ~3.4e38 / ~1.18e-38 的量程。如果 Gelu 输出值接近 FP16 的边缘区间,先转 FP16 会改变数值甚至压成 0 或 inf,而直接转 BF16 仍保持合理值。

实际后果:Golden与Kernel实现的 0.19 偏差。

一个 INT8×INT8→BF16 的 Qbmm 算子验证时出现了 8/16384 个不匹配,最大偏差 0.19(约 24 个 ulp)。分析后发现 Golden 走了路径 B(直接从 FP32 取高 16 位),而 kernel 实际走的是路径 A(中间有 FP16 RNE 截断)。两条路径各走各的,结果自然对不上。修复 Golden 使其严格走路径 A 后,偏差消失。

这个案例说明:不是 BF16 精度差,也不是 kernel 算错------是 Golden 没有匹配 kernel 的真实数据流。偏差是确定性的、可重复的,它根源于浮点计算的一个根本特征------详见第五节。

(完整回放见 §5.4)

4.4 本节小结

  • 当前 B4 路径是代码复用选择,不是硬件限制
  • 只要 kernel 实际走了这条路,Golden 就必须跟,案例中的 0.19 偏差就是"没跟"的代价

五、Golden 设计的哲学

5.1 浮点计算没有"客观正确答案"

上一节引出的问题------"Golden 到底应该是什么"------需要先接受一个反直觉的事实:在浮点计算中,不存在"客观正确答案"

每次浮点运算都伴随着舍入:

  • 0.1 + 0.2 的"数学真值"是 0.3,但浮点结果是 0.30000000000000004
  • 1e10 + 1.0 的"数学真值"是 10000000001,但浮点结果是 10000000000.0------1.0 被"吞掉"了,因为在那个量级上 ulp 已经等于 8

这意味着,所谓的 Golden,并不是某个绝对的真值。它的精确定义是:

在指定数值模型下,模拟目标运算过程的参考输出。

它不是"绝对真值",而是"在同等精度条件下的参考答案"。

回到 Qbmm 的例子:Kernel 在 Gelu 之后经过了一次 FP16 的 RNE 截断才得到 BF16。如果你写 Golden 时跳过这次截断,直接从 FP32 提取 BF16,你的 Golden 代表的就是另一条数据流的输出------不是 Kernel 的数据流。这种情况下你验证的不是"Kernel 算得对不对",而是"你的 Golden 和 Kernel 之间的路径差异有多大"。

5.2 Golden 必须模拟真实数据流

这个偏差不是"噪声"或"随机误差"------它是一个确定的、可重复的差异。因为每次 FP32 → FP16 的 RNE 舍入都是一个确定性的数学操作,输入相同,输出就相同。Golden 跳过它,就等于用了另一条数据流。

所以 Golden 设计的第一原则是:

Golden 必须按 Kernel 的真实数据流写,而不是按"看起来更直接"的数学路径写。

5.3 行业中的一致做法

这个原则并非 AscendC 独有,而是整个行业的共识:

  • CUDA 的 cuBLAS :提供 CUBLAS_COMPUTE_32F / CUBLAS_COMPUTE_16F 等不同的计算精度模式。用户选择的精度决定了 Golden 应该模拟的数值模型
  • PyTorchtorch.allclose 函数有默认的容限参数,文档明确说明"must match the dtype's precision"
  • TensorRT:量化工具的 calibration 输出明确要求"CPU golden must simulate quantization error"

这不是"屈服于硬件的怪规矩",而是对浮点计算本质的尊重。

5.4 完整回放:0.19 的误差是怎么来的

最后,用本节开头的案例做一个完整回放。

一个 INT8 × INT8 → BF16 的 Qbmm 算子,一次验证中在 16384 个输出元素里发现了 8 个不匹配 ,最大偏差 0.19

0.19 是什么概念?BF16 在数值 1.0 附近的 ulp 约 0.0078。0.19 是 24 个 ulp------远超正常精度偏差(通常几 ulp 以内就算正常)。出错的输出值在数值 1 附近,偏差达到 0.19 意味着 BF16 尾数的低几位完全对不上。

分析下来,问题的根源不在 BF16 精度,也不在 kernel 实现,而在于 Golden 的写法。具体来说,Golden 犯了两个错误:

错误一:FP16 截断的顺序错了。 Golden 在 Gelu 计算之前 就做了 FP16 截断,而 kernel 是在 Gelu 之后才做 FP16 截断。这导致 Gelu 收到的输入值不同------先截断再 Gelu 和先 Gelu 再截断,结果天然不同。

错误二:跳过了 Gelu 之后的那次 FP16 截断。 Golden 从 FP32 直接提取 BF16 位,跳过了 kernel 路径中的 FP32 → FP16 → FP32 → 提取 BF16 这个关键步骤,导致最终结果包含了本应被 FP16 RNE 舍入丢弃的精度位。

两个错误叠加,在 Gelu 输出的典型范围(0~1)内,某些敏感位置的误差被放大到 BF16 ulp 的 24 倍。

修复后的 Golden 严格按 kernel 路径模拟------Gelu 之后先 FP32 → FP16(RNE) → FP32 → 取高 16 位------mismatch 归零。

这不是 BF16 的"精度锅"。 BF16 本身在这个应用中是完全够用的。问题在于 Golden 没有忠实地模拟 kernel 的数据流------它省略了路径中的一次 FP16 截断,从而引入了一个系统性的偏差。同样的教训也适用于所有浮点验证场景。

5.5 一句话总结

浮点没有客观的真值。每种格式都有自己的精度边界,每个算子都有一条特定的数据流。Golden 的任务不是"算出正确答案",而是"在相同的精度模型下,忠实地模拟 kernel 的每一步"。


关键 takeaway

  1. BF16 的核心设计思想:保留 FP32 的量程,牺牲精度,换来不会溢出的 16 位格式------这是它成为训练和推理"默认精度"的根本原因
  2. FP32 → FP16 是带 RNE 舍入的截断 (误差有界,≤ ±½ ulp);FP32 → BF16 是纯截断(误差取决于低 16 位的实际值)
  3. Golden 必须模拟 kernel 的真实数据流------浮点没有客观真值,忠实地模拟每一步才是验证的意义所在