为啥发布和调试模式下的代码行为不同?

Posted

技术标签:

【中文标题】为啥发布和调试模式下的代码行为不同?【英文标题】:Why is code behavior different in release & debug mode?为什么发布和调试模式下的代码行为不同? 【发布时间】:2017-12-01 10:57:34 【问题描述】:

考虑以下代码:

private static void Main(string[] args)

    var ar = new double[]
    
        100
    ;

    FillTo(ref ar, 5);
    Console.WriteLine(string.Join(",", ar.Select(a => a.ToString()).ToArray()));


public static void FillTo(ref double[] dd, int N)

    if (dd.Length >= N)
        return;

    double[] Old = dd;
    double d = double.NaN;
    if (Old.Length > 0)
        d = Old[0];

    dd = new double[N];

    for (int i = 0; i < Old.Length; i++)
    
        dd[N - Old.Length + i] = Old[i];
    
    for (int i = 0; i < N - Old.Length; i++)
        dd[i] = d;

Debug 模式下的结果是:100,100,100,100,100。 但在发布模式下是:100,100,100,100,0。

发生了什么?

已使用 .NET Framework 4.7.1 和 .NET Core 2.0.0 进行了测试。

【问题讨论】:

您使用哪个版本的 Visual Studio(或编译器)? 复制;将Console.WriteLine(i); 添加到最终循环 (dd[i] = d;) 中“修复”它,这表明存在编译器错误或 JIT 错误;调查 IL... @Styxxy,在 vs2015、2017 上测试并针对每个 .net 框架 >= 4.5 绝对是一个错误。如果您删除 if (dd.Length &gt;= N) return;,它也会消失,这可能是一个更简单的复制。 毫不奇怪,一旦比较是苹果对苹果,.Net Framework 和 .Net Core 的 x64 代码生成具有相似的性能,因为(默认情况下)它本质上是相同的 jit 生成代码。将 .Net Framework x86 codegen 的性能与 .Net Core 的 x86 codegen(自 2.0 开始使用 RyuJit)进行比较会很有趣。在某些情况下,旧的 jit(又名 Jit32)知道 RyuJit 不知道的一些技巧。如果您发现任何此类情况,请确保在 CoreCLR 存储库上为他们打开问题。 【参考方案1】:

这似乎是一个 JIT 错误;我已经测试过:

// ... existing code unchanged
for (int i = 0; i < N - Old.Length; i++)

    // Console.WriteLine(i); // <== comment/uncomment this line
    dd[i] = d;

并添加 Console.WriteLine(i) 修复它。唯一的 IL 变化是:

// ...
L_0040: ldc.i4.0 
L_0041: stloc.3 
L_0042: br.s L_004d
L_0044: ldarg.0 
L_0045: ldind.ref 
L_0046: ldloc.3 
L_0047: ldloc.1 
L_0048: stelem.r8 
L_0049: ldloc.3 
L_004a: ldc.i4.1 
L_004b: add 
L_004c: stloc.3 
L_004d: ldloc.3 
L_004e: ldarg.1 
L_004f: ldloc.0 
L_0050: ldlen 
L_0051: conv.i4 
L_0052: sub 
L_0053: blt.s L_0044
L_0055: ret 

// ...
L_0040: ldc.i4.0 
L_0041: stloc.3 
L_0042: br.s L_0053
L_0044: ldloc.3 
L_0045: call void [System.Console]System.Console::WriteLine(int32)
L_004a: ldarg.0 
L_004b: ldind.ref 
L_004c: ldloc.3 
L_004d: ldloc.1 
L_004e: stelem.r8 
L_004f: ldloc.3 
L_0050: ldc.i4.1 
L_0051: add 
L_0052: stloc.3 
L_0053: ldloc.3 
L_0054: ldarg.1 
L_0055: ldloc.0 
L_0056: ldlen 
L_0057: conv.i4 
L_0058: sub 
L_0059: blt.s L_0044
L_005b: ret 

看起来完全正确(唯一的区别是额外的ldloc.3call void [System.Console]System.Console::WriteLine(int32),以及br.s 的不同但等效的目标)。

我怀疑它需要 JIT 修复。

环境:

Environment.Version: 4.0.30319.42000 &lt;TargetFramework&gt;netcoreapp2.0&lt;/TargetFramework&gt; VS:15.5.0 预览版 5.0 dotnet --version:2.1.1

【讨论】:

那么到哪里报告bug呢? 我也在 .NET full 4.7.1 上看到它,所以如果这不是 RyuJIT 错误,我会吃掉我的帽子。 我无法重现,安装了 .NET 4.7.1,现在可以重现。 @MarcGravell .Net 框架 4.7.1 和 .net Core 2.0.0 @AshkanNourzadeh 老实说,我可能会记录它here,强调人们认为这是一个 RyuJIT 错误【参考方案2】:

确实是组装错误。 x64,.net 4.7.1,发布版本。

反汇编:

            for(int i = 0; i < N - Old.Length; i++)
00007FF942690ADD  xor         eax,eax  
            for(int i = 0; i < N - Old.Length; i++)
00007FF942690ADF  mov         ebx,esi  
00007FF942690AE1  sub         ebx,ebp  
00007FF942690AE3  test        ebx,ebx  
00007FF942690AE5  jle         00007FF942690AFF  
                dd[i] = d;
00007FF942690AE7  mov         rdx,qword ptr [rdi]  
00007FF942690AEA  cmp         eax,dword ptr [rdx+8]  
00007FF942690AED  jae         00007FF942690B11  
00007FF942690AEF  movsxd      rcx,eax  
00007FF942690AF2  vmovsd      qword ptr [rdx+rcx*8+10h],xmm6  
            for(int i = 0; i < N - Old.Length; i++)
00007FF942690AF9  inc         eax  
00007FF942690AFB  cmp         ebx,eax  
00007FF942690AFD  jg          00007FF942690AE7  
00007FF942690AFF  vmovaps     xmm6,xmmword ptr [rsp+20h]  
00007FF942690B06  add         rsp,30h  
00007FF942690B0A  pop         rbx  
00007FF942690B0B  pop         rbp  
00007FF942690B0C  pop         rsi  
00007FF942690B0D  pop         rdi  
00007FF942690B0E  pop         r14  
00007FF942690B10  ret  

问题出在地址 00007FF942690AFD,jg 00007FF942690AE7。如果 ebx(其中包含 4,循环结束值)大于 (jg),则它会跳回 eax,即值 i。当然,当它是 4 时它会失败,因此它不会写入数组中的最后一个元素。

它失败了,因为它是 i 的寄存器值(eax,在 0x00007FF942690AF9),然后用 4 检查它,但它仍然必须写入该值。很难确定问题的确切位置,因为它看起来可能是 (N-Old.Length) 优化的结果,因为调试版本包含该代码,但发布版本预先计算了这一点。所以这是由 jit 人来解决的 ;)

【讨论】:

这些天我需要抽出一些时间来学习汇编/CPU操作码。也许我天真地一直在想“嗯,我可以读写 IL - 我应该能够理解它” - 但我从来没有解决它:) x64/x86 并不是最棒的汇编语言;) 它有这么多的操作码,我曾经读过没有人知道所有的操作码。不确定这是不是真的,但一开始读起来并不容易。尽管它确实使用了一些简单的约定,例如 []、源部分之前的目标以及这些寄存器的全部含义(al 是 rax 的 8 位部分,eax 是 rax 的 32 位部分等)。您可以在 vs tho 中逐步完成它,这应该会教您一些要点。我相信你会很快学会,因为你已经知道 IL 操作码 ;)

以上是关于为啥发布和调试模式下的代码行为不同?的主要内容,如果未能解决你的问题,请参考以下文章

为啥在调试模式和运行模式下保留计数不同?

为啥 Qt 用户界面在发布模式和调试模式下看起来不同

Flutter(Web)提供程序不在发布模式下工作,但在调试中工作

带有 javascript 调试的 Visual Studio 在直接运行和在调试模式下运行时显示不同的行为

为啥颤振调试应用程序显示“慢速模式”横幅?

如何确定是不是启用了“调试模式”