JIT コンパイル

このページの目次

JIT(Just-In-Time)コンパイルは、プログラム実行中に何らかの表現を実行可能コードに変換します。入力にはバイトコード、IR(中間表現)、または他の表現が使えます。コンパイル単位は関数、ループ、トレース、またはモジュールなどです。初回呼び出し時にトリガーするか、バックグラウンドでプリコンパイルするかは、ランタイムのポリシーによって決定されます。

重要なトレードオフは、コンパイルコストをその後の実行による利益で回収できるかどうかです。これには高速なコードの生成が必要ですが、同時に、寿命の短いコードに対して過度に高価な最適化を行わないことも求められます。

単純な利益モデル

最適化コンパイルのコストを 、旧コードの1回あたりの実行時間を 、新コードを 、さらに実行される回数を とします。他のコストを無視した場合、純粋な時間節約を得るには K(t₀−t₁)>C である必要があります。

例えば、コンパイルに 4ms かかり、1回あたりの節約が 2μs の場合、コンパイルコストを相殺するには少なくとも約 2000 回の追加実行が必要です。実際には、プロファイリング、ガード、コード領域、キャッシュ、デオープティマイズのコストも考慮する必要があります。これが、「生成されたコードの方が速い」からといって、短いプログラム全体が必ずしも速くなるわけではない理由を説明しています。

閾値は今回の残り作業量にのみ適用される

4ms のコンパイルコストと 1回あたり 2μs の節約は、2000 回でちょうど釣り合います。純粋な利益を得るには、この回数を超える必要があります。ホットスポットがこれまでに何回実行されたかは、将来の利益計算には直接含まれません。すでに費やされたインタプリタの実行時間は、今回のコンパイルによって取り戻すことはできないからです。

compile_us = 4000
saved_per_call_us = 2
def gain(remaining_calls):
    return remaining_calls * saved_per_call_us - compile_us
assert gain(1000) == -2000
assert gain(2000) == 0
assert gain(3000) == 2000
# 600μs のデオープティマイズ/再コンパイルコストが別途かかったと仮定する。
assert gain(3000) - 600 == 1400

実際の意思決定では、残りの実行回数と失効率の不確実性に向き合わなければなりません。終了間近のループを最適化するのは損になる可能性があります。バックグラウンドコンパイルは CPU とメモリ帯域を占有するため、コンパイルスレッドとリクエストスレッドが異なるからといって、コンパイルコストをサービス全体の資源勘定から除外することはできません。上記は直列時間の予算モデルであり、並列コンパイル時のウォークロック(実時間)での利益を直接示す式ではありません。

過去にすでにホットになっていたとしても、今回のコンパイルコストを相殺できるか?

ここで比較すべきは、今から始まる2つの戦略です。過去の実行回数が増えれば、両方の総勘定に同じ沈没コストが加算されるだけなので、将来の純利益には影響しません。下の図では、過去の分を共通コストとして独立して扱い、コンパイル投資は残りの 回分の節約と比較しています。等号は利益が出ているわけではなく、ちょうど釣り合っている状態を示します。実際のシステムでは を予測する必要があり、その予測は外れる可能性があります。

図を準備しています
過去にすでにホットになっていたとしても、今回のコンパイルコストを相殺できるか?

既に実行された部分は共通の沈没コストです。今回の C を返済するには、将来の節約分 K(t₀−t₁) のみを使います。K=2000 で釣り合い、それを超えて初めて純利益が発生します。過去がさらにホットでも、将来に十分な実行回数が残っている保証はありません。

階層化:まず起動コストを下げ、次にホットスポットに投資する

階層目標一般的な作業
インタプリタ高速な開始実行命令のデコードとディスパッチ、一部フィードバックの収集
ベースラインコンパイラ低いコンパイルコストでインタプリタオーバーヘッドを解消高速なマシンコード生成、限定的な最適化
最適化コンパイラホットコードの効率向上インライン展開、型特化、冗長性除去など

すべてのランタイムが全階層を持っているわけではなく、またすべての関数が階層を順に通過する保証もありません。ポリシーは呼び出し回数、ループバックエッジ、コードサイズ、既存のフィードバック、システムリソースなどを参照できます。失効や環境変化により、再コンパイルが発生することもあります。

V8 の公開設計を例にとると、Ignition はインタプリタ、Sparkplug はバイトコードからマシンコードを生成するベースラインコンパイラです。Maglev の設計は、中間最適化層がコンパイル速度とコード品質のバランスをどのように取るかを示しています。これらは役割を理解するための説明であり、ある歴史的文章にある階層構成がすべてのバージョンで不変のパイプラインであるわけではありません。Sparkplug、Maglev

フィードバックとインラインキャッシュ:このアクセスポイントで何が起こったかを記憶する

同じソースコードの位置で obj.x の実行が繰り返されると仮定します。一般的な属性の検索では、オブジェクトのレイアウト、属性記述子、継承関係などを解釈する必要があります。インラインキャッシュは、そのアクセスポイントで観察された shape などの条件と、それに対応する高速操作を記録します。

推測最適化:高速パスには正当なミスマッチ処理が必要 obj.x の読み取り shape / 依存関係のチェック ヒット:固定オフセットでの読み取り 完全な属性検索は不要 ミスマッチ:スローパスまたはデオープティマイズ 元の言語セマンティクスを維持 ガードはオブジェクトのレイアウトなどの条件を確認します。キャッシュヒット率は具体的なワークロードによります。固定のパーセンテージはありません。

単一多型キャッシュは1つの形状を保持し、多型キャッシュは少数の形状を保持します。多くの異なる形状が観察されると、高多型に適した検索戦略に切り替えることができます。システムによって使用される構造と閾値は異なります。

キャッシュは通常、アクセスポイントとそのコンテキストに関連付けられており、「直前に任意のオブジェクトの型であったもの」をグローバルな答えとして使うわけではありません。オブジェクトのレイアウト、プロトタイプチェーン、またはメソッド定義が変更された場合、キャッシュが古い実装を指し続けないよう、ガードまたは依存関係の失効メカニズムが必要です。IC はインタプリタ内にも存在でき、最適化されたマシンコードでなければ使用できないわけではありません。

推測最適化:仮定は明示的に保護されなければならない

JIT は変数が主に整数であることを観察し、整数用の高速パスを生成できますが、言語は次回に浮動小数点数、文字列、または大きな整数の出現を許可する可能性があります。ガードは関連する前提条件を検証し、ミスマッチが発生した場合は一般的なスローパスに入り、またはより低い最適化層に戻って実行します。

AOT(Ahead-of-Time)でも、静的解析、PGO(Profile-Guided Optimization)、コードのバージョン管理、およびランタイムチェックを通じて推測を使用できます。違いは、フィードバックの取得とコード生成のタイミングにあり、AOT が常にランタイム情報を知らないため最適化できない、あるいは JIT のみが発展的であるということではありません。

デオープティマイズ:言語レベルの状態への復帰

最適化により、変数はレジスタに格納され、冗長な値は削除され、関数はインライン展開され、場合によっては1つのオブジェクトが複数のスカラーに分割されます。推測が外れた場合、ランタイムは後続の実行に必要な論理状態を再構築しなければなりません。これには、現在のバイトコード位置、ローカル変数、オペランドスタック、インライン展開された呼び出しフレーム、および必要なオブジェクトが含まれます。

したがって、デオープティマイズメタデータは、現在のマシン状態からより低いレベルの表現へどのように復帰するかを記録します。いくつかの値は直接読み取れますが、記録に従って再マテリアライズ(具現化)が必要な値もあります。数個のレジスタを保存するだけでは、インライン展開やスカラー置換が行われたプログラムを復帰させることはできません。

復帰点では副作用も考慮する必要があります。例えば、すでに1回の引き落としが実行された後、ガードが失敗した場合、関数の先頭から再実行して再度引き落としが行われるのを防ぐ必要があります。デオープティマイズは、意味的に正しい位置まで継続しなければならず、「インタプリタに戻ってすべての操作をやり直す」という漠然とした処理であってはなりません。

デオープティマイズで復帰するのは意味的な状態であり、関数を最初からやり直すわけではない 完了した副作用 ログ append("begin") を1回実行 最適化されたループの現在の状態 i = 1、total = INT32_MAX 次の加算ガードが失敗 現在の項目はまだコミットされていない 復帰:i = 1 から継続し、既存の total を起点とする 実行済みのログは再実行しない。次の項目は汎用整数セマンティクスで計算され、オーバーフロー結果は沿用しない 教育モデルは、任意精度の整数セマンティクスとガードで保護された int32 の高速パスを採用しており、V8 の実装ではない。

副作用を伴うデオープティマイズの状態モデル

教育言語の整数は任意精度で成長できると仮定し、最適化バージョンは int32 の範囲のみを処理するとします。以下では Python を用いて、「演算前に前提条件をチェックし、失敗後に i と total を復帰する」ことをシミュレートします。これはマシンコードを生成するものではなく、Python インタプリタ自体の JIT 動作を証拠として使用するものでもありません。

MIN32, MAX32 = -(1 << 31), (1 << 31) - 1

def resume_generic(values, i, total):
    while i < len(values):
        total += values[i]
        i += 1
    return total

def specialized(values, effects):
    effects.append("begin")
    total = 0
    i = 0
    while i < len(values):
        item = values[i]
        candidate = total + item  # モデルでは任意精度でまずガード条件を計算
        if not (MIN32 <= item <= MAX32 and MIN32 <= candidate <= MAX32):
            snapshot = {"pc": "loop_add", "i": i, "total": total}
            return resume_generic(values, i, total), snapshot
        total = candidate
        i += 1
    return total, None

effects = []
result, snapshot = specialized([MAX32, 1, 2], effects)
assert result == MAX32 + 3
assert snapshot == {"pc": "loop_add", "i": 1, "total": MAX32}
assert effects == ["begin"]
result, snapshot = specialized([10, 20], [])
assert result == 30 and snapshot is None

ガードが失敗したとき、i=1 は values[1] がまだ total に加算されていないことを示します。i=2 に復帰すると、1 を見逃してしまいます。関数の先頭に復帰すると、begin が再度実行されてしまいます。実際のジェネレータは、オーバーフローフラグ付きのマシン加算を実行してから分岐するかもしれませんが、デオープティマイズメタデータは、正しい元のオペランドと復帰位置を復元できなければならず、すでにラップアラウンドしたマシン結果を言語結果として偽装してはいけません。

ここで別の関数がインライン展開されていた場合、低いレベルのスタックには1つのマシンスタックフレームしか存在しないかもしれませんが、論理的には2つの呼び出しフレームを復帰させる必要があります。オブジェクトがすでにスカラー置換されている場合、それをマテリアライズし、要求されたアイデンティティ関係を維持する必要があります。「フィールドが同じ新しいオブジェクトを生成する」だけでは、参照の同一性などのセマンティクスを常に維持できるとは限りません。V8 Maglev の設計では、フレーム状態の記録が、推測最適化と正しい復帰の正しさを結びつける重要な部分です。Maglev デオープティマイズ

ガード失敗後、どこから再開すれば副作用を重複させないか?

状態を復帰する際には、少なくとも以下の点を回答できなければなりません:どの操作がまだ行われていないか、どの値がすでに計算済みか、どの副作用がすでに発生したか。以下では、意図的に「最初から再実行」と「現在の項目をスキップ」も比較可能な誤った戦略として実装しています。前者は最終的な合計値は正しいかもしれませんが、begin を重複して実行してしまいます。後者は効果の回数が正常に見えますが、1項目分を加算し忘れます。値、位置、効果の両方を確認して初めて、デオープティマイズの継続が正しいかどうかを説明できます。

図を準備しています
ガード失敗後、どこから再開すれば副作用を重複させないか?

2回目の加算が int32 を超え、ガードはその実行前に失敗しました。正しく i=1、total=2147483647 に復帰し、すでに1回発生した begin を保持します。最初から再実行すると効果が重複し、現在の項目をスキップすると計算漏れが発生します。

OSR:関数が返還する前に実行バージョンを切り替える

1回しか呼び出されないが、長時間実行されるループがある場合、次の関数呼び出しのタイミングで最適化コードを使うのは遅すぎます。OSR(On-Stack Replacement)は、実行中の適切な位置で、現在のアクティブな状態を別のバージョンのコードへ転送します。

一般的な用途は、インタプリタまたはベースラインのホットループから最適化コードへ移行することです。一部のランタイムでは、降層変換を記述するために広義のスタック上置換を使用することもあります。これはデオープティマイズと同じ状態変換の問題を共有しますが、「デオープティマイズの特殊な例」と単純に定義することはできません。昇層 OSR は、まさにより最適化されたバージョンへの移行だからです。

OSR エントリでは、各アクティブ値の由来を明示する必要がある

例えば、ループが values[0:3] を処理済みで、次のインデックス 3 を処理しようとしているとします。新しいコードは、現在の配列参照、i=3、および累積された total を受け取らなければなりません。通常の関数エントリの i=0、total=0 の初期値を沿用することはできません。ループ外では使用されなくなった一時値は転送しなくても構いませんが、後続で必要な値には確実な由来が必要です。

転送情報必要な理由欠落した場合の結果
復帰/進入するプログラム位置ループの頭、ループ本体の中間、バックエッジを区別操作の重複またはスキップ
アクティブなローカル変数とオペランド終了していない計算を継続誤った初期値の使用
オブジェクトと GC 参照の位置ガーベジコレクタがアクティブなオブジェクトを見つけられるようにオブジェクトの誤った回収または更新の漏れ
例外とインライン呼び出しの状態投擲、アンワインド、デバッグセマンティクスの維持例外が誤った処理範囲に落ちる

OSR コンパイルでも、生成中に仮定がすでに適用できなくなっていることが判明する場合があります。エントリ時には対応する条件を検証する必要があります。ホットネスカウンターは試す価値があるかを決定し、エントリガードは此刻安全に切り替えられるかを確認します。これらは互いに代替できません。

コードキャッシュ:生成後、安全な公開と回収

JIT はコード領域の割り当て、リロケーションの処理、GC および例外展開情報の登録、プラットフォームの要件に従った実行可能コードの公開および命令キャッシュの同期を行います。各関数をまず .so として記述してから dlopen する必要はありません。ランタイムは通常、メモリ内のコードとシンボルを直接管理できます。

コードを回収する前に、スレッドがまだそのコードを実行していないことを確認し、戻りアドレス、関数エントリ、またはキャッシュ参照が宙に浮いていないことを確認する必要があります。どのアロケータ、Eviction ポリシー、および書き込み実行権限の変換を採用するかは、実装とプラットフォームの制約によって決定され、すべてのコードキャッシュがスラブ加 LRU を使用する統一ルールはありません。

評価時にはいくつかの段階を分けて見る

指標露呈する可能性がある問題
コールドスタートと最初のリクエストパース、ロード、初期コンパイルコスト
ウォームアップ過程昇層速度、コンパイルスレッドの競合
定常状態のスループットとテールレイテンシコード品質、GC、スケジューリング、ミスマッチ
デオープティマイズおよび再コンパイル回数不安定な仮定、入力形状の変化
コードおよびメタデータ領域専用バージョンの膨張、回収の遅れ

ウォームアップ後の小さなループの断片だけを測定し、その結果をサービス全体の性能として一般化しないでください。AOT と JIT は組み合わせることができ、キャッシュ、オフラインプロファイル、またはプリコンパイルされた基本コードによってランタイムの作業量を削減できます。

関連セクション:ガーベジコレクション、リンクとロード。

参考

Keywords: JIT, 階層化コンパイル, インラインキャッシュ, 推測, ガード, デオープティマイズ, OSR, コードキャッシュ, AOT, PGO