浮点数用来编码形如的有理数,在涉及非常大的数字()、非常接近于 0 的数字()或者作为实数的近似值的运算中很有作用。
浮点数的不精确性
打开命令行中的 jshell 工具,在里面输入“0.3 + 0.6”,然后会得到如下结果:
jshell> 0.3 + 0.6
$1 = 0.8999999999999999
可以看到一个简单的加法,竟然没办法得出一个精确的结果,其原因就是计算机使用浮点数表示小数,而浮点数只能进行实数的近似值的运算,像 0.3,0.6,0.9 这些小数都是没办法精确表示的。
而这也是很好理解的事情,如果计算机使用 32 比特编码一个数字,理论上可以表示 2 的 32 次方个数(约 40 亿),而这显然没办法精确表示整个实数集。
那为什么计算机连 0.3,0.6 这些简单的小数也无法精确表示呢?
这和计算机采用的浮点表示法有关。接下来本文将介绍浮点表示法的具体编码方式,并探讨编程中如何使用浮点数以及如何处理因为浮点的不精确性可能导致的问题。
二进制小数
形如
这样的表示法,其中每个二进制位的取值为 0 或 1,这样的二进制小数表示的数 b 的定义如下:
所以小数的二进制表示法仅能表示可以写成形式的数,其它的数只能近似表示。
按以上编码方式,二进制小数的表示范围是极其有限的,这种固定小数点位置的表示方法(定点表示法)不能有效地表示特别大的数字。
IEEE 浮点表示
通过给定 x 和 y 的值来表示形如的数,可以使用有限长度有效地表示特别大的数字。IEEE 浮点标准用的形式来表示一个数:
-
符号
一个单独的符号位 s 直接编码符号 s, 决定这个数是负数(s=1)还是正数(s=0) -
尾数
n 位小数字段编码尾数 M -
阶码
k 位阶字段编码阶码 E
单精度浮点格式(C 语言 float 类型)中 s、exp、frac 三个字段分别为 1 位,k=8 位,n=23 位,得到一个 32 位的表示;双精度浮点格式(C 语言 double 类型)中 s、exp、frac三个字段分别为 1 位,k=11 位,n=52 位,得到一个 64 位表示。
根据 exp 的值,被编码的值可以分为三种不同的情况:
- 规格化的值:阶码位既不全为 0,也不全为 1 的情况
阶码位被解释为以偏置形式表示的有符号数,也就是说阶码的值 E = e - Bias,其中 e 为无符号数,其位表示为,Bias 是一个等于的偏置值(单精度127,双精度1023)。
尾数位被解释为描述小数值 f,其中 0 ≤ f <1,其二进制表示为。尾数定义为 M = 1 + f,这种方式也叫做隐含的以 1 开头的表示。
- 非规格化的值:阶码位全为 0
阶码的值 E = 1 - Bias,尾数的值为 M = f。
非规格化提供了表示数值 0 的方法;另外还可以表示非常接近 0.0 的数,它们具有逐渐溢出的属性,可能的数值分布均匀地接近 0.0。
- 特殊值:阶码位全为 1
当尾数位全为 0 时,表示无穷;尾数位非 0 时,表示“NaN”,即“不是一个数(Mot a Number)”。
关于浮点数的一些其它性质
非规格化数 E 定义为 1-Bias 而不是 -Bias,可以补偿非规格化数的尾数没有隐含的开头的 1,使非规格化数和规格化数之间平滑转变。
将浮点数的位表达式解释为无符号整数,对于正数而言其大小关系不变,这样设计是为了浮点数能够使用整数排序函数来进行排序。处理负数时大小关系相反。
舍入
浮点数无法精确的表示实数,对于值 x,通过一种方法,找到最接近的值 ,它可以用浮点形式表示出来,这就是舍入运算的任务。
IEEE 浮点格式定义了四种不同的舍入方式,默认方式是向偶数舍入,而其它三种可以用于计算上界和下界。
向偶数舍入
也称为向最接近的值舍入,是默认的方式,对于两个可能结果的中间数值,采用向上或向下的舍入方式,使结果的最低有效位数字为偶数。应用于二进制小数时,可以认为 0 是偶数, 1 是奇数。
这样做的好处是可以避免向上舍入或向下舍入引入的的统计偏差,50% 的时间里它将向上舍入,50% 的时间里它将向下舍入。向零舍入
正数向下舍入,负数向上舍入,得到,使得。向上舍入
得到,使得。向下舍入
得到,使得。
转换
C 语言中 int、float 和 double 之间进行类型转换时,遵循的原则如下:
- int 转换成 float,数字不会溢出,但可能被舍入
- int 或 float 转换成 double,能够保留精确的值
- double 转换为 float,可能溢出或被舍入
- float 或 double 转换成 int,值会向零舍入。同时可能溢出,C 标准没有对这种情况制定固定的标准。与 Intel 兼容的微处理器指定位模式[1000...](字长为 ω 时的 )为整数不确定值。一个从浮点数到整数的转换,如果不能为该浮点数找到一个合理的近似值,就会产生这样一个值。
浮点数的加法和精度损失
浮点数的加法实现原理就是先对齐、再计算。两个浮点数的指数位可能是不一样的,所以我们要把两个的指数位变成一样的,然后只去计算有效位的加法就好了。例如 0.5+0.125 的浮点数运算的过程如下图所示。
指数位较小的数,需要在有效位进行右移,在右移的过程中,最右侧的有效位就被丢弃掉了。这会导致对应的指数位较小的数,在加法发生之前,就丢失精度。两个相加数的指数位差的越大,位移的位数越大,可能丢失的精度也就越大。
32 位浮点数的有效位长度一共只有 23 位,如果两个数的指数位差出 23 位,较小的数右移 24 位之后,所有的有效位就都丢失了。这也就意味着,只要两个数,差出 ,也就是差不多 1600 万倍,那这两个数相加之后,结果完全不会变化。
运行下面的代码你会发现,一个值为 2000 万的 32 位浮点数和 1 相加,+1 这个过程因为精度损失,被“完全抛弃”了。
public class FloatPrecision {
public static void main(String[] args) {
float a = 20000000.0f;
float b = 1.0f;
float c = a + b;
System.out.println("c is " + c);
float d = c - a;
System.out.println("d is " + d);
}
}
输出结果:
is 2.0E7
d is 0.0
Kahan Summation 算法
一个常见的应用场景是,在一些“积少成多”的计算过程中,比如在机器学习中,我们经常要计算海量样本计算出来的梯度或者 loss,于是会出现几亿个浮点数的相加。每个浮点数可能都差不多大,但是随着累积值的越来越大,就会出现“大数吃小数”的情况。
看下面这个程序,用一个循环相加 2000 万个 1.0f,最终的结果会是 1600 万左右,而不是 2000 万。这是因为,加到 1600 万之后的加法因为精度丢失都没有了。
public class FloatPrecision {
public static void main(String[] args) {
float sum = 0.0f;
for (int i = 0; i < 20000000; i++) {
float x = 1.0f;
sum += x;
}
System.out.println("sum is " + sum);
}
}
输出结果:
sum is 1.6777216E7
有一种叫作 Kahan Summation 的算法可以解决这个问题。算法的对应代码如下:
public class KahanSummation {
public static void main(String[] args) {
float sum = 0.0f;
float c = 0.0f;
for (int i = 0; i < 20000000; i++) {
float x = 1.0f;
float y = x - c;
float t = sum + y;
c = (t-sum)-y;
sum = t;
}
System.out.println("sum is " + sum);
}
}
输出结果:
sum is 2.0E7
这个算法的原理就是在每次的计算过程中,都用一次减法,把当前加法计算中损失的精度记录下来,然后在后面的循环中,把这个精度损失放在要加的小数上,再做一次运算。这个方法常用来解决大量数据累加中,浮点数精度带来的“大数吃小数”问题
最后
本文主要总结了浮点数的表示方式、浮点的舍入算法、C 语言中浮点类型的转换原则等,并介绍了浮点加法精度损失可能导致的问题以及如何通过 Kahan Summation 算法解决“大数吃小数”问题。
本文主要参考自《深入理解计算机系统》第三章和极客时间《深入浅出计算机组成原理》第 15、16 讲,想要了解更多细节和原理,可以查阅上述资料。