跳轉到

4.3 Cranelift 與自訂位元碼的 JIT 後端#

迷你 JIT 的天花板#

第 4.2 節的 JIT 能跑,但每加一個特性就要查 Intel 手冊、手寫編碼、自己處理:

  1. 多平台:x86-64 與 AArch64 指令集完全不同
  2. 暫存器分配:手寫 push/pop 溢出在複雜函式會爆炸
  3. 呼叫約定:參數暫存器、堆疊對齊、caller/callee-saved 暫存器保存
  4. 正確性驗證:誰保證生成的機器碼沒有 UB?

這四件事正是 Cranelift 存在的理由。Cranelift 是 Rust 寫的編譯器後端函式庫(起源於 SpiderMonkey 的 WASM 支援實驗,現為 Wasmtime 專案一部分),把「機器碼生成」變成呼叫 API。

架構:把後端當函式庫#

分工原則:你的前端只負責「把語意變成 IR」,所有機器相依的髒活給 Cranelift。這正是第 2.1 節三段式架構的實踐——IR 是契約。

CLIF:Cranelift 的中間表示#

CLIF(Cranelift IR)的特點:

  • SSA 形式(static single assignment):每個變數只賦值一次,讓最佳化(常數傳播、死碼刪除)變得平凡
  • 顯式型別:i32, i64, f64, 指標型別
  • 顯式記憶體與控制流:load/store、block/branch,沒有隱藏行為

用 Cranelift 給 MinLang 做 JIT#

# Cargo.toml
[dependencies]
cranelift = "0.110"
cranelift-jit = "0.110"
cranelift-module = "0.110"

把 MinLang 的 Expr::BinOp 翻到 CLIF(以 (a + b) * c 為例):

// src/clif.rs
use cranelift::prelude::*;
use cranelift_jit::{JITBuilder, JITModule};
use cranelift_module::{Linkage, Module};

pub struct MinLangJit {
    module: JITModule,
    builder_context: FunctionBuilderContext,
    ctx: Context,
}

impl MinLangJit {
    pub fn new() -> Self {
        let flag_builder = settings::builder();
        flag_builder.set("use_colocated_libcalls", "false").unwrap();
        flag_builder.set("is_pic", "false").unwrap();
        let isa_builder = cranelift_native::builder().unwrap();
        let builder = JITBuilder::with_isa(isa_builder.finish(target_lexicon::Triple::host()), cranelift_module::default_libcall_names());
        MinLangJit {
            module: JITModule::new(builder),
            builder_context: FunctionBuilderContext::new(),
            ctx: Context::new(),
        }
    }

    /// 編譯 fn(a: i64, b: i64) -> i64 { (a + b) * c } 形式的運算式
    pub fn compile_add_mul(&mut self) -> *const u8 {
        let int = types::I64;
        self.ctx.func.signature.params.push(AbiParam::new(int));
        self.ctx.func.signature.params.push(AbiParam::new(int));
        self.ctx.func.signature.returns.push(AbiParam::new(int));

        let func_id = self.module
            .declare_function("expr", Linkage::Export, &self.ctx.func.signature)
            .unwrap();

        {
            let mut builder = FunctionBuilder::new(&mut self.ctx.func, &mut self.builder_context);
            let entry = builder.create_block();
            builder.append_block_params_for_function_params(entry);
            builder.switch_to_block(entry);
            builder.seal_block(entry);

            let (a, b) = {
                let params = builder.func.dfg.block_params(entry);
                (params[0], params[1])
            };

            // ---- 這裡就是「AST/位元碼 → CLIF」的翻譯核心 ----
            // add = a + b
            let sum = builder.ins().iadd(a, b);
            // c = 10(MinLang 的常數,經常數池而來)
            let c = builder.ins().iconst(int, 10);
            // result = add * c
            let result = builder.ins().imul(sum, c);
            // ------------------------------------------------

            builder.ins().return_(&[result]);
            builder.finalize();
        }

        self.module.define_function(func_id, &mut self.ctx).unwrap();
        self.module.clear_context(&mut self.ctx);
        self.module.finalize_definitions().unwrap();

        let code_ptr = self.module.get_finalized_function(func_id);
        code_ptr
    }
}

呼叫它:

fn main() {
    let mut jit = MinLangJit::new();
    let f = jit.compile_add_mul();
    type Fn2 = unsafe extern "C" fn(i64, i64) -> i64;
    let f: Fn2 = unsafe { std::mem::transmute(f) };
    println!("{}", unsafe { f(3, 4) });  // (3+4)*10 = 70
}

翻譯器的通用模式:雙向樹遍歷#

真正的 MinLang JIT 前端是對 AST 做一次後序遍歷,每個節點產生 CLIF 值。與第 2.3 節位元碼編譯器對照:

MinLang 節點 位元碼(2.3 節) CLIF
Num(n) Push n iconst
a + b gen(a), gen(b), Add iadd va, vb
a < b gen(a), gen(b), Lt icmp + bint
if c {t} else {e} JumpIfFalse/回填 brif + blocks
let x = v; body Store slot block 參數或棧槽(stack_slot)
呼叫 Call nargs call + 簽名

注意 if 的翻譯從「回填跳轉」變成「建立 blocks」——CLIF 是顯式控制流圖(CFG),分支目標是具名 block 而非數字位址,省掉了回填,也讓資料流分析(最佳化的基礎)成為可能。

暫存器分配與呼叫約定:看不見的功臣#

上面的例子完全沒碰暫存器——Cranelift 的 regalloc2 自動完成:

  • SSA 的虛擬值 → 實體暫存器(含溢出到棧槽)
  • System V AMD64 / Win64 / AArch64 呼叫約定(依 host 三元組自動選擇)
  • callee-saved 暫存器的保存與恢復(prologue/epilogue)

這些是第 4.2 節手寫 JIT 無法擴展的根本原因,也是 LLVM 時代(2000-)「後端函式庫化」趨勢的核心內容。Cranelift 與 LLVM 的差別在設計目標:

LLVM Cranelift
定位 全功能 AOT/JIT 後端 快速編譯的安全 JIT 後端
編譯速度 慢(深度最佳化) 快(WASM 的延遲要求)
正確性驗證 依賴測試 IR 設計可形式化(VeriJIT 研究方向)
語言 C++ Rust

熱點偵測:把 VM 與 JIT 縫合#

完整的 JIT 語言執行器是「VM + JIT」的混合體(4.1 節 V8 管線的縮影):

pub struct Engine {
    vm: Vm,
    jit: MinLangJit,
    pub hot: HashMap<u16, u32>,   // proto id → 呼叫計數
}

impl Engine {
    pub fn call(&mut self, proto_id: u16, args: Vec<Value>) -> Result<Value, String> {
        // 計數超過門檻 → 編譯成機器碼,之後直接走 JIT
        let count = self.hot.entry(proto_id).or_insert(0);
        *count += 1;
        if *count == 100 {   // 熱點門檻
            self.compile_and_cache(proto_id)?;
        }
        if self.jitted.contains_key(&proto_id) {
            return self.call_native(proto_id, args);   // 原生路徑
        }
        self.vm.call(proto_id, args)                    // 直譯路徑
    }
}

門檻值(V8 用 1000 量級)是在「編譯成本」與「執行收益」間的權衡(4.1 節公式)。真實系統還需要 deopt:型別假設破滅時從機器碼退回直譯器,這需要在機器碼中插入 guard 與退路映射表——這是 JIT 工程最難的部分,本書点到為止,讀者可追 Wasmtime 或 V8 源碼。

習題#

  1. 把 MinLang 的 if 翻到 CLIF:建立三個 block(then/else/merge),用 brif 連接。
  2. 把熱點門檻從 100 改成 10 與 1000,測量 loop(10000) 總時間,解釋為什麼太低與太高都差。
  3. 閱讀 Cranelift 文件中的 stack_slot,實作 let 的棧槽版翻譯,與 block 參數版比較生成碼(用 clif 輸出)。