【问题标题】:float arithmetic and x86 and x64 context浮点算术和 x86 和 x64 上下文
【发布时间】:2016-12-19 15:16:01
【问题描述】:

我们在 VisualStudio 进程上下文(x86 上下文)和 VisualStudio 上下文(x64 上下文)之外运行一些代码。我注意到以下代码在两种上下文中都提供了不同的结果(x86 中的 100000000000 和 x64 中的 99999997952)

float val = 1000f;
val = val * val;
return (ulong)(val * 100000.0f);

我们需要以可靠的方式从float值中获取ulong值,无论上下文和ulong值如何,都只是为了哈希目的。我在 x64 和 x86 上下文中测试了这段代码,确实得到了相同的结果,看起来很可靠:

float operandFloat = (float)obj;
byte[] bytes = BitConverter.GetBytes(operandFloat);
Debug.Assert(bytes.Length == 4);
uint @uint = BitConverter.ToUInt32(bytes, 0);
return (ulong)@uint;

这段代码可靠吗?

【问题讨论】:

  • 视情况而定。您是否希望它也适用于 x86[-64] 以外的其他架构?即使您将兼容性限制为 ieee754 float,由于qNAN/sNAN 编码差异,您仍然不会获得完美的可移植性。
  • 谢谢,我认为我们可以忍受各种架构差异,关于 Nan 会为 Nan 分配一个特殊的 ulong 值会起作用吗?
  • 恐怕我听不懂你的评论。
  • @MargaretBloom 这个问题可能是由于 32 位变体使用了可恶的 x87 FPU,带来了带来的所有痛苦。
  • 附带说明(我在汇编级别调试代码),x86 代码使用 FPU,而 x64 代码使用 XMM。将 1E+11(XMM0 = 51BA43B7) 转换为 (XMM0 = 42374876E0000000) 即 99999997952.0 时,该错误发生在 cvtss2sd xmm0,xmm0

标签: c# .net x86 x86-64 floating-accuracy


【解决方案1】:

正如其他人在 cmets 中所推测的那样,您观察到的差异是执行浮点运算时的差分精度的结果,这是由于 32 位和 64 位构建执行这些操作的方式不同而引起的。

您的代码由 32 位 (x86) JIT 编译器翻译成以下目标代码:

fld   qword ptr ds:[0E63308h]  ; Load constant 1.0e+11 onto top of FPU stack.
sub   esp, 8                   ; Allocate 8 bytes of stack space.
fstp  qword ptr [esp]          ; Pop top of FPU stack, putting 1.0e+11 into
                               ;  the allocated stack space at [esp].
call  73792C70                 ; Call internal helper method that converts the
                               ;  double-precision floating-point value stored at [esp]
                               ;  into a 64-bit integer, and returns it in edx:eax.
                               ; At this point, edx:eax == 100000000000.

请注意,优化器已将您的算术计算 ((1000f * 1000f) * 100000f) 折叠为常数 1.0e+11。它已将此常量存储在二进制数据段中,并将其加载到 x87 浮点堆栈的顶部(fld 指令)。然后,代码通过sub跟踪堆栈指针 (esp) 分配 8 个字节的堆栈空间(足够用于 64 位双精度浮点值)。 fstp 指令将值从 x87 浮点堆栈的顶部弹出,并将其存储在其内存操作数中。在这种情况下,它将它存储到我们刚刚在堆栈上分配的 8 个字节中。所有这些改组都是毫无意义的:它可能只是将浮点常量 1.0e+11 直接加载到内存中,绕过 x87 FPU 的行程,但 JIT 优化器并不完美。最后,JIT 发出代码调用内部辅助函数,该函数将存储在内存 (1.0e+11) 中的双精度浮点值转换为 64 位整数。 64 位整数结果在寄存器对edx:eax 中返回,这是 32 位 Windows 调用约定的惯例。当此代码完成时,edx:eax 包含 64 位整数值 100000000000,即 1.0e+11,与您预期的完全一样。

(希望这里的术语不会太混乱。请注意,有两个不同的“堆栈”。x87 FPU 有一系列寄存器,它们像堆栈一样被访问。我指的是这个作为 FPU 堆栈。然后是您可能熟悉的堆栈,它存储在主内存中并通过堆栈指针访问,esp。)


但是,64 位 (x86-64) JIT 编译器的处理方式略有不同。这里最大的区别在于 64 位目标始终使用 SSE2 指令进行浮点运算,因为所有支持 AMD64 的芯片也支持 SSE2,并且 SSE2 比旧的 x87 FPU 更高效、更灵活。具体来说,64 位 JIT 会将您的代码转换为以下内容:

movsd  xmm0, mmword ptr [7FFF7B1A44D8h]  ; Load constant into XMM0 register.
call   00007FFFDAC253B0                  ; Call internal helper method that converts the
                                         ;  floating-point value in XMM0 into a 64-bit int
                                         ;  that is returned in RAX.

这里马上就出错了,因为第一条指令加载的常量值是 0x42374876E0000000,这是 99999997952.0 的二进制浮点表示。问题是 不是 正在转换为 64 位整数的辅助函数。相反,它是 JIT 编译器本身,特别是优化器例程,它正在预先计算常量。

为了深入了解它是如何出错的,我们将关闭 JIT 优化并查看代码的样子:

movss    xmm0, dword ptr [7FFF7B1A4500h]  
movss    dword ptr [rbp-4], xmm0  
movss    xmm0, dword ptr [rbp-4]  
movss    xmm1, dword ptr [rbp-4]  
mulss    xmm0, xmm1  
mulss    xmm0, dword ptr [7FFF7B1A4504h]  
cvtss2sd xmm0, xmm0  
call     00007FFFDAC253B0 

第一条movss 指令将一个单精度浮点常量从内存加载到xmm0 寄存器中。然而这一次,这个常数是 0x447A0000,它是 1000 的精确二进制表示——代码中的初始 float 值。

第二条movss 指令右转并将xmm0 寄存器中的值存储到内存中,第三条movss 指令重新加载刚刚存储的值从内存中返回进入xmm0 寄存器。 (告诉你这是未优化的代码!)它还将相同值的第二个副本从内存加载到xmm1 寄存器中,然后将xmm0xmm1 中的两个单精度值相乘(mulss)一起。这是您的val = val * val 代码的直译。此操作的结果(以 xmm0 结尾)是 0x49742400,即 1.0e+6,正如您所期望的那样。

第二条mulss 指令执行val * 100000.0f 操作。它隐式加载单精度浮点常数 1.0e+5 并将其与 xmm0 中的值相乘(回想一下,它是 1.0e+6)。不幸的是,此操作的结果不是您所期望的。而不是 1.0e+11,实际上是 9.9999998e+10。为什么?因为 1.0e+11 不能精确地表示为单精度浮点值。最接近的表示是 0x51BA43B7,或 9.9999998e+10。

最后,cvtss2sd 指令将xmm0 中的(错误!)标量单精度浮点值就地转换为标量双精度浮点值。在对该问题的评论中,Neitsa 建议这可能是问题的根源。事实上,正如我们所见,问题的根源在于 previous 指令,即执行乘法的指令。 cvtss2sd 只是将已经不精确的单精度浮点表示 (0x51BA43B7) 转换为不精确的双精度浮点表示:0x42374876E0000000 或 99999997952.0。

这正是 JIT 编译器执行的一系列操作,以生成初始双精度浮点常量,该常量被加载到优化代码中的 xmm0 寄存器中。

尽管我在整个答案中一直暗示 JIT 编译器是罪魁祸首,但事实并非如此!如果您在以 SSE2 指令集为目标时用 C 或 C++ 编译了相同的代码,您将得到完全相同的不精确结果:99999997952.0。 JIT 编译器的性能正如人们所期望的那样——也就是说,如果人们的期望被正确校准为浮点运算的不精确性!


那么,这个故事的寓意是什么?其中有两个。首先,浮点运算很棘手,there is a lot to know about them。其次,鉴于此,在进行浮点运算时始终使用可用的最高精度

32 位代码产生了正确的结果,因为它使用的是双精度浮点值。使用 64 位,1.0e+11 的精确表示是可能的。

64 位代码产生了不正确的结果,因为它使用的是单精度浮点值。由于只有 32 位可供使用,1.0e+11 的精确表示是不可能的。

如果您使用 double 类型开头就不会遇到这个问题:

double val = 1000.0;
val = val * val;
return (ulong)(val * 100000.0);

这确保了所有架构上的正确结果,不需要像问题中建议的那样丑陋、不可移植的位操作黑客。 (这仍然不能确保正确的结果,因为它没有解决问题的根源,即您想要的结果不能直接用 32 位单精度 float 表示。)

即使您必须将输入作为单精度 float,也应立即将其转换为 double,然后在双精度空间中执行所有后续算术操作。这仍然可以解决这个问题,因为初始值 1000 可以精确地表示为 float

【讨论】:

  • 不错的答案。我想知道这是否是 JIT/编译器的错误。我最近没有仔细阅读 C# 语言规范,但我发现在 x86 情况下,当没有可以表示该值的浮点数时,结果是 10e11 让我感到不舒服。这就像 JIT 编译器在内部使用双精度来折叠操作。
  • @margaret 这正是它所做的。也不是错误。 C# 规范明确允许以比要求更高的精度执行浮点运算。请参见第 4.1.6 节,“浮点类型”。 Here is an easily linkable version。具体来说,底部段落:“浮点运算可能以比运算结果类型更高的精度执行......” 如果我推测,我会说这是添加的正是因为 x87 FPU 的 80 位扩展精度才符合规范。 :-)
猜你喜欢
  • 2014-05-07
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2013-05-15
  • 2012-03-08
  • 2011-12-03
相关资源
最近更新 更多