try ai
科普
编辑
分享
反馈
  • 浮点数运算

浮点数运算

SciencePedia玻尔百科
核心要点
  • 浮点数是对实数的有限近似,这导致了表示上的间隙和不可避免的舍入误差。
  • 基本代数定律(如结合律)在浮点数运算中不成立,这意味着运算顺序会改变最终结果。
  • 浮点数的限制在各个领域都有实际影响,例如导致金融领域的累积误差和科学模拟中的时钟停滞问题。
  • IEEE 754 标准包含无穷大、带符号的零和 NaN 等特殊值,用以处理异常操作并提高计算的健壮性。
  • 像非规格化数这样的特性提供了“渐进下溢”功能,防止了数值突变为零,使数值算法更加稳定。

引言

内存有限的计算机如何表示无限连续的实数?答案是,它不能——至少不能完美地表示。取而代之的是,它依赖于一种被称为浮点运算的巧妙而复杂的近似系统。虽然这个系统是现代科学和数字生活的基石,但它在一套反直觉的规则下运行,在这些规则中,常见的数学定律会发生扭曲和失效。许多程序员没有意识到这些微妙之处,导致代码产生令人困惑和不正确的结果。本文旨在揭开浮点数世界的神秘面纱,为读者提供对计算机数值领域至关重要的理解。

首先,在 ​​原理与机制​​ 部分,我们将根据通用的 IEEE 754 标准剖析浮点数的结构。我们将揭示为何可表示的数之间存在间隙,基本算术运算会如何以意想不到的方式失败,以及像 NaN 和非规格化数这类“奇特生物”的用途。随后,在 ​​应用与跨学科联系​​ 部分,我们将涉足金融、天体物理学和数字音频等多个领域,见证这些原理在现实世界中的后果,揭示有限精度如何导致金钱消失、颜色褪变和模拟失败。

原理与机制

如果你问一位计算机科学家,“你如何将无限、无缝的数字海洋——实数——装入有限的计算机内存盒子中?”,他们可能会微微一笑。坦率的答案是,你做不到。计算机内部的数字世界并非你在数学中学到的那条平滑、连续的线。它是一个离散的、颗粒状的景观,一个由精心挑选的点组成的巨大但有限的集合,用以近似真实世界。理解这个景观,连同它的间隙、奇异的“生物”和独特的算术规则,就像学习一个新宇宙的基本法则。这是一段揭示现代计算背后深邃独创性的旅程。

数字的剖析

从本质上讲,表示实数的问题是一个效率问题。我们人类有一种非常简洁的方式来书写极大或极小的数字:科学记数法。我们不把光速写成每秒 299,792,458299,792,458299,792,458 米;而是写成大约 3×1083 \times 10^83×108 米/秒。我们有一组有效数字(尾数,3)和一个指数(8),它告诉我们小数点应该放在哪里。

计算机做的完全相同,但它们用二进制思考。实现这一点的通用标准被称为 ​​IEEE 754​​,它定义了浮点数的“数字基因”。让我们来剖析最常见的类型——64位 ​​双精度​​ 浮点数。每个“double”都是一个64位的信息包,分为三个部分:

  • ​​符号 (sss):​​ 单个比特。000 代表正数,111 代表负数。很简单。

  • ​​指数 (EEE):​​ 11位字段。它不直接存储2的幂次。相反,它存储一个数字,从中减去一个“偏置值”以获得真实的指数。这个巧妙的技巧使得指数能够表示非常大和非常小的缩放因子。

  • ​​小数部分 (fff):​​ 52位字段,你可以把它看作是二进制小数点后的有效数字。

一个(正的、规格化的)数的值 VVV 是这样重建的: V=(1.f)2×2E−biasV = (1.f)_2 \times 2^{E - \text{bias}}V=(1.f)2​×2E−bias 等等,那个“1.”是从哪里来的?这是该标准的第一个天才之举:​​隐含的前导比特​​。在二进制科学记数法中,任何非零数字都可以被调整为以1开头。例如,数字 101121011_210112​(十进制中的11)可以写成 1.0112×231.011_2 \times 2^31.0112​×23。既然对于大多数数字来说,这个前导1总是存在,为什么还要浪费一个比特来存储它呢?IEEE 754 标准只是假设它存在,从而让我们以52个比特的代价获得了53个比特的精度。这是免费的午餐,在计算世界里,很少有比这更美妙的事情了。

现实中的间隙:精度及其限制

小数部分和指数部分的比特数有限,这导致了浮点运算最重要的一个后果:并非所有数字都能被表示。可表示的数在数轴上就像广阔海洋中的孤岛。任意两个可表示的数之间都有一个间隙。

让我们思考一个能立即揭示这个问题的问题:对于尾数有24位精度的单精度浮点数([binary32](/sciencepedia/feynman/keyword/binary32)),你无法完美存储的第一个正整数是什么?

起初,你可能认为所有整数都可以。在一定范围内,确实如此。整数1是 1.0×201.0 \times 2^01.0×20。整数2是 1.0×211.0 \times 2^11.0×21。整数3是 1.5×211.5 \times 2^11.5×21(即 1.12×211.1_2 \times 2^11.12​×21)。只要一个整数的二进制表示能够被“塞进”24个有效比特内,就没问题。对于所有小于等于 2242^{24}224(即 16,777,21616,777,21616,777,216)的整数,这都完美有效。这个数可以写成 1.0×2241.0 \times 2^{24}1.0×224,其尾数只有一个比特(即隐含的1)。

但下一个整数 N=224+1N = 2^{24} + 1N=224+1 呢?在二进制中,这个数是一个1,后面跟着23个零,最后再跟一个1。 100000000000000000000000121000000000000000000000001_210000000000000000000000012​ 为了表示这个数,我们需要同时捕捉到第一个1和最后一个1。所需比特的总跨度是25位。而我们的尾数只有24位的精度。空间不够!224+12^{24} + 1224+1 掉进了一个间隙里。计算机必须将它舍入到它的邻居之一,即 2242^{24}224 和 224+22^{24}+2224+2。是的,你没看错。在这个数量级上,连续可表示数之间的间隙是2。“奇数”的概念已经消失了。

这就引出了一个关键概念:​​机器精度​​ (ϵmach\epsilon_{mach}ϵmach​)。它被定义为 1.01.01.0 与下一个可表示的浮点数之间的间隙。它是这样一个最小的数,将它加到 1.01.01.0 上可以得到一个确实不同于 1.01.01.0 的结果。对于双精度数,ϵmach\epsilon_{mach}ϵmach​ 是 2−522^{-52}2−52,一个极小的数(约 2.22×10−162.22 \times 10^{-16}2.22×10−16)。

你甚至可以自己做一个实验来发现这一点。从 epsilon = 1.0 开始。然后,在一个循环中,只要 1.0 + epsilon/2.0 仍然大于 1.0,就不断将 epsilon 减半。当循环停止时,你的 epsilon 就是机器精度! 这个简单的算法揭示了你所使用的数字系统的基本粒度。

整数间隙和机器精度之间的联系是深刻的。使计算机认为 N+1N+1N+1 与 NNN 相同的最小整数 NNN 是多少?答案恰好是 N=2/ϵmachN = 2/\epsilon_{mach}N=2/ϵmach​。对于单精度,这个数是 2242^{24}224。对于双精度,这个情况发生在 NNN 约为 9×10159 \times 10^{15}9×1015 时。这不仅仅是一个趣闻;它是一个硬性限制,影响着从金融计算到科学模拟的一切。

算术的诡计

如果说数字的表示方法很奇怪,那么它的算术运算就更奇怪了。你在代数课上学到的规则——如结合律这样坚实可靠的规则——开始变得扭曲和失效。

最著名的陷阱是“0.1问题”。拿一段简单的代码,将 0.10.10.1 自身相加十次。结果......不是 1.01.01.0。为什么?原因与 1/31/31/3 是一个无限循环小数(0.333...0.333...0.333...)相同。数字 0.10.10.1 是分数 1/101/101/10。要在某个基数下有有限表示,分数分母的质因数也必须是该基数的质因数。对于基数10,质因数是2和5。对于基数2,唯一的质因数是2。由于分母10有一个因数5,数字 0.10.10.1 在二进制中变成了一个无限循环小数: 0.110=0.0001100110011...20.1_{10} = 0.0001100110011..._20.110​=0.0001100110011...2​ 计算机用其有限的52位小数部分,必须截断并舍入这个数。你在代码中写下的 0.1 已经是一个近似值。当你将这个有微小偏差的数自身相加十次时,微小的误差会累积起来。最终结果是一个接近但不与计算机对 1.0 的近似值在比特位上完全相同的数。

这引出了编程的一条基本规则:​​永远不要对浮点数进行精确相等性测试。​​直接比较 a == b 是灾难的根源。相反,你必须测试它们是否“接近”,即检查它们之间的差值是否小于某个容差。最好的方法通常是结合使用相对容差(用于大数)和绝对容差(用于接近零的数)。但即使这样也有一个陷阱:这种新的“近似相等”关系不具有传递性。你可能会发现 a 接近 b,b 接近 c,但 a 并不接近 c。

怪事不止于此。考虑加法结合律:(a+b)+c=a+(b+c)(a+b)+c = a+(b+c)(a+b)+c=a+(b+c)。它是代数的基石。让我们用三个浮点数来测试它: a=2100,b=−2100,c=1a = 2^{100}, \quad b = -2^{100}, \quad c = 1a=2100,b=−2100,c=1 首先,我们计算 (a+b)+c(a+b)+c(a+b)+c。括号内,a+ba+ba+b 会精确抵消,结果为 000。然后 0+c0+c0+c 就是 111。结果是 111。

现在,我们计算 a+(b+c)a+(b+c)a+(b+c)。计算机首先计算 b+cb+cb+c,即 −2100+1-2^{100} + 1−2100+1。数字 111 比 21002^{100}2100 小得不成比例。为了将它们相加,计算机必须对齐它们的二进制小数点,这意味着需要将 1 的比特位向右移动很远,以至于它们超出了52位小数部分的末端。这个 1 完全被舍入过程“吸收”了,所以 b+cb+cb+c 的结果就是 −2100-2^{100}−2100。最终的计算是 a+(−2100)a + (-2^{100})a+(−2100),结果是 000。

一个计算得出 111,另一个得出 000。结合律被打破了。运算顺序至关重要,这是数值分析学家在构建稳定和精确算法时必须时刻牢记的事实。

奇异“生物”园:零、无穷大和NaN

IEEE 754 标准不仅仅是一个近似系统;它是一个完整的数值计算框架,其中包含一些迷人的“生物”来处理边界情况。这些情况是通过指数域中的特殊模式来编码的。

  • ​​带符号的零:​​ 该标准同时拥有 +0.0+0.0+0.0 和 −0.0-0.0−0.0。这看起来似乎是多余的,但它对于保留如何得到零的信息至关重要。例如,1.0/∞=+0.01.0 / \infty = +0.01.0/∞=+0.0,而 1.0/(−∞)=−0.01.0 / (-\infty) = -0.01.0/(−∞)=−0.0。符号告诉你来自零的哪一侧,这个细节在复分析和某些物理模型中至关重要。

  • ​​无穷大:​​ 当结果太大无法表示,或者你除以零时,会发生什么?程序不会崩溃,而是会得到一个特殊值:Infinity。涉及无穷大的算术运算有明确的定义:∞+5=∞\infty + 5 = \infty∞+5=∞, 10/∞=+0.010 / \infty = +0.010/∞=+0.0。这使得计算在原本会停止的情况下得以继续进行。

  • ​​NaN (非数值):​​像 0/00/00/0 或 ∞−∞\infty - \infty∞−∞ 这样的不确定运算的结果是什么?答案是 NaN。这个值有一个独特的属性,即它不等于任何东西,包括它自己。如果 x 是 NaN,那么像 x == x 这样的检查将返回 false,这提供了一种检测无效结果的可靠方法。

  • ​​非规格化数与渐进下溢:​​ 该标准处理过小数字的方式可能是最微妙和优雅的特性。在 IEEE 754 之前,如果计算结果小于最小可表示的规格化数,它将被“刷新为零”。这在零附近造成了一个危险的“下溢间隙”,即使 x != y,x - y = 0 这个等式也可能成立。这会以不可预测的方式破坏算法。

    解决方案是​​非规格化数​​。这些是特殊的、极小的数,它们填补了最小规格化数(对于双精度数是 2−10222^{-1022}2−1022)与零之间的间隙。它们牺牲精度位来表示更小的量级,从而创造了​​渐进下溢​​,使得数字在接近零时平滑地失去精度,而不是突然掉下悬崖。这种设计选择可以防止程序崩溃。例如,在某个计算产生一个极小的分母,该分母本会被刷新为零并导致除零错误的情况下,渐进下溢会将其保留为一个非零的非规格化数,从而允许除法完成并产生一个非常大但有限的结果。

总而言之,浮点数的世界是人类智慧的证明。它是一个务实、强大且设计精美的复杂系统,旨在驾驭驯服无穷这一不可能的任务。它有自己的一套物理法则,学习这些法则是掌握科学计算艺术的第一步。这是一个充满惊喜的世界,但它受制于深刻而优雅的逻辑。

应用与跨学科联系

既然我们已经探索了浮点数的内部工作原理,我们可能会倾向于将这些知识作为纯粹的技术奇闻收藏起来,认为这只是最专业的计算机架构师才需要关心的问题。但那就错了。我们生活的世界——我们听的音乐、看的电影、依赖的金融市场,以及塑造我们未来的科学发现——都建立在这些有限、近似的数字基础之上。我们讨论过的那些微妙规则不仅仅是深奥的细节;它们是我们计算宇宙的物理定律。就像在物理世界中一样,忽视这些定律会导致出人意料、有时甚至是灾难性的结果。让我们踏上一段旅程,看看有限精度的幽灵如何萦绕在我们的数字世界中,揭示其危险与深邃之美。

日常数字的诡计

我们的旅程并非始于实验室,而是始于更熟悉的地方:银行、电影院和录音棚。

想象一下,你正在为一家高频交易公司设计软件。每天都有数百万笔交易发生,每笔交易都会产生微小的利润或亏损,比如几美分。你有两种选择来追踪总利润:你可以将利润以整数“分”为单位累加,或者将每笔利润转换成“元”(例如,2美分变成 0.020.020.02 美元),然后使用标准浮点数求和。常识告诉我们结果应该相同。但如果你在数百万笔交易中运行这个模拟,一个神秘的偏差就会出现。以美元为单位的浮点数总和将与从整数“分”转换而来的精确总和不完全匹配。钱去哪了?它消失在二进制算术的表示裂缝中。像 0.010.010.01 这样的简单值无法在二进制中完美表示,就像 13\frac{1}{3}31​ 无法写成有限小数一样。每笔交易中这个微小的、重复的误差,在累加数百万次后,会汇集成一个明显的差异。这是一个深刻的教训:对于必须精确的计算,比如会计,依赖整数运算通常是唯一安全的途径。

理想与可表示之间的这种同样的张力也出现在我们的感官世界中。考虑一台现代数码相机正在拍摄一张高动态范围(HDR)图像。它使用浮点数来记录从洞穴最深的阴影到太阳耀眼光芒的广阔光谱。这给了摄影师难以置信的灵活性。但是,当这张内容丰富的图像被保存为标准的8位JPEG文件以便在线共享时,会发生什么呢?存储在32位浮点数(其尾数有24位精度)中的连续亮度范围被压缩到仅有 28=2562^8 = 25628=256 个离散级别。信息损失是巨大的——从24位精度下降到仅8位。实际上,我们为每个像素的每个颜色值丢弃了16位的信息。结果是图像在平滑的渐变(如日落)中可能出现难看的“色带”,并丢失了极暗和极亮区域的所有微妙细节。浮点世界的丰富性被类整数的8位世界的简朴所扁平化。

在数字音频领域,情况完全相同。专业音频通常使用32位浮点数进行录制和混音。为什么不直接使用高分辨率整数,比如24位PCM(脉冲编码调制)?答案在于动态范围。24位整数格式有一个固定的本底噪声。它就像一把每毫米都有刻度的尺子。它很适合测量几厘米长的物体,但对于测量头发的粗细却毫无用处。对于一个非常安静的声音,其信号会被量化噪声所掩盖——声音比尺子上最小的刻度还要小。然而,浮点数就像一把神奇的、自适应的尺子。它的“刻度”(可表示数之间的间距)会随着所测量值的减小而缩小。这意味着无论声音是震耳欲聋的钹声还是最微弱的耳语,其相对精度,即信号量化噪声比(SQNR),都能保持惊人的高水平。对于一个低至 −120-120−120 dB 的耳语般安静的信号,24位PCM系统的信号量化噪声比可能非常糟糕,而32位浮点系统则能保持其原始质量,因为它的24位尾数精度会通过指数进行缩放,以匹配信号的微小量级。

模拟中的幽灵

如果浮点数的细微差别能让金钱消失、颜色褪变,那么想象一下,在运行数周、模拟数十亿年宇宙演化的科学模拟中,它们能做什么。

考虑一个追踪行星轨道的天体物理学模拟。程序通过重复执行简单的求和:tnew=told+Δtt_{\mathrm{new}} = t_{\mathrm{old}} + \Delta ttnew​=told​+Δt,以微小、离散的时间步长 Δt\Delta tΔt 来推进时间。假设模拟已经运行了很长时间,总流逝时间 toldt_{\mathrm{old}}told​ 变得巨大,可能有数十亿秒。然而,时间步长 Δt\Delta tΔt 必须保持很小以维持精度。我们遇到了一个大数与一个小数相加的情况。正如我们所学到的,可表示的浮点数之间的间距随着其量级的增大而增大。最终,总时间 toldt_{\mathrm{old}}told​ 变得如此之大,以至于到下一个可表示数之间的间隙大于时间步长 Δt\Delta tΔt。当计算机尝试加上 Δt\Delta tΔt 时,结果落入 toldt_{\mathrm{old}}told​ 自身的舍入区间内。总和 told+Δtt_{\mathrm{old}} + \Delta ttold​+Δt 被直接舍入回 toldt_{\mathrm{old}}told​。时钟停滞了。我们模拟中的行星在轨道上冻结,不是因为物理模型有错误,而是因为我们数字系统的基本粒度。这就是为什么科学代码中敏感的累加变量几乎总是以最高可用精度(double 而非 float)存储的原因。

这种分辨率上的限制也束缚了我们对纯数学世界的探索。Mandelbrot 集是一个著名的分形,其边界包含了无限复杂的细节织锦。我们通过“放大”复平面上的一个区域来探索它。但这段通往无限的旅程被我们浮点数的限制所截断。当我们放大得更深时,计算机屏幕上各点之间的距离变得比用来表示它们的数字的机器精度还要小。不同的数学位置会坍缩到同一个浮点数值上。此外,迭代计算 zn+1=zn2+cz_{n+1} = z_n^2 + czn+1​=zn2​+c 对每一步发生的微小舍入误差都很敏感。经过数百次迭代后,这些误差会累积,导致计算出的轨迹偏离真实轨迹,描绘出一幅充满噪声、扭曲的深处景象。我们永远无法看到真正的 Mandelbrot 集;我们只能看到它在有限精度算术之光下投下的阴影。

这种极端的敏感性是混沌的本质,也就是著名的“蝴蝶效应”。我们可以通过像逻辑斯蒂映射这样的简单迭代公式 xk+1=4xk(1−xk)x_{k+1} = 4x_k(1-x_k)xk+1​=4xk​(1−xk​) 看到这一点。如果我们从两个初始值 x0x_0x0​ 和 y0y_0y0​ 开始,它们仅在最后一位上相差一个单位——一个机器精度量级的扰动——它们的轨迹在最初几十次迭代中会相互追踪。但随后,它们会突然完全分道扬镳,最终到达完全不同的地方。初始的微小误差被方程的非线性动力学指数级放大。这不仅仅是一个数学上的奇观;它也是为什么长期天气预报不可能的根本原因,并揭示了为什么复杂系统的模拟在预测能力上具有内在的局限性。

计算的基础

浮点运算的触角伸入我们计算方式的最深层基础,影响着我们信任的算法和将我们的思想转化为指令的编译器。

即使是像用于求方程根的二分法这样稳健的方法也不能幸免。该方法通过重复平分一个已知包含根的区间来工作。但是你不能永远平分下去。最终,区间会变得如此之小,以至于其两个端点是相邻的浮点数。下一个计算出的中点将不可避免地被舍入到其中一个端点,区间宽度将停止缩小。对于区间 [1,2][1, 2][1,2] 内的双精度数,这个硬性限制仅在52次迭代后就会达到。

更快、更复杂的算法通常更脆弱。例如,割线法通过在曲线上取两点画一条直线来逼近函数的根。它的公式包含一个形如 f(xn)−f(xn−1)f(x_n) - f(x_{n-1})f(xn​)−f(xn−1​) 的分母。当算法收敛到根时,f(xn)f(x_n)f(xn​) 和 f(xn−1)f(x_{n-1})f(xn−1​) 都趋近于零。计算机被迫减去两个非常小且几乎相等的数。这是灾难性抵消的温床,大部分有效数字都被抹去,只留下一个由噪声主导的结果。这可能导致算法灾难性地失败,跳转到远离根的随机位置。专业数值程序员的标志不仅是了解快速算法,还要知道如何建立保障措施——例如,在检测到这种不稳定性时切换到像二分法这样更安全的方法。

这些问题甚至会导致图论等领域的算法悄无声息地失败。Bellman-Ford 算法用于在网络中寻找最短路径,并能检测“负权环”——你可以永远遍历以获得越来越低的成本的路径。这种检测依赖于像 d[u]+w(u,v)d[v]d[u] + w(u,v) d[v]d[u]+w(u,v)d[v] 这样的比较。现在,假设一个环的真实权重是一个非常小的负数,比如 −10−15-10^{-15}−10−15,但边权重本身非常大,比如 10910^9109。当计算机将大的边权重加到路径距离上时,环权重的微小负数部分可能小于加法本身的舍入误差。这个环在计算上变得不可见;它在数学上是负的,但在数值上是零或正的。算法将错误地报告不存在负权环,这是一个微妙但关键的失败。

也许最根本的是,浮点运算摧毁了初等代数的一大支柱:结合律。我们都学过 (a+b)+c=a+(b+c)(a+b)+c = a+(b+c)(a+b)+c=a+(b+c)。但在浮点数的世界里,这并不成立。考虑将三个数相加:a=1016a = 10^{16}a=1016,b=−1016b = -10^{16}b=−1016,和 c=1c=1c=1。 如果我们计算 (a+b)+c(a+b)+c(a+b)+c,内部的和 a+ba+ba+b 精确为零,而 0+c0+c0+c 是 111。 但如果我们计算 a+(b+c)a+(b+c)a+(b+c),内部的和是 −1016+1-10^{16}+1−1016+1。由于 101610^{16}1016 附近的可表示数之间的间隙大于 111,数字 111 完全在舍入中丢失了。这个和被舍入回 −1016-10^{16}−1016。最终的计算是 1016+(−1016)10^{16} + (-10^{16})1016+(−1016),结果为 000。 所以,(a+b)+c=1(a+b)+c = 1(a+b)+c=1,但 a+(b+c)=0a+(b+c)=0a+(b+c)=0。定律被打破了。 这不是一个小细节。这就是为什么编译器不能为了优化你的代码而随意重新排序你的浮点数计算。这样做可能会改变最终结果。只有当你明确允许编译器对数学运算采取“快速而粗略”的方式,接受潜在的数值差异以换取速度时,才会执行此类优化。

从我们的银行账户到浩瀚星空,浮点数的有限性和粒度性是我们计算领域一个不可回避的特征。这是一个要求人们尊重其法则的世界,它会以准确性和稳定性回报细心的程序员,同时用莫名其妙的错误给粗心者带来意外。理解它,不仅能让你对计算机的工作方式有更深的直觉,还能让你领悟到完美的、连续的数学世界与有限的、离散的机器世界之间那段根本性的舞蹈。