浮点数精度丢失: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 为例,一个浮点数由三部分组成:

  1. 符号位 sign:1 bit。
  2. 指数 exponent:8 bit,使用偏置编码。
  3. 尾数 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$ 无法有限写成小数是同一类问题。

误差为什么会累积

浮点运算的每一步通常都会经历“计算出更高精度中间结果,再舍入回目标格式”的过程。一次舍入误差可能很小,但循环累加、减去两个接近的数、不同数量级的数相加,都会放大误差影响。

典型问题有三类:

  1. 累加误差:反复加一个无法精确表示的小数,例如循环加 0.1
  2. 大小数相加:1e20 + 1float64 中可能看不到 1 的影响,因为 1 小于当前量级下的间隔。
  3. 灾难性消去:两个非常接近的数相减,有效数字会大量丢失。

例如:

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.42.4000000000000000000000001 经过 math.Float64bits() 转换后的结果是一样的。自然,二者在 map 看来就是同一个 key。

还有两个坑:

  1. NaN != NaN,所以 NaN 作为 key 时很容易出现无法按直觉查询的问题。
  2. +0.0 == -0.0 为 true,但二者 bit 表示不同。不同语言或序列化层如果按 bit 处理,可能得到不同结果。

工程上如果需要用小数做 key,通常更推荐先归一化成整数。例如金额用“分”或更小单位保存,价格 tick 用整数 tick 数保存,坐标或指标按业务精度放大后取整。

工程建议

  1. 金额、账务、撮合价格等场景不要直接用二进制浮点数表达精确十进制小数,优先使用整数定点数或 decimal 库。
  2. 科学计算可以使用浮点数,但要关注算法稳定性,例如 Kahan summation、排序后累加、避免相近大数相减。
  3. 比较浮点数时不要盲目使用 ==,除非比较的是协议定义的特殊值、枚举式浮点值,或你明确需要 bit 级相等。
  4. 日志和序列化要保留足够有效位,否则打印出来的值可能无法 round-trip 回原来的浮点数。
  5. map key、数据库索引、缓存 key 中出现 float 时,要先问清楚业务语义:是精确 bit 模式,还是某个精度下的等价类。

浮点数不是“不准确”,而是“精确地表示了某个二进制近似值”。理解这一点后,很多看似诡异的问题都会变成可解释、可规避的工程取舍。


浮点数精度丢失:IEEE 754、舍入误差与工程实践
https://xiao-nanbei.github.io/2021/12/13/对浮点数精度丢失的一点研究/
作者
Xuan Tan
发布于
2021年12月13日
许可协议