mcpField
D
dotnet-ci-benchmarking

dotnet-ci-benchmarking

github.com/wshaddix测试与质量8669 次安装35 周前更新
代码仓库

关于 dotnet-ci-benchmarking

为在 GitHub Actions CI 中持续运行 BenchmarkDotNet 性能检查提供指导,涵盖 JSON 基线、阈值回归检测,以及结果报告和构建门禁。

主要功能

  1. 01管理 BenchmarkDotNet JSON 基线
  2. 02按可配置阈值比较结果
  3. 03用 GitHub Actions 制品存储基线
  4. 04在拉取请求中报告回归
  5. 05在 CI 中使用快速基准测试

适用场景

  1. 01检测拉取请求中的性能变化
  2. 02以分配回归作为合并门禁
  3. 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 Pagesbenchmark-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 注意事项

  1. 在 CI 中使用 Job.ShortRun,而不是 Job.Default —— 默认基准测试任务会运行许多轮迭代以提高统计精度,每个基准测试类别耗时 10–30 分钟甚至更久。CI 流水线需要更快地获得反馈,因此应使用 ShortRun(3 次预热,5 次迭代)。
  2. 将阈值设在测量噪声底线之上 —— 共享 CI Runner 会因其他负载而产生 5–10% 的计时偏差。在共享 Runner 上设置 5% 的阈值会产生误报。可通过多次运行同一段代码并测量偏差来校准。
  3. 将分配变化作为硬性门槛 —— 分配次数具有确定性,不受 Runner 噪声影响。从零分配变为非零分配始终是真实回归,与计时波动不同。
  4. 仅从 main 分支更新基线 —— 如果 PR 分支可以更新基线,某个 PR 中的回归就会成为新基线,从而在后续比较中被掩盖。
  5. 始终在 bash 步骤中设置 set -euo pipefail —— 如果没有 pipefail,回归检测脚本在管道中(例如 script | tee)以非零状态退出时,不会导致 GitHub Actions 步骤失败。
  6. 妥善处理基线缺失的情况 —— 首次 CI 运行时没有可供比较的基线。请在基线下载步骤中使用 continue-on-error: true,并在不存在基线时跳过比较。
  7. 导出 JSON,而不只是 Markdown —— Markdown 报告便于人类阅读,但无法供自动化回归检测解析。请始终在配置中加入 [JsonExporterAttribute.Full] 或 JsonExporter.Full。

评论

支持 Markdown