IEEE 754 二进制浮点数:FP16 编码、指数偏置与小数误差
从一张 FP16 位结构图切入,逐步解释指数偏置、二进制循环小数,以及 0.1 + 0.2 不等于 0.3 的根源。
- 先把图中的 16 位拆成三段
- 沿着图片还原出 3.140625
- 指数偏置把无符号字段映射成正负指数
- 尾数的权重来自二进制位置计数法
- 十进制 0.1 为什么成为无限二进制小数
- 有限精度如何造成 0.1 + 0.2 != 0.3
- “十进制无限、二进制有限”的反例并不存在
- 浮点数适合近似计算,不适合假装绝对精确
下面这张图把 16 个二进制位还原成了十进制 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,因此:
结果是正数。
中间 5 位指数是 10000₂,作为无符号整数等于 16。不过 16 只是指数编码值,不是最终要使用的指数。FP16 规定指数偏置为 15,所以真实指数为:
这一段最终贡献的是缩放因子 $2^1$。
右侧 10 位尾数是 1001001000。小数点后的每一位依次代表 $2^{-1},2^{-2},\ldots,2^{-10}$;其中第 1、4、7 位是 1,因此:
规格化二进制数总能写成 $1.xxxxx_2\times2^e$,开头的 1 是固定的,不必占用存储位。这就是图中没有画在蓝色方格内的隐含前导 1。所以完整有效数字不是 $F$,而是:
三段结果现在可以合并:
\[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;去掉两端的特殊编码后,普通编码 00001 到 11110 便映射成尽量居中的 $-14$ 到 $15$。编码值并不是实际指数,普通数的实际指数为 $e=E-15$。
| 指数位 | 编码值 $E$ | 实际指数 $e$ |
|---|---|---|
00001 | 1 | -14 |
01111 | 15 | 0 |
10000 | 16 | 1 |
11110 | 30 | 15 |
两端没有用于普通数: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.1 | 0.2 | 0 | 0.2 |
| 0.2 | 0.4 | 0 | 0.4 |
| 0.4 | 0.8 | 0 | 0.8 |
| 0.8 | 1.6 | 1 | 0.6 |
| 0.6 | 1.2 | 1 | 0.2 |
余数重新回到 0.2,后续过程便不断重复:
其中的 0011 会无限循环。它也可以写成规格化形式:
浮点格式的尾数位数有限,只能在某处舍入。不同精度保存的是不同的邻近值:
| 格式 | 实际保存的十进制值 |
|---|---|
| FP16 | 0.0999755859375 |
| float32 | 0.10000000149011612… |
| double | 0.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.1 和 0.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…,在二进制中同样会循环:
因为最简分母 3 既不能整除 $10^n$,也不能整除 $2^n$。但在三进制中,它恰好是有限小数 $0.1_3$。
另一方面,任何有限二进制小数的分母都是 $2^n$;十进制的底数包含因子 2,所以它一定也能有限表示。因此,“二进制有限但十进制无限”的数不存在,有限二进制小数只是有限十进制小数的一个子集。
浮点数适合近似计算,不适合假装绝对精确
浮点表示用固定的位数覆盖极大的数值范围,代价就是大多数十进制小数只能取邻近值。科学计算通常接受这种取舍,但比较结果时应使用误差范围,例如 abs(a - b) < 1e-9,而不是直接判断相等。
金额、账务等要求十进制精确的场景,应使用十进制定点或高精度类型,例如 Java 的 BigDecimal、Python 的 Decimal;也可以把金额换算成最小单位,以整数保存。理解进制、有限位编码和舍入之间的关系,才能判断一次浮点误差究竟是正常现象,还是程序真正的计算错误。
