Repository navigation
A property read that MISSES costs 7,300-16,400 instructions against node's 15 (500-1000x); every if (obj.maybeThere) pays it #10849
Description
A property read that misses — the key is not on the receiver and not anywhere on its prototype chain, so the answer is undefined — costs perry between 7,300 and 16,400 instructions. node and bun charge about 15, the same as a hit.
This is not an exotic path. It is what every if (obj.maybeThere), every opts.timeout ?? 30, every typeof x.then === "function" and every feature-detect in a dependency pays, on every iteration.
Found while attributing the inherited-read cache (#10834 / #10842); it is not that cache's doing (the numbers below are the same with PERRY_INHERITED_IC=0) and nobody is working on it, so filing rather than absorbing it.
Measurement
perf stat -x, -e instructions:u, min of 3, per-iteration cost fitted between N=200 k and N=5 M, perry flat across both ranges, output identical to node on every row. perry v0.5.1623 + PR #10846, node v26.8.1, /root/.bun/bin/bun, perrymaster (x86-64).
| fixture | receiver | perry | node | bun | perry / node |
|---|---|---|---|---|---|
hit_plain |
{x:0,a:1}, reads .a |
116.0 | 11.9 | 10.0 | 10x |
miss_plain |
{x:0,a:1}, reads .zzz |
7351.7 | 14.7 | 14.1 | 500x |
miss_class |
new K(), reads .zzz |
8669.7 | 15.2 | 12.9 | 570x |
miss_create |
Object.create(P), reads .zzz |
16388.4 | 15.4 | 14.9 | 1064x |
The shape of it: a HIT on the same object is 116 instructions, so the miss costs 63x to 141x what the hit costs, and the whole of that is the runtime discovering that the answer is undefined. An Object.create receiver costs more than twice a plain one, which says the chain walk is re-run per read on the miss path exactly as it was on the inherited-hit path before #10834.
Repro
// miss_plain.js — .ts twin is identical (no TypeScript syntax)
const N = Number(process.argv[2]);
const R = {x:0, a:1};
function run(n){ let h=0; for(let i=0;i<n;i++){ R.x=i; if(R.zzz===undefined) h++; } return h+R.x; }
console.log(run(N));R.x = i keeps the body loop-variant so node cannot delete it (node's slope is 14.7, not 0). Swap the receiver for Object.create({a:1}) or new K() for the other two rows, and R.zzz for R.a for the hit baseline.
Fixtures and the measurement harness are on perrymaster at /root/miss/ (miss_setup.sh, miss_measure.sh).
Where it goes
Not attributed yet — that is the first piece of work. Two things are already known from callgrind on the neighbouring path:
- a miss runs the full own-key search, then the whole generic getter, then the class-surface fallback ladder (vtable, declaration prototype,
Object.prototype), and nothing memoizes the verdict; - the verdict is memoizable. "This (ShapeId, interned key) pair resolves to nothing" is a function of the shape and the chain, and perf(runtime): one validity word replaces the per-hop chain walk, and one flags word replaces two registry probes #10842 has just landed a global validity word that says when a chain has changed — a negative entry for a miss is the same object as the negative entry that PR already records for a refused inherited read.
Suggested shape, for whoever picks it up: a (ShapeId, key pointer) -> not-present negative cache keyed on object::proto_validity::proto_validity(), primed at the one place that already knows the search failed. #10834's own experience is the warning worth inheriting: recording only the POSITIVE answers there made an accessor read 424 instructions per read slower than no cache at all, because the walk ran and was discarded every time.
Not taking this — flagging it so the measurement is not lost.
Activity
proggeramlug commentedon Sep 21, 2026
Duplicate of #10495, which reported the same thing from the package performance audit. The re-measurement on v0.5.1623 and the note that #10842's validity word now makes the verdict cheaply memoizable are posted there instead.