名称:dotnet-ci-benchmarking 描述:“在CI管道中检测性能回归的门控。自动阈值警报、基线跟踪、趋势报告。” 用户可调用:false
dotnet-ci-benchmarking
在CI管道中检测性能回归的持续基准测试指南。涵盖使用BenchmarkDotNet JSON导出器的基线文件管理、用于基于工件的基线比较的GitHub Actions工作流、具有可配置阈值的回归检测模式以及性能下降的告警策略。
版本假设: BenchmarkDotNet v0.14+用于JSON导出,GitHub Actions运行器环境。示例使用actions/upload-artifact@v4和actions/download-artifact@v4。
范围
- 使用BenchmarkDotNet JSON导出器的基线文件管理
- 用于基于工件的基线比较的GitHub Actions工作流
- 具有可配置阈值的回归检测
- 性能下降的告警策略
超出范围
- BenchmarkDotNet设置和基准类设计 – 参见[技能:dotnet-benchmarkdotnet]
- 性能架构模式 – 参见[技能:dotnet-performance-patterns]
- 性能分析工具(dotnet-counters、dotnet-trace、dotnet-dump) – 参见[技能:dotnet-profiling]
- OpenTelemetry指标和分布式追踪 – 参见[技能:dotnet-observability]
- 可组合CI/CD工作流设计 – 参见[技能:dotnet-gha-patterns]
交叉引用: [技能:dotnet-benchmarkdotnet]用于基准类设置和JSON导出器配置,[技能:dotnet-observability]用于关联基准回归与运行时指标变化,[技能: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) // 减少迭代次数以提高CI速度
.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工件策略。对于可组合工作流模式和可重用操作,参见[技能:dotnet-gha-patterns]。
GitHub Actions基准工作流
基本基准工作流
name: 基准测试
on:
pull_request:
paths:
- 'src/**'
- 'benchmarks/**'
workflow_dispatch:
permissions:
contents: read
actions: read # 工件下载所需
jobs:
benchmark:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 设置 .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: 运行基准测试
run: dotnet run -c Release --project benchmarks/MyBenchmarks.csproj -- --exporters json
- name: 上传基准结果
uses: actions/upload-artifact@v4
with:
name: benchmark-results-${{ github.sha }}
path: benchmarks/BenchmarkDotNet.Artifacts/results/
retention-days: 90
基线比较工作流
此工作流下载先前运行的基线并与当前结果比较:
name: 基准回归检查
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: 设置 .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: 下载基线结果
uses: actions/download-artifact@v4
with:
name: benchmark-baseline
path: ./baseline-results
continue-on-error: true
id: download-baseline
- name: 运行基准测试
run: dotnet run -c Release --project ${{ env.BENCHMARK_PROJECT }} -- --exporters json
- name: 与基线比较
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: 上传当前结果作为新基线
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: 上传比较报告
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模式,参见[技能:dotnet-gha-patterns]。
回归检测模式
基于阈值的比较
使用百分比阈值比较当前基准结果与基线。当当前均值超过基线均值超过配置阈值时,标记回归:
#!/usr/bin/env python3
"""compare-benchmarks.py -- 从BenchmarkDotNet JSON导出检测基准回归。"""
import json
import sys
from pathlib import Path
def load_benchmarks(results_dir: str) -> dict:
"""从BenchmarkDotNet JSON导出文件加载基准结果。"""
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:
"""比较当前结果与基线。返回回归列表。"""
baseline = load_benchmarks(baseline_dir)
current = load_benchmarks(current_dir)
regressions = []
for name, curr in current.items():
if name not in baseline:
continue # 新基准,无法比较
base = baseline[name]
if base["mean"] == 0:
continue # 避免除以零
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="比较BenchmarkDotNet结果")
parser.add_argument("--baseline", required=True, help="基线结果目录路径")
parser.add_argument("--current", required=True, help="当前结果目录路径")
parser.add_argument("--threshold", type=float, default=10.0,
help="回归阈值百分比(默认:10)")
parser.add_argument("--output", default="comparison.md", help="输出Markdown文件")
args = parser.parse_args()
regressions = compare(args.baseline, args.current, args.threshold)
with open(args.output, "w") as f:
if regressions:
f.write("## 检测到基准回归
")
f.write("| 基准 | 基线(ns) | 当前(ns) | 变化 | 分配增量 |
")
f.write("|-----------|--------------|-------------|--------|-------------|
")
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 |
")
f.write(f"
阈值:{args.threshold}%
")
else:
f.write("## 基准结果
未检测到回归 ")
f.write(f"(阈值:{args.threshold}%)。
")
if regressions:
print(f"回归:{len(regressions)}个基准测试超出 "
f"{args.threshold}%阈值", file=sys.stderr)
sys.exit(1)
选择阈值
| 环境 | 建议阈值 | 理由 |
|---|---|---|
| 专用基准硬件 | 5% | 噪声低;小回归是信号 |
| GitHub Actions共享运行器 | 10-15% | 共享运行器引入5-10%的噪声邻居方差 |
| 自托管运行器 | 5-10% | 比共享更稳定,但仍监控方差 |
经验校准阈值: 在CI环境中运行相同的基准套件5-10次,不更改代码。观察到的最大方差设置噪声底限。将阈值设置在此噪声底限之上(通常为观察方差的2倍)。
分配回归检测
内存分配回归比时序回归更可靠,因为分配是确定性的(不受噪声邻居影响):
# 添加到比较函数:
if alloc_change > 0:
regressions.append({
"name": name,
"type": "分配",
"baseline_alloc": base["allocated"],
"current_alloc": curr["allocated"],
"alloc_change": alloc_change,
})
使用分配变化作为硬门控(零容忍零分配路径中的新分配),时序变化作为软门控(带阈值的警告)。
告警策略
PR评论与回归摘要
将基准比较结果发布为PR评论,以供审查者可见:
- name: 用结果评论PR
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: 检查回归
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
# 脚本在检测到回归时退出非零 -- 失败作业
对于必需状态检查和与基准门控的分支保护集成,参见[技能:dotnet-gha-patterns]。
趋势跟踪
对于超越单PR比较的长期趋势分析,将结果上传到持久存储并随时间跟踪指标:
| 方法 | 工具 | 复杂性 |
|---|---|---|
| GitHub Actions工件 | 内置,90天保留 | 低 – 仅工件下载/上传 |
| 带benchmark-action的GitHub页面 | 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中仅运行关键路径基准以减少管道持续时间:
# 仅运行"Critical"类别的基准
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: 夜间基准测试(完整套件)
on:
schedule:
- cron: '0 3 * * *' # UTC时间凌晨3点每天
workflow_dispatch:
jobs:
benchmark-full:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 设置 .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: 运行完整基准套件
run: dotnet run -c Release --project benchmarks/MyBenchmarks.csproj -- --exporters json
# 无--filter:运行所有基准,包括Extended类别
- name: 上传完整结果
uses: actions/upload-artifact@v4
with:
name: benchmark-full-${{ github.run_number }}
path: benchmarks/BenchmarkDotNet.Artifacts/results/
retention-days: 90
对于跨TFMs的计划工作流模式和矩阵构建,参见[技能:dotnet-gha-patterns]。
代理注意事项
- 在CI中使用
Job.ShortRun,而非Job.Default– 默认基准作业运行许多迭代以获得统计精度,每个基准类需要10-30+分钟。CI管道需要更快的反馈与ShortRun(3次预热,5次迭代)。 - 设置阈值高于测量的噪声底限 – 共享CI运行器引入5-10%的时序方差来自噪声邻居。在共享运行器上设置5%的阈值会产生误报。通过多次运行相同代码并测量方差来校准。
- 使用分配变化作为硬门控 – 分配计数是确定性的,不受运行器噪声影响。从零到非零的分配变化始终是真实回归,不同于时序变化。
- 仅从main分支更新基线 – 如果PR分支可以更新基线,一个PR中的回归成为新基线,从后续比较中掩盖它。
- 在bash步骤中始终设置
set -euo pipefail– 没有pipefail,回归检测脚本在管道中退出非零时不会失败GitHub Actions步骤。 - 优雅处理缺失基线 – 第一次CI运行没有基线可比较。在基线下载步骤使用
continue-on-error: true,当无基线存在时跳过比较。 - 导出JSON,不仅是Markdown – Markdown报告是人类可读的,但非机器可解析用于自动回归检测。始终在配置中包含
[JsonExporterAttribute.Full]或JsonExporter.Full。