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
程序会自动完成这些步骤:
- 预热阶段:运行若干轮,消除 JIT 首次编译开销
- 正式采样:多次迭代采集数据
- 统计计算:均值、标准差、误差
- 输出报告:控制台 + 本地文件
测试报告默认输出目录: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控制台项目
步骤:
- 解决方案里新增 Console 项目(Benchmark 项目)
- 这个 Benchmark 项目添加 NuGet:
BenchmarkDotNet - Benchmark 项目添加项目引用,引用
YourBusinessLib - 在 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);
优点
- 不会污染业务项目依赖,业务不需要引用 BenchmarkDotNet
- 发布业务包 / 上线的时候,完全不带基准测试代码
- 配置干净,不容易误在 Debug、CI 里意外跑 benchmark
- 适合复杂方案:Web、类库、多模块项目
缺点
新增一个项目,解决方案多一个节点
运行
# 进到Benchmark项目目录执行
dotnet run -c Release --filter *TextHelperBench
3.2 Benchmark 和业务代码在同一个项目内(适合简单小工具、类库)
项目结构:
YourBusinessLib/
├─ Utils/TextHelper.cs // 业务代码
└─ Benchmarks/TextHelperBench.cs // Benchmark类,放单独文件夹区分
操作要点:
- 在业务项目安装
BenchmarkDotNetNuGet 包 - Benchmark 类放在单独文件夹(
Benchmarks),方便区分业务代码 - 同样使用
BenchmarkSwitcher,但是有一个关键问题:这个项目是类库,没有 Program 入口!
两种解决办法:
方式 B1:项目是控制台项目,自带 Program(最简单)
直接在 Program 写 Switcher,和之前一样。
方式 B2:项目是 ClassLib(类库,无入口)
不能直接 dotnet run。两种选择:
- 临时增加一个
BenchmarkEntry.cs 作为入口,只在本地开发使用,发布时注意排除; - 还是新建一个极小的 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
- 小工具库非常方便
缺点
-
业务项目依赖了 BenchmarkDotNet,打包、nuget 发布时会带上这个包,需要在 csproj 标记 PrivateAssets=”all”
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" PrivateAssets="all" />PrivateAssets="all":代表这个包只用于开发,不会作为依赖对外输出。 -
容易误提交 Benchmark 代码到生产分支、CI 流水线误跑基准测试
-
如果是 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);
怎么套用到两种集成方案
-
方案A:独立Benchmark控制台项目(推荐)
- Benchmark项目引用你的WebApi/业务类库;
- 安装
Microsoft.Extensions.DependencyInjection; - 入口依旧是
BenchmarkSwitcher,代码完全不变; - 注意:
appsettings.json 设置属性:复制到输出目录。
-
方案B:Benchmark 和 ASP.NET Core Api 在同一个项目
- csproj 中
BenchmarkDotNet 包加上 PrivateAssets="all",防止发布带入; - Benchmark 类放到单独
Benchmarks文件夹; - 入口Main可以用
#if DEBUG包裹隔离(仅本地测试); - DI 的基准模板完全不用改动,直接复用上面的代码。
- csproj 中
生命周期小提醒
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 匹配,* 是通配符。
八、避坑清单
- Debug 模式跑基准测试:JIT优化关闭,结果无效
- 在 Benchmark 方法内部做数据初始化:初始化放到
GlobalSetup - 逻辑太简单、单次耗时极低:容易被系统噪声干扰,可以循环放大工作量
- 测试时后台大量占用CPU:杀毒、IDE、浏览器会增大误差
- 把 Benchmark 放到 xUnit/NUnit 单元测试里运行:官方不推荐,容易出问题
- 尽量保证测试方法有返回值: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
