仮想マシンとバイトコード実行(スタック型VSレジスタ型)
VMの内部が見えると、なぜLuaやDalvikが速いのかが腑に落ちる。スタック型とレジスタ型の命令ディスパッチと計算機モデルの差を原理から解き明かす。
- 1.スタック型VM(JVM)はオペランドを暗黙のスタックで受け渡し、レジスタ型VM(Dalvik/Lua)は命令にレジスタ番号を直接埋め込みます。
- 2.同じ計算をレジスタ型は少ない命令数で表せるため、命令ディスパッチ(次の命令へ飛ぶ処理)の回数が減り分岐予測ミスを抑えられます。
- 3.一方でスタック型は命令が短く生成も検証も単純で、移植性とコンパイラ実装の容易さに優れます。優劣ではなくトレードオフです。
バイトコードという計算機モデル
バイトコードを実行する仮想マシン(VM)は、物理 CPU を模した抽象的な計算機です。命令セットとオペランドの受け渡し規約を自前で定義し、ホスト CPU 上のインタプリタループがそれを1命令ずつ解釈します。この受け渡し規約こそが、VM 設計の根幹を二分する論点です。
- スタック型 VM — オペランドを置く場所を持たず、暗黙のオペランドスタックを介して値を授受します。代表は JVM、CPython、WebAssembly。
- レジスタ型 VM — 命令そのものに仮想レジスタ番号を書き込み、値の置き場を明示します。代表は Android の Dalvik、Lua 5、Erlang の BEAM。
両者は表現力としては等価(チューリング完全)ですが、同じ計算を何個の命令で・どんな形で表すかが決定的に異なります。スタックの基礎はデータ構造、機械語への翻訳全般はコンパイルとインタプリタも参照してください。
同じ式を両モデルで表す
a + b を計算して変数 c に代入する処理を、両者のバイトコードで書き比べます。
スタック型(JVM風) レジスタ型(Dalvik風)
iload a # a をpush add r2, r0, r1
iload b # b をpush # r0=a, r1=b, 結果を r2 へ
iadd # 2つpop→和をpush
istore c # popして c へ
スタック型は4命令、レジスタ型は1命令です。スタック型では各オペランドを「いったんスタックに積む」ための load 命令が必ず先行し、演算結果も一度スタックに戻ります。対してレジスタ型は、ソース2つとデスティネーション1つを1命令にまとめて符号化できます。命令あたりの仕事量(密度)が高いのがレジスタ型の本質です。
| 観点 | スタック型VM | レジスタ型VM |
|---|---|---|
| オペランドの所在 | 暗黙のスタック頂上 | 命令に埋めたレジスタ番号 |
| 命令の長さ | 短い(オペランドほぼ無し) | 長い(番号フィールドを持つ) |
| 同一計算の命令数 | 多い(load/storeが増える) | 少ない |
| ディスパッチ回数 | 多い | 少ない |
| 代表例 | JVM / CPython / Wasm | Dalvik / Lua 5 / BEAM |
命令ディスパッチが性能を握る
インタプリタの実行時間の大半は、命令ディスパッチ——「現在の命令を実行し、次の命令の処理コードへ飛ぶ」操作——が占めます。素朴な実装は次のループです。
while (1) {
opcode = code[pc++];
switch (opcode) { /* この分岐が要 */
case OP_IADD: ...; break;
case OP_ILOAD: ...; break;
/* ... */
}
}
この switch は、CPU から見ると毎回行き先の変わる間接分岐です。分岐予測器は直前の履歴から飛び先を推測しますが、バイトコード列は命令種が不規則に並ぶため予測が当たりにくく、予測ミスのたびにパイプラインがストールします。ここで効いてくるのが命令数の差です。レジスタ型は同じ計算をより少ない命令で表すため、ループの周回数=ディスパッチ回数自体が減り、予測ミスの絶対数も減ります。Dalvik や Lua がスタック型より速いとされる主因はこれです。
レジスタ型は1命令が長く、オペランドのデコード(レジスタ番号の取り出し)も増えます。つまり「命令数は減るが1命令あたりのコストは上がる」トレードオフです。実測で速いのは、削減できたディスパッチ回数と分岐予測ミスの節約が、デコード増分を上回るケースが多いためであり、常に勝つわけではありません。
ディスパッチ手法そのものの最適化
ディスパッチ自体を速くする工夫は、スタック型・レジスタ型の別とは独立に効きます。代表がダイレクトスレッディングで、各命令の末尾に「次命令の処理コードへ直接ジャンプする」コードを置きます。GCC の computed goto(ラベルアドレスを値として扱う拡張)で実装され、CPython もこの手法を採用しています。
/* computed goto によるダイレクトスレッディング */
static void *table[] = { &&OP_IADD, &&OP_ILOAD, /* ... */ };
#define DISPATCH() goto *table[code[pc++]]
OP_IADD: /* ... */ DISPATCH();
OP_ILOAD: /* ... */ DISPATCH();
中央の switch を1か所通る方式と違い、分岐元が命令ごとに分散します。すると CPU の分岐予測器は「命令 X の次は命令 Y が来やすい」という命令列のクセを学習でき、予測精度が上がります。switch 方式に対して 2 割前後の高速化が報告されることもあります。さらに進めると、ホットな経路を実行時に機械語へ変換する JIT に至り、ディスパッチそのものを消し去ります。
HotSpot VM(JVM)は、起動直後はバイトコードをインタプリタで実行し、頻繁に回る箇所(ホットスポット)を検出してから機械語へコンパイルします。「最初は移植性と起動の軽さ、走り込んだら速度」という段階戦略です。背景はコンパイルとインタプリタで扱っています。
生成・検証・移植のしやすさ
性能ではスタック型が不利に見えますが、別の軸では明確な利点があります。
- コンパイラが書きやすい — 式は木構造であり、後行順(ポストオーダー)に辿るだけでスタック型バイトコードが素直に出力できます。レジスタ型はどの値をどのレジスタに割り当てるか(レジスタ割り当て)を決める必要があり、ここは計算量の観点でも厄介な最適化問題です。
- 検証が単純 — JVM は実行前にバイトコードのスタック整合性(各地点で型と深さが一致するか)を検証します。スタックという一本道の構造は、この抽象解釈による検証と相性が良いのです。
- 命令が短く密 — オペランドを持たないぶん、配布されるバイトコード列がコンパクトになります。
Dalvik や Lua の仮想レジスタは、実体としてはコールフレーム内の配列のスロットであり、ホスト CPU の物理レジスタとは別物です。命令に番号を埋めても、その値が CPU レジスタに乗る保証はありません。VM レベルの命令数削減と、JIT が行う物理レジスタ割り当ては、別の階層の話だと切り分けてください。
まとめ
スタック型 VM とレジスタ型 VM の違いは、オペランドを暗黙のスタックで運ぶか、命令に番号を埋めて明示するかという計算機モデルの差です。レジスタ型は同じ計算を少ない命令で表せるため、ディスパッチ回数と分岐予測ミスが減り、純インタプリタとしては速くなりやすい——これが Dalvik や Lua の設計判断の背景です。一方スタック型は、コンパイラ実装・バイトコード検証・移植性で優位に立ち、JVM や Wasm が選ぶ理由になっています。さらにダイレクトスレッディングや JIT を重ねれば、どちらのモデルでもディスパッチのコストは大きく削れます。「どちらが速いか」ではなく「何を単純にし、何を犠牲にするか」というトレードオフとして捉えるのが正確です。
プログラミング Article
仮想マシンとバイトコード実行(スタック型VSレジスタ型)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
仮想マシン
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 5
導入後に効く点
同じ計算をレジスタ型は少ない命令数で表せるため、命令ディスパッチ(次の命令へ飛ぶ処理)の回数が減り分岐予測ミスを抑えられます。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 5
判断チェックリスト
- 自社の用途が「仮想マシン / バイトコード」に近いか確認する。
- 強みである「スタック型VM(JVM)はオペランドを暗黙のスタックで受け渡し、レジスタ型VM(Dalvik/Lua)は命令にレジスタ番号を直接埋め込みます。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。