跳转到内容
← 返回核心概念
系统与架构当代约 20 分钟阅读

浮点数与 IEEE 754

Floating Point and IEEE 754

IEEE 754浮点舍入NaN非规格化数

1985 年,IEEE 公布 754 浮点标准时,业界还在用互不兼容的格式:同一段 Fortran 在不同机器上会对同一个中间值给出不同的舍入。

威廉·卡亨(William Kahan)带领的委员会把符号、指数、尾数、四种舍入、无穷与 NaN 写成一张所有人都能实现的合同。

2008 年修订加入二进制16、十进制格式和融合乘加;2019 年再整理推荐函数与错误处理。

今天从 CPU 标量单元到 GPU 张量核,默认语言仍是这张合同。浮点不是「小数的二进制版」。它是有限的、带指数的、带舍入规则的子集,选择这张子集的方式决定了哪些计算稳定、哪些会在看似无害的减法里丢掉所有有效位。

破除误解:精度不是小数点后几位

binary64(双精度)有 53 比特有效精度(含隐藏的前导 1),不是「15 位小数」这种十进制口诀所能概括。靠近 1 的 ulp(最后一位所代表的间距)约 2−522^{-52};靠近 2502^{50} 的 ulp 已经是 1。

同一类型在数轴上不是均匀网格。把「双精度很准」理解成处处准到 10−1510^{-15},会在大整数、日期、货币上直接出错。

第二个误解是 NaN 等于「错误码」。IEEE 754 的 NaN 参与运算:NaN 与任何东西比较都不小于、不大于、不等于(NaN == NaN 为假);payload 可以编码诊断。

这让向量化循环不必每步分支,也让调试者有时恨得牙痒——静默 NaN 会漂过二十层调用才在绘图里变成缺口。

第三个误解是非规格化数(subnormals)可以忽略。它们填补零与最小规格化数之间的空隙,使加减法在下溢附近逐渐变慢而不是突然跳到零(gradual underflow)。

硬件若把 subnormal 做成微码陷阱,一段看似无害的物理模拟会突然慢一百倍。这是数值与体系结构的交界面,不是边角料。

现场:一个减法如何吃掉有效位

计算 f(x)=1+x−1f(x)=\sqrt{1+x}-1 在 x=10−16x=10^{-16} 时,用 binary64 直接写会得到 0,因为 $1+x$ 已经舍入回 1。有理化成 x/(1+x+1)x/(\sqrt{1+x}+1) 则给出正确量级。Catastrophic cancellation 不是 bug,是网格在 1 附近的间距大于 $x$。

Goldberg 1991 年的经典文章 What Every Computer Scientist Should Know about Floating-Point Arithmetic 把这类例子写成工程师的必读,不是因为例子巧妙,而是因为每个图形、控制、统计库都会撞上。

舍入模式改变合同。默认 round-to-nearest-even 避免统计偏差;向零、向正无穷、向负无穷用于区间算术和某些财务规则。融合乘加(FMA)把 a⋅b+ca\cdot b+c 当成一次舍入,误差小于「先乘再加」两次舍入之和,也让同一个多项式在「开 FMA」和「关 FMA」的编译器之间对不齐。

可复现性于是变成编译选项问题,而不只是算法问题。Kahan 求和用一个补偿变量把每次舍入误差存起来再喂回去,把条件数恶劣的长求和从 O(nε)O(n\varepsilon) 拉到接近 O(ε)O(\varepsilon)。

它改变的不是类型,是误差的排列。IEEE 754 保证的是每次基本运算的舍入;算法决定这些局部误差如何堆积。

核心:格式、异常与语言的缝

binary32 / binary64 / binary16 共享同一套字段:1 比特符号、偏置指数、尾数。全 1 指数留给无穷与 NaN;全 0 指数留给零与 subnormal。偏置让比较可以先当无符号整数比——这是硬件喜欢的技巧,也是「负数零」必须存在的原因:它让 $1/-0$ 得到 −∞-\infty,从而保持某些恒等式在符号上说得通。

异常是 sticky 标志:无效、下溢、上溢、除零、不精确。默认常常不 trap,只置位。这和整数除零在多数 ISA 上直接故障不同。

语言若把浮点异常藏起来,库作者就只能靠 NaN 传播和手工检查。C、Fortran、IEEE 推荐的 pragma 并不总被 JVM 或 JavaScript 暴露。GPU 和机器学习把这张合同撕开一条实践缝。

Tensor 核大量使用 binary16、bfloat16、甚至 8 比特块浮点;它们不保证与 CPU 的 binary32 累加顺序一致。训练能收敛,不表示两次运行的逐比特结果相同。

IEEE 754 仍是标量与存储的共同语言;加速器上的归约树是另一份、常常没有写入标准的合同。十进制浮点(decimal64 等)为财务和度量衡保留:二元分数写不出 0.1。

大多数科学计算仍走二进制,因为硬件快。选错格式会造成「显示 0.1、内部却是循环二进制」的老笑话;选对格式也不能拯救条件数是 101610^{16} 的线性系统。

机制:比特字段、四种舍入与融合乘加

binary64 的布局是 1 位符号、11 位指数、52 位显式尾数。指数偏置 1023:存成 1023 表示 202^0,存成 1 表示最小规格化指数 $-1022$。隐藏位让规格化数的有效精度变成 53 比特。

binary32 是 8 位指数、23 位尾数,偏置 127。binary16 是 5 位指数、10 位尾数,偏置 15;能表示的最大值约 65504,深度学习里一个溢出就会变成无穷。

bfloat16 改成 8 位指数、7 位尾数:动态范围接近 binary32,精度只剩大约 3 位十进制。Google TPU 和后来的 GPU 选它,是为了 squashing 时少溢出,不是为了更准。

四种舍入里,默认 roundTiesToEven:恰好在两可之间时选最低有效位为 0 的那个。这避免「总是进位」造成的统计漂移。向 +∞+\infty、向 −∞-\infty、向零是区间算术和某些法定舍入的工具。

2019 年标准还讨论 roundTiesToAway,十进制财务更常见。

FMA 计算 (a⋅b)+c(a\cdot b)+c,只舍入一次。误差界限是 0.5 ulp0.5\,\mathrm{ulp},而「先乘再加」可以到约 1 ulp1\,\mathrm{ulp} 再加一次。多项式求值、复乘、点积的最后一步会因为开不开 FMA 差一个 ulp。

C 的 fma、Fortran 的合约表达式、以及 -mfma 编译开关,把这件事从算法搬到工具链。同一份 BLAS 在 Haswell 与 RISC-V 上逐比特不同,常常是 FMA 与累加顺序,不是谁算错。

x87 的 80 比特扩展寄存器是更老的缝:中间值多保留 11 位尾数,写回内存时再圆成 64。同一表达式多一次溢出到内存,结果就变。

IEEE 754 允许更高中间精度;这是历史兼容,也是跨平台仿真分叉的经典原因。比较可以先当无符号整数做,是因为偏置指数把规格化数排成了与实数序一致的比特序。

硬件用这条技巧实现无分支的 min/max;NaN 与负零是例外,必须另走。

max(+0,-0) 的符号在各实现间曾不一致,2019 年标准把推荐写得更死。

这不是吹毛求疵:图形里的深度比较若把 $-0$ 当正,Z-fighting 会换一种方式出现。

机制:补偿求和、双舍入与 subnormal 的硬件陷阱

Kahan 求和维护一个补偿变量 $c$,每次把上次丢掉的低位加回来。对条件数温和的求和,误差从 O(nε)O(n\varepsilon) 降到 O(ε)O(\varepsilon) 量级。Priest 的倍精度、以及 later 的算法 2Sum / Fast2Sum,把「和与误差」拆成一对浮点数,误差本身仍是可表示的。

它们不改变 IEEE 类型,改变的是误差被记住还是被扔掉。长点积、Gram–Schmidt、以及朴素方差公式 E[x2]−(E[x])2E[x^2]-(E[x])^2,都会在这里失掉有效位。

Welford 在线方差是另一条工程修复:用递推避免两个大正数相减。双舍入发生在「先圆到中间格式、再圆到目标格式」。

x87 把 binary64 运算圆到 80 比特再写回,可能与直接 binary64 差 1 ulp。Java 曾经用 strictfp 锁住这个问题。今天的 SSE2/AVX 默认就是 binary64 宽度,双舍入少了很多,但 GPU 的混合精度张量核把同类问题换了个形状:乘在 fp16,累加在 fp32,再写回 fp16。

subnormal 的最小 binary64 大约是 4.9×10−3244.9\times 10^{-324}。Flush-to-zero(FTZ)和 denormals-are-zero(DAZ)在 DSP 与 GPU 上常见:下溢直接变 0,换吞吐量。物理模拟若在接近零的力上依赖渐进下溢,FTZ 会把势能阱削平。

Intel 早期 Pentium 4 对 subnormal 的微码辅助曾让循环慢两个数量级;后来硬件填了这条路径,但 ARM 与 GPU 上仍要实测。这是体系结构税,不是数值分析习题里的 εmach\varepsilon_{\mathrm{mach}}。

Intel 的 FTZ/DAZ 控制位、ARM 的 FZ 位、CUDA 的 --ftz,默认值并不统一。同一份内核在 CPU 上渐进下溢、在 GPU 上直接变零,能量守恒检查会差出一个「几乎是零」的残差。

物理引擎若要跨设备对拍,必须把这些控制位写进启动清单,而不能假设「都是 IEEE」。

机制:语言默认值如何把合同藏起来

JavaScript 的 Number 只有 binary64。整数超过 2532^{53} 不再能表示所有整数:9007199254740993 === 9007199254740992 为真。JSON 里的大整数 ID 必须改成字符串,不是风格问题。

BigInt 是事后补丁,不参与 Math。

Java 的 double 接近 754,但 strictfp 与 JIT 对 FMA 的历史态度让可复现性依赖版本。Python 的 float 是 binary64;decimal.Decimal 走十进制,慢两个数量级以上,财务和计量用它是因为 0.1 可精确。

C 和 C++ 把 FLT_EVAL_METHOD 暴露给库作者:表达式是否在比名义类型更宽的格式里求值。IEEE 推荐的动态舍入模式,在多线程里是全局状态——改舍入等于改整台机器当次的合同。

这与「函数参数」的心智模型冲突,也是为什么区间库很少在运行时切换模式,而把四种舍入写成四套静态代码。

机器学习框架再加一层:PyTorch 的 autocast 把部分 ops 降到 fp16/bf16,累加器可能仍是 fp32。两次训练的 loss 曲线接近,不表示逐比特相同。

要可复现,必须锁种子、锁算法、锁确定性 kernel,并接受变慢。IEEE 754 管到标量运算;框架管到图调度。两张合同叠在一起,缺口在归约树。

并行归约树的结合顺序随线程块数目变化。即使每次加法都正确舍入到 0.5 ulp,一千次相加的有效误差仍可随树形走到 O(εn)O(\varepsilon\sqrt{n}) 或 O(εn)O(\varepsilon n),取决于是否补偿。

ML 训练能收敛,是因为噪声被优化器当成梯度的一部分;科学计算的回归测试把同一噪声当成失败。

两套社区对「可接受的 ulp」没有同一张表。

工程现场:制表两难、扩展精度与确定性训练

table-maker's dilemma 说明连初等函数都没有免费的正确舍入。要决定 sin⁡x\sin x 该落到哪一个浮点值,有时需要远超工作精度的中间位。IEEE 754-2008 把推荐函数写进标准,实现仍可能在最后一位争执。这是数学库的成本,不是「双精度已经够了」能跳过的。

x87 的 80 比特扩展是另一份历史税。同一段 C 在 32 位 ABI 上把中间值留在 80 比特寄存器,在 SSE2 上只用 64 比特,结果分叉。IEEE 754 允许更高中间精度,这是兼容,也是可复现性漏洞。跨平台物理引擎必须禁止扩展精度、固定舍入。

共识协议与密码学几乎总是改用整数。浮点不是错误码通道,它是错误工具:同一表达式在 x86 与 ARM 上的中间宽度曾经让分布式模拟分叉。

GPU 与并行计算 上的张量核把合同再撕一刀。binary16、bfloat16、TF32、8 比特块浮点,累加顺序由硬件归约树决定。训练能收敛,不表示两次运行逐比特相同。需要比特级复现时,必须锁算法、锁库、锁归约顺序,而不是只锁随机种子。

Sterbenz 引理给出一条安全减法:若 x/2≤y≤2xx/2\le y\le 2x 且两者同号,则 $x-y$ 在规格化浮点里精确。灾难性取消发生在两数极近但还不够近、或量级差太大的时候。1+x−1\sqrt{1+x}-1 在 x=10−16x=10^{-16} 失败,是因为 $1+x$ 先被并回 $1$。有理化把减法换成一次除法,避开那次致命的近 $1$ 加法。

两次求和把 $a+b$ 拆成头与尾,使实数和在无溢出时被精确捕获。Kahan 求和是补偿变量版。它们不改 IEEE 类型,改的是误差的代数排列。IEEE 保证每次基本运算正确舍入;算法决定这些半 ulp 如何堆成 O(nε)O(n\varepsilon) 还是 O(ε)O(\varepsilon)。

线性规划 的可行性容忍度若小于 ulp,可行域会在网格上消失。纠错码 处理的是离散符号上的噪声,浮点处理的是网格投影。两者都谈误差,对象不同:一个要恢复原码字,一个要控制放大。把 Hamming 距离套到 ulp 上会讲错故事。

代价与争议

标准换来可移植,也换来复杂。NaN 的 payload、signaling vs quiet、默认环境能否动态改舍入,各语言落实不一致。Web 的 JavaScript 只有一个 Number(binary64),整数超过 2532^{53} 就开始跳;BigInt 是后来补丁。

这不是 JavaScript 独有的蠢,这是「一种默认浮点走天下」的代价。可复现性与性能冲突。并行归约、FMA、fast-math 把结合律还给编译器,于是同一份源码在 -O3 下不再服从纸上代数。

数值库要么锁住环境,要么把「近似到 ulp」写成规范,而不是「等于」。对加密和共识,浮点几乎总是错误工具:同一表达式在 x86 与 ARM 上的中间 80 比特扩展曾经让分布式模拟分叉。

共识协议用整数和规范编码;物理引擎若必须跨平台,就要固定舍入与禁止扩展精度。IEEE 754 允许实现使用更高中间精度,这是历史兼容,也是可复现性的漏洞。

fast-math 还会假设没有 NaN、可以用代数重写 $x*0=0$,于是带无穷的物理量被悄悄改写。编译器文档把这些列成开关;生产事故把它们列成「同一份源码两个二进制」。

binary64 靠近 2532^{53} 的整数已经开始跳格:连续整数无法全部表示。JavaScript 的时间戳、JSON 里的大整数 ID,都会在这里静默出错。BigInt 是补丁,不是把 Number 变精确。货币若走浮点,分位会在累加里漂移;财务合同应走十进制或整数分。

subnormal 的控制位是另一份生产税。flush-to-zero 让实时音频不再碰到微码陷阱,也让渐进下溢的保证消失。科学积分与游戏物理不应共用同一编译开关。把 -ffast-math 写进全仓库默认,等于同时打开无 NaN 假设、代数重写和可能的 FMA 收缩。

binary32 的 ulp 在 $1$ 附近约 2−232^{-23},大约七位十进制。把传感器读数累加到 10710^7 次,末位已经不可信,除非改用补偿求和或更高精度累加器。机器学习里的 fp16 累加器若不到 fp32,点积的条件数会提前爆炸。这是格式选择,不是「再训练几个 epoch」能修的。

区间算术用向正、向负无穷两套舍入包住真值。它要求能动态改舍入模式。多线程里舍入模式是全局状态,区间库因此常把四种舍入写成四套静态代码,而不是运行时切换。这是合同变成代码组织问题的地方。

跨域连接

  • 测度论:浮点集是实数线上的有限子集,舍入是到该子集的投影。投影不保持加法,于是「几乎处处」的连续恒等式在网格上失败。把误差看成测度零集合上的病态,会误导工程师——取消发生在一个正测的、可明确描述的区域里,比如 1+x−1\sqrt{1+x}-1 的 x≈0x\approx 0。网格在 2e2^e 处的间距是 2e−522^{e-52},这是带原子的离散测度,不是勒贝格意义下可以忽略的点集。
  • 线性代数:矩阵条件数把输入的相对误差放大成解的相对误差,与浮点 ulp 相乘才是实际残差。高斯消元的主元选择、迭代法的停机,全是在这张非均匀网格上控制放大。条件数 101610^{16} 的 n×nn\times n 系统会把 binary64 的 16 位十进制吃光。没有条件数,IEEE 754 的 53 比特只是虚假安全感;换 fp16 只是把同一放大提前暴露。
  • 概率:随机舍入把确定性偏差换成期望无偏、方差可算的噪声,近年在深度学习低精度训练里重新流行。它不取消 IEEE 的默认最近偶数,它是另一种、需要额外随机比特的合同。Connolly、Higham 等人分析过随机舍入对求和与 GEMM 的误差。概率在这里是舍入策略,不是对输入数据的统计假设;输入仍可以是最坏情况的病态矩阵。
  • 证明:浮点程序的正确性证明必须把舍入写进语义,而不能先在实数上证明再「相信足够精度」。Flocq、CompCert 的浮点层、以及 SMT 里的浮点理论,都是把 754 的逐比特规则当成公理。2Sum 的正确性、FMA 的误差公式、以及「无溢出时比较可当整数」这类硬件技巧,都有机器核对的证明。纸上代数在这里是待证命题,不是默认背景。

参考文献

  • IEEE Standard for Floating-Point Arithmetic, IEEE 754-1985; IEEE 754-2008; IEEE 754-2019.
  • Goldberg, D. "What Every Computer Scientist Should Know about Floating-Point Arithmetic." ACM Computing Surveys 23(1):5–48, 1991.
  • Kahan, W. 关于逐步下溢与 754 设计动机的笔记与讲义(伯克利,多年修订)。
  • Higham, N. J. Accuracy and Stability of Numerical Algorithms. 2nd ed. SIAM, 2002.
  • Muller, J.-M. et al. Handbook of Floating-Point Arithmetic. 2nd ed. Birkhäuser, 2018.
  • Boldo, S., Jourdan, J.-H., Leroy, X. & Melquiond, G. "A Formally-Verified C Compiler Supporting Floating-Point Arithmetic." Proceedings of ARITH, 2013.
  • Connolly, M. P., Higham, N. J. & Mary, T. "Stochastic Rounding and Its Probabilistic Backward Error Analysis." SIAM Journal on Scientific Computing 43(1):A566–A585, 2021.

延伸阅读

  • Boldo, S. & Melquiond, G. Computer Arithmetic and Formal Proofs. ISTE / Elsevier, 2017. (用证明助手处理 754)