dotnet-ci-benchmarking について
GitHub Actions の CI で BenchmarkDotNet による継続的な性能チェックを行うためのガイダンスです。JSON のベースライン、閾値による回帰検出、結果の報告やビルド制御を扱います。
主な機能
- 01BenchmarkDotNet の JSON ベースライン管理
- 02設定可能な閾値で結果を比較
- 03GitHub Actions の成果物にベースラインを保存
- 04プルリクエストで回帰を報告
- 05CI 向けの短時間ベンチマーク
活用シーン
- 01プルリクエストで性能変化を検出
- 02割り当て回帰をマージ条件に設定
- 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.BytesAllocatedPerOperation | 1 操作あたりの 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 Pages | benchmark-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] を参照してください。
エージェントに関する注意事項
- CI では
Job.DefaultではなくJob.ShortRunを使用してください -- デフォルトのベンチマークジョブは統計的な精度を得るために多数の反復を実行するため、ベンチマーククラスごとに 10~30 分以上かかります。CI パイプラインでは、ShortRun(ウォームアップ 3 回、反復 5 回)を使って、より迅速にフィードバックを得る必要があります。 - しきい値は測定されたノイズフロアより高く設定してください -- 共有 CI ランナーでは、他の処理による影響で計測時間に 5~10%のばらつきが生じます。共有ランナーで 5%のしきい値を設定すると、誤検知が発生します。同じコードを複数回実行してばらつきを測定し、調整してください。
- 割り当て数の変化を厳格なゲートとして使用してください -- 割り当て数は決定論的であり、ランナーのノイズの影響を受けません。割り当てが 0 から 0 以外に変わった場合は、計測時間のばらつきとは異なり、必ず実際のリグレッションです。
- ベースラインは main ブランチからのみ更新してください -- PR ブランチからベースラインを更新できると、ある PR のリグレッションが新しいベースラインとなり、その後の比較で検出されなくなります。
- bash のステップでは必ず
set -euo pipefailを設定してください --pipefailがない場合、パイプライン内(例:script | tee)でリグレッション検出スクリプトがゼロ以外のコードで終了しても、GitHub Actions のステップは失敗しません。 - ベースラインがない場合は適切に処理してください -- CI の初回実行時には比較対象のベースラインがありません。ベースラインのダウンロードステップに
continue-on-error: trueを使用し、ベースラインが存在しない場合は比較をスキップしてください。 - Markdown だけでなく JSON もエクスポートしてください -- Markdown レポートは人間には読みやすいものの、自動リグレッション検出では機械的に解析できません。設定には必ず
[JsonExporterAttribute.Full]またはJsonExporter.Fullを含めてください。