Skip to content

jit: gh#394 warmup follow-ons — cache FrameRoot shadow-stack cell + correct walk-end handback vsd - #698

Merged
youknowone merged 3 commits into
mainfrom
miframe
Jul 21, 2026
Merged

jit: gh#394 warmup follow-ons — cache FrameRoot shadow-stack cell + correct walk-end handback vsd#698
youknowone merged 3 commits into
mainfrom
miframe

Conversation

@youknowone

@youknowone youknowone commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Follow-on to #692, carrying two independent gh#394 warmup cleanups that a re-profile of the #692 result surfaced. Each is separately gated and A/B-measured.


Commit 1 — cache the shadow-stack cell in FrameRoot; const-init SHADOW_STACK

#692 cut the per-opcode-step FrameRoot::frame() reload count in eval_loop_jit (gh#394 driver residual); a re-profile showed majit_gc::shadow_stack::get still the #1 non-idle warmup self-time frame — the remaining ~3 seeds/step each pay a thread-local resolution (_tlv_get_addr on macOS) because SHADOW_STACK.with() runs on every access.

  • majit-gc: add ShadowStackSlot (a cached *const RefCell<ShadowStack>) plus shadow_stack_slot() / unsafe slot_get(). The cell address is stable for the owning thread's life — the same invariant MutatorEntry already relies on for STW walks.
  • pyre-jit: FrameRoot::new resolves the thread-local cell once and stores the slot; frame() re-reads entries[depth] through the cached slot instead of resolving the thread-local on every call. FrameRoot is a same-thread stack local, so the cached pointer can neither outlive nor cross its thread. slot_get takes the same transient borrow the old get() did, and re-reads still index the live entries Vec, so a Vec realloc between reads is unaffected.
  • Also const-initialize the SHADOW_STACK thread-local (ShadowStack::new is now const with an empty Vec instead of Vec::with_capacity(64)), dropping the per-access lazy-init guard.

Orthodoxy. RPython does not re-resolve the root-stack base per access: it lives in a plain global (gcdata singleton, framework.py), the C backend resolves it once per function (gc_enter_roots_frame, funcgen.py), the x86 JIT caches the root-stack top in a register across call boundaries (assembler.py:_load_shadowstack_top_in_ebx), and shadowcolor.py even hoists root saves out of loops. pyre's per-access SHADOW_STACK.with() is the divergence; caching the resolution once per activation and reusing it between GC safepoints brings the access cost back in line with upstream. The cache never spans a thread-switch/GIL boundary (single stack-local FrameRoot per activation).

Perf (A/B, interleaved best-of-N, EPOCHREALTIME): run 1 (15 rounds) +3.66% best-of-N / +3.85% median, bands disjoint; run 2 (12 rounds) +3.71% / +3.52%, disjoint. Every round faster.


Commit 2 — correct valuestackdepth on the super-instruction walk-end handback

The non-flush walk-end handback in compile_and_run_once set the resume pc but not valuestackdepth: when a loop-header marker sits inside a super-instruction, the walk closes the loop at loop_header_pc + 1 and leaves valuestackdepth advanced through the super-instruction, while the handback wrote only last_instr/next_instr. The frame was handed back with pc and operand depth inconsistent, and eval_loop_jit patched it downstream by recomputing instruction.stack_effect every opcode step and, when the projected push overflowed the frame array and the depth table agreed, advancing the pc instead of dispatching. That per-step stack_effect was the #2 non-idle warmup self-time frame.

  • Make the handback symmetric with the flush leg (restore_resume_state_from) and the blackhole legs (apply_blackhole_crn_handoff): after set_last_instr_from_next_instr(restart_pc), call correct_resume_vsd(frame, restart_pc) to re-derive the operand depth from the resume pc via the depth table.
  • The handed-back frame is then self-consistent, so the per-step reconciliation block and its stack_effect recompute are removed. valuestackdepth is now only ever compared against the precomputed depth table (depth_based_vsd_for_wcode / liveness), never re-derived per dispatch step — the depth table is the single source of truth, and rustpython's stack_effect_info leaves the hot path.

Orthodoxy. PyPy's dispatch loop never computes a per-step stack effect: valuestackdepth is an incrementally-maintained counter (pyframe.py push/popvalue), stack_effect is compile-time only (co_stacksize, astcompiler/assemble.py), and the JIT frame is a virtualizable restored positionally from resume data in one shot (rpython/jit/metainterp/virtualizable.py), so pc and vsd are never mutually inconsistent at a merge point. Fixing the seam so the frame is self-consistent — rather than patching the inconsistency downstream — is the orthodox shape.

Perf (A/B, interleaved best-of-N, 21 rounds): +1.39% best-of-N / +0.17% median, bands overlap. Perf-neutral-to-slightly-positive; this commit lands as a correctness/orthodoxy cleanup that removes machinery (the per-step reconciliation), with the A/B confirming no regression.


Correctness (whole branch)

  • check.py --backend dynasm,cranelift: 241/241 dynasm + 241/241 cranelift, byte-identical both backends (measured on both the pre- and post-commit-2 base).
  • MAJIT_GC_STRESS=1 gc_stress 12/12 — the UAF gate: a moving collection at every allocation window.
  • cargo test -p majit-gc 191, cargo test -p pyre-jit 320+12 (both eval-loop twins).
  • Golden warmup output byte-identical (3004320000) for both commits.

opened by Claude

Summary by CodeRabbit

  • Bug Fixes

    • Improved garbage-collection root tracking during JIT execution.
    • Fixed frame and operand-stack state restoration when execution resumes inside optimized instruction sequences.
    • Improved consistency after garbage-collection points, reducing the risk of incorrect execution state.
  • Performance

    • Reduced repeated overhead when accessing per-thread garbage-collection state during JIT operations.

FrameRoot::frame() read the current frame root through
shadow_stack::get(depth), which resolves the SHADOW_STACK thread-local on
every call (a _tlv_get_addr per access on macOS). eval_loop_jit re-reads
the root through frame() several times per opcode step, so the thread-local
resolution recurs across the whole warmup loop.

Resolve the thread-local cell once in FrameRoot::new and hold the pointer
in the root (ShadowStackSlot); frame() now re-reads entries[depth] through
the cached cell via slot_get. The cell address is stable for the owning
thread's life (the invariant MutatorEntry already relies on for STW walks),
and FrameRoot is a same-thread stack local, so the cached pointer cannot
outlive or cross its thread. slot_get takes the same transient borrow the
old get() did; a re-read still indexes the live entries Vec, so a Vec
realloc between reads is unaffected.

Also const-initialize the SHADOW_STACK thread-local (ShadowStack::new is
now const with an empty Vec instead of Vec::with_capacity(64)), dropping
the per-access lazy-init guard.

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The change adds a cached per-thread shadow-stack slot API, uses it for JIT frame-root retrieval, simplifies post-collection dispatch reseeding, and synchronizes restart instruction and operand-stack state.

Changes

JIT and GC integration

Layer / File(s) Summary
Shadow-stack slot API
majit/majit-gc/src/shadow_stack.rs
Shadow-stack initialization becomes const-compatible, and a resolved per-thread slot handle supports indexed GcRef access.
Cached frame-root access
pyre/pyre-jit/src/eval.rs
FrameRoot caches the shadow-stack slot and uses it for subsequent frame retrieval.
JIT dispatch and resume state
pyre/pyre-jit/src/eval.rs
Post-collection dispatch reseeds frame state, while restart handling corrects instruction position and operand-stack depth.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant JIT as JIT eval
  participant Root as FrameRoot
  participant Stack as Shadow stack
  JIT->>Root: Create frame root
  Root->>Stack: Resolve and cache shadow-stack slot
  JIT->>Root: Retrieve frame
  Root->>Stack: Read entry through cached slot
  JIT->>JIT: Reseed dispatch state after collection
  JIT->>JIT: Correct restart instruction and operand depth
Loading

Possibly related PRs

  • youknowone/pyre#522: Both changes optimize shadow-stack access in JIT-related execution paths.

Suggested reviewers: lifthrasiir

Poem

I cached the stack-slot, quick as a hare,
Roots now find their frames with care.
The JIT hops back to the proper place,
With balanced operands and steady pace.
Thump-thump, the restart path is bright!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly reflects the main changes: caching the FrameRoot shadow-stack cell and fixing walk-end handback VSD.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch miframe

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Wrap the slot_get expression to the form rustfmt 1.9 produces. Whitespace only.

Assisted-by: Claude
The non-flush walk-end handback set the resume pc but not valuestackdepth:
when a loop-header marker sits inside a super-instruction, the walk closes
the loop at loop_header_pc + 1 and leaves valuestackdepth advanced through
the super-instruction, while the handback wrote only last_instr/next_instr.
The frame was handed back with pc and operand depth inconsistent, and
eval_loop_jit patched it downstream by recomputing instruction.stack_effect
every opcode step and, when the projected push overflowed the frame array
and the depth table agreed, advancing the pc instead of dispatching.

Make the handback symmetric with the flush leg and the blackhole legs
(apply_blackhole_crn_handoff): after set_last_instr_from_next_instr, call
correct_resume_vsd(restart_pc) to re-derive the operand depth from the
resume pc. The handed-back frame is then self-consistent, so the per-step
reconciliation and its stack_effect recompute are removed. valuestackdepth
is now only ever compared against the precomputed depth table
(depth_based_vsd_for_wcode), never re-derived from a per-step stack effect
-- the dispatch loop no longer computes a stack effect at all, and the
depth table is the single source of truth. rustpython's stack_effect_info
is no longer consulted on the hot path.

Assisted-by: Claude
@youknowone youknowone changed the title jit: cache the shadow-stack cell in FrameRoot; const-init SHADOW_STACK (gh#394 follow-on) jit: gh#394 warmup follow-ons — cache FrameRoot shadow-stack cell + correct walk-end handback vsd Jul 21, 2026
@youknowone

Copy link
Copy Markdown
Owner Author

Added commit 370e908 — the second gh#394 warmup follow-on (correct valuestackdepth on the super-instruction walk-end handback, removing the per-step stack_effect reconciliation). Body updated to cover both commits.

A/B for this commit (cand-1 baseline vs this commit, interleaved best-of-N, 21 rounds): +1.39% best-of-N / +0.17% median, bands overlap — perf-neutral-to-slightly-positive. It lands as a correctness/orthodoxy cleanup that removes machinery; the A/B confirms no regression. Gates green: check.py 241/241 both backends byte-identical, gc_stress 12/12, pyre-jit 320+12, golden 3004320000.

commented by Claude

@github-actions

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 370e908).
Updated: 2026-07-21T09:40:38.263Z

Files in the reviewed diff
majit/majit-gc/src/shadow_stack.rs
pyre/pyre-jit/src/eval.rs

1. Regressions to PyPy parity introduced by this patch

None.

2. Other mismatches introduced by this patch

None.

3. Pre-existing mismatches (already present before this patch)

None.

4. Structural adaptations

  • majit/majit-gc/src/shadow_stack.rs:433 ↔ rpython/translator/c/funcgen.py:278 — Rust caches an opaque TLS RefCell address; the C backend resolves a single gc_enter_roots_frame setup per generated function. This is a Rust TLS/runtime adaptation, not a semantic root-stack divergence.

  • majit/majit-gc/src/shadow_stack.rs:375 ↔ rpython/memory/gctransform/shadowstack.py:344const TLS initialization requires Vec::new() rather than PyPy’s eagerly raw-allocated fixed-capacity root stack. Capacity/allocation timing differs, while push/pop/root visibility semantics remain equivalent.

  • pyre/pyre-jit/src/eval.rs:6931 ↔ pypy/module/pypyjit/interp_jit.py:87 — restart-PC stack-depth correction is required because Pyre’s CPython-compatible compiler can place JIT markers inside super-instructions; PyPy dispatches its own bytecode one instruction at a time. This is an opcode/compiler adaptation.

@youknowone

Copy link
Copy Markdown
Owner Author

CI status: all green except one pre-existing failure.

  • pyre/check.py (ubuntu-24.04): FAIL wasm synth/delete_negative_open_slice_hot wrong output (+ its cranelift twin), Linux-x86_64 only. dynasm 241/241 passes; macOS and Windows check.py both pass; all cargo test / wasm build / CPython gate / Codex parity / fmt jobs pass.
  • This failure is pre-existing and not caused by this PR: it reproduces on main (e.g. commit ad785680f, same check.py (ubuntu-24.04) failure), and the negative-open-slice deletion path on the Linux wasm/cranelift backends is untouched by these commits — the diff is confined to pyre/pyre-jit/src/eval.rs (a dynasm/interp valuestackdepth seam) and the majit-gc shadow-stack slot cache. Local dynasm+cranelift on macOS are byte-identical 241/241.

commented by Claude

@youknowone
youknowone merged commit 56644af into main Jul 21, 2026
30 of 31 checks passed
@youknowone
youknowone deleted the miframe branch July 21, 2026 12:18
youknowone added a commit that referenced this pull request Aug 13, 2026
`run_perfn_walk` set `WALK_END_RESTART_PC` for every `CloseLoop`. The
portal consumes that cell only on the leg where the walk-end flush
declined, and a decline keeps the legacy replay, whose contract is that
the frame still holds pre-walk state. Applying the resume pc there moved
`last_instr` and `valuestackdepth` to the loop header while the locals
stayed at the trace entry, so the frame carried values from two points at
once: `_process_class` resumed with `cmp_fields` unbound and `field`
holding a bound method pushed ~14 lines later, raising
`TypeError: 'str' object is not an iterator` at dataclasses.py:1170.

Set the cell only when the resume pc differs from the loop header — the
marker legs it was added for (#698), where a loop-header marker inside a
super-instruction leaves the frame advanced past the header.

Reproduced on darwin-aarch64 and linux-aarch64 with a 15-line dataclass
loop under `PYRE_JIT="threshold=3,function_threshold=3"`; green after the
change at thresholds 3, 4, 5, 6, 8, 12, 20, 40, 80 and 200.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 13, 2026
`run_perfn_walk` set `WALK_END_RESTART_PC` for every `CloseLoop`. The
portal consumes that cell only on the leg where the walk-end flush
declined, and a decline keeps the legacy replay, whose contract is that
the frame still holds pre-walk state. Applying the resume pc there moved
`last_instr` and `valuestackdepth` to the loop header while the locals
stayed at the trace entry, so the frame carried values from two points at
once: `_process_class` resumed with `cmp_fields` unbound and `field`
holding a bound method pushed ~14 lines later, raising
`TypeError: 'str' object is not an iterator` at dataclasses.py:1170.

Set the cell only when the resume pc differs from the loop header — the
marker legs it was added for (#698), where a loop-header marker inside a
super-instruction leaves the frame advanced past the header.

Reproduced on darwin-aarch64 and linux-aarch64 with a 15-line dataclass
loop under `PYRE_JIT="threshold=3,function_threshold=3"`; green after the
change at thresholds 3, 4, 5, 6, 8, 12, 20, 40, 80 and 200.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 14, 2026
`run_perfn_walk` set `WALK_END_RESTART_PC` for every `CloseLoop`. The
portal consumes that cell only on the leg where the walk-end flush
declined, and a decline keeps the legacy replay, whose contract is that
the frame still holds pre-walk state. Applying the resume pc there moved
`last_instr` and `valuestackdepth` to the loop header while the locals
stayed at the trace entry, so the frame carried values from two points at
once: `_process_class` resumed with `cmp_fields` unbound and `field`
holding a bound method pushed ~14 lines later, raising
`TypeError: 'str' object is not an iterator` at dataclasses.py:1170.

Set the cell only when the resume pc differs from the loop header — the
marker legs it was added for (#698), where a loop-header marker inside a
super-instruction leaves the frame advanced past the header.

Reproduced on darwin-aarch64 and linux-aarch64 with a 15-line dataclass
loop under `PYRE_JIT="threshold=3,function_threshold=3"`; green after the
change at thresholds 3, 4, 5, 6, 8, 12, 20, 40, 80 and 200.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 14, 2026
`run_perfn_walk` set `WALK_END_RESTART_PC` for every `CloseLoop`. The
portal consumes that cell only on the leg where the walk-end flush
declined, and a decline keeps the legacy replay, whose contract is that
the frame still holds pre-walk state. Applying the resume pc there moved
`last_instr` and `valuestackdepth` to the loop header while the locals
stayed at the trace entry, so the frame carried values from two points at
once: `_process_class` resumed with `cmp_fields` unbound and `field`
holding a bound method pushed ~14 lines later, raising
`TypeError: 'str' object is not an iterator` at dataclasses.py:1170.

Set the cell only when the resume pc differs from the loop header — the
marker legs it was added for (#698), where a loop-header marker inside a
super-instruction leaves the frame advanced past the header.

Reproduced on darwin-aarch64 and linux-aarch64 with a 15-line dataclass
loop under `PYRE_JIT="threshold=3,function_threshold=3"`; green after the
change at thresholds 3, 4, 5, 6, 8, 12, 20, 40, 80 and 200.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 14, 2026
…ection (#1198)

* jit: do not hand back a walk-end resume pc the flush declined

`run_perfn_walk` set `WALK_END_RESTART_PC` for every `CloseLoop`. The
portal consumes that cell only on the leg where the walk-end flush
declined, and a decline keeps the legacy replay, whose contract is that
the frame still holds pre-walk state. Applying the resume pc there moved
`last_instr` and `valuestackdepth` to the loop header while the locals
stayed at the trace entry, so the frame carried values from two points at
once: `_process_class` resumed with `cmp_fields` unbound and `field`
holding a bound method pushed ~14 lines later, raising
`TypeError: 'str' object is not an iterator` at dataclasses.py:1170.

Set the cell only when the resume pc differs from the loop header — the
marker legs it was added for (#698), where a loop-header marker inside a
super-instruction leaves the frame advanced past the header.

Reproduced on darwin-aarch64 and linux-aarch64 with a 15-line dataclass
loop under `PYRE_JIT="threshold=3,function_threshold=3"`; green after the
change at thresholds 3, 4, 5, 6, 8, 12, 20, 40, 80 and 200.

Assisted-by: Claude

* cpyext: address review findings on the extension slice

Replace the three cpyext tables' `LazyLock<Mutex<..>>` with a `ForkMutex<T>`
whose lock word `after_fork_child` rebuilds in place, keeping the payload: the
child inherits the parent's mappings, so the loaded libraries and the raw-mirror
census must survive while only the stale lock word is replaced.

Seed `__spec__`/`__loader__`/`__package__`/`__file__` on a natively resolved
extension module from `_bootstrap_external.spec_from_file_location`, the way the
source and builtin branches of the same `load_part` already do.

Accept `create_dynamic(spec, file)`: the fixed arity of 1 rejected the two-
argument call with a TypeError before the loader ran.

Accept `m_size == 0` in `PyModule_Create2` alongside `-1`; neither allocates
per-module state.

Set `PY_VERSION_HEX` to `0x030E06F0`, matching `sys.hexversion`. The previous
`0x030E0000` sorts below `0x030E00F0` (3.14.0 final).

Root `path_list` before the `__path__` store, which allocates.

Assert `PyObjectRef` and `majit_ir::GcRef` have the same size and alignment at
the root-forwarding cast.

Assisted-by: Claude

* call: reload constructor arguments from the shadow stack

`type_descr_call_with_mode` pinned neither the type nor its arguments, and
`call_with_kwargs_in_ctx` pinned them at entry but then forwarded the
incoming slices raw. Both build the `__init__` argument list after `__new__`
has run Python code, so a minor collection during `__new__` left the
forwarded slice holding pre-move addresses; `__init__` stored one of those
into an instance attribute, and the next collection tripped over it through
the remembered set.

Both paths now read the type and every argument back through
`pyre_object::gc_roots`, which is the shape `type_descr_call_impl` already
used.

Assisted-by: Claude

* _ctypes: reject a symbol that resolves to address 0

`dlsym` reports a miss by returning NULL, and a resolver that itself returns
NULL leaves `dlerror` unset, so `lookup_function_symbol_addr` reported
success with address 0. The unix `lookup_symbol` now rejects that, matching
`rdynload.dlsym`, and `_ctypes.dlsym` goes through `lookup_symbol` instead of
calling the host lookup directly.

Assisted-by: Claude

* cpyext: reject an init symbol that resolves to address 0

`load_extension_module` transmuted the looked-up address to the init
signature without checking it, so a symbol resolving to NULL became a call
through a null pointer.

Assisted-by: Claude

* gc: describe the mapdict storage trace as it is implemented

`mapdict_storage_custom_trace`'s doc called `storage` an off-GC
`Box<Vec<PyObjectRef>>` and said `instance_walk_boxed_storage` consults
the map to skip erased unboxed slots.  Neither holds: `storage` is a
GC-managed leaf block allocated stable and non-moving by
`alloc_mapdict_storage_block`, and the walk iterates `0..capacity`
unconditionally, which `erase_unboxed` licenses by storing an ordinary
`GC_INT_ARRAY` reference in the slot.

Assisted-by: Claude

* jit: publish the gcmap around the varsize nursery slowpath call

`CallMallocNurseryVarsize` stored a null gcmap into the jitframe before
calling `dynasm_nursery_slowpath_varsize`, which can collect.  A null
gcmap tells the collector the frame holds no references, so the slots
the register allocator spilled into (it uses `SAVE_ALL_REGS` here) are
not traced and the values they hold are not forwarded.  The fixed-size
siblings (`CallMallocNursery` / `CallMallocNurseryHeaderless` /
`CallMallocNurseryVarsizeFrame`) already push
`pending_malloc_nursery_gcmap` and spill the registers into the jitframe
slots the gcmap's bits name; this brings the varsize path to the same
shape on both backends.

The register allocator already attaches a gcmap to this op through
`perform_with_gcmap`, so the value was available and unused.

This does not change the `-m test.test_unittest` GC crash: 5/5 runs
before and after the change abort with the same `GC BUG: invalid
type_id`.  A constant-length array lowers through `gen_malloc_nursery`
(the `total_size >= 0` arm of `handle_new_array`), not this op.

Assisted-by: Claude

* list: root the subscript operands across the replacement materialization

`STORE_SUBSCR` pops the container, key and value off the value stack
before dispatching, so the frame no longer roots any of them.
`setitem_list_slice` then held all three as bare addresses across
`slice_unpack` (which honors `__index__`), `collect_iterable` (which runs
the iterable's own Python code) and the two `w_list_new` allocations.
Publish the operands on the shadow stack at entry and re-read each after
every step that can collect; the extended-slice loop brackets its item
root per iteration rather than pushing one root per element.

Measured with `MAJIT_GC_NURSERY_POISON=1` on `-m test.test_unittest`,
aarch64 Linux: before the change 3/3 runs abort in
`switch_to_object_strategy` on a receiver whose header and body both read
the poison fill; after it, 0/5 and the suite reaches
`Ran 1090 tests ... OK`. `PYRE_NO_JIT=1` reproduces the same abort at the
same site, so the window is in the interpreter.

This does not change the `GC BUG: invalid type_id` abort on the same
fixture: 5/5 runs with poison off before and after.

Assisted-by: Claude
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant