Skip to content

descr gate: close the wasm hole, and derive index_in_parent from the parent that gets indexed - #892

Merged
youknowone merged 3 commits into
mainfrom
cel
Jul 30, 2026
Merged

descr gate: close the wasm hole, and derive index_in_parent from the parent that gets indexed#892
youknowone merged 3 commits into
mainfrom
cel

Conversation

@youknowone

@youknowone youknowone commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Follow-up to #876 (merged as 8f0ead8492). Three commits: two close review
findings raised on that PR after it landed, one closes a defect the review
pointed at that turned out to be reachable in release builds.

1. The wasm backend passed a gate it could not fail

#876 put the descr-universe invariants in JITSTATS_BADNESS_FIELDS. On wasm the
module exported only pyre_jit_loops_aborted and
pyre_jit_internal_compile_panics, so the three descr_set_* counters never
reached the [jit-stats] line — and _jit_stats_regression_floor reads a field
missing from the current run as 0. A wasm-only regression passed silently.

Adds pyre_jit_descr_set_{absent,ambiguous,stale_absent,resolved} guest exports,
printed from the runner. Both they and descr_set_jit_stats() read one producer,
descr_set_counts(), so the gated numbers cannot drift from the printed ones.

Observed rather than assumed:

[jit-stats] loops_aborted=0 internal_compile_panics=0 descr_set_resolved=262 \
            descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0

No baseline re-record needed: the committed wasm baselines carry only the two old
keys, the run emits the new ones as 0, and missing-as-0 compares them equal. A
future 0 -> N still fires, because a field missing from the baseline also reads
as 0.

2. The snapshot parser was defined three times

_jit_stats_fields_equal, _jit_stats_diff and _jit_stats_regression_floor
each carried the same nested parse. Extracted as _parse_jit_stats.

3. index_in_parent came from the caller, and callers disagree

Upstream has no such argument. heaptracker.py:60-72 get_fielddescr_index_in is
the only numberer and shares its skip set with :96-112 all_fielddescrs, so
all_fielddescrs(S)[i].get_index() == i holds by construction.

pyre numbers fields in several places and they disagree: the analyzer's
field_pos and the assembler's specs.len() both count the two header words at
offsets 0 and 8 that the runtime publish skips, because the skip sets drop only
typeptr while pyre spells that header ob_type / w_class.

This is not cosmetic. all_fielddescrs()[index_in_parent] is load-bearing at
optimizeopt/info.rs force_box, which .expects it in release builds — so
a field carrying one convention under a parent numbered by another is either an
out-of-range panic or a store emitted against a different field. The existing
field_descr_identity_census cannot see it: it compares Arc identity only, and a
minted field publishes its own parent, so it reports Converged.

get_field_descr now derives the index from the parent that will actually be
indexed. Where there is nothing to derive from — no parent published, or an empty
list — or where the name is absent from a non-empty list, the caller's index
stands.

Match on the name only. An offset fallback was tried and measured unsound: one
struct is still reachable under several identity keys (intval under three,
PyFrame under two), so the parent found can belong to a different struct and a
same-offset field of another type reads as this one. That produced 22/4/17 synth
failures across the three backends with Const::getint on non-Int variant: Ref(..) and SIGSEGV. Closing that key aliasing is separate work.

Also adds field_position_jit_stats, because descr_set_absent /
descr_set_ambiguous cannot answer this question — they ask whether a member
resolved to some descr, not whether it resolved correctly, and they read 0
throughout the above.

Verification

ALL PASSED: dynasm    343/343
ALL PASSED: cranelift 343/343
ALL PASSED: wasm      340/340

cargo test -p majit-ir -p majit-metainterp: 1414 + 295 + … passed, 0 failed.

Deliberately not in this PR

Keying _cache_field on the bare field name. descr.py:218-233 keys on
fieldname and uses the qualified spelling only for the display name at :227;
several pyre groups spell the struct into the key itself. Stripping the prefix
alone breaks the build: assembler.rs bh_field_name qualifies a name only when it
does not already contain a ., so the prefixed runtime keys and the serialized
spec keys were agreeing through that heuristic. Both sides have to move in one
commit.

Declining to publish a fieldless parent. mint_field publishes a parent via
get_size_descr(key, size, 0, false) — vtable-less and caller-sized, which
descr.py:111-116 makes impossible — and nothing fills its all_fielddescrs.
The census shows the state is real and large (field_pos_parent_empty), but
whether any of it reaches a consumer is unmeasured, and declining would raise
descr_set_absent off zero. Consumer-side counters first.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

JIT descr-set counters are centralized in the runtime, exported through wasm-host functions, emitted in runner snapshots, and parsed by shared checker logic for equality, diffs, and regression floors.

Changes

JIT statistics counters

Layer / File(s) Summary
Descr-set counter API
pyre/pyre-jit-trace/src/jitcode_runtime.rs, pyre/pyre-jit/src/lib.rs
Adds DescrSetCounts, computes its four counters, uses them for formatted JIT stats, and re-exports the counting function.
Wasm counter exports
pyre/pyre-wasm/src/lib.rs, pyre/pyre-wasm-runner/src/main.rs
Exports the four counters for wasm-host and includes them in the MAJIT_STATS [jit-stats] record.
Shared checker snapshot parsing
pyre/check.py
Adds shared snapshot parsing and uses it for field equality, diffs, and regression-floor calculations.

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

Sequence Diagram(s)

sequenceDiagram
  participant JITRuntime
  participant WasmExports
  participant WasmRunner
  participant CheckPy
  JITRuntime->>WasmExports: expose descr-set counter fields
  WasmExports->>WasmRunner: return counter values
  WasmRunner->>CheckPy: emit normalized [jit-stats] snapshot
  CheckPy->>CheckPy: parse fields for comparisons and regression floors
Loading

Possibly related PRs

  • youknowone/pyre#876: Introduces related JIT-stats descr-set invariants and checker integration.

Suggested reviewers: lifthrasiir

Poem

I’m a rabbit counting stats in the night,
Four little counters now hop into sight.
Rust gathers numbers, wasm lets them roam,
The checker parses each one safely home.
Hop, hop—no hidden regressions in flight!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title captures the wasm-hole fix in the descr gate, though the index_in_parent clause is not reflected in the changes.
✨ 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 cel

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 Jul 30, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 8d96dc8).
Updated: 2026-07-30T09:56:54.882Z

Files in the reviewed diff
majit/majit-ir/src/descr.rs
pyre/check.py
pyre/pyre-jit-trace/src/jitcode_runtime.rs
pyre/pyre-jit/src/lib.rs
pyre/pyre-wasm-runner/src/main.rs
pyre/pyre-wasm/src/lib.rs
pyre/pyrex/src/lib.rs

1. Regressions to PyPy parity introduced by this patch

  • majit/majit-ir/src/descr.rs:1062 ↔ rpython/jit/backend/llsupport/descr.py:218 — the new re-derivation can make the cached descriptor’s index_in_parent differ from the caller’s supplied value; a later cache hit then trips the existing describes_same_field debug assertion at descr.rs:989. Upstream derives the index solely from (STRUCT, fieldname) before caching, so equivalent calls never disagree or abort.

2. Other mismatches introduced by this patch

  • pyre/pyrex/src/lib.rs:967 ↔ rpython/jit/codewriter/heaptracker.py:51field_pos_* is emitted only as an informational line. pyre/check.py does not include these keys in JITSTATS_BADNESS_FIELDS or snapshots, and wasm does not export them. Thus a nonzero field_pos_rederived/unresolved—which denotes violation of upstream’s positional-field invariant—still passes the regression gate.

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

  • majit/majit-translate/src/codewriter/heaptracker.rs:179 ↔ rpython/jit/codewriter/heaptracker.py:97 — Pyre’s get_fielddescr_index_in does not skip c__pad*, while its own all_fielddescrs_into does skip them at line 220. Upstream uses the same skip rules in both walkers. This can produce an index that addresses the wrong positional field.

  • majit/majit-ir/src/descr.rs:4507 ↔ rpython/jit/backend/llsupport/descr.py:238 — Pyre’s keyed-group factory creates field descriptors before publishing the parent SizeDescr; the new repair therefore takes its parent absent fallback and retains the producer-supplied index. Upstream always assigns parent_descr = get_size_descr(...) before returning the field descriptor, whose full field list is consequently available for consistent positional indexing.

4. Structural adaptations

  • majit/majit-ir/src/descr.rs:673 ↔ rpython/jit/backend/llsupport/descr.py:14 — process-global atomic census counters have no PyPy equivalent; PyPy keeps only per-GcCache descriptor state. This is diagnostic infrastructure, not interpreter/JIT semantics.

  • pyre/pyre-wasm/src/lib.rs:369 ↔ rpython/jit/backend/llsupport/descr.py:25 — wasm exports plus host-runner collection are necessary because the guest cannot write PyPy-style stderr diagnostics directly.

  • pyre/check.py:448 ↔ rpython/jit/metainterp/warmspot.py:0 — normalized multi-line [jit-stats] parsing is Pyre’s external regression-harness machinery; it has no corresponding PyPy runtime algorithm.

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

🤖 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 `@pyre/check.py`:
- Around line 448-454: Add explicit return type annotations to both
_parse_jit_stats and _jit_stats_fields_equal, using syntax supported by the
repository’s minimum Python version and matching each function’s actual return
value.
🪄 Autofix (Beta)

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: 1038d0b9-a358-4314-b4c5-f14e13806200

📥 Commits

Reviewing files that changed from the base of the PR and between 6efa1e2 and 0c38511.

📒 Files selected for processing (5)
  • pyre/check.py
  • pyre/pyre-jit-trace/src/jitcode_runtime.rs
  • pyre/pyre-jit/src/lib.rs
  • pyre/pyre-wasm-runner/src/main.rs
  • pyre/pyre-wasm/src/lib.rs

Comment thread pyre/check.py
Comment on lines +448 to +454
def _parse_jit_stats(snapshot):
"""Split a normalized jit-stats snapshot into its `field -> value` map. A
snapshot that was never taken reads as no fields at all, so every caller can
treat "absent" and "absent from this side" the same way."""
if snapshot is None:
return {}
return dict(line.split("=", 1) for line in snapshot.splitlines())

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 return annotations to satisfy Ruff.

Ruff reports ANN202 for _parse_jit_stats and _jit_stats_fields_equal; add explicit return annotations consistent with the repository’s supported Python version.

Also applies to: 457-461

🧰 Tools
🪛 Ruff (0.16.0)

[warning] 448-448: Missing return type annotation for private function _parse_jit_stats

(ANN202)

🤖 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 `@pyre/check.py` around lines 448 - 454, Add explicit return type annotations
to both _parse_jit_stats and _jit_stats_fields_equal, using syntax supported by
the repository’s minimum Python version and matching each function’s actual
return value.

Source: Linters/SAST tools

`_jit_stats_fields_equal`, `_jit_stats_diff` and `_jit_stats_regression_floor`
each defined the same nested `parse`, splitting snapshot lines on the first "="
and reading `None` as no fields. Move it to `_parse_jit_stats` and call it from
all three.

Assisted-by: Claude
The wasm module exported only `loops_aborted` and `internal_compile_panics`, so
the three descr-universe members of `JITSTATS_BADNESS_FIELDS` never reached the
`[jit-stats]` line on that backend. `_jit_stats_regression_floor` reads a field
missing from the current run as 0, so a wasm-only `descr_set_absent`,
`descr_set_ambiguous` or `descr_set_stale_absent` passed the hard gate.

Add `pyre_jit_descr_set_{absent,ambiguous,stale_absent,resolved}` guest exports
and print them from the runner's MAJIT_STATS line. Both they and
`descr_set_jit_stats` now read `descr_set_counts`, so the gated numbers cannot
drift from the printed ones.

Assisted-by: Claude
`get_field_descr` took `index_in_parent` from its caller. Upstream has no such
argument: `heaptracker.py:60-72 get_fielddescr_index_in` is the only numberer and
shares its skip set with `:96-112 all_fielddescrs`, so
`all_fielddescrs(S)[i].get_index() == i` holds by construction.

pyre numbers fields in several places and they disagree. The analyzer's
`field_pos` and the assembler's `specs.len()` both count the two header words at
offsets 0 and 8 that the runtime publish skips, because the skip sets drop only
`typeptr` and pyre spells that header `ob_type` / `w_class`.

`all_fielddescrs()[index_in_parent]` is load-bearing at `optimizeopt/info.rs`
`force_box`, which `.expect`s it in release builds, so a field carrying one
convention under a parent numbered by another is either an out-of-range panic or
a store emitted against a different field.

Derive the index from the parent that will actually be indexed, matching on the
cache key. Where there is nothing to derive from — no parent published, or a
parent with an empty list — or where the name is absent from a non-empty list,
the caller's index stands.

Match on the name only. An offset fallback was measured to be unsound while one
struct is still reachable under several identity keys: the parent found can
belong to a different struct, and a same-offset field of another type then reads
as this one (`Const::getint on non-Int variant: Ref(..)`, 22/4/17 synth failures
across the three backends).

Add `field_position_jit_stats`, because `descr_set_absent` / `descr_set_ambiguous`
cannot see any of this: they ask whether a member resolved to some descr, not
whether it resolved correctly.

Assisted-by: Claude
@youknowone youknowone changed the title descr gate: close the wasm hole the gate shipped with, and fold the duplicated snapshot parser descr gate: close the wasm hole, and derive index_in_parent from the parent that gets indexed Jul 30, 2026

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8d96dc8a27

ℹ️ 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 +1062 to +1064
fd.index_in_parent =
Self::derive_index_in_parent(parent.as_ref(), field_name, offset, index_in_parent)
.unwrap_or(index_in_parent);

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 Derive field positions at their producers

When a runtime descriptor group is the first publisher, make_simple_descr_group_keyed_with_headerless calls get_field_descr before register_keyed_size, so this lookup sees no parent and preserves the header-counting caller index; the later registration only changes the parent backreference, not index_in_parent. The result therefore still depends on publication order and the runtime-first path can retain the out-of-range or wrong-field index this change is meant to eliminate. Fix the independent field-list producers so they use the same upstream numbering rather than repairing only cache misses.

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

Useful? React with 👍 / 👎.

Comment on lines +1062 to +1064
fd.index_in_parent =
Self::derive_index_in_parent(parent.as_ref(), field_name, offset, index_in_parent)
.unwrap_or(index_in_parent);

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 Validate cache hits against the normalized index

In debug and test builds, whenever this branch actually rederives a caller index, a subsequent lookup from the same producer hits the cache and the earlier debug_assert! compares the normalized cached index against the original caller index through describes_same_field. For example, a first lookup corrected from 2 to 0 makes the next identical lookup assert that cached 0 equals requested 2, so ordinary descriptor reuse panics precisely in the mismatch scenario this code handles. Normalize the expected index before performing the cache-hit validation.

Useful? React with 👍 / 👎.

Comment on lines +625 to +630
let mut counter = |name| {
instance
.get_typed_func::<(), u64>(&mut store, name)
.and_then(|f| f.call(&mut store, ()))
.unwrap_or(0)
};

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 Fail closed when descriptor-counter exports are missing

When PYRE_WASM_MODULE points to an older or otherwise incompatible module that lacks one of these new exports, get_typed_func fails and this converts the failure to the healthy value 0. Because _jit_stats_regression_floor also interprets a missing baseline/current field as zero, such a module silently bypasses the descriptor gate this change is intended to close. Under MAJIT_STATS, missing or wrongly typed badness-counter exports should fail the run rather than be reported as zero.

Useful? React with 👍 / 👎.

@youknowone
youknowone merged commit 505a593 into main Jul 30, 2026
19 checks passed
@youknowone
youknowone deleted the cel branch July 30, 2026 12:48
youknowone added a commit that referenced this pull request Jul 30, 2026
…e hypotheses survive (#905)

* descr: census the fieldless size descrs and whether their layout exists elsewhere

`get_size_descr` returns on cache hit (descr.py:108-109), so whoever publishes a
key first decides what every later consumer sees. A mint publishing
`get_size_descr(key, size, 0, false)` — vtable-less and caller-sized, a shape
descr.py:111-116 cannot produce — therefore outranks a real layout published
after it. Nothing measured how often that happens or whether the displaced
layout existed at all.

`GcCache::size_shell_census` reports `[published, fieldless, shadowing, aliased,
aliased_multi]`. `shadowing` is a fieldless parent for which `_cache_field`
holds fields under the SAME key; `aliased` is a shadowing parent whose struct
also reaches the cache under another key that did get a populated list.

On dynasm/int_loop (identical on fannkuch and nbody):

  size_shell_published=1106 size_shell_fieldless=693 size_shell_shadowing=693
  size_shell_aliased=572 size_shell_aliased_multi=190

`fieldless == shadowing` exactly: every fieldless parent in the cache has fields
cached under its own key. 572 of them have a shape-matching populated twin under
a different key; 190 of those match on two or more slots.

`aliased` matches on shape — equal `size()` and every cached
`(offset, field_size)` present in the candidate — not on owner name. An earlier
name-based version reported 0: `get_field_descr` falls back to a `T<type_id>.`
stand-in for mint sites carrying only the numeric struct identity, so all 693
shell owners are `T<id>` strings that cannot match a real `Owner.field`
spelling. `size_shell_owner_sample` prints both sets under
`PYRE_SIZE_SHELL_OWNERS=1` so that zero cannot be read as a finding again.
`aliased_multi` restricts to shells carrying two or more cached fields, since a
one-field shell matches any same-size struct owning that slot.

These are diagnostic: check.py narrows snapshots to `JITSTATS_SNAPSHOT_FIELDS`,
so no baseline records them.

Also restores three doc comments that 8d96dc8 detached from their items by
inserting between the comment and what it documented — `GcCache`,
`GcCache::get_field_descr` and `descr_set_counts` — and drops the sentence in
`get_field_descr` describing an offset fallback that commit did not keep.

Assisted-by: Claude

* descr: normalise the cache-hit check, and stop reading a missing wasm export as 0

Two findings from the review of #892.

`get_field_descr` normalises `index_in_parent` against the parent, so the cached
descr holds the derived number while callers keep handing in their own. The
cache-hit `debug_assert!` compared the cached value against the raw argument, so
the SECOND lookup of any rederived field asserted that (say) cached 0 equals
requested 2 — firing on exactly the fields the normalisation exists for. Only
debug builds carry `debug_assert!`, so the release gate could not see it;
`field_pos_rederived=21` on int_loop is how many fields were eligible.

The check now runs the caller's index through the same parent lookup first.
`find_index_in_parent` is that lookup with no census side effect, because the
census measures what producers hand in and a cache hit is one producer's answer
read back, not a second producer disagreeing. `derive_index_in_parent` keeps the
counters and delegates. Its unused `offset` parameter goes with it — the offset
fallback it was for is not coming back while one struct is reachable under
several identity keys.

`a_rederived_field_survives_a_second_lookup` covers it; it panics without the
change.

Second: the wasm runner resolved every `MAJIT_STATS` counter with
`unwrap_or(0)`. Zero is the healthy value for all six, and
`_jit_stats_regression_floor` also reads a field missing from a run as zero, so a
module built before these exports existed would have gated on nothing while
looking perfectly green — the same vacuous-gate hole this PR set out to close,
reintroduced one level down. Missing or wrongly typed exports are now named and
the run exits non-zero.

Assisted-by: Claude

* descr: check the two invariants descr.py gets for free, and name what fails

The shell census said 693 parents list nothing while holding fields. That says
where the layout is, not whether anything is currently wrong, so add the two
checks upstream never needs and read them.

`positional_invariant_census` checks `all_fielddescrs(S)[i].get_index() == i`.
heaptracker.py:60-72 and :96-112 are one walker sharing one skip set, so upstream
this holds by construction; pyre builds the list from cache-or-mint results, so a
descr minted earlier by a header-counting producer could sit at a position it
disagrees with. Measured on int_loop: positional_slots=1794
positional_misplaced=0. The lookup `optimizeopt/info.rs force_box` depends on is
intact.

`identity_collision_census` checks whether one key carries more than one struct,
by comparing a cached field's offset against the same field in its parent's list.
descr.py keys on the lltype STRUCT object, so a key means one struct; pyre keys
on `path_hash(<some spelling>)` minted by several producers. Measured:
key_compared=1766 key_conflicting=0. A field cannot be at two offsets in one
struct, so zero here is zero collisions among fields both sides name.

That leaves `field_pos_unresolved` — fields cached under a key whose parent lists
other fields but not them. `size_shell_owner_sample` now names them, and they are
one shape: the cached key is a bare `__pos_0` while the parent lists
`__pos_0.flags`, `__pos_0.ob.w_class`, `__pos_0.deque`. 178 distinct. That is the
key SPELLING split, not two structs sharing a key.

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