本文へスキップ
BenchmarkDotNet で正しくベンチマークを取る実践ガイドのアイキャッチ画像
Performance Tuning

BenchmarkDotNet で正しくベンチマークを取る実践ガイド

公開: 更新: 約8分で読めます

「この書き方のほうが速いはず」という直感は、実測するとしばしば裏切られる。特にマイクロ秒からナノ秒のオーダーで動作するコードは、JIT の最適化やハードウェアの挙動が複雑に絡み合い、素朴な Stopwatch による計測では正しい数値が得られない。BenchmarkDotNet は、こうしたマイクロベンチマークにつきまとう落とし穴を吸収し、統計的に信頼できる結果を出すための .NET 標準的なライブラリである。本記事では、.NET 10 を前提に、なぜマイクロベンチマークが難しいのかという原理から、BenchmarkDotNet の導入、主要な属性、結果表の読み方、そして CI での回帰検知までを実践的にまとめる。パフォーマンス改善の全体像は.NET パフォーマンス最適化 完全ガイドも参照してほしい。

なぜマイクロベンチマークは難しいのか

短いコードの実行時間を正確に測るのは、想像以上に難しい。理由は大きく 3 つある。

1 つ目は JIT のウォームアップである。.NET のメソッドは初回呼び出し時に JIT コンパイルされ、実行回数が増えると Tiered Compilation によってより最適化されたコードへ再コンパイルされる。したがって最初の数回の実行は本来の性能を反映しておらず、これを計測に含めると数字が大きく歪む。

2 つ目はデッドコード除去である。計測対象の戻り値をどこにも使わないと、JIT は「この計算は無意味だ」と判断してコードごと削除してしまう。その結果、実際には何も実行されていない「0 ナノ秒」の測定になりかねない。

3 つ目は計測ノイズである。OS のスケジューリング、他プロセスの負荷、GC の発生、CPU の周波数変動などにより、同じコードでも実行ごとに時間はばらつく。1 回だけ測って比較しても、それが本当の差なのか単なる揺らぎなのか区別できない。BenchmarkDotNet は、ウォームアップの分離、結果の強制的な消費、多数回の反復と統計処理によって、これらの問題をまとめて解消する。

BenchmarkDotNet の計測の流れと結果表
ウォームアップと計測イテレーションを経て、Mean や Allocated を含む結果表で比較する。

BenchmarkDotNet の導入

まずコンソールプロジェクトを作成し、NuGet パッケージを追加する。ベンチマークは必ず Release ビルドで実行する必要がある。Debug ビルドでは JIT の最適化が抑制され、実運用とかけ離れた数字になるためである。

dotnet new console -n MyBench
cd MyBench
dotnet add package BenchmarkDotNet

# 実行は必ず Release 構成で行う
dotnet run -c Release

エントリポイントでは BenchmarkRunner.Run にベンチマーククラスの型を渡す。BenchmarkDotNet は内部で計測専用の別プロセスを生成し、隔離された環境で正確に測定する仕組みになっている。

using BenchmarkDotNet.Running;

BenchmarkRunner.Run<StringBenchmarks>();

デバッガをアタッチした状態や Debug 構成で実行すると、BenchmarkDotNet 自身が警告を出して結果の信頼性が低いことを知らせる。この警告が出ている間の数字は参考値にとどめるべきである。

[Benchmark] と [Params] でメソッドを定義する

計測したいメソッドに [Benchmark] を付けると、そのメソッドがベンチマーク対象になる。入力サイズを変えて傾向を見たい場合は [Params] を使うと、指定した値それぞれについて自動的に計測が繰り返される。

using BenchmarkDotNet.Attributes;

public class StringBenchmarks
{
    [Params(10, 100, 1000)]
    public int N;

    [Benchmark]
    public string Concat()
    {
        var s = "";
        for (int i = 0; i < N; i++)
            s += i;
        return s;
    }

    [Benchmark]
    public string StringBuilder()
    {
        var sb = new System.Text.StringBuilder();
        for (int i = 0; i < N; i++)
            sb.Append(i);
        return sb.ToString();
    }
}

各ベンチマークメソッドは値を return していることに注目してほしい。戻り値を返すことで BenchmarkDotNet が結果を消費し、デッドコード除去による測定の無効化を防いでいる。

[GlobalSetup] と [IterationSetup] で前処理を分離する

ベンチマーク本体には、純粋に計測したい処理だけを残したい。テストデータの生成や初期化といった前処理は、計測対象から外す必要がある。[GlobalSetup] はベンチマーク実行全体で 1 度だけ呼ばれ、重い初期化に適している。

public class SortBenchmarks
{
    private int[] _data = default!;

    [Params(1000, 100000)]
    public int Size;

    [GlobalSetup]
    public void Setup()
    {
        var rnd = new Random(42);
        _data = new int[Size];
        for (int i = 0; i < Size; i++)
            _data[i] = rnd.Next();
    }

    [Benchmark]
    public int[] SortCopy()
    {
        var copy = (int[])_data.Clone();
        Array.Sort(copy);
        return copy;
    }
}

これに対し [IterationSetup] は各反復の直前に呼ばれる。破壊的な操作を測る場合など、毎回入力を作り直したいときに使うが、呼び出しコストが計測に混ざりやすいため、ナノ秒オーダーの短いベンチマークでは避けるのが無難である。後始末が必要なら [GlobalCleanup] や [IterationCleanup] を併用する。

[Baseline] と MemoryDiagnoser で比較と割り当てを測る

複数の実装を比べるときは、基準にしたいメソッドへ [Benchmark(Baseline = true)] を付ける。すると結果表に Ratio 列が追加され、他の実装がベースラインの何倍速い(遅い)かが一目でわかる。

実行時間だけでなくメモリ割り当ても重要な指標である。クラスに [MemoryDiagnoser] を付けると、各ベンチマークのヒープ割り当て量(Allocated)と GC の発生回数(Gen0/Gen1/Gen2)が計測される。割り当ての削減は GC 負荷の低減に直結するため、レイテンシ改善では実行時間と同じくらい注視すべき数字である。

using BenchmarkDotNet.Attributes;

[MemoryDiagnoser]
public class LookupBenchmarks
{
    private readonly int[] _data = Enumerable.Range(0, 1000).ToArray();

    [Benchmark(Baseline = true)]
    public int LinqCount() => _data.Where(x => x % 2 == 0).Count();

    [Benchmark]
    public int LoopCount()
    {
        int c = 0;
        foreach (var x in _data)
            if (x % 2 == 0) c++;
        return c;
    }
}

この例では、LINQ 版がイテレータや述語デリゲートを割り当てるのに対し、ループ版は割り当てゼロになることが Allocated 列に表れる。GC やメモリの掘り下げは.NET のメモリと GC チューニング実践ガイドで解説している。

Warmup と Iteration の仕組み、Job 設定

BenchmarkDotNet の 1 回の計測は、いくつかの段階に分かれている。まず Pilot フェーズで、1 回の測定に何回メソッドを呼び出せば十分な精度が得られるかを自動決定する。次に Warmup フェーズで JIT のウォームアップと状態の安定化を行い、この間の結果は捨てられる。最後に Actual フェーズで実際の測定を多数回繰り返し、統計を取る。この分離こそが、素朴な計測との決定的な違いである。

これらの回数や環境は Job で制御できる。手早く回したい開発中は反復回数を抑えた [SimpleJob] が便利で、逆に複数のランタイムやビルド構成を横断比較したいときは複数の Job を宣言する。

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;

// 短時間で回すための簡易ジョブ
[SimpleJob(RuntimeMoniker.Net10_0, warmupCount: 3, iterationCount: 5)]
public class QuickBenchmarks
{
    [Benchmark]
    public double Work() => Math.Sqrt(12345.678);
}

生成されたネイティブコードそのものを確認したい場合は [DisassemblyDiagnoser] を付けると、JIT が出力したアセンブリを出力できる。インライン化やベクトル化が効いているかを、推測ではなく実物で確認できる。

避けるべき罠

正しく BenchmarkDotNet を使っても、ベンチマークの書き方次第で無意味な数字を得てしまうことがある。代表的な罠を押さえておきたい。

第一に、結果を消費しないことである。前述のとおり、計算結果は必ず return するか、Consumer に渡して JIT に「使われている」と認識させる。戻り値を void のまま捨てると、丸ごと最適化で消える恐れがある。

第二に、副作用や状態の持ち越しである。ベンチマークメソッドが外部の可変状態を書き換えると、反復ごとに条件が変わってしまう。フィールドをインクリメントし続けるようなコードは、キャッシュへの初回アクセスと 2 回目以降で挙動が異なり、数字が安定しない。入力は [GlobalSetup] で用意し、メソッドは冪等に保つのが基本である。

第三に、非現実的な入力での計測である。常に同じ小さな値や、CPU の分岐予測が完璧に当たるような規則的なデータを使うと、実運用より楽観的な結果になる。[Params] で複数サイズを試し、傾向として評価するのが安全である。

結果表の読み方と CI での回帰検知

実行が終わると、コンソールと Markdown/CSV にサマリー表が出力される。中心となる列は Mean(平均実行時間)、Error(信頼区間の半分幅)、StdDev(標準偏差)、そして [MemoryDiagnoser] 併用時の Allocated である。

| Method        | N    | Mean       | Error    | StdDev   | Allocated |
|-------------- |----- |-----------:|---------:|---------:|----------:|
| Concat        | 1000 | 12.480 us  | 0.121 us | 0.113 us |  505.9 KB |
| StringBuilder | 1000 |  3.210 us  | 0.028 us | 0.026 us |   16.2 KB |

読み方のコツは、Mean だけを見ないことである。StdDev が Mean に対して大きい場合は測定が不安定で、2 つの実装の Mean の差が Error の範囲に収まっているなら、その差は有意とは言えない。上の例のように差が桁違いで Error が十分小さければ、はっきりした差だと結論できる。Allocated の差も、GC を含めた総合的な性能を判断する材料になる。

CI での回帰検知は、絶対値のしきい値ではなくベースラインとの相対比較で考えるのが実践的である。実行環境ごとに絶対時間は変動するため、同一ジョブ内で旧実装と新実装を並べて Ratio を見るか、過去の結果 JSON を保存して相対的な悪化率で判定する。ハードウェアの違いによるノイズを避けるため、比較は必ず同じマシン・同じ実行内で行うのが原則である。.NET 10 のランタイム自体による高速化の実測は.NET 10 の JIT と GC はどれだけ速くなったのかで扱っている。

まとめ

マイクロベンチマークは、JIT のウォームアップ、デッドコード除去、計測ノイズという 3 つの要因によって、素朴な計測では簡単に誤った結論へ導かれる。BenchmarkDotNet は、計測プロセスの隔離、Warmup と Actual の分離、多数回の反復と統計処理、結果の強制消費によってこれらを吸収し、信頼できる数字を提供する。[Benchmark] と [Params] で対象を定義し、[GlobalSetup] で前処理を分離し、[Baseline] と [MemoryDiagnoser] で比較と割り当てを可視化し、結果表では Mean だけでなく Error と StdDev を合わせて有意性を判断する。この基本を押さえれば、推測ではなく計測に基づいて最適化を進められる。

エンハンスド株式会社では、計測に基づく .NET アプリのパフォーマンス改善を支援しています。

この記事をシェア

コピーしました

関連記事