Skip to content

perf(codegen,runtime): polymorphic property-read cache + arr.length short-circuit — interp.ts 3.96s → 2.39s - #7753

Merged
proggeramlug merged 4 commits into
mainfrom
perf/7-interp-gap
Aug 10, 2026
Merged

perf(codegen,runtime): polymorphic property-read cache + arr.length short-circuit — interp.ts 3.96s → 2.39s#7753
proggeramlug merged 4 commits into
mainfrom
perf/7-interp-gap

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

The gap

gc-handoff/apps/interp.ts — a tree-walking interpreter (lexer, precedence-climbing parser, recursive let, closures over environments), 189 statements, no dynamic remainder — ran 3.96 s against Node 26.5.1's 0.32 s. That is 12.3× Node, three times worse than any synthetic benchmark in the corpus (all now 2.3–4.0×), 2.3× worse than scriptc, and it is the only program in the corpus that resembles real software.

GC is not involved: 0.04 s of pause across the whole run, 38 minors, zero fulls.

It is now 2.39 s (1.66× faster, 12.3× → 7.4× Node).

Root cause

The per-site property-read cache holds exactly one entry. evalNode dispatches on n.kind === "num" | "str" | "var" | "bin" | "if" | …, so the receiver at those sites cycles through five shapes and the single entry is wrong on essentially every read. Each miss re-derives the receiver kind from scratch — proxy band, closure magic, the registered-buffer and typed-array registries (both behind thread-locals), the accessors latch — then linear-scans the keys array with a js_string_equals per key. That was 34% of the reduced program.

It also explains the profile lines that made no sense on their face: typedarray::lookup_typed_array_kind at 3.4% and buffer::header::is_registered_buffer at 1.9% in a program that uses neither typed arrays nor Buffers. They were not a separate problem — they were inside the miss handler.

Three hypotheses died first, and one of the refutations was misread

  • String-keyed union tags. js_jsvalue_equals + from_utf8 + memcmp + js_string_equals = 23% of the profile. Refuted: tag_str.ts vs tag_num.ts are both 0.06 s, and converting the whole interpreter to numeric tags moved 3.88 → 3.71 s (4%). Most of that 23% was the keys scan inside the miss handler, not the user's ===.
  • Runtime-built heap strings compare slowly. Refuted: streq_sub.ts is 0.05 s vs Node's 0.07. Interning every identifier at lex time: 3.88 → 3.87. Numeric opcodes: 3.88 → 3.89.
  • A megamorphic cliff at some shape count. A clean arity sweep (meg{2..12}.ts, loop body and array size held fixed) showed a flat 3.0–3.7× at every arity, and that was read as "no cliff, so not the cause". It is the opposite: flat-and-already-3× from two shapes onward is exactly the signature of a one-entry cache. The sweep could never show a cliff because there is nothing to fall off.

The discriminating probe was bench/a_flatnode.ts — the same interpreter with every AST node built from one shape, so every n.* read is monomorphic, with recursion depth, allocation count, string traffic and the environment chain all held constant. 3.88 → 2.84 s. That is what this PR went after.

Change 1 — polymorphic ways

@perry_ic_N widens [8 x i64][12 x i64]: the MRU entry [token, slot, epoch] keeps its exact prior meaning, followed by four (token, slot) ways and a victim counter. The miss handler cascades the shape it evicts into a way instead of discarding it.

The way compares are emitted inside the miss block, below the feedback records and above the call, so a monomorphic site executes the identical instruction sequence it did before — the new work is reached only by a site that was already going to call the runtime, and the typed-feedback counters are unchanged.

Two things had to be right, and a test found each:

  • The MRU token must be evicted from the ways when promoted, or a way permanently duplicates word 0 and a k-shape rotation caches only k−1.
  • The ways must accept keys-POINTER tokens. Shape-ID-only ways need no epoch validation, which makes them look like the safe design — and they are useless: a plain object literal is built through a generated __AnonShape_* constructor, so it has a real class_id and primes a keys pointer. Shipped that way it was a measured 6% regression, the compare sequence running on every miss and never once hitting.

Admitting pointer tokens inherits #6080a — a freed keys-array address can be recycled under a different shape, and a stale way would pointer-match and load the wrong slot silently. So the ways share word 2's epoch snapshot: the emitted predicate requires cache[2] == @PERRY_IC_EPOCH, and pic_prime_get wipes every way whenever it writes a new epoch, dropping the evicted token too. A readable way is always one primed in the epoch word 2 still holds.

3.96 → 3.01 s. js_object_get_field_ic_miss fell from 9.0% of the profile to 1.0%.

Change 2 — arr.length short-circuit

With evalNode fixed, the entire remaining miss cost moved to one place: 1143 of 5241 leaf samples, all from lookup, none of it a polymorphic object read. It was names.length.

The inline cache requires a GC_TYPE_OBJECT receiver by construction (#72), so every dynamic .length misses permanently, by design — and then walks a ladder built for objects, which repeats the registry probes in js_object_get_field_by_name before reaching the array arm. For a variable lookup written the ordinary way, for (i = 0; i < names.length; i++), that single read was 22% of total run time.

js_object_get_field_ic_miss now answers it directly when the receiver's GcHeader says GC_TYPE_ARRAY — a genuine dense array, since buffers, typed arrays, lazy arrays, Sets and Maps all carry distinct obj_types and an Array subclass instance is an ObjectHeader. js_array_length still resolves growth-forwarding stubs, proxies and subclass receivers, and the expression returned is the one the by-name array arm already computes — a short-circuit, not a second implementation.

3.01 → 2.39 s.

Tests

New, and each one fails if the thing it names is undone:

test pins
alternating_shapes_all_become_inline_resolvable a k-shape rotation caches all k (caught the MRU-duplicate bug)
pointer_tokens_do_reach_a_way ways are not narrowed back to ID tokens (the 6% regression)
an_epoch_change_wipes_every_way the #6080a discipline, both halves
monomorphic_site_never_fills_a_way the monomorphic path is untouched
overflow_rotates_without_corrupting_pairs >capacity degrades to a miss, never a wrong slot
array_length_short_circuit_agrees_with_the_full_ladder equivalence with js_object_get_field_by_name_f64 for empty/small/grown arrays, a same-length non-length key, and length on a plain object
pic_cache_words_match_codegen / pic_cache_layout_matches_runtime the paired width constant — a narrower global would be an out-of-bounds store from the runtime
generic_property_get_tries_ways_before_calling_the_miss_handler the way block stays above the call (below it, it silently stops paying for itself)

Measurements

Quiet M1 mini, best-of-5, absolute seconds. Outputs verified byte-identical to node --experimental-strip-types before timing.

program before after
interp.ts 3.96 2.39
b_fib.ts (reduced case) 3.88 2.32

Protected floors — all hold, each also A/B'd against a same-host v0.5.1434 build:

churn churn_alloc push_cls push_num churn_read cycles deeplist tree tree_wide retain retain_wide fib40
after 0.42 0.36 0.35 0.13 0.02 0.19 0.24 1.65 2.11 0.53 1.08 0.39
floor 0.42 0.38 0.36 0.15 0.03 0.20 0.26 1.67 2.15 0.56 1.12 0.41

gc-handoff/apps/iso_miss.ts prints checksum 437840 misses 0 — gated on the miss counter, not the aggregate, because a perf change has previously made interp.ts's total read correct while a silent-wrong-answer GC bug (#7682) was fully intact. Also clean under PERRY_GC_VERIFY_EVACUATION=1 and PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800, which matter here because the ways hold raw heap addresses in a global no GC scanner can see.

What is left

interp.ts is 7.4× Node, not the ~5× scriptc reaches. The remaining profile is js_jsvalue_equals (15.7% — === is still a runtime call per comparison, and an inline fast path only resolves the ~20% of comparisons that are true), the per-object GC layout tables on the allocation path (7.7%, the #7510/#7469 area), write barriers (5.9%), and js_dyn_index_get's registry probes (~3% — the same "route by GcHeader before probing address registries" shape fixed here twice). None of those is a single-mechanism gap the way this one was.

Refs #5094, #6759, #7469.

https://claude.ai/code/session_015JgLM9UWGa6WAMix7CvhQJ

Summary by CodeRabbit

  • Performance

    • Improved property-read performance with expanded polymorphic caching.
    • Added faster handling for array .length reads while preserving proxy, forwarding, and subclass behavior.
    • Improved cache efficiency across multiple object shapes and property metadata formats.
  • Bug Fixes

    • Strengthened cache invalidation across garbage-collection cycles.
    • Improved correctness and recovery when cache entries are rotated or temporarily unavailable.
  • Tests

    • Added regression, correctness, garbage-collection, and memory-safety coverage for property reads and array lengths.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The property-read inline cache now supports four polymorphic ways in a 12-word layout. It validates tokens, slots, and GC epochs. Array length reads use a dedicated miss-handler path. Code generation and tests use the shared cache size.

Changes

Polymorphic property-read cache

Layer / File(s) Summary
Runtime cache layout and priming
crates/perry-runtime/src/object/field_get_set/ic_miss.rs, crates/perry-runtime/src/object/field_get_set.rs
The runtime defines the expanded PicCache. Cache priming supports shape-ID and keys-pointer tokens, deduplication, round-robin eviction, and GC epoch invalidation.
Miss handling and array length resolution
crates/perry-runtime/src/object/field_get_set/ic_miss.rs
The miss path accepts PicCache, primes polymorphic entries, and resolves exact array length reads before general property lookup.
Code generation and polymorphic dispatch
crates/perry-codegen/src/expr/property_get/generic_dispatch.rs, crates/perry-codegen/src/expr/mod.rs, crates/perry-codegen/src/expr/property_get.rs, crates/perry-codegen/src/codegen/*.rs
Generated cache globals use PIC_CACHE_WORDS. Generic property reads check active polymorphic ways before calling the runtime miss handler.
Cache and behavior validation
crates/perry-codegen/src/expr/property_get/tests.rs, crates/perry-runtime/src/object/field_get_set/ic_miss.rs, crates/perry-runtime/src/object/field_get_set.rs, crates/perry-runtime/src/node_submodules/tests.rs, crates/perry-runtime/src/value/dynamic_object.rs, changelog.d/7753-polymorphic-property-read-cache.md, Cargo.toml, CLAUDE.md
Tests cover layout compatibility, polymorphic resolution, pointer tokens, epoch invalidation, slot pairing, overflow rotation, and array-length equivalence. The changelog records benchmark and validation results. The workspace version changes to 0.5.1442.

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

Sequence Diagram(s)

sequenceDiagram
  participant GeneratedPropertyGet
  participant GenericDispatch
  participant PicCache
  participant IC_MissHandler
  participant ArrayLength
  GeneratedPropertyGet->>GenericDispatch: Check MRU and polymorphic ways
  GenericDispatch->>PicCache: Validate epoch, token, and slot
  PicCache-->>GenericDispatch: Return cached field on hit
  GenericDispatch->>IC_MissHandler: Call on cache miss
  IC_MissHandler->>ArrayLength: Resolve exact array length key
  ArrayLength-->>IC_MissHandler: Return array length
  IC_MissHandler->>PicCache: Prime token and slot
Loading

Possibly related PRs

  • PerryTS/perry#6807: Adds related shape and PIC token handling that this change extends to polymorphic cache ways.
  • PerryTS/perry#6808: Introduces related property-read shape, keys-token, and slot-bounds logic.
  • PerryTS/perry#7434: Adds related read-PIC epoch validation that this change preserves across polymorphic ways.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the polymorphic property-read cache, the arr.length optimization, and the measured performance improvement.
Description check ✅ Passed The description thoroughly covers motivation, changes, tests, benchmarks, safety validation, and related issues, despite not using the template headings or checklist.
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.
✨ 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 perf/7-interp-gap

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.

proggeramlug pushed a commit that referenced this pull request Aug 10, 2026
… short-circuit (#7753)

A tree-walking interpreter ran 12.3x Node — three times worse than any
synthetic benchmark in the corpus, and the only program in it that
resembles real software. GC was 1% of the run. The cause was that the
per-site property cache holds exactly ONE entry, so a receiver with more
than one shape misses on essentially every read and pays a full
re-derivation of the receiver kind plus a linear keys scan.

Two changes, one root cause:

* `@perry_ic_N` grows from `[8 x i64]` to `[12 x i64]` — the MRU entry
  keeps its exact prior meaning, followed by four `(token, slot)` ways.
  The miss handler cascades the shape it evicts into a way instead of
  discarding it, and the emitted way compares sit inside the miss block
  above the call, so a monomorphic site's instruction sequence is
  unchanged. Ways accept keys-POINTER tokens (an ID-token-only way set
  never fills for object literals, and shipped that way it was a 6%
  REGRESSION), so they inherit #6080a and share word 2's epoch snapshot:
  a new epoch wipes every way and drops the evicted token.

* The inline cache cannot cache `arr.length` by construction (#72
  requires a GC_TYPE_OBJECT receiver), so every dynamic `.length` misses
  permanently and then walks an object ladder. On a variable lookup
  written `for (i = 0; i < names.length; i++)` that one read was 22% of
  total run time. The miss handler now answers it directly for a
  GC_TYPE_ARRAY receiver, via the same expression the by-name array arm
  already computes.

interp.ts 3.96s -> 2.39s on the quiet M1 mini; all twelve protected
benchmark floors hold; outputs byte-identical to node 26.5.1.

Claude-Session: https://claude.ai/code/session_015JgLM9UWGa6WAMix7CvhQJ

@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

🧹 Nitpick comments (1)
crates/perry-codegen/src/expr/property_get/generic_dispatch.rs (1)

533-543: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting the inline-capacity bound into a shared helper.

Lines 533-542 repeat the exact instruction sequence emitted at lines 433-438: load field_count from safe_obj_handle + 12, zero-extend, clamp to INLINE_SLOT_FLOOR, then compare. The two copies must stay in agreement, because a divergence would let one path load past a receiver's field region while the other rejects it. A small helper that emits the (limit) value and is called from both the MRU hit block and the way block would make that agreement structural.

The duplicated load also sits between two js_typed_feedback_record_* calls, so LLVM cannot always CSE it away.

🤖 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 `@crates/perry-codegen/src/expr/property_get/generic_dispatch.rs` around lines
533 - 543, Extract the duplicated inline-capacity calculation into a shared
helper that emits the clamped field-count limit from safe_obj_handle. Use this
helper in both the MRU hit path around the existing bound logic and the way
block before comparing way_slot, preserving the current INLINE_SLOT_FLOOR
behavior and bounds checks.
🤖 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 `@crates/perry-codegen/src/expr/property_get/tests.rs`:
- Around line 199-205: Update the assertion in the property-get test around emit
so it inspects only global declarations whose names start with `@perry_ic_`, then
verify every such declaration uses PIC_CACHE_WORDS. Ensure the test cannot pass
from an unrelated matching width and fails if any perry_ic cache has a different
width.

---

Nitpick comments:
In `@crates/perry-codegen/src/expr/property_get/generic_dispatch.rs`:
- Around line 533-543: Extract the duplicated inline-capacity calculation into a
shared helper that emits the clamped field-count limit from safe_obj_handle. Use
this helper in both the MRU hit path around the existing bound logic and the way
block before comparing way_slot, preserving the current INLINE_SLOT_FLOOR
behavior and bounds checks.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1f387b7e-a13e-4bfb-ac1b-745099560df9

📥 Commits

Reviewing files that changed from the base of the PR and between 27d5358 and be0a1f3.

📒 Files selected for processing (13)
  • changelog.d/7753-polymorphic-property-read-cache.md
  • crates/perry-codegen/src/codegen/closure.rs
  • crates/perry-codegen/src/codegen/entry.rs
  • crates/perry-codegen/src/codegen/function.rs
  • crates/perry-codegen/src/codegen/method.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/property_get.rs
  • crates/perry-codegen/src/expr/property_get/generic_dispatch.rs
  • crates/perry-codegen/src/expr/property_get/tests.rs
  • crates/perry-runtime/src/node_submodules/tests.rs
  • crates/perry-runtime/src/object/field_get_set.rs
  • crates/perry-runtime/src/object/field_get_set/ic_miss.rs
  • crates/perry-runtime/src/value/dynamic_object.rs

Comment on lines +199 to +205
let ir = emit(false, None);
assert!(
ir.contains(&format!(
"= private global [{PIC_CACHE_WORDS} x i64] zeroinitializer"
)),
"every @perry_ic_N must be emitted at the width the runtime writes:\n{ir}"
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Check every @perry_ic_N declaration.

This assertion searches only for a width fragment. It does not require an @perry_ic_N declaration, and it does not reject another cache declaration with a different width. A matching unrelated global can make the test pass while pic_prime_get writes past a cache global. Filter the declarations by @perry_ic_ and assert that every declaration uses PIC_CACHE_WORDS.

Suggested assertion
-    assert!(
-        ir.contains(&format!(
-            "= private global [{PIC_CACHE_WORDS} x i64] zeroinitializer"
-        )),
-        "every `@perry_ic_N` must be emitted at the width the runtime writes:\n{ir}"
-    );
+    let expected = format!("[{PIC_CACHE_WORDS} x i64] zeroinitializer");
+    let ic_globals: Vec<_> = ir
+        .lines()
+        .filter(|line| line.contains("`@perry_ic_`") && line.contains("global ["))
+        .collect();
+    assert!(!ic_globals.is_empty(), "expected an emitted `@perry_ic_N` global:\n{ir}");
+    assert!(
+        ic_globals.iter().all(|line| line.contains(&expected)),
+        "every `@perry_ic_N` must use {expected}:\n{ir}"
+    );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let ir = emit(false, None);
assert!(
ir.contains(&format!(
"= private global [{PIC_CACHE_WORDS} x i64] zeroinitializer"
)),
"every @perry_ic_N must be emitted at the width the runtime writes:\n{ir}"
);
let ir = emit(false, None);
let expected = format!("[{PIC_CACHE_WORDS} x i64] zeroinitializer");
let ic_globals: Vec<_> = ir
.lines()
.filter(|line| line.contains("`@perry_ic_`") && line.contains("global ["))
.collect();
assert!(!ic_globals.is_empty(), "expected an emitted `@perry_ic_N` global:\n{ir}");
assert!(
ic_globals.iter().all(|line| line.contains(&expected)),
"every `@perry_ic_N` must use {expected}:\n{ir}"
);
🤖 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 `@crates/perry-codegen/src/expr/property_get/tests.rs` around lines 199 - 205,
Update the assertion in the property-get test around emit so it inspects only
global declarations whose names start with `@perry_ic_`, then verify every such
declaration uses PIC_CACHE_WORDS. Ensure the test cannot pass from an unrelated
matching width and fails if any perry_ic cache has a different width.

proggeramlug pushed a commit that referenced this pull request Aug 10, 2026
…amorphic latch (#7753)

The ways are a trade, not a free win, and just past capacity the trade
inverts. Holding everything else fixed and varying only the number of
shapes at one site (bench/poly_read{5,,7}.ts):

  shapes at the site      5 (=capacity)     6        7
  v0.5.1434                   1.98 s     1.97 s   1.98 s
  ways, no off-switch         0.79 s     1.87 s   2.71 s

2.5x faster at capacity, 37% SLOWER one shape past it — four dependent
loads per read that can never hit. So the compares now sit behind their
own branch on a way-state word, and a site that keeps evicting a way by
capacity latches them off, leaving no readable way behind.

Two policy failures, both measured, both now tests:

* Count CONSECUTIVE capacity evictions, not cumulative. A cumulative
  count latches any long-running site that ever sees a stray shape —
  evalNode handles let/fun twice per round, 80 strays across a run — and
  turned the ways off on the very site they were built for (2.39 ->
  3.03 s).

* The latch must not be permanent. "Megamorphic" is a property of a
  program PHASE, not a site: interp.ts's string-building sub-program
  drives evalNode through a different shape set, and a sticky latch let
  that phase kill the site for the rest of the process (3.02 s). It is a
  countdown instead — each miss while latched adds one, and after
  PIC_LATCH_RETRY misses the ways get another chance.

The codegen ordering test also becomes structural rather than textual:
it now asserts pic.ways ends in a branch choosing between the way load
and the miss call, so block emission order cannot make it vacuous.

Claude-Session: https://claude.ai/code/session_015JgLM9UWGa6WAMix7CvhQJ

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/perry-runtime/src/object/field_get_set/ic_miss.rs (1)

329-423: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Register the pointer-token cache with the GC.

token can be a keys-array heap address and Lines 339 and 421 store it in PicCache. The cache is emitted in globals, but this change does not register a mutable root scanner for those pointer-bearing entries. Epoch invalidation only prevents later cache hits. It does not make the pointers visible during collection or relocation.

Register the cache storage with gc_register_mutable_root_scanner, or change the cache representation so it does not retain raw heap pointers. Keep the epoch invalidation behavior after this change. As per coding guidelines, “Any runtime cache or side table holding raw heap pointers must be registered with gc_register_mutable_root_scanner in the same change.”

🤖 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 `@crates/perry-runtime/src/object/field_get_set/ic_miss.rs` around lines 329 -
423, Register the pointer-bearing PicCache storage with
gc_register_mutable_root_scanner so tokens stored by pic_prime_get are visible
and updated during GC collection or relocation. Add the mutable-root scanner for
the emitted global cache representation, while preserving the existing epoch
invalidation and cache reset behavior.

Source: Coding guidelines

🤖 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.

Outside diff comments:
In `@crates/perry-runtime/src/object/field_get_set/ic_miss.rs`:
- Around line 329-423: Register the pointer-bearing PicCache storage with
gc_register_mutable_root_scanner so tokens stored by pic_prime_get are visible
and updated during GC collection or relocation. Add the mutable-root scanner for
the emitted global cache representation, while preserving the existing epoch
invalidation and cache reset behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 71c9e8e4-8368-4122-a046-892114ab6a22

📥 Commits

Reviewing files that changed from the base of the PR and between be0a1f3 and ee2059b.

📒 Files selected for processing (4)
  • changelog.d/7753-polymorphic-property-read-cache.md
  • crates/perry-codegen/src/expr/property_get/generic_dispatch.rs
  • crates/perry-codegen/src/expr/property_get/tests.rs
  • crates/perry-runtime/src/object/field_get_set/ic_miss.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/perry-codegen/src/expr/property_get/tests.rs
  • crates/perry-codegen/src/expr/property_get/generic_dispatch.rs

Ralph Küpper added 3 commits August 10, 2026 11:16
… short-circuit (#7753)

A tree-walking interpreter ran 12.3x Node — three times worse than any
synthetic benchmark in the corpus, and the only program in it that
resembles real software. GC was 1% of the run. The cause was that the
per-site property cache holds exactly ONE entry, so a receiver with more
than one shape misses on essentially every read and pays a full
re-derivation of the receiver kind plus a linear keys scan.

Two changes, one root cause:

* `@perry_ic_N` grows from `[8 x i64]` to `[12 x i64]` — the MRU entry
  keeps its exact prior meaning, followed by four `(token, slot)` ways.
  The miss handler cascades the shape it evicts into a way instead of
  discarding it, and the emitted way compares sit inside the miss block
  above the call, so a monomorphic site's instruction sequence is
  unchanged. Ways accept keys-POINTER tokens (an ID-token-only way set
  never fills for object literals, and shipped that way it was a 6%
  REGRESSION), so they inherit #6080a and share word 2's epoch snapshot:
  a new epoch wipes every way and drops the evicted token.

* The inline cache cannot cache `arr.length` by construction (#72
  requires a GC_TYPE_OBJECT receiver), so every dynamic `.length` misses
  permanently and then walks an object ladder. On a variable lookup
  written `for (i = 0; i < names.length; i++)` that one read was 22% of
  total run time. The miss handler now answers it directly for a
  GC_TYPE_ARRAY receiver, via the same expression the by-name array arm
  already computes.

interp.ts 3.96s -> 2.39s on the quiet M1 mini; all twelve protected
benchmark floors hold; outputs byte-identical to node 26.5.1.

Claude-Session: https://claude.ai/code/session_015JgLM9UWGa6WAMix7CvhQJ
…amorphic latch (#7753)

The ways are a trade, not a free win, and just past capacity the trade
inverts. Holding everything else fixed and varying only the number of
shapes at one site (bench/poly_read{5,,7}.ts):

  shapes at the site      5 (=capacity)     6        7
  v0.5.1434                   1.98 s     1.97 s   1.98 s
  ways, no off-switch         0.79 s     1.87 s   2.71 s

2.5x faster at capacity, 37% SLOWER one shape past it — four dependent
loads per read that can never hit. So the compares now sit behind their
own branch on a way-state word, and a site that keeps evicting a way by
capacity latches them off, leaving no readable way behind.

Two policy failures, both measured, both now tests:

* Count CONSECUTIVE capacity evictions, not cumulative. A cumulative
  count latches any long-running site that ever sees a stray shape —
  evalNode handles let/fun twice per round, 80 strays across a run — and
  turned the ways off on the very site they were built for (2.39 ->
  3.03 s).

* The latch must not be permanent. "Megamorphic" is a property of a
  program PHASE, not a site: interp.ts's string-building sub-program
  drives evalNode through a different shape set, and a sticky latch let
  that phase kill the site for the rest of the process (3.02 s). It is a
  countdown instead — each miss while latched adds one, and after
  PIC_LATCH_RETRY misses the ways get another chance.

The codegen ordering test also becomes structural rather than textual:
it now asserts pic.ways ends in a branch choosing between the way load
and the miss call, so block emission order cannot make it vacuous.

Claude-Session: https://claude.ai/code/session_015JgLM9UWGa6WAMix7CvhQJ
… real numbers (#7753)

PIC_WAY_STATE moves from word 11 (byte 88, a second 64-byte line) to word
3, alongside the MRU entry the miss path has already touched; the ways
start at 4 and now fill the 12-word global exactly, which the layout
tests assert.

Measured, the move made NO difference — poly_read7 is 2.16 s either way
— so the changelog says that rather than claiming the ~9% the cache-line
argument predicted. It is kept for the exact layout.

Also corrects the measurement tables to the final build, run interleaved
against the same-host v0.5.1434 binaries:

  interp.ts        3.96 -> 2.47 s  (12.3x -> 7.7x node)
  poly_read5 (5 shapes, = capacity)  2.04 -> 0.82 s   2.5x
  poly_read7 (7 shapes, > capacity)  2.04 -> 2.16 s   +5.9%, latched

Nine of the twelve protected benchmarks are identical to the same-host
baseline; churn, tree and retain each read one timer tick slower and none
reads faster, which is at the measurement floor but one-sided, so it is
reported as "no regression I can measure" rather than "no regression".

Claude-Session: https://claude.ai/code/session_015JgLM9UWGa6WAMix7CvhQJ

@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 `@changelog.d/7753-polymorphic-property-read-cache.md`:
- Around line 195-199: Update the benchmark summary paragraph in the changelog
to accurately reflect the table: state that seven results are identical,
acknowledge the 0.01-second improvements for push_num and retain_wide, and
remove the incorrect claim that no benchmark reads faster.

In `@crates/perry-codegen/src/expr/property_get/generic_dispatch.rs`:
- Around line 31-34: Update the documentation above PIC_WAY_STATE to describe
negative latch states generally, rather than claiming a sticky -1 state. State
that any negative state temporarily skips way probes and may increment toward
zero, while zero and positive states retain their documented behavior.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b5da77a0-9f64-4d01-aa3e-ef1b9f466b18

📥 Commits

Reviewing files that changed from the base of the PR and between 2c53cd0 and 8d6f322.

📒 Files selected for processing (4)
  • changelog.d/7753-polymorphic-property-read-cache.md
  • crates/perry-codegen/src/expr/property_get/generic_dispatch.rs
  • crates/perry-codegen/src/expr/property_get/tests.rs
  • crates/perry-runtime/src/object/field_get_set/ic_miss.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/perry-codegen/src/expr/property_get/tests.rs
  • crates/perry-runtime/src/object/field_get_set/ic_miss.rs

Comment on lines +195 to +199
Nine of twelve are identical. `churn`, `tree` and `retain` each read one
centisecond — one timer tick — slower, and none reads faster; an earlier
interleaved run at the same load had `churn` 0.43 vs 0.43 and `tree` 1.76 vs
1.75. So this is at or below the measurement floor, but it is one-sided, and the
honest statement is "no regression I can measure", not "no regression".

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Correct the protected-benchmark summary.

The table in Lines 192-193 has seven identical results, not nine. It also shows push_num and retain_wide improving by 0.01 seconds, so the statement that no benchmark reads faster is incorrect. Update this paragraph to match the table.

Suggested wording
-Nine of twelve are identical. `churn`, `tree` and `retain` each read one
-centisecond — one timer tick — slower, and none reads faster;
+Seven of twelve are identical. `churn`, `tree` and `retain` each read one
+centisecond — one timer tick — slower; `push_num` and `retain_wide` each read
+one centisecond faster;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Nine of twelve are identical. `churn`, `tree` and `retain` each read one
centisecond — one timer tick — slower, and none reads faster; an earlier
interleaved run at the same load had `churn` 0.43 vs 0.43 and `tree` 1.76 vs
1.75. So this is at or below the measurement floor, but it is one-sided, and the
honest statement is "no regression I can measure", not "no regression".
Seven of twelve are identical. `churn`, `tree` and `retain` each read one
centisecond — one timer tick — slower; `push_num` and `retain_wide` each read
one centisecond faster; an earlier interleaved run at the same load had `churn`
0.43 vs 0.43 and `tree` 1.76 vs 1.75. So this is at or below the measurement
floor, but it is one-sided, and the honest statement is "no regression I can
measure", not "no regression".
🤖 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 `@changelog.d/7753-polymorphic-property-read-cache.md` around lines 195 - 199,
Update the benchmark summary paragraph in the changelog to accurately reflect
the table: state that seven results are identical, acknowledge the 0.01-second
improvements for push_num and retain_wide, and remove the incorrect claim that
no benchmark reads faster.

Comment on lines +31 to +34
/// Way-state word: `> 0` means at least one way is populated and the compares
/// are worth running; `0` (fresh / epoch-wiped) and `-1` (sticky megamorphic)
/// both skip them. Mirrors the runtime's `PIC_WAY_STATE`.
pub(crate) const PIC_WAY_STATE: usize = 3;

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 | 🟡 Minor | ⚡ Quick win

Correct the PIC_WAY_STATE documentation.

Line 31-34 describes the negative state as -1 and sticky. In crates/perry-runtime/src/object/field_get_set/ic_miss.rs lines 335-429, the runtime stores -PIC_LATCH_RETRY and increments negative states toward zero. Document the condition as any negative latch state that temporarily skips way probes.

🤖 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 `@crates/perry-codegen/src/expr/property_get/generic_dispatch.rs` around lines
31 - 34, Update the documentation above PIC_WAY_STATE to describe negative latch
states generally, rather than claiming a sticky -1 state. State that any
negative state temporarily skips way probes and may increment toward zero, while
zero and positive states retain their documented behavior.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merging as v0.5.1442

I went after the soundness risk first

The ways hold raw heap addresses in a global no GC scanner can see, so the epoch discipline is the whole safety argument. Three checks:

  1. The wipe has a subject. Disabling if !epoch_held && c[PIC_WAY_STATE] >= 0 fails an_epoch_change_wipes_every_way at ic_miss.rs:1334.
  2. The megamorphic-latch interaction is safe, and I checked it rather than reading the comment. Skipping the wipe when PIC_WAY_STATE < 0 looked like a hole — word 2 is set to the new epoch unconditionally, so stale ways surviving a latched period could become readable once the countdown re-arms. It isn't: ic_miss.rs:409-413 zeroes every way before setting c[PIC_WAY_STATE] = -PIC_LATCH_RETRY, so a latched site has nothing to wipe. The invariant holds, but it is a two-place invariant and worth a comment cross-reference at the wipe site.
  3. Under real evacuation pressure, on a 5-shape rotation of my own:
    PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800
      → exit 0, correct answer
      → forced_collections=4218 copying_minors=4218 moved_objects=960059
    PERRY_GC_VERIFY_EVACUATION=1 → exit 0, correct answer
    
    Nearly a million objects relocated with the ways live. That is the run that matters.

All eight named tests present and passing; a 5-shape A/B on my (loaded) box gives 0.65 → 0.51 s.

The diagnosis is the best part, and it includes a correction

A clean arity sweep showed a flat 3.0–3.7× at every arity, and that was read as "no cliff, so not the cause". It is the opposite: flat-and-already-3× from two shapes onward is exactly the signature of a one-entry cache. The sweep could never show a cliff because there is nothing to fall off.

Re-reading a refutation as a confirmation is rare and worth naming. Three hypotheses were killed with measurements first (string-keyed tags 4%, interning +0.01 s, numeric opcodes −0.01 s), and a_flatnode.ts — the same interpreter with every node one shape, holding recursion depth, allocations and string traffic constant — is a genuinely discriminating probe rather than a plausible one.

It also explains the profile lines that made no sense: lookup_typed_array_kind at 3.4% and is_registered_buffer at 1.9% in a program using neither. They weren't a second problem; they were inside the miss handler.

Two design traps, each caught by a test rather than reasoning

  • The MRU token must be evicted from the ways on promotion, or a k-shape rotation caches k−1 forever.
  • Shape-ID-only ways look like the safe design and are useless — a plain object literal goes through a generated __AnonShape_* constructor, so it has a real class_id and primes a keys pointer. Shipped that way it was a measured 6% regression: "the compare sequence running on every miss and never once hitting." A design that is safer and does nothing is the hardest kind to catch by review.

The megamorphic latch is the right answer to the asymmetry it names (+37% on a 7-shape site vs 2.5× on a 5-shape one), and making it a countdown rather than a one-way door is what stops a transiently-wide site from being punished forever.

Change 2

arr.length missing permanently by design — because the IC requires a GC_TYPE_OBJECT receiver (#72) — and costing 22% of total run time for one read in an ordinary for (i = 0; i < names.length; i++) is the kind of thing that hides precisely because it looks like nothing. Routing by GcHeader before probing address registries is the same shape fixed twice here.

Verification posture

Gating iso_miss.ts on the miss counter rather than the aggregate is exactly right, and the stated reason is the one that matters: a perf change previously made interp.ts's total read correct while #7682 was fully intact.

Protected floors all hold, A/B'd against a same-host v0.5.1434 build. The "what is left" section is honest — 7.4× Node, not scriptc's ~5× — and correctly says none of the residue is a single-mechanism gap the way this one was.

Gates 21/21.

@proggeramlug
proggeramlug merged commit 411a96e into main Aug 10, 2026
1 of 16 checks passed
@proggeramlug
proggeramlug deleted the perf/7-interp-gap branch August 10, 2026 09:38
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