Adding a Cranelift JIT that runs 10–23× faster than CPython

A deep dive into risp's opt-in Cranelift JIT in Rust: a decline-don't-guess type checker, lowering if/cond/let to Cranelift IR, inline signed-overflow detection, tail-calls as loop back-edges, and a deopt guard — beating CPython by 10× to 42×.

Source: github.com/kissishka/risp

After eleven optimization steps, risp’s bytecode VM beat CPython everywhere except call-bound recursionfib(30) stayed 1.55× behind. No amount of polishing a portable match-dispatched loop beats CPython’s specialised native call path at pure recursion. So the last step stops interpreting and starts emitting machine code with Cranelift. The result: fib 6.3 ms vs CPython’s 61 ms (~10×), tailsum 2.4 ms (13×), the arithmetic loop 4.1 ms (23×), and a cond/let/and/or-dense dispatch loop 5 ms vs 208 ms (~42×).

The hard part of a JIT isn’t speed. It’s staying correct. risp’s answer is to compile only what it can prove equivalent to the tree-walker, and decline everything else.

Decline, don’t guess: eligibility

Only a narrow subset is eligible: a top-level (def NAME (lambda …)), fixed arity ≤ 4, single-expression body, stably bound (never redefined or set!). The driver doesn’t even infer the return type. It guesses Int, then Bool, and accepts the first guess that type-checks as a consistent fixed point:

let ret = [Ty::Int, Ty::Bool]
    .into_iter()
    .find(|&guess| checker.check(&body, guess, &mut params.clone()) == Some(guess));
if let Some(ret) = ret {
    out.push(FnDef { name: name.clone(), params, body, ret, guarded });
}

The checker is a total function returning Option<Ty>: Some if the expression provably lives in the {Int, Bool} subset, None for anything else. And None propagates through ?, so a single unsupported leaf disqualifies the whole function:

fn check(&self, expr: &Value, self_ret: Ty, scope: &mut Vec<Rc<str>>) -> Option<Ty> {
    match expr {
        Value::Int(_) => Some(Ty::Int),
        Value::Bool(_) => Some(Ty::Bool),
        Value::Symbol(s) => scope.iter().any(|l| l == s).then_some(Ty::Int),
        Value::Pair(_) => {
            let (head, args) = as_call(expr)?;
            match &*head {
                "if" => {
                    if args.len() != 3 || self.check(&args[0], self_ret, scope)? != Ty::Bool {
                        return None;   // an Int condition is DECLINED (risp's 0 is truthy)
                    }
                    let t = self.check(&args[1], self_ret, scope)?;
                    let e = self.check(&args[2], self_ret, scope)?;
                    (t == e).then_some(t)   // both branches must share a type
                }
                "and" | "or" => self.check_and_or(&args, self_ret, scope),
                "cond"       => self.check_cond(&args, self_ret, scope),
                "let"        => self.check_let(&args, self_ret, scope),
                _ if self.is_primitive(head, scope) => { /* 2 Int args -> Int/Bool */ }
                _ if head == &**self.self_name => { /* self-call: Int args -> self_ret */ }
                _ => None,   // lists, closures, cross-function calls, ...
            }
        }
        _ => None,
    }
}

Look at what it refuses: an Int used as an if condition (risp truthiness makes 0 truthy, which a raw Cranelift branch would misread), a cond without a final else (the value wouldn’t always be typed), and/or over non-Bool operands, and anything involving lists, strings, closures, or cross-function calls. There’s exactly one way to say “yes” and a dozen ways to say “no.” If the JIT can’t prove equivalence, the function is handed back to the VM untouched. That’s what lets it be aggressive (raw i64s, mem::transmute at the boundary) without ever risking a wrong answer: the only risk it takes is failing to compile something it could have. And because the integer subset is closed under i64 (every leaf is an i64, / is excluded because it promotes to float), the JIT never has to box a value mid-execution. It’s registers from entry to return.

Lowering to Cranelift IR

Every variable becomes a Cranelift Variable (a mutable SSA slot), so parameters, let-bindings, and merge points all reuse the builder’s own use/def machinery and Cranelift inserts the phis automatically. Bodies compile in one of two modes: tail (every path ends in a terminator) or value (produce one i64). An if in value position shows the pattern cleanly — both branches define one result variable and jump to a merge block:

fn value_if(&mut self, args: &[Value]) -> Result<ClValue, RispError> {
    let cond = self.value(&args[0])?;
    let then_b = self.b.create_block();
    let else_b = self.b.create_block();
    let merge = self.b.create_block();
    let rv = self.b.declare_var(types::I64);
    brif(self.b, cond, then_b, else_b);

    self.b.switch_to_block(then_b);
    let tv = self.value(&args[1])?;
    self.b.def_var(rv, tv);
    jump(self.b, merge);

    self.b.switch_to_block(else_b);
    let ev = self.value(&args[2])?;
    self.b.def_var(rv, ev);
    jump(self.b, merge);

    self.b.switch_to_block(merge);
    Ok(self.b.use_var(rv))
}

Booleans have no separate IR type. They’re iconst 0/1, and comparisons uextend their result to i64, so the whole machine works on one register class. and/or lower to short-circuit branch chains where the skipped operand is never evaluated (matching the tree-walker exactly), and because the type checker forced all operands to Bool, branching on them directly is sound. This is why the cond/let/and/or-dense dispatch benchmark is the JIT’s widest margin (42×): those control forms become native branches instead of VM opcode dispatch.

Checked arithmetic in native code

Speed that changes a result is a bug. + - * emit inline signed-overflow detection so the JIT traps exactly where the VM’s checked_add would error. Addition uses the classic sign-bit identity: overflow iff the operands share a sign but the result differs:

"+" => {
    let r = self.b.ins().iadd(a, c);
    let t1 = self.b.ins().bxor(a, r);
    let t2 = self.b.ins().bxor(c, r);
    let t3 = self.b.ins().band(t1, t2);
    let ov = self.b.ins().icmp_imm(IntCC::SignedLessThan, t3, 0);
    (r, ov)
}
"*" => {
    // Full 128-bit product; overflow iff it doesn't fit back in i64.
    let a128 = self.b.ins().sextend(types::I128, a);
    let b128 = self.b.ins().sextend(types::I128, c);
    let p = self.b.ins().imul(a128, b128);
    let lo = self.b.ins().ireduce(types::I64, p);
    let lo128 = self.b.ins().sextend(types::I128, lo);
    let ov = self.b.ins().icmp(IntCC::NotEqual, lo128, p);
    (lo, ov)
}
// then: brif(self.b, overflow, self.trap_block, cont);

On overflow, control branches to a trap block that records the failure into a context struct and returns. Back in Rust, the call boundary turns that into the same typed error the builtin raises:

if ctx.trapped != 0 {
    return match ctx.errkind {
        ERR_DEOPT => Ok(None),   // re-run on the VM (see below)
        _ => Err(RispError::Custom("integer overflow".into())),
    };
}

So (* 9223372036854775807 2) errors identically whether it ran on the VM or the JIT. It never wraps.

Tail calls are loop back-edges; non-tail calls are native calls

A function tail-calling itself must run in constant native stack to match the VM’s TCO — so it compiles not to a call but to a jump back to the loop header with the parameter variables rebound:

// Self tail-call -> rebind params and loop (constant native stack).
_ if &*head == self.self_name && args.len() == self.arity && self.lookup(&head).is_none() => {
    let mut newvals = Vec::with_capacity(args.len());
    for a in &args { newvals.push(self.value(a)?); }   // evaluate ALL args first
    for (i, nv) in newvals.into_iter().enumerate() {
        self.b.def_var(self.param_vars[i], nv);
    }
    jump(self.b, self.header);
    return Ok(());
}

Evaluating every argument before rebinding any parameter is what makes (f b a) correct. The result is a genuine machine-code loop: no call, no stack growth. A non-tail self-call (like fib’s two recursive calls) instead compiles to a real native call and then checks whether the callee trapped, bailing if so. That distinction is what the next section hinges on.

Deopting instead of crashing

Non-tail recursion uses the native C stack, which is bounded (~8 MiB). A linearly deep non-tail recursion — (sum n) = (+ n (sum (- n 1))), a million deep — would overflow it and SIGABRT, whereas the tree-walker recurses on the heap and returns a value. To stay behaviorally identical without crashing, every function with a non-tail self-call carries a runtime depth guard that deopts past a ceiling:

let depth_entry = if def.guarded {
    let d = b.ins().load(types::I64, MemFlags::trusted(), ctx_val, 16);
    let d1 = b.ins().iadd(d, b.ins().iconst(types::I64, 1));
    b.ins().store(MemFlags::trusted(), d1, ctx_val, 16);
    let over = b.ins().icmp_imm(IntCC::SignedGreaterThan, d1, DEPTH_LIMIT);
    let deopt_block = b.create_block();
    brif(&mut b, over, deopt_block, header);
    // deopt_block: set ctx.errkind = ERR_DEOPT, return sentinel 0.
    Some(d)
} else {
    jump(&mut b, header);   // no guard, no per-call overhead at all
    None
};

Past DEPTH_LIMIT (10,000) it records ERR_DEOPT, which is not an error, and returns, signaling the VM to re-run the call on its heap-stack bytecode path. The guard counts live depth, not total calls: every normal return restores the saved pre-bump depth, so fib’s two sibling subtrees don’t add up. That’s why fib never trips it (fib(30) is only ~30 frames deep, vastly below 10,000) and runs fully native for its 10× win, while the deopt path is pure insurance for pathological linear recursion. Which function needs a guard is decided statically by walking the body for a non-tail self-call, so a pure tail-recursive loop pays nothing.

The call boundary

The native function’s ABI is extern "C" fn(*mut JitCtx, i64…) -> i64, where JitCtx is #[repr(C)] so the compiled code can hard-code its field offsets:

#[repr(C)]
struct JitCtx {
    trapped: i64,   // @ 0
    errkind: i64,   // @ 8
    depth: i64,     // @ 16
}

When the VM reaches a call whose callee has native code and whose arguments are all Int, it transmutes the finalized code pointer to the right arity and jumps in:

let r: i64 = unsafe {
    match entry.arity {
        1 => mem::transmute::<*const u8, extern "C" fn(*mut JitCtx, i64) -> i64>(p)(cx, int(0)),
        2 => mem::transmute::<*const u8, extern "C" fn(*mut JitCtx, i64, i64) -> i64>(p)(cx, int(0), int(1)),
        // ... 0, 3, 4
        _ => unreachable!("jit arity is capped at 4 during eligibility"),
    }
};

A fib call then calls fib with a native call instruction, register-allocated, never re-entering the interpreter. Three interpreter costs vanish at once: no opcode dispatch, no frame plumbing (args ride in registers), and no boxing. And the fallback is what makes this safe to ship: a function that was never eligible has no native entry, so its call sites run the ordinary bytecode path, and even an eligible function falls back per-call the moment it sees a non-Int argument at runtime. The JIT is a pure accelerator layered over the unchanged step-1–11 engine.

Why it’s opt-in

Cranelift is a real dependency, so the whole JIT lives behind #[cfg(feature = "jit")]. A default cargo build still resolves zero dependencies and ships the std-only interpreter unchanged; --features jit is the “I’ll take the dependency for native speed” switch. The one shape a portable interpreter couldn’t win falls to native code by an order of magnitude (fib from 1.55× behind to ~10× ahead) without the default build taking on a single line of external code. And every bit of it is held byte-identical to the tree-walker by the same differential tests that guard the VM: overflow parity, deopt-instead-of-crash, and decline-correctly are all asserted against the reference engine.

Frequently asked questions

How much faster than CPython is a Cranelift JIT for a Lisp?
risp's opt-in Cranelift JIT runs fib about 10x faster than CPython 3.14 (6.3 ms vs 61 ms), a tail-sum loop 13x, an arithmetic loop 23x, and a control-flow-dense dispatch loop about 42x (5 ms vs 208 ms).
How does a JIT stay correct?
risp's JIT compiles only the narrow subset it can prove equivalent to the tree-walker (fixed-arity integer and boolean functions) and declines everything else, handing it back to the VM. A total type checker returns None for any unsupported leaf, so a single unhandled case disqualifies the whole function.
What is deoptimization (deopt) in a JIT?
Deopt means bailing out of native code back to the interpreter instead of crashing. risp's JIT runs non-tail recursion on the bounded C stack; past a depth limit it records a deopt signal and re-runs the call on the heap-stack VM, matching the tree-walker's result without a stack overflow.

← All posts