C# 分支语句出现奇怪的行为

4

我正在学习C#,遇到了这个问题:

static int F(int n) 
{
    if (n == 1)
        return 1;
    
    return 1;
}

这会生成预期的结果:
<Program>$.<<Main>$>g__F|0_0(Int32)
    L0000: mov eax, 1
    L0005: ret

正如你所看到的,编译器理解到这个if语句是没有意义的,因此“删除”了它。

现在,让我们尝试添加更多的if语句:

static int G(int n) 
{
    if (n == 1)
        return 1;
    
    if (n == 1)
        return 1;
    
    return 1;
}

现在,这将生成以下ASM代码:

<Program>$.<<Main>$>g__G|0_1(Int32)
    L0000: cmp ecx, 1        ; do we need this?
    L0003: jne short L000b   ; do we need this?
    L0005: mov eax, 1
    L000a: ret
    L000b: mov eax, 1
    L0010: ret

奇怪的是,当您添加“>=5”个分支时,它会再次理解它们是不需要的。
static int H(int n) 
{
    if (n == 1)
        return 1;
    
    if (n == 1)
        return 1;
    
    if (n == 1)
        return 1;
    
    if (n == 1)
        return 1;
    
    if (n == 1)
        return 1;
    
    return 1;
}

输出:

<Program>$.<<Main>$>g__H|0_2(Int32)
    L0000: mov eax, 1
    L0005: ret

问题

  • 第二种情况为什么会生成额外的指令?

注释

  • SharpLab链接,如果您想试玩一下。
  • 这是使用C所生成的GCC(-O2)
int 
f(int n) {
    if (n == 1)
        return 1;
    
    return 1;
}

int 
g(int n) {
    if (n == 1)
        return 1;
    
    if (n == 1)
        return 1;
    
    return 1;
}

int
h(int n) {
    if (n == 1)
        return 1;
    
    if (n == 1)
        return 1;
    
    if (n == 1)
        return 1;
    
    if (n == 1)
        return 1;
    
    if (n == 1)
        return 1;
    
    return 1;
}

生成如下内容:

f:
   mov     eax, 1
    ret
g:
    mov     eax, 1
    ret
h:
    mov     eax, 1
    ret

这里是Godbolt链接。

  • 函数的IL代码:
.method assembly hidebysig static 
        int32 '<<Main>$>g__F|0_0' (
            int32 n
        ) cil managed 
    {
        // Method begins at RVA 0x2052
        // Code size 6 (0x6)
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: ldc.i4.1
        IL_0002: pop
        IL_0003: pop
        IL_0004: ldc.i4.1
        IL_0005: ret
    } // end of method '<Program>$'::'<<Main>$>g__F|0_0'

    .method assembly hidebysig static 
        int32 '<<Main>$>g__G|0_1' (
            int32 n
        ) cil managed 
    {
        // Method begins at RVA 0x2059
        // Code size 12 (0xc)
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: ldc.i4.1
        IL_0002: bne.un.s IL_0006

        IL_0004: ldc.i4.1
        IL_0005: ret

        IL_0006: ldarg.0
        IL_0007: ldc.i4.1
        IL_0008: pop
        IL_0009: pop
        IL_000a: ldc.i4.1
        IL_000b: ret
    } // end of method '<Program>$'::'<<Main>$>g__G|0_1'

    .method assembly hidebysig static 
        int32 '<<Main>$>g__H|0_2' (
            int32 n
        ) cil managed 
    {
        // Method begins at RVA 0x2066
        // Code size 30 (0x1e)
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: ldc.i4.1
        IL_0002: bne.un.s IL_0006

        IL_0004: ldc.i4.1
        IL_0005: ret

        IL_0006: ldarg.0
        IL_0007: ldc.i4.1
        IL_0008: bne.un.s IL_000c

        IL_000a: ldc.i4.1
        IL_000b: ret

        IL_000c: ldarg.0
        IL_000d: ldc.i4.1
        IL_000e: bne.un.s IL_0012

        IL_0010: ldc.i4.1
        IL_0011: ret

        IL_0012: ldarg.0
        IL_0013: ldc.i4.1
        IL_0014: bne.un.s IL_0018

        IL_0016: ldc.i4.1
        IL_0017: ret

        IL_0018: ldarg.0
        IL_0019: ldc.i4.1
        IL_001a: pop
        IL_001b: pop
        IL_001c: ldc.i4.1
        IL_001d: ret
    } // end of method '<Program>$'::'<<Main>$>g__H|0_2'

2
我不会期望有一个非常有趣的原因。答案可能是“因为JIT编译器的这个特定实现细节:(在此处插入实现细节)”。我相当肯定5只是一个神奇数字。 - Sweeper
是的,我认为这背后肯定有原因(也许他们是故意这么做的,但可能并没有什么理由吧?)。 - user12722843
很有趣的是了解每种情况下的IL代码是什么样子。也就是说,C#编译器是否真正进行了优化(或者没有进行优化),还是由JIT来完成的。 - PMF
@PMF 在那里添加了 IL 代码。 - user12722843
1
奇怪,我本来以为IL代码已经被优化了。但显然并没有。 - PMF
2个回答

3
通常情况下,JIT中会出现另一种被忽略的优化。如果这在实际应用中出现,请向使用的JIT开发人员(可能是Microsoft)报告此问题。但很可能他们故意调整了JIT,因为真正代码中有这样无用的if语句并不常见。当然会发生,但大多数if语句都不是无用的。
总的来说,对于你之前提出的问题(关于C编译器可以找到但C#不能的优化), JIT必须快速编译,没有时间去寻找更多的优化,所以你应该期望像这样的糟糕结果。
为什么是5?编译器通常对代码大小或分支数等使用启发式技术来做出决策,也许在这种情况下是根据分支路径之间是否存在某些共性来进行决策。在你的情况下,5个if语句足以超过某些启发式阈值。如果你正在调查一个开源的JIT,你可以挖掘出具体在哪个源代码中做出了决策。
特别是对于一个JIT,其中编译时间直接与优化质量交换时,跳过检查这一点可能是有意义的。但这对于预编译的C编译器来说是不合适的;如果你告诉它们去优化,它们就会去做。
因此,任何MSVC可以优化但C#不能的东西都很可能只是出于保持JIT快速运行而做出的启发式选择。我不知道MSVC是否是一个好的基准测试,但与GCC和clang相比,它并不是最积极或最好的优化编译器。
正如@PMF在评论中提到的那样,这实际上是C#编译器本身可以在IL中进行的一种优化,而不是留给JIT。但在大多数情况下,它只有在像内联之类的事情后才能看到(这不是故意重复编写的情况)。
尽管如此,这仍然是MS实现一种优化的方式(适用于这种故意冗余的情况),而不会影响JIT时间与汇编速度之间的权衡。

2
在这里稍微详细解释一下为什么优化取决于 if 的数量:
使用只有一个 if 的版本进行的优化似乎实际上发生在编译时,因为在这种情况下生成的IL不包含任何条件语句(它确实包含一些冗余加载简单 pop 掉的值的代码,但显然JIT能够将其从生成的代码中消除)。实际上,您给出的所有IL转储都显示编译器正在优化掉最后一个 if ,因此IL中的条件语句/返回值数量比C#源代码中少一个(这对后面很重要)。正如其他人所提到的,编译器可能应该优化掉所有多余的条件语句,而不仅仅是最后一个,但让我们尝试弄清楚JIT在这里做了什么。
因此问题是:在 JIT 处理少于 5 个 if 的情况下未被优化的地方,JIT 在 5 个 if 的情况下有什么不同之处?要确定这一点,基本上需要在调试模式下从源代码构建 .NET 运行时,然后使用 JIT 转储功能查看 JIT 编译方法时的确切操作。经过这样做,首先明显的区别在于,在后一种情况下,一个早期的 JIT 阶段会合并方法的返回值,由于所有的返回值都相同,它们被合并成一个单一的返回块。然后 JIT 的后续阶段能够确定条件跳转是多余的,因此也消除了所有的跳转,生成的本机代码是我们预期的简单的 mov eax,1/ret。
现在我们怀疑合并返回是触发完全优化的关键,我们需要确定为什么5个if是神奇数字。这其实相对容易 - JIT方法中有4个返回的硬限制(参见:flowgraph.cpp#L2127)。虽然在C#源代码中,有6个return版本的if,但请记住编译器会优化掉最后一个if,只留下5个返回在IL中,因此只有5个(或更多)if版本超过了这个限制,并导致了返回合并的出现。
最终,我们可以通过重新构建JIT并减少返回次数的限制来测试假设,看看它是否正确地优化了2-4个if语句的情况。不幸的是,这并不像简单地将JIT的最大返回次数更改为1那样简单,因为这会触发稍微不同的返回合并行为,所以我们将尝试将最大返回次数设置为2,并且我可以确认它确实正确地优化了我们预期的3和4个if版本。

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