暗无天日

=============>DarkSun的个人博客

读:Floating Dragon — 三个关于浮点数的反直觉事实

Julia Desmazes 五年前尝试从零实现浮点数运算,结果失败了。五年后她重新尝试,这次不光写了 C 代码,还画了硬件原理图,用 Verilog 实现了完整的 FPU,最后在 130nm 工艺上流了两次片。Floating Dragon 就是她记录这段旅程的长文。

先问个问题,你觉得自己懂浮点数吗?

浮点数比你想象的更复杂

大多数程序员对浮点数的认知停留在「符号位 + 指数 + 尾数,不能精确比较」这个层面。但 IEEE 754 规范里的东西远不止这些。

  1. 0.0 有两个版本, +0.0-0.0 。IEEE 专门规定了 x - x 永远等于 +0.0 ,不管 x 是正还是负。两个零在内存里长得不一样,但比较相等。
  2. NaN 不等于 NaNx != xx 是 NaN 时返回 true ,这打破了「任何东西都等于自己」这个所有编程语言里最基础的假设。
  3. subnormal 存在的意义之一是保证,如果 x ≠ y ,那么 x - y 永远不会等于 0。没有它的话,两个不同的小数相减可能直接 underflow 成 0。

    小贴士 。subnormal 是什么——一般来说,浮点数的隐含位是 1(尾数表示 1.xxx ),而 subnormal 的隐含位是 0(尾数表示 0.xxx ),指数段全为 0。这些数填在 0 和最小正规数之间的空隙里。没有它们,相减结果落到这个空隙就只能 underflow 成 0。

  4. 以及「无序」这种第四种比较结果。除了大于、等于、小于,浮点数比较还有「无序」,只在涉及 NaN 时出现。 not(x < y)x >= y 不再等价,数学里的三分律在浮点数世界里塌了。

    小贴士 。三分律——在实数里,任意两个数比较,大于、等于、小于三者必定有且只有一个成立。NaN 出现后,两个操作数的关系变成了「无序」,不属于这三种中的任何一种,三分律不再成立。

如果你看完上面这段觉得「还好吧,IEEE 都规定好了,照着实现就行」,请看第一个反直觉事实。

你以为 IEEE 754 是铁律

IEEE 754 存在的意义是让同一个浮点运算在不同硬件上结果一致。对通用 CPU 来说至关重要,同一个程序在 Intel 和 AMD 的机器上跑,浮点结果应该一样。

但 Desmazes 的硬件是矩阵乘法加速器(脉动阵列),不是通用 CPU。她的芯片只需要跟自己一致,不用跟别人的浮点结果对齐。

于是她做了一个让 IEEE 信徒血压升高的事,砍掉了所有不需要的东西。

她的 bfloat16 实现只有一种舍入模式,RZ(round toward zero,向零舍入),而不是 IEEE 规定的五种。选 RZ 不光因为硬件实现最简单,还因为它溢出时不会产生 ±∞ ,而是 clamp 到最大或最小值。

小贴士 。clamp 和 RZ 为什么不会产生无穷大——计算结果超出 bfloat16 能表示的最大值(比如 65504 )时,RZ 模式直接停在最大值,不溢出到无穷大。因为 RZ 永远向零方向舍入,最大值离零比无穷大近,所以舍入结果就是最大值本身。其他舍入模式(比如向正无穷舍入的 RU)会跳过最大值直接到 +∞

这一个特性引发了连锁删除。RZ 不会产生无穷大,无穷大永远不会作为运算结果出现。只要输入也没有无穷大,NaN 也就不会产生(加减乘除中 NaN 的唯一自然产生路径是对无穷大做运算,比如 +∞ - ∞0/0 )。于是无穷大和 NaN 的硬件支持都可以砍掉。

subnormal 也一并砍了,硬件代价远超收益。

小贴士 。subnormal 的代价和收益——bfloat16 尾数 7 位,subnormal 的正值总共 2^7 - 1 = 127 个(全零编码是 0,不是 subnormal),正负加起来 254 个值。收益是 gradual underflow,填平 0 和最小正规数之间的空隙,保证 x≠y ⇒ x-y≠0 。代价是要多一套处理隐含位为 0 的逻辑,归一化移位器要更宽,边界条件检测要更复杂——面积变大,关键路径变长,频率被拖慢。为了 254 个极少用到的值花这些硬件,不值得。

最终结果是 1 位符号、8 位指数、7 位尾数,只做向零舍入,没有无穷大、没有 NaN、没有 subnormal。

这里的关键洞察比「砍功能省硬件」更深。 你不仅不需要 IEEE 的全部功能,在很多场景下,带着全部功能反而是错误的选择 。IEEE 754 是为通用 CPU 之间的可移植性设计的,每支持一种舍入模式就多一组逻辑门,每兼容一种特殊值关键路径就长一截。如果你的硬件不需要跟外部对齐结果,这些全是白花的面积和功耗,除了拖慢芯片频率没有任何好处。

你以为 C++ 标准库的 bfloat16_t 就是硬件上的 bfloat16

砍规范已经够反直觉了。更让人意外的是,即使你用的类型名字就叫 bfloat16_t ,标准库也未必按 bfloat16 的方式计算。

Desmazes 用 C++23 的 std::bfloat16_t 作为 golden model 来验证她的硬件设计。在芯片验证里,golden model 是一个用软件实现的参考模型,同样的输入,软件算一遍,硬件算一遍,两者的输出必须一致。软件这边是「正确答案」,硬件要跟它对齐。

结果对不上。

她追查发现, bfloat16_t 的加法被 gcc 编译成这样:

call   __extendbfsf2    ; bfloat16 转成 float32
addss  ...              ; 用 float32 做加法
call   __truncsfbf2     ; float32 截断回 bfloat16

用伪代码翻译一下:

bfloat16_t a, b, c;
c = a + b;
// 实际发生的事情:
float a32 = extend_to_float32(a);   // 精度提升
float b32 = extend_to_float32(b);
float c32 = a32 + b32;              // 用 float32 算
c = truncate_to_bfloat16(c32);      // 截断回来

问题出在中间那步。 float32 的内部精度是 p=24 位,bfloat16 只有 p=8 位。精度差别导致标准库的中间结果和硬件直接用 8 位精度算的结果不同,截断回来后就会差 1 ulp

小贴士 。精度 pulp —— p 是尾数有效位的总位数,等于存储位数加 1(正规数有一个隐含的 1)。float32 存 23 位加隐含 1 位, p=24 ;bfloat16 存 7 位加隐含 1 位, p=8ulp (unit in the last place,末位单位)是相邻两个浮点数之间的间距,差 1 ulp 就是差了一个最小步长。标准库用 float32(24 位精度)算完再截断到 bfloat16(8 位精度),中间多了 16 位精度,截断时舍入方向可能跟直接用 8 位算的不同,结果刚好差那么一丝。

这不是 gcc 的 bug。bfloat16 由 Google 提出用于 AI 加速,但它 不是 IEEE 754 定义的类型,没有规范约束它的运算行为。C++ 标准库选了一个性能最优的实现方式,用 float32 来冒充 bfloat16,把 bfloat16 提升成 float32,借 CPU 的 FPU 算完,再截回来。这个策略在通用计算上完全合理,但对想用它做硬件验证的人来说是灾难。

Desmazes 自己的总结一针见血:「bfloat16 最好的地方是没有 spec,所以可以随意实现;bfloat16 最烂的地方也是没有 spec,所以每个人实现的都不一样。」

复杂的设计反而更快

最后一个教训来自硬件设计的具体细节,但原则适用于任何编译型语言。

Desmazes 的加法器关键路径上有一个 LZC(Leading Zero Count,前导零计数)模块,用来计算结果尾数中第一个 1 的位置,以确定要做多少位移位。她翻阅文献,实现了一个精巧的树形 LZC,时序已经收敛了。

一个朋友建议她,别搞这么复杂,写个 priority mux(优先级匹配),让综合器自己去优化。

小贴士 。几个硬件术语——「时序收敛」指电路里所有信号在时钟沿到来前都能稳定到达,设计在目标频率下通过了时序检查。「综合器」是把 Verilog 硬件描述语言翻译成逻辑门电路(网表)的工具,相当于硬件世界的编译器。

她第一反应是「这不可能比树形更快」。priority mux 的逻辑太「傻」了:

always @(*) begin
    casez (data_i)
        9'b1????????: zero_cnt = 4'd0;
        9'b01???????: zero_cnt = 4'd1;
        9'b001??????: zero_cnt = 4'd2;
        9'b0001?????: zero_cnt = 4'd3;
        9'b00001????: zero_cnt = 4'd4;
        9'b000001???: zero_cnt = 4'd5;
        9'b0000001??: zero_cnt = 4'd6;
        9'b00000001?: zero_cnt = 4'd7;
        9'b000000001: zero_cnt = 4'd8;
        default:      zero_cnt = 4'd0;
    endcase
end

从高位到低位,找到第一个 1 就输出它的位置。yosys 把这个模块综合成了 19 个 cell,逻辑深度 3 级,比手工优化的树形 LZC 还快了 0.05ns。

小贴士 。yosys、cell、逻辑深度——yosys 是开源的 RTL 综合工具;cell 是芯片工艺库里预定义的基本逻辑单元(与门、或门、触发器等),综合的过程就是把 Verilog 代码映射到这些单元上;逻辑深度 3 级指信号从输入到输出最多经过 3 个逻辑门,越浅越快。

改进不大,但方向完全出乎意料。直觉上更高效的树形设计,被工具用傻白甜的写法打败了。

手工优化不是没用。只是现代综合器(以及编译器)对目标平台的优化空间理解得比你深。你给它越直白、越规则的代码,它能做的优化越多。你手写的聪明算法反而可能堵住工具的优化路径。

最后

Desmazes 的开篇是一句自白,I have a confession to make: floating point scares me。五年前她被浮点数打败过。五年后她卷土重来,用硬件实现了浮点运算,流了两次片,最高跑到 454MHz。

然后她说,做了这一切之后,我仍然不敢说我懂了浮点数。但至少我知道了坑有多深,也知道了如果真的想掌握它,我该做什么。

这三件事指向同一个方向。你依赖的每一层抽象,IEEE 规范也好,标准库也好,手写的精巧算法也好,都可能在某个你没想到的角落塌掉。不亲手挖到底,你永远不知道塌的位置在哪。

浮点数 : IEEE754 : bfloat16 : 读后笔记