我们在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;
这段代码可靠吗?
答案 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
。