浮点, 让多少老司机折戟?
原文:https://zhuanlan.zhihu.com/p/558540055
浮点值应该是我们比较熟悉的一种数据类型,工作中经常用到,会进行比较、计算、转换等等,这些数值操作往往隐藏着很多陷阱,有的可能对计算值产生微小偏差而被忽略,有的可能造成重大软件事故
前言
浮点值应该是我们比较熟悉的一种数据类型,工作中经常用到,会进行比较、计算、转换等等,这些数值操作往往隐藏着很多陷阱,有的可能对计算值产生微小偏差而被忽略,有的可能造成重大软件事故。
什么是浮点
浮点,英文 float point,其字面意义就是可以漂移的小数点(浮动的小数点),来表示含有小数的数值。
我们在数学运算中,经常会遇到无限小数,如1/3=0.333333....
无限循环,然而计算机存储容量是有限的,需要舍弃掉一些精度,存储近似值。
浮点在计算机中存储
以前不同 CPU 的浮点表示方式是不同的,这就导致了机器之间的兼容性问题,后来英特尔发布了 8087 浮点处理器,被 IEEE 采用,成为业界的标准,被几乎所有 CPU 采用(有的 GPU 除外)。
IEEE754 定义了浮点的存储格式,如何取舍(rounding mode),如何操作(加减乘除等)以及异常处理标准(异常发生的时机与处理方式)。
为了简单起见,我们只看 float32,float64 及其他,原理都一样。
float32 是使用 32 位来表示浮点数的类型,主要有三个部分
sign,符号位: 1bit,0 是负数,1 是正数
exponent,指数部分: 8bit,以 2 为底的指数,
fraction,有效数部分: 23bit,实际表示 24bit 数值,浮点数具体的数值,
图来自维基百科
所以,一个浮点值可以表示为
$$符号位
符号位 1 个 bit,0 表示负数,1 表示正数。
指数
IEEE754 标准规定固定值为
e 为指数位数的长度。
单浮点为 8 位,所以是 2^(8-1) - 1=127,而此值是有符号数,所以范围是[-127, 128],而规定-127 和 128 作为特殊用途,所以真正的有效范围取值是[-126, 127]指数是-127 时的二进制是:00000000,表示指数-127,即 2^-127,特殊用途,表示非正规化,后面详细说明指数是-1 时的二进制是:01111110,表示指数-1,即 2^-1 指数是 0 时的二进制是:01111111,表示指数 0,即 2^0 指数是 1 时的二进制是:10000000,表示指数 1,即 2^1 指数是 127 时的二进制是:11111110,表示指数 127,即 2^127 指数是 128 时的二进制是:11111111,表示指数 128,即 2^128,表示无穷大所以指数位是以 01111111 作为偏移中心点的,称为 bias,所以当指数位为 127 时,则 exponent 时 0。
采用符号位+指数(实际值加上固定的偏移)+有效位数的存储方式,好处是可以用固定 bit 的无符号整数来表示所有的指数值,所以就可以按照字典比较两个浮点值的大小。例如,我们在自研数据库实现中,如果索引是浮点值,则对正浮点数编码时直接按照 IEEE 标准的 bit 存储方式进行编码,这样天然就是有序的。
正规化
采用科学计数法的形式,如果浮点的指数部分在 0 < exponent <= 2^e - 2 之间, 且有效部分最高有效位是 1,那么这种浮点数是正规形式的。
所以浮点值表示为
m 是 mantissa,即可以认为是 fraction
e 是 exponent
c 是 e 最大值的一半
e 能表示的最大值是 255,一半则是 127。所以 c 是 127。第一个有效数字是 1,但是其实 1 没有必要存储,所以就会省略掉,所以 23bit 能表示 24bit 的值。
那我们将 10.1 转成正规化浮点,整数 10 的二进制是:1010, 小数 0.1,十进制小数转二进制,是将小数值转成 2^-1 到 2^-E 的和的形式(二进制,所以相当于除以 2),如
无法除尽,有效位 24bit,所以二进制为 1010.00011001100110011010
再将其正规化,小数点前保留一位,1 可以省略
指数位2^(e-c)
,2^3=2^(130-127)`,所以是 10000010, 符号位是 0,所以 10.1 的二进制表示为:01000001001000011001100110011010
使用转换工具
非正规化
正规化能存储最小的正数值是
当指数和有效数都是 0 是,就是数学上的 0,然而如果指数是 0,有效数不是 0,则称为 underflow,意思非常接近于 0,无法用正规的浮点来表示,这种就称为非正规化浮点。
非正规浮点值表示为:
有效数最高位位是 0,所以可以用来存储更接近于 0 的数字。(-c+1)等于-126,指数和正规化一样小,但由于最高位可以是 0,所以在有效位上,能表示更小的小数值,精度多了 22 位。
我们看下正规化最小正数
再看看非正规化最小正数
可以看出有效位数非正规化精度多了 22 位,最小值十进制从正规化的1.17 x e^-38
变成非正规化的1.4 x e^-45
。
特殊值
IEEE 标准还规定了一些浮点的特殊值
NaN 表示 not a number,没有意义的值,如 0/0, sqrt(-1)。
浮点的精度
数学上是否可以用有限的数字来表示一个有理数,取决于其基数。如,对于基数是 10,1/2 可以表示为 0.5,而 1/3 则表示为 0.333333.....,无法用有限数字表示。
而对于基数是 2 来说,只有分母是 2 的倍数的数才可以被有限数字表示;任何其他分母不是以 2 为倍数的,则无法用有限数字表示。所以对于这种数字,则无法"准确"转换成 10 进制。
例如十进制 0.1,转换成二进制则无法用有限数字表示,需要截断,就造成精度丢失而产生计算过程中的误差。
由于浮点的表示是
既然是有效数乘以指数,那么其精度会随着数值越大而降低。
如浮点 1 的二进制是00111111100000000000000000000000
,而正规化所能表示的 1 的下一个浮点值是00111111100000000000000000000001
(有效位最后一位改成 1),是 1.00000011921,两者相差 0.00000011921。浮点 10000 的二进制是01000110000111000100000000000000
,而其下一个浮点值是01000110000111000100000000000001
,十进制为 10000.0009766,两者相差 0.0009766,可见,其精度降低了几个数量级。
我们来打印 1 到 10 亿量级递增的浮点值精度,即每个量级的值和它浮点所能表示的下一个浮点值。
可以看出,浮点 1 和其下一个浮点值差为 0.00000011920928955078,而浮点 1000000000 和其下一个浮点值的差为 64。
所以随着浮点值越大,其精度也越低。
不准确的浮点
除了前面说的精度问题外,还有以下情况会导致浮点"不准确"
类型转换
算术运算
进制转换
类型转换
一般情况下,我们都知道类型转换可能导致精度丢失,但还有一些其他情况也会导致数值不准确。
如下
输出结果是
由于b=a
赋值是直接将 a 按照 bit 转换成 b,并不是将 0.1 转换为 b,所以其有效数的精度是和 float 一样的。而double c = 0.1
的精度却比 float 高很多,所以造成两者并不相等。
算术运算
浮点值只要经过运算,就可能不符合数学的运算性质,如结合律、分配律等。
如(0.1/10)*10
不等于 0.1。
如下例子
求得 x 的值
我们自己计算得出 x 的值是:0.05, -200.05
但我们通过在程序中用浮点计算得出是:0.050003051757812,-200.050003051757812,0.05 与 0.050003051757812 不相等。
进制转换
前面讨论过进制转换可能导致精度丢失。
浮点比较
既然浮点存储会精度丢失,那么使用浮点进行比较、计算等都需要考虑精度的丢失以及精度偏差的累计等等问题。
而我们使用最多的应该还是对浮点进行比较,那么我们就来了解下浮点该如何进行比较。
直接比较
在没有了解浮点前,可能大部分人都会这么比较
前面我们讨论过,浮点有精度问题,所以如果a
精度丢失了,那么和b
则不相等。
Epsilon
我发现很多文章介绍浮点比较是这样的,我也在不少代码中见过此类用法。
前面我们讨论过由于随着浮点值越大,其精度会越低,而 epsilon 是 1 和 1 后一个浮点值的差值,所以当浮点值大于 1 时,这样比较就不正确。
Relative epsilon
那我们是否可以通过根据浮点数大小来缩放差值呢?代码如下:
这种方式正确吗?
我们使用前面的方程
求得值为 0.050003051757812,-200.050003051757812,而我们期望的是 0.05, -200.05。
按照此函数比较值 0.05 和 0.050003051757812,计算fabs(a - b) / std::min(fabs(a), fabs(b))
是 0.00006102025508880615234375,如果 diff 使用 FLT_EPSILON,则返回 false。
ULPs(units of least precision)最小精度单元
如果知道某个浮点值和其后一个浮点值的差值,就可以通过fabs(a-b) < ULPs(a)
这个差值来判断是否相等。
我们称这个差值为最小精度单元 units of least precision。
我们知道了浮点按照 IEEE 标准的存储方式,那么就可以按照 bit 位相减的方式来计算浮点值之间有多少个最小精度单元。
那我们来比较前面的方程,比较
其值 0.0500030517578125 与 0.05 的 ULPs 是 819!!
而在计算前,我们又怎么知道用一个多大的 ulpsEpsilon 合适呢?
回归本质
前面讨论了几种比较方法,那么到底用哪一种方式比较浮点呢?
其实,并没有一个统一的答案,要视不同的情况而定。
我们先回顾前面的直接比较方法:return a == b;
。
这种方法真的不正确吗?
其实,只要不进行类型转换、运算等,就不会有问题!
假如,我们将浮点写入一个文件,后面再读入此浮点进行比较是否有更改,只要写入文件时按照 bit 位写入文件,那么就不会有问题!
再看return fabs(a - b) <= FLT_EPSILON;
,如果 a 和 b 都小于 1,则这种方法也没有问题。
至于 ULPs,其相对比较准确,但是,浮点经过一系列计算后,我们无法知道浮点精度丢失了多少,该选择一个多大 ulpsEpsilon。所以一个浮点经过大量计算后,我们所期望他的精度最多丢失多少,那么就选择一个多大的 ulpsEpsilon。
最后是 relative_epsilon,此函数计算一个相对的 epsilon,相对于两个比较的浮点而言,他们的差与其值的比。同样,对于一个经过大量计算的浮点,如果它的值与我们期望的值的误差(丢失的精度)在此浮点值中的比不大于多少就表示两者相等,则可以使用 relative_epsilon。
例如,浮点值 0.05 与 0.0500030517578125 的 ULPS 是 819,而 relative epsilon 是 0.00006102025508880615234375,可见其误差在 0.05 的占比是非常低的,如果我们能接受这种占比的误差,就可以选择 relative epsilon 的比较方式。
前面用了很多比较方法,但都没有考虑浮点的特殊值,一个正确的比较函数应该考虑这些情况,如下,我们完善 ULPS 的比较。
最后
不知有多少老司机在浮点值上踩过坑,有的甚至造成重大的事故,甚至灾难。
从文中我们了解了浮点存储原理,精度丢失问题,以及如何进行比较,希望能给大家带来帮助。
版权声明: 本文为 InfoQ 作者【楚】的原创文章。
原文链接:【http://xie.infoq.cn/article/577daf8088772ef34f014496f】。文章转载请联系作者。
评论