浅谈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 的回答BF162018 年Google Brain 团队为 TPU v2 设计了一种新的 16 位浮点格式——BF16Brain 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 快数倍今天的主流 GPUA100 / H100 / B200和 NPU昇腾 910B都原生支持 BF16。在大模型推理中vLLM、TGI、TensorRT-LLM 等框架的默认推理精度也普遍是 BF16 而非 FP16——原因很简单attention score 的计算中softmax 之前的最大值可能很大FP16 容易溢出BF16 则不会。二、三种浮点格式的数据表示2.1 位分配全景FP32、FP16、BF16 都源于 IEEE 754 标准但它们的位预算分配完全不同。用一张表来对比字段FP32FP16BF16总位数321616符号位111指数位858尾数位23107最大值~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 ulpulpunit in the last place——相邻两个浮点数之间的差距。在数值 1.0 附近FP32 的 ulp 约 1.19e-7FP16 约 9.77e-4BF16 约 7.81e-3。注意 ulp 不是常数——数值越大ulp 也越大。三、FP32 如何转换为 FP16 和 BF16理解了三种格式的位布局后很自然会问FP32 怎么转成 FP16 和 BF16答案是两种转换走的是完全不同的路径。3.1 FP32 → FP16带舍入的截断FP32 转 FP16 不是简单地砍掉多余的 bit。IEEE 754 规定了一个标准方法round-to-nearest-evenRNE。可以这样理解FP32 的 23 位尾数要缩减到 FP16 的 10 位。被砍掉的那 13 位里包含了丢掉的部分还有多少的信息。RNE 的策略是丢掉的部分不到半个 ulp → 直接截断丢掉的部分超过半个 ulp → 向上进位恰好半个 ulp→ 按取偶原则看保留的最后一位如果是奇数就 1 变成偶数如果是偶数就保持RNE 保证转换误差被严格限制在±½ 个 ulp以内。这个取偶规则是为了避免统计偏差——如果总是向上进位系统误差会累积。3.2 FP32 → BF16纯截断没有任何舍入与 FP16 的 RNE 不同BF16 的转换极其简单——就是直接砍掉低 16 位FP3232 bits eeeeeeee mmmmmmmmmmmmmmmmmmmmmmm ↓ 砍掉低 16 位 BF1616 bits 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 → FP16FP32 → 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 算子输出可以是 FP16B1 分支或 BF16B4 分支。两条分支走的是同一套代码——从矩阵乘到 Gelu前几步完全共享仅当最终输出格式不同时才分叉。共享路径INT8 × INT8 矩阵乘——Cube 单元执行中间结果在 INT32 累加器中累积FixpipeINT32 → FP16——当前代码将MatmulType的 C-type 配置为halffixpipe 按此格式输出CastFP16 → FP32——为后续浮点计算做准备FP32 反量化——乘以 x1Scale 和 x2ScaleFP32 Gelu——非线性激活在 FP32 精度下计算Gelu 后的分叉B1 分支FP16 输出CastFP32 → FP16RNE 舍入——← 这就是输出本身写出到 GMFP16B4 分支BF16 输出CastFP32 → FP16RNE 舍入——← 复用 FP16 路径CastFP16 → FP32zero-extend取高 16 位 → BF16关键说明B4 的 FP16 步骤不是为 BF16 特加的。它是 B1 路径的自然终点——Gelu 后统一 Cast 回 FP16FP16 分支直接写出BF16 分支在此基础上做half → float32 → 取高 16 位的额外转换。B4分支的当前代码选half作为 Cube fixpipe 输出类型和 GM buffer 类型是实现选择不是硬件限制是 Claude Code 生成该算子时为了复用 B1 代码所做的选择不是最优有更优的路径是从Gelu后FP32直接转换BF16。而当前讨论的是在这种情况下Golden应该怎么写。Golden 的模拟路径与 kernel 对齐FP32 矩阵乘跳过 fixpipe 的 FP16 中间步骤FP32 反量化FP32 GeluFP32 → FP16RNE 舍入——必须模拟的精度截断点FP16 → FP32zero-extend取高 16 位 → BF164.3 为什么 Golden 必须对齐这条「非最优」路径既然当前路径不是最优Golden 为什么还要跟着绕理论原因从 FP32 出发两条路径不等价。路径 Akernel 实际走的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-5BF16 与 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.44.4 本节小结当前 B4 路径是代码复用选择不是硬件限制只要 kernel 实际走了这条路Golden 就必须跟案例中的 0.19 偏差就是”没跟”的代价五、Golden 设计的哲学5.1 浮点计算没有”客观正确答案”上一节引出的问题——“Golden 到底应该是什么”——需要先接受一个反直觉的事实在浮点计算中不存在”客观正确答案”。每次浮点运算都伴随着舍入0.1 0.2 的”数学真值”是 0.3但浮点结果是 0.300000000000000041e10 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 的每一步”。关键 takeawayBF16 的核心设计思想保留 FP32 的量程牺牲精度换来不会溢出的 16 位格式——这是它成为训练和推理”默认精度”的根本原因FP32 → FP16 是带 RNE 舍入的截断误差有界≤ ±½ ulpFP32 → BF16 是纯截断误差取决于低 16 位的实际值Golden 必须模拟 kernel 的真实数据流——浮点没有客观真值忠实地模拟每一步才是验证的意义所在