@@ -11,9 +11,9 @@ separate costs in dependency order:
11114 . lower the hottest builtin projection calls into direct interpreter fast paths instead of paying
1212 full generic ` Call ` dispatch
1313
14- The intent is not to redesign ordinary RustScript function calls. Those are already compiled
15- inline today in [ ` src/compiler/codegen.rs ` ] ( ../src/compiler/codegen.rs ) , so the main
16- runtime tax is on builtin and host dispatch .
14+ The first four stages did not redesign ordinary RustScript function calls. Current script functions
15+ use real callable frames and dynamic calls route through ` CallValue ` . The native trace-graph
16+ follow-up preserves those semantics and targets the remaining trace-boundary overhead .
1717
1818## Current Snapshot (2026-03-20 Baseline)
1919
@@ -254,10 +254,295 @@ Post-fix CPU 11 A/B, four alternating runs:
254254| fixed JIT | ` 530.3 ms ` | ` 485.3-551.0 ms ` |
255255
256256The fixed JIT is ` 71.7% ` faster than the pre-fix master JIT and is within ` +3.9% ` of the same-run
257- interpreter median. It remains ` 6.54x ` slower than the old ` 0.23.1 ` JIT because ` 0.23.1 ` inlined
258- direct named function calls, while real script call frames intentionally route them through
259- ` CallValue ` . Restoring that older speed requires a separate compiler direct-call inlining design;
260- the VM mitigation prevents pathological slowdown without changing callable semantics.
257+ interpreter median. It remains ` 6.54x ` slower than the old ` 0.23.1 ` JIT. The old compiler flattened
258+ direct named calls, while the current runtime preserves real callable frames and routes dynamic
259+ calls through ` CallValue ` . Source-level direct-call flattening is retained only as a historical
260+ performance reference. The follow-up design must preserve callable semantics and remove native
261+ trace-boundary overhead inside the JIT.
262+
263+ ### Follow-up native trace graph plan (2026-07-18)
264+
265+ #### Research basis
266+
267+ LuaJIT v2.1 was used as the architectural reference for the remaining gap:
268+
269+ - hot loops become root traces; the default ` hotloop ` threshold is ` 56 `
270+ - a repeatedly taken guard exit starts a side trace at the default ` hotexit=10 `
271+ - the side trace is initialized by replaying the parent exit snapshot
272+ - ` lj_asm_patchexit() ` redirects the parent guard to the side-trace machine-code entry
273+ - the side-trace head inherits parent register and spill state instead of reconstructing the full
274+ interpreter state
275+ - Lua calls and returns can be recorded in one trace; the call target is guarded by closure or
276+ prototype identity, while frame state remains represented in trace slots and snapshots
277+ - actual interpreter restoration is reserved for an unlinked exit, failed guard path, error, or
278+ other true deoptimization
279+ - compile failures are managed per exit with ` tryside ` and ` maxside ` limits instead of disabling an
280+ entire callable prototype after one frequent branch pattern
281+
282+ A local LuaJIT experiment with a tight loop, a Lua comparator call, and a two-way branch produced
283+ one root trace and two side traces:
284+
285+ ``` text
286+ [TRACE 1 lj-tight-loop.lua:8 loop]
287+ [TRACE 2 (1/5) lj-tight-loop.lua:2 -> 1]
288+ [TRACE 3 (1/3) lj-tight-loop.lua:7 -> 1]
289+ ```
290+
291+ The root recording contained ` CALL ` , ` FUNCF ` , comparator bytecode, ` RET1 ` , and ` FORL ` . Both hot side
292+ traces linked back to the root trace without interpreter dispatch.
293+
294+ RustScript currently performs a materially different transition for every linked trace:
295+
296+ 1 . materialize dirty locals and update ` Vm.ip `
297+ 2 . call ` resume_linked_trace `
298+ 3 . look up the next entry in Rust by ` (frame_key, ip, stack_depth) `
299+ 4 . invoke the next native function
300+ 5 . call ` pd_vm_native_frame_state() ` again at callable-frame entry
301+
302+ The unbacked callable JIT produced roughly ` 359k-392k ` native executions per sort run. The three
303+ CPU 11 diagnostic runs amortized to ` 4.2-7.3 us ` per native boundary plus trace body, with a
304+ ` 6.6 us ` median. Removing a single frame helper cannot close the gap while these boundaries remain.
305+
306+ #### Shared correctness constraints
307+
308+ Both directions below must preserve these invariants:
309+
310+ - source-level function values, closures, recursion, captures, and dynamic call targets keep their
311+ existing semantics
312+ - a linked native edge cannot bypass a yield, wait, fuel, epoch, error, or debugger boundary
313+ - values transferred across a linked edge retain the existing drop contract exactly once
314+ - an unlinked or invalidated edge uses the existing deoptimization path and reconstructs the same
315+ ` Vm.stack ` , ` Vm.locals ` , ` Vm.ip ` , and active frame
316+ - linked code must not keep stale callable, trace, executable-buffer, or captured-value identity
317+ - trace invalidation disconnects incoming edges before executable memory or constants are released
318+ - initial rollout links only traces with the same ` frame_key ` , stack depth, and non-yielding status
319+ - root and callable traces retain independent entry keys and debug metadata
320+ - interruption checks remain reachable in native loops after edges are linked
321+
322+ #### Direction 1: fuse a hot trace region into one Cranelift function
323+
324+ ** Objective:** Compile a root trace and its hot same-frame side traces as one native control-flow
325+ region so linked edges pass SSA values directly between Cranelift blocks.
326+
327+ ** Why first:** This removes the dominant handoff without introducing a cross-buffer ABI, writable
328+ machine-code patching, or a tail-call convention. It also provides the parent-exit lineage and
329+ snapshot-import model that Direction 2 needs later.
330+
331+ ** Target architecture:**
332+
333+ - identify links by ` TraceExitKey { parent_trace_id, exit_id } ` , not only the destination
334+ ` (frame_key, ip, stack_depth) `
335+ - count and compile hot exits independently
336+ - represent a region as one root trace plus a bounded set of side traces
337+ - import each side trace from the exact parent ` SsaExit ` materializations
338+ - map the parent exit's live locals and operand values to child block parameters
339+ - lower a linked parent guard to an intra-function Cranelift branch
340+ - keep scalar and pointer values in SSA form across the edge
341+ - retain the original deopt block for every unlinked outgoing edge
342+ - run the callable frame-state entry guard once at the external region entry; internal side-trace
343+ blocks do not call it again
344+ - recompile and atomically replace a region entry when a newly hot side trace is admitted
345+ - cap region growth by trace count, IR instruction count, machine-code size, and compile latency
346+
347+ ** Primary files:**
348+
349+ - ` src/vm/jit/trace.rs `
350+ - add parent-exit hotness, link state, compile-attempt state, and region membership
351+ - ` src/vm/jit/ir.rs `
352+ - add region-level block/value identity and parent-snapshot import metadata
353+ - ` src/vm/jit/deopt.rs `
354+ - expose exact parent-exit materializations for child import
355+ - ` src/vm/jit/native/lower.rs `
356+ - lower multiple SSA traces into one Cranelift function and branch directly across linked edges
357+ - ` src/vm/jit/native/mod.rs `
358+ - represent a compiled region and its entry/keepalive state
359+ - ` src/vm/jit/runtime.rs `
360+ - install region replacements and leave Rust dispatch only for unlinked exits
361+ - ` tests/jit/jit_tests.rs `
362+ - add structural, callable-frame, deopt, interruption, and ownership tests
363+ - ` tests/jit/perf_tests.rs `
364+ - add region-link characterization counters
365+
366+ ** Implementation milestones:**
367+
368+ 1 . ** Add parent-exit identity and counters.**
369+ - RED: a branch-heavy callable workload must report repeated executions of one exact parent exit.
370+ - GREEN: record per-exit executions without changing dispatch.
371+ - Keep the existing frame backoff active during this milestone.
372+
373+ 2 . ** Create side-trace snapshot import IR.**
374+ - RED: importing a child from a parent exit must preserve scalar, tagged, stack, and dirty-local
375+ materializations.
376+ - GREEN: build child entry parameters from ` SsaExit ` values and verify the combined IR.
377+ - Reject cross-frame, yielding, mismatched-depth, and unsupported ownership edges.
378+
379+ 3 . ** Lower a two-trace region.**
380+ - RED: a hot alternating branch must still increment Rust linked-handoff counters after warmup.
381+ - GREEN: compile parent and one child in the same Cranelift function; the linked edge becomes an
382+ internal branch and no longer increments the handoff counter.
383+ - Preserve the original parent deopt block until the replacement region is installed.
384+
385+ 4 . ** Support cycles and several side traces.**
386+ - RED: a parent-to-child-to-parent loop must require repeated external native entries.
387+ - GREEN: map the cycle to Cranelift blocks and PHI-style block parameters with no host-stack
388+ growth.
389+ - Add deterministic region-size limits and refuse an edge before partial installation.
390+
391+ 5 . ** Install and invalidate regions safely.**
392+ - RED: JIT config changes, host-import changes, trace blocking, and callable replacement must not
393+ leave an incoming edge pointing at old code.
394+ - GREEN: publish a complete replacement region, switch the cached entry, then retire the prior
395+ keepalive after all references are disconnected.
396+
397+ 6 . ** Retire prototype-wide backoff for successfully linked exits.**
398+ - Keep backoff for unsupported or repeatedly failing exits.
399+ - Move compile attempts and blacklist state to ` TraceExitKey ` .
400+ - A successful region edge clears its failure streak and cannot block unrelated exits in the
401+ same callable frame.
402+
403+ 7 . ** Run the sort performance gate.**
404+ - use CPU 11 with four alternating Criterion runs
405+ - compare current interpreter, region JIT, and crates.io ` 0.23.1 `
406+ - record native executions, external exits, internal region edges, Rust handoffs, helper calls,
407+ trace count, region count, IR size, machine-code size, and compile time
408+
409+ ** Required tests:**
410+
411+ - ` trace_jit_region_links_hot_same_frame_side_exit `
412+ - ` trace_jit_region_cycles_without_external_handoffs `
413+ - ` trace_jit_region_unlinked_exit_restores_callable_frame `
414+ - ` trace_jit_region_preserves_owned_value_drop_contract `
415+ - ` trace_jit_region_respects_fuel_and_epoch_interrupts `
416+ - ` trace_jit_region_invalidation_disconnects_old_entries `
417+ - ` trace_jit_region_rejects_cross_frame_or_yielding_edges `
418+
419+ ** Acceptance criteria:**
420+
421+ - all existing workspace tests and clippy remain clean
422+ - fused edges perform no Rust lookup, frame-state helper call, or ` Vm.locals ` materialization
423+ - the branch-heavy callable integration test shows internal region-edge activity and bounded external
424+ handoffs on its second run
425+ - ` script-bench-rs ` preserves sorted output and native coverage
426+ - the sort JIT median is at most ` 2x ` the ` 0.23.1 ` JIT median on the same CPU and at least ` 50% `
427+ below the same-run interpreter median
428+ - if the performance gate is missed, record a helper/edge/call breakdown before widening the region
429+ or starting Direction 2
430+
431+ #### Direction 2: independent side traces with machine-level links
432+
433+ ** Objective:** Keep side traces in independent executable buffers while linking parent exits to
434+ child entries without Rust dispatch or full VM-state reconstruction.
435+
436+ ** When to use:** Start after Direction 1 has validated parent-exit lineage and snapshot import, and
437+ only if region recompilation, code duplication, or region-size limits prevent adequate coverage.
438+
439+ ** Target architecture:**
440+
441+ - each hot parent exit owns a stable link slot outside executable memory
442+ - the parent guard loads the link slot and transfers control to the child when populated
443+ - a zero or invalidated slot follows the original deopt path
444+ - side traces are compiled from one exact parent snapshot and have one inherited-state ABI
445+ - a generated system-ABI wrapper enters the trace graph
446+ - linked trace bodies use Cranelift's tail-call convention and ` return_call_indirect ` so graph cycles
447+ do not grow the host stack
448+ - scalar values cross the side-entry ABI unboxed
449+ - tagged and owned values use an ABI-safe carrier with explicit move/borrow ownership; pointers to a
450+ parent function's temporary stack slots cannot survive a tail transfer
451+ - child code can tail-link to the root or another compatible side trace
452+ - link publication uses release ordering; execution loads use acquire ordering
453+ - invalidation clears incoming link slots before releasing child code and constants
454+ - executable pages remain write-protected after publication; mutable link slots avoid runtime
455+ machine-code rewriting and preserve current W^X behavior
456+
457+ ** Cranelift constraints discovered during research:**
458+
459+ - the current dependency is ` cranelift-codegen 0.129.1 `
460+ - ` return_call_indirect ` is available
461+ - only ` CallConv::Tail ` reports tail-call support; the current default platform convention does not
462+ - current traces are compiled independently and copied into separate ` ExecutableBuffer ` mappings
463+ - current ` ExecutableBuffer ` exposes no runtime patch API
464+ - a system-ABI wrapper plus tail-call trace bodies and data link slots is therefore preferred over
465+ editing executable bytes
466+
467+ ** Primary files:**
468+
469+ - all Direction 1 trace-lineage, snapshot, and test surfaces
470+ - ` src/vm/jit/native/lower.rs `
471+ - add side-entry signatures, tail transfers, and link-slot loads
472+ - ` src/vm/jit/native/mod.rs `
473+ - model root wrappers, tail-call bodies, link slots, and keepalive dependencies
474+ - ` src/vm/native/exec.rs `
475+ - retain W^X mappings; add patch support only if data slots prove insufficient
476+ - ` src/vm/jit/runtime.rs `
477+ - publish/unpublish links and service only unlinked deoptimizations
478+
479+ ** Implementation milestones:**
480+
481+ 1 . ** Define and verify inherited-state ABI classes.**
482+ - classify scalar, pointer, tagged, borrowed, and owned values
483+ - prove exact ownership transfer and deopt reconstruction for every class
484+ - reject unsupported signatures before native code is published
485+
486+ 2 . ** Build a root wrapper and one tail-linked child.**
487+ - system ABI enters the wrapper
488+ - wrapper calls the ` CallConv::Tail ` root body
489+ - root uses ` return_call_indirect ` for the populated side link
490+ - test the zero-slot deopt path and populated-slot direct path
491+
492+ 3 . ** Add cyclic graph linking.**
493+ - parent, child, and root may tail-link without host-stack growth
494+ - run millions of transitions under a stack-depth probe
495+ - ensure interruption and error exits return through the wrapper correctly
496+
497+ 4 . ** Add link lifetime and invalidation.**
498+ - track incoming links for each compiled trace
499+ - clear all incoming slots before code retirement
500+ - prevent ABA reuse with generation identity or non-reused link objects
501+ - keep child constants and executable memory alive while any slot can reach them
502+
503+ 5 . ** Replace runtime dispatch for compatible edges.**
504+ - keep Rust dispatch for cold, unsupported, yielding, cross-frame, and invalidated exits
505+ - collect separate direct-link and fallback-handoff counters
506+
507+ 6 . ** Run the same correctness and performance gates as Direction 1.**
508+ - compare direct-linked side traces with fused regions on identical trace graphs
509+ - prefer the simpler fused region unless independent traces provide a measured compile-time,
510+ code-size, or coverage advantage
511+
512+ ** Required tests:**
513+
514+ - ` trace_jit_side_link_slot_switches_between_deopt_and_child `
515+ - ` trace_jit_tail_link_cycle_has_bounded_host_stack `
516+ - ` trace_jit_side_entry_transfers_owned_values_once `
517+ - ` trace_jit_side_link_invalidation_clears_incoming_slots `
518+ - ` trace_jit_side_link_generation_prevents_stale_entry_reuse `
519+ - ` trace_jit_side_link_respects_callable_frame_and_interrupt_boundaries `
520+
521+ ** Acceptance criteria:**
522+
523+ - compatible linked exits execute with no Rust dispatcher or callable frame-state helper
524+ - graph cycles show bounded host-stack use
525+ - no executable page is writable while reachable for execution
526+ - clearing a link immediately restores the existing deopt behavior
527+ - owned and captured values retain exact release behavior under linking and invalidation
528+ - performance is no worse than Direction 1 on the sort workload; any added complexity must be
529+ justified by measured region compile-time, code-size, or coverage gains
530+
531+ #### Direction selection and shared rollout
532+
533+ Direction 1 is the implementation default. Direction 2 is a continuation path, not a parallel first
534+ implementation. The following components must be designed for reuse by both:
535+
536+ - ` TraceExitKey ` and per-exit hotness/attempt/blacklist state
537+ - parent snapshot import and inherited-value classification
538+ - linked-edge counters and diagnostics
539+ - deopt equivalence tests
540+ - callable-frame, ownership, interruption, and invalidation test fixtures
541+ - region/side-trace size and compile-time telemetry
542+
543+ Source compiler direct-call flattening is not part of either direction. A later call-tracing phase may
544+ record guarded ` CallValue -> callee -> return ` paths into the native trace graph while preserving real
545+ frames in snapshots.
261546
262547Verification:
263548
@@ -268,8 +553,8 @@ Verification:
268553
269554## Non-Goals
270555
271- - redesigning RustScript source-level function semantics
272- - introducing general closure values or call frames as part of this work
556+ - changing RustScript source-level function, closure, capture, or recursion semantics
557+ - restoring source-compiler function flattening or removing real callable frames
273558- removing ` Arc ` from the VM value model
274559- broad AOT architecture work beyond the minimal compatibility needed for the hot builtin lowering
275560- changing user-visible RSS syntax as part of this plan
0 commit comments