同一份账,六种加法,六个答案
这是这本书的招牌。把一堆数加起来,是编程里最没有悬念的操作——它没有分支、没有近似、没有可调参数,只有一个 for 循环。可是同一份数据,六种写法会给出六个不同的答案,最好和最差之间差 七个数量级。而其中最差的那一种,正是绝大多数人会写的那一种。
一家小交易所的一天。开盘余额 4321.55 元,当天发生 50 万笔转账。每笔转账在流水里留下两条分录(复式记账:转出方 −v,转入方 +v),金额从 0.01 元到 100 亿元不等(对数均匀分布),全部打乱成一份一百万条的流水。
因为每一笔都是有出有进,所有转账精确地互相抵消(+v 和 −v 是位模式完全相反的两个 double,相加严格为零)。所以收盘余额的正确答案精确已知:还是 4321.55。
现在写一段最自然的代码来结账:
let 收入 = 0, 支出 = 0; for (const 分录 of 流水) 分录 >= 0 ? (收入 += 分录) : (支出 += 分录); const 余额 = 收入 + 支出;
问:这样算出来的余额是多少?
第二问:如果改用 Kahan 补偿求和——那个专门为了消除累加误差而发明的经典算法——能算对吗?
六种写法,一份数据
下面这六种,全部跑在同一份一百万条的流水上,同一台机器,同一个 double:
| 写法 | 算出来的余额 | 差多少钱 | 差几个 ulp |
|---|---|---|---|
| ① 先收后支再相减 最自然、最多人写的那种 | 4320.46875 | −1.0813 元 | 1 188 846 947 533 |
| ② 朴素累加 按流水顺序一路加 | 4321.555351564428 | +0.0053516 元 | 5 884 107 315 |
| ③ 先按金额排序再加 教科书推荐:先加小的 | 4321.549999237061 | −7.63 × 10⁻⁷ 元 | 838 861 |
| ④ 成对求和 分治,numpy 默认的做法 | 4321.55029296875 | +2.93 × 10⁻⁴ 元 | 322 122 547 |
| ⑤ Kahan 补偿求和 1965 年的经典 | 4321.550004312536 | +4.31 × 10⁻⁶ 元 | 4 741 683 |
| ⑥ Neumaier 补偿求和 Kahan 的 1974 年修正版 | 4321.5500000000002 | 0 | 0 |
| ★ 精确(正确舍入) | 4321.5500000000002 | — | — |
六个答案。最好和最差之间差了 七个数量级。
而且请注意排序:写起来最自然的那个是最差的。
① 为什么「先收后支再相减」最差
这个写法在业务上完全合理——报表本来就要分别列出收入和支出。可它在数值上是这一章所有陷阱的合体:
Σ收入 = 181 107 043 984 376.00 元
Σ支出 = −181 107 043 980 055.53 元
└────────┬────────┘
两个 1.8×10¹⁴ 的数
ulp(1.8×10¹⁴) = 0.03125 元 ← 相减之前,精度就只剩三分二厘了
相减 ⇒ 4320.46875
这是第 3 章那件事:两个几乎相等的大数相减。
κ = (1.8e14 + 1.8e14) / 4321.55 ≈ 8.4 × 10¹⁰
更值得看的是下面这个——就算两边的累加都用精确算术做(一次抹零都不发生),最后那一步减法还是要在 double 里做:
Σ收入(精确后存 double)= 181107043984375.06
Σ支出(精确后存 double)= −181107043980053.53
相减 = 4321.53125
⟨差 −0.01875 元⟩
★ 两边算得再准都没用。**问题出在「把它写成两个大数的差」这个决定上。**
这句话值得停一下。你在选择数据结构和计算顺序的时候,就已经把条件数定下来了。之后写多好的求和算法,都只是在一个已经被定死的天花板下面挣扎。
③ 排序为什么赢了,以及为什么它赢得可耻
「先加小的」是数值分析教科书里最常见的一条建议,第 4 章也提过。这里它确实拿了个不错的成绩(差 838 861 个 ulp,比朴素累加好三个数量级)。
但它赢的理由经不起看:每笔转账的 +v 和 −v 绝对值一模一样,按绝对值排序之后,它们被排到了紧挨着的位置,一加就当场对消。累加器因此从头到尾都待在很小的数上,脚下的刻度一直很细。
换一份数据——比如金额有零有整、正负不成对的真实流水——这个优势立刻消失。排序是一条依赖数据分布的建议,不是一条定理。而这一章要找的是不依赖运气的写法。
「先加小的」不是一条可靠的规则,它是一条关于特定数据的观察。
可靠的规则只有两条,而且都不需要看数据:
- 别让累加器长得比它该有的大。——分治(④)就是干这个的:每个中间累加器只加 log n 层,永远长不大。
- 把每一步抹掉的量记下来,还回去。——补偿求和(⑤⑥)就是干这个的。
它俩正交,可以叠加使用。而且都是 O(n),代价只是常数倍。
④ 成对求和:五行代码,免费的三个数量级
成对求和(pairwise summation)的想法一句话说完:别顺着加,对半分。
function pairwiseSum(a, lo = 0, hi = a.length) {
const n = hi - lo;
if (n <= 128) { // 小块直接加,让 CPU 跑满
let s = 0;
for (let i = lo; i < hi; i++) s += a[i];
return s;
}
const mid = lo + (n >> 1);
return pairwiseSum(a, lo, mid) + pairwiseSum(a, mid, hi); // 分治
}
误差从 O(√n) 降到 O(√log n)——一百万个数,√n = 1000,√log₂n ≈ 4.5。而速度和朴素累加基本一样(因为小块内部还是顺序加,缓存友好;分治只多了 log n 层函数调用)。
这就是为什么 numpy.sum 默认就是它。不用你做任何事,你已经在用一个比 for 循环好三个数量级的求和算法了。
顺带一个很有意思的细节:这台机器上,我写的成对求和给出 4321.55029296875,而 numpy.sum 给出 4321.5501708984375——两个都叫「成对求和」,答案不一样,因为 numpy 的内层小块是按 8 路展开的,累加顺序不同。这一条会在第 19 章变成一整章。
⑤⑥ 补偿求和,以及 Kahan 为什么会输
补偿求和的想法更聪明:每加一次,都把「刚才被抹掉的那一点」算出来,存在一个小变量里,下一次加之前先补回去。
// Kahan(1965)
function kahanSum(a) {
let s = 0, c = 0; // c 装着「上次欠下的」
for (const x of a) {
const y = x - c; // 先把欠账还上
const t = s + y; // 加(这一步会抹零)
c = (t - s) - y; // ★ 算出这次抹掉了多少
s = t;
}
return s;
}
第四行是全部的魔法:(t − s) 是「累加器实际增加了多少」,减去 y(本该增加多少),差就是被抹掉的量。而根据第 3 章的 Sterbenz 引理,这两步减法本身是精确的——补偿项被完整地捞了出来。
可是在这份数据上,Kahan 输给了 Neumaier,差了 4.31 × 10⁻⁶ 元。为什么?
因为 Kahan 有一个前提:|s| ≥ |x|,累加器比新来的数大。这时 (t − s) 才等于「s 增加的量」。而这份流水里金额跨了十二个数量级,经常有一笔 100 亿砸进一个只有几百块的累加器——这时候被抹掉的是 s 的低位,不是 x 的,Kahan 那行减法捞出来的东西就不对了。
Neumaier 在 1974 年补了这个洞,办法很朴素:比一下谁大,谁大就用谁的公式。
// Neumaier(1974)—— 也叫 Kahan–Babuška–Neumaier
function neumaierSum(a) {
let s = 0, c = 0;
for (const x of a) {
const t = s + x;
c += Math.abs(s) >= Math.abs(x)
? (s - t) + x // 累加器大:丢的是 x 的低位
: (x - t) + s; // 新数大:★ 丢的是 s 的低位
s = t;
}
return s + c; // 最后一次性补回
}
多了一个 if。就这一个 if,把 4.31×10⁻⁶ 的误差变成了 0——它在这份数据上给出的是正确舍入的结果,和精确算术逐位相同。
正确舍入(correctly rounded)是这本书里精度的最高级别,值得单独定义:
一个运算叫「正确舍入」的,如果它给出的结果恰好等于「用无限精度算完,再存一次 double」。也就是说,它的误差最多半个 ulp,一格都不多。
IEEE 754 要求加减乘除和开方必须正确舍入(这是这个标准最重要的一条)。但「一串数的和」不在其中——标准只管单次运算。所以「正确舍入的求和」需要专门的算法:Python 的 math.fsum(用 Shewchuk 的多重展开法)、Neumaier 在多数情况下、或者像这本书的探针那样直接用 BigInt 精确算完再舍一次。
顺带说一句,标准库的 sin、exp、pow 这些不保证正确舍入——这就是第 19 章「不同平台答案不同」的一个来源。
这一章值得完整复现。Python 自带 math.fsum(正确舍入),可以直接当真值用:
import math, random
random.seed(19910225)
led = [4321.55] # 开盘余额
for _ in range(500_000): # 五十万笔转账 = 一百万条分录
v = 10 ** random.uniform(-2, 10) # 0.01 元 到 100 亿元
led += [v, -v] # 复式记账:一出一进
random.shuffle(led)
def naive(a):
s = 0.0
for x in a: s += x
return s
def kahan(a):
s = c = 0.0
for x in a:
y = x - c; t = s + y; c = (t - s) - y; s = t
return s
def neumaier(a):
s = c = 0.0
for x in a:
t = s + x
c += (s - t) + x if abs(s) >= abs(x) else (x - t) + s
s = t
return s + c
pos = [x for x in led if x >= 0]; neg = [x for x in led if x < 0]
print('① 先收后支再相减 ', repr(naive(pos) + naive(neg)))
print('② 朴素累加 ', repr(naive(led)))
print('③ 排序后累加 ', repr(naive(sorted(led, key=abs))))
print('⑤ Kahan ', repr(kahan(led)))
print('⑥ Neumaier ', repr(neumaier(led)))
print('★ math.fsum ', repr(math.fsum(led)))
print(' 真值应为 4321.55')
具体数字会和上面的表不同(这里用的是 Python 的随机数,书里那份是另一台生成器),但六个答案不一样、①最差、⑥最好这个格局一定重现。装了 numpy 的话再加一行 numpy.sum(led),会得到第七个答案。
python3 ledger.py # 一百万条,几秒钟
在线跑用 Google Colab(一百万条在 python.org/shell 上会超时;改成 5 万笔转账也能看出全部现象)。
金融系统里的求和是有法定精度要求的。这就是为什么会计系统一律不用 double——第 17 章会讲怎么做。但这一章的教训对任何求和都成立,不只是钱。
深度学习里的 loss 和梯度归约。一个 batch 的 loss 是几千个样本 loss 的和;一次梯度同步是几十上百块卡的求和。这些求和用的都是 fp16 或 bf16——尾数只有 8 到 11 位,误差比这一章大好几个数量级。PyTorch 的 sum 在 CUDA 上默认用树形归约(也就是成对求和的同类),并且允许把累加器提到 fp32(dtype=torch.float32)。这两个默认设置都是这一章的直接产物。
监控和统计聚合。Prometheus 的 counter、时序库的 sum_over_time、埋点系统里的日活累加——都是长时间累加。它们通常用 float64 而不是 float32,理由就在这里。
物理引擎和 N 体模拟。算合力就是求和,而合力经常是「一堆大力互相抵消之后剩下的小量」——第 3 章的相消 + 这一章的累加,两个坑叠在一起。天体力学的长期积分因此几乎一定要用补偿求和。
「求和就是求和,一个 for 循环还能有什么讲究。数据量大的时候顶多慢一点。」
求和是最容易踩坑、也最容易免费修好的操作之一。同一份数据六种写法七个数量级,而最好的那种(Neumaier)只比 for 循环多五行代码、慢两到三倍;成对求和更是几乎不要钱。
另一半错误是「慢一点」这个假设。数值上更好的写法往往更快:成对求和对缓存和向量化都更友好,numpy 用它是因为它又准又快,不是因为它准所以忍受它慢。
真正该记的判据是这三个问题:① 累加器会长到多大?② 数据里有没有跨量级的项?③ 最终结果是不是「大数抵消后剩下的小量」?三个都答「是」,就必须换写法——比如这一章的这份账。
正确答案是 D:4320.46875,差 1.0813 元。第二问:Kahan 算不对——它给出 4321.550004312536,差 4.31×10⁻⁶ 元;要 Neumaier 才行。
A 「每一笔都精确,加起来当然精确」——前半句对(每笔金额和它的相反数都是精确的 double,两两相加严格为零),后半句是这一章的全部内容。问题不在数据,在你把它们加起来的顺序上。同一份数据,顺序一换,答案就换。 B 「差一两个 ulp」——如果余额那一格的 ulp 是 7.1×10⁻¹³(4321.55 附近),那差一两个 ulp 是 10⁻¹² 量级。实际差了 1.08 元,相差十二个数量级。差距全部来自「中途累加器爬到了 1.8×10¹⁴」——在那个高度,ulp 是 0.03125 元。误差是在山顶上产生的,不是在终点。 C 「差五厘钱」——这是朴素累加(②)的答案,4321.555351564428。猜 C 的人多半在想「顺着加一遍」这个写法。它确实比先分组好两个数量级,因为累加器是随机游走(第 4 章),不会一路爬到 1.8×10¹⁴。把一件事拆成两半分别做完再合并,有时候比一口气做完更糟——这一点很违反工程直觉。这一章的改写有两层,第二层比第一层重要得多:
let s = 0; for (const x of a) s += x;
第一层(改算法):换成成对求和(免费,快,三个数量级)或 Neumaier(五行,慢两三倍,通常正确舍入)。numpy / PyTorch 用户其实已经在用成对求和了,前提是你调的是 np.sum 而不是自己写 for。
第二层(改问法):别把「余额」写成「Σ收入 + Σ支出」。这个式子的条件数是 8.4×10¹⁰,写什么求和算法都救不回来。要么直接累加净额,要么用整数分。
第二层才是这一章真正的教训:你选择的计算路径决定了条件数,而条件数决定了天花板。先选路径,再挑算法——顺序反了,后面做多少优化都是在天花板底下折腾。
这一章的一句话
求和不是一个动作,是一个有六种写法、七个数量级差距的设计决策;而其中最贵的那笔账,是在你决定「先分组再相减」的那一刻就记下的。
卷 II 到这里结束。你现在有了完整的诊断工具:条件数(问题)、后向稳定(算法)、以及连接它们的那条公式。
卷 III 换一件事做:动手改。接下来四章里,每一章都是同一个动作——不换语言、不加精度、不改数据,只把式子换一种代数上完全等价的写法,然后看误差掉几个数量级。第一个例子是每个人初中就会的求根公式:解 x² + 10⁹x + 1 = 0,公式给出的小根是 0;改一行,得到 −10⁻⁹。