You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
perf: property access is 13x-112x node warm — two static reads cost 162 instructions against node's 12, and a const-key read costs 7.6x the same read spelled statically #10761
Property access — the most executed operation in JavaScript — costs perry 13× to 112× what node costs, measured with both JITs warm.
Fitted at two widely separated ranges so warmup contamination is visible. perry's figures are identical at both ranges (it is AOT and has no warmup); node's and bun's fall sharply, which is why a small-range fit understates this badly.
operation
fit range
perry
node
bun
O.a + O.b — two static reads
20k→200k
162
53
21
500k→5M
162
12
25
O[K] + O[J] — two const-key reads
20k→200k
1236
41
84
500k→5M
1236
11
15
O.v = k; h += O.v — write then read
20k→200k
182
74
71
500k→5M
183
12
13
String(k % 1000) — number to string
20k→200k
433
75
78
500k→5M
435
114
32
The gaps, warm
operation
perry vs best of node/bun
two static property reads
13.5× (162 vs 12)
two const-key property reads
112× (1,236 vs 11)
property write + read
15× (183 vs 12)
String(n)
13.6× (435 vs 32)
A single static property read costs perry ~81 instructions. node does it in ~6.
Why the const-key row is the place to start
O[K] with a hoisted const K = "a" costs 1,236 for two reads — 112× node — while the identical reads spelled O.a cost 162. That is a 7.6× penalty for the spelling alone, on a pure hit path with no missing keys, no prototype walk, and no invalidation obligations whatsoever.
It is the same class of defect as #10743 (a[i] += 1 costing 11.5× a[i] = a[i] + 1): a fast path exists and a common spelling cannot reach it. #10743 turned out to be tractable — the fix was in the matcher, not the lowering — and it took that row from 277 to 25.5.
Fit per-op costs at two widely separated ranges and check the value is stable. This campaign published three wrong tables before adopting that: one comparing totals (crediting perry with node's ~95M startup), one fitting deltas inside node's warmup, and one using a benchmark shape real code does not use. perry's flatness across ranges is the detector — if perry's number does not move and the other runtime's moves 4×, the other runtime is not yet at steady state.
The 5× is redundant guard work, not lookup cost — measured
Reading the same property three times in a row costs perry 52 instructions per read. node costs 0.8.
Receiver passed as a parameter so nothing can constant-fold it:
body
perry
node
perry marginal
h += O.a
64.00
12.88
first read 55
h += O.a; h += O.b
113.00
13.11
+49
h += O.a; h += O.a; h += O.a
165.00
14.41
+52, same key
perry shares nothing between accesses — not across a store, not across a different property, not even across the identical read repeated in straight-line code. prop_cse.rs handles diverging guard chains, not straight-line repetition.
Breaking down the mutated row (O.a = k; h += O.a), 110 instructions total:
Ir
what
25
store guard
21
js_array_numeric_value_to_raw_f64
1
the store
5
barrier
23
read guard — the byte-identical sequence, same receiver, same register, ~100 bytes later
8
dynamic add
5
barrier
3
poll
9
tail
Three removals need no cache, no invalidation contract and no new guard: the duplicate proof (21%), normalising a value the guard four instructions earlier already proved is a non-NaN double (19%), and two barrier probes (9%).
Why the read_stub hoist is not the answer
read_stub is real and its design number is right — measured at 16 instructions per probe, hitting, on a declared-class receiver where try_data_get_bytes declines. Hoisted, the floor is ~230 per read, a 3.0× win — and still 1.55× node.
But callgrind call counts settle it: the invariant row makes zero runtime calls, and the mutated row's only callee is the store's value normaliser. The stub lives inside js_object_get_field_by_name, and none of the four rows in this issue ever reach it. The mutated row would be unchanged to the instruction.
Most of try_data_get_bytes's guards turn out to be free or cheap to close for the stub's receivers; the one genuine blocker is arena::classify_heap_generation != Unknown, the malloc-backed-exotic exclusion. Two gaps found during that audit are filed separately as #10768 — they are live in current code regardless of any hoist.
Its §2a used a const O declared inside a function as the "local receiver" control and concluded a ~14-instruction root-reload cost. That is wrong: perry constant-folds those reads, so the loop body has no guard chain and no load at all, only a vaddsd of a literal. There is no root-reload effect — module-global (57, +45 marginal) and parameter (64, +50) behave the same, with the receiver in a register either way.
The #10761 table, the const-key attribution and PR #10766's gates are unaffected.
Re-measured on v0.5.1617 (merge train 238) — the const-key row is 112× → 10.4×, and one number here is now badly stale
Everything below is fitted warm (500k→5M), perf stat -e instructions:u, min of 3, on perrymaster. perry is flat at both fit ranges on every row; node and bun fall 5–10× between ranges, which is why only the warm column is quoted. Output byte-identical to node on every row.
#
operation
as filed
now
node
bun
gap then
gap now
1
O.a + O.b
162
108
11.64
11.32
13.5×
9.5×
2
O[K] + O[J]
1236
108
12.67
10.37
112×
10.4×
3
O.v = k; h += O.v
183
132
18.08
10.30
15×
12.8×
4
String(k % 1000)
435
419
113.98
30.51
13.6×
13.7×
The headline claim in this issue no longer holds. "A const-key read costs 7.6× the same read spelled statically" is fixed: rows 1 and 2 are now the same number, 108. #10766 (const substitution followed by re-applying the literal-index fold) makes O[K] compile identically to O.a. 1236 → 108 is 11.4×.
Row 4 has not moved.#10776 fixed a format!("{x:.1100}") in spec_to_fixed, which is not on String(n)'s path. It remains the worst of the four and still needs the _box ABI.
The blocker on rows 1–3 is now measurable, and it is #7109
Same scope in every row below — the loop is inside run(n) — and only the binding site of O differs. This is the control the earlier analysis in this campaign lacked:
binding of O
perry
node
optimiser report
function-local const
40
11.32
Ptr<Shape> 1 selected
function-local const, escaping into keep(O)
47
10.94
Ptr<Shape> 1 selected
function-local const, escaping into a module global
The cost tracks PERRY_OPT_REPORT exactly: 1-selected → 40–47, 0-selected → 108–109. The Ptr<Shape> fact is worth ~61–68 instructions/iteration, 2.3–2.7×, on this operation.
Scalar replacement was the obvious confound and is ruled out by the third row: that object genuinely escapes into a called function and still costs 47.
The fact does not reach parity on its own: 40–47 against node's ~11 is still 3.5–4.3×. It removes about 63% of the gap; the remainder is the guarded-add diamond and the NaN-boxed accumulator.
Correction to my own prediction
Before measuring I wrote down that rows 1 and 3 should be "essentially unchanged", row 2 near 169 and row 4 near 160. Three of those four were wrong — 1 and 3 moved materially (−33%, −28%), 2 came in well under at 108, and 4 did not move at all. Recording it because the prediction is what made the surprise on rows 1 and 3 visible enough to chase to #10774.
Package-level measurement from the Phase 3 attribution (#11464, benchmarks/packages/PROFILE.md): origin/main36420d2, release compiler, auto-optimize, Linux x86-64, perf record -e instructions:u --call-graph dwarf at two N (per-iteration, startup cancels), Node 26.5.1 oracle with outputs checked equal. "% of excess" = share of (Perry − Node) instructions/iter, equal-weight over the 23 packages with Perry/Node ≥ 2×.
Property-lookup slow paths are the largest bucket: 24.5% of total excess, ≥5% in 21 of 23 packages. Measured as entry points, get_field_ic_miss_impl is 10.5% of excess (≥2% in 17 packages), js_object_get_field_by_name_f64 3.9% and js_object_get_field_by_property_id_f64 4.0%. Leaves: shape_descriptor_by_id (4.0% self), try_data_get_bytes, get_field_by_name_past_inherited_cache, get_field_by_name_object_tail, keys_find_slot_by_bytes. On the by-name path, every miss also runs core::str::from_utf8 (1.8% self) and OwnedStringBytes::copy_of_key (0.8%) on the key.
Top sites: moment.js:173-179isValid (m._strict, flags.invalidWeekday), 40% of moment/diff_duration; lru-cache #set/#get (index.ts:2180/3023 via its source map); luxon getters (return this.invalid === null, this._zone) under cron, 48% of cron/next_dates via js_object_get_field_by_property_id_f64; validator/lib/util/merge.jsobj[key] in for…in (#10753); mongodb List.push (src/utils.ts:709).
Closing: re-measured on main ebc858c (v0.5.1656, which includes #11680 and #11776). Instructions per op are instructions:u medians, computed as ΔI/ΔN. Output matches node 26.8.1.
Row
Filed
Now
Node
Now/node
O.a+O.b
162
12
12.6
0.95×
O[K]+O[J]
1,236
12
12.5
0.96×
O.v=k; h+=O.v
183
11
19.5
0.57×
String(k%1000)
435
36
113
0.32×
The issue body had no source code, so these fixtures were rebuilt from its table.
Activity
proggeramlug commentedon Sep 19, 2026
The 5× is redundant guard work, not lookup cost — measured
Reading the same property three times in a row costs perry 52 instructions per read. node costs 0.8.
Receiver passed as a parameter so nothing can constant-fold it:
h += O.ah += O.a; h += O.bh += O.a; h += O.a; h += O.aperry shares nothing between accesses — not across a store, not across a different property, not even across the identical read repeated in straight-line code.
prop_cse.rshandles diverging guard chains, not straight-line repetition.Breaking down the mutated row (
O.a = k; h += O.a), 110 instructions total:js_array_numeric_value_to_raw_f64Three removals need no cache, no invalidation contract and no new guard: the duplicate proof (21%), normalising a value the guard four instructions earlier already proved is a non-NaN double (19%), and two barrier probes (9%).
Why the
read_stubhoist is not the answerread_stubis real and its design number is right — measured at 16 instructions per probe, hitting, on a declared-class receiver wheretry_data_get_bytesdeclines. Hoisted, the floor is ~230 per read, a 3.0× win — and still 1.55× node.But callgrind call counts settle it: the invariant row makes zero runtime calls, and the mutated row's only callee is the store's value normaliser. The stub lives inside
js_object_get_field_by_name, and none of the four rows in this issue ever reach it. The mutated row would be unchanged to the instruction.Most of
try_data_get_bytes's guards turn out to be free or cheap to close for the stub's receivers; the one genuine blocker isarena::classify_heap_generation != Unknown, the malloc-backed-exotic exclusion. Two gaps found during that audit are filed separately as #10768 — they are live in current code regardless of any hoist.Correction to PR #10766's report
Its §2a used a
const Odeclared inside a function as the "local receiver" control and concluded a ~14-instruction root-reload cost. That is wrong: perry constant-folds those reads, so the loop body has no guard chain and no load at all, only avaddsdof a literal. There is no root-reload effect — module-global (57, +45 marginal) and parameter (64, +50) behave the same, with the receiver in a register either way.The #10761 table, the const-key attribution and PR #10766's gates are unaffected.
perf(transform): re-apply the literal-key member fold after const sub…
fix(codegen,runtime): canonicalise NaNs read out of an ArrayBuffer so…
fix(codegen): canonicalise NaNs arriving from native code, not just f…
fix(codegen): canonicalise NaNs arriving from native code, not just f…
fix(codegen): canonicalise NaNs arriving from native code, not just f…
proggeramlug commentedon Sep 20, 2026
Re-measured on v0.5.1617 (merge train 238) — the const-key row is 112× → 10.4×, and one number here is now badly stale
Everything below is fitted warm (500k→5M),
perf stat -e instructions:u, min of 3, on perrymaster. perry is flat at both fit ranges on every row; node and bun fall 5–10× between ranges, which is why only the warm column is quoted. Output byte-identical to node on every row.O.a + O.bO[K] + O[J]O.v = k; h += O.vString(k % 1000)The headline claim in this issue no longer holds. "A const-key read costs 7.6× the same read spelled statically" is fixed: rows 1 and 2 are now the same number, 108. #10766 (const substitution followed by re-applying the literal-index fold) makes
O[K]compile identically toO.a. 1236 → 108 is 11.4×.Row 4 has not moved. #10776 fixed a
format!("{x:.1100}")inspec_to_fixed, which is not onString(n)'s path. It remains the worst of the four and still needs the_boxABI.The blocker on rows 1–3 is now measurable, and it is #7109
Same scope in every row below — the loop is inside
run(n)— and only the binding site ofOdiffers. This is the control the earlier analysis in this campaign lacked:OconstPtr<Shape> 1 selectedconst, escaping intokeep(O)Ptr<Shape> 1 selectedconst, escaping into a module global0 selectedconst(#7109)0 selected0 selectedThe cost tracks
PERRY_OPT_REPORTexactly: 1-selected → 40–47, 0-selected → 108–109. ThePtr<Shape>fact is worth ~61–68 instructions/iteration, 2.3–2.7×, on this operation.Scalar replacement was the obvious confound and is ruled out by the third row: that object genuinely escapes into a called function and still costs 47.
Two things worth recording:
Ptr<Shape>gate was still in place — it guarded gc: compiled constructor receiver goes stale across the globalThis-population collection (unmasked by #6982) #6991, which fix(gc): run the globalThis bootstrap in a no-move window (#7217) #7249 had already closed in the runtime. perf(codegen): stop disabling Ptr<Shape> in entry bodies for a bug fixed in the runtime — 110 to 89 instructions (#10769) #10774 lifted it, and a module-level loop now costs 47 instead of 108. Lifting a stale gate promoted the blocker sitting behind it.The fact does not reach parity on its own: 40–47 against node's ~11 is still 3.5–4.3×. It removes about 63% of the gap; the remainder is the guarded-add diamond and the NaN-boxed accumulator.
Correction to my own prediction
Before measuring I wrote down that rows 1 and 3 should be "essentially unchanged", row 2 near 169 and row 4 near 160. Three of those four were wrong — 1 and 3 moved materially (−33%, −28%), 2 came in well under at 108, and 4 did not move at all. Recording it because the prediction is what made the surprise on rows 1 and 3 visible enough to chase to #10774.
Fixtures and harness:
/root/ops4/on perrymaster.perf(codegen): a property read on a Ptr<Shape> receiver with a numeri…
proggeramlug commentedon Sep 27, 2026
Package-level measurement from the Phase 3 attribution (#11464,
benchmarks/packages/PROFILE.md):origin/main36420d2, release compiler, auto-optimize, Linux x86-64,perf record -e instructions:u --call-graph dwarfat two N (per-iteration, startup cancels), Node 26.5.1 oracle with outputs checked equal. "% of excess" = share of (Perry − Node) instructions/iter, equal-weight over the 23 packages with Perry/Node ≥ 2×.Property-lookup slow paths are the largest bucket: 24.5% of total excess, ≥5% in 21 of 23 packages. Measured as entry points,
get_field_ic_miss_implis 10.5% of excess (≥2% in 17 packages),js_object_get_field_by_name_f643.9% andjs_object_get_field_by_property_id_f644.0%. Leaves:shape_descriptor_by_id(4.0% self),try_data_get_bytes,get_field_by_name_past_inherited_cache,get_field_by_name_object_tail,keys_find_slot_by_bytes. On the by-name path, every miss also runscore::str::from_utf8(1.8% self) andOwnedStringBytes::copy_of_key(0.8%) on the key.Top sites:
moment.js:173-179isValid(m._strict,flags.invalidWeekday), 40% of moment/diff_duration; lru-cache#set/#get(index.ts:2180/3023via its source map); luxon getters (return this.invalid === null,this._zone) under cron, 48% of cron/next_dates viajs_object_get_field_by_property_id_f64;validator/lib/util/merge.jsobj[key]infor…in(#10753); mongodbList.push(src/utils.ts:709).proggeramlug commentedon Oct 3, 2026
Closing: re-measured on main ebc858c (v0.5.1656, which includes #11680 and #11776). Instructions per op are
instructions:umedians, computed as ΔI/ΔN. Output matches node 26.8.1.O.a+O.bO[K]+O[J]O.v=k; h+=O.vString(k%1000)The issue body had no source code, so these fixtures were rebuilt from its table.
perf(object): answer computed string-key reads from the receiver shape (