Skip to content

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

Closed

Description

@proggeramlug

Summary

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.

Relationship to existing issues

Measurement notes for anyone reproducing

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.

Activity

  1. proggeramlug commented on Sep 19, 2026

    @proggeramlug
    ContributorAuthor

    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.

    Correction to PR #10766's report

    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.

  2. proggeramlug commented on Sep 20, 2026

    @proggeramlug
    ContributorAuthor

    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 109 12.99 0 selected
    module-level const (#7109) 108 10.90 0 selected
    function parameter 109 9.54 0 selected

    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.

    Two things worth recording:

    1. Three storage classes are denied the fact, not one — module-level bindings (repsel: module-init / program-entry bodies are excluded from canonical i32+Str selection, so a top-level hot loop promotes nothing #7109), function parameters, and a local that escapes into a module-level global. Only the first is filed.
    2. repsel: module-init / program-entry bodies are excluded from canonical i32+Str selection, so a top-level hot loop promotes nothing #7109 was promoted by lifting a different gate. Earlier in this campaign I concluded repsel: module-init / program-entry bodies are excluded from canonical i32+Str selection, so a top-level hot loop promotes nothing #7109 was the less important blocker. That was true only while the entry-body 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.

  3. proggeramlug commented on Sep 27, 2026

    @proggeramlug
    ContributorAuthor

    Package-level measurement from the Phase 3 attribution (#11464, benchmarks/packages/PROFILE.md): origin/main 36420d2, 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-179 isValid (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.js obj[key] in for…in (#10753); mongodb List.push (src/utils.ts:709).

  4. proggeramlug commented on Oct 3, 2026

    @proggeramlug
    ContributorAuthor

    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.

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