关于 dotnet-ci-benchmarking
为在 GitHub Actions CI 中持续运行 BenchmarkDotNet 性能检查提供指导,涵盖 JSON 基线、阈值回归检测,以及结果报告和构建门禁。
主要功能
- 01管理 BenchmarkDotNet JSON 基线
- 02按可配置阈值比较结果
- 03用 GitHub Actions 制品存储基线
- 04在拉取请求中报告回归
- 05在 CI 中使用快速基准测试
适用场景
- 01检测拉取请求中的性能变化
- 02以分配回归作为合并门禁
- 03跟踪 CI 运行间的基准结果
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-ci-benchmarking安装后在 Claude Code 中输入/dotnet-ci-benchmarking确认已加载。
详细介绍
dotnet-ci-benchmarking
用于在 CI 流水线中检测性能回归的持续基准测试指南。涵盖使用 BenchmarkDotNet JSON 导出器管理基线文件、用于基于制品进行基线比较的 GitHub Actions 工作流、带可配置阈值的回归检测模式,以及性能下降的告警策略。
**版本假设:**使用 BenchmarkDotNet v0.14+ 导出 JSON,并使用 GitHub Actions 运行器环境。示例使用 actions/upload-artifact@v4 和 actions/download-artifact@v4。
**不在范围内:**BenchmarkDotNet 设置、基准测试类设计、内存诊断器和常见问题由本 Epic 的配套技能负责——参见 [skill:dotnet-benchmarkdotnet]。面向性能的架构模式由 [skill:dotnet-performance-patterns] 负责。分析工具(dotnet-counters、dotnet-trace、dotnet-dump)由 dotnet-profiling 介绍。OpenTelemetry 指标收集和分布式跟踪——参见 [skill:dotnet-observability]。可组合的 CI/CD 工作流设计和矩阵构建策略——参见 [skill:dotnet-gha-patterns]。架构模式(缓存、弹性)——参见 [skill:dotnet-architecture-patterns]。
交叉引用:[skill:dotnet-benchmarkdotnet] 介绍基准测试类设置和 JSON 导出器配置;[skill:dotnet-observability] 介绍如何将基准测试回归与运行时指标变化关联起来;[skill:dotnet-gha-patterns] 介绍可组合的工作流模式(可重用工作流、复合操作、矩阵构建)。
基线文件管理
BenchmarkDotNet JSON 导出
BenchmarkDotNet 的 JSON 导出器会生成机器可读的结果,供自动比较使用。在基准测试类中配置导出器:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Exporters.Json;
[JsonExporterAttribute.Full]
[MemoryDiagnoser]
public class CriticalPathBenchmarks
{
[Benchmark(Baseline = true)]
public void ProcessOrder() { /* ... */ }
[Benchmark]
public void ProcessOrderOptimized() { /* ... */ }
}
也可以通过自定义配置为所有基准测试类配置导出器:
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Exporters.Json;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
var config = ManualConfig.Create(DefaultConfig.Instance)
.AddJob(Job.ShortRun) // fewer iterations for CI speed
.AddExporter(JsonExporter.Full)
.WithArtifactsPath("./benchmark-results");
BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, config);
JSON 导出结构
导出的 JSON 文件(*-report-full.json)包含结构化的基准测试结果:
{
"Title": "CriticalPathBenchmarks",
"Benchmarks": [
{
"FullName": "MyApp.Benchmarks.CriticalPathBenchmarks.ProcessOrder",
"Statistics": {
"Mean": 1234.5678,
"Median": 1230.1234,
"StandardDeviation": 15.234,
"StandardError": 4.812
},
"Memory": {
"BytesAllocatedPerOperation": 1024,
"Gen0Collections": 0.0012,
"Gen1Collections": 0,
"Gen2Collections": 0
}
}
]
}
用于回归比较的关键字段:
| 字段 | 用途 |
|---|---|
Statistics.Mean | 平均执行时间(纳秒) |
Statistics.Median | 中间执行时间(更能抵御异常值的影响) |
Statistics.StandardDeviation | 测量值的变异程度 |
Memory.BytesAllocatedPerOperation | 每次操作的 GC 分配量 |
基线存储策略
| 策略 | 优点 | 缺点 | 最适用场景 |
|---|---|---|---|
| 提交到 Git 的基线文件 | 有版本记录、可审计、无需外部依赖 | 仓库体积会增大;必须有意更新 | 小型基准测试套件、硬件稳定的环境 |
| GitHub Actions 制品 | 不会增加仓库体积;自动保留 | 默认保留 90 天;跨工作流访问需要令牌 | 大型基准测试套件、共享运行器 |
| 外部存储(S3/Azure Blob) | 历史记录无限;可跨仓库共享 | 需要额外基础设施;需要管理凭据 | 多仓库基准测试比较 |
本技能默认采用 GitHub Actions 制品策略。有关可组合的工作流模式和可重用操作,请参见 [skill:dotnet-gha-patterns]。
GitHub Actions 基准测试工作流
基本基准测试工作流
name: Benchmarks
on:
pull_request:
paths:
- 'src/**'
- 'benchmarks/**'
workflow_dispatch:
permissions:
contents: read
actions: read # required for artifact download
jobs:
benchmark:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Run benchmarks
run: dotnet run -c Release --project benchmarks/MyBenchmarks.csproj -- --exporters json
- name: Upload benchmark results
uses: actions/upload-artifact@v4
with:
name: benchmark-results-${{ github.sha }}
path: benchmarks/BenchmarkDotNet.Artifacts/results/
retention-days: 90
基线比较工作流
此工作流会下载之前运行生成的基线,并与当前结果进行比较:
name: Benchmark Regression Check
on:
pull_request:
paths:
- 'src/**'
- 'benchmarks/**'
permissions:
contents: read
actions: read
env:
BENCHMARK_PROJECT: benchmarks/MyBenchmarks.csproj
RESULTS_DIR: benchmarks/BenchmarkDotNet.Artifacts/results
jobs:
benchmark:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Download baseline results
uses: actions/download-artifact@v4
with:
name: benchmark-baseline
path: ./baseline-results
continue-on-error: true
id: download-baseline
- name: Run benchmarks
run: dotnet run -c Release --project ${{ env.BENCHMARK_PROJECT }} -- --exporters json
- name: Compare with baseline
if: steps.download-baseline.outcome == 'success'
shell: bash
run: |
set -euo pipefail
python3 scripts/compare-benchmarks.py \
--baseline ./baseline-results \
--current "${{ env.RESULTS_DIR }}" \
--threshold 10 \
--output benchmark-comparison.md
- name: Upload current results as new baseline
if: github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v4
with:
name: benchmark-baseline
path: ${{ env.RESULTS_DIR }}/
retention-days: 90
overwrite: true
- name: Upload comparison report
if: steps.download-baseline.outcome == 'success'
uses: actions/upload-artifact@v4
with:
name: benchmark-comparison-${{ github.sha }}
path: benchmark-comparison.md
retention-days: 30
关键设计决策:
- 在下载基线时设置
continue-on-error: true,以处理首次运行的情况(此时还不存在基线) - 仅在
main分支合并时更新基线,以防 PR 分支污染基线 overwrite: true会替换之前的基线制品
有关将这些内联工作流转换为可重用 workflow_call 模式的方法,请参见 [skill:dotnet-gha-patterns]。
回归检测模式
基于阈值的比较
使用百分比阈值将当前基准测试结果与基线进行比较。当当前平均值超过基线平均值,且超出幅度大于配置的阈值时,就会标记为回归:
#!/usr/bin/env python3
"""compare-benchmarks.py -- Detect benchmark regressions from BenchmarkDotNet JSON exports."""
import json
import sys
from pathlib import Path
def load_benchmarks(results_dir: str) -> dict:
"""Load benchmark results from BenchmarkDotNet JSON export files."""
benchmarks = {}
for json_file in Path(results_dir).glob("*-report-full.json"):
with open(json_file) as f:
data = json.load(f)
for bm in data.get("Benchmarks", []):
name = bm["FullName"]
benchmarks[name] = {
"mean": bm["Statistics"]["Mean"],
"median": bm["Statistics"]["Median"],
"stddev": bm["Statistics"]["StandardDeviation"],
"allocated": bm.get("Memory", {}).get("BytesAllocatedPerOperation", 0),
}
return benchmarks
def compare(baseline_dir: str, current_dir: str, threshold_pct: float) -> list:
"""Compare current results against baseline. Returns list of regressions."""
baseline = load_benchmarks(baseline_dir)
current = load_benchmarks(current_dir)
regressions = []
for name, curr in current.items():
if name not in baseline:
continue # new benchmark, no comparison possible
base = baseline[name]
if base["mean"] == 0:
continue # avoid division by zero
time_change_pct = ((curr["mean"] - base["mean"]) / base["mean"]) * 100
alloc_change = curr["allocated"] - base["allocated"]
if time_change_pct > threshold_pct:
regressions.append({
"name": name,
"baseline_mean": base["mean"],
"current_mean": curr["mean"],
"change_pct": time_change_pct,
"alloc_change": alloc_change,
})
return regressions
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Compare BenchmarkDotNet results")
parser.add_argument("--baseline", required=True, help="Path to baseline results directory")
parser.add_argument("--current", required=True, help="Path to current results directory")
parser.add_argument("--threshold", type=float, default=10.0,
help="Regression threshold percentage (default: 10)")
parser.add_argument("--output", default="comparison.md", help="Output markdown file")
args = parser.parse_args()
regressions = compare(args.baseline, args.current, args.threshold)
with open(args.output, "w") as f:
if regressions:
f.write("## Benchmark Regressions Detected\n\n")
f.write("| Benchmark | Baseline (ns) | Current (ns) | Change | Alloc Delta |\n")
f.write("|-----------|--------------|-------------|--------|-------------|\n")
for r in regressions:
f.write(f"| `{r['name']}` | {r['baseline_mean']:.2f} | "
f"{r['current_mean']:.2f} | +{r['change_pct']:.1f}% | "
f"{r['alloc_change']:+d} B |\n")
f.write(f"\nThreshold: {args.threshold}%\n")
else:
f.write("## Benchmark Results\n\nNo regressions detected ")
f.write(f"(threshold: {args.threshold}%).\n")
if regressions:
print(f"REGRESSION: {len(regressions)} benchmark(s) exceeded "
f"{args.threshold}% threshold", file=sys.stderr)
sys.exit(1)
选择阈值
| 环境 | 建议阈值 | 理由 |
|---|---|---|
| 专用基准测试硬件 | 5% | 噪声底限低;微小回归也有参考价值 |
| GitHub Actions 共享运行器 | 10-15% | 共享运行器会因邻居噪声产生 5-10% 的方差 |
| 自托管运行器 | 5-10% | 比共享运行器更稳定,但仍需监控方差 |
**根据实测校准阈值:**在 CI 环境中不更改代码,使用同一套基准测试运行 5-10 次。观察到的最大方差即为噪声底限。将阈值设在噪声底限之上(通常为观察到的方差的 2 倍)。
分配回归检测
内存分配回归比计时回归更可靠,因为分配是确定性的(不受邻居噪声影响):
# Add to the compare function:
if alloc_change > 0:
regressions.append({
"name": name,
"type": "allocation",
"baseline_alloc": base["allocated"],
"current_alloc": curr["allocated"],
"alloc_change": alloc_change,
})
将分配变化设为硬门槛(零分配路径对新增分配的容忍度为零),将计时变化设为软门槛(超过阈值时发出警告)。
告警策略
在 PR 评论中汇总回归情况
将基准测试比较结果作为 PR 评论发布,方便审阅者查看:
- name: Comment PR with results
if: steps.download-baseline.outcome == 'success' && github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const body = fs.readFileSync('benchmark-comparison.md', 'utf8');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body
});
回归时使构建失败
让比较脚本以非零状态退出,从而使 GitHub Actions 作业失败。这样可以阻止合入引入性能回归的 PR:
- name: Check for regressions
if: steps.download-baseline.outcome == 'success'
shell: bash
run: |
set -euo pipefail
python3 scripts/compare-benchmarks.py \
--baseline ./baseline-results \
--current "${{ env.RESULTS_DIR }}" \
--threshold 10
# Script exits non-zero if regressions found -- fails the job
有关必需状态检查以及将分支保护与基准测试门槛集成的信息,请参阅 [skill:dotnet-gha-patterns]。
趋势跟踪
如需进行超越单个 PR 比较的长期趋势分析,请将结果上传到持久化存储,并持续跟踪指标:
| 方式 | 工具 | 复杂度 |
|---|---|---|
| GitHub Actions 工件 | 内置,保留 90 天 | 低——仅需下载/上传工件 |
| 使用 benchmark-action 的 GitHub Pages | benchmark-action/github-action-benchmark@v1 | 中——自动生成趋势图表 |
| 外部时序数据库 | InfluxDB、Prometheus + Grafana | 高——完整的可观测性技术栈 |
对于大多数项目,最简单的方式是使用本技能中展示的基于工件的基线比较。当需要跨多个版本进行历史回归分析时,再升级为趋势跟踪。
CI 专用的 BenchmarkDotNet 配置
使用 ShortRun 加快 CI 速度
完整的基准测试运行需要 10-30 分钟以上。在 CI 中使用 Job.ShortRun 减少迭代次数,同时保留检测回归的能力:
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Jobs;
public class CiConfig : ManualConfig
{
public CiConfig()
{
AddJob(Job.ShortRun
.WithWarmupCount(3)
.WithIterationCount(5)
.WithInvocationCount(1));
AddExporter(BenchmarkDotNet.Exporters.Json.JsonExporter.Full);
}
}
根据环境有条件地应用:
var config = Environment.GetEnvironmentVariable("CI") is not null
? new CiConfig()
: DefaultConfig.Instance;
BenchmarkRunner.Run<CriticalPathBenchmarks>(config);
筛选 CI 中运行的基准测试
在 CI 中仅运行关键路径基准测试,以缩短流水线耗时:
# Run only benchmarks in the "Critical" category
dotnet run -c Release --project benchmarks/MyBenchmarks.csproj -- \
--filter *Critical* --exporters json
[BenchmarkCategory("Critical")]
[MemoryDiagnoser]
[JsonExporterAttribute.Full]
public class CriticalPathBenchmarks
{
[Benchmark]
public void ProcessOrder() { /* ... */ }
}
[BenchmarkCategory("Extended")]
[MemoryDiagnoser]
public class ExtendedBenchmarks
{
[Benchmark]
public void RareCodePath() { /* ... */ }
}
每个 PR 都运行 Critical 基准测试;按夜间计划运行 Extended 基准测试。
夜间基准测试计划
name: Nightly Benchmarks (Full Suite)
on:
schedule:
- cron: '0 3 * * *' # 3 AM UTC daily
workflow_dispatch:
jobs:
benchmark-full:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Run full benchmark suite
run: dotnet run -c Release --project benchmarks/MyBenchmarks.csproj -- --exporters json
# No --filter: runs all benchmarks including Extended category
- name: Upload full results
uses: actions/upload-artifact@v4
with:
name: benchmark-full-${{ github.run_number }}
path: benchmarks/BenchmarkDotNet.Artifacts/results/
retention-days: 90
有关计划工作流模式以及跨 TFM 的矩阵构建,请参阅 [skill:dotnet-gha-patterns]。
Agent 注意事项
- 在 CI 中使用
Job.ShortRun,而不是Job.Default—— 默认基准测试任务会运行许多轮迭代以提高统计精度,每个基准测试类别耗时 10–30 分钟甚至更久。CI 流水线需要更快地获得反馈,因此应使用ShortRun(3 次预热,5 次迭代)。 - 将阈值设在测量噪声底线之上 —— 共享 CI Runner 会因其他负载而产生 5–10% 的计时偏差。在共享 Runner 上设置 5% 的阈值会产生误报。可通过多次运行同一段代码并测量偏差来校准。
- 将分配变化作为硬性门槛 —— 分配次数具有确定性,不受 Runner 噪声影响。从零分配变为非零分配始终是真实回归,与计时波动不同。
- 仅从 main 分支更新基线 —— 如果 PR 分支可以更新基线,某个 PR 中的回归就会成为新基线,从而在后续比较中被掩盖。
- 始终在 bash 步骤中设置
set -euo pipefail—— 如果没有pipefail,回归检测脚本在管道中(例如script | tee)以非零状态退出时,不会导致 GitHub Actions 步骤失败。 - 妥善处理基线缺失的情况 —— 首次 CI 运行时没有可供比较的基线。请在基线下载步骤中使用
continue-on-error: true,并在不存在基线时跳过比较。 - 导出 JSON,而不只是 Markdown —— Markdown 报告便于人类阅读,但无法供自动化回归检测解析。请始终在配置中加入
[JsonExporterAttribute.Full]或JsonExporter.Full。