数字营销 · Web开发 · 基础设施

浮点运算,浮点数与定点数

从“0.1+0.2为什么不等于0.3”出发,分析计算机中的浮点数表示、IEEE 754 标准以及浮点加法过程,并解释浮点运算产生精度误差的原因。

我们评测 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

然后进行尾数相加。

计算机进行浮点数加减运算时,通常需要经过类似这样的过程:

  1. 检查特殊值和 0;
  2. 比较两个数的指数并进行对阶;
  3. 对有效数字进行加减;
  4. 对结果进行规格化;
  5. 根据舍入规则进行舍入。

具体的流程可以参考这篇文章。因此,浮点运算可能在多个环节产生精度损失。

第一,在十进制转换为二进制时,由于浮点数的位数有限,无限循环的二进制小数只能进行截断或舍入。

第二,在浮点加减法进行对阶时,需要把较小指数的数字进行移位,这也可能导致低位精度损失。

第三,计算结果在规格化和舍入时,同样可能损失一部分精度。

那么,文章开头的 0.1 + 0.2 到底在哪里产生了误差?我们来还原一下过程。首先,分别求出 0.10.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,而很可能只是因为实际参与运算的数据类型和精度不同

这也说明,理解浮点数,不能只看“十进制小数长什么样”,还需要理解它在计算机内部究竟是以什么格式保存和运算的。

参考资料

  1. IEEE754
  2. 浮点运算
  3. 浮点数的运算原理–IEEE 754
  4. 浮点数解惑

评论0

欢迎分享你的看法,也欢迎补充不同的实践经验。