Baseline native tier
On this page 8
This remains the general native JavaScript tier. The separate optimizing tier executes only its documented exact subset and otherwise preserves this baseline/bytecode fallback.
Issue #52 tracks the first native-code tier above the bytecode VM. Its purpose is general engine throughput: compile bytecode that has proved hot, preserve the interpreter's semantics, and fall back cleanly whenever the tier cannot yet represent an operation. It must never recognize a benchmark, source string, or function name.
Tier contract
Every Chunk starts in the interpreter and owns one atomic tier record:
cold: count completed entries without adding work to each opcode.compiling: exactly one thread claimed compilation; other threads keep interpreting.ready: the entry pointer and immutable metadata have been published with release/acquire ordering.rejected: this chunk cannot currently be compiled. Rejection is cached so unsupported bytecode does not repeatedly enter the compiler.
Compilation happens only at a chunk entry, never in the opcode loop. The first
implementation may reject a whole chunk when any instruction is unsupported.
Later versions can leave side exits at unsupported instructions, but they must
reconstruct an exact Exec state: operand stack, accumulator, instruction
pointer, handler stack, and frame.
Generated code is immutable after publication. A tier record may be shared by
JavaScript Threads, so publication and rejection are race-safe; only the
winner of the cold to compiling transition allocates code. Context teardown
owns all generated mappings and waits until no engine thread can execute them.
Embedders can set Context.Options.enable_jit = false to keep every chunk on
the bytecode VM. The switch is fixed for the context lifetime, so differential
tests and profiles can compare identical source without a timing-sensitive tier
toggle or partially published code.
Entry ABI
The native entry point receives one pointer to a stable NativeFrame defined
by the JIT module, rather than depending on Zig's private calling convention or
the in-memory layout of Interpreter and Exec. The current frame contains:
- raw frame slots and numeric operand-spill storage;
- the exact interpreter step counter plus checkpoint and budget countdowns;
- an opaque runtime context and C-callable checkpoint/remainder helpers;
- result bits and an exit instruction pointer written by native code.
It intentionally does not expose the private Interpreter, Chunk, or Exec
layouts. A future resumable side exit must append the state it needs to this
ABI and reconstruct the full interpreter activation explicitly; returning
side_exit from a partially executed entry and simply restarting the function
would duplicate steps and side effects.
Value is an eight-byte NaN-boxed word. Native arithmetic may stay unboxed in
registers inside a proven numeric region, but every safepoint and side exit
must materialize canonical Values in GC-visible frame or operand slots.
Generated code may not embed movable GC pointers. Constants are loaded through
the owning Chunk or recorded as explicit roots.
The entry returns a small status, not a Zig error union:
complete: the result is in the native frame accumulator;side_exit: resume the bytecode interpreter at the published instruction;throw: the interpreter exception slot and exact VM state are live;stop: termination, step-budget, GIL-yield, or GC work requires the runtime.
This keeps exceptions and allocation in ordinary Zig runtime stubs. Native code never unwinds through Zig frames.
On Darwin AArch64, every baseline numeric and optimizer entry that changes SP or calls a runtime helper creates the canonical frame record: it saves x29/x30, sets x29 to the resulting SP, and restores the pair through one final epilogue. This keeps the native caller reachable to asynchronous frame-chain walkers after the two-instruction prologue. Constant-return leaf stubs remain frameless because they neither change SP nor call another function.
The code generator publishes that distinction as an explicit unwind plan; an
external publisher never recognizes instructions after the fact. The opt-in
Darwin adapter turns the plan into one PC-relative AArch64 DWARF FDE with exact
rows at function entry, after each prologue instruction, throughout the body,
and after the final frame-record restore. The rows describe every saved x19-x28
and d8-d15 register used by the baseline variant, not only the caller PC. The
adapter registers the FDE with __register_frame before publication completes
and calls __deregister_frame before releasing the FDE or executable mapping.
Leaf entries receive the corresponding SP/LR rule without a synthetic frame.
First supported region
The first useful compiler covers bytecode common to numeric functions and loops:
- constants, booleans, stack moves, accumulator updates;
- local loads and stores;
- Number guards and
+,-,*,/, remainder, and comparisons; - unconditional and conditional branches;
- return and halt.
Integer remainder is permitted only under the same guards as the VM fast path; all other Number cases call the semantic helper. A failed Number, frame, stack, or bounds guard exits to the interpreter before changing observable state.
Property access follows after the numeric core. Its generated fast path uses the existing shape/slot inline-cache contract, including the parallel seqlock mode and GC write barrier. Arrays and calls use runtime stubs until dedicated representations are proven correct.
Guarded integer specialization
Issue #56 tracks the next numeric tier. Development measurements show that removing FP register moves or constant setup is no longer material: integer-valued loop state still travels through doubles, and remainder repeatedly validates and converts it. The next speed tier therefore specializes proven integer regions rather than adding benchmark-specific peepholes.
Entry guards run before native step accounting and before any frame-slot mutation. If a parameter violates the compiled integer/range assumptions, the entry may return to bytecode at instruction zero with no state to reconstruct. Once observable native work begins, a failed assumption may use only one of these exact paths:
- a cold semantic helper that completes the current bytecode operation;
- a range proof that made failure impossible; or
- a resumable side exit that publishes the precise instruction, operand stack, locals, and already-consumed step count.
The dataflow lattice distinguishes signed/non-negative integers and conservative ranges at control-flow merges. Integer locals and operands may stay in GPRs, but safepoints and returns materialize canonical Number words. Overflow cannot wrap: it must be proved absent, handled by a full-precision cold path, or leave through the precise side-exit contract. Fractional values, NaN, infinities, and negative zero continue in the generic Number tier or interpreter.
The specialization is selected from bytecode plus runtime guards. It must not inspect source strings, function names, benchmark names, or call-site identity.
Safepoints and accounting
Back edges and calls are safepoints. Before either one, native code spills live
values and publishes the current instruction pointer. The runtime stub applies
the same obligations as runChunk:
- increment and enforce the evaluation step budget;
- observe worker termination;
- yield a contended JavaScript GIL when configured;
- service a requested precise-GC safepoint with all values visible;
- preserve the handler stack and pending exception.
The compiler emits a bytecode-to-native map for diagnostics and future stack maps. Until precise native stack maps exist, no GC pointer may be live only in a machine register across a safepoint.
Direct Context.compactGarbage movement is permitted between evaluations,
after the active-interpreter registry proves that no NativeFrame exists.
Context.requestGarbageCompaction may also be serviced inside the current
AArch64 numeric tier's checkpoint island: it first publishes canonical locals,
spills live operands, and retains only numeric managed state in registers.
Published code, tier records, bytecode chunks, and the NativeFrame itself do
not move, so the same entry resumes after its registered frame/realm roots are
rewritten. Every generic VM, host-callback, side-exit, exception, other-thread,
and conservative-stack boundary remains fail-closed.
Executable memory
All native emitters share the versioned js.jit.ExecutableMemoryProfile
contract. Version 1 owns allocation, instruction-cache synchronization, the
single writable-to-executable publication transition, exact used-byte ranges,
and retirement. Its stable eight-byte encoding contains only owned version,
platform, architecture, backend, cache, protection, and patching identifiers;
it never contains Zig target ordinals, runtime addresses, or process-local
state. Generated bytes are immutable after publication.
The memory contract currently has these target profiles:
- macOS allocates
MAP_JITmappings and usespthread_jit_write_protect_nparound writes, followed bysys_icache_invalidateon AArch64; - Linux AArch64 and x86-64 allocate anonymous read/write pages, synchronize the
instruction cache when the architecture requires it, and make one
process-wide
mprotecttransition to read/execute; - every other target fails closed before allocation.
Executable-memory capability is deliberately separate from native-tier
availability. A Linux profile proves the allocation and W^X boundary but does
not advertise a Linux code generator; js.jit.supported remains true only for
the released macOS AArch64 emitter, and unsupported tiers continue in bytecode.
This prevents a new allocator backend from silently becoming a public codegen
claim.
zig build executable-memory-wx-test independently executes tiny
architecture-specific code after publication and uses child processes to prove
that executing while writable and writing after publication both fault. CI
runs this as its own Linux x86-64 gate. Bytecode analysis remains separate from
the AArch64 assembler, and an x86-64 emitter remains future backend work.
Native PC ownership
Context.Options.native_observability opts a context into an owned native-PC
registry. At publication, each baseline or optimizer artifact receives a stable
artifact id and sanitized symbol name plus its exact half-open executable range,
function identity, script id, source URL, and definition coordinates. The
registry covers live artifacts and mappings retained by an older execution
epoch. Lookup returns an owned copy, so releasing the owner lock cannot leave a
profiler holding slices into reclaimed metadata.
Observed baseline compilation also emits sorted, artifact-relative PC change rows. Each executable range names its exact bytecode offset; prologue, epilogue, and other artifact plumbing are explicitly unmapped. When that bytecode is an inspector statement boundary, adoption copies the same script identity and one-based source position used by the inspector. Bytecodes without an explicit statement node remain source-unmapped instead of inheriting a guessed nearest line. The rows are passed to external publishers and returned by owned PC lookups. Disabled compilation does not allocate or retain a map.
Observed optimizer compilation uses the same row format. Real SSA operations carry their graph's bytecode origin; branch tests, return arms, side exits, deopt polls, and loop controls use their explicit branch or recovery record. Hoisted operations retain their real origin, while synthetic edge-copy shuffles, path-dependent joins, and the shared epilogue reset attribution to unmapped. The VM resolves exact inspector statement sites for optimizer rows at the same pre-publication boundary used by baseline code.
Retirement removes the registry row before unmapping executable memory. Once the
last execution lease releases and reclamation completes, the old PC no longer
resolves; address reuse therefore cannot inherit a stale function identity.
With the option disabled, publication allocates and retains no native metadata.
Context.lookupNativeCodeSignalSafe exposes the same exact artifact, tier,
live/retired state, native offset, bytecode offset, and source identity to an
embedder crash/signal path without allocation, locks, I/O, or borrowed storage.
It copies into caller-owned buffers and fails rather than truncating an
identity. The Owner-local registry unlinks a retiring artifact, waits for
pre-existing lock-free readers, and only then permits metadata destruction and
executable address reuse.
Context.Options.native_code_publisher installs an embedder-owned external
publisher and implies native_observability. Publication returns an opaque
artifact-owned token; retirement invokes its infallible unregister callback
before metadata destruction and executable unmapping. The publisher and its
context must outlive the Context and its shared realms.
jit.gdbJitPublisher() is the opt-in macOS adapter for the standard GDB JIT
protocol, which LLDB also implements. It builds an in-memory Mach-O object whose
__text section has the exact existing executable address and byte size. When
an artifact has exact source rows, the object also owns minimal DWARF v4
__debug_abbrev, __debug_info, and __debug_line sections. Each mapped PC
sets its exact one-based line and column. An explicitly unmapped PC closes the
current line sequence, so a debugger cannot inherit a nearby JavaScript line
across prologue, join, move, or epilogue plumbing. The adapter registers and
unregisters the object with the artifact; it does not copy, link, or remap
executable code. The process-global __jit_debug_descriptor and
__jit_debug_register_code symbols are emitted only when an embedder selects
this adapter, so the default static library does not collide with a host that
already owns the protocol. LLDB disables this loader by default on macOS; use
settings set plugin.jit-loader.gdb.enable on.
Linux embedders can instead keep a js.jit.PerfJitDumpWriter at a stable
address, pass its publisher() through Context.Options.native_code_publisher,
and retire every using Context before calling deinit(). The writer creates
the executable jit-<pid>.dump discovery mapping required by perf record,
emits source-debug records before their matching code-load records, and leaves
the closed dump on disk for perf inject --jit. Each exact PC-map span is a
separate load record: source-bearing spans carry their exact one-based line,
while prologue, epilogue, and synthetic spans remain named but source-unmapped.
The adapter is Linux-only and opt-in; it does not claim Linux native execution
before the backend release gates do.
jit.gdbJitStats() returns one lock-coherent process-wide snapshot of the
adapter's live registration count, owned symbol-object bytes, owned unwind
bytes, and monotonic register/unregister totals. Live storage is incremented
before the debugger registration callback and decremented only after its
unregister callback returns, so the counters describe the same lifetime in
which a debugger may read the object. The comparison runner's
single_observed mode registers benchmark sources before evaluation and emits
these exact fields plus generated-code and resident-memory gauges before and
after Context teardown. Both benchmark states use that same binary.
The native_observability profile of zig build instrumentation-overhead
alternates that mode against the disabled single mode, rejects native
tier/code/checksum drift, and requires all live debugger storage to retire
before accepting a sample. Quick runs exercise the collector only; an overhead
claim still requires the quiet-reference-host gates described in
benchmarks.md.
zig build native-observability-lldb-test drives the real LLDB loader. Pending
baseline and optimizer symbol breakpoints must resolve at offset zero of their
exact JIT sections. Both objects must expose one compilation unit, leave their
entry prologues unmapped, and resolve source breakpoints to the exact fixture
file, line, and nonzero column. After the baseline canonical frame prologue,
LLDB must also walk from the generated frame to the host binary. The production
fixture requires
_Unwind_Find_FDE to resolve the exact live generated range and to stop
resolving it after Context teardown; both debugger symbols must disappear at
the same boundary.
zig build native-observability-signal-test installs a real SA_SIGINFO
handler, targets the JavaScript execution thread with SIGUSR1, extracts the
interrupted PC from the host ucontext, and resolves an exact production JIT
source row through Context.lookupNativeCodeSignalSafe. The handler performs
no allocation, locking, or I/O; all identity verification and teardown happen
after the sender proves the last targeted signal was handled. The same fixture
accepts -Dtsan=true to cover its atomic handoff.
zig build native-observability-crash-test runs the same production JIT in a
child process and targets its JavaScript thread with SIGABRT. Once the handler
interrupts an exact generated source row, it copies the PC range, artifact,
tier, symbol, function, script, and source identity into a fixed-size record,
writes that record to a pipe opened before exec, and terminates with _exit.
The handler performs no allocation, locking, formatting, or access to engine
memory after the record is copied. The parent requires one complete record and
validates every dynamic identity field against the interrupted PC. External
system-profiler image publication, inline-frame maps, and logical
async/deopt/Wasm stack reconstruction remain separate work; the Owner-local
registry alone still does not make anonymous MAP_JIT leaves visible to an
external profiler.
Correctness and performance gates
Each compiler feature lands with differential tests that execute the same source with the native tier forced off and forced on, comparing result, exception, and externally visible state. GC stress, termination, recursion, and shared-realm tests are required before those paths can enter native code.
Performance evidence uses the symmetric JSC protocol in
benchmarks.md. Quick paired measurements guide development;
the 1,540-sample publication matrix is rerun only after a meaningful batch of
optimizations. A speedup is not accepted if checksums, supported rows, or
execution accounting differ.
The July 16 dispatch profile
captures arithmetic, properties, arrays, and recursive Fibonacci from the same
ReleaseFast comparison runner. Arithmetic spends 93.3% of reported collapsed
leaf samples in generated MAP_JIT code with no residual runChunk leaves;
properties split between guarded VM kernels and residual dispatch; arrays
remain primarily residual dispatch plus dense-array runtime helpers; Fibonacci
spends 96.6% in the guarded observable-recurrence kernel. This is the current
evidence boundary for broader native coverage.
Repeated focused test builds grow the reproducible .zig-cache quickly; see
dev-cache.md for inspecting and safely reclaiming it.