Skip to content

Commit 64f1ba5

Browse files
committed
plan(jit): add native trace graph directions
1 parent a746d6e commit 64f1ba5

1 file changed

Lines changed: 294 additions & 9 deletions

File tree

plans/pd_vm_call_overhead_reduction_plan.md

Lines changed: 294 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ separate costs in dependency order:
1111
4. 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

256256
The 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

262547
Verification:
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

Comments
 (0)