揭开浮点数的面纱
本文是对 Bartosz Ciechanowski 的 Exposing Floating Point 的中文译介与整理。原文配套的交互工具 float.exposed 很适合拿来检查半精度、单精度和双精度浮点数的真实编码。
浮点数最容易让人产生“玄学感”的地方,是它明明用起来像实数,却又会在某些地方表现得很不像实数:
-
0.1 + 0.2不一定等于你眼里的0.3 - 很大的整数之间会突然出现空洞
-
NaN甚至不等于它自己 -
0.0和-0.0编码不同,但比较时又相等
如果从 IEEE 754 的二进制科学记数法开始看,这些现象并不神秘。浮点数本质上就是:
为了让阅读时方便随手验证,文末的工具栏里也内嵌了一个简化版的 float.exposed:你输入一个十进制数,下面会展示它对应的位串和实际存储值。
一、从十进制写法开始
我们平时写 327.849,其实是在写每一位数字对应的十进制权重:
3·10² + 2·10¹ + 7·10⁰ + 8·10⁻¹ + 4·10⁻² + 9·10⁻³ = 327.849这种写法很自然,但它有几个小麻烦:
- 特别小的数会有一长串前导零,例如
0.000000000653 - 特别大的数不容易一眼看出量级,例如
7298345251 - 尾部的
0既不省空间,也不方便看精度,例如7298000000
科学记数法把小数点移动到第一个非零数字之后,再用指数记录移动了多少位:
+,有效数字是 3.27849,指数是 2,底数是 10。如果只需要保留 4 位有效数字,327.849 可以近似为:
所谓“精度”,就是我们愿意保留多少位有效数字。
二、二进制也一样
二进制和十进制没有本质区别,只是底数从 10 变成了 2。例如:
1001.0101₂ = 8 + 1 + 0.25 + 0.0625 = 9.3125同样可以写成二进制科学记数法:
二进制科学记数法有一个非常重要的特点:只要数不是 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,刚好用满 24 位有效数字。但不是所有十进制小数都能被二进制有限表示。比如 0.2 的二进制有效数字会无限循环:
1001 这段会反复出现,有限长度的 float 只能截断并舍入。因此 0.2f 实际存下来的并不是数学上精确的 0.2,而是非常接近它的:
0.20000000298023223876953125
这并不是实现“算错了”,而是有限 bit 装不下无限循环的二进制展开。著名的 0.1 + 0.2 != 0.3 也是同样的原因——0.1、0.2、0.3 都是无限循环二进制小数,被分别舍入后再相加,结果落在了 0.3 隔壁的另一个 float 上。
四、把一个数编码成 float
用原文中的例子:-2343.53125。它在二进制下是:
规格化成二进制科学记数法:
一个 float 的 32 bit 被分成三段:
1. 符号位
IEEE 754 用 0 表示正数,1 表示负数。本例的数是负数,符号位为:
2. 尾数字段
规格化形式是 1.0010010011110001。开头的 1 是隐含位,不写入编码,只存小数点后面的部分,并在末尾补零到 23 bit:
3. 指数字段
真实指数是 11,但指数字段不能直接存带符号整数。float 使用偏置值 127 把指数挪到非负区间:
biased exponent = 11 + 127 = 138 = 10001010₂
所以指数字段是:
4. 拼起来
把三段按 sign | exponent | fraction 的顺序排好:
1100 0101 0001 0010 0111 1000 1000 0000,十六进制 0xC512 7880。把这组 bit pattern 用 C/C++、LLDB 或在线工具反向解释,都能得到同一个 float。浮点数不是“把十进制字符串存起来”,而是把二进制科学记数法拆成这三段存起来。
5. 边敲边看
下面这个小工具可以让你随手验证。换一个数字试试 0.1、0.2、16777217、1e30,看看符号 / 指数 / 尾数三段各是什么:
五、特殊值
float 的 8 bit 指数字段有 0..255 共 256 种编码。普通规格化数只使用其中的 1..254。剩下的两个值 0 和 255 被用来表示特殊情况。
1. 一张速查图
2. 正零和负零
当指数字段全 0,尾数字段也全 0 时,值是 +0.0 或 -0.0,由符号位决定:
0.0 == -0.0 为真,但它们的 32 bit 编码不同。负零常常用来保留“从负方向下溢到 0”这类符号信息——例如一个非常小的负数除以一个非常大的正数,可能会得到 -0.0。
3. 无穷大
当指数字段全 1,尾数字段全 0 时,值是正负无穷:
无穷大通常来自上溢,或者来自除以带符号的零:正数除以 +0.0 得 +∞,除以 -0.0 得 -∞。有限数和无穷大做加减乘除时,大多数结果都符合直觉——∞ + 1 仍然是 ∞,∞ × -1 是 -∞。
4. NaN
当指数字段全 1,尾数字段非零时,值是 NaN(Not a Number):
常见会产生 NaN 的操作:
0 × ∞+∞ + (-∞)0 / 0∞ / ∞- 对负数开平方
NaN 最反直觉的一点是:它不等于任何东西,包括它自己。因此 x != x 常被用作判断 NaN 的底层技巧(在 C/C++ 里编译器有时会激进地优化它,更稳妥的写法是 isnan(x))。
5. 最大值、最小规格化值与次正规数
float 的最大有限值用倒数第二大的指数字段(254)和全 1 的尾数字段:
3.40282347 × 1038。最小的正规格化 float 是:
2-126,约 1.17549435 × 10-38,即 FLT_MIN。但 FLT_MIN 并不是 float 能表示的最小正数。指数编码为 0、尾数非零时,进入次正规数(subnormal / denormal)区域:真实指数仍按 -126 解释,但隐含位从 1 变成 0:
+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 倍:
[2n, 2n+1) 内部 float 是均匀的,但越往右整体越稀疏。float 能逐个表示所有整数,一直到 224 = 16777216 为止。下一个可表示的 float 是 16777218:
16777217 没有任何 float 编码可以精确表示,会被舍入到相邻两个里更近的那个。这就是为什么用 float 累加大整数会出现“卡住”的现象:当累加到 224 之后,每次加 1 实际上落到了上一个值,循环永远不会推进。
七、把 float 当成整数看
对正的 float 来说,如果忽略三段结构,直接把 32 bit 当成无符号整数看,有一个很漂亮的性质:整数编码加 1,通常就会得到下一个可表示的 float。
例如:
2097151.875。把完整 bit 串当无符号整数加 1,尾数会全部进位到指数:
0 10010011 11111111111111111111111
+ 0 00000000 00000000000000000000001
─────────────────────────────────────
0 10010100 00000000000000000000000
再把结果按 float 三段解释:
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 位有效数字(double 是 DBL_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,把二进制科学记数法编码得尽量紧凑、可比较、可渐进下溢,并为错误和溢出留下可传播的特殊值。
参考
- Bartosz Ciechanowski: Exposing Floating Point
- 配套工具:float.exposed
- David Goldberg: What Every Computer Scientist Should Know About Floating-Point Arithmetic
- Bruce Dawson: Comparing Floating Point Numbers, 2012 Edition
- Exploring Binary: Floating-Point Articles