好,我们先看图的简单部分。峰值是由重新分配、复制和垃圾收集引起的——这并不奇怪。缓存位置很容易解释少数首次添加到列表的异常低时间 - 虽然堆仍然适合整个内存,但内存访问可以是随机的,同时仍然具有非常低的延迟。一旦堆变得足够大,并且数组长度值(以及列表计数值)距离插入的值足够远,缓存局部性就会变得明显 - 在我的机器上使用 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 本身,但趋势在测试数据中仍然可见,但我必须注意,我的曲线远没有你的那么平滑,而且偏差很大。典型的周期可能需要大约 20 毫秒,而峰值高达 5 秒。
好的,是时候看看拆解了。我的测试代码很简单(只是内部循环体):
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 方法本身了 :) 请记住,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
我们这里还有三个内存访问。前两个本质上是相同的,因为正在加载的值被放置得足够近。该数组只会在第一次调整数组大小之前并置,这进一步提高了前几次插入的缓存性能。请注意,CPU 在这里可以并行执行的操作并不多,但是三个内存访问仍然应该只支付一次延迟成本。几乎总是会正确预测分支 - 仅在达到数组大小时才采用,之后我们对每个列表执行一次相同的分支。
剩下两部分:添加项目本身,并更新列表的内部版本(以使列表上任何正在进行的枚举失败):
_items[_size++] = item;
_version++;
在汇编中有点冗长:)
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
就是这样。我们有永远不会被采用的分支(假设是单线程的;我们之前已经检查过数组大小)。我们有很多访问:四个与列表本身相关(包括两个更新),还有两个在数组上(包括一个更新)。现在,虽然列表上没有缓存未命中的原因(它几乎总是已经加载),但由于更新而导致失效。相反,在我们的场景中,数组访问总是会导致缓存未命中,唯一的例外是在第一次调整数组大小之前。实际上,您可以看到首先没有缓存未命中(数组和对象并置,很小),然后有一个未命中(仍然并置,但项目超出了缓存行),然后是两个(长度和项目访问超出了缓存行)。
这当然很有趣(并且可以从手动优化中受益微小位:P),但它再次只为我们提供了分析数据的“阶梯”。重要的是,没有涉及分配,所以没有 GC。
有了这一切,我得出的结论是,当不需要调整数组大小时,List.Add 确实是 O(1)。对于非常小的数组(以及与它们的引用共存的数组),有一些额外的优化可以使事情变得更快,但这在这里并不重要。
因此,您在分析数据中看到的趋势必须是环境的,或者与分析本身直接相关,或者只是选择不当的平均方法。例如,如果我在 100 000 个列表上运行它:
- 添加前 550 个项目
- 再添加 100 项
- 还有另外 100 项
2 和 3 所用的时间之间存在差异,但没有趋势 - 2 更快和 3 更快的可能性一样(在 ~400 毫秒的时间跨度上相差约 2 毫秒,所以大约有 0.5% 的偏差)。然而,如果我用 2100 个项目进行“热身”,则后续步骤所需的时间几乎是以前的一半。更改列表的数量不会对每个集合产生明显的影响(当然,只要所有内容都适合您的物理内存:))。
好的,即使只是在调试器外部以发布模式运行一个简单的Stopwatch,并且对结果数据进行简单采样,这也是非常明显的。所以我们可以排除分析影响和统计错误。
但环境原因可能是什么?
- GC 根本不参与,在数组大小调整之外。没有分配,并且分析器非常清楚在调整大小之间也没有发生 GC 的事实(尽管这对于并发 GC 的价值是有限的 :))。调整 GC 设置会使一切变得更慢,但同样,只会影响调整大小的尖峰及其附近的环境。最重要的是,列表的数量(以及堆大小)对趋势没有任何影响,如果 GC 是原因,这将是相当令人惊讶的。
- 堆是零散的,但非常有序。这使得重定位在内存压力下的开销更小,但同样只影响数组调整大小。在任何情况下,这都不足为奇,而且事实上有据可查。
所以,看看这一切……我不知道为什么会出现这种趋势。但是,请注意,趋势也绝对不是线性的——随着列表大小的增加,增长会迅速下降。从大约 15k 项开始,趋势完全消失,所以 Add 确实是 O(1),不包括数组调整大小 - 它只是在某些大小上有一些奇怪的行为 :)
...除非您预先分配列表。在这种情况下,结果与我仅基于缓存位置的预测 100% 一致。这似乎表明调整大小和 GCing 模式对通常的缓存算法的效率有巨大的影响(至少在我的 CPU 上——这会变化很大,我估计)。还记得我们谈到在整个Add 操作期间发生的缓存未命中吗?有一个技巧——如果我们可以在两个循环之间保持足够多的缓存行,缓存未命中将经常被避免;如果我们假设 64 字节缓存行和最佳缓存失效算法,您将不会错过 List 成员访问和数组长度访问,每个数组在 16 次添加中只有一次未命中。我们根本不需要数组的其余部分!您还需要一些其他缓存行(例如,列表实例),但数组是迄今为止最大的交易。
现在,让我们算一下。十万个集合,每个 2*64B 的缓存在最坏的情况下加起来为 12 MiB,我有 10 MiB 的缓存 - 我可以几乎将所有相关的数组数据放入缓存中!现在,当然,我不是唯一使用该缓存的应用程序(和线程),因此我们可以预期翻转点会略低于理想值 - 让我们看看更改集合数量如何改变我们的结果。
列表预分配到 8000 个项目 (32 kB),添加 2000 个项目,100、100
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
哈!很好看。时间随着列表计数线性增加,直到最后一个项目——那是我们的缓存用完的时候。这大约是缓存使用总量的 3-8 MiB - 很可能是我忽略了一些也需要缓存的重要事物,或者对部分操作系统或 CPU 进行了一些优化以防止我占用整个缓存或其他东西的结果 :)
非常小的列表计数中的轻微非线性很可能与低级缓存的缓慢溢出有关 - 400 很适合我的二级缓存,800 已经溢出一点,1600 多一点,到 3200 时,几乎可以完全忽略二级缓存。
对于我们的最终检查,相同的场景,但添加了 4000 个项目而不是 2000 个:
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
如您所见,项目数量对插入时间(每个项目)没有任何影响,整个趋势就消失了。
所以,你有它。这种趋势是由 GC 间接引起的(通过代码中的次优分配和 GC 中破坏缓存局部性的压缩模式)和直接缓存溢出。由于项目数量较少,任何给定的所需内存现在更有可能在缓存中。当数组需要调整大小时,大部分缓存的内存几乎毫无价值,并且会慢慢失效并被更有用的内存替换——但整个内存使用模式与 CPU 的优化模式相去甚远。相比之下,通过保持数组预先分配,我们确保一旦我们在内存中拥有列表,我们还可以看到数组长度(奖励 1),并且已经指向数组末尾的缓存行将在一些循环中有用(奖金 2)。由于没有调整数组大小,因此这些对象都不需要在内存中移动,而且托管非常好。