专业编程基础技术教程

网站首页 > 基础教程 正文

C#想要跑的快,就得问BenchMarkDotnet!

ccvgpt 2024-12-28 11:47:14 基础教程 2 ℃

简介

了解我们的代码性能是开发的关键部分。我们力求在保持代码可读性的同时,编写最优化和性能最佳的代码。

在这篇文章中,我将向大家展示如何测试我们代码的性能,对我们的代码进行基准测试。

C#想要跑的快,就得问BenchMarkDotnet!

什么是基准测试

基准测试是在特定条件下衡量我们的代码、应用、系统或硬件的性能。

其目标是收集精确的数据,了解系统在处理速度、内存使用、资源消耗或吞吐量等指标上的表现,并确定哪些区域的性能可以被优化。

为什么使用Stopwatch不可靠

在C#中使用Stopwatch类进行基准测试有很多问题。尽管它为测量方法或过程的经过时间提供了一种简单的方式,但它缺乏精确的基准测试所需的精确度、控制和一致性。

在我讲述这个工具的不足之前,让我们看看我们如何可以用它来完成一些非常简单的任务。

using System.Diagnostics;

// 创建一个Stopwatch实例
var sw = new Stopwatch();

// 开始计时
sw.Start();

// 运行代码
var sum = 0;
for (int i = 0; i < 100; i++)
{
    sum += i * i;
    Console.WriteLine(#34;{sw.ElapsedMilliseconds}");
}
// 停止计时
sw.Stop();

// 打印使用时间
Console.WriteLine(#34;Elapsed time: {sw.ElapsedMilliseconds} ms");

这将打印出每次迭代过去的毫秒数以及最后经过的毫秒数。由于这是一个短程序,我们可以通过使用ticks来转换为纳秒,如下所示:

long ticks = stopwatch.ElapsedTicks;
double nanoseconds = (ticks * 1e9) / Stopwatch.Frequency;

如果我们想快速比较两种方法或在开发过程中识别明显的性能瓶颈,使用Stopwatch可能很有用。这是一种轻量级的方法,可以初步了解可能需要优化的代码部分。

Stopwatch的弊端

  • 默认情况下缺乏精确度,只能精确到大约100纳秒,这对于较小的快速微操作可能没有用。
  • JIT(即时)编译 - 当代码第一次运行时,JIT编译器在运行代码之前先编译代码,这会导致延迟并错误的衡量完成的时间。后续运行的代码会稍微快一些,然而,Stopwatch并没有考虑到这一点。记住这一点,我们可以通过多次运行代码以求缓解这个问题。
  • 垃圾收集(GC) - 如果在Stopwatch测量期间发生垃圾收集,记录的时间将包括GC暂停时间,这并不能反映我们的代码的实际执行时间。

这些只是使用Stopwatch测试代码性能的一些基本和最常见的缺陷,但还有其他的。

那么,最佳的方法是什么?

BenchmarkDotNet是一个在.NET中进行基准测试的库,它非常的流行,可以通过nuget进行安装。

它以以下方式克服了许多上述挑战:

  • 代码预热 - 自动预热代码(通过运行代码几次)以避免与JIT相关的不准确性。
  • 多次代码迭代 - 多次运行代码以分析和计算统计汇总,包括执行时间、堆内存分配等。可以配置运行代码的次数。
  • 隔离环境 - 管理垃圾收集并隔离执行环境以减少外部干扰。

如何使用BenchMarkDotnet

首先,我们需要安装Nuget包。要做到这一点,我们需要在命令行/终端中运行以下命令:

dotnet add package BenchmarkDotnet

然后我们需要一些方法来进行基准测试,所以创建一个.Net 8 C#控制台应用程序,包含以下两个类文件:

// Program.cs
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<Benchmarks>();
//Benchmarks.cs
using BenchmarkDotNet.Attributes;

public class Benchmarks
{
    private readonly int[] _numbers = Enumerable.Range(1, 1000).ToArray();

    [Benchmark]
    public int ForLoopSum()
    {
        int sum = 0;
        for (int i = 0; i < _numbers.Length; i++)
        {
            sum += _numbers[i];
        }

        return sum;
    }

    [Benchmark]
    public int ForeachLoopSum()
    {
        var sum = 0;
        foreach (int number in _numbers)
        {
            sum += number;
        }

        return sum;
    }

    [Benchmark]
    public int LinqSelect()
    {
        return _numbers.Sum();
    }
}

以上,我们有三种不同的方法来累加一个整数数组,每种方法都以略有不同的方式进行。这是一个完美的例子,展示了基准测试如何帮助我们在代码库中选择最佳解决方案。

如何运行基准测试

要运行基准测试,我们可以在终端/命令行中运行以下命令。

dotnet build
dotnet run -c Release

然后,BenchmarkDotnet将多次运行标有 [Benchmark] 属性的方法,并将结果以易于阅读的表格形式输出,如下:

Method

Mean

Error

StdDev

ForLoopSum

434.2 ns

0.40 ns

0.31 ns

ForeachLoopSum

321.9 ns

1.22 ns

1.14 ns

LinqSelect

189.4 ns

0.84 ns

0.70 ns

  • Method - 测试中的方法名称
  • Mean - 显示以纳秒计的平均时间。
  • Error - 表示误差范围,告诉我们由于系统中的随机因素,“平均值”结果可能会有多大的变化。这个数字越低越好,这里我们可以看到非常小的误差范围,意味着结果是稳定的,而大的数字则意味着更多的不确定性/不可靠的结果。
  • StdDev - 显示基准测试结果的一致性。低偏差分数表明在多次运行中所花费的时间非常相似,增加了可靠性。如果标准偏差很高,那就意味着方法的执行时间在多次运行之间变化很大。

如何测量内存分配

了解我们方法的运行速度是很有必要的。然而,我们的性能和优化不仅仅关于执行时间,有时我们应该确保没有内存泄漏或大量的内存被使用,尤其是在大规模的执行过程中。 我们可以将 [MemoryDiagnoser] 应用到基准测试类,这会通知基准测试库将内存统计信息包含到被测试的方法中。

当我们运行我们的基准测试时,我们得到以下输出:

Method

Mean

Error

StdDev

Allocated

ForLoopSum

436.8 ns

5.32 ns

4.98 ns

-

ForeachLoopSum

324.6 ns

2.20 ns

2.06 ns

-

LinqSelect

192.7 ns

2.40 ns

2.24 ns

-

但等等,"Allocated"列只有一个破折号,这是为什么?结果在哪里?
像数组求和这样的简单操作通常不会分配内存,因为它们通常只使用栈内存,而BenchmarkDotNet并不以相同的方式跟踪栈内存。 但是,使用以下测试,我们可以看到如何分析内存分配:

public class MemoryBenchmark
{
    [Benchmark]
    public string StringConcatenation()
    {
        string result = "";
        for (int i = 0; i < 1000; i++)
        {
            result += "text";
        }
        return result;
    }

    [Benchmark]
    public string StringBuilderConcatenation()
    {
        var builder = new System.Text.StringBuilder();
        for (int i = 0; i < 1000; i++)
        {
            builder.Append("text");
        }
        return builder.ToString();
    }
}

输出:

Method

Mean

Error

StdDev

Gen0

Allocated

StringConcatenation

218.930 us

0.7230 us

0.6409 us

641.8457

3933.56 KB

StringBuilderConcatenation

1.645 us

0.0034 us

0.0030 us

2.6875

16.47 KB

这里我们有两个新的列:

  • Gen0列:
    Gen0列指示在每个方法的执行期间发生了多少次Gen0垃圾收集。
    .Net使用一个分代垃圾收集系统,其中内存被分为三个"代"(Gen0,Gen1,和Gen2)。
    • Gen0(第0代):保存短期对象,如临时变量和小的、快速丢弃的对象。Gen0收集是GC最快的类型,但仍会引入一些开销。Gen0的例子包括方法中的局部变量、临时对象,或者后面不再使用的方法调用参数。
    • Gen1和Gen2:这是用于生命周期较长的对象,这些对象在Gen0收集后依然存在,如在应用程序的生命周期内保持活动的静态对象(即,单例),缓存对象或在许多操作中使用的大型集合。
  • Gen0中的对象被快速但频繁地收集,而Gen2中的对象被不频繁但更费力地收集,因为它们更大或更持久。大量的Gen0收集可能是内存使用效率低下的指标,而Gen2或3的收集可能表明我们的应用程序在内存中保持了太多的长期对象。
  • Allocated列:
    Allocated列显示每个方法在执行期间分配的总内存。这通常以千字节(KB)为单位报告。

这些信息帮助我们了解每个方法的内存密集程度,这可能会影响性能,特别是如果该方法被频繁调用。

例如,StringBuilderConcatenation比StringConcatenation更节省内存,所以在内存使用有限制或者这个操作被频繁执行的情况下,它更可取。

我们还可以用BenchmarkDotnet测试什么?

吞吐量

  • 分析每秒可以执行多少次方法的迭代。
  • 表示代码的效率和可扩展性。

JIT(即时)优化影响

  • 评估JIT优化对性能的影响。
  • 可以测试冷启动(首次运行性能)与稳定状态性能(后续运行)。

平台和框架的差异
我们可以在不同的.NET运行时(例如,.NET 6,.NET 8,.NET Framework)上运行相同代码的基准测试,以比较是否值得将我们的应用程序升级到新的系统。
只需在应用程序的.csproj文件中更新TargetFramework节点,以指向我们希望测试的框架。
在我们的基准测试类中添加以下属性(基于目标运行时)。

[SimpleJob(runtimeMoniker: RuntimeMoniker.Net60)]
[SimpleJob(runtimeMoniker: RuntimeMoniker.Net80)]

当我们运行我们的应用程序时,我们会得到如下的输出,突出显示了.net 6和.net 8在方法上的差异。

Method

Job

Runtime

Mean

Error

StdDev

StringConcatenation

.NET 6.0

.NET 6.0

286.503 us

3.5004 us

3.1030 us

StringBuilderConcatenation

.NET 6.0

.NET 6.0

4.595 us

0.0620 us

0.0580 us

StringConcatenation

.NET 8.0

.NET 8.0

222.270 us

1.7561 us

1.4664 us

StringBuilderConcatenation

.NET 8.0

.NET 8.0

1.650 us

0.0139 us

0.0116 us

输入参数的影响

  • 支持参数化基准测试,以测试不同输入如何影响性能。
  • 有助于确定最优输入范围或问题边缘案例。

我们可以这样做:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

public class SortBenchmark
{
    [Params(10, 100, 1000)]  // 数组大小
    public int N;

    [Params(10, 100, 1000)]  // 数组最大数值
    public int MaxValue;

    private int[] data;

    [GlobalSetup]
    public void Setup()
    {
        data = new int[N];
        var rand = new Random();
        for (int i = 0; i < N; i++)
        {
            data[i] = rand.Next(MaxValue);
        }
    }

    [Benchmark]
    public void SortArray()
    {
        Array.Sort(data);  // 排序
    }
}

class Program
{
    static void Main(string[] args)
    {
        // 运行 benchmark
        BenchmarkRunner.Run<SortBenchmark>();
    }
}

第三方库性能
使用上述技术,我们可以比较完成相同任务的不同第三方库的性能,以便在库使用上做出正确的决策。

结论

以上就是如何对我们的C#应用程序进行基准测试。使用这些方法、工具和技术的组合,可以给我们提供多样化的基准测试。 我们可以使用基准测试来改进我们的应用程序的代码库,帮助做出升级路径和方法选择的决策。
这就是这篇关于benchmarking的介绍,不知道是否对大家有帮助?欢迎大家留言告诉我^_^

Tags:

最近发表
标签列表