小土刀

【计算机系统导论】2.5 浮点数


计算机算数涉及的两个基本方面是数的表示方法(二进制格式)和基本算术运算(加减乘除)的算法。这两个方面既适用于整数算术、也适用于浮点算术。

浮点数表示成一个数(有效值,significant)乘以一个定值(基值,base) 的某个整数幂(指数,exponent)。浮点数能够表示很大的数和很小的数。

  • 浮点表示(原理,IEEE 754)
  • 浮点算术(加减乘除、精度考虑)

2.5.1 定义

浮点数可以用一个统一的公式来表达:

$$ \sum_{k=-j}^ib_k\times 2^k $$

例如

$$ 5\frac{3}{4}=101.11_2 \;,\; 2\frac{7}{8}=10.111_2 \;,\; 1\frac{7}{16}=1.0111_2 $$

可以看到除以二就相当于右移,并且可以横跨小数点。注意 $0.111…_2$ 非常接近于 1,因为

$$ 1/2 + 1/4 + 1/8 + … + 1/2^i + … \to 1.0$$

通常用 $1.0-\epsilon$ 来表示这个值。

细心的同学就会发现,这种表达方式其实是比较明显的限制的,比如说,只有形为 $\frac{x}{2^k}$ 的小数部分可以被精确表示,其他的数字会变成循环的小数,例如:$\frac{1}{3}=0.0101010101[01]…_2$。

除此之外,另一个问题在于,如果给定了 w 个比特,能够表达的数字其实是有限的,具体的原因会在后面详细解释。

2.5.2 IEEE 754

大多数处理器都实现了 IEEE 754 标准,用于浮点表示和浮点运算。IEEE 754 定义了 32 位和 64 位两种浮点数格式。

存储程序计算机有一个副作用,就是位模式没有内在的含义。相同的位模式可能代表有符号整数、无符号整数、浮点数和指令等。对这个字操作的指令决定了其意义。

计算机算数和用纸和笔手算的算术不同的地方是受到有限精度的约束。这个限制可能会因为计算中数大于或者小于预先的设定而导致无效操作。这种异常,称为『上溢』或『下溢』,可能导致异常、中断或类似于意外的子程序调用。

浮点算术因为是对实际的数字的近似而增加了挑战性,而且要小心确保所选的计算机数能最接近地表示实际数字。不精确和有限的表达带来的挑战是数值分析领域灵感的部分来源。最近转向并行性的趋势使得数值分析再次被关注起来,在顺序计算机上是完全安全的东西,在并行计算机里需要重新考虑,在寻找快速的算法的同时也要有正确的结果。

过去的多年里,计算机算术变得非常标准化,极大地增加了程序的可移植性。当今销售的大量计算机都采用了二进制整数补码算术和 IEEE 754 二进制浮点算术。

IEEE二进制浮点数算术标准(IEEE 754)是20世纪80年代以来最广泛使用的浮点数运算标准,为许多CPU与浮点运算器所采用。这个标准定义了表示浮点数的格式(包括负零-0)与反常值(denormal number)),一些特殊数值(无穷(Inf)与非数值(NaN)),以及这些数值的“浮点数运算符”;它也指明了四种数值舍入规则和五种例外状况(包括例外发生的时机与处理方式)。

IEEE 754规定了四种表示浮点数值的方式:单精确度(32位)、双精确度(64位)、延伸单精确度(43比特以上,很少使用)与延伸双精确度(79比特以上,通常以80位实现)。只有32位模式有强制要求,其他都是选择性的。大部分编程语言都有提供IEEE浮点数格式与算术,但有些将其列为非必需的。例如,IEEE 754问世之前就有的C语言,现在有包括IEEE算术,但不算作强制要求(C语言的float通常是指IEEE单精确度,而double是指双精确度)。

该标准的全称为IEEE二进制浮点数算术标准(ANSI/IEEE Std 754-1985),又称IEC 60559:1989,微处理器系统的二进制浮点数算术(本来的编号是IEC 559:1989)[1]。后来还有“与基数无关的浮点数”的“IEEE 854-1987标准”,有规定基数为2跟10的状况。现在最新标准是“ISO/IEC/IEEE FDIS 60559:2010”。

在六、七十年代,各家计算机公司的各个型号的计算机,有着千差万别的浮点数表示,却没有一个业界通用的标准。这给数据交换、计算机协同工作造成了极大不便。IEEE的浮点数专业小组于七十年代末期开始酝酿浮点数的标准。在1980年,英特尔公司就推出了单片的8087浮点数协处理器,其浮点数表示法及定义的运算具有足够的合理性、先进性,被IEEE采用作为浮点数的标准,于1985年发布。而在此前,这一标准的内容已在八十年代初期被各计算机公司广泛采用,成了事实上的业界工业标准。加州大学伯克利分校的数值计算与计算机科学教授威廉·卡韩被誉为“浮点数之父”。

IEEE 的浮点数标准更多是从数值角度来建立的,对于舍入,上溢出和下溢出都有比较统一的处理方法。但与此同时也给硬件优化带来了比较大的困难。因为和平时使用的数制也有一定差异,从理解的角度来看不够直观,但是好在主流的 CPU 都支持浮点数,所以我们不必过多涉及这方面的细节。

在 IEEE 标准中,我们用下面的公式来表达浮点数:

$$(-1)^s \; M \; 2^E$$

其中 s 是符号位,决定正负;M 通常是一个值在 [1.0, 2.0) 的小数;E 是次方数。具体编码时结构如下,这里用单精度、双精度和扩展精度为例:

其中 s 对应着符号位,exp 对应着 E(注意,不一定等于 E,因为位数限制表达能力有限),frac 对应着 M(注意,不一定等于 M,因为位数限制表达能力有限)。不同的位数就代表了不同的表示能力,也就是单精度,双精度,扩展精度的来源。

规范化值(Normalized Values)

在 $exp \ne 000…0$ 和 $exp \ne 111…1$ 时,表示的其实都是规范化的值,为什么说是规范化呢?这里只需要大概知道因为实数轴上原来连续的值会被规范到有限的定值上并且这些定值之间的间距也是不一样的,具体可以通过后面给出的例子来理解(所以现在不明白也不用担心)

再来回顾一下我们计算浮点数的公式:

$$v=(-1)^s \; M \; 2^E$$

这里的 E 是一个偏移的值 $E=Exp-Bias$,其中

  • Exp: 是 exp 编码区域的无符号数值
  • Bias:值为 $2^{k-1} - 1$ 的偏移量,其中 k 是 exp 编码的位数,也就是说
    • 单精度:127(Exp: 1…254, E: -126…127)
    • 双精度:1023(Exp: 1…2046, E: -1022…1023)

之所以需要采用一个偏移量,是为了保证 exp 编码只需要以无符号数来处理。

而对于 M,一定是以 1 开头的:也就是 $M=1.xxx…x_2$。其中 xxx 的部分就是 frac 的编码部分,当 frac=000.00 的时候值最小($M=1.0$),当 frac=111。。。1 的时候值最大($M=2.0-\epsilon$),也就是说开头的 1 是『免费附送的』,并不需要实际的编码位。

举个例子,float F = 15213.0;,那么

$$15213_{10}=11101101101101_2=1.1101101101101_2 \times 2^{13}$$

于是 frac 部分的值就是小数点后面的数值,而 Exp = E + Bias = 13 + 127 = 140 = $10001100_2$,于是编码出来的浮点数是这样的:

0 10001100 11011011011010000000000
s exp frac

非规范化值(Denormalized Values)

当 $exp = 000…0$ 的时候,值是非规范化的,意思是,虽然实数轴上原来连续的值会被规范到有限的定值上,但是并些定值之间的间距也是一样的,具体可以通过后面给出的例子来理解(所以现在不明白也不用担心)

$$v=(-1)^s \; M \; 2^E$$

和前面不同的是

$$E = 1 - Bias$$

而且 $M=0.xxx…x_2$,不是以 1 开头了。

当 exp=000…0 且 frac = 000…0 时,表示 0,而且因为符号位的缘故,实际上是有 +0 和 -0 两种的。而在 exp=000..0 且 $frac \ne 000…0$ 时,数值是接近 0 的,并且间距是一致的

特殊值

还有一种特殊情况,就是 $exp = 111…1$ 时,表示一些特殊值。

当 exp=111…1 且 frac = 000…0 时,表示 $\infty$,而且因为符号位的缘故,实际上是有 $+\infty$ 和 $-\infty$ 两种的。那些会溢出的操作就会用这个来表示,比如 $1.0/0.0=-1.0/0.0=+\infty\;,\;1.0/-0.0=-\infty$

而在 exp=111…1 且 $frac \ne 000…0$ 时,我们认为这不是一个数值(Not-a-Number,NaN),用来表示那些没办法确定的值,比如 $sqrt(-1),\infty-\infty,\infty\times 0$

2.5.3 实例学习

可能通过文字描述还是不够清晰,我们来看看上面各种情况对应到数轴中是怎么样的:

接下来举一个实际的例子,我们采用 1 位符号位,4 位 exp 位,3 位 frac 位,因此对应的 bias 为 7。回顾一下几个重要公式:

$$v=(-1)^s \; M \; 2^E$$

对于规范化数:$E=Exp-Bias$;对于非规范数:$E = 1 - Bias$,正数部分的数值为

s exp frac E 值
------------------------------------------------------------------
0 0000 000 -6 0 # 这部分是非规范化数值,下一部分是规范化值
0 0000 001 -6 1/8 * 1/64 = 1/512 # 能表示的最接近零的值
0 0000 010 -6 2/8 * 1/64 = 2/512
...
0 0000 110 -6 6/8 * 1/64 = 6/512
0 0000 111 -6 7/8 * 1/64 = 7/512 # 能表示的最大非规范化值
------------------------------------------------------------------
0 0001 000 -6 8/8 * 1/64 = 8/512 # 能表示的最小规范化值
0 0001 001 -6 9/8 * 1/64 = 9/512
...
0 0110 110 -1 14/8 * 1/2 = 14/16
0 0110 111 -1 15/8 * 1/2 = 15/16 # 最接近且小于 1 的值
0 0111 000 0 8/8 * 1 = 1
0 0111 001 0 9/8 * 1 = 9/8 # 最接近且大于 1 的值
0 0111 010 0 10/8 * 1 = 10/8
...
0 1110 110 7 14/8 * 128 = 224
0 1110 111 7 15/8 * 128 = 240 # 能表示的最大规范化值
------------------------------------------------------------------
0 1111 000 n/a 无穷 # 特殊值

观察上表,我们可以发现如下一些比较有意思的规律:

  • exp=0000 时,也就是非规范化的情况,间距是一致的,都是 1/8
  • 因为位数的限制,从零到一之间的数字只能以 1/8 为最小单位来表示,且相邻数字间间距一样
  • 在规范化的部分,可以发现由于 exp 部分的不同,所以相邻数字间的间隔也是不同的,比方说最接近 1 的数字是 15/16 和 9/8,分别相差 1/16 和 1/8,这也是由于 IEEE 浮点数表示法的公式决定的

之后改成用 Go 写的

#include <stdio.h>
#include <stdlib.h>
/**
* @brief The entry of this program
*
* @param argc counts of argument
* @param argv argument variables stored in
*
* @return EXIT_SUCCESS
*/
int main (int argc, char **argv)
{
float f = 7.5;
unsigned int i = *(unsigned int *)&f;
printf ("i:0x%x\n", i);
return EXIT_SUCCESS;
} //end of function main

输出 i:0x40F00000

格式

| |
|<------------------------4Bytes/32Bits------------------------>|
| |
|1Bit(Sign:+/-) 23Bits(Digit) |
+-+---------------+---------------------------------------------+
|x|x x x x x x x x|x x x x x x x x x x x x x x x x x x x x x x x|
+-+---------------++-+-+----------------------------------------+
8Bits(Exp) | | +-(2^-3)
| +-(2^-2)
+-(2^-1)

浮点数7.5如何存储

+7.5 = 3.75 * (21) = 1.875 * (22)

  • Sign = 0
  • Exp - 127 = 2
  • Exp = 129 = 1000 0001(Binary)
  • $0.875 = 0.5 + 0.25 + 0.125 = (2^{-1}) + (2^{-2}) + (2^{-3})$

Digit = 111 0000 0000 0000 0000 0000(Binray)

将上述计算结果代如浮点数格式后,i即为0x40F00000

1Bit(+/-) 23Bits(Digit)
+-+---------------+---------------------------------------------+
|0|1 0 0 0 0 0 0 1|1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0|
+-+---------------+---------------------------------------------+
8Bits(Exp)
+---------------+---------------+---------------+---------------+
|0 1 0 0 0 0 0 0|1 1 1 1 0 0 0 0|0 0 0 0 0 0 0 0|0 0 0 0 0 0 0 0|
+---------------+---------------+---------------+---------------+
0x40 0xF0 0x00 0x00

2.5.4 舍入

对于浮点数的加法和乘法来说,我们可以先计算出准确值,然后转换到合适的精度。在这个过程中,既可能会溢出,也可能需要舍入来满足 frac 的精度。

在二进制中,我们舍入到最近的偶数,即如果出现在中间的情况,舍入之后最右边的值要是偶数,对于十进制数,例子如下:

原数值 舍入结果 原因
2.8949999 2.89 不到一半,正常四舍五入
2.8950001 2.90 超过一般,正常四舍五入
2.8950000 2.90 刚好在一半时,保证最后一位是偶数,所以向上舍入
2.8850000 2.88 刚好在一半时,保证最后一位是偶数,所以向下舍入

对于二进制数也是类似的

十进制 二进制 舍入结果 十进制 原因
2 又 3/32 10.00011 10.00 2 不到一半,正常四舍五入
2 又 3/16 10.00110 10.01 2 又 1/4 超过一般,正常四舍五入
2 又 7/8 10.11100 11.00 3 刚好在一半时,保证最后一位是偶数,所以向上舍入
2 又 5/8 10.10100 10.10 2 又 1/2 刚好在一半时,保证最后一位是偶数,所以向下舍入

2.5.5 浮点数加法

$$(-1)^{s_1}\; M_1 \; 2^{E_1} + (-1)^{s_2}\; M_2 \; 2^{E_2}$$ 这里假设 $E_1 > E_2$,结果是 $(-1)^{s}\; M \; 2^{E}$ ,其中 $s = s_1 \land s_2, M = M_1 + M_2, E = E_1$
  • 如果 M 大于等于 2,那么把 M 右移,并增加 E 的值
  • 如果 M 小于 1,把 M 左移 k 位,E 减少 k
  • 如果 E 超出了可以表示的范围,溢出
  • 把 M 舍入到 frac 的精度

基本性质

  • 相加可能产生 infinity 或者 NaN
  • 满足交换率
  • 不满足结合律(因为舍入会造成精度损失,如 (3.14+1e10)-1e10=0,但 3.14+(1e10-1e10)=3.14
  • 加上 0 等于原来的数
  • 除了 infinity 和 NaN,每个元素都有对应的倒数
  • 除了 infinity 和 NaN,满足单调性,即 $a\ge b \to a+c \ge b+c$

2.5.6 浮点数乘法

$$(-1)^{s_1}\; M_1 \; 2^{E_1} \times (-1)^{s_2}\; M_2 \; 2^{E_2}$$

结果是 $(-1)^{s}\; M \; 2^{E}$,其中 $ s= s_1 \land s_2, M = M_1 \times M_2, E = E_1 + E_2$

  • 如果 M 大于等于 2,那么把 M 右移,并增加 E 的值。
  • 如果 E 超出了可以表示的范围,溢出
  • 把 M 舍入到 frac 的精度

基本性质

  • 相乘可能产生 infinity 或者 NaN
  • 满足交换率
  • 不满足结合律(因为舍入会造成精度损失)
  • 乘以 1 等于原来的数
  • 不满足分配率 1e20*(1e20-1e20)=0.01e20*1e20-1e20*1e20=NaN
  • 除了 infinity 和 NaN,满足单调性,即 $a\ge b \to a\times c \ge a\times b$

计算机设计者必须考虑如何处理算术溢出。但是一些编程语言如 C 和 Java 会忽略整数溢出,而 Ada 和 Fortran 语言则需要通知程序溢出。因此程序员或者是编程环境必须决定在溢出发生时如何处理。

当乘数为常数时,乘法也可以用位移来替代。一些编译器将有短常数的乘法替换为一系列的移位和加法。几乎每个编译器都将以 2 为底的指数乘法替换为移位来进行优化。

在支持浮点算数方面,体系结构设计者面临着一个问题:是否和整数指令使用相同的寄存器,或者为浮点增加一组专用的寄存器。因为程序通常对不同的数据执行整数和浮点操作,单独的寄存器会稍微增加程序中要执行的指令数目。主要的影响是需要建立一组单独的用于浮点寄存器和内存之间传送数据的指令。

单独的浮点寄存器的好处是倍增了寄存器数目而不需要在指令格式中增加更多的位数,因为使用了相互独立的整数和浮点寄存器堆而倍增了寄存器带宽,另外,还可以量身定做针对浮点的寄存器;例如,一些计算机将寄存器中各种大小的源操作数转化为一种单一的内部格式。

捧个钱场?

热评文章