Skip to content

バイトコードとコンパイラ

構文解析が生み出した抽象構文木(AST)は、木構造のままでも解釈実行できます。しかしループのように同じ箇所を何度も実行するコードでは、そのつど木をたどり直す費用が無視できません。そこで多くのエンジンは、AST を仮想マシン向けの平坦な命令列(バイトコード)へと一度コンパイルし、以降はその命令列を走らせます。この章では、命令(オペコード)をどう設計するか、リテラルや変数をどう格納・参照するか、ジャンプ先が未定の分岐をどう解決するか、そして固定幅のフィールドに値を詰める際に潜む罠を扱います。生成された命令列を実際に解釈実行する仕組みは次章「インタプリタ実行」で扱い、本章は「命令列をどう作るか」に絞ります。

なぜ AST をバイトコードにするのか

AST を直接たどるツリーウォーク型は実装が単純ですが、実行のたびに次の費用を払い続けます。

  • 構造判定の反復: ノードごとに「これは加算か、関数呼び出しか」を判定する分岐が、実行のたびに繰り返される。
  • 木の再訪: ループ本体は繰り返し評価されるが、木の形が不変であるにもかかわらず毎回たどり直す。
  • ポインタ追跡: 子ノードは別々に確保されるため、木をたどると散らばったメモリを次々に参照することになり、キャッシュ効率が悪い。

バイトコードへのコンパイルは、これらの費用を「実行前に一度だけ」払う方式です。構造判定はコンパイル時に済ませ、結果を線形な命令列として書き出します。実行時には命令を先頭から順に読むだけで済み、木の形の解析は不要になります。命令列は連続したメモリに置けるためキャッシュ効率も良く、さらに命令列という中間表現があることで、後述の定数畳み込みのような最適化を挟む余地も生まれます。

コンパイル対象の意味は言語仕様が定めています。たとえば変数のスコープ規則(どの名前がどの環境に属するか)は ECMA-262 の Lexical Environment が規定しており、コンパイラはこの規則に従って名前をスロットやアップバリューへ割り当てます。

オペコード設計

バイトコードの「命令」に相当するのがオペコード(opcode)です。1 つのオペコードは「何をするか」を表す番号と、それに付随するオペランド(操作対象)からなります。設計の要点は、命令の粒度・オペランドの符号化・よく使う場合の専用命令の三つです。

粒度 — 細かい命令か、粗い命令か

1 命令にどれだけの仕事をさせるかを粒度と呼びます。

  • 細かい(fine-grained)命令: 「スタックに定数を積む」「加算する」「ローカルに書き戻す」のように、一つ一つを最小単位にする。命令の種類が少なく実装が単純で、組み合わせで多様な処理を表せる。反面、一つの式でも多数の命令に展開され、ディスパッチ(命令の取り出しと分岐)の回数が増える。
  • 粗い(coarse-grained)命令: 頻出パターンをまとめた命令を用意する。たとえば「ローカル変数に定数を加算して書き戻す」を 1 命令にする(このように複数の操作を 1 命令に融合することをスーパーインストラクションと呼ぶ)。ディスパッチ回数は減るが、命令の種類が増えて実装と保守の負担が増し、めったに出ないパターン用の命令は割に合わない。

実務では、大半を細かい命令で構成しつつ、計測で効くと分かった頻出パターンにだけ粗い命令を足す、という折衷が一般的です。

オペランドの符号化 — 即値か、定数プール索引か

オペランドの持ち方には二つの流儀があります。

  • 即値オペランド(immediate): 命令列の中に値そのものを埋め込む。たとえば「小さい整数を積む」命令のオペランドに 5 をそのまま書く。読み出しが速く追加の間接参照が要らないが、命令列に埋め込める大きさに限りがあり、大きな値や参照型は埋め込めない。
  • 定数プール索引(constant-pool index): 値は別表(定数プール)に一度だけ格納し、命令には「その表の何番目か」という索引だけを書く。任意の大きさ・種類の値を扱え、同じ値の重複を排除できる。ただし実行時に索引から表を引く間接参照が一段挟まる。

小整数や短いジャンプ距離のような小さく頻出する値は即値に、文字列・大きな数値・関数定義のような大きな値は定数プール索引に、と使い分けるのが定石です。

観点 即値オペランド 定数プール索引
読み出し 直接(速い) 表を引く間接参照が一段
扱える値の範囲 フィールド幅ぶんの小さな値 任意の大きさ・種類
重複の排除 できない 同じ値を共有できる
命令列の大きさ 値ぶん膨らむ 索引ぶん(小さい)
主な用途 小整数・短いジャンプ距離 文字列・大数値・関数定義

よく使う場合の専用命令(ファストパス)

出現頻度の高い操作には、汎用命令とは別に専用命令を設けると速くなります。典型例は次の二つです。

  • 小整数の push: 0 1 のような特定の定数を積む専用命令を用意すれば、オペランドすら不要になり、命令 1 個で完結する。
  • 低位のローカルスロット: 先頭数個のローカル変数専用の読み書き命令を設ける。スロット番号を即値でなく命令自体に埋め込めるため、オペランドの読み出しが省ける。

専用命令を増やすほど個々の頻出操作は速くなりますが、命令表が肥大化し、命令のディスパッチ機構(次章)も大きくなります。専用命令は「計測で頻出と裏づけられたものだけ」に絞るのが健全です。

観点 大きな専用命令セット 小さな汎用命令セット
頻出操作の速度 速い(専用経路) 汎用命令の組み合わせで遅め
命令の種類数 多い 少ない
実装・保守コスト 高い 低い
命令列の大きさ 小さくなりやすい 大きくなりやすい
適する場面 計測で頻出が判明した操作 まず動かす・保守を優先

定数プール

前述の定数プールは、関数ごと(または命令列ごと)に持つ値の表です。ソース中のリテラル(文字列・大きな数値リテラルなど)や、プロパティアクセスに使う名前(obj.foo"foo")は、この表に一度だけ格納し、命令からは索引で参照します。

同じリテラルや名前が本体に何度現れても、プールには一つだけ入れて索引を共有すれば、メモリを節約できコンパイル結果も小さくなります。コンパイラは「この値はすでにプールにあるか」を調べ、あれば既存の索引を返し、なければ追加して新しい索引を返す、という登録処理を通してプールを構築します。

定数プールの索引にも幅の上限があります(後述の罠を参照)。

ローカル変数スロットとスコープ

多くのバイトコード VM はスタックマシンとして設計されます。演算はオペランドスタックの上で行い、たとえば加算は「スタックから 2 値を降ろし、和を積む」という形になります。

このモデルでは、ローカル変数と一時値にコンパイル時にスロット番号を割り当てます。関数に入るとその関数用のフレームが確保され、各ローカルはフレーム内の固定位置(スロット)に対応します。名前解決はコンパイル時に済むため、実行時には「名前 x を探す」のではなく「スロット 3 を読む」だけで済みます。これが名前を毎回ハッシュ表で引く方式より速い理由です。

スロット割り当てはスコープ構造に従います。ブロックに入るとそのブロックのローカルにスロットを与え、ブロックを抜けるとそのスロットは後続の別のローカルに再利用できます。コンパイラはスコープの入れ子を追いながら、名前をスロット番号へ写像していきます。この写像の規則そのものは、前述のとおり言語仕様の Lexical Environment が定めるスコープ規則に対応します。

クロージャとアップバリュー

関数が、自分を包む外側の関数の変数を参照するとき、その捕捉した変数をアップバリュー(upvalue)と呼びます。クロージャは「関数本体 + 捕捉したアップバリューの集合」として表現されます。

function outer() {
  let count = 0;
  return function inner() { count += 1; return count; };  // count を捕捉
}

ここで innerouter のローカル count を参照します。問題は、outer が返った後も innercount を使い続けることです。countouter のフレーム(関数が返れば消える)に置いたままでは、返却後に無効なメモリを指してしまいます。この寿命の食い違いを扱うために、アップバリューには二つの状態を設けるのが定石です。

  • 開いた(open)アップバリュー: 捕捉元の変数がまだ生きている(外側の関数が実行中)間は、アップバリューはそのスタックスロットを直接指す。外側と内側が同じ変数を共有でき、一方の書き込みが他方に見える。
  • 閉じた(closed)アップバリュー: 捕捉元のフレームが終了する(スコープを抜ける)直前に、変数の値をスタックの外(ヒープ上のセル)へ退避し、以後アップバリューはそのセルを指すようにする。これを「アップバリューを閉じる」と呼ぶ。

この開閉の仕組みにより、外側が生きている間は共有の効率を保ちつつ、外側が消えても捕捉した変数を安全に生き残らせられます。

flowchart LR
    subgraph open["開いた状態(outer 実行中)"]
        I1["inner のアップバリュー"] --> S1["outer フレームの count スロット"]
    end
    subgraph closed["閉じた状態(outer 返却後)"]
        I2["inner のアップバリュー"] --> H1["ヒープ上のセル(退避した count)"]
    end
    open -. "outer が返る時に閉じる" .-> closed

捕捉した変数がスタックにある間は開いた状態で直接指し、外側の関数が返る直前にヒープのセルへ退避して閉じた状態へ移る。

コンパイラは、内側の関数が「外側のどのローカルを」「あるいはさらに外側から受け継いだどのアップバリューを」捕捉するかを解析し、クロージャ生成命令にその捕捉情報を持たせます。

ジャンプのパッチ

制御構造(if・ループ・break など)は、命令列上ではジャンプ命令(実行位置を別の命令へ移す命令)に落ちます。ジャンプには向きによって二つの扱いがあります。

  • 後方ジャンプ(backward jump): ループの先頭へ戻るような、すでに生成済みの命令へのジャンプ。飛び先の位置はもう分かっているので、そのまま距離を書き込める。
  • 前方ジャンプ(forward jump): if の条件が偽なら本体を飛び越す、といった、まだ生成していない命令へのジャンプ。命令を出す時点では飛び先が未確定なので、距離を書けない。

前方ジャンプは、まず距離を空欄(仮の値)にして命令を出しておき、飛び先が確定した時点でその空欄を正しい距離で埋める、という二段構えで解決します。この後埋めをジャンプのパッチ(back-patching)と呼びます。実装上は、空欄にした命令の位置を控えておき、飛び先が定まったら「飛び先 − 命令位置」を計算して書き戻します。

// if (cond) { ... } のおおまかな生成手順
<cond を評価する命令列>
emit JUMP_IF_FALSE, ????      // 飛び先未定。位置を控えておく
<本体の命令列>
patch(控えた位置, 現在位置)    // 本体の直後を飛び先として空欄を埋める

ここで重要なのが、ジャンプの距離を格納するフィールドの幅が固定(たとえば 16 ビット)だということです。関数本体が極端に大きいと、前方ジャンプの距離がこの幅で表せる最大値を超えることがあります。その場合、正しい距離を書けないのに黙って下位ビットだけを書いてしまうと、まったく別の命令へ飛ぶ壊れたコードになります。したがって、距離が幅を超えたらコンパイルエラーとして拒否しなければなりません(次節)。

実装上の罠: 固定幅フィールドへの詰め込み

オペコードのオペランドは、多くの場合サイズの決まったフィールドに格納されます。たとえば「引数の個数」は 1 バイト、「配列リテラルの要素数」は 2 バイト、といった具合です。ここには、値がフィールド幅を超えたときに黙って壊れるという共通の罠があります。

  • 切り詰め(truncation): フィールドより大きい値を書くと、上位ビットが捨てられ、下位ビットだけが残る。たとえば 1 バイト(最大 255)のフィールドに 300 を書くと 44 になる。命令自体は生成できてしまうため、コンパイルは通り、実行時に「引数を 44 個しか渡していない」かのように振る舞う。原因の分かりにくい誤動作になる。
  • 変換時のクラッシュ: 実装言語によっては、大きい整数を小さい整数型へ狭める変換で、オーバーフロー検査に引っかかって異常終了する。切り詰めよりは早く気づけるが、利用者から見れば入力に対してエンジンが落ちる不具合であることに変わりはない。

この罠は特定のフィールドに限りません。次のいずれもが同じ構造を持ちます。

  • 引数の個数・要素数・プロパティ数: 呼び出しの実引数、配列・オブジェクトリテラルの要素数など、「個数」を数えるオペランド。
  • 定数プール索引: プールが索引の幅を超える数のエントリを持つと、索引が表せなくなる。
  • ローカルスロット番号: 関数内のローカル・一時値が多すぎて、スロット番号がフィールド幅を超える場合。
  • ジャンプ距離: 前節のとおり、関数本体が大きすぎて距離がフィールド幅を超える場合。

対策はいずれも共通で、値をフィールドへ書く前に上限を検査し、超えていたら明確なコンパイルエラーを出すことです。「引数が多すぎます」「関数が大きすぎます」「定数が多すぎます」のように、何の上限を超えたかが分かるメッセージにします。要点は次のとおりです。

  • 検査は書き込みの直前・コンパイル時に行う。実行時に露見する切り詰めより、コンパイル時の明確な拒否のほうがはるかに扱いやすい。
  • 検査は狭める変換より手前に置く。変換で落ちてから気づくのでは遅い。
  • 上限は「フィールド幅で表せる最大値」であり、フィールド設計と一対で決まる。フィールド幅を変えたら上限も追随させる。

いずれのフィールドも、幅を広げれば上限は上がりますが、命令列は大きくなります。上限を上げること自体が目的ではなく、「表せない値は決して黙って書かない」という不変条件を守ることが目的です。

コンパイル時の簡単な最適化: 定数畳み込み

中間表現があると、実行前に済ませられる計算をコンパイル時に処理できます。代表例が定数畳み込み(constant folding)です。オペランドがすべてコンパイル時に定まっている式は、その場で計算して結果の定数に置き換えます。

// ソース                コンパイル後に相当する命令
let a = 60 * 60 * 24;  →  PUSH_CONST 86400   // 実行時に掛け算しない

畳み込みは実行時の演算命令を減らし、命令列も短くします。ただし言語の意味を変えてはいけません。とくに JavaScript では、浮動小数点の丸め・負ゼロ・NaN・数値と文字列にまたがる + の型変換など、素朴に畳み込むと結果が変わる場合があります。畳み込みは、実行時とまったく同じ規則で計算できると保証できる範囲に限る必要があります(数値まわりの落とし穴は第V部「数値と精度」で扱います)。

まとめ

  • AST をバイトコードにコンパイルするのは、構造判定と木の再訪という実行時の費用を、実行前の一度きりに移すためである。
  • オペコード設計は、命令の粒度・オペランドの符号化(即値か定数プール索引か)・頻出操作の専用命令という三つのトレードオフからなり、専用命令は計測に裏づけて絞る。
  • リテラルや名前は定数プールに一度だけ格納して索引で共有し、ローカル・一時値はコンパイル時にスタックスロットへ割り当てる。
  • クロージャは捕捉変数(アップバリュー)を持ち、外側が生きている間は開いた状態でスタックを共有し、外側の終了時に閉じてヒープへ退避することで寿命の食い違いを解消する。
  • 前方ジャンプは空欄で出しておき飛び先確定時にパッチする。後方ジャンプは距離が既知なので直接書ける。
  • オペランドは固定幅のフィールドに入るため、個数・索引・スロット番号・ジャンプ距離のいずれも、幅を超える値は黙って切り詰められるか変換で落ちる。書き込み直前・コンパイル時に上限を検査し、明確な「多すぎる」エラーを出すのが唯一の安全策である。

参考文献