Criterionを使ってRustコードをベンチマークする方法
Everett Pompeii
ベンチマークとは?
ベンチマークとは、コードの性能をテストして、その処理速度(レイテンシ)や処理量(スループット)を確認することを指します。 この、ソフトウェア開発において見落とされがちなステップは、高速で高性能なコードを作成および維持するために重要です。 ベンチマークは開発者がコードがさまざまな作業負荷や条件下でどれだけうまく動作するかを理解するために必要な指標を提供します。 機能のリグレッションを防ぐためにユニットテストや統合テストを書くのと同様に、 パフォーマンスのリグレッションを防ぐためにもベンチマークを書くべきです。 パフォーマンスのバグもバグです!
RustでFizzBuzzを書く
ベンチマークを書くためには、ベンチマークするソースコードが必要です。 まず、非常にシンプルなプログラム、 FizzBuzz を書いてみましょう。
FizzBuzzのルールは以下の通りです:
1
から100
までの整数を印刷するプログラムを書く:
- 三の倍数の場合は、
Fizz
を印刷します- 五の倍数の場合は、
Buzz
を印刷します- 三と五の両方の倍数の場合は、
FizzBuzz
を印刷します- それ以外の場合は、数字を印刷します
FizzBuzzは多くの方法で書くことができます。 なので、私のお気に入りの方法で進めることにしましょう。
main
関数を作成する1
から100
までを含む範囲で繰り返します。- 各数値に対して、
3
と5
の両方で余り(除算後の余り)を計算します。 - 2つの余りにパターンマッチを行います。
余りが
0
の場合、その数は指定された因数の倍数です。 3
と5
の両方で余りが0
の場合はFizzBuzz
を出力します。3
のみで余りが0
の場合はFizz
を出力します。5
のみで余りが0
の場合はBuzz
を出力します。- それ以外の場合は、単に数字を出力します。
ステップバイステップで進める
このステップバイステップのチュートリアルを進めるには、まずRustをインストールする必要があります。
🐰 このポストのソースコードはGitHub上で利用可能です。
Rustがインストールされたら、ターミナルウィンドウを開き、次のコマンドを入力します:cargo init game
その後、新たに作成されたgame
ディレクトリに移動します。
src
という名前のディレクトリがあり、その中にmain.rs
というファイルが存在しているはずです:
先ほどのFizzBuzzの実装をその内容と置き換えてから、cargo run
を実行します。
出力結果は次のようになるはずです:
🐰 ブーム! コーディングインタビューを突破していますね!
新たなCargo.lock
ファイルが生成されているはずです:
これ以上進む前に、マイクロベンチマークとマクロベンチマークの違いについて話すことが重要です。
マイクロベンチマークとマクロベンチマーク
ソフトウェアベンチマークには、マイクロベンチマークとマクロベンチマークの2つの主要なカテゴリーがあります。
マイクロベンチマークは、ユニットテストと同様のレベルで動作します。
例えば、単一の数値に対してFizz
、Buzz
、またはFizzBuzz
を決定する関数のベンチマークはマイクロベンチマークになります。
一方、マクロベンチマークは、統合テストと同様のレベルで動作します。
例えば、1
から100
までの全ゲームをプレイするFizzBuzzの関数のベンチマークは、マクロベンチマークになります。
一般的に、可能な限り低い抽象レベルでテストすることが最善です。 ベンチマークの場合、これにより保守性が向上し、測定値のノイズを減らすことに役立ちます。 しかし、エンドツーエンドテストがシステム全体が予想通りに組み合わさるかのサニチェックに非常に役立つように、 マクロベンチマークがあると、ソフトウェアを通る重要なパスが性能を維持するために非常に役立ちます。
Rustにおけるベンチマーク
Rustでのベンチマークにおいて人気のあるオプションは、libtest bench、Criterion、そしてIaiです。
libtestはRustの組み込みユニットテストとベンチマークフレームワークです。
Rust標準ライブラリの一部であるにもかかわらず、libtest benchはまだ不安定とされており、nightly
コンパイラリリースでのみ利用可能です。
安定バージョンのRustコンパイラで動作させるためには、別のベンチマークハーネスを使用する必要があります。
しかし、どちらも積極的に開発されているわけではありません。
Rustエコシステム内で最も人気のあるベンチマークハーネスはCriterionです。
これは安定版とnightly
版の両方のRustコンパイラリリースで動作し、Rustコミュニティ内で事実上の標準として定着しています。
Criterionはlibtest benchと比べてはるかに多機能です。
Criterionの実験的な代替としてIaiがありますが、これはCriterionの作成者によって開発されたものです。 しかし、壁時計時間の代わりに命令カウントを使用します:CPU命令、L1アクセス、L2アクセス、RAMアクセスです。 これにより、これらのメトリクスはラン間でほぼ同一であるべきなので、シングルショットベンチマークが可能になります。
全てがBencherによってサポートされています。それならばなぜCriterionを選ぶのでしょうか? Criterionは、Rustコミュニティにおける事実上の標準ベンチマーキングハーネスです。 私は、コードのレイテンシをベンチマーキングするためにCriterionを使用することをお勧めします。 つまり、Criterionは壁時計時間の測定に適しています。
FizzBuzzのリファクタリング
FizzBuzzアプリケーションをテストするためには、ロジックとプログラムのmain
関数を切り離す必要があります。
ベンチマークハーネスはmain
関数をベンチマークできません。これを行うためには、いくつかの変更を行う必要があります。
src
の下に、新しいファイルlib.rs
を作成します:
以下のコードをlib.rs
に追加します:
play_game
: 符号なし整数n
を指定してfizz_buzz
を呼び出し、print
がtrue
の場合は結果を出力します。fizz_buzz
: 符号なし整数n
を受け取り、実際のFizz
、Buzz
、FizzBuzz
、または数字のロジックを実行して結果を文字列として返します。
その後main.rs
を以下のように更新します:
game::play_game
:lib.rs
で作成したgame
クレートからplay_game
をインポートします。main
: プログラムのメインエントリーポイントで、1
から100
までの数字を反復処理し、各数字に対してplay_game
を呼び出し、print
をtrue
に設定します。
FizzBuzzのベンチマーク
コードをベンチマークするために、benches
ディレクトリを作成し、ベンチマークを含むファイル play_game.rs
を追加する必要があります:
play_game.rs
に次のコードを追加します:
Criterion
ベンチマークランナーをインポートします。- 我々の
game
クレートからplay_game
関数をインポートします。 Criterion
の mutable reference を受け取るbench_play_game
という名前の関数を作成します。Criterion
インスタンス(c
)を使用して、bench_play_game
という名前のベンチマークを作成します。- ベンチマークランナー(
b
)を使用して、マクロベンチマークを何度も実行します。 - コンパイラが我々のコードを最適化しないように、“black box” 内でマクロベンチマークを実行します。
1
から100
まで繰り返します。- 各数字に対して、
play_game
を呼び出します。print
をfalse
に設定します。
次に、ベンチマークを実行するための game
クレートを設定する必要があります。
あなたの Cargo.toml
ファイルの 最後 に、以下を追加してください:
criterion
: パフォーマンステストのためにのみ使用するので、開発依存関係としてcriterion
を追加します。bench
:play_game
をベンチマークとして登録し、harness
をfalse
に設定します。なぜなら、我々は Criterion をベンチマークハーネスとして使用するからです。
さあ、コードをベンチマークする準備が整いました。cargo bench
を実行します:
🐰 レタスターニップビート!私たちは最初のベンチマークメトリクスを得ました!
ついに…ちょっと待って、私たちのユーザーは新機能を求めています!
RustでFizzBuzzFibonacciを書く
私たちの主要業績指標(KPI)が下降しているため、製品マネージャー(PM)は新機能の追加を希望しています。多くのブレインストーミングとユーザーインタビューの結果、単なるFizzBuzzだけでは足りないと判断されました。今日の子供たちは新しいゲーム、FizzBuzzFibonacciを求めています。
FizzBuzzFibonacciの規則は以下の通りです:
1
から100
までの整数を印刷するプログラムを書く:
- 3の倍数には
Fizz
を印刷- 5の倍数には
Buzz
を印刷- 三と五の倍数には
FizzBuzz
を印刷- フィボナッチ数列の一部である数には、
Fibonacci
だけを印刷- それ以外のすべてには、その数値を印刷
フィボナッチ数列は、それぞれの数が前の二つの数の和である数列です。
例えば、0
と1
から始めると、フィボナッチ数列の次の数は1
になります。
そして、それに続く数は:2
, 3
, 5
, 8
と続きます。
フィボナッチ数列の一部である数はフィボナッチ数として知られています。なので、フィボナッチ数を検出する関数を書く必要があります。
フィボナッチ数列を書く方法はたくさんありますし、同様にフィボナッチ数を検出する方法もたくさんあります。 だから私のお気に入りを選びます:
is_fibonacci_number
という名前の関数を作成し、符号なし整数を引数に取り、ブールを返します。0
から我々の与えられた数n
の間で全ての数値に対して反復します。- フィボナッチ数列を
0
および1
から開始し、それぞれをprevious
とcurrent
の両方の数値とし初期化します。 current
の数値が現在のi
の反復より少ない間反復します。previous
とcurrent
の数値を加えてnext
の数値を取得します。previous
の数値をcurrent
の数値にアップデートします。current
の数値をnext
の数値にアップデートします。- 一度
current
が与えられた数値n
と等しいかそれ以上になったら、ループを終了します。 current
の数値が与えられた数値n
と等しいかどうか確認し、そうであればtrue
を返します。- それ以外の場合は、
false
を返します。
次に fizz_buzz
関数を更新する必要があります:
fizz_buzz
関数の名前をfizz_buzz_fibonacci
に変更して、より説明的にします。- 弊社の
is_fibonacci_number
ヘルパー関数を呼び出します。 is_fibonacci_number
から得た結果がtrue
の場合はFibonacci
を返します。is_fibonacci_number
の結果がfalse
の場合は、同じFizz
、Buzz
、FizzBuzz
、または数値のロジックを実行し、結果を返します。
fizz_buzz
を fizz_buzz_fibonacci
に変更するため、 play_game
関数も更新する必要があります:
私たちの main
と bench_play_game
の両関数は全く同じままで構いません。
FizzBuzzFibonacciのベンチマーク
ここで、私たちはベンチマークを再実行できます:
おお、すごい!Criterionは、私たちのFizzBuzzとFizzBuzzFibonacciゲームのパフォーマンスの違いを+568.69%
と教えてくれました。
あなたの数字は私のものと少し違うでしょう。
しかし、2つのゲームの違いはおそらく5x
の範囲内でしょう。
これは私にとっては良い感じです!特に、私たちのゲームに_Fibonacci_という高尚な響きのフィーチャーを追加することによるものです。
子供たちはそれを大好きになるでしょう!
RustにてFizzBuzzFibonacciを展開
我々のゲームは大ヒットです!子供たちは確かにFizzBuzzFibonacciを遊ぶのが大好きです。
それほどに、経営陣から続編を求める声が聞こえてきました。
しかし、これは現代の世界、我々は一度きりの購入ではなく、年間定期収入(ARR)が必要です!
我々のゲームの新たなビジョンは、それがオープンエンドであり、1
から100
(包含)の間に生息するのではなく、新たなフロンティアへと向かうことです。
Open World FizzBuzzFibonacciのルールは次のとおりです:
以下のように、任意の 正の整数を受け取って印刷するプログラムを書きます:
- 3の倍数の場合、
Fizz
を出力する- 5の倍数の場合、
Buzz
を出力する- 3と5の両方の倍数の場合、
FizzBuzz
を出力する- フィボナッチ数列の一部である数値は、
Fibonacci
のみを出力する- それ以外の場合は、数値を出力する
我々のゲームが任意の数値で動作できるようにするため、コマンドライン引数を受け取る必要があります。
main
関数を以下のように更新してください:
- コマンドラインから我々のゲームに渡されたすべての引数(
args
)を収集します。 - 我々のゲームに渡された最初の引数を取得し、それを符号なし整数
i
として解析します。 - 解析に失敗した場合、または引数が渡されない場合は、入力として
15
を用いて我々のゲームをデフォルトで遊びます。 - 最後に、新たに解析した符号なし整数
i
で我々のゲームを遊びます。
これで我々のゲームは何の数でも遊べます!
我々のゲームに引数を渡すためにcargo run
の後に--
を使用してください:
そして、もし我々が数字を省略したり、無効な数字が提供されたりすると:
うわー、それは手厚いテストだった!CIがパスします。我々の上司たちは大喜びです。 それでは、出荷しましょう! 🚀
終わり
🐰 … あなたのキャリアの終わりかもしれない?
冗談じゃない!全てが炎上しています!🔥
最初は全てうまく行っているように見えました。 しかし、土曜日の午前2時07分に僕のページャーが鳴った:
📟 あなたのゲームが炎上しています!🔥
ベッドから飛び起き、何が起こっているのかを理解しようとしました。 ログを検索しようと試みましたが、何もかもがクラッシュし続けていて困難でした。 最終的に、問題を見つけました。その子供たち! 彼らは私たちのゲームが大好きで、最大百万までプレイしていました! ひらめきの一瞬で、新たに2つのベンチマークを追加しました:
- ゲームを数字一百(
100
)でプレイするためのマイクロベンチマークbench_play_game_100
- ゲームを数字一百万(
1_000_000
)でプレイするためのマイクロベンチマークbench_play_game_1_000_000
私がそれを実行したとき、これを得ました:
それを待って…それを待って…
何!403.57 ns
x 1,000
は 403,570 ns
になるべきで、9,596,800 ns
(9.5968 ms
x 1_000_000 ns/1 ms
)にはならないはずです 🤯
私がフィボナッチ数列コードを機能的に正しく実装したにもかかわらず、どこかにパフォーマンスのバグがあるはずです。
RustでFizzBuzzFibonacciを修正する
もう一度 is_fibonacci_number
関数を見てみましょう:
パフォーマンスを考えると、不要な余分なループがあることに気づきます。
for i in 0..=n {}
ループを完全に取り除き、
与えられた数値(n
)と current
の値を単に比較するだけで良いのです🤦
- あなたの
is_fibonacci_number
関数を更新します。 - フィボナッチ数列を
0
と1
から始めるprevious
とcurrent
の数で初期化します。 - 与えられた数
n
よりもcurrent
数が小さい間、繰り返します。 previous
とcurrent
の数を足してnext
の数を得ます。previous
の数をcurrent
の数に更新します。current
の数をnext
の数に更新します。current
が与えられた数n
よりも大きくなれば、ループを退出します。current
の数が与えられた数n
と等しいかどうかを確認し、その結果を返します。
それでは、ベンチマークを再実行し、どうなったか見てみましょう:
おお、ワ オ!私たちの bench_play_game
ベンチマークは、元のFizzBuzzのときのものと同じくらいに戻ってきました。
そのスコアがもともとどれくらいだったか覚えていられれば良かったのですが、もう3週間も経っています。
私のターミナルの履歴はそこまで遡ることができません。
また、Criterionは最新の結果についてしか比較しません。
しかし、それは近いと思います!
bench_play_game_100
ベンチマークはほぼ10倍下がりました。 -93.950%
。
そして bench_play_game_1_000_000
ベンチマークは10,000倍以上下がりました! 9,596,800 ns
から 30.403 ns
に!
私たちはさえもCriterionの変化のメーターを最大まで振り切りました、それは -100.000%
までしか表示しません!
🐰 ねえ、少なくとも私たちはこのパフォーマンスバグを本番環境に進出する前に捕まえた…ああ、そうだった…
CIでパフォーマンスの後退を捕捉する
私のちょっとしたパフォーマンスのバグが原因で我々のゲームが大量の否定的なレビューを受けたことに、エクゼクティブたちは不満を持っていました。 彼らは再びそれを起こさないようにと言い、どうすれば良いのか尋ねると、ただ再びやらないようにと言われるだけでした。 どうすればそれを管理することができるのでしょうか‽
幸運なことに、Bencherという素晴らしいオープンソースツールを見つけました。 超大盤振る舞いの無料枠があるので、私の個人的なプロジェクトではBencherクラウドをただ使うことができます。 そして、仕事では全てが私たちのプライベートクラウド内にある必要があるので、Bencher Self-Hostedを使い始めました。
Bencherには組み込みのアダプターがあり、 そのためCIに簡単に統合することができます。クイックスタートガイドをフォローした後、 私は私のベンチマークを実行し、それらをBencherで追跡することができます。
この素敵なウサギが私にくれた便利なタイムマシンを使って、私たちがずっとBencherを使っていたらどうなっていたかを時間を遡って再現しました。 最初にバギーなFizzBuzzFibonacciの実装をプッシュしたところを見ることができます。 私のプルリクエストに対するコメントとしてCIで直ちに失敗が出ました。 その同じ日に、私はその無駄な、余分なループを取り除くことでパフォーマンスのバグを修正しました。 火事はありません。ただ幸せなユーザーたちだけです。
Bencher: 連続ベンチマーキング
Bencherは、連続ベンチマーキングツールのスイートです。 パフォーマンスの後退があなたのユーザーに影響を与えたことはありますか? Bencherなら、それが起こるのを防げた可能性があります。 Bencherは、パフォーマンスの低下を_productionに到達する_前に検出し、防止することを可能にします。
- 実行: お気に入りのベンチマーキングツールを使用してベンチマークをローカルまたはCIで実行します。
bencher
CLIは単にあなたの既存のベンチマークハーネスをラップし、その結果を保存します。 - 追跡: ベンチマークの結果を時間と共に追跡します。ソースブランチ、テストベッド、測定基準に基づいてBencherのWebコンソールを使用して結果を監視、クエリ、グラフ化します。
- キャッチ: CIでパフォーマンスの後退をキャッチします。Bencherは最先端のカスタマイズ可能な分析を使用して、パフォーマンスの後退がProductionに到達する前にそれを検出します。
機能の後退を防ぐためにユニットテストがCIで実行されるのと同じ理由で、Bencherを使用してCIでベンチマークを実行してパフォーマンスの後退を防ぐべきです。パフォーマンスのバグはバグです!
CIでパフォーマンスの回帰を捉えるのを開始してください - Bencher Cloudを無料で試す。