←記事一覧

バイトコード VM の仕組み — インタプリターから JIT コンパイラーまで

Python や Java のようなバイトコード VM(仮想マシン)の内部構造の解説。ソースコードからバイトコードへのコンパイル、スタックマシンとレジスターマシンの違い、インタプリターの実装パターン、JIT コンパイラーによる実行時の最適化までを扱います。

VMPythonJavaJITRuntime

プログラミング言語の実行方式を「コンパイル言語」と「インタプリター言語」に二分する説明をよく見かけます。しかし、現代の主要言語(Python、Java、C#、JavaScript、Ruby、Lua など)の多くは、その中間に位置するバイトコード VM(仮想マシン)方式を採用しています。

この方式では、ソースコードをまずバイトコードという中間表現にコンパイルし、VM のインタプリターがそれを 1 命令ずつ実行します。よく実行される部分は、JIT コンパイラーが実行中にネイティブコードへ変換し、インタプリターのオーバーヘッドを省きます。

動画で読む4:45合成音声ナレーション・字幕付き
文字起こしを表示

PythonやJavaは、コンパイル言語か、インタプリタ言語か。その二分法だけでは、今の実行環境は説明できません。多くの言語は、ソースをバイトコードに変え、VMで実行し、よく実行される部分だけをJITで速くします。

ネイティブコンパイルは、CやRustのように、ソースをCPUが直接実行する機械語へ変換します。純粋なインタプリタは、ソースを毎回読みながら実行するので手軽ですが、解析のコストが重くなります。バイトコードVMはその中間です。ソース解析は一度だけにし、移植しやすい中間表現をVMが実行します。

パイプラインは、字句解析、構文解析、意味解析、そしてバイトコード生成へ進みます。たとえば x = 1 + 2 × 3 は、まず名前、数値、演算子というトークン列になります。次にASTが作られ、掛け算が足し算より深い位置に置かれます。演算子の優先順位は、木の形に埋め込まれるのです。

CPythonは、定数式1 + 2 × 3をコンパイル時に7へ畳み込むことがあります。生成される命令は、LOAD_CONSTやSTORE_FASTなど、オペコードとオペランドの列です。CPython 3.6以降の基本コード単位は2バイトで、必要ならEXTENDED_ARGで大きな引数を表します。

VMの命令形式には、大きくスタックマシンとレジスタマシンがあります。スタックマシンでは、a + b × c をLOAD、MUL、ADDで積み上げて計算します。レジスタマシンなら、MULでR1へ、ADDでR0へ、の2命令だけで同じ計算を表せます。実行する命令数は約半分に減る一方、コードは約4分の1大きくなります。

インタプリタの心臓部は、フェッチ、デコード、ディスパッチを繰り返すループです。命令ポインタがオペコードを読み、ディスパッチが処理へ飛び、スタックやローカル変数を書き換えます。Pythonのforループも、反復用の命令列として、1命令ずつ実行されます。

最も単純な実装は、巨大なswitch文でオペコードを分岐するスイッチディスパッチです。computed gotoは各ハンドラから次へ直接ジャンプし、CPythonのコメントでは最大15〜20%速いとされます。CPython 3.11は、BINARY_OPをBINARY_OP_ADD_INTのように型へ合わせて書き換えます。この特殊化にフレームの軽量化なども加わり、CPython 3.11はJITなしで3.10より平均25%高速になりました。

JITは、実行中に集めたプロファイルを使って、よく走るホットスポットをネイティブコードへ変換します。動的型付け言語では、型特殊化が鍵です。いつもintなら、汎用の足し算をint向けの直接加算へ置き換えます。ただし、その仮定はガードで守られます。突然floatが来たら脱最適化し、フレームを再構築してインタプリタへ戻ります。長いループでは、OSR(オンスタックリプレースメント)で、実行中のフレームをJITコードへ載せ替えます。

実用的なJITは、コンパイル時間と最適化レベルのトレードオフを、多段階コンパイルで扱います。HotSpotの代表的な経路は、Tier 0のインタプリタから、Tier 3のC1、そしてTier 4のC2です。V8は、Ignition、Sparkplug、Maglev、TurboFanで、起動と最適化を両立します。

VMごとの選択も違います。CPythonは安定性を重視し、JITを最近ようやく実験的に導入しました。JVMは長期稼働サーバー向けの重厚なJIT、V8は起動時間と最適化を両立する多段階構成を選びます。3.11の特殊化、3.13のcopy-and-patch JIT、3.14の末尾呼出しインタプリタを加えました。WebAssemblyの広がりも、バイトコードVMの設計思想がブラウザの外へ進んでいることを示しています。

バイトコードVMは、移植性、起動時間、最適化余地のバランスを取りながら、CPUとの距離を縮めてきました。記事では、バイトコード実行、スタックマシンとレジスタマシン、JITの段階昇格を、インタラクティブデモで試せます。

実行方式の分類 — コンパイル・インタプリター・VM

プログラムの実行方式は、ソースコードを実行前に何へ変換するかで大きく 3 つに分けられます。

ネイティブコンパイル

C、C++、Rust、Go などの言語は、ソースコードを直接機械語(ネイティブコード)にコンパイルします。生成された実行ファイルは CPU が直接実行するため VM のディスパッチのオーバーヘッドがなく、通常は非常に高い性能を得られます。ただし、ワークロードによってはプロファイル情報を使う JIT が、事前コンパイルしたネイティブコードを上回ることもあります。

ソースコード (.c) → コンパイラ → 機械語 (.exe) → CPU が直接実行

一方、生成されるバイナリーはコンパイルしたプラットフォーム専用なので、異なる OS・アーキテクチャで動かすには再コンパイルが必要です。

純粋インタプリター

初期の BASIC や Shell スクリプトのように、ソースコードを 1 行ずつ読み取りながら逐次実行する方式です。

ソースコード → インタプリタがソースを1行ずつ解析・実行

プラットフォームに依存せず手軽に実行できますが、ソースコードの解析(パース)を繰り返すため、バイトコード方式やネイティブ実行より遅くなりがちです。

バイトコード VM 方式

Python、Java、C# などが採用する方式です。ソースコードをまず中間表現(バイトコード)にコンパイルし、そのバイトコードを仮想マシン(VM)が実行します。

ソースコード → コンパイラ → バイトコード → VM が実行

この方式には以下の利点があります:

  • ポータビリティー: バイトコードはプラットフォームに依存しない。VM さえあればどこでも動く(Java の "Write once, run anywhere")
  • 純粋インタプリターより高速: ソースコードの解析は一度だけ。バイトコードはコンパクトで効率的なフォーマット
  • 最適化の余地: JIT コンパイラーを導入すれば、実行時にネイティブコード並みの速度を実現できる

コンパイルパイプライン — ソースコードからバイトコードへ

ソースコードは、字句解析、構文解析、意味解析、バイトコード生成の 4 段階を経てバイトコードになります。ここでは Python を例にします。

字句解析(Lexing / Tokenization)

ソースコードの文字列をトークン(字句)の列に変換します。

# ソースコード
x = 1 + 2 * 3
トークン列:
  NAME("x") → EQUAL("=") → NUMBER("1") → PLUS("+")
  → NUMBER("2") → STAR("*") → NUMBER("3") → NEWLINE

字句解析器(レキサー)は、空白やコメントをスキップしつつ、ソースコードを意味のある最小単位に分割します。

構文解析(Parsing)

トークン列を抽象構文木(AST: Abstract Syntax Tree)に変換します。AST はプログラムの構造を木構造で表現したものです。

       Assign
       /    \
    Name   BinOp(+)
     "x"   /      \
        Num(1)   BinOp(*)
                 /      \
              Num(2)   Num(3)

構文解析器(パーサー)は、言語の文法規則に従ってトークン列を解析し、演算子の優先順位(* は + より先に計算)などを正しく反映した木構造を構築します。

意味解析(Semantic Analysis)

AST を検査し、後の段階で使う情報を付け加えます。主な処理は次のとおりです。

  • 変数のスコープ解決(ローカル変数か、グローバル変数か、クロージャのキャプチャか)
  • 型チェック(静的型付け言語の場合)
  • 定数畳み込みなどの簡単な最適化

Python の場合はシンボルテーブルの構築がこの段階で行われ、各変数がどのスコープに属するかが決定されます。

バイトコード生成(Code Generation)

AST(またはその中間表現)を走査して、バイトコード命令列を生成します。

# Python で実際のバイトコードを確認する
import dis
 
def example():
    x = 1 + 2 * 3
 
dis.dis(example)
  2           RESUME                   0
 
  3           LOAD_CONST               1 (7)
              STORE_FAST               0 (x)
              RETURN_CONST             0 (None)

この出力から、次の 3 点が分かります。

  1. 定数畳み込み: 1 + 2 * 3 は 7 に事前計算されています。CPython のコンパイラーが AST の段階で定数式を評価しているためです
  2. LOAD_CONST / STORE_FAST: バイトコードはスタックマシンモデルに基づいています(後述)
  3. RETURN_CONST: Python の関数は明示的な return がなくても None を返します

ループを含む関数では、次のようになります。

def loop_example():
    total = 0
    for i in range(10):
        total += i
    return total
 
dis.dis(loop_example)
  2           RESUME                   0
 
  3           LOAD_CONST               1 (0)
              STORE_FAST               0 (total)
 
  4           LOAD_GLOBAL              1 (range)
              LOAD_CONST               2 (10)
              CALL                     1
              GET_ITER
        >>    FOR_ITER                 7 (to 36)
              STORE_FAST               1 (i)
 
  5           LOAD_FAST                0 (total)
              LOAD_FAST                1 (i)
              BINARY_OP               13 (+=)
              STORE_FAST               0 (total)
              JUMP_BACKWARD            9 (to 18)
 
        >>    END_FOR
              POP_TOP
 
  6           LOAD_FAST                0 (total)
              RETURN_VALUE

for ループは、GET_ITER → FOR_ITER → JUMP_BACKWARD という命令の並びに変換されています。

次のデモでは、バイトコードを 1 命令ずつステップ実行し、命令ポインター(IP)、オペランドスタック、ローカル変数がどう変わるかを確認できます。

バイトコード実行シミュレーター

total = 0; i = 1 から始めて total += i; i += 1 を i < 3 の間繰り返す関数を、CPython のバイトコードを簡略化した命令列で1命令ずつ実行します。

0→RESUME0
1→LOAD_CONST0 (0)
2→STORE_FAST0 (total)
3→LOAD_CONST1 (1)
4→STORE_FAST1 (i)
5→LOAD_FAST0 (total)
6→LOAD_FAST1 (i)
7→BINARY_OP+=
8→STORE_FAST0 (total)
9→LOAD_FAST1 (i)
10→LOAD_CONST2 (1)
11→BINARY_OP+
12→STORE_FAST1 (i)
13→LOAD_FAST1 (i)
14→LOAD_CONST3 (3)
15→COMPARE_OP<
16→POP_JUMP_IF_TRUE5
17→LOAD_FAST0 (total)
18→RETURN_VALUE
オペランドスタック
(空)
ローカル変数
(なし)
IP
0
1 / 26

バイトコードの構造

バイトコードは基本的に命令(オペコード)とオペランドのペアが並んだバイト列です。

オペコードとオペランド

バイトコード命令:
  [opcode] [operand]
  1バイト   0〜数バイト
 
例(CPython 3.12+、基本命令幅は1ワード = 2バイト、一部はインラインキャッシュ付き):
  LOAD_CONST    1     → 定数テーブルのインデックス1の値をスタックにプッシュ
  STORE_FAST    0     → スタックトップの値をローカル変数スロット0に格納
  BINARY_OP     0     → スタックから2つの値をポップし、加算してプッシュ

CPython 3.6 以降のバイトコードはワードコード形式で、各コード単位は 2 バイト(1 バイトの opcode + 1 バイトの oparg)です。一部の opcode は、命令の後ろにインラインキャッシュ用のコード単位(各 2 バイト)を追加で予約します。オペランドが 255 を超える場合は、1 個以上の EXTENDED_ARG 命令を前置して拡張します。

コードオブジェクト

Python では、コンパイルされたバイトコードはコードオブジェクト(code object)として格納されます。

def add(a, b):
    return a + b
 
code = add.__code__
print(code.co_code)        # 生のバイト列
print(code.co_consts)      # 定数テーブル: (None,)
print(code.co_varnames)    # ローカル変数名: ('a', 'b')
print(code.co_stacksize)   # 必要なスタックの深さ: 2
print(code.co_nlocals)     # ローカル変数の数: 2

コードオブジェクトは、命令列に加えて、定数テーブル、変数名テーブル、必要なスタックの深さなど、VM が実行に使うメタデータを持っています。

.pyc ファイル — バイトコードのキャッシュ

Python はモジュールをインポートする際、コンパイル済みのバイトコードを .pyc ファイルとしてキャッシュします。

__pycache__/
  module.cpython-312.pyc

.pyc ファイルのフォーマット:

オフセットサイズ内容
04 バイトマジックナンバー(Python バージョンを識別)
44 バイトビットフィールド(無効化モードのフラグ)
88 バイトタイムスタンプ方式: 4 バイトのソース mtime + 4 バイトのソースサイズ。ハッシュ方式: 8 バイトのソースハッシュ
16以降マーシャルされたコードオブジェクト

次回の import 時に .pyc が最新であれば、ソースコードの再コンパイルを省いてバイトコードを直接ロードするので、起動時間が短くなります。

スタックマシンとレジスターマシン

VM のアーキテクチャには大きく分けてスタックマシンとレジスターマシンの 2 種類があります。

スタックマシン

CPython、JVM、CLR が採用するモデルです。演算はすべてオペランドスタックを通じて行われます。

式: a + b * c のバイトコード(スタックマシン)
 
命令             スタック状態(左がボトム)
────────────────────────────────────────
LOAD a           [a]
LOAD b           [a, b]
LOAD c           [a, b, c]
MUL              [a, (b*c)]
ADD              [(a+b*c)]

長所:

  • 命令がコンパクト(オペランドにレジスター番号を指定する必要がない)
  • コード生成が容易(AST の後置走査で直接生成できる)
  • 命令セットがシンプルで VM の実装が簡単

短所:

  • 同じ計算を行うのに命令数が多くなる(LOAD/STORE が頻出)
  • スタックの読み書きがメモリーアクセスになりオーバーヘッドが生じる

レジスターマシン

Lua(5.0 以降)、Dalvik VM(Android)、一部の JS エンジンが採用するモデルです。仮想レジスターの配列を使って演算します。

式: a + b * c のバイトコード(レジスタマシン)
 
命令                      レジスタ状態
────────────────────────────────────────
MUL  R1, R_b, R_c         R1 = b * c
ADD  R0, R_a, R1           R0 = a + b * c

長所:

  • 命令数が少ない(1 命令で複数のオペランドを指定できる)
  • データの移動が少なく、インタプリターのディスパッチオーバーヘッドが減る

短所:

  • 命令が大きい(レジスター番号分のビットがオペランドに必要)
  • コード生成が複雑(レジスター割り当てが必要)

実際の VM の選択

VMアーキテクチャ言語
CPythonスタックマシンPython
JVMスタックマシンJava, Kotlin, Scala, Clojure
CLRスタックマシンC#, F#, VB.NET
Lua VMレジスターマシンLua
DalvikレジスターマシンJava(Android、Android 5.0 以降は ART に移行)
BEAMレジスターマシン(ハイブリッド)Erlang, Elixir

Yunhe Shi らの研究("Virtual Machine Showdown: Stack Versus Registers", VEE 2005)では、Java VM のスタック型バイトコードをレジスター型に変換すると、実行される VM 命令の数(インタプリターのディスパッチ回数)が平均 47% 以上減る一方、バイトコードのサイズは 25% 大きくなったと報告されています。拡張版の論文(ACM TACO 2008)では、それぞれ 46% 以上と 26% です。

次のデモでは、同じ式 a + b × c をスタックマシンとレジスターマシンで同時に実行し、命令数を比べられます。

スタックマシン vs レジスターマシン

同じ式 a + b × c を、スタックマシンとレジスターマシンで1命令ずつ並べて実行し、命令数を比べます。

スタックマシン(6 命令)
LOAD a
LOAD b
LOAD c
MUL
ADD
STORE result
スタック
(空)
レジスターマシン(3 命令)
MUL R1, b, c
ADD R0, a, R1
MOV result, R0
レジスター
a2
b3
c4
R0-
R1-
1 / 7

インタプリターの実装パターン

バイトコードを実行するインタプリター(VM のコア部分)の実装パターンは、主にディスパッチ(次に実行する命令のハンドラーへ制御を移す処理)の書き方で分かれます。

Switch-based Interpreter(スイッチディスパッチ)

最もシンプルな実装です。巨大な switch 文で各オペコードを処理します。

// 簡略化した VM ループ(C 言語)
void eval(uint8_t *bytecode, Value *stack) {
    uint8_t *ip = bytecode;   // Instruction Pointer
    Value *sp = stack;         // Stack Pointer
 
    for (;;) {
        uint8_t opcode = *ip++;
        switch (opcode) {
            case OP_LOAD_CONST: {
                uint8_t idx = *ip++;
                *sp++ = constants[idx];
                break;
            }
            case OP_ADD: {
                Value b = *--sp;
                Value a = *--sp;
                *sp++ = value_add(a, b);
                break;
            }
            case OP_STORE_FAST: {
                uint8_t slot = *ip++;
                locals[slot] = *--sp;
                break;
            }
            case OP_RETURN: {
                return;
            }
            // ... 数百のオペコード
        }
    }
}

長所: 実装が単純で理解しやすい。CPython の歴史的な中核実装。

短所: CPU の分岐予測が効きにくい。すべてのオペコードが同じ switch の分岐点を共有するため、分岐予測器は「次にどのオペコードが来るか」を予測できず、パイプラインストールが頻発します。

Threaded Interpreter(computed goto)

GCC 拡張の計算済み goto(computed goto)を使い、各ハンドラーの末尾から次の命令のハンドラーへ直接ジャンプする方式です。次の例では、バイトコード列にオペコードの番号をそのまま置き、ディスパッチのたびに dispatch_table を引いてジャンプ先を決めます。次節の分類では Token Threaded にあたります。

void eval_threaded(uint8_t *bytecode, Value *stack) {
    // 各ハンドラのアドレステーブル
    static void *dispatch_table[] = {
        [OP_LOAD_CONST] = &&op_load_const,
        [OP_ADD]        = &&op_add,
        [OP_STORE_FAST] = &&op_store_fast,
        [OP_RETURN]     = &&op_return,
        // ...
    };
 
    uint8_t *ip = bytecode;
    Value *sp = stack;
 
    #define DISPATCH() goto *dispatch_table[*ip++]
 
    DISPATCH();  // 最初の命令へジャンプ
 
op_load_const: {
        uint8_t idx = *ip++;
        *sp++ = constants[idx];
        DISPATCH();
    }
op_add: {
        Value b = *--sp;
        Value a = *--sp;
        *sp++ = value_add(a, b);
        DISPATCH();
    }
op_store_fast: {
        uint8_t slot = *ip++;
        locals[slot] = *--sp;
        DISPATCH();
    }
op_return:
        return;
}

長所: 各ハンドラーの末尾が異なる間接ジャンプになるため、CPU の分岐予測がハンドラーごとに独立したエントリーを持てる。CPython のソースコード(Python/ceval_macros.h)のコメントでは、記述した時点で switch 版より最大 15〜20% 速かった(コンパイラーと CPU アーキテクチャーによる)とされています。

短所: GCC/Clang の拡張に依存(標準 C ではない)。MSVC ではサポートされていません。

CPython 3.x はコンパイル時に computed goto が使えるかを検出し、使える場合はこの方式を採用します。

Direct Threaded / Subroutine Threaded

上の例のように実行時にテーブルを引く方式(Token Threaded)のほかにも、次のスレッディング方式があります。

  • Direct Threaded: 実行前にバイトコード列をハンドラーのアドレスの列に変換しておき、goto **ip++ のようにテーブルを引かずにジャンプする。ディスパッチ 1 回あたりのメモリー読み出しが 1 回減る代わりに、命令ごとにポインター 1 つ分の領域が要り、Token Threaded よりコードが大きくなる
  • Subroutine Threaded: 各オペコードのハンドラーを関数として実装し、バイトコード列を関数ポインターの配列に変換する。call/ret の命令が使われるため、CPU のリターンスタックバッファー(RSB)による分岐予測が効く

Specializing Adaptive Interpreter(特殊化適応型インタプリター)

CPython 3.11(PEP 659)で導入された最適化です。バイトコード命令を、実行時に観測したデータ型のパターンに合わせて特殊化(specialize)します。

通常の命令:       BINARY_OP  ADD
                     ↓ 実行時に「整数 + 整数」パターンが検出される
特殊化された命令: BINARY_OP_ADD_INT

仕組み:

  1. 各命令に適応カウンターが付与される
  2. 命令が一定回数実行されると、VM が最近のオペランドの型パターンを分析する
  3. パターンが安定していれば、汎用命令をより効率的な特殊化命令に書き換える
  4. 予期しない型が来た場合は脱最適化(deoptimize)して汎用命令に戻す

主な特殊化命令(CPython 3.12+):

汎用命令特殊化命令条件
LOAD_ATTRLOAD_ATTR_INSTANCE_VALUEインスタンス属性(辞書を引かずにインスタンスの値配列から読む)
LOAD_ATTRLOAD_ATTR_SLOT__slots__ を使うクラス
LOAD_ATTRLOAD_ATTR_MODULEモジュール属性
BINARY_OPBINARY_OP_ADD_INT両オペランドが int
BINARY_OPBINARY_OP_ADD_FLOAT両オペランドが float
BINARY_OPBINARY_OP_ADD_UNICODE両オペランドが str
COMPARE_OPCOMPARE_OP_INT整数同士の比較
CALLCALL_PY_EXACT_ARGSPython 関数への呼び出し(引数数一致)
FOR_ITERFOR_ITER_LISTリストのイテレーション
UNPACK_SEQUENCEUNPACK_SEQUENCE_TWO_TUPLE2 要素タプルのアンパック

What's New in Python 3.11 によると、CPython 3.11 は、この特殊化とフレームの軽量化(後述)などの最適化を合わせて、pyperformance ベンチマークで 3.10 より平均 25% 高速になりました(Ubuntu Linux 上の GCC ビルドでの測定)。JIT を使わず、インタプリターの改良だけで得た改善です。

フレームとコールスタック

フレームオブジェクト

関数が呼び出されるたびに、VM はフレーム(スタックフレーム / 実行フレーム)を作成します。

┌─────────────────────────────────┐
│         フレーム (frame)         │
├─────────────────────────────────┤
│  コードオブジェクト (code)       │  → バイトコード命令列
│  命令ポインタ (IP)              │  → 現在実行中の位置
│  ローカル変数配列 (locals)      │  → [a, b, result, ...]
│  オペランドスタック (stack)     │  → 演算用の一時的なスタック
│  前のフレームへのリンク         │  → 呼び出し元のフレーム
│  例外ハンドラのスタック         │  → try/except の情報
│  ビルトイン名前空間への参照     │  → builtins
│  グローバル名前空間への参照     │  → globals
└─────────────────────────────────┘

コールスタックの成長

def outer():
    x = 10
    return inner(x)
 
def inner(n):
    return n * 2
実行時のコールスタック:
 
inner(10) のフレーム  ← 現在実行中
  code: inner.__code__
  locals: [n=10]
  stack: []
──────────────────
outer() のフレーム
  code: outer.__code__
  locals: [x=10]
  stack: []
──────────────────
<module> のフレーム
  code: <module>.__code__
  locals: []
  stack: []

inner が return すると、そのフレームが破棄され、制御が outer のフレームに戻ります。

CPython は再帰制限(デフォルト 1000。sys.getrecursionlimit() で取得できる)によって、フレームスタックの最大深さを抑えています。これはスタックオーバーフローによるセグメンテーションフォルトを防ぐための安全弁です。

フレームの最適化

フレームの生成と破棄は呼び出しごとのコストになるため、各 VM はこれを減らす工夫をしています。

  • CPython 3.11+: インラインフレーム(_PyInterpreterFrame)。インタプリター内部のフレームスタックにフレームを保持し、PyFrameObject は必要になったときだけ実体化することで、毎回のヒープ割り当てを避ける
  • JVM: フレームはネイティブスタック上に配置。JIT コンパイラーがインライン展開により関数呼び出しを完全に除去することもある
  • V8: JIT コンパイラーがインライン展開によりフレームを統合し、脱最適化メタデータを含むスタックフレームレイアウトを最適化

オブジェクトの表現とタグ付き値

動的型付け言語の VM は、値と型情報をまとめて表現する必要があります。この表現によって、整数 1 つを扱うのにヒープ割り当てが要るかどうかが決まり、性能に直接影響します。

ボックス化(Boxing)

CPython では、すべての値は PyObject 構造体へのポインターとして表現され、整数もヒープ上のオブジェクトです。−5〜256 の小さな整数だけは、あらかじめ用意された共有オブジェクトを使い回します。

// CPython の PyObject 構造体(簡略化)
typedef struct {
    Py_ssize_t ob_refcnt;    // 参照カウント
    PyTypeObject *ob_type;   // 型へのポインタ
} PyObject;
 
// Python の整数は任意精度(多倍長)なので可変長の digit 配列を持つ
typedef struct {
    PyObject_VAR_HEAD            // ob_refcnt + ob_type + ob_size
    digit ob_digit[1];           // 任意精度の桁配列(30ビット/digit)
} PyLongObject;
// ※CPython 3.12+ では PyLong の内部表現が変更され、compact な値を
//   より安く判定できるようになったが、整数は引き続き PyObject ベースの値である

Python の int は任意精度整数であり、どんなに大きな値でも表現できます。小さな整数 42 でも、64 ビット版 CPython では参照カウント(8 バイト)+ 型ポインター(8 バイト)+ サイズ(8 バイト)+ 4 バイトの digit 1 個で、28 バイトを使います(sys.getsizeof(42) が返す値)。C 言語の int(4 バイト)の 7 倍で、メモリーアロケーターによる切り上げの分がさらに加わります。

NaN Boxing

一部の VM では、IEEE 754 の NaN(Not a Number)の内部表現を活用して、64 ビットのダブル型の中に型タグと値を同時に格納する技法を使います。

IEEE 754 double (64 bits):
  符号(1) | 指数(11) | 仮数(52)
 
NaN のビットパターン:
  x | 11111111111 | 1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
                     ↑ quiet NaN ビット
 
値の格納:
  ・浮動小数点数: そのまま IEEE 754 のビットパターンとして格納
  ・整数、ポインタ、真偽値: NaN の仮数部にタグビット + 値を格納
NaN Boxing のレイアウト例:
  [0][11111111111][1][TTT][PPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPP]
                       ↑ タグ(3ビット)  ↑ ペイロード(48ビット)
 
タグ:
  000 = ポインタ (オブジェクト)
  001 = 32ビット整数
  010 = 真偽値
  011 = nil/null
  ...

LuaJIT、JavaScriptCore(Safari)、SpiderMonkey(Firefox)がこの方式を使用しています。SpiderMonkey では、エンジン全体の基本的な値表現として NaN Boxing を使っています。値がスタック上の 64 ビットワードに収まるため、浮動小数点数や小さな整数をヒープ割り当てなしで扱え、ポインターの間接参照も省けます。

タグ付きポインター(Tagged Pointer)

64 ビットアーキテクチャでは、アラインメントによりポインターの下位数ビットが常に 0になります。この空きビットを型タグとして利用する手法です。

通常のポインタ(8バイトアラインメント):
  PPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPP000
                                                                ↑ 常に 0(下位3ビット)
 
タグ付きポインタ:
  PPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPP|TTT|
  ↑ ポインタ部分(上位61ビット)                                ↑ タグ(下位3ビット)

V8(Google の JavaScript エンジン)では Smi (Small Integer) と HeapObject を区別するためにタグ付きポインターを使用します:

  • 最下位ビットが 0 → Smi(小さな整数。pointer compression 構成では一般に 31 ビット)
  • 最下位ビットが 1 → HeapObject へのポインター(タグビットをマスクして参照)

Ruby の CRuby(MRI)でも同様の手法が使われており、Fixnum(小さな整数)はヒープ割り当てなしに表現されます。

JIT コンパイラー — 実行時のネイティブコード生成

JIT(Just-In-Time)コンパイラーは、プログラムの実行中にバイトコードをネイティブコード(機械語)にコンパイルし、以降はそのネイティブコードを直接実行します。

なぜ JIT が必要なのか

インタプリターでバイトコードを実行する場合、1 つのバイトコード命令を処理するために数十〜数百のネイティブ命令が実行されます。

バイトコード: BINARY_OP_ADD_INT
 
インタプリタが実際に行う処理(概略):
  1. オペコードをフェッチ(メモリ読み取り)
  2. ディスパッチテーブルからハンドラのアドレスを取得
  3. ハンドラにジャンプ
  4. スタックからオペランドB をポップ
  5. スタックからオペランドA をポップ
  6. A の型チェック(int か?)
  7. B の型チェック(int か?)
  8. オーバーフローチェック
  9. 加算を実行
  10. 結果をスタックにプッシュ
  11. 次のオペコードに進む
 
JIT がネイティブコードに変換した場合:
  add rax, rbx    ← たった1命令(もしくは数命令)

整数の加算 1 回に、インタプリターは数十個の機械語命令を使いますが、JIT 後は 1〜数命令で済みます。JIT が取り除くのは、この差の部分です。

JIT コンパイルの基本フロー

  ソースコード
      ↓
  バイトコードにコンパイル
      ↓
  インタプリタで実行開始
      ↓
  プロファイリング(型情報、実行頻度の収集)
      ↓
  ホットスポットの検出(頻繁に実行されるコード)
      ↓
  JIT コンパイル(バイトコード → IR → 最適化 → ネイティブコード)
      ↓
  ネイティブコードで実行(次回以降はインタプリタをバイパス)
      ↓
  仮定が崩れたら脱最適化(ネイティブコード → インタプリタに戻る)

メソッド JIT vs トレーシング JIT

JIT コンパイラーは、何を単位にコンパイルするかで大きく 2 つに分けられます。

メソッド JIT(Method-based JIT)

関数(メソッド)単位でコンパイルする方式です。JVM(HotSpot C2)、V8、.NET の RyuJIT がこの方式を採用しています。

関数 calculate() が1000回呼ばれた
  → 「ホットメソッド」と判定
  → 関数全体をネイティブコードにコンパイル
  → 次回以降はネイティブコードを実行

長所: コンパイル単位が明確で最適化しやすい。インライン展開との相性が良い。 短所: 大きな関数をコンパイルすると時間がかかる。関数内の一部しかホットでなくてもまとめてコンパイルされる。

トレーシング JIT(Trace-based JIT)

実行パスをトレース(追跡)し、実際に実行されたバイトコード列をコンパイルする方式です。LuaJIT がこの方式を採用しており、かつての Mozilla TraceMonkey も同じ方式でした。

ループの実行パスを記録:
  LOAD x → LOAD y → ADD → COMPARE → BRANCH(true) → STORE → JUMP_BACK
 
この「トレース」をネイティブコードにコンパイル:
  ・分岐は常に true と仮定(ガード命令で検証)
  ・型は記録時の型を仮定(ガード命令で検証)
  ・ガードが失敗したらインタプリタに戻る

長所: ループの最適化に非常に強い。実際の実行パスのみをコンパイルするため、デッドコードが含まれない。関数境界を超えたインライン化が自然に行われる。 短所: 複雑な分岐パターンに弱い(ガードの失敗による脱最適化が頻発する)。トレースの記録にオーバーヘッドがある。

型特殊化(Type Specialization)

動的型付け言語の JIT において最も重要な最適化が型特殊化です。

def add(a, b):
    return a + b

この関数は int、float、str、list など任意の型の引数を受け取れますが、プロファイリングにより「この関数は常に int で呼ばれている」という情報が得られれば、JIT は次のように特殊化できます:

汎用版(インタプリタ):
  1. a の型を取得
  2. b の型を取得
  3. 型に応じた __add__ メソッドを検索
  4. __add__ を呼び出し
  5. 結果をボックス化して返す
 
特殊化版(JIT、int 想定):
  ; ガード: a が int であることを確認
  test [rdi + type_offset], INT_TAG
  jne deoptimize
  ; ガード: b が int であることを確認
  test [rsi + type_offset], INT_TAG
  jne deoptimize
  ; 直接加算
  mov rax, [rdi + value_offset]
  add rax, [rsi + value_offset]
  ; オーバーフローチェック
  jo  deoptimize
  ret

型チェックのオーバーヘッドはガード命令で最小化されます。ガードが成功すれば最適化されたパスを走り、失敗すれば脱最適化(deoptimization)してインタプリターに戻ります。

インライン展開(Inlining)

関数呼び出しを、呼び出し先の関数本体に置き換える最適化です。呼び出しのコストがなくなるだけでなく、展開後のコードにさらに別の最適化をかけられるため、JIT の効果を大きく左右します。

def square(x):
    return x * x
 
def sum_of_squares(a, b):
    return square(a) + square(b)

JIT コンパイラーはこれを以下のように変換します:

sum_of_squares のネイティブコード(インライン展開後):
  ; square(a) をインライン化
  mov rax, [rdi]      ; a の値
  imul rax, rax       ; a * a
  mov rcx, rax        ; 結果を保存
 
  ; square(b) をインライン化
  mov rax, [rsi]      ; b の値
  imul rax, rax       ; b * b
 
  ; 加算
  add rax, rcx        ; a*a + b*b
  ret

インライン展開のメリット:

  • 関数呼び出し/リターンのオーバーヘッドが除去される
  • インライン化後のコードに対してさらなる最適化(定数伝播、デッドコード除去など)が適用できる
  • 呼び出し先のコンテキスト(型情報)を引き継いだ最適化が可能

脱最適化(Deoptimization)

JIT が前提としていた「仮定」が実行中に崩れた場合、ネイティブコードの実行を中断してインタプリターに戻る必要があります。これが脱最適化です。

def process(x):
    return x + 1
 
# 最初の1万回は int で呼ばれる → JIT が int 版を生成
for i in range(10000):
    process(i)
 
# 突然 float で呼ばれる → ガードが失敗、脱最適化が発生
process(3.14)

脱最適化のプロセス:

  1. ガード命令が失敗を検出
  2. その時点のマシンレジスターの状態をキャプチャ
  3. インタプリターのフレーム(スタック、ローカル変数、命令ポインター)を再構築
  4. インタプリターに制御を戻す
  5. プロファイル情報を更新し、再コンパイルのトリガーを設定

脱最適化はコストの高い操作ですが、まれにしか起きなければ全体としては速くなります。頻繁に起きると、コンパイル→脱最適化→再コンパイルを繰り返して、かえって遅くなる場合があります。

On-Stack Replacement (OSR)

通常、JIT コンパイルは関数の次回呼び出し時から有効になります。しかし、1 回の呼び出しの中で長いループが回る場合、次の呼び出しを待っていると、そのループは最後までインタプリターで実行されてしまいます。

OSR は、現在実行中のインタプリターフレームをJIT コンパイルされたネイティブコードの途中に「載せ替える」技術です。

実行中のループ:
  for i in range(1000000):  ← ループのイテレーション中に...
      ...
                             ★ JIT コンパイルが完了!
                             ★ OSR: インタプリタからネイティブコードに切り替え
                             ★ ループの残りはネイティブコードで実行

OSR のために必要な処理:

  1. インタプリターのフレーム状態(ローカル変数、スタック、ループカウンター)をキャプチャ
  2. ネイティブコードのフレームレイアウトにマッピング
  3. ネイティブコードのループ本体の適切な位置にジャンプ

JVM の HotSpot、V8、SpiderMonkey が OSR をサポートしています。

多段階 JIT コンパイル

JIT コンパイルでは、最適化を強くするほどコンパイルに時間がかかります。多くの JIT は、これを多段階コンパイル(Tiered Compilation)で扱います。まず軽い最適化ですぐにコンパイルし、実行回数が増えたコードだけを、時間をかけて強く最適化し直す方式です。

次のデモでは、関数がインタプリターからベースライン JIT、最適化 JIT へと昇格していく過程と、脱最適化・再コンパイルのサイクルを確認できます。

多段階 JIT コンパイルの動作

3つの関数のうち、呼び出し回数が増えたものがインタプリター → ベースライン JIT → 最適化 JIT へ昇格し、その1つが脱最適化と再コンパイルを経る様子を示します。しきい値と回数は説明用の値です。

インタプリター
ベースライン JIT
最適化 JIT
脱最適化
calculate()
インタプリター
呼び出し回数: 0
helper()
インタプリター
呼び出し回数: 0
format()
インタプリター
呼び出し回数: 0
1 / 8

JVM HotSpot の多段階コンパイル

JVM の HotSpot は 5 つのティアを持ち、最も一般的なパスは Tier 0 → Tier 3 → Tier 4(インタプリター → C1 完全プロファイリング → C2)です:

Tier 0: インタプリタ(プロファイリング情報を収集)
  ↓ メソッドの呼び出し回数がしきい値を超える(通常ここが最初のコンパイル)
Tier 3: C1、完全なプロファイリング付き
  ↓ 十分なプロファイル情報が蓄積
Tier 4: C2(サーバーコンパイラ)、積極的最適化
 
特殊なケースで使用されるティア:
Tier 1: C1、プロファイリングなし(トリビアルなメソッド向け)
Tier 2: C1、限定的プロファイリング付き(C2 キューが満杯の場合のフォールバック)
ティアコンパイラーコンパイル速度最適化レベルプロファイリング
0なし(インタプリター)-なしあり
1C1高速基本的なし
2C1高速基本的限定的
3C1高速基本的完全
4C2低速積極的-

C1(クライアントコンパイラー)は短い起動時間が重要なアプリケーション向けに、素早く基本的な最適化を行います。C2(サーバーコンパイラー)は長時間稼働するサーバー向けに、時間をかけて積極的な最適化を行います。

C2 が行う主な最適化:

  • ループアンローリング
  • エスケープ解析(escape analysis)による割り当ての除去
  • ロック省略(lock elision)
  • 範囲チェック除去(bounds check elimination)
  • デッドコード除去
  • ベクトル化(SIMD 命令の利用)

V8 の多段階コンパイル

Google の V8 JavaScript エンジンも多段階コンパイルを採用しています。

Ignition(インタプリタ)
  ↓ 関数がホットになる
Sparkplug(ベースラインコンパイラ、V8 v9.1+)
  ↓ さらなるプロファイリング
Maglev(中間層コンパイラ、Chrome 117+ / V8 v11.7+)
  ↓ 十分なプロファイル情報
TurboFan(最適化コンパイラ)
  • Ignition: レジスターベースのバイトコードインタプリター。全コードをまずここで実行
  • Sparkplug: バイトコードから直接ネイティブコードを生成する非最適化コンパイラー。コンパイルが極めて高速(中間表現を作らず、バイトコードを 1 回走査するだけで機械語を出す)
  • Maglev(Chrome 117+ / V8 v11.7+): SSA ベースの中間表現を使い、型フィードバックに基づく最適化を行う中間層コンパイラー
  • TurboFan: Sea of Nodes IR を使う最上位の最適化コンパイラー。インライン展開、ループ最適化、エスケープ解析などを実施

GraalVM の Graal JIT

Oracle が開発する GraalVM は、Java 自身で実装された JIT コンパイラー(Graal)を搭載しています。

主な特徴:

  • Partial Escape Analysis: オブジェクトが特定のパスでのみエスケープする場合、そのパスだけ割り当てを行い、他のパスではスカラー値として保持する
  • 多言語対応(Truffle フレームワーク): Truffle の API(@Specialization、@NodeChild など)を使って AST インタプリターを記述すると、Graal JIT が自動的に最適化を適用する。Python、Ruby、JavaScript、R、LLVM bitcode など多数の言語をサポート
  • ネイティブイメージ: AOT(Ahead-Of-Time)コンパイルにより、ミリ秒単位の起動時間で実行可能なスタンドアロンバイナリーを生成

主要 VM の比較

主要なバイトコード VM として、CPython、JVM、CLR、LuaJIT、V8 の特徴を順に挙げます。

CPython(Python)

特徴:
  ・スタックベースのバイトコード VM
  ・GIL(Global Interpreter Lock)によりマルチスレッドが制限される
  ・参照カウント + 循環参照検出 GC
  ・CPython 3.11: Specializing Adaptive Interpreter
  ・CPython 3.13: 実験的 JIT(copy-and-patch JIT)
  ・CPython 3.13: free-threaded モード(GIL 無効化、実験的)
  ・CPython 3.14: free-threaded ビルドが正式サポートへ(PEP 779)
  ・CPython 3.14: 末尾呼出しインタプリタ(tail-call interpreter、オプトイン)

CPython 3.13 で導入されたcopy-and-patch JITは、事前に用意されたネイティブコードのテンプレート(ステンシル)をコピーして、オペランドをパッチするだけの極めて軽量な JIT です。コンパイル時間を最小限に抑えつつ、インタプリターのディスパッチオーバーヘッドを除去します。

copy-and-patch JIT はバイトコードを直接コンパイルするのではなく、まず Tier 2 オプティマイザーがバイトコードをマイクロオペレーション(μop)の中間表現に変換し、その μop 列に対して JIT が適用されます。

Copy-and-patch のフロー:
  1. ビルド時に各 μop のネイティブコードテンプレート(ステンシル)を生成
  2. 実行時に Tier 2 オプティマイザがバイトコードを μop 列に変換
  3. ホットな μop 列のテンプレートをメモリにコピー
  4. オペランド(定数値、変数スロット番号)をパッチ
  5. テンプレートを連結してネイティブコード列を完成
  6. 連結されたテンプレート列を直接実行

この JIT は Python 3.14 時点でも依然として実験的な位置づけですが、macOS / Windows の公式バイナリーに同梱され、環境変数 PYTHON_JIT=1 で試せるようになりました。What's New in Python 3.14 は、有効にしたときの性能への影響を、ワークロードによって典型的には 10% 遅くなる〜20% 速くなる範囲としており、本番環境での利用は推奨していません。実行ファイルが JIT に対応しているかは sys._jit.is_available() で、現在のプロセスで JIT が有効かは sys._jit.is_enabled() で確認できます。

さらに Python 3.14 では、巨大な C の switch 文ではなく命令ごとの小さな C 関数を末尾呼出しでつなぐ新しいインタプリターが、オプトインで追加されました(--with-tail-call-interp。現状 Clang 19 以降の x86-64 / AArch64 のみ)。同じ文書によると、Clang 19 でビルドした従来のインタプリターと比べて、ベンチマーク pyperformance の幾何平均で約 3〜5% 速くなるとされています(プラットフォームとアーキテクチャーによる)。言語仕様もバイトコードも変えず、ディスパッチの書き方を変えただけでこの差が出ており、「インタプリターの実装パターン」の節で見たとおり、ディスパッチの実装は性能を左右します。

free-threaded ビルドは、Python 3.14 で PEP 779 により正式サポート(フェーズ II)に移行しました。What's New in Python 3.14 によると、当初の free-threaded ビルドでは無効化されていた特殊化適応型インタプリター(PEP 659)も有効になり、シングルスレッド実行時のペナルティーは約 5〜10% まで縮まっています(プラットフォームと C コンパイラーによる)。ただし、free-threaded ビルドと JIT は現時点では併用できません。

JVM(Java, Kotlin)

特徴:
  ・スタックベースのバイトコード VM
  ・多段階 JIT(C1 + C2, または Graal)
  ・高度な GC(G1, ZGC, Shenandoah など)
  ・バイトコード検証(verifier)によるセキュリティ保証
  ・クラスローダによる動的クラスロード

JVM のバイトコード検証器は、バイトコード実行前に以下を検証します:

  • スタックのオーバーフロー/アンダーフローがないこと
  • ローカル変数のアクセスが範囲内であること
  • 型の整合性が保たれていること
  • finally ブロックが正しく接続されていること

これにより、不正なバイトコードの実行を防ぎ、VM のメモリー安全性を保証します。

CLR(C#, F#)

特徴:
  ・スタックベースの CIL(Common Intermediate Language)
  ・RyuJIT(JIT コンパイラ)
  ・値型(struct)のサポート — ボックス化を回避
  ・Span<T> / stackalloc によるゼロアロケーション
  ・NativeAOT による事前コンパイルもサポート
  ・多世代 GC(世代別マーク&スイープ)

CLR の特徴の一つは値型のサポートです。ローカル変数の struct はスタック上に置かれ、クラスのフィールドとしての struct は親オブジェクトの中にそのまま埋め込まれます。どちらも個別のヒープ割り当てが不要なので、割り当て回数と GC の負荷を減らせます。

// 値型 — スタック上に配置
struct Point { public int X, Y; }
 
// 参照型 — ヒープに割り当て
class PointClass { public int X, Y; }
 
void Example() {
    Point p = new(1, 2);       // スタック上。GC 不要
    PointClass pc = new(1, 2); // ヒープ割り当て。GC 対象
}

LuaJIT

特徴:
  ・レジスタベースのバイトコード VM
  ・トレーシング JIT
  ・NaN Boxing による値表現
  ・極めて高速(動的型付け言語で最速クラス)
  ・FFI(Foreign Function Interface)が高速

LuaJIT は Mike Pall が開発したトレーシング JIT です。公式サイトによると、LuaJIT は最も高速な動的言語実装の一つと広く見なされています。同サイトは、動的言語のオーバーヘッドを大きく減らしたことで、従来は静的言語のオフラインコンパイラーに限られていた性能域に入るとも説明しています。コードベースは、C とアセンブリー(DynASM)で約 12 万行と小規模です(2026 年 11 月時点の v2.1 ブランチ、全 CPU アーキテクチャ分の合計)。

LuaJIT のトレーシング JIT の仕組み:

  1. ホットループ検出: ループの反復回数や関数の呼び出し回数を数え、一定回数に達したループや関数を「ホット」と判定
  2. トレース記録: ホットループの実行パスを「トレース」として記録。関数呼び出しも展開して記録
  3. SSA 変換: 記録しながら、各バイトコードを SSA(Static Single Assignment)形式の IR 命令に変換
  4. 最適化: 定数畳み込み、代数的簡約、CSE(共通部分式除去)、DCE(デッドコード除去)、ループ展開とコピー置換によるループ不変な命令の巻き上げ、レジスター割り当て
  5. コード生成: 最適化された IR から直接ネイティブコードを生成(公式サイトが挙げる対応 CPU は x86, x64, ARM, ARM64, PPC, MIPS32, MIPS64)

V8(JavaScript)

特徴:
  ・バイトコード(Ignition)+ 多段階 JIT
  ・Hidden Class(隠しクラス)によるオブジェクトの効率的表現
  ・Inline Cache(IC)によるプロパティアクセスの高速化
  ・世代別 GC(Scavenger + Mark-Compact)
  ・タグ付きポインタ

V8 のHidden Class(Map)は、動的型のオブジェクトに対して C++ のクラスのような固定レイアウトを推論する仕組みです。

// この2つのオブジェクトは同じ Hidden Class を共有する
let a = { x: 1, y: 2 };
let b = { x: 3, y: 4 };
 
// 以下は新しい Hidden Class への遷移を引き起こす
a.z = 5;

同じプロパティーを同じ順序で追加したオブジェクトは同じ Hidden Class を共有し、JIT コンパイラーはこの Hidden Class を基にプロパティーアクセスを最適化します。

具体例で見る:Python バイトコードの実行トレース

再帰呼び出しを含む関数を例に、CPython がバイトコードを 1 命令ずつどう実行するかを追います。

def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)
import dis
dis.dis(fibonacci)
  2           RESUME                   0
 
  3           LOAD_FAST                0 (n)
              LOAD_CONST               1 (1)
              COMPARE_OP              26 (<=)
              POP_JUMP_IF_FALSE        1 (to 14)
 
  4           LOAD_FAST                0 (n)
              RETURN_VALUE
 
  5     >>    LOAD_GLOBAL              1 (fibonacci)
              LOAD_FAST                0 (n)
              LOAD_CONST               1 (1)
              BINARY_OP               10 (-)
              CALL                     1
              LOAD_GLOBAL              1 (fibonacci)
              LOAD_FAST                0 (n)
              LOAD_CONST               2 (2)
              BINARY_OP               10 (-)
              CALL                     1
              BINARY_OP                0 (+)
              RETURN_VALUE

fibonacci(3) を呼び出した場合を、スタックの変化と合わせてトレースします。

fibonacci(3) の実行トレース:
 
命令                    スタック           備考
─────────────────────────────────────────────────────
RESUME                  []                 関数の開始(内部チェックのみ)
LOAD_FAST 0 (n)         [3]                n=3 をプッシュ
LOAD_CONST 1 (1)        [3, 1]             定数 1 をプッシュ
COMPARE_OP <= (26)      [False]            3 <= 1 → False
POP_JUMP_IF_FALSE       []                 False なのでジャンプ
LOAD_GLOBAL 1           [NULL, fibonacci]  NULL + 関数オブジェクトをプッシュ
LOAD_FAST 0 (n)         [NULL, fibonacci, 3]  n=3
LOAD_CONST 1 (1)        [NULL, fibonacci, 3, 1]  定数 1
BINARY_OP - (10)        [NULL, fibonacci, 2]  3 - 1 = 2
CALL 1                  [→ fibonacci(2) を呼び出し]
                        ... 再帰呼び出し ...
                        [result_1]          fibonacci(2) の結果 = 1
LOAD_GLOBAL 1           [1, NULL, fibonacci]
LOAD_FAST 0 (n)         [1, NULL, fibonacci, 3]
LOAD_CONST 2 (2)        [1, NULL, fibonacci, 3, 2]
BINARY_OP - (10)        [1, NULL, fibonacci, 1]  3 - 2 = 1
CALL 1                  [→ fibonacci(1) を呼び出し]
                        ... 再帰呼び出し ...
                        [1, result_2]       fibonacci(1) の結果 = 1
BINARY_OP + (0)         [2]                1 + 1 = 2
RETURN_VALUE            → 2 を返す

インラインキャッシュ — 動的ディスパッチの高速化

動的型付け言語ではプロパティーアクセスやメソッド呼び出しのたびに、オブジェクトの型を確認してプロパティーの位置を検索する必要があります。インラインキャッシュ(Inline Cache / IC)は、この検索結果をキャッシュして再利用する仕組みです。

IC の状態遷移

Uninitialized(未初期化)
  ↓ 最初のアクセス
Monomorphic(単形)
  → 1つの型のみが観測された。キャッシュヒット率が最も高い
  ↓ 異なる型が観測される
Polymorphic(多形)
  → 2〜4つの型が観測された。複数のキャッシュエントリを線形探索
  ↓ さらに異なる型が観測される
Megamorphic(超多形)
  → 多数の型が観測された。キャッシュを諦め、汎用パスにフォールバック

具体例

function getX(obj) {
    return obj.x;  // ← この箇所に IC が設定される
}
 
// 常に同じ形状のオブジェクトで呼ぶ → Monomorphic(最速)
getX({ x: 1, y: 2 });
getX({ x: 3, y: 4 });
 
// 異なる形状のオブジェクトが混在 → Polymorphic → Megamorphic
getX({ x: 1 });
getX({ x: 1, y: 2, z: 3 });
getX({ a: 0, x: 1 });

Monomorphic な IC はプロパティーアクセスを 1 回の比較 + 1 回のメモリー読み取りまで高速化できます。JIT コンパイラーは Monomorphic な IC の情報を使って、型ガード付きの直接フィールドアクセスコードを生成します。

プロファイリングの仕組み

JIT コンパイラーは「どのコードをコンパイルするか」「どの型で特殊化するか」を、実行中に集めたプロファイル情報をもとに判断します。

プロファイリング手法の比較

VMプロファイリング手法仕組み
JVM HotSpot呼び出しカウンター + バックエッジカウンターメソッド呼び出し回数とループのバックエッジ回数をカウント。時間経過で減衰(decay)させ、最近のホットスポットを優先
V8割り込みバジェット(Interrupt Budget)バイトコード命令ごとにバジェットをデクリメント。バジェットが 0 になるとコンパイルをトリガー
CPython 3.11+命令ごとの適応カウンター各バイトコード命令にカウンターを設置。しきい値到達で特殊化を試行

HotSpot がカウンターを減衰させるのは、起動時にだけ多く実行され、定常状態では実行されないコードが、いつまでも「ホット」と見なされないようにするためです。

GC と JIT の連携 — セーフポイント

JIT コンパイルされたネイティブコードは、ガベージコレクターと協調して動作する必要があります。GC がヒープを走査・移動するには、すべてのスレッドが安全な状態(セーフポイント)で停止していなければなりません。

セーフポイントの配置

JIT コンパイラーはネイティブコード内にセーフポイントポーリングを挿入します。

JIT 生成コード(概略):
  ...
  add rax, rbx          ; 計算
  test [safepoint_page], eax  ; ★ セーフポイントポーリング
  ...                    ; safepoint_page が不可読にされると
                         ; ページフォルトが発生 → GC に制御を渡す

JVM HotSpot では、セーフポイントは以下の場所に配置されます:

  • メソッドの return 直前
  • ループのバックエッジ(ループの末尾から先頭へのジャンプ)
  • JNI / ネイティブメソッド呼び出しの遷移点

カウントループ(反復回数が既知の int ループ)ではバックエッジのセーフポイントが省略される場合があり、GC の停止時間(Time-To-Safepoint)が予想外に長くなる原因になることがあります。JDK 10 で導入されたループストリップマイニング(G1 などで既定で有効)は、カウントループをチャンクに分割してチャンク間にセーフポイントを挿入することで、この問題を緩和しています。

JIT のセキュリティー — W^X 問題

JIT はネイティブコードをメモリーに書き込み、そのメモリーを実行します。同じ領域を書き込み可能(W)かつ実行可能(X)のまま置いておくと、W^X(Write XOR Execute)というセキュリティーポリシーに違反します。

W^X 問題への対処

手法説明採用例
W/X トグルコード生成時は W、完了後に X に切り替え多くの JIT
ダブルマッピング同一物理ページを W(書き込み用)と X(実行用)の 2 つの仮想アドレスにマッピングOpenJDK, V8
JIT 禁止JIT を使用せず、AOT またはインタプリターのみiOS アプリ(Safari 以外)

iOS では、Apple がサードパーティーアプリに JIT を禁止しています。Safari と WKWebView(Chrome、Firefox などのサードパーティーブラウザーを含む)では JIT が有効ですが、JavaScriptCore を直接組み込むアプリは JIT を使えず、インタプリター(LLInt)だけで動作します。そのため、こうしたアプリでは JavaScript の実行が遅くなることがあります。

まとめ

バイトコード VM は、ソースコードをプラットフォームに依存しないバイトコードにし、よく実行される部分だけを JIT でネイティブコードにすることで、ポータビリティーと実行効率を両立させています。

ソースコード
  ↓ 字句解析 → 構文解析 → 意味解析 → バイトコード生成
バイトコード(ポータブルな中間表現)
  ↓ VM が実行
インタプリタ(全コードをまず実行)
  ↓ プロファイリング
JIT コンパイラ(ホットスポットをネイティブコードに変換)
  ↓ 型特殊化 → インライン展開 → 各種最適化
ネイティブコード(CPU が直接実行)

各 VM は異なるトレードオフを選んでいます。

  • CPython: シンプルさとエコシステムの安定性を重視。JIT は 3.13 で実験的に導入
  • JVM: 長期稼働サーバーに最適化された重厚な JIT パイプライン
  • V8: 短いスクリプトの起動時間とサーバーサイドの最適化を両立する多段階コンパイル
  • LuaJIT: 小さなコードベースで高い性能を出すトレーシング JIT

CPython は 3.11 の Specializing Adaptive Interpreter、3.13 の実験的な copy-and-patch JIT、3.14 の末尾呼出しインタプリターと、2020 年代に入ってからも高速化の取り組みを続けています。WebAssembly(WASM)によって、バイトコード VM の設計はブラウザーの外、サーバーサイドやエッジコンピューティングにも広がっています。

参考文献

  • Aho, Lam, Sethi, Ullman. Compilers: Principles, Techniques, and Tools (2nd Edition), 2006
  • Smith & Nair. Virtual Machines: Versatile Platforms for Systems and Processes, 2005
  • Yunhe Shi, David Gregg, Andrew Beatty, M. Anton Ertl. "Virtual Machine Showdown: Stack Versus Registers", VEE 2005. https://doi.org/10.1145/1064979.1065001
  • Yunhe Shi, Kevin Casey, M. Anton Ertl, David Gregg. "Virtual Machine Showdown: Stack Versus Registers", ACM Transactions on Architecture and Code Optimization 4(4), 2008. https://doi.org/10.1145/1328195.1328197
  • CPython Developer's Guide. https://devguide.python.org/internals/
  • Mark Shannon. "Faster CPython" (PEP 659 — Specializing Adaptive Interpreter)
  • Mike Pall. "LuaJIT 2.0 intellectual property disclosure and research opportunities", 2009
  • "Life of a V8 JavaScript object", V8 Blog. https://v8.dev/blog
  • Würthinger et al. "One VM to Rule Them All" (Truffle/Graal), ACM DL, 2013
  • Bryce Adelstein Lelbach. "A Deep Introduction to JIT Compilers: JITs are not very Just-in-Time", CppCon 2021
  • Hölzle, Chambers, Ungar. "Optimizing Dynamically-Typed Object-Oriented Languages With Polymorphic Inline Caches", ECOOP, 1991
  • Agesen. "Design and Implementation of PEP, a Java Just-In-Time Translator", Sun Microsystems, 1997