JavaScriptCore の内側:4段階の実行パイプライン
前提知識
- ›第 1 回:WebKit を読み解く — アーキテクチャの概要とコードベースのマップ
- ›第 2 回:WTF とメモリ管理 — RefPtr、WeakPtr、コンテナ型
- ›コンパイラ理論の基礎知識:AST、中間表現 (IR)、SSA 形式
- ›JIT コンパイルと投機的最適化の概念に慣れていること
JavaScriptCore の内側:4段階の実行パイプライン
JavaScriptCore(JSC)は、ソースコードを解析してバイトコードにコンパイルし、ホットな関数をより積極的な最適化ティアへと段階的に再コンパイルする、フル機能の JavaScript エンジンです。この多段階アプローチにより、JSC は起動レイテンシ(ページを素早く表示したいユーザーのニーズ)とピーク時のスループット(計算を高速に実行したい Web アプリのニーズ)のバランスを取っています。
第 1 回で確認したように、JSC はビルドスタック第 3 層に位置しており、コンテナやスマートポインタ(第 2 回)のために WTF へ依存しつつも、WebCore への依存は持ちません。JSC はスタンドアローンのシェルとしてビルド・実行できます。実際、jsc.cpp がそのエントリーポイントを提供しており、VM を起動してコマンドラインからスクリプトを実行できます。
字句解析と構文解析:ソースコードから AST へ
JavaScript の実行はテキストから始まります。Lexer<T> クラスは生のソース文字列をトークンのストリームへと変換します。文字型(Latin-1 用の LChar、UTF-16 用の UChar)に対してテンプレート化されており、ASCII のみのソースコードには最適化されたパスが使われます。実際のところ、ほとんどの JavaScript はこのケースに該当します。
Parser<T> は再帰下降パーサーで、トークンストリームを消費しながら Nodes.h で定義されたノード型を用いて AST を生成します。AST のノード階層は非常に広範で、式ノード、文ノード、宣言ノードなど多数が Nodes.h に定義されています。
flowchart LR
Source["JavaScript Source"] --> Lexer["Lexer<br/>Tokenization"]
Lexer --> Tokens["Token Stream"]
Tokens --> Parser["Parser<br/>Recursive Descent"]
Parser --> AST["AST<br/>(Nodes.h types)"]
AST --> BytecodeGen["BytecodeGenerator"]
BytecodeGen --> CodeBlock["CodeBlock<br/>(bytecode + metadata)"]
設計上の重要な決断として、パーサーは「TreeBuilder」という抽象化を採用しています。常に完全な AST を構築するのではなく、SyntaxChecker ツリービルダーを使うことで、ノードをまったく割り当てずに構文だけを検証することができます。これは、呼ばれることのない関数ボディをスキャンする際に特に有効です。
Tips: JSC は関数ボディを遅延解析します。スクリプトが最初に読み込まれたとき、完全に解析されるのはトップレベルのコードだけです。内側の関数は構文チェックのみが行われ、AST の構築は実際に初めて呼び出されたときまで行われません。これにより、多くの関数が呼ばれない大規模なスクリプトの解析時間を大幅に削減できます。
バイトコード生成とバイトコードフォーマット
BytecodeGenerator は AST を走査しながら、レジスタベースのバイトコードを出力します。JVM のようなスタックベースの VM とは異なり、JSC のバイトコードは仮想レジスタを操作します。これにより、JIT コンパイル時に物理的な CPU レジスタへのマッピングがより自然に行えます。
バイトコード命令は bytecode/ ディレクトリで定義されています。各命令は get_by_id(プロパティアクセス)、call(関数呼び出し)、add(算術演算)のようなコンパクトな操作です。生成されたバイトコードは、定数、例外ハンドラー、型プロファイルのメタデータとともに CodeBlock に格納されます。CodeBlock は JSC におけるコンパイル済みコードの基本単位です。
| 概念 | 場所 | 目的 |
|---|---|---|
| バイトコード命令 | Source/JavaScriptCore/bytecode/ |
命令の定義とエンコード |
| BytecodeGenerator | Source/JavaScriptCore/bytecompiler/ |
AST → バイトコードへの変換 |
| CodeBlock | Source/JavaScriptCore/bytecode/CodeBlock.h |
関数のバイトコードとメタデータのコンテナ |
| 値プロファイル | Source/JavaScriptCore/bytecode/ |
実行中に収集する型フィードバック |
各 CodeBlock はインタープリタティアから始まり、関数が「ホット」になるにつれて上位のティアへと昇格していきます。
ティア 1 — LLInt:低レベルインタープリター
Low-Level Interpreter(LLInt)は最初の実行ティアです。ネイティブコードを生成することなく、バイトコード命令を直接実行します。エントリーポイントは LLInt::setEntrypoint で、CodeBlock をインタープリターのディスパッチテーブルへと接続します。
LLInt は llint/ 内の .asm ファイルで定義されたカスタムのマクロアセンブリ DSL で記述されており、「オフラインアセンブラー」によって各ターゲットプラットフォーム向けの C++ またはネイティブコードに変換されます。このアプローチにより、LLInt はアーキテクチャをまたいだ移植性を保ちつつ、単純な C++ のスイッチディスパッチよりも高速に動作します。
flowchart TD
CB["CodeBlock<br/>(bytecode)"] --> LLInt["LLInt<br/>Interpreter"]
LLInt --> Exec["Execute bytecodes<br/>one at a time"]
Exec --> Profile["Collect type profiles:<br/>• Value types seen<br/>• Branch targets taken<br/>• Call targets"]
Profile --> Hot{"Function hot<br/>enough?"}
Hot -->|No| Exec
Hot -->|Yes| Baseline["Promote to<br/>Baseline JIT"]
実行中、LLInt は各操作に流れ込む型を記録する値プロファイルを収集します。関数の実行回数がしきい値を超えると、LLInt は Baseline JIT への昇格をトリガーします。
ティア 2 — Baseline JIT:テンプレートコンパイル
Baseline JIT はシンプルな「テンプレート」コンパイラーです。バイトコード命令ごとに固定されたネイティブ機械語命令のシーケンスを出力します。命令をまたいだ最適化もなく、レジスタ割り当てもインライン化もありません。コンパイルは高速で、生成されるコードはインタープリターの約 5〜10 倍の速度で実行されます。
Baseline JIT でコンパイルされたコードのデータ構造は BaselineJITCode.h に定義されており、プロパティアクセス用のインラインキャッシュ(PropertyInlineCache)や算術プロファイルなどが含まれます。
Baseline JIT には二つの役割があります。実行ティアとしての役割と、プロファイラーとしての役割です。LLInt が開始した型フィードバックの収集を引き継ぎ、インラインキャッシュからさらに豊富な情報を収集します。
- プロパティアクセス IC は
get_by_idやput_by_idでアクセスされるオブジェクトのStructure(隠しクラス)を記録します。 - Call IC は各コールサイトで呼び出される関数を記録します。
- 算術プロファイル はオペランドが整数、浮動小数点数、BigInt のどれであるかを記録します。
蓄積されたこれらのプロファイルデータが、次のティアの投機的最適化を駆動します。
ティア 3 — DFG JIT:投機的最適化
Data Flow Graph(DFG)JIT は、最も興味深いティアです。バイトコードを 1 命令ずつ変換するのではなく、DFG は操作間の値の依存関係を捉えた中間表現、すなわちデータフローグラフを構築します。そして、下位ティアで収集された型プロファイルを活用して、型に関する投機的な仮定を立てます。
sequenceDiagram
participant Baseline as Baseline JIT
participant DFG as DFG Compiler
participant Native as DFG-compiled Code
participant OSR as OSR Exit
Baseline->>DFG: Function is hot, promote
DFG->>DFG: Build DFG IR from bytecode
DFG->>DFG: Insert type guards based on profiles
DFG->>DFG: Optimize: CSE, constant folding, etc.
DFG->>Native: Emit native code with guards
Note right of Native: Fast path: types match
Native->>Native: Execute optimized code
Note right of Native: Slow path: type mismatch
Native->>OSR: OSR Exit - deoptimize
OSR->>Baseline: Resume in Baseline JIT
例えば、x + y に常に整数オペランドが渡されてきたことをプロファイルが示していれば、DFG は型ガードを伴う高速な整数加算を出力します。実行時に x が文字列だった場合、ガードが失敗し、OSR Exit(On-Stack Replacement Exit)が発生します。これにより実行が Baseline JIT に戻り、最適化されていないコードが想定外の型を正しく処理します。
DFG のソースは Source/JavaScriptCore/dfg/ にあり、以下が含まれます。
- DFG IR の定義(ノード、エッジ、型)
- 投機的オプティマイザー
- OSR エントリー(実行中の Baseline 関数をループの途中でそのまま昇格させる仕組み)
- OSR エグジット(投機が失敗したときのベイルアウト)
- 各種最適化フェーズ(CSE、デッドコード削除、強度削減など)
ティア 4 — FTL JIT と B3 バックエンド
Faster Than Light(FTL)JIT は最高スループットのティアです。DFG IR を受け取り、JSC 独自の SSA ベースのコンパイラバックエンドである B3(Bare Bones Backend)へと変換します。B3 は 2016 年に LLVM の後継として採用されました。古典的な最適化の大部分を提供しつつ、LLVM よりも桁違いに高速なコンパイルを実現しています。
エントリーポイントは FTL::compile です。
namespace JSC { namespace FTL {
void compile(State&, Safepoint::Result&);
} }
B3 は Source/JavaScriptCore/b3/ で定義される独自の IR を持ちます。これは基本ブロック、値、操作で構成される伝統的な SSA 形式です。B3 の下には Air(Assembly IR)があり、実際の機械語命令に近い低レベルな表現です。Air はレジスタ割り当てと命令選択を担い、最終的な機械語コードを出力します。
flowchart TD
DFG_IR["DFG IR"] --> FTL["FTL Lowering"]
FTL --> B3_IR["B3 SSA IR"]
B3_IR --> Opts["B3 Optimizations:<br/>• Constant folding<br/>• CFG simplification<br/>• Strength reduction<br/>• Loop-invariant code motion"]
Opts --> Air["Air IR<br/>(low-level)"]
Air --> RegAlloc["Register Allocation"]
RegAlloc --> MachineCode["Native Machine Code<br/>(x86-64 / ARM64)"]
重要なのは、B3 が DFG では行わないコンパイラレベルの最適化を適用する点です。DFG は JavaScript のセマンティクスレベル(型の特殊化、インライン化)で最適化し、B3 はマシンレベル(レジスタ圧力、命令スケジューリング、冗長なロードの削除)で最適化します。この 2 つのティアは互いを補完し合っています。
Riptide:並行ガベージコレクター
JSC は JavaScript オブジェクトのガベージコレクターとして Riptide を使用しています(第 2 回で解説した C++ オブジェクトへの参照カウントとは別の仕組みです)。Riptide は並行・世代別のマーク&スイープ型コレクターで、Source/JavaScriptCore/heap/ に実装されています。
設計上の主なポイントは以下のとおりです。
- 並行マーキング。 GC スレッドがミューテーター(JavaScript の実行スレッド)と並行してオブジェクトグラフを走査します。これにより停止時間を最小化できます。
- ライトバリアー。 JIT コンパイルされたコードがオブジェクトにポインタを書き込む際は、GC に通知するライトバリアーを実行する必要があります。各 JIT ティアはバリアーをコード生成に組み込んでいます。
- 保守的なスタックスキャン。 コレクション中、GC はネイティブスタックをスキャンしてオブジェクトポインタの可能性がある値を探します。JIT コンパイルされたコードは JS の値を CPU レジスタやスタックスロットに保持するため、この処理は不可欠です。
- 後退ウェーブフロント。 並行マーキング中に変更されたオブジェクトの修正に stop-the-world フェーズを必要とせず、Riptide は並行した変更を適切に処理する後退ウェーブフロントアルゴリズムを採用しています。
heap/ ディレクトリには、コレクションを統括する Heap クラス、アロケーションの単位である MarkedBlock、マーキングの中心的な処理を担う SlotVisitor、JSC 全体で使用されるバリア型 WriteBarrier<T> が含まれています。
ランタイム組み込みオブジェクトと JSC シェル
runtime/ ディレクトリには JavaScript 組み込みオブジェクトの C++ 実装が含まれています。JSArray、JSObject、JSFunction、JSPromise、RegExpObject、MapObject など、JavaScript コードが操作するオブジェクトのネイティブ実装が数多く揃っています。
スタンドアローンの jsc.cpp シェルはすべてをまとめ上げるものです。VM(JSC の中心的な状態オブジェクト)と GlobalObject を生成し、スクリプトを全パイプラインに通して実行します。WebCore やブラウザの UI を使わずに JSC を単体でテストするのに非常に役立ちます。JavaScript を与えてどのティアが動作するかを観察したり、DFG IR をダンプしたり、GC の挙動をプロファイリングしたりすることができます。
| ディレクトリ | 内容 |
|---|---|
parser/ |
Lexer、Parser、AST ノード型 |
bytecompiler/ |
BytecodeGenerator |
bytecode/ |
バイトコード定義、CodeBlock、値プロファイル |
llint/ |
LLInt インタープリター(マクロアセンブリ DSL) |
jit/ |
Baseline JIT コンパイラー |
dfg/ |
DFG IR、投機的オプティマイザー、OSR 機構 |
ftl/ |
DFG IR から B3 への FTL 変換 |
b3/ |
B3 SSA コンパイラバックエンド |
b3/air/ |
Air 低レベル IR とレジスタアロケーター |
heap/ |
Riptide GC:マーキング、スイープ、バリアー |
runtime/ |
組み込み JS オブジェクトと VM クラス |
次の記事へのつながり
JavaScriptCore は実行エンジンを提供しますが、Web ページは JavaScript だけでできているわけではありません。次の記事では WebKit 最大のコンポーネントである WebCore を取り上げ、HTML バイト列からピクセルに至るまでのフルレンダリングパイプラインを追っていきます。DOM ツリーの構築、CSS の解決(JSC のアセンブラインフラを再利用した CSS JIT セレクタコンパイラーを含む)、そしてレイアウトとペイントによる視覚的出力の生成を解説します。さらに重要な点として、JSC の JavaScript の世界と WebCore の C++ DOM オブジェクトを橋渡しする Web IDL バインディングシステムについても詳しく見ていきます。