FABRKNT
Inside Revm — EVM エンジンを読む
Revmの心臓部
レッスン 6 / 17·CONTENT10 分25 XP
コース
Inside Revm — EVM エンジンを読む
レッスンの役割
CONTENT
順序
6 / 17

レッスン5 — 命令テーブルをステップで組み立てる

問い

EVM がバイトコード中に 0x01 を見たとき、どんな仕組みで add が呼ばれると決まるか? それがディスパッチ — 1 バイトを 1 関数呼び出しに変換、ホットパスのホットパス。素朴な match から本物の 256 スロット const テーブルまで、どう積み上げるか?

原理(最小モデル)

  • 本物の形. const fn instruction_table_impl<WIRE: InterpreterTypes, H: Host>() -> InstructionTable<WIRE, H> { let mut table = [Instruction::unknown(); 256]; table[ADD as usize] = Instruction::new(arithmetic::add); ... }
  • 5 ステップ積み上げ. 256 アーム match → 配列ルックアップ → const fnInstruction 構造体ラップ → 完成形。
  • 配列が 256 サイズの理由. Opcode は 1 バイト = 256 通り、固定サイズ配列が空間網羅、各バイトは Opcode を持つか unknown にマップ。
  • const fn の意味. コンパイル時に評価、構築済みテーブルがバイナリのデータセクションに焼き込まれる、起動コストゼロ、実行時の TABLE は手書き配列リテラルと同一。
  • Instruction 構造体ラップ. ① 将来のメタデータ拡張(gas_cost、name 等)、② 型規律(シグネチャ不一致は代入行でコンパイルエラー)。
  • Instruction::unknown() 初期化. 未定義 Opcode が UB や静かな見逃しではなくクリーン Unknown 停止を生む。

具体例

ステップ 0 — 素朴 match:

fn dispatch(byte: u8, ctx: &mut Context) -> Result {
    match byte {
        0x01 => add(ctx),
        0x02 => mul(ctx),
        // ... アーム 256 個
        _ => return Err(Unknown),
    }
}

問題: ① ジャンプテーブル変換が LLVM 任せ(保証なし)、② 巨大 match 編集 UX。

ステップ 1 — 関数ポインタ配列:

let mut table: [fn(&mut Context) -> Result; 256] = [unknown; 256];
table[0x01] = add;
table[0x02] = mul;
fn dispatch(byte: u8, ctx: &mut Context) -> Result {
    (table[byte as usize])(ctx)
}

インデックス参照 1 回、O(1) 保証、ハッシュなし。Opcode は 1 バイト = 256 通り、固定サイズ配列がバイト空間網羅。

ステップ 2 — テーブルを const にする:

const fn build_table() -> [fn(&mut Context) -> Result; 256] {
    let mut t = [unknown; 256];
    t[0x01] = add;
    t[0x02] = mul;
    // ...
    t
}
const TABLE: [fn(&mut Context) -> Result; 256] = build_table();

const fn = コンパイル時に評価可能。コンパイラが build_table() をコンパイル中に実行、結果配列を凍結してバイナリのデータセクションに焼き込む。実行時の TABLE は手書き配列リテラルと同一、ディスパッチ準備の実行時コストはゼロ。

ステップ 3 — 関数ポインタを構造体で包む:

#[derive(Debug)]
pub struct Instruction<W: InterpreterTypes, H: ?Sized> {
    fn_: fn(InstructionContext<'_, H, W>) -> InstructionExecResult,
}

impl<W: InterpreterTypes, H: Host + ?Sized> Instruction<W, H> {
    #[inline]
    pub const fn new(fn_: fn(InstructionContext<'_, H, W>) -> InstructionExecResult) -> Self {
        Self { fn_ }
    }
}

2 利点: ① 将来のメタデータ拡張(gas_cost / name 等)をディスパッチシグネチャを変えずに追加、② 型規律 = Instruction::new(arithmetic::add) は裸の関数ポインタ代入より型安全、シグネチャ不一致は 代入の行 でコンパイルエラー(バグを実行時から構築時へ前倒し)。ジェネリクス W: InterpreterTypes, H: ?Sized は 2 レッスン前に積み上げた ITH と同形。

ステップ 4 — 完成形:

#[derive(Debug)]
pub struct Instruction<W: InterpreterTypes, H: ?Sized> {
    fn_: fn(InstructionContext<'_, H, W>) -> InstructionExecResult,
}

impl<W: InterpreterTypes, H: Host + ?Sized> Instruction<W, H> {
    #[inline]
    pub const fn new(fn_: fn(InstructionContext<'_, H, W>) -> InstructionExecResult) -> Self {
        Self { fn_ }
    }
}

ステップ 5 — 完成形:

const fn instruction_table_impl<WIRE: InterpreterTypes, H: Host>() -> InstructionTable<WIRE, H> {
    use bytecode::opcode::*;
    let mut table = [Instruction::unknown(); 256];

    table[ADD as usize] = Instruction::new(arithmetic::add);
    table[MUL as usize] = Instruction::new(arithmetic::mul);
    table[SUB as usize] = Instruction::new(arithmetic::sub);
    // ... 残り全 Opcode
    table[LT as usize] = Instruction::new(bitwise::lt);
    // ...

    table
}

Opcode バイトマップ(参考):

バイトOpcode
0x01ADD
0x02MUL
0x60–0x7FPUSH1–PUSH32
0x80–0x8FDUP1–DUP16
0xA0–0xA4LOG0–LOG4
0x0C–0x0F未割当 ← カスタム Opcode のためのギャップ
0x21–0x2F未割当

失敗例(誤解)

match を LLVM が必ずジャンプテーブルに変換する」— 間違い。「普通」やってくれるがコンセンサス契約に「普通」は載せられない、配列インデックス参照が O(1) 保証

HashMap<u8, Instruction> で十分」— 間違い。ハッシュ計算オーバーヘッド + アロケーション + キャッシュミス、配列が最速。

「裸の fn ポインタで十分、構造体ラップは飾り」— 間違い。① 将来のメタデータ拡張、② 型規律 = シグネチャ不一致が代入行でコンパイルエラー、裸 fn だと実行時まで気付かない。

ステップで組み立てる

Step 1: 素朴 match の 2 問題

ジャンプテーブル保証なし + 巨大 match 編集 UX。

Step 2: 関数ポインタ配列

インデックス参照 1 回、O(1) 保証、256 サイズで空間網羅。

Step 3: const fn でコンパイル時構築

バイナリのデータセクションに焼き込み、起動コストゼロ。

Step 4: Instruction { fn_ } ラップ

将来メタデータ + 型規律(シグネチャ不一致を代入時に検出)。

Step 5: 完成形

const fn instruction_table_impl<WIRE, H>() + unknown() 初期化 + 定義済み Opcode 上書き。

答え合わせ

  • 配列を matchHashMap でなく使う理由: ① O(1) 保証(ハッシュなし、LLVM のジャンプテーブル変換の匙加減なし)、② 256 サイズでバイト空間網羅、各バイトは定義済み Opcode か unknown にマップ、形と最悪レイテンシ両方が契約の一部。
  • const fn の節約: 実行時にテーブル構築コードは 走らない = スロットを埋めるシーケンスがコンパイル時に解決、バイナリのデータセクションに焼き込まれた配列リテラルを読むだけ。ディスパッチ準備の起動コストゼロ。
  • unknown() 初期化の理由: 未定義バイトがどれも UB や静かな見逃しではなく クリーン Unknown 停止 を生む。unknown() は安全なデフォルト、定義済み Opcode が上書き、意図的な安全選択。

合格基準

  • 完成形の const fn instruction_table_impl<WIRE, H>() 4 構成要素を即書ける。
  • 配列が 256 サイズである理由(バイト空間網羅 + O(1) 保証)を即答できる。
  • const fn の実行時節約を 1 文で説明できる。
  • Instruction 構造体ラップの 2 利点(メタデータ + 型規律)を言える。
  • unknown() 初期化の安全選択理由を即答できる。

まとめ(3行)

  • 本物の命令テーブル = 256 スロット [Instruction; 256] 配列 + const fn でバイナリに焼き込み + unknown() 初期化 + 定義済み Opcode 上書き。
  • 5 ステップ積み上げ(match → 配列 → const fnInstruction ラップ → 完成形)、各ステップに O(1) 保証 / 起動コストゼロ / 型規律の明確な動機。
  • ジェネリクス <WIRE, H> は前 2 レッスンの ITH と同形 = 1 テーブルで全実行モード × 全ホスト型対応、次レッスンで自前 Opcode を配線。