dotnetCI基准测试技能 dotnet-ci-benchmarking

本技能专注于在dotnet项目的持续集成(CI)管道中实施性能基准测试,用于自动检测性能回归。它提供基准文件管理、GitHub Actions工作流集成、阈值配置和告警策略的完整指南。关键词:性能基准测试、CI/CD、回归检测、BenchmarkDotNet、GitHub Actions、dotnet开发。

DevOps 0 次安装 0 次浏览 更新于 3/6/2026

名称:dotnet-ci-benchmarking 描述:“在CI管道中检测性能回归的门控。自动阈值警报、基线跟踪、趋势报告。” 用户可调用:false

dotnet-ci-benchmarking

在CI管道中检测性能回归的持续基准测试指南。涵盖使用BenchmarkDotNet JSON导出器的基线文件管理、用于基于工件的基线比较的GitHub Actions工作流、具有可配置阈值的回归检测模式以及性能下降的告警策略。

版本假设: BenchmarkDotNet v0.14+用于JSON导出,GitHub Actions运行器环境。示例使用actions/upload-artifact@v4actions/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]。


代理注意事项

  1. 在CI中使用Job.ShortRun,而非Job.Default – 默认基准作业运行许多迭代以获得统计精度,每个基准类需要10-30+分钟。CI管道需要更快的反馈与ShortRun(3次预热,5次迭代)。
  2. 设置阈值高于测量的噪声底限 – 共享CI运行器引入5-10%的时序方差来自噪声邻居。在共享运行器上设置5%的阈值会产生误报。通过多次运行相同代码并测量方差来校准。
  3. 使用分配变化作为硬门控 – 分配计数是确定性的,不受运行器噪声影响。从零到非零的分配变化始终是真实回归,不同于时序变化。
  4. 仅从main分支更新基线 – 如果PR分支可以更新基线,一个PR中的回归成为新基线,从后续比较中掩盖它。
  5. 在bash步骤中始终设置set -euo pipefail – 没有pipefail,回归检测脚本在管道中退出非零时不会失败GitHub Actions步骤。
  6. 优雅处理缺失基线 – 第一次CI运行没有基线可比较。在基线下载步骤使用continue-on-error: true,当无基线存在时跳过比较。
  7. 导出JSON,不仅是Markdown – Markdown报告是人类可读的,但非机器可解析用于自动回归检测。始终在配置中包含[JsonExporterAttribute.Full]JsonExporter.Full