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
几乎没有用,因此“删除”了它。
现在,让我们尝试添加更多 if
s:
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
问题
- 在第二种情况下它生成额外的指令是否有原因?
备注
- SharpLablink想玩就玩
- 这是
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 link.
- 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 中又错过了一次优化。将它报告给您正在使用的任何 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 不包含任何条件(它确实包含一些冗余值加载只是 pop
ped 之后,但显然 JIT 能够从生成的代码中消除这些)。事实上,all 你给出的 IL 转储表明编译器正在优化最后一个 if
,所以 IL 中 conditionals/returns 的数量是比 C# 源代码中的数字少一(这与后面相关)。正如其他人提到的那样,编译器可能应该优化掉 所有 冗余条件,而不仅仅是最后一个,但让我们尝试找出 JIT 在这里做它正在做的事情的原因。
因此问题是:JIT 在 5 if
s 中有什么不同导致它在少于 5 if
s 时没有被优化?确定这一点几乎需要在调试模式下从源代码构建 .NET 运行时,然后使用 JIT 转储功能来查看 JIT 在编译方法时到底在做什么。完成此操作后,第 2 和第 5 if
版本之间的第一个明显区别是,在后一种情况下,早期的 JIT 阶段开始,其中从方法中合并 returns,并且由于所有 return是一样的,它们都被合并成一个return块。然后,JIT 的后期阶段能够确定条件跳转是冗余的,因此也消除了所有这些跳转,并且生成的本机代码是我们期望的简单 mov eax,1/ret
。
现在我们怀疑 return 合并是触发完全优化的关键,我们需要确定为什么 5 if
s 是幻数。结果证明这相对容易 - 在 JITted 方法中有 4 returns 的硬限制(参见:flowgraph.cpp#L2127)。虽然 5 if
版本在 C# 源代码中实际上有 6 return
s,回想一下,编译器优化掉了最后的 if
,在 IL 中只留下 5 return
s,所以只有第 5 个(或更多)if
版本超过了这个限制并导致 return-merging 开始。
最后我们可以通过减少 returns 的数量限制重建 JIT 来检验假设,看看它是否正确优化了 2-4 if
s 的情况。不幸的是,这并不像将 JIT 的最大 return 数量更改为 1 那么简单,因为这会触发略微不同的 return-合并行为,因此我们将尝试设置 [=43] 的最大数量=]s 到 2,我可以确认它确实按照我们的预期正确优化了 3 和 4 if
版本。
我在玩 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
s:
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
问题
- 在第二种情况下它生成额外的指令是否有原因?
备注
- SharpLablink想玩就玩
- 这是
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 link.
- 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 中又错过了一次优化。将它报告给您正在使用的任何 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 不包含任何条件(它确实包含一些冗余值加载只是 pop
ped 之后,但显然 JIT 能够从生成的代码中消除这些)。事实上,all 你给出的 IL 转储表明编译器正在优化最后一个 if
,所以 IL 中 conditionals/returns 的数量是比 C# 源代码中的数字少一(这与后面相关)。正如其他人提到的那样,编译器可能应该优化掉 所有 冗余条件,而不仅仅是最后一个,但让我们尝试找出 JIT 在这里做它正在做的事情的原因。
因此问题是:JIT 在 5 if
s 中有什么不同导致它在少于 5 if
s 时没有被优化?确定这一点几乎需要在调试模式下从源代码构建 .NET 运行时,然后使用 JIT 转储功能来查看 JIT 在编译方法时到底在做什么。完成此操作后,第 2 和第 5 if
版本之间的第一个明显区别是,在后一种情况下,早期的 JIT 阶段开始,其中从方法中合并 returns,并且由于所有 return是一样的,它们都被合并成一个return块。然后,JIT 的后期阶段能够确定条件跳转是冗余的,因此也消除了所有这些跳转,并且生成的本机代码是我们期望的简单 mov eax,1/ret
。
现在我们怀疑 return 合并是触发完全优化的关键,我们需要确定为什么 5 if
s 是幻数。结果证明这相对容易 - 在 JITted 方法中有 4 returns 的硬限制(参见:flowgraph.cpp#L2127)。虽然 5 if
版本在 C# 源代码中实际上有 6 return
s,回想一下,编译器优化掉了最后的 if
,在 IL 中只留下 5 return
s,所以只有第 5 个(或更多)if
版本超过了这个限制并导致 return-merging 开始。
最后我们可以通过减少 returns 的数量限制重建 JIT 来检验假设,看看它是否正确优化了 2-4 if
s 的情况。不幸的是,这并不像将 JIT 的最大 return 数量更改为 1 那么简单,因为这会触发略微不同的 return-合并行为,因此我们将尝试设置 [=43] 的最大数量=]s 到 2,我可以确认它确实按照我们的预期正确优化了 3 和 4 if
版本。