C# 带有分支的奇怪行为 注意事项

我在玩 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 链接,如果你想玩的话。
  • 这是 GCC (-O2) 使用 C 生成的:
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'
marxxxx 回答:C# 带有分支的奇怪行为 注意事项

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

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

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

特别是对于编译时更直接地与优化质量进行权衡的 JIT,跳过检查的过程可能是有意义的。但它不适用于提前的 C 编译器;如果您告诉他们进行优化,他们就会这样做。

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


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

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

,

在此更详细地了解为什么会根据 if 的数量进行优化/不进行优化:

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

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

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

最后我们可以通过在减少返回数量限制的情况下重建 JIT 来测试假设,看看它是否正确优化了 2-4 if s 的情况。不幸的是,这并不像将 JIT 的最大返回数更改为 1 那样简单,因为这会触发稍微不同的返回合并行为,因此我们将尝试将最大返回数设置为 2,然后我可以确认它确实如此按照我们的预期正确优化 3 和 4 if 版本。

本文链接:https://www.f2er.com/1275261.html

大家都在问