README.md

September 3, 2026 · View on GitHub

 ███████╗██╗   ██╗███████╗███████╗██╗   ██╗███╗   ███╗
 ██╔════╝██║   ██║██╔════╝██╔════╝██║   ██║████╗ ████║
 █████╗  ██║   ██║███████╗█████╗  ██║   ██║██╔████╔██║
 ██╔══╝  ██║   ██║╚════██║██╔══╝  ╚██╗ ██╔╝██║╚██╔╝██║
 ██║     ╚██████╔╝███████║███████╗ ╚████╔╝ ██║ ╚═╝ ██║
 ╚═╝      ╚═════╝ ╚══════╝╚══════╝  ╚═══╝  ╚═╝     ╚═╝

CI Crates.io Downloads Docs.rs Docs License: MIT

[LANGUAGE-AGNOSTIC BYTECODE VM WITH FUSED SUPERINSTRUCTIONS POWERING THE FASTEST INTERPRETED LANGUAGES]

"One VM to run them all."

A language-agnostic bytecode virtual machine with fused superinstructions and 3 stage (linear, block, tracing) Cranelift JIT. Any language frontend compiles to fusevm opcodes and gets fused hot-loop dispatch, extension opcode tables, stack-based execution with slot-indexed fast paths, and native code compilation via Cranelift — for free. 235 opcodes across 22 sections, 11 fused superinstructions, 29 first-class shell ops, 61 first-class AWK ops. Cranelift 0.130 behind jit feature flag.

cargo add fusevm --features jit   # with Cranelift JIT
cargo add fusevm                  # interpreter only

Read the Docs · Engineering Report · API Reference · Crates.io · strykelang · zshrs


Table of Contents


[0x00] OVERVIEW

fusevm is the shared execution engine behind eighteen language frontends — zshrs, strykelang, awkrs, vimlrs, elisprs, rubylang, arb, pythonrs, phplang, node-js, rlang/R, javars, kotlinrs, scalars, groovyrs, go-rs, and tclrs/Tcl, plus texrs (a TeX engine — Knuth's mouth and expander), the newest: its mouth and expander lower to the same bytecode and it ships --disasm, --tiers, --dap and --aot; TeX's stomach is not implemented. They all compile to the same Op enum. The VM doesn't care which language produced the bytecodes.

zshrs  ──► shell  compiler ──┐
stryke ──► stryke compiler ──┤
awk    ──► awk    compiler ──┤
viml   ──► viml   compiler ──┤
elisp  ──► elisp  compiler ──┤
ruby   ──► ruby   compiler ──┤
rlang  ──► r      compiler ──┤
go-rs  ──► go     compiler ──┤
arb    ──► arb    compiler ──┼──► fusevm::Op ──► VM::run() ──┐
python ──► python compiler ──┤                              │
php    ──► php    compiler ──┤                              │
node   ──► node   compiler ──┤                              │
java   ──► java   compiler ──┤                              │
kotlin ──► kotlin compiler ──┤                              │
scala  ──► scala  compiler ──┤                              │
groovy ──► groovy compiler ──┤                              │
tcl    ──► tcl    compiler ──┘                              │

                                   JitCompiler tiers (Cranelift 0.130)
                                   ├── Linear JIT (straight-line, instant)
                                   ├── Block JIT (CFG, threshold 1)
                                   └── Tracing JIT (hot loop, threshold 50,
                                                   deopts on guard miss)


                                     native x86-64 / aarch64
  • Fused superinstructions — the compiler detects hot patterns and emits single ops instead of multi-op sequences
  • Extension dispatch — language-specific opcodes via Extended(u16, u8) with registered handler tables
  • Stack + slots — stack-based execution with slot-indexed fast paths for locals
  • Three-tier Cranelift JIT — Linear JIT (straight-line, compile-on-first-call), Block JIT (CFG-aware, threshold 1), Tracing JIT (records hot loop paths, threshold 50, deopts on type-guard miss)
  • Zero-clone dispatch — ops borrowed from chunk, in-place array/hash mutation, Cow<str> string coercion
  • Lean foundational dependencies — pure Rust, no unsafe in the core; runtime deps are durable, widely-vetted crates (serde, tracing, glob, chrono); Cranelift JIT and libc disk-cache are opt-in feature flags

[0x01] INSTALL

cargo add fusevm
# or from source
git clone https://github.com/MenkeTechnologies/fusevm && cd fusevm && cargo build

Cargo features:

FeatureEffect
jitCranelift-backed native JIT (linear, block, and tracing tiers).
jit-disk-cachePersists compiled native code to ~/.cache/fusevm-jit so codegen is skipped across process restarts. Implies jit; on by default once enabled (see JIT Compilation).
aotClosed-world ahead-of-time compiler (src/aot.rs) that lowers a whole Chunk to a relocatable native object via cranelift-object. Implies jit.
ffiRuntime behind inline rust { … } blocks (src/ffi.rs): compiles the block body to a cdylib, caches it, dlopens it, and registers each pub extern "C" fn export as a callable.
rkyv-archiveDerives rkyv archive impls on Value and Chunk for zero-copy mmap-backed bytecode caches.

[0x02] USAGE

use fusevm::{Op, ChunkBuilder, VM, VMResult, Value};

let mut b = ChunkBuilder::new();
b.emit(Op::LoadInt(40), 1);
b.emit(Op::LoadInt(2), 1);
b.emit(Op::Add, 1);

let mut vm = VM::new(b.build());
// Optional: enable tracing JIT — hot loops will be recorded and
// JIT-compiled at runtime. Requires `--features jit`.
#[cfg(feature = "jit")]
vm.enable_tracing_jit();

match vm.run() {
    VMResult::Ok(val) => println!("result: {}", val.to_str()),  // "42"
    VMResult::Error(e) => eprintln!("error: {}", e),
    VMResult::Halted => {}
}

[0x03] ARCHITECTURE

                  ┌──────────────────────────────────┐
                  │         Language Frontend         │
                  │   (stryke, zshrs, or your own)    │
                  └──────────────┬───────────────────┘
                                 │ compile

                  ┌──────────────────────────────────┐
                  │       ChunkBuilder::emit()       │
                  │   Op enum ──► Chunk (bytecodes)  │
                  └──────────────┬───────────────────┘

                    ┌────────────┴────────────┐
                    ▼                         ▼
          ┌─────────────────┐     ┌─────────────────────┐
          │   VM::run()     │     │   JitCompiler       │
          │  match-dispatch │     │  Cranelift codegen   │
          │   interpreter   │     │  (eligible chunks)   │
          └─────────────────┘     └─────────────────────┘

Execution tiers — one semantic source of truth

fusevm has four ways to execute a chunk, and they all agree on what every op means because they route through — or fall back to — a single function, VM::exec_op (src/vm.rs), documented in-source as "the single source of truth for op semantics."

TierEntryHow it runs an op
InterpreterVM::run (src/vm.rs)Dispatch loop calls exec_op(ops, ip, …) per op; the returned ExecFlow says continue or terminate.
Linear / Block / Tracing JITJitCompiler (src/jit.rs)Emits specialized Cranelift IR for the eligible integer/float/slot subset; anything ineligible bails or deopts back to the interpreter — i.e. back to exec_op.
AOTaot::compile_object (src/aot.rs)Native driver, one Cranelift block per op; unspecialized ops call exec_op through the extern "C" fusevm_aot_exec_op shim (VM::aot_exec_op).

Consequences that fall out of the single-source design:

  1. Semantics never fork. A new op is implemented once, in exec_op, and every tier inherits it — the JIT/AOT specialize performance, never behavior.
  2. Specialize the hot subset, lean on the interpreter for the tail. JIT and AOT only lower the scalar (int/float/bool/slot) ops that pay off; string/array/hash/host ops stay in exec_op.
  3. Deopt is just "resume exec_op at this ip." On a tracing-JIT guard miss, materialize_deopt_frames (src/vm.rs) rebuilds the value stack (stack_buf + per-entry stack_kinds so floats bit-cast back through f64::from_bits) and the inlined call frames (return_ip + slot values), so the interpreter picks up mid-loop with byte-identical state.

[0x04] FUSED SUPERINSTRUCTIONS

The performance secret. The compiler detects hot patterns and emits single ops instead of multi-op sequences:

Fused OpReplacesEffect
AccumSumLoop(sum, i, limit)GetSlot + GetSlot + Add + SetSlot + PreInc + NumLt + JumpIfFalseEntire counted sum loop in one dispatch
SlotIncLtIntJumpBack(slot, limit, target)PreIncSlot + SlotLtIntJumpIfFalseLoop backedge in one dispatch
ConcatConstLoop(const, s, i, limit)LoadConst + ConcatAppendSlot + SlotIncLtIntJumpBackString append loop in one dispatch
PushIntRangeLoop(arr, i, limit)GetSlot + PushArray + ArrayLen + Pop + SlotIncLtIntJumpBackArray push loop in one dispatch

Each fused op eliminates N-1 dispatch cycles, stack pushes, and branch mispredictions from the hot path.


[0x05] OP CATEGORIES

235 opcodes across 22 sections in src/op.rs:

CategoryCountExamples
Constants & Stack12Nop, LoadInt, LoadFloat, Pop, Dup, Swap
Variables7GetVar, SetVar, GetSlot, SetSlot, SlotArrayGet
Arrays & Hashes20ArrayPush, HashGet, MakeArray, HashKeys
Arithmetic9Add, Sub, Mul, Div, Pow
String3Concat, StringRepeat, StringLen
Comparison14NumEq, StrLt, Spaceship, StrCmp
Logical / Bitwise10RubyTruthy, LogNot, LogAnd, BitAnd, Shl, Shr
Control Flow5Jump, JumpIfFalse, JumpIfTrueKeep
Functions / Scope5Call, Return, PushFrame, PopFrame
I/O3Print, PrintLn, ReadLine
Collections2Range, RangeStep
Higher-Order5MapBlock, GrepBlock, SortBlock, ForEachBlock
Fused11AccumSumLoop, SlotIncLtIntJumpBack, ConcatConstLoop, PreIncSlot, PostIncSlot, PreDecSlot, PostDecSlot
Builtins1CallBuiltin(id, argc) (140 IDs in shell_builtins.rs)
Shell Ops29Exec, PipelineBegin, Redirect, Glob, TestFile, RegexMatch
AWK Ops61AwkFieldGet, AwkPrint, AwkStrtonum, AwkDivJit, AwkModJit, AwkGensub, AwkOrd, AwkChr, AwkMkbool, AwkIntdiv
Float / Int Math26SqrtFloat, Atan2Float, Log2Float, RoundFloat, GcdInt, LcmInt, TimeInt
Cooperative Concurrency10Go, ChanMake, ChanSend, ChanRecv, ChanRecvOk, ChanClose, Select, CallDynamic, MulModFloor, MulAddModFloor
Extension2Extended(u16, u8), ExtendedWide(u16, usize)

[0x06] EXTENSION MECHANISM

Language-specific opcodes use Extended(u16, u8) which dispatches through a handler table registered by the frontend:

let mut vm = VM::new(chunk);
vm.set_extension_handler(Box::new(|vm, id, arg| {
    match id {
        0 => { /* language-specific op 0 */ }
        1 => { /* language-specific op 1 */ }
        _ => {}
    }
}));

stryke registers ~450 extended ops. zshrs registers ~20. awkrs registers ~95. elisprs registers 10. vimlrs takes the other route — ~510 builtin IDs through CallBuiltin rather than extended ops. They don't conflict — each frontend owns its own ID space.

Shell Host (0.10.0+)

Shell-specific runtime ops (Glob, TildeExpand, BraceExpand, WordSplit, ExpandParam, CmdSubst, ProcessSubIn/Out, Redirect, HereDoc, HereString, PipelineBegin/Stage/End, SubshellBegin/End, TrapSet/TrapCheck, WithRedirectsBegin/End, CallFunction, StrMatch, RegexMatch) dispatch through the ShellHost trait. The frontend (zshrs) provides a real implementation; without one, the VM uses minimal stubs that keep stack discipline correct.

use fusevm::{ShellHost, VM, Chunk, Value};

struct MyHost;
impl ShellHost for MyHost {
    fn glob(&mut self, pattern: &str, _recursive: bool) -> Vec<String> { /* … */ vec![] }
    fn tilde_expand(&mut self, s: &str) -> String { /* … */ s.into() }
    fn cmd_subst(&mut self, sub: &Chunk) -> String { /* run sub, capture stdout */ String::new() }
    // … other methods have default impls
}

let mut vm = VM::new(chunk);
vm.set_shell_host(Box::new(MyHost));

Sub-execution (cmd substitution, process substitution, trap handlers) is delivered to the host as &Chunk references taken from the parent's sub_chunks table. Build them with ChunkBuilder::add_sub_chunk(sub) -> u16 and reference by index in Op::CmdSubst(idx), Op::ProcessSubIn(idx), Op::ProcessSubOut(idx), Op::TrapSet(idx).

AWK Host (0.13.0+)

The 61 first-class Op::Awk* variants dispatch through the AwkHost trait. AWK's data model (numeric-string duality, CONVFMT/OFMT coercion, $0/$n/NF field coupling, SUBSEP arrays, regex, getline/printf IO) lives in the frontend (awkrs), so most AWK ops require a registered host; without one they stay inert but stack-balanced.

Twenty-nine builtins are the exception — they execute natively even with no host registered. Most are pure on fusevm::Value; rand/srand run against a VM-owned PRNG seed (execution-intrinsic state, reset with the VM); strftime/mktime read the system timezone but need no AWK runtime state:

  • Strings: substr, index, tolower, toupper, scalar length(s).
  • Characters (gawk): ord (first char → codepoint), chr (codepoint → char, empty if invalid).
  • Math: int, sqrt, sin, cos, exp, log, atan2 (pure f64), intdiv (truncating integer quotient; Undef on divide-by-zero), intdiv0 (same, but 0 on divide-by-zero), mkbool (1/0 by truthiness).
  • Bitwise (gawk): and, or, xor, compl, lshift, rshift (operands truncated to integers).
  • Conversion (gawk): strtonum (0x… hex, 0… octal, else longest decimal/float prefix).
  • Time (gawk): systime, strftime, mktime (chrono-backed; local-tz and UTC paths).
  • PRNG (POSIX/gawk): rand, srand (glibc LCG over a VM-owned seed initialized to 1; deterministic without a host).
  • Arithmetic (POSIX awk): AwkDiv (a / b), AwkMod (a % b) — float divide/modulo that raise a fatal "division by zero attempted" / "division by zero attempted in \%'"runtime error on a zero divisor (vs the shell-arithmeticOp::Div/Op::Mod, which yield Undef/0). Host-independent; interpreter-only (not block/trace-JIT-eligible, since they conditionally trap). AwkDivJit/AwkModJitare block-JIT-eligible variants with byte-identical interpreter semantics: the block JIT emits a **guarded early-exit** (compare the divisor to0.0; on equality call the fusevm_jit_awk_div_traplibcall with a code —1div /2mod — andreturna sentinel, elsefdiv/fmod). The VM's block-dispatch path reads the trap channel after the compiled run and converts a set code into the same fatal error the interpreter raises, so a JIT-compiled for(;;) x = 1/0traps instead of producinginf/NaNor hanging. The trap libcall is not a registered host-helper id, soAwkDivJit/AwkModJitchunks skip on-disk cache persistence (in-process JIT only) and never touch the shared cache schema — zshrs/stryke (which emit onlyOp::Div/Op::Mod`) get byte-identical native code.

AWK control flow has no fusevm::Value representation (next/nextfile/exit are statements, not expressions). Op::AwkSignal(code) carries it host-free: it halts the current chunk and stashes code (awk_builtins::signal::{NEXT, NEXTFILE, EXIT}) in the VM, which the frontend driver reads via VM::awk_signal() after run() to drive its own record/file/exit flow. zshrs/stryke never emit it, so awk_signal() stays None for them and Halted is byte-identical to before — the channel is a VM-state side effect, not a new VMResult variant. Interpreter-only.

use fusevm::{VM, ChunkBuilder, Op, Value};

let mut b = ChunkBuilder::new();
let s = b.add_constant(Value::str("hello"));
b.emit(Op::LoadConst(s), 1);
b.emit(Op::LoadInt(2), 1);
b.emit(Op::LoadInt(3), 1);
b.emit(Op::AwkSubstr(3), 1);          // substr("hello", 2, 3)
let mut vm = VM::new(b.build());      // no set_awk_host needed
// vm.run() → "ell"

A registered host may still override these (e.g. locale-aware casing, MPFR-precision math, or gawk's fatal-error on negative bitwise operands); the native path is used only when no host is present. length(\$0) and length(arr) always need the host (field/array state). rand/srand also need the host (RNG seed state).


[0x07] JIT COMPILATION

The JitCompiler compiles eligible chunks to native code via Cranelift 0.130. Enable with cargo add fusevm --features jit.

use fusevm::{JitCompiler, ChunkBuilder, Op, Value};

let mut b = ChunkBuilder::new();
b.emit(Op::LoadInt(40), 1);
b.emit(Op::LoadInt(2), 1);
b.emit(Op::Add, 1);
let chunk = b.build();

let jit = JitCompiler::new();
if jit.is_linear_eligible(&chunk) {
    // Compiles to native x86-64/aarch64, caches, and runs
    let result = jit.try_run_linear(&chunk, &[]);  // Some(Int(42))
}

Linear JIT — eligible ops

CategoryJIT'd Ops
ConstantsLoadInt, LoadFloat, LoadConst (int/float), LoadTrue, LoadFalse
ArithmeticAdd, Sub, Mul, Div, Mod, Pow, Negate, Inc, Dec
ComparisonNumEq/Ne/Lt/Gt/Le/Ge, Spaceship
BitwiseBitAnd/Or/Xor/Not, Shl, Shr
LogicLogNot
StackPop, Dup, Swap, Rot
SlotsGetSlot, SetSlot, PreIncSlot, PreIncSlotVoid, AddAssignSlotVoid

Int/float promotion: when either operand is float, both are promoted to f64. Cranelift emits iadd/fadd/fcvt_from_sint as needed. Runtime helpers for Pow (wrapping integer + f64::powf) and Mod (float fmod).

JIT tier ladder

fusevm runs three JIT tiers in increasing order of optimization power and compile cost. A given chunk can be served by exactly one tier — they cover disjoint cases:

TierTriggerCoverageSpeculation
Linearis_linear_eligible + first callStraight-line expression chunks; returns Value (int or float)None — IR matches bytecode exactly
Blockis_block_eligible + 1 invocationWhole-chunk CFG (loops, branches, fused backedges)None — slot ops assume i64
Tracing50 backedges through any loop headerHot path through anything; recorded loop body compiled with type-specialized IRSlot-type entry guard; deopt to interpreter on guard miss

Tuning warmup for re-run-heavy workloads

The block (default 1) and tracing (default 50) warmup thresholds are how many times a chunk must run before that tier compiles it. They are tunable two ways:

  • Per process, no recompile — set environment variables (great for a shell rc when you re-run the same scripts constantly):

    export FUSEVM_JIT_BLOCK_THRESHOLD=0   # block-JIT the whole chunk on its FIRST run (max eager)
    export FUSEVM_JIT_TRACE_THRESHOLD=10  # arm hot-loop traces sooner
    

    These are read once per thread when the JIT is first touched, applied on top of the compiled defaults.

  • Per thread, programmatically — via TraceJitConfig (block_threshold / trace_threshold) and JitCompiler::set_config.

For workloads that run the same scripts over and over, combine a low warmup with the jit-disk-cache feature (on by default): the warmup decides when a tier engages, and the disk cache makes the resulting native code free to reload on the next run — so you get AOT-like speed without explicitly AOT-compiling. Setting FUSEVM_JIT_BLOCK_THRESHOLD=0 is the most aggressive: every block-eligible chunk is compiled to native on its first invocation and reloaded from ~/.cache/fusevm-jit on subsequent runs. The trade-off is a one-time codegen cost the very first time a chunk is ever seen (paid once, then cached), so raise the thresholds again for scripts that genuinely run only once.

Tracing JIT is opt-in per VM (vm.enable_tracing_jit()). The recorder anchors at backward branches, captures the executed op sequence on the next iteration through the header, and installs a compiled trace that runs the loop body in native code until the loop's exit condition becomes false. Slot type changes between invocations cause the entry guard to refuse the trace; after 5 such guard mismatches the trace is blacklisted and never retried.

Cross-call inlining (phase 2). Op::Call to a sub-entry resolves to the callee's bytecode IP at recording time, and the callee body inlines into the trace IR. Each inlined frame gets its own slot-variable scope (caller slots eagerly promoted from the slot pointer; callee slots lazily allocated zero-initialized). Op::Return and Op::ReturnValue truncate the abstract stack to the frame's entry mark, mirroring interpreter semantics. Args travel via the value stack — no movement to slots is required.

Caller-frame internal branches with side-exits (phase 3). Loops with if/else bodies are now traceable. The recorder captures the executed direction at each conditional jump (via parallel recorded_ips), and the compiler emits a brif guard at every internal branch: the runtime condition must match the recorded direction, otherwise control transfers to a per-branch side-exit block that spills the caller's slot variables and returns the un-recorded direction's IP for the interpreter to resume from.

Callee-frame branches with frame materialization on deopt (phase 4). Branches are now allowed inside inlined callees, not just the caller frame. When a side-exit fires from inside an inlined callee, the trace populates a DeoptInfo out-parameter the VM uses to materialize synthetic Frames on vm.frames — each with its return_ip pointing back to the post-Op::Call IP in the parent, and slot values copied from the trace's per-frame Cranelift Variables. The interpreter then resumes mid-callee with a correctly shaped call stack; when the callee eventually hits Op::Return, the synthetic frame is popped and execution continues in the parent. Bounds: max 4 inlined frames at any side-exit, max 16 slot indices per inlined frame.

Value-stack reconstruction on deopt (phase 5). The "abstract stack empty at branch" restriction is lifted: branches can fire while the trace's abstract stack still holds intermediate values. At side-exit, those values are written into DeoptInfo.stack_buf (capacity 32) and the VM pushes them onto vm.stack so the interpreter resumes with the same stack state the bytecode would have at the deopt IP. Phase 5b adds a parallel stack_kinds tag array so Float entries get bit-cast through f64::from_bits and materialized as Value::Float (not just Value::Int). This unlocks short-circuit &&/|| patterns and any branch where intermediate float/int computations live on the value stack.

Side-exit deopt counter + auto-blacklist (phase 6). Each compiled trace's TraceCacheEntry tracks a side_exit_count distinct from the entry-guard deopt_count. When a brif guard inside the trace fires (the trace returns a resume IP that isn't the loop fallthrough), the counter increments; after MAX_SIDE_EXITS (50) misses the trace is blacklisted and never retried. This avoids the pathological case where the recorded path doesn't match runtime and every iteration pays trace+deopt+interpret cost. Note: full side-trace stitching — recording from the side-exit IP and linking the new trace into the main one — is deferred (it's substantial work on its own).

Persistent trace metadata (phase 7). TraceMetadata is a serde-serializable struct (chunk hash, anchor IP, fallthrough IP, op sequence, recorded IPs, slot-kind snapshot). JitCompiler::trace_export extracts it from a compiled-trace cache entry; trace_import re-installs it on a fresh JitCompiler after verifying chunk_op_hash still matches. Persistence format is intentionally caller-owned — fusevm doesn't ship a file layout, so users can pick JSON, bincode, sqlite, or anything else with serde support.

Bounded recursion inlining (phase 8). The recorder's hard-no on self-recursive calls is relaxed to a depth cap (MAX_INLINE_RECURSION = 4 levels). A self-call up to that depth is inlined like any other Call; deeper recursion aborts the trace and the interpreter handles it. Combined with phase 4's frame materialization, this enables tracing of tail-recursive helpers up to the cap.

Side-trace stitching (phase 9). When a main trace's side-exit fires repeatedly at the same IP, the recorder rearms at that IP and records a side trace: the bytecode path from the side-exit forward to the loop's backward branch. TraceRecorder splits its anchor into record_anchor_ip (cache key — the side-exit IP) and close_anchor_ip (the enclosing loop's header where the closing branch lands). Side traces compile via trace_install_with_kind and don't loop in their own IR — both directions of the closing branch exit, returning either the close target (so the main trace runs the next iteration) or the loop's fallthrough IP (loop done). The VM's chained-dispatch path runs after each main-trace deopt: if a side trace is registered at the resume IP, dispatch it; otherwise bump the main trace's side_exit_count toward auto-blacklist. Chains are bounded by MAX_TRACE_CHAIN (4) per backward-branch hop. Phase 6's blacklist counter is reserved for cases where no side trace is helping — productive deopts don't penalize the main trace. Side traces use the same eligibility rules as main traces and don't recursively spawn further side traces from their own deopts (their side-exits still bump the main trace's blacklist counter).

Loops over globals (phase 10). The tracing tier no longer bails when a loop body touches a global. A trace records the globals it referenced and promotes them to native registers for the duration of the loop, spilling back on exit or deopt. The entry guard is extended with a per-referenced-index check — whether each referenced global is still a number — rather than the whole-frame check used for slots, so an unrelated global changing type does not invalidate the trace. TraceMetadata carries a global_kinds_at_anchor fingerprint beside the existing slot fingerprint; it is serde(default), so a trace persisted by a build that predates global support still deserializes (an empty vector says exactly what it means — that trace referenced no global). An imported trace re-checks the live global table before it runs rather than trusting the persisted kinds.

Persistent native-code disk cache (jit-disk-cache). Enable with cargo add fusevm --features jit-disk-cache to cache compiled native code to disk, skipping Cranelift codegen across process restarts — a big win for workloads that re-launch the VM repeatedly (e.g. running a large test suite over and over). The cache covers all three tiers (linear, block, tracing) and is on by default once the feature is enabled, writing to ~/.cache/fusevm-jit. Override the directory with the FUSEVM_JIT_CACHE_DIR env var or JitCompiler::set_jit_cache_dir(Some(dir)); disable at runtime with FUSEVM_JIT_CACHE_DIR=off or set_jit_cache_dir(None).

Cache files are tier-tagged (.lin. / .blk. / .trc.) and keyed by the chunk's op-hash (the tracing tier additionally keys on the record-anchor IP and verifies a content hash over the recorded ops, IPs, slot types, and constants, so divergent recorded paths never collide). Blobs store the native code plus a small relocation table re-patched on load; loading mmaps the code with W^X handling (pthread_jit_write_protect_np + icache invalidation on Apple Silicon, mprotect elsewhere). Writes publish via a unique temp file + atomic rename, so the cache is safe under many concurrent processes. The loader is conservative: any chunk whose code carries a relocation other than a known host-helper call falls back to the in-memory JIT, so an untested target degrades to "no caching" rather than miscompiling. The cache is behavior-transparent — it only eliminates Cranelift codegen time; tier selection, warmup thresholds, and results are identical to an uncached run. Benchmark (cargo bench --features jit-disk-cache --bench jit_disk_cache): a cached block load is ~35µs versus ~152µs for cold codegen.

Size control. Each blob is small — roughly 100 bytes for a linear chunk, up to a few KB for block/trace — and the cache writes one blob per unique JITable segment per script version, so it grows slowly but is never automatically trimmed by op-hash (an edited script produces new hashes; the old blobs linger). To keep it bounded there's a total-size cap, default 256 MiB, enforced by oldest-first (mtime) eviction down to 80% of the cap, applied opportunistically as new blobs are written (so no scan cost on most writes). Controls:

KnobEffect
FUSEVM_JIT_CACHE_MAX_BYTESCap as bytes or with a k/m/g suffix (e.g. 512m, 2g). 0/off/unlimited disables eviction. Overridden by the programmatic setter.
JitCompiler::set_jit_cache_max_bytes(Some(n))Same cap programmatically; Some(0) = unlimited, None = restore env/default resolution.
JitCompiler::jit_cache_size_bytes()Current total cache size in bytes (None if disabled).
JitCompiler::prune_jit_cache()Force an immediate eviction pass against the cap; returns bytes freed.
JitCompiler::clear_jit_cache()Delete every blob (repopulates lazily next run); returns files removed.
rm -rf ~/.cache/fusevm-jitManual nuke.

The cached blob's fingerprint hashes the crate version, so a cache written by an older fusevm is never loaded by a newer one — a lowering fix can't be resurrected from a warm cache.

The interpreter is the specification

A chunk can run interpreted or native depending only on how hot it has become, so every tier must produce the same answer as the interpreter, including the numeric type. Anything else is a wrong answer whose value depends on JIT warmup state — the same chunk giving different results on row 1 and row 2 of the same input.

Two ops make this non-obvious, because they are float-only in the interpreter where a native lowering would "naturally" be integer:

OpContractNotes
DivAlways Float, and Undef when the divisor is zero1 / 2 is Float(0.5), never Int(0). -0.0 counts as a zero divisor (the test is b.to_float() == 0.0).
PowAlways Float2 ** -1 is Float(0.5), never Int(0).

Native code has no Undef, so the tiers handle the zero divisor differently by necessity: AOT emits fdiv and deopts to the interpreter on zero, while the linear/block/tracing tiers have no mid-chunk deopt and instead decline to compile a Div whose divisor is not a provably nonzero constant. Mod is declined the same way unless its divisor is a constant with |k| >= 2 or a float, because native srem traps on a zero divisor and on i64::MIN % -1 where the interpreter answers 0.

tests/jit_block.rs pins tier agreement for these directly: each case runs with block_threshold at u32::MAX (interpreter) and 0 (native) and requires the two results to match.

Value::Bool is the other kind native code cannot hold, and each tier answers it differently by design:

  • Linear returns a Value, so it reproduces the interpreter exactly: the boolean rides the integer return register as 0/1 and is boxed back to Bool from the abstract simulation's result kind. A chunk that consumes a boolean numerically declines instead, because Value::Bool is not a native number — arith_int_fast routes it through to_float, so true + 1 is Float(2.0), not Int(2), and native code has no way to produce that from an integer register.

  • AOT carries Kind::Bool through its own lattice and boxes the same way.

  • Block carries no boolean through its register lattice — JitTy is Int-or-Float — but it does not have to in order to answer one. A chunk's result kind is a static property of its last op, so a terminal boolean compiles and comes back as BlockNum::Bool, which is the channel that keeps the kind. Every other escape route still declines: a boolean may otherwise only be produced when the very next op consumes it as a truth value (JumpIfTrue/JumpIfFalse) or discards it (Pop), and storing it to a slot or feeding it to arithmetic makes the chunk ineligible. A JIT region is held to the stricter rule too, since its result flows back onto the surrounding chunk's stack where the next op may consume it as a number.

  • Tracing has no boolean kind and still declines every escaping boolean, terminal ones included. Its results are stored into raw i64 slots and read back under each slot's own kind, so a boolean landing in a float-kinded slot is read as an f64 bit pattern — the worst of the failures below, and not something a result-kind channel fixes.

    Before that rule, VM::run boxed BlockNum::Int(n) as Value::Int(n) before any frontend saw it, so a boolean-valued chunk changed variant on its second run, once the block cache went warm:

    chunk1st call (interpreter)2nd call (block JIT), before the fix
    1 < 2Bool(true)Int(1)
    1 > 2Bool(false)Int(0)
    LoadFalseBool(false)Int(0)
    true + 1Float(2.0)Int(2)

    The difference is observable: Bool(false).to_str() is "" where Int(0).to_str() is "0", and arithmetic on a Bool coerces through to_float. In the tracing tier it was worse than a variant error — a boolean stored into a float-kinded slot was written to the raw i64 slot buffer and read back as an f64 bit pattern, so 0 + true answered Float(5e-324) (the bits of 1) and 0 - true answered NaN (the bits of -1) where the interpreter answers Float(1.0) and Float(-1.0).

    The block half of that is now closed: BlockNum::Bool carries a terminal boolean's kind out, so 1 < 2 compiles and answers Bool(true) rather than declining. Widening JitTy itself — a boolean kind live through the block/tracing pipeline, which is what a boolean operand or a boolean stored to a slot would need — remains open.

tests/tier_matrix_diff.rs pins all of it: every native-lowerable op crossed with the operand edges — 0, -1, i64::MIN, 2^53+1, -0.0, ±1e30, booleans — run through the interpreter, the linear JIT, the block JIT, the tracing JIT, and the AOT compiler, compared on the Value variant and on raw float bits.

A tier that declines a case is skipped, never scored as agreement — so a harness whose corpus never reaches a tier would report a clean run it never earned. That is not hypothetical: the tracing tier anchors only on a conditional backward branch, and every chunk in this file was straight-line until diff_trace wrapped each op in a hot do-while loop. The trace harness therefore also asserts that at least one case actually compiled a trace, so it fails loudly rather than passing vacuously if the loop shape ever stops closing.

The ±1e30 edge is what separates Cranelift's trapping fcvt_to_sint from the saturating fcvt_to_sint_sat: Inc/Dec on a float operand used the former and executed an illegal instruction where the interpreter returns Int(i64::MIN) (Value::to_int is f as i64, which saturates) and the AOT tier declines the operand outright. Nothing caught it because is_block_eligible_op rejects Inc/Dec only under strict_numeric() — the non-default path.


[0x08] AHEAD-OF-TIME COMPILATION

The aot feature (src/aot.rs) compiles a whole Chunk to a native object file via Cranelift's ObjectModule, then links it against the fusevm runtime into a standalone executable — with no interpreter dispatch loop at run time. It's a closed-world compiler shared by every frontend, so AOT lives here once and each frontend's --build calls into it.

Threaded-code baseline

The bytecode dispatch loop (VM::run) is replaced by a native function with one Cranelift block per op. Each op block calls the per-op runtime step (VM::aot_exec_op, reached through the extern "C" fusevm_aot_exec_op shim), which runs that op via the same VM::exec_op the interpreter uses, and returns the next instruction index (or -1 to terminate). The native code branches on that through a central dispatch block:

entry → dispatch(0)
dispatch(ip): br_table ip → [block_0, …, block_{n-1}]  (default → ret)
block_i:      next = exec_op(vm, i);  if next < 0 → ret  else → dispatch(next)
ret:          finish(vm); return

Routing every op through dispatch (rather than static fall-through) keeps the lowering uniform for data-dependent targets — Op::Jump, the JumpIf* family, and intra-chunk Op::Call/Op::Return, whose target is only known at run time — without the native code ever reading the VM struct layout. The interpreter dispatch loop is gone; the work each op does is unchanged.

Native op specialization

Layered on top of the threaded path, build_entry lowers chunks that are scalar computations directly to native IR (no per-op shim call). analyze_native runs an abstract interpretation over the operand stack — tracking int-vs-bool Kinds, finding basic-block leaders, checking join consistency — and when a region qualifies, build_entry_native emits one Cranelift block per leader with the operand stack held in frontend Variables (an i64 and an f64 per stack position; the plan's Kinds say which is live). This covers:

  • Integer and float arithmetic/comparison, including int→float promotion mirroring the interpreter, Mod (integer srem with trap-divisor guards, or an fmod libcall for floats), and Pow/PowFloat via a powf libcall.
  • Math intrinsicsAbs/Sqrt/Ceil/Floor/Trunc/Round as single instructions; Sin/Cos/Tan/Exp/Log/Atan2 via libcalls; GcdInt/LcmInt as internal Euclid loops; the awk scalar ops (AwkDiv/AwkMod and their JIT twins, AwkSqrtJit/AwkLogJit with warn-and-return-NaN on a negative argument).
  • Bitwise/shift, Inc/Dec, booleans (LoadTrue/LoadFalse/LogNot/LogAnd/LogOr), three-way Spaceship, stack shuffles, native control flow (Jump/JumpIf*, including value-keeping JumpIf*Keep).
  • Integer slots and globals (GetVar/SetVar/DeclareVar) held in SSA registers under a definite-assignment analysis, plus the fused hot-loop slot super-ops (PreIncSlot/…, AddAssignSlotVoid, SlotIncLtIntJumpBack, and AccumSumLoop — whose internal while i < limit { sum += i; i += 1 } becomes a real native loop).

A fully-scalar loop runs entirely in registers; only the final result is boxed back into the VM.

Inline/shim boundary

Chunks that mix scalar work with heap ops don't fall back wholesale. For sink ops (Print/PrintLn) the native code spills the top register scalars onto the boxed vm.stack (per Kind), runs the op via the shim, and continues — so a hot numeric loop with embedded output stays native. For source ops whose result kind is statically known (AwkGetFieldNum, always Float) it runs the op via the shim then reloads the pushed value into a register with no type guard. Slots and globals are typed by a chunk-wide inferred kind, so a float accumulator (sum += 0.5) lowers to an f64 register.

Builtin calls cross the same boundary — operands spilled from registers, the handler run through the shim, the result threaded back as a boxed handle — with one addition: the shim's returned next-ip is honoured, because a builtin is the one shimmed op that can end the run (a handler calling VM::request_halt, e.g. a shell exit) or move vm.ip.

It is gated on Chunk::builtin_argc_is_arity, off by default. Native codegen needs argc to be the op's exact stack effect, and that is a property of the frontend's builtin table, not of the opcode — zshrs, for instance, emits CallBuiltin(BUILTIN_XTRACE_ARGS, 2) for a handler that pops one value and peeks the rest, leaving them for the following op. A frontend sets the flag only when every one of its handlers pops exactly argc and pushes one result; otherwise a builtin call stays a deopt point. A chunk that lowers builtins inline must also register-cache no slot or global, since a handler holds &mut VM and both slots and globals are public.

Choosing between the two paths

A deopt is one-way: the interpreter owns everything after it. So a plan that lowers a short prefix and deopts is worse than no plan at all, because the threaded path lowers every op to a native block. build_named weighs the two — the native path wins when it lowers the chunk outright, when the region it covers contains a loop, or when it covers at least half the ops; otherwise the threaded path takes the chunk. Without that gate a one-op plan silently beat a full threaded lowering: a shell chunk starts LoadInt then CallBuiltin, so an entire script compiled to a native driver of two calls (push_int, then resume) with the whole program interpreted.

aot::lowering_for(&chunk) reports that decision without running codegen:

match fusevm::aot::lowering_for(&chunk) {
    Lowering::Native { covered, deopts } => …, // ops in registers; `deopts` exit to the interpreter
    Lowering::Threaded => …,                   // one native block per op, each calling the runtime
}

A frontend should pin it in a test. That regression was invisible from outside fusevm — the binary was produced, ran, and printed the right answer.

Partial deopt (one-way exit to the interpreter)

Anything the native path can't handle at a given op — a string/array/hash/heap op, a heap constant load, or an operand-type mismatch — becomes a deopt point: the analysis lowers everything around it, stops propagating past it, and codegen emits a deopt there. emit_deopt writes the definitely-assigned register-cached slots/globals back to the VM (a merely maybe-assigned slot at a deopt point forces a wholesale threaded fallback, since a register can't distinguish a real 0 from Undef), spills the live operand stack, and calls fusevm_aot_resume to hand the rest of the run to the interpreter at the deopt ip. Op::Div uses this for its rare divide-by-zero (native fdiv on the common path, deopt only on a zero divisor); GetStatus ($?) is lowered as a statically-typed Status source. A chunk falls back to threaded wholesale only for genuinely structural reasons (stack underflow, inconsistent kind join, mixed-kind slot, non-numeric final result).

build_entry is generic over Cranelift's Module, so the in-memory JIT path that validates the compiler (run_chunk_native) and the on-disk ObjectModule path share identical codegen.

APIPurpose
aot::compile_object(&chunk, path)Emit a relocatable .o exporting fusevm_aot_entry plus the serialized chunk (fusevm_aot_chunk_blob / …_len).
aot::run_chunk_native(&chunk, register)Compile in-process via Cranelift and run it — validates codegen end to end.
aot::fusevm_aot_run_embedded()Runtime entry for a linked binary: rebuilds the VM from the embedded chunk, calls the frontend's fusevm_aot_register_builtins, runs the native entry, and maps the result to an exit code.
aot::compile_program_object(…)Per-chunk native lowering. Lowers a whole program — the top-level chunk and every named sub-chunk — into one object, so each callable gets its own native symbol instead of only the entry point.
aot::build_named(…)Emits one named native function for a single chunk. Generic over Cranelift's Module, so the in-memory and ObjectModule paths share codegen. The building block compile_program_object calls per chunk.

Chunk::native_id (u32) tags a chunk with the native function lowered for it, which is how a call site reaches a sibling chunk's compiled body instead of falling back to the interpreter.

Seeded param slots. Chunk::aot_seeded_slots declares how many leading frame slots (0..aot_seeded_slots) the caller fills before the run. The native driver's entry guard treats exactly those as definitely-assigned integer slots, so a chunk invoked with pre-filled parameters is still natively lowerable — without the declaration those slots read as possibly-undefined and the whole chunk would deopt to threaded dispatch.

Link the emitted object against a frontend runtime (which provides fusevm_aot_register_builtins) to produce the standalone binary. On macOS the link needs -framework CoreFoundation. The staticlib crate-type in Cargo.toml builds libfusevm.a so the object can be linked against the runtime.


[0x09] VALUE REPRESENTATION

Value is a tagged enum with fast-path immediates:

VariantRepresentationSize
UndefTag only0 bytes payload
Int(i64)Inline8 bytes
Float(f64)Inline8 bytes
Bool(bool)Inline1 byte
Str(Arc<String>)Heappointer
Array(Arc<Vec<Value>>)Heap, shared buffer + copy-on-writepointer
Hash(HashMap<String, Value>)Heap, in-place mutation7 words
Status(i32)Inline4 bytes
Ref(Box<Value>)Heappointer
NativeFn(u16)Inline2 bytes
Obj(u32)Inline4 bytes

String coercion returns Cow<str> via as_str_cow() — borrows the inner Arc<String> for Str variants, avoiding allocation on string comparisons, concatenation, hash key lookup, and I/O.

Arrays are shared on read, copied on write

Array holds an Arc<Vec<Value>>, so cloning a Value — which happens on every stack push, slot read, and global read — is a refcount bump, not a copy of the sequence. Arrays remain values, not references: every mutation goes through Value::array_mut (Arc::make_mut), which copies the buffer first when another Value still holds it, so a copy taken before a write never observes that write.

Construct arrays with Value::array(vec); read with as_array(), mutate with array_mut(), and take ownership with into_array() (which skips the copy when it is the sole owner).

This is what keeps per-element iteration linear. A frontend that lowers seq[i] as "load the collection, then index it" used to pay one full copy of the sequence per iteration — O(n) per step, O(n²) overall. Measured on a 16,000-element indexed read loop, the interpreter went from 1.5330 s to 0.0016 s, and the growth from ~4x per doubling to ~2x.

The serde encoding is unaffected: serde's rc feature encodes Arc<T> exactly as T, so the bincode/JSON bytes for a Value — and therefore for a Chunk in a frontend's on-disk bytecode cache — are unchanged. tests/array_hash_ops.rs pins those bytes.

Array and hash mutations (ArrayPush, ArrayPop, ArrayShift, ArraySet, SlotArraySet, HashSet, HashDelete) operate in place — no clone-modify-writeback cycle. Read-only access (ArrayGet, SlotArrayGet, ArrayLen, HashGet, HashExists, HashKeys, HashValues) borrows directly from the globals vector or the frame's slots.


[0x0A] BENCHMARKS

All benchmarks run via criterion on Apple M-series. cargo bench for all, cargo bench --features jit --bench jit_vs_interp for JIT comparisons. HTML report at target/criterion/report/index.html.

Classic algorithms

BenchmarkTimeOps/sec
fib_iterative(35)2.7 µs374k
fib_recursive(20) — 21,891 calls1.28 ms783
ackermann(3,4) — 10,547 calls774 µs1.3k
sum(1..1M) fused AccumSumLoop142 ns7.0M
sum(1..1M) unfused loop ops31.0 ms32
nested_loop(100×100)352 µs2.8k
dispatch_nop_1M — raw dispatch overhead819 µs1.22 Gops/sec
string_build(10k) via ConcatConstLoop11.9 µs84k

Interpreter vs Cranelift JIT vs native Rust

Slot-based inputs prevent constant folding — honest apples-to-apples comparison:

WorkloadInterpreterJIT (cached)Native RustJIT vs interpJIT vs native
slot_mixed × 1002.2 µs75 ns42 ns29x faster1.8x slower
slot_bitwise × 2006.6 µs130 ns74 ns51x faster1.8x slower
slot_float × 2003.1 µs246 ns137 ns13x faster1.8x slower

JIT cache lookup is O(1) — chunk hash precomputed at build time (24ns overhead). The linear JIT is consistently ~1.8x slower than LLVM -O3 on real computation and 13–51x faster than the interpreter.

Block JIT — loops and branches compiled to native code

The block JIT handles real control flow — loops, conditionals, fused backedges:

BenchmarkInterpreterBlock JITSpeedup
sum(1..1M) unfused loop30.0 ms315 µs95x
nested_loop(100×100)340 µs9.5 µs36x

The block JIT compiles the full CFG to native code via Cranelift. All mutable state flows through the slots pointer (*mut i64), and AccumSumLoop is register-allocated with block parameters — no memory traffic in the inner loop.

Float slots (SlotKind::Float). Slots are promoted to Cranelift i64 variables holding raw bits. When a slot's kind is Float, the i64 is the f64 bit pattern: GetSlot bitcasts I64 → F64 (and integer operands are converted with fcvt_from_sint before float arithmetic), SetSlot bitcasts F64 → I64. Pass slot kinds via try_run_block_kinded / try_run_block_eager_kinded; the kind vector is folded into the native-code cache key (TLS and the on-disk *.blk.fjit blob) so float-specialized code is never reused for an integer slot or vice-versa. The default try_run_block / try_run_block_eager (no kinds) treat every slot as Int. Storing a float into a slot the caller declared Int has no i64 encoding, so the block tier now declines the chunk rather than truncating: x = 3.5 into a slot that entered the chunk holding an integer used to read back as Int(3) once the block cache warmed (the interpreter answers Float(3.5) every time), and x = 1e30 reached Cranelift's trapping fcvt_to_sint and killed the process with SIGILL. Declare the slot SlotKind::Float and the value round-trips exactly. This is what lets a frontend block-JIT-compile f64 AWK numeric chunks (e.g. x = trunc(x + c), lowered through Op::TruncFloat) and persist them to the shared on-disk cache. Integer-only fused superinstructions (PreIncSlot, AccumSumLoop, SlotIncLtIntJumpBack, …) bail to the interpreter on a Float slot rather than miscompute it.

AWK math ops in the JIT. Op::AwkInt is never compiled natively, in any tier. It is host-dispatched — VM::run sends it to AwkHost::int, an overridable trait method — and the implementations in use disagree on the result variant: the default awk_host::awk_int narrows to Value::Int when the truncation fits an i64 (int(3.7) is Int(3)), while a frontend that models every AWK number as an f64 answers Float(3.0). Cranelift code sees neither, so any fixed lowering is a silent wrong answer for some host. Because the block tier warms up, the kind-preserving trunc it used to emit made the same VM running the same Chunk answer Int(3) on the first call and Float(3.0) on every call after. Past 2^53 there was a second, independent error: awk_host::awk_int truncates through an f64, so int($2^{53}$ + 1) is Int(9007199254740992), while native code that never left an integer register answered Int(9007199254740993). A frontend that wants awk's int() to run in native code should emit Op::TruncFloat, whose contract is pure (Float(trunc(x)) for every operand, no host consulted) and identical in all four tiers. The general rule: an op that reaches AwkHost may be lowered natively only if every conforming host produces exactly the value the lowering does. The transcendentals Op::AwkSin / AwkCos / AwkExp / AwkAtan2 compile to Cranelift libcalls into small extern "C" Rust helpers (fusevm_jit_sin_f64, …) that canonicalize a NaN result to +nan to match gawk/awkrs. These follow the same None-guarded import pattern as the existing pow/fmod/lognot libcalls — the helper imports are declared only when the op appears in the chunk (MathIds::declare), so chunks without them compile to byte-identical native code. For the on-disk cache the helper relocations are keyed by stable host-helper ids (H_SIN_F64H_ATAN2_F64), carried in the per-function [Option<FuncId>; 8] helper table and re-resolved on load via host_addr (cache SCHEMA_VERSION 16). The gawk bitwise builtins Op::AwkAnd / AwkOr / AwkXor (variadic, ≥2 args) also compile natively: each operand is converted to i64 with a saturating fcvt_to_sint_sat (matching awkrs's num_to_u64, which truncates and saturates NaN→0 / ±inf→i64 bounds rather than trapping), folded with Cranelift band/bor/bxor, and pushed back as an integer. No libcall and no host needed — pure integer arithmetic — so they are admitted to is_block_eligible_op directly.

Trapping div/mod in the JIT (guarded early-exit). Op::AwkDivJit / AwkModJit are the block-JIT-eligible counterparts of the interpreter-only AwkDiv/AwkMod. Float fdiv/fmod do not hardware-trap (they yield inf/NaN), so a JIT-compiled awk division must check the divisor explicitly: the codegen pops divisor then dividend, emits fcmp eq divisor, 0.0, and branches — the trap block calls the fusevm_jit_awk_div_trap(code) libcall (code = 1 for div, 2 for mod) into a thread-local channel and returns a sentinel, while the continuation block computes fdiv (div) or the fmod libcall (mod). After the compiled block returns, the VM's block-dispatch path calls take_awk_div_trap() and, if a code was set, raises the same fatal "division by zero attempted" / …in \%'` error the interpreter raises — before writing slots back. Because the trap libcall is not a registered host-helper id, these chunks skip on-disk persistence (in-process JIT only) and add nothing to the cache schema; frontends that never emit them (zshrs/stryke) are byte-identical.

Tracing JIT — hot loop bodies compiled to native code

cargo bench --features jit --bench jit_trace (Apple M-series). Trace recorded at threshold 5 (default 50 in production) so the cache is primed before measurement; all reported times are steady-state hot-path execution.

Synergistic three-tier dispatch (phase 10). When enable_tracing_jit() is called, VM::run consults all three Cranelift tiers in priority order: block JIT first if the chunk is fully eligible (zero VM-side overhead, direct fn-ptr through the slot pointer), tracing JIT for hot loops in chunks block JIT can't handle, interpreter for cold paths and edge cases. Block-eligible chunks short-circuit before tracing JIT records anything — the two tiers never compete on the same chunk.

BenchmarkIterationsInterpreterBlock JIT (direct)Tracing-JIT VMVM vs InterpVM vs Block
counter_loop1,00024.0 µs309 ns474 ns51x1.53x slower
counter_loop10,000236.1 µs2.69 µs2.79 µs84x1.04x slower
counter_loop100,0002,354 µs26.71 µs26.95 µs87x1.01x slower
loop_with_branch1,00040.2 µs300 ns474 ns85x1.58x slower
loop_with_branch10,000410.3 µs2.68 µs2.83 µs145x1.06x slower
loop_with_branch100,0003,942 µs26.46 µs26.64 µs148x1.01x slower

counter_loop is a tight for i { i++ } integer counter — about as friendly to a JIT as bytecode gets. loop_with_branch adds an internal if i > 0 { ... } inside the body to exercise the phase-3 branch-guard machinery; the recorded path's brif compares slot value to zero each iteration.

The "Block JIT (direct)" column measures JitCompiler::try_run_block invoked directly with no VM around it — the floor for what's achievable through the JIT pipeline. The "Tracing-JIT VM" column measures VM::run() with enable_tracing_jit() set on a block-eligible chunk; the VM auto-dispatches block JIT before reaching the interpreter. The remaining 1.0–1.7x gap between the two is purely VM construction + slot copy-in/out overhead per vm.run() call (constant, ~150-200 ns); native execution itself is identical.

For chunks that aren't block-eligible (anything with extension ops, host builtins, or polymorphic types), block JIT bows out and the same VM::run path falls through to the interpreter with tracing JIT's recorder armed at backward branches — that's where tracing JIT earns its keep, accelerating loops in code block JIT can't take. The two tiers cover disjoint cases at runtime.

VMPool — VM reuse for callers running many small chunks

VMPool recycles VM instances so callers running many short-lived chunks (REPL, eval loops, batch evaluation) can skip the per-call VM::new() cost. acquire pops a recycled VM and resets its state via VM::reset; release returns it for reuse.

use fusevm::{ChunkBuilder, Op, VMPool, VMResult, Value};

let mut pool = VMPool::new();
for _ in 0..1000 {
    let mut b = ChunkBuilder::new();
    b.emit(Op::LoadInt(40), 1);
    b.emit(Op::LoadInt(2), 1);
    b.emit(Op::Add, 1);
    pool.with(b.build(), |vm| {
        assert!(matches!(vm.run(), VMResult::Ok(Value::Int(42))));
    });
}

When the pool actually helps: chunks where VM::new() cost dominates the run. Measured on a 3-op chunk (LoadInt(40); LoadInt(2); Add):

PatternTime/call
VM::new(chunk) per call130 ns
pool.acquire(chunk) per call163 ns

For tiny chunks the pool is slowerreset does more bookkeeping (drop the old chunk, clear globals, zero the deopt buffer) than VM::new skips. The pool wins for chunks where:

  • Globals/name pool is large (>16 entries — reset's resize is amortized vs vec![Value::Undef; n])
  • Many slots get used (frame.slots Vec capacity is preserved across reuse)
  • Tracing JIT runs (deopt buffer is already zeroed and cached eligibility carries over… well, doesn't, since chunk hash differs — gets recomputed)

Honest read: VMPool is useful for multi-chunk evaluation loops with non-trivial chunk shapes. For uniform tight loops, pure VM::new is fine. The API is shipped so callers can pick. ~10 LOC if your call site looks like for chunk in ... { VM::new(chunk).run() }.

Frontend adoption. All eighteen sibling frontends already on the VM (strykelang, awkrs, zshrs, vimlrs, elisprs, rubylang, phplang, pythonrs, node-js, arb, rlang, javars, kotlinrs, scalars, groovyrs, go-rs, tclrs, texrs) drive fusevm::VM through bridge layers, NOT direct emit. The common pattern is: (1) frontend-side eligibility analysis (which subroutines / bodies / per-record rules can be lowered to fusevm ops at all), (2) op-vector → fusevm::Chunk translation cached behind frontend-owned OnceCell / HashMap so the 2-pass translation runs once per source program region, not per call, (3) VMPool on the frontend Runtime so VM::reset(chunk) recycles slot/stack/globals Vec capacities across invocations, (4) narrow writeback driven by a precomputed Vec<u16> of Op::SetSlot targets so only mutated slots get copied back to the frontend's runtime. The on-disk JIT cache (keyed by op-hash) handles compiled-code persistence; the per-frontend in-process caches above handle the upstream chunk-build and runtime-setup costs the disk cache can't touch. strykelang adds STK_VAL_LOAD_CONST to make LoadConst-bearing chunks disk-cache safe (index-based, not per-process pointer).

Tracking improvements

cargo bench --bench vm_bench -- --save-baseline before   # save baseline
# ... make changes ...
cargo bench --bench vm_bench -- --baseline before        # compare
open target/criterion/report/index.html                  # HTML graphs

[0x0B] WEBASSEMBLY / WEB WORKER

fusevm compiles to wasm32-unknown-unknown so a frontend (stryke/zshrs/awkrs) can parse source to a Chunk and run VM::run() inside a browser web worker. The worker build is the default, feature-less build:

cargo build --target wasm32-unknown-unknown          # interpreter only

The jit, aot, jit-disk-cache, and ffi features must stay off on wasm: they pull in Cranelift, libc (dlopen / executable mmap), and a runtime rustc shell-out, none of which target wasm. The interpreter is the whole worker runtime.

Clock

std::time::SystemTime::now() panics on wasm32-unknown-unknown (no clock), so the VM's clock ops (Op::TimeInt, awk systime/srand) read through [fusevm::sysclock], which is chrono-backed. On wasm, chrono reads Date.now() through the JS host (its wasmbind feature, active automatically for wasm targets), so the clock works in a worker with no extra wiring.

I/O bridging

wasm has no real stdout/stdin. Install an output sink and (optionally) an input source on the VM so Op::Print/Op::PrintLn/Op::ReadLine route to the JS host instead. With neither installed, output/input are byte-for-byte the previous direct-stdio behaviour, so native frontends are unaffected.

use std::sync::{Arc, Mutex};
use fusevm::VM;

let captured = Arc::new(Mutex::new(String::new()));
let buf = Arc::clone(&captured);

let mut vm = VM::new(chunk);
// Send-safe: the sink writes to an Arc<Mutex<_>>, not a JS handle, so VM
// stays Send (usable from VMPool on native).
vm.set_output_sink(Box::new(move |s: &str| buf.lock().unwrap().push_str(s)));
// Pre-loaded stdin; returns None at end of input (ReadLine then pushes Undef).
let mut lines = vec!["line 2".into(), "line 1".into()];
vm.set_input_source(Box::new(move || lines.pop()));

vm.run();
// In a worker: postMessage(captured). See examples/wasm_worker_host.rs.

examples/wasm_worker_host.rs is a runnable, pure-Rust demonstration (no wasm-bindgen dependency) that builds and runs on native and wasm alike:

cargo run   --example wasm_worker_host                              # prints "hello 42"
cargo build --example wasm_worker_host --target wasm32-unknown-unknown

Frontend glue

fusevm is a library; it carries no wasm-bindgen dependency. The frontend crate owns the #[wasm_bindgen] entry and the worker JS. A representative frontend entry and worker.js:

// in the frontend crate (compiled to a cdylib for wasm)
#[wasm_bindgen]
pub fn run_source(src: &str) -> String {
    let chunk = my_frontend::compile(src);        // source -> fusevm::Chunk
    let captured = std::rc::Rc::new(...);          // or Arc<Mutex<String>>
    let mut vm = fusevm::VM::new(chunk);
    vm.set_output_sink(/* append to captured */);
    vm.run();
    captured_string                                // returned to JS
}
// worker.js
import init, { run_source } from "./my_frontend.js";
await init();
self.onmessage = (e) => self.postMessage(run_source(e.data));

Caveats

  • External commands. Op::Exec/ExecBg/CallFunction have no process model in a worker. A host-less wasm build returns exit status 127; a frontend overrides ShellHost::exec to bridge to the JS host if it needs to.
  • Interactive stdin. The input-source hook covers pre-loaded input. Blocking interactive reads inside a worker require SharedArrayBuffer + Atomics.wait (which need cross-origin-isolation headers) and are the frontend's responsibility.
  • Filesystem/glob. ShellHost::glob and awk file getline have no fs in a worker; override the relevant host methods to bridge as needed.

[0x0C] COOPERATIVE CONCURRENCY

sched::Scheduler layers green-thread goroutines and channels over the single-VM dispatch loop, so a frontend (e.g. Go) can express go, make(chan), <-, and close without a bespoke runtime.

Each goroutine is its own VM sharing the program Chunk and the frontend's thread-local heap. Five interpreter-only ops raise a scheduling request in the VM and halt the chunk — the same "op stashes a value, halts, driver reads it after run()" pattern as Op::AwkSignal:

OpMeaning
Go(name_idx, argc)spawn a goroutine running sub name_idx with argc args
ChanMakeallocate a channel (capacity popped); pushes its id
ChanSendsend the popped value on the popped channel (may block)
ChanRecvreceive from the popped channel, pushing the value (may block)
ChanRecvOktwo-value receive: pushes [value, ok], ok on top (0 = closed and drained)
ChanCloseclose the popped channel

The scheduler owns the channel table and a run queue, reads each request via VM::take_sched, and resumes a VM by delivering results directly onto its stack (the op has already advanced ip, so a resumed VM continues past the op — no rewind, no re-execution). Channels follow CSP semantics: a buffered channel holds up to cap values; an unbuffered channel hands a value straight from a blocked sender to a blocked receiver. When every goroutine is blocked, the scheduler reports a deadlock (all goroutines are asleep).

A receive on a closed, drained channel yields the frontend's zero value (Scheduler::with_recv_zero), which ChanRecv alone cannot distinguish from a zero a sender really sent. ChanRecvOk is the two-value form that can: it pushes [value, ok] with ok on top — 1 for a real value, 0 once the channel is closed and drained — which is what for v := range ch and v, ok := <-ch need to terminate. Blocking is identical; a parked ChanRecvOk is delivered ok = 1 when a sender wakes it and ok = 0 when a close does.

use fusevm::{Scheduler, VM};

// `chunk` emits Go/ChanMake/ChanSend/ChanRecv; `make_vm` builds a fresh,
// fully-configured VM per goroutine (same chunk + builtins + hooks).
let fc = chunk.clone();
Scheduler::new(move || VM::new(fc.clone())).run(VM::new(chunk))?;

The model is single-threaded and cooperative — goroutines yield only at channel operations and completion — which is faithful for channel-driven programs without the data races an OS-thread model would introduce over a thread-local heap. Frontends that never emit these ops are wholly unaffected.


[0x0D] HOST HOOKS & CHUNK INTROSPECTION

The VM hands a frontend three decision points it cannot express in bytecode: what an unset read means, where an arithmetic op came from, and what a frame slot is called. All three are opt-in — a frontend that installs nothing keeps the previous behaviour byte for byte.

APIPurpose
VM::set_undef_hook(hook)Strict-undef mode. A read of an unset variable asks the host instead of yielding Undef. The hook takes an UndefRead and answers Ok(Value) to substitute a value or Err(String) to raise.
VM::is_strict_undef()Whether an undef hook is installed.
VM::set_sited_numeric_hook(hook)Like a NumericHook, but the callback receives a NumericCall carrying the site of the arithmetic. Wins over a plain NumericHook when both are set; either puts the VM in strict numeric mode.
VM::frame_slot_names()The current frame's slot names.
VM::slot_names_at(up)The same, up frames out from the current one.
ChunkBuilder::set_sub_slot_names(entry_ip, names)Name a sub-chunk's frame slots at build time.
Chunk::sub_slot_names_at(entry_ip)Read them back.

UndefRead identifies the read precisely enough that a frontend can refuse one and allow another: name (the interned name for a global, None for a frame slot — a slot is addressed by index and carries no name), index, from_slot, plus the owning chunk, because an ip alone does not identify a site once a frontend compiles nested scripts (an eval, a lambda) that hold more than one chunk.

Because a frame slot carries no name in the bytecode, the slot-name APIs are what let a strict-undef frontend report x is not defined rather than slot 3 is not defined.

A full pool refuses instead of aliasing

The constant and name pools are u16-indexed. add_constant / add_name cannot report exhaustion, so a frontend that overflows a pool would silently get an index that aliases an existing entry — a wrong-constant bug at run time with no diagnostic. The fallible forms return None instead:

let Some(idx) = b.try_add_constant(Value::str("hello")) else {
    return Err("constant pool exhausted".into());
};

try_add_name behaves the same for the name pool. Prefer both in any frontend whose input size is not bounded at compile time.


[0xFF] LICENSE

MIT — Copyright (c) 2026 MenkeTechnologies