揭开浮点数的面纱


本文是对 Bartosz Ciechanowski 的 Exposing Floating Point 的中文译介与整理。原文配套的交互工具 float.exposed 很适合拿来检查半精度、单精度和双精度浮点数的真实编码。

浮点数最容易让人产生“玄学感”的地方,是它明明用起来像实数,却又会在某些地方表现得很不像实数:

  • 0.1 + 0.2 不一定等于你眼里的 0.3
  • 很大的整数之间会突然出现空洞
  • NaN 甚至不等于它自己
  • 0.0-0.0 编码不同,但比较时又相等

如果从 IEEE 754 的二进制科学记数法开始看,这些现象并不神秘。浮点数本质上就是:

一句话总结:固定长度的二进制有效数字,配上一个有范围限制的二进制指数,去近似表示实数。所有奇怪的行为,几乎都来自这两个“有限”。

为了让阅读时方便随手验证,文末的工具栏里也内嵌了一个简化版的 float.exposed:你输入一个十进制数,下面会展示它对应的位串和实际存储值。

一、从十进制写法开始

我们平时写 327.849,其实是在写每一位数字对应的十进制权重:

3102
2101
7100
.
810-1
410-2
910-3
3·10² + 2·10¹ + 7·10⁰ + 8·10⁻¹ + 4·10⁻² + 9·10⁻³ = 327.849

这种写法很自然,但它有几个小麻烦:

  • 特别小的数会有一长串前导零,例如 0.000000000653
  • 特别大的数不容易一眼看出量级,例如 7298345251
  • 尾部的 0 既不省空间,也不方便看精度,例如 7298000000

科学记数法把小数点移动到第一个非零数字之后,再用指数记录移动了多少位:

+3.27849 × 102
符号是 +,有效数字是 3.27849,指数是 2,底数是 10

如果只需要保留 4 位有效数字,327.849 可以近似为:

+3.278 × 102

所谓“精度”,就是我们愿意保留多少位有效数字。

二、二进制也一样

二进制和十进制没有本质区别,只是底数从 10 变成了 2。例如:

123
022
021
120
.
02-1
12-2
02-3
12-4
1001.0101₂ = 8 + 1 + 0.25 + 0.0625 = 9.3125

同样可以写成二进制科学记数法:

+1.0010101 × 23

二进制科学记数法有一个非常重要的特点:只要数不是 0,规格化后最高位一定是 1。因为二进制里非零数字只有 1。这个事实让 IEEE 754 偷到了一个很值钱的 bit——既然第一位必然是 1,那么这个 1 就不需要真的存进编码里。

三、浮点数到底是什么

浮点数可以看作有两条限制的二进制科学记数法:

  • 有效数字的位数有限
  • 指数的范围有限

以 IEEE 754 单精度 float 为例:

项目 float
总长度 32 bit
有效数字精度 24 bit(含隐含位)
指数字段 8 bit
尾数字段 23 bit
实际指数范围 [-126, +127]
偏置值 127

“有效数字精度是 24 bit”,但尾数字段只有 23 bit,差出来的那一位就是上文那个一定为 1 的最高位——隐含位(implicit bit),不显式存储。

例如下面这个数刚好可以被 float 精确表示:

-1.00101100110110001101001 × 219
小数点后正好 23 位,加上隐含的开头 1,刚好用满 24 位有效数字。

但不是所有十进制小数都能被二进制有限表示。比如 0.2 的二进制有效数字会无限循环:

+1.10011001100110011001100… × 2-3
1001 这段会反复出现,有限长度的 float 只能截断并舍入。

因此 0.2f 实际存下来的并不是数学上精确的 0.2,而是非常接近它的:

0.20000000298023223876953125

这并不是实现“算错了”,而是有限 bit 装不下无限循环的二进制展开。著名的 0.1 + 0.2 != 0.3 也是同样的原因——0.10.20.3 都是无限循环二进制小数,被分别舍入后再相加,结果落在了 0.3 隔壁的另一个 float 上。

四、把一个数编码成 float

用原文中的例子:-2343.53125。它在二进制下是:

-100100100111.100012

规格化成二进制科学记数法:

-1.0010010011110001 × 211

一个 float 的 32 bit 被分成三段:

ssign · 1 bit eeeeeeeeexponent · 8 bit ffffffffffffffffffffffffraction · 23 bit

1. 符号位

IEEE 754 用 0 表示正数,1 表示负数。本例的数是负数,符号位为:

1sign

2. 尾数字段

规格化形式是 1.0010010011110001。开头的 1 是隐含位,不写入编码,只存小数点后面的部分,并在末尾补零到 23 bit:

1implicit 00100100111100010000000fraction

3. 指数字段

真实指数是 11,但指数字段不能直接存带符号整数。float 使用偏置值 127 把指数挪到非负区间:

biased exponent = 11 + 127 = 138 = 10001010₂

所以指数字段是:

10001010exponent

4. 拼起来

把三段按 sign | exponent | fraction 的顺序排好:

1sign 10001010exponent 00100100111100010000000fraction
连起来就是 1100 0101 0001 0010 0111 1000 1000 0000,十六进制 0xC512 7880

把这组 bit pattern 用 C/C++、LLDB 或在线工具反向解释,都能得到同一个 float浮点数不是“把十进制字符串存起来”,而是把二进制科学记数法拆成这三段存起来。

5. 边敲边看

下面这个小工具可以让你随手验证。换一个数字试试 0.10.2167772171e30,看看符号 / 指数 / 尾数三段各是什么:

normal
真实指数
规格化形式
实际存储值
十六进制 bits
与输入的差

五、特殊值

float 的 8 bit 指数字段有 0..255 共 256 种编码。普通规格化数只使用其中的 1..254。剩下的两个值 0255 被用来表示特殊情况。

1. 一张速查图

普通数
sign | exponent = 1..254 | fraction = any
±0
sign | 00000000 | 00000000000000000000000
次正规数
sign | 00000000 | fraction != 0
±∞
sign | 11111111 | 00000000000000000000000
NaN
sign | 11111111 | fraction != 0

2. 正零和负零

当指数字段全 0,尾数字段也全 0 时,值是 +0.0-0.0,由符号位决定:

_sign 00000000exponent 00000000000000000000000fraction

0.0 == -0.0 为真,但它们的 32 bit 编码不同。负零常常用来保留“从负方向下溢到 0”这类符号信息——例如一个非常小的负数除以一个非常大的正数,可能会得到 -0.0

3. 无穷大

当指数字段全 1,尾数字段全 0 时,值是正负无穷:

_sign 11111111exponent 00000000000000000000000fraction

无穷大通常来自上溢,或者来自除以带符号的零:正数除以 +0.0+∞,除以 -0.0-∞。有限数和无穷大做加减乘除时,大多数结果都符合直觉——∞ + 1 仍然是 ∞ × -1-∞

4. NaN

当指数字段全 1,尾数字段非零时,值是 NaN(Not a Number):

_sign 11111111exponent at least one 1fraction

常见会产生 NaN 的操作:

  • 0 × ∞
  • +∞ + (-∞)
  • 0 / 0
  • ∞ / ∞
  • 对负数开平方

NaN 最反直觉的一点是:它不等于任何东西,包括它自己。因此 x != x 常被用作判断 NaN 的底层技巧(在 C/C++ 里编译器有时会激进地优化它,更稳妥的写法是 isnan(x))。

5. 最大值、最小规格化值与次正规数

float 的最大有限值用倒数第二大的指数字段(254)和全 1 的尾数字段:

0sign 11111110exponent 11111111111111111111111fraction
约为 3.40282347 × 1038

最小的正规格化 float 是:

0sign 00000001exponent 00000000000000000000000fraction
2-126,约 1.17549435 × 10-38,即 FLT_MIN

FLT_MIN 并不是 float 能表示的最小正数。指数编码为 0、尾数非零时,进入次正规数(subnormal / denormal)区域:真实指数仍按 -126 解释,但隐含位从 1 变成 0

0sign 00000000exponent 00000000000110001101001fraction
含义是 +0.000000000001100011010012 × 2-126,远小于 FLT_MIN

次正规数让浮点数可以渐进下溢:从最小规格化数继续往 0 靠近时,可表示值不会突然断崖式跳到 0,而是均匀地铺满最后一段空间。代价是有效精度逐位缩水,所以浮点性能敏感的代码(特别是 SIMD / GPU 内核)有时会把 FTZ(flush-to-zero)打开,把次正规数当作 0 处理。

六、浮点数不是连续的

因为有效数字位数有限,float 不可能表示任意实数。更重要的是,指数会让可表示数的间隔并不均匀——在相邻的两个 2 的幂之间间距是固定的,但每跨过一个 2 的幂,间距都要翻倍。

区间 相邻 float 的间距(ULP)
[0.5, 1.0) 2-24
[1.0, 2.0) 2-23 = FLT_EPSILON
[223, 224) 1(每一个整数都能表示)
[224, 225) 2(隔一个整数才能表示一个)
[2n, 2n+1) 2n-23

下面这张图把这种“随指数翻倍”的稀疏感画出来——每一段 [2n, 2n+1) 内部 float 是均匀的,但段与段之间的密度差了一个 2 倍:

2⁰ (1) 2¹ (2) 2⁴ 2⁵ 每越过一个 2ⁿ,相邻 float 的间距就翻一倍
这是示意图:每一段 [2n, 2n+1) 内部 float 是均匀的,但越往右整体越稀疏。

float 能逐个表示所有整数,一直到 224 = 16777216 为止。下一个可表示的 float16777218

16777216 → next float = 16777218
中间的 16777217 没有任何 float 编码可以精确表示,会被舍入到相邻两个里更近的那个。

这就是为什么用 float 累加大整数会出现“卡住”的现象:当累加到 224 之后,每次加 1 实际上落到了上一个值,循环永远不会推进。

七、把 float 当成整数看

对正的 float 来说,如果忽略三段结构,直接把 32 bit 当成无符号整数看,有一个很漂亮的性质:整数编码加 1,通常就会得到下一个可表示的 float

例如:

0sign 10010011exponent 11111111111111111111111fraction
这是 2097151.875

把完整 bit 串当无符号整数加 1,尾数会全部进位到指数:

  0 10010011 11111111111111111111111
+ 0 00000000 00000000000000000000001
─────────────────────────────────────
  0 10010100 00000000000000000000000

再把结果按 float 三段解释:

0sign 10010100exponent 00000000000000000000000fraction
得到下一个可表示值 2097152.0

这正是 IEEE 754 把字段排成 sign | exponent | fraction 这种顺序的妙处:尾数溢出时会自然进位到指数,恰好对应“跨过一个 2 的幂、间距翻倍”的边界。最大有限值再加一步会变成 +∞;最小规格化值往下走会进入次正规数;最小次正规数再往下就是 0。同样的性质也让 memcmp 可以直接比较两个非负 float 的大小(处理负数时还要再翻转一下)。

这个技巧有边界:

  • 它不能自动在 +0.0-0.0 之间跳转
  • 无穷大继续加可能进入 NaN 区域
  • NaN 的整数序也不该被当作普通数值序

八、half、float、double

IEEE 754 的常见二进制浮点类型遵循同一套规则,只是总 bit 数、指数字段和尾数字段大小不同。

类型 总 bit 指数字段 尾数字段 有效数字精度 实际指数范围 偏置值
half / binary16 16 5 10 11 bit [-14, +15] 15
float / binary32 32 8 23 24 bit [-126, +127] 127
double / binary64 64 11 52 53 bit [-1022, +1023] 1023

下列规则对三者都成立:

  • 指数全零 + 尾数全零 → ±0.0
  • 指数全一 + 尾数全零 → ±∞
  • 指数全一 + 尾数非零 → NaN
  • 指数全零 + 尾数非零 → 次正规数
  • 普通规格化数都有一个不显式存储的开头 1

half 在图形、深度学习和带宽敏感场景中很常见——它非常省空间,但指数范围和有效精度都小很多。double 则用更多 bit 换来更大的范围和更高精度,是 JavaScript Number、Python float、和大多数科学计算默认使用的类型。

九、类型转换

从小类型转到大类型时,值可以保持完全一致。例如 half → float → double:新的尾数字段在后面补零,指数按新的偏置值重新编码即可。

从大类型转到小类型时就不一定了:

  • 如果尾数多出来的 bit 都是 0,并且指数落在目标类型的范围内,值可以精确保留
  • 如果尾数装不下,需要舍入
  • 如果指数超出目标范围,可能变成 ±∞
  • NaN 仍是 NaN,无穷大仍是无穷大

默认舍入方式是 round-to-nearest, ties-to-even(最近偶数舍入):四舍六入,正中间时取偶数末位。这个规则比日常的“.5 一律进位”更不容易产生系统性偏差。举个直观例子:如果两个比例分别是 72.5%27.5%,普通半入法会得到 73% + 28% = 101%;最近偶数法则得到 72% + 28% = 100%

IEEE 754 还定义了另外四种舍入模式(向上、向下、向零、向远离零),它们大多在区间运算或可重现性要求高的场景里出现,C 里通过 <fenv.h> 切换,平时用得不多。

十、打印浮点数

打印浮点数也有坑。%f%e 默认不打印足够多的数字,所以两个不同的 float 完全可能被打印成一样的文本。

如果想保证“打印出来再读回去仍是同一个 float”,至少要 FLT_DECIMAL_DIG = 9 位有效数字(doubleDBL_DECIMAL_DIG = 17):

printf("%.9g\n",  some_float);   // 至少 9 位
printf("%.17g\n", some_double);  // 至少 17 位

缺点是简单的数也会被打印得很长。

更适合精确表达浮点数的是十六进制浮点格式,也就是 printf%a

0x1.810682p+1
0x1.810688p+1

这里 0x 表示十六进制,p+1 后面的是 2 的指数。每个 hex digit 刚好对应 4 个 bit,因此能用很短的字符串无损地表达底层 bit。打印调试时如果怀疑某个数被舍入了,把它打成 %a 通常立刻就能看出来。

另一个容易混淆的点是:

不是每个十进制小数都能被浮点数精确表示;但每一个已经存在的浮点数都有一个精确的十进制展开

原因是有限二进制小数的分母一定是 2n,而 2n × 5n = 10n。例如 1/16 = 625/10000 = 0.0625。只是这个精确十进制展开有时会非常长——double 表示的最小次正规数有 700 多位十进制小数,长到完全不适合人读。

十一、把要点收束一下

理解浮点数时,记住下面这几件事就基本够用了:

  • float 不是实数,而是一张有限的、间隔不均匀的数值表
  • float 的本体是 sign | exponent | fraction
  • 普通数的真实形式是 (-1)sign × 1.fraction × 2exponent - bias
  • 次正规数把隐含的 1 改成 0,用于平滑靠近 0
  • 指数全 1 的区域留给 ±∞ 和 NaN
  • 相邻浮点数的间隔随指数变大而变大,变化粒度叫 ULP
  • 十进制打印和二进制存储不是一回事,调试精确值时 %a 很有用

浮点数确实有很多边角,但它不是一堆例外堆出来的怪物。相反,很多看似奇怪的行为都来自同一个朴素设计:用有限 bit,把二进制科学记数法编码得尽量紧凑、可比较、可渐进下溢,并为错误和溢出留下可传播的特殊值。

参考