ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

浮点数精度问题全解析:从IEEE 754到FP32/BF16选型

2026/8/27 5:25:55 拓冰建站 浏览量
浮点数精度问题全解析:从IEEE 754到FP32/BF16选型 很多有经验的开发者第一次接触浮点数都被同一个问题弄懵过print(0.1 0.2 0.3)如果你之前没运行过这里先给出答案结果是False。再进一步打印0.1 0.2你会看到0.30000000000000004。很多人第一次看到这个结果时会觉得“语言坏了”接着会去查各种资料把“浮点数有精度问题”这句话背下来然后继续踩坑。真正的问题不是“0.1 加 0.2 算不准”而是浮点数的误差会在什么时候出现、以什么方式出现、到底能不能消除、遇到之后怎么处理。如果你只是背下结论不了解它的细节后面大概率还会在比较、累加、金额计算、深度学习训练和部署中吃更大的亏。这篇文章想把这些事情讲透。我会从 IEEE 754 的基本结构说起把规格化、精度、指数范围这些概念用开发场景解释清楚然后重点讲实际项目里最常见的几个浮点数陷阱比如直接比较、精度累加、大数吃小数、NaN 和负零再结合当下的热门话题聊聊 FP32、FP16、BF16、TF32 在模型训练和部署中应该怎么选。读完之后你至少能回答三个问题第一浮点数为什么会有误差第二你的项目里哪些地方能用浮点数哪些地方绝对不能用第三当别人问起0.1 0.2不等于0.3时你能用两三句话讲明白背后的原因而不是只说一句“这是精度问题”。1. 浮点数为什么“算不准”0.1 在二进制里是循环小数几乎所有语言的浮点数都遵循同一个标准IEEE 754。这个标准用二进制来存储小数结构是“符号位 指数位 尾数位”。为什么用二进制存储就会出问题关键在于“0.1 这个十进制小数在二进制里是一个无限循环小数”。我们平时用的十进制里1/3 这样的数写出来是 0.3333...永远写不完所以只能用有限位数近似。二进制里也一样0.1 没法用有限位二进制小数精确表示。换算成二进制它是一个无限循环的模式0.0001100110011001100110011001100110011001100110011001101...IEEE 754 的尾数只有有限位。单精度浮点数有 23 位尾数双精度浮点数有 52 位尾数。既然写不完就必须截断从截断那一刻起误差就已经存在了。所以0.1在内存里并不是“真正意义上的 0.1”而是“最接近 0.1 的一个二进制小数”。0.2也一样0.3也一样。0.1 0.2的结果是两个近似值相加再经过一次舍入最终得到的是0.30000000000000004。而0.3本身在二进制里的近似值是另一个数。这两个数并不相等所以0.1 0.2 0.3返回False。这里有一个很重要的判断浮点数的误差不是“语言没写对”而是“用有限位二进制来表示实数”这个行为本身的必然结果。任何语言只要遵循 IEEE 754都会出现同样的问题。用不用 Python改成 Java、Go、C结果都一样。那为什么我们平时写的程序没有被各种 0.1、0.2 搞得全线崩溃因为很多场景不需要精确比较只需要近似结果。排布图、物理坐标、模型权重、温度显示这些地方误差在可接受范围内浮点数就足够用。但对金额、库存、计数、ID 这类业务数据来说浮点数的“近似性”就是致命的。这也是后面工程实践中要反复强调的一句话不是不能用浮点数而是不能在精确语义的要求下使用浮点数。2. 看懂 IEEE 754符号位、指数位、尾数位与规格化为了理解误差从哪来、会大到什么程度我们有必要把 IEEE 754 的分段结构看一遍。以最常见的双精度浮点数double为例它总共占 64 位分成三部分部分位数作用符号位 sign1 位0 表示正数1 表示负数指数位 exponent11 位决定数值的范围可以表示很大的数和很小的数尾数位 mantissa/fraction52 位决定数值的精度单精度浮点数float则是 1 位符号、8 位指数、23 位尾数总共 32 位。这个结构里最容易让人困惑的是“规格化”。根据 IEEE 754 的规定一个正常的浮点数会被写成这种形式(-1)^sign * 1.fraction * 2^(exponent - bias)也就是说尾数部分默认有一个隐含的“1.”在前面。不管底数是多少规格化浮点数总能把有效数字表示成1.xxx的形式而这个隐含的 1 不需要额外用一位存储所以实际上相当于多了 1 位精度。举个例子二进制的1.011在规格化表示下尾数部分存的是011前面那个1是隐含的。如果数值太小小到连1.xxx都表示不了就会进入非规格化范围也常被称为次正规数。次正规数的精度会下降这是很多新手在写极小数值计算时发现“误差突然变大”的原因之一。为什么指数部分要加一个偏移量也就是 bias因为指数有正有负用偏移量可以避开符号位方便比较大小。单精度浮点数的指数偏移是 127所以指数位存的二进制值减去 127才是真正的指数。双精度浮点数的偏移量是 1023。现在再回头看0.1。写成 IEEE 754 的规格化形式后尾数被截断成 52 位于是0.1在内存里的“真身”是这样一个数print(0.1.hex()) # 输出0x1.999999999999ap-4输出结果里的0x1.999999999999a是十六进制尾数-4表示指数。如果你把这个十六进制转成十进制得到的是0.1000000000000000055511151231257827021181583404541015625没错内存里的0.1其实比十进制的 0.1 大一点点。这个例子能直观说明一件事浮点数是一个近似集合而不是你熟悉的那个十进制小数。在看代码的时候我建议你可以多用float.hex()或者 C 语言里的printf(%a, x)来查看一个浮点数的真实二进制结构这比单纯打印十进制结果更能理解问题。因为printf默认打印的十进制已经是经过舍入后的结果你看到的是一个“被合理化”的数值而不是真实存储值。3. 浮点数最常踩的 5 个陷阱理解了二进制表示下面这些坑就都能说清楚了。我在日常开发和代码评审里见过最多的浮点数问题基本集中在这五类。3.1 陷阱一直接用比较浮点数这是最常见的坑也是最容易被新手忽略的。a 0.1 0.2 b 0.3 print(a b) # False print(a) # 0.30000000000000004有些场景里两个浮点数明明“应该相等”但就是不相等。比如在循环里累加几次之后再和预期结果比较大概率会失败。解决思路不是“把浮点数误差清除”而是“允许误差存在”。比较两个浮点数时可以用绝对误差或者相对误差def almost_equal(a, b, rel_tol1e-9, abs_tol1e-12): return abs(a - b) max(rel_tol * max(abs(a), abs(b)), abs_tol) print(almost_equal(0.1 0.2, 0.3)) # TruePython 的math.isclose已经内置了这个逻辑参数含义也对应上面这个公式。在 C、Java、Go 里需要自己实现类似的函数。比较字节特征时也是如此避免直接写死精确值。3.2 陷阱二累加误差被不断放大单个浮点数的误差看起来很小但如果你在一个循环里累加成千上万次误差会被不断放大。假如你想计算 1000000 个 0.1 的和天真写法是total 0.0 for _ in range(1000000): total 0.1 print(total)真实结果并不是精确的 100000.0因为每一次累加都会产生一次舍入小数部分会被反复截断或进位。累加次数越多误差越大。优化的办法有很多最简单的是改变顺序比如把小数和整数分开处理或者使用 Kahan 求和算法来补偿每一次累加丢失的低位部分。更稳妥的思路是如果累加的对象是有明确语义的量比如金额、件数就不要用浮点数。这个后面第三节会展开讲。3.3 陷阱三大数吃小数当两个数相差悬殊时小数的有效数字可能被直接“吃掉”。large 1e16 small 1.0 print(large small) # 输出1e16为什么1e16 1.0还是1e16因为双精度浮点数只有 52 位尾数。1e16 这个数已经需要比较多的二进制位数来表示它尾数部分已经没有足够的空间再容纳“1.0”带来的微小变化。所以相加之后结果四舍五入回1e16。这类问题在科学计算、金融风控、订单金额累计中都可能出现。如果你要同时处理一个上万的大数和一个不足 1 的小数并且两者的加减会影响最终业务结果请先把数据统一到同一种精度策略下必要时转成整数处理。3.4 陷阱四NaN 和无穷大的比较行为IEEE 754 里有三个特殊值正无穷、负无穷、NaN。NaN 表示“不是一个数”比如 0.0 除以 0.0或者对一个负数取平方根。它有一个反直觉的特性nan_value float(nan) print(nan_value nan_value) # False print(nan_value ! nan_value) # True任何值与 NaN 比较结果都是 FalseNaN 甚至不等于它自己。如果你在代码里判断if x float(nan)这个判断永远不会成立。正确判断方式是math.isnan(x)。无穷大也不能随便参与运算。inf减去inf会变成 NaNinf乘以 0 也是 NaN。这些特殊值传入神经网络训练、数据库存储、JSON 序列化时都可能让程序出现难以排查的问题。特别是从外部接口拿数据时要先过滤 NaN 和 inf。3.5 陷阱五负零 -0.0 与格式化输出0.0和-0.0在比较时是相等的但在某些场景下它们是不同的数。zero 0.0 negative_zero -0.0 print(zero negative_zero) # True print(1 / zero) # ZeroDivisionError: division by zero print(1 / negative_zero) # ZeroDivisionError: division by zero在支持浮点除零的 C 里1.0 / 0.0是正无穷1.0 / -0.0是负无穷。数学语义上负零不能简单等同于正零。对于大多数业务系统负零主要出现在日志展示和序列化里。比如两个很小的数相减结果可能是-0.000000000000001四舍五入后变成-0.00。如果展示层对负零没有特殊处理用户看到“-0.00”会觉得产品有 Bug。处理方式往往是在格式化前判断是否接近零。4. 比较、舍入、Decimal到底该怎么正确处理第一反应如果是“既然有误差那我用round把结果四舍五入不就行了”那还远远不够。4.1round只解决显示问题不解决计算问题round(0.1 0.2, 2)确实能得到0.3但如果后面还要继续参与运算round之后的数值仍然可能不够精确。round适合输出时的格式化不适合作为业务计算的修正手段。比如print(round(2.675, 2))很多人预期是2.68实际输出是2.67。因为2.675在二进制里存储的值略小于十进制看到的 2.675四舍五入时被舍掉了。这个例子说明round操作本身的对象就已经不是一个精确的十进制小数。4.2 用 Decimal 处理十进制精确计算对于金额、税率、利率这类业务数据更推荐使用十进制字符串去构造decimal.Decimal而不是直接用浮点数from decimal import Decimal amount Decimal(19.99) tax_rate Decimal(0.06) tax amount * tax_rate print(tax)Decimal 的核心优势是“以十进制方式模拟人类的小数运算”因此0.1 0.2在 Decimal 里可以精确得到0.3。但它也不是万能的Decimal的除法仍然可能产生循环小数所以同样需要设置精度和舍入规则from decimal import Decimal, getcontext, ROUND_HALF_UP getcontext().prec 10 result Decimal(1) / Decimal(3) print(result)这样得到的是0.3333333333是一个按精度截断后的近似值。如果项目使用 Java类似的能力来自BigDecimal但要注意构造时优先使用字符串构造器new BigDecimal(0.1)和new BigDecimal(0.1)的结果完全不同。后者会把浮点数的二进制近似值原样带进来。4.3 使用高精度整数代替浮点数在不需要小数语义的地方直接用整数是最彻底的方案。比如金额用“分”存储时长用“毫秒”或“微秒”存储百分比用“万分比”存储。这样既避免了浮点误差也方便数据库索引和运算。很多国际支付系统的经验都是面向用户展示用小数内部存储和计算用整数。这不是老套的工程约束而是长期与浮点数搏斗之后总结出来的最佳实践。5. 从 FP32 到 FP16、BF16、TF32模型训练与部署的浮点选型近几年浮点数话题在深度学习领域又火了起来。原因是模型参数越来越多算力和显存成了瓶颈业界开始用更低位宽的浮点数来换速度、换显存。这类经验对做后端服务、算法工程的同学同样有参考价值。常见的四种浮点格式分别是 FP32、FP16、BF16、TF32。它们的结构差异直接决定了动态范围和精度格式符号位指数位尾数位大体用途FP321823通用数学计算模型训练默认精度FP161510混合精度训练、推理加速表示范围小BF16187与 FP32 同指数范围精度低适合训练和大动态范围推理TF321810特定硬件加速环境下代替 FP32 计算吞吐更高单看尾数位FP32 的精度明显高于 FP16 和 BF16。这也意味着直接把所有 FP32 参数换成 FP16模型精度很可能会受损尤其是容易出现梯度下溢的情况。FP16 的指数位只有 5 位能表示的最小正规格化数约为 6.1e-5一些小的梯度很容易被舍入成 0。BF16 之所以在训练场景中受欢迎是因为它保留了与 FP32 相同的 8 位指数位动态范围基本不变即使尾数精度低了梯度消失的问题仍然比 FP16 轻。TF32 则是特定 GPU 硬件上的加速格式可以理解成用 10 位尾数在特殊计算单元里模拟 FP32 的常用范围。它并不是所有硬件都支持使用前需要确认设备和框架版本。那么工程上怎么选一个相对稳妥的判断是模型训练优先看混合精度方案不能一刀切地用 FP16关键层仍然需要 FP32。推理部署时如果模型对数值范围敏感BF16 通常比 FP16 更适合作为替代格式。如果只是推理服务需要压缩体积很多场景还会选择 INT8 量化这已经超过了本期话题。无论切换到哪种低精度格式都不能只看推理快了、显存降了还要做精度对比测试例如输出 logits 的平均误差、分类准确率、回归误差是否在允许范围内。选型的关键依据是“业务接受多少误差”。对图像分类来说小数点后第四位的误差可能无关紧要对回归任务或者关键指标计算误差可能直接导致结果不可用。所以最好的习惯是建立一套简单的评测脚本记录全量数据在 FP32 与低精度格式下输出的偏差分布再决定是否上线。6. 浮点数问题排查清单总结实际项目里最常见的现象和排查路径可以做成下面这张表问题现象可能原因排查方式解决方案0.1 0.2 ! 0.3十进制小数无法用二进制精确表示float.hex()查看真实存储值使用math.isclose或容差比较循环累加后结果偏差越来越大每次累加都会发生舍入误差误差累积对比从大到小、从小到大累加的结果改成整数累加或使用 Kahan 求和大数加小数结果没有变化双精度尾数位不足小数被舍入打印两个数的hex()表示统一量级或换 Decimal/整数判断x float(nan)永远失败NaN 不等于任何值包括自己打印type(x)检查运算来源用math.isnan(x)判断日志或接口返回-0.0运算结果产生负零打印符号位格式化前判断是否接近 0模型从 FP32 转 FP16 后精度骤降FP16 动态范围小部分参数下溢或溢出对比原始模型和转换模型的输出分布改用 BF16或对关键层保留 FP32JSON 里浮点数被截断或序列化不一致默认格式化小数位数不足对比原始值与序列化值使用足够位数的十进制字符串格式排查时切忌只盯着一个结果看。先确认是什么运算产生了这个数再确认它进入系统之前是不是已经是近似值最后才能判断是该修展示、修比较逻辑还是修存储类型。7. 工程实践建议什么时候用浮点数什么时候千万别用下面这些建议适合写进团队代码规范和 Review 清单里。7.1 明确浮点数的使用边界适合用浮点数的场景包括物理坐标、模型权重、科学计算、图像像素、概率值、信号处理。核心特征是不需要“精确等于某个人类约定值”只需要在允许误差范围内逼近真实值。不适合用浮点数的场景包括金额、库存、订单数量、配置版本号、身份证号、电话号码、任何用作唯一标识的字段。核心特征是“必须精确相等”或者“二进制表示的十进制规则不适用”。7.2 日志与序列化要保留足够的有效位打印浮点数时语言默认格式往往会隐藏真实误差。C 语言里printf(%f, x)只能打印出 6 位小数Java 的Double.toString已经尽力给出最短往返表示但不同语言处理方式并不一致。如果要把浮点数写入日志做问题排查建议输出足够多的有效位比如 Python 用repr或format(x, .17g)C 语言用printf(%.17g, x)保证丢进日志的字符串能完整还原出同一个二进制浮点数。这样排查问题时才能从日志反推出原始数据。7.3 为数值计算编写测试断言单元测试里如果直接断言某个浮点计算等于某个常量很容易在代码重构或平台切换时突然挂掉。建议统一使用“相对误差 绝对误差”的断言函数。大型计算模块里还要增加边界用例极小值、极大值、NaN、Infinity、负数、零。7.4 控制数值算法的误差来源同一个数学公式不同的计算顺序可能带来完全不同的精度表现。两个非常接近的大数相减时会引发灾难性抵消把有效数字全消掉。此时可以改写成等价形式例如使用expm1(log1p(...))之类的数值稳定函数或者重新推导公式。这不是要求每个开发者都变成数值分析专家但遇到问题时要知道从“计算顺序”和“数值稳定”这两个角度去排查而不是盲目怀疑语言层面的浮点实现。7.5 训练和推理场景要记录精度对比结果如果要在深度学习项目里切换低精度浮点格式建议准备一份精度对比报告。至少包括模型输出均值、标准差、最大绝对误差、分类准确率或回归指标。对比结果本身不一定要多复杂但足以说明“切换格式后仍然满足业务要求”。8. 收尾学会与浮点数共存回到最开始的问题。0.1 0.2不等于0.3不是 Bug也不是哪个语言写错了而是现实世界与二进制表达之间的天然鸿沟。任何遵循 IEEE 754 的标准语言都会这样。理解这一点之后真正重要的是建立一套与浮点数相处的规则。我的建议是在脑子里把浮点数当成“尺子上的刻度”它适合测量但不适合计数。需要精确的地方就用整数或者十进制字符串系统需要近似的地方就用浮点数并约定好误差范围。两边边界清晰之后大部分坑都不会再踩到。如果你现在正被一段浮点数代码折磨建议按这篇文章的顺序检查一遍先看存储和比较方式再看累加顺序最后确认是否用了不合适的数值类型。技术上的问题往往不是代码写得复杂而是我们从一开始就对“数值的精确语义”缺少一个明确约定。