基准测试探险 - 内存分配





5.00/5 (4投票s)
基准测试探险 - 内存分配
一段时间以来,我一直在参与开源的 BenchmarkDotNet 库,以及项目所有者 Andrey Akinshin。我们的目标是提供一个 .NET 基准测试库,该库应该
- 准确
- 易于使用
- 有帮助
首先,我们尽一切努力确保 BenchmarkDotNet 提供准确的测量结果,其他一切都只是 “圣代上的糖霜”。也就是说,没有准确的测量结果,基准测试库就毫无用处,尤其是显示纳秒级结果的库。
但是,一旦解决了第一点 “如何工作”,第二点 “入门” 就变得有些主观了。使用 BenchmarkDotNet 几乎只需要在方法上添加一个 [Benchmark]
特性,然后按照 GitHub README 中的 分步指南 来运行它。我将让您自行决定它是否易于使用,但这仍然是我们努力的方向。完成“入门”指南后,还有一套完整的 教程基准测试,以及一些更 真实的示例 供您参考。
“有帮助”
但这篇帖子不会是 BenchmarkDotNet 的一般教程,而是将重点介绍它提供的一些特定工具,用于诊断基准测试中发生的情况,或者换句话说,帮助您回答“为什么基准测试 A 比基准测试 B 慢?”这个问题。
字符串连接与 StringBuilder
让我们从一个简单的基准测试开始
public class Framework_StringConcatVsStringBuilder { [Params(1, 2, 3, 4, 5, 10, 15, 20)] public int Loops; [Benchmark] public string StringConcat() { string result = string.Empty; for (int i = 0; i < Loops; ++i) result = string.Concat(result, i.ToString()); return result; } [Benchmark] public string StringBuilder() { StringBuilder sb = new StringBuilder(string.Empty); for (int i = 0; i < Loops; ++i) sb.Append(i.ToString()); return sb.ToString(); } }
注意:如果还不清楚,[Params(..)]
特性允许您针对一组不同的输入值运行相同的基准测试。在这种情况下,在运行基准测试的另一个实例之前,Loops
字段会依次设置为列表中的每个值,即 1, 2, 3, 4, 5, 10, 15, 20
。
如果您在 C# 中编程的时间够长,您无疑会得到“使用 StringBuilder 连接字符串”的指导,但实际区别是什么呢?
嗯,就耗时而言,确实有区别,但即使有 20
次循环,差距也不大,我们谈论的是大约 500 ns
,即 0.0005 ms
,所以您必须大量执行才能注意到减速。
但是,这次让我们看看如果我们启用了 BenchmarkDotNet 的“垃圾回收”(GC)诊断,结果会是什么样子。
在这里,我们可以清楚地看到基准测试之间的区别。一旦我们超过 10 次循环,StringBuilder
基准测试就比 StringConcat
高效得多。它会减少更多的“第 0 代”回收,并且每次 Operation
(即每次调用基准测试方法)分配的字节数大约减少 50%。
值得注意的是,在这种情况下,10 次循环是临界点。在此之前,StringConcat
速度稍快且内存分配较少,但在之后,StringBuilder
更高效。原因是 StringBuilder
类本身存在内存开销,当您只追加少量短 string
(正如在此特定基准测试中所做的那样)时,这种开销会主导成本。有趣的是,.NET 运行时开发人员注意到了这种开销,因此引入了一个 StringBuilder 缓存,以便重用现有实例,而不是每次都分配新实例。
Dictionary vs IDictionary
但一个不太为人所知的例子呢?假设在进行一些重构后,您注意到应用程序触发了更多的第 0/1/2 代回收(您是否监控了生产系统中的这些指标?)在查看最近的代码提交并进行一些分析后,您将问题缩小到一个将变量声明从 Dictionary
更改为 IDictionary
的重构,即 Stack Overflow 问题正在讨论的正是这种类型的重构。
为了基准测试实际发生的情况,我们可以编写如下代码
public class Framework_DictionaryVsIDictionary { Dictionary<string, string=""> dict; IDictionary<string, string=""> idict; [Setup] public void Setup() { dict = new Dictionary<string, string="">(); idict = (IDictionary<string, string="">)dict; } [Benchmark] public Dictionary<string, string=""> DictionaryEnumeration() { foreach (var item in dict) { ; } return dict; } [Benchmark] public IDictionary<string, string=""> IDictionaryEnumeration() { foreach (var item in idict) { ; } return idict; } }
注意:我们特意不对 foreach
循环中的项做任何操作,因为我们只想查看这 2 个集合迭代的差异。另请注意,我们使用的是相同的底层数据结构,只是在第二个基准测试中通过 IDictionary
强制类型转换来访问。
那么我们得到的结果是什么?
非常清楚,通过 IDictionary
接口访问相同的数据会导致大量额外的分配,每个 foreach
循环大约 22 字节。这反过来又会触发大量额外的 GC 回收。值得指出的是,当 BenchmarkDotNet 执行时,它会运行相同的基准测试方法(在此例中为 IDictionaryEnumeration()
)数百万次,以便我们获得准确的测量结果。因此,第 0 代
回收的实际数量并不重要,重要的是与 DictionaryEnumeration()
基准测试相比的相对数量。
现在,这种情况可能看起来有点牵强,我必须承认在开始调查它之前就知道答案,然而它确实源于一个由 Ben Adams 发现的真实问题。有关完整的背景信息,请参阅 CoreCLR GitHub 问题 Avoid enumeration allocation via interface,但如下所示,这被识别出来是因为在 Kestrel/ASP.NET 中,请求/响应头保存在 IDictionary
数据结构中,因此在每秒 100 万次请求的处理速度下,每秒会产生额外的 128 MB 垃圾。
最后,关于额外分配的技术解释是什么?引用 Microsoft 的 Stephen Toub 的话:
…但是当通过接口访问时,您使用的是返回
IEnumerator<KeyValuePair<TKey,TValue>>
的接口方法,而不是Dictionary<TKey, TValue>.Enumerator
,因此该结构会被装箱。
然后在同一问题的更下方:
是的,问题不仅仅是枚举器分配,还有基于接口的分派。除了装箱枚举器之外,每个元素调用的
MoveNext
和Current
调用从潜在的可内联非虚拟调用变成了接口调用。
实现细节
这一切都得益于 .NET 运行时生成的出色的 垃圾回收 ETW 事件。特别是,每次分配大约 100 KB 时都会触发的 GCAllocationTick_V2 事件。下面是一个典型事件的 XML 表示,您可以看到刚刚分配了 0x1A060
或 106,592 字节。
<UserData> <GCAllocationTick_V3 xmlns='myNs'> <AllocationAmount>0x1A060</AllocationAmount> <AllocationKind>0</AllocationKind> <ClrInstanceID>34</ClrInstanceID> <AllocationAmount64>0x1A060</AllocationAmount64> <TypeID>0xEE05D18</TypeID> <TypeName>LibGit2Sharp.Core.GitDiffFile</TypeName> <HeapIndex>0</HeapIndex> <Address>0x32056CD0</Address> </GCAllocationTick_V3> </UserData>
为了收集这些事件,BenchmarkDotNet 使用了 Windows 内置的 logman 工具。该工具在后台运行,收集指定的 ETW 事件,直到您要求停止。这些事件会持续写入一个 .etl
文件,然后可以使用 Windows Performance Analyzer 等工具读取该文件。ETW 事件收集完毕后,BenchmarkDotNet 会使用出色的 TraceEvent 库解析它们,代码如下所示:
using (var source = new ETWTraceEventSource(fileName)) { source.Clr.GCAllocationTick += (gcData => { if (statsPerProcess.ContainsKey(gcData.ProcessID)) statsPerProcess[gcData.ProcessID].AllocatedBytes += gcData.AllocationAmount64; }); source.Clr.GCStart += (gcData => { if (statsPerProcess.ContainsKey(gcData.ProcessID)) { var genCounts = statsPerProcess[gcData.ProcessID].GenCounts; if (gcData.Depth >= 0 && gcData.Depth < genCounts.Length) { // ignore calls to GC.Collect(..) from BenchmarkDotNet itself if (gcData.Reason != GCReason.Induced) genCounts[gcData.Depth]++; } } }); source.Process(); }
希望这向您展示了 BenchmarkDotNet 的一些强大功能,下次您需要对 .NET 代码进行(微)基准测试时,请考虑尝试一下。希望它可以避免您自己编写基准测试代码。
这篇名为 Adventures in Benchmarking - Memory Allocations 的帖子首次出现在我的博客 Performance is a Feature! 上。
CodeProject