为什么使用内联初始化创建数组会很慢?

39

为什么行内数组初始化比迭代初始化慢那么多?我运行了这个程序来比较它们,单一初始化所需的时间比用for循环执行要长得多。

这是我在LinqPad中编写的测试程序。

var iterations = 100000000;
var length = 4;

{
    var timer = System.Diagnostics.Stopwatch.StartNew();

    for(int i = 0; i < iterations; i++){
        var arr = new int[] { 1, 2, 3, 4 };
    }
    timer.Stop();
    "Array- Single Init".Dump();
    timer.Elapsed.Dump();
}

{
    var timer = System.Diagnostics.Stopwatch.StartNew();

    for(int i = 0; i < iterations; i++){
        var arr = new int[length];
        for(int j = 0; j < length; j++){
            arr[j] = j;
        }
    }
    timer.Stop();
    "Array- Iterative".Dump();
    timer.Elapsed.Dump();
}

结果:

Array - Single Init
00:00:26.9590931

Array - Iterative
00:00:02.0345341

我还在不同的电脑上运行了VS2013社区版最新的VS2015预览版,并得到了类似于LinqPad结果的结果。

我以发布模式运行了代码(即:启用编译器优化),与上面的结果非常不同。这两个代码块这次出来非常相似。这似乎表明这是一个编译器优化问题。

Array - Single Init
00:00:00.5511516

Array - Iterative
00:00:00.5882975

4
你的迭代列表正在添加10个项目,而其他的正在添加四个。 - Servy
4
你的测试可能无效,因为编译器可以优化掉无用代码,并且你没有对循环中创建的对象进行任何操作。 - gknicker
4
显然,如果你禁用优化,代码将会被糟糕地优化。这对于任何人都不应该是一个惊喜吧?如果你想关注操作的时间,你需要启用优化,否则结果就没有意义。 - Servy
1
有没有人注意到,内联版本创建了一个{1,2,3,4}的数组,而迭代版本创建了{0,1,2,3}的数组? - displayName
2
@displayName 哈哈,是的,但这不会影响性能 :) - Tamir Vered
显示剩余15条评论
2个回答

53
首先,由于 C# 级别的分析只会显示执行时间最长的 C# 代码行,这当然是内联数组初始化,所以我们无法从中获得任何信息。但为了好玩,我们还是来分析一下:

Profiling Results

现在,当我们看到预期的结果时,让我们观察一下IL级别的代码,并尝试看看两个数组初始化之间的区别:
  • 首先,我们将查看标准数组初始化:
  • For Loop

    一切看起来都很好,循环正在按照我们预期的方式执行,没有明显的开销。

  • 现在让我们来看看内联数组初始化:
  • Inline Array Initializer

    • 前两行创建了一个大小为4的数组。
    • 第三行将生成的数组指针复制到评估堆栈上。
    • 最后一行将数组本地设置为刚刚创建的数组。
现在我们将关注剩下的两行:

第一行代码(L_001B)加载了一些编译时类型,其类型名称为__StaticArrayInitTypeSize=16,字段名为1456763F890A84558F99AFA687C36B9037697848,并且它在名为<PrivateImplementationDetails>的类中,该类位于Root Namespace中。如果我们查看这个字段,我们会发现它完全包含了所需的数组,就像我们想要的那样将其编码为字节:

.field assembly static initonly valuetype <PrivateImplementationDetails>/__StaticArrayInitTypeSize=16 1456763F890A84558F99AFA687C36B9037697848 = ((01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00))

第二行调用一个方法,该方法使用我们刚在L_0060中创建的空数组和这个编译时类型返回初始化的数组。
如果我们尝试查看此方法的代码,我们将看到它是在CLR内部实现的
[MethodImpl(MethodImplOptions.InternalCall), SecuritySafeCritical, __DynamicallyInvokable]
public static extern void InitializeArray(Array array, RuntimeFieldHandle fldHandle);

所以我们要么需要在已发布的CLR源代码中找到其源代码,但我无法为此方法找到,要么我们可以在程序集级别上进行调试。由于我的Visual-Studio出现问题并且在程序集视图中存在问题,让我们尝试另一种方式,并查看每个数组初始化的内存写入。
从循环初始化开始,在开头我们可以看到有一个空的int[]被初始化(在图片中,0x724a3c88是int[]类型,0x00000004是数组的大小,然后我们可以看到16个字节的零)。

Empty Array Memory

当数组被初始化时,我们可以看到内存中填充了相同的类型大小指示器,只是它还包含数字0到3:

Initialized Array Memory

当循环迭代时,我们可以看到下一个数组(用红色标记)在第一个数组之后分配,这也意味着每个数组消耗16 + type + size + padding = 19字节

New Array

对于内联类型初始化器执行相同的过程后,我们可以看到在数组初始化之后,堆中还包含了其他类型,这可能是由于在System.Runtime.CompilerServices.InitializeArray方法内部进行的,因为数组指针和编译时类型标记被加载到评估堆栈上而不是堆中(在IL代码的L_001BL_0020行):

Inline Array Initialization

现在使用内联数组初始化程序分配下一个数组,我们可以看到下一个数组仅在第一个数组的开头后64个字节处分配!

2 Inline Initialized Arrays

因此,内联数组初始化器之所以更慢是由于以下几个原因:

  • 分配了更多的内存(来自CLR内部的不需要的内存)。
  • 除了数组构造函数之外,还有方法调用开销。
  • 如果CLR分配了除数组之外的更多内存,则可能会执行一些其他不必要的操作。

现在来看内联数组初始化器DebugRelease之间的区别:

如果您检查调试版本的汇编代码,它看起来像这样:

00952E46 B9 42 5D FF 71       mov         ecx,71FF5D42h  //The pointer to the array.
00952E4B BA 04 00 00 00       mov         edx,4  //The desired size of the array.
00952E50 E8 D7 03 F7 FF       call        008C322C  //Array constructor.
00952E55 89 45 90             mov         dword ptr [ebp-70h],eax  //The result array (here the memory is an empty array but arr cannot be viewed in the debug yet).
00952E58 B9 E4 0E D7 00       mov         ecx,0D70EE4h  //The token of the compilation-time-type.
00952E5D E8 43 EF FE 72       call        73941DA5  //First I thought that's the System.Runtime.CompilerServices.InitializeArray method but thats the part where the junk memory is added so i guess it's a part of the token loading process for the compilation-time-type.
00952E62 89 45 8C             mov         dword ptr [ebp-74h],eax
00952E65 8D 45 8C             lea         eax,[ebp-74h]  
00952E68 FF 30                push        dword ptr [eax]  
00952E6A 8B 4D 90             mov         ecx,dword ptr [ebp-70h]  
00952E6D E8 81 ED FE 72       call        73941BF3  //System.Runtime.CompilerServices.InitializeArray method.
00952E72 8B 45 90             mov         eax,dword ptr [ebp-70h]  //Here the result array is complete  
00952E75 89 45 B4             mov         dword ptr [ebp-4Ch],eax  

另一方面,发布版本的代码如下:
003A2DEF B9 42 5D FF 71       mov         ecx,71FF5D42h  //The pointer to the array.
003A2DF4 BA 04 00 00 00       mov         edx,4  //The desired size of the array.
003A2DF9 E8 2E 04 F6 FF       call        0030322C  //Array constructor.
003A2DFE 83 C0 08             add         eax,8  
003A2E01 8B F8                mov         edi,eax  
003A2E03 BE 5C 29 8C 00       mov         esi,8C295Ch  
003A2E08 F3 0F 7E 06          movq        xmm0,mmword ptr [esi]  
003A2E0C 66 0F D6 07          movq        mmword ptr [edi],xmm0  
003A2E10 F3 0F 7E 46 08       movq        xmm0,mmword ptr [esi+8]  
003A2E15 66 0F D6 47 08       movq        mmword ptr [edi+8],xmm0

调试优化使得无法查看arr的内存,因为在IL级别上该局部变量从未被设置。正如您所看到的,此版本使用movq,这是复制编译时类型的内存到已初始化数组的最快方法,通过两次复制QWORD(2个int放在一起!)来精确地复制我们数组的内容,即16位。

1
非常出色的回答,你是MSIL的大师!谢谢。 - AFract
我非常确定这是小端序,而不是大端序。大端序意味着最重要的字节先出现。这些是小端序:0x00000004 存储为 04 00 00 000x724a3c88 存储为 88 3c 4a 72,最不重要的字节(即小端)先出现。 - Dave Cousineau
1
我时不时地会来看这个答案,每次都试图更好地理解它。 - DLeh
InitializeArray的源代码在这里:https://github.com/dotnet/coreclr/blob/c98155df54da9247e598068cc956dfe9aede610e/src/classlibnative/bcltype/arraynative.cpp#L1373 - poizan42

6

静态数组初始化的实现方式有所不同。它将把位存储在一个内嵌的类中,该类将被命名为<PrivateImplementationDetails>...

它所做的是将数组数据存储为位于某个特殊位置的程序集中,在从程序集加载时调用RuntimeHelpers.InitializeArray来初始化数组。

请注意,如果您使用反编译工具查看编译后的C#源代码,将无法注意到这里描述的任何内容。您需要查看反编译工具中的IL视图。

[MethodImpl(MethodImplOptions.InternalCall), SecuritySafeCritical, __DynamicallyInvokable]
public static extern void InitializeArray(Array array, RuntimeFieldHandle fldHandle);

你可以看到这是在CLR中实现的(标记为InternalCall),然后映射到COMArrayInfo::InitializeArray(ecall.cppsscli中)。
FCIntrinsic("InitializeArray", COMArrayInfo::InitializeArray, CORINFO_INTRINSIC_InitializeArray)

COMArrayInfo::InitializeArray(位于comarrayinfo.cpp中)是一个神奇的方法,它使用嵌入在汇编代码中的位值来初始化数组。

我不确定为什么这需要很长时间才能完成;我没有好的解释。我猜测这是因为它要从物理程序集中提取数据?我不确定。你可以自己深入研究这些方法。但你可以了解一些情况:它并不会被编译成你在代码中看到的那样。

你可以使用像IlDasmDumpbin这样的工具来查找更多信息,当然还可以下载sscli

顺便说一下:我从 "bart de smet" 的Pluralsight课程中获得了这些信息。


5
你所谈论的代码实际上并未被使用。抖动优化器会将其删除,因此根本不会剩下任何数组。这个问题的基本问题在于该基准测试并不代表真实的代码,并且没有启用优化器来执行它。 - Hans Passant
@HansPassant 是的,我注意到当优化开关打开时,它运行得非常快(显然被优化了)。当关闭优化时,也许这个魔法是导致性能损失的原因? - Sriram Sakthivel

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接