我们评测 CPU 性能的时候,经常会谈到一个概念:浮点运算。我经常在想,浮点运算到底是什么?为什么它会成为评测 CPU 性能的一个重要指标?要搞清楚这个问题,首先需要了解浮点数和定点数。
先看一个非常典型的例子,用 JavaScript 执行:
console.log(0.1+0.2);
结果发现:
0.1 + 0.2 ≠ 0.3
这似乎非常反常识,为什么会这样?
计算机中数字的表示
我们知道,计算机通过内存存储数据,一个字节有 8 个 bit,可以表示 256 种不同的状态。如果用来表示无符号整数,一个字节可以表示 0~255。通过多个字节,我们可以表示更大的整数。例如在 32 位环境中,一个 32 位整数可以直接由 CPU 进行表示和计算;如果数字更大,也可以通过多个机器字长进行分步处理。但即使这样,我们还是会遇到另外一个问题。
如果引入非整数,我们会发现很多数字根本无法被精确表示。整数之间存在无穷多个实数,还有一些无理数,例如圆周率 π、√2 等,也无法通过有限数量的二进制位精确表示。因此,计算机只能采用近似表示的方法保存这些数字。

计算机中的所有信息最终都要使用二进制表示。
例如字符 A 用 ASCII 表示为:
01000001
整数 10 的二进制表示为:
00001010
那么,如果是小数,例如 0.2,用二进制又应该如何表示?我们知道:
(0.1)10 = 1/10
(0.1)2 = 1/2
于是会发现,十进制中的很多有限小数,在转换成二进制之后可能变成无限循环小数。
例如:
(0.2)10 = (0.001100110011001100110011...)2
这就是问题的根源。
对于这些无法用有限位数精确表示的数字,计算机只能保存一个最接近它的值,因此就出现了文章开头 0.1 + 0.2 的问题。那么,计算机中的浮点数到底是如何表示的呢?现代计算机普遍遵循 IEEE 754 标准,其中最常见的是单精度和双精度浮点数,分别占用 32 bit 和 64 bit。
以单精度浮点数为例,它由三部分组成:
符号位 1 bit
指数位 8 bit
尾数位 23 bit
双精度浮点数则是:
符号位 1 bit
指数位 11 bit
尾数位 52 bit
这里通常把第三部分称为尾数(significand),也常被称作有效数字部分。符号位为 0 表示正数,1 表示负数;指数部分采用偏置表示法。例如,0.75 用 IEEE 754 单精度表示:
(0.75)10
= (0.11)2
= (1.1 × 2^-1)2
对于规格化的二进制浮点数,最高位一定是 1,因此这个 1 可以省略,只保存小数部分。
最终:
0 01111110 10000000000000000000000
也就是:
0x3F400000
我们可以用 C 语言简单验证:
#include <stdio.h>
typedef union{
int a;
float b;
} data;
int main()
{
data d;
d.a = 1061158912;
printf("Convert a to hex %x\n", d.a);
printf("Convert a to float %f\n", d.b);
return 0;
}
用 gcc 编译运行:
Convert a to hex 3f400000
Convert a to float 0.750000
可以看到,二进制位模式 0x3f400000 被解释成单精度浮点数之后,确实得到了 0.75。所以,浮点数并不是简单地把一个小数直接存进内存,而是按照 IEEE 754 规定的格式,把数字拆分成符号、指数和有效数字三个部分。
浮点数的运算
既然浮点运算成为衡量 CPU 性能的一个重要指标,那么 CPU 又是如何进行浮点运算的呢?我们已经知道,浮点数是通过类似科学计数法的形式表示的。以加法为例:
a = 1.23 × 10^3
b = 4.25 × 10^8
如果要进行相加,可以先把两个数字调整到相同的指数:
a = 0.0000123 × 10^8
b = 4.25 × 10^8
然后进行尾数相加。
计算机进行浮点数加减运算时,通常需要经过类似这样的过程:
- 检查特殊值和 0;
- 比较两个数的指数并进行对阶;
- 对有效数字进行加减;
- 对结果进行规格化;
- 根据舍入规则进行舍入。
具体的流程可以参考这篇文章。因此,浮点运算可能在多个环节产生精度损失。
第一,在十进制转换为二进制时,由于浮点数的位数有限,无限循环的二进制小数只能进行截断或舍入。
第二,在浮点加减法进行对阶时,需要把较小指数的数字进行移位,这也可能导致低位精度损失。
第三,计算结果在规格化和舍入时,同样可能损失一部分精度。
那么,文章开头的 0.1 + 0.2 到底在哪里产生了误差?我们来还原一下过程。首先,分别求出 0.1 和 0.2 的二进制表示:
(0.1)10
= (0.0001100110011001100110011...)2
≈ 1.10011001100110011001101 × 2^-4
(0.2)10
= (0.0011001100110011001100110...)2
≈ 1.10011001100110011001101 × 2^-3
两个数的指数相差 1,因此需要先对阶。将 0.1 的有效数字右移一位:
a = 0.11001100110011001100110 × 2^-3
b = 1.10011001100110011001101 × 2^-3
然后进行相加:
a + b
= 10.01100110011001100110011 × 2^-3
再进行规格化:
= 1.001100110011001100110011 × 2^-2
经过最终的舍入之后,得到一个与 0.3 非常接近、但并不完全相等的浮点数。
这就是为什么我们会看到类似:
0.1 + 0.2 = 0.30000000000000004
这样的结果。不过这里还有一个非常有意思的问题。我分别用 JavaScript、Python 和 C 语言测试 0.1 + 0.2,发现 JavaScript 和 Python 中通常得到:
0.30000000000000004
而 C 语言测试时却得到了前面推导出的单精度结果。这里其实涉及另外一个非常重要的问题:不同语言和不同类型使用的浮点精度并不一定相同。
JavaScript 的 Number 使用双精度 IEEE 754;Python 2 的 float 在常见实现中也通常对应 C 的 double。而如果 C 语言代码明确使用 float,那么只有 32 bit 精度,因此最终结果就可能与 JavaScript、Python 不同。
所以,看到不同语言输出不同的小数结果,并不意味着它们违反了 IEEE 754,而很可能只是因为实际参与运算的数据类型和精度不同。
这也说明,理解浮点数,不能只看“十进制小数长什么样”,还需要理解它在计算机内部究竟是以什么格式保存和运算的。
评论0
欢迎分享你的看法,也欢迎补充不同的实践经验。