卷 I · 栅栏CH 01深度 1/20

你以为在算数,其实在往栅栏上靠

大多数人对浮点数的全部认识,是「0.1 + 0.2 不等于 0.3」。这个例子有一个很坏的副作用:它让人以为这是小数点后面的小事,是显示的问题,四舍五入一下就过去了。这一章要做的第一件事,就是把这个印象拆掉——同样的一次抹零,1991 年让一枚拦截弹的窗口挪偏了 575 米

零基础起点跨领域对照表爱国者导弹

▷ 猜一下差多少

1991 年 2 月,沙特宰赫兰。一套爱国者防空系统连续开机运行了大约 100 小时。它内部用一个计数器记录「开机以来过了多少个十分之一秒」,然后乘以 0.1 换算成秒——而 0.1 这个数,被存在一个二十四位的定点寄存器里。

问:开机 100 小时之后,这套系统认为的「现在几点」,和真实时间差多少?

A 差不到一毫秒。二十四位精度,误差在小数点后七八位,累计一百小时也就那么点 B 差大约 0.34 秒 C 差好几分钟 D 不会差。定点数是精确的,不像浮点数会飘

顺便预测第二个数:来袭目标以 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)+ca+(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 之间。