for循环迭代变量的复用
Reuse of for loop iteration variable
我看到很多关于是在 for
循环作用域内部还是外部声明变量的问题。对此进行了详细讨论,例如 here, here, and here。答案是绝对没有性能差异(相同的 IL),但为了清楚起见,最好在最严格的范围内声明变量。
我对稍微不同的情况感到好奇:
int i;
for (i = 0; i < 10; i++) {
Console.WriteLine(i);
}
for (i = 0; i < 10; i++) {
Console.WriteLine(i);
}
对比
for (int i = 0; i < 10; i++) {
Console.WriteLine(i);
}
for (int i = 0; i < 10; i++) {
Console.WriteLine(i);
}
我希望这两种方法在发布模式下都能编译成相同的 IL。然而,这种情况并非如此。我将为您提供完整的 IL,并指出不同之处。第一种方法有一个本地:
.locals init (
[0] int32 i
)
而第二个只有两个局部变量,每个 for
循环计数器一个:
.locals init (
[0] int32 i,
[1] int32 i
)
所以这两者之间有一个没有被优化掉的区别,这让我很惊讶。
为什么我会看到这个,这两种方法之间是否真的存在性能差异?
为了回答您的问题,您实际上在第一种情况下声明了一个局部变量,在第二种情况下声明了两个局部变量。 C# 编译器显然不重用局部变量,尽管我认为这样做是允许的。我的猜测是,这不是值得编写复杂分析来处理的性能提升,如果 JIT 足够智能,无论如何都可以处理它,甚至可能没有用。但是,您期望看到的优化已经完成,只是不在 IL 级别。它由 JIT 编译器在发出的机器代码中完成。
这是一个足够简单的案例,检查发出的机器代码实际上可以提供信息。总结是这两种方法将 JIT 编译为相同的机器代码(x86 如下所示,但 x64 机器代码也相同)因此使用较少的局部变量不会带来性能提升。
关于条件的快速说明,我将这两个片段放入不同的方法中。然后我查看了 Visual Studio 2015 年的反汇编,使用 .NET 4.6.1 运行时、x86 发布构建(即优化)并在 JIT 编译后 附加调试器方法(至少在没有附加调试器的情况下调用)。我禁用了方法内联以保持两种方法之间的一致性。要查看反汇编,请在所需方法中放置一个断点,附加,转到调试 > Windows > 反汇编。按 F5 到 运行 断点。
废话不多说,第一种方法反汇编为
for (i = 0; i < 10; i++)
010204A2 in al,dx
010204A3 push esi
010204A4 xor esi,esi
{
Console.WriteLine(i);
010204A6 mov ecx,esi
010204A8 call 71686C0C
for (i = 0; i < 10; i++)
010204AD inc esi
010204AE cmp esi,0Ah
010204B1 jl 010204A6
}
for (i = 0; i < 10; i++)
010204B3 xor esi,esi
{
Console.WriteLine(i);
010204B5 mov ecx,esi
010204B7 call 71686C0C
for (i = 0; i < 10; i++)
010204BC inc esi
010204BD cmp esi,0Ah
010204C0 jl 010204B5
010204C2 pop esi
010204C3 pop ebp
010204C4 ret
第二种方法反汇编为
for (int i = 0; i < 10; i++)
010204DA in al,dx
010204DB push esi
010204DC xor esi,esi
{
Console.WriteLine(i);
010204DE mov ecx,esi
010204E0 call 71686C0C
for (int i = 0; i < 10; i++)
010204E5 inc esi
010204E6 cmp esi,0Ah
010204E9 jl 010204DE
}
for (int i = 0; i < 10; i++)
010204EB xor esi,esi
{
Console.WriteLine(i);
010204ED mov ecx,esi
010204EF call 71686C0C
for (int i = 0; i < 10; i++)
010204F4 inc esi
010204F5 cmp esi,0Ah
010204F8 jl 010204ED
010204FA pop esi
010204FB pop ebp
010204FC ret
如您所见,除了相应跳转的不同偏移量外,代码是相同的。
这些方法非常简单,因此跟踪循环计数器的工作是通过 esi 寄存器完成的。
留作 reader 在 x64 中验证的练习。
只是为上面的详细答案添加一些内容。 C# 编译器很少进行优化,例如连接字符串文字 ("a" + "b") 和计算常量。因此,查看 C# 编译器生成的 IL 进行优化是毫无意义的。相反,您应该查看 JIT 编译器生成的汇编器。
构建参数也可以抑制 JIT 优化。因此,请确保您设置了发布构建模式并清除了 VS 调试选项
中的 "Suppress JIT optimization on module load" 标志
作为对现有答案的补充,请注意,将两个变量合并为一个变量实际上可能 损害 性能,具体取决于 JIT 编译器能够推断出的信息。
如果 JIT 编译器发现两个生命周期不重叠的变量,免费 为两者使用相同的位置(通常是寄存器)。但如果 JIT 编译器看到单个变量,则 需要 使用相同的位置。或者,更准确地说,需要在整个生命周期内保持变量的值。
在您的特定情况下,这意味着在第一个循环结束后和第二个循环开始之前,编译器不能丢弃变量的值并将该位置重新用于其他目的。
但即使使用单个 IL 变量,也没有给出 JIT 编译器实际上将其视为单个变量的情况。聪明的编译器可以看到,当代码离开第一个循环时,变量在被覆盖之前不会被再次读取。所以它可以把单个 IL 变量当作两个,并丢弃循环之间的值。
总结一下:
- 对于不分析变量生命周期的愚蠢编译器,一个变量优于两个变量。
- 对于一个体面的编译器,可以分析变量的生命周期但不能拆分变量,两个变量比一个更好。
- 聪明的编译器,能分析变量的生命周期,还能拆分变量,没关系。
JIT 编译器是#2 或#3,因此在 IL 中使用两个变量是有意义的。
我看到很多关于是在 for
循环作用域内部还是外部声明变量的问题。对此进行了详细讨论,例如 here, here, and here。答案是绝对没有性能差异(相同的 IL),但为了清楚起见,最好在最严格的范围内声明变量。
我对稍微不同的情况感到好奇:
int i;
for (i = 0; i < 10; i++) {
Console.WriteLine(i);
}
for (i = 0; i < 10; i++) {
Console.WriteLine(i);
}
对比
for (int i = 0; i < 10; i++) {
Console.WriteLine(i);
}
for (int i = 0; i < 10; i++) {
Console.WriteLine(i);
}
我希望这两种方法在发布模式下都能编译成相同的 IL。然而,这种情况并非如此。我将为您提供完整的 IL,并指出不同之处。第一种方法有一个本地:
.locals init (
[0] int32 i
)
而第二个只有两个局部变量,每个 for
循环计数器一个:
.locals init (
[0] int32 i,
[1] int32 i
)
所以这两者之间有一个没有被优化掉的区别,这让我很惊讶。
为什么我会看到这个,这两种方法之间是否真的存在性能差异?
为了回答您的问题,您实际上在第一种情况下声明了一个局部变量,在第二种情况下声明了两个局部变量。 C# 编译器显然不重用局部变量,尽管我认为这样做是允许的。我的猜测是,这不是值得编写复杂分析来处理的性能提升,如果 JIT 足够智能,无论如何都可以处理它,甚至可能没有用。但是,您期望看到的优化已经完成,只是不在 IL 级别。它由 JIT 编译器在发出的机器代码中完成。
这是一个足够简单的案例,检查发出的机器代码实际上可以提供信息。总结是这两种方法将 JIT 编译为相同的机器代码(x86 如下所示,但 x64 机器代码也相同)因此使用较少的局部变量不会带来性能提升。
关于条件的快速说明,我将这两个片段放入不同的方法中。然后我查看了 Visual Studio 2015 年的反汇编,使用 .NET 4.6.1 运行时、x86 发布构建(即优化)并在 JIT 编译后 附加调试器方法(至少在没有附加调试器的情况下调用)。我禁用了方法内联以保持两种方法之间的一致性。要查看反汇编,请在所需方法中放置一个断点,附加,转到调试 > Windows > 反汇编。按 F5 到 运行 断点。
废话不多说,第一种方法反汇编为
for (i = 0; i < 10; i++)
010204A2 in al,dx
010204A3 push esi
010204A4 xor esi,esi
{
Console.WriteLine(i);
010204A6 mov ecx,esi
010204A8 call 71686C0C
for (i = 0; i < 10; i++)
010204AD inc esi
010204AE cmp esi,0Ah
010204B1 jl 010204A6
}
for (i = 0; i < 10; i++)
010204B3 xor esi,esi
{
Console.WriteLine(i);
010204B5 mov ecx,esi
010204B7 call 71686C0C
for (i = 0; i < 10; i++)
010204BC inc esi
010204BD cmp esi,0Ah
010204C0 jl 010204B5
010204C2 pop esi
010204C3 pop ebp
010204C4 ret
第二种方法反汇编为
for (int i = 0; i < 10; i++)
010204DA in al,dx
010204DB push esi
010204DC xor esi,esi
{
Console.WriteLine(i);
010204DE mov ecx,esi
010204E0 call 71686C0C
for (int i = 0; i < 10; i++)
010204E5 inc esi
010204E6 cmp esi,0Ah
010204E9 jl 010204DE
}
for (int i = 0; i < 10; i++)
010204EB xor esi,esi
{
Console.WriteLine(i);
010204ED mov ecx,esi
010204EF call 71686C0C
for (int i = 0; i < 10; i++)
010204F4 inc esi
010204F5 cmp esi,0Ah
010204F8 jl 010204ED
010204FA pop esi
010204FB pop ebp
010204FC ret
如您所见,除了相应跳转的不同偏移量外,代码是相同的。
这些方法非常简单,因此跟踪循环计数器的工作是通过 esi 寄存器完成的。
留作 reader 在 x64 中验证的练习。
只是为上面的详细答案添加一些内容。 C# 编译器很少进行优化,例如连接字符串文字 ("a" + "b") 和计算常量。因此,查看 C# 编译器生成的 IL 进行优化是毫无意义的。相反,您应该查看 JIT 编译器生成的汇编器。
构建参数也可以抑制 JIT 优化。因此,请确保您设置了发布构建模式并清除了 VS 调试选项
中的 "Suppress JIT optimization on module load" 标志作为对现有答案的补充,请注意,将两个变量合并为一个变量实际上可能 损害 性能,具体取决于 JIT 编译器能够推断出的信息。
如果 JIT 编译器发现两个生命周期不重叠的变量,免费 为两者使用相同的位置(通常是寄存器)。但如果 JIT 编译器看到单个变量,则 需要 使用相同的位置。或者,更准确地说,需要在整个生命周期内保持变量的值。
在您的特定情况下,这意味着在第一个循环结束后和第二个循环开始之前,编译器不能丢弃变量的值并将该位置重新用于其他目的。
但即使使用单个 IL 变量,也没有给出 JIT 编译器实际上将其视为单个变量的情况。聪明的编译器可以看到,当代码离开第一个循环时,变量在被覆盖之前不会被再次读取。所以它可以把单个 IL 变量当作两个,并丢弃循环之间的值。
总结一下:
- 对于不分析变量生命周期的愚蠢编译器,一个变量优于两个变量。
- 对于一个体面的编译器,可以分析变量的生命周期但不能拆分变量,两个变量比一个更好。
- 聪明的编译器,能分析变量的生命周期,还能拆分变量,没关系。
JIT 编译器是#2 或#3,因此在 IL 中使用两个变量是有意义的。