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