你是否留意过 0.1 + 0.2 在计算机中等于多少?如果没有注意过的话,你可以立刻尝试打印一下「0.1 + 0.2 == 0.3」的结果。无论你使用的是 Js,Golang,或者是 Java,C,你都能得到一个反直觉的结果,0.1 + 0.2 == 0.3 返回的是 false。 为什么计算机连如此基础的计算都会算错?在实际工作中,我们能信任计算机的计算结果吗?本文将从 0.1 + 0.2 展开,解释为什么浮点数的计算存在误差,并向读者展示计算误差在何种情况下会累积到不可接受的程度,并提供一种简单的估计误差的方法论,期望在设计系统时,这个方法论能帮我们提前考虑到计算误差的风险,并在系统设计时就能规避这样的风险。
浮点数标准 IEEE 754
既然要说浮点数的问题,那就不得不提起 IEEE 754 标准,几乎所有的现代的 CPU 都采用这个标准。它决定了浮点数如何存储,如何计算。不过,在介绍 IEEE 754 之前,我们先从数学的角度简单看一下,计算机浮点数标准面临的问题。
从数学到计算机
数学是一门抽象的学科,而计算机会面临着具体的实现。我们的浮点数标准就是在做这样一件事:建立计算机中存储的一个零一序列到抽象数学中的一个数字的一对一的映射。 而这里有一个无法解决的问题,数学中任意小段连续的数轴中的小数就是无限的,这意味着我们需要无限个互不相同的零一序列(一对一的映射)才能表示。但是,现实的计算机中,我们没有无限的内存,有限的内存就意味着我们只能表示有限的数字。这种有限与无限的割裂,就导致任何浮点数标准都一定存在大量被舍弃的数字,大量在标准中不能精确表示的数字。
简单而言,从数学角度说,无论任何浮点数标准,都面临用有限的集合去映射无限的集合的问题,都一定会存在精度问题。 而 IEEE 754 作为在六七十年代各种各样浮点数实现中胜出的标准,具有足够的合理性和先进性,并且在现在已经成为工业界的事实标准。
很多不了解计算精度问题的人,在遇到问题后,可能会抱怨标准,但事实上,浮点数的计算精度问题,既不是编程语言的问题,也不是标准的问题,而是现实约束下的数学悖论,无论任何浮点数的实现,都会面临类似的问题。只有我们理解这些问题,我们才能在写代码,系统设计时合理的去规避可能的精度问题。
IEEE 754
在 IEEE 754 标准下,一个浮点数会以科学计数法的形式进行存储,如下图所示:
其中,最高位一位是符号位,用于区分正负,中间 exponent 代表指数部分,低位 fraction 存储有效数字,可以形象地理解 fraction 决定了数字是哪个数字,exponent 决定了小数点在哪。fraction 这部分是后文核心,后文以「有效位」代指 fraction 所占用的存储空间,同时为了叙述方便,用「浮点位」代指 exponent 所占用的存储空间。
有效位的位数取决于具体使用的浮点数标准,单精度浮点数(float)中,我们有 23 位有效位,8位浮点位,而在双精度浮点数中,则有 52 位有效位,11 位浮点位。
由于浮点数精度问题核心是因为内存限制,也就是存储导致的,IEEE 754 其他方面的标准我们不再额外介绍,在了解了浮点数是如何存储的之后,我们便探索一下,计算误差是怎么出现的。
计算误差是如何产生的
我们都知道,计算机中,数字都是以二进制的形式存储的,在我们最初的例子中,0.1 和 0.2 虽然在十进制中是一个有限小数,但是转化为二进制后,却是一个无限小数,这导致了在存储 0.1 和 0.2 时,其实已经存在一定的精度丢失,而这样计算结果自然也存在误差。那我们能下结论说精度问题是因为参与运算的数值不能精确存储导致的吗?
计算误差的产生
事实上是不能的,我们可以看一个例子,在例子中,我们会使用十进制,因为这更符合我们的直觉,同时也不影响问题的阐述。假设我们在使用一个只有三位有效位的标准,现在,我们看一下计算「10 + 3.14 + 2.71」的结果,很明显,参与运算的所有数字都可以通过三位有效位来精确表达。我们精确计算的结果是 15.85,四舍五入保留三位有效位,结果是 15.9。现在,我们看一下计算机的计算结果:
可以看到,计算机的计算结果是 15.8,相比精确计算的结果,计算机出现了误差。这也告诉我们,计算误差并不是简单因为参与计算的项不能精确表示,其本质原因,还是有效位位数的限制。并不是所有参与计算的数值都是精确的,结果就一定是精确的。
到目前为止,我们看到的计算误差都很小,似乎就出现在最低位,那么,我们能放心地说,浮点数计算的误差是很小的,可以忽略的吗?
计算误差的积累
为了说明误差大小的问题,我们来看一个极端一点的例子,假设现在只有两位有效位,于是我们的浮点数标准能表示的最大数值是 99,最小精度是 0.01。现在我们计算一下「10+ 0.11 + 0.11 + ... + 0.11」,总共 100 个 0.11。我们精确计算的结果是 10 + 11 = 21。我们再看一下计算机的结果:
可以看到一个神奇的现象,在我们的浮点数标准下,10 + 0.11 永远等于 10, 10 相比于 21,误差是 11,而 11 已经是两位有效位下能表示的最高位数(十位)。进一步,我们继续增加 0.11 的数量,这个误差还会持续增大,甚至可以超过浮点数能够表示的最大数值 99。这种规模的误差,可就一点都不“小”。这也说明,浮点数的计算误差是不能随便忽略的。
如何估计系统中可能的计算误差
我们已经看到浮点数的计算误差可能很非常巨大,而在我们平时的工作中,仍然会有大量场景充斥着浮点数计算,它们都有问题吗?我们能信任浮点数的计算结果吗?
误差估计
这些问题我们可以拆开来看:
首先浮点数运算作为计算机世界的基础,如果它如此不堪,这个世界恐怕也很难支撑起一次工业革命级别的变革。
其次,从原理上我们也可以看到,浮点数计算的精度问题从数学层面就是不可避免的,现实中我们可以通过整数或者定点数来规避,但是这会带来其他额外的问题(溢出,性能还有程序复杂度),但单就浮点数计算的层面,这个误差是不可避免的。
归根结底,在误差一定存在的情况下,我们就需要去了解误差的影响,评估我们的系统是否存在误差风险?以及这种误差会影响业务吗?于是,对于涉及大量浮点数计算的系统,我们需要能够简单有效的评估,系统可能达到的误差是不是会超过业务可以接受的范围。我们先直接给出结论,然后再看一下具体的证明。
结论
为了估计系统面临的误差风险,我们需要估计如下三个量,然后结合浮点数有效位的限制,做出估计:
- a - 系统中可能涉及的最大整数数值
- b - 系统要求的精度(比如小数点后 6 位)
- c - 系统的运算规模
- n - 浮点数有效位的位数
然后通过下述公式计算:
如果上述不等式成立,则系统面临误差风险,右侧比左侧超出的越多,则出现不可接受范围的误差的可能性越大。
论证
首先,我们将浮点数的位做一下划分,如下图所示:
高位 X 位称作精确位,这部分就是业务要求,需要保证精确的位数,它实际上由整数部分和小数部分组成,也就是结论中的 a 和 b,通俗的说,a 确定了在最坏情况下,整数部分需要多少位才能表示,b 则是由业务决定需要保证的精度。而 X 就是精确表示的 a.b 这个数所需要的位数。我们可以很容易有下推论:
低位 Y 位被称作误差位,这部分就是我们可以允许存在误差的地方,只要误差不超过 Y 位,我们的系统就不存在误差风险。接下来就需要我们对 Y 做出评估。
可以回忆一下 「10 + 0.11 + ...」的例子,我们可以看到,随着计算次数的增加,误差在持续的扩大,这也告诉我们,计算次数是会影响到误差的。但是具体怎么影响,我们需要能够量化,否则我们也无法估计误差。我们先看一下,单次计算时的误差情况如下图:
从图中可以看到,在单次计算时,由于存储限制而被舍弃的精度 z,一定小于计算结果高位全是 0,只有最低位是 1 的数值,我们用大写 Z 记录这个只有最低位是 1 的值,由于被舍弃的精度一定小于 Z,我们可以用 Z 来约束单次计算的误差,即单次计算的误差 z < Z 是恒成立的。
而在最坏的情况下,计算结果的精确位部分就需要 X 位,并且每一次计算所产生的的误差是累积的,不会互相抵消。这种情况下,误差的积累速度是最快的。我们用这种情况下只有最低位是 1 的 Z 来估计所有计算的误差。我们很容易能发现,对于任意一次计算的误差 z,z < Z 是恒成立的。因为如果不成立,那么计算的结果一定出现了比 a 更大的数值,这不符合我们的假设。
现在,单次计算的误差不超过 Z,而我们允许的误差位只有 Y 位,于是,我们可以很容易得出,当
即,计算次数达到了 2 的 Y + 1 次方时,此时,在最坏的情况下,我们的误差需要 Y + 1 位才能表示,这种情况,系统就会面临误差风险。
于是,通过联立下述方程:
我们可以得到最终的不等式:
我们可以简单体验一下这个公式,假设我们系统中可能涉及的最大整数不超过一亿,系统要求小数点后 6 为都是精确的,我们可以简单计算 loga + logb 并向上取整得到 50,这表明我们需要 50 位都是精确的。假设我们使用 64 位双精度浮点数标准,此时 n = 52,根据上述公式我们,我们可以判断,在最坏的情况下,计算次数超过 8 次,便有可能导致最后一位(第 6 位)小数不精确。如果我们只要求 2 位小数精确,我们可以再次计算,此时精确位只需要 37 位,此时计算规模不超过 2^16 时,我们的系统都能保证精确。
计算规模的时滞效应
在我们提及计算规模时,主要是最指系统中某个浮点数是通过大量浮点数计算最终得出来的,并不是说多少个浮点数参与了运算,如果他们的参与与结果无关,其实也没有影响。不过这里额外再补充一些容易被我们忽视的场景,因为很多时候,这些场景并不是传统意义上计算规模巨大的场景。
这些场景主要是指存在时滞效应的场景,简单地说,单看这个场景,它涉及的计算量并不大,但是随着时间流逝,系统运行时间加长,某些浮点数的值可能会经历巨量的计算。
最常见的就是互联网的场景,假设我们设计了一个支付系统,并且双精度浮点数存储用户余额,在用户支付或者收账时,我们提供一个新的浮点数,并将它累加到原来的余额中去,此时就会有一次浮点数计算。但是在传统意义中,这不算计算量很大的场景。
我们还是以最大整数不超过一亿,小数点要求 2 为的前提,通过前面的计算我们可以知道,计算规模在不超过 2^16 = 25536 次时,我们的系统不存在风险。我们回顾一下我们的场景,虽然用户支付收账每次并不涉及很大的运算量,但是计算的误差会持续的累积到用户余额这个字段中,假设这个用户每天进行 100 次交易,我们可以推算,最坏的情况下,在 256 天后,这个用户的余额就可能在分这一单位上出现误差。
这就是所谓的时滞效应,在设计系统时,特别是数据库中的浮点数,我们需要考虑这些字段是否有很高的精度要求,如果是的话,对于计算规模的考虑,我们还需要考虑时间带来的影响。
如何规避精度问题
我们已经看到,从数学层面,浮点数的精度问题就是不可避免的,同时我们也看到了如何估计系统是否存在误差风险。现实中,大部分情况下我们都不会面临误差风险,这时,刻意规避精度问题是没有必要的,因为所有的「规避精度问题」的方案并不是免费的,他们各自有着各自的缺点,只是在存在精度要求场景下,这些缺点不那么重要而已。
使用整数和定点数
与浮点数不同,整数是可以完全避免计算误差的。因为对于整数而言,在其可以表示的数值范围内,整数的数量是有限的,所有整数都可以精确表示。但是对于浮点数而言,任意小的连续区间内都有无限的浮点数,则必然存在无限不能精确表示的数值。简而言之,相较于浮点数,整数只存在溢出问题,不存在精度问题。
使用整数是一种简单高效办法,整数的计算仍然是 CPU 直接提供的能力,没有额外的计算成本,在计算规模很大时,没有额外的计算成本是很香的。如果系统中所有数值都可以通过整数表示,这是一种非常有效的解决精度问题的办法。但有些时候,计算的中间结果并不一定可以用整数表示,比如除法运算,指数运算(比如算年化)等等。
在计算的中间结果并不一定可以用整数表示的情况下,还有一种被称作定点数的办法,其思路则是使用整数存储有效数字,再使用另一个整数存储指数,然后以这种模式实现各种基础运算,Java 中的 BigDecimal、Golang 中的 math/big 都是这样的思路。值得一提的是,定点数并不是完全解决了浮点数的精度问题(这是解决不了的),而是可以给我们更多的有效位的位数。
因为 CPU 架构的限制,CPU 实现的浮点数标准最多就是双精度,回想之前我们的计算,在最大数值不超过一亿,精度要求 2 位小数时,最坏情况下,只需要 2^16 = 25536 次的计算规模,我们便会面临精度风险,如果我们的有效位位数从 float64 的 52 位变成 uint64 的 64 位,则需要有 2^28 = 268435456 次的计算规模才可能面临精度风险,还是以用户余额为例,如果用户仍然每天交易 100 单,则在最坏的情况下,需要 7000 多年才会在分上出现精度问题,这完全够用了。
使用定点数的弊端则是计算的复杂度成倍上升,本来单就加法运算而言,一个指令就能完成相加,但是使用定点数,我们将处理诸如对齐小数点,溢出判断等等问题,单次计算所需要的指令会成倍增加。
误差补偿算法
定点数是很好的通用的解决方案,其缺点可能就在于不够高效,在一些场景中,存在更为高效的误差补偿算法,他们能够高效地解决误差问题,我们将介绍 Kahan 求和算法,看一下误差补偿是如何实现的。误差补偿的思路其实不复杂,回忆单次计算的误差的图:
我们每次计算,都有一个 z 被舍弃掉,随着计算次数增加,z 的累积可能导致误差不可接受。误差补偿的思路就是:每次计算我们尝试把 z 存储起来,并且在下一次计算时,我们把 z 再重新加入计算。这样每次计算都会把上次计算的误差考虑进去,系统中的误差不会随着计算次数累积,最终的误差不会超过 Z,也就是只有最低位取 1 的数值。
Kahan 求和算法
Kahan 求和算法就是一个有补偿的求和算法,我们可以简单学习一下这个算法,看一下误差补偿大致应该如何实现,下面的例子来自维基百科:
function KahanSum(input: number[]){
var sum = 0.0;
var c = 0.0; // 存储被舍弃的精度
for (let i = 1; i < input.length; i++) {
var y = input[i] - c // 将 c 加到新的数值中
var t = sum + y // 舍弃了精度的结果
// 这里有个假设前提, sum 相比 y 是一个大数,在求和场景下,绝大部分情况都是成立的
c = (t - sum) - y // (t - sum) 的到 y 没有被舍弃的高位部分,再 - y 就得到被舍弃的精度 c
sum = t
}
return sum
}
我们可以通过「10 + 3.14159 + 2.71828」的例子看一下 Kahan 求和算法,我们假设现在只有 6 为有效位,仍然使用十进制计算,精确计算的结果是 10005.85987,四舍五入结果是 10005.9,如果不做任何处理直接相加,我们可以得到 10 + 3.14159 + 2.71828 = 10003.1 + 2.71828 = 100005.8 的错误结果。那么,Kahan 求和算法是如何规避精度问题的呢?
首先在第一轮循环时:
y = 3.14159 - 0.00000 y = input[i] - c,c 第一次为 0
t = 10000.0 + 3.14159
= 10003.14159
= 10003.1 保留 6 为有效位
c = (10003.1 - 10000.0) - 3.14159 计算 c ,这里顺序很重要,要先减去大数
= 3.10000 - 3.14159 y 被保留的部分减去实际上的 y
= -0.0415900 得到由于有效位限制被舍弃的数值
sum = 10003.1
第二轮循环时:
y = 2.71828 - (-0.0415900) 上次计算的误差被加到新的输入中
= 2.75987
t = 10003.1 + 2.75987
= 10005.85987
= 10005.9 保留 6 为有效位
c = (10005.9 - 10003.1) - 2.75987 计算 c
= 2.80000 - 2.75987 此时是四舍五入时进位的场景
= 0.040130 此时 c 是正值,会在下次计算中被减去
sum = 10005.9 我们得到了正确的结果
我们可以看到,Kahan 求和算法巧妙的将计算误差保留了下来,不过这也是针对求和场景,在求和场景下,通常就是因为 sum 这样一个大数的存在,导致小数值的精度丢失较多。Kahan 求和算法并不适用所有场景,但是误差补偿的思想仍是适用的。
综合来看
在需要规避精度问题时,首先应该确定的是,系统可能存在精度问题吗?实际上,大部分被我们忽略的场景实际上是不存在精度问题的,他们要么对精度要求并没有这么高,要么计算量实际达不到可能出现精度问题的规模。
在需要规避精度问题时,整数或者定点数都是有效且简单的方案,通常他们应该被优先考虑,在计算规模特别大,或者定点数提供的有效位都不满足要求时,我们可以考虑是否可以有对应场景的误差补偿算法,并通过这些算法约束系统产生的误差,防止其随着计算次数而出现大量累积。
写在最后
本文介绍了浮点数误差的来源,为什么它是无法避免的。同时,本文尝试提供一种简单的,能够在工作中快速计算的方法论,帮助我们评估系统可能面临的误差问题。这个方法论需要估计下述几个量:
- a - 系统中可能涉及的最大整数数值
- b - 系统要求的精度(比如小数点后 6 位)
- c - 系统的运算规模
- n - 浮点数有效位的位数
然后通过下述公式计算:
公式右侧超过左侧越多,则出现误差问题的可能性越大。在右侧比左侧大一点时,在最坏的情况下,系统不能保证最低的精度要求。在我们的系统面临误差问题时,我们介绍了规避精度问题的一些办法,也介绍了一个较为巧妙的有补偿的求和算法。在最后,再总结(重申)一下本文的观点:
- 浮点数的精度问题是现实约束下的数学问题,是无法避免的
- 我们应该能够估计精度风险,并且在出现精度风险时,能够解决这些风险
- 浮点数计算结果如果不存在精度问题,剩下的都是显示问题。怎么显示,PM 说了算(你就说你想保留几位吧)。