ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

浮点数陷阱全解析:从IEEE 754到fp16/bf16/tf32选型

浮点数陷阱全解析:从IEEE 754到fp16/bf16/tf32选型 如果你刚学编程不久大概率见过这张流传很广的图 0.1 0.2 0.30000000000000004很多人的第一反应是Python 的加法是不是有问题或者编译器是不是有 bug还有人会专门写篇文章吐槽“浮点数真垃圾”。但如果你真正去读 IEEE 754 标准去理解浮点数在内存里是怎么存放的就会发现这些“陷阱”不是异常而是必然。0.1 0.2 不等于 0.3恰恰说明这个标准设计得足够通用只是它的通用性覆盖不了“十进制小数恰好等于二进制小数”这种直觉。本文标题是“浮点数和陷阱”这里的“陷进”我就当它是“陷阱”来写。我会从最底层的位布局讲起讲到精度丢失、大数吞小数、比较失败、溢出下溢这些经典问题再给出一套可以落地的规避方案。最后我会把视野拉高一点聊聊深度学习模型部署里 fp32、fp16、bf16、tf32 应该如何选型——这也是近两年把“浮点数”话题重新推向热门的真实场景。读完这篇文章你至少能回答三个问题浮点数为什么会丢精度能丢多少写业务代码时哪些地方必须避开浮点数做模型推理时fp16、bf16、tf32 到底怎么选1. 这篇文字真正要解决的问题先说一个容易让新手困惑的点浮点数在绝大部分编程语言里都是默认的小数类型。你用 Python 写0.1用 Java 写0.1f用 C 写0.1语言都会自动把它当成浮点数来处理。这就造成了一个假象浮点数等于小数小数的一切运算规则都可以套在浮点数上。但事实是浮点数是一套非常底层的标准化二进制格式它追求的是用有限位表示尽量大范围的数而不是“精确表示所有十进制小数”。这两个目标之间存在根本冲突由此产生了大量开发中常见的“莫名其妙”的现象累加很多次之后结果比预期小了一点。两个“相近”的数相减结果出现巨大误差。浮点数直接和 0 比较永远不相等。数据库字段用 float 存储金额对账时账目不平。串口打印浮点数时只输出一堆科学计数法或者乱码。深度学习模型训练时loss 为 NaN原因可能只是梯度下溢。这些问题分散在不同语言、不同框架里但根源只有一个大家对 IEEE 754 底层机制不够清楚。本文不是简单列几个“避坑技巧”而是把这些坑的成因串起来让你以后遇到浮点数问题自己能推理出原因而不是靠搜答案碰运气。2. IEEE 754 核心概念符号位、指数位、尾数位当前几乎所有现代 CPU、GPU、编程语言都采用 IEEE 754 标准来表示浮点数。这个标准的核心思路是把一个数拆成符号、指数、尾数三个部分在有限的 bit 里存储。最常见的两种格式类型总位数符号位指数位尾数位约等于十进制精度float单精度3218237 位有效数字double双精度641115215~16 位有效数字以 32 位 float 为例它的内存布局是这样的0 00000000 00000000000000000000000 ^ ^ ^ 符号 指数 尾数符号位0 表示正数1 表示负数。指数位用来表示数值的“范围量级”但存储的是经过偏移后的值不是直接存指数。尾数位用来表示数值的“精度”存储的是小数点后的二进制数字。这样设计的直接好处是同样 32 位你可以表示从很小很小的数到很大很大的数动态范围远超 32 位整数。但代价就是精度有限尾数只有 23 位超出部分必须舍入。这就是“浮点”两个字的含义小数点不是固定的它会根据指数“浮动”从而用有限的位数覆盖一个很大的数值范围。2.1 规格化数与非规格化数IEEE 754 标准里大部分普通数都属于规格化数。规格化数的尾数部分默认前面的整数位是 1所以尾数位实际上多了一位有效精度标准文档里通常把这种隐藏的 1 称为“隐式位”或“隐藏位”。比如单精度 float 的尾数虽然只有 23 位但加上隐藏位实际有效精度是 24 位二进制。所以我们在计算十进制有效数字时大约说 float 有 7 位有效数字double 有 15~16 位。当一个数非常接近 0指数部分已经小到无法再表示规格化数时标准会转入非规格化数模式。这时隐藏位不再生效精度会进一步下降但好处是数值可以无限接近 0不会突然跳到一个很大的“最低规格化数”。非规格化数的存在主要在数值计算中避免“突然下溢成 0”的尴尬。这个概念在普通业务开发里不常遇到但在科学计算、深度学习训练里非常重要。很多人训练模型时一旦梯度很小就会碰到非规格化数带来的性能下降或精度问题。3. 为什么 0.1 0.2 不等于 0.3这是浮点数最经典的入门问题。要理解它不能只看十进制要先把十进制小数转成二进制小数。十进制整数转二进制用除法十进制小数转二进制用乘法十进制小数 0.125 转二进制0.125 * 2 0.25整数位为 0 0.25 * 2 0.5 整数位为 0 0.5 * 2 1.0 整数位为 1结束所以 0.125 的二进制是0.001这是一个能精确表示的十进制小数。再看 0.10.1 * 2 0.2整数位为 0 0.2 * 2 0.4整数位为 0 0.4 * 2 0.8整数位为 0 0.8 * 2 1.6整数位为 1 0.6 * 2 1.2整数位为 1 0.2 * 2 0.4整数位为 0 ...到这里你会发现0.1 的二进制小数会一直循环下去是一个无限循环小数。0.2 也是无限循环小数0.3 也是。计算机只能用有限的 32 位或 64 位去存它所以必然要做舍入。这意味着0.1 本身在内存里就不是精确的 0.1只是“最接近 0.1 的那个二进制浮点数”。两个不精确的数相加结果自然也不精确于是就会产生0.30000000000000004这种结果。用代码验证一下print(0.1) print(0.2) print(0.1 0.2) print(0.1 0.2 0.3)输出0.1 0.2 0.30000000000000004 False注意Python 打印 0.1 时显示的是0.1不是0.10000000000000000555这是因为 Python 对浮点数做 repr 时会找到一个最短的十进制字符串保证它转回来还是同一个浮点数。这是一种“友好显示”策略不代表浮点数内部就是精确的。Java 也会遇到同样的问题public class FloatDemo { public static void main(String[] args) { double a 0.1; double b 0.2; System.out.println(a b); System.out.println(a b 0.3); } }输出0.30000000000000004 false4. 浮点数的四大经典陷阱理解了底层原理我们再回头看开发中常见的四类问题就会觉得这些都是“活该”而不是“灵异事件”。4.1 精度丢失任何超出尾数位精度的数都无法精确表示。比如 0.1234567890123456789 0.12345678901234568 999999999999999999.0 # 太大用 double 也无法精确 1000000000000000000.0对于精确性要求极高的场景如金额计算、科学计算中的关键参数直接使用 float/double 是危险行为。4.2 大数吞小数当一个很大的数加上一个很小的数时小数可能直接被“吃掉”。因为浮点数表示法里绝对精度是跟指数相关的数越大相邻两个可表示数之间的间隔越大。 16777216.0 1.0 16777216.016777216 是 2 的 24 次方在单精度 float 中这个数附近的“步长”已经大于 1所以加 1 等于没加。double 里也会有类似问题只是阈值更大。这个陷阱在累加统计时非常危险。比如一个循环要往一个 double 变量里累加 0.1循环十亿次结果不会是你期望的 1 亿而可能差出一截。4.3 溢出与下溢指数位的位数决定了浮点数的表示范围。单精度 float 的最大值大约 3.4e38超过这个值会发生上溢变成无穷大。单精度 float 的最小规格化正值大约 1.18e-38小于这个值会进入非规格化区间再小就变成 0。上溢后程序不一定报错而是会得到一个inf继续参与计算可能产生nan。 1e308 * 10.0 inf在深度学习训练里下溢更常见。如果模型输出经过 softmax 后非常小然后在反向传播中继续传递梯度可能直接变成 0网络就“学不动”了。4.4 比较判断失败这是业务代码里最容易踩的坑。很多人写代码时会对浮点数做“相等”判断if (score 0.5) { // do something }如果 score 是经过多步加法计算出来的 0.5它很可能在二进制表示里并不是精确的 0.5比较结果就是 false。更危险的是有些场景下比较结果“大多数时候是对的”只在某些特殊输入下出错这种 bug 非常难排查。正确做法是使用误差范围abs(a - b) epsilon或者使用 Decimal、整数、字符串比较等替代方案。5. 不同编程语言的解决方案对比浮点数陷阱是所有语言共通的但不同语言提供了不同的解决工具。5.1 Python用 Decimal 做精确十进制运算Python 官方提供了decimal标准库专门用于需要精确十进制表示的场景比如金额计算。from decimal import Decimal, getcontext # 设置精度 getcontext().prec 28 a Decimal(0.1) b Decimal(0.2) print(a b) # 0.3 print(a b Decimal(0.3)) # True注意Decimal(0.1)是从字符串构造而不是从 float 构造。如果写成Decimal(0.1)它会把已经丢失精度的 float 值原样转进去反而更糟from decimal import Decimal print(Decimal(0.1)) # 0.1000000000000000055511151231257827021181583404541015625 print(Decimal(0.1)) # 0.1这是很多初学者容易忽略的细节。使用 Decimal 时只要能保证输入来自字符串或整数计算过程就是可控的。代价是 Decimal 运算比原生 float 慢不少不适合大规模数值计算。5.2 Java使用 BigDecimal并优先使用字符串构造Java 的对应工具是BigDecimalimport java.math.BigDecimal; public class DecimalDemo { public static void main(String[] args) { BigDecimal a new BigDecimal(0.1); BigDecimal b new BigDecimal(0.2); BigDecimal sum a.add(b); System.out.println(sum); // 0.3 System.out.println(sum.compareTo(new BigDecimal(0.3)) 0); // true } }同样建议使用字符串构造BigDecimal a new BigDecimal(0.1);避免使用new BigDecimal(0.1)因为 double 参数版本会把浮点数的二进制表示“如实”转成 BigDecimal结果会是一长串错误值。BigDecimal 比较大小建议使用compareTo而不是equals因为equals会比较 scale1.0和1.00在equals下不相等但在数值上是相等的。5.3 金额计算场景使用最小单位整数除了 Decimal/BigDecimal还有一种非常工程化的做法金额不使用小数存储而是使用“分”作为单位用整数存储。比如 12.34 元存成 1234 分。后续所有加减乘除都在整数域进行不存在任何浮点数精度问题。只有在展示给用户时才做一次除法这样既简单又高效。使用这种方式时要小心单位换算本身可能引入小数。比如费率是 0.003你算手续费时要先思考清楚精度规则避免出现“分”以下的舍入误差。6. 深度学习模型部署中的浮点数选型fp32、fp16、bf16、tf32浮点数不只是编程语言里的一个坑它同时也是深度学习性能优化的核心战场。近年来GPU 硬件和深度学习框架都开始强调低精度计算fp16、bf16、tf32、int8。很多人以为“低精度就是会损失精度、让模型变差”但实际情况要复杂得多。格式全称指数位尾数位相对 fp32 的精度主要用途fp32单精度浮点823基准训练、推理兜底精度fp16半精度浮点510较低范围窄移动端推理、部分训练加速bf16Brain Floating Point87范围同 fp32精度较低大模型训练、分布式训练tf32Tensor Float 32810介于 fp16 和 fp32 之间NVIDIA Ampere 架构加速计算6.1 fp16范围窄但速度确实快fp16 只有 16 位其中指数位只有 5 位可以表示的最大值大约是 65504最小规格化正数大约是 6.1e-5。如果训练过程中出现了大于 65504 的中间结果fp16 会直接变成 inf。所以 fp16 训练通常会配备“loss scaling”机制把 loss 乘上一个大的缩放因子让梯度不至于太小而下溢等计算完梯度后再反过来缩放。这套机制在混合精度训练框架如 PyTorch AMP里已经是标配。6.2 bf16为“大模型训练”而生bf16 的设计很有意思它把 16 位里的 8 位留给了指数尾数只保留 7 位。这样做的效果是bf16 的表示范围和 fp32 几乎一样但精度大幅下降。对于大模型训练来说末位误差对参数更新的影响往往小于“梯度消失导致无法更新”的影响所以 bf16 非常受欢迎。用 bf16 训练几乎不需要担心 overflow反而要担心精度太低导致 loss 收敛不稳定。实践中通常结合 fp32 的 master weights主权重副本来做参数更新。6.3 tf32NVIDIA 专为 Ampere 架构设计的加速格式tf32 是 NVIDIA 在 Ampere 架构上主推的一种格式。它其实是 fp32 的“截断版”保留 8 位指数和 10 位尾数但内部会用 fp32 的位宽去做计算从而在矩阵乘法中实现约等于 fp32 精度的效果同时把计算速度提升到接近 fp16。在 PyTorch 中启用 TF32 通常只需要设置一个环境变量或一行代码torch.backends.cuda.matmul.allow_tf32 True torch.backends.cudnn.allow_tf32 True注意allow_tf32默认一般是关闭的因为它会牺牲一部分精度。如果你的模型对精度不太敏感可以开启换取明显加速。6.4 如何选择给出一个比较务实的判断场景推荐格式原因小规模训练、模型调参fp32稳定、不用操心精度问题大模型预训练bf16 fp32 master weights动态范围大无需 loss scaling显存受限的推理fp16体积小、速度快、范围够用NVIDIA Ampere/Ada 架构 GPU 训练tf32 可选开启加速明显性能下降有限嵌入式/边缘设备int8 或 fp16需要额外量化校准选型没有一个通用最优解关键在于先理解模型的敏感度。对精度不敏感的模型例如某些视觉模型可以大胆尝试低精度对文本生成这类对长尾细节敏感的任务最好先用 fp32 做 baseline再对比低精度的指标差异。7. 如何查看浮点数的底层位形式理解浮点数的一个好方法是直接看它在内存里的二进制位。这样你能直观看到“1”和“0”是如何分布到符号位、指数位、尾数位上的。Python 可以用 struct 拿到 float 的原始 4 字节或 8 字节import struct def float_to_bin(f): # 打包成 IEEE 754 单精度 4 字节 packed struct.pack(f, f) # 解包成整数 as_int struct.unpack(I, packed)[0] # 转换为 32 位二进制 return f{as_int:032b} def double_to_bin(f): packed struct.pack(d, f) as_int struct.unpack(Q, packed)[0] return f{as_int:064b} print(float_to_bin(0.1)) print(double_to_bin(0.1))输出示例00111101110011001100110011001101 0011111110111001100110011001100110011001100110011001100110011010你可以看到0.1 的二进制表示并不是一个整整齐齐的有限序列而是经过舍入后的近似值。把0.1 0.2的位形式打出来再和0.3对比就能明显看出两者最后几位不同。Java 里可以这样查看public class FloatBits { public static void main(String[] args) { float f 0.1f; int bits Float.floatToIntBits(f); System.out.println(Integer.toBinaryString(bits)); } }如果你在做嵌入式开发经常需要把浮点数拆成字节通过串口发送这种位运算思路特别实用。比如把一个 float 转成 4 个字节union float_bytes { float f; unsigned char bytes[4]; };这是一种省内存、适合协议直传的做法但要注意字节序问题。如果你不想写代码也可以在搜索引擎搜索“32位浮点数转换工具”“浮点数转换在线工具”等关键词很多在线页面可以直接输入十进制小数输出二进制位和十六进制。这类工具对快速验证某个想法的帮助很大。8. 常见问题与排查思路问题现象可能原因排查方式解决方案0.1 0.2 不等于 0.3二进制无限小数尾数舍入打印多位小数或查看二进制表示使用 Decimal/BigDecimal/误差比较两个 double 数值相等判断失败浮点数计算产生微小误差打印差值计算 abs(a-b)使用 epsilon 范围比较累加结果越来越小大数吞小数重复舍入打印累加过程的相对误差使用 Kahan 求和算法或 Decimalsoftmax 输出为 0数值下溢检查输入范围打印 logits使用 log_softmax 或在计算中减去最大值模型训练 loss 出现 NaN梯度上溢或下溢检查 loss 值、梯度范数开启 loss scaling使用 bf16串口打印浮点数乱码字节序不一致或格式错误打印原始 hex 数据统一大小端确认发送和接收的字节顺序数据库对比金额不平float 字段精度不够查看数据库字段类型改用 DECIMAL/NUMERIC 类型Python Decimal 精度不对从 float 构造 Decimal打印 Decimal 对象内部值使用字符串构造 Decimal9. 最佳实践与工程建议9.1 能用整数不要用浮点金额、数量、序号、ID、百分比整数这些场景能不用浮点数就不要用。整数运算在二进制下是精确的语义清晰永远不会出现“金额差一分”的问题。9.2 比较浮点数必须引入容差在需要判断两个浮点数是否相等时不要直接用而是定义 epsilondef almost_equal(a, b, eps1e-9): return abs(a - b) eps但在不同量级下固定 epsilon 也不一定合适。更通用的方式是“相对误差绝对误差”结合这里不展开只要记住epsilon 的选择要与数值量级匹配。9.3 减少中间舍入多个数值累加时尽量把数先按大小排序从小到大累加或者使用更高精度的中间类型比如 Python 里用内置高精度、Java 里用 BigDecimal 做中间计算。这能显著降低累计误差。9.4 输出用户可见内容时再格式化在计算过程中保持浮点精度只在最后输出时格式化print(f{value:.2f})Java 里可以用String.format(%.2f, value)或DecimalFormat。注意这只是“显示格式化”不会改变内部的值所以不能用来解决“比较相等”的问题。9.5 数据库金额字段选对类型如果是 MySQL金额字段推荐DECIMAL(10, 2)这样的小数类型不要使用FLOAT或DOUBLE。数据库字段类型一旦选错后续迁移成本很高。9.6 深度学习训练要关注数值区间使用 bf16 和 fp16 时不要只盯着模型指标还要监控中间数值的分布。PyTorch 中可以通过 hook 打印梯度范数如果发现大量 0 或 inf就说明数值区间已经失控。合理的初始化、梯度裁剪、loss scaling 能解决大多数问题。9.7 保留可复现实验环境浮点数在 CPU 和 GPU 上、不同编译器优化级别下运算顺序可能不同导致微小差异。做论文实验或精度对比时尽量固定环境记录运行参数避免“结果差一点点但找不到原因”。10. 总结与后续学习方向这篇文章从 IEEE 754 的符号位、指数位、尾数位出发解释了浮点数陷阱产生的底层原因然后给出了四类经典问题的表现和规避方式最后把视角拉到深度学习模型部署讨论了 fp32、fp16、bf16、tf32 的选型思路。核心就一句话浮点数不是“坏掉的小数”它是用有限二进制位近似所有实数的通用方案。你只能在“速度、范围、精度”三者之间做权衡不可能全部都要。如果你想继续深入建议往这几个方向走阅读 IEEE 754 标准原文中的舍入规则理解“四舍六入五成双”在二进制里怎么实现。学习 Kahan 求和、牛顿迭代、数值稳定的矩阵分解这些是数值计算里降低浮点误差的经典工具。了解 CUDA 混合精度训练的实现细节读 PyTorch AMP 和 NVIDIA 官方文档对比不同精度设置下模型的 loss 收敛曲线。如果你做嵌入式开发可以研究一下定点数表示法很多 MCU 上定点数比浮点数更适合做稳定控制。浮点数的坑并不神秘你花在理解底层原理上的每一分钟都会在以后排查疑难 bug 时加倍还回来。建议顺手收藏这篇文章下次遇到 0.1 0.2 相关的争议直接把它转给同事。
返回列表