正如其他人在 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 寄存器中,然后将xmm0 和xmm1 中的两个单精度值相乘(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。