浮点数精度丢失:IEEE 754、舍入误差与工程实践
问题现象
先看一个很常见的例子:
float f = 0;
for(int i=0;i<100;i++){
f+=0.1f;
}
printf("%f\n",f);数学上 $0.1 \times 100 = 10$,但这段代码可能输出 10.000002。这不是 C 语言算错了,而是 0.1f 本身无法被二进制浮点数精确表示,循环累加又把每一步舍入误差继续传播。
我们打印小数点后更多位数,能看得更清楚。printf("%.20f\n",f);,得到的结果如下:
我们通过gdb调试看看存储在内存中的数据是什么样子的:
我们将其转化为二进制是 0100 0001 0010 0000 0000 0000 0000 0010。
将这个 IEEE 754 标准的二进制浮点数转化为十进制为:10.000001907348633。
IEEE 754 如何表示浮点数
以 float32 为例,一个浮点数由三部分组成:
- 符号位 sign:1 bit。
- 指数 exponent:8 bit,使用偏置编码。
- 尾数 fraction:23 bit,规格化数隐含一个最高位 1。
规格化浮点数的值可以写成:
float64 也是同样思路,只是指数位扩展到 11 bit,尾数位扩展到 52 bit。尾数位数决定了有效精度,因此 float64 不是“精确小数”,只是比 float32 能表示更多二进制有效位。
为什么 0.1 不能精确表示
十进制小数能否有限表示,取决于分母质因数是否只包含 2 和 5。比如 $0.5 = 1/2$ 可以有限表示,$0.25 = 1/4$ 也可以。但二进制小数只能有限表示分母质因数只包含 2 的数。
分母里有 5,所以它在二进制里会变成无限循环小数。硬件只能保存有限位尾数,于是必须舍入。也就是说,程序里写下 0.1 的那一刻,进入寄存器或内存的就已经是最接近 0.1 的某个二进制浮点数。
类似地,下面这段代码不是特例:
float f = 0.1f;
printf("%.18f\n",f);上述代码中打印结果如下所示:
我们来看看内存中如何表示这个数据的:
将这个二进制数转化为十进制数为:
可以看到某些浮点数在写入内存的过程中就存在误差,这是二进制浮点表示的自然结果,和十进制中 $1/3$ 无法有限写成小数是同一类问题。
误差为什么会累积
浮点运算的每一步通常都会经历“计算出更高精度中间结果,再舍入回目标格式”的过程。一次舍入误差可能很小,但循环累加、减去两个接近的数、不同数量级的数相加,都会放大误差影响。
典型问题有三类:
- 累加误差:反复加一个无法精确表示的小数,例如循环加
0.1。 - 大小数相加:
1e20 + 1在float64中可能看不到1的影响,因为1小于当前量级下的间隔。 - 灾难性消去:两个非常接近的数相减,有效数字会大量丢失。
例如:
package main
import "fmt"
func main() {
var x float64 = 1e20
fmt.Println((x + 1) - x)
}直觉上结果是 1,但实际可能是 0。原因不是加法规则变了,而是在 1e20 这个量级附近,float64 可表示数之间的间隔已经远大于 1。
ULP 与 EPSILON
比较浮点数时常说的 EPSILON 不是一个固定真理。它应该和业务量级相关。1e-9 对金额可能太大,对物理仿真可能又太小。
更底层的概念是 ULP(unit in the last place),表示某个浮点数附近相邻可表示数之间的距离。浮点数越大,相邻两个可表示数的绝对间隔通常也越大。因此同一个绝对误差阈值不能适用于所有量级。
更稳妥的比较方式通常同时考虑绝对误差和相对误差:
func almostEqual(a, b, absTol, relTol float64) bool {
diff := math.Abs(a - b)
if diff <= absTol {
return true
}
return diff <= relTol*math.Max(math.Abs(a), math.Abs(b))
}绝对误差适合接近 0 的比较,相对误差适合大数比较。
排序和相等判断
当判断两个浮点数是否“业务上相等”时,可以使用如下思想:
if (fabs(a - b) < EPSILON) {
//执行当两个浮点数 a 和 b 相等时的操作
}但要注意:这只适合业务语义下的近似相等,不适合所有场景。如果要做 map key、排序去重、缓存索引,就必须明确使用精确 bit 模式还是归一化后的业务值。
float 可以作为 map key 吗
从语法上看,Go 语言中只要是可比较的类型都可以作为 key。除开 slice、map、function 这几种类型,其他类型大多可以。浮点数支持 == 和 !=,所以 float64 可以作为 map key。
问题在于:语法允许不代表业务上合适。
我们来看如下例子:
func main() {
m := make(map[float64]int)
m[1.4] = 1
m[2.4] = 2
for k, v := range m {
fmt.Printf("[%v, %d] ", k, v)
}
fmt.Printf("k: %v, v: %d\n", 2.400000000001, m[2.400000000001])
fmt.Printf("k: %v, v: %d\n", 2.4000000000000000000000001, m[2.4000000000000000000000001])
}程序输出如下:
[2.4, 2] [1.4, 1]
k: 2.400000000001, v: 0
k: 2.4, v: 2我们发现 2.4000000000000000000000001 可以查到 value。原因是这个十进制字面量在编译后被舍入成了和 2.4 相同的 float64。
Go 可以通过以下函数观察浮点数的 bit 表示:
// Float64frombits returns the floating point number corresponding
// the IEEE 754 binary representation b.
func Float64frombits(b uint64) float64 { return *(*float64)(unsafe.Pointer(&b)) }也就是 IEEE 754 规定的格式。
我们再来输出点东西:
package main
import (
"fmt"
"math"
)
func main() {
m := make(map[float64]int)
m[2.4] = 2
fmt.Println(math.Float64bits(2.4))
fmt.Println(math.Float64bits(2.400000000001))
fmt.Println(math.Float64bits(2.4000000000000000000000001))
}输出结果如下:
4612586738352862003
4612586738352864255
4612586738352862003转成十六进制如下:
0x4003333333333333
0x4003333333333BFF
0x4003333333333333由此可见,由于精度受限,2.4 和 2.4000000000000000000000001 经过 math.Float64bits() 转换后的结果是一样的。自然,二者在 map 看来就是同一个 key。
还有两个坑:
NaN != NaN,所以 NaN 作为 key 时很容易出现无法按直觉查询的问题。+0.0 == -0.0为 true,但二者 bit 表示不同。不同语言或序列化层如果按 bit 处理,可能得到不同结果。
工程上如果需要用小数做 key,通常更推荐先归一化成整数。例如金额用“分”或更小单位保存,价格 tick 用整数 tick 数保存,坐标或指标按业务精度放大后取整。
工程建议
- 金额、账务、撮合价格等场景不要直接用二进制浮点数表达精确十进制小数,优先使用整数定点数或 decimal 库。
- 科学计算可以使用浮点数,但要关注算法稳定性,例如 Kahan summation、排序后累加、避免相近大数相减。
- 比较浮点数时不要盲目使用
==,除非比较的是协议定义的特殊值、枚举式浮点值,或你明确需要 bit 级相等。 - 日志和序列化要保留足够有效位,否则打印出来的值可能无法 round-trip 回原来的浮点数。
- map key、数据库索引、缓存 key 中出现 float 时,要先问清楚业务语义:是精确 bit 模式,还是某个精度下的等价类。
浮点数不是“不准确”,而是“精确地表示了某个二进制近似值”。理解这一点后,很多看似诡异的问题都会变成可解释、可规避的工程取舍。