浮点数的乐趣

本文深入探讨了浮点数表示的细节,特别是双精度(double)浮点数在计算中的精度问题。通过分析IEEE754标准,解释了浮点数的存储结构,包括符号位、指数和有效数字。作者展示了浮点数相等性比较的陷阱,并提出了通过提高计算精度进行舍入以减少误差的方法。此外,文章还介绍了如何在C#中实现特定精度的浮点数比较,以确保相等的浮点数产生相同的哈希码。
摘要由CSDN通过智能技术生成

介绍

WPF 在其大部分公共 API 中使用双精度浮点数(在 C# 中为 double),并且在其大部分内部呈现中使用单精度浮点数。所以浮点数学是我们经常处理的事情。奇怪的是,我实际上对血淋淋的细节知之甚少,直到我最近尝试编写一个必须使用双精度数作为键的容器,这需要解决浮点数学中的精度问题。我发现整个练习非常有趣,所以我将在这里展示我学到的东西。

问题

考虑这段代码:

if (0.8 - 0.7 == 0.1)
{
    Console.WriteLine("Math makes sense.");
}

信不信由你,比较会失败。这让我很困扰,因为我不知道如何编写能够按预期工作的代码。(在这个简单的情况下,编译器实际上会生成一个警告代码无法访问。)在互联网上搜索,我发现了一篇很棒的文章,我推荐它作为一个很好的阅读:

Comparing Floating Point Numbers

一种常见的方法是从不直接比较浮点数,而只是检查差异的绝对值是否足够小。当然,问题在于很难为“足够小”选择正确的值。特别是因为浮点数涵盖了很大的范围——从非常非常小到非常非常大。例如,+/- 0.0001 对于某些值可能很好,但对于非常非常大的数字可能不是很好,对于非常非常小的数字肯定是一个糟糕的选择,因为这个容差实际上大于正在使用的数字。

另一个想法是用浮点数的大小来缩放容差。基本上,该技术使用的容差是值的百分比。这对我来说似乎是一个合理的想法,尽管可能难以有效实施。

另一个想法是考虑相等的两个双精度数,它们仅在有效数字的最低有效位上有所不同,这是上面文章的前提。

无论您做什么,都需要牢记 .Net 框架对相等性和哈希码的要求。您需要确保任何比较相等的东西都会生成相同的哈希码。不这样做会导致细微的错误,例如丢失哈希表中的数据。

在设计解决方案之前,让我们深入了解浮点格式的细节。

背景

首先,阅读有关IEEE 754-2008 标准的维基百科文章。我们最关心的两种格式是binary64(C# 中的 double)和binary32(C# 中的 float)。当然,IEEE 标准的原则也可以应用于其他大小,例如有趣的Minifloat格式。

今天,我们通常用十进制 位置表示法来表示数字。例如,数字 12.34。每个数字都可以理解为乘以数字系统的基数的某个幂(由数字的位置表示)。同样,使用十进制(以 10 为底),我们可以将此简写表示形式扩展为:

指数符号可以用作非常大或非常小的数字的更紧凑的表示。例如,数字 1,234,000,000,000 可以使用指数符号以多种方式书写:



 

最后一种形式被称为归一化指数符号,并且这种符号中的每个数字都有一个唯一的表示。当我们尝试在计算机中表示数字时,这很有帮助。

数的大小由指数决定,数的精度由系数决定,否则称为有效数。

Base 10 很简单,Base 2 怎么样

当然,我们感兴趣的是基数 2,因为这是计算机在其正常的二进制数据表示中使用的。同样的原理也适用:即每个数字(0 或 1)乘以数字系统(2)的基数的某个幂(由数字的位置表示)。所以我们原来的 12.25(以 10 为底)的例子可以写成:

在二进制 IEEE 754 格式中使用的一个有趣的观察是,在以 2 为底的归一化指数表示法中,前导数字始终为 1。您将在下一节中看到它是如何派上用场的。

二进制表示

如简介中所述,IEEE 754-2008 标准定义了许多二进制格式。现在我们将考虑binary64(C# 中的双精度)。顾名思义,这种二进制格式是 64 位宽,并按如下方式分配位:

零件尺寸
符号1 位
指数11 位
有效数字52 位

符号位很简单:0 表示正数,1 表示负数。需要注意的是,与更典型的二进制补码技术相比,这是对负数进行编码的不同方式。例如,有正零和负零

指数稍微复杂一些。作为 11 位,它可以编码 0 到 2047 之间的值,包括 0 和 2047。(请注意,0 和 2047 都有特殊含义,不用作指数。)指数字段需要存储正值和负值(对于非常小和非常大的数字)。IEEE 754 标准不存储符号位,而是指定指数偏差为 1023。在存储所需的指数值之前,添加 1023 并存储结果。读取指数字段时,减去 1023 即可获得最初预期的值。结果是指数可以有效地介于 –1022 和 1023 之间(含)。

如前所述,0 和 2047 (0x7FF) 都是指数字段的特殊值。值 0 表示该数字低于正常值。之前我们注意到,归一化的指数表示法要求小数点前有一个非零数字,小数点后的其余数字是 - 整个乘以基数,提高到某个指数。我们还注意到,在二进制中,小数点前的数字始终为 1。有两种情况违反了此规则:


  1. 纯零显然不能有非零数字!
  2. 非常非常小的数字
    由于指数范围有限,即使小于 1 * 2^-1022 的数字也不能用前导数字 1 表示。

对于这些情况,指数字段包含 0。实际指数值被认为是 –1022(好像该字段包含 1),并且假定该数字在小数点前具有前导 0,这意味着从技术上讲,该数字不是在归一化指数符号中更长。但是,该表示仍然是唯一的,因为鉴于指数字段的限制,没有其他方法可以表示这些非常非常小的数字。

指数字段中的另一个特殊值是 2047 (0x7FF)。当指数字段包含此值时,数字为 NaN(非数字)或 Infinity。如果有效数字为 0,则该数字被认为是无穷大。如果显着性不为零,则该数字被认为是 NaN。一些直接的观察结果:无穷大可以是正数或负数,并且基本上比最大非无限值大一个值(或比最小负非无限值小一个值)。可以有许多不同的 NaN 值——所有不同的可能有效值——这有时用于携带额外的信息。符号位可以为 NaN 设置清除,它被简单地忽略。假设 NaN 可以是安静的或发信号的,但实际格式尚不清楚(至少对我而言)。

可代表的价值

由于 double 是 64 位的,我们知道可以表示的值的数量是有限的。计算所有可表示的值——NaN、无穷大、正负、次正态、正态等——是一个简单的计算:264 = 18,446,744,073,709,551,616。

现在有很多数字!将浮点数(不是浮点值)表示为带符号的 64 位数字有助于加强格式背后的直觉:

请注意,该图未按比例绘制。我的意思是绝大多数可表示的数字都在法线范围内。Subnormals 和 NaNs 只占可表示值的一小部分。我尝试用虚线表示这一点。该图仅显示了正半部分。负半部分完全相同,所有数值都是负数。请注意,既有正无穷也有负 0,也有正无穷和负无穷。虽然在技术上存在正负 NaN,但符号被忽略。

浮点值的分布

现在我们已经了解了表示如何在 0、次法线、法线、无穷大和 NaN 之间分布,让我们看看实际的浮点值是如何分布的。64 位双精度值包含太多无法推理的值,因此我将冒昧地切换到简化的 minifloat 格式。我选择了 6 位格式:1 个符号位、3 个指数位和 2 个有效位(但请记住,我们也有一个隐式有效位),以及 2 的指数偏差。在 excel 中,我创建了下表:

这是对 IEEE 浮点格式如何工作的非常好的、简洁的总结。但是,如果我们将结果绘制成图表,我们会看到浮点值遵循一条有趣的曲线:

正在上传…重新上传取消

同样,水平轴是 6 位作为整数的天真解释(尽管符号保留为单独的位,而不是使用二进制补码)。根据我们选择的 IEEE-ish minifloat 格式,纵轴是这些位表示的实际浮点值。

如您所见,分布不是线性的。事实上,我想不出所料,分布是指数的。

实际上,在每个指数带内,分布是线性的,我在上图中用颜色进行了编码。每个波段的指数基本上是值均匀分布的直线的斜率。您还可以看到,接近 0 的浮点值沿垂直轴密集排列,而较大的浮点值沿垂直轴展开。沿水平轴的间距是固定的。

这使我们回到了我们的问题:浮点数学中的错误可能是在最低有效位中累积的。但最低有效位实际上可以表示相当大范围的值,从非常小到非常大。例如,在我们上面使用的 minifloat 格式中,您可以看到最低有效位导致大于 0 的最小表示为 0.125,而最低有效位导致次大值改变 4.0 变为最大价值。

这只是我们愚蠢的 minifloat 格式。我们可以计算完整的 64 位格式。64 位双精度浮点格式中的最低有效位的值范围可以是:

正在上传…重新上传取消

从绝对意义上讲,这是一个令人难以置信的可能错误范围。

介绍 Binary64 类型

为了对 binary64 格式(C# 中的双精度)的结构进行实验和调查,我构建了 Binary64 类,您可以在我的 codeplex 站点上找到它。

正在上传…重新上传取消

Binary64 结构使用显式字段偏移来重叠 64 位双精度和 64 位 ulong(联合的托管等效项)。这个结构的大小仍然是 64 位,因此传递的效率与 double 或 uint 一样。

Binary64 可以从 double 或 ulong 构造,并且可以隐式地转换为两者之一。还有一些方法可以获取下一个和上一个可表示值。知道我们现在所知道的,很明显这些只是增加和减少 ulong ——除了处理少数情况,例如正负 0。

Binary64 结构还公开了浮点数的有趣部分:符号、指数和有效数。我通过类型仔细地公开了这些,例如用于指数的 Binary64Exponent 和用于有效数的 Binary64Significand。

我还通过 DoubleExtensions 类提供扩展方法,将大部分功能添加到 double 类。

最后,我提供了一个 Round 方法,我们将在下面讨论。

调查错误

本文开头的 0.8 – 0.7 == 0.1 条件到底有什么问题?使用 Binary64 类,很容易调查。

零件价值
字面价值0.8
二进制 64 位0x3FE999999999999A
符号积极的
无偏指数0x3FE
有偏指数-1
有效数字0x000999999999999A
隐式前导数字?是的
有效分子7,205,759,403,792,794
有效分母4,503,599,627,370,496
有效分数1.6000000000000000888178419700125

正在上传…重新上传取消

因此,实际值略大于 0.8,因为很难以基数 2 编码这个精确值。请注意,之前的可表示值略小于 0.8。

零件价值
字面价值0.7
二进制 64 位0x3FE6666666666666
符号积极的
无偏指数0x3FE
有偏指数-1
有效数字0x0006666666666666
隐式前导数字?是的
有效分子6,305,039,478,318,694
有效分母4,503,599,627,370,496
有效分数1.3999999999999999111821580299875

正在上传…重新上传取消

因此,实际值略小于 0.7,因为在基数 2 中表示这个精确数字的难度相同。请注意,下一个可表示的值略大于 0.7。

显然,由于我们正在处理两个不精确的表示,因此每个都累积了一个错误。任何数学运算的结果都可能仍然包含错误。确实,我们可以看到:

零件价值
字面价值0.8 - 0.7
二进制 64 位0x3fb99999999999A0
符号积极的
无偏指数0x3FB
有偏指数-4
有效数字0x00099999999999A0
隐式前导数字?是的
有效分子7,205,759,403,792,800
有效分母4,503,599,627,370,496
有效分数1.6000000000000014210854715202004

显然这也不完全正确。但更有趣的是,计算结果 0.8-0.7 和字面量 0.1 之间有 6 个可表示的 binary64 值。查看每个的 binary64 表示,我们看到错误已累积在最低有效 6 位中:

十进制十六进制二进制
0.8 - 0.70x3FB99999999999A011111110111001100110011001100110011001100110011001100110100000
0.10x3FB999999999999A11111110111001100110011001100110011001100110011001100110011010

建议:通过降低精度四舍五入

正如我们所看到的,浮点数学是不精确的,并且由于最低有效位的单个更改所引入的误差幅度的巨大范围,舍入策略具有挑战性。

我建议以比比较更高的精度执行计算。这并不是一个真正的新想法。例如,图形卡能够以比像素着色器中以编程方式提供的精度更高的精度在管道中执行计算。当降低比较精度时,我们显然会首先丢弃最低有效位。

为了实现舍入,而不仅仅是截断,我使用了在截断前加一半的技巧。Binary64.Round 方法将无意义数字的数量作为参数。基于此,它可以很容易地计算出那些无意义数字中可表示的浮点数的数量,并在清除那些无意义的数字之前将它们的一半相加。

请记住——这种舍入是关于舍入到最接近的第 n 个可表示值,而不是绝对意义上的舍入。只需将其视为降低精度。

对于浮点数,比较的精度非常重要,我认为它应该是类型签名的一部分。C++ 可以在模板签名中使用 int 值,但遗憾的是 C# 不能对泛型做同样的事情。如果可以的话,我们可以有一个像 Binary64Compare<42> 这样的甜蜜模板来表示 42 位有效数字而不是标准的 52(忽略隐含的前导数字)。这种类型将清楚地表明 10 个最低有效位被认为会累积错误并将被舍入。

介绍 Double42 类型

由于我想在类型中编码精度信息,并且考虑到 .Net 泛型的限制,我们可以做的下一个最好的事情是声明具体类型。出于我的目的,我假设 10 位错误是可以容忍的合理数量。您可以做出自己的特定领域选择并实现自己的类。

Double42 结构只包含一个双精度数,因此它应该与双精度数一样有效。它不会改变 double 的值,而是在内部根据需要进行舍入。

Double42 可以从 double 构造,并且可以隐式转换为 double 和从 double 转换。

此类型仅用于比较,以支持以比比较更高的精度进行计算的原则,因此它不提供任何数学运算。它实现了 IEquatable 和 IComparable。

如本文开头所述,Double42 也正确实现了 GetHashcode 和 Equals,因此任何两个“相等”的数字也产生相同的哈希码。

最后,我们开始编写我们想要的代码:

double a = 0.8 - 0.7;
if ((Double42)a == 0.1)
{
    Console.WriteLine("Math makes sense.");
} 
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值