Zipp engine architecture
Zipp is designed so a JIT can keep unboxed values in machine registers across call boundaries, and so every optimization has an exact, cheap way back to the interpreter. This page walks the pipeline from source to native code and explains the decisions behind it.
The pipeline
JavaScript or Python source → front end → register bytecode → interpreter → hot-loop OSR or whole-function JIT → x86-64 or ARM64 native code. The garbage collector, object shapes and inline caches sit beside every stage. On wasm32, and on native targets without a JIT, the same bytecode runs on the pure interpreter.
| Crate | Role | Rust lines |
|---|---|---|
zipp-vm | Lexer, parser, compiler, bytecode, interpreter, runtime, GC, native JITs, Python frontend | 345,805 |
zipp-cli | The zipp binary: js, mjs, py, run, sandbox | 3,962 |
zipp-wasm | Persistent-VM WebAssembly embedding for browser hosts (own workspace) | 3,549 |
zipp-sandbox | Hardened native runner with no JIT and unsafe forbidden (own workspace) | 206 |
zipp-regress | Fork of the regress ECMAScript regex engine with a UTF-16 mode and an x86-64 regex JIT | 51,966 |
rustpython-parser | Vendored Python parser, parser only, used by the Python frontend | 72,455 |
The sandbox and WebAssembly crates are separate Cargo workspaces on purpose: Cargo unifies features across a workspace, and keeping them apart is what guarantees at compile time that a hardened build contains no VM JIT, no regex JIT and no unsafe code in either engine.
Values: NaN-boxing in one u64
A Value is a single u64, Copy, exactly eight bytes. A finite or infinite double is stored as itself. Everything else lives inside the quiet-NaN space: tag 0x7FF9 is a 32-bit integer, 0x7FFA a boolean, 0x7FFB null, 0x7FFC undefined and 0x7FFD a 32-bit heap index. Numbers that fit in an i32 are stored as integers, which is what lets the INT JIT tier run arithmetic on raw machine integers with an overflow check instead of on doubles.
Heap references are indices, not pointers. The heap is a Vec<HeapObj> addressed by u32, and that single decision shapes the collector: it never has to move an object, because identity is the slot number.
An explicit-frame register VM
Each function compiles to a flat Vec<Instr> over a fixed register file of reg_count slots indexed by u16. Registers, not a value stack, are the addressing mode; the instruction set favours three-address arithmetic and is kept as a fieldful Rust enum rather than packed bytes so the JIT consumes the same structured form the interpreter does.
JavaScript recursion lives in a Vec of frames over that register file, never on the native Rust stack. The consequences are practical: deep recursion ends in a catchable RangeError; calls, exceptions, async jobs, modules, realm state and host hooks are runtime data structures; and native deoptimization can flush register homes and resume the interpreter at an exact bytecode instruction because nothing about the interpreter's state is hidden in native stack frames.
Objects, shapes and inline caches
A shape names a key sequence: every object built by appending the same property names with the same attributes in the same order shares one, so "do these two objects lay out identically?" becomes a u32 compare. Layouts that stop benefiting from sharing fall back to dictionary mode.
Per-call-site inline caches on GetProp, SetProp, Call, CallMethod and SuperMethod guard on receiver identity or exact shape plus live heap versions and prototype-hop versions. Every optimized property route has a precise slow fallback, and caches are only installed in main-program or loader-recorded module functions, never in eval or new Function code.
Strings are either a contiguous Str with cached length and ASCII metadata, or a Cons rope built by + in O(1) and flattened in place on first content access. Property keys are stored as UTF-8 and remain scalar; string values preserve lone surrogates.
JIT tiers on x86-64
The native tier is a template JIT emitted with dynasm, not a tracing JIT. A function is offered to it after eight interpreter calls; a loop after eight back-edges, via on-stack replacement of the loop region. Compile-time gating decides what it can handle, and runtime guards bail to the exact bytecode instruction for anything else.
| Tier | What it keeps in registers | Typical wins |
|---|---|---|
| SROA | Scalars promoted out of short-lived objects | Object-literal temporaries in hot loops |
| INT / INT-GPR | Unboxed 32-bit integers | Arithmetic, comparisons, pinned dense arrays and strings, selected inlined calls |
| REGALLOC / DOUBLE | Unboxed doubles in SSE register homes | Numeric loops, typed-array math |
| MEM | Boxed Values with guarded inline caches and helper calls | Broad bytecode coverage for everything else |
Overflow never wraps silently: an integer add that would leave the i32 range bails. Integer homes cannot encode -0, so operations that could produce it bail too. Side exits flush every live home, identify the resume instruction, and count against a bounded deoptimization budget of 64 per region; clean exits forgive accumulated deopts, so the limit is a rate rather than a lifetime total, and regions that keep deoptimizing are evicted.
The ARM64 tier is a deliberately smaller guarded baseline: bounded call-free integer functions and numeric loops with exact-ip fallback, no helper calls and no OSR yet. Windows on ARM uses a bespoke W^X path because the generic cache-maintenance sequence is illegal at EL0.
ZIPP_JITLOG=1 zipp js app.js # log what compiled and why
ZIPP_JITDECLINE=1 zipp js app.js # log what was declined
ZIPP_NOJIT=1 zipp js app.js # interpreter only
ZIPP_JIT_THRESHOLD=1 zipp js app.js # compile everything immediately
ZIPP_PROF=1 zipp js app.js # sampling profilerGarbage collection: a non-moving generational nursery
The heap uses stable indices, a non-moving generational nursery and a mark-sweep old space. Minor collections trace young reachability plus remembered old-to-young edges; major collections trace the complete graph. Because identity is the slot index, promotion from young to old is a bookkeeping change on the same index with no copy.
The write barrier is a young byte array parallel to the object vector with a remembered set of dirty old objects. Stores into large traced objects record at value granularity instead of re-scanning the whole holder; the crossover is 4,096 traced slots. Collections are scheduled on bytes allocated, not only on object counts, after an emulator encoding a 23 KB framebuffer per frame died ninety seconds into a game under the older policy.
ZIPP_NO_NURSERY=1 # major collections only
ZIPP_GC_STRESS=1 # collect aggressively to shake out rooting bugs
ZIPP_NURSERY_VERIFY=1 # check remembered-set invariants
ZIPP_NURSERY_YOUNG_BUDGET=<bytes>The nursery was built in August 2026 in three stages, the first of which was refuted by measurement before the third landed with its own prover. It went default-on on 19 August, taking the headline benchmark from 1.28× to 1.21× Node. The journal records the stages.
Regular expressions
Regexes run on a fork of the regress engine with a UTF-16 mode so that ranges and positions are code-unit indices as ECMAScript requires, bounded backtracking for the sandbox, and an optional x86-64 regex JIT. The sandbox builds select prohibit-unsafe and bounded-backtracking; ZIPP_NO_RX_JIT=1 disables the regex JIT at runtime.
Metering and the sandbox profile
The instrument feature adds a step budget, cooperative abort and per-instruction tracing for embedders running untrusted code. It costs a branch in the dispatch loop, so it is off by default, and enabling it on a VM turns the JIT off for that VM. The WebAssembly artifact uses meter-only: step, heap, output, dynamic-code and regex limits stay, the trace recorder is compiled out, and the host enforces wall-clock time by terminating the Worker. The sandbox page lists every limit.