```
███████╗██╗ ██╗███████╗███████╗██╗ ██╗███╗ ███╗
██╔════╝██║ ██║██╔════╝██╔════╝██║ ██║████╗ ████║
█████╗ ██║ ██║███████╗█████╗ ██║ ██║██╔████╔██║
██╔══╝ ██║ ██║╚════██║██╔══╝ ╚██╗ ██╔╝██║╚██╔╝██║
██║ ╚██████╔╝███████║███████╗ ╚████╔╝ ██║ ╚═╝ ██║
╚═╝ ╚═════╝ ╚══════╝╚══════╝ ╚═══╝ ╚═╝ ╚═╝
```
[](https://github.com/MenkeTechnologies/fusevm/actions/workflows/ci.yml)
[](https://crates.io/crates/fusevm)
[](https://crates.io/crates/fusevm)
[](https://docs.rs/fusevm)
[](https://menketechnologies.github.io/fusevm/)
[](https://opensource.org/licenses/MIT)
### `[LANGUAGE-AGNOSTIC BYTECODE VM WITH FUSED SUPERINSTRUCTIONS POWERING THE FASTEST INTERPRETED LANGUAGES]`
> *"One VM to run them all."*
## `[PATENT PENDING]`
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.
```sh
cargo add fusevm --features jit # with Cranelift JIT
cargo add fusevm # interpreter only
```
### [`Read the Docs`](https://menketechnologies.github.io/fusevm/) · [`Engineering Report`](https://menketechnologies.github.io/fusevm/report.html) · [`API Reference`](https://docs.rs/fusevm) · [`Crates.io`](https://crates.io/crates/fusevm) · [`strykelang`](https://github.com/MenkeTechnologies/strykelang) · [`zshrs`](https://github.com/MenkeTechnologies/zshrs)
---
## Table of Contents
- [\[0x00\] Overview](#0x00-overview)
- [\[0x01\] Install](#0x01-install)
- [\[0x02\] Usage](#0x02-usage)
- [\[0x03\] Architecture](#0x03-architecture)
- [\[0x04\] Fused Superinstructions](#0x04-fused-superinstructions)
- [\[0x05\] Op Categories](#0x05-op-categories)
- [\[0x06\] Extension Mechanism](#0x06-extension-mechanism)
- [\[0x07\] JIT Compilation](#0x07-jit-compilation)
- [\[0x08\] Ahead-of-Time Compilation](#0x08-ahead-of-time-compilation)
- [\[0x09\] Value Representation](#0x09-value-representation)
- [\[0x0A\] Benchmarks](#0x0a-benchmarks)
- [\[0x0B\] WebAssembly / Web Worker](#0x0b-webassembly--web-worker)
- [\[0x0C\] Cooperative Concurrency](#0x0c-cooperative-concurrency)
- [\[0x0D\] Host Hooks & Chunk Introspection](#0x0d-host-hooks--chunk-introspection)
- [\[0xFF\] License](#0xff-license)
---
## [0x00] OVERVIEW
fusevm is the shared execution engine behind seventeen language frontends — [zshrs](https://github.com/MenkeTechnologies/zshrs), [strykelang](https://github.com/MenkeTechnologies/strykelang), [awkrs](https://github.com/MenkeTechnologies/awkrs), [vimlrs](https://github.com/MenkeTechnologies/vimlrs), [elisprs](https://github.com/MenkeTechnologies/elisprs), [rubylang](https://github.com/MenkeTechnologies/rubylang), [arb](https://github.com/MenkeTechnologies/arb), [pythonrs](https://github.com/MenkeTechnologies/pythonrs), [phplang](https://github.com/MenkeTechnologies/phplang), [node-js](https://github.com/MenkeTechnologies/node-js), [rlang](https://github.com/MenkeTechnologies/rlang)/R, [javars](https://github.com/MenkeTechnologies/javars), [kotlinrs](https://github.com/MenkeTechnologies/kotlinrs), [scalars](https://github.com/MenkeTechnologies/scalars), [groovyrs](https://github.com/MenkeTechnologies/groovyrs), [go-rs](https://github.com/MenkeTechnologies/go-rs), and [tclrs](https://github.com/MenkeTechnologies/tclrs)/Tcl. 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
```sh
cargo add fusevm
# or from source
git clone https://github.com/MenkeTechnologies/fusevm && cd fusevm && cargo build
```
**Cargo features:**
| Feature | Effect |
|---------|--------|
| `jit` | Cranelift-backed native JIT (linear, block, and tracing tiers). |
| `jit-disk-cache` | Persists compiled native code to `~/.cache/fusevm-jit` so codegen is skipped across process restarts. Implies `jit`; on by default once enabled (see [JIT Compilation](#0x07-jit-compilation)). |
---
## [0x02] USAGE
```rust
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."*
| Tier | Entry | How it runs an op |
|------|-------|-------------------|
| **Interpreter** | `VM::run` (`src/vm.rs`) | Dispatch loop calls `exec_op(ops, ip, …)` per op; the returned `ExecFlow` says continue or terminate. |
| **Linear / Block / Tracing JIT** | `JitCompiler` (`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`. |
| **AOT** | `aot::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 Op | Replaces | Effect |
|----------|----------|--------|
| `AccumSumLoop(sum, i, limit)` | `GetSlot + GetSlot + Add + SetSlot + PreInc + NumLt + JumpIfFalse` | Entire counted sum loop in one dispatch |
| `SlotIncLtIntJumpBack(slot, limit, target)` | `PreIncSlot + SlotLtIntJumpIfFalse` | Loop backedge in one dispatch |
| `ConcatConstLoop(const, s, i, limit)` | `LoadConst + ConcatAppendSlot + SlotIncLtIntJumpBack` | String append loop in one dispatch |
| `PushIntRangeLoop(arr, i, limit)` | `GetSlot + PushArray + ArrayLen + Pop + SlotIncLtIntJumpBack` | Array 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`:
| Category | Count | Examples |
|----------|-------|---------|
| Constants & Stack | 12 | `Nop`, `LoadInt`, `LoadFloat`, `Pop`, `Dup`, `Swap` |
| Variables | 7 | `GetVar`, `SetVar`, `GetSlot`, `SetSlot`, `SlotArrayGet` |
| Arrays & Hashes | 20 | `ArrayPush`, `HashGet`, `MakeArray`, `HashKeys` |
| Arithmetic | 9 | `Add`, `Sub`, `Mul`, `Div`, `Pow` |
| String | 3 | `Concat`, `StringRepeat`, `StringLen` |
| Comparison | 14 | `NumEq`, `StrLt`, `Spaceship`, `StrCmp` |
| Logical / Bitwise | 10 | `RubyTruthy`, `LogNot`, `LogAnd`, `BitAnd`, `Shl`, `Shr` |
| Control Flow | 5 | `Jump`, `JumpIfFalse`, `JumpIfTrueKeep` |
| Functions / Scope | 5 | `Call`, `Return`, `PushFrame`, `PopFrame` |
| I/O | 3 | `Print`, `PrintLn`, `ReadLine` |
| Collections | 2 | `Range`, `RangeStep` |
| Higher-Order | 5 | `MapBlock`, `GrepBlock`, `SortBlock`, `ForEachBlock` |
| **Fused** | **11** | `AccumSumLoop`, `SlotIncLtIntJumpBack`, `ConcatConstLoop`, `PreIncSlot`, `PostIncSlot`, `PreDecSlot`, `PostDecSlot` |
| Builtins | 1 | `CallBuiltin(id, argc)` (140 IDs in `shell_builtins.rs`) |
| Shell Ops | 29 | `Exec`, `PipelineBegin`, `Redirect`, `Glob`, `TestFile`, `RegexMatch` |
| AWK Ops | 61 | `AwkFieldGet`, `AwkPrint`, `AwkStrtonum`, `AwkDivJit`, `AwkModJit`, `AwkGensub`, `AwkOrd`, `AwkChr`, `AwkMkbool`, `AwkIntdiv` |
| Float / Int Math | 26 | `SqrtFloat`, `Atan2Float`, `Log2Float`, `RoundFloat`, `GcdInt`, `LcmInt`, `TimeInt` |
| Cooperative Concurrency | 10 | `Go`, `ChanMake`, `ChanSend`, `ChanRecv`, `ChanRecvOk`, `ChanClose`, `Select`, `CallDynamic`, `MulModFloor`, `MulAddModFloor` |
| Extension | 2 | `Extended(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:
```rust
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.
```rust
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-arithmetic `Op::Div`/`Op::Mod`, which yield `Undef`/`0`). Host-independent; interpreter-only (not block/trace-JIT-eligible, since they conditionally trap). `AwkDivJit` / `AwkModJit` are block-JIT-eligible variants with byte-identical interpreter semantics: the block JIT emits a **guarded early-exit** (compare the divisor to `0.0`; on equality call the `fusevm_jit_awk_div_trap` libcall with a code — `1` div / `2` mod — and `return` a sentinel, else `fdiv`/`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/0` traps instead of producing `inf`/`NaN` or hanging. The trap libcall is not a registered host-helper id, so `AwkDivJit`/`AwkModJit` chunks skip on-disk cache persistence (in-process JIT only) and never touch the shared cache schema — zshrs/stryke (which emit only `Op::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.
```rust
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`.
```rust
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
| Category | JIT'd Ops |
|----------|-----------|
| Constants | `LoadInt`, `LoadFloat`, `LoadConst` (int/float), `LoadTrue`, `LoadFalse` |
| Arithmetic | `Add`, `Sub`, `Mul`, `Div`, `Mod`, `Pow`, `Negate`, `Inc`, `Dec` |
| Comparison | `NumEq`/`Ne`/`Lt`/`Gt`/`Le`/`Ge`, `Spaceship` |
| Bitwise | `BitAnd`/`Or`/`Xor`/`Not`, `Shl`, `Shr` |
| Logic | `LogNot` |
| Stack | `Pop`, `Dup`, `Swap`, `Rot` |
| Slots | `GetSlot`, `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:
| Tier | Trigger | Coverage | Speculation |
|------|---------|----------|-------------|
| **Linear** | `is_linear_eligible` + first call | Straight-line expression chunks; returns `Value` (int or float) | None — IR matches bytecode exactly |
| **Block** | `is_block_eligible` + 1 invocation | Whole-chunk CFG (loops, branches, fused backedges) | None — slot ops assume i64 |
| **Tracing** | 50 backedges through any loop header | Hot path through anything; recorded loop body compiled with type-specialized IR | Slot-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):
```sh
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 `Frame`s 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:
| Knob | Effect |
|------|--------|
| `FUSEVM_JIT_CACHE_MAX_BYTES` | Cap 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-jit` | Manual 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:
| Op | Contract | Notes |
|---|---|---|
| `Div` | **Always `Float`**, and `Undef` when the divisor is zero | `1 / 2` is `Float(0.5)`, never `Int(0)`. `-0.0` counts as a zero divisor (the test is `b.to_float() == 0.0`). |
| `Pow` | **Always `Float`** | `2 ** -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 and tracing** have no boolean kind at all: `JitTy` and the public
`BlockNum` are both Int-or-Float, so they **decline** instead. A boolean may
only be produced when the very next op consumes it as a truth value
(`JumpIfTrue`/`JumpIfFalse`) or discards it (`Pop`); anything else — storing
it to a slot, feeding it to arithmetic, or leaving it as the chunk's result —
makes the chunk ineligible and the interpreter runs it. Declining is always a
correct answer, so the hot loop shape (`GetSlot`, `LoadInt`, `NumLt`,
`JumpIfTrue`) is unaffected while the divergent shapes fall back.
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:
| chunk | 1st call (interpreter) | 2nd call (block JIT), before the fix |
|---|---|---|
| `1 < 2` | `Bool(true)` | `Int(1)` |
| `1 > 2` | `Bool(false)` | `Int(0)` |
| `LoadFalse` | `Bool(false)` | `Int(0)` |
| `true + 1` | `Float(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)`.
Widening the lattice — a `JitTy::Bool` through the whole block/tracing
pipeline plus a `BlockNum` variant — would let these chunks compile instead of
declining, and 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:
```text
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
`Kind`s, 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 `Variable`s (an `i64` and an `f64` per stack
position; the plan's `Kind`s 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 intrinsics** — `Abs`/`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.
### 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.
| API | Purpose |
|---|---|
| `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:
| Variant | Representation | Size |
|---------|---------------|------|
| `Undef` | Tag only | 0 bytes payload |
| `Int(i64)` | Inline | 8 bytes |
| `Float(f64)` | Inline | 8 bytes |
| `Bool(bool)` | Inline | 1 byte |
| `Str(Arc<String>)` | Heap | pointer |
| `Array(Arc<Vec<Value>>)` | Heap, shared buffer + copy-on-write | pointer |
| `Hash(HashMap<String, Value>)` | Heap, in-place mutation | 7 words |
| `Status(i32)` | Inline | 4 bytes |
| `Ref(Box<Value>)` | Heap | pointer |
| `NativeFn(u16)` | Inline | 2 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](https://crates.io/crates/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
| Benchmark | Time | Ops/sec |
|-----------|------|---------|
| `fib_iterative(35)` | 2.7 µs | 374k |
| `fib_recursive(20)` — 21,891 calls | 1.28 ms | 783 |
| `ackermann(3,4)` — 10,547 calls | 774 µs | 1.3k |
| `sum(1..1M)` fused `AccumSumLoop` | 142 ns | 7.0M |
| `sum(1..1M)` unfused loop ops | 31.0 ms | 32 |
| `nested_loop(100×100)` | 352 µs | 2.8k |
| `dispatch_nop_1M` — raw dispatch overhead | 819 µs | **1.22 Gops/sec** |
| `string_build(10k)` via `ConcatConstLoop` | 11.9 µs | 84k |
### Interpreter vs Cranelift JIT vs native Rust
Slot-based inputs prevent constant folding — honest apples-to-apples comparison:
| Workload | Interpreter | JIT (cached) | Native Rust | JIT vs interp | JIT vs native |
|----------|-------------|--------------|-------------|---------------|---------------|
| `slot_mixed × 100` | 2.2 µs | **75 ns** | 42 ns | **29x faster** | 1.8x slower |
| `slot_bitwise × 200` | 6.6 µs | **130 ns** | 74 ns | **51x faster** | 1.8x slower |
| `slot_float × 200` | 3.1 µs | **246 ns** | 137 ns | **13x faster** | 1.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:
| Benchmark | Interpreter | Block JIT | Speedup |
|-----------|-------------|-----------|---------|
| `sum(1..1M)` unfused loop | 30.0 ms | **315 µs** | **95x** |
| `nested_loop(100×100)` | 340 µs | **9.5 µs** | **36x** |
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_F64`…`H_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 `return`s 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.
| Benchmark | Iterations | Interpreter | Block JIT (direct) | Tracing-JIT VM | VM vs Interp | VM vs Block |
|---|---|---|---|---|---|---|
| `counter_loop` | 1,000 | 24.0 µs | 309 ns | **474 ns** | **51x** | 1.53x slower |
| `counter_loop` | 10,000 | 236.1 µs | 2.69 µs | **2.79 µs** | **84x** | 1.04x slower |
| `counter_loop` | 100,000 | 2,354 µs | 26.71 µs | **26.95 µs** | **87x** | 1.01x slower |
| `loop_with_branch` | 1,000 | 40.2 µs | 300 ns | **474 ns** | **85x** | 1.58x slower |
| `loop_with_branch` | 10,000 | 410.3 µs | 2.68 µs | **2.83 µs** | **145x** | 1.06x slower |
| `loop_with_branch` | 100,000 | 3,942 µs | 26.46 µs | **26.64 µs** | **148x** | 1.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.
```rust
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`):
| Pattern | Time/call |
|---|---|
| `VM::new(chunk)` per call | 130 ns |
| `pool.acquire(chunk)` per call | 163 ns |
For tiny chunks the pool is *slower* — `reset` 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 seventeen sibling frontends (strykelang, awkrs, zshrs, vimlrs, elisprs, rubylang, phplang, pythonrs, node-js, arb, rlang, javars, kotlinrs, scalars, groovyrs, go-rs, tclrs) 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
```sh
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:
```sh
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.
```rust
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:
```sh
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`:
```rust
// 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
}
```
```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`:
| Op | Meaning |
|---|---|
| `Go(name_idx, argc)` | spawn a goroutine running sub `name_idx` with `argc` args |
| `ChanMake` | allocate a channel (capacity popped); pushes its id |
| `ChanSend` | send the popped value on the popped channel (may block) |
| `ChanRecv` | receive from the popped channel, pushing the value (may block) |
| `ChanRecvOk` | two-value receive: pushes `[value, ok]`, `ok` on top (`0` = closed and drained) |
| `ChanClose` | close 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.
```rust
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.
| API | Purpose |
|---|---|
| `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:
```rust
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](https://github.com/MenkeTechnologies)