4. 浮点加减法的误差传播

浮点加减法看起来只是“把两个数加起来”,但在 IEEE 754 双精度(binary64)下,一次加法要经过对阶、尾数相加、规格化、舍入四个阶段,每一步都可能引入误差。理解误差从哪里来,比记住“浮点不精确”这句结论重要得多。本节围绕三个关键机制展开:对齐移位造成的低位丢失、尾数截断/舍入造成的不可逆信息损失,以及灾难性抵消造成的有效位急剧衰减,并用可运行的代码演示“大数吃小数”和“抵消放大误差”两类典型现象。

一、对齐移位:指数差决定谁被“挤出去”

设两个双精度数 x、y,尾数各 53 位(含隐含位)。相加前必须让两者阶码一致,通常把阶码较小的数右移,使其指数等于较大者。右移的位数 d = |Ex − Ey|。被右移的数每右移一位,最低位就移出有效尾数范围,只能进入保护位、舍入位和粘滞位(guard/round/sticky)。当 d 超过约 53 时,较小数的全部有效位都被挤出,它对结果的贡献只剩“是否触发舍入”这一比特信息。

这就是“大数吃小数”的根源:不是加法算错了,而是小数的信息在对齐阶段就已经离开了可表示范围。下面用 Python 演示,所有输出均为示例环境下的合理结果,读者可在本机复现。

import math

a = 1.0e16          # 约 2^53.15
b = 1.0
print(a + b == a)   # True:b 被完全吃掉
print((a + b) - a)  # 0.0,b 的信息彻底丢失

# 连续累加:小数被反复吞掉
s = 0.0
for _ in range(10_000_000):
    s += 1.0e-8
print(s)            # 远小于 0.1,误差随规模累积

# 用 math.fsum 做精确求和作为对照
vals = [1.0e-8] * 10_000_000
print(math.fsum(vals))  # 接近 0.1

注意 a + b == a 为 True 并不代表程序有 bug,而是 binary64 在 1e16 附近的 ulp(单位末位)约为 2,1.0 小于半个 ulp,舍入后结果回到 a。判断“是否被吃掉”的实用标准是:|y| < 0.5·ulp(x) 时,x + y 在默认舍入模式下等于 x。

二、尾数截断与舍入:误差的第二次注入

对齐之后,两个 53 位尾数相加可能产生 54 位甚至更多位的结果,必须舍入回 53 位。IEEE 754 默认使用“就近舍入、偶数优先”(round-to-nearest, ties-to-even),它保证单次运算的相对误差不超过 0.5 ulp,即机器 epsilon 的一半(binary64 的 eps ≈ 2.22e-16)。但“单次误差有界”不等于“多次运算误差有界”:误差会随运算次数累积,也可能被后续的减法放大。

一个常被忽视的细节是双重舍入:在 x87 80 位扩展精度或某些融合乘加(FMA)场景中,中间结果先舍入到扩展精度、再舍入到目标精度,可能比单次舍入多出 1 ulp 的偏差。工程上应尽量让中间结果保持在目标精度,或使用 FMA 减少一次舍入。

三、灾难性抵消:减法把相对误差放大

当两个相近的数相减时,结果的绝对值很小,而参与运算的两个数各自的绝对误差并未减小,于是相对误差被放大。设 x、y 相近,各自有绝对误差 ε,则 x − y 的相对误差约为 ε/|x − y|。|x − y| 越小,放大越严重。这就是灾难性抵消(catastrophic cancellation)。

# 经典示例:用公式 (1 - cos x) / x^2 计算 x 很小时的函数值
import math

def f_naive(x):
    return (1.0 - math.cos(x)) / (x * x)

def f_stable(x):
    # 1 - cos x = 2 sin^2(x/2),避免相近数相减
    s = math.sin(x / 2.0)
    return 2.0 * s * s / (x * x)

for x in (1e-2, 1e-4, 1e-6, 1e-8):
    print(f"{x:.0e}  naive={f_naive(x):.12f}  stable={f_stable(x):.12f}")

示例输出中,x = 1e-8 时 naive 版本会得到 0.0 或明显偏离 0.5 的值,而 stable 版本稳定趋近 0.5。原因很直接:cos(1e-8) 与 1.0 的差约为 5e-17,已经接近甚至低于 1.0 附近的 ulp(约 2.2e-16),相减后有效位几乎耗尽。改写为 2sin²(x/2) 后,不再有相近数相减,误差被控制在正常舍入水平。

抵消本身不是错误,它是减法语义的正确结果;错误在于用抵消后的结果去承载本应由原始数据精度支撑的信息。判断是否发生灾难性抵消,可观察结果的有效位数是否远少于输入的有效位数。

四、误差传播的完整链路

把上述机制串起来,一次浮点加法从输入到输出的误差路径可以用下图表示。图中区分了“对齐移位导致的低位丢失”“舍入注入的 0.5 ulp 误差”和“抵消放大相对误差”三个环节,并标出可干预的位置。

浮点加减法误差传播链路(binary64) 输入 x, y 各 53 位尾数 ① 对齐移位 右移 d = |Ex − Ey| 位 ② 尾数相加 结果可达 54+ 位 ③ 规格化+舍入 就近偶数,≤0.5 ulp 输出结果 53 位尾数 误差注入 A:低位丢失 d 越大,被移出的有效位越多 d > 53 时小数几乎全丢 误差注入 B:舍入 单次 ≤ 0.5 ulp 多次运算会累积 误差放大 C:灾难性抵消 相近数相减 → 结果绝对值小 → 相对误差 ≈ ε / |x − y| 有效位从 53 位骤降,后续运算继续放大 可干预点 排序后累加 / Kahan 求和 代数变形避免相近相减 判断依据:结果有效位数显著少于输入有效位数,或 |y| < 0.5·ulp(x) 时 x + y 退化为 x 工程对策:调整运算顺序、使用补偿求和、改写公式、必要时提升到更高精度

五、工程上的应对策略

针对上述三类误差,工程实践中有几条成熟做法。第一,控制对齐移位的规模:累加大量量级差异大的数时,先按绝对值从小到大排序再累加,可显著减少“大数吃小数”。第二,使用补偿求和:Kahan 求和用一个补偿变量记录每次舍入丢失的低位,把误差重新注入后续累加,能把求和误差从 O(n·eps) 降到接近 O(eps)。第三,改写公式规避抵消:如上面的 1−cos x 改为 2sin²(x/2),或用 math.expm1、math.log1p 等专门函数处理小量。第四,用高精度做参照:Python 的 decimal、fractions 或 math.fsum 可作为验证基准,帮助判断误差是否来自算法而非实现。

def kahan_sum(values):
    total = 0.0
    comp = 0.0          # 补偿项,保存被舍入丢失的低位
    for v in values:
        y = v - comp
        t = total + y
        comp = (t - total) - y
        total = t
    return total

vals = [1.0e-8] * 10_000_000
print(kahan_sum(vals))   # 明显优于朴素累加,接近 0.1

需要强调的是,这些手段解决的是误差传播问题,而不是让浮点变成精确算术。判断一段数值代码是否可靠,应关注三点:输入的有效位数量、中间是否出现相近数相减、累加规模与量级差异。把这三项作为代码审查的固定检查项,比事后调试更有效。

快速自检口诀:看指数差(是否超过 53 位)、看减法(是否相近数相减)、看累加(是否大量小量级数被大数吞掉)。三者任一命中,就应重新审视运算顺序或公式形式。