Skip to content

jit: trace the cranelift force token as a GC slot, plus _pickle and JitCodeBuilder follow-ups - #1084

Merged
youknowone merged 11 commits into
mainfrom
aheui
Aug 7, 2026
Merged

jit: trace the cranelift force token as a GC slot, plus _pickle and JitCodeBuilder follow-ups#1084
youknowone merged 11 commits into
mainfrom
aheui

Conversation

@youknowone

@youknowone youknowone commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Four independent changes: one GC-tracing correctness fix in the JIT, two allocation/lookup reuses, and one diag counter.

jit: trace the force token as a GC slot

virtualref.py:19 declares virtual_token and rvirtualizable.py:29 declares
vable_token as llmemory.GCREF, and jitframe.py:59-61 makes JITFRAME a
GcStruct allocated by lltype.malloc at :50. cranelift allocates its
JITFRAME from the nursery accordingly
(compiler.rs:7353 run_compiled_code_inner, realloc :3800, both
gc.alloc_nursery_no_collect_typed), but three copies of that address were
excluded from GC tracing, each citing the same premise — "an active JITFRAME
address — libc::calloc'd on a host-side pool, not nursery/oldgen"
. That
premise holds only for dynasm (alloc_off_gc_jitframe, runner.rs:2815). On
cranelift a minor collection moves the frame, updates the shadow-stack root,
and leaves all three copies dangling; force_token_to_dead_frame
(compiler.rs:2888) validates only != 0, so a recycled word passes and
jitframe_resolve's jf_forward walk faults.

The fix is the tracing, not the allocation — jitframe.py says JITFRAME is
movable and GC-allocated, so cranelift's nursery allocation is the
parity-correct half. rgc.pin appears under rpython/jit/ only in
zrpy_gc_test.py, so pinning the frame has no upstream basis either.

  • virtual_token joins the vref gc_ptr_offsets
  • pyframe_object_custom_trace visits vable_token
  • the cranelift FORCE_TOKEN result is no longer excluded from relocatable
    ref-root slots
  • TOKEN_TRACING_RESCALL was the u64::MAX sentinel, which is not a legal
    value for a traced slot; it becomes the address of a registered GC leaf,
    matching virtualizable.py:326-330 where the sentinel is the prebuilt
    _dummy object. This matters because the two tracing paths differ: minor
    (collector.rs:2457/:2490) gates every slot on is_nursery_object_start
    so junk is inert, but major grey_child (:3266) gates on
    is_managed_heap_object and then panics on a bad type id.
  • host-side active-token stores now take the same write barrier a compiled
    SETFIELD_GC store would

Evidence

The defect surfaces under three different exit codes — 139 (SIGSEGV), 134
(force_token_to_dead_frame: jf_force_descr is null, compiler.rs:2896) and
101 (GC BUG panic) — so the crash predicate is exit NOT IN {0,1}.

PYPY_GC_NURSERY=2G (minors effectively off) is the decisive control: it makes
the reproducer clean, which is what identifies the family.

Same-base A/B, the commit checked in and out at file level, full
lib-python/3/test/test_pickle.py on cranelift, 3 runs each:

tree result
without this commit 134 / 134 / 139
with it signature gone

Reduced reproducer (test_pickle.py InMemoryPickleTests PyPicklerTests PyPicklingErrorTests): 8/13 crashes → 0/6. GC stress at
PYPY_GC_NURSERY=64K / 512K: clean 6/6. Full test_pickle now runs
Ran 999 tests with no panic.

_pickle: reuse the unpickler stacks and cache the pickler write callable

Pickler resolves file.write once in the constructor into a rooted w_write
field, and Framer::flush calls it directly instead of re-resolving the method
per flush. The constructor's error order is unchanged: the write check still
precedes the protocol-5 buffer_callback check.

This commit also moved the unpickler's stack and metastack to the constructor;
that half is reverted by _pickle: allocate the unpickler stack and metastack per load again below. Reviewing the two commits together shows only
the Pickler change.

majit: drop two dead allocation sources in JitCodeBuilder

add_struct_field_descr deep-copied the whole parent BhSizeSpec (one owned
String per field) on every field-descr mint, although
patch_field_descr_parents replaces that snapshot with the final merged spec
and struct_size_specs entries are only inserted or merged, never removed.
register_struct_layout rebuilt field_specs_from_layout on each of its ~211
calls for ~5 distinct layouts, where the merge pushes nothing and the following
re-sort/re-index are no-ops.

Measured on aheui's mainloop fixed per-process init (never-tracing, 200
iterations, min of 5 interleaved rounds): 243.7 → 214.6 us/call.

majit: report the JUMP block's declined close to the diag census

The two sibling cross-loop close sites bump bridge_declined_close (50) when an
attempt is declined; the JUMP block's own Declined arm bumped nothing.
Reported by CodeRabbit on #1040.


Follow-ups on this branch

jit: mint the tracing sentinel on the runtime GC condition, not cfg(test)

The sentinel commit above guarded its unmanaged fallback with #[cfg(test)],
which is set only while majit-metainterp compiles its own test harness.
Built as an ordinary dependency the arm disappears, so pyre-jit-trace's
may_force_vable_escape_surfaces_typed_abort and
may_force_with_active_vable_executes_and_clears_token — which drive the token
protocol without a collector — reached the assert_ne! and panicked. cargo build stays green either way; the failing tests live in a different crate from
the guard.

Branching on the unset type id itself is the profile-independent condition: the
leaf type is registered by the same setup that installs a collector, so an unset
id means there is no managed heap to mint the object in, and the host address
stays outside it where is_managed_heap_object rejects it before any tracing
path reads its header. alloc_virtual_ref in the same file already spells this
window the same way. The ordering requirement moves to
set_tracing_rescall_dummy_gc_type_id, which asserts the sentinel has not
already been minted — checked where it is provable rather than guessed from
cross-crate initialization order.

_pickle: record the measured write-resolution and constructor-order divergences

Comments only. interp_pickle.py resolves file.write at :555-560 only to
validate and re-resolves it per write in _Framer.file_write (:353), and
checks buffer_callback before the file in descr__new__ (:1822). Probed on
3.14.5, neither holds — so both sites now carry the measurement:

pickle.Pickler write resolution both constructor faults
CPython 3.14.5 cached at construction TypeError (write first)
pypy3 3.11.15 re-resolved per write ValueError (buffer_callback first)
pyre (this branch) cached at construction TypeError

_pickle: allocate the unpickler stack and metastack per load again

interp_pickle.py:2042-2043 installs a fresh stack and metastack on every
load. CPython does reuse — but it reuses one Pdata for the unpickler's
whole lifetime
, with MARK tracked by num_marks/fence indices inside that
one object (_pickle.c Pdata_New:454, :6888-6899); there is no metastack of
containers. Reuse is coherent only inside that structure. Layered onto the
metastack-of-lists port it produced a shape neither upstream has, and since
mark() rebinds w_stack to a fresh list, any load reaching STOP with an open
MARK dropped the constructor's list entirely.

It is observable. Capturing the live stack during one load through
gc.get_referents and appending through it during a later one changes that
load's result:

load3
reused stacks (<function poison>, 'z')
CPython 3.14.5, pypy3 3.11.15, after the revert (1, 'z')

Separately filed on the PR and not closed by this revert: gc.get_referents
exposes pyre's internal unpickler lists as ordinary mutable lists
(module/gc/mod.rs:213-224), so the same corruption is reachable inside a
single load. CPython hands out a Pdata, which has no append. That predates
this branch.

Verification

gate result
pyre/check.py exit 0 — dynasm 391/391, cranelift 391/391, wasm 387/387
cargo test --all --no-default-features --features dynasm exit 0, 7494 passed
cargo test --workspace --features cranelift exit 0, 7494 passed
the two may_force tests, before/after the sentinel fix 2 failed → 5 passed

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Performance

    • Reduced redundant layout processing during JIT compilation.
    • Added accurate timing for optimization and compilation stages.
    • Improved serialization write efficiency for file-backed output.
  • Reliability

    • Improved garbage-collection tracking for JIT-managed references and virtualizable values.
    • Added safer handling of runtime tracing state across garbage-collector resets.
    • Preserved serialization error behavior while improving streaming stability.
  • Diagnostics

    • Added bridge-optimization rejection metrics for improved runtime profiling.
  • Developer Experience

    • Exposed timing and WebAssembly clock utilities for supported integrations.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The PR updates GC-managed tracing-rescall tokens, JIT root tracing, compilation timing, struct-layout caching, bridge diagnostics, and cached pickle writers.

Changes

GC tracing and virtualizable tokens

Layer / File(s) Summary
GC-traced token contracts
majit/majit-metainterp/src/virtualizable.rs, majit/majit-metainterp/src/virtualref.rs
Tracing-rescall tokens now use a runtime-generated value. Virtualizable token reads, writes, residual-call handling, and tests use the shared token.
Heap-aware sentinel lifecycle
majit/majit-metainterp/src/virtualref.rs
The sentinel uses registered GC leaf types, heap-specific allocation, atomic publication, and host-heap fallback behavior.
JIT root tracing and validation
pyre/pyre-jit/src/eval.rs, pyre/pyre-jit/tests/gc_stress.rs, majit/majit-backend-cranelift/src/compiler.rs, majit/majit-metainterp/src/virtualizable.rs
JIT tracing forwards token fields and traces virtual-reference fields. Cranelift treats FORCE_TOKEN as an ordinary reference root. The stress test verifies sentinel reminting after heap rebuilds.

Cached pickle writers

Layer / File(s) Summary
Writer resolution and framing
pyre/pyre-interpreter/src/module/_pickle/pickler.rs
W_Pickler resolves and pins file.write. Framing and large-payload paths use the cached writer with dynamic lookup fallback.
Writer propagation through dump
pyre/pyre-interpreter/src/module/_pickle/pickler.rs
Serialization snapshots and pins the cached writer. pickle_core_impl passes it to Framer while preserving the compatibility wrapper.

JIT compilation and layout instrumentation

Layer / File(s) Summary
Timer availability and compilation timing
majit/majit-metainterp/src/jitprof.rs, majit/majit-metainterp/src/pyjitpl.rs
Instant is publicly available for native and wasm targets. Compilation logs now receive measured optimization and backend durations.
Struct-layout caching
majit/majit-metainterp/src/jitcode/assembler.rs
Struct registration skips known offsets. Field descriptors retain lightweight parent specifications until parent patching.
Bridge decline diagnostics
majit/majit-metainterp/src/jitdriver.rs
Declined bridge-close attempts increment bridge_declined_close when optimization was attempted.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant JIT as pyre-jit GC initialization
  participant VirtualRef as virtualref
  participant GC as GC heap
  participant Frame as PyFrame tracing
  JIT->>VirtualRef: register sentinel GC type
  VirtualRef->>GC: allocate and root tracing sentinel
  VirtualRef-->>Frame: provide tracing-rescall token
  Frame->>Frame: forward vable_token
  Frame->>GC: trace virtual_token and forced
Loading

Possibly related PRs

Poem

A rabbit hops where GC roots align,
Heap-born tokens now safely shine.
Writers stay pinned through every stream,
JIT clocks measure each compile dream.
Layouts rest light, bridges count true—
Fresh sentinel magic bounds the queue!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main Cranelift force-token GC tracing change and the related _pickle and JitCodeBuilder follow-ups.
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.
✨ 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 aheui

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.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit b713cb9).
Updated: 2026-08-07T05:43:17.621Z

Files in the reviewed diff
majit/majit-backend-cranelift/src/compiler.rs
majit/majit-metainterp/src/jitcode/assembler.rs
majit/majit-metainterp/src/jitdriver.rs
majit/majit-metainterp/src/jitprof.rs
majit/majit-metainterp/src/pyjitpl.rs
majit/majit-metainterp/src/virtualizable.rs
majit/majit-metainterp/src/virtualref.rs
pyre/pyre-interpreter/src/module/_pickle/pickler.rs
pyre/pyre-jit/src/eval.rs
pyre/pyre-jit/tests/gc_stress.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)

  • majit/majit-metainterp/src/jitcode/assembler.rs:660 ↔ rpython/jit/codewriter/heaptracker.py:60 — layout merging identifies fields solely by byte offset (any(|ef| ef.offset == nf.offset)), so distinct named overlapping/flattened fields collapse. Upstream enumerates every named non-void field, and caches field descriptors by (STRUCT, fieldname) in rpython/jit/backend/llsupport/descr.py:218; the Rust layout can therefore omit a valid parent slot and cannot preserve upstream field identity.

4. Structural adaptations

  • pyre/pyre-interpreter/src/module/_pickle/pickler.rs:49 ↔ pypy/module/_pickle/interp_pickle.py:353 — Rust stores and reuses the bound file.write callable, whereas the local PyPy 3.11 implementation resolves write for each output. This is an explicitly documented CPython 3.14 compatibility adaptation, not a PyPy-parity regression.

  • majit/majit-metainterp/src/virtualref.rs:216 ↔ rpython/jit/metainterp/virtualizable.py:326 — the tracing sentinel is lazily allocated as a rooted old-generation object and reminted when the Rust GC heap is rebuilt; upstream creates one translated GC object at initialization. This accommodates pyre’s replaceable GC/test heaps while retaining the required GCREF sentinel semantics.

  • pyre/pyre-jit/src/eval.rs:1393 ↔ rpython/jit/metainterp/virtualizable.py:311 — pyre retains a host-side tracer for Dynasm’s off-GC JIT frames while Cranelift uses managed JITFRAMEs. Upstream represents FORCE_TOKEN/JITFRAME uniformly as a GC reference; this is a backend/runtime representation adaptation.

@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: c1966c6182

ℹ️ 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 +289 to +293
if let Some(write_slot) = self.write_slot {
call_fn(
pyre_object::gc_roots::shadow_stack_get(write_slot),
&[pyre_object::gc_roots::shadow_stack_get(bytes_slot)],
)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep resolving the stream's write method

When a custom output stream replaces its write attribute after Pickler construction, this calls the previously bound method rather than the current attribute. The old call_meth path matched pypy/module/_pickle/interp_pickle.py:353-354, where every flush uses space.call_method(self.w_file, 'write', ...); retain that dynamic lookup instead of caching the callable.

AGENTS.md reference: AGENTS.md:L231-L233

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Probed rather than assumed, because this is a behaviour claim rather than a structural one — and the two references disagree.

interp_pickle.py does re-resolve per write, as quoted. But _pickle is an accelerator whose observable behaviour has to match CPython, and both CPython implementations capture file.write once at construction: _Pickler_SetOutputStream in the C accelerator, and pickle.py:465 (self._file_write = file.write, handed to _Framer.__init__) in the pure-Python class. Note interp_pickle.py:555-560 already resolves write in __init__ — it just discards the result and re-resolves in _Framer.file_write (:353).

Rebinding file.write after construction and then calling dump():

pickle.Pickler pickle._Pickler
CPython 3.14.5 orig=1 rebound=0 — cached orig=3 rebound=0 — cached
pypy3 3.11.15 orig=0 rebound=3 — re-resolved orig=3 rebound=0 — cached
pyre, this branch orig=1 rebound=0 — cached orig=3 rebound=0 — cached

So caching moves pyre onto the target; the per-write lookup it replaced was the deviation. pypy3's own app-level _Pickler disagrees with its interp-level one, which is the tell.

The same probe caught an adjacent divergence worth recording: with a file lacking write and buffer_callback at protocol 4, 3.14 reports TypeError: file must have a 'write' attribute while pypy3 reports ValueError: buffer_callback needs protocol >= 5 (descr__new__, interp_pickle.py:1822, checks it first). pyre already ordered these the 3.14 way and this patch preserved it.

Disposition: won't-fix, documented. Both measurements are now recorded at their sites in 9b27a774ee6 so this does not get re-filed.

commented by Claude

Comment on lines +314 to +316
unsafe {
pyre_object::listobject::w_list_clear(cur(slot).w_stack);
pyre_object::listobject::w_list_clear(cur(slot).w_metastack);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Allocate fresh unpickler stacks for every load

On second and subsequent load() calls, this clears and reuses the constructor-owned list objects, while upstream W_Unpickler.load installs fresh metastack and stack lists on every invocation (pypy/module/_pickle/interp_pickle.py:2042-2043). This is the allocation-reuse shortcut that the repository's strict structural-parity rule forbids; create and assign fresh lists in load() as upstream does.

AGENTS.md reference: AGENTS.md:L231-L233

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

You're right, and the reason is stronger than the one filed. Reverted in 033471d66d8.

My first instinct was to defend it the way I defended the Pickler half — "CPython's accelerator reuses too" — and that defence is measurably wrong as applied. CPython's stack is one Pdata for the unpickler's whole lifetime, invariant even across a load that ends with an unbalanced MARK, because MARK is tracked by num_marks/fence indices inside that one object (_pickle.c Pdata_New:454, :6888-6899). There is no metastack of containers at all. pyre keeps PyPy's metastack-of-lists (unpickler.rs mark() rebinds w_stack to a fresh list) and bolted CPython's reuse policy on top, producing a shape neither upstream has. Measured:

3.14  Pdata identity stable across open-MARK loads: True
pyre  same-container-across-loads: True  (two plain loads)
      same-container-across-loads: False (open-MARK load between)

So the comment the commit introduced — "Reset the constructor-owned stacks in place" — was already false: after any load reaching STOP with an open MARK, w_stack points at a mark()-allocated list and the constructor's is dropped.

It is observable, not just structural. Capture the live stack during one load via gc.get_referents and append through it during a later one:

load3: (<function poison>, 'z')     reused stacks
load3: (1, 'z')                     3.14.5, pypy3 3.11.15, and after the revert

Everything else came back identical to 3.14 across ~2000 differential fuzz cases, double/triple loads, framed proto-4, cross-load memo, mid-load reducer failure, truncation, unbalanced-MARK carry-over, and __reduce__ / __setstate__ / persistent_load / find_class re-entry.

One thing worth filing separately, which this revert does not close: gc.get_referents(unpickler) hands pyre's live internal stack / metastack / memo to Python as ordinary mutable lists (module/gc/mod.rs:213-224), so the same corruption is reachable inside a single load. CPython hands out a Pdata, which has no append. That predates this PR.

cargo test --all --no-default-features --features dynasm exit 0 (7495 passed) and pyre/check.py exit 0 (391/391 · 391/391 · 387/387) after the revert.

commented by Claude

@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: d614830a7b

ℹ️ 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 +214 to +217
assert!(
TRACING_RESCALL_DUMMY_PTR.get().is_none(),
"JITFRAME_DUMMY type registered after the tracing sentinel was minted",
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Allow rebuilding the GC after dummy initialization

When any GC-stress test reaches a traced residual call, token_tracing_rescall() permanently initializes TRACING_RESCALL_DUMMY_PTR; the next test calls reset_gc_fresh_for_test() (pyre/pyre-jit/tests/gc_stress.rs:50), which rebuilds the GC and invokes this setter again, so this assertion panics before the replacement heap is installed. Make repeat registration compatible with the test-only GC reset so the required cargo test --features dynasm suite can run more than one GC-stress case.

AGENTS.md reference: AGENTS.md:L236-L237

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed and fixed in e6c1e754891 — thank you, this was a real landmine.

The chain is as described: reset_gc_fresh_for_testbuild_gc (pyre-jit/src/eval.rs:1338) → set_tracing_rescall_dummy_gc_type_id (:3639), once per GC-stress worker. It does not fire today only because no program in gc_stress.rs reaches a traced residual call, so the sentinel is never minted — all 32 pass. One test that does would have hit it.

Digging into it turned up the larger problem behind the assertion: the sentinel was a OnceLock, so even without any assertion a rebuilt heap would keep the address minted in the previous one. is_managed_heap_object stops recognising it once that heap is replaced, which puts the traced virtual_token / vable_token slots back on an address the collector does not own — exactly what this PR set out to remove. Blocking re-registration would have frozen that state rather than fixed it.

So the address now lives in an AtomicUsize that the setter clears, and the next request mints in the heap that is current; publication goes through a compare-exchange so racing minters agree on one address. The assertion is gone.

Added tracing_sentinel_is_reminted_for_a_rebuilt_heap to gc_stress.rs, which mints the sentinel between two resets and requires a fresh address. Against the previous commit it panics at virtualref.rs:214 with the message you predicted; with the fix the binary is 33/33.

commented by Claude

youknowone added a commit that referenced this pull request Aug 6, 2026
`set_tracing_rescall_dummy_gc_type_id` is called from `build_gc`
(`pyre-jit/src/eval.rs:1338,3639`), which `reset_gc_fresh_for_test` runs again
per GC-stress worker. The sentinel was a `OnceLock`, so a second heap kept the
address minted in the first — `is_managed_heap_object` no longer recognises it
once the heap it belongs to has been replaced, putting the traced
`virtual_token` / `vable_token` slots back on an address the collector does not
own. The assertion added with the previous commit turned that into a panic on
the second registration instead.

Hold the address in an `AtomicUsize` the setter clears, so the next request
mints in the heap that is now current, and publish it with a compare-exchange
so racing minters agree on one address.

The GC-stress harness produces this ordering the moment one of its programs
reaches a traced residual call; none does today, so the new test mints the
sentinel between two resets directly. It panics at the old assertion without
this change.

Reported by the Codex review bot on #1084.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 6, 2026
`interp_pickle.py:2042-2043` installs a fresh `stack` and `metastack` on every
`load`, and `_pickle.c` reuses one `Pdata` for the unpickler's whole lifetime
with MARK tracked by `num_marks`/`fence` indices inside it (`Pdata_New`,
`_pickle.c:454`). Reuse is coherent only inside the second structure; layered
onto the metastack-of-lists port it produced a shape neither has, and
`mark()` rebinding `w_stack` to a fresh list meant the constructor's list was
dropped by any load that reached STOP with an open MARK.

It is observable. Capturing the live stack during one `load` through
`gc.get_referents` and appending through it during a later one changes that
load's result:

    load3: (<function poison>, 'z')     reused stacks
    load3: (1, 'z')                     3.14.5, pypy3 3.11.15, and this commit

Reported by the Codex review bot on #1084.

Assisted-by: Claude
@youknowone youknowone changed the title jit: trace the cranelift force token as a GC slot, plus _pickle and JitCodeBuilder allocation reuse jit: trace the cranelift force token as a GC slot, plus _pickle and JitCodeBuilder follow-ups Aug 6, 2026
`Unpickler.load` allocated a fresh stack and metastack list on every
call; both are now owned by the constructor, initialized lazily for the
`__new__`-only path, and cleared in place instead.

`Pickler` resolves `file.write` once in the constructor and stores it in
a rooted `w_write` field; `Framer::flush` calls it directly rather than
re-resolving the method on every flush. The constructor's error order is
unchanged: the `write` check still precedes the protocol-5
`buffer_callback` check.

Assisted-by: Claude
`virtualref.py:19` declares `virtual_token` and `rvirtualizable.py:29`
declares `vable_token` as `llmemory.GCREF`, and `jitframe.py:59-61` makes
JITFRAME a `GcStruct` allocated by `lltype.malloc` at `:50`. cranelift
allocates its JITFRAME from the nursery accordingly, but both token slots
and the FORCE_TOKEN result were excluded from GC tracing on the written
premise that a JITFRAME address is `libc::calloc`'d and therefore outside
the GC heap — which holds only for the dynasm backend.

Register `virtual_token` in the vref `gc_ptr_offsets`, visit
`vable_token` in `pyframe_object_custom_trace`, and stop excluding the
FORCE_TOKEN result from the cranelift relocatable ref-root slots.

`TOKEN_TRACING_RESCALL` was the `u64::MAX` sentinel, which is not a legal
value for a traced slot; it becomes the address of a registered GC leaf,
matching `virtualizable.py:326-330` where the sentinel is the prebuilt
`_dummy` object. Host-side active-token stores now take the same write
barrier a compiled SETFIELD_GC store would.

Assisted-by: Claude
The two sibling cross-loop close sites bump `bridge_declined_close` (50) when
an attempt is declined; the JUMP block's own Declined arm bumped nothing. Bump
it on the same `attempted` condition the decline latch uses, so the tally
counts closes an optimizer pass rejected rather than headers the gate skipped.

Reported by CodeRabbit on #1040.

Assisted-by: Claude
`add_struct_field_descr` deep-copied the whole parent `BhSizeSpec`
(one owned `String` per field) on every field-descr mint, although
`patch_field_descr_parents` — called unconditionally from `try_finish`
after the decline early-return — replaces that snapshot with the final
merged spec, and `struct_size_specs` entries are only inserted or
merged, never removed. Carry the scalar fields and leave
`all_fielddescrs` empty; `type_id` is the only part the patch pass
reads.

`register_struct_layout` rebuilt `field_specs_from_layout` on each of
its ~211 calls for ~5 distinct layouts. When the cached spec already
lists an offset for every incoming field the merge pushes nothing and
the following re-sort/re-index are no-ops, and the branch never writes
`size`, `is_gc_managed` or `headerless`, so return before building the
discarded vector.

Measured on aheui's `mainloop` fixed per-process init (never-tracing,
200 iterations, min of 5 interleaved rounds): 243.7 -> 214.6 us/call.

Assisted-by: Claude
…est)

`allocate_tracing_rescall_dummy` guarded its unmanaged fallback with
`#[cfg(test)]`, which is set only while majit-metainterp compiles its own
test harness. Built as an ordinary dependency the arm disappears, so
`pyre-jit-trace`'s `may_force_vable_escape_surfaces_typed_abort` and
`may_force_with_active_vable_executes_and_clears_token` — which drive the
token protocol without a collector — reached the `assert_ne!` and panicked.

Branch on the unset type id itself: the leaf type is registered by the same
setup that installs a collector, so an unset id means there is no managed
heap to mint the object in, and the host address stays outside it where
`is_managed_heap_object` rejects it before any tracing path reads its
header. `alloc_virtual_ref` spells the same window the same way. The
ordering requirement moves to `set_tracing_rescall_dummy_gc_type_id`, which
asserts the sentinel has not already been minted.

Assisted-by: Claude
…ivergences

`interp_pickle.py` resolves `file.write` at `:555-560` only to validate and
re-resolves it per write in `_Framer.file_write` (`:353`), and checks
`buffer_callback` before the file in `descr__new__` (`:1822`). Measured on
3.14.5 neither holds: rebinding `file.write` after construction is not
observed by a later `dump()` — `pickle.py:465` captures the callable the
same way — and a call carrying both constructor faults reports the `write`
TypeError. pyre matches the measurements; note them at both sites.

Assisted-by: Claude
`set_tracing_rescall_dummy_gc_type_id` is called from `build_gc`
(`pyre-jit/src/eval.rs:1338,3639`), which `reset_gc_fresh_for_test` runs again
per GC-stress worker. The sentinel was a `OnceLock`, so a second heap kept the
address minted in the first — `is_managed_heap_object` no longer recognises it
once the heap it belongs to has been replaced, putting the traced
`virtual_token` / `vable_token` slots back on an address the collector does not
own. The assertion added with the previous commit turned that into a panic on
the second registration instead.

Hold the address in an `AtomicUsize` the setter clears, so the next request
mints in the heap that is now current, and publish it with a compare-exchange
so racing minters agree on one address.

The GC-stress harness produces this ordering the moment one of its programs
reaches a traced residual call; none does today, so the new test mints the
sentinel between two resets directly. It panics at the old assertion without
this change.

Reported by the Codex review bot on #1084.

Assisted-by: Claude
`interp_pickle.py:2042-2043` installs a fresh `stack` and `metastack` on every
`load`, and `_pickle.c` reuses one `Pdata` for the unpickler's whole lifetime
with MARK tracked by `num_marks`/`fence` indices inside it (`Pdata_New`,
`_pickle.c:454`). Reuse is coherent only inside the second structure; layered
onto the metastack-of-lists port it produced a shape neither has, and
`mark()` rebinding `w_stack` to a fresh list meant the constructor's list was
dropped by any load that reached STOP with an open MARK.

It is observable. Capturing the live stack during one `load` through
`gc.get_referents` and appending through it during a later one changes that
load's result:

    load3: (<function poison>, 'z')     reused stacks
    load3: (1, 'z')                     3.14.5, pypy3 3.11.15, and this commit

Reported by the Codex review bot on #1084.

Assisted-by: Claude
rustfmt collapses the `compare_exchange` call in `token_tracing_rescall`
onto one line. No behaviour change.

Assisted-by: Claude
`WarmState::log_compile` takes `opt_time` and `compile_time` and forwards
them to the jitlog, which prints them as the `Optimization time:` /
`Compilation time:` lines of the `MAJIT_STATS` summary
(`majit-trace/src/logger.rs:194-207`, summed over every compile). Both
production call sites passed `Duration::ZERO`, so both lines read `0.0ms`
for every program.

`compile_loop_body` now times the optimize block — the primary
`optimize_trace_with_constants_and_inputs_vable_out`, the without-unroll
retry taken on `InvalidLoop`, and loop vectorization — and separately the
`self.backend.compile_loop` call already wrapped by
`profiler.enter_backend()`. `compile_retrace` gets the same treatment for
its own optimizer and backend calls.

`jitprof.rs` privately imported `std::time::Instant`, or its
`wasm_clock::Instant` shim on wasm32 where `Instant::now()` panics; both
become `pub use` so `pyjitpl.rs` reaches the platform-agnostic type
instead of naming `std::time::Instant` directly.

Measured on aheui: `standard/loop` (85 recorded ops) reports 3.1ms /
1.3ms, `logo` (33177) reports 149.0ms / 25.3ms.

Assisted-by: Claude
`self.stats.loops_compiled += 1` appears at five places in pyjitpl.rs, but
only `compile_loop_body` (:6808) and `compile_retrace` (:7978) called
`warm_state.log_compile`. `finish_and_compile`, `compile_simple_loop` and
`compile_entry_bridge` compiled a loop and told the jitlog nothing.

`log_compile` is the sole source of the `=== JIT Statistics ===` block
that `MAJIT_STATS=1` prints (`majit-trace/src/logger.rs:194-207`), so a
run whose loop arrived through one of those three paths reported
`Traces compiled: 0`, `Total ops recorded: 0` and both times `0.0ms`
while the run had in fact compiled a loop.

Each of the three now times its own optimizer invocation and its own
`backend.compile_loop` call and passes the counts and durations, matching
the two working sites.

On aheui's logo at `MAJIT_TRACE_LIMIT=30000` — a limit low enough that
the whole-program trace aborts and the loop arrives through one of these
paths — the block goes from `0 / 0 / 0.0ms / 0.0ms` to `Traces compiled:
1`, 24004 recorded ops, 7065 after optimization, 42.3ms and 9.7ms. At the
default limit the same program is unchanged at 1 / 33177 / 20564, so no
compile is now counted twice.

Assisted-by: Claude

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@majit/majit-backend-cranelift/src/compiler.rs`:
- Around line 4701-4707: Add a regression test near the existing loop-invariant
deoptimization and call-may-force tests that captures a ForceToken, performs an
intervening nursery collection via CallMallocNursery, then verifies a later
token use—such as a fail argument, CallMayForceI, or force() result—contains the
moved JITFRAME address rather than the stale address. Model the setup and
assertions on
loop_invariant_deopt_ref_with_preamble_use_survives_nursery_collection.

In `@majit/majit-metainterp/src/virtualref.rs`:
- Around line 198-219: Update the tracing rescall sentinel synchronization in
allocate_tracing_rescall_dummy and token_tracing_rescall: use Acquire for
fast-path pointer loads and failed compare_exchange outcomes, AcqRel for
successful compare_exchange operations, and Release when resetting
TRACING_RESCALL_DUMMY_PTR in set_tracing_rescall_dummy_gc_type_id. Leave
TRACING_RESCALL_DUMMY_GC_TYPE_ID ordering Relaxed.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ebb9ec11-2210-48cf-bd7f-fd9102ee4283

📥 Commits

Reviewing files that changed from the base of the PR and between b99bf57 and b713cb9.

📒 Files selected for processing (10)
  • majit/majit-backend-cranelift/src/compiler.rs
  • majit/majit-metainterp/src/jitcode/assembler.rs
  • majit/majit-metainterp/src/jitdriver.rs
  • majit/majit-metainterp/src/jitprof.rs
  • majit/majit-metainterp/src/pyjitpl.rs
  • majit/majit-metainterp/src/virtualizable.rs
  • majit/majit-metainterp/src/virtualref.rs
  • pyre/pyre-interpreter/src/module/_pickle/pickler.rs
  • pyre/pyre-jit/src/eval.rs
  • pyre/pyre-jit/tests/gc_stress.rs

Comment on lines +4701 to +4707
fn build_force_token_set(_inputargs: &[InputArg], _ops: &[Op]) -> IndexSet<u32> {
// FORCE_TOKEN is a GCREF to the active JITFRAME
// (`virtualizable.py:315-318`, `resoperation.py:1090`). Keep its in-frame
// copies in the ordinary Ref root set so moving collectors update them.
// The empty compatibility set leaves the existing exit-layout plumbing in
// place while giving FORCE_TOKEN the same treatment as every other Ref.
IndexSet::new()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a regression test for FORCE_TOKEN surviving a GC move.

This diff's purpose is to keep FORCE_TOKEN's in-frame copies in the ordinary Ref root set so a moving collector updates them. This file has no test exercising that exact property: existing loop_invariant_deopt_ref_* tests cover ordinary Ref inputargs/op-results across a nursery collection, and test_call_may_force_* tests exercise forcing/guard-not-forced without triggering GC between ForceToken and its use. Add a test that captures a ForceToken, triggers a nursery collection through an intervening collecting call (e.g., CallMallocNursery), and asserts the token used later (in a fail-arg, a CallMayForceI, or the frame returned by force()) reflects the moved JITFRAME address rather than a stale one.
Do you want me to generate this test, modeled on loop_invariant_deopt_ref_with_preamble_use_survives_nursery_collection?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@majit/majit-backend-cranelift/src/compiler.rs` around lines 4701 - 4707, Add
a regression test near the existing loop-invariant deoptimization and
call-may-force tests that captures a ForceToken, performs an intervening nursery
collection via CallMallocNursery, then verifies a later token use—such as a fail
argument, CallMayForceI, or force() result—contains the moved JITFRAME address
rather than the stale address. Model the setup and assertions on
loop_invariant_deopt_ref_with_preamble_use_survives_nursery_collection.

Comment on lines +198 to +219
/// Lazy initialisation of the `_dummy` address, as a `usize` because raw
/// pointers are not `Sync`. Zero means "not minted yet"; a null sentinel would
/// collide with `TOKEN_NONE`, so it is not a value this can ever hold.
static TRACING_RESCALL_DUMMY_PTR: AtomicUsize = AtomicUsize::new(0);

const TRACING_RESCALL_DUMMY_GC_TYPE_ID_UNSET: u32 = u32::MAX;
static TRACING_RESCALL_DUMMY_GC_TYPE_ID: AtomicU32 =
AtomicU32::new(TRACING_RESCALL_DUMMY_GC_TYPE_ID_UNSET);

/// Publish the registered leaf type used by the prebuilt
/// `virtualizable.py:326-330 JITFRAME_DUMMY` object.
///
/// Registration comes from `build_gc`, so a second call means a second heap.
/// A sentinel minted in the previous one is no longer part of the live heap —
/// `is_managed_heap_object` would stop recognising it and the traced
/// `virtual_token` / `vable_token` slots would be back to holding an address
/// the collector does not own. Drop it so the next request mints in the heap
/// that is now current.
pub fn set_tracing_rescall_dummy_gc_type_id(type_id: u32) {
TRACING_RESCALL_DUMMY_GC_TYPE_ID.store(type_id, Ordering::Relaxed);
TRACING_RESCALL_DUMMY_PTR.store(0, Ordering::Relaxed);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Survey other lazy-publish atomic patterns for the established ordering convention.

rg -n -B2 -A2 'compare_exchange' majit/majit-metainterp/src/ majit/majit-gc/src/ --type=rust
rg -n -B2 -A2 'OnceLock' majit/majit-metainterp/src/virtualref.rs

Repository: youknowone/pyre

Length of output: 1318


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## virtualref.rs relevant lines"
sed -n '150,285p' majit/majit-metainterp/src/virtualref.rs

echo
echo "## rgil.rs compare_exchange and surrounding ordering context"
sed -n '170,225p' majit/majit-gc/src/rgil.rs

echo
echo "## Rust AtomicUsize::compare_exchange documentation in std source if vendored? (no-op)"
rg -n "compare_exchange_ordering|compare_exchange" /usr/local/rustup/toolchains/$(rustc +stable --version 2>/dev/null | awk '{print $2}')/lib/rustlib/src/rust/library/std/src/sync/atomic.rs 2>/dev/null | head -80 || true

Repository: youknowone/pyre

Length of output: 9095


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import re
import urllib.request
url = "https://doc.rust-lang.org/std/sync/atomic/struct.AtomicUsize.html#method.compare_exchange"
text = urllib.request.urlopen(url, timeout=10).read().decode("utf-8", errors="replace")
for needle in [
    "memory ordering",
    "Acquire semantics",
    "Release semantics",
    "compare_exchange",
]:
    idx = text.find(needle)
    if idx != -1:
        start = max(idx - 300, 0)
        end = min(idx + 600, len(text))
        print(f"## found: {needle}")
        print(text[start:end])
        print()
PY

Repository: youknowone/pyre

Length of output: 2439


🌐 Web query:

Rust std sync atomic compare_exchange memory ordering Acquire Release semantics documentation

💡 Result:

In Rust, the compare_exchange method for atomic types takes two memory ordering arguments: success and failure [1][2]. These arguments define how the operation interacts with the memory model's happens-before relationship [3][4]. The success ordering describes the requirement for the full read-modify-write operation if the comparison succeeds (the value matches the current value and the new value is stored) [1][2]. The failure ordering describes the requirement for the load operation performed if the comparison fails (the value does not match) [1][2][5]. Acquire and Release semantics are commonly used together to establish synchronization [6][7]: 1. Release semantics, when applied to a store, ensure that all memory operations occurring before the store remain ordered before it [6][7]. 2. Acquire semantics, when applied to a load, ensure that all memory operations occurring after the load remain ordered after it [6][7]. When used in compare_exchange: - Using Acquire as the success ordering makes the store part of the operation Relaxed [1][2]. - Using Release as the success ordering makes the successful load part of the operation Relaxed [1][2]. - Therefore, AcqRel is typically used for the success ordering if both Acquire and Release semantics are required for the read-modify-write operation [6][5]. - The failure ordering must be one of Relaxed, Acquire, or SeqCst [1][2]. Since a failing compare_exchange is only a load operation, it cannot provide Release semantics [1][2][5]. Rust's memory orderings follow C++20 atomic rules [6][3]. For a detailed understanding of how these orderings affect code execution, consult the official Rust documentation for std::sync::atomic or The Rustonomicon [6][3][7].

Citations:


🌐 Web query:

Rust compare_and_swap AcqRel release acquire ordering failure

💡 Result:

In Rust, compare_exchange and compare_exchange_weak require two memory ordering arguments: one for success and one for failure [1][2]. The failure ordering specifies the memory consistency guarantees applied when the operation fails to update the atomic value (i.e., when it effectively performs a load) [1][2][3]. Key constraints and behaviors for the failure ordering include: 1. Permitted Values: The failure ordering must be no stronger than the success ordering [4][5]. Additionally, only Relaxed, Acquire, and SeqCst are permitted as failure orderings [1][2][5]. Release and AcqRel are explicitly disallowed because they would imply store semantics, which do not occur when the compare-and-swap operation fails [4]. 2. Relationship to Success Ordering: The failure ordering cannot be stronger than the success ordering [4][5]. For example, if you choose AcqRel for success, you may use Acquire or Relaxed for failure [5]. 3. Why AcqRel/Release are disallowed on failure: Because a failed compare-and-swap operation performs only a load (it does not write to the memory location), it cannot satisfy Release semantics, which require a store [4]. Using AcqRel or Release as a failure ordering is logically inconsistent with the nature of a failed atomic read-modify-write operation [4][6]. 4. Practical Application: When using AcqRel for success, the failure ordering should generally be chosen based on what synchronization is required when the CAS fails. If the failure path requires synchronizing with previous writes (e.g., in a lock-free data structure), Acquire is the appropriate choice to ensure visibility of preceding memory operations [7][8]. If no such synchronization is needed, Relaxed is sufficient and often more performant [9][5]. In summary, Rust enforces these rules to align with the C++ memory model, ensuring that the specified orderings match the actual memory access patterns (load vs. read-modify-write) triggered by the outcome of the atomic operation [6][10].

Citations:


🌐 Web query:

std::sync::atomic AtomicUsize compare_exchange orderings Acquire Release success failure Rust

💡 Result:

The compare_exchange method on Rust atomic types, such as AtomicUsize, allows for precise control over memory ordering by requiring two distinct Ordering parameters: success and failure [1][2]. Success Ordering The success parameter defines the memory ordering applied to the read-modify-write operation if the comparison succeeds (i.e., the atomic's value matches the current value and the new value is stored) [1][2]. If the operation succeeds, it behaves as a full read-modify-write operation using the specified ordering [3]. For example, if you provide Acquire as the success ordering, the store part of the operation is treated as Relaxed; if you provide Release, the load part is treated as Relaxed [1][2]. Failure Ordering The failure parameter defines the memory ordering applied to the load operation if the comparison fails (i.e., the atomic's value does not match the current value, and no store occurs) [1][2][3]. Because the operation acts strictly as a load in this case, the failure ordering must be weaker than or equal to the success ordering [4]. Consequently, the failure ordering can only be Ordering::SeqCst, Ordering::Acquire, or Ordering::Relaxed [1][2]. Usage Note The compiler ensures the generated instructions satisfy both constraints, even if it must choose a stronger ordering than requested for one of the cases [5]. Providing distinct orderings is often critical for performance and correctness; for instance, when implementing a spinlock, you might only need Acquire ordering on failure to synchronize with a previous store, while requiring stronger guarantees on success [3]. Finally, note that compare_exchange is the idiomatic replacement for the now-deprecated compare_and_swap [6][2]. For loops, consider using compare_exchange_weak, which allows for spurious failures but can produce more efficient machine code [7][4].

Citations:


🌐 Web query:

documentation AtomicUsize compare_exchange_acquire_release_acquire ordering failure

💡 Result:

In Rust's AtomicUsize (and other atomic types), the compare_exchange and compare_exchange_weak methods accept two ordering parameters: success and failure [1][2]. These specify the memory ordering constraints for the operation when it succeeds (performing a read-modify-write) and when it fails (performing only a load) [1][3]. The failure ordering is restricted to Ordering::SeqCst, Ordering::Acquire, or Ordering::Relaxed [1][3]. Additionally, the failure ordering must be equivalent to or weaker than the success ordering [4][5]. Using success = Ordering::AcqRel and failure = Ordering::Acquire is a valid and commonly used configuration [6]. In this case: - Success: The operation performs an Acquire-Release read-modify-write [1][7]. - Failure: The operation performs an Acquire load [1][7]. Documentation and compiler checks enforce that the failure ordering cannot be Release or AcqRel, as these are not applicable to the load operation that occurs on failure [7][8]. The "failure" ordering argument specifically describes the ordering requirement for the load that occurs when the comparison fails [1][3]. For context, developers sometimes encounter issues if they attempt to pass an invalid combination, such as a failure ordering that is stronger than the success ordering or an inapplicable ordering (like Release) for the failure case [8]. If you are looking to combine AcqRel for success and Acquire for failure, this is supported and standard for synchronizing state changes while ensuring memory visibility [6].

Citations:


🌐 Web query:

"draft" "compare_exchange" "AtomicUsize" "Ordering::AcqRel" "failure ordering"

💡 Result:

When using AtomicUsize::compare_exchange with Ordering::AcqRel as the success ordering, the failure ordering must be a strictly weaker or equivalent ordering that is valid for a load operation [1][2][3]. Specifically, when compare_exchange fails, it performs a load operation, and the failure ordering specifies the memory ordering requirements for that load [1][4]. For compare_exchange, the failure ordering can only be Ordering::SeqCst, Ordering::Acquire, or Ordering::Relaxed [1][5][4]. Using Ordering::AcqRel as a success ordering implies that if the operation succeeds, it performs a read-modify-write operation with Acquire and Release semantics [6][7]. If it fails, the Release part of the AcqRel requirement is irrelevant because no store occurs; therefore, the operation defaults to Acquire semantics [1][4][3]. In practice, if you specify AcqRel for success, the standard failure ordering is Ordering::Acquire [1][3]. This ensures that even if the comparison fails, the operation still provides Acquire semantics, which is necessary to synchronize with preceding releases in other threads [1][8]. Attempting to pass an invalid ordering for failure (such as Release or AcqRel) will cause the operation to panic [2][3]. Summary of standard mappings for compare_exchange failure: - If success is AcqRel, the failure ordering should be Acquire [1][3]. - The failure ordering must be equal to or weaker than the success ordering [2][3]. - Only SeqCst, Acquire, and Relaxed are valid as failure orderings [1][5][4].

Citations:


🌐 Web query:

Rust atomic compare_exchange success Acquire store is relaxed

💡 Result:

In Rust's atomic compare_exchange operation, using Ordering::Acquire as the success memory ordering implies that the store part of the read-modify-write operation is performed with Ordering::Relaxed semantics [1][2][3]. When you specify an Ordering for the success case of compare_exchange, it defines the behavior of the entire read-modify-write operation [1][3]. According to the official Rust documentation [1][2][3]: * When Ordering::Acquire is used for the success case, the operation performs an acquire load (to observe memory) and a relaxed store (to update the value). * Conversely, using Ordering::Release for the success case makes the load part of the operation relaxed, while maintaining release semantics for the store [1][2][3]. This distinction is important because it dictates how the operation synchronizes memory with other threads. An Acquire success ordering ensures that the load component synchronizes with previous release operations in other threads, while the store component only provides the atomic update without adding additional release synchronization requirements [4][5]. For context, compare_exchange requires two ordering arguments: 1. success: The ordering used if the comparison succeeds (the read-modify-write operation) [1][3]. 2. failure: The ordering used if the comparison fails (a simple load operation) [1][3]. The failure ordering is restricted to Ordering::SeqCst, Ordering::Acquire, or Ordering::Relaxed [1][2].

Citations:


Use acquire/release ordering for the traced token_tracing_rescall sentinel.

allocate_tracing_rescall_dummy writes the dummy ObjectHeader with std::ptr::write, then token_tracing_rescall publishes the address with Relaxed loads and a Relaxed compare_exchange. Relaxed ordering does not establish the needed happens-before relationship, so another thread can observe the published address without seeing the object initialization.

Use Acquire for the fast-path load and for the failing CAS outcome. Since success performs an atomic update, use AcqRel for the successful compare_exchange success ordering and Acquire for its failure ordering, and use Release for the unsetting store in set_tracing_rescall_dummy_gc_type_id. TRACING_RESCALL_DUMMY_GC_TYPE_ID can remain Relaxed because the Release store to TRACING_RESCALL_DUMMY_PTR orders the preceding write in that function.

This also applies to the same sentinel pattern around allocate_tracing_rescall_dummy and the type-id unset path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@majit/majit-metainterp/src/virtualref.rs` around lines 198 - 219, Update the
tracing rescall sentinel synchronization in allocate_tracing_rescall_dummy and
token_tracing_rescall: use Acquire for fast-path pointer loads and failed
compare_exchange outcomes, AcqRel for successful compare_exchange operations,
and Release when resetting TRACING_RESCALL_DUMMY_PTR in
set_tracing_rescall_dummy_gc_type_id. Leave TRACING_RESCALL_DUMMY_GC_TYPE_ID
ordering Relaxed.

@youknowone
youknowone merged commit 128590c into main Aug 7, 2026
14 of 17 checks passed
@youknowone
youknowone deleted the aheui branch August 7, 2026 07:30
youknowone added a commit that referenced this pull request Aug 9, 2026
…#1120)

* _pickle: reuse the unpickler stacks and cache the pickler write callable

`Unpickler.load` allocated a fresh stack and metastack list on every
call; both are now owned by the constructor, initialized lazily for the
`__new__`-only path, and cleared in place instead.

`Pickler` resolves `file.write` once in the constructor and stores it in
a rooted `w_write` field; `Framer::flush` calls it directly rather than
re-resolving the method on every flush. The constructor's error order is
unchanged: the `write` check still precedes the protocol-5
`buffer_callback` check.

Assisted-by: Claude

* _pickle: allocate the unpickler stack and metastack per load again

`interp_pickle.py:2042-2043` installs a fresh `stack` and `metastack` on every
`load`, and `_pickle.c` reuses one `Pdata` for the unpickler's whole lifetime
with MARK tracked by `num_marks`/`fence` indices inside it (`Pdata_New`,
`_pickle.c:454`). Reuse is coherent only inside the second structure; layered
onto the metastack-of-lists port it produced a shape neither has, and
`mark()` rebinding `w_stack` to a fresh list meant the constructor's list was
dropped by any load that reached STOP with an open MARK.

It is observable. Capturing the live stack during one `load` through
`gc.get_referents` and appending through it during a later one changes that
load's result:

    load3: (<function poison>, 'z')     reused stacks
    load3: (1, 'z')                     3.14.5, pypy3 3.11.15, and this commit

Reported by the Codex review bot on #1084.

Assisted-by: Claude

* jit: map live values onto the front target LABEL's own argument order

A dispatch-key entry lands on a LABEL whose arguments are its own, not the
frontend's state-field layout. Three sites re-mapped the values only when the
two disagreed in length:

    if live_values.len() != expected { pack_front_target_live_values(...) }

Equal arity was taken as equal order. `aheui`'s `pi/pi.jinseo` under the
cranelift backend produced a four-argument `(Int, Ref, Ref, Ref)` LABEL and a
four-value `(selected: Int, stacksize: Int, selected_ref: Ref,
storage_ref: Ref)` state, so the mapping was skipped and `Int(2)` entered slot
1. Compiled code loads a Ref slot as a pointer without checking it:
`EXC_BAD_ACCESS address=0x1c`. Only cranelift reaches this — every other
backend reports `supports_dispatch_key_entry() == false`, so its dispatch key
is always 0 and the peeled preamble runs.

Run the mapping unconditionally at all three sites and decline the entry when
it is unavailable; the fallback is the key-0 entry the other backends take.
`try_resume_into_compiled_loop`'s full-live fallback is mapped where it is
produced, so `direct_live_values` is now LABEL-ordered at every caller.

`pack_front_target_live_values` additionally confirms each packed value's type
against the LABEL's declared inputarg type, and `back_edge_internal` confirms
the same for the compact values it does not re-map.

For `pi.jinseo` the recorded mapping is `sources=[1, 2, 3]` against a
four-argument LABEL: the fourth argument is a trace-internal value with no
state field behind it, so the entry is declined.

Official aheui suite, whole corpus, at MAJIT_THRESHOLD 100/1000/5000/20000/
100000: cranelift 62/62 at every step (previously 61/62 with `pi.jinseo`
segfaulting at 1000, 5000, 20000 and 100000), dynasm 62/62 unchanged, no
crash or abort lines. jitstats 0 failed, baselines not re-recorded.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 10, 2026
`interp_pickle.py:2042-2043` installs a fresh `stack` and `metastack` on every
`load`, and `_pickle.c` reuses one `Pdata` for the unpickler's whole lifetime
with MARK tracked by `num_marks`/`fence` indices inside it (`Pdata_New`,
`_pickle.c:454`). Reuse is coherent only inside the second structure; layered
onto the metastack-of-lists port it produced a shape neither has, and
`mark()` rebinding `w_stack` to a fresh list meant the constructor's list was
dropped by any load that reached STOP with an open MARK.

It is observable. Capturing the live stack during one `load` through
`gc.get_referents` and appending through it during a later one changes that
load's result:

    load3: (<function poison>, 'z')     reused stacks
    load3: (1, 'z')                     3.14.5, pypy3 3.11.15, and this commit

Reported by the Codex review bot on #1084.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 10, 2026
…descr (#1142)

* _pickle: reuse the unpickler stacks and cache the pickler write callable

`Unpickler.load` allocated a fresh stack and metastack list on every
call; both are now owned by the constructor, initialized lazily for the
`__new__`-only path, and cleared in place instead.

`Pickler` resolves `file.write` once in the constructor and stores it in
a rooted `w_write` field; `Framer::flush` calls it directly rather than
re-resolving the method on every flush. The constructor's error order is
unchanged: the `write` check still precedes the protocol-5
`buffer_callback` check.

Assisted-by: Claude

* _pickle: allocate the unpickler stack and metastack per load again

`interp_pickle.py:2042-2043` installs a fresh `stack` and `metastack` on every
`load`, and `_pickle.c` reuses one `Pdata` for the unpickler's whole lifetime
with MARK tracked by `num_marks`/`fence` indices inside it (`Pdata_New`,
`_pickle.c:454`). Reuse is coherent only inside the second structure; layered
onto the metastack-of-lists port it produced a shape neither has, and
`mark()` rebinding `w_stack` to a fresh list meant the constructor's list was
dropped by any load that reached STOP with an open MARK.

It is observable. Capturing the live stack during one `load` through
`gc.get_referents` and appending through it during a later one changes that
load's result:

    load3: (<function poison>, 'z')     reused stacks
    load3: (1, 'z')                     3.14.5, pypy3 3.11.15, and this commit

Reported by the Codex review bot on #1084.

Assisted-by: Claude

* majit: carry a struct field's declared width and signedness into its descr

`jit_inline`/`jit_interp` gain an `int_fields = { Struct::field => u32 }`
clause. A field named there registers `size_of::<ty>()` and the type's
signedness with the struct layout; an undeclared field keeps the signed
machine-word default. A generated `const _: fn(&Struct) -> ty` makes the
declaration a compile error unless the Rust field really has that type.

`field_specs_from_layout`, `add_struct_field_descr` and the
`struct_fields_write_effect_info` twin now read the width and flag from the
registered layout instead of re-deriving one signed machine word from the IR
type, so whichever of them mints the field descr first records the same width.
The registered layout tuple grows from `(offset, is_ref, name)` to
`(offset, is_ref, name, field_size, is_signed)`.

Assisted-by: Claude

* majit: spell the int-field width default as i64, and update the layout tuple's test callers

The macro's width for an undeclared integer field was `size_of::<usize>()`,
which is 4 on wasm32. `scalar_size` reports `size_of::<i64>()` for every
non-`Ref` field, so the default spells that instead; a `Ref` field's width comes
from `Type::Ref` and ignores the declared one either way.

`generate_inline_helper_jitcode_with_calls` took a new `int_fields` parameter
and the registered layout tuple grew two elements, but six `#[cfg(test)]` call
sites in majit-macros and majit-metainterp still used the old arity. `cargo
check` does not compile those, so only `cargo test` sees them. Also reformat the
`newlist_clear` layout literal, which `cargo fmt --check` rejected.

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