Skip to content

インタプリタ実行

前章でコンパイラが生成したバイトコードは、それ自体では何もしません。命令列を一つずつ読み取り、実際に値を計算し、オブジェクトを操作し、関数を呼び出す実行主体が必要です。それがバイトコード仮想マシン (VM) であり、その心臓部が命令を次々に選び分けて処理する「ディスパッチループ」です。この章では、スタックマシンとしての VM の構造、ディスパッチ方式の選択肢とそのトレードオフ、命令ごとの実行コストを抑えつつ実行を中断可能に保つセーフポイントの設計、そして算術演算の整数ファストパスを扱います。あわせて、教科書的には有効とされる最適化が実測では必ずしも効かないという実務的な知見を率直に述べます。

スタックマシンとしての仮想マシン

バイトコード VM の多くはスタックマシンとして設計されます。演算に使う値を専用のオペランドスタックに積み、命令はスタックのてっぺんから値を取り出して計算し、結果をまた積み戻します。たとえば a + b は「a を積む」「b を積む」「加算命令(スタック上位2値を取り出し、和を積む)」という三命令で表せます。

instruction     operand stack (top on right)
--------------  ---------------------------
(start)         [ ]
PUSH a          [ a ]
PUSH b          [ a  b ]
ADD             [ a+b ]

a + b を評価する三命令のそれぞれの実行後で、オペランドスタックの中身がどう推移するか(右端がスタックのてっぺん)。

VM が実行時に持つ主な状態は次のとおりです。

  • 命令ポインタ (instruction pointer): 次に実行するバイトコード上の位置。
  • オペランドスタック: 計算途中の一時値を積むスタック。
  • ローカルスロット: 関数のローカル変数・引数を格納する固定長の領域(前章で割り当てた番号でアクセスする)。
  • フレーム情報: 呼び出しごとの戻り先・スタック底など。

実行の骨格は単純です。命令ポインタが指すオペコードを読み、それに対応する処理を行い、命令ポインタを次に進める。これをプログラムが終わるまで繰り返します。この「オペコードを読んで対応処理へ飛ぶ」ステップをディスパッチと呼び、ループ全体の性能を大きく左右します。

loop:
    op = code[ip]      # オペコードを読む
    ip = ip + 1
    dispatch(op):      # op に応じた処理へ分岐
        case OP_ADD:  ...
        case OP_LOAD: ...
        ...
    goto loop

命令一つあたりの処理は軽いものが多いため、この「読む・分岐する・戻る」に費やす費用の割合が相対的に大きくなります。ディスパッチの作り方が問われるのはこのためです。

ディスパッチ方式

単純な switch ループ

もっとも素直な方式は、無限ループの中でオペコードに対する switch(多分岐)を置き、各命令を case として書くものです。上の擬似コードがそのままこの形です。

  • 利点: 実装が明快で読みやすい。オペコードの追加は case を足すだけ。移植性が高く、どんなコンパイラでも同じように書ける。
  • 欠点: 分岐点が一箇所に集中する。ループの末尾から switch の先頭へ戻り、そこで再び多分岐する構造のため、CPU の分岐予測が効きにくい。

分岐予測とは、CPU が「次にどの分岐先へ進むか」を先読みして命令パイプラインを埋め続ける仕組みです。予測が外れるとパイプラインを捨てて詰め直すため、大きな遅延(ミスペナルティ)が生じます。単純 switch では、実行中のあらゆるオペコードが同じ一つの分岐点を通ります。「加算の次はロード、その次は比較」といった命令列に現れる規則性(あるオペコードの次に来やすいオペコードの偏り)を、一箇所の分岐点では捉えにくく、予測が当たりにくくなります。

スレッデッドコードと computed-goto 方式

この弱点を狙った古典的な最適化がスレッデッドコード (threaded code) です。分岐点を一箇所に集約するのをやめ、各命令ハンドラの末尾に、次の命令へのディスパッチを埋め込む発想です。命令 A の処理を終えたら、共通のループ先頭に戻らず、A のハンドラ自身が「次のオペコードを読んで、その命令のハンドラへ直接飛ぶ」。

これを実現する代表的な手法が、各ハンドラの位置(ラベルのアドレス)を表に持ち、次オペコードでその表を引いて飛び先を決める computed-goto 方式(オペコードでアドレス表を引いて間接ジャンプする)です。命令ごとに独立した分岐点を持つことになるため、分岐予測は命令の種類ごとに履歴を学習でき、「この命令の次はこの命令が来やすい」という偏りを捉えやすくなります。これがスレッディングで速くなりうる理論的な根拠です。

# computed-goto 方式のイメージ(各ハンドラが自前で次へ飛ぶ)
handler_ADD:
    ... 加算処理 ...
    op = code[ip]; ip = ip + 1
    goto table[op]      # 次命令のハンドラへ直接ジャンプ

handler_LOAD:
    ... ロード処理 ...
    op = code[ip]; ip = ip + 1
    goto table[op]

このほか、命令列そのものにハンドラのアドレスを並べておく方式(直接スレッディング)や、ハンドラ番号の列を介する方式(間接スレッディング)など、いくつかの変種があります。いずれも「分岐点を分散させ、次命令への遷移を各ハンドラに持たせる」という核心を共有します。

方式の比較

観点 単純 switch スレッデッド / computed-goto
実装の明快さ 高い やや低い(表・ラベルの管理が要る)
分岐予測の効きやすさ 分岐点が一箇所で効きにくい 分岐点が分散し効きやすい(理論上)
移植性 どのコンパイラでも書ける 言語・コンパイラの機能に依存しうる
コード量 小さい ハンドラ末尾の重複で増えやすい
実効速度 素直 速くなりうるが保証はない(後述)

実務的な知見: スレッディングは常に速いとは限らない

スレッデッドコードは分岐予測を根拠に「速くなりうる」最適化ですが、実際に速くなるかどうかは、インタプリタを記述する言語とコンパイラのバックエンドに依存します。ここは机上の理屈と実測が食い違いやすい代表例なので、率直に述べます。

現代の最適化コンパイラは、switch を素朴な連鎖比較にはコンパイルしません。飛び先アドレスの表を作って間接ジャンプする形(ジャンプテーブル)へ最適化することが多く、この時点で単純 switch も「一回の間接ジャンプ」に近づきます。さらにコンパイラによっては、ループ末尾の共通ディスパッチを各 case の末尾へ複製する最適化(末尾複製)を自前で行い、結果的にスレッディングと同等の分岐点分散を生み出すこともあります。

このため、単純 switch を、手作業でラベル付き継続やスレッデッド形式へ書き換えても、速くならないどころか遅くなることがあります。書き換えによってコンパイラが得意としていた最適化が阻害されたり、ハンドラ末尾のディスパッチ重複でコードが膨らんで命令キャッシュの効きが悪化したり、レジスタ割り当てが変わって不利になったりするためです。本エンジンも単純 switch 方式を採用しています。

ここから引き出すべき教訓は、方式そのものの優劣ではなく手順です。

  • ディスパッチ最適化は、必ず実際のツールチェーン上で計測して判断する。「スレッディングは速い」は一般論であって、あなたの言語・コンパイラ・対象 CPU で成り立つとは限らない。
  • 計測なしに複雑な構造へ書き換えると、可読性・移植性を失ったうえに速度も得られない、という最悪の結果になりうる。
  • まず単純 switch で正しく動かし、プロファイルでディスパッチが実際にボトルネックだと確認してから、方式変更を A/B で比較する。

この「教科書的な最適化を計測で裏取りする」姿勢は、ディスパッチに限らずインタプリタ最適化全般に通じる原則です。

命令ごとのコストとセーフポイント

ディスパッチと並んで、ループの実効速度を左右するのが命令一つあたりに課す付随処理です。VM は実行を続ける中で、周期的に次のようなチェックをする必要があります。

  • GC の要求: ヒープが逼迫し、ガベージコレクションを走らせるべきか。
  • 割り込み: 外部から実行中断を要求されていないか(暴走コードの中止など)。
  • 実行時間・ステップ数の上限: 制限を超えていないか(サンドボックスや時間制限)。

これらを毎命令チェックすると、判定と分岐がホットパスに常時乗り、命令本体の軽さに対して無視できない費用になります。かといってチェックを疎にしすぎると、単純な無限ループ while (true) {} を中断できなくなります。

この緊張を解く定石がセーフポイント (safe-point) です。「際限なく実行が続くコードは、必ず特定の種類の命令を通過する」という観察に基づき、チェックをそれらの命令に限定します。具体的には次の点です。

  • 後方ジャンプ / ループの先頭: 無限ループは必ずここを通る。
  • 関数の呼び出しと復帰: 深い再帰や大量の呼び出しは必ずここを通る。

これらの点だけで GC・割り込み・時間上限をチェックすれば、途中に何個の軽量命令が並んでいてもそこにはチェックを置かずに済み、直線的な命令列を高速に流せます。それでいて、無限ループも無限再帰も必ずセーフポイントを通るため、実行は依然として中断可能なままです。

遅延できないチェックとの区別

すべてのチェックをセーフポイントへ寄せられるわけではありません。保留中の例外のように、発生した瞬間に実行を止めなければ意味論が壊れるものは、遅延できません。たとえばある命令が例外を送出したら、次の命令を実行してはならないため、この種のチェックは命令の実行結果として毎回見ることになります(多くの場合、命令ハンドラが失敗を返し、ディスパッチループがそれを検出して巻き戻す形で、専用の毎命令分岐を増やさずに扱います)。

つまりチェックは性質で二分できます。遅延してよいもの(GC・割り込み・時間上限)はセーフポイントへ遅延できないもの(即時停止が要る例外)は各命令の経路に。この振り分けが、タイトなループと正しい中断可能性を両立させます。

デバッグ / ストレスモードの別扱い

セーフポイントは通常運転のための最適化です。一方、GC のバグを炙り出すには「あらゆる命令境界で GC が走りうる」状況を作りたい場面があります。この用途のために、GC チェックを毎命令で行うストレスモードを、通常のセーフポイント判定とは別のゲートとして用意しておくと有用です。ふだんはセーフポイントで疎に、検証時は毎命令で密に、と切り替えられます(GC ストレスによる不具合検出は第VI部で扱います)。

算術・比較命令の整数ファストパス

JavaScript の数値は言語仕様上すべて倍精度浮動小数点数ですが、実際にはループカウンタや配列インデックスのように小整数として使われる場面が圧倒的に多いという事実があります(値表現側の整数タグについては第I部「値の表現」を参照)。この偏りを、演算命令の実装で活かします。

加算・比較・インクリメントといった命令のハンドラでは、まず「両オペランドが小整数か」を判定し、そうであれば整数演算だけで結果を返すファストパスを置きます。整数でない場合や桁あふれの場合にのみ、浮動小数点・型変換 (coercion)・オブジェクトの valueOf 呼び出しといった一般経路 (slow path) へ進みます。

handler_ADD:
    b = pop(); a = pop()
    if is_small_int(a) and is_small_int(b):    # ファストパスを最初に判定
        r = a + b
        if not overflow(r):
            push(as_int(r)); goto next
    push(general_add(a, b))                     # 一般経路(浮動小数・coercion 等)
    goto next

肝心なのは判定の順序です。整数ファストパスは、一般経路の分岐より前に置いて初めて効果が出ます。値表現が整数タグを持っていても、ハンドラが先に浮動小数や型変換の分岐へ入ってしまえば、整数の速さは活かされません。多くのコードで両辺が小整数になるからこそ、その最頻ケースを最短経路で抜けさせることに意味があります。

インタプリタの実務的な下限

ここまでの工夫 — ディスパッチの見直し、セーフポイントによる付随処理の削減、整数ファストパス — は、いずれもインタプリタの定数倍を縮める最適化です。しかし忘れてはならない現実があります。バイトコードインタプリタには、命令一つあたりに必ずかかる費用(ディスパッチ・スタック操作・タグ判定)があり、これは原理的にゼロにはできません

ネイティブの機械語に変換する JIT は、この命令ごとの費用そのものを消し去り、レジスタ上で計算を完結させられます。そのため、ループ主体の数値計算のようなワークロードでは、どれだけインタプリタを磨いても JIT には及ばず、ある倍率だけ遅い水準にとどまります。これはインタプリタの欠陥ではなく、方式の性質です。過度な期待をせず、「JIT なしで到達できる現実的な下限」を見据えることが、最適化の労力配分を誤らないコツです(この下限の定量的な議論と公平な計測手法は第V部「パフォーマンス」で扱います)。

まとめ

  • バイトコード VM はスタックマシンとして設計され、その中核は命令を選び分けるディスパッチループである。
  • 単純 switch は明快だが分岐点が一箇所に集中し分岐予測が効きにくい。スレッデッド / computed-goto 方式は分岐点を分散させ、理論上は予測が効きやすい。
  • ただしスレッディングが実際に速いかは言語とコンパイラに依存する。単純 switch のラベル化・スレッデッド化が高速化しない、あるいは回帰することもあり、ディスパッチ最適化は必ず実測で裏取りする。
  • GC・割り込み・時間上限のチェックはセーフポイント(後方ジャンプと呼び出し / 復帰)に寄せ、タイトなループと中断可能性を両立させる。即時停止が要る保留中例外だけは遅延できず各命令の経路に残す。GC ストレス用の毎命令チェックは別ゲートで用意する。
  • 算術・比較命令では整数ファストパスを一般経路より前に判定して初めて効果が出る。
  • インタプリタには命令ごとの原理的費用があり、ループ主体のコードでは JIT に対してある倍率だけ遅い実務的な下限が残る。

参考文献