CLR JIT 优化违反因果关系?

14
我写了一个示例来教导我的同事为什么测试浮点数的相等性通常是不好的。我选择的示例是将0.1加十次,然后与1.0进行比较(这是我在入门数值课程中学到的)。我很惊讶地发现这两个结果是相等的(代码+输出)。
float @float = 0.0f;
for(int @int = 0; @int < 10; @int += 1)
{
    @float += 0.1f;
}
Console.WriteLine(@float == 1.0f);

经过一些调查,发现这个结果不能够被依赖(类似于浮点数相等性)。我认为最令人惊讶的是,在另外一个代码之后添加代码可以改变计算结果 (代码和输出)。请注意,这个示例有完全相同的代码和IL,只是多了一行C#代码。

float @float = 0.0f;
for(int @int = 0; @int < 10; @int += 1)
{
    @float += 0.1f;
}
Console.WriteLine(@float == 1.0f);
Console.WriteLine(@float.ToString("G9"));

我知道在浮点数上使用等号是不对的,因此不应该太在意这个问题,但我发现这个问题相当令人惊讶,我的朋友们也都觉得很奇怪。在执行计算后进行其他操作会改变前面计算的值吗?我认为这不是人们通常心中的计算模型。

我并没有完全被难住,似乎可以安全地假设在“相等”情况下发生了某种优化,从而改变了计算的结果(在调试模式下构建可以避免“相等”情况)。显然,当CLR发现它稍后需要将float装箱时,就会放弃这种优化。

我搜索了一下,但找不到这种行为的原因。有人能给我指点一下吗?


还有一点有趣的地方:如果我直接复制您的第一个示例,没有做任何修改,运行后结果跟您初始看到的不一样了。 - fuglede
4个回答

18
这是JIT优化器工作方式的一个副作用。如果要生成的代码较少,则它将执行更多的工作。原始片段中的循环被编译为以下内容:
                @float += 0.1f;
0000000f  fld         dword ptr ds:[0025156Ch]          ; push(intermediate), st0 = 0.1
00000015  faddp       st(1),st                          ; st0 = st0 + st1
            for (int @int = 0; @int < 10; @int += 1) {
00000017  inc         eax  
00000018  cmp         eax,0Ah 
0000001b  jl          0000000F 

当你添加额外的Console.WriteLine()语句时,它会将其编译成这样:

                @float += 0.1f;
00000011  fld         dword ptr ds:[00961594h]          ; st0 = 0.1
00000017  fadd        dword ptr [ebp-8]                 ; st0 = st0 + @float
0000001a  fstp        dword ptr [ebp-8]                 ; @float = st0
            for (int @int = 0; @int < 10; @int += 1) {
0000001d  inc         eax  
0000001e  cmp         eax,0Ah 
00000021  jl          00000011 
请注意地址15与地址17+1a之间的差异,第一个循环将中间结果保留在FPU中。第二个循环将其存回@float本地变量。当它在FPU中时,结果可以使用完全精度计算。然而,将其存回会将中间结果截断为一个浮点数,这样会丢失很多位精度。
虽然不太令人愉快,但我认为这不是一个bug。x64 JIT编译器的行为也不同。您可以在connect.microsoft.com上提出您的建议。

好的回答。我也不会称其为一个 bug。更像是一种不寻常(且绝对意料之外)的副作用。 - Gobiner

6

4
你在Intel处理器上运行了吗?
有一种理论是,JIT允许@float完全在浮点寄存器中累积,这将是完整的80位精度。这样计算就足够准确了。
代码的第二个版本没有完全适合寄存器,因此@float必须被“溢出”到内存中,这会导致80位值向下舍入为单精度,从而得到单精度算术预期的结果。
但这只是一个非常随机的猜测。必须检查JIT编译器生成的实际机器代码(通过打开反汇编视图进行调试)。
编辑:
嗯......我在本地测试了你的代码(Intel Core 2,Windows 7 x64,64位CLR),我总是得到了“预期”的舍入误差。无论是在发布还是调试配置中。
以下是Visual Studio在我的机器上显示的第一个代码片段的反汇编:
xorps       xmm0,xmm0 
movss       dword ptr [rsp+20h],xmm0 
        for (int @int = 0; @int < 10; @int += 1)
mov         dword ptr [rsp+24h],0 
jmp         0000000000000061 
        {
            @float += 0.1f;
movss       xmm0,dword ptr [000000A0h] 
addss       xmm0,dword ptr [rsp+20h] 
movss       dword ptr [rsp+20h],xmm0 // <-- @float gets stored in memory
        for (int @int = 0; @int < 10; @int += 1)
mov         eax,dword ptr [rsp+24h] 
add         eax,1 
mov         dword ptr [rsp+24h],eax 
cmp         dword ptr [rsp+24h],0Ah 
jl          0000000000000042 
        }
        Console.WriteLine(@float == 1.0f);
etc.

x64和x86 JIT编译器之间存在差异,但我无法访问32位机器。


我肯定已经在Intel处理器上运行过它。虽然我不知道dotnetpad是否可以在Intel处理器上运行。 - Gobiner

2

我的理论是,如果没有 ToString 行,编译器能够将函数静态优化为单个值,并且它以某种方式补偿了浮点误差。但是,当添加 ToString 行时,优化器必须以不同的方式处理浮点数,因为这是方法调用所要求的。这只是一个猜测。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接