打开浏览器的开发者工具,在控制台输入0.1 + 0.2。结果不是0.3,而是0.30000000000000004

这不是JavaScript的bug。Python、Java、C、Rust——几乎所有主流语言都会给出同样的答案。你的编程语言没有错,错的可能是你对计算机如何处理数字的直觉。

这个看似微不足道的精度问题,在1991年2月25日的沙特阿拉伯达兰市,导致28名美军士兵丧生。

一个不可能精确表示的数

问题的根源在于进制转换。

在十进制系统中,我们能精确表示 1/2(0.5)、1/4(0.25)、1/5(0.2)、1/8(0.125),因为这些分数的分母只包含10的质因数2和5。但 1/3 是无限循环小数0.333…,因为3不是10的因数。

计算机使用二进制。二进制的唯一质因数是2。这意味着,只有分母是2的幂的分数才能被精确表示。1/2(0.1二进制)、1/4(0.01二进制)、1/8(0.001二进制)——这些都没问题。

但 1/10 呢?

1/10 在二进制中是无限循环小数:0.0001100110011001100110011...

就像十进制无法精确表示 1/3 一样,二进制也无法精确表示 1/10。当你在代码中写下0.1时,计算机实际上存储的是一个近似值。在IEEE 754双精度浮点数中,这个近似值精确等于:

3602879701896397 / 2^55 ≈ 0.1000000000000000055511151231257827021181583404541015625

同样,0.2存储的是另一个近似值。当这两个近似值相加时,结果必然也是一个近似值——而且不等于0.3的近似值。

这不是实现错误,而是数学上的必然。只要使用有限位数的二进制,就无法精确表示某些十进制小数。

IEEE 754:混乱中诞生的秩序

在1985年之前,浮点数世界是混乱的。

IBM使用十六进制浮点格式,指数固定为7位。CDC和Cray使用反码表示,同时存在+0和-0。有些计算机在做乘法时会"丢失"最后四位;有些计算机的数值在做加法时可以继续变大,但乘以1.0就会溢出。

程序员不得不为每种计算机编写特定版本的代码,或者插入各种奇怪的"魔法语句"来应对不同机器的怪异行为。可靠的、可移植的数值软件成本高昂,几乎只有AT&T和五角大楼负担得起。

1976年,Intel开始为其8086/8处理器设计浮点协处理器。John Palmer博士说服Intel需要一个统一的算术标准。他找到了加州大学伯克利分校的William Kahan——一位在数值分析领域深耕多年、曾为惠普计算器提升数值能力的专家。

Kahan和他的学生Jerome Coonen、访问教授Harold Stone一起,起草了一份后来被称为"K-C-S"的规范。这份规范不仅定义了格式和运算,更重要的是为每个设计决策提供了详尽的理由。

IEEE p754委员会面临多个提案。DEC主张采用其VAX格式,这有庞大的现有安装基础支持。但VAX的"D"格式双精度只有8位指数——这个范围后来被证明太窄。DEC随后推出了"G"格式,采用11位指数,这正是K-C-S选择的范围。

真正的争议焦点是渐进式下溢(Gradual Underflow)

渐进式下溢:一场持续多年的战争

在IEEE 754之前,几乎所有计算机都采用"刷零"策略:当数值小到一定程度时,直接变成零。这在小数和零之间创造了一个巨大的"鸿沟"——比相邻浮点数之间的距离大出许多个数量级。

Kahan认为这是一个危险的设计。当一个程序跌入这个鸿沟时,可能会产生灾难性的错误。大学计算中心的数据显示,在1970年代,每台DEC计算机每月至少有一个用户因为下溢问题而受害。

渐进式下溢用"次正规数"(subnormal numbers)填补这个鸿沟。当数值低于最小正规数时,精度逐渐降低,但数值本身不突然归零。这使得对于有限浮点数x和y,x - y == 0当且仅当x == y——一个程序员理所当然期望成立的不变量。

但这个设计引发了激烈的反对。DEC的数值专家Mary Payne领导了反对阵营。他们声称渐进式下溢会严重拖慢硬件速度,因为需要额外的步骤来处理次正规数。

争议持续了多年。1981年,DEC委托马里兰大学的G.W. (Pete) Stewart III教授评估渐进式下溢的价值——显然期望他证明这是一个坏主意。但在波士顿的一次会议上,Stewart口头报告的结论是:权衡利弊,渐进式下溢是正确的选择。

DEC在会议主场的这次挫败,动摇了他们继续在委员会层面反对K-C-S的决心。最终,IEEE 754-1985标准通过,渐进式下溢成为标准的一部分。

Kahan后来透露,他当时确实知道如何在硬件中高效实现渐进式下溢——但他不能透露,因为那是Intel的商业机密。他在Intel的i8087设计中已经实现了这些优化,却只能对委员会说"这应该是可行的"。

28条生命:Patriot导弹的代价

1991年2月25日,海湾战争。一枚伊拉克飞毛腿导弹射向沙特阿拉伯达兰市的美军军营。

爱国者导弹防御系统已经连续运行了约100小时。它的雷达系统追踪到飞毛腿导弹,但拦截失败了。导弹击中军营,28名士兵丧生,约100人受伤。

事后调查显示,失败的原因是一个软件精度错误。

爱国者系统使用一个24位定点寄存器来存储时间。系统内部时钟以1/10秒为单位计时,转换为秒时需要乘以1/10。问题在于,1/10在二进制中是无限循环小数。24位截断后的误差约为0.000000095秒。

运行100小时后,累积误差达到约0.34秒。

飞毛腿导弹的速度约为每秒1676米。0.34秒的误差意味着约573米的距离偏差——这足以让目标移出爱国者导弹追踪的"范围门"。系统根本"看"不到导弹在预期位置,因此没有发射拦截弹。

讽刺的是,这个精度问题已经在部分代码中修复,但不是全部。这意味着不同部分的计算误差不会相互抵消,反而会叠加。

GAO的报告指出:错误与目标速度和系统运行时间成正比。运行时间越长,误差越大;目标越快,影响越严重。

这是一个典型的浮点数精度问题被放大到灾难性后果的案例。如果系统定期重启,或者使用更高精度的表示,悲剧可能避免。但"定期重启"这个解决方案本身,就暴露了对浮点数行为理解的缺失。

Ariane 5:另一种溢出

1996年6月4日,欧洲空间局的阿丽亚娜5号火箭首次发射。这枚火箭耗资70亿美元开发,运载的货物价值5亿美元。

发射后约40秒,火箭偏离轨道,解体爆炸。

调查报告揭示,失败源于惯性参考系统的一个软件错误:一个64位浮点数被转换为16位有符号整数。这个数值超过了16位整数的最大值(32767),转换失败,导致系统崩溃。

更讽刺的是,这段代码来自阿丽亚娜4号。在4号上,这个特定变量不会达到如此大的值;但5号的水平速度更高,数值超过了阈值。

这是一个与精度无关,但与数值表示的边界条件密切相关的错误。IEEE 754标准设计了Infinity来处理溢出——如果原始代码使用的是符合标准的浮点运算而非整数转换,系统可能只是收到一个Infinity值,然后继续运行,而不是崩溃。

William Kahan后来评论:阿丽亚娜5号的悲剧,“在默认的IEEE 754浮点策略下本不会发生”。

NaN:不是数字的数字

IEEE 754中最奇特的设计之一是NaN(Not a Number)。

任何了解浮点数的人都知道,0/0或√(-1)会产生NaN。但NaN的设计远比"错误标记"复杂。

标准定义了两类NaN:安静NaN(quiet NaN, qNaN)信号NaN(signaling NaN, sNaN)。安静NaN会在运算中静默传播;信号NaN则可能触发异常。

更引人注目的是NaN的"载荷"(payload)。在双精度浮点数中,NaN有51位可以用于存储任意信息。标准建议"尽可能保留NaN中的诊断信息",这为创造性使用留下了空间。

实践中,这个空间被用于NaN-boxing:在NaN的载荷中存储所有其他类型的值和类型标签。JavaScriptCore(Safari的JavaScript引擎)和LuaJIT都使用这种技术,在64位中表示所有语言值——指针、整数、布尔值——而不需要额外的内存分配。

这是一个优雅的黑客技巧,利用标准有意留下的空白,实现了动态类型语言的高效实现。

机器epsilon:认识精度边界

要量化浮点数的精度极限,关键概念是机器epsilon

机器epsilon定义为1.0与下一个可表示的浮点数之间的差值。对于双精度浮点数,这个值约为2.22 × 10^-16

理解机器epsilon的含义很重要:它不是浮点数的"绝对误差",而是相对误差的界。对于任何浮点运算,相对误差通常不超过机器epsilon的量级。

但这意味着精度是不均匀的。在1.0附近,精度约为10^-16;但在10^16附近,精度只有约1。数值越大,可区分的"步长"越大。

这就是为什么游戏开发者在处理大世界坐标时头疼。当玩家远离原点时,坐标值增大,精度降低。在32位浮点数中,距离原点约3300万单位处,精度已降至约2——相邻坐标之间的"间隙"已经超过一个整数单位。

Minecraft的世界边界问题就是这个原因:使用32位浮点数的物理引擎在远离出生点时开始出现诡异的行为。

比较的艺术:epsilon不是万能药

当需要比较浮点数时,最常见的建议是"不要用==,用epsilon"。但这个建议往往被误用。

错误的写法:

if abs(a - b) < 0.0001:  # 硬编码的epsilon

这种方法的问题是:对于0.0001级别的数值,0.0001的epsilon是巨大的;对于1e20级别的数值,0.0001的epsilon则毫无意义。

更合理的方法是使用相对误差

def approximately_equal(a, b, rel_tol=1e-9):
    return abs(a-b) <= max(rel_tol * max(abs(a), abs(b)), 1e-12)

Python的math.isclose()函数实现了这个逻辑,同时提供了绝对误差和相对误差的选项。

但即使这个方法也不是万能的。当数值接近零时,相对误差会失效(除以接近零的值)。对于不同应用场景,可能需要不同的比较策略。

Kahan求和:一个聪明的补救

当累加大量浮点数时,精度损失会累积。简单的for x in array: sum += x会产生显著误差。

1965年,Kahan发明了补偿求和算法,后来被称为Kahan求和:

def kahan_sum(arr):
    sum = 0.0
    c = 0.0  # 补偿变量
    for x in arr:
        y = x - c
        t = sum + y
        c = (t - sum) - y
        sum = t
    return sum

核心思想是追踪每次加法"丢失"的精度,并在后续加法中补偿。这可以将误差从O(n·ε)降低到O(ε),几乎消除累积误差。

Python的math.fsum()实现了更强大的版本,追踪所有"丢失的数字",确保最终结果只有一次舍入。对于需要高精度求和的场景,这是比手写循环更好的选择。

BigDecimal:金融世界的救星

对于涉及货币的应用,浮点数的精度问题不可接受。这就是为什么Java有BigDecimal,Python有decimal模块。

这些实现使用十进制而非二进制表示。每个数字都以十进制形式存储,计算也以十进制进行。这意味着0.1 + 0.2精确等于0.3,正如人类直觉所期望。

但便利的代价是性能。BigDecimal运算比原生浮点数慢一个数量级以上,且需要更多内存。对于科学计算中的大规模数组运算,这种开销不可接受。但对于金融系统,正确性比速度更重要。

使用BigDecimal的正确姿势是:

// 错误:double已经损失精度
BigDecimal bad = new BigDecimal(0.1);

// 正确:使用字符串构造
BigDecimal good = new BigDecimal("0.1");

数据库设计也反映了这种权衡。MySQL和PostgreSQL都提供DECIMAL/NUMERIC类型,用于需要精确十进制表示的列。存储空间和计算代价更高,但避免了金融错误的风险。

混合精度:AI时代的权衡

深度学习训练催生了对浮点数的重新思考。

传统科学计算使用双精度(64位)。但神经网络训练发现,混合精度——使用半精度(16位)进行前向传播,单精度(32位)维护主权重——可以在几乎不损失模型质量的情况下,大幅提升训练速度和降低显存占用。

半精度有两种主要格式:FP16BF16

FP16(IEEE半精度)有5位指数和10位尾数。问题在于其动态范围有限——最大值约65504,容易出现溢出。

BF16(Brain Float 16)由Google Brain提出,有8位指数和7位尾数。指数位数与FP32相同,意味着动态范围几乎不变,只是精度降低。这使BF16在训练中更"稳定",不需要复杂的loss scaling技巧。

这是浮点数设计权衡的典型体现:精度 vs 范围 vs 存储。不同应用场景需要不同的平衡点。

2029:下一个路口

IEEE 754-2019是标准的当前版本,主要是澄清和缺陷修复。下一个主要修订计划在2029年。

标准面临的新挑战包括:人工智能对更低精度格式的需求、量子计算对数值表示的新要求、以及十进制浮点数在硬件层面的更广泛支持。

同时,一些研究者开始探索浮点数的替代方案。John Gustafson提出的posit格式声称在相同位数下提供更高的精度和更广的范围。但这些替代方案尚未获得足够的行业动力来挑战IEEE 754的主导地位。

回到起点

当你在控制台输入0.1 + 0.2时,看到的那个奇怪的0.30000000000000004,不是bug,而是四十年计算机科学发展史上无数权衡和妥协的缩影。

它反映了二进制与十进制的根本不兼容,记录了IEEE p754委员会数年的激烈争论,承载了William Kahan对"即使是不懂数值分析的程序员也能写出正确代码"的愿景,也见证了Patriot导弹和Ariane 5号因数值计算错误而酿成的悲剧。

理解浮点数,就是理解计算机作为有限状态机与数学理想之间的永恒张力。我们用有限的位数去逼近无限精度的实数,必然要在某个地方妥协。

关键在于,知道在哪里妥协,以及妥协的代价是什么。


参考资料

  1. IEEE Standard for Floating-Point Arithmetic (IEEE 754-2019). IEEE Computer Society, 2019.

  2. Goldberg, D. (1991). What Every Computer Scientist Should Know About Floating-Point Arithmetic. ACM Computing Surveys, 23(1), 5-48.

  3. Kahan, W. (1998). An Interview with the Old Man of Floating-Point. IEEE Computer.

  4. GAO Report IMTEC-92-26. Patriot Missile Defense: Software Problem Led to System Failure at Dhahran, Saudi Arabia. February 1992.

  5. Nion, A. (2019). The secret life of NaN. anniecherkaev.com.

  6. Python Documentation. 15. Floating-Point Arithmetic: Issues and Limitations.

  7. 0.30000000000000004.com - Floating Point Math.

  8. Kahan, W. (1965). Further Remarks on Reducing Truncation Errors. Communications of the ACM.

  9. Higham, N. J. (2002). Accuracy and Stability of Numerical Algorithms (2nd ed.). SIAM.

  10. Muller, J.-M. et al. (2010). Handbook of Floating-Point Arithmetic. Birkhäuser.

  11. Intel Corporation. (2018). IEEE Standard 754 for Binary Floating-Point Arithmetic.

  12. Wikipedia. IEEE 754 - Binary Floating-Point Arithmetic.

  13. Sleipner A Platform Failure. SINTEF Civil and Environmental Engineering Report.

  14. Hough, D. G. (2019). The IEEE Standard 754: One for the History Books. IEEE Computer.

  15. PyTorch Documentation. What Every User Should Know About Mixed Precision Training.