卷 I · 栅栏CH 02深度 2/20

栅栏的形状

上一章说数轴上有一排刻度。这一章要看清这排刻度长什么样——而它最重要的性质,是不均匀。刻度在原点附近密得可怕,越往外越稀,稀到某个地方之后,连整数都装不下了。搞清楚这个形状,后面十八章里一大半的怪现象都会自己解释自己。

ulp机器 epsilon2⁵³ 那道坎

▷ 猜一下差多少

把所有的 double(不含无穷、NaN,含次正规数)排成一列。

问:其中有百分之多少,落在 −1 和 1 之间

A 不到 1%。double 的范围到 1.8×10³⁰⁸,−1 到 1 只是其中极小的一段 B 大约 10% C 大约 50% D 超过 99%

顺便估一下第二个数:1e16 + 1 === 1e16 会返回什么?

三段:符号、指数、尾数

一个 double 是 64 个二进制位,分成三段:

┌─┬───────────┬────────────────────────────────────────────────────┐
│S│  指数 11 位 │                 尾数 52 位                          │
└─┴───────────┴────────────────────────────────────────────────────┘

0.1 存进去长这样:
0 01111111011 1001100110011001100110011001100110011001100110011010
↑ 正号        ↑ 循环节 1001 一直到位置用完,最后一位向上进了一位

它表示的值是:

值 = ± 1.m₁m₂…m₅₂ ₂ × 2^(指数−1023)
       └────┬────┘
        叫「尾数」,注意小数点左边那个 1 是白送的
        (因为规范化之后首位必定是 1,干脆不存)

所以实际有效位数是 53 位,而不是 52 位。

这就是二进制版的科学计数法。和十进制科学计数法一样,它有一个关键性质:不管这个数多大多小,有效位数永远是那么多位

而这一条,直接决定了刻度的形状。

相对精度恒定 ⇒ 绝对间距随大小翻倍

把指数固定住,尾数从全 0 走到全 1,你就走遍了一个「段」(正式名字叫 binade,就是 [2ᵏ, 2ᵏ⁺¹) 这样一段)。每一段里,尾数有 52 位可以变,所以每一段里恰好有 2⁵² = 4 503 599 627 370 496 个 double

[1, 2) 里有 2⁵² 个。[2, 4) 里也有 2⁵² 个。可是 [2, 4) 这段有多长?两倍长。同样多的刻度铺在两倍长的路上,间距就翻了一倍

这个「相邻两个 double 之间的距离」有一个专门的名字:ulp(unit in the last place,最后一位的单位)。它是这本书里最重要的一个度量单位——后面所有的误差,都会用「差了几个 ulp」来说,因为只有这个说法在不同量级之间可比。

xulp(x):相邻两个 double 的距离
12.220446 × 10⁻¹⁶= 2⁻⁵²
1 0001.136868 × 10⁻¹³
1 000 0001.164153 × 10⁻¹⁰
10⁹1.192093 × 10⁻⁷≈ 0.1 微秒
10¹⁵0.125已经比 1 小不了多少
10¹⁶2★ 相邻两个 double 差 2 ⇒ 中间的奇数根本不存在
10¹⁷16这一带只有 16 的倍数

所以:

> 1e16 + 1 === 1e16
true
> 2 ** 53 + 1 === 2 ** 53
true
> Number.MAX_SAFE_INTEGER
9007199254740991          // = 2⁵³ − 1

2⁵³ 是一道很干净的坎:小于它的整数,double 全都能精确表示;到了它,ulp 正好变成 2,从此奇数消失。再往上,能表示的整数越来越稀。

✎ 术语正名

机器 epsilon(machine epsilon,常写作 ε 或 eps)这个词被用得很乱,至少有两个含义在流通:

  • ulp(1) = 2⁻⁵² = 2.220446049250313 × 10⁻¹⁶。这是「1 之后的下一个 double 与 1 的距离」。C 的 DBL_EPSILON、Python 的 sys.float_info.epsilon、JS 的 Number.EPSILON、numpy 的 finfo(float64).eps 都是这个。
  • 2⁻⁵³ = 1.1102230246251565 × 10⁻¹⁶。这是「单次舍入的最大相对误差」,因为最坏也就抹掉半格。数值分析的教科书(比如 Higham)里的 u 是这一个。

差一个 2 倍,在推导误差界的时候会让人对不上。这本书统一用第一个含义eps = 2⁻⁵²,需要「半格」的时候明写 eps/2

最有用的一句话是这个:任何一次浮点运算的相对误差,都不超过 eps/2。写成式子就是 fl(a ⊕ b) = (a ⊕ b)(1 + δ),其中 |δ| ≤ eps/2。整本书的误差分析都从这一行长出来。

那 49.98% 是怎么回事

现在可以回答开头那个问题了。

刻度的疏密由指数决定,而指数是均匀分布在位模式里的:11 位指数有 2048 个取值。其中一半(指数字段小于 1023)对应的数小于 1,另一半对应的数大于等于 1。每一段里的 double 个数都一样,而小于 1 的段数和大于 1 的段数差不多相等。

直接数一下。double 的位模式如果当成无符号整数来读,恰好是单调的(这是 IEEE 754 一个非常漂亮的设计),于是「有多少个正 double 小于 1」就等于「1 的位模式是多少」:

1.0 的位模式 = 0x3FF0000000000000 = 4 607 182 418 800 017 408
              ⇒ [0, 1) 里正好有这么多个正 double

全部有限正 double(不含 0)
            = 0x7FF0000000000000 − 1 = 9 218 868 437 227 405 311

占比 = 4607182418800017407 / 9218868437227405311 = 49.9756%

加上负半轴,结论是:全部 double 里有 49.98% 落在 −1 和 1 之间。剩下那一半要覆盖从 1 一直到 1.8×10³⁰⁸ 的全部范围。

这不是浪费,这是把精度花在了对的地方。浮点数的设计目标从来不是「让每个数都精确」,而是「让相对误差处处一样小」。你关心 1.0000001 和 1.0000002 的区别,也关心 1.0000001×10³⁰⁰ 和 1.0000002×10³⁰⁰ 的区别——但你从来不关心 10³⁰⁰ 和 10³⁰⁰+1 的区别。浮点数就是照着这个假设造的。

◆ 主线

double 保证的是相对精度,不是绝对精度。它承诺:任何一个数存进来,误差不超过它自己的 2⁻⁵³ 倍。它没有承诺任何绝对的东西。

这条承诺的直接后果是:只要你的计算过程里出现了「大数和小数放在一起」,小数就有被整个吃掉的风险——不是精度不够,是格子在那个位置本来就这么粗。第 3、4、8、10 章讲的全是这一件事的不同形态。

格子的两头:次正规数、零、无穷和 NaN

指数字段的两个极端值被留作特殊用途,于是数轴的两端各有一段特别的区域:

位模式表示说明
指数全 0,尾数全 0±0有两个零。+0 === -0true,但 1/+0 = Infinity1/-0 = -Infinity
指数全 0,尾数非 0次正规数白送的那个前导 1 不再白送。这一段刻度是等距的,一直铺到最小的 5×10⁻³²⁴
指数全 1,尾数全 0±Infinity溢出的去处
指数全 1,尾数非 0NaN有 2⁵³−2 个不同的 NaN 位模式。NaN !== NaN

次正规数(subnormal,也叫 denormal)值得单独说一句。没有它的话,最小的正规数 2.2×10⁻³⁰⁸ 到 0 之间会是一片空白,a - b == 0 就不再等价于 a == b。有了次正规数,这个等价关系才成立——这叫渐进下溢(gradual underflow),是 Kahan 当年在 IEEE 754 标准会上力争下来的。代价是某些 CPU 上碰到次正规数会慢一到两个数量级,所以很多高性能库和 GPU 干脆开 flush-to-zero 把它们直接砍成 0。第 18 章会看到,这个开关在 fp16 训练里是个真问题。

⌨ 自己跑一遍

亲手量一遍刻度的间距。ulp(x) 的算法就是「把位模式当整数加 1,再减回去」:

const b = new ArrayBuffer(8), f = new Float64Array(b), u = new BigUint64Array(b);
const bits = x => { f[0] = x; return u[0]; };
const from = n => { u[0] = n; return f[0]; };
const ulp  = x => { x = Math.abs(x); return from(bits(x) + 1n) - x; };

for (const x of [1, 1e3, 1e6, 1e9, 1e15, 1e16, 1e17])
  console.log(x, ulp(x));
console.log(1e16 + 1 === 1e16, 2 ** 53 + 1 === 2 ** 53);

会打出:

1 2.220446049250313e-16
1000 1.1368683772161603e-13
1000000 1.1641532182693481e-10
1000000000 1.1920928955078125e-7
1000000000000000 0.125
10000000000000000 2
100000000000000000 16
true true

node -e "..." / 或者直接粘进浏览器控制台(F12)

Python 里更省事:math.ulp(x) 是内置的(3.9 起)。math.nextafter(x, math.inf) 给出下一个 double。

▸ 在现实里

时间戳。Unix 毫秒时间戳现在是 1.7×10¹² 量级,ulp 约 2.4×10⁻⁴ 毫秒——用 double 存毫秒完全没问题。但换成纳秒就是 1.7×10¹⁸ 量级,ulp 变成 256 纳秒:你存的纳秒时间戳,最后八位是假的。这就是为什么高精度时间一律用 int64,而不是 double。

JavaScript 和后端的经典拉锯。JS 只有 double,Number.MAX_SAFE_INTEGER 是 9 007 199 254 740 991。而雪花 ID、数据库自增 bigint、Discord/Twitter 的消息 ID 全是 64 位整数,普遍超过 2⁵³。JSON 解析一过,末几位就悄悄变了——这不是 JSON 的锅,是 1e16 + 1 === 1e16 的锅。所以这类 ID 在 API 里一律传字符串

游戏里的远方。用 float32(尾数只有 24 位)存世界坐标时,离原点越远刻度越粗,物体开始抖。Minecraft 早期版本(Beta 1.7.3 及更早)在离出生点约 1 250 万格的地方出现过著名的「远境」(Far Lands):地形生成里的浮点计算在那个量级上精度崩掉,世界变成一堵墙。开放世界引擎的通行解法叫浮动原点——不停地把世界坐标系搬到玩家脚下,让玩家永远待在刻度最密的地方。

✗ 这个直觉是错的

「double 有 15 到 17 位有效数字,所以 15 位以内的整数都是精确的。」

「15 到 17 位」说的是十进制有效数字的换算(53 × log₁₀2 ≈ 15.95),它是个近似说法,不是判据。

真正的判据是 2⁵³ = 9 007 199 254 740 992:小于它的整数一个不漏地都能精确表示,等于和超过它就开始漏。9 007 199 254 740 993 存不下,而它是 16 位数——按「15 位以内安全」的说法,它本该没问题。

正确的问法从来不是「有几位有效数字」,而是:在我关心的那个量级上,ulp 是多少?这个数字才能直接和你的精度要求比大小。

◇ 对账

正确答案是 C49.9756% 的 double 落在 −1 和 1 之间。1e16 + 1 === 1e16 返回 true

A 「−1 到 1 只是极小的一段」——这是在用长度思考。而 double 的分布不是按长度均匀的,是按指数均匀的:每个 2 倍区间分到同样多的刻度。按长度算,(−1,1) 确实只占 10⁻³⁰⁸ 那么点;按个数算,它占了一半。这两种「多」是完全不同的东西,混起来就会得出 A。 B 「大约 10%」——方向对了,量级没到。凡是猜了 A 或 B 的人,直觉里都有一条隐含假设:数轴上的点是均匀铺开的。这本书剩下的部分会反复把这条假设敲掉。 D 「超过 99%」——过头了。这个答案对应的是「几乎所有精度都花在了 0 附近」,但指数字段是对称分割的:小于 1 的段和大于等于 1 的段各占一半,所以是 50%,不会更多。差的那 0.02% 来自指数全 1 那 2⁵² 个位模式被拿去做 Infinity 和 NaN 了。
◈ 换一种写法

知道 ulp 之后,最容易换掉的一处写法是「什么类型存什么东西」:

用 double 存钱的分、纳秒时间戳、数据库自增 ID、区块高度。这些量都在往 10¹⁵ 以上爬,而那一带的 ulp 已经大于 1。 凡是计数性质的量(分、纳秒、ID、序号),一律用整数类型;JS 里用 BigInt 或字符串。判据很简单:这个量会不会超过 2⁵³?会,就不能用 double。

注意这一条和「精度不够」无关。哪怕换成 128 位浮点,坎只是挪到了 2¹¹³——计数的东西就该用计数的类型,这是类型选择问题,不是精度问题。

这一章的一句话

double 的刻度按指数均匀、按数值不均匀;它承诺的是相对精度,所以只要大数和小数被放进同一个式子,小的那个就有被整根吃掉的风险。

下一章去看这本书里最反直觉的一件事:减法本身不产生任何误差——两个 double 相减,结果如果落在格子上就是精确的。可是恰恰是减法,能在一步之内毁掉一个算式的全部有效位。计算 √(x+1) − √x,在 x = 1e8 处,直接照着写会得到 0.00005000000055588316,而正确答案是 0.000049999999875——八位有效数字,一步蒸发。