
如果让我列一个“被开发者严重低估的基础课”清单浮点运算一定排得进前三。做了十几年底层开发我见过太多人栽在浮点数上写业务代码的同事为了 0.10.2 不等于 0.3 改了大半天 bug做数值仿真的老手调了三天收敛问题最后发现是累积误差在捣鬼。要说完全不了解 IEEE 754那不公平但大部分人的认知停留在“浮点数有精度损失、不要直接比较”这句话上至于为什么有损失、损失是怎么一步步放大的、工程上到底该怎么处理往往是笔糊涂账。这篇是系列第一讲我打算把浮点运算最核心的底层逻辑讲透。面向写代码的工程师、搞算法的同学以及所有需要处理数值计算的人。读完你至少能回答几个经典问题0.1 为什么不能精确存储NaN 怎么产生的比较两个浮点数到底该用什么姿势以及当你看到一段代码里出现了if (a b)且 a、b 都是浮点数时为什么应该本能地警觉。1. 为什么计算机科学家必须重新认识浮点数1.1 从十进制思维到二进制现实的落差我们从小接触的算术是十进制的所以天然觉得 0.1 就是一个再普通不过的有穷小数。但在二进制里只有那些能写成若干 2 的负幂之和的数才能用有限位精确表示比如 0.5、0.25、0.125。0.1 呢它等于 1/10分母里有因子 5二进制表示是无限循环的0.0001100110011001100110011…… 永远写不完。这个落差是绝大部分精度问题的根源。你的十进制直觉告诉你“这个数很简单”但机器的二进制脑回路只认 2 的幂。就好比在十进制里1/3 用小数怎么写都是 0.3333… 无限循环只能截断。浮点数对 0.1 做的事情本质上和你在考试卷上把 1/3 写成 0.333 没区别只是它截断得更讲究、更隐蔽。很多初学者以为“浮点数不是精确的就是 bug”这个观点要修正。浮点数从设计之初就没打算精确表示所有实数它的目标是在有限的 32 位或 64 位里尽可能合理地覆盖一个广阔但离散的数值范围。搞懂这点后续所有坑你都能顺着逻辑推出来。1.2 浮点数不是数学实数而是离散映射把浮点数理解成一个巨大的、可数的“网格”会顺很多。每个浮点数值实际上只是这个网格上离你输入的实数最近的一个点。你可以把单精度 float 想象成一张稀疏地图double 比它密一点但仍然是离散的。你写下的0.1在编译运行时会被转换成网格上最接近 0.1 的那个节点之后所有运算都是在这些节点之间跳转、取近似。这个观点为什么重要因为它能帮你纠正一个错误的预期数学上连续、可导、唯一的那套直觉在浮点世界里通通需要打折扣。两个函数在数学上完全等价写成浮点代码后结果可以不完全一样因为每步舍入都在“映射”上留下痕迹。现在业界有很多数值算法课程专门研究“改写运算顺序以降低误差”就是因为浮点不是一个封闭的代数系统它不具备结合律也不具备分配律。1.3 哪些场景最容易踩坑从我接触过的项目看浮点坑集中出现在几类地方第一循环累加比如粒子系统的总能量统计、信号处理中的积分器误差会因为迭代次数而被反复放大第二几何判断比如判断点在三角形内、两条射线是否相交这类算法对相近数值极其敏感第三价格和库存计算很多人觉得“加起来差一分钱没什么”但对财务系统这是事故第四收敛判断迭代求解器的退出条件如果只靠绝对误差很容易陷入死循环第五并行归约不同的线程顺序会得到不同的浮点结果导致相同输入在不同机器上输出不一致。并不是说这些场景不能用浮点而是说你在设计阶段就要预期到误差的存在并决定容忍度。下面我们先把 IEEE 754 的底层机制拆开看看浮点数到底长什么样。2. IEEE 754 标准浮点数的底层设计拆解2.1 符号位、指数、尾数三段的含义如今几乎所有主流 CPU 都实现了 IEEE 754 标准。一个双精度浮点数占 64 位分成三段最高位是符号位sign接下来 11 位是指数位exponent最后 52 位是尾数位fraction / significand。单精度则是 1 8 23。公式可以写成[ x (-1)^s \times (1 f / 2^{52}) \times 2^{(e - 1023)} ]这里的 f 是尾数字段表示的整数把它除以 2^52 就得到了小数部分。指数位为什么要减去一个偏移量 1023因为指数需要既能表示正数也能表示负数IEEE 754 选择了“偏移二进制”这种形式真实的指数是e - 1023。这个设计有个额外好处非负浮点数如果按位解释成无符号整数大小顺序和浮点数值大小顺序完全一致很多排序算法可以直接拿位模式比较这也是标准的高明之处。举个例子双精度下的 1.0符号位 0指数存储值 1023尾数全 0。3.0 呢二进制是 1.1 乘以 2 的一次方所以指数存储值为 1024尾数字段里存的是二进制 0.1也就是 2^51 这个整数对应的位模式。你如果打印 double 的十六进制位会看到0x4008000000000000这种东西看多了就习惯了。2.2 规格化与非规格化数值绝大多数浮点数走的是“规格化”路径指数的存储值既不全 0 也不全 1此时尾数前面隐含一个前导 1。比如 1.5 存储为 1.1 × 2^0这个“1”不占位是白送的精度所以双精度有效数字大概有 15 到 17 位十进制。当指数位全 0 时规则变了前导 1 被移除表示的是 0.f × 2^(1 - 1023)。这些叫非规格化数专门用来填补 0 附近的一小段“空白”。为什么不直接用最小的规格化数因为最小规格化双精度约等于 2.2250738585072e-308它离 0 还有一段距离如果小于这个数的任何值都直接变成 0那么减法tiny1 - tiny2会瞬间损失所有相对精度。非规格化数提供了一条“平滑下溢”的通道虽然它们的精度会随指数变小而降低但至少不是在 0 附近制造悬崖。这里埋了一个很经典的性能黑洞在某些老款 CPU 上非规格化数的运算比规格化数慢几十倍甚至上百倍。如果你发现某段代码在数据很小的时候突然卡顿可以检查一下是否进入非规格化区间必要时使用FTZflush to zero或DAZdenormals are zero模式把非规格化数直接清零换取性能提升。2.3 特殊值无穷、NaN 与 ±0IEEE 754 用指数位全 1 来标记特殊值。如果尾数全 0表示正无穷或负无穷比如 1.0/0.0 在默认舍入下会得到正无穷。如果尾数不全为 0则表示 NaNNot a Number。常见来源包括 0/0、无穷减无穷、对负数开平方还有 NaN 参与的任何运算。NaN 是个非常有意思的设计它像一个传染源任何一个操作数出现 NaN结果必然是 NaN。这保证了错误的传播是“显式”的而不是悄悄变成某个看似合理的数字。但在实际调 bug 时它也很坑因为一个 NaN 可能要经过好几层函数才被外面发现而你找根源时得从后往前翻。我自己的习惯是在关键的数值函数入口做一次isnan()断言尤其是那种从第三方库读进来的数据宁可慢一点也要先把脏数据挡在外面。±0 也是一个容易忽略的细节。IEEE 754 里 0 和 -0 是不同位模式但比较时相等。除法里它们不一样1/0 得到正无穷1/-0 得到负无穷。另外很多算法中对符号零有依赖比如atan2的象限判断不要轻易把 -0 规约成 0。3. 舍入与精度误差是怎么一步步放大的3.1 二进制小数无法精确表示十进制小数第 1 节说到 0.1 的二进制是无限循环小数那计算机怎么存储它呢答案是舍入到最近的网格点。双精度下0.1 实际存储的值是 0.1000000000000000055511151231257827021181583404541015625。你可以用 Python 的print(f{0.1:.17g})或者0.1.hex()看到它的真实面目。它比数学上的 0.1 略大一点点这个差值就是第一重舍入误差。这解释了为什么 0.2 存储成 0.2000000000000000111022302462515654042363166809082031250.3 存储成 0.299999999999999988897769753748434595763683319091796875。注意不是每个十进制的“看着简单”的数都有同样的问题比如 0.5、0.25 就能精确表示。所以别再背“浮点数不精确”这种笼统说法要学会判断一个十进制数能否精确表示取决于它的分母在二进制下能否被 2 的幂整除。3.2 四种舍入模式与最近偶舍入IEEE 754 定义了四种舍入模式默认的是“舍入到最近偶数优先”round to nearest, ties to even。为什么选偶数优先如果两个候选值距离完全相等传统“四舍五入”会一律向上造成统计偏差。取偶数可以保证在一连串舍入中向上和向下的次数大致持平误差分布更对称。其他三种模式分别是向零舍入、向下舍入、向上舍入。它们在特定场景很有用比如区间算术中要保证结果落在真实值的上下界内就需要同时向下和向上各算一遍。但日常计算默认都是最近偶舍入。这里有个冷知识默认舍入模式下0.5 会舍入到 01.5 会舍入到 22.5 会舍入到 23.5 会舍入到 4。你可以自己验证在很多语言里round(2.5)结果可能不是你小学学的“3”。金融领域是舍入问题重灾区。比如用 double 计算金额0.1 0.2 已经偏了再叠加四舍五入规则银行系统分分钟差几分钱。所以实务上金额通常用整数分、或者高精度十进制字符串、或者专门的钱包类型而不是浮点。3.3 误差放大减法抵消与累积误差比单个舍入更危险的是误差放大效应。经典操作是“两个非常接近的大数相减”a 1.0000000000000002 b 1.0 c a - b数学上 c 是 0.0000000000000002看起来很精确的确定值。但在双精度下a 和 b 各自都已经经过了舍入它们的相对误差约 10^-16 量级相减之后结果的绝对误差也差不多是 10^-16但结果本身的量级是 10^-16相对误差直接变成接近 1相当于有效数字全丢光了。更隐蔽的是累积误差。在循环里反复做sum x每次加法的舍入误差会积累。举个例子给 100 万个很小的数求和如果先把大数加起来后面小数可能压根加不进去如果从小往大加情况会好一些。Kahan 求和算法可以部分补偿误差Python 的math.fsum就是干这个的它用了一个类似的思想能把累积误差降到最低。要记住一个指标双精度数在 1 附近的 ULP约为 2.22e-16决定了它的相对精度天花板。如果你在统计计算里发现结果和理论值对不上不要先怀疑公式错了先排查有没有大数减小数、有没有顺序敏感。4. 浮点数比较的陷阱为什么 0.10.2 不等于 0.34.1 手工推导 0.10.2 的计算路径现在我们可以把最著名的例子完整推一遍。0.1 实际存储为 0.10000000000000000555111512312578270211815834045410156250.2 实际存储为 0.2000000000000000111022302462515654042363166809082031259。两个数相加机器先做指数对齐再对尾数求和最后按最近偶原则舍入到 52 位尾数。得到的双精度结果是 0.3000000000000000444089209850062616169452667236328125。而 0.3 单独转换成双精度结果是 0.299999999999999988897769753748434595763683319091796875。两个值在二进制网格上不是同一个点所以0.1 0.2 0.3在 double 下严格为 false。很多人喜欢用“误差小于多少所以相等”来圆但严谨的结论是这本来就是两个不同的浮点数它们之间的差距约 4.44e-17恰好处于双精度 ULP 尺度附近。理解了这一点你就不会被各种“为什么 Python 里 0.10.2 不等于 0.3”的帖子吓到因为这是标准行为不是 Python 或 C 的 bug更不是编译器的锅。4.2 绝对误差、相对误差与 ULP判断两个浮点数是否“足够接近”必须先确定用什么度量。绝对误差就是|a - b|适合比较数值本身都接近 0 的情况。但一旦两个数量级拉大绝对误差就不靠谱。比如 a 123456789012345680.0b 123456789012345670.0它们的差是 10在这个量级下双精度的 ULP 本身就是 16所以这两个数差几个 ULP 完全正常。你用abs(a-b) 1e-5这种阈值去比等于什么都没比。相对误差是|a-b| / max(|a|, |b|)能反映“在这个量级上差了多少比例”。但纯相对误差在比较两个非常接近 0 的数时也会失效因为分母接近 0 时相对误差爆炸。所以工程里经常用混合策略abs(a-b) tol * max(abs(a), abs(b), 1.0)其中那个1.0就是为了在接近 0 时退化成绝对误差。ULP 是另一个更“底层”的度量。某个浮点数的 ULP 是指从它到下一个可表示浮点数的距离。两个数差几个 ULP才真正反映它们在二进制网格上隔了多远。C 语言里有nextafter函数可以顺着网格往后取数如果你要检查 a 是否“离 b 不超过 3 个 ULP”可以直接循环三次nextafter去做。这种比较方式在数值分析里更受尊敬因为它不依赖人为拍脑袋的 ε。4.3 实际工程中的比较策略根据项目场景我总结了几种比较策略。第一种如果你确定 a 和 b 都由同一套计算路径产生理论上应该严格相等那可以直接比如从同一个数组里读两个相同的索引。但这种场景极少而且一旦未来改了编译器优化选项仍然可能出问题。第二种给一个全局 epsilon比如abs(a-b) 1e-9。这种做法在原型期可以接受但要注意固定 epsilon 在不同量级下会失效。使用时最好结合相对误差写成abs(a-b) eps * max(abs(a), abs(b), 1.0)。这个公式在多数图形学和物理引擎里够用但如果你在做金融请直接换十进制整数。第三种用 ULP 比较比如把浮点数变成整数位模式然后看它们的整数差。这在某些干净库里叫做almostEqualUlps。前面说过非负浮点数的位模式可以按整数比较大小所以计算两个浮点数之间差多少个 ULP本质上就是计算两个整数之差只是要在符号、NaN、无穷这些特殊值上做额外防护。第四种彻底避免浮点比较。在需要精确定位的场景比如数据库索引、哈希表键、交易流水号不要拿 double 直接当 key。要么转成固定精度的定点数要么用字符串序列化。这一条真的非常重要我在后面避坑清单里还会提。5. 实操经验我在项目里如何驯服浮点数5.1 实战案例物理引擎的坐标漂移修复我在一个三维仿真项目里遇到过非常典型的浮点问题物体长时间运行后会开始轻微抖动进而发生碰撞穿透。一开始我们怀疑是碰撞检测算法有 bug后来把每个环节的数值抓出来打日志发现渲染层用的是 float 类型直接把世界坐标几千到几十万米传给 GPU。到了几十万米的尺度float 的精度已经下降到毫米级而我们的物体运动每帧只有零点几毫米自然就会出现位置来回跳动。修复方案分两部分。第一渲染和仿真统一改用 double旧代码所有涉及坐标的地方全局替换。第二引入局部坐标每个物体不再存绝对世界坐标而是存相对于“摄像机/玩家”的偏移。由于偏移量通常只有几十到几百米浮点网格在这个量级下依然很密误差就变小了。这个技巧在游戏引擎里叫“floating origin”在科学计算可视化里也很常用。这个案例给我的教训是不要想当然地认为“float 够用”。一个数需要多少位精度不取决于它看起来多大而取决于它和谁比较、需要区分多小的差值。选类型之前先算一算目标量级下的 ULP。5.2 在线性代数与统计计算中的刻意练习数值计算里还有两个高频翻车点。一个是线性代数很多人喜欢算矩阵的逆尤其是通过伴随矩阵或者显式公式去算这在接近病态矩阵时误差非常大。实际工程里应该用矩阵分解比如 LU、Cholesky、SVD之后用回代求解方程而不是真的去求逆矩阵。你观察过numpy.linalg的接口会发现它让你solve而不是inv背后就是这个道理。另一个是统计量计算。教科书里方差公式Var E[X^2] - E[X]^2在数学上漂亮但在浮点世界里是灾难。当数据均值很大、方差很小时E[X^2]和E[X]^2两个大数相减会触发毁灭性的减法抵消。更稳的是两遍算法先算均值再对偏差平方求和。如果数据必须流式处理用 Welford 算法能在一次遍历里同时维护均值和方差数值稳定性好得多。Python 的statistics库内部其实就类似这么干。另外我会刻意在工具链里使用math.fsum代替手写 sum 循环尤其当列表里既有正数又有负数时。它通过补偿项保留低位信息误差通常小几个数量级。代价是稍微慢一点但对于非性能瓶颈完全值得。5.3 工具与调试技巧调试浮点问题最关键的是能“看到”浮点数的真实值。大多数人以为打印%.2f就够了实际上你需要%.17g才能把 double 的精度展示完整。C 语言里printf(%a, x)可以直接输出十六进制浮点直接反映位模式Python 里x.hex()同样好用。这两个命令是我排查精度问题时最先敲的。如果要看位模式本身可以用memcpy把 double 复制成 uint64_t然后打印十六进制。这比数学公式直观得多。你在处理跨平台结果不一致时通常能一眼看出两端差在最低的哪几个 bit。另外一个非常实用的习惯是在怀疑编译器优化改变浮点结果时用-ffp-contractoff或-ffast-math做对比实验。-ffast-math会放宽 IEEE 语义很多情况下能提速但也可能让原本成立的崩溃谨慎用它尤其在库代码里。代码审查工具也可以帮你扫雷。比如 clang-tidy 有检查浮点的规则我自己会把这类检查开成 warning 级避免同事在不知不觉间写出脆弱的比较。工具替代不了思考但能帮你多抓几个漏网之鱼。6. 避坑清单浮点运算常见问题速查表6.1 常见问题、原因与对策表我在项目里把浮点问题整理成了一张速查表遇到怪现象先对表自查常见问题可能原因可行对策0.10.2 ! 0.3二进制无法精确表示十进制小数经过舍入后两个结果落在不同网格点用相对误差 绝对误差混合比较不要结果出现 NaN0/0、inf - inf、对负数开方或输入本身带 NaN入口处检查 isnan隔离脏数据数值越迭代越飘每次运算舍入误差累积或者大数加小数被吞掉用 Kahan 求和 / math.fsum调整运算顺序金额计算差一分double 的十进制表示有误差四舍五入又叠加用整数分、Decimal、定点数别用浮点管钱收敛循环跳不出收敛判定只用绝对误差接近 0 时阈值失效用abs(x_new - x_old) tol * max(abs(x_new), 1.0)图形抖动/碰撞穿透float 精度不足或者绝对坐标过大换 double用局部坐标 / floating origin不同机器结果不一致并行归约顺序不定或者编译器启用了 fast-math固定归约树顺序关闭影响语义的优化排序/查哈希表行为奇怪浮点结果直接作为 key微小误差改变了位模式转定点、转字符串或使用基于区间的比较这张表不是万能药但每次遇到问题我都会先圈定它是“表示问题、运算问题、比较问题”中的哪一类再决定对策。多数项目里超过 80% 的浮点 bug 能落到这三类里。6.2 设计评审与代码审查自查点在代码评审时我有一套固定的浮点自查清单。看到浮点变量先问它有没有和另一个浮点变量做比较如果有改成近似比较或者解释为什么可以精确相等。看到循环里有累加先问累加顺序是什么能否改成增加精度的求和看到接口边界先问从外部读入的数值有没有做 NaN、Inf 检查。还有一个容易被忽略的点不要把浮点数作为map的 key。即使两个计算结果“应该一样”因为它们经过的运算路径不同二进制位模式也可能不同。如果你确实需要查表可以把 key 换算成固定精度字符串比如保留 6 位小数前提是这个精度满足你的业务容忍度或者用整数索引。面试中我也常问这几个问题浮点数能表示的最大有限数是多少为什么0.0/0.0不是运行时异常而是 NaNdouble 的机器精度 epsilon 是多少这个问题没有标准答案能答出“取决于量级、1.0 附近约 2.22e-16”就说明你是真的理解了网格模型而不是背了结论。说起这个我想起当年在项目里被浮点坑的次数多了养成了个习惯凡是涉及浮点先问三个问题——这个数从哪里来要经过几步运算最后要和谁比较。把这三个问题答透百分之八十的坑都能提前躲开。尤其是第一次接触 IEEE 754 的读者不用急着背所有特殊值先把“浮点数是离散有限集合”这个认知焊在脑子里后面的知识才有地方挂靠。这一篇算是把地基打起来了下一篇我再接着聊舍入模式对数值算法的影响、编译器优化和 FMA 带来的那些细微差别以及高精度计算的取舍。