Skip to content

パフォーマンス

エンジンを速くしたいとき、真っ先に思い浮かぶのは「ディスパッチを速いものに書き換える」「命令を減らす」といった手ですが、どれがどれだけ効くのか、そもそも効くのかは、直感では判断できません。この章では、バイトコードインタプリタの時間がどこに消えるのかを分解し、それを縮める代表的なレバー(ディスパッチ・値表現・オペコード融合・インラインキャッシュ)を整理します。続いて、インタプリタには原理的な下限があり JIT との間に埋めがたい差が残ること、それでも軽量インタプリタが実務で十分に競争力を持つ領域があることを述べます。そして本章の核として、「もっともらしい最適化の多くは実測すると効かない」という事実と、命令を削るより桁違いに重要な「アルゴリズムの計算量」の話を、率直に扱います。

インタプリタの時間はどこへ消えるのか

ループ主体のコードで、インタプリタが消費する時間はおおまかに次の積で近似できます。

実行時間 ≒ (1命令あたりのコスト) × (1反復あたりの命令数) × (反復回数)

反復回数はプログラムが決めるものなので、エンジン側が動かせるのは前の二つ、1命令あたりのコスト1反復あたりの命令数です。1命令あたりのコストは、オペコードを読んで対応処理へ飛ぶディスパッチ、オペランドスタックの積み降ろし、値のタグ判定といった、命令本体とは別に必ずかかる付随費用が大きな割合を占めます(第II部「インタプリタ実行」)。命令数は、同じ計算をより少ない命令で表せれば減らせます。

この分解から、最適化のレバーは次の四つに整理できます。

レバー 何を縮めるか 代表的な手法 期待できる効果の性質
ディスパッチ最適化 1命令あたりの分岐コスト スレッデッドコード / computed-goto 定数倍。効くかは言語・コンパイラ依存(後述)
値表現 / 整数ファストパス 1命令あたりの計算・変換コスト NaN-boxing、小整数タグ 定数倍。数値主体のコードに効く
命令数の削減 1反復あたりの命令数 オペコード融合、定数畳み込み 定数倍。融合できる頻出パターンに比例
インラインキャッシュ 繰り返す解決の再計算コスト 形状→スロットのキャッシュ プロパティアクセス主体のコードに効く

いずれも定数倍の改善である点が重要です。反復回数そのものを減らすわけではないので、後述するアルゴリズムの計算量の問題(反復回数側が n² に膨らむ類)には無力です。まずはこの区別を押さえておきます。

命令数を減らす: オペコード融合

同じ計算をより少ない命令で表せれば、その分だけディスパッチ回数が減ります。頻出する命令の並びを一つの複合命令にまとめるオペコード融合 (superinstruction) はその代表です。たとえば「ローカル変数をロードして即値を足す」が頻出するなら、ロードと加算を別々の二命令で回すのではなく、両者を一命令にまとめればディスパッチが一回で済みます。融合はディスパッチ費用を命令本体に対して相対的に薄める手であり、ディスパッチが重いインタプリタほど効きやすい一方、命令の種類が増えてコンパイラや命令キャッシュへの負荷は上がります。どの並びを融合すべきかは、実際のコードでの出現頻度をプロファイルして決めるべきで、思いつきで種類を増やしても割に合いません。

インラインキャッシュ

プロパティアクセス obj.x は、素朴に実装すると毎回それなりの作業を要します。オブジェクトの形状(hidden class。第II部「オブジェクトモデル」)を調べ、プロパティ名 x がどのスロットに格納されているかを解決し、そのスロットを読む、という手順です。ところが実際のプログラムでは、同じ obj.x というバイトコード上の一箇所が、ループの中で同じ形状のオブジェクトに対して何度も実行されることが圧倒的に多いという偏りがあります。

インラインキャッシュ (inline cache, IC) は、この偏りを突いた最適化です。プロパティアクセスを解決した結果 —「形状 S のオブジェクトなら、x はスロット番号 k にある」— を、そのアクセスを行うバイトコード上の箇所のそばに記憶しておきます。次に同じ箇所を通ったとき、対象オブジェクトの形状が記憶した S と一致すれば、名前解決を丸ごと飛ばして「スロット k を読む」だけで済みます。解決という繰り返しの作業を、初回の一度に畳み込むわけです。

概念的には、各アクセス箇所が「前回見た形状」と「そのときのスロット」を覚えています。

handler_GET_PROP:
    obj = pop()
    if obj.shape == site.cached_shape:      # ヒット: 形状が前回と同じ
        push(obj.slots[site.cached_slot])   #   解決を飛ばして直接読む
    else:                                    # ミス: 解決し直してキャッシュを更新
        (slot, shape) = resolve(obj, name)
        site.cached_shape = shape
        site.cached_slot  = slot
        push(obj.slots[slot])
    goto next

単相・多相・メガモルフィック

キャッシュが箇所ごとに何種類の形状を覚えるかで、IC の状態を区別します。

状態 意味 典型例 速度
単相 (monomorphic) その箇所が常に同じ一種類の形状を見る ループで同型オブジェクトだけを処理 最速。比較一回でヒット
多相 (polymorphic) 少数(数種類)の形状が混在する 形の違うオブジェクトを数種類扱う 数個の候補を線形に照合。まだ速い
メガモルフィック (megamorphic) 多種多様な形状が通る 任意の形のオブジェクトを受ける汎用コード キャッシュを諦め一般解決に戻る

多相までは、覚える形状を数個の小さな表(polymorphic inline cache)にして線形照合で捌けます。しかし形状の種類が閾値を超えると、キャッシュの照合自体が割に合わなくなるため、その箇所をメガモルフィックと印付けし、以降はキャッシュを試みず一般の解決経路に委ねます。無理にキャッシュを持ち続けるより、諦めるほうが速いという判断です。

無効化: 形状が変わったとき

IC は「形状が同じなら格納場所も同じ」という前提に立っています。したがって、その前提が崩れる操作 — オブジェクトへのプロパティ追加・削除、__proto__ の差し替え、あるいは形状を共有していたオブジェクト群の形状遷移 — が起きたら、その形状に紐づくキャッシュは無効化しなければなりません。無効化を怠れば、古いスロット番号で別の値を読んで誤った結果を静かに返す(silent wrong result)ことや、存在しないスロットへのアクセスにつながります。

素朴な実装では、キャッシュに記憶した形状と実物の形状を毎回比較するだけで、形状が変われば自然にミスとして扱われるため、明示的な無効化は不要です(上の擬似コードがこの形)。より進んだ実装(たとえばプロトタイプ上のプロパティをキャッシュする場合)では、プロトタイプチェーン上の変更を検知して依存するキャッシュを失効させる仕組みが要ります。どこまで凝るかは、IC が守る不変条件(「キャッシュが指す場所は今も正しいか」)を、どの操作が破りうるかを洗い出して決めます。

IC はプロパティアクセスが支配的なコードには大きく効きますが、数値計算ループのようにプロパティアクセスをほとんど含まないコードには当然効きません。レバーは効く相手を選ぶ、という一般則がここにも現れます。

実務的な下限と JIT との差

ここまでのレバーはすべて、1命令あたりのコストや命令数といった定数倍を縮めるものでした。磨けば速くはなりますが、消せない費用があります。バイトコードインタプリタは、命令一つを実行するたびに、ディスパッチ・スタック操作・タグ判定という付随費用を必ず払うからです。これは方式に内在する費用で、どれだけ工夫してもゼロにはできません。これがインタプリタの実務的な下限です。

ネイティブの機械語へ変換する JIT は、この命令ごとの付随費用そのものを消し去ります。ホットなループを、値をレジスタに載せたまま回る機械語へコンパイルしてしまえば、ディスパッチもスタックの積み降ろしもタグ判定も、ループの内側から消えます。そのため、ループ主体の数値計算のようなワークロードでは、インタプリタをどれだけ磨いても JIT にはある倍率だけ及ばない水準にとどまります。

ここで最適化の労力配分について冷静な見取り図を持っておく必要があります。

  • ディスパッチ・ファストパス・IC の改善は、同種のインタプリタ(peer interpreter)との差を詰めるには有効です。同じ土俵の競合に対して健闘できる水準までは、これらの積み重ねで到達できます。
  • しかしそれらは、JIT との差を埋めるものではありません。定数倍の改善をいくら重ねても、命令ごとの原理的費用が残る以上、JIT が消してしまう費用のところで頭打ちになります。
観点 バイトコードインタプリタ JIT
命令ごとの付随費用 ディスパッチ・スタック・タグ判定が常に残る ホットパスから消える
ループ主体の計算 ある倍率だけ遅い(下限がある) 速い
起動までの時間 短い(コンパイル段が軽い) ウォームアップに時間がかかる
メモリ使用量 小さい コンパイル済みコードと補助データで増える
実装の複雑さ 低い 高い
移植性 高い 低い(コード生成が対象アーキ依存)

この表が示すのは、優劣ではなく適材適所です。インタプリタが JIT に負けるのはループ主体の計算処理であって、それ以外の軸(起動・メモリ・実装コスト)ではむしろインタプリタが優位です。次節でその「負けない領域」を具体的に見ます。

軽量インタプリタが健闘する領域

「インタプリタは JIT より遅い」は、正確には「解釈されるバイトコードを大量に回すワークロードでは遅い」という限定つきの命題です。裏を返せば、バイトコードをほとんど回さないワークロードでは、この下限は問題になりません。

ネイティブ組み込みが律速する仕事

JSON.parse / JSON.stringify、文字列や配列の組み込みメソッド(String.prototype 系、Array.prototype.sort など)は、その処理の大半がエンジン内部のネイティブコードで実行されます。スクリプト側は「組み込みを一回呼ぶ」だけで、あとは解析や整形の重い作業がネイティブコードの中で完結します。このとき仕事量を支配するのはネイティブコードの効率であって、バイトコードのディスパッチ費用ではありません。つまりこの種の仕事はインタプリタのディスパッチ税を払いません

その結果、こうしたネイティブ律速のワークロードでは、軽量インタプリタが JIT を積んだ大規模エンジンと互角、あるいは上回ることが十分にありえます。差がつくのはあくまで「解釈実行されるバイトコードの量」の部分であり、それが小さければ、そもそも詰められている差も小さいからです。「計算ループでは遅い」と「ネイティブ律速の仕事では競争力がある」は、矛盾なく同居します。

ただしこれには前提があります。ネイティブ側の実装が効率的であること。組み込みの中に計算量の罠(後述)があれば、この優位は簡単に吹き飛びます。ネイティブ律速で健闘できるのは、そのネイティブが速いときだけです。

起動時間とメモリ

JIT はコードを生成してから速くなるため、実行開始からピーク性能に達するまでウォームアップを要し、その間コンパイルのための時間とメモリを費やします。短命なスクリプト、CLI ツール、サーバレスのような「起動してすぐ少し動いて終わる」用途では、ウォームアップに入る前に仕事が終わってしまうため、この初期費用がそのまま重荷になります。

軽量インタプリタはコンパイル段が軽く、バイトコードを吐いたらすぐ回り始めるので、起動レイテンシが小さく、メモリ使用量も小さいという強みがあります。埋め込み用途(第V部「埋め込みと C API」)や多数のインスタンスを同時に走らせる状況では、1インスタンスあたりのメモリフットプリントがそのまま収容数を決めるため、この軽さは計算速度とは別軸の実利になります。

まとめると、エンジンの性能は単一の数字では語れません。ワークロードの性質(計算主体か、ネイティブ律速か、短命か)と、測る軸(スループットか、起動か、メモリか)によって、どのエンジンが有利かは入れ替わります。

実務的な知見 / 実装上の罠

ここからが本章の核心です。最適化はもっともらしさで判断してはならず、必ず計測で裏取りするというのが、実装を通じて繰り返し確かめられる原則です。

もっともらしい最適化は、しばしば効かない

「この改善は理屈の上では効くはず」という予想は、実測すると外れることがよくあります。しかも「効かない」だけでなく「かえって遅くなる」ことすらあります。代表例を挙げます。

  • switch ディスパッチをスレッデッド形式へ書き換える: 分岐予測の観点からは速くなりうる古典的最適化ですが(第II部「インタプリタ実行」)、実際に速くなるかはインタプリタを書く言語とコンパイラのバックエンド次第です。現代の最適化コンパイラは単純 switch を既にジャンプテーブルへ最適化し、末尾複製まで自前で行うことがあるため、手作業でスレッデッド化しても有意差が出ない、あるいはコード膨張や最適化阻害で回帰することがあります。
  • 毎命令の安全チェックをセーフポイントへ寄せる: GC・割り込み・時間上限のチェックを後方ジャンプや呼び出しに限定するのは正しい設計方針ですが、タイトなループでその効果を測ると、計測誤差の範囲に収まってしまうこともあります。コンパイラが元々そのチェックをうまく処理していたり、分岐予測がほぼ完璧に当てていたりすると、削っても目に見える差にならないのです。

ここで強調したいのは、こうした「効かなかった」という結果は失敗ではなく、正常な計測結果であるということです。最適化のアイデアの多くは効かない、というのが実態に近く、効くものを見分ける唯一の方法が計測です。効くと信じて計測を省き、複雑な構造へ書き換えてしまうと、可読性と移植性を失ったうえに速度も得られない、という最悪の結果を招きます。

したがって手順は決まっています。まず素直な実装で正しく動かし、プロファイルでボトルネックを特定し、候補の最適化をA/B で計測し、有意な改善が出たものだけを採る。負の結果が出たら、それは仮説が棄却されたという情報であって、素直な実装のまま据え置くのが正解です。

公平な計測の最低条件

計測で判断する以上、その計測が信用できなければ意味がありません。最低限、次を守らないと数字に騙されます(手法の詳細は第VI部「ベンチマーク」で扱います)。

  • 最適化ビルドで測る: デバッグビルドの数字は最適化を判断する材料になりません。
  • ウォームアップと繰り返し: 初回実行はキャッシュが冷えており、外れ値になりがちです。複数回まわし、ばらつき(分散)まで見ます。
  • 差が誤差に埋もれていないか: 改善幅が計測のばらつきより小さいなら、それは「効いた」とは言えません。

アルゴリズムの正しさこそがパフォーマンスである

定数倍のレバーをどれだけ磨いても、計算量そのものが間違っていれば無意味です。前述のとおり、ディスパッチや IC の改善は「1反復あたりのコスト」を下げるだけで、「反復回数」が入力サイズに対して n² に膨らむ構造には手が出せません。組み込みに紛れ込んだ偶発的な O(n²) は、あらゆるマイクロ最適化を軽く飲み込みます。典型的な罠を挙げます。

  • ソートに挿入ソートを使ってしまう: Array.prototype.sort の内部実装が挿入ソートのままだと、要素数に対して O(n²) になります。入力が小さいうちは気づかず、大きくなった途端に破綻します。安定なマージソート等 O(n log n) の実装に替えれば、桁違いに速くなります(コンパレータ実行中に GC が走りうる点への配慮も別途必要です)。
  • JSON パーサが要素ごとに線形走査する: パース中に、要素を追加するたびに既存分を舐め直すような処理(たとえばリバイバ用の副作用追跡を常時走らせる)が入ると、全体で O(n²) になります。その走査を本当に必要なときだけ行うよう変えると二次のコストが消えます。
  • 文字列連結をループで素朴に繰り返す: 連結のたびに新しい文字列を丸ごとコピーする実装だと、累積で O(n²) のコピーが発生します。

これらに共通するのは、入力を大きくして初めて露見するという性質です。小さな入力での単発計測では、O(n²) と O(n log n) の区別はつきません。だからこそ、計測は入力サイズを変えたスケーリング計測で行う価値があります。入力を 2 倍・4 倍・8 倍と増やしたとき、時間が比例して伸びるのか、それとも二乗で跳ね上がるのかを見れば、計算量の異常はグラフの傾きとして一目で分かります。前節で述べた「ネイティブ律速なら軽量エンジンでも健闘する」という優位も、そのネイティブ組み込みにこの種の O(n²) が潜んでいれば即座に失われます。

要するに、オペコードを一つ削る努力よりも、組み込みに紛れた計算量の異常を一つ見つけて潰す努力のほうが、桁違いに大きな見返りをもたらすことが多い、ということです。最適化はまず計算量から、定数倍はその後で、という順序を守るべきです。

まとめ

  • インタプリタの実行時間は「1命令あたりのコスト × 1反復あたりの命令数 × 反復回数」で近似でき、動かせるレバーはディスパッチ最適化・値表現/整数ファストパス・オペコード融合・インラインキャッシュの四つ。いずれも定数倍の改善である。
  • インラインキャッシュは、プロパティ解決の結果(形状→スロット)をアクセス箇所に記憶し、同形状の反復アクセスを高速化する。単相が最速、多相まで小さな表で捌き、種類が増えたらメガモルフィックとして一般解決へ戻す。形状が変わる操作ではキャッシュを無効化する。
  • バイトコードインタプリタには命令ごとの原理的費用があり、これが実務的な下限になる。定数倍の改善は同種インタプリタとの差は詰められるが、JIT がホットパスから費用そのものを消す差は埋められない。
  • ネイティブ組み込みが律速する仕事(JSON、文字列・配列の組み込み)はディスパッチ税を払わないため軽量エンジンでも互角以上に戦え、起動レイテンシとメモリフットプリントは小さいエンジンの強みである。「計算で遅い」と「ネイティブ律速・起動・メモリで競争力がある」は同居する。
  • もっともらしい最適化の多くは実測すると効かず、時に回帰する。負の結果は正常であり、計測なしに複雑化してはならない。プロファイルで特定し、A/B で裏取りし、効いたものだけ採る。
  • 定数倍のどのレバーよりも、組み込みに潜む偶発的な O(n²) を潰すほうが見返りが大きい。計算量の異常は入力サイズを変えたスケーリング計測で傾きとして炙り出す。

参考文献