【转载】float 浮点数精度丢失问题分析

787 阅读7分钟

原文链接:float 浮点数精度丢失问题分析 | 程序员阿Tu

正文

前言

最近在研究如何在UE4的游戏引擎中搭建出地球,从而承载地图可视化的内容。粗略来讲,地球可以被视为一个半径 6371km 的球体,而 UE4 中一个单位代表真实世界的 1cm ,因此如果要制作一个 1:1 还原世界的地球,映射到 UE4 世界中地球半径为 637100000 个单位,使用三维直角坐标系表示球面上的点,每个分量也需要千万、亿级别的表达。

依照这种逻辑制作出的地球,当摄像机在地面上空漫游运动时,会发现摄像机出现了无法控制的抖动,究其原因, UE4 中表示位置使用的都是 float 类型的 FVector,过大的数量级导致了精度丢失。为了解决这个问题,需要从原理上理解 float,才能分析问题产生的根本原因。

思考

int, float, double 这些类型编码时频繁使用,但具体的存储方式却关注的很少。我们知道 intfloat 类型都使用 4 字节 32 位二进制进行存储,因此可以先做一个横向对比:

int 是有符号整型,先不考虑符号位以及补码表示等概念,每一位 2 进制只有 0 和 1 两种选择,因此 int 型最多能表达 [公式] 个整数,这与 int 能够表达[公式]范围内共[公式]个整数吻合。

float 同样也使用 4 字节存储,但除了整数外还能够存储小数位,并且表示的范围比 int 还要更大。这就带来了一些思考:float 一定是使用了与 int 不同的存储方式,才能存储更多的数据,这种方式优点十分明显,但是否也有其局限性缺点

float 存储方式

int 的存储方式不同,float 为了支持小数,因此采用科学记数法进行数据的表示。在十进制中,一个科学计数法的数字可以被写为 [公式] 的形式, [公式] 是 1-9 中的一个整数。

推演到二进制中,一个科学计数法的数字可以被写为 [公式] 的形式,因为[公式] 只能取 1,所以可以节省一位,表达为[公式],因此 float 需要存储的内容有以下三部分

  1. [公式] :符号位(sign),占 1 位,表示浮点数的正负,定义 0 为负,1 为正。
  2. [公式] :指数位(exponent),占 8 位,采用无符号整数表示 0~255 共 256 个数字。为了表示无符号整数,实际是对数字做了偏移 +127 处理,因此 127 表示的是 0 。除了预留 全0全1 两个数字外(即 0 和 255,预留用作特殊表示,会在下面的小节进行叙述),指数范围取值在 -126 ~ +127 这一区间,偏移完即 1~254
  3. [公式] :尾数位(significand),占 23 位,加上显式的整数位,一共有 24 位的精度。

以小数 0.15625 为例,可以按照这个规则,推导一下 float 的存储方式:

这里需要复习一下十进制小数写成二进制的规则,即 “乘 2 取整,顺序排列”,通过不断的左移反推每一位小数。计算过程见下表:

剩余小数乘2取整数位
0.156250.31250
0.31250.6250
0.6251.251
0.250.50
0.511

可见最后二进制小数结果为 0.00101 ,为了调整成整数位为 1 的形式,需要左移 3 位,因此结果可以表示成 [公式] ,指数位做 +127 偏移得到结果 124 ,即 0111,1100 ,因此最后的 32 位结果如下图所示:

了解了存储方式后,就能够推导出 float 能够表达的数字范围了。指数偏移后最大为 254 ,所以偏移前为 127 ,尾数为全为 1 ,因此最大和最小数字为 [公式],逼近 [公式]

[公式] 转换为十进制为 [公式]

因此通常说 float 表示的数字范围为 [公式] 。

float 表示一个整数的局限性

根据上述讨论的记数方法,一个 float 数,由符号位决定正负,由尾数位决定数字构成,由指数位决定小数点在尾数上的左右移动

尾数的 23 位加上显式为 1 的整数位共 24 位,而对于一个 24 位的二进制数,能够表示的最大数字为 [公式] ,转换为十进制为 16777215,使用二进制科学计数法表示为 [公式] 。

从这里可以看到,小于 16777215 的整数,都能够靠尾数确定数字构成,指数控制小数点移动这种方式表示出来。

那大于 16777215 的整数呢?

16777216比较特殊,是 [公式] ,因此可以表示为 [公式] ,只靠控制指数位就表示了出来。

接下来看 16777217 ,即 [公式] ,按照二进制规则可以写成 1(23个0)1,共 25 位,转换为科学计数法时可以看到,最后的一个 1 在第 25 位,会被尾数位丢失掉,导致记录的数字还是 16777216,造成了精度的丢失。在 UE4 中也可以进行验证,当将 Camera 的某一分量设置为 16777217 时,会被自动显示为 16777216

接下来看 16777218 ,即 [公式] ,按照二进制规则可以写成 1(22个0)10,共 25 位,转换为科学计数法时可以看到,因为 1 在第 24 位,因此没有被丢失造成精度丢失。

由此可知,并不是所有超过 16777216 的整数都会遇到精度丢失的问题。

实际上,16777218 / 2 = 8388609 = 8388608 + 1[公式] ,二进制可以写成 1(22个0)116777218 尾数与其完全一致,只是通过指数位多移动了一位。所以,所有大于 16777216 的整数,只要能够表示为 0~16777215 之间一个数字进行移位( [公式] )操作,就能够保证不丢失精度。

小于等于 16777216 的所有整数可以被精确表示,这就是 float 保证 7 位有效数字的原因。

float 表示小数

根据小数位的二进制表示方式我们可以知道,一个小数需要拆分为多个小数的加和形式。

以 0.75 为例,可以分解为 0.5+0.25 ,转换为二进制即为 0.11 ,但更多的小数会使得二进制表示无限循环,因为存储位数有限,从而只能逼近小数,造成精度误差

以 0.7 为例,直接使用 “乘 2 取整,顺序排列” 后可以看到,结果为 0.10 接上 1100 的无限循环。

剩余小数乘2取整数位
0.71.41
0.40.80
0.81.61
0.61.21
0.20.40
0.40.80
0.81.61
0.61.21
0.20.40
0.40.80

下图是 IEEE754 给出的 floatdouble 精度范围

可以看到,当 float 数字在千万级别时,精度是 1 ,即已经无法感知到小数位的变化

为了达到小数点后一位的精度,至少要保证 float 数字在百万以内,如果需要更高精度,就要根据图表控制 float 数字的数量级。

这是在不使用 double 的情况下唯一可以解决精度丢失问题的方法。

附:float 指数位预留值

前面说到指数位预留了全0全1,实际上是用来表示 float0无穷以及 NaN (Not a Number)等情况,用于覆盖无法直接用科学记数法表示的情况,具体的所有情况可以看下面的表格。

指数位尾数位为0尾数位不为0
全00非标准值
其他标准值标准值
全1±∞NaN

其中非标准值用于扩充 [公式] 情况下整数位为0的情况,当尾数位是 [公式] 时,表示的非标准值为 [公式] ,使得最趋近于0的值为 [公式] 。