文章

IEEE 754 二进制浮点数:FP16 编码、指数偏置与小数误差

从一张 FP16 位结构图切入,逐步解释指数偏置、二进制循环小数,以及 0.1 + 0.2 不等于 0.3 的根源。

IEEE 754 二进制浮点数:FP16 编码、指数偏置与小数误差
  1. 先把图中的 16 位拆成三段
  2. 沿着图片还原出 3.140625
  3. 指数偏置把无符号字段映射成正负指数
  4. 尾数的权重来自二进制位置计数法
  5. 十进制 0.1 为什么成为无限二进制小数
  6. 有限精度如何造成 0.1 + 0.2 != 0.3
  7. “十进制无限、二进制有限”的反例并不存在
  8. 浮点数适合近似计算,不适合假装绝对精确

下面这张图把 16 个二进制位还原成了十进制 3.140625。看似只是一道换算题,却已经包含浮点数最重要的三个机制:符号如何编码、指数为什么需要偏置,以及尾数为什么只能保存有限精度。沿着图中从左到右的三段结构逐步计算,可以自然地看清这些机制如何共同作用。

FP16 的符号位、指数位和尾数位,以及位模式 0 10000 1001001000 到十进制 3.140625 的换算过程

FP16 位结构与示例数值 3.140625 的完整换算过程。

先把图中的 16 位拆成三段

IEEE 754 的 FP16,也称半精度浮点数,按照图中的颜色把 16 位分成三个字段:

字段位数作用
符号位 $S$1决定正负
指数字段 $E$5决定二进制小数点整体移动多少位
小数字段 $F$10保存有效数字的小数部分

图中的完整位模式可以分组写成:

1
2
0 | 10000 | 1001001000
S |   E   |     F

对于普通的规格化数,这三个字段按下面的公式组合:

\[x=(-1)^S\times 2^{E-15}\times(1+F)\]

公式中的 $S$、$E$、$F$ 分别来自三段编码。此时先把它当作一张计算路线图,接下来按照图片中的顺序逐段代入。

沿着图片还原出 3.140625

图片最左侧的符号位是 0,因此:

\[(-1)^S=(-1)^0=1\]

结果是正数。

中间 5 位指数是 10000₂,作为无符号整数等于 16。不过 16 只是指数编码值,不是最终要使用的指数。FP16 规定指数偏置为 15,所以真实指数为:

\[e=E-15=16-15=1\]

这一段最终贡献的是缩放因子 $2^1$。

右侧 10 位尾数是 1001001000。小数点后的每一位依次代表 $2^{-1},2^{-2},\ldots,2^{-10}$;其中第 1、4、7 位是 1,因此:

\[F=2^{-1}+2^{-4}+2^{-7}=0.5+0.0625+0.0078125=0.5703125\]

规格化二进制数总能写成 $1.xxxxx_2\times2^e$,开头的 1 是固定的,不必占用存储位。这就是图中没有画在蓝色方格内的隐含前导 1。所以完整有效数字不是 $F$,而是:

\[1+F=1.5703125\]

三段结果现在可以合并:

\[x=1\times2^1\times(1+0.5703125)=3.140625\]

至此,图片底部的十进制结果已经完整还原出来。计算中的两个特殊设计——指数减去 15、尾数补上 1——还需要分别解释它们为何存在。

指数偏置把无符号字段映射成正负指数

指数位本身按无符号整数读取。为了让它同时表达负指数和正指数,IEEE 754 为 $k$ 位指数字段规定一个固定偏置:

\[\text{bias}=2^{k-1}-1\]

FP16 有 5 位指数,因此偏置是 $2^4-1=15$。这个数让指数字段的中间编码 01111 对应实际指数 0;去掉两端的特殊编码后,普通编码 0000111110 便映射成尽量居中的 $-14$ 到 $15$。编码值并不是实际指数,普通数的实际指数为 $e=E-15$。

指数位编码值 $E$实际指数 $e$
000011-14
01111150
10000161
111103015

两端没有用于普通数:00000 留给零和非规格化数,11111 留给无穷大和 NaN。因此 FP16 规格化数的指数范围是 $-14$ 到 $15$。偏置编码还让编码大小与真实指数大小保持同向,硬件比较和排序更直接。

尾数的权重来自二进制位置计数法

图片把尾数的 10 个位置分别标成 $2^{-1}$ 到 $2^{-10}$,这与十进制小数的位权规则完全对应。十进制小数点后的权重依次是 $10^{-1},10^{-2},\ldots$;二进制只是把底数换成了 2。例如:

\[0.101_2=1\times2^{-1}+0\times2^{-2}+1\times2^{-3}=\frac58=0.625_{10}\]

因此,任何有限位二进制小数都可以写成 $m/2^n$,其中 $m$、$n$ 是整数。这条性质直接划定了二进制能够精确保存哪些分数。

更一般地,把分数 $p/q$ 约到最简后,它能在 $b$ 进制中有限表示,当且仅当存在某个整数 $n$,使 $q$ 能整除 $b^n$。换句话说,分母的所有质因数都必须出现在进制的底数中。

进制底数的质因数可以有限表示的最简分母
十进制$10=2\times5$只含 2 和 5
二进制$2$只能是 $2^n$
三进制$3$只能是 $3^n$

这解释了为什么 $1/2$ 在十进制和二进制中都能写完,而 $1/10$ 在十进制中是 0.1,到了二进制却会无限循环。

十进制 0.1 为什么成为无限二进制小数

十进制的 $0.1=1/10$,最简分母含有因子 5,不能写成 $m/2^n$。用“反复乘 2、取整数部分”的方法可以直接看到循环:

当前小数乘以 2取出的位余数
0.10.200.2
0.20.400.4
0.40.800.8
0.81.610.6
0.61.210.2

余数重新回到 0.2,后续过程便不断重复:

\[0.1_{10}=0.00011001100110011\ldots_2\]

其中的 0011 会无限循环。它也可以写成规格化形式:

\[0.1_{10}=1.1001100110011\ldots_2\times2^{-4}\]

浮点格式的尾数位数有限,只能在某处舍入。不同精度保存的是不同的邻近值:

格式实际保存的十进制值
FP160.0999755859375
float320.10000000149011612…
double0.10000000000000000555…

打印工具通常会输出较短的 0.1,因为这个短字符串足以还原到同一个浮点编码,但内部值并没有因此变得精确。

有限精度如何造成 0.1 + 0.2 != 0.3

如果保留无限位,二进制加法仍然严格成立:

1
2
3
4
  0.00011001100110011...₂
+ 0.00110011001100110...₂
---------------------------
  0.01001100110011001...₂

误差来自参与运算的三个十进制字面量已经分别被舍入。以常见的 IEEE 754 double 为例:

1
2
3
4
0.1       → 0x3fb999999999999a
0.2       → 0x3fc999999999999a
0.3       → 0x3fd3333333333333
0.1 + 0.2 → 0x3fd3333333333334

0.10.2 的近似值相加后还要再次舍入,最终落到 0x3fd3333333333334,即约 0.30000000000000004440…。直接写出的 0.3 则落到前一个相邻编码,约为 0.29999999999999998890…。两个编码只差一个 double 在该数量级上的最小刻度,却依然是两个不同的数。

更准确地描述,计算机执行的是:

\[\operatorname{round}\bigl(\operatorname{round}(0.1)+\operatorname{round}(0.2)\bigr) \ne \operatorname{round}(0.3)\]

“十进制无限、二进制有限”的反例并不存在

$1/3$ 在十进制中是 0.333…,在二进制中同样会循环:

\[\frac13=0.010101\ldots_2\]

因为最简分母 3 既不能整除 $10^n$,也不能整除 $2^n$。但在三进制中,它恰好是有限小数 $0.1_3$。

另一方面,任何有限二进制小数的分母都是 $2^n$;十进制的底数包含因子 2,所以它一定也能有限表示。因此,“二进制有限但十进制无限”的数不存在,有限二进制小数只是有限十进制小数的一个子集。

浮点数适合近似计算,不适合假装绝对精确

浮点表示用固定的位数覆盖极大的数值范围,代价就是大多数十进制小数只能取邻近值。科学计算通常接受这种取舍,但比较结果时应使用误差范围,例如 abs(a - b) < 1e-9,而不是直接判断相等。

金额、账务等要求十进制精确的场景,应使用十进制定点或高精度类型,例如 Java 的 BigDecimal、Python 的 Decimal;也可以把金额换算成最小单位,以整数保存。理解进制、有限位编码和舍入之间的关系,才能判断一次浮点误差究竟是正常现象,还是程序真正的计算错误。

本文由作者按照 CC BY 4.0 进行授权