【问题标题】:C# strange behavior with branching带有分支的 C# 奇怪行为
【发布时间】:2021-07-18 05:37:18
【问题描述】:

我在玩 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

奇怪的是:当您添加 &gt;= 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'

【问题讨论】:

  • 我不会想到一个非常有趣的原因。答案可能是“因为 JIT 编译器的这个特定实现细节:”。我很确定 5 只是一个神奇的数字。
  • 是的,我认为这是有原因的(也许他们是故意的,但可能没有原因?)。
  • 如果了解 IL 对这些情况的每一种情况会很有趣。这意味着 C# 编译器实际上是在进行优化(或不进行优化)还是 JIT。
  • @PMF 在那里添加了 IL 代码。
  • 奇怪,我原以为 IL 已经优化了。但显然不是。

标签: c# assembly x86-64 compiler-optimization jit


【解决方案1】:

根据ifs 的数量,在这里更详细地解释为什么优化会发生/不会发生:

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

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

现在我们怀疑返回合并是触发全面优化的关键,我们需要确定为什么 5 ifs 是神奇的数字。事实证明这相对容易——在 JITted 方法中有 4 个返回的硬性限制(请参阅:flowgraph.cpp#L2127)。虽然 5 if 版本在 C# 源代码中实际上有 6 个 returns,但请记住编译器优化了最后一个 if,在 IL 中只剩下 5 个 returns,所以它只有 5 个(或更多) if 版本超出此限制并导致返回合并启动。

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

【讨论】:

    【解决方案2】:

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

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

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

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

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


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

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

    【讨论】:

      猜你喜欢
      • 2020-09-03
      • 1970-01-01
      • 2011-03-16
      • 2011-08-07
      • 1970-01-01
      • 2012-11-12
      • 2019-08-19
      • 2012-01-10
      • 2016-04-30
      相关资源
      最近更新 更多