原文链接:float 浮点数精度丢失问题分析 | 程序员阿Tu
正文
前言
最近在研究如何在UE4的游戏引擎中搭建出地球,从而承载地图可视化的内容。粗略来讲,地球可以被视为一个半径 6371km 的球体,而 UE4 中一个单位代表真实世界的 1cm ,因此如果要制作一个 1:1 还原世界的地球,映射到 UE4 世界中地球半径为 637100000 个单位,使用三维直角坐标系表示球面上的点,每个分量也需要千万、亿级别的表达。
依照这种逻辑制作出的地球,当摄像机在地面上空漫游运动时,会发现摄像机出现了无法控制的抖动,究其原因, UE4 中表示位置使用的都是 float 类型的 FVector,过大的数量级导致了精度丢失。为了解决这个问题,需要从原理上理解 float,才能分析问题产生的根本原因。
思考
int, float, double 这些类型编码时频繁使用,但具体的存储方式却关注的很少。我们知道 int 和 float 类型都使用 4 字节 32 位二进制进行存储,因此可以先做一个横向对比:
int 是有符号整型,先不考虑符号位以及补码表示等概念,每一位 2 进制只有 0 和 1 两种选择,因此 int 型最多能表达 个整数,这与
int 能够表达范围内共
个整数吻合。
float 同样也使用 4 字节存储,但除了整数外还能够存储小数位,并且表示的范围比 int 还要更大。这就带来了一些思考:float 一定是使用了与 int 不同的存储方式,才能存储更多的数据,这种方式优点十分明显,但是否也有其局限性和缺点。
float 存储方式
与 int 的存储方式不同,float 为了支持小数,因此采用科学记数法进行数据的表示。在十进制中,一个科学计数法的数字可以被写为 的形式,
是 1-9 中的一个整数。
推演到二进制中,一个科学计数法的数字可以被写为 的形式,因为
只能取 1,所以可以节省一位,表达为
,因此
float 需要存储的内容有以下三部分:
:符号位(sign),占
1位,表示浮点数的正负,定义 0 为负,1 为正。:指数位(exponent),占
8位,采用无符号整数表示0~255共 256 个数字。为了表示无符号整数,实际是对数字做了偏移 +127 处理,因此 127 表示的是 0 。除了预留 全0 和 全1 两个数字外(即 0 和 255,预留用作特殊表示,会在下面的小节进行叙述),指数范围取值在-126 ~ +127这一区间,偏移完即1~254。:尾数位(significand),占
23位,加上显式的整数位,一共有 24 位的精度。
以小数 0.15625 为例,可以按照这个规则,推导一下 float 的存储方式:
这里需要复习一下十进制小数写成二进制的规则,即 “乘 2 取整,顺序排列”,通过不断的左移反推每一位小数。计算过程见下表:
| 剩余小数 | 乘2 | 取整数位 |
|---|---|---|
| 0.15625 | 0.3125 | 0 |
| 0.3125 | 0.625 | 0 |
| 0.625 | 1.25 | 1 |
| 0.25 | 0.5 | 0 |
| 0.5 | 1 | 1 |
可见最后二进制小数结果为 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)1 ,16777218 尾数与其完全一致,只是通过指数位多移动了一位。所以,所有大于 16777216 的整数,只要能够表示为 0~16777215 之间一个数字进行移位(
)操作,就能够保证不丢失精度。
小于等于 16777216 的所有整数可以被精确表示,这就是 float 保证 7 位有效数字的原因。
float 表示小数
根据小数位的二进制表示方式我们可以知道,一个小数需要拆分为多个小数的加和形式。
以 0.75 为例,可以分解为 0.5+0.25 ,转换为二进制即为 0.11 ,但更多的小数会使得二进制表示无限循环,因为存储位数有限,从而只能逼近小数,造成精度误差。
以 0.7 为例,直接使用 “乘 2 取整,顺序排列” 后可以看到,结果为 0.10 接上 1100 的无限循环。
| 剩余小数 | 乘2 | 取整数位 |
|---|---|---|
| 0.7 | 1.4 | 1 |
| 0.4 | 0.8 | 0 |
| 0.8 | 1.6 | 1 |
| 0.6 | 1.2 | 1 |
| 0.2 | 0.4 | 0 |
| 0.4 | 0.8 | 0 |
| 0.8 | 1.6 | 1 |
| 0.6 | 1.2 | 1 |
| 0.2 | 0.4 | 0 |
| 0.4 | 0.8 | 0 |
下图是 IEEE754 给出的 float 和 double 精度范围
可以看到,当 float 数字在千万级别时,精度是 1 ,即已经无法感知到小数位的变化。
为了达到小数点后一位的精度,至少要保证 float 数字在百万以内,如果需要更高精度,就要根据图表控制 float 数字的数量级。
这是在不使用 double 的情况下唯一可以解决精度丢失问题的方法。
附:float 指数位预留值
前面说到指数位预留了全0和全1,实际上是用来表示 float 的 0、无穷以及 NaN (Not a Number)等情况,用于覆盖无法直接用科学记数法表示的情况,具体的所有情况可以看下面的表格。
| 指数位 | 尾数位为0 | 尾数位不为0 |
|---|---|---|
| 全0 | 0 | 非标准值 |
| 其他 | 标准值 | 标准值 |
| 全1 | ±∞ | NaN |
其中非标准值用于扩充 情况下整数位为0的情况,当尾数位是
时,表示的非标准值为
,使得最趋近于0的值为
。