浮点数:IEEE 754 的表示与精度

浮点数是如何表示的?

IEEE 754 的二进制浮点数把位串分成三段:

  • $s$ 是符号位,1 bit,0 为正、1 为负。
  • $E$ 是指数域,$e$ 个 bit,按无符号整数存储。
  • $M$ 是尾数域,$m$ 个 bit,只存小数点后面的部分。

$\text{bias}=2^{e-1}-1$。$e=8$ 时是 127,$e=11$ 时是 1023,半精度 $e=5$ 时是 15。

常用的几种格式:

格式 符号 指数 尾数 bias 最大正规数 最小正规数 最小次正规数
FP16 1 5 10 15 65504 $6.10\times10^{-5}$ $5.96\times10^{-8}$
BF16 1 8 7 127 $3.39\times10^{38}$ $1.18\times10^{-38}$ $9.18\times10^{-41}$
FP32 1 8 23 127 $3.40\times10^{38}$ $1.18\times10^{-38}$ $1.40\times10^{-45}$
FP64 1 11 52 1023 $1.80\times10^{308}$ $2.23\times10^{-308}$ $4.94\times10^{-324}$
FP8 E4M3 1 4 3 7 448 $1.56\times10^{-2}$ $1.95\times10^{-3}$
FP8 E5M2 1 5 2 15 57344 $6.10\times10^{-5}$ $1.53\times10^{-5}$

BF16 和 FP32 的指数都是 8 位,所以动态范围完全一样,只是尾数从 23 位砍到 7 位。FP16 反过来,尾数还有 10 位,指数只剩 5 位,量级一大就溢出。

浮点数的尾数是怎么计算的?

尾数的计算分四步。先把十进制数转成二进制,再规格化成 $1.xxx\times2^{k}$,然后把小数点后的位舍入到 $m$ 位,最后把指数加上偏移写进指数域。

规格化那一步是隐藏位的来源。任何非零二进制数都能写成 $1.d_1d_2\dots\times2^{k}$,小数点前那一位恒为 1,不用存。$m$ 位尾数因此给出 $m+1$ 位有效精度。这也保证了表示的唯一性,同一个数只有一种写法。

拿 0.1 举例。小数部分乘 2 取整,循环做下去:

1
2
3
4
5
6
7
0.1 × 2 = 0.2  → 0
0.2 × 2 = 0.4 → 0
0.4 × 2 = 0.8 → 0
0.8 × 2 = 1.6 → 1,余 0.6
0.6 × 2 = 1.2 → 1,余 0.2
0.2 × 2 = 0.4 → 0
...

小数点后前四位是 0001,从第五位起 1001 循环:

$E=-4+127=123$。尾数取 23 位是 10011001100110011001100,第 24 位是 1 且后面还有非零位,向上进位:

存下来的值其实是 $0.10000000149011612$,误差 $1.49\times10^{-9}$,不到半个 ulp(0.1 附近的 ulp 是 $2^{-27}\approx7.45\times10^{-9}$)。0.1 在二进制里是无限循环小数,任何有限位宽都只能存近似值,FP64 也一样。

舍入默认用「就近取偶」:落在两个可表示数正中间时,取尾数末位为偶的那个。这条规则避免了「一律向上」带来的系统性偏置。

$1+2^{-24}$ 恰好是 1.0 和 $1+2^{-23}$ 的中点,末位为偶,舍回 1.0。$1+3\times2^{-24}$ 是 $1+2^{-23}$ 和 $1+2^{-22}$ 的中点,取偶数尾数的 $1+2^{-22}$。

1
2
3
4
import numpy as np

np.float32(1) + np.float32(2**-24) # 1.0
np.float32(1) + np.float32(3 * 2**-24) # 1.0000002,即 1 + 2^-22

24 位有效二进制位能保证多少位十进制?$24\times\log_{10}2\approx7.22$,所以 FP32 大约 7 位十进制有效数字。想无损往返(十进制写出去再读回来还是同一个浮点数),需要 $\lceil 1+p\lg 2\rceil$ 位,FP32 是 9 位。这也是 printf("%.9g") 能还原 float 的原因。

浮点数如何表示 0 附近的数字

如果只有正规数,能表示的最小正数是 $2^{-126}\approx1.18\times10^{-38}$,再往下直接掉到 0。0 附近因此留出一段无法表示的区间。最小的正规数乘以 $0.01$,真值是 $1.18\times10^{-40}$,没有次正规数就只能存成 0,相对误差 100%。梯度乘学习率、损失里的 exp 输出,都容易落到这一段。

IEEE 754 用次正规数填这个洞。指数域全 0 时,隐藏位不再补 1,指数固定为 $1-\text{bias}$:

对 FP32 就是 $0.M\times2^{-126}$。此时尾数没有隐藏位,$M$ 每加 1,数值就加 $2^{-149}$。间距变成常数,不再随数值缩小。

数值 位模式 说明
$1.0$ 0x3F800000 正规数
$1.18\times10^{-38}$ 0x00800000 最小正规数 $2^{-126}$
$1.18\times10^{-38}$ 略小 0x007FFFFF 最大次正规数
$1.40\times10^{-45}$ 0x00000001 最小次正规数 $2^{-149}$
$0$ 0x00000000 零

关键是 $2^{-126}$ 两侧的间距都是 $2^{-149}$,跨过这个边界没有跳变。这叫渐进下溢:数值越靠近 0,绝对精度保持不变,相对精度逐渐变差,直到最后一位也保不住,才归零。

相对精度的退化可以算出来。次正规数区间里 ulp 恒为 $2^{-149}$,所以相对间距是 $2^{-149}/x$。在 $x=2^{-126}$ 处是 $2^{-23}$,和正规数衔接;到 $x=2^{-140}$ 已经变成 $2^{-9}\approx2\times10^{-3}$;到最小次正规数,相对间距是 1,只剩一个有效位。

零有正负两个。+0 和 -0 数值相等,但 $1/(+0)=+\infty$,$1/(-0)=-\infty$。符号位在除法、atan2、复数的分支切割里会被读到,不能简单当成同一个东西。

硬件层面还有一点要注意。不少 GPU 和 SIMD 实现默认开启 FTZ(flush to zero)和 DAZ(denormals are zero),把次正规数直接当 0 处理,为的是避免次正规运算拖慢流水线。同一个模型换个后端,0 附近的结果可能不一样。FP16 训练要做 loss scaling,一部分原因也是次正规数区间太窄,梯度一小就整片归零。

浮点数如何计算相对精度?

浮点数的绝对间距不是常数,相对间距才是。对 $x\in[2^{k},2^{k+1})$,尾数 $m$ 位,间距是

把它除以 $x$,得到相对间距的范围:

FP32 就是 $(2^{-24},2^{-23}]$。每跨过一个 2 的幂,间距翻倍,相对间距从下界跳回上界。所以同一个 float 在 1.0 附近能分辨 $1.19\times10^{-7}$,到 2.0 附近间距翻倍到 $2.38\times10^{-7}$,相对精度没变。

由此得到两个常用的量,名字容易混,最好分开记:

  • 机器精度 $\varepsilon=2^{-m}$,也就是 1.0 和下一个浮点数的距离,FP32 是 $1.19\times10^{-7}$。NumPy 的 finfo.eps 用的是这个定义。
  • 单位舍入误差 $u=\varepsilon/2=2^{-(m+1)}$,FP32 是 $5.96\times10^{-8}$。就近舍入下,$|\operatorname{fl}(x)-x|\le u|x|$,误差分析里用的是这个。

有的教材把 $u$ 叫 machine epsilon,看到「机器精度」这个词先确认对方用的是哪个定义,两者差一倍。

格式 尾数位 $\varepsilon$ $u$ 十进制有效位 往返所需位数
FP16 10 $9.77\times10^{-4}$ $4.88\times10^{-4}$ 3.31 5
BF16 7 $7.81\times10^{-3}$ $3.91\times10^{-3}$ 2.41 4
FP32 23 $1.19\times10^{-7}$ $5.96\times10^{-8}$ 7.22 9
FP64 52 $2.22\times10^{-16}$ $1.11\times10^{-16}$ 15.95 17

为什么可以用 $2^{E-\text{bias}}$ 做近似?

尾数在 $[1,2)$ 之间,所以

把 $2^{E-\text{bias}}$ 当作这个数的代表值,相对误差最大接近 50%(数值越靠近区间上端越大),但量级一定对。判断溢出、估算动态范围、给 FP8 选缩放因子,用这一步就够了。FP8 的 scale 通常就取 $\text{scale}=2^{\lfloor\log_2\max|x|\rfloor}$ 这种 2 的幂,反量化时乘一个 2 的幂也不引入额外舍入误差。

细一层是带上尾数。$1.M$ 落在 $[1,2)$ 内,相对误差被压到 $2^{-(m+1)}$ 以内,也就是前面算的单位舍入误差 $u$。到这一步才是浮点数真正的相对精度,前面那个 50% 只是量级估计。

再细一层是取对数。对两边取 $\log_2$,得到 $\log_2 x\approx(E-\text{bias})+\log_2(1.M)$,而 $\log_2(1+f)\approx f$,$f$ 是尾数域按 $2^{-m}$ 归一化后的小数。把位模式当成整数 $I=E\cdot2^{m}+M$ 来读,就有

这就是那个近似最实用的一面:指数域和尾数域在整数视角下拼成一条斜率固定的直线,浮点数的位模式近似是它自己的对数。快速求 $\log$、$\exp$、平方根倒数的那类做法(Schraudolph 近似、Quake 里的 0x5f3759df)都从这里来,误差只来自 $\log_2(1+f)$ 与 $f$ 的偏差,最大约 0.086 个二进制位,用一两步牛顿迭代就能压回正常精度。

几个容易踩的坑

  • 0.1 + 0.2 != 0.3 在 FP64 下成立,结果是 $0.30000000000000004$。有意思的是 FP32 下这两个式子恰好相等,因为舍入刚好抵消,但这只是巧合,不能当规律用。
  • 大数加小数会被整个吃掉。FP32 里 $10^{8}+1$ 还是 $10^{8}$,因为这一段的间距是 8;$2^{24}+1$ 也还是 $2^{24}$,因为从 $2^{24}$ 起间距变成 2。做累加时先把小数加起来,再和大数相加。
  • 溢出不会报错,直接给出 $\pm\infty$;继续算就得到 NaN,再往后整条计算链都被污染。混合精度训练里 loss 变 NaN,先查 scale 是不是太大。
  • float32 能精确表示的最大连续整数是 $2^{24}=16777216$,再往上就有整数表示不了。用浮点存索引、计步数,超过这个值要留意。

总结

浮点数用固定位宽换来了浮动精度:指数决定量级,尾数决定量级内的分辨率,偏移让指数域可以按整数比较。尾数靠规格化省下隐藏位,靠就近取偶控制舍入偏置。0 附近用次正规数把间距接上,代价是相对精度随数值缩小而退化。衡量精度时看相对间距而不是绝对间距,单次误差是 $u$,累积误差是 $\gamma_n$,相消会把误差放大 $\kappa$ 倍。而 $2^{E-\text{bias}}$ 这个近似,本质上是指数域和尾数域在整数视角下拼成了一条近似的对数直线。

参考资料