IEEE-754 浮点数

当我们设计自定义的浮点类型时,省略了很多重要的小细节:

  • 在整个数据中我们应该分配多少比特用于表示尾数部分和指数部分?
  • 符号位为0表示的是正数还是负数?
  • 这些比特是如何在内存中存储的?
  • 我们怎么表示0?
  • 舍入操作是怎么进行的?
  • 如果我们试图除以0会发生什么?
  • 如果我们试图对一个负数进行开方会发生什么?
  • 如果对最大可表示的数继续增大会发生什么?
  • 我们可以检测到上述行为的发生吗?

早期的计算机并不支持浮点运算,当供应商开始添加浮点协处理器时,它们对这些问题应该如何解答有些不同的看法。由于实现方式多样,使得浮点运算难以可靠和便携地使用,尤其对于那些开发编译器的人来说。

1985年,电气和电子工程师协会(IEEE)发布了一项名为IEEE 754的标准,这项标准为浮点数应该如何工作提供了正式的规范,这一规范很快就被供应商接受并在几乎所有的通用计算机中使用。

浮点格式

与我们自己实现的浮点类型类似,硬件浮点类型也使用一个位来表示符号(sign),同时使用可变数量的位来表示指数(exponent)和尾数(mantissa)部分。例如,在标准的32位浮点数编码中,它使用第一个(最高)位来表示符号,然后使用接下来的8位来表示指数,最后使用剩下的23位来表示尾数。

浮点数采用这样的固定存储顺序的原因是,有助于比较和排序,你可以使用类似于无符号整数的比较器电路来进行操作,除了在其中一个数是负数时可能需要反转一些位。

出于同样的原因,指数是有偏置的(biased):"biased"在这里的意思是指数部分实际的值等于存储的值减去一个固定的偏移值,这个偏移值在32位浮点数中为127。这样做的目的是使得具有负指数部分的浮点数也可以使用无符号整数进行存储。以上面图片中的例子为例:

(1)0×2011111002127×(1+22)=2124127×1.25=1.258=0.15625(-1)^0 × 2^{01111100_2-127} × (1 + 2^{-2}) = 2^{124-127} × 1.25 = \frac{1.25}{8} = 0.15625

IEEE 754 及随后的一些标准定义了不止一种尺寸的浮点表示,最常用的如下:

Type Sign Exponent Mantissa Total bits Approx. decimal digits
single 1 8 23 32 ~7.2
double 1 11 52 64 ~15.9
half 1 5 10 16 ~3.3
extended 1 15 64 80 ~19.2
quadruple 1 15 112 128 ~34.0
bfloat16 1 8 7 16 ~2.3

IEEE 754标准中定义的不同精度的浮点数在实际硬件中的支持情况不同:

  • 大部分的中央处理器(CPU)都支持单精度和双精度浮点数计算,这也是C语言中的floatdouble类型所对应的精度。
  • 扩展格式(Extended format)主要在x86架构中使用,并在C语言中以long double类型存在。然而,在Arm架构的CPU中,long double 类型会退化成双精度。64位的尾数(mantissa)的选择是为了确保每个long long整数可以被精确表示。此外,还有一种分配了32位尾数的40位格式。
  • 四倍精度(Quadruple)和256位的“八倍”(octuple)精度格式主要用于特定的科学计算,普通的硬件并不支持。
  • 半精度(Half-precision)只支持一小部分的操作,主要应用在诸如机器学习、特别是神经网络的领域,因为这些应用需要执行大量的计算,但并不需要高精度。
  • 半精度正在逐渐被bfloat16类型所取代,它牺牲了3位尾数位,但数值范围与单精度(single)相同,这使得它可以与单精度互操作。它主要被一些专用硬件所采用,如张量处理单元(TPU)、现场可编程门阵列(FPGA)和图形处理器(GPU)。"bfloat"这个名字来源于“Brain float”。

低精度类型需要的内存带宽较小,操作执行周期也通常较少(例如,相同的除法指令可能因数据类型不同需要x,y,或z个周期),因此在允许一定误差的情况下,使用它们更为合适。

近年来,深度学习已经发展成为一个非常受欢迎且计算密集的领域,它对低精度矩阵乘法的需求巨大。这导致了制造商开发专门的硬件,或者至少添加支持这类计算的专门指令。最明显的例子就是Google开发了一个名为TPU(tensor processing unit,张量处理单元)的定制芯片,专门用于进行128x128的bfloat矩阵乘法运算,而NVIDIA在其新的GPU中添加了“张量核心”,能一次性完成4x4的矩阵乘法运算。

除了尺寸大小不同,所有的浮点类型的行为基本相同。

处理Corner Cases

整数算术面对如除以零这样的corner cases的默认处理方式是崩溃,即程序会停止运行并报错。

有时候,软件崩溃会反过来导致真正的物理崩溃。1996年Ariane 5火箭(欧洲宇航局用来运送物资到近地轨道的太空运载火箭)首次飞行以爆炸告终,原因是由于浮点数向整数转换溢出这个corner case的处理原则(停止计算并报错)导致了火箭的导航系统误以为自己偏离了航道,进行了大幅修正,最后导致价值200百万美元的火箭解体。

有一种方法可以更优雅地处理这些corner cases,即通过硬件中断。这是一种硬件和操作系统级别的解决方案。当发生异常(如除以零或溢出)时,以下是会发生的事情:

  • 中央处理器(CPU)中断当前程序的执行;
  • CPU将所有相关信息打包到一个名为“中断向量”的数据结构中;
  • CPU将这个中断向量传递给操作系统,操作系统会根据情况处理:
    • 如果存在处理代码(如“try-except”块),操作系统就会调用这些代码。
    • 如果不存在处理代码,操作系统就会结束程序的运行。

这是一种复杂的机制,值得专门写一篇文章来详细介绍。但是,因为这是一本关于性能的书,你只需要知道,这种机制相当慢,在实时系统(如导航火箭)中并不理想。因为在这种情况下,程序的运行速度和反应时间是至关重要的,如果花费太多时间处理异常,可能会有严重的后果。

NaN值,零值和无穷大

浮点数算术通常要处理噪声较大的实际数据。在这种情况下,出现异常要比在整数算术中更常见。因此,处理这些异常的默认行为是不同的。与其让程序崩溃,不如在不中断程序执行的情况下用一个特殊值替换产生异常的结果(除非程序员明确要求中断程序)。

这种特殊值的第一种类型是两个无穷大值:一个是正无穷大,另一个是负无穷大。如果一个操作的结果无法在可表示的范围内容纳,就会生成这两个无穷大值。在算术运算中,对无穷大值的处理如下:

<x<+x=x÷=0-∞ < x < ∞ \\ ∞ + x = ∞ \\ x ÷ ∞ = 0

当我们试图将一个值除以零时将会发生什么情况。因为我们有正无穷大和负无穷大,那么结果应该是正无穷大还是负无穷大呢? 这个问题其实不存在歧义,因为有两个零:正零和负零(这在直觉上可能不易理解)。所以当我们将一个值除以零时,结果将取决于是正零还是负零。例如,一个正数除以正零的结果是正无穷大,而一个正数除以负零的结果是负无穷大。这是按照浮点数运算的标准来处理的。

1+0=+10=\frac{1}{+0} = +∞ \quad \frac{1}{-0} = -∞

一些趣事:x + 0.0 不能被化简为x,但x + (-0.0)可以,所以在使用零作为初始值时,负零比正零更好,这样编译器更容易进行优化。至于原因,根据IEEE浮点数算术规定,+0.0 + -0.0 == +0.0,所以对于x + 0.0来说,如果x = -0.0,结果就会错误(两个零的存在经常令人头痛)。如果有需要,可以通过给编译器添加-fno-signed-zeros参数来禁用这种行为。

零是通过将所有位都设为零来编码的(负零的符号位除外)。无穷是通过将所有的指数位设为1,所有的尾数位设为0来编码的,符号位用来区别正无穷和负无穷。

还有一种特殊的值,即“非数字”(not-a-number,NaN)。这种值是由数学运算错误产生的结果,如:

log(1), arccos(1.01), , +, 0×, 0÷0, ÷log(-1),\space arccos(1.01),\space ∞-∞, \space -∞+∞, \space 0 × ∞, \space 0 ÷ 0, \space ∞ ÷ ∞

NaN有两种类型:信号NaN(Signaling NaN)和静默NaN(Quiet NaN)。 信号NaN在产生后会(或不会,具体取决于FPU配置)触发一个异常,引发一个中断,程序就可以对这个异常进行处理。 静默NaN在算术运算中会静默的传播,即在其他数与NaN进行运算的结果仍然是NaN。

在二进制中, 无论是信号NaN还是静默NaN,其阶码部分都全为1,但尾数部分并非全0(全0时,表示正负无穷),这样就可以将它们与无穷区分开来。所以表示NaN的编码有非常多(其中信号NaN的尾数最后一位为0,静默NaN的尾数最后一位为1)。

拓展阅读

如果你有兴趣,你可以阅读经典的《What Every Computer Scientist Should Know About Floating-Point Arithmetic》(1991)和介绍Grisu3的论文,Grisu3是目前最先进的浮点数打印技术。