65.9K
CodeProject 正在变化。 阅读更多。
Home

深入探讨 .NET 循环性能、边界检查、迭代和展开

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.90/5 (60投票s)

2014年11月20日

CPOL

12分钟阅读

viewsIcon

62062

深入了解 .NET 数组访问优化,以及如何通过模式化代码来提高效率。

引言

在本文中,我将详细介绍 .NET 如何处理涉及数组和集合访问的循环,以及您可以期望哪些类型的优化。我之前的一篇文章中提到过其中一些优化,但在本文中,我们将深入探讨并展示更多示例。我们将深入研究生成的 IL 以及实际执行的 x64 汇编代码。

这些优化对您的程序有影响吗?只有当您的程序是 CPU 密集型的,并且集合迭代是您处理的核心部分时,才会有影响。正如您将看到的,如果不小心,有一些方法会损害您的性能,但这只有在它占您程序很大一部分的情况下才有意义。我的理念是:大多数人永远不需要了解这些,但如果您需要,那么理解系统的每一层都很重要,这样您才能做出明智的选择。这就是我写我的书《Writing High-Performance .NET Code》的原因,也是我写这些文章的原因——为您提供有关 .NET 程序编译和执行时实际发生的情况的信息。

其中一些信息您可以在其他地方找到,但我希望向您展示如何自己获取这些信息,这样您就不必依赖别人公开这些信息,尤其是在新版本的 JIT 发布时。

我使用的工具:ILSpy 用于查看 IL,Visual Studio 2013 用于调试和查看带有 .NET 4.5 的程序集。我以 Release 模式编译代码,明确针对 x64。调试时,不要从 Visual Studio 开始调试——那样无法准确反映您的代码。相反,单独启动程序并附加调试器。我通过在 Main() 开始处调用 Debugger.Launch() 来轻松实现这一点,然后通过 Ctrl-F5 执行程序。

关于程序集的说明。我选择 x64 是因为如今它应该是开发的首选。对于下面提到的寄存器名称,请理解 eax 和 rax 等指的是同一个寄存器——rax 是 eax 的 64 位版本,本质上是 eax 的符号扩展版本,但它们都指向同一个物理位置。ecx/rdx、ecx/rcx 等也是如此。

边界检查

在 .NET 中存在边界检查是因为 CLR 必须始终确保托管代码的内存安全。它必须确保托管代码不可能损坏已定义数据结构外部的内存。这意味着以下代码不允许执行

int[] array = new int[100];
array[100] = 1;

尝试执行此操作应该会导致异常,而不是允许发生内存损坏。这意味着 CLR 必须在数组访问中注入边界检查。如果未执行某些优化,这可能会使重复的数组访问速度加倍。这种知识使一些人不愿意使用 .NET 来编写高性能代码,但我们将看到 JIT 编译器确实会在某些情况下进行这些优化(而在其他情况下则会不足)。但是,请记住我在引言中说过的话:这些类型的优化只有在您的程序是 CPU 密集型的,并且这些代码位于最内层、最常调用的代码中时才有意义。即便如此,即使您不必担心这种级别的性能,了解如何调查这些类型的细节也很有启发性。

基准循环

首先,让我们看看最基本的循环

[MethodImpl(MethodImplOptions.NoInlining)]
private static long GetSum(int[] array)
{ 
    long sum = 0; 
    for (int i =0; i < array.Length; i++)
    { 
        sum += array[i]; 
    } 
    return sum; 
}

它只是遍历整个数组并对所有元素求和。我明确禁用了内联,因为这可能会使分析有些混乱,而我们只想专注于循环优化,而不是 JIT 编译器此处可以且将会进行的其他改进。

在 IL 中,此方法如下所示(带有我自己的注释)

.method private hidebysig static 
	int64 GetSum (
		int32[] 'array'
	) cil managed noinlining
{
	// Method begins at RVA 0x20a4
	// Code size 26 (0x1a)
	.maxstack 3
	.locals init (
		[0] int64 sum,
		[1] int32 i
	)

	IL_0000: ldc.i4.0    // Push 0 onto evaluation stack as a 4-byte integer
	IL_0001: stloc.0     // Pop from evaluation stack and store into local variable at location 0, sum.
	IL_0002: ldc.i4.0    // Push 0 to evaluation stack
	IL_0003: stloc.1     // Pop and store in local variable i.
	IL_0004: br.s IL_0012     //  Jump to IL_0012 (unconditionally)
	// loop start (head: IL_0010)
		IL_0006: ldloc.0    // Push sum onto stack
		IL_0007: ldarg.0    // Push array onto stack
		IL_0008: ldloc.1    // Push i onto stack
		IL_0009: ldelem.i4  // Pop array and i off the stack and push array[i] onto the stack
		IL_000a: add        // Pop array[i] and sum off the stack, add, and push result to stack
		IL_000b: stloc.0    // Pop result off stack and store in sum
		IL_000c: ldloc.1    // Push i onto stack
		IL_000d: ldc.i4.1   // Push 1 onto stack
		IL_000e: add        // Pop i and 1 off of stack, add, and push result to stack
		IL_000f: stloc.1    // Pop result off stack and store in i

		IL_0010: ldloc.1    // Push i onto stack
		IL_0011: ldarg.0    // Push array onto stack
		IL_0012: ldlen      // Pop array off of stack, get length, and push length onto stack
		IL_0013: conv.i4    // Convert length to 4-byte integer
		IL_0014: blt.s IL_0006    // Pop i and length off of stack. If i < length, jump to IL_0006
	// end loop

	IL_0016: ldloc.0    // Push sum onto stack
	IL_0017: ret        // Return
} // end of method Program::GetSum

执行循环似乎做了很多工作。IL 是一种简单的基于堆栈的语言,非常冗长,但 JIT 编译器会将其转换为实际的机器代码,该代码利用了高效的 CPU 指令和寄存器。生成的程序集是这样的

00007FFADFCC02D0  mov         rdx,rcx                 // Copy array address to rdx
00007FFADFCC02D3  xor         ecx,ecx                 // Set ecx (sum) to 0
00007FFADFCC02D5  mov         r10,qword ptr [rdx+8]   // Copy array length to r10
00007FFADFCC02D9  test        r10d,r10d               // See if length is 0
00007FFADFCC02DC  jle         00007FFADFCC0303        // If so, skip the loop
00007FFADFCC02DE  mov         r8d,ecx                 // Copy 0 to r8 (source of ecx is not relevant)
00007FFADFCC02E1  xor         r9d,r9d                 // Set r9 to 0 (memory offset into array)
00007FFADFCC02E4  nop         word ptr [rax+rax]      // NOP
00007FFADFCC02F0  mov         eax,dword ptr [rdx+r9+10h]  // Copy array[i] to eax
00007FFADFCC02F5  add         ecx,eax                 // Add value to sum
00007FFADFCC02F7  inc         r8d                     // i++
00007FFADFCC02FA  add         r9,4                    // Increment memory address by 4 bytes
00007FFADFCC02FE  cmp         r8d,r10d                // i < length ?
00007FFADFCC0301  jl          00007FFADFCC02F0        // If so, loop back a few statements for another iteration
00007FFADFCC0303  mov         eax,ecx                 // Copy sum to return register
00007FFADFCC0305  ret                                 // Return

这里有几点需要注意

  1. 代码同时跟踪索引值 i 和数组值的直接内存地址,这比每次循环迭代执行乘法和偏移要高效得多。
  2. 没有数组边界检查!这有时是 .NET 新手担心的问题——我们如何在没有边界检查的情况下维护内存安全?在这种情况下,JIT 编译器认为不需要边界检查,因为它知道此循环的所有约束。

重新引入边界检查

现在让我们引入一些可变性。如果我们改用一些边界来迭代,这会改变情况吗?

[MethodImpl(MethodImplOptions.NoInlining)]
private static int GetSum(int[] array, int start, int end)
{
    int sum = 0;
    for (int i = start; i < end; i++)
    {
        sum += array[i];
    }
    return sum;
}
.
.
.
// Called with:
GetSum(array, 25, 75);

IL 的差异并不太有趣,所以我不会展示它,但机器指令肯定会改变

00007FFADFCC0320  sub         rsp,28h               // Adjust stack pointer
00007FFADFCC0324  mov         r9,rcx                // Copy array address to r9
00007FFADFCC0327  xor         ecx,ecx               // Set ecx (sum) to 0
00007FFADFCC0329  cmp         edx,r8d               // edx has i in it, initialized with start (25). Compare that to r8, which has the end value (75)
00007FFADFCC032C  jge         00007FFADFCC0348      // If start >= end, skip loop.
00007FFADFCC032E  mov         r10,qword ptr [r9+8]  // Copy array length to r10
00007FFADFCC0332  movsxd      rax,edx               // Copy start to rax
00007FFADFCC0335  cmp         rax,r10               // Compare i to array length
00007FFADFCC0338  jae         00007FFADFCC0350      // If i >= length, jump to this address (below)
00007FFADFCC033A  mov         eax,dword ptr [r9+rax*4+10h]  // Copy array[i] to eax
00007FFADFCC033F  add         ecx,eax               // sum += array[i]
00007FFADFCC0341  inc         edx                   // i++
00007FFADFCC0343  cmp         edx,r8d               // Compare i to end value
00007FFADFCC0346  jl          00007FFADFCC0332      // If i < jump to beginning of loop (above)
00007FFADFCC0348  mov         eax,ecx               // Copy sum to return register
00007FFADFCC034A  add         rsp,28h               // Fix up stack pointer
00007FFADFCC034E  ret                               // Return

00007FFADFCC0350  call        00007FFB3F7E6590      // Throw an exception!

最显著的区别是,现在在每次循环迭代中,它都会将数组索引与数组长度进行比较,如果越界,我们会得到一个异常。通过此更改,我们将循环的运行时间加倍了。边界检查就在那里。

那么为什么现在要这样做呢?是因为我们正在处理子数组吗?这很容易测试。您可以修改本文开头的原始函数,使其从 25 迭代到 75,您会发现编译器没有插入边界检查。

不,在这种情况下进行边界检查的原因是因为编译器无法证明输入值是 100% 安全的。

好的,那么。在循环之前检查开始和结束变量怎么样?我怀疑这很快会导致理解代码的麻烦。一旦约束不是常量,您就必须处理变量别名、副作用以及跟踪每个值的用法——这对 JIT 编译器来说并不实用。当然,在我这个简单的示例方法中,这可能很容易,但对于通用情况来说,如果不是不可能的话,也可能非常困难。

其他获取边界检查的方法

即使是对索引变量进行简单的修改,您都可以从逻辑上证明它不会超出数组边界,也可能导致出现边界检查

[MethodImpl(MethodImplOptions.NoInlining)]
private static void Manipulate(int[] array)
{
    for (int i=0; i < array.Length; i++)
    {
        array[i] = array[i / 2];
    }
}

i / 2 不可能超出 array.Length,但边界检查仍然存在

00007FFADFCE03B0  sub         rsp,28h              // Adjust stack pointer  
00007FFADFCE03B4  mov         r8,qword ptr [rcx+8] // Copy array length to r8
00007FFADFCE03B8  test        r8d,r8d              // Test if length is 0
00007FFADFCE03BB  jle         00007FFADFCE03E8     // If 0, skip loop
00007FFADFCE03BD  xor         r9d,r9d              // Set i to 0
00007FFADFCE03C0  xor         r10d,r10d            // Set destination index to 0
00007FFADFCE03C3  mov         eax,r9d              // Set eax (source array index) to 0
00007FFADFCE03C6  cdq                              // sign extends eax into edx (needed for division)
00007FFADFCE03C7  sub         eax,edx              // eax = eax - edx 
00007FFADFCE03C9  sar         eax,1                // Shift right (divide by two)
00007FFADFCE03CB  movsxd      rax,eax              // copy 32-bit eax into 64-bit rax and sign extend
00007FFADFCE03CE  cmp         rax,r8               // Compare source index against array length
00007FFADFCE03D1  jae         00007FFADFCE03F0     // If greater than or equal, jump and throw exception
00007FFADFCE03D3  mov         eax,dword ptr [rcx+rax*4+10h]  // Copy value from array[i/2] to eax
00007FFADFCE03D7  mov         dword ptr [rcx+r10+10h],eax    // Copy value from eax to array[i]
            
00007FFADFCE03DC  inc         r9d                  // Increment i
00007FFADFCE03DF  add         r10,4                // Increment destination address by 4 bytes
00007FFADFCE03E3  cmp         r9d,r8d              // Compare i against array length
00007FFADFCE03E6  jl          00007FFADFCE03C3     // If less, loop again
00007FFADFCE03E8  add         rsp,28h              // Adjust stack
00007FFADFCE03EC  ret                              // return

00007FFADFCE03F0  call        00007FFB3F7E6590     // Throw exception

所以,即使我们可以证明它是安全的,编译器也不会。

还有一种方式,有点烦人。还记得第一个基准示例吗,那个没有边界检查的。如果我们有完全相同的代码,但它操作的是一个静态值而不是参数,会怎么样?

00007FFADFCE0500  mov         rax,0A42A235780h  
00007FFADFCE050A  mov         rax,qword ptr [rax]  
00007FFADFCE050D  movsxd      r9,ecx  
00007FFADFCE0510  mov         r8,qword ptr [rax+8]  
00007FFADFCE0514  cmp         r9,r8                 // bounds check!
00007FFADFCE0517  jae         00007FFADFCE0530  
00007FFADFCE0519  mov         eax,dword ptr [rax+r9*4+10h]  
00007FFADFCE051E  add         edx,eax  
00007FFADFCE0520  inc         ecx  
00007FFADFCE0522  cmp         ecx,r8d  
00007FFADFCE0525  jl          00007FFADFCE0500  
00007FFADFCE0527  mov         eax,edx  
00007FFADFCE0529  add         rsp,28h  
00007FFADFCE052D  ret  
                            
00007FFADFCE0530  call        00007FFB3F7E6590  

边界检查就在那里。

好的,那么如果我们很聪明,并将静态数组传递给我们的基准方法,而它不知道它是静态的呢?在这种情况下,没有边界检查——它与基准情况完全相同。

JIT 无法优化但可以优化的另一个情况是向后迭代数组

[MethodImpl(MethodImplOptions.NoInlining)]
private static int GetBackwardsSum(int[] array)
{
    int sum = 0;
    for (int i = array.Length - 1; i >= 0; i--)
    {
        sum += array[i];
    }
    return sum;
}

不幸但真实——尽管这看起来像基准示例,但它没有优化。

最后,考虑重复访问的情况

for (int i = 2;i < array.Length - 1; i++)
{
    int x = array[i-2];
    int y = array[i-2];
}

在这种情况下,编译器将发出一个边界检查,该检查足以满足两个访问。

使用 fixed 消除边界检查

消除边界检查的另一种方法是使用 fixed 关键字。我不太推荐它,因为它存在安全性和安全性方面的考虑,您绝对应该仔细考虑。但是,如果您确实需要对具有某种非平凡访问模式但否则会触发边界检查的数组进行最佳访问,这可能适合您。要使用 fixed,您必须为项目启用 unsafe 代码。

以下是使用 fixed 迭代数组并避免边界检查的两种选择

[MethodImpl(MethodImplOptions.NoInlining)]
unsafe private static int GetBackwardsSumFixedSubscript(int[] array)
{
    int sum = 0;
    fixed (int* p = array)
    {
        for (int i = array.Length - 1; i >= 0; i--)
        {
            sum += p[i];
        }
    }
    return sum;
}

[MethodImpl(MethodImplOptions.NoInlining)]
unsafe private static int GetBackwardsSumFixedPointer(int[] array)
{
    int sum = 0;
    fixed (int* p = &array[array.Length - 1])
    {
        int* v = p;
        for (int i = array.Length - 1; i >= 0; i--)
        {
            sum += *v;
            v--;
        }
    }
    return sum;
}

任何一种都可以工作,但请记住,unsafe 确实意味着不安全!您可能会在此方法中踩到内存。我建议将所有 unsafe 代码隔离到其自己的模块、类或其他受限范围内,并在代码审查期间对其进行额外审查。您还可能需要处理部署 unsafe 代码的组织安全问题。如果您可以使用纯托管代码消除边界检查,请强烈优先考虑该方法。

Foreach 与 For

现在,让我们将焦点转移到一个抽象级别。在 .NET 中,您可以使用 foreach 关键字迭代大多数集合。在大多数情况下,foreach 是调用 GetEnumerator()MoveNext() 等方法的语法糖。在这方面,可以公平地假设 foreach 的总体性能比简单的 forwhile 循环要差。

然而,情况并非完全如此。在某些情况下,编译器会识别一个简单的数组 for 循环的本质,而不管使用的语法如何。

数组上的 Foreach

如果我们以如下方式将上面的基准循环转换为 foreach

private static int GetSumForeach(int[] array)
{
    int sum = 0;
    foreach(var val in array)
    {
        sum += val;
    }
    return sum;
}

在这种情况下生成的 IL 是什么?

.method private hidebysig static 
	int32 GetSumForeach (
		int32[] 'array'
	) cil managed 
{
	// Method begins at RVA 0x2098
	// Code size 28 (0x1c)
	.maxstack 2
	.locals init (
		[0] int32 sum,
		[1] int32 val,
		[2] int32[] CS$6$0000,
		[3] int32 CS$7$0001
	)

	IL_0000: ldc.i4.0
	IL_0001: stloc.0
	IL_0002: ldarg.0
	IL_0003: stloc.2
	IL_0004: ldc.i4.0
	IL_0005: stloc.3
	IL_0006: br.s IL_0014
	// loop start (head: IL_0014)
		IL_0008: ldloc.2
		IL_0009: ldloc.3
		IL_000a: ldelem.i4
		IL_000b: stloc.1
		IL_000c: ldloc.0
		IL_000d: ldloc.1
		IL_000e: add
		IL_000f: stloc.0
		IL_0010: ldloc.3
		IL_0011: ldc.i4.1
		IL_0012: add
		IL_0013: stloc.3

		IL_0014: ldloc.3
		IL_0015: ldloc.2
		IL_0016: ldlen
		IL_0017: conv.i4
		IL_0018: blt.s IL_0008
	// end loop

	IL_001a: ldloc.0
	IL_001b: ret
} // end of method Program::GetSumForeach

将其与基准循环的 IL 进行比较。唯一的区别是存在一些额外的、生成的局部变量来存储数组和索引变量。否则,它看起来就像一个循环。事实上,程序集代码也看起来完全相同(包括消除了边界检查)

00007FFADFCB01F0  mov         rdx,rcx  
00007FFADFCB01F3  xor         ecx,ecx  
00007FFADFCB01F5  mov         r10,qword ptr [rdx+8]  
00007FFADFCB01F9  test        r10d,r10d  
00007FFADFCB01FC  jle         00007FFADFCB0223  
00007FFADFCB01FE  mov         r8d,ecx  
00007FFADFCB0201  xor         r9d,r9d  
00007FFADFCB0204  nop         word ptr [rax+rax]  
00007FFADFCB0210  mov         eax,dword ptr [rdx+r9+10h]  
00007FFADFCB0215  add         ecx,eax  
00007FFADFCB0217  inc         r8d  
00007FFADFCB021A  add         r9,4  
00007FFADFCB021E  cmp         r8d,r10d  
00007FFADFCB0221  jl          00007FFADFCB0210  
00007FFADFCB0223  mov         eax,ecx  
00007FFADFCB0225  ret  

List<T> 上的 For 循环

在看到 foreach 操作 List<T> 之前,我们应该看看 for 是如何工作的

private static int GetSumFor(List<int> array)
{
    int sum = 0;
    for (int i = 0; i < array.Count; i++)
    {
        sum += array[i];
    }
    return sum;
}

以及相应的 IL

.method private hidebysig static 
	int32 GetSumFor (
		class [mscorlib]System.Collections.Generic.List`1 'array'
	) cil managed 
{
	// Method begins at RVA 0x20a0
	// Code size 31 (0x1f)
	.maxstack 3
	.locals init (
		[0] int32 sum,
		[1] int32 i
	)

	IL_0000: ldc.i4.0
	IL_0001: stloc.0
	IL_0002: ldc.i4.0
	IL_0003: stloc.1
	IL_0004: br.s IL_0014
	// loop start (head: IL_0014)
		IL_0006: ldloc.0
		IL_0007: ldarg.0
		IL_0008: ldloc.1
		IL_0009: callvirt instance !0 class [mscorlib]System.Collections.Generic.List`1::get_Item(int32)
		IL_000e: add
		IL_000f: stloc.0
		IL_0010: ldloc.1
		IL_0011: ldc.i4.1
		IL_0012: add
		IL_0013: stloc.1

		IL_0014: ldloc.1
		IL_0015: ldarg.0
		IL_0016: callvirt instance int32 class [mscorlib]System.Collections.Generic.List`1::get_Count()
		IL_001b: blt.s IL_0006
	// end loop

	IL_001d: ldloc.0
	IL_001e: ret
} // end of method Program::GetSumFor

它看起来与数组上的 for 循环非常相似,但检索值和长度 (Count) 时使用了一些方法调用而不是直接内存访问。

然而,由此生成的程序集代码更有趣,并显示了一些微妙而重要的区别

00007FFADFCD0BE0  push        rbx  	// Stack frame setup
00007FFADFCD0BE1  push        rsi  
00007FFADFCD0BE2  push        rdi  
00007FFADFCD0BE3  sub         rsp,20h  
00007FFADFCD0BE7  mov         rdx,rcx    // Copy array address to rdx
00007FFADFCD0BEA  xor         ecx,ecx    // Set i to 0. i is used only for the loop condition.
00007FFADFCD0BEC  xor         r11d,r11d  // Set j to 0. j is used as the iterator on the internal array inside List
00007FFADFCD0BEF  mov         r8d,ecx    // Set sum to 0
00007FFADFCD0BF2  mov         ebx,dword ptr [rdx+18h]    // Copy length to ebx
// LOOP START
00007FFADFCD0BF5  cmp         ecx,ebx                    // Compare i to length
00007FFADFCD0BF7  jl          00007FFADFCD0C00           // if less, jump below
00007FFADFCD0BF9  mov         eax,r8d                    // else copy sum to return value
00007FFADFCD0BFC  jmp         00007FFADFCD0C26           // jump to end of function
00007FFADFCD0BFE  xchg        ax,ax                      // No-op

00007FFADFCD0C00  mov         eax,dword ptr [rdx+18h]    // Copy list's length to eax
00007FFADFCD0C03  cmp         ecx,eax                    // Compare i to length
00007FFADFCD0C05  jae         00007FFADFCD0C35           // if greater than or equal, jump and throw exception
00007FFADFCD0C07  mov         r9,qword ptr [rdx+8]       // Copy List's array address to r9
00007FFADFCD0C0B  movsxd      r10,r11d                   // Copy j  to r10 and sign extend.
00007FFADFCD0C0E  mov         rax,qword ptr [r9+8]       // Copy array's length to rax
00007FFADFCD0C12  cmp         r10,rax                    // Compare j to array' length
00007FFADFCD0C15  jae         00007FFADFCD0C30           // If greater than or equal, jump and throw exception
00007FFADFCD0C17  mov         eax,dword ptr [r9+r10*4+10h]  // copy array[j] (list[i]) to eax
00007FFADFCD0C1C  add         r8d,eax                    // sum += array[j]
00007FFADFCD0C1F  inc         ecx                        // i++
00007FFADFCD0C21  inc         r11d                       // j++
00007FFADFCD0C24  jmp         00007FFADFCD0BF5           // Jump to beginning of loop 
00007FFADFCD0C26  add         rsp,20h                    // Clean up stack
00007FFADFCD0C2A  pop         rdi  
00007FFADFCD0C2B  pop         rsi  
00007FFADFCD0C2C  pop         rbx  
00007FFADFCD0C2D  ret                                    // return

00007FFADFCD0C30  call        00007FFB3F7E6590           // Throw exception

00007FFADFCD0C35  mov         ecx,0Dh                    
00007FFADFCD0C3A  call        00007FFB3CD990F8           // Throw exception

以下是发生的情况

  • List<int> 上的方法调用已被内联——我们获得了对 List<int> 维护的底层数组的直接内存访问。
  • 有一个对 i 的迭代,它将其与 List<int> 的长度进行比较。
  • 有一个单独的对 j(我在注释中称之为 j)的迭代,它将其与 List<int> 包装的内部数组的长度进行比较。
    • 其中任何一个迭代失败都会导致抛出异常。

重复的迭代实际上并没有使循环比纯数组慢两倍——仍然只有一次内存访问,其余所有操作都在寄存器上进行,这非常高效。但是,如果您在数组和 List<T> 之间进行选择,那么对于纯粹的效率,很难超越数组。

List<T> 上的 Foreach

最后,让我们将注意力转向 List<T> 上的 foreach。编译器(或 JIT)是否会将其转换为简单的 for 循环?

不会。这是 IL

.method private hidebysig static 
	int32 GetSumForeach (
		class [mscorlib]System.Collections.Generic.List`1 list
	) cil managed 
{
	// Method begins at RVA 0x20f4
	// Code size 50 (0x32)
	.maxstack 2
	.locals init (
		[0] int32 sum,
		[1] int32 val,
		[2] valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator CS$5$0000
	)

	IL_0000: ldc.i4.0
	IL_0001: stloc.0
	IL_0002: ldarg.0
	IL_0003: callvirt instance valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<!--0--> class [mscorlib]System.Collections.Generic.List`1::GetEnumerator()
	IL_0008: stloc.2
	.try
	{
		IL_0009: br.s IL_0017
		// loop start (head: IL_0017)
			IL_000b: ldloca.s CS$5$0000
			IL_000d: call instance !0 valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator::get_Current()
			IL_0012: stloc.1
			IL_0013: ldloc.0
			IL_0014: ldloc.1
			IL_0015: add
			IL_0016: stloc.0

			IL_0017: ldloca.s CS$5$0000
			IL_0019: call instance bool valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator::MoveNext()
			IL_001e: brtrue.s IL_000b
		// end loop

		IL_0020: leave.s IL_0030
	} // end .try
	finally
	{
		IL_0022: ldloca.s CS$5$0000
		IL_0024: constrained. valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator
		IL_002a: callvirt instance void [mscorlib]System.IDisposable::Dispose()
		IL_002f: endfinally
	} // end handler

	IL_0030: ldloc.0
	IL_0031: ret
} // end of method Program::GetSumForeach

我不会展示此程序的程序集代码,但可以说,它也相当不同。

(IEnumerable<T>)array 上的 Foreach

您会期望这样的代码发生什么?

private static int GetSumForeach(int[] array)
{
    var collection = array as IEnumerable<int>;
    int sum = 0;
    foreach (var val in collection)
    {
        sum += val;
    }
    return sum;
}

在这种情况下,数组优化将丢失,您将得到与 List<T> 上的 foreach 相同的结果。

循环展开

循环展开是通过每次迭代执行更多工作来减少循环开销的做法,从而减少迭代次数。对于循环繁重的代码,它可以产生很大的影响。

您可以自己进行这种展开,例如通过以下示例

private static int GetSum(int[] array)
{
    int sum = 0;
    for (int i = 0; i < array.Length-4; i+=4)
    {
        sum += array[i];
        sum += array[i+1];
        sum += array[i+2];
        sum += array[i+3];
    }
    return sum;
}

在某些情况下,编译器可以为您完成这种优化,但要进行循环展开,必须遵守一个主要限制:循环约束必须是静态可确定的,以便编译器知道如何修改循环。

这就是为什么我们在之前示例的程序集中看不到任何循环展开的原因。循环的长度始终是可变的。如果我们改用如下内容呢?

const int ArrayLength = 100;

private static int GetSum(int[] array)
{
    int sum = 0;
    for (int i = 0; i < ArrayLength; i++)
    {
        sum += array[i];
    }
    return sum;
}

此方法的 IL 在外观上与之前相似,但程序集显示了优化

00007FFADFCE01C0  sub         rsp,28h              // Setup stack frame
00007FFADFCE01C4  mov         rdx,rcx              // Copy array address to rdx
00007FFADFCE01C7  xor         ecx,ecx              // Set sum to 0
00007FFADFCE01C9  xor         r8d,r8d              // Set address offset to 0
00007FFADFCE01CC  mov         rax,qword ptr [rdx+8]// Copy array length to rax
00007FFADFCE01D0  mov         r9d,60h              // Copy value 96 to r9
00007FFADFCE01D6  cmp         r9,rax               // Compare 96 to length
00007FFADFCE01D9  jae         00007FFADFCE0230     // If greater than or equal, jump and throw exception
00007FFADFCE01DB  mov         r9d,61h              // Copy value 97 to r9
00007FFADFCE01E1  cmp         r9,rax               // Compare to length
00007FFADFCE01E4  jae         00007FFADFCE0230     // If greater than or equal, jump and throw exception
00007FFADFCE01E6  mov         r9d,62h              // Copy value 98 to r9
00007FFADFCE01EC  cmp         r9,rax               // Compare to length
00007FFADFCE01EF  jae         00007FFADFCE0230     // If greater than or equal, jump and throw exception
00007FFADFCE01F1  mov         r9d,63h              // Copy value 99 to r9
00007FFADFCE01F7  cmp         r9,rax               // Compare to length
00007FFADFCE01FA  jae         00007FFADFCE0230     // If greater than or equal, jump and throw exception
00007FFADFCE01FC  nop         dword ptr [rax]      // Nop
// LOOP START
00007FFADFCE0200  mov         eax,dword ptr [rdx+r8+10h]  // Copy array[i] to eax
00007FFADFCE0205  add         ecx,eax                     //sum += array[i]
00007FFADFCE0207  mov         eax,dword ptr [rdx+r8+14h]  // Copy array[i+1] to eax
00007FFADFCE020C  add         ecx,eax                     // sum += array[i+1]
00007FFADFCE020E  mov         eax,dword ptr [rdx+r8+18h]  // Copy array[i+2] to eax
00007FFADFCE0213  add         ecx,eax                     // sum += array[i+2]
00007FFADFCE0215  mov         eax,dword ptr [rdx+r8+1Ch]  // Copy array[i+3] to eax
00007FFADFCE021A  add         ecx,eax                     // sum += array[i+3]
00007FFADFCE021C  add         r8,10h                      // Increment address offset by 16 bytes (4 integers)
00007FFADFCE0220  cmp         r8,190h                     // Compare address offset to 400
00007FFADFCE0227  jl          00007FFADFCE0200            // If less, loop around
00007FFADFCE0229  mov         eax,ecx                     // Copy sum to return value
00007FFADFCE022B  add         rsp,28h                     // Fix up stack
00007FFADFCE022F  ret                                     // return

00007FFADFCE0230  call        00007FFB3F7E6590            // throw exception

这基本操作是

  1. 检查我们是否可以安全地对 N 个值进行增量并溢出数组(在本例中 N 为 4)。
  2. 每个循环体执行 N 个操作。

然而,一旦您偏离了静态确定性,就无法依赖此优化。但是,如果您对数组有特殊的了解(例如,它们的长度始终是 4 的倍数),那么您可以在源代码中自己进行循环展开。

就当前的 JIT 编译器而言,如果您将此示例中的数组大小更改为 101,您将失去此优化。某些编译器会对此进行拆分——一个展开的循环,末尾附加剩余部分。JIT 编译器目前不这样做(请记住,它受到严格的时间限制)。

当然,最终的循环展开就是消除循环本身,并将每个操作单独列出。对于小型操作,这可能是可取的——这只是您必须在代码维护和性能之间做出的权衡。

摘要

如果您需要在性能更关键的组件上获得最后一点性能,尤其是在 CPU 密集型的情况下,了解代码中的微小差异如何转化为更有效或更低效的机器代码非常重要。

在所有集合中,数组的访问效率最高,但 List<T> 也非常接近。在迭代数组时,您应该尽量在可能的情况下消除边界检查。在决定 forforeach 之间时,请记住,除了纯数组之外,foreach 可能涉及方法调用。

了解允许循环展开的条件。如果您能够构建代码以允许它,并且它能产生影响,那就去做吧,或者手动展开循环以获得性能提升。

最重要的是,找出如何自己回答这些类型的问题,这样下次出现深入的性能问题时,您就知道如何快速确定答案。

© . All rights reserved.