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 发现它稍后需要对浮点数进行装箱时,就会放弃优化。

我搜索了一下,但找不到这种行为的原因。任何人都可以提示我吗?

4

4 回答 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 

Note the difference at address 15 vs address 17+1a, the first loop keeps the intermediate result in the FPU. The second loop stores it back to the @float local variable. While it stays inside the FPU, the result is calculated with full precision. Storing it back however truncates the intermediate result back to a float, losing lots of bits of precision in the process.

While unpleasant, I don't believe this is a bug. The x64 JIT compiler behaves differently yet. You can make your case at connect.microsoft.com

于 2010-02-08T23:26:26.337 回答
6

FYI, the C# spec notes that this behaviour is legal and common. See these questions for more details and simmilar scenarios:

于 2010-02-27T02:11:21.130 回答
4

你是在英特尔处理器上运行的吗?

一种理论是,允许将 JIT@float完全累积在浮点寄存器中,这将是完整的 80 位精度。这样计算可以足够准确。

代码的第二个版本不完全适合寄存器,因此@float必须“溢出”到内存中,这会导致 80 位值向下舍入为单精度,从而给出单精度算术预期的结果。

但这只是一个非常随机的猜测。必须检查 JIT 编译器生成的实际机器代码(打开反汇编视图进行调试)。

编辑:

嗯...我在本地测试了您的代码(英特尔酷睿 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 位机器。

于 2010-02-08T23:04:47.580 回答
2

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

于 2010-02-08T23:06:13.707 回答