Skip to content

jit: revalidate the quasi-immutable fields, share one QuasiImmut, drop the loop-invalidation timer (method-cache pair 38 → 39 ops; the 38 was unsound) - #956

Merged
youknowone merged 7 commits into
mainfrom
rewrite-tracer
Aug 1, 2026
Merged

Conversation

@youknowone

@youknowone youknowone commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Follow-up to #940, which made W_TypeObject._version_tag quasi-immutable. Reviewing
that merge surfaced that the tracking it relies on was keyed on the wrong structure,
that nothing revalidated a ? field between tracing and optimizing, and that a
background thread was invalidating every compiled loop on a wall clock.

quasiimmut_seen was a faithful port with zero users

CacheEntry.quasiimmut_seen{,_refs} (heapcache.rs:78-79) is cleared exactly where
heapcache.py:70-77 / :121-129 clears it — and nothing read it. A pyre-only
HeapCache.quasi_immut_known flat set, cleared only by reset() at trace teardown,
did the work instead. clear_caches_varargs (heapcache.py:341-370) was already
arming need_guard_not_invalidated per general call; it just had nothing to clear,
so the second folded lookup after a residual call reused the first one's pin and no
second GUARD_NOT_INVALIDATED was emitted (heap.py:810-822).

Two un-inlined method calls on one instance: QuasiimmutField ×1 becomes ×2, each
followed by GuardNotInvalidated(), with the call between. The method-cache pair's
steady body goes 38 → 39 ops — the 38 in #940's title was unsound
, so #940's
"41 → 38" reads "41 → 39" after this branch.

QuasiImmutDescr.is_still_valid_for had no port, and the namespace form had no descr

quasiimmut.py:135-143 captures constantfieldbox at trace time and heap.py:794-808
raises InvalidLoop('quasi immutable field changed during tracing') when the field
moved before optimization. Pyre had neither half.

The captured value now rides on the op as arg(1) (TraceCtx::field_sanity_load, the
bh_getfield_gc_* triple) and OptHeap::quasiimmut_field_still_valid re-reads through
OptContext::get_runtime_field. A per-op descr like upstream's is not available here:
pyre descrs are registry-indexed singletons, so a fresh Arc per read mints a registry
entry per read and misses heap.rs:3274 quasi_immut_cache, which keys on the Arc
pointer.

The module-global folds were worse off: they recorded QUASIIMMUT_FIELD(dict, slot)
with no descr at all, and optimize_QUASIIMMUT_FIELD gates the revalidation on
op.getdescr(), so that form skipped it entirely. The marker now names the field the
dependency is really on — ModuleDictStrategy.version, celldict.py:34 _immutable_fields_ = ["version?"] — the same field getdictvalue_no_unwrapping
promotes before its elidable lookup. _setitem_str_cell_known (celldict.py:80-90)
calls mutated() before every write that replaces a stored pointer, and an in-place
cell write leaves the pointer alone, so the version is exactly what proves the baked
ConstPtr(cell) still stands.

Two folds against one namespace now share one QUASIIMMUT_FIELD where the per-slot
scheme emitted one each.

One QuasiImmut, and it is not thread-safe by inheritance

rpython/jit/metainterp/quasiimmut.py is now ported once, in
pyre-object/src/quasiimmut.rs, and both ? fields share it —
W_TypeObject._version_tag (typeobject.py:177) and ModuleDictStrategy.version.
Before this each had its own bare Vec<Weak<..>>: no compress_looptokens_list, so an
object recompiled against many times and never mutated grew one entry per compile, and
ModuleDictStrategy's was pushed to without synchronisation while mutated() swept it
from another thread.

rg 'allocate_lock|Lock\(' rpython/jit/ returns nothing: upstream gets the
null-then-sweep pair of _invalidate_now (quasiimmut.py:129-134) free from the GIL.
pyre runs Python threads on real OS threads, so QuasiImmutField carries an
AtomicPtr plus a parking_lot::Mutex<()>, swapping under the lock. Only the
dereferencing paths take it — is_installed() stays lock-free, because that bare test
is the whole cost a mutation on an object no loop watches has to pay. Both owners are
allocated non-moving (try_gc_alloc_stable_raw / malloc_typed), so an embedded lock
cannot be remapped out from under a holder.

The stress test was written against a deliberately-unlocked control first: two
registrar threads spinning while four mutators each run 200 000 invalidations aborts
the control with heap corruption, and passes with the lock.

The 50 ms loop-invalidation timer has no upstream counterpart

JitDriver spawned a thread that woke every 50 ms and invalidated an epoch_qmut that
every back edge had registered the running loop token's invalidation flags into.
Flipping those fails GUARD_NOT_INVALIDATED in compiled code and makes memmgr evict
the token (memmgr.py:70-73), on a wall-clock schedule. quasiimmut.py has no timer,
thread or sleep, and invalidate is reached only from _invalidate_now, on the write
to the field. The comment claiming this stood in for "RPython's GC/signal-triggered
invalidation" described a mechanism that does not exist upstream, and
with_options' opt-out had one caller — new, passing true.

It also self-healed every stale const-fold within 50 ms, which is what made a
missing-invalidation bug unreproducible. Removing it leaves
majit_metainterp::quasiimmut with no users at all (get_current_qmut_instance and
make_invalidation_function already had none), so that module goes too.

w_type_add_subclass's dirty mark

Unrelated to the ? fields, found while auditing the same file. The prebuilt-family
dirty bit was set at the top of w_type_add_subclass, ahead of the
existing == w_subclass early return that stores nothing, so a re-registration
dirtied the whole prebuilt family and cost the next minor collection a full walk for
no store. Both store sites now call one helper that marks and then takes the write
barrier the two arms duplicated.

The mark stays ahead of the barrier. Host-side allocation cannot collect —
dynasm_alloc_nursery_typed routes to try_alloc_nursery_no_collect_typed and spills
to old-gen on nursery full — but try_gc_write_barrier reaches gc_sync::gc_op,
which leaves RUNNING and parks on gc_mutex: an entry-style safepoint where another
thread's stop-the-world collection runs. Marking behind it would leave a window with
the slot updated and the bit clear. The other 21 mark_prebuilt_roots_dirty call
sites were audited against that rule and none violate it.

Verification

On e6e2b25a715 (#930), the current base:

  • check.py --backend dynasm,cranelift — 358/358 each, 2/2 backend runs
  • cargo test --release -p pyre-object -p pyre-jit-trace -p majit-metainterp -p majit-trace — 2207 passed, 0 failed
  • cargo test --release -p pyre-jit --features dynasm --test gc_stress — 23/23
  • cargo fmt --all --check clean

The perf figure was taken before the rebase, on a9a867e81ee: min-of-7 CPU time over
int_loop / fib_loop / nested_loop / float_loop / fannkuch / nbody / spectral_norm moves
between −1.8% and +3.0% with the timer gone. The branch delta is byte-identical across
the two bases, so it was not re-measured.

authored by Claude

`is_quasi_immut_known` / `quasi_immut_now_known` now read and write
`CacheEntry.quasiimmut_seen{,_refs}` (heapcache.py:604-627) and take
`(fielddescr, box)` in upstream's argument order, replacing the flat
`HeapCache.quasi_immut_known` set.

The two `CacheEntry` fields were already ported, along with their
`_clear_cache_on_write` (heapcache.py:70-77) and `_invalidate_unescaped`
(heapcache.py:121-129) clears, but had no readers or writers.  The flat
set that stood in for them was cleared only by `reset()`, i.e. at trace
teardown.

`clear_caches_varargs` (heapcache.py:341-370) arms
`need_guard_not_invalidated` and runs `invalidate_unescaped` per entry
for every general call, so with the mark held there a residual call
between two reads of the same quasi-immutable field re-emits
QUASIIMMUT_FIELD, which in turn emits a second GUARD_NOT_INVALIDATED
(heap.py:810-822).  On the two-method-call fixture the pre-opt block goes
from one QuasiimmutField to two and the steady body from 38 to 39 ops.

Assisted-by: Claude
The tracer captures the field's value where it records QUASIIMMUT_FIELD
(`QuasiImmutDescr.get_current_constant_fieldvalue`,
quasiimmut.py:135-143) and carries it on the op.  OptHeap re-reads the
live value through `get_runtime_field` and returns
`InvalidLoop('quasi immutable field changed during tracing')` when the
two disagree (heap.py:798-804 `QuasiImmutDescr.is_still_valid_for`),
instead of compiling a loop whose baked constant nothing re-proves.

Upstream hangs the captured value on a `QuasiImmutDescr` minted per
recorded op; pyre's descrs are registry-indexed singletons, so it rides
on the op as `arg(1)`, the way the `record_namespace_quasiimmut_field`
twin already carries its slot index.

`w_type_notify_quasi_immut_watchers` unlinks `quasi_immut_watchers`
before the sweep and drops the box
(`make_invalidation_function._invalidate_now`, quasiimmut.py:129-134),
so the next registration allocates a fresh instance.

Assisted-by: Claude
`w_type_notify_quasi_immut_watchers` read the raw `quasi_immut_watchers`
pointer, nulled it and dropped the `Box` with no synchronisation, and
`w_type_register_quasi_immut_watcher` allocated and pushed through the
same unguarded pointer.  Upstream's `_invalidate_now`
(quasiimmut.py:129-134) is indivisible against `register_loop_token`
because of the GIL; pyre runs Python threads on real OS threads
(`module/thread/mod.rs` `start_new_thread`) and has none, so two threads
mutating one class both saw the same instance and both freed it, and a
mutator could free the instance a compiling thread was pushing into.

The field becomes an `AtomicPtr` plus a `quasi_immut_lock`.  The bare
null test stays outside the lock; the get-or-create, every dereference
and the `Box` free happen under it.  The lock is per type rather than
address-striped because a `W_TypeObject` is allocated through
`try_gc_alloc_stable_raw` or `malloc_typed` and never moves.

The sweep moves to a free function, `w_type_sweep_quasi_immut_watchers`,
registered in `jit_fnaddr`: `dont_look_inside` emits a call target only
for a free function, so the annotation on the `QuasiImmut::invalidate`
method left the tracer with a policy marker and no trampoline.

`type_object_destructor` now drops the instance of a swept type.

`quasi_immut_publication_is_serialised_across_threads` aborts with heap
corruption in `Vec` when the sweep is reverted to an unlocked
load/store.

Assisted-by: Claude
`W_TypeObject._version_tag?` and `ModuleDictStrategy.version?` are the
tree's two `_immutable_fields_` entries spelled with a `?`.  Upstream
serves both from one `QuasiImmut` class reached through the hidden
`mutate_<name>` pointer field the rtyper synthesises; pyre had a
`QuasiImmut` in `typeobject` for the first and a bare
`version_watchers: Vec` in `celldict` for the second.

Both now hold a `quasiimmut::QuasiImmutField`, the synthesised field
written out.  For `version_watchers` that changes three things:

- `register_version_watcher` pushed with no synchronisation while
  `mutated()` swept the same `Vec` from another thread.
- `register_version_watcher` had no `compress_looptokens_list`
  (quasiimmut.py:77-82), so a module recompiled against many times and
  never mutated grew one entry per compile.
- `mutated()` now unlinks the instance before sweeping it
  (`_invalidate_now`, quasiimmut.py:129-134) instead of retaining a
  swept list.

`sweep_version_watchers` and `w_type_sweep_quasi_immut_watchers` collapse
into one residual, `quasiimmut::sweep_quasi_immut_field`.

check.py dynasm 358/358.

Assisted-by: Claude
The global cell folds recorded `QUASIIMMUT_FIELD(dict, slot)` with no
descr and a slot index standing in for one.  Nothing could revalidate it:
`OptHeap` gates the `is_still_valid_for` port (heap.py:794-808) on
`op.getdescr()`, so the descr-less namespace form skipped it and a global
rebound between the walker recording the fold and the optimizer running
left the baked `ConstPtr(cell)` in place.

The marker now names the field the dependency is actually on —
`ModuleDictStrategy.version`, `celldict.py:34 _immutable_fields_ =
["version?"]`, the same field `getdictvalue_no_unwrapping` promotes
before its elidable lookup.  `_setitem_str_cell_known` (celldict.py:80-90)
calls `mutated()` before every write that replaces a stored pointer and
an in-place cell write leaves the pointer alone, so the version is what
proves the baked address still stands.  `record_namespace_quasiimmut_field`
and the descr-less branch in `optimize_QUASIIMMUT_FIELD` are gone, and so
is the `usize::MAX` slot sentinel the absent-name guard used to keep its
key from colliding.

The marker attaches to the strategy box, where `version` lives, so
`register_quasi_immutable_deps` selects the registrar by the recorded
field index: a `ModuleDictStrategy` has no `PyObject` header for the
type-keyed one to read.  The recorded `u32` therefore becomes the descr
registry index rather than an effect index; nothing read it before.

Two folds against one namespace now share one `QUASIIMMUT_FIELD` instead
of carrying one per slot.

`emit_namespace_cell_store_fold` returns whether it folded.  It could not
decline before; now that it can, an `Ok(())` would have told the caller
the store was emitted when it was not.

`register_quasi_immutable_deps` is the only drain of
`last_quasi_immutable_deps`, and it ran only when the attempt produced a
new artifact, so a recompile's dependencies stayed in the field and were
registered against the next compile's flag.

check.py dynasm 358/358, cranelift 358/358.
`synth/global_quasiimmut_invalidation` prints 1000099 instead of
99505000 with the strategy registration disabled.

Assisted-by: Claude
`JitDriver` spawned a thread that woke every 50 ms and called
`QuasiImmut::invalidate()` on an `epoch_qmut` that every `run_back_edge`
had registered every invalidation flag of the running loop token into.
Flipping those flags fails `GUARD_NOT_INVALIDATED` in compiled code and
makes `memmgr` evict the token (memmgr.py:70-73), on a wall-clock
schedule.

`rpython/jit/metainterp/quasiimmut.py` has no timer, thread or sleep, and
no other RPython path invalidates a loop on a clock: `invalidate` is
reached only from `_invalidate_now`, on the write to the quasi-immutable
field.  The comment claiming this stood in for "RPython's GC/signal-
triggered invalidation" described a mechanism that does not exist
upstream.

`with_options`' `periodic_invalidation` opt-out had exactly one caller,
`new`, passing `true`; no consumer ever declined it.

That leaves `majit_metainterp::quasiimmut` with no users at all — its
`get_current_qmut_instance` and `make_invalidation_function` already had
none — so it goes with the timer.  The live port is
`pyre_object::quasiimmut`, which is where the two `?` fields' instances
hang and which `pyre-object` can reach without depending on the
metainterp.

check.py dynasm 358/358, cranelift 358/358.  Min-of-7 CPU time over
int_loop / fib_loop / nested_loop / float_loop / fannkuch / nbody /
spectral_norm moves between -1.8% and +3.0%.

Assisted-by: Claude
`w_type_add_subclass` set the prebuilt-family dirty bit at the top of the
function, before the `existing == w_subclass` early return that stores
nothing, so a re-registration marked the whole prebuilt family dirty and
cost the next minor collection a full walk for no store.

Both store sites now call `note_weak_subclass_store`, which marks and then
takes the write barrier the two arms previously duplicated.

The mark stays ahead of the barrier.  Host-side allocation cannot collect
— `dynasm_alloc_nursery_typed` routes to `try_alloc_nursery_no_collect_typed`
and spills to old-gen on nursery full — but `try_gc_write_barrier` reaches
`gc_sync::gc_op`, which leaves RUNNING and parks on `gc_mutex`: an
entry-style safepoint where another thread's stop-the-world collection
runs.  Marking behind it would expose a window where the slot is updated
and the bit is clear.

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Aug 1, 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: 35 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: d496945c-a869-4186-a254-42965ef0feb4

📥 Commits

Reviewing files that changed from the base of the PR and between e6e2b25 and 24f84e4.

📒 Files selected for processing (17)
  • majit/majit-backend/src/resume_guard_descr.rs
  • majit/majit-metainterp/src/jitdriver.rs
  • majit/majit-metainterp/src/lib.rs
  • majit/majit-metainterp/src/optimizeopt/heap.rs
  • majit/majit-metainterp/src/quasiimmut.rs
  • majit/majit-trace/src/heapcache.rs
  • pyre/pyre-interpreter/src/jit_fnaddr.rs
  • pyre/pyre-jit-trace/src/descr.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs
  • pyre/pyre-jit-trace/src/state.rs
  • pyre/pyre-jit/src/eval.rs
  • pyre/pyre-object/src/celldict.rs
  • pyre/pyre-object/src/dictmultiobject.rs
  • pyre/pyre-object/src/lib.rs
  • pyre/pyre-object/src/quasiimmut.rs
  • pyre/pyre-object/src/typeobject.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/24f84e44c833de7d07a5d79cffc9d2f818b543c3/pyre-jit/src/eval.rs#L5299
P1 Badge Make validation and watcher publication atomic

When another Python thread mutates a quasi-immutable field after quasiimmut_field_still_valid rereads it but before this post-compilation registration runs, invalidation can remove the old QuasiImmut (or observe no installed instance), after which this loop creates/registers against a fresh instance even though the artifact baked the previous value. Its invalidation flag therefore remains false, and with the periodic invalidator removed the stale loop can execute indefinitely until an unrelated later mutation. Preserve the trace-time QuasiImmut identity/generation through registration, or synchronize the revalidation and publication with mutation as one protocol.

AGENTS.md reference: AGENTS.md:L194-L196

ℹ️ 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 1, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 24f84e4).
Updated: 2026-08-01T11:37:46.613Z

Files in the reviewed diff
majit/majit-backend/src/resume_guard_descr.rs
majit/majit-metainterp/src/jitdriver.rs
majit/majit-metainterp/src/lib.rs
majit/majit-metainterp/src/optimizeopt/heap.rs
majit/majit-metainterp/src/quasiimmut.rs
majit/majit-trace/src/heapcache.rs
pyre/pyre-interpreter/src/jit_fnaddr.rs
pyre/pyre-jit-trace/src/descr.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs
pyre/pyre-jit-trace/src/state.rs
pyre/pyre-jit/src/eval.rs
pyre/pyre-object/src/celldict.rs
pyre/pyre-object/src/dictmultiobject.rs
pyre/pyre-object/src/lib.rs
pyre/pyre-object/src/quasiimmut.rs
pyre/pyre-object/src/typeobject.rs

Codex did not produce a report (exit 1). Last log lines:

collecting all differences, organize the report into separate sections:

1. Cases where our patch regressed PyPy parity compared to main
2. Other mismatches introduced by our patch
3. Mismatches that already existed before this patch
4. Structural adaptations

Exceptions: some differences cannot be ported 1:1 because of Python 3.11 vs
3.14 differences, opcode mismatches caused by using a CPython-compatible
compiler, GIL/free-threading differences, and fundamental implementation-
language differences between RPython and Rust. Mark those separately under
"Structural adaptations".

Scope discipline: before writing the report, run
`git diff upstream/main --name-only` and treat that file list as the
authoritative definition of "this patch" (when an authoritative changed-file
list is appended below, use that instead of re-deriving it). Findings under
sections 1 and 2 MUST cite our-side files from that list; a divergence in any
file NOT in the list is by definition not introduced by this patch — report
it under section 3 instead, or omit it. Verify every section-1/2 citation
against the list before finalizing the report.

---

Output format requirements (so the report can be parsed mechanically and
posted/triaged automatically). Use these four headings VERBATIM, in this
order, and nothing else at heading level 2:

## 1. Regressions to PyPy parity introduced by this patch
## 2. Other mismatches introduced by this patch
## 3. Pre-existing mismatches (already present before this patch)
## 4. Structural adaptations

Under each heading, list every finding as a bullet. For each finding cite the
concrete `our_file.rs:line ↔ rpython_or_pypy_file.py:line` pair and quote the
divergence concisely. If a section has no findings, still emit the heading
followed by a single line `None.` so all four sections are always present.
Do not modify any files; produce the report only.

Authoritative changed-file list for this patch (git diff upstream/main --name-only):
majit/majit-backend/src/resume_guard_descr.rs
majit/majit-metainterp/src/jitdriver.rs
majit/majit-metainterp/src/lib.rs
majit/majit-metainterp/src/optimizeopt/heap.rs
majit/majit-metainterp/src/quasiimmut.rs
majit/majit-trace/src/heapcache.rs
pyre/pyre-interpreter/src/jit_fnaddr.rs
pyre/pyre-jit-trace/src/descr.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs
pyre/pyre-jit-trace/src/state.rs
pyre/pyre-jit/src/eval.rs
pyre/pyre-object/src/celldict.rs
pyre/pyre-object/src/dictmultiobject.rs
pyre/pyre-object/src/lib.rs
pyre/pyre-object/src/quasiimmut.rs
pyre/pyre-object/src/typeobject.rs
warning: Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.
ERROR: Selected model is at capacity. Please try a different model.
ERROR: Selected model is at capacity. Please try a different model.

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