# Wasmi 2.0 interpreter engineering

Wasmi is a WebAssembly interpreter written in Rust, used in plugin hosts (Typst, Zellij, Josh), smart-contract platforms (Soroban, Ripple) and small devices. Robin Freyler's release post for Wasmi 2.0, the result of eight months of work sponsored by the Stellar Development Foundation, reports a ~2.2x speedup over Wasmi 1.0 in geometric mean across the `wasmi-benchmarks` suite on an Apple M2 Pro, then explains where it came from [[wasmi-2-engineering]].

Every comparison in the post is the author benchmarking his own interpreter, on his own suite. The suite is public and meant to be reproducible, and the post says Wasmi 2.0 borrowed ideas from each rival it measures: Wasm3, WAMR's fast interpreter, Wasmtime's Pulley, and Makepad's Stitch. The rival results appear only as charts on three machines (Apple M2 Pro, AMD EPYC 7763, Intel Xeon Platinum 8370C); the text's own verdict is that Wasmi 2.0 "clearly belongs to the category of the fastest portable Wasm interpreters", with startup time roughly unchanged from 1.0. Treat that as a vendor claim with a public benchmark attached.

The design choices are more durable than the ranking. Wasm3 and Stitch share much of the same architecture, and the post is careful about where Wasmi deliberately differs from them.

## Dispatch modes

Wasmi translates Wasm bytecode into its own IR before running it, and each IR instruction has a handler function. 2.0 supports four ways of getting from one handler to the next, all sharing the same execution logic:

| Mode | How it dispatches | Crate features |
|---|---|---|
| Direct-threaded code | Handler function pointers are embedded in the IR; each handler tail-calls the next. Used by Wasm3 and Stitch. | none |
| Indirect-threaded code | Opcodes in the IR, a jump table maps each to its handler. Roughly 10-15% slower, much smaller IR. | `indirect-dispatch` |
| Switch-loop | A loop around a `match`, as in Wasmi 1.0. Needed where tail calls are unavailable; slow on Apple Silicon in particular. | `portable-dispatch` + `indirect-dispatch` |
| Call-loop | A loop that calls each handler without tail calls. Slow and memory-hungry, not recommended; exists only because the two features are independent. | `portable-dispatch` |

The `auto-dispatch` feature picks a threaded mode where the platform allows it. The general technique is [threaded code](https://en.wikipedia.org/wiki/Threaded_code).

## One handler signature, and the argument-register budget

All handlers take the same nine arguments: a type-erased store, the instruction pointer, the value-stack pointer, a pointer and length for the default linear memory `(memory 0)`, the current instance, and three accumulator registers (`ireg` for integers and references, `freg32`, `freg64`). They return a `Done` bit pattern that says why execution stopped.

```rust
fn(
    store: &mut PrunedStore,
    ip: Ip,
    sp: Sp,
    mem0: Mem0Ptr,
    mem0_len: Mem0Len,
    instance: Inst,
    ireg: Ireg,
    freg32: Freg32,
    freg64: Freg64,
) -> Done;
```

Seven of those want general-purpose registers, but `sysv64` passes only six integer arguments in registers. A seventh would spill to the stack on every dispatch. Stitch and Wasm3 avoid the problem by using six and four GPRs. Wasmi passes the `instance` pointer in a floating-point register instead, picking it because instance access only happens on already expensive operations, and the post reports that benchmarks show the move costs little. Rust's unstable `preserve_none` ABI might remove the constraint later.

## Accumulator registers

Wasmi 1.0 addressed every operand and result as a stack slot. An `i64.add` handler decoded a result slot, a left-operand slot and an immediate, loaded from the stack, added, and stored back. In 2.0 the result and often an operand live implicitly in an accumulator register, which the calling convention keeps in a real hardware register:

```rust
fn i64_add(ip: Ip, sp: Sp, ireg: i64, ..) -> Done {
    let rhs: i64 = decode_i64(ip);
    ireg = ireg + rhs;
    ip.offset(encode_size::<i64_add>);
    next!(ip, ireg, ..)
}
```

On aarch64 the whole handler is four instructions: load the next handler while bumping `ip`, load the immediate, add, branch.

The price is copy instructions the old design did not need. `(local 0) + 10` stored into `(local 1)` was one 1.0 instruction and is two in 2.0 (`i32_add_rsi`, then `u64_copy_sr`). When a later computation overwrites `ireg` while an earlier result is still pending on the Wasm operand stack, the translator must first copy it out to a slot. Wasmi reduces the damage two ways. It adds specialized copies for fixed small locals (`u64_copy_sNr` for `N = 0..10`, the float equivalents, and `u64_copy_sNsM` for local-to-local with `N,M = 0..5`), so the local index need not be decoded. And it fuses opcodes: `add` and `load` followed directly by `local.set` or `local.tee` are common enough in real Wasm to deserve single instructions that write both the register and a slot.

Accumulators also survive control-flow boundaries. A `block`, `if` or `loop` whose results (or loop parameters) end in an `i32` and an `f32` returns them in `ireg` and `freg32`; only the tail of the result list goes in registers, and the rest go in stack slots. That lets loop induction variables stay in a register. The same treatment for function calls regressed performance and was not merged.

## Instance objects at a fixed offset

In 1.0 an instance held one heap allocation per kind of object (memories, globals, tables and so on), each a list of handles resolved through the store. `global.get` took three dependent loads, slow enough that 1.0 special-cased `(global 0)`, which C compiled to Wasm uses as its shadow-stack pointer.

2.0 starts from the observation that every instance of a module has the same object layout, so an object's address is a property of the module. `InstanceEntity` became a dynamically sized type: a fixed header followed by one contiguous `handles` buffer ordered memories, globals, tables, funcs, elems, datas. Memories come first so `(memory 0)` sits at address 0 and memory addresses fit in 16 bits. Data segments come last because the `data count` section may not be known when the module is created. The header caches `(table 0)` for `call_indirect`. Each handle carries a pointer straight to the store-owned object, filled in at instantiation, which required the store's `Arena` containers to become a `StableArena` that never moves its contents. IR now names objects by instance address, so access is one offset from `instance`. The `global_get_u64_r` handler compiles to six aarch64 instructions, and the `(global 0)` special case is gone because `(global 1)` is now just as fast.

Wasm3 and Stitch get the same speed differently: their bytecode is per instance, with object pointers baked in. Wasmi keeps module-level bytecode shared by all instances of a module, which the post says saves a lot of memory, and reaches comparable access cost through the layout instead. The `counter-global` benchmark isolates exactly this.

## A lock-free code map for calls

Wasmi 1.0 kept all compiled function bodies in one mutex-guarded arena, so every Wasm-to-Wasm call took a lock. Shared module-level bytecode makes an engine-wide function address valid for every instance, so 2.0 can bake function pointers into call instructions the way Stitch and Wasm3 do. That only works if functions never move, and the code map grows as modules are compiled, possibly while other threads run. 2.0 stores functions in append-only buckets that never reallocate. Adding functions is serialized; reading them is lock-free. Each entry has its own atomic state, so the hot path of `call_internal` checks that the function is compiled and jumps, while lazy compilation happens at most once per function on the cold path.

On the call-heavy `fibonacci-rec` benchmark the post reports Wasmi ahead of both Stitch and Wasm3, and attributes the lead to things other than the code map: those two merge the call and value stacks, which costs extra copies, and Wasm3 also copies a per-function constant pool on each call.

## Fixed 64-bit stack cells

With the `simd` feature on, Wasmi 1.0 widened every stack cell to 128 bits, which cost 5-10% from memory traffic, cache use and wider copies at calls. 2.0 always uses 64-bit cells and gives each `v128` value two adjacent cells, allocated by the translator. SIMD instructions move the same number of 64-bit words as before. On CoreMark, 1.0 loses roughly 8% with `simd` enabled; 2.0 with and without it are within noise.

## The biggest win was undoing a compiler pass

While benchmarking rivals, Freyler found Stitch's CoreMark score had dropped roughly 30%, from over 3000 to about 2200, between Rust 1.91 and 1.92. The cause was `DestinationPropagation`, a MIR optimization enabled by default in 1.92 that merges locals holding the same value. In a conditional-branch handler it collapsed the taken and not-taken dispatch paths into one: a `csel` picks the next `ip` and a single indirect `br` jumps to it.

```text
branch_i32_lt_ri:
    ldp w8, w9, [x1, #8]
    sxtw x8, w8
    add x10, x1, #16
    add x8, x1, x8
    cmp w9, w6
    csel x1, x8, x10, gt ; pick the target
    ldr x7, [x1]
    br x7                ; one branch site for both outcomes
```

That shared branch site gives the predictor a single entry with mixed history. Threaded interpreters are fast in large part because each handler has its own dispatch branch that the predictor can learn separately, and this pass quietly merged two of them. Fixing Stitch restored its score to over 3000. Wasmi's own `i32.lt` handler had the same problem; after the fix it has a conditional branch and two separate `br` sites, and Wasmi 2.0's CoreMark went from ~2800 to over 4200, about 50%, which the author calls the single most important optimization of the release. Only the tail-call dispatch modes improved.

This is the inverse of [[compiler-codegen-luck]], where an accidental switch to a branchless [[conditional-move]] made quicksort six times faster because its branch was unpredictable. Here the branchless form was the loss: dispatch branches are predictable per site, and merging sites destroyed that.

## What remains

Wasmi 3.0 targets WebAssembly 3.0, which still needs the `function-references`, `exception-handling` and `gc` proposals. The Stellar sponsorship ends in October 2026, and the author is looking for new funding.

For other runtimes in the vault, see [[wazero]] (pure Golang, interpreter plus compiler) and [[wasmer]] (native multi-backend). A different route to a fast interpreter, deriving a JIT from it instead of tuning its dispatch, is [[meta-tracing]].
