打开浏览器的开发者工具,在控制台输入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位)维护主权重——可以在几乎不损失模型质量的情况下,大幅提升训练速度和降低显存占用。
半精度有两种主要格式:FP16和BF16。
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号因数值计算错误而酿成的悲剧。
理解浮点数,就是理解计算机作为有限状态机与数学理想之间的永恒张力。我们用有限的位数去逼近无限精度的实数,必然要在某个地方妥协。
关键在于,知道在哪里妥协,以及妥协的代价是什么。
参考资料
-
IEEE Standard for Floating-Point Arithmetic (IEEE 754-2019). IEEE Computer Society, 2019.
-
Goldberg, D. (1991). What Every Computer Scientist Should Know About Floating-Point Arithmetic. ACM Computing Surveys, 23(1), 5-48.
-
Kahan, W. (1998). An Interview with the Old Man of Floating-Point. IEEE Computer.
-
GAO Report IMTEC-92-26. Patriot Missile Defense: Software Problem Led to System Failure at Dhahran, Saudi Arabia. February 1992.
-
Nion, A. (2019). The secret life of NaN. anniecherkaev.com.
-
Python Documentation. 15. Floating-Point Arithmetic: Issues and Limitations.
-
0.30000000000000004.com - Floating Point Math.
-
Kahan, W. (1965). Further Remarks on Reducing Truncation Errors. Communications of the ACM.
-
Higham, N. J. (2002). Accuracy and Stability of Numerical Algorithms (2nd ed.). SIAM.
-
Muller, J.-M. et al. (2010). Handbook of Floating-Point Arithmetic. Birkhäuser.
-
Intel Corporation. (2018). IEEE Standard 754 for Binary Floating-Point Arithmetic.
-
Wikipedia. IEEE 754 - Binary Floating-Point Arithmetic.
-
Sleipner A Platform Failure. SINTEF Civil and Environmental Engineering Report.
-
Hough, D. G. (2019). The IEEE Standard 754: One for the History Books. IEEE Computer.
-
PyTorch Documentation. What Every User Should Know About Mixed Precision Training.