C#を用いたアプリケーション開発において、「この処理にどれくらいの時間がかかっているのか」を正確に把握することは、パフォーマンスチューニングの第一歩です。
単に「遅い」と感じるだけでなく、定量的なデータに基づいてボトルネックを特定することで、効果的な改善が可能になります。
現在の .NET 環境では、簡易的な計測から厳密なマイクロベンチマークまで、目的や用途に応じて最適な手法が用意されています。
本記事では、定番の Stopwatch クラスから、業界標準となっている BenchmarkDotNet、さらには最新の .NET で推奨される手法まで、それぞれの特徴と使い分けを詳しく解説します。
なぜ処理時間の計測が必要なのか
ソフトウェアの規模が大きくなるにつれ、アルゴリズムの選択やメモリ割り当ての効率がシステム全体のレスポンスに大きな影響を与えます。
特にマイクロサービスや高頻度のデータ処理を行うシステムでは、数ミリ秒、あるいは数マイクロ秒の差がスループットの決定的な違いを生むことがあります。
しかし、計測方法を誤ると、実行環境のノイズや JIT (Just-In-Time) コンパイルの影響を受けてしまい、正しくないデータをもとに最適化を行ってしまう危険があります。
「正しく測る」ことは「正しく直す」ことと同じくらい重要であることを念頭に置く必要があります。
簡易的な計測:Stopwatch クラスの活用
C# で最も一般的かつ手軽な計測手法は、System.Diagnostics.Stopwatch クラスを使用することです。
このクラスは、オペレーティングシステムとハードウェアが提供する高分解能パフォーマンスカウンタを利用しており、高い精度で経過時間を取得できます。
基本的な使い方
Stopwatch を使用する場合、インスタンスを作成して Start() を呼び出し、処理が終わった後に Stop() を呼び出します。
using System;
using System.Diagnostics;
using System.Threading;
namespace PerformanceMeasurement
{
class Program
{
static void Main(string[] args)
{
// Stopwatchのインスタンスを作成
Stopwatch sw = new Stopwatch();
Console.WriteLine("計測を開始します...");
sw.Start(); // 計測開始
// 計測対象の処理(例:500ミリ秒待機)
Thread.Sleep(500);
sw.Stop(); // 計測終了
// 結果の出力
Console.WriteLine($"経過時間 (ミリ秒): {sw.ElapsedMilliseconds} ms");
Console.WriteLine($"経過時間 (ティック単位): {sw.ElapsedTicks}");
Console.WriteLine($"経過時間 (詳細): {sw.Elapsed}");
}
}
}
計測を開始します...
経過時間 (ミリ秒): 500 ms
経過時間 (ティック単位): 1562500
経過時間 (詳細): 00:00:00.5001234
Stopwatch 使用時の注意点
Stopwatch は非常に便利ですが、マイクロ秒単位の極めて短い処理を計測する場合には注意が必要です。
- JIT コンパイルの影響:
メソッドが最初に呼び出される際、.NET は中間言語をマシンコードにコンパイルします。この初回のコンパイル時間を含めて計測してしまうと、2回目以降の実行速度を正しく反映できません。正確なデータを得るためには、計測前に一度「空回し」をして、JIT コンパイルを済ませておく(ウォームアップ)必要があります。 - 精度の限界:
Stopwatch.IsHighResolutionフィールドを確認することで、現在の環境が高分解能タイマーをサポートしているか確認できます。現代の Windows 環境であればほぼ true ですが、極めて高い精度を求める場合は後述するベンチマーク専用ライブラリが推奨されます。
高性能な代替手段:ValueStopwatch
近年、.NET の内部ランタイム開発(dotnet/runtime)などで頻繁に利用されているのが ValueStopwatch という構造体です。
Stopwatch クラスは参照型 (class) であるため、インスタンス化のたびにヒープメモリを消費し、GC (Garbage Collection) の対象となります。
非常に頻繁に計測を行うループ内や、ゼロアロケーションを目指すライブラリ開発では、値型 (struct) として実装された ValueStopwatch を使用することでオーバーヘッドを最小限に抑えられます。
なお、これは標準ライブラリとして公開されているものではなく、多くの開発者が自前で定義するか、Internal な実装を模倣して利用するパターンが多いです。
ユニットテストと親和性の高い TimeProvider
.NET 8 以降、時間の概念を抽象化する System.TimeProvider が導入されました。
これを利用することで、処理時間の計測をユニットテストでモック化することが容易になります。
TimeProvider を使った計測
public class ProcessingService
{
private readonly TimeProvider _timeProvider;
public ProcessingService(TimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
public void Execute()
{
// 計測の開始時間を取得
long startTime = _timeProvider.GetTimestamp();
// 何らかの重い処理
DoSomething();
// 経過時間を計算
TimeSpan elapsed = _timeProvider.GetElapsedTime(startTime);
Console.WriteLine($"実行時間: {elapsed.TotalMilliseconds} ms");
}
private void DoSomething() => Thread.Sleep(100);
}
TimeProvider を使うメリットは、テストコードにおいて「実際には時間を経過させずに、特定の時間が経過したと見せかける」ことができる点にあります。
ロジック内で時間に基づく分岐や計測がある場合、この手法が現在の .NET における設計のベストプラクティスと言えます。
厳密な計測の決定版:BenchmarkDotNet
「メソッド A と メソッド B、どちらが速いか」を厳密に比較したい場合、自前でループを回して Stopwatch で測る手法は推奨されません。
CPU のクロック周波数の変動、メモリ配置、OS のコンテキストスイッチなど、多くの要因が結果を歪めてしまうからです。
これらの問題を解決し、統計的に正しい計測データを提供してくれるのが BenchmarkDotNet です。
BenchmarkDotNet の導入と実装
NuGet パッケージから BenchmarkDotNet をインストールして使用します。
using System.Security.Cryptography;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
namespace MyBenchmarks
{
[MemoryDiagnoser] // メモリ割り当て量も計測する
public class Md5VsSha256
{
private const int DataSize = 10000;
private readonly byte[] _data = new byte[DataSize];
[GlobalSetup]
public void Setup()
{
new Random(42).NextBytes(_data);
}
[Benchmark]
public byte[] Md5() => MD5.HashData(_data);
[Benchmark]
public byte[] Sha256() => SHA256.HashData(_data);
}
public class Program
{
public static void Main(string[] args)
{
// ベンチマークの実行
var summary = BenchmarkRunner.Run<Md5VsSha256>();
}
}
}
BenchmarkDotNet を選ぶべき理由
BenchmarkDotNet は単に時間を測るだけでなく、以下の処理を自動的に行います。
- ウォームアップ:JIT コンパイルが完了するまで予備実行を行う。
- 統計的安定化:結果が安定するまで複数回の試行を行い、平均、中央値、標準偏差を算出する。
- メモリ計測:
[MemoryDiagnoser]属性を付けるだけで、GC の発生回数や確保されたバイト数を表示。 - レポート出力:Markdown や CSV、HTML などの形式で結果を出力。
最適化のプロセスにおいて、「感覚的な速さ」ではなく「統計的な有意差」を確認できるのが最大の強みです。
用途別の手法比較
各手法の特徴を以下の表にまとめました。
| 手法 | 推奨される用途 | 精度 | メモリ消費 | 特徴 |
|---|---|---|---|---|
Stopwatch | デバッグ、簡易的なログ出力 | 中 | 低(class) | 直感的で使いやすく、標準機能で完結。 |
ValueStopwatch | 高頻度な計測、低レイテンシ設計 | 中 | ゼロ (struct) | GC 負荷を排除したいライブラリ開発向け。 |
TimeProvider | テスト容易性重視のアプリケーション | 中 | 低 | モック化が可能で、モダンな設計に適する。 |
BenchmarkDotNet | アルゴリズム比較、性能要件の検証 | 高 | – | 統計的精度が非常に高く、デファクトスタンダード。 |
処理時間計測における注意点
どの手法を用いるにしても、計測結果を正しく解釈するためにはいくつかの「落とし穴」を避ける必要があります。
1. リリースビルドで計測する
デバッグモード(Debug Build)では、コンパイラによる最適化が無効化されており、実際の運用環境とは大きく異なる結果になります。
必ず Release ビルドで計測を行ってください。 特に BenchmarkDotNet は、Debug ビルドで実行しようとすると警告を出して停止するよう設計されています。
2. デバッガーを外す
Visual Studio などのデバッガーをアタッチした状態では、ランタイムの挙動が変わる可能性があります。
計測時はデバッガーを通さず、バイナリを直接実行することが望ましいです。
3. 計測対象の粒度を考える
数ナノ秒で終わるような極小の処理を Stopwatch で 1 回だけ測っても、それは誤差を測っているようなものです。
極小の処理は BenchmarkDotNet で数千回試行させるか、あるいは大きなループの中でまとめて計測するようにしましょう。
4. 外部要因の排除
計測中はブラウザで動画を再生したり、重いウイルススキャンを走らせたりしないようにします。
CPU のリソースが奪われると、計測結果のバラツキ(標準偏差)が大きくなり、信頼性が低下します。
実践:どちらが速いか計測してみる
例として、文字列の連結に string += string を使う場合と StringBuilder を使う場合の差を、BenchmarkDotNet で可視化してみましょう。
[MemoryDiagnoser]
public class StringConcatenationBenchmark
{
[Params(10, 100, 1000)]
public int Iterations;
[Benchmark]
public string PlusOperator()
{
string result = string.Empty;
for (int i = 0; i < Iterations; i++)
{
result += i.ToString();
}
return result;
}
[Benchmark]
public string UsingStringBuilder()
{
var sb = new StringBuilder();
for (int i = 0; i < Iterations; i++)
{
sb.Append(i);
}
return sb.ToString();
}
}
このように [Params] 属性を使うと、データ件数ごとのパフォーマンス特性の変化を一覧で確認できます。
件数が少ないときは差がなくても、件数が増えるにつれて指数関数的に差が開く様子を数値で証明できます。
まとめ
C# における処理時間の計測は、用途に応じて以下のように使い分けるのが「最適解」です。
- アプリケーションの動作ログや、数ミリ秒単位のざっくりとした計測には Stopwatch。
- ユニットテストでの検証や、DI を活用したモダンな設計には TimeProvider。
- アルゴリズムの比較や、ナノ秒単位の厳密なパフォーマンス検証には BenchmarkDotNet。
「おそらくこちらのほうが速いだろう」という推測ではなく、適切なツールを用いて得られた客観的なデータこそが、高品質なコードを生む裏付けとなります。
まずは手近な処理を Stopwatch で測ることから始め、徐々にベンチマークツールを活用したプロフェッショナルな最適化に挑戦してみてください。
