【译】使用 Visual Studio Profiler 进行基准测试

在 Visual Studio 17.13 预览版中,我们发布了更新的 BenchmarkDotNet 诊断器,允许您使用性能分析器中的更多工具来分析基准测试。有了这个变化,可以非常快速地挖掘 CPU 使用情况和内存分配,从而使测量/修改/测量周期快速而高效。

对实际项目进行基准测试

因此,为了展示我们如何使用这些工具使事情变得更好,让我们来测试一个真实的项目。在撰写本文时,CsvHelper 是 Nuget.org 上排名67的最受欢迎的包,当前版本的下载量超过900万次。如果我们可以对其进行基准测试并使其变得更好,我们就可以帮助许多用户。

您可以在 https://github.com/karpinsn/CsvHelper 上拉取我的分支。值得注意的变化是,我添加了一个新的控制台项目(CsvHelper.Benchmarks),我们可以使用它来存储基准测试,添加了 BenchmarkDotNet 包来执行实际的基准测试运行,以及一个简单的 EnumerateRecords 基准测试,它将 CSV 流解析为记录,如下所示。

复制代码
public class BenchmarkEnumerateRecords
{
    private const int entryCount = 10000;
    private readonly MemoryStream stream = new();
    public class Simple
    {
        public int Id { get; set; }
        public string Name { get; set; }
    }
    
    [GlobalSetup]
    public void GlobalSetup()
    {
        using var streamWriter = new StreamWriter(this.stream, null, -1, true);
        using var writer = new CsvWriter(streamWriter, CultureInfo.InvariantCulture, true);
        var random = new Random(42); // Pick a known seed to keep things consistent
        var chars = new char[10];
        string getRandomString()
        {
            for (int i = 0; i < 10; ++i)
                chars[i] = (char)random.Next('a', 'z' + 1);
            return new string(chars);
        }
        writer.WriteHeader(typeof(Simple));
        writer.NextRecord();
        for (int i = 0; i < BenchmarkEnumerateRecords.entryCount; ++i)
        {
            writer.WriteRecord(new Simple()
            {
                Id = random.Next(),
                Name = getRandomString()
            });
            writer.NextRecord();
        }
    }
    
    [GlobalCleanup]
    public void GlobalCleanup()
    {
        this.stream.Dispose();
    }
    
    [Benchmark]
    public void EnumerateRecords()
    {
        this.stream.Position = 0;
        using var streamReader = new StreamReader(this.stream, null, true, -1, true);
        using var csv = new CsvReader(streamReader, CultureInfo.InvariantCulture, true);
        foreach (var record in csv.GetRecords<Simple>())
        {
            _ = record;
        }
    }
}

这里有几点需要注意。我们有一个全局设置函数,它创建一个简单的 CSV 流,并将其保存在内存流中。我们在基准测试运行的[GlobalSetup]中这样做,这样它就不会影响基准测试的结果,我们只想对 CSV 文件的实际解析进行基准测试,而不是创建测试数据。

接下来,我们有一个全局清理函数,它可以正确地释放我们的内存流,在添加更多基准测试的情况下,这是一个很好的实践,这样我们就不会持续泄漏内存。

最后,我们的基准测试只是从流中创建一个 CsvReader,然后从中读取每条记录。这将演习 CsvHelper 的解析功能,而这正是我们将要尝试和优化的。

深入了解基准

从这里,您可以向基准类添加一个 BenchmarkDotNet(BDN)Diagnoser,以便在运行时捕获有关基准的信息。BenchmarkDotNet 附带了一个[MemoryDiagnosers],它可以捕获内存分配和总体内存使用信息。如果我们添加这个特性并运行基准测试,您应该得到类似的结果:

从这里可以看到 BDN 提供的正常平均值、误差和标准偏差,以及我们的诊断程序的输出,其中显示我们在基准测试期间分配了1.69 MB 内存,并将其分解为不同的 GC 堆。如果我们想进一步挖掘,然后我们可以包括来自 Nuget.org 的 Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers 包,它将 BenchmarkDotNet 挂到 VisualStudio Profiler 中,这样我们就可以看到在运行期间发生了什么。在包含这个包并将[DotNetAllocDiagnoser]和[DotNetObjectAllocJobConfiguration]添加到基准测试并重新运行后,我们得到:

最值得注意的是底部的一行,它显示了收集到的诊断文件的路径,这是 Visual Studio Profiler 文件格式的。随着新的更新,它会自动在 VS 中打开,现在我们有了所有需要挖掘的东西,可能会减少一些内存分配。

狩猎内存分配

现在我们有了一个详细描述运行中所有内存分配的诊断,让我们看看是否可以减少我们正在进行的内存分配并减少垃圾收集器的负载。

由于我们的基准测试被设计为从内存流中反序列化10,000条记录,因此我们希望查找10,000的倍数,因为这表明它正在为每条记录进行分配。我们立即看到 String, Type[],int32 和 Simple。String 和 Int32 是我们的 Simple 记录类型上的属性这是合理的分配,Simple 是我们反序列化的记录类型这也是合理的分配。Type[] 有点可疑,进一步挖掘事情只会看起来更糟:

在这种情况下,看起来我们正在为我们反序列化的每条记录分配一个空的 Type[],每条记录为24字节,在这个基准测试运行中总共分配了7.6MB。在基准测试中,这些无法保存任何数据的垃圾分陪占总内存分配的14%。这太疯狂了,我们应该能解决好的。双击该类型显示了回溯,这表明它来自某个匿名函数:

转到源代码(右键单击上下文菜单->Go to Source File),我们看到以下内容:

对我来说,这个分配的来源并不明显,所以最简单的方法就是在 CreateInstance 调用中添加一个断点,然后在调试器中查看。现在 BDN 在一个单独的进程中运行我们的基准测试,以更好地控制基准测试,所以要调试,我们只需实例化我们的基准测试并自己调用基准测试方法。我们可以这样更新 main:

复制代码
static async Task Main(string[] args)
{
    //_ = BenchmarkRunner.Run<BenchmarkEnumerateRecords>();

    var benchmarks = new BenchmarkEnumerateRecords();
    benchmarks.GlobalSetup();
    await benchmarks.EnumerateRecords();
    benchmarks.GlobalCleanup();
}

在调试器中运行,我们到达分配发生的地方:

再一次,它不是特别明显的显示分配发生在哪里,所以让我们进入调用,以防这是来自内联框架:

该方法有一个 Type 参数,但没有 Type[]。它是一个相对较短的函数,也许它和它的调用者是内联的,所以让我们再次进入。

不幸的是,还是没有 Type[],但是这个方法被标记为 AggressiveInlining,这解释了为什么我们没有在分配堆栈中看到它。最后一步,我们得到了 Type[] 分配!

这就是"啊哈时刻"!我们调用 GetArgTypes,它根据传入的 object[] 返回 Type[]。我们首先分配一个与 object[] 大小相同的数组,但如果 object[] 的长度为0,那么我们分配一个新的长度为0的数组。在这种情况下,我们可以很容易地通过检查参数大小和在没有参数可以从中获取类型的情况下尽早返回来解决这个问题。

复制代码
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static Type[] GetArgTypes(object?[] args)
{
    if (args.Length == 0)
    {
        return Array.Empty<Type>();
    }
    var argTypes = new Type[args.Length];
    for (var i = 0; i < args.Length; i++)
    {
        argTypes[i] = args[i]?.GetType() ?? typeof(object);
    }
    return argTypes;
}

在做了这个更改之后,我们可以重新运行我们的基准测试,这是一个测量/修改/测量的过程,我们得到以下结果:

我们有效地减少了约14%的分配内存!虽然这看起来不是一个巨大的胜利,但这与记录的数量有关。对于具有大量记录的 CSV 文件来说,这是一个巨大的胜利,特别是在一个已经非常快速和大量优化的库中。

让我们知道您的想法

总而言之,我们能够在一篇博客文章中对一个真实的项目,添加一个基准测试,使用 Visual Studio 分析器,并做出有意义的贡献。通过创建基准测试套件,可以很容易地隔离您希望通过测量/修改/测量来改进的特定代码,并查看性能优化的影响。我们很想听听您的想法!

原文连接:https://devblogs.microsoft.com/visualstudio/benchmarking-with-visual-studio-profiler/#benchmarking-a-real-project