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

基准测试探险 - 内存分配

starIconstarIconstarIconstarIconstarIcon

5.00/5 (4投票s)

2016年9月11日

CPOL

6分钟阅读

viewsIcon

10349

基准测试探险 - 内存分配

一段时间以来,我一直在参与开源的 BenchmarkDotNet 库,以及项目所有者 Andrey Akinshin。我们的目标是提供一个 .NET 基准测试库,该库应该

  1. 准确
  2. 易于使用
  3. 有帮助

首先,我们尽一切努力确保 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 连接字符串”的指导,但实际区别是什么呢?

StringConcat Vs StringBuilder - Basic Results

嗯,就耗时而言,确实区别,但即使有 20 次循环,差距也不大,我们谈论的是大约 500 ns,即 0.0005 ms,所以您必须大量执行才能注意到减速。

但是,这次让我们看看如果我们启用了 BenchmarkDotNet 的“垃圾回收”(GC)诊断,结果会是什么样子。

StringConcat Vs StringBuilder - Results with GC Diagnostic

在这里,我们可以清楚地看到基准测试之间的区别。一旦我们超过 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 强制类型转换来访问。

那么我们得到的结果是什么?

Dictionary v IDictionary - GC Results.png

非常清楚,通过 IDictionary 接口访问相同的数据会导致大量额外的分配,每个 foreach 循环大约 22 字节。这反过来又会触发大量额外的 GC 回收。值得指出的是,当 BenchmarkDotNet 执行时,它会运行相同的基准测试方法(在此例中为 IDictionaryEnumeration())数百万次,以便我们获得准确的测量结果。因此,第 0 代回收的实际数量并不重要,重要的是与 DictionaryEnumeration() 基准测试相比的相对数量。

现在,这种情况可能看起来有点牵强,我必须承认在开始调查它之前就知道答案,然而它确实源于一个由 Ben Adams 发现的真实问题。有关完整的背景信息,请参阅 CoreCLR GitHub 问题 Avoid enumeration allocation via interface,但如下所示,这被识别出来是因为在 Kestrel/ASP.NET 中,请求/响应头保存在 IDictionary 数据结构中,因此在每秒 100 万次请求的处理速度下,每秒会产生额外的 128 MB 垃圾。

Dictionary v IDictionary - In Kestrel and ASPNET

最后,关于额外分配的技术解释是什么?引用 Microsoft 的 Stephen Toub 的话:

…但是当通过接口访问时,您使用的是返回 IEnumerator<KeyValuePair<TKey,TValue>> 的接口方法,而不是 Dictionary<TKey, TValue>.Enumerator因此该结构会被装箱

然后在同一问题的更下方

是的,问题不仅仅是枚举器分配,还有基于接口的分派。除了装箱枚举器之外,每个元素调用的 MoveNextCurrent 调用从潜在的可内联非虚拟调用变成了接口调用

实现细节

这一切都得益于 .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! 上。

© . All rights reserved.