你以为在算数,其实在往栅栏上靠
大多数人对浮点数的全部认识,是「0.1 + 0.2 不等于 0.3」。这个例子有一个很坏的副作用:它让人以为这是小数点后面的小事,是显示的问题,四舍五入一下就过去了。这一章要做的第一件事,就是把这个印象拆掉——同样的一次抹零,1991 年让一枚拦截弹的窗口挪偏了 575 米。
1991 年 2 月,沙特宰赫兰。一套爱国者防空系统连续开机运行了大约 100 小时。它内部用一个计数器记录「开机以来过了多少个十分之一秒」,然后乘以 0.1 换算成秒——而 0.1 这个数,被存在一个二十四位的定点寄存器里。
问:开机 100 小时之后,这套系统认为的「现在几点」,和真实时间差多少?
顺便预测第二个数:来袭目标以 1676 米/秒飞行,这点时间差会让系统预测的拦截窗口偏出多少米?
先把那个著名的例子处理掉
打开任何一门语言的控制台,输入 0.1 + 0.2:
> 0.1 + 0.2 0.30000000000000004
这个例子被引用了三十年,而它教会大家的东西基本是错的。它给人的印象是:浮点数会在很后面的位上出一点小毛病。于是应对方式也就顺理成章——显示的时候 toFixed(2) 一下,比较的时候留个 1e-9 的余量,日子照过。
真实情况要更彻底一些:问题不在加法,问题在你写下 0.1 的那一刻就已经发生了。
把这三个数的精确值打印出来看看。注意,下面这些不是「近似展开」,而是这几个 double 所代表的那个数的完整、精确、有限位的十进制写法——每一个 double 都是一个精确的二进制分数,所以它一定有一个有限的十进制展开:
# 你写下的 机器实际存的(精确值,一位不差)
0.1 → 0.1000000000000000⟨055511151231257827021181583404541015625⟩
0.2 → 0.2000000000000000⟨11102230246251565404236316680908203125⟩
0.3 → 0.29999999999999998⟨8897769753748434595763683319091796875⟩
# 而 0.1 + 0.2 算出来的那个 double 是:
0.1 + 0.2 → 0.3000000000000000⟨444089209850062616169452667236328125⟩
↑ 和上面那个 0.3 不是同一个数。第 12 章会说明它们其实是邻居。
# 琥珀色的部分,就是机器替你多写或少写的那一点。
看第一行。你以为你存了 0.1,机器存的是一个比 0.1 大 5.5511151231257827 × 10⁻¹⁸ 的数。这个「多出来的一点」就是这本书的主角,全书用琥珀色标记它。它有一个名字叫舍入误差,但这个名字太温柔了。我更愿意叫它抹零——像收银员把账单末尾的几分钱抹掉一样:单次不值一提,问题从来不在单次。
为什么会有这一步
十进制里,1/3 写不完:0.333333……。这件事没人觉得奇怪,因为 3 不整除 10。
二进制里,分母只能是 2 的幂。1/10 的分母有个因子 5,所以 1/10 在二进制里写不完——它是一个无限循环小数:
1/10 = 0.0001100110011001100110011001100110011… ₂
└──┘└──┘└──┘ 「0011」无限循环下去
double 的尾数只有 53 位,写到第 53 位就得停。
停下来的时候,要么向上凑一点,要么向下砍一点——这就是抹零。
所以真正的画面不是「计算机算错了」,而是:
数轴上并不是每个点都能被存下来。double 能表示的数是一排离散的刻度,像尺子上的格子。你写下的任何一个数,如果不恰好落在某根刻度上,就会被推到最近的那一根。推过去的那一小段距离,就是被抹掉的那一点。
这一步不是 bug,是设计。它每一次运算都会发生一遍:加一次抹一次,乘一次抹一次。整本书要回答的问题只有一个——这些被抹掉的碎屑,什么时候会长成一个能毁掉答案的东西。
画出来是这样。下面这把尺子上,每一根刻度是一个 double 能表示的数;墨蓝的点是你真正想要的 1/10;它落在两根刻度之间,于是被推到了右边那一根:
你以为它是 X,其实它是 Y
这一节是整本书的地图。左边是大多数人默认的那个理解,右边是学完这本书之后会换上的那个。如果你只带走一样东西,带走这张表。
| 你以为它是 X | 其实它是 Y |
|---|---|
| 计算机在做数学 | 计算机在做一套有限的、不满足结合律的近似算术。(a+b)+c 和 a+(b+c) 是两个不同的表达式 |
| 误差来自「机器不够精确」 | 误差来自「你要的数不在刻度上」。加精度只是把刻度画密,永远画不满 |
| 误差大小取决于你用几位 | 取决于两个完全独立的量:问题有多敏感(条件数),和你的算法有多老实(稳定性)。前者你改不了,后者你能改 |
| 结果对不对,看残差/loss 小不小 | 残差小只说明你「精确地解了一个很像的问题」。那个问题的答案可以离你要的答案很远 |
| 民调误差 ±3% 就是结论有 ±3% 的不确定 | 那是输入扰动。输出扰动 = 条件数 × 输入扰动。有些结论的条件数是 100 |
| 模型效果差是因为数据不够 | 也可能是因为损失函数在最优点附近是病态的:梯度告诉你的方向本身就没几位是对的 |
| 深度学习需要高精度 | 训练要的是动态范围,不是精度。bf16 只有 7 位尾数、两位十进制不到,照样训得出大模型 |
| 同一份代码跑两遍结果一样 | 换个线程数、换个 CPU、加个编译选项,答案就变。「可复现」是一个要专门去争取的属性 |
| 编译器优化不改变程序语义 | -ffast-math 明确允许改变浮点语义。这不是 bug,是文档里写着的 |
这张表里每一行,后面都有一整章。
1991 年 2 月 25 日,宰赫兰
回到开头那个问题。
爱国者系统的内部时钟是一个整数计数器,每十分之一秒加一。要把它换算成秒,需要乘以 0.1。而 0.1 被存在一个 24 位的定点寄存器里——注意,这里不是浮点数,是定点数;但抹零这件事和格式无关,只要格子是有限的,就一定会抹。
把 0.1 写成二进制,在第 23 位小数处砍掉:
1/10 = 0.0001100110011001100110011001100…₂
存进去 = 0.00011001100110011001100₂ ← 砍在这里
= 209715 / 2097152
= 0.099999904632568359375
每一个 tick 的误差 = 1/10 − 209715/2097152
= 1 / 10485760
= ⟨9.5367431640625 × 10⁻⁸⟩ 秒
一亿分之九点五秒。单次看,这是个荒唐地小的数。
然后乘以次数:
100 小时 = 100 × 3600 × 10 = 3 600 000 个 tick
累计漂移 = 3 600 000 × 1/10485760
= 360000 / 2²⁰
= 0.34332275390625 秒 ← 这一步用精确分数算,不是用 double 减法
目标飞行速度 1676 米/秒
波门偏移 = 0.34332275390625 × 1676 = 575.408935546875 ≈ 575.4 米
系统按照自己那个慢了三分之一秒的时钟去预测目标下一刻的位置,于是在雷达上开出的跟踪波门——那个「接下来该往哪里看」的小窗口——落在了真实目标之外五百多米的地方。目标没有出现在窗口里,系统判定这是一个虚假回波,把它丢掉了。
那天来袭的飞毛腿导弹击中了一处美军营房,28 人死亡,近百人受伤。
这件事最刺人的地方不是误差本身,而是它早就被知道了。美国审计总署事后的报告(GAO/IMTEC-92-26,1992 年 2 月)写得很清楚:军方在两周前就已经收到以色列方面关于时钟漂移的报告,并已在准备修正版软件;补丁抵达宰赫兰的日期是 1991 年 2 月 26 日——事发的第二天。报告同时指出,系统原本被设计为只连续运行几小时(那时漂移只有几十米,仍在波门宽度内),是实战中长时间不关机的用法把这个误差喂大的。
精度(precision)和准确度(accuracy)在日常里通用,在这本书里必须分开:
- 精度说的是「格子有多密」——尾数有几位。这是格式的属性,double 是 53 位,bf16 是 8 位。
- 准确度说的是「你算出来的东西离真答案有多远」。它由精度、问题的敏感度、算法的写法三样共同决定。
提高精度不一定提高准确度——第 7 章会给一个例子:把精度提到 60 位十进制,答案照样全错。
把一个 double 的精确值打印出来,是这本书最常做的一个动作。两行代码就够:
from fractions import Fraction
for x in (0.1, 0.2, 0.3, 0.1 + 0.2):
print(repr(x).ljust(22), Fraction(x)) # 精确的分数
print(' ' * 22, '%.55f' % x) # 精确的十进制展开
会打出:
0.1 3602879701896397/36028797018963968
0.1000000000000000055511151231257827021181583404541
0.30000000000000004 1351079888211149/4503599627370496
0.3000000000000000444089209850062616169452667236328
Python 的 Fraction(x) 从一个 float 构造时给出的是精确分数,不是近似——因为每一个 double 本来就是一个分母为 2 的幂的分数。'%.55f' 会一直打到展开结束,后面全是 0。
python3 -c "from fractions import Fraction; print(Fraction(0.1))"
没装 Python 也行:浏览器控制台里 (0.1).toFixed(55) 会打出同一串数字。要在线跑就用 python.org/shell。
这本书要讲的东西,在下面这些地方每天都在起作用,而且大部分时候没人意识到:
- 你的对账系统。一天几十万笔流水,用 double 累加余额。第 8 章会给出一个具体的数:账面差多少钱。
- 你训练的模型。混合精度、loss scaling、master weights、梯度裁剪——这些工程惯例全部是为了对付抹零。第 18 章会说清每一条在防哪一种。
- 你写的单元测试。
assertEquals(expected, actual, 1e-9)里那个1e-9在大多数场合是错的。第 12 章会说该写什么。 - 任何一次「换台机器结果就不一样」的排查。第 19 章会在本机用 C 编译器把这件事复现出来:同一份源码,加一个编译开关,答案就换一个。
「误差在小数点后十六位,无所谓。真正重要的位在前面。」
这句话在一次运算之后是对的。它错在假设了两件事:误差不会被累加,也不会被放大。
爱国者那个例子里,单次误差是 10⁻⁸ 量级,一百小时之后变成 10⁻¹ 量级——整整涨了七个数量级,而它做的事情只是加了三百六十万次。
更狠的还在后面:第 3 章会看到,一次减法就能把 10⁻¹⁶ 的误差瞬间提拔到 10⁻⁸,不需要累加,不需要时间。判据是:看这个误差有没有机会被放大,而不是看它现在有多小。
正确答案是 B:差 0.34332275390625 秒。波门偏移 575.4 米。
A 「二十四位精度,误差在小数点后七八位」——这半句完全正确,单次误差确实是 9.54×10⁻⁸。错在下半句:一百小时里这个动作重复了 360 万次,而这些误差方向完全一致(每次都是向下砍),所以它们不抵消,直接相加。方向一致是这里的关键,第 4 章会讲方向随机的情况——那时误差只按 √n 长,不按 n 长。 C 「差好几分钟」——量级想大了。真要差几分钟,系统早就自己发现了;正因为只差三分之一秒,它才一路带病运行到出事。最危险的误差从来不是最大的那个,是刚好大到毁掉结果、又小到不触发任何报警的那个。 D 「定点数是精确的」——这是个很常见的误解。定点数在加减上确实精确(同一个刻度间距下不会漂),但它照样有刻度间距。1/10 在二进制定点数里同样写不完,同样要砍。第 17 章会说清楚:能救钱的不是「定点」,是十进制或者整数分。爱国者的漏洞,用今天的代码写出来长这样——而修法不需要更高的精度,只需要换一个写法:
t += 0.1; 每个 tick 累加一次。误差按次数线性堆积,跑得越久越离谱。
t = ticks * 0.1; 计数用整数,最后只乘一次。误差就只有一次抹零,跑一万年也不长。
这是全书出现频率最高的一招:把「重复很多次的小误差」改写成「只发生一次的小误差」。它一分钱不花,也不需要换数据类型。
这一栏会在每一章末尾出现。二十章下来,它会从一个技巧变成一种下意识——看到一个式子,先问它有没有第二种写法。
这一章的一句话
你写下的数和机器存下的数之间,永远隔着一次抹零;这一次抹零本身微不足道,全书剩下的十九章讲的都是它怎么被养大。
下一章去看这把尺子长什么样。有一件事大概会出乎意料:double 的刻度不是均匀的——在 1 附近,相邻两个 double 相差 2.22×10⁻¹⁶;到了 10¹⁶ 附近,相邻两个 double 相差 2。也就是说 1e16 + 1 === 1e16 会返回 true。还有一个更古怪的事实等着:全部 double 里,有 49.98% 挤在 −1 和 1 之间。