BenchmarkDotNet:.NET 性能基准测试入门

BenchmarkDotNet:.NET 性能基准测试入门

前言

在 .NET 开发中,我们经常会纠结:​string +​、插值字符串、​string.Concat​ 哪个更快?​List<T>​ 和数组遍历性能差距多大?​Span<T> 真的能减少内存分配吗?

很多人会用 ​Stopwatch 简单计时,但手动计时有一堆问题:没有预热、受 JIT 编译干扰、样本太少、无法统计内存 GC、误差不可控。

BenchmarkDotNet 是 .NET 生态公认的基准测试库,它自动处理预热、多轮采样、统计标准差、输出内存分配与 GC 信息,帮你得到可信的性能数据。

️ 核心前提:BenchmarkDotNet 必须在 Release 模式运行,Debug 下 JIT 优化被关闭,测试结果没有参考价值。

一、快速上手:创建项目并安装包

推荐单独新建一个控制台项目存放基准代码,不和业务代码、单元测试混在一起,避免互相干扰。

# 创建控制台项目
dotnet new console -n BenchmarkDemo
cd BenchmarkDemo

# 安装 BenchmarkDotNet
dotnet add package BenchmarkDotNet

二、集成与测试

1. 全新项目独立测试

示例:字符串拼接对比

新建基准测试类,我们对比三种字符串拼接方式。​[MemoryDiagnoser]​ 是最常用的诊断器,可以输出内存分配、Gen0 GC 信息。​[Benchmark(Baseline = true)] 标记基准方法,其他方法会自动和它对比,生成 Ratio 相对比值。

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

[MemoryDiagnoser]
public class StringConcatBenchmark
{
    private readonly string _s1 = "Hello";
    private readonly string _s2 = "BenchmarkDotNet";

    /// <summary>基准方法</summary>
    [Benchmark(Baseline = true)]
    public string PlusOperator()
    {
        return _s1 + _s2;
    }

    [Benchmark]
    public string StringInterpolation()
    {
        return $"{_s1}{_s2}";
    }

    [Benchmark]
    public string StringConcat()
    {
        return string.Concat(_s1, _s2);
    }
}

// Program.cs
BenchmarkRunner.Run<StringConcatBenchmark>();

如果你有多个测试类,推荐使用 ​BenchmarkSwitcher,支持命令行筛选,更实用:

var switcher = BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly);
switcher.Run(args);

执行测试

执行命令,​一定要带 ​ ​-c Release​:

dotnet run -c Release

程序会自动完成这些步骤:

  1. 预热阶段:运行若干轮,消除 JIT 首次编译开销
  2. 正式采样:多次迭代采集数据
  3. 统计计算:均值、标准差、误差
  4. 输出报告:控制台 + 本地文件

测试报告默认输出目录:​BenchmarkDotNet.Artifacts/results里面包含 Markdown、CSV、HTML 可视化报告。

2. BenchmarkSwitcher方式

BenchmarkRunner.Run<xxx>()​ 只能单次硬编码跑一个 Benchmark 类;​BenchmarkSwitcher​ 扫描当前程序集里所有标记了 ​[Benchmark]​ 的类,​支持命令行参数筛选、交互式选择、批量运行,是实际项目里最常用的方式。

基础最简代码(Program.cs)

using BenchmarkDotNet.Running;

// 扫描当前程序集所有 Benchmark
var switcher = BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly);

// 把命令行 args 传给它
switcher.Run(args);

运行后有两种使用模式:交互模式 和 命令行参数模式

交互模式(不传任何参数)

dotnet run -c Release

程序会列出所有找到的 Benchmark,输入序号选择要跑哪一个:

Available benchmarks:
0: StringConcatBenchmark
1: ArraySumBenchmark
Select benchmark index:

适合本地随手测试。

命令行筛选模式

语法:​--filter 匹配表达式,glob 通配:

  • * 任意字符
  • ClassName 匹配类名
  • ClassName.MethodName 精准匹配方法
# 运行全部
dotnet run -c Release --filter *

# 只跑 StringConcatBenchmark 这个类下所有方法
dotnet run -c Release --filter *StringConcatBenchmark

# 只跑 StringConcatBenchmark 里面的 PlusOperator 方法
dotnet run -c Release --filter *StringConcatBenchmark.PlusOperator

# 匹配名字包含 Sum 的所有Benchmark
dotnet run -c Release --filter *Sum*

常用配套参数(和 filter 一起组合)

# 只列出所有可执行项,不运行(预览,非常好用)
dotnet run -c Release --list flat

# 输出简短结果,减少日志
dotnet run -c Release --filter * --short

匹配规则:​[Namespace.ClassName.MethodName],写过滤条件可以带上命名空间缩小范围。

指定多个筛选条件

多个 filter 用空格隔开:

dotnet run -c Release --filter *StringConcatBenchmark *ArraySumBenchmark

3. 结合现有项目代码

3.1 单独新建Benchmark 项目( 官方推荐,生产首选)

结构示例:

YourSolution/
├─ YourBusinessLib/        # 业务库(类库,核心算法、工具类)
└─ YourBenchmarkProject/  # Benchmark控制台项目

步骤:

  1. 解决方案里新增 ​Console 项目(Benchmark 项目)
  2. 这个 Benchmark 项目添加 NuGet:​BenchmarkDotNet
  3. Benchmark 项目添加​项目引用​,引用 ​YourBusinessLib
  4. 在 Benchmark 项目里写 Benchmark 类,直接 new 业务里的服务 / 工具类测试

示例: 业务代码(YourBusinessLib)

// YourBusinessLib/TextHelper.cs
namespace YourBusinessLib;
public static class TextHelper
{
    public static string ConcatText(string a, string b)
    {
        return a + b;
    }
}

Benchmark 项目代码:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using YourBusinessLib;

[MemoryDiagnoser]
public class TextHelperBench
{
    private string _a = "aaa";
    private string _b = "bbb";

    [Benchmark(Baseline = true)]
    public string TestConcat()
    {
        return TextHelper.ConcatText(_a, _b);
    }
}

// Program.cs
var switcher = BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly);
switcher.Run(args);

优点

  1. 不会污染业务项目依赖,业务不需要引用 BenchmarkDotNet
  2. 发布业务包 / 上线的时候,完全不带基准测试代码
  3. 配置干净,不容易误在 Debug、CI 里意外跑 benchmark
  4. 适合复杂方案:Web、类库、多模块项目

缺点

新增一个项目,解决方案多一个节点

运行

# 进到Benchmark项目目录执行
dotnet run -c Release --filter *TextHelperBench

3.2 Benchmark 和业务代码在同一个项目内(适合简单小工具、类库)

项目结构:

YourBusinessLib/
├─ Utils/TextHelper.cs       // 业务代码
└─ Benchmarks/TextHelperBench.cs // Benchmark类,放单独文件夹区分

操作要点:

  1. 在业务项目安装 ​BenchmarkDotNet NuGet 包
  2. Benchmark 类放在单独文件夹(​Benchmarks),方便区分业务代码
  3. 同样使用 ​BenchmarkSwitcher​,但是有一个关键问题:这个项目是类库,没有 Program 入口!

两种解决办法:

方式 B1:项目是控制台项目,自带 Program(最简单)

直接在 Program 写 Switcher,和之前一样。

方式 B2:项目是 ClassLib(类库,无入口)

不能直接 dotnet run。两种选择:

  1. 临时增加一个 ​BenchmarkEntry.cs​ 作为入口,​只在本地开发使用,发布时注意排除;
  2. 还是新建一个极小的 Benchmark 控制台项目引用这个类库(回到方案 A)。
// Benchmarks/TextHelperBench.cs
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser]
public class TextHelperBench
{
    private string _a = "aaa";
    private string _b = "bbb";

    [Benchmark]
    public string Test() => YourBusinessLib.TextHelper.ConcatText(_a, _b);
}
// BenchmarkEntry.cs (仅本地测试,提交代码可以 #if DEBUG 包裹)
#if DEBUG
using BenchmarkDotNet.Running;
namespace YourBusinessLib;
public class BenchmarkEntry
{
    public static void Main(string[] args)
    {
        var switcher = BenchmarkSwitcher.FromAssembly(typeof(BenchmarkEntry).Assembly);
        switcher.Run(args);
    }
}
#endif

️ 这里用 ​#if DEBUG​ 只是一个保护手段:​Benchmark 仍然必须 Release 运行,这个预编译只是防止上线打包带进入口代码,不要混淆。

优点

  • 不用新增项目,代码就近,修改业务代码同步修改 benchmark
  • 小工具库非常方便

缺点

  1. 业务项目依赖了 BenchmarkDotNet,打包、nuget 发布时会带上这个包,需要在 csproj 标记 PrivateAssets=”all”

    <PackageReference Include="BenchmarkDotNet" Version="0.14.0" PrivateAssets="all" />
    

    PrivateAssets="all":代表这个包只用于开发,不会作为依赖对外输出。

  2. 容易误提交 Benchmark 代码到生产分支、CI 流水线误跑基准测试

  3. 如果是 Core Web 项目:不推荐,Benchmark 会扫描整个程序集,启动速度变慢,还容易和 Web 托管环境冲突

三、BenchmarkDotNet 配合 ASP.NET Core DI 测试业务服务

很多业务代码依赖依赖注入容器:​IxxxService​、仓储、配置等,不能直接 ​new​ 实例。BenchmarkDotNet 本身不带DI,我们遵循一套​通用写法,不管是【独立Benchmark项目】还是【和Api同项目】都可以复用。

第一步: 把 WebApi 的服务注册逻辑抽成扩展方法(Web 和 Benchmark 共用,保证注册一致)

public static IServiceCollection AddBusinessServices(this IServiceCollection services)
{
    services.AddScoped<IUserService, UserService>();
    services.AddScoped<IOrderRepository, OrderRepository>();
    services.Configure<AppSettings>(opt => { });
    // 关闭日志,避免日志输出干扰性能统计
    services.AddLogging(c => c.ClearProviders());
    return services;
}

Web 项目 ​Program.cs 正常调用:

builder.Services.AddBusinessServices();

第二步: Benchmark 类,固定模板

[MemoryDiagnoser]
public class UserServiceBenchmark
{
    // 缓存业务服务实例
    private IUserService _userService = null!;
    private IServiceProvider _serviceProvider = null!;

    [GlobalSetup]
    public void BuildDiAndGetService()
    {
        // 1. 组装服务,复用和线上一致的注册代码
        var services = new ServiceCollection();
        services.AddBusinessServices();

        // 2. 构建容器
        _serviceProvider = services.BuildServiceProvider(validateScopes: false);

        // 3. 根据服务生命周期获取实例
        // Singleton:直接 GetRequiredService
        // Scoped / Transient:需要创建 Scope
        using var scope = _serviceProvider.CreateScope();
        _userService = scope.ServiceProvider.GetRequiredService<IUserService>();
    }

    [Benchmark(Baseline = true)]
    public async Task RunBusinessMethod()
    {
        // 只测业务逻辑!不在这里构建容器、解析服务
        await _userService.GetUserInfoAsync(10001);
    }

    [GlobalCleanup]
    public void DisposeContainer()
    {
        (_serviceProvider as IDisposable)?.Dispose();
    }
}

如果需要读取 ​appsettings.json​,在构建 ​ServiceCollection​ 之前先构建 ​IConfiguration 传入即可:

var config = new ConfigurationBuilder()
    .AddJsonFile("appsettings.json")
    .Build();
services.AddSingleton<IConfiguration>(config);

怎么套用到两种集成方案

  1. 方案A:独立Benchmark控制台项目(推荐)

    • Benchmark项目引用你的WebApi/业务类库;
    • 安装 ​Microsoft.Extensions.DependencyInjection;
    • 入口依旧是 ​BenchmarkSwitcher,代码完全不变;
    • 注意:​appsettings.json​ 设置属性:​复制到输出目录。
  2. 方案B:Benchmark 和 ASP.NET Core Api 在同一个项目

    • csproj 中 ​BenchmarkDotNet​ 包加上 ​PrivateAssets="all",防止发布带入;
    • Benchmark 类放到单独 ​Benchmarks 文件夹;
    • 入口Main可以用 ​#if DEBUG 包裹隔离(仅本地测试);
    • DI 的基准模板完全不用改动,直接复用上面的代码。

生命周期小提醒

  • Singleton:根容器直接解析,不用 scope;
  • Scoped​:必须 ​CreateScope(),否则触发DI范围校验报错;
  • Transient:每次Get都会新建对象,若要测试“每次解析”的开销,单独写一个benchmark,一般业务测试建议提前拿好实例。

高频坑

  • 在 ​[Benchmark] 方法里面 Build 容器、CreateScope:容器耗时被统计进去,数据无效;
  • 启动完整 ​WebApplication 做基准测试;
  • 依赖真实数据库、Redis:基准测试波动大,优先Mock外部依赖;
  • 忘记关闭日志,大量控制台输出带来额外耗时。

补充:Mock外部依赖

如果要隔离数据库,在 ​AddBusinessServices 之后覆盖注册Mock:

services.AddScoped<IOrderRepository>(_ =>
{
    var mock = new Mock<IOrderRepository>();
    mock.Setup(m => m.QueryAsync(It.IsAny<int>()))
        .ReturnsAsync(new Order());
    return mock.Object;
});

四、进阶:参数化 + 初始化

很多时候我们希望在不同数据规模下对比性能,​[Params]​ 可以自动遍历参数;​[GlobalSetup]​ 在所有测试执行前运行一次,适合加载测试数据,​不会计入耗时。

[MemoryDiagnoser]
public class ArraySumBenchmark
{
    [Params(100, 1000, 10000)]
    public int Count { get; set; }

    private int[] _data;

    [GlobalSetup]
    public void PrepareData()
    {
        _data = Enumerable.Range(1, Count).ToArray();
    }

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

    [Benchmark]
    public int ForeachSum()
    {
        int sum = 0;
        foreach (var item in _data)
        {
            sum += item;
        }
        return sum;
    }
}

生命周期特性一览

  • [GlobalSetup]:全部测试执行前执行一次,初始化数据
  • [GlobalCleanup]:全部测试结束后执行一次,释放资源
  • [IterationSetup]:每一轮迭代前执行(谨慎使用,会参与计时)
  • [IterationCleanup]:每一轮迭代后执行

五、看懂输出表格

一份典型结果表格:

Method Mean Ratio Gen0 Allocations
PlusOperator 18.25 ns 1.00 0.0057 1
StringInterpolation 18.31 ns 1.00 0.0057 1
StringConcat 17.92 ns 0.98 0.0057 1

字段含义:

  • Mean:单次执行平均耗时
  • Ratio:相对基准方法的性能比值,1.00 代表基准,越小越快
  • Gen0:0代GC回收频率,数值越高,GC压力越大
  • Allocations:每次调用分配的对象数量,追求零分配时目标是 0

小贴士:如果 Gen0 和 Allocations 都是 0,代表该方法没有在堆上分配对象。

六、常用特性与诊断器

// 查看汇编代码,研究JIT优化
[DisassemblyDiagnoser]

// 多Runtime对比
[SimpleJob(RuntimeMoniker.Net60)]
[SimpleJob(RuntimeMoniker.Net80)]

// 导出R图,适合博客放性能对比图表
[RPlotExporter]

// 线程、锁相关诊断
[ThreadingDiagnoser]

Job 运行时长预设:

  • ShortRunJob:快速跑,适合本地调试,误差偏大
  • MediumRunJob:日常对比首选,平衡速度与精度
  • LongRunJob:正式对比、发表结论时使用,样本更多,误差更小

七、命令行筛选(搭配 BenchmarkSwitcher)

# 只跑指定类
dotnet run -c Release --filter *StringConcatBenchmark*

# 只跑指定方法
dotnet run -c Release --filter *PlusOperator

# 预览所有测试项,不执行测试
dotnet run -c Release --list flat

--filter​ 使用 glob 匹配,​* 是通配符。

八、避坑清单

  1. Debug 模式跑基准测试:JIT优化关闭,结果无效
  2. 在 Benchmark 方法内部做数据初始化:初始化放到 ​GlobalSetup
  3. 逻辑太简单、单次耗时极低:容易被系统噪声干扰,可以循环放大工作量
  4. 测试时后台大量占用CPU:杀毒、IDE、浏览器会增大误差
  5. 把 Benchmark 放到 xUnit/NUnit 单元测试里运行:官方不推荐,容易出问题
  6. 尽量保证测试方法有返回值:BenchmarkDotNet 会自动消费返回值,防止 JIT 把代码当成死代码直接优化删掉

九、什么时候适合用 BenchmarkDotNet?

不同算法、不同写法横向性能对比

验证 Span<T>、Memory、结构体改造是否真的减少分配

版本升级(.NET6 → .NET8)验证框架API性能变化

给技术博客提供可信的数据支撑结论

不适合:接口压测、并发场景压测(这类用 wrk、k6、BenchmarkDotNet 不擅长并发)

文章摘自:https://www.cnblogs.com/ykbb/p/23400498/benchmarkdotnet-getting-started-with-net-performance-benchmarks-21qtpi