BenchmarkDotNet 可复现微基准指南从问题设计到实验报告专栏C# 与常用数据结构源码剖析版本原则BenchmarkDotNet 的特性、Job API、列名和诊断器会随包版本变化。示例表达实验形状使用时应在项目中固定包版本并保留生成的完整报告不适用范围微基准不能代替 Unity 真机帧分析、服务端负载测试、长时间 GC/内存趋势和端到端延迟测量。一个基准可以非常精确地回答错误问题。它可以稳定地证明“方案 A 比方案 B 快”但两个方法处理了不同数据、没有保持顺序或将构造成本放在不同计时边界。工具不会自动验证业务语义。所以 BenchmarkDotNet 的深度用法不是背诵特性而是将性能假设转换为可证伪实验再让工具帮你管理进程、预热、迭代、计时、统计与产物。1. 先写实验协议再写[Benchmark]一个合格假设应包含对象例如.NET 8 Listint的三种遍历形状而不是“C# foreach”负载输入大小、数据分布、中标率、冲突率、读写比指标稳态吞吐、每操作分配、代码形状还是冷启动控制变量SDK/runtime、JIT/AOT、GC、CPU、OS、电源、进程优先级正确性预言所有候选返回相同结果并保持必要顺序/异常语义决策阈值差异多大才值得增加复杂度而不是看到 Ratio 就优化。例如“数组遍历比 List 快”太宽。可验证版是“在固定 .NET runtime 上对预构造的同一N个int序列求和比较数组索引、具体Listint索引和具体 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 Listint _list null!; [GlobalSetup] public void GlobalSetup() { _array Enumerable.Range(0, N).ToArray(); _list new Listint(_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 是成本曲线不是装饰表格数据结构的选择经常在某个规模或分布后才反转。只测N1000无法了解曲线。参数应覆盖空、单元素和字/缓存行边界小规模常数成本区中等规模的典型业务区超过 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 倍”而会提供源码、版本、参数、数据分布、机器、原始产物、分配与机器码证据说明差异是否足以影响真实用户指标。微基准是证据链的一环而不是终审法庭。
