同一份代码,两台机器,两个答案
前十八章讲的是「答案有多准」。这一章讲一件更基础、也更让人不安的事:答案是不是同一个。会看到同一份 C 源码、同一台机器、同一个编译器,只换一个编译开关,结果就变;还会看到同一份数据、同一个 for 循环,只改线程数,得到六个不同的答案。而这些全部是标准允许的行为。
一段十行的 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 + 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 章),但没有要求 sin、exp、pow、log。于是 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 和 Infinity | isnan(x) 可能被优化成常量 false。你的 NaN 检查整个消失 |
-fno-signed-zeros | +0 和 −0 没区别 | 1/x 在 x=−0 时符号可能变 |
-freciprocal-math | x/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.99983897537 和 node -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。
正确答案是 C(D 也对):-O0 给出 999999.99983897537,-O2 -ffast-math 给出 1000000.0000223046——后者更接近正确答案。第二问:a*b+c 也会变,默认(FMA 收缩)给 −4.93 × 10⁻³²,加 -ffp-contract=off 给 0。
-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,但必须——把 isnan/isinf 的检查移到没开这个开关的编译单元里,或者改用位模式判断((bits & 0x7ff0…) == 0x7ff0…)。
要绝对确定(共识、锁步、审计):别用浮点。用整数或定点。这不是保守,这是唯一能保证的做法——以太坊和 RTS 游戏是同一个理由。
测试里断言 assert result == 0.1234567890123
断言容差(第 12 章),并且把容差定成「几个 ulp」而不是一个绝对数。跨平台的测试尤其如此——差几个 ulp 是正常的,差几百万个才是 bug。
还有一条几乎零成本的:把编译器版本、编译选项、BLAS 实现、线程数记进产物的元数据里。出问题的时候,这几行能省掉几天。
这一章的一句话
浮点加法不满足结合律,而编译器和并行硬件都必须重排加法——所以「同一份代码给出同一个答案」不是默认,是一个你要付性能去买的属性。
最后一章不再引入新东西。它把前十九章压成一张自查表(合上书之后能带走的那部分)、一张「什么时候该用什么」的决策表,以及一份继续往下走的路线图。还会回答一个从第 1 章就悬着的问题:这套东西真正的用途,其实不在浮点数上。