float算术和x86和x64上下文

时间:2016-12-19 15:16:02

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

我们在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;

这段代码可靠吗?

1 个答案:

答案 0 :(得分:3)

正如其他人在评论中推测的那样,你所观察到的差异是进行浮点算术时差分精度的结果,这是由于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寄存器中,然后将mulss中的两个单精度值相乘{{1 }和xmm0在一起。这是您的xmm1代码的字面翻译。此操作的结果(最终位于val = val * val)为0x49742400,即1.0e + 6,正如您所期望的那样。

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

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

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

虽然我一直暗示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

如果您使用xmm0类型开头,则不会出现此问题:

double

这确保了所有体系结构的正确结果,而不需要像问题中建议的那样丑陋,不可移植的位操作黑客。 (这仍然不能确保正确的结果,因为它没有解决问题的根源,即你想要的结果不能直接用32位单精度double val = 1000.0; val = val * val; return (ulong)(val * 100000.0); 来表示。)

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