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 变量当作两个,并丢弃循环之间的值。

总结一下:

  1. 对于不分析变量生命周期的愚蠢编译器,一个变量优于两个变量。
  2. 对于一个体面的编译器,可以分析变量的生命周期但不能拆分变量,两个变量比一个更好。
  3. 聪明的编译器,能分析变量的生命周期,还能拆分变量,没关系。

JIT 编译器是#2 或#3,因此在 IL 中使用两个变量是有意义的。