我平时在写浮点数相关的代码时,经常会用一些可笑的技巧来“规避”浮点数运算导致的随机误差,浮点数运算细节晦涩难懂,我确实一直对浮点数原理及其运算一知半解,写起代码来战战兢兢,本文参考刘纯根所著的《浮点计算——编程原理、实现与应用》,把浮点数的原理简单梳理一遍(主要以float为例),作为笔记,以备查验。
IEEE754标准规定,浮点数由“符号”、“指数”和“尾数”3部分构成:
精度 | C++类型 | 长度 | 符号位数 | 指数位数 | 尾数位数 | 有效位数 | 指数偏移 | 隐含位 |
---|---|---|---|---|---|---|---|---|
单精度浮点数 | float | 32 | 1 | 8 | 23 | 24 | 127 | 1个隐含位 |
双精度浮点数 | double | 64 | 1 | 11 | 52 | 53 | 1023 | 1个隐含位 |
扩展双精度浮点数 | long double | 80 | 1 | 15 | 64 | 64 | 16383 | 无隐含位 |
float的规格化表示为: ± 1. f × 2 E − 127 ±1.f \times 2 ^ {E - 127} ±1.f×2E−127,其中, f f f是尾数, E E E是指数。以float为例:
比如十进制数123.125,其二进制表示为:1111011.001,规格化表示为: 1.111011001 × 2 6 1.111011001\times2^6 1.111011001×26 也就是 1.111011001 × 2 133 − 127 1.111011001\times2^{133 - 127} 1.111011001×2133−127 , f f f= 111011001, E E E = 133,二进制为10000101,图示如下:
规格化表示的浮点数,整数位固定为1,可以省略,所以用23位可以存储24位的尾数。这里可以得出一个结论:任意一个int值(二进制表示),只要存在这样的序列:从最低位开始找到第一个1,然后从这个1向高位数移动24位停下,如果更高的位上不再有1,那么该int值即可被float精确表示,否则就不行。简单说,就是第一个1开始到最后一个1为止的总位数超过24,那么该int值就不能被float类型精确表示,例:
图中能被丢弃的0,在尾数上体现出来,丢弃一个0,尾数的1就前移1位,并没有损失精度。
很容易得出,从1开始的连续整数里面第一个不能被float精确表示的整数,其二进制形式为:1000000000000000000000001,即16777217: 1.000000000000000000000001 × 2 24 1.000000000000000000000001\times2^{24} 1.000000000000000000000001×224 , f f f有24位,最后一个1只能舍弃,也就是 1.00000000000000000000000 × 2 24 1.00000000000000000000000\times2^{24} 1.00000000000000000000000×224,即 1.0 × 2 24 1.0\times2^{24} 1.0×224,这个数实际上是16777216。也就是说16777217和16777216的内存表示是一样的:
那么16777217之后的下一个可以被float精确表示的int值是多少呢?很简单,向16777217上不断的加1,直到满足“第一个1开始到最后一个1为止的总位数为24位”:
1000000000000000000000010就是16777218,规格化表示为: 1.00000000000000000000001 × 2 24 = 1.00000000000000000000001 × 2 151 − 127 1.00000000000000000000001\times2^{24} =1.00000000000000000000001\times2^{151 - 127} 1.00000000000000000000001×224=1.00000000000000000000001×2151−127
其 f f f 是23位(最后一位是1)。
二进制的浮点数,其科学记数法的形式为:
± X n X n − 1 … X 0 . X − 1 X − 2 … X − m = ± X n . X n − 1 … X 0 X − 1 X − 2 … X − m × 2 n ±X_nX_{n-1}\dots X_0.X_{-1}X_{-2}\dots X_{-m}=±X_n . X_{n-1}\dots X_0X_{-1}X_{-2}\dots X_{-m}\times2^n ±XnXn−1…X0.X−1X−2…X−m=±Xn.Xn−1…X0X−1X−2…X−m×2n
其中 X i X_i Xi只能是0或1。规格化表示为:
± X n X n − 1 … X 0 . X − 1 X − 2 … X − m = ± 1. X n − 1 … X 0 X − 1 X − 2 … X − m × 2 n ±X_nX_{n-1}\dots X_0.X_{-1}X_{-2}\dots X_{-m}=±1 . X_{n-1}\dots X_0X_{-1}X_{-2}\dots X_{-m}\times2^n ±XnXn−1…X0.X−1X−2…X−m=±1.Xn−1…X0X−1X−2…X−m×2n
即约定小数点位于最高位的1之后,因此 X n X_n Xn不能是0。既然整数位只能是1,那么这一位可以不用存储,称之为隐含位,也就是说大家心里明白就行。这样做的好处是可以多存储一位小数部分,但是在作浮点运算时需要特殊处理,运算之前要补齐这一位,运算之后又得略去这一位,会有一点点性能损耗;如果有效位本来就足够多,省去整数位也赚不了多少便宜,这可能是扩展双精度浮点数不采用这种方案的原因。
指数n是一个普通的整数,可正可负(负指数表示纯小数),在计算机科学,整数编码一般采用补码(负数是其正数的反码+1),最高位是符号位(0表示正数,1表示负数),但IEEE754标准中,浮点数的指数使用了“真值 + 偏移值”的编码方式,将原码空间分成两部分,小的部分表示负数,大的部分表示正数。一般的,全0和全1都有特殊用途,以float为例,其指数部分有8位,原码空间就是0 ~ 255,不算0和255(全0和全1),剩下254个数,对半分即127,则N位指数的编码和原值有下列关系:
E = e + 2 N − 1 − 1 E=e+2^{N-1}-1 E=e+2N−1−1
其中, E E E:指数编码; e e e:指数真值 。float类型的指数编码就是: E = e + 127 E=e+127 E=e+127,[ 1,127 ) 是负数, [ 127, 254 ] 是正数,0和255有特殊用途。
IEEE标准定义了6类浮点数:
指数 | 分类 | 隐含位 | 尾数 | 说明 |
---|---|---|---|---|
xx…xx | 有限数 | 1 | xx…xx | 指数非全0,非全1,有隐含位 |
00…00 | 0 | 0 | 0 | 尾数为0 |
00…00 | 弱规范数 | 无 | xx…xx | 尾数不为0 |
11…11 | ±∞ | 1 | 0 | 尾数为0 |
11…11 | QNaN | 1 | 1xx…xx | 尾数不为0,尾数高位为1 |
11…11 | SNaN | 1 | 0xx…xx | 尾数不为0,尾数高位为0 |
有限数就是遵循规格化的常规数,其指数在最小数和最大数之间,且整数位恒为1,其形式为:
± 1. f × 2 E − o f f s e t ±1.f×2^{E−offset} ±1.f×2E−offset
例如float,其指数 E E E有8位,取值范围为 ( 0, 255 ) 也就是 [ 1, 254 ] 。有限数在运算过程中最常见的问题就是溢出,即运算结果无法用有限数表示。
0,有一位隐含位(为0),除符号位可能不为0外,其它所有位都为0,也就是说存在正0和负0,其形式为:
± ( 0.0 ) × 2 0 − o f f s e t ±(0.0)×2^{0-offset} ±(0.0)×20−offset
数学上,0没有正负之说,但按IEEE754标准,却有正负0之分(由符号位标识),注意:正0应该和负0相等,而不应正0大于负0。
弱规范数的指数和0一样,都是全0,但没有隐含位,尾数部分不为0,其形式为:
± ( f ) × 2 0 − o f f s e t ±(f)×2^{0-offset} ±(f)×20−offset
弱规范数的整数位是尾数的最高位。由于弱规范数没有隐含位,在向有限数转换时,要向高位移一位,以产生隐含位,但指数不变(不减1);从有限数形式转换成弱规范数形式时,正好相反,向低位移1位,指数仍不变。
在计算过程中,如果中间结果小于最小的有限数却不是0,即出现下溢,如果当做0处理,可能会导致计算终止,引入“弱规范数”之后,在0和最小的有限数之间有相当一部分数可以表示为“弱规范数”,从而提高了计算能力。
± ∞ ±∞ ±∞,指数部分为全1(即最大值),整数位是1(是隐含位),尾数是0,其形式为:
± ( 1.0 ) × 2 M A X − o f f s e t ±(1.0)×2^{MAX-offset} ±(1.0)×2MAX−offset
float类型即: ± ( 1.0 ) × 2 255 − o f f s e t ±(1.0)×2^{255-offset} ±(1.0)×2255−offset,产生 ∞ ∞ ∞的情形一般有:
● 自身运算,例如 ∞ + 1.0 = ∞ ∞+1.0=∞ ∞+1.0=∞
● 被0除,例如 1 / 0 = ∞ 1/0=∞ 1/0=∞, 1 / − 0 = − ∞ 1/{-0}=-∞ 1/−0=−∞
● 上溢,即计算结果超出了类型范围
NaN,即Not a Number,和 ∞ ∞ ∞一样,指数部分为全1(最大值),整数位是1(是隐含位),但尾数部分不为0,其形式为:
± ( 1. f ) × 2 M A X − o f f s e t ±(1.f)×2^{MAX-offset} ±(1.f)×2MAX−offset
其中, f f f 不为0。NaN有SNaN(Signal NaN)和QNaN(Quiet NaN)之分,IEEE标准要求:SNaN参与运算要触发异常,而QNaN则不触发异常。两者的区别在于,SNaN的尾数最高位是0,而QNaN的尾数最高位是1。
IEEE标准只规定NaN的尾数不为0,并没有限定应该是什么,这就给予具体实现一定的空间。比如可以在计算出问题时,在尾数部分设置一些特殊的值,有利于调试。
NaN有一些晦涩的运算规则:
● 0 × ∞ = N a N 0×∞=NaN 0×∞=NaN,因此“0乘任何数都是0”不是恒成立的
● NaN参与的所有逻辑运算,只有 N a N ! = N a N NaN!=NaN NaN!=NaN为真,其它的都不为真,即使 N a N = = N a N NaN==NaN NaN==NaN的结果也为
false,因此在浮点数的逻辑运算中,编译器没有办法做积极的优化,因为如果有NaN参与逻辑运算,比如
x = N a N x=NaN x=NaN,那么 ! ( x < y ) !(x < y) !(x<y)和 x > = y x>=y x>=y就不等价了。
令人无语的是,编译器不一定遵循IEEE标准的规定,可想而知,浮点运算有多难搞。
根据有限数的规格化形式,指数取最小值1,隐含位是1,尾数取最小值0(23位都是0):
1.0 × 2 1 − 127 = 1.0 × 2 − 126 = 1.1754943508222875079687365372222 e − 38 1.0×2^{1−127}=1.0×2^{-126}=1.1754943508222875079687365372222e^{-38} 1.0×21−127=1.0×2−126=1.1754943508222875079687365372222e−38
如果浮点运算的结果小于这个数,就出现下溢,一般将其结果转换为最小的有限数,或者弱规范数,如果弱规范数也不能表示,那么将转换成0。
根据有限数的规格化形式,指数取最大值254,隐含位是1,尾数取最大值(23位都是1):
1.11111111111111111111111 × 2 254 − 127 = ( 2 − 2 − 23 ) × 2 127 = 2 128 − 2 104 = 3.4028234663852885981170418348452 e 38 1.11111111111111111111111×2^{254−127}\\=(2-2^{-23})×2^{127}=2^{128}-2^{104}\\=3.4028234663852885981170418348452e^{38} 1.11111111111111111111111×2254−127=(2−2−23)×2127=2128−2104=3.4028234663852885981170418348452e38
如果浮点运算的结果超过这个数,就出现上溢,一般将其结果转换为最近的有限数或 ∞ ∞ ∞。
根据弱规范数的规格化形式,尾数取最小值:
0.0000000000000000000001 × 2 0 − 127 = 2 − 22 × 2 − 127 = 2 − 149 = 1.4012984643248170709237295832899 e − 45 0.0000000000000000000001×2^{0−127}\\=2^{-22}×2^{-127}=2^{-149}=1.4012984643248170709237295832899e^{-45} 0.0000000000000000000001×20−127=2−22×2−127=2−149=1.4012984643248170709237295832899e−45
f f f 是23位,且最高位是整数位,因此小数点之后只有22位,最后一位为1,即可得出该数。
FLT_EPSILON是C++定义的一个float数,该数是满足 1.0 + F L T _ E P S I L O N ! = 1.0 1.0 + FLT\_EPSILON != 1.0 1.0+FLT_EPSILON!=1.0的最小的float有限数,比该数还小的float有限数会有: 1.0 + x = 1.0 1.0 + x = 1.0 1.0+x=1.0。根据这个定义,从比1.0大的最小float有限数开始推导:
1.00000000000000000000001 × 2 127 − 127 = 1.00000000000000000000001 = > F L T _ E P S I L O N = 0.00000000000000000000001 = 2 − 23 = 0.00000011920928955078125 ≈ 1.192092896 e − 7 1.00000000000000000000001×2^{127−127}\\=1.00000000000000000000001\\=>FLT\_EPSILON = 0.00000000000000000000001\\=2^{-23}=0.00000011920928955078125\approx 1.192092896e^{-7} 1.00000000000000000000001×2127−127=1.00000000000000000000001=>FLT_EPSILON=0.00000000000000000000001=2−23=0.00000011920928955078125≈1.192092896e−7
它的规格化表示: 1.0 × 2 104 − 127 1.0×2^{104−127} 1.0×2104−127, E E E = 104, f f f = 0。注意,该数远不是最小的正float有限数,它比最小的正float有限数还要“大很多”,它的指数是-23,而最小的正float有限数的指数是-126。这个数并没有什么特别的意义,在代码中无条件的使用 f a b s ( x − y ) < F L T _ E P S I L O N fabs(x-y)
浮点运算,深奥、晦涩、难懂!我们对浮点运算的所有想当然的假设可能都是不靠谱的。正如Herb Sutter所说,世界上的人可以分3种:
● 一种是知道自己不懂浮点运算(我就是);
● 一种是以为自己懂浮点运算;
● 最后一种是极少的专家级人物,他们想知道自己是否有可能最终完全理解浮点运算。