jz → native binary

April 25, 2026 · View on GitHub

Compile a jz JavaScript source to a standalone native executable. The pipeline self-hosts watr's WAT compiler at the time of writing and serves as the reference target for "how fast can a jz-produced wasm get if you really care."

  watr/src/compile.js

   jz (NaN-boxed f64 ABI, JZ-aware)


  jz-watr.wasm

   wasm-opt -O3 (Binaryen)


  jz-watr-opt.wasm

   wasm2c --enable-exceptions


  watr.c (post-processed: A2a nullify barriers + A2b hoist memory base)

   clang -O3 -flto -fprofile-instr-generate          ──► profraw
   clang -O3 -flto -fprofile-instr-use=watr.profdata ──► watr-native

Quick start

./scripts/native/build.sh           # full PGO pipeline → /tmp/jz-c/watr-native
./scripts/native/build.sh clean     # wipe BUILD_DIR

BIN=/tmp/jz-c/watr-native node scripts/bench-native.mjs   # regression gate

Env overrides:

VariableDefaultNotes
BUILD_DIR/tmp/jz-cAll transient artefacts land here.
WABT_DIR/Users/div/projects/wabtProvides bin/wasm2c and wasm2c/*.
WASM_OPT$(which wasm-opt)Binaryen.
CCclangNeeds LTO + PGO.

Files

PathRole
build.shThree-stage PGO build orchestrator.
gen-watr-wasm.mjsjz-compiles watr/src/compile.js, runs wasm-opt -O3jz-watr-opt.wasm.
postprocess-watr.awkA2b: hoist instance->w2c_memory.data per function + macro-shadow load/store.
harness.cMedian-of-90 bench harness; re-instantiates every 5 iters to bound bump-heap.
env-stubs.cEmpty __ext_* import stubs.
wasm-rt-exceptions-stub.cTrap-only EH (watr has 5 throws, 0 catches).

Why each stage matters

wasm-opt -O3 trims wasm2c's input by ~10% on parser-heavy paths. Raw jz output has redundant locals and unhoisted loads that wasm2c can't undo once it's serialized to C.

PGO closes the last ~5% on the hottest inner loops (parser identifier walk, uleb encode, bump alloc) by giving clang accurate branch frequencies and inlining decisions. Profile is collected from a weighted sample of watr/test/example/*.wat — heavy iters on raycast/maze/containers/snake/etc., light pass over the rest.

A1 (-fno-exceptions + trap-only EH stub) removes throw_with_stack machinery. watr has 5 throws and 0 catches — we're never propagating, so the runtime only needs wasm_rt_trap.

A2a (sed nullifies FORCE_READ_INT/FORCE_READ_FLOAT) is the biggest single win. wasm2c emits __asm__("" ::"r"(var)) after every load to "force the value into a register," but clang's PGO+LTO treats those as side-effecting barriers that defeat CSE of instance->w2c_memory.data. Killing them unlocks the .data hoist on parser hot loops:

f5 inner loop, before:        12 insts/iter, .data reloaded 4×
f5 inner loop, after A2a:      4 insts/iter, .data hoisted above the loop

644M-call function on the PGO trace; ~8% on parser-heavy workloads.

A2b (postprocess-watr.awk) goes further. Even with A2a, clang refuses to CSE instance->w2c_memory.data across CFG joins inside a single function — f6 still reloaded it 5 times. The awk pass injects, at the top of every function that takes (w2c_jzwatr* instance, ...):

__attribute__((unused)) u8* const __restrict__ _md = instance->w2c_memory.data;

…and shadows the wasm2c load/store inlines with macros that reference _md directly. The __restrict__ plus const-locality plus PGO is what finally lets clang keep the base in a register across the entire function. f6: 5 reloads → 1.

A3 removes C++ EH tables (-fno-exceptions -fno-unwind-tables -fno-asynchronous-unwind-tables), the stack protector (no untrusted input), and merges constants. Smaller .text and .rodata → better i-cache / constant-pool behaviour.

WASM_RT_MEMCHECK_GUARD_PAGES moves bounds checks from inline branches to OS-level guard pages. WASM_RT_NONCONFORMING_UNCHECKED_STACK_EXHAUSTION turns FUNC_PROLOGUE into a no-op (no ++wasm_rt_call_stack_depth per call).

Regression gate

scripts/bench-native.mjs walks watr/test/example/*.wat, runs each through both the native binary and a steady-state V8 baseline (200 iters or 200ms of warmup, whichever is longer; fresh node process per run to avoid in-process tier-up bias), and asserts that native is faster than V8 on every example.

Each side is invoked RUNS times (default 3) and we take the min; this is robust against macOS scheduler jitter without burying real regressions.

ITERS=30  RUNS=3  MARGIN=1.0   # defaults

Current result on M4 Max (range across runs):

19/21 wins (1.04× – 4.0×)
 2/21 ties (raycast.wat, containers.wat — 0.97×–1.01×, within noise floor)

raycast and containers exercise the same identifier-resolution path that V8's TurboFan also optimises near-optimally; we've matched V8 to within measurement noise but not consistently beaten it. Tier B will close that gap through watr-source-level changes (the bottleneck is the structure of the JS, not the codegen).