BenchmarkDotNet 可复现微基准指南:从问题设计到实验报告
专栏:C# 与常用数据结构源码剖析
版本原则:BenchmarkDotNet 的特性、Job API、列名和诊断器会随包版本变化。示例表达实验形状,使用时应在项目中固定包版本并保留生成的完整报告
不适用范围:微基准不能代替 Unity 真机帧分析、服务端负载测试、长时间 GC/内存趋势和端到端延迟测量。
一个基准可以非常精确地回答错误问题。它可以稳定地证明“方案 A 比方案 B 快”,但两个方法处理了不同数据、没有保持顺序,或将构造成本放在不同计时边界。工具不会自动验证业务语义。
所以 BenchmarkDotNet 的深度用法不是背诵特性,而是将性能假设转换为可证伪实验,再让工具帮你管理进程、预热、迭代、计时、统计与产物。
1. 先写实验协议,再写[Benchmark]
一个合格假设应包含:
- 对象:例如
.NET 8 List<int>的三种遍历形状,而不是“C# foreach”; - 负载:输入大小、数据分布、中标率、冲突率、读写比;
- 指标:稳态吞吐、每操作分配、代码形状,还是冷启动;
- 控制变量:SDK/runtime、JIT/AOT、GC、CPU、OS、电源、进程优先级;
- 正确性预言:所有候选返回相同结果,并保持必要顺序/异常语义;
- 决策阈值:差异多大才值得增加复杂度,而不是看到 Ratio 就优化。
例如“数组遍历比 List 快”太宽。可验证版是:“在固定 .NET runtime 上,对预构造的同一N个int序列求和,比较数组索引、具体List<int>索引和具体 List foreach;构造不计入稳态计时,检查和作为返回值防止死代码消除。”
2. 一个可审查的基准骨架
using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; [MemoryDiagnoser] public class SequenceSumBenchmarks { [Params(32, 1_024, 65_536)] public int N { get; set; } private int[] _array = null!; private List<int> _list = null!; [GlobalSetup] public void GlobalSetup() { _array = Enumerable.Range(0, N).ToArray(); _list = new List<int>(_array); // Setup 内先验证输入,不把错误推迟到数千次测量后。 if (_array.Length != _list.Count) throw new InvalidOperationException(); } [Benchmark(Baseline = true)] public long ArrayIndex() { long sum = 0; for (int i = 0; i < _array.Length; i++) sum += _array[i]; return sum; } [Benchmark] public long ListIndex() { long sum = 0; for (int i = 0; i < _list.Count; i++) sum += _list[i]; return sum; } [Benchmark] public long ListForeach() { long sum = 0; foreach (int value in _list) sum += value; return sum; } } public static class Program { public static void Main(string[] args) => BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); }骨架有几个有意选择:不手写极少 warmup/测量次数来追求快速,让工具先使用符合包版本的默认自适应策略;返回总和,使结果可观测;将数据构造放在GlobalSetup,因为问题是遍历;使用long降低求和溢出干扰。
如果真正问题包含创建列表,就应将创建明确放入另一组基准,不能在某个候选中计时、在另一个候选的 Setup 中免费完成。
3. Setup/Cleanup 是实验边界
3.1 Global 与 Iteration 生命周期
GlobalSetup用于某个基准案例/参数组的长生命周期准备;IterationSetup用于每个迭代需要重置的状态。但 IterationSetup 会改变调用组织与可测量时间,不应为方便滥用在纳秒级方法上。
例如测量Dictionary.Remove,每次删除都改变状态。可选:
- 每迭代重建字典,基准方法内批量删除;
- 每个操作创建独立字典,同时把创建定义为工作量;
- 预先准备多个独立字典,基准中每次取下一个,同时计算数组寻址成本;
- 只测一次长场景并用端到端工具,不强行伪装成微基准。
没有方案能免费重置世界。报告中必须说明状态如何恢复,并检查重置是否污染 GC/缓存。
3.2 Cleanup 不是基准内容
文件、数据库、非托管内存与线程需在 Cleanup 释放,但清理失败会使后续案例受污染。将每个案例放在独立进程是 BenchmarkDotNet 的重要优势,仍不能忽略外部状态。
4. Job 决定运行时世界
Job 描述 runtime、平台、JIT、GC 和迭代策略等。不应为了让基准十秒结束就随意设一次 launch、一次 warmup、三次测量,然后把高噪声平均值当成证据。
需要自定义时,先回答:
- 要比较 .NET 8 与另一 runtime,还是只固定一个运行时?
- 需要冷启动/
RunStrategy.ColdStart,还是预热后稳态吞吐? - 需要观察 Tier 0,还是让分层编译与 PGO 进入稳态?
- 环境变量是否会改变 JIT/GC,是否在报告中显示?
- 各 Job 是否使用同一 CPU 架构和能力集?
跨 runtime 比较要检查 API 实现是否已变;测到的差异可来自库源码、JIT、GC 或发布布局,不应全归因于 JIT。
5. Params 是成本曲线,不是装饰表格
数据结构的选择经常在某个规模或分布后才反转。只测N=1000无法了解曲线。参数应覆盖:
- 空、单元素和字/缓存行边界;
- 小规模常数成本区;
- 中等规模的典型业务区;
- 超过 CPU 缓存或进入 LOH/大对象的区域;
- 真实峰值与一个有意的过载值;
- 不同分布:已排序/随机,均匀/热点,低/高哈希冲突。
参数组数量是各维度的笛卡尔积,可迅速爆炸。先用预实验找转折点,再用少量代表点做稳定测量。
6. 正确性:基准框架不是测试框架
基准执行相同代码很多次,但并不意味着它会为你比较所有方法的结果。在基准项目之外建立单元/属性/差分测试:
- 同一输入下所有候选返回同一值;
- 溢出、NaN、null、空集合和异常类型一致;
- 如果 swap-back 改变顺序,测试必须明确业务不需顺序,而不是只比较集合元素;
- 池化方案在异常、取消和重复归还下不泄漏/不重用;
- 并发方案不丢、不重,且线性化契约正确。
GlobalSetup 内可以做小规模 smoke check,但不用它代替完整测试套件。
7. 死代码消除、常量折叠与内联
“没有返回值,JIT 就会删掉整个new List”不是通用规则:托管分配、异常和副作用会限制优化。但返回结果或使用Consumer仍是明确可观测性的好实践。
另一个更隐蔽的陷阱是基准输入在编译时/运行时可被特化:方法小到被完全内联,固定数组内容被折叠,或外层调用与真实应用的调用边界不同。微基准测量的是“该调用形状被优化后”,并不保证独立方法成本。
查看 disassembly 能确认是否内联、向量化、消除边界检查或根本未执行预期路径。不应为“保持公平”随意加NoInlining;真实代码会内联时,禁止它反而测了虚构负载。只在问题本身是“不内联调用成本”时才固定。
8. 分配与 GC 列的正确解读
Allocated是基准测量边界内的托管分配估计,不是峰值驻留内存,不包含所有非托管/GPU 内存,也不证明对象活了多久。Gen0/1/2列是按操作归一化的收集观测,显示-不应被解读为整个应用“无 GC”。
分配来源包括:
- 列表/字典对象和支持数组;
- 数组扩容与旧数组后续回收;
- 装箱、闭包、委托和 LINQ 迭代器;
- async 状态机/完成源;
- 池的首次填充、超容量回退和清理临时对象。
如果优化将分配从每次操作移到 GlobalSetup,这可能正是“预分配与复用”假设,但报告必须同时呈现启动成本、峰值容量和驻留,而不声称成本消失。
9. 统计列:不要用固定阈值代替判断
Mean、Median、StdDev、Error、分位数和 Ratio 分别回答不同问题。“StdDev < 3% 就稳定”、“Ratio > 2 就显著”是没有通用依据的经验口号。
解读步骤是:
- 检查工具警告、离群值、分布形状和自适应迭代是否充分;
- 看候选差异与不确定性区间的关系,不只看小数点;
- 跨日/跨机器重复,确认差异不是温度、背景进程或频率调整;
- 比较差异的业务影响:即使统计可分辨,在真实请求/帧中占比过小也可不值得;
- 检查更快方案是否以内存、可读性、顺序、端延迟或构建大小交换。
Ratio 是相对同参数基线的便利摘要,它不是跨不同参数行互比的通用值。
10. 诊断器与硬件计数器
10.1 Disassembly
用于检查内联、虚调用、边界检查、向量化、代码大小和泛型特化。机器码是目标 CPU/runtime/tier 下的证据,不是 C# 语言永久契约。
10.2 MemoryDiagnoser
用于发现每操作托管分配。它不提供完整保留图,长寿命对象与静态缓存需要堆快照/趋势工具。
10.3 ThreadingDiagnoser
可观察特定锁竞争和线程池行为指标,但并发基准还必须定义线程数、键热度、读写比、启动栅栏、正确性和 CPU 过度订阅。
10.4 Hardware Counters
分支误预测、缓存失效和指令等计数器可帮助解释“为什么”,但受 OS、权限、虚拟化和 CPU 支持限制。它们是归因证据,不是只要越小就越好的独立 KPI。
诊断器会改变执行环境与时间,应在必要时分开运行“精确计时”与“深度归因”两套 Job。
11. 并发基准为什么特别难
在一个[Benchmark]中每次启动Parallel.For,测到的是线程池调度、分区、委托与容器操作的组合。这未必错,但不能将结果全归于容器。
专业并发基准需要:
- 在计时前创建长寿命工作者并用栅栏同步起点;
- 使用独立键和单一热键两种负载,区分整体吞吐与热点争用;
- 测读多写少、平衡、写多和空容器竞争;
- 验证结果总数、唯一性与线性化契约;
- 报告单个线程延迟分布和总吞吐,不只报所有线程完成的墙钟时间;
- 将线程数与物理/逻辑核、SMT、NUMA 和过度订阅关联。
对严格并发数据结构论文级对比,通用微基准框架可能只是组件,还需要专用 harness、线性化检查和硬件拓扑控制。
12. 数据结构基准案例:字典查找
一个只用连续整数键的字典查找基准无法代表字符串、复合键或敌对哈希。可建立以下参数组:
| 维度 | 代表输入 | 要回答的问题 |
|---|---|---|
| 规模 | 小/中/超缓存规模 | 固定开销和局部性何时转折 |
| 中标率 | 全中、全不中、混合 | 成功/失败路径及分支 |
| 键类型 | int、字符串、定制 struct | 哈希、比较、宽度与装箱 |
| 冲突 | 正常 comparer、可控差 comparer | 平均和最坏路径 |
| 访问 | 顺序、随机、热点 | 缓存与分支预测 |
| 容量 | 预分配/从空构建 | 查找成本与构建/扩容成本 |
不应用构造一个“所有键哈希相同”的人工 comparer 后,将结果推广为普通字典性能;它用于检查冲突下的退化和防御边界。
13. 与 Unity 的边界
BenchmarkDotNet 主要面向标准 .NET 进程环境,它不会自动测量 Unity PlayerLoop、IL2CPP AOT、Burst Job、NativeContainer、渲染线程或真机热降频。可以用一个独立 .NET 基准快速检验纯算法假设,但最终要在 Unity 目标后端重写可控 harness,用 ProfilerRecorder/自定义 Marker 和真机构建测量。
两类结果不同时,不要选喜欢的一个;查看 API 实现、JIT/AOT、GC、安全检查、调度与 CPU 差异。
14. 从基准到回归门槛
一次手工基准报告很快失效。若结论对工程重要,将它变成以下产物:
- 固定 SDK/runtime/BenchmarkDotNet 版本的基准项目;
- 单元/差分正确性测试;
- 记录 OS、CPU、BIOS/电源和环境变量的运行脚本;
- 完整 Markdown/CSV/JSON 与 disassembly 产物;
- 基线与候选 commit、分支的精确对应;
- 适度的回归预算,考虑共享 CI 噪声;
- 不稳定时报告需人工复核,而不是用一次尖峰阻断所有合并。
最好不在不受控的共享 CI 上使用极小时间差作硬门槛。可在专用 runner 定期跑完整套件,PR 上只跑正确性和大颗粒 smoke benchmark。
14.1 先排除环境漂移,再讨论回归
门槛失败时,第一步不是对比Mean,而是对比本次与基线的环境头和归档清单。SDK、runtime 补丁、BenchmarkDotNet 包版本、操作系统内核、CPU 与微码、BIOS 和电源模式、核心亲和性、环境变量任一改变,都可能使两份数据不再可比。还要确认 ReadyToRun、分层编译、动态 PGO 与 GC 模式没有被启动参数或机器配置悄然切换。
第二步是判断这是“性能回归”还是“无效实验”。工具警告、子进程启动失败、diagnoser 产物缺失、样本明显双峰、热降频、后台负载抢占,或工具版本导致基准包装与生成项目改变,都应先标记为“无结论”,而不是直接给业务代码判罪。对双峰数据应查看时间线、GC 事件与编译阶段;对基准包装变化,应先确认比较的还是否为同一实验问题。
每次运行应封存为不可变的证据包,将源码 commit、BenchmarkDotNet 生成项目、完整控制台日志、环境头、全量统计、CSV/JSON/Markdown、反汇编与进程退出状态绑定到同一运行编号。这样审查者才能重建“在什么代码、什么机器、什么参数下得到什么”,而不是只看一张截图。
只有正确性测试通过,且在受控环境中的重复运行能复现同一方向,门槛才可输出“通过”或“回归”;其余情况输出“无结论”并保留证据。不要为了让 CI 变绿而删除所谓离群点、拓宽预算或重选基线;如果必须调整,应记录原因、审批人与新旧阈值,再从基线开始重跑。
15. 常见反例
15.1 只跑一次Stopwatch
它混合 JIT、类型初始化、线程池爬坡和调度器噪声。如果要测冷启动就明确测冷启动;如果要测稳态就使用预热和多迭代。
15.2 每个方法执行不同数量工作
一个方法在 Setup 预构建 Lookup,另一个每次重建,然后声称查找更快。拆分构建和查找两个问题,再增加端到端负载。
15.3 污染对照状态
两个 Benchmark 共享一个静态列表,第一个先排序/填充,第二个得到已预热状态。每个候选使用等价但独立的输入,或明确测共享缓存场景。
15.4 用平均值隐藏双峰
背景 GC、分层编译切换或 CPU 降频可产生两类样本。查分布、timeline 与环境,不要仅用一个 Mean 压平原因。
15.5 微基准赢了就直接上线
优化可改变端延迟、驻留内存、可读性和错误边界。在端到端压测/真机帧测量中验证,通过灰度与监控确认用户指标。
16. 实验报告模板
问题/决策: 业务语义与正确性测试: 候选实现与 commit: SDK/runtime/GC/BenchmarkDotNet: OS/CPU/内存/电源模式: Job/环境变量: 参数与数据分布: Setup/Reset/Cleanup 边界: 结果:完整产物路径 统计异常/工具警告: 分配/GC/Disassembly/计数器证据: 是否支持原假设: 替代解释: 对真实负载的预期影响: 需要的端到端/真机验证: 回归门槛与灰度监控:17. 总结
BenchmarkDotNet 解决的是“如何在受控 .NET 进程中反复测量代码”,不是“如何自动得到正确性能结论”。可信基准从可证伪假设、等价工作量和正确性测试开始,再用 Job、预热、多迭代、诊断器与统计管理噪声。
成熟的报告不会只说“A 快 1.16 倍”,而会提供源码、版本、参数、数据分布、机器、原始产物、分配与机器码证据,说明差异是否足以影响真实用户指标。微基准是证据链的一环,而不是终审法庭。