Skip to content

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

@proggeramlug

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:

function f(o: any): number {
  let s = 0;
  for (let i = 0; i < N; i++) { o["field_0"] = i; s += o["field_0"]; }
  return s;
}
receiver reached via perry node
module-level const binding ~3 ms ~1
any-typed function parameter 67 ms ~1

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:

js_put_value_set_dyn_ic_miss                    225
_platform_memcmp                                182
object::keys_lookup::keys_find_slot_by_bytes    141
gc::roots::runtime_handles::RuntimeHandleScope::new  131
object::shapes::shape_descriptor_by_id          116
js_put_value_set                                116

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.

Activity

  1. proggeramlug commented on Aug 31, 2026

    @proggeramlug
    ContributorAuthor

    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.

  2. proggeramlug commented on Aug 31, 2026

    @proggeramlug
    ContributorAuthor

    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

    1. Key construction — replacing "field_" + j with a pre-built key array barely moves perry (29 → 28 ms) though it halves node's.
    2. IC associativity / eviction — a site cycling 20 distinct keys is cheaper per op than a site using one literal key; eviction predicts the opposite.
    3. Bracket vs dot, any vs typed — all four combinations are fast when the property is at slot 0.
    4. Receiver kind (my filed claim) — global, local, any param, typed param all 2–4 ms.
    5. 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.

  3. 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
  4. proggeramlug commented on Aug 31, 2026

    @proggeramlug
    ContributorAuthor

    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:

    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         10
    

    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.

  5. proggeramlug commented on Aug 31, 2026

    @proggeramlug
    ContributorAuthor

    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_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:

    1. Runtime — js_put_value_set_ic_miss: replace the idx >= alloc_limit bail with priming an overflow-encoded slot, mirroring dyn_ic_try_store.
    2. 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.
    3. 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.

  6. proggeramlug commented on Aug 31, 2026

    @proggeramlug
    ContributorAuthor

    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_ptr disappear 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.

  7. added a commit that references this issue on Sep 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions