卷 V · 换格CH 19深度 19/20

同一份代码,两台机器,两个答案

前十八章讲的是「答案有多准」。这一章讲一件更基础、也更让人不安的事:答案是不是同一个。会看到同一份 C 源码、同一台机器、同一个编译器,只换一个编译开关,结果就变;还会看到同一份数据、同一个 for 循环,只改线程数,得到六个不同的答案。而这些全部是标准允许的行为。

★★ 本机实测结合律不成立-ffast-math / FMA

▷ 猜一下差多少

一段十行的 C 程序,第 4 章那个循环:

double s = 0.0;
for (int i = 0; i < 10000000; i++) s += 0.1;
printf("%.17g\n", s);

在同一台机器(这台 Mac)上,用同一个 clang,编译两次:一次 cc -O0,一次 cc -O2 -ffast-math

问:两次的输出?

A 完全相同。编译优化不改变程序语义,这是编译器的基本承诺 B 相同,但 -ffast-math 那次快一些 C 不同:999999.99983897537 和 1000000.0000223046 D 不同,而且 -ffast-math 那次更接近正确答案 1000000

第二问:同样这两次编译,a*b + c 这一行的结果会相同吗?

本机实测

下面这些都是在写这本书的这台机器上真跑出来的(clang 14,x86-64 macOS):

$ cc -O0 fma.c -o t && ./t
a*b+c   = -4.9303806576313238e-32
(x+y)+z = 1
x+(y+z) = 0
sum 0.1 x1e7 = 999999.99983897537

$ cc -O2 -ffast-math fma.c -o t && ./t
a*b+c   = -4.9303806576313238e-32
(x+y)+z = 1
x+(y+z) = 1                        ★ 变了
sum 0.1 x1e7 = 1000000.0000223046  ★ 变了

$ cc -O2 -ffp-contract=off fma.c -o t && ./t
a*b+c   = 0                        ★ 又变了

答案是 C,而且 D 也对——-ffast-math 那次给出的 1000000.0000223046-O0 那次的 999999.99983897537 更接近正确答案。

这一点很值得强调:优化改变结果,方向是随机的。它有时更准,有时更差,而你无法预测是哪一种。

顺带一个交叉核对:-O0 那个 999999.99983897537,和第 4 章里 Node.js 跑出来的、以及 Python 跑出来的,逐位相同。三套完全不同的运行时,只要严格按 IEEE 754 顺序执行,就一定给出同一个位模式。这就是标准的价值,也正是 -ffast-math 放弃掉的东西。

为什么会变:四个来源

① 加法不满足结合律

volatile double x = 1e16, y = -1e16, z = 1.0;

(x + y) + z  =  0 + 1  =  1
x + (y + z)  =  1e16 + (-1e16 + 1)  =  1e16 + (-1e16)  =  0
                                        ↑ 那个 1 在 -1e16 面前被抹掉了

这是这一章的根。实数加法满足结合律,浮点加法不满足。而编译器为了向量化和指令级并行,必须重排加法顺序——重排就换答案。

-ffast-math 的作用就是「允许编译器假装浮点加法满足结合律」。上面那个例子里,加了这个开关之后 x+(y+z) 被编译器直接重排成了 (x+y)+z,输出从 0 变成 1。

② 并行归约的宽度

不用编译器,光是「用几路累加器」就够了。同一份 65536 个数的数组,同一个求和逻辑:

归约宽度结果离精确值差几个 ulp(带符号)
1 路−2080957.3456573018−13
2 路−2080957.3456573093−45
4 路−2080957.3456573056−29
8 路−2080957.3456572972+7
16 路−2080957.3456572958+13
32 路−2080957.3456572976+5

六种宽度,六个不同的答案。注意偏移是带符号的,而且分布在精确值两侧——1 路差 −13 格,16 路差 +13 格,同样远、方向相反。也就是说,你没法靠「哪个宽度更准」来选:它们各自的误差是不同顺序的舍入攒出来的,方向纯属偶然。而「宽度」这个东西,在真实系统里由 SIMD 位宽、线程数、GPU 的 block 大小、cuDNN 挑的 kernel 决定——这些都不是你写在代码里的。换一张显卡、换一个 batch size、换一个 cuDNN 版本,宽度就可能变。

③ FMA:一次舍入还是两次

fma(a, b, c)a*b + c,中间的乘积不舍入,整个式子只舍一次。这比分开算更准,但结果不同

a = 1 + 2⁻⁵²,  b = 1 − 2⁻⁵²,  c = −1

a × b 的精确值 = 1 − 2⁻¹⁰⁴
double 存不下 ⇒ 抹成 1
(a*b) + c = 1 − 1 = 0                      ← 分开算,两次舍入

fma(a, b, c) = −2⁻¹⁰⁴ = −4.9303806576313238e−32   ← 一次舍入,精确

★ 本机默认(-ffp-contract=on):编译器把 a*b+c 自动变成 fma ⇒ 得到 −4.93e−32
★ 加 -ffp-contract=off                              ⇒ 得到 0

注意这里有多刁钻:同一行源码 a*b + c,编译器默认就会把它换成 FMA(C 标准允许这种「收缩」)。所以「有没有 FMA 硬件」「编译器版本」「优化级别」三者中的任何一个变化,都会让这一行给出不同的数。

④ 数学库函数不保证正确舍入

IEEE 754 要求加减乘除和开方必须正确舍入(第 8 章),但没有要求 sinexppowlog。于是 glibc、macOS 的 libm、MSVC、Intel MKL、CUDA 的实现各自可以差最后一两个 ulp。

这就是为什么「同一个 Python 脚本,Linux 和 Mac 上结果差一点」——大部分时候差的不是 Python,是底下的 libm。

◆ 主线

「可复现」不是浮点计算的默认属性,是一个要专门去争取的属性。

而争取它是有代价的,代价通常是性能

  • 关掉 -ffast-math ⇒ 编译器不能向量化归约循环。
  • 固定归约顺序 ⇒ 放弃并行求和。
  • 关掉 FMA ⇒ 放弃一半的浮点吞吐,而且结果反而更不准

所以这里没有「正确答案」,只有一个必须显式做出的选择:你要的是「每次都一样」,还是「尽量快」?大多数项目从来没做过这个选择,于是默认拿到了「尽量快」,然后在某次换硬件时被咬一口。

-ffast-math 到底放弃了什么

这个开关不是「稍微不那么严格」,它是一整包假设。GCC 和 clang 下它等价于同时打开:

子开关它允许编译器假设后果
-fassociative-math加法乘法满足结合律可以重排求和 ⇒ 结果变(上面那两行)
-ffinite-math-only不会出现 NaN 和 Infinityisnan(x) 可能被优化成常量 false。你的 NaN 检查整个消失
-fno-signed-zeros+0 和 −0 没区别1/x 在 x=−0 时符号可能变
-freciprocal-mathx/y 可以换成 x * (1/y)多一次舍入
-funsafe-math-optimizations各种代数化简x*0 → 0(对 NaN 和 Inf 是错的)

第二行最危险,值得单独说:-ffinite-math-only 会让 isnan() 失效。因为编译器被告知「不可能有 NaN」,于是它可以把这个判断优化成常量假。你的 NaN 保护代码被编译器删掉了,而它什么也不会说。

更麻烦的是:这个开关会传染。如果一个共享库是用 -ffast-math 编译的,在某些平台上它会在加载时修改进程级的 FPU 控制字(打开 flush-to-zero),于是你的程序里所有的次正规数都被冲成 0——包括不是你编译的那些代码。

✎ 术语正名

确定性(deterministic)在这里有三个强度,混用会出大问题:

  • 运行间确定(run-to-run):同一台机器、同一个二进制、同一份输入,跑两次结果一样。这是最低要求,而多线程归约会破坏它(线程完成顺序不定)。
  • 位级可复现(bitwise reproducible):换机器、换编译器、换线程数,结果的位模式完全相同。科学计算和审计场景要求这个。
  • 数值等价:结果在容差内相同。这是大多数项目实际需要的,而它需要你写对第 12 章那个比较判据。

PyTorch 的 torch.use_deterministic_algorithms(True) 争取的是第一个(同机器同版本可复现),不保证跨设备。要跨设备位级一致,得用固定顺序的归约,性能代价通常在两到十倍。

⌨ 自己跑一遍

这一章必须自己编译一次才有体感。把下面存成 fp.c

#include <stdio.h>
#include <math.h>
int main(void) {
  volatile double a = 1.0 + 0x1p-52, b = 1.0 - 0x1p-52, c = -1.0;
  printf("a*b+c   = %.17g\n", a * b + c);
  printf("fma     = %.17g\n", fma(a, b, c));
  volatile double x = 1e16, y = -1e16, z = 1.0;
  printf("(x+y)+z = %.17g\n", (x + y) + z);
  printf("x+(y+z) = %.17g\n", x + (y + z));
  double s = 0.0;
  for (int i = 0; i < 10000000; i++) s += 0.1;
  printf("sum 0.1 x1e7 = %.17g\n", s);
  return 0;
}

然后编译三次:

$ cc -O0 fp.c -o t -lm && ./t
a*b+c   = -4.9303806576313238e-32
fma     = -4.9303806576313238e-32
(x+y)+z = 1
x+(y+z) = 0
sum 0.1 x1e7 = 999999.99983897537

$ cc -O2 -ffast-math fp.c -o t -lm && ./t
x+(y+z) = 1                          ★ 结合律被「修好」了
sum 0.1 x1e7 = 1000000.0000223046    ★ 循环被向量化,累加顺序变了

$ cc -O2 -ffp-contract=off fp.c -o t -lm && ./t
a*b+c   = 0                          ★ 不许收缩成 FMA
fma     = -4.9303806576313238e-32    (显式调 fma 不受影响)

-O0 那次的 999999.99983897537node -e "let s=0;for(let i=0;i<1e7;i++)s+=0.1;console.log(s)" 对一下——逐位相同

cc -O0 fp.c -o t -lm && ./t

Mac 和 Linux 自带 cc。Windows 用 WSL 或 MSVC(MSVC 对应的开关是 /fp:fast/fp:precise)。想在线看汇编差异:godbolt.org——把这段贴进去,切换 -O2-O2 -ffast-math,能直接看到向量化指令出现

▸ 在现实里

训练不可复现。「我设了随机种子,为什么两次训练结果不同?」种子只管随机数,管不了归约顺序。cuDNN 会为同一个卷积挑不同的算法(取决于当时的显存和 benchmark 结果),atomicAdd 的完成顺序是不定的。torch.use_deterministic_algorithms(True) + CUBLAS_WORKSPACE_CONFIG 能换回同机器可复现,代价是慢。

金融的监管要求。风险模型必须能复现历史计算——监管来查的时候,你得能拿出「当时那个数是怎么来的」。这类系统一律禁用 -ffast-math,很多干脆全程用定点或十进制(第 17 章)。

区块链共识。全网节点必须对同一笔交易算出完全相同的结果,否则链会分叉。所以以太坊虚拟机根本不提供浮点运算——只有整数。这不是能力不足,是一个深思熟虑的设计:浮点在这个场景下是不可用的,因为它的结果依赖执行环境。

游戏的确定性联机。RTS 和格斗游戏常用「锁步同步」——每个客户端各自跑同一份模拟,只同步输入。只要有一台机器的浮点结果差一个 ulp,几分钟后两边的世界就完全不同了。所以这类游戏要么全用定点数,要么把浮点严格限制在确定的指令序列上(软件实现的数学函数、禁用 FMA、禁用向量化)。

科学计算的复现危机。近年不少论文因为「换台机器跑不出原结果」被质疑。而其中一部分根本不是造假,就是这一章。好的做法是:论文里写清编译器版本、编译选项、BLAS 实现和线程数——这些和随机种子一样,都是实验条件。

✗ 这个直觉是错的

「编译器优化不改变程序的语义。开 -O3 只是让它跑得更快。」

对整数运算,这句话成立。对浮点,-O2 / -O3 本身通常也成立(默认会保持 IEEE 语义),但有一个例外是默认开着的:FMA 收缩。C 标准允许编译器把 a*b+c 变成 FMA,clang 默认就这么做——你什么开关都没加,结果已经和「按字面执行」不同了。

-ffast-math明确地、文档里写着地放弃 IEEE 语义。它不是 bug,也不是激进优化——它是一份契约:你告诉编译器「我不在乎精确的浮点语义」,编译器就当真了。

危险的是这份契约常常是被别人替你签的:某个 CMake 模板里写着 -Ofast(它包含 -ffast-math),或者某个依赖库是这么编的。检查一下你的构建配置里有没有 -ffast-math / -Ofast / /fp:fast,这是五分钟的事,而且可能解释掉一个困扰很久的 bug。

◇ 对账

正确答案是 CD 也对):-O0 给出 999999.99983897537-O2 -ffast-math 给出 1000000.0000223046——后者更接近正确答案。第二问:a*b+c 也会变,默认(FMA 收缩)给 −4.93 × 10⁻³²,加 -ffp-contract=off0

A 「编译优化不改变语义」——这是编译器对整数和内存的承诺,对浮点它从来没做过这个承诺(除非你要求它做)。而且 FMA 收缩是默认开启的,也就是说,哪怕你不加任何开关,「按字面执行」也已经不成立了。 B 「相同,只是更快」——「更快」是对的:-ffast-math 让编译器把那个循环向量化成四路或八路累加,所以它既更快、又给出不同答案,而这两件事是同一个原因(重排)造成的。速度和确定性在这里是同一枚硬币的两面。 D 「更接近正确答案」——这个也对,而且指出了一件很重要的事:向量化归约相当于第 8 章的成对求和,它天然比顺序累加更准。所以 -ffast-math 在求和上通常会提高精度。问题从来不是「优化让结果变差」,而是「优化让结果变得不可预测」——这一章的标题是「两个答案」,不是「一个错答案」。
◈ 换一种写法

先做一个显式的选择,再照着配:

构建配置里有 -Ofast / -ffast-math / /fp:fast,而没人记得为什么 要可复现:-O2 -fno-fast-math -ffp-contract=off;固定 BLAS 线程数(OMP_NUM_THREADS=1);固定归约顺序。并且把这些写进文档,当成实验条件的一部分。 要速度、不要求位级一致:保留 -ffast-math,但必须——把 isnanisinf 的检查移到没开这个开关的编译单元里,或者改用位模式判断((bits & 0x7ff0…) == 0x7ff0…)。 要绝对确定(共识、锁步、审计):别用浮点。用整数或定点。这不是保守,这是唯一能保证的做法——以太坊和 RTS 游戏是同一个理由。 测试里断言 assert result == 0.1234567890123 断言容差(第 12 章),并且把容差定成「几个 ulp」而不是一个绝对数。跨平台的测试尤其如此——差几个 ulp 是正常的,差几百万个才是 bug。

还有一条几乎零成本的:把编译器版本、编译选项、BLAS 实现、线程数记进产物的元数据里。出问题的时候,这几行能省掉几天。

这一章的一句话

浮点加法不满足结合律,而编译器和并行硬件都必须重排加法——所以「同一份代码给出同一个答案」不是默认,是一个你要付性能去买的属性。

最后一章不再引入新东西。它把前十九章压成一张自查表(合上书之后能带走的那部分)、一张「什么时候该用什么」的决策表,以及一份继续往下走的路线图。还会回答一个从第 1 章就悬着的问题:这套东西真正的用途,其实不在浮点数上。