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
Description
Activity
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.
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 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_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
- Key construction — replacing
"field_" + jwith 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,
anyvs typed — all four combinations are fast when the property is at slot 0. - Receiver kind (my filed claim) — global, local,
anyparam, 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.
- Key construction — replacing
- 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. 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:object::shapes::shape_descriptor_by_id 49 js_put_value_set_ic_miss 29 js_object_get_field_by_name 18 js_object_get_field_ic_miss 15 core::str::converts::from_utf8 14 object::prop_plan::read_plan_lookup 12 object::field_set_by_name::fast_paths::try_existing_own_data_overwrite 12 object::shapes::shape_live_inline_slot_count_by_id 10So 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.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_count as usize; if idx as usize >= 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_0placed at index p, accessed 200 000 times, everything else held constant:index of field_0perry 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_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:
- Runtime —
js_put_value_set_ic_miss: replace theidx >= alloc_limitbail with priming an overflow-encoded slot, mirroringdyn_ic_try_store. - Codegen —
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. - The get side too —
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.
- Runtime —
- added a commit that references this issue
on Aug 31, 2026 - added a commit that references this issue
on 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.
- added a commit that references this issue
on Sep 21, 2026
bench_object_propertymeasures 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:
constbindingany-typed function parameterThat 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:
"field_" + jwith a pre-built key array barely moves perry (29 → 28 ms) though it halves node's time (12 → 6).js_string_concat_valueis high in the profile but is not the constraint.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.aon 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_valuesymbol, 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 anyfunction 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.