mcpField
D
dotnet-ci-benchmarking

dotnet-ci-benchmarking

github.com/wshaddixテスト・品質8669 回インストール35 週間前に更新
リポジトリ

dotnet-ci-benchmarking について

GitHub Actions の CI で BenchmarkDotNet による継続的な性能チェックを行うためのガイダンスです。JSON のベースライン、閾値による回帰検出、結果の報告やビルド制御を扱います。

主な機能

  1. 01BenchmarkDotNet の JSON ベースライン管理
  2. 02設定可能な閾値で結果を比較
  3. 03GitHub Actions の成果物にベースラインを保存
  4. 04プルリクエストで回帰を報告
  5. 05CI 向けの短時間ベンチマーク

活用シーン

  1. 01プルリクエストで性能変化を検出
  2. 02割り当て回帰をマージ条件に設定
  3. 03CI 実行間のベンチマーク結果を追跡

インストール

コマンドをコピーしてターミナルで実行します。

$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 ワークフロー、しきい値を設定できるリグレッション検出パターン、パフォーマンス低下のアラート戦略について説明します。

バージョンに関する前提条件: JSON エクスポートには BenchmarkDotNet v0.14+、GitHub Actions のランナー環境を使用します。例では actions/upload-artifact@v4 と actions/download-artifact@v4 を使用します。

対象外: BenchmarkDotNet のセットアップ、ベンチマーククラスの設計、メモリ診断、よくある落とし穴については、このエピックに付属するスキルが担当します。詳しくは [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] を参照してください。

相互参照:ベンチマーククラスのセットアップと JSON エクスポーターの設定については [skill:dotnet-benchmarkdotnet]、ベンチマークのリグレッションとランタイムメトリクスの変化の関連付けについては [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.BytesAllocatedPerOperation1 操作あたりの 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 を指定し、初回実行(ベースラインがまだ存在しない場合)に対応します
  • PR ブランチがベースラインに影響しないよう、ベースラインは main ブランチへのマージからのみ更新します
  • 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 の設定

CI の高速化に ShortRun を使用する

ベンチマークを最後まで実行すると、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] を参照してください。


エージェントに関する注意事項

  1. CI では Job.Default ではなく Job.ShortRun を使用してください -- デフォルトのベンチマークジョブは統計的な精度を得るために多数の反復を実行するため、ベンチマーククラスごとに 10~30 分以上かかります。CI パイプラインでは、ShortRun(ウォームアップ 3 回、反復 5 回)を使って、より迅速にフィードバックを得る必要があります。
  2. しきい値は測定されたノイズフロアより高く設定してください -- 共有 CI ランナーでは、他の処理による影響で計測時間に 5~10%のばらつきが生じます。共有ランナーで 5%のしきい値を設定すると、誤検知が発生します。同じコードを複数回実行してばらつきを測定し、調整してください。
  3. 割り当て数の変化を厳格なゲートとして使用してください -- 割り当て数は決定論的であり、ランナーのノイズの影響を受けません。割り当てが 0 から 0 以外に変わった場合は、計測時間のばらつきとは異なり、必ず実際のリグレッションです。
  4. ベースラインは main ブランチからのみ更新してください -- PR ブランチからベースラインを更新できると、ある PR のリグレッションが新しいベースラインとなり、その後の比較で検出されなくなります。
  5. bash のステップでは必ず set -euo pipefail を設定してください -- pipefail がない場合、パイプライン内(例:script | tee)でリグレッション検出スクリプトがゼロ以外のコードで終了しても、GitHub Actions のステップは失敗しません。
  6. ベースラインがない場合は適切に処理してください -- CI の初回実行時には比較対象のベースラインがありません。ベースラインのダウンロードステップに continue-on-error: true を使用し、ベースラインが存在しない場合は比較をスキップしてください。
  7. Markdown だけでなく JSON もエクスポートしてください -- Markdown レポートは人間には読みやすいものの、自動リグレッション検出では機械的に解析できません。設定には必ず [JsonExporterAttribute.Full] または JsonExporter.Full を含めてください。

コメント