Rustでカスタムベンチマークハーネスを構築する方法

Everett Pompeii

Everett Pompeii


ベンチマークとは?

ベンチマークとは、コードの性能をテストして、その処理速度(レイテンシ)や処理量(スループット)を確認することを指します。 この、ソフトウェア開発において見落とされがちなステップは、高速で高性能なコードを作成および維持するために重要です。 ベンチマークは開発者がコードがさまざまな作業負荷や条件下でどれだけうまく動作するかを理解するために必要な指標を提供します。 機能のリグレッションを防ぐためにユニットテストや統合テストを書くのと同様に、 パフォーマンスのリグレッションを防ぐためにもベンチマークを書くべきです。 パフォーマンスのバグもバグです!

Rustにおけるベンチマーク

Rustでのベンチマークにおいて人気のあるオプションは、libtest benchCriterion、そしてIaiです。

libtestはRustの組み込みユニットテストとベンチマークフレームワークです。 Rust標準ライブラリの一部であるにもかかわらず、libtest benchはまだ不安定とされており、nightlyコンパイラリリースでのみ利用可能です。 安定バージョンのRustコンパイラで動作させるためには、別のベンチマークハーネスを使用する必要があります。 しかし、どちらも積極的に開発されているわけではありません。

Rustエコシステム内で最も人気のあるベンチマークハーネスはCriterionです。 これは安定版とnightly版の両方のRustコンパイラリリースで動作し、Rustコミュニティ内で事実上の標準として定着しています。 Criterionはlibtest benchと比べてはるかに多機能です。

Criterionの実験的な代替としてIaiがありますが、これはCriterionの作成者によって開発されたものです。 しかし、壁時計時間の代わりに命令カウントを使用します:CPU命令、L1アクセス、L2アクセス、RAMアクセスです。 これにより、これらのメトリクスはラン間でほぼ同一であるべきなので、シングルショットベンチマークが可能になります。

以上のことを念頭に置いて、コードのウォールクロックタイムをベンチマークしたいのであれば、Criterionを使用することをお勧めします。 共有ランナーを使用してCIでコードをベンチマークしたい場合は、Iaiをチェックしてみる価値があります。 ただし、Iaiは3年以上更新されていません。 そのため、代わりにIai-Callgrindを使用することを検討するかもしれません。

しかし、ウォールクロックタイムや命令カウントをベンチマークしたくない場合はどうでしょうか? 全く異なるベンチマークを追跡したい場合はどうでしょうか? 幸運なことに、Rustを使用するとカスタムベンチマークハーネスを非常に簡単に作成できます。

cargo bench の動作

カスタムベンチマークハーネスを作成する前に、Rust ベンチマークの仕組みを理解する必要があります。 ほとんどのRust開発者にとって、これは cargo bench コマンド を実行することを意味します。 cargo bench コマンドはベンチマークをコンパイルして実行します。 デフォルトでは、cargo bench は組み込み(ただし不安定な)libtest ベンチハーネスを使用しようとします。 libtest ベンチはコードを通過して #[bench] 属性で注釈されたすべての関数を実行します。 カスタムベンチマークハーネスを使用するためには、cargo bench に libtest ベンチを使用しないように指示する必要があります。

cargo benchでカスタムベンチマークハーネスを使用する

cargo benchにlibtestのベンチを使用させないようにするためには、Cargo.tomlファイルに以下を追加する必要があります。

Cargo.toml
[[bench]]
harness = false

残念ながら、カスタムベンチマークハーネスでは#[bench]属性を使用できません。 いつの日か使えるようになるかもしれませんが、今日はまだです。 その代わりに、ベンチマークを保持するための別のbenchesディレクトリを作成する必要があります。 benchesディレクトリは、統合テストにおける testsディレクトリ に相当します。 benchesディレクトリ内の各ファイルは別々のクレートとして扱われます。 そのため、ベンチマークの対象となるクレートはライブラリクレートでなければなりません。 つまり、lib.rsファイルを持っている必要があります。

例えば、gameという基本的なライブラリクレートがあれば、benchesディレクトリにplay_gameという名前のカスタムベンチマークファイルを追加することができます。 ディレクトリ構造は以下のようになります:

game
├── Cargo.lock
├── Cargo.toml
└── benches
└── play_game.rs
└── src
└── lib.rs

次に、cargo benchにカスタムベンチマーククレートplay_gameについて知らせる必要があります。 そのために、Cargo.tomlファイルを更新します:

Cargo.toml
[[bench]]
name = "play_game"
harness = false

ベンチマークするコードを書く

パフォーマンステストを書く前に、ベンチマークするためのライブラリコードが必要です。 私たちの例では、FizzBuzzFibonacciゲームを行います。

FizzBuzzFibonacciのルールは以下の通りです:

1から100(含む)までの整数を出力するプログラムを書いてください:

  • 3の倍数の場合、Fizzを出力
  • 5の倍数の場合、Buzzを出力
  • 3と5の両方の倍数の場合、FizzBuzzを出力
  • フィボナッチ数列の一部である数の場合、Fibonacciのみを出力
  • その他のすべての数については、その数を出力

src/lib.rsの実装は以下の通りです:

src/lib.rs
pub fn play_game(n: u32, print: bool) {
let result = fizz_buzz_fibonacci(n);
if print {
println!("{result}");
}
}
fn fizz_buzz_fibonacci(n: u32) -> String {
if is_fibonacci_number(n) {
"Fibonacci".to_string()
} else {
match (n % 3, n % 5) {
(0, 0) => "FizzBuzz".to_string(),
(0, _) => "Fizz".to_string(),
(_, 0) => "Buzz".to_string(),
(_, _) => n.to_string(),
}
}
}
fn is_fibonacci_number(n: u32) -> bool {
let (mut previous, mut current) = (0, 1);
while current < n {
let next = previous + current;
previous = current;
current = next;
}
current == n
}

カスタムベンチマークハーネスの作成

benches/play_game.rs 内にカスタムベンチマークハーネスを作成します。 このカスタムベンチマークハーネスはヒープの割り当てを測定するものです。 測定には the dhat-rs crate を使用します。 dhat-rs は Rust プログラムのヒープ割り当てを追跡するための素晴らしいツールで、 Rust のパフォーマンスエキスパートである Nicholas Nethercote によって作成されました。 ベンチマーク関数の管理を支援するために、 非常に多作な David Tolnay による the inventory crate を使用します。

それでは、dhat-rsinventoryCargo.toml ファイルに dev-dependencies として追加しましょう:

Cargo.toml
[dev-dependencies]
dhat = "0.3"
inventory = "0.3"

カスタムアロケータの作成

独自のベンチマークハーネスではヒープ割り当てを測定するため、カスタムヒープアロケータを使用する必要があります。 Rustでは、 #[global_allocator] 属性 を使用してカスタムのグローバルヒープアロケータを設定できます。 benches/play_game.rs の先頭に次のコードを追加します:

benches/play_game.rs
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;

これにより、Rustは dhat::Alloc をグローバルヒープアロケータとして使用することを指示します。

🐰 グローバルヒープアロケータを設定できるのは一つだけです。 複数のグローバルアロケータを切り替える場合は、Rustのフィーチャー を使用した条件付きコンパイルで管理する必要があります。

カスタムベンチマークコレクターの作成

カスタムベンチマークハーネスを作成するためには、ベンチマーク関数を識別して保存する方法が必要です。 CustomBenchmark という構造体を使用して、各ベンチマーク関数をカプセル化します。

benches/play_game.rs
#[derive(Debug)]
struct CustomBenchmark {
name: &'static str,
benchmark_fn: fn() -> dhat::HeapStats,
}

CustomBenchmark は名前と、結果として dhat::HeapStats を返すベンチマーク関数を持ちます。

次に、inventory クレートを使用して、すべての CustomBenchmark のコレクションを作成します:

benches/play_game.rs
#[derive(Debug)]
struct CustomBenchmark {
name: &'static str,
benchmark_fn: fn() -> dhat::HeapStats,
}
inventory::collect!(CustomBenchmark);

ベンチマーク関数の作成

さて、FizzBuzzFibonacciゲームを実行するベンチマーク関数を作成しましょう:

benches/play_game.rs
fn bench_play_game() -> dhat::HeapStats {
let _profiler = dhat::Profiler::builder().testing().build();
std::hint::black_box(for i in 1..=100 {
play_game(i, false);
});
dhat::HeapStats::get()
}

一行ずつ確認していきます:

  • CustomBenchmarkで使用された署名に一致するベンチマーク関数を作成します。
  • テストモードでdhat::Profilerを作成し、 dhat::Allocのカスタムグローバルアロケーターから結果を収集します。
  • コンパイラがコードを最適化しないように、「ブラックボックス」内でplay_game関数を実行します。
  • 1から100まで反復します(包含)。
  • 各数値に対して、printfalseに設定してplay_gameを呼び出します。
  • ヒープ割り当て統計をdhat::HeapStatsとして返します。

🐰 play_game関数のprintfalseに設定しました。 これにより、play_gameが標準出力に出力しないようにします。 このようにライブラリ関数をパラメーター化すると、 ベンチマークを行いやすくなります。 しかし、これはライブラリを実際に使用する際と 全く同じ方法でベンチマークを行わないことになります。

この場合、自分たちに問う必要があります:

  1. 標準出力に出力するためのリソースを気にする必要があるか?
  2. 標準出力に出力することがノイズの原因となる可能性があるか?

この例では、次のように決定しました:

  1. いいえ、標準出力に出力することは気にしません。
  2. はい、大変なノイズの原因となる可能性があります。

したがって、このベンチマークの一部として標準出力への出力を省略しました。 ベンチマークは難しく、このような質問に対する唯一の正解はありません。 状況次第です.

ベンチマーク関数の登録

ベンチマーク関数を書いたら、それを CustomBenchmark として作成し、inventory を使用してベンチマークコレクションに登録する必要があります。

benches/play_game.rs
fn bench_play_game() -> dhat::HeapStats {
let _profiler = dhat::Profiler::builder().testing().build();
std::hint::black_box(for i in 1..=100 {
play_game(i, false);
});
dhat::HeapStats::get()
}
inventory::submit!(CustomBenchmark {
name: "bench_play_game",
benchmark_fn: bench_play_game
});

もし複数のベンチマークがあれば、このプロセスを繰り返します:

  1. ベンチマーク関数を作成する。
  2. ベンチマーク関数のための CustomBenchmark を作成する。
  3. CustomBenchmarkinventory コレクションに登録する。

カスタムベンチマークランナーの作成

最後に、カスタムベンチマークハーネスのためのランナーを作成する必要があります。 カスタムベンチマークハーネスは、実際にはベンチマークをすべて実行し、結果を報告するバイナリです。 ベンチマークランナーはそのすべてを統括します。

結果をBencher Metric Format (BMF) JSONで出力したいと思います。 これを実現するために、最後の依存関係として David Tolnayによるserde_jsonクレートを追加します!

Cargo.toml
[dev-dependencies]
dhat = "0.3"
inventory = "0.3"
serde_json = "1.0"

次に、CustomBenchmarkがベンチマーク関数を実行し、その結果をBMF JSONとして返すメソッドを実装します。

benches/play_game.rs
impl CustomBenchmark {
fn run(&self) -> serde_json::Value {
let heap_stats = (self.benchmark_fn)();
let measures = serde_json::json!({
"Final Blocks": {
"value": heap_stats.curr_blocks,
},
"Final Bytes": {
"value": heap_stats.curr_bytes,
},
"Max Blocks": {
"value": heap_stats.max_blocks,
},
"Max Bytes": {
"value": heap_stats.max_bytes,
},
"Total Blocks": {
"value": heap_stats.total_blocks,
},
"Total Bytes": {
"value": heap_stats.total_bytes,
},
});
let mut benchmark_map = serde_json::Map::new();
benchmark_map.insert(self.name.to_string(), measures);
benchmark_map.into()
}
}

BMF JSON結果には、各ベンチマークに対して6つのメトリクスが含まれます:

  • Final Blocks: ベンチマーク終了時に割り当てられた最終的なブロック数。
  • Final Bytes: ベンチマーク終了時に割り当てられた最終的なバイト数。
  • Max Blocks: ベンチマーク実行中に一度に割り当てられた最大ブロック数。
  • Max Bytes: ベンチマーク実行中に一度に割り当てられた最大バイト数。
  • Total Blocks: ベンチマーク実行中に割り当てられた総ブロック数。
  • Total Bytes: ベンチマーク実行中に割り当てられた総バイト数。

最後に、inventoryコレクション内のすべてのベンチマークを実行し、 その結果をBMF JSONとして出力するmain関数を作成できます。

benches/play_game.rs
fn main() {
let mut bmf = serde_json::Map::new();
for benchmark in inventory::iter::<CustomBenchmark> {
let mut results = benchmark.run();
bmf.append(results.as_object_mut().unwrap());
}
let bmf_str = serde_json::to_string_pretty(&bmf).unwrap();
std::fs::write("results.json", &bmf_str).unwrap();
println!("{bmf_str}");
}

カスタムベンチマークハーネスを実行する

すべてが整いました。 ついにカスタムベンチマークハーネスを実行することができます。

Terminal window
cargo bench

標準出力およびresults.jsonという名前のファイルに出力される結果は次のようになります:

{
"bench_play_game": {
"Current Blocks": {
"value": 0
},
"Current Bytes": {
"value": 0
},
"Max Blocks": {
"value": 1
},
"Max Bytes": {
"value": 9
},
"Total Blocks": {
"value": 100
},
"Total Bytes": {
"value": 662
}
}
}

表示される正確な数値は、コンピューターのアーキテクチャによって少し異なる場合があります。 しかし、重要なのは少なくとも最後の4つの指標にいくつかの値があることです。

カスタムベンチマーク結果を追跡する

ほとんどのベンチマーク結果は一時的なものです。 ターミナルのスクロールバック制限に達すると消えてしまいます。 一部のベンチマークハーネスでは結果をキャッシュできますが、それを実装するのは非常に手間がかかります。 さらに、その結果をローカルに保存することしかできません。 幸運なことに、私たちのカスタムベンチマークハーネスはBencherと連携します! Bencherは継続的ベンチマークツールのスイートであり、 ベンチマークの結果を時間をかけて追跡し、 パフォーマンスの退行を本番環境に到達する_前に_捕捉することができます。

Bencher CloudまたはBencher Self-Hostedのセットアップが完了すると、 次のコマンドを実行して、カスタムベンチマークハーネスからの結果を追跡できます:

Terminal window
bencher run --file results.json "cargo bench"

また、カスタムベンチマークをBencherで追跡する方法に関する詳細や、 JSONベンチマークアダプターについても読むことができます。

まとめ

この投稿では、Rustエコシステムで最も人気のある3つのベンチマークハーネスについて紹介しました:libtest benchCriterion、およびIaiです。これらは大部分のユースケースをカバーしますが、場合によってはウォールクロックタイムや命令カウント以外のものを測定する必要があるかもしれません。そのために、カスタムベンチマークハーネスを作成することにしました。

私たちのカスタムベンチマークハーネスは、dhat-rsを使用してヒープアロケーションを測定します。ベンチマーク関数は inventory を使用して収集されます。実行すると、ベンチマーク結果は Bencher Metric Format (BMF) JSON として出力されます。そして、Bencherを使用してカスタムベンチマーク結果を時間経過とともに追跡し、CIでパフォーマンスのリグレッションを検出することができます。

このガイドのすべてのソースコードはGitHubで利用可能です

Bencher: 連続ベンチマーキング

🐰 Bencher

Bencherは、連続ベンチマーキングツールのスイートです。 パフォーマンスの後退があなたのユーザーに影響を与えたことはありますか? Bencherなら、それが起こるのを防げた可能性があります。 Bencherは、パフォーマンスの低下を_productionに到達する_前に検出し、防止することを可能にします。

  • 実行: お気に入りのベンチマーキングツールを使用してベンチマークをローカルまたはCIで実行します。 bencher CLIは単にあなたの既存のベンチマークハーネスをラップし、その結果を保存します。
  • 追跡: ベンチマークの結果を時間と共に追跡します。ソースブランチ、テストベッド、測定基準に基づいてBencherのWebコンソールを使用して結果を監視、クエリ、グラフ化します。
  • キャッチ: CIでパフォーマンスの後退をキャッチします。Bencherは最先端のカスタマイズ可能な分析を使用して、パフォーマンスの後退がProductionに到達する前にそれを検出します。

機能の後退を防ぐためにユニットテストがCIで実行されるのと同じ理由で、Bencherを使用してCIでベンチマークを実行してパフォーマンスの後退を防ぐべきです。パフォーマンスのバグはバグです!

CIでパフォーマンスの回帰を捉えるのを開始してください - Bencher Cloudを無料で試す

🤖 このドキュメントは OpenAI GPT-4 によって自動的に生成されました。 正確ではない可能性があり、間違いが含まれている可能性があります。 エラーを見つけた場合は、GitHub で問題を開いてください。.