Skip to content

jit: gate the module-dict strategy read on the dict actually being one - #985

Merged
youknowone merged 1 commit into
mainfrom
aheui
Aug 2, 2026
Merged

youknowone merged 1 commit into
mainfrom
aheui

Conversation

@youknowone

Copy link
Copy Markdown
Collaborator

Follow-up to #977, from its review round. The first item is a crash.

The crash

W_ModuleDictObject.mstrategy sits at the same byte offset as W_DictObject's
dstrategy, which is a &'static dyn DictStrategy — a fat pointer. So an
unchecked w_module_dict_get_strategy on a plain dict returns one half of that
pair. Every strategy singleton is a zero-sized static, so the result is non-null
and points into read-only memory: a caller testing .is_null() to mean "not a
module dict" gets false, and then reads — or atomically writes — past the
static.

exec("for i in range(200000):\n x = i\n", {})

dies with SIGBUS in QuasiImmutField::invalidate, on the parking_lot Mutex
compare-and-swap, 36 bytes past EMPTY_KWARGS_DICT_STRATEGY in __TEXT.__const.
exec(src, {}) and types.FunctionType(code, {}) both hand the frame a plain
W_DictObject for globals — exec_or_eval passes globals_arg through
verbatim — and #977's force check read the strategy off it.

w_module_dict_strategy_or_null is w_module_dict_get_strategy plus the
is_module_dict test, for callers that have not proven the layout. All three
walker sites take it. Two of them predate #977 — the MAKE_FUNCTION fold and
walker_pin_namespace_version — and their .is_null() tests now mean what
their comments claim instead of being unconditionally false.

Declining is also the orthodox answer, not a heuristic: a plain dict has no
version? field, so hook_setfield (rclass.py:714-718) emits no
jit_force_quasi_immutable for its write and there is nothing to abandon the
trace for.

global_store_plain_dict_globals covers a hot STORE_GLOBAL / DELETE_GLOBAL
under exec(src, {}) and under FunctionType(code, {}), plus a module-scope
loop so the plain-dict decline cannot be mistaken for "never traced".

The w_locals gate

The module-scope gate now applies to STORE_NAME only.

STORE_NAME writes get_or_create_w_locals, so an ABSENT w_locals is a fresh
throwaway mapping rather than globals, and a fold that set the module cell would
write the wrong dict — the LOAD fold's null arm is correct (load_name_value
really does fall through to globals) but the store's was not.

STORE_GLOBAL names globals outright and its frame is a function frame whose
w_locals is legitimately null, so the same gate applied there declined every
function-scope global store. That showed up as store_global_hot 0.09s against
a 14x ratio gate on both native backends, which is how the over-application was
caught.

Two smaller items

Verification

pyre/check.py, all three backends:

backend result
dynasm 2 failed / 367 passed
cranelift 2 failed / 367 passed
wasm 2 failed / 363 passed

exception_args_virtual and list_length_hint_validate fail identically in
main's own CI at a72d9cb8d0c (run 30738774634), with the same numeric deltas.

One item I could not attribute and did not re-record: wasm
pickle_terminal_raise_resume reads loops_aborted 54 -> 59 locally, and main's
base CI passes it. The native backends are bit-identical to that fixture's
committed baseline (33 loops / 0 aborted / 487 guard failures) and
abort: force quasi-immut is 0 there, so it is not this change's semantics; the
counter is layout-sensitive on that fixture. Whether it reproduces on the CI
host is the open question this PR's wasm leg answers.

🤖 Generated with Claude Code

@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: 17 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: 0714c5ef-85db-40d8-b5a3-93689e92e5d3

📥 Commits

Reviewing files that changed from the base of the PR and between a69ae31 and e1e3d0d.

📒 Files selected for processing (10)
  • majit/majit-ir/src/effectinfo.rs
  • pyre/bench/synth/global_store_plain_dict_globals.cranelift.jitstats
  • pyre/bench/synth/global_store_plain_dict_globals.dynasm.jitstats
  • pyre/bench/synth/global_store_plain_dict_globals.py
  • pyre/bench/synth/global_store_plain_dict_globals.wasm.jitstats
  • pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs
  • pyre/pyre-object/src/celldict.rs
  • pyre/pyre-object/src/dictmultiobject.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.

`W_ModuleDictObject.mstrategy` sits at the same offset as `W_DictObject`'s
`dstrategy`, which is a `&'static dyn DictStrategy` fat pointer, so an
unchecked `w_module_dict_get_strategy` on a plain dict returns one half of that
pair. Every strategy singleton is a zero-sized static, so the result is non-null
and points into read-only memory: `.is_null()` cannot reject it, and the caller
goes on to read — or atomically write — past the static.

`exec("for i in range(200000):\n x = i\n", {})` reached this through the new
quasi-immut force check and died with SIGBUS in `QuasiImmutField::invalidate`,
a Mutex compare-and-swap 36 bytes past `EMPTY_KWARGS_DICT_STRATEGY`.
`exec(src, {})` and `types.FunctionType(code, {})` both hand the frame a plain
`W_DictObject` for globals.

- `w_module_dict_strategy_or_null` is `w_module_dict_get_strategy` with the
  `is_module_dict` test, for callers that have not proven the layout.
- All three walker sites take it: the force check
  (`try_walker_force_quasi_immut_namespace_write`), the MAKE_FUNCTION fold and
  `walker_pin_namespace_version`. The latter two predate this branch and their
  `.is_null()` tests now mean what their comments claim.
- `global_store_plain_dict_globals` covers a hot STORE_GLOBAL / DELETE_GLOBAL
  under `exec(src, {})` and under `FunctionType(code, {})`, plus a module-scope
  loop so the plain-dict decline cannot be mistaken for "never traced".

Also in this pass, from the same review round:

- The `w_locals` module-scope gate applies to STORE_NAME only. STORE_NAME writes
  `get_or_create_w_locals`, so an ABSENT `w_locals` is a fresh throwaway mapping
  rather than globals and the fold must decline; STORE_GLOBAL names globals
  outright and its frame's `w_locals` is legitimately null, so the same gate
  there declined every function-scope global store (`store_global_hot`
  0.09s → 14x gate).
- `store_bump_prediction_matches_the_write` pins `store_would_bump_version`
  against `write_cell` over all six slot/value shapes.
- Drop the `PyreHelperKind` "keep this at the enum tail" note: serde encodes the
  positional variant index, nothing decodes the tag numerically, and `descrs.bin`
  is written and read within one build whose codegen cache is keyed on the file.

check.py: dynasm 2 failed / 367 passed, cranelift 2 failed / 367 passed —
`exception_args_virtual` and `list_length_hint_validate`, both in main's own
failure set at a72d9cb (CI run 30738774634).

Assisted-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: e1e3d0dcb8

ℹ️ 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".

// fold set the module cell. STORE_GLOBAL names globals outright, and its
// frame is a function frame whose `w_locals` is legitimately null, so the
// gate must not apply to it.
if helper == majit_ir::PyreHelperKind::StoreName {

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 STORE_GLOBAL tied to the callee frame

When an inlined non-portal callee executes STORE_GLOBAL after a bridge/blackhole resume, the register-form frame operand can still identify the outer caller—codewriter.rs:14704-14709 explicitly documents the equivalent cross-module namespace collapse. Bypassing the locals gate for every StoreGlobal now lets this fold derive w_globals from that shared caller frame and eagerly mutate the caller's IntMutableCell; in particular, a caller with materialized locals was declined by the previous gate but can now update the wrong module's same-named global. Thread the callee's red frame into this specialization rather than enabling the fold from the shared frame anchor.

AGENTS.md reference: AGENTS.md:L32-L42

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit e1e3d0d).
Updated: 2026-08-02T14:43:59.949Z

Files in the reviewed diff
majit/majit-ir/src/effectinfo.rs
pyre/bench/synth/global_store_plain_dict_globals.py
pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs
pyre/pyre-object/src/celldict.rs
pyre/pyre-object/src/dictmultiobject.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

@youknowone
youknowone merged commit ee4ede2 into main Aug 2, 2026
16 of 19 checks passed
@youknowone
youknowone deleted the aheui branch August 2, 2026 16:52
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