C#中的浮点数计算是否一致?是否可能一致?

160
不,这不是另一个"(1/3.0)*3 != 1"的问题。
我最近一直在阅读浮点数方面的内容;具体来说,同样的计算在不同的架构或优化设置下可能会得出不同的结果。
这对于存储回放的视频游戏或是点对点网络(与服务器-客户端相对),它们依赖于所有客户端每次运行程序都生成完全相同的结果——一个浮点计算中的小偏差可能导致不同机器上的截然不同的游戏状态(甚至是在同一台机器上!)。
即使在“遵循”IEEE-754的处理器之间也会发生这种情况,主要是因为一些处理器(即x86)使用双扩展精度。也就是说,它们使用80位寄存器进行所有计算,然后将其截断为64位或32位,导致舍入结果与使用64位或32位进行计算的机器不同。
我在网上看到了几种解决此问题的方案,但都是针对C++而非C#:
  • 使用_controlfp_s(Windows),_FPU_SETCW(Linux?)或fpsetprec(BSD)禁用双重扩展精度模式(以便所有 double 计算都使用IEEE-754 64位)。
  • 始终使用相同的编译器及相同的优化设置,并要求所有用户使用相同的CPU架构(不进行跨平台操作)。但由于我的“编译器”实际上是JIT,每次程序运行时可能会有不同的优化结果,因此我认为这是不可能的。
  • 使用定点算术,尽量避免使用floatdouble。虽然 decimal 可以用于此目的,但速度较慢,并且 System.Math 库中的所有函数都不支持它。

那么,在C#中这真的是一个问题吗?如果我只想支持Windows(不是Mono),会怎样呢?

如果确实存在这个问题,那么有没有任何方法可以强制我的程序以正常的双精度运行?

如果没有,是否有任何库可以帮助保持浮点数计算的一致性?


1
我看到了这个问题,但每个答案要么重复问题而没有解决方案,要么说“忽略它”,这不是一个选项。我在gamedev上问了一个类似的问题,但是(因为受众群体)大多数答案似乎是针对C ++的。 - BlueRaja - Danny Pflughoeft
1
不是答案,但我相信在大多数领域中,您可以设计系统,使所有共享状态都是确定性的,并且由此产生的性能下降并不显著。 - driushkin
1
@Peter你知道.Net有没有快速浮点数仿真器吗? - CodesInChaos
1
Java 是否受此问题的影响? - Josh
3
Java有一个strictfp关键字,它强制所有计算都使用指定的大小(floatdouble),而不是扩展大小。但是,Java在IEE-754支持方面仍然存在许多问题。很少(非常少)编程语言能够良好地支持IEE-754。 - porges
显示剩余6条评论
10个回答

56

我不知道在.NET中如何使普通的浮点数确定性。JITter允许创建在不同平台(或不同版本的.NET之间)表现不同的代码。因此,在确定性的.NET代码中使用普通的float是不可能的。

我考虑过的解决方法:

  1. 在C#中实现FixedPoint32。虽然这不太难(我有一个半成品的实现),但很小的值范围使其使用起来非常繁琐。你必须时刻小心,以免溢出或失去太多精度。最终,我发现这比直接使用整数还要麻烦。
  2. 在C#中实现FixedPoint64。我发现这相当困难。对于某些操作,128位的中间整数会很有用。但是.NET没有提供这样一种类型。
  3. 实现自定义32位浮点数。缺少BitScanReverse内置函数在实现时会有一些麻烦。但目前我认为这是最有前途的路径。
  4. 对于数学运算,使用本机代码。会在每次数学运算上产生委托调用的开销。

我刚刚开始了一个32位浮点数运算的软件实现。它可以在我的2.66GHz i3上每秒执行大约7000万次加法/乘法。 https://github.com/CodesInChaos/SoftFloat。显然,它仍然非常不完整且存在缺陷。


3
.NET提供了一个“无限”大小的整数类型BigInteger,虽然不如本机int或long快,但它确实存在。这种类型为F#创建,但可以在C#中使用。 - Rune FS
2
如果你要做这些事情,最好先尝试使用 decimal,因为它更简单。只有在它对于手头的任务来说太慢时,才值得考虑其他方法。 - Roman Starkov
我学习了一个特殊情况,其中浮点数是确定性的。我得到的解释是:对于乘法/除法,如果FP数字中有一个是2的幂次方数(2^x),则在计算过程中,significant/mantissa不会改变。只有指数会改变(点会移动)。因此,舍入永远不会发生。结果将是确定性的。 - zigzag
一个像2^32这样的数字表示为(指数:32,尾数:1)。如果我们将其与另一个浮点数(exp,man)相乘,则结果为(exp + 32,man * 1)。对于除法,结果是(expo-32,man * 1)。将尾数乘以1不会改变尾数,因此它有多少位并不重要。 - zigzag
抱歉给你点了踩。我在手机上误点了(如果这是一个词),现在无法更改。 - Thomas Padron-McCarthy

29

C#规范(§4.1.6浮点类型)明确允许使用高于结果精度的精度进行浮点计算。因此,我认为您不能直接在.Net中使这些计算具有确定性。其他人提出了各种解决方法,您可以尝试使用它们。


9
我刚刚意识到,如果一个人分发已编译的程序集,C#规范就不太重要了。它只有在想要源兼容性时才很重要。真正重要的是CLR规范。但我相当确定它的保证和C#的保证一样脆弱。 - CodesInChaos
每次操作后将类型转换为 double 是否会去除不必要的位,从而产生一致的结果呢? - IS4
4
@IllidanS4,我认为这并不能保证结果始终如一。 - svick

16
以下页面在您需要绝对可移植性的情况下可能会有所帮助。它讨论了用于测试IEEE 754标准实现的软件,包括用于模拟浮点运算的软件。大多数信息可能特定于C或C ++。

http://www.math.utah.edu/~beebe/software/ieee/

关于定点数的注释

二进制定点数也可以作为浮点数的替代品,这可以从四种基本算术运算中看出:

  • 加法和减法很简单。它们与整数的工作方式相同。只需加或减即可!
  • 要将两个定点数相乘,先将两个数相乘,然后将小数位向右移动指定数量的位。
  • 要将两个定点数相除,将被除数左移指定数量的小数位,然后除以除数。
  • Hattangady(2007)的第四章提供了有关实现二进制定点数的其他指导(S.K. Hattangady,“Development of a Block Floating Point Interval ALU for DSP and Control Applications”,硕士论文,北卡罗来纳州立大学,2007年)。

二进制定点数可以在任何整数数据类型上实现,例如int、long和BigInteger,以及不符合CLS标准的类型uint和ulong。

如另一个答案所建议的那样,您可以使用查找表,其中表中的每个元素都是二进制定点数,以帮助实现复杂函数,例如正弦、余弦、平方根等等。如果查找表比定点数粒度低,建议通过将查找表的粒度的一半加到输入中来四舍五入输入:

// Assume each number has a 12 bit fractional part. (1/4096)
// Each entry in the lookup table corresponds to a fixed point number
//  with an 8-bit fractional part (1/256)
input+=(1<<3); // Add 2^3 for rounding purposes
input>>=4; // Shift right by 4 (to get 8-bit fractional part)
// --- clamp or restrict input here --
// Look up value.
return lookupTable[input];

6
你应该把这个上传到开源代码项目网站,比如sourceforge或者github。这样可以更容易找到,更容易贡献,更容易放在你的简历上等等。另外,一些源代码提示(随意忽略):使用“const”代替“static”来声明常量,以便编译器能够优化它们;首选成员函数而不是静态函数(这样我们可以调用例如“myDouble.LeadingZeros()”,而不是“IntDouble.LeadingZeros(myDouble)”);尽量避免使用单个字母命名变量(例如,“MultiplyAnyLength”有9个字母,使其非常难以理解)。 - BlueRaja - Danny Pflughoeft
1
使用unchecked和非CLS兼容类型(如ulonguint等)时要小心,因为它们很少使用,JIT不会像正常类型(如longint)那样进行积极优化,因此使用它们实际上可能比使用普通类型更慢。此外,C#具有运算符重载,这个项目将从中受益匪浅。最后,是否有任何相关的单元测试?除了这些小事情之外,Peter做得非常好,这太令人印象深刻了! - BlueRaja - Danny Pflughoeft
感谢您的评论。我确实对代码进行单元测试。它们相当广泛,但现在还不太适合发布。我甚至编写了单元测试辅助程序,以使编写多个测试更容易。目前我不使用重载运算符,因为我计划完成后将代码转换为Java。 - Peter O.
2
有趣的是,当我在你的博客上发布帖子时,我没有注意到那个博客属于你。我只是决定尝试一下 Google+,而在它的 C# 火花中建议了那篇博客文章。所以我想,“我们两个同时开始写这样的东西真是一个非凡的巧合”。但当然我们有相同的触发器 :) - CodesInChaos
2
为什么要将它移植到Java?Java已经通过strictfp提供了确定性浮点数运算的保证。 - Antimony

9

这对C#来说是个问题吗?

是的。不同的架构并不是最大的问题,不同的帧率等可能会导致偏差,因为浮点表示中存在不准确性 - 即使它们是相同的不准确性(例如,相同的架构,但一台机器上的GPU速度较慢)。

我可以使用System.Decimal吗?

你当然可以,但它非常慢。

有没有办法强制我的程序以双精度运行?

有。你可以自己托管CLR运行时;在调用CorBindToRuntimeEx之前,将所有必要的调用/标志(更改浮点算术行为的)编译到C++应用程序中。

是否有任何库可以帮助保持浮点计算一致性?

我不知道有没有这样的库。

还有其他解决方法吗?

我曾经解决过这个问题,想法是使用QNumbers。它们是一种固定点实数,但不是十进制(基于10)的固定点 - 而是二进制(基于2)的固定点;因此,对它们的数学原语(加、减、乘、除)比朴素的十进制固定点要快得多;特别是如果n对于两个值都是相同的(在你的情况下是这样)。此外,因为它们是整数,所以它们在每个平台上都有明确定义的结果。

请记住,帧率仍然会影响它们,但它并不像之前那么糟糕,并且可以使用同步点轻松纠正。

我可以在QNumbers中使用更多的数学函数吗?

可以,将十进制转换为QNumbers即可。此外,你应该真正使用查找表来计算三角函数(sin、cos);因为这些函数在不同的平台上可能会产生非常不同的结果 - 如果编写正确,它们可以直接使用QNumbers。


4
不确定你所说的帧率是什么问题。显然,您希望有一个固定的更新速率(例如在此处查看http://gamedev.stackexchange.com/questions/15192),无论是否与显示帧率相同都不重要。只要所有机器上的不精确性相同,就可以了。我完全不理解你的第三个答案。 - BlueRaja - Danny Pflughoeft
@BlueRaja:问题“有没有办法强制我的程序以双精度运行?”要么是重新实现整个公共语言运行时,这将非常复杂,要么是从C#应用程序中使用对C++ DLL的本机调用,如用户shelleybutterfly的答案所示。 将“QNumbers”仅视为二进制定点数,如我在答案中所示(直到现在我才看到二进制定点数被称为“QNumbers”)。 - Peter O.
@Pieter O. 你不需要重新实现运行时。我公司所使用的服务器将CLR运行时作为本地C++应用程序托管(SQL Server也是如此)。我建议你搜索CorBindToRuntimeEx。 - Jonathan Dickinson
@BlueRaja 这取决于具体的游戏。将固定帧率步长应用于所有游戏并不是可行的选择,因为AOE算法会引入人为延迟,这在FPS等游戏中是不可接受的。 - Jonathan Dickinson
1
@Jonathan:这只是在仅发送输入的点对点游戏中存在的问题 - 对于这些游戏,你必须有一个固定的更新速率。大多数FPS游戏不是这样工作的,但是少数必须具有固定的更新速率。请参考此问题 - BlueRaja - Danny Pflughoeft
@BlueRaja 谢谢你的回复。非常有启发性 - 我仍然认为在固定时间步长的情况下,定点数可以是重要的(但不那么重要),因为毕竟你要听从操作系统调度程序的命令(但再次强调,这也不是*那么糟糕)。 - Jonathan Dickinson

6
根据这篇稍旧的MSDN博客文章,JIT不会使用SSE / SSE2进行浮点运算,而是全部使用x87。因此,正如您所提到的,您必须担心模式和标志,在C#中无法控制。因此,使用普通浮点运算将不能保证在每台计算机上都能获得完全相同的结果。
为了精确重现双精度,您需要进行软件浮点(或定点)仿真。我不知道有哪些C#库可以实现此功能。
根据您需要的操作,您可能可以使用单精度来解决问题。以下是思路:
- 在单精度下存储所有关心的值。 - 执行操作: - 将输入扩展为双精度。 - 使用双精度执行操作。 - 将结果转换回单精度。
x87的主要问题在于,计算可能以53位或64位的精度进行,具体取决于精度标志和寄存器是否溢出到内存。但对于许多操作,以高精度执行操作并将其舍入回低精度将保证正确答案,这意味着答案在所有系统上都保证相同。无论您是否获得了额外的精度,都不要紧,因为您有足够的精度来保证在任何情况下都能获得正确的答案。
此方案应适用于以下操作:加法,减法,乘法,除法,sqrt。像sin,exp等操作将无法工作(结果通常匹配但不能保证)。“当双重舍入无害时?”ACM引用(需要付费注册) 希望这可以帮助到您!

2
这也是一个问题,即.NET 5、6或42可能不再使用x87计算模式。标准中没有要求使用它。 - Eric J.

5
如其他答案所述: 即使是在纯Windows环境下,这也是C#中的一个问题。
至于解决方案: 如果您使用内置的BigInteger类并将所有计算缩放到定义的精度(通过对任何此类数字的计算/存储使用公共分母)中,则可以减少(并付出一些努力/性能损失)完全避免该问题。
根据OP的要求 - 关于性能: System.Decimal表示具有1位符号和96位整数以及“比例”(表示小数点位置)的数字。对于您进行的所有计算,它必须在此数据结构上操作,并且不能使用CPU内置的任何浮点指令。
BigInteger“解决方案”做了类似的事情-只是您可以定义需要/想要多少位数字...也许您只需要80位或240位的精度。
缓慢性总是来自必须通过仅使用整数指令模拟所有这些数字上的操作而不使用CPU/FPU内置指令,从而导致每个数学运算的更多指令。
为了减少性能损失,有几种策略-例如QNumbers(请参见Jonathan Dickinson的答案-C#中的浮点数学一致吗?可以吗?)和/或缓存(例如三角计算...)等。

1
请注意,BigInteger 仅适用于 .Net 4.0。 - svick
我猜测 BigInteger 的性能损失甚至超过了 Decimal 的性能损失。 - CodesInChaos
在这里的一些答案中,有几次提到使用Decimal(@Jonathan Dickinson - 'dog slow')或BigInteger(@CodeInChaos上面的评论)会影响性能 - 请问是否可以提供一些关于这些性能问题的解释,并说明它们是否真的是阻止提供解决方案的关键因素。 - Barry Kaye
@Yahia - 感谢您的编辑 - 非常有趣的阅读,但是,您能否给出一个大致估计,如果不使用“float”,性能会受到多大影响?我们是在说比使用“float”慢10%还是慢10倍 - 我只是想了解所涉及的数量级。 - Barry Kaye
它更可能是1:5的比例,而不是“仅有10%”。 - Yahia

2

这里是我第一次尝试“如何做到这一点”的方法:

  1. 创建一个ATL.dll项目,其中包含一个简单的对象,用于执行重要的浮点操作。确保使用禁用任何非xx87硬件进行浮点运算的标志进行编译。
  2. 创建调用浮点运算并返回结果的函数;从简单的开始,如果对您有效,您可以随时增加复杂性以满足您的性能需求。
  3. 在实际数学周围放置control_fp调用,以确保在所有计算机上执行相同的操作。
  4. 引用您的新库并测试以确保其按预期工作。

(我认为您只需编译为32位.dll,然后可以在x86或AnyCpu上使用它[或者在64位系统上仅针对x86];请参见下面的注释。)

那么,假设它可行,如果您想使用Mono,我想您应该能够以类似的方式在其他x86平台上复制该库(当然不是COM;虽然,也许可以用wine?但是这方面超出了我的范围……)。

假设您可以使其正常工作,您应该能够设置自定义函数,可以同时执行多个操作以修复任何性能问题,并且您将拥有浮点数学,从而可以跨平台获得一致的结果,仅用少量的C++代码编写,同时保留其余代码用C#。


编译成32位的.dll文件,然后使用...AnyCpu。我认为这只能在32位系统上运行。在64位系统上,只有针对“x86”的程序才能加载32位的dll文件。 - CodesInChaos

2

虽然我不是游戏开发者,但我有很多处理计算难题的经验...所以我会尽力而为。

我的策略基本上是这样的:

  • 使用较慢(如果必要的话;如果有更快的方法,那就太好了!)但可预测的方法来获得可重复的结果
  • 对其他所有内容都使用双倍精度(例如,渲染)

简而言之,你需要找到平衡点。如果你花费30毫秒进行渲染(约33帧每秒),而只花费1毫秒进行碰撞检测(或插入其他高度敏感的操作)——即使你将关键算术的时间增加三倍,对帧速率的影响也只是从33.3fps降至30.3fps。

我建议你对所有事情进行分析,计算出每个显著昂贵计算所需的时间,然后使用一种或多种解决此问题的方法重复测量并查看影响。


1
检查其他答案中的链接可以清楚地表明,您永远无法保证浮点数是否被“正确”实现,或者对于给定的计算,您是否始终会获得一定的精度,但也许您可以尽力而为,方法是(1)将所有计算截断到一个公共最小值(例如,如果不同的实现将为您提供32到80位的精度,则始终将每个操作截断到30或31位),(2)在启动时拥有一些测试用例的表格(加、减、乘、除、平方根、余弦等边界情况),如果实现计算出与表格匹配的值,则不必进行任何调整。

“始终将每个操作截断为30或31位” - 这正是x86机器上的float数据类型所做的 - 然而,这将导致与仅使用32位进行所有计算的机器略有不同的结果,并且这些小变化会随着时间的推移而传播。 因此,产生了这个问题。 - BlueRaja - Danny Pflughoeft
如果“N位精度”意味着任何计算都准确到那么多位,且机器A的精度为32位,而机器B的精度为48位,则两台机器进行的任何计算的前32位应该是相同的。在每次操作后将结果截断为32位或更少是否可以使两台机器保持完全同步?如果不行,有什么例子吗? - Witness Protection ID 44583292

-3

你的问题相当复杂和技术性 O_o。不过我或许能提供一些想法。

你肯定知道 CPU 在进行任何浮点运算后会进行一些调整,而 CPU 提供了几个不同的指令来进行不同的舍入操作。

因此对于一个表达式,你的编译器将选择一组指令来得到一个结果。但是如果有其他指令流程,即使它们意图计算相同的表达式,也可能提供另一个结果。

由舍入调整造成的“错误”会在每个进一步的指令中增加。

例如,在汇编级别上,a * b * c 与 a * c * b 是不等价的。

我并不完全确定这一点,你需要找一个比我更了解 CPU 架构的人 :p

不过要回答你的问题:在 C 或 C++ 中,你可以解决你的问题,因为你有一些控制你的编译器生成机器码的能力,但是在 .NET 中,你没有任何控制。因此只要你的机器码可能不同,你就永远无法确定确切的结果。

我很好奇这可能会成为一个问题的方式,因为变化似乎非常小,但如果您需要真正准确的操作,我能想到的唯一解决方案将是增加您的浮点寄存器的大小。 如果可以,请使用双精度或甚至长双精度(不确定在CLI中是否可行)。

我希望我表达得足够清楚,我的英语并不完美(...根本不是 : s)


9
想象一款点对点射击游戏。你开枪打了一个人,你命中了他,他死了,但是这非常接近,你几乎没打中。然而在另一个人的电脑上使用了略微不同的计算,它认为你没有命中。现在你看到问题了吗?在这种情况下,增加寄存器的大小是无济于事的(至少不完全有效)。在每台电脑上使用完全相同的计算方法则可以解决这个问题。 - svick
5
在这种情况下,通常并不关心结果有多接近真实结果(只要在合理范围内),但重要的是所有用户得到的结果都是完全相同的。 - CodesInChaos
1
你说得对,我没有考虑到这种情况。然而,在这一点上,我同意@CodeInChaos的看法。我认为重要的决定不应该被做两次,这更像是一个软件架构问题。例如,射手应用程序应该进行计算并将结果发送给其他程序。这样就不会出现错误。你只有一个命中或未命中,但只有一个人做出决定。就像@driushkin所说的那样。 - AxFab
3
@Aesgar:是的,大多数射击游戏都是这样工作的;那个“权威”被称为服务器,我们把整体架构称为“客户端/服务器”架构。然而,还有另一种架构:点对点(P2P)。在P2P中,没有服务器;相反,所有客户端在任何操作发生之前都必须互相验证。这会增加延迟,使其不适用于射击游戏,但大大降低了网络流量,使其非常适合需要小延迟(约250毫秒)可接受,但同步整个游戏状态不可行的游戏,如C&C和星际争霸。 - BlueRaja - Danny Pflughoeft
5
在P2P游戏中,你无法依赖可信赖的机器。如果你允许一个站点决定他的子弹是否击中,你就打开了客户端作弊的可能性。此外,有时候链接甚至无法处理结果产生的大量数据——游戏是通过发送指令而非结果来工作的。我玩实时战略游戏,很多时候我看到很多垃圾在游戏中飞来飞去,这些数据根本无法通过普通家用上行链接发送。 - Loren Pechtel
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接