Skip to content

perf(runtime,codegen): overflow-slot properties prime the constant-key ICs (27 → 5 ms, 5×) - #9302

Merged
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:perf/9287-overflow-ic
Aug 31, 2026
Merged

perf(runtime,codegen): overflow-slot properties prime the constant-key ICs (27 → 5 ms, 5×)#9302
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:perf/9287-overflow-ic

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes #9287.

The defect

A property whose slot index is past the object's inline region could never be IC-cached for a constant-key access. js_put_value_set_ic_miss bailed with idx >= alloc_limit before priming; js_object_get_field_ic_miss broke out of its keys walk at the same condition. Every access paid the full miss ladder — shape-descriptor lookup, read-plan probe, byte-wise keys scan, a handle scope. An env-gated miss-handler probe put 99.9998% of 2.4M declines on that one bail.

A plain {} has two inline slots (INLINE_SLOT_FLOOR), so the cliff sits between index 1 and index 2:

field_0 at index before after node
0 3 ms 3 0
1 3 ms 3 0
2 27 ms 9 0
4 25 ms 6 1
8 27 ms 5 2

(200k write+read pairs, quiet host, checksums node-identical.)

The fix — reuse the dynamic-key IC's overflow model end to end

The dynamic-key IC solved this exact problem long ago: IC_SLOT_OVERFLOW_BIT in the slot word, dyn_ic_try_store on a hit. This PR extends the same encoding to the constant-key ICs:

  • Miss handlers prime idx | IC_SLOT_OVERFLOW_BIT (bounded by the shape's logical key count; descriptor-bearing receivers still excluded; the get side additionally requires the value be readable through overflow_get right now).
  • Emitted MRU hit paths branch on the bit: inline slots keep their exact address arithmetic; encoded slots route through two thin entries — js_put_value_set_ic_overflow_store (literally dyn_ic_try_store: spill store, stable-tombstone hole check, barriers) and js_object_get_field_ic_overflow_load (overflow_get, full-miss fallback on a tombstoned slot). Sites whose property is inline never see the bit, so their code path is unchanged and the branch predicts perfectly.
  • One hazard closed structurally: pic_prime_get cascades the previous MRU entry into the polymorphic ways, whose emitted path computes a raw inline address with no bit test. The cascade now refuses an encoded slot — a polymorphic site rotating overflow shapes re-primes the MRU per shape (pre-perf(codegen,runtime): polymorphic property-read cache + arr.length short-circuit — interp.ts 3.96s → 2.39s #7753 behaviour for that site) instead of ever letting the bit reach the ways.

Negative proofs, stated honestly

Two deliberate breakages did not discriminate, and I am reporting that rather than claiming coverage I don't have: with the get helper's hole check removed, and separately with the cascade guard removed, every fixture I could construct still matched node — a leaked hole is normalized to undefined by every consumer I tried (strict equality, typeof, JSON, array storage), and the wild-load case never materialized observably. Both guards stay: they are cheap, they mirror the dynamic IC's audited path, and "could not construct the failure" is not "cannot fail".

What the 7 added tests do guard, all verified against node before being written: delete-after-prime, accessor-install-after-prime, freeze-after-prime, shape change, delete-then-revive, three-shape rotation at one site, and pointer values through the overflow write under PERRY_GC_HEAP_LIMIT=8 PERRY_GC_FORCE_EVACUATE=1.

What this does not fix, measured

bench_object_property itself stays at ~34 ms vs node 13: its cost is computed-key writes onto fresh objects — shape transitions plus key concat/intern through the dynamic-key IC — a different path from constant-key slot caching. The defect fixed here is the one any helper touching obj.field_17 by name pays on every wide object; the benchmark's residual is follow-up work on #9287.

Validation

  • perry-runtime 2891/0 (single-threaded), perry-codegen 1863/0, -D warnings 0
  • thread-local policy OK, raw-handle debt 963 = baseline, local-binding-type audit OK
  • 7/7 new integration tests (issue_9287_overflow_slot_ic), 8 differential fixtures + 7 adversarial cases node-identical, including under forced evacuation

Summary by CodeRabbit

  • Performance

    • Improved constant-key property access for wide objects, delivering approximately 5× faster access to fields beyond the first two slots.
    • Improved performance for repeated writes to properties stored outside the inline region.
  • Bug Fixes

    • Improved cache invalidation and fallback behavior when object properties are deleted, changed to accessors, frozen, reshaped, or affected by garbage collection.
    • Prevented stale values during optimized property reads and writes.

Ralph Küpper added 2 commits August 31, 2026 17:33
…y ICs (27 -> 5 ms, 5x)

A property whose slot index is past the object's inline region could never
be IC-cached for a constant-key access. `js_put_value_set_ic_miss` bailed
with `idx >= alloc_limit` before priming, and `js_object_get_field_ic_miss`
broke out of its keys walk at the same condition — so every access to such
a property missed both caches forever, paying the full miss ladder (shape
descriptor lookup, read-plan probe, byte-wise keys scan, a handle scope).
A miss-handler probe put 99.9998% of 2.4M declines on that one bail.

A plain `{}` has two inline slots (INLINE_SLOT_FLOOR), so this was the
cliff between index 1 and index 2:

    field_0 at index 0    3 ms          node 0     (200k write+read pairs)
    field_0 at index 1    3 ms          node 0
    field_0 at index 2   27 ms -> 9     node 0
    field_0 at index 4   25 ms -> 6     node 1
    field_0 at index 8   27 ms -> 5     node 2

The fix reuses the dynamic-key IC's overflow model end to end. The miss
handlers prime `idx | IC_SLOT_OVERFLOW_BIT` (bounded by the shape's
logical key count, descriptor-bearing receivers still excluded), and the
emitted MRU hit paths branch on the bit: inline slots keep their exact
address arithmetic, encoded slots route through two new thin entries —
`js_put_value_set_ic_overflow_store`, which is `dyn_ic_try_store` (the
dynamic IC's audited validate-and-store: spill buffer, stable-tombstone
hole check, barriers), and `js_object_get_field_ic_overflow_load`, which
loads through `overflow_get` and falls back to the full miss handler on a
tombstoned slot. The bit never appears at a site whose property is inline,
so their hit path is unchanged and the new branch predicts perfectly.

One hazard is closed structurally rather than checked: `pic_prime_get`
cascades the previous MRU entry into the polymorphic ways, and the emitted
WAY path computes a raw inline address from the slot word with no bit
test. An encoded slot must therefore never enter the ways — the cascade
now refuses one, and a polymorphic site rotating overflow shapes re-primes
the MRU per shape, which is the pre-PerryTS#7753 behaviour for that site.

Honesty about the negative proofs, because two of them did not fire: with
the get helper's hole check deliberately removed, and separately with the
cascade guard removed, every fixture I could construct still matched node
— the leaked hole is normalized to undefined by every consumer tried
(strict equality, typeof, JSON, array storage), and the wild-load case
never materialized observably. Both guards stay: they are cheap, they
mirror the dynamic IC's audited path, and "could not construct the
failure" is not "cannot fail". The seven added tests guard the
invalidation story (delete, accessor install, freeze, shape change,
delete-then-revive, shape rotation, pointer values under forced
evacuation) — all verified against node first, all node-identical
including under PERRY_GC_HEAP_LIMIT=8 + FORCE_EVACUATE.

What this does NOT fix, measured: bench_object_property itself stays at
~34 ms vs node 13. Its cost is computed-key writes onto fresh objects —
shape transitions plus key concat/intern through the dynamic-key IC — a
different path from constant-key slot caching. The isolated defect this
fixes is the one a helper touching `obj.field_17` by name pays on every
wide object.

perry-runtime 2891/0 single-threaded, perry-codegen 1863/0, -D warnings 0,
thread-local policy OK, raw-handle debt 963 = baseline, 7/7 new
integration tests, 8 differential fixtures + 7 adversarial cases
node-identical.

Refs PerryTS#9287.
@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9a8811ff-bb4c-47e8-b2ec-e4dcf449a875

📥 Commits

Reviewing files that changed from the base of the PR and between d20fb4f and a775538.

📒 Files selected for processing (7)
  • changelog.d/9287-overflow-slot-ic.md
  • crates/perry-codegen/src/expr/property_get/generic_dispatch.rs
  • crates/perry-codegen/src/expr/proxy_reflect.rs
  • crates/perry-codegen/src/runtime_decls/objects.rs
  • crates/perry-runtime/src/object/field_get_set/ic_miss.rs
  • crates/perry-runtime/src/proxy/put_value.rs
  • crates/perry/tests/issue_9287_overflow_slot_ic.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 2 remain after this review.


📝 Walkthrough

Walkthrough

Constant-key property get and put inline caches now prime overflow-buffer slots, encode them with IC_SLOT_OVERFLOW_BIT, and route cache hits through overflow-aware runtime helpers. Integration tests cover invalidation across deletion, accessors, freezing, shape changes, and evacuating GC.

Changes

Overflow Slot Inline Caches

Layer / File(s) Summary
Overflow slot encoding and priming
crates/perry-runtime/src/object/field_get_set/ic_miss.rs, crates/perry-runtime/src/proxy/put_value.rs, crates/perry-codegen/src/runtime_decls/objects.rs
Valid overflow slots are encoded with IC_SLOT_OVERFLOW_BIT. Overflow slots are excluded from polymorphic inline ways. Runtime declarations expose overflow get and put helpers.
Overflow get path
crates/perry-runtime/src/object/field_get_set/ic_miss.rs, crates/perry-codegen/src/expr/property_get/generic_dispatch.rs
MRU get hits dispatch encoded overflow slots to js_object_get_field_ic_overflow_load, which reads through overflow_get and falls back on holes or invalid receivers.
Overflow put path
crates/perry-runtime/src/proxy/put_value.rs, crates/perry-codegen/src/expr/proxy_reflect.rs
Static write hits dispatch overflow slots to js_put_value_set_ic_overflow_store. Failed validation follows the existing miss path, and successful writes enter the result merge.
Overflow IC validation coverage
crates/perry/tests/issue_9287_overflow_slot_ic.rs, changelog.d/9287-overflow-slot-ic.md
Integration tests cover hot access and cache invalidation for overflow properties. The changelog records the overflow-slot cache behavior change.

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

Merge Risk: 🔵 Low · up to a7755

The PR adds overflow-slot caching for constant-key property access and preserves the existing fallback and safety checks. It is mergeable with explicit owner awareness that concurrent execution of the same generated module must be thread-confined or otherwise protect cache publication, since an unverified race could return an incorrect property value.

Sequence Diagram(s)

sequenceDiagram
  participant ConstantKeyAccess
  participant PropertyGetPIC
  participant js_object_get_field_ic_overflow_load
  participant overflow_get
  ConstantKeyAccess->>PropertyGetPIC: access constant-key property
  PropertyGetPIC->>js_object_get_field_ic_overflow_load: dispatch encoded overflow slot
  js_object_get_field_ic_overflow_load->>overflow_get: read spill-buffer field
  overflow_get-->>js_object_get_field_ic_overflow_load: value or TAG_HOLE
  js_object_get_field_ic_overflow_load-->>PropertyGetPIC: value or miss fallback
  PropertyGetPIC-->>ConstantKeyAccess: property value
Loading

Suggested reviewers: thehypnoo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the performance fix: constant-key inline caches now support overflow-slot properties. The reported timing improvement is relevant context.
Description check ✅ Passed The description provides a detailed summary, implementation changes, related issue, test coverage, validation results, and known limitations. It does not reproduce the template headings or checklist, …
Linked Issues check ✅ Passed The changes satisfy issue #9287 by caching constant-key accesses for properties beyond the inline slot region. The implementation preserves inline-slot behavior, handles overflow loads and stores, and…
Out of Scope Changes check ✅ Passed The changelog entry, runtime and codegen changes, overflow IC helpers, and integration tests are directly related to issue #9287 and the stated performance objective. No unrelated code changes are ide…
Docstring Coverage ✅ Passed Docstring coverage is 85.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 20 functions across 6 files. (1 skipped: 1 …
Full details: Description check

Explanation

The description provides a detailed summary, implementation changes, related issue, test coverage, validation results, and known limitations. It does not reproduce the template headings or checklist, but the required information is substantially present.

Full details: Linked Issues check

Explanation

The changes satisfy issue #9287 by caching constant-key accesses for properties beyond the inline slot region. The implementation preserves inline-slot behavior, handles overflow loads and stores, and covers mutation and invalidation cases with tests.

Full details: Out of Scope Changes check

Explanation

The changelog entry, runtime and codegen changes, overflow IC helpers, and integration tests are directly related to issue #9287 and the stated performance objective. No unrelated code changes are identified.

Full details: Docstring Coverage

Explanation

Docstring coverage is 85.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 20 functions across 6 files. (1 skipped: 1 unsupported.)

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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
proggeramlug merged commit 730e0a9 into PerryTS:main Aug 31, 2026
28 of 29 checks passed
proggeramlug added a commit that referenced this pull request Aug 31, 2026
* codegen: a float accumulator over masked reads earns the dense range clone

17_loop_data_dependent: 475 ms -> 219 ms against node's 220 ms on an idle
Mac mini -- parity, from 2.16x. Sums bit-identical across 100M data-dependent
float recurrence steps.

    sum = sum * x[i & 63] + x[(i * 7) & 63]   // rejected
    sum = sum * x[i & 63]                     // admitted

The discriminator was the accumulator's static numeric proof. `+` can be
concatenation, so the dense tier's per-statement proof demands both operands
numeric; a reassigned accumulator has no such proof, because its own writes
read the guarded array, whose element proof only exists once the guard has
run. A chicken-and-egg that `*` never faces -- multiplication needs only the
weaker inert fact. Confirmed by instrumenting the two conjuncts of the dense
LocalSet arm: the failing one is the proof, on exactly the fixtures whose
accumulator writes contain a plain-array read.

The matcher now peels the accumulator: when the proof fails on the LocalSet
target of a self-accumulating write, it retries with the target treated as
numeric BY CONTRACT, records it pending, and then verifies every pending
local with the same collector the lowering runs
(`collect_numeric_accumulators`), rejecting the whole dense match with its
own named trace reasons (`accumulator_needs_single_array`,
`accumulator_not_provable`) if the two disagree -- so the clone can never
contain a dynamic `+` under facts that forbid one.

The contract is enforced at run time twice over: the clone's entry emits a
genuine-double tag check on the accumulator, and the dense entry guard
validates the whole masked window hole-free. A string-seeded accumulator and
a string element both route to the slow copy and produce node's
concatenation, verified under PERRY_GC_FORCE_EVACUATE.

Supporting changes:

* `accumulator_rhs_is_numeric` accepts masked static-window reads of the
  tracked array (`masked_reads_validated`), sound because the dense guard
  validated the window union hole-free. Fixing that exposed a match-arm
  reachability bug: `_ if offset_reads_inlined` was a guarded catch-all, so
  ANY arm placed after it was unreachable whenever the flag was set -- the
  first version of this change sat exactly there and verified as a no-op.
  The two tests are now one combined catch-all.
* `emit_range_loop_accumulator_admission` admits a masked-only single array
  (counter-bearing arrays keep priority; multiple arrays still decline).
* `MaskedWindowArrayFact` carries `numeric_accumulators` so `is_numeric_expr`
  sees admitted accumulators while the clone lowers -- without this the add
  inside the clone would stay dynamic, which is a collecting call under facts
  that assume none (the #9259 cascade shape). Mirrors the string-window
  fact's field (#9160).

perry-codegen lib 1378/0; packed-loop integration suite 59/0 across 11 files;
3 new regression tests (admission + node-identical result, string-seeded
accumulator, string element), each under forced evacuation.

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

* refactor(runtime): split ic_miss.rs and array/tests.rs under the file cap

#9302 and #9307 each tipped a file that was already within ~35 lines of the
2000-line gate. Extracts the C3C PIC test module and the Array.prototype
method-discriminator tests into sibling files; no behaviour change.

* fix(codegen): drop the now-dead catch-all after the combined accumulator arm

#9303's combined `_ =>` arm made the trailing `_ => false` unreachable, which
is a `-D warnings` failure. Removing it is #9308's fix, which the combined arm
needs to be complete.

* style: rustfmt

* chore: changelog fragment for the train13 follow-up

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
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.

Property access past the FIRST slot misses its cache: 3 ms vs 28 ms for the same object (bench_object_property, 2.6x)

1 participant