Skip to content

ベンチマーク

エンジンが正しく動くようになったら、次に問われるのは「速いのか」です。しかし性能の計測は、正しさの検証よりもかえって誤りやすい領域です。最適化前のデバッグビルドをうっかり測ってしまう、一回きりの実行の揺らぎを性能差と取り違える、インタプリタと JIT を計算ループで比べて「桁違いに遅い」と落胆する — こうした失敗は、いずれも「速い/遅い」という結論そのものを無意味にします。この章では、性能を正直に測るとはどういうことかを扱います。公平な計測が難しい理由と落とし穴、計測すべき軸の分離、入力サイズを変えて計算量バグを見つける手法、そしてベンチマークに過剰適合する危険を、実装経験に基づいて述べます。本書の最終章として、品質保証全体を締めくくる位置づけでもあります。

なぜ公平な計測は難しいのか

性能の数字は、一見すると客観的です。「秒」や「メガバイト」という単位で出てくるため、そのまま比較できると錯覚しがちです。しかし計測値は、測り方の前提を丸ごと含んだ結果です。同じエンジン・同じコードでも、ビルド構成・実行回数・測定区間・比較相手の選び方を変えれば、数字は簡単に一桁変わります。

問題は、これらの前提が数字そのものには現れないことです。「10倍遅い」という結論を受け取った側は、それが本質的な性能差なのか、測り方の産物なのかを区別できません。だからこそ、計測する側が前提を管理し、結果とともに前提を明示する責任を負います。以下では、その前提を崩す典型的な落とし穴を順に見ていきます。

落とし穴1: ビルド構成 — デバッグビルドを測る

もっとも古典的で、もっとも影響の大きい失敗が、最適化されていないデバッグビルドを測ってしまうことです。

開発中のエンジンは、通常デバッグ用の構成でビルドされています。この構成では、コンパイラの最適化が無効(あるいは弱い)なうえに、境界チェック・アサーション・未初期化検出・型タグの検証といった安全チェックが命令ごとに挟まっています。これらは開発の生命線ですが、実行速度には壊滅的です。デバッグビルドは、最適化リリースビルドに対して容易に一桁遅くなります。

この状態で計測して「うちのエンジンは他より10倍遅い」と結論づけるのは、まったくの誤りです。比較相手(たとえば配布用にビルドされた成熟エンジン)は当然リリース構成なので、そもそも土俵が違います。デバッグビルドの数字は、自エンジンの前回との退行チェックにすら使いにくい — 安全チェックの有無で本体の変更が霞むためです。

対策は単純です。性能を語る計測は、必ず最適化を有効にしたリリースビルドで行うこと。あわせて、次を習慣にします。

  • 計測用ビルドではアサーションと安全チェックを無効化する(ただし後述のとおり、正しさの検証は別途行う)。
  • 「どの構成で測ったか」を結果に必ず添える。構成の記載がない性能数字は信用しない。
  • CI などで自動計測する場合、デバッグ構成のバイナリを誤って測らないよう、ビルド種別をスクリプトで固定する。

大きな性能差を見たとき、原因を探る前にまずビルド構成を疑うのが鉄則です。実務では、「桁違いに遅い」の相当部分がこの取り違えで説明できます。

落とし穴2: ウォームアップと実行ごとのノイズ

計測環境は静かではありません。OS のスケジューリング、CPU の周波数変動(サーマルスロットリングやターボブースト)、キャッシュの状態、他プロセスの干渉、メモリアロケータの初期状態 — こうした要因で、まったく同じ処理でも実行時間は毎回ぶれます。

このため、一回の実行結果はノイズであって信号ではありません。信頼できる数字を得るには、同じ計測を複数回繰り返し、代表値を取ります。代表値の取り方には流儀があります。

  • 最小値(best-of-N): 「外乱がもっとも少なかった実行」を計算の真の実力とみなす考え方。純粋な計算処理では有力です。
  • 中央値(median): 外れ値に強く、分布の中心を素直に表します。
  • 平均±標準偏差: ばらつきの大きさそのものを報告したいときに使います。ばらつきが大きいなら、その事実こそ重要です。

いずれにせよ、代表値だけでなくばらつきも見ることが肝心です。二つの構成の差が数パーセントで、かつ実行ごとのばらつきがそれと同程度なら、その差はノイズであって性能差ではありません。区別できない差を「高速化した」と主張してはいけません。統計的に有意な差かどうかを、実行間のばらつきに照らして判断します。

ウォームアップの扱い

多くのランタイムには「温まる」過程があります。コードキャッシュの充填、分岐予測器の学習、メモリの確保、遅延初期化などが最初の数回で起こり、それ以降は定常状態になります。定常状態の性能を測りたいなら、最初の数回(ウォームアップ)を捨ててから計測します。

ただし何を測りたいかで扱いは変わります。サーバのように長時間動き続ける用途なら定常状態が本質ですが、コマンドラインツールのように一度起動して終わる用途では、むしろ温まる前の初回こそが現実です。ウォームアップを捨てるかどうかは、想定する使われ方で決めるべきで、機械的に捨てればよいものではありません。

測定区間 — プロセス全体か、計算だけか

「何を計測区間に含めるか」も結果を左右します。プロセスの起動から終了までの実時間(wall-clock)には、プロセス生成・エンジン初期化・ソースの読み込みとパース・そして計算本体のすべてが含まれます。一方、スクリプト内部にタイマーを仕込んで計算本体だけを測れば、起動やパースを除いた純粋な計算時間が得られます。

この二つは別物です。短い処理ではプロセス全体時間の大半が起動とパースで占められ、計算の改善が数字に現れません。逆に長い計算では起動時間は誤差に埋もれます。計算性能を語るなら計算区間を、起動性能を語るならプロセス全体を、目的に応じて測り分けます。

落とし穴3: 何を測るか — 軸を混同しない

「速い」は一つの数字ではありません。性能には少なくとも三つの独立した軸があり、それぞれ勝者が異なります。これらを一つの「速さ」に丸めると、比較が意味を失います。

測るもの 典型的な計測方法 効きやすい要因 何が有利か
計算スループット 定常状態での計算の速さ ウォームアップ後の計算区間を反復計測 ディスパッチ・整数ファストパス・インラインキャッシュ・JIT の有無 JIT を持つエンジン、成熟した最適化
起動 / コールドスタート遅延 起動して最初の結果までの時間 プロセス全体の実時間(初回、反復なし) 初期化コスト・パース速度・遅延初期化・バイナリサイズ 軽量エンジン、初期化の小さい設計
ピークメモリ 実行中の最大常駐メモリ 別プロセスで最大 RSS を監視 値表現の大きさ・GC 方式・ヒープ確保戦略 省メモリな値表現、コンパクトな GC

要点は、これらを別々に報告することです。計算スループットで劣るエンジンが、起動遅延やメモリでは勝つ、という状況はごく普通に起こります。用途によって重視すべき軸は違います。バッチ計算なら計算スループット、CLI ツールや関数実行環境なら起動遅延、組み込みやメモリ制約環境ならピークメモリが主戦場です。三軸を一列に並べて総合順位をつけるような要約は、たいてい誤解を生みます。

計算スループットとメモリの間にはしばしばトレードオフがあります。GC を疎に走らせればスループットは上がるがピークメモリは増える、といった具合です。片方だけを測ると、もう片方を犠牲にした「改善」を成果と誤認しかねません。

落とし穴4: 多エンジン比較の公平性

自エンジンを他のエンジンと比べたくなるのは自然ですが、ここに大きな落とし穴があります。クラスの違うエンジンを、そのクラス差が最大に出る土俵で比べることです。

もっとも顕著なのが、インタプリタと JIT エンジンを計算ループで比べるケースです。前章までで見たとおり、バイトコードインタプリタには命令ごとの原理的な費用(ディスパッチ・スタック操作・タグ判定)があり、ネイティブコードへ変換する JIT はこれを消し去ります。したがって、数値計算のタイトなループで両者を比べれば、JIT が数倍から一桁速いのは当然の結果であって、そこに新しい情報はありません。この「大差」を成果として報告しても、あるいは悲観して報告しても、読み手を誤らせるだけです。

情報量のある比較は、次のように選びます。

  • 同じクラスの相手と比べる: インタプリタ方式のエンジンなら、同じくインタプリタ方式のエンジンと計算性能を比べる。ここでの差は実装の巧拙を反映し、意味があります。
  • クラス差が縮む軸で比べる: 起動遅延やメモリでは、JIT を持つ大規模エンジンがむしろ不利になりやすい(初期化やコードキャッシュのコスト)。軽量インタプリタが正当に勝てる土俵であり、比べる価値があります。
  • ネイティブ律速のワークロードで比べる: JSON のパースのように処理の大半がエンジン組み込みのネイティブ実装で行われるものは、VM のディスパッチ費用が全体に占める割合が小さく、軽量エンジンでも健闘しやすい。ここでの比較はエンジンコアではなく組み込み実装の質を映します。

そして何より、公平性の但し書きを結果に明記することです。「このワークロードは JIT に構造的に有利であり、示された差はクラス差であって実装差ではない」と一文添えるだけで、数字の解釈を誤らせずに済みます。都合のよい土俵だけを選んで勝ちを主張するのは、正直な計測ではありません。不利な軸も含めて報告してこそ信頼されます。

落とし穴5: 計測中の正しさ — チェックサム照合

速さを競うと、正しさが置き去りになりがちです。しかし、間違った答えを高速に出す実装には何の価値もありません。ベンチマークの計算を途中で端折ったり、最適化が意味論を壊したりしていれば、速い数字は嘘です。

対策として、各ベンチマークは計算結果のチェックサムを返し、全エンジンでそれが一致することを検証します。ループの合計値、生成した文字列のハッシュ、ソート後の代表要素など、処理の成果を要約した一つの値を最後に出力させ、期待値(あるいはエンジン間)で突き合わせます。

これには二重の効能があります。第一に、最適化やコンパイラが計算を不当に削除・短絡していないことを保証します(結果を捨てるだけのループは、賢いコンパイラに丸ごと消されることがあり、これも「速すぎる」偽の数字の原因になります)。第二に、チェックサムがエンジン間で食い違えば、それ自体がバグの兆候です。性能計測が意味論バグの検出器を兼ねる — これは差分テスト(第VI部「差分テスト」)と同じ発想で、ベンチマークにも組み込む価値があります。

計算量スケールでアルゴリズムバグを見つける

ここまでは「公平に測る」話でしたが、ベンチマークにはもう一つ、微視的な最適化よりしばしば価値の高い使い方があります。入力サイズを変えて計測し、時間の伸び方(成長率)を見ることです。

単一サイズのベンチマークは、絶対値しか教えてくれません。それが「本来より遅い」のか「これが妥当な速さ」なのかは、一点では判断できません。ところが、入力サイズを 2 倍・4 倍・8 倍と増やして計測すると、時間の伸び方が見えます。線形なアルゴリズムなら入力が倍になれば時間もおよそ倍ですが、もし時間が倍を超えて(入力が倍になるたびに約 4 倍ずつ)伸びるなら、そこには意図しない O(n²) が潜んでいます。

入力サイズ 線形 O(n) の目安 二次 O(n²) の目安
n t t
2n 約 2t 約 4t
4n 約 4t 約 16t
8n 約 8t 約 64t

この「倍にして倍を超えたら赤信号」という単純な観察は、単一サイズのベンチマークが完全に見逃す種類のバグを炙り出します。小さな入力では二次項が定数項に埋もれて見えず、実運用の大きな入力で初めて破綻する — という最悪のパターンを、事前に捕まえられます。

エンジン開発で O(n²) が紛れ込む箇所は、驚くほど多岐にわたります。ソートで安定でない・遅いアルゴリズムを使っている、パーサやリバイバのように要素ごとに全体を走査する副作用を毎回実行している、文字列連結を繰り返して都度コピーしている、正規表現がステップごとにパターンを再解析している — いずれも、単一サイズでは「少し遅い」程度にしか見えません。スケール計測は、これらを「少し遅い」ではなく「サイズとともに破綻する」構造的欠陥として可視化します。

実務的には、微視的な定数倍の最適化に取りかかる前に、まず主要な処理をスケール計測して成長率を確かめるのが費用対効果に優れます。二次的な計算量を一つ線形に直す効果は、定数倍を数パーセント削る最適化を何十回積み重ねても届かないことがあります。

ミクロベンチマークとマクロベンチマーク

ベンチマークは粒度で二つに大別できます。

  • ミクロベンチマーク: 単一の機能や演算(プロパティアクセス、関数呼び出し、特定の組み込みメソッドなど)を隔離して測る。ボトルネックの切り分けや、特定最適化の効果測定に向きます。
  • マクロベンチマーク: 現実的なワークロード全体(パーサ、テンプレートエンジン、数値計算スクリプトなど)を測る。実利用に近い総合性能を映します。

両者は補完関係にあります。ミクロは原因を特定でき、マクロは全体像を与えます。ミクロだけを追うと、全体では誤差にしかならない箇所を延々と磨く危険があり、マクロだけでは、遅い原因がどこにあるか分かりません。

ベンチマークへの過剰適合

ここに、最適化にひそむ最大の落とし穴があります。特定のベンチマークにだけ効く最適化を積み重ねると、そのベンチマークの数字は上がるが、実利用は速くならない(むしろ遅くなる)ことがあります。ベンチマークはあくまで実利用の代理指標であって、目的そのものではありません。

過剰適合は、次のような形で現れます。ベンチマークが多用する特定パターンだけを特別扱いする分岐を足す、ベンチマークの入力に合わせてヒューリスティックの閾値を調整する、といった変更です。これらはベンチマークのスコアを押し上げますが、その分だけコードは複雑になり、ベンチマークが踏まないパスにオーバーヘッドを乗せます。指標を目標にした瞬間、その指標は良い指標でなくなる、という一般則がここにも当てはまります。

防ぐには、ベンチマークを多様に保ち、定期的に入れ替え、常に「この最適化は代理指標だけでなく現実の使われ方を速くするか」を問い直すことです。単一のスコアを追い求める姿勢そのものが、過剰適合を招きます。

実装上の罠・知見

  • 大きな性能差は、まず測定の産物を疑う: 「他より一桁遅い」と見えたとき、その相当部分は本質的な性能差ではなく、デバッグビルドを測っていたか、入力サイズ次第で破綻する O(n²) を踏んでいたかのどちらかで説明できることが多いです。ビルド構成の確認とスケール計測を先に済ませれば、存在しない「本質的な遅さ」を追いかける無駄な最適化を避けられます。原因究明は、本体のプロファイリングよりも前に、まず前提(構成・計算量・正しさ)の確認から始めるのが近道です。
  • 数パーセントの差は主張しない: 実行ごとのばらつきと同程度の差を「高速化」と呼ばないこと。反復のばらつきを添えて、有意に区別できる差だけを成果とします。区別できない差を積み上げた「改善」は、後から見ると総和がゼロだったということが珍しくありません。
  • 消えるループに注意: 結果を使わないベンチマークは、最適化コンパイラに丸ごと削除されて「無限に速い」偽の数字を出すことがあります。チェックサムを最後に外へ出力させ、計算が実際に行われたことを保証します。
  • 教科書的な最適化は計測で裏取りする: 第V部「パフォーマンス」で触れたように、「速いはず」の変更が実測で効かない・退行することは珍しくありません。ベンチマークは、最適化の効果を確かめるためにこそ使うものであって、効くと信じて入れた変更を追認するために使うものではありません。
  • 軸をまたいだトレードオフを見落とさない: 計算スループットの改善がピークメモリの悪化と引き換えになっていないか、起動遅延を犠牲にしていないか。改善したい軸だけを測ると、他軸の劣化が計測外に隠れます。
  • 公平性の但し書きを省かない: 有利な土俵の数字だけを見せるのは、誤りではないにせよ不誠実です。クラス差なのか実装差なのかを明記し、不利な軸も併記してはじめて、計測は信頼に足るものになります。

まとめ

  • 性能の数字は測り方の前提を丸ごと含む。前提を管理し、結果とともに明示することが計測者の責任である。
  • 最大の落とし穴はデバッグビルドの計測で、これだけで一桁の見かけ上の遅さを生む。性能を語る計測は必ず最適化リリースビルドで行う。
  • 一回の実行はノイズ。反復して代表値(最小・中央値など)とばらつきを見て、区別できる差だけを主張する。ウォームアップと測定区間(プロセス全体か計算だけか)は目的に応じて選ぶ。
  • 計算スループット・起動遅延・ピークメモリは独立した軸であり、勝者が異なる。別々に報告する。
  • 多エンジン比較では、インタプリタと JIT を計算ループで比べても得るものはない。同クラスの相手や、起動・メモリ・ネイティブ律速の軸で比べ、公平性の但し書きを添える。
  • 各ベンチマークはチェックサムを返し全エンジンで照合する。間違った答えを速く出す実装は無価値であり、不一致はバグの兆候になる。
  • 入力サイズを変えて成長率を見れば、単一サイズでは隠れる O(n²) を発見できる。微視的最適化より価値が高いことが多い。
  • ベンチマークは実利用の代理指標にすぎない。過剰適合を避け、指標そのものを目標にしない。

本書はここまで、エンジンのパイプライン全体像から値表現・オブジェクトモデル・メモリ管理・言語機能の実現・応用トピック、そして品質保証へと積み上げてきました。締めくくりの本章が繰り返し説いたのは、「正直であること」です。安全チェックを外した最適化ビルドで、ばらつきを添えて、公平な土俵で、正しい答えを出していることを確かめながら測る — この地道な規律こそが、エンジンを速くする以前に、速さについて正しく語れるようにします。計測を誤れば、その上に築く最適化の努力はすべて幻を追うことになります。エンジン開発の最後の仕上げが「正直な計測」であるのは、偶然ではありません。

参考文献