我使用.NET Framework中实现的集合类型做了一些基准测试。
从参考源我知道List<T>
使用数组来存储内容。为避免每次插入时调整数组大小,每次可用空间用完时都会array length gets doubled。
现在,我的基准测试会将随机long
值插入List
(请参阅上图中的大小 - 时间 - 图表)。有明显的&#34;滞后峰值&#34;列表大小如128或256,其中必须重新分配内部数组。但是大小为512(而128,但是?),似乎有一个非常大的延迟,插入一个项目所需的时间会持续增加。
根据我的理解,除了需要重新分配内部数组的情况外,图表应该是严格不变的。这种行为是否有任何原因,可能与CLR或Windows内存管理/内存碎片有关?
基准测试在Windows 10 / i7-3630QM机器上以64位应用程序执行(源代码如下所示)。由于单个添加操作无法测量,因此我创建了1000个列表,并为每个列表大小添加了一个项目。
for (int i = 1; i <= MaxCollectionSize; i++)
{
// Reset time measurement
TestContainer.ResetSnapshot();
// Enable time measurement
TestContainer.BeginSnapshot();
// Execute one add operation on 1000 lists each
ProfileAction.Invoke(TestContainer);
TestContainer.EndSnapShot();
double elapsedMilliseconds = (TestContainer.GetElapsedMilliSeconds() / (double)Stopwatch.Frequency) * 1000;
// ...
}
编辑:我仔细检查了我的结果,是的,它们是可重现的。我将测试的集合数量从1000增加到10000,结果现在更加平滑(见下图)。现在可以清楚地看到调整内部阵列大小的尖峰。然而,图中的步骤仍然存在 - 这是与预期的O(1)复杂性的分歧,如果忽略调整大小,数组插入应该是。
我还尝试在每次Add
操作之前触发GC集合,图表保持完全相同。
关于创建委托对象的问题:我的所有委托(如ProfileAction
)都是在一个完整的测试周期中保留的实例属性,在本例中是10000个列表,每个列表都有1000个添加操作。
答案 0 :(得分:5)
C#是否以不同的方式存储大于512长(4096字节)的数组?
没有。当总大小为(IIRC)84kB或更大时,它会使用:使用大对象堆(不是压缩或世代)。
然而:
创建1000个列表,并为每个列表大小添加一个项目。
每次测试时间约为5毫秒。 Windows调度程序增量大于此值(实际值已在40毫秒到100毫秒之间使用,具体取决于版本和版本)。你能看到调度程序执行一个线程切换吗?
建议你尝试每种尺寸运行至少250毫秒,以平衡这些效果。
编辑:另外,正如Lasse对问题的评论所说:这可能是GC。为了消除时间,在尺寸循环开始时,但在开始时钟之前,强制GC。还要监控GC性能计数器。
答案 1 :(得分:3)
好的,让我们先看看图片的简单部分。尖峰是由重新分配,复制和垃圾收集引起的 - 没有什么大惊喜。缓存局部性很容易解释列表中少数第一次添加的异常低时间 - 虽然堆仍然适合整个内存,但内存访问可以是随机的,同时仍然是非常低的延迟。一旦堆变得足够大,并且数组长度值(以及列表计数值)与插入的值相距足够远,缓存局部性就会变得明显 - 在我的机器上用32位x86代码进行测试时,优化缓存局部性可以将整个测试的性能提高四倍。
然而,虽然这些效果很好地解释了尖峰本身以及每个尖峰之后的操作比尖峰之前花费更多时间的事实,但它们并没有真正解释随后的趋势 - 没有明显的插入第600个元素的原因应该比插入第550个元素花费更长的时间(假设最后一次调整大小为512左右)。分析很好地证明了不变的成本非常高,但是并没有显示出随着时间的推移显着增加的东西。
我的测试代码被简化为基础:
var collections = new List<int>[100000];
for (var i = 0; i < collections.Length; i++)
{
collections[i] = new List<int>();
}
for (var i = 0; i < 1024; i++)
{
for (var j = 0; j < collections.Length; j++)
{
collections[j].Add(i);
}
}
尽管剩下的唯一抽象是Add
本身,但趋势在测试数据中仍然可见,但我必须注意到我的曲线远不如你的那么平滑,并且偏差很大。典型的周期可能需要大约20ms,而峰值可能高达5s。
好的,是时候看看反汇编了。我的测试代码非常简单(只是内部循环体):
002D0532 mov eax,dword ptr [ebp-18h]
002D0535 mov ecx,dword ptr [eax+esi*4+8]
002D0539 mov edx,ebx
002D053B cmp dword ptr [ecx],ecx
002D053D call 7311D5F0
collections
引用存储在堆栈中。正如预期的那样,i
和j
都在寄存器中,实际上,j
位于esi
中,这非常方便。因此,首先我们引用collections
,添加j * 4 + 8
以获取实际的列表引用,并将其存储在ecx
(this
中我们方法中的i
。重新打电话)。 ebx
存储在edx
中,但必须移至Add
才能调用Add
- 在两个通用寄存器之间传输值没什么大不了的,但是那里&# 39; s简单的乐观空检查,最后是调用本身。
首先要注意的是,没有涉及分支,因此没有分支错误预测。其次,我们有两个内存访问 - 第一个是在堆栈上,几乎可以保证总是在缓存中。第二个更糟糕 - 我们得到缓存局部性问题。但是,由此产生的延迟完全取决于数组的长度(和数量),因此应该(和确实)与数组的大小相关。
查看ecx
方法本身的时间:)请记住,edx
包含列表实例,而8bf1 mov esi, ecx
8bfa mov edi, edx
8b460c mov eax, DWORD PTR [esi+0xc] ; Get the list size
8b5604 mov edx, DWORD PTR [esi+0x4] ; Get the array reference
3bf204 cmp eax, DWORD PTR [edx+0x4] ; size == array.Length?
741c je HandleResize ; Not important for us
包含我们正在添加的项目。
首先,有通常的方法序言,没什么特别的。接下来,我们检查数组大小:
_items[_size++] = item;
_version++;
我们这里还有三次内存访问。前两个基本相同,因为加载的值足够紧密地共同定位。该阵列仅在第一个数组调整大小之前进行共处,这进一步提高了前几个插入的缓存性能。请注意,CPU在这里可以并行执行并不是很多,但是三次内存访问仍然只需要支付延迟成本一次。分支几乎总是被正确预测 - 只有在达到数组大小后才会进行预测,之后我们为每个列表执行相同的分支。
剩下两件:添加项目本身,并更新列表的内部版本(以使列表中的任何正在进行的枚举失败):
8b5604 mov edx, DWORD PTR [esi+0x4] ; get the array reference again
8b4e0c mov ecx, DWORD PTR [esi+0xc] ; ... and the list size
8d4101 lea eax, [ecx+0x1] ; Funny, but the best way to get size + 1 :)
89460c mov DWORD PTR [esi+0xc], eax ; ... and store the new size back in the list object
3b4a04 cmp ecx, DWORD PTR [edx+0x4] ; Array length check
7318 jae ThrowOutOfRangeException ; If array is shorter than size, throw
897c8a08 mov DWORD PTR [edx+ecx*4+0x8], edi ; Store item in the array
ff4610 inc DWORD PTR [esi+0x10] ; Increase the version
; ... and the epilogue, not important
在集会中有点啰嗦:)
Stopwatch
那就是它。我们有永远不会采取的分支(假设是单线程的;我们之前已经检查过数组大小)。我们有很多访问:四个相对于列表本身(包括两个更新),另外两个在数组上(包括一个更新)。现在,虽然没有理由在列表上发生缓存未命中(它几乎总是已加载),但由于更新而存在失效。相反,在我们的场景中,阵列访问总是会导致缓存未命中,唯一的例外是在第一个阵列调整大小之前。事实上,你可以看到,首先没有缓存未命中(数组和对象共处,小),然后一个未命中(仍然并置,但超出缓存行的项目),然后是两个(长度和项目访问超出了缓存行)。
这当然很有趣(并且可以从手动优化中获得 tiny 位:P),但它再次只给我们提供了#34;楼梯&#34;关于分析数据。重要的是,没有涉及分配,因此没有GC。
有了这一切,我得出结论,当不需要调整数组时,List.Add确实是O(1)。对于非常小的数组(以及与其引用相关的数组),有一些额外的优化可以使事情变得更快,但这在这里并不重要。
因此,您在分析数据中看到的趋势必须是环境趋势,或者与分析本身直接相关,或者只是选择不当的平均方法。例如,如果我在100 000个列表上运行它:
时间2和3之间存在差异,但没有趋势 - 它可能更快2,因为3更快(在时间跨度上差异约为2毫秒) 〜400ms左右,偏差约0.5%)。然而,如果我做了#34;热身&#34;与2100项相反,后续步骤几乎是以前的一半。更改列表的数量并不会对每个集合产生明显的影响(当然,只要所有内容都适合您的物理内存:)。)
好的,即使在发布模式下只在调试器外部运行一个简单的Add
,并且对结果数据进行简单采样,这一点也非常明显。因此,我们可以排除分析效果和统计错误。
但可能是环境原因?
所以,看看这一切......我不知道为什么趋势存在。但是,请注意趋势肯定不是线性的 - 当您增加列表大小时,增加会迅速下降。从约15k项开始,趋势完全消失,因此Add
确实是O(1),不包括数组调整大小 - 它只是在某些大小上有一些奇怪的行为:)
...除非您预先分配列表。在这种情况下,结果与我基于缓存局部性的预测100%一致。这似乎表明调整大小和GCing模式对通常的缓存算法的效率有很大影响(至少在我的CPU上 - 这会有很大的不同,我会重新调整)。还记得我们谈到整个Lists A B C
400 18 1 1
800 52 2 2
1600 120 6 6
3200 250 12 12
6400 506 25 25
12800 1046 52 53
25600 5821 270 270
操作期间发生的缓存未命中吗?这是一个技巧 - 如果我们能够在两个循环之间保持足够的缓存行,则可以经常避免缓存未命中;如果我们假设64字节高速缓存行和最佳高速缓存失效算法,您将不会在List成员访问和数组长度访问中获得错误,每16个增加一次,每个数组只丢失一次。我们根本不需要阵列的其余部分!还有一些你需要的缓存行(例如,列表实例),但这些数组是迄今为止最大的一笔。
现在,让我们做数学运算。十万个集合,在最坏的情况下每个2 * 64B的缓存增加到12 MiB,我有10 MiB的缓存 - 我可以几乎适合缓存中的所有相关数组数据!现在,当然,我不是唯一使用该缓存的应用程序(和线程),因此我们可以预期翻转点会低于理想值 - 让我们看看如何更改集合的数量改变我们的结果。
预先分配到8000个项目(32 kB)的列表,添加2000个项目,100个,100个
Lists A B C
400 42 1 1
800 110 3 2
1600 253 6 6
3200 502 12 12
6400 1011 25 25
12800 2091 52 53
25600 10395 250 250
哈!非常清晰可见。时间顺序很好地随着列表计数线性增加,直到最后一项 - 我们的缓存耗尽时。这总计大约3-8 MiB的缓存使用总量 - 很可能是因为我忽略了一些需要缓存的重要事情,或者部分操作系统或CPU的一些优化以防止我占用整个缓存或东西:)
非常小的列表计数中的轻微非线性很可能与较低级别缓存的缓慢溢出有关 - 400适合我的L2缓存,800已经溢出一点,1600相当多一点当我们达到3200时,L2缓存几乎可以完全忽略。
对于我们的最终检查,相同的情况,但添加4000项而不是2000:
{{1}}
正如您所看到的,项目计数对插入时间(每个项目)没有任何影响,整个趋势就消失了。
所以,你有它。这种趋势是由GC间接导致的(通过代码中的次优分配和GC中的压缩模式中断缓存局部性)和直接缓存溢出。对于较小的项目计数,现在任何给定的所需内存都将更多地存在于缓存中。当需要调整数组的大小时,大多数缓存内存都是毫无价值的,并且会慢慢失效并替换为更有用的内存 - 但整个内存使用模式与CPU优化的内容相差甚远。相反,通过保持数组预分配,我们确保一旦我们在内存中有列表,我们也会看到数组长度(奖励1),并且已经指向数组末尾的缓存行将对几个循环有用(奖金2)。由于没有数组调整大小,所以这些对象都不需要在内存中移动,并且有很好的托管。