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.

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