浮点运算和 x86 和 x64 上下文

float arithmetic and x86 and x64 context

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

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

我们需要以可靠的方式从浮点值中获取一个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;

这个代码可靠吗?

正如其他人在评论中推测的那样,您观察到的差异是进行浮点运算时精度差异的结果,这是由于 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 个字节的堆栈 space(足够 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,正如您所期望的那样。

(希望这里的术语不会太混乱。注意有两个不同的 "stacks"。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 寄存器,然后将 [=23] 中的两个单精度值相乘 (mulss) =] 和 xmm1 在一起。这是您的 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,并在双精度[=106]中进行所有后续算术操作=].那仍然可以解决这个问题,因为初始值 1000 可以精确地表示为 float.