C# 带有分支的奇怪行为

C# strange behavior with branching

我在玩 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 几乎没有用,因此“删除”了它。

现在,让我们尝试添加更多 ifs:

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

问题

备注

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 link.

.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'

像往常一样,JIT 中又错过了一次优化。将它报告给您正在使用的任何 JIT 的开发人员(大概是 Microsoft),尤其是当它出现在实际用例中时。但他们可能故意以这种方式调整他们的 JIT,大概是因为实际代码中包含这种无用的 if 语句的情况并不常见。肯定会发生,但大多数如果不是没有用。

这个问题和您之前的问题(关于 C 编译器找到但 C# 找不到的优化)的一般答案是 JIT 必须快速编译,没有时间搜索尽可能多的优化,所以你应该期待这样的废话。

为什么是 5?编译器经常在代码大小或分支数量上使用 heuristics 来做出决定,也许在这种情况下是否尝试寻找分支路径之间的一些共性。在您的情况下,5 个 if 语句足以突破某些启发式的阈值。如果您正在研究开源 JIT,如果您想深入研究并具体找出它在源代码中的哪个位置做出了决定,您可以可以

特别是对于编译时更直接地权衡优化质量的 JIT,跳过检查这一点的传递可能是有意义的。但它不适用于提前的 C 编译器;如果你告诉他们优化,他们会的。

因此,MSVC 可以优化但 C# 不能优化的任何东西都可能只是为了保持 JIT 快速而做出的启发式选择。 IDK 如果 MSVC 是一个很好的基准,但它不是与 GCC 和 clang 相比,最积极或最好的优化编译器。


正如@PMF 在评论中提到的那样,这实际上是 C# 编译器本身可以在 IL 中进行的优化,而不是将其留给 JIT。但可能在实际程序中发生的大多数情况下(不是故意冗余编写的),它仅在内联之类的事情之后可见。

不过,这将是 MS 实现优化的一种方式 (这适用于这种故意冗余的情况)而不会损害 JIT 时间与 asm 速度的权衡。

在这里更详细地说明为什么优化 does/doesn 不会发生取决于 if 的数量:

1-if 版本的优化实际上似乎发生在编译时,因为在这种情况下生成的 IL 不包含任何条件(它确实包含一些冗余值加载只是 popped 之后,但显然 JIT 能够从生成的代码中消除这些)。事实上,all 你给出的 IL 转储表明编译器正在优化最后一个 if,所以 IL 中 conditionals/returns 的数量是比 C# 源代码中的数字少一(这与后面相关)。正如其他人提到的那样,编译器可能应该优化掉 所有 冗余条件,而不仅仅是最后一个,但让我们尝试找出 JIT 在这里做它正在做的事情的原因。

因此问题是:JIT 在 5 ifs 中有什么不同导致它在少于 5 ifs 时没有被优化?确定这一点几乎需要在调试模式下从源代码构建 .NET 运行时,然后使用 JIT 转储功能来查看 JIT 在编译方法时到底在做什么。完成此操作后,第 2 和第 5 if 版本之间的第一个明显区别是,在后一种情况下,早期的 JIT 阶段开始,其中从方法中合并 returns,并且由于所有 return是一样的,它们都被合并成一个return块。然后,JIT 的后期阶段能够确定条件跳转是冗余的,因此也消除了所有这些跳转,并且生成的本机代码是我们期望的简单 mov eax,1/ret

现在我们怀疑 return 合并是触发完全优化的关键,我们需要确定为什么 5 ifs 是幻数。结果证明这相对容易 - 在 JITted 方法中有 4 returns 的硬限制(参见:flowgraph.cpp#L2127)。虽然 5 if 版本在 C# 源代码中实际上有 6 returns,回想一下,编译器优化掉了最后的 if,在 IL 中只留下 5 returns,所以只有第 5 个(或更多)if 版本超过了这个限制并导致 return-merging 开始。

最后我们可以通过减少 returns 的数量限制重建 JIT 来检验假设,看看它是否正确优化了 2-4 ifs 的情况。不幸的是,这并不像将 JIT 的最大 return 数量更改为 1 那么简单,因为这会触发略微不同的 return-合并行为,因此我们将尝试设置 [=43] 的最大数量=]s 到 2,我可以确认它确实按照我们的预期正确优化了 3 和 4 if 版本。