You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Property access past the FIRST slot misses its cache: 3 ms vs 28 ms for the same object (bench_object_property, 2.6x) #9287
bench_object_property measures 34 ms against node's 13 (2.6×) on the quiet host. Root-caused, with controls, to a single variable: whether the receiver arrives as a function parameter or a module-level binding.
The isolation
Same object, same constant key, same bracket syntax, 200 000 write+read pairs. Every write is read back and the checksums match node exactly, so nothing is dead-store eliminated:
That is ~170 ns per operation against node's ~5 ns, for a constant-key access on a stable object.
What it is not — three hypotheses I tested and refuted
Recording these so nobody re-derives them:
Not key construction. Replacing "field_" + j with a pre-built key array barely moves perry (29 → 28 ms) though it halves node's time (12 → 6). js_string_concat_value is high in the profile but is not the constraint.
Not IC associativity. A site cycling 20 distinct keys is cheaper per operation than a site using one literal key. A direct-mapped-eviction story predicts the opposite.
Not bracket-vs-dot, and not any-vs-typed. With a module-level receiver, all four combinations (any/typed × dot/bracket) run at 2–4 ms.
The control that matters: p.a = i; s += p.a on a typed object is 1 ms — parity with node. Perry's ordinary property store is fine.
Profile of the slow shape
sample, self-time ("sort by top of stack"), 200k×20 writes:
So every access misses the dynamic IC and falls back to a byte-wise linear key search (keys_find_slot_by_bytes + memcmp), plus a handle scope per operation.
One caveat on reading that profile: the top frame by raw count is an async_hooks/blob_reader_promise_value symbol, in a program with no async and no blobs. I believe that is identical-code-folding collapsing several bodies onto one symbol, so I excluded it rather than chase it. Worth knowing if anyone profiles this area — it appeared in both of my samples.
Why this shape matters beyond the benchmark
An any-typed (or untyped-JS) parameter is the normal way a helper receives an object, so this is not an exotic path — it is what any function set(o, k, v) helper pays. The benchmark's 2.6× is a diluted view of a 67× per-operation gap, because the benchmark also spends time on allocation and shape transitions that perry does competitively.
Not claimed by me; I stopped at the isolation. The obvious direction is making a constant-key access on a parameter receiver reach the same slot-caching path a module-level receiver already gets, but I have not looked at whether that is an IC-shape problem or a missing specialization, and the difference decides the fix.
Taking this. Claiming explicitly so it doesn't collide — ownership went back and forth twice today on adjacent work.
Scope I'll be in: the dynamic property-set/get IC (js_put_value_set_dyn_ic / _miss), object::keys_lookup, and whatever decides that a module-level receiver gets a slot-cached path while an any parameter does not.
Not in: the packed-loop matchers or the range tier (@ECS1's), the array-store tier, or the accumulator admission path in #9279.
Correcting my own root cause. It is not the parameter — it is the property's SLOT POSITION. Everything above about any-typed parameters is wrong.
The actual variable
Same object size, same key, same site, same call shape. The only difference is whether the accessed property was added first or last:
field_0 at
perry
node
slot 0, object has 5 props
3 ms
0
slot 0, object has 21 props
3 ms
2
slot 4 (added last, 5 props)
28 ms
2
slot 20 (added last, 21 props)
32 ms
3
Perry has a fast path for the first slot only. Any property at a later slot pays ~10×, and then grows slowly with object size (29 → 38 ms from 4 to 32 properties) — the shape of a linear scan, which matches keys_find_slot_by_bytes + _platform_memcmp dominating the profile.
Node is flat across all of it.
This explains the benchmark directly: bench_object_property writes 20 fields per object, and 19 of them are past slot 0.
What I got wrong, and how
I concluded "access through an any parameter" because my slow fixture used one and my fast fixture did not. It also differed in two other ways I did not control — the objects had 20 properties instead of 1, and the accessed property was added last. Re-tested with the receiver isolated: a module-global receiver, a local, an any parameter, a typed parameter and dot-vs-bracket all run at 2–4 ms. Parameter-ness costs nothing.
Hypotheses now tested and refuted — all five recorded so nobody repeats them
Key construction — replacing "field_" + j with a pre-built key array barely moves perry (29 → 28 ms) though it halves node's.
IC associativity / eviction — a site cycling 20 distinct keys is cheaper per op than a site using one literal key; eviction predicts the opposite.
Bracket vs dot, any vs typed — all four combinations are fast when the property is at slot 0.
Receiver kind (my filed claim) — global, local, any param, typed param all 2–4 ms.
Polymorphic site — 1/2/3 shapes at one site cost 3/6/8 ms, i.e. flat per operation.
The control that anchors all of it: a first-slot property is 3 ms, which is node-competitive. Perry's object model is fine; the slot lookup past the first entry is not.
I am still on this. The next question is whether the first-slot fast path is a deliberate single-entry cache that should be widened, or whether later slots are missing a cached-offset path that slot 0 gets for an unrelated reason — that decides whether this is a small change or a real one.
changed the title [-]Property access through an `any` parameter misses its IC on every op: 67 ms vs node 1 ms (bench_object_property, 2.6x)[/-][+]Property access past the FIRST slot misses its cache: 3 ms vs 28 ms for the same object (bench_object_property, 2.6x)[/+]on Aug 31, 2026
Correct profile for the constant-key case, plus one useful negative. The earlier profile in this issue was of a different fixture and should not be used.
The negative: the dynamic-key IC is not involved
I instrumented dyn_ic_try_store (proxy/put_value.rs) with env-gated counters at all five rejection points plus the way-lookup outcome, and ran the slow fixture. It never fired — not one call. So js_put_value_set_dyn_ic is not on the constant-key path at all.
The js_put_value_set_dyn_ic_miss frame I quoted earlier came from profiling bench_object_property itself, which uses computed keys ("field_" + j). Carrying that profile onto a constant-key fixture was my error; the two shapes take different paths.
The actual profile
o["field_0"] = i; s += o["field_0"] on an object where field_0 is not the first slot, 2M iterations, self-time:
So both the constant-key set IC and the get IC miss on every access, and a shape-descriptor lookup dominates. This is js_put_value_set_ic / js_object_get_field_ic, not the dynamic-key IC.
Both ICs have their hit check emitted inline by codegen (only the _miss halves exist in the runtime), and that inline check handles arbitrary slots — selected_slot / slot_offset / fields_base, with an overflow-bit path. So the codegen side is not obviously limited to the first slot, and the miss is not explained by the emitted check refusing later slots.
Where that leaves it
The isolation stands and is unchanged: slot 0 is 3 ms, any later slot is 28–32 ms, same object, same key, same emitted code. What is not yet established is why the constant-key ICs miss for a non-first slot when the receiver is stable and the site is monomorphic.
I have not resolved that and am not going to guess at it — this issue has already cost me five refuted hypotheses, and each one looked plausible before it was measured. The next concrete step is to instrument js_put_value_set_ic_miss the same way I did the dynamic one, which distinguishes "the cache was never primed" from "primed and invalidated" from "primed but the hit check rejects". That is one build and it decides the fix.
Root cause confirmed, with the boundary measured. A property in OVERFLOW storage can never be IC-cached for a constant-key access.
The line
js_put_value_set_ic_miss (proxy/put_value.rs), just before it primes the cache:
let alloc_limit = shape.live_inline_slot_countasusize;if idx asusize >= alloc_limit {return result;// <- cache is never primed}
The miss handler completes the store correctly and then declines to prime whenever the property's index is past the object's inline slot count. So every subsequent access to that property misses again, forever.
The boundary, measured
field_0 placed at index p, accessed 200 000 times, everything else held constant:
index of field_0
perry
node
0
3 ms
0
1
3 ms
0
2
27 ms
0
3
25
1
4
25
1
6
26
2
8
27
2
The cliff is exactly at index 2, and INLINE_SLOT_FLOOR = 2 (object/mod.rs:49). A plain {} gets two inline slots; index 0–1 prime and are node-competitive, index ≥2 is overflow and never primes. Flat thereafter, with a slow creep as the object grows — the signature of the fallback scan, not of the slot depth.
That is the whole of bench_object_property: 20 fields per object, 18 of them in overflow.
The fix, and why it is not a one-liner
The dynamic-key IC already solves this exact problem. dyn_ic_try_store encodes overflow slots with IC_SLOT_OVERFLOW_BIT and loads from the overflow array on a hit. So there is a working model in-tree for the encoding.
What is missing is the other half: the constant-key IC's hit check is emitted inline by codegen (expr/proxy_reflect.rs — only the _miss halves live in the runtime), and that emitted check has no overflow path at all. Priming an overflow slot without teaching the inline check to honour the bit would be a miscompile, not a slow path.
So the change is:
Runtime — js_put_value_set_ic_miss: replace the idx >= alloc_limit bail with priming an overflow-encoded slot, mirroring dyn_ic_try_store.
Codegen — proxy_reflect.rs: teach the inline hit check to test the overflow bit and take fields_base from the overflow array. The dynamic IC's equivalent shows the shape.
The get side too — js_object_get_field_ic_miss shows the same miss in the profile and almost certainly has the same guard.
Steps 1 and 2 must land together. I am working on it.
Increment 2 result: correct, fired, and inert — dropped. This closes the runtime-shave phase of this issue with a measured conclusion.
The scope-free lane for already-interned keys (validation ladder + transition lookup + shape install with no handle scope, minimal scope only around a growing spill store) was implemented and verified: all correctness fixtures node-identical including intern-then-miss under PERRY_GC_HEAP_LIMIT=8 PERRY_GC_FORCE_EVACUATE=1, and the profile proves the lane engages — RuntimeHandleScope::{new,push} and root_string_ptrdisappear entirely from top-of-stack, and the fast-path's self time drops 56 → 14 samples.
Wall time: unchanged (prebuilt-keys 39 → 39-40 ms, benchmark ~33 → ~33). Third instance on this path of sampled self-time overstating a cluster ~4× against its measured wall share. The removed instructions were real but cheap — TLS bookkeeping that pipelined under the surrounding loads.
Not shipping it: an inert-on-wall change that duplicates a 100-line validation ladder is maintenance cost without benefit. #9329 (increment 1, measured ~10%) stands alone.
Where this leaves the shape, with numbers
perry 39 ms vs node 12 on 400k transition writes ≈ 68 ns/write of ladder spread evenly across the remaining profile — install_cached_object_shape_transition (19 samples), the dyn-IC entry pair (33), layout_note_slot (16), shape_object_kind_by_id (13), stamp_object_shape_id (13), transition_cache_lookup (12), the store + barrier. Every function is now individually thin; the count of runtime calls per write is the cost.
The design that would reach parity (unclaimed)
An emitted-code transition IC, per site: cache (prev_shape_token, key_bits) → (target_shape_id, next_keys, slot) in the site's dynamic-IC ways (a transition-flavored entry alongside the existing overwrite entries), with the hit path inline: token+key compare, shape-id store, keys-pointer store + barrier, slot store, ~20 instructions, zero calls. The global transition cache already owns the GC rooting story for next_keys; the site cache can hold an index into it rather than the pointer, dodging per-site rooting entirely. Invalidation rides the existing rule — ShapeIds are never reused, so a stale entry misses on the token compare.
That is a codegen + cache-ABI project of the same scale as the packed-loop tiers, and it is what V8-class engines do. Until someone takes it, this shape sits at ~2.6× with the ladder fully shaved.
Activity
proggeramlug commentedon Aug 31, 2026
Taking this. Claiming explicitly so it doesn't collide — ownership went back and forth twice today on adjacent work.
Scope I'll be in: the dynamic property-set/get IC (
js_put_value_set_dyn_ic/_miss),object::keys_lookup, and whatever decides that a module-level receiver gets a slot-cached path while ananyparameter does not.Not in: the packed-loop matchers or the range tier (@ECS1's), the array-store tier, or the accumulator admission path in #9279.
proggeramlug commentedon Aug 31, 2026
Correcting my own root cause. It is not the parameter — it is the property's SLOT POSITION. Everything above about
any-typed parameters is wrong.The actual variable
Same object size, same key, same site, same call shape. The only difference is whether the accessed property was added first or last:
field_0atPerry has a fast path for the first slot only. Any property at a later slot pays ~10×, and then grows slowly with object size (29 → 38 ms from 4 to 32 properties) — the shape of a linear scan, which matches
keys_find_slot_by_bytes+_platform_memcmpdominating the profile.Node is flat across all of it.
This explains the benchmark directly:
bench_object_propertywrites 20 fields per object, and 19 of them are past slot 0.What I got wrong, and how
I concluded "access through an
anyparameter" because my slow fixture used one and my fast fixture did not. It also differed in two other ways I did not control — the objects had 20 properties instead of 1, and the accessed property was added last. Re-tested with the receiver isolated: a module-global receiver, a local, ananyparameter, a typed parameter and dot-vs-bracket all run at 2–4 ms. Parameter-ness costs nothing.Hypotheses now tested and refuted — all five recorded so nobody repeats them
"field_" + jwith a pre-built key array barely moves perry (29 → 28 ms) though it halves node's.anyvs typed — all four combinations are fast when the property is at slot 0.anyparam, typed param all 2–4 ms.The control that anchors all of it: a first-slot property is 3 ms, which is node-competitive. Perry's object model is fine; the slot lookup past the first entry is not.
I am still on this. The next question is whether the first-slot fast path is a deliberate single-entry cache that should be widened, or whether later slots are missing a cached-offset path that slot 0 gets for an unrelated reason — that decides whether this is a small change or a real one.
[-]Property access through an `any` parameter misses its IC on every op: 67 ms vs node 1 ms (bench_object_property, 2.6x)[/-][+]Property access past the FIRST slot misses its cache: 3 ms vs 28 ms for the same object (bench_object_property, 2.6x)[/+]proggeramlug commentedon Aug 31, 2026
Correct profile for the constant-key case, plus one useful negative. The earlier profile in this issue was of a different fixture and should not be used.
The negative: the dynamic-key IC is not involved
I instrumented
dyn_ic_try_store(proxy/put_value.rs) with env-gated counters at all five rejection points plus the way-lookup outcome, and ran the slow fixture. It never fired — not one call. Sojs_put_value_set_dyn_icis not on the constant-key path at all.The
js_put_value_set_dyn_ic_missframe I quoted earlier came from profilingbench_object_propertyitself, which uses computed keys ("field_" + j). Carrying that profile onto a constant-key fixture was my error; the two shapes take different paths.The actual profile
o["field_0"] = i; s += o["field_0"]on an object wherefield_0is not the first slot, 2M iterations, self-time:So both the constant-key set IC and the get IC miss on every access, and a shape-descriptor lookup dominates. This is
js_put_value_set_ic/js_object_get_field_ic, not the dynamic-key IC.Both ICs have their hit check emitted inline by codegen (only the
_misshalves exist in the runtime), and that inline check handles arbitrary slots —selected_slot/slot_offset/fields_base, with an overflow-bit path. So the codegen side is not obviously limited to the first slot, and the miss is not explained by the emitted check refusing later slots.Where that leaves it
The isolation stands and is unchanged: slot 0 is 3 ms, any later slot is 28–32 ms, same object, same key, same emitted code. What is not yet established is why the constant-key ICs miss for a non-first slot when the receiver is stable and the site is monomorphic.
I have not resolved that and am not going to guess at it — this issue has already cost me five refuted hypotheses, and each one looked plausible before it was measured. The next concrete step is to instrument
js_put_value_set_ic_missthe same way I did the dynamic one, which distinguishes "the cache was never primed" from "primed and invalidated" from "primed but the hit check rejects". That is one build and it decides the fix.proggeramlug commentedon Aug 31, 2026
Root cause confirmed, with the boundary measured. A property in OVERFLOW storage can never be IC-cached for a constant-key access.
The line
js_put_value_set_ic_miss(proxy/put_value.rs), just before it primes the cache:The miss handler completes the store correctly and then declines to prime whenever the property's index is past the object's inline slot count. So every subsequent access to that property misses again, forever.
The boundary, measured
field_0placed at index p, accessed 200 000 times, everything else held constant:field_0The cliff is exactly at index 2, and
INLINE_SLOT_FLOOR = 2(object/mod.rs:49). A plain{}gets two inline slots; index 0–1 prime and are node-competitive, index ≥2 is overflow and never primes. Flat thereafter, with a slow creep as the object grows — the signature of the fallback scan, not of the slot depth.That is the whole of
bench_object_property: 20 fields per object, 18 of them in overflow.The fix, and why it is not a one-liner
The dynamic-key IC already solves this exact problem.
dyn_ic_try_storeencodes overflow slots withIC_SLOT_OVERFLOW_BITand loads from the overflow array on a hit. So there is a working model in-tree for the encoding.What is missing is the other half: the constant-key IC's hit check is emitted inline by codegen (
expr/proxy_reflect.rs— only the_misshalves live in the runtime), and that emitted check has no overflow path at all. Priming an overflow slot without teaching the inline check to honour the bit would be a miscompile, not a slow path.So the change is:
js_put_value_set_ic_miss: replace theidx >= alloc_limitbail with priming an overflow-encoded slot, mirroringdyn_ic_try_store.proxy_reflect.rs: teach the inline hit check to test the overflow bit and takefields_basefrom the overflow array. The dynamic IC's equivalent shows the shape.js_object_get_field_ic_missshows the same miss in the profile and almost certainly has the same guard.Steps 1 and 2 must land together. I am working on it.
docs(changelog): fragment for PerryTS#9287
perf(runtime,codegen): overflow-slot properties prime the constant-ke…
proggeramlug commentedon Aug 31, 2026
Increment 2 result: correct, fired, and inert — dropped. This closes the runtime-shave phase of this issue with a measured conclusion.
The scope-free lane for already-interned keys (validation ladder + transition lookup + shape install with no handle scope, minimal scope only around a growing spill store) was implemented and verified: all correctness fixtures node-identical including intern-then-miss under
PERRY_GC_HEAP_LIMIT=8 PERRY_GC_FORCE_EVACUATE=1, and the profile proves the lane engages —RuntimeHandleScope::{new,push}androot_string_ptrdisappear entirely from top-of-stack, and the fast-path's self time drops 56 → 14 samples.Wall time: unchanged (prebuilt-keys 39 → 39-40 ms, benchmark ~33 → ~33). Third instance on this path of sampled self-time overstating a cluster ~4× against its measured wall share. The removed instructions were real but cheap — TLS bookkeeping that pipelined under the surrounding loads.
Not shipping it: an inert-on-wall change that duplicates a 100-line validation ladder is maintenance cost without benefit. #9329 (increment 1, measured ~10%) stands alone.
Where this leaves the shape, with numbers
perry 39 ms vs node 12 on 400k transition writes ≈ 68 ns/write of ladder spread evenly across the remaining profile —
install_cached_object_shape_transition(19 samples), the dyn-IC entry pair (33),layout_note_slot(16),shape_object_kind_by_id(13),stamp_object_shape_id(13),transition_cache_lookup(12), the store + barrier. Every function is now individually thin; the count of runtime calls per write is the cost.The design that would reach parity (unclaimed)
An emitted-code transition IC, per site: cache
(prev_shape_token, key_bits) → (target_shape_id, next_keys, slot)in the site's dynamic-IC ways (a transition-flavored entry alongside the existing overwrite entries), with the hit path inline: token+key compare, shape-id store, keys-pointer store + barrier, slot store, ~20 instructions, zero calls. The global transition cache already owns the GC rooting story fornext_keys; the site cache can hold an index into it rather than the pointer, dodging per-site rooting entirely. Invalidation rides the existing rule — ShapeIds are never reused, so a stale entry misses on the token compare.That is a codegen + cache-ABI project of the same scale as the packed-loop tiers, and it is what V8-class engines do. Until someone takes it, this shape sits at ~2.6× with the ladder fully shaved.
perf(runtime): try the transition fast path before rooting in the dyn…
fix: keep main green after #9329 (#9336)
perf(#10863): let an overflow-key read site reach the megamorphic latch