The Zinc VM: a typed register interpreter, with JIT tiers where they are allowed (design)
Design study, 2026-09-28. Status: proposal. It builds on docs/reports/ios-core-runtime.md (the iOS core, .zbc
bundles, OTA, App Store rules), which it does not repeat. The prototype lives in research/vm-proto/ and is
not part of the build. The sources are listed at the end.
Implementation update, 2026-09-29: an experimental typed backend (objects, arrays, closures, classes and a traced heap), shared scalar native C ABI, QuickJS CLI runner, and AArch64 baseline JIT are now integrated. See execution engines for actual commands, limitations and checks. The full architecture below remains a design; these prototype performance numbers do not describe the integrated backend.
Summary
- One bytecode, three engines.
.zbcis a typed register bytecode emitted from MIR.- Tier 0 is an interpreter built from musttail handlers. It runs everywhere, including iOS.
- Tier 1 is a copy-and-patch baseline JIT whose stencils come from the same handler source. It runs on macOS, Linux, Android and the Pi.
- Tier 2 is an optimizing JIT for hot typed loops, built on the MIR project’s backend.
- Measured on the prototype (M1 Pro, four kernels, median wall time):
- The best interpreter (tail calls, pre-decoded operands, an accumulator pinned in machine registers) is
6.6–10.7× faster than QuickJS and 2.0–5.3× faster than Hermes (Hermes 0.12 bytecode,
-O). - It is 2.8–15× slower than Zinc native, with a geomean around 6×. The 15× case (spectralnorm) falls to about 9× once a MIR inlining pass is modeled.
- The best interpreter (tail calls, pre-decoded operands, an accumulator pinned in machine registers) is
6.6–10.7× faster than QuickJS and 2.0–5.3× faster than Hermes (Hermes 0.12 bytecode,
- What the prototype taught:
- Typing removes tag checks. The accumulator is the next biggest win: 20–28% on float kernels.
- Switch dispatch costs 1.5–1.8× more than threaded dispatch.
- Super-instructions and
preserve_nonebarely register. - On a latency-bound float loop, a naive template JIT that keeps slots in memory is no faster than the interpreter. The same JIT with typed slots pinned to machine registers runs at 1.3× native.
- So the JIT’s value comes from register allocation, which typed bytecode makes almost free.
1. Bytecode
Register machine, not stack
The literature points the same way.
- Lua 5.0 moved from a stack VM to a register VM with three-address, 32-bit instructions. Each statement of their example compiles to one instruction instead of three or four. The paper measures a loop at 44% of Lua 4.0’s time, and fib at 72%.
- Hermes is register-based with 8-bit register operands (“no function ever uses more than 256 registers”)
and
Longvariants for spills and far jumps. - V8 Ignition is a register machine plus an implicit accumulator.
The counterexamples are stack machines for a reason:
- Dart’s kernel bytecode interpreter (the
DART_DYNAMIC_MODULESpath) is stack-based because it maps straight from Kernel AST. - Wasm interpreters such as Wizard and JSC’s IPInt interpret stack code in place, to avoid a translation pass, and pay for it with side tables.
Zinc has an SSA MIR with a CFG, so a register allocator comes for free: registers are MIR values after coalescing. Register code also maps 1:1 onto a JIT’s virtual registers. Register, then.
Typed operations
Sema already decides the machine type of every MIR value (i32, u32, f64, f32, fixed point, bool,
String, Ref<T>, Array<T>, Fn, Dyn). Opcodes therefore carry the type:
ADD_I32,ADD_F64,LT_I32_JMP;AGET_F64/ASET_REFwith bounds checks;GETF_F64 dst, obj, #offset, a field at a fixed C++ offset;CALL,CALL_AOT #thunk,CALLV(vtable slot),CALLF(closure);DYN_*, which only appears in gradual code.
Consequences:
- There is no tag check or boxing anywhere in typed code. A register is the raw C++ word, as
ios-core-runtime.mdrequires for shared values. - The loader’s verifier is a type checker over registers, like the JVM’s split verifier. It checks operand types per op, branch targets and frame sizes.
- Fixed point gets its own ops (
MUL_FX16), so the result is bit-identical tozrt::Fx<F>.
Encoding
- Instructions:
- 32-bit words: 8-bit opcode, then
A B C(8 bits each) orA Bx(8 + 16). - A
WIDEprefix gives 16/32-bit operands, like Hermes’sLongforms. - Jumps carry a 16-bit signed offset, or a following word.
- Constants live in a per-function pool.
f64constants useK-operand variants (ADDK_F64,DIVK_F64,KDIV_F64, as in Lua 5.4), so noLOADKis spent in inner code.
- 32-bit words: 8-bit opcode, then
- At load, each function is pre-decoded into 16-byte
{handler*, a, b, c, flags, imm32}records. That memory is plain data, so this is legal on iOS. - The accumulator. The prototype adds one operand value,
ACC, which is legal in the typed-value positions of each op:- Pre-decoding picks a handler specialization per combination of accumulator operands, e.g.
MUL_F64<acc=A|B>becomesfmul d0, d0, d1. - MIR’s single-use expression temporaries (a value defined and consumed by the next op) are the ones assigned
to
ACC. - This is V8 Ignition’s accumulator and wasm3’s
r0/fp0, but typed. There are two accumulators, one integer and one float, which never need to be distinguished at run time.
- Pre-decoding picks a handler specialization per combination of accumulator operands, e.g.
Super-instructions
Candidates:
- compare-and-branch (
LT_I32_JMP); FORLOOP(increment, compare, branch);GETF+arith (ADDF_F64 dst, obj, #off);AGET+arith;CALL_AOTwith its error check (CHECKfolded in, RT-05).
In the prototype, FORLOOP versus its two-op expansion made no measurable difference (±3%, noise): out-of-order
cores overlap a cheap extra dispatch. Super-instructions should come from profiles of the conformance suite
(counting op pairs) rather than intuition. The ones that pay remove memory traffic: GETF+arith, and array
access with its bounds check folded.
Inline caches for Dyn
Typed objects need no cache: GETF uses the C++ field offset from abi.ts’s class descriptors.
Caches only matter for Dyn (decision 0014). Today DynObj is a hashed Map<String, Dyn> and there are no
per-site caches (DYN-04 was deferred). The VM version:
- Hidden classes for
DynObj. Add aShape*(an interned, transition-linked key list) and store values in a slot vector. Insertion order is preserved by construction, so JSON output stays identical. - Per-site cache.
DYN_GET dst, obj, #key, #icindexes a per-function IC array:{Shape*, slot}in the monomorphic case, 4 entries in the polymorphic one, then megamorphic. This is Hermes’sGetById … cacheIdx, and Luau’s scheme where the compiler predicts a hash slot and the VM corrects it at run time. - Typed objects seen through Dyn. The cache keys on the class id and caches the field offset plus a type
tag. That replaces today’s virtual
zrt_get/zrt_setstring compare. Map<K,V>and records, when typed, are not Dyn and need no IC.
The native emitter can use the same Shape and IC layout later; it is a runtime change that is not VM-specific.
Quickening without a JIT
Rewriting pre-decoded records at run time is plain data mutation and is allowed on iOS:
- The
DYN_ADDhandler rewrites itself toDYN_ADD_NUMafter seeing two numbers, falling back on a miss. DYN_GETrewrites toDYN_GET_MONOwith the shape and slot inlined in the record.CALLVrewrites to a directCALLwhen a site stays monomorphic. It is guarded by a class-id compare.
CPython 3.11’s specializing interpreter follows this pattern. Typed code rarely needs it: Sema already specialized it.
2. Dispatch: measured
The prototype (research/vm-proto/vm.cpp) is about 500 lines. It has 34 typed ops, whose semantics are written
once as macros and expanded into each strategy. Four kernels from tests/bench/kernels are hand-assembled the
way MIR would lower them (same algorithms, sizes and float evaluation order). Every engine prints byte-identical
output, and run.mjs asserts it.
node research/vm-proto/run.mjs --runs 7 measures median wall time from spawn to exit, in ms, on an M1 Pro
running macOS 15.7 with Apple clang 21 -O2.
The machine was shared with other jobs (load average ~15), so compare within a row. Absolute times are about 10–20% above PERF.md’s quiet run.
An earlier run agrees within about 10%: tail-acc took 40 / 71 / 366 / 646 ms, and tail 40 / 93 / 492 / 862 ms. A third run at load 20–26 was discarded; even native mandelbrot moved 4× in it.
| Kernel | switch | goto | goto-direct | tail | tail-acc | tail-acc, no FORLOOP | tail-acc + inline | Zinc native | QuickJS | Hermes 0.12 (hbc) | Node 24 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| fib | 77 | 46 | 43 | 43 | 43 | 41 | 42 | 12 | 282 | 226 | 85 |
| mandelbrot | 157 | 89 | 98 | 99 | 78 | 75 | 77 | 28 | 836 | 154 | 82 |
| nbody | 636 | 396 | 477 | 463 | 361 | 365 | 359 | 39 | 2730 | 1373 | 350 |
| spectralnorm | 1072 | 769 | 949 | 900 | 656 | 675 | 382 | 44 | 4315 | 2026 | 123 |
The strategies:
- switch:
for(;;) switchover the instruction words. - goto: computed goto, token-threaded (
goto *L[op]) over 8-byte words. - goto-direct: computed goto, direct-threaded over pre-decoded 16-byte records.
- tail:
[[clang::musttail]]handler chain over pre-decoded records. - tail-acc: tail, plus the int and f64 accumulators as handler arguments, i.e. pinned in
x2/d0. - +inline: spectralnorm’s
A(i,j)inlined, as a MIR inlining pass would do.
A preserve_none build of tail-acc measured within 3% of tail-acc.
What the measurements say:
- Switch is last everywhere, 1.4–1.8× slower than the threaded variants. It is kept only as the portable fallback (MSVC, old GCC).
- Without the accumulator, the threaded variants are within about 10% of each other. Token threading over 8-byte words even beats 16-byte pre-decoded records on the float kernels, because the instruction stream is smaller.
- The accumulator is the big lever: −20% on mandelbrot, −22% on nbody and −27% on spectralnorm compared
with
tail. It is only natural with musttail, where the handler arguments are machine registers. Clang then compilesMUL_F64 ACC, ACC, r5to five instructions:ldr x4,[x0,#16]!; ldurb w8,[x0,#-7]; ldr d1,[x1,x8,lsl#3]; fmul d0,d0,d1; br x4. With computed goto, one label per specialization would have to be written by hand. - Calls dominate after that. spectralnorm makes 40 M calls to
A. Inlining it cuts the time by 42%. Frames are the Lua scheme: the callee’s window starts at the caller’s argument base, soCALLdoes no copying. MIR inlining of small leaf functions is worth more to the VM than to native code, where clang already inlines. - The residual gap to native is frame traffic: every operand is a load or store relative to
fp. On nbody, native keeps the whole body pair in registers, and the VM does 3GETFper field update. - CPython measured the same thing. Its tail-call interpreter’s first “10–15%” win shrank to 1–5% once a clang 19 computed-goto regression was accounted for. The dispatch technique matters less than what the handlers keep in registers.
Choice:
- musttail handlers with pre-decoded records and accumulator specializations (clang, and GCC ≥ 15, which has
musttail); - computed goto on 8-byte words where musttail is unavailable;
- switch as the last resort.
A handler table is data, and the handlers are ordinary signed code, so all three are iOS-legal.
3. The no-JIT tier (iOS)
Ingredients, in order of measured or expected weight:
- Typed ops, unboxed registers. An
f64register is adoubleand ani32register is anint32_t. There are no tag checks or overflow-to-double paths, unlike QuickJS and Hermes. Hermes’sAddN/MulNget partway there when its optimizer proves a number, which explains its relatively good mandelbrot. - Accumulator specializations and K-operand forms (above).
- RC elision from MIR (the pass decision 0001 was waiting for).
- Registers are borrowed unless MIR says owned: parameters, locals that are never stored, loop variables.
- Owned registers get explicit
RETAIN/RELEASEops placed where the C++ destructor would run (§5). - The prototype runs borrowed and drops only its one owned temporary (
DROP_ARR). - Native Zinc today counts through RAII
Ref<T>copies. That is where binarytrees and sort spend their time, and where the VM can come close to native.
- AOT runtime calls with the shared representation. Strings,
Map,Set, sort, JSON, formatting, the kit and the UI are compiled into the core.CALL_AOTpasses registers as-is throughabi.tsthunks, so a VM string is aStrObj*. Runtime-bound kernels therefore run near native speed. - Cheap closures.
- A VM closure is a
FnObj<Sig>subclass (VmFn<Sig>, one per signature in the ABI table) holding a function pointer and captured values. Mutated captures are cells, as in HIR today. - Inside the VM,
CALLFchecksvtbl == VmFnand enters the callee’s pre-decoded code directly, without a C++call(). - Luau-style caching can reuse non-capturing closures.
- A VM closure is a
- MIR passes the VM needs more than native does:
- inlining of small leaves (the measured −42%);
- range analysis (
number→i32loop counters, LNG-04 already in Sema); - bounds-check hoisting.
Per-kernel estimate against PERF.md’s quiet-machine numbers:
- Measured kernels are scaled by the tail-acc/native ratio in the table above. For spectralnorm, the inlined ratio is used.
- The others are estimated from where their time goes.
- The Hermes column is Hermes 0.12 measured here, or a guess (~) from the op mix.
- Node includes about 45 ms of startup.
| Kernel | Zinc native | Zinc VM (est.) | QuickJS | Hermes | Node | VM vs QuickJS | Basis |
|---|---|---|---|---|---|---|---|
| fib | 14.7 | ~55 | 240 | 226 | 69 | 4.4× faster | measured 3.7× native |
| mandelbrot | 21.1 | ~60 | 694 | 154 | 73 | 11× faster | measured 2.8× |
| nbody | 33.8 | ~310 | 2716 | 1373 | 355 | 8.8× faster | measured 9.2× |
| spectralnorm | 54.3 | ~470 | 4306 | 2026 | 114 | 9× faster | measured 8.7× with inlining |
| fannkuchredux | 370 | ~2 600 | 7741 | ~3 500 | 274 | ~3× faster | int array loop, like nbody (~7×) |
| sort | 156 | ~450 | 835 | ~900 | 667 | ~1.9× faster | native sort; comparator re-enters the VM, 20 M × ~15 ns |
| binarytrees | 13.3 | ~30 | 109 | ~120 | 57 | ~3.5× faster | zrt alloc + RC calls; ~2× native |
| mapset | 10.0 | ~16 | 101 | ~110 | 68 | ~6× faster | Map/Set in AOT; ~1.6× |
| strings | 27.4 | ~35 | 57 | ~70 | 83 | ~1.6× faster | StrBuilder in AOT; ~1.3× |
| jsonout | 2.1 | ~3 | 4.7 | ~6 | 50 | ~1.5× faster | JSON in AOT |
Geomean: about 3.2× slower than Zinc native, 4× faster than QuickJS, and about 3× faster than Hermes 0.12.
That is “Hermes class or better” with margin. The caveat is that Hermes has improved since 0.12 (2022), and Static Hermes adds typed compilation. On the compute kernels the VM is at parity with Node, including Node’s startup. Without the startup Node is well ahead: 2–5× on nbody and spectralnorm.
4. JIT tiers (macOS, Linux, Android, Raspberry Pi)
Tier 1: copy-and-patch baseline
Copy-and-patch (Xu and Kjolstad, OOPSLA 2021):
- Clang compiles C++ stencils with holes (relocations standing for operands, constants and continuation addresses).
- At run time, the JIT memcpys stencils and patches the holes.
- The paper reports code generation 2 orders of magnitude faster than LLVM -O0, with better code.
Two production data points:
- CPython 3.13 (PEP 744) builds stencils with LLVM at build time (3–60 s of build) and runs “about as fast” as its specializing interpreter, with 10–20% more memory.
- Deegen generates both the interpreter and a baseline JIT from one semantic description. Its baseline is 360% faster than PUC Lua, and 33% slower than LuaJIT’s optimizing JIT.
For Zinc the stencils come from the same BODY_* handler source compiled a second time, with operands as
extern symbols (holes) and the musttail continuation as a hole that becomes a fall-through. The new code is
limited to an object-file extractor (a build-time Node script reading Mach-O/ELF relocations) and a
patcher of about 1 KLOC.
Prototype result (mandelbrot, arm64, MAP_JIT; 42 bytecodes compiled in 8–10 µs):
| mandelbrot (ms) | Interpreter, tail-acc | Template JIT, slots in memory (464 B) | Template JIT, typed slots pinned to registers (220 B) | Zinc native |
|---|---|---|---|---|
| median | 78 | 82 | 36 | 28 |
- A naive stencil JIT that loads and stores every operand from the frame gains nothing on this loop. The
loop-carried dependency through
x/ypays store-to-load forwarding either way, and the interpreter’s dispatch hides behind that latency. - Pinning typed slots to registers is what pays: 2.2× over the interpreter, 1.3× of native.
- Typed bytecode makes it trivial. Every slot has one static type, so
f64slots map tod16..d31andi32slots to sparexregisters, with no guards and no allocator. - A JS baseline JIT such as Sparkplug cannot do this; it keeps the interpreter’s frame layout, and gains 5–15% on Speedometer.
- Typed bytecode makes it trivial. Every slot has one static type, so
- So Tier 1 should be copy-and-patch with a static slot-to-register map for the hottest typed slots of a function, spilled around calls. The spills are stencil variants, much like the accumulator variants.
Tier 2: optimizing
The input is the MIR (SSA, CFG, types), not the bytecode, so all of Sema’s knowledge is kept. It covers typed hot loops and Dyn code with shape guards.
| Backend | Size | Compile speed | Code quality | Platforms | Fit |
|---|---|---|---|---|---|
| MIR (Makarov) | 23 K LOC, ~557 KB | ~100× faster than GCC -O2 | ~91% of GCC -O2 | x86-64, aarch64 (Linux/macOS), ppc64le, s390x, riscv64 | chosen: C API, MIT, has lazy BB versioning |
| Cranelift | a large Rust dependency | ~10× faster than LLVM | ~14% slower than LLVM | x86-64, aarch64, s390x, riscv64 | Rust-only API; a C shim and cargo in a C++ build |
| sljit | small | very fast (no optimizer) | template-level | many ISAs, 32-bit ARM included | an alternative for Tier 1 on armv7 |
| asmjit | ~500 KB | assembler only | depends on our allocator | x86, x64, AArch64 only | would mean writing a backend |
| LLVM ORC | tens of MB of LLVM libraries | slow | best | everywhere | too big for a device runtime; fine for zinc dev on desktop |
MIR’s C API (MIR_new_func, MIR_gen) takes a low-level typed IR that Zinc MIR lowers into almost 1:1. Its
aarch64 and x86-64 backends cover macOS, Linux, Android and the Pi 4/5. The Pi Zero / armv6 stays on Tier 0
(or sljit).
Tier-up, OSR, deopt
- Counters. Each function has an entry counter, and each loop has a back-edge counter in the
FORLOOP/JMPback-edge handlers (the Ignition / Sparkplug pattern).- Tier 1 compiles at about 100 calls or 1 000 iterations, since it is cheap.
- Tier 2 compiles at about 10 000, on a background thread.
- OSR. At a loop header the frame layout is the bytecode register file, and Tier 1 keeps it for spilled slots. Entry reloads the pinned slots from the frame.
- Deopt.
- Typed code has nothing to guard. Guards appear only for Dyn shape checks, quickened
CALLVtargets and speculativei32ranges. - Each guard carries a deopt map from machine registers to bytecode registers. Deopt writes the registers back and resumes the interpreter at the guard’s bytecode.
- RC stays exact because ownership is a MIR property, the same in every tier (§5).
- Typed code has nothing to guard. Guards appear only for Dyn shape checks, quickened
W^X
- macOS arm64.
mmap(MAP_JIT), thenpthread_jit_write_protect_np(0)→ write →(1)→sys_icache_invalidate.- The toggle is per thread, so the Tier 2 background thread writes while other threads keep executing.
- A hardened-runtime app needs the
com.apple.security.cs.allow-jitentitlement. - The prototype exercises exactly this sequence.
- Linux, Android.
- Use a dual mapping: one
memfd_createfile mapped RW at one address and RX at another, so no page is ever W+X. This is the approach PEP 744 requires (“at no point is the data both writable and executable”). - Fall back to
mprotectflips when memfd is unavailable. - Flush with
__builtin___clear_cache.
- Use a dual mapping: one
- iOS. Not applicable: Tier 0 only.
5. Memory and RC: identical destruction order
The sim oracle compares bytes. Output depends on destruction order: weak references observe it (MEM-13),
using/finally run at scope exit, and future finalizers would too. So the VM must release at the same
points as native. The rule:
- The C++ emitter’s RAII semantics become explicit MIR facts:
- a temporary dies at the end of its full-expression;
- a local dies at the end of its scope, in reverse declaration order;
- a parameter dies at return;
- a moved value (return, last use into a store) is not released.
- A single RC pass on MIR, shared by
emit-bc.tsand lateremit-cpp.ts, computes owned vs borrowed registers and emitsRETAIN/RELEASE. It may elide a pair only when no observable event (a call, a release of another object, a store) lies between them. - While native still uses
Ref<T>RAII, the pass runs in “C++ order” mode and differential tests hold it honest. - On the exception path (RT-05), the
CHECKop jumps to a per-scope landing block that releases the owned registers in the same order as the C++ scope guards. - The accumulator never holds an owned reference. Accumulator ops are typed scalars only, so RC never crosses a register-cached value.
6. Interop
- VM → AOT.
CALL_AOT #thunk, base, n. The thunk is generated byabi.tsper exported signature: it reads typed registers and calls the C++ function. Pointers are passed as borrowed and results come back owned. There is no marshalling, because the register holds the C++ word. - Classes.
abi.tsclass descriptors give field offsets, soGETF/SETFare direct. A VM class extending a native one (a React component) is aVmSub_<Base>whose virtual overrides trampoline into VM methods. - UI engine → VM callbacks. The UI holds
Fn<Sig>=Ref<FnObj<Sig>>. A VM closure is aVmFn<Sig> : FnObj<Sig>, and itscall()pushes a VM frame on the current thread’s VM stack and runs the interpreter untilRET. Re-entry must be cheap (~15 ns), since sort comparators and layout callbacks re-enter per element. It needs:- no allocation per call;
- the register file preallocated;
- a
cspsentinel so thatRETinto native returns from the handler chain.
- Errors (RT-05).
THROWsetszrt::g_errexactly as native does.- After a
CALL_AOT, and after any VM call to athrowsfunction, a foldedCHECKtestsg_errand jumps to the landing block, or returns if the frame has no handler. - Crossing into native is invisible: native checks
g_errafter the call as it always has. - Panics (bounds, null, division by zero) call the same
zrt::panicwith the same text, and add the bytecode location for the red box.
- Async and generators.
SUSPEND/RESUMEsave the pre-decodedipand the register window into the heap frame (decision 0007), and the microtask queue is shared.
7. Testing
zinc test --target vmruns every conformance program under Tier 0. The runner’s existing byte-exact comparison makes sim, native and the VM mutual oracles.- Tier-forcing flags.
--vm-tier=0|1|2pins a tier.--vm-tier=stresscompiles every function at its first call and deopts at every guard’s first execution.- Every conformance program runs in each mode in CI (macOS arm64, Linux x86-64).
- Mixed mode. For each program, AOT a random subset of modules and interpret the rest, to exercise the ABI thunks both ways.
- Differential fuzzing.
- A generator of well-typed Zinc programs (in the Csmith style, over the strict profile plus Dyn) runs under
sim, native and each VM tier, comparing output bytes and live-object counts (
zrt::live_objects). - Bytecode-level fuzzing of the verifier: mutated
.zbcfiles must be rejected or run safely. This matters because OTA bundles are untrusted input.
- A generator of well-typed Zinc programs (in the Csmith style, over the strict profile plus Dyn) runs under
sim, native and each VM tier, comparing output bytes and live-object counts (
- The dispatch prototype’s check (every strategy prints the QuickJS checksum) becomes a unit test of the handler macros.
8. Size budget (arm64, -Os)
| Component | Estimate | Basis |
|---|---|---|
| Tier 0 handlers | 25–40 KB | prototype: 34 ops = 1.7 KB unspecialized (~50 B/op). ~250 ops × ~2 useful ACC variants |
| Loader, verifier, pre-decoder, IC runtime, frames, VmFn, deopt entry | 30–50 KB | |
| ABI thunks and descriptors | 50–300 KB | depends on the exported API surface (the kit is large) |
| Tier 0 total over zrt | ~100–400 KB | Luau’s core loop is ~16 KB on x64 |
| Tier 1 stencils and patcher | +40–80 KB | stencils ≈ handler size again, plus relocation tables |
| Tier 2 (MIR generator) | +~560 KB | MIR README |
For comparison:
- QuickJS: the
qjsbinary is 724 KB, and aqjschello world is 776 KB (PERF.md). Bellard quotes 367 KiB of x86 code. - Hermes: the 0.12
hermesCLI (compiler included) is 7.8 MB universal. The runtime-only library is smaller, but still multi-MB with ICU and the JS builtins.
Zinc’s builtins are the existing AOT zrt, which native apps already carry, so the VM adds only the engine.
9. Phased plan (one experienced engineer)
| Phase | Content | Effort | Reuses |
|---|---|---|---|
| V0 | Make MIR total: try/catch error edges, async/generator resume edges, cells, closures. Add the RC pass (owned/borrowed, C++ order) | 5–7 wk | hir.ts, mir.ts (SSA, folding, DCE) |
| V1 | emit-bc.ts: register allocation by coalescing, typed ops, K forms, ACC assignment. .zbc format and verifier | 5–6 wk | MIR, Sema types, dynShape plans |
| V2 | Tier 0 interpreter: musttail + goto + switch from one handler source; CALL_AOT, VmFn, g_err, panics; --target vm on the whole conformance suite | 6–8 wk | zrt, sim oracle, zinc test |
| V3 | abi.ts thunks and class descriptors; mixed mode (the same work the iOS core needs) | 4–5 wk | emit-cpp.ts’s naming and layout |
| V4 | Dyn shapes and ICs, quickening | 3–4 wk | zrt_dyn.h |
| V5 | MIR inlining, ranges, bounds hoisting; super-instructions from profiles | 3–4 wk | MIR passes (benefit native too) |
| V6 | Tier 1 copy-and-patch with pinned typed slots; OSR; W^X on macOS and Linux | 6–8 wk | handler sources as stencils |
| V7 | Tier 2 on MIR (Makarov): lowering, background compile, deopt maps, Dyn guards | 8–10 wk | Zinc MIR |
| V8 | Fuzzers, tier-stress CI, the size diet | ongoing, 3 wk to set up | conformance suite |
V0 to V3 are about 5–6 months. That matches the “P1: VM, byte-exact” line in ios-core-runtime.md and delivers
the iOS tier. V4 to V5 bring Dyn and the performance targets. V6 and V7 (about 4 months) are optional, for
desktop, Android and the Pi.
Risks:
| Risk | Mitigation |
|---|---|
| RC order drift between VM and native | One RC pass for both emitters; live-object counts in differential tests |
| Frame traffic keeps nbody-like code at ~9× native | Tier 1 pinning off iOS; on iOS, GETF+arith super-instructions and zinc promote (AOT the hot module) |
| Compiler-specific dispatch (musttail) | Computed-goto and switch builds from the same macros, tested in CI |
| Re-entry cost for callbacks (sort, UI) | Preallocated VM stack, no allocation per call, measured in V2 |
| MIR-project maintenance (one author) | Tier 2 is optional; the Tier 1 stencils need nothing external |
| Verifier bugs become sandbox escapes | Bytecode fuzzing; no raw-pointer ops in bundles |
Appendix: prototype
research/vm-proto/vm.cpphas the ops, the five dispatch strategies, thejit/jit-pinexperiment and the kernels.research/vm-proto/run.mjsbuilds everything, runs each engine, checks outputs and prints the tables. WithHERMES/HERMESCset to thehermes-engine-cli@0.12.0npm binaries, it also runs Hermes. That version has noclass, so nbody’s single class is rewritten into a constructor function for Hermes only.- The code is research only. It is not wired into the build and changes no compiler or runtime behaviour.
Sources (read for this report)
- Lua 5.0 implementation, register VM and benchmarks: https://www.lua.org/doc/jucs05.pdf
- Hermes design and bytecode list (register VM,
Reg8/Longforms,AddN,GetByIdcache index): https://github.com/facebook/hermes/blob/main/doc/Design.md, https://github.com/facebook/hermes/blob/main/include/hermes/BCGen/HBC/BytecodeList.def - QuickJS (sizes, bench): https://bellard.org/quickjs/
- Luau performance (IC with predicted slots, fastcalls, ~16 KB core loop, optional JIT): https://luau.org/performance
- wasm3 interpreter (M3 tail-call threading,
r0/fp0register caching, 4–15× native): https://github.com/wasm3/wasm3/blob/main/docs/Interpreter.md - musttail interpreters: https://blog.reverberate.org/2021/04/21/musttail-efficient-interpreters.html. CPython
3.14 tail-call interpreter and its correction: https://docs.python.org/3/whatsnew/3.14.html,
https://blog.nelhage.com/post/cpython-tail-call/. GCC 15
musttail: https://gcc.gnu.org/gcc-15/changes.html - V8 Ignition and Sparkplug: https://v8.dev/blog/ignition-interpreter, https://v8.dev/blog/sparkplug
- Copy-and-patch: https://arxiv.org/abs/2011.13127. Deegen: https://arxiv.org/abs/2411.11469, https://sillycross.github.io/2022/11/22/2022-11-22/. PEP 744: https://peps.python.org/pep-0744/
- Dart VM and the
DART_DYNAMIC_MODULESKBC interpreter: https://mrale.ph/dartvm/, https://github.com/dart-lang/sdk/blob/main/runtime/vm/interpreter.h, https://github.com/dart-lang/sdk/blob/main/runtime/vm/constants_kbc.h - In-place Wasm interpretation (Wizard): https://arxiv.org/abs/2205.01183
- MIR project: https://github.com/vnmakarov/mir. Cranelift: https://cranelift.dev. sljit: https://github.com/zherczeg/sljit. asmjit: https://asmjit.com
- Apple JIT porting guide (MAP_JIT,
pthread_jit_write_protect_np,sys_icache_invalidate): https://developer.apple.com/documentation/apple-silicon/porting-just-in-time-compilers-to-apple-silicon. The page renders in JS only; the API sequence was verified by compiling and running the prototype against the macOS SDK.