Skip to content

jit, gc, _pickle: memoize callee inline verdicts; fix the immortal-pickler child loss and the unguarded probe in major marking - #994

Merged
youknowone merged 5 commits into
mainfrom
fbw
Aug 3, 2026
Merged

youknowone merged 5 commits into
mainfrom
fbw

Conversation

@youknowone

@youknowone youknowone commented Aug 2, 2026 •

Copy link
Copy Markdown
Collaborator

Five commits: two on the walker's inline path, three closing documented GC/descr defects.

pyre-jit-trace: decide callee body-shape verdicts once per jitcode payload

Six try_walker_inline_* decline predicates are pure functions of the callee's code object but re-decoded the callee bytecode at every call-site evaluation (callee_body_contains_raise, method_form_callee_body_supported, the three exception_string_override_*, and the abort_permanent scan). They now compute once into InlineBodyFacts, a OnceCell on PyJitCodePayload beside sub_descr_pool — the home the file already documented for this kind of verdict, and one replace_with drops together with the body it describes.

That retires CALLEE_ABORT_PERMANENT_SEEN, a thread-local BTreeMap keyed on the raw w_code address, whose surrounding comment already named the hazard: a freed-and-recycled code object answers for the wrong callee.

Upstream makes this decision once per graph at codewriter time (policy.py look_inside_graph) and bakes it into the jitcode; _opimpl_inline_call1/2/3 have no runtime decline at all. pyre decides at trace time, so the verdicts at least become per-code-object rather than per-call-site.

pyre-jit-trace: cache the four PYRE_FBW_* / PYRE_P2 diag flags

22 std::env::var_os sites now route through OnceLock<bool> helpers matching the pcmap_*_audit_enabled shape already in that module. PYRE_FBW_STRICT_DIAG was being read inside callee_fast_path_inlinable's per-opcode decode loop.

jit: publish the residual-call last_instr under the parent-struct field descr

vable_setfield_descr is a raw pass-through to record_op_with_descr and does not apply the vable_static_record_descr resolution that vable_setfield does. The pre-residual-call publish passed the vinfo's own static_field_descrs[idx], which numbers the field by the vinfo [token, statics, arrays] order rather than PyFrame's struct declaration order.

emit_traceback_node writes the same field on the same frame at the same offset through the parent-struct descr. Under two identities neither store supersedes the other and flush order decides which reaches the frame. virtualizable.py:71 builds the vinfo descrs with cpu.fielddescrof(VTYPE, name), so upstream has one object per field. fbw_state.rs already made this correction for the exit publish.

Also deletes gen_writeback_inline_frame_to_heap, which carried the same shape on two more stores and has had no callers since the FBW walker became the sole tracer.

Verified by pyre/bench/frame_lineno_mid_replay_regression.py, which pins tb_lineno against tb_frame.f_lineno on a frame that catches — the two-writers-one-offset case.

_pickle, memoryview: allocate the pickler types stable and barrier their field stores

An immortal object is traced exactly once — grey_child's registered-header path sets VISITED and nothing clears it, and minors never trace immortal children. The only thing that recurses into an immortal's children is walk_raw_immortal_roots, which runs from root slots only. So an immortal with mutable GC slots keeps its children alive only while it sits on a root slot at collection time; holding one in an instance attribute, a module global or a list element loses them.

W_Pickler / W_Unpickler / their memo proxies / W_BufferWrapper are exactly that shape (w_memo persists across dump calls). All five now use allocate_stable — non-moving managed old-gen, an ordinary marked and swept object, which is the state W_Pickler(W_Root) has upstream.

The proxies proved the second half was missing: UnpicklerMemoProxy::clear carries a write barrier on its w_memo store while PicklerMemoProxy::clear carries none. Added pickler_write_barrier on every pickler GC-pointer store per framework.py:1423. clear_memo additionally re-reads self from a pinned slot after w_list_new, which can collect.

A repro holding the pickler in an instance attribute, a module global and a list element across two majors died 5/10 runs with GC BUG: invalid type_id ... site=remember_young_pointer_insert; 0/20 after.

gc: range-check the address before dereferencing it in the major marking path

seed_major_root probed an arbitrary address before any range check — registered_pyobject_header filters only on alignment and a minimum address, then loads the vtable word at addr and the header at addr - 8. The major root banks are untyped i64 slices (walk_bh_regs over a blackhole registers_r sized to num_regs with only the live subset filled; walk_resume_ref_roots over the in-flight resume image) whose contract is that non-pointer slots pass through unchanged. The minor path still range-checks first in drag_out_root, so this was major-only.

incminimark.py _collect_obj performs no such probe: the type system guarantees every Ptr(GcStruct) root is a real GC object. pyre's roots are untyped and the arena range check stands in for that guarantee, so it must precede every dereference. The same probe is dropped from grey_child's header_bearing arm, which had a second effect: an address it admitted got VISITED set with no pass to clear it (the only reset walks prebuilt_root_objects), so such an object was traced in the first major after it appeared and never again. That arm also returned ahead of the GC BUG: invalid major child diagnostic.

The probe was reaching non-arena picklers because those objects were immortal; the preceding commit makes them managed, so is_managed_heap_object answers for them directly. pickle_ctor_args — the fixture the probe was introduced for — passes 10/10 without it, jitstats unchanged.

Verification

Local, on the current base:

  • pickle_ctor_args 10/10, jitstats identical to baseline (loops_compiled=5 bridges_compiled=0 loops_aborted=0 guard_failures=1)
  • mutate_then_raise_caught 0 crashes / 40 under MAJIT_STRICT=1
  • pickler-in-instance-attribute repro 0/20 (was 5/10)
  • frame_lineno_mid_replay_regression.py PASS
  • cargo test -p majit-gc 228 passed; cargo test -p pyre-jit-trace --features dynasm 337 passed
  • check_synthetic.py ALL PASSED
  • test_descr, test_pickletools, test_picklebuffer — no regressions

Not run locally: full check.py (cranelift/wasm backends). Note check.py is red on main itself at every recent commit, and the two jit-stats fixtures that fail here — exception_args_virtual and list_length_hint_validate — are named in #970's commit message as deliberately left alone ("re-recording them would disarm the floor"). The sandbox e2e job is flaky on main (pass/fail/pass across three consecutive main commits).

— authored by Claude

@coderabbitai

coderabbitai Bot commented Aug 2, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

@youknowone, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 33 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e43027da-3232-4f36-8968-5ec2f0d53ac5

📥 Commits

Reviewing files that changed from the base of the PR and between 5fc310a and fff3d0b.

📒 Files selected for processing (14)
  • majit/majit-gc/src/collector.rs
  • pyre/pyre-interpreter/src/module/_pickle/pickler.rs
  • pyre/pyre-interpreter/src/module/_pickle/unpickler.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/diag.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/resume_snapshot.rs
  • pyre/pyre-jit-trace/src/pyjitcode.rs
  • pyre/pyre-jit-trace/src/state.rs
  • pyre/pyre-jit-trace/src/trace.rs
  • pyre/pyre-jit-trace/src/trace_opcode.rs
  • pyre/pyre-jit/src/eval.rs
  • pyre/pyre-object/src/memoryview.rs

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

https://github.com/youknowone/pyre/blob/4d60a8164ddd6ea51c41661fd93f99a15e77d853/pyre-jit-trace/src/jitcode_dispatch/diag.rs#L55-L57
P2 Badge Route every inline diagnostic check through the cache

When PYRE_FBW_INLINE_DIAG is normally unset, eligible user-call inline attempts still execute the direct std::env::var check at inline_call.rs:2841, so this hot path continues taking the environment lock and walking the environment table despite the new cache. Additional direct checks remain at inline_call.rs:4201,4395,4535 and vable_ops.rs:360; route these through fbw_inline_diag_enabled() as well to complete the intended optimization.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@github-actions

github-actions Bot commented Aug 3, 2026 •

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit fff3d0b).
Updated: 2026-08-03T05:49:14.680Z

Files in the reviewed diff
majit/majit-gc/src/collector.rs
pyre/pyre-interpreter/src/module/_pickle/pickler.rs
pyre/pyre-interpreter/src/module/_pickle/unpickler.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/diag.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/resume_snapshot.rs
pyre/pyre-jit-trace/src/pyjitcode.rs
pyre/pyre-jit-trace/src/state.rs
pyre/pyre-jit-trace/src/trace.rs
pyre/pyre-jit-trace/src/trace_opcode.rs
pyre/pyre-jit/src/eval.rs
pyre/pyre-object/src/memoryview.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/collector.rs:2477 ↔ rpython/memory/gc/incminimark.py:2739 — Rust root banks may contain untyped/stale words, so the port must verify managed-heap membership before dereferencing; RPython’s typed Ptr(GcStruct) roots need only the nursery check.

  • pyre/pyre-interpreter/src/module/_pickle/pickler.rs:372 ↔ pypy/module/_pickle/interp_pickle.py:495 — allocate_stable replaces PyPy’s ordinary GC allocation because Rust methods retain raw self pointers across calls that can allocate; stable old-gen allocation plus explicit barriers preserves object identity/liveness.

  • pyre/pyre-interpreter/src/module/_pickle/unpickler.rs:106 ↔ pypy/module/_pickle/interp_pickle.py:1968 — same stable-allocation adaptation for raw-pointer-backed Rust instance methods and proxy back-references.

  • pyre/pyre-object/src/memoryview.rs:123 ↔ pypy/objspace/std/memoryobject.py:86 — the CPython-style buffer wrapper is a separately allocated, stable Rust GC object so its raw-pointer fields remain valid across allocation; PyPy keeps this relationship in its managed object graph.

  • pyre/pyre-jit-trace/src/pyjitcode.rs:328 ↔ rpython/jit/codewriter/policy.py:48 — PyPy decides graph inlining during translation and rewrites calls to inline_call_*; pyre’s CPython-bytecode full-body walker instead caches equivalent body facts on the per-code payload. The cache is correctly invalidated with the payload in replace_with at pyre/pyre-jit-trace/src/pyjitcode.rs:545.

  • pyre/pyre-interpreter/src/pycode.rs:498 ↔ pypy/interpreter/pycode.py:93 — pre-existing: PyCode is malloc_typed/non-moving in pyre rather than an ordinary traced PyPy GC object. Its explicit raw-root walkers are the Rust lifetime adaptation; this file is not part of the patch.

…yload

Six inline-recipe predicates decode the whole callee body, and the recipes
called them per call site on every trace that reached the call:
`callee_body_contains_raise`, `method_form_callee_body_supported`,
`exception_string_override_straight_line`, `..._sample_safe`,
`..._has_nested_call`, and the `abort_permanent` scan.

Each is a pure function of the callee's assembled bytecode and its descr pool,
which `sub_jitcode_body_for_code` and `sub_jitcode_descr_pool_for_code` resolve
from the same code object. `PyJitCodePayload` now carries them as an
`InlineBodyFacts` `OnceCell` beside `sub_descr_pool`, and `replace_with` drops
them with the body they describe.

This retires `CALLEE_ABORT_PERMANENT_SEEN`, a thread-local `BTreeMap` keyed by
the raw `w_code` address. Its own comment recorded the hazard: switching
`w_code_new` to `try_gc_alloc_stable` would let a reclaimed address answer for
another code object, where a stale `false` admits an inline whose body carries
`abort_permanent`.

Every substituted site already declined when the descr pool was absent, so
resolving the verdicts before that check leaves the decisions unchanged.

Assisted-by: Claude
`std::env::var_os` takes a global lock and walks the env table on every call.
`PYRE_FBW_STRICT_DIAG` was read from inside `callee_fast_path_inlinable`'s
per-opcode decode loop, and `PYRE_FBW_INLINE_DIAG` on every inline decline.

Adds `fbw_strict_diag_enabled`, `fbw_inline_diag_enabled`, `fbw_mf_diag_enabled`
and `p2_diag_enabled` to `jitcode_dispatch::diag`, matching the `OnceLock<bool>`
shape of the `pcmap_*_audit_enabled` helpers already there, and routes all 22
call sites in the crate through them.

Assisted-by: Claude
…ld descr

`vable_setfield_descr` is a raw pass-through to `record_op_with_descr`; it does
not apply the `vable_static_record_descr` resolution that `vable_setfield`
does. The pre-residual-call `last_instr` publish passed the vinfo's own
`static_field_descrs[idx]`, which numbers the field by the vinfo
`[token, statics, arrays]` order rather than PyFrame's struct declaration
order, so the store carried a descr identity of its own.

`emit_traceback_node` writes the same field on the same frame at the same
offset through `pyframe_next_instr_descr()`, i.e. the parent-struct descr.
Under two identities neither store supersedes the other and which one reaches
the frame is decided by deferred-flush order rather than record order.
`virtualizable.py:71` builds the vinfo descrs with
`cpu.fielddescrof(VTYPE, name)`, so upstream has one object per field and the
question cannot arise. `fbw_state.rs` already made this correction for the
exit publish; this is the same correction for the residual-call publish.

Also delete `gen_writeback_inline_frame_to_heap`, which carried the same
vinfo-descr shape on two more stores. It has had no callers since the trait
meta-interpreter was retired and the FBW walker became the sole tracer; its
live successor, the CALL_ASSEMBLER inline-callee `last_instr` pin, already
uses `pyframe_next_instr_descr()`.

`trace_ctx.rs gen_store_back_in_vable` records under the vinfo descr too, but
it is a faithful port of `pyjitpl.py:3489-3521`, writes all six statics plus
the array at once, and is shared with the synthetic-VTYPE unit tests; it is
left alone.

Verified: pyre/bench/frame_lineno_mid_replay_regression.py PASS (it pins
`tb_lineno` against `tb_frame.f_lineno` on a frame that catches, which is the
two-writers-one-offset case).

Assisted-by: Claude
…eir field stores

An immortal object allocated with `alloc_with_gc_header` is traced exactly
once: `grey_child`'s registered-header path sets VISITED and nothing clears
it, and minors never trace immortal children at all. The only thing that
recurses into an immortal's children is `walk_raw_immortal_roots`, which runs
from root slots only. So an immortal with mutable GC slots keeps its children
alive only while it sits on a frame/shadow root slot at collection time —
holding one in an instance attribute, a module global or a list element loses
them.

`W_Pickler` / `W_Unpickler` / their memo proxies / `W_BufferWrapper` are
exactly that shape: `w_memo` persists across `dump` calls. Allocate all five
with `allocate_stable`, i.e. non-moving managed old-gen with
TRACK_YOUNG_PTRS — an ordinary marked and swept object, which is the state
`W_Pickler(W_Root)` has upstream. All five together: an immortal proxy over a
managed pickler would recreate the latch on the proxy.

The proxies' twin methods proved the second half was already missing:
`UnpicklerMemoProxy::clear` carries `unpickler_write_barrier` on its
`w_memo` store while `PicklerMemoProxy::clear` carries nothing. Add
`pickler_write_barrier` and call it on every pickler GC-pointer store —
`clear_memo`, `set_memo`, `set_dispatch_table`, `set_persistent_id`, the proxy
`clear` — per `framework.py:1423`, which requires one barrier per
non-constant GC setfield regardless of the child's generation. The deleters
store `PY_NULL`, a constant, and are left alone. The two post-allocate proxy
wirings get one as well: the initializing-store exemption assumes a fresh
young object, and `malloc_typed_stable` has already consumed the proxy's
TRACK_YOUNG_PTRS by firing the managed barrier during construction.

`clear_memo` additionally re-reads `self` from a pinned slot after
`w_list_new`, which can collect; it was storing through the pre-call `&mut`.

Sweeping a `W_Unpickler` now leaks its `encoding`/`errors` `String` buffers,
since the registration installs no destructor — strictly better than today,
where the whole object leaks.

Verified with a repro holding the pickler in an instance attribute, a module
global and a list element, dumping across two major collections: 5/10 runs
died with `GC BUG: invalid type_id ... site=remember_young_pointer_insert`
before the fix, 0/20 after. test_descr, test_pickletools and test_picklebuffer
report no regressions, and pickle_ctor_args jitstats are unchanged
(loops_compiled=5, bridges_compiled=0, loops_aborted=0, guard_failures=1).

`W_MemberDescr` is the censused sixth type and is deliberately excluded: its
setter uses `mark_prebuilt_roots_dirty`, an immortal-only mechanism, and
member descriptors are minted for every `__slots__` name including those on
builtin types anchored in immortal type dicts.

Assisted-by: Claude
…ing path

`seed_major_root` probed an arbitrary address before any range check:
`registered_pyobject_header` filters only on `addr >= GcHeader::SIZE` and
8-byte alignment, then performs two loads — the vtable word at `addr` and the
header at `addr - 8`. The major root banks are untyped `i64` slices
(`walk_bh_regs` over a blackhole `registers_r` sized to the jitcode's
`num_regs` with only the live subset filled, and `walk_resume_ref_roots` over
the in-flight resume image), whose documented contract is that slots holding
non-pointers pass through unchanged. So the probe could dereference an
arbitrary word. The minor path was never changed and still range-checks first
in `drag_out_root`, which is why this was major-only.

`incminimark.py:2739-2753 _collect_obj` performs no such probe: the type
system guarantees every `Ptr(GcStruct)` root is a real GC object, so the only
tests are non-null and `is_in_nursery`. pyre's roots are untyped, and the
arena range check is what stands in for that guarantee, so it has to precede
every dereference. Restore that order in `seed_major_root` and drop the same
probe from `grey_child`'s `header_bearing` arm.

The `grey_child` arm had a second effect: an address it admitted got VISITED
set with nothing to clear it. The only reset passes are the oldgen sweeps and
`finish_incremental_cycle`, which iterates only `prebuilt_root_objects` — a
list appended to solely under NO_HEAP_PTRS. A non-arena object admitted there
was therefore traced in the first major after it appeared and never again.
`incminimark.py:2782` states the invariant: such an object "should be in
'prebuilt_root_objects', and the GCFLAG_VISITED will be reset at the end of
the collection". The arm also returned ahead of the `GC BUG: invalid major
child` diagnostic, swallowing the class of bug the probe was added to chase.

`registered_pyobject_header` keeps its remaining caller in `do_write_barrier`,
where the bootstrap-prebuilt family needs it; its doc is updated to say so.

The probe was reaching non-arena picklers because those objects were immortal.
The preceding commit allocates them stable, i.e. managed, so
`is_managed_heap_object` now answers for them directly and the probe has
nothing left to find there. Validated in that combination: pickle_ctor_args,
the fixture the probe was introduced for, passes 10/10 with jitstats
unchanged from baseline (loops_compiled=5, bridges_compiled=0,
loops_aborted=0, guard_failures=1). Also 0/40 on mutate_then_raise_caught
under MAJIT_STRICT, 0/20 on the pickler-in-instance-attribute repro,
`cargo test -p majit-gc` 228 passed, and check_synthetic.py ALL PASSED.

Also refresh a stale comment in pyre-jit: PyCode is allocated with
`malloc_typed`, not `Box::into_raw`, so the reason its registered TypeInfo
trace is inert is its empty gc_ptr_offsets.

Assisted-by: Claude
@youknowone youknowone changed the title jit: memoize the callee body-shape inline verdicts on the jitcode payload; cache the PYRE_FBW_*/PYRE_P2 diag flags jit, gc, _pickle: memoize callee inline verdicts; fix the immortal-pickler child loss and the unguarded probe in major marking Aug 3, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fff3d0b473

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +55 to +57
pub(crate) fn fbw_inline_diag_enabled() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENABLED.get_or_init(|| std::env::var_os("PYRE_FBW_INLINE_DIAG").is_some())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route the remaining inline diagnostics through the cache

When PYRE_FBW_INLINE_DIAG is unset (the normal configuration), several hot-path consumers still bypass this helper and call std::env::var on every inline attempt, notably inline_call.rs:2841, inline_call.rs:4395, inline_call.rs:4535, and vable_ops.rs:360. Consequently, successful user-call and type-call tracing still takes the global environment lock that this change is intended to remove; use fbw_inline_diag_enabled() at the remaining consumers as well.

Useful? React with 👍 / 👎.

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