Skip to content

perf: a dynamic string-keyed property read is 5.5x node when the key is present and 23x when absent, while static reads and array indexing both beat node #10753

Description

@proggeramlug

Summary

A dynamic string-keyed property read costs 5.5× node when the key is present and 23× when it is absent — while both of its component operations are things perry wins.

Per operation, fitted N=2,000→20,000, perf stat -e instructions:u. perry from origin/main + #10731 + #10746 + #10752; node v26.8.1; bun 1.3.14. Output checked equal to node on every row.

case perry node bun vs node
O.a — static key 113 153 56 1.35× — perry wins
W[i].length — the array index alone, no object read 70 202 105 2.88× — perry wins
O[K] — hoisted const K = "a" 622 190 50 0.30×
O[W[i]] — every key present 697 279 119 0.40×
O[W[i]] — some keys absent 3,118 135 150 0.04×
Map.get(W[i]) — same keys, for comparison 417 135 182 0.32×

What the decomposition says

Reading a statically known property is fast — perry beats node. Indexing the key array is fast — perry beats node by 2.9×. Composing them costs 697, against 183 for the two halves measured separately, so roughly 500 instructions appear that belong to neither.

And a miss is 4.5× worse than a hit — 3,118 against 697. That is the shape that matters most in practice, because O[k] || default, if (table[k]), and sparse lookup tables are all miss-heavy by design.

Note const K = "a" is barely better than a key read from an array (622 vs 697), so this is not about the key expression being dynamic — a constant string key already pays most of it. The cost is in the lookup itself.

Why this is worth doing next

It is larger than regex. On the per-op crossover table in #10695 regex is 0.15× against both runtimes; the miss path here is 0.04×, and unlike regex there is no narrower workaround — object property access by computed key is not an optional idiom.

Of the five realistic programs in #10695, records (0.45×) is object- and Set-bound, and tok (0.28×) does a keyword lookup per identifier. Both are in this path.

The related sibling, string-keyed Map, is #10697 — 4.2× node in the commonest shape, and Map.get measures 0.32× here, consistent with it.

Where I would look

The likely candidates, in the order I would check them:

  1. Per-lookup string hashing or interning — if the key's hash is recomputed per access rather than carried on the string, a constant key would pay it too, which matches const K costing 622.
  2. The miss path walking the prototype chain by name, with the 4.5× hit-to-miss ratio being that walk plus a second full lookup on Object.prototype. The JSON attribution in perf(json): JSON.stringify costs ~3,000 instructions per object visited (~20x node); array elements and primitives are fine #10696 found exactly this shape — a by-name prototype walk per object — costing ~450 instructions there.
  3. Whether an inline cache exists for computed keys at all. codegen: a string's .codePointAt in cc's hottest loop lowers to NativeMethodCall{module:"child_process", class_name:"Instance"} #9847 and the dynprop campaign made computed-key writes beat node (0.83×); reads appear not to have had the same treatment.

Related: #10695 (crossover map and real-program standings), #10697 (string-keyed Map), #10696 (the by-name prototype walk in JSON.stringify), #10741 (why primitive wins may not transfer to real loops — this one should, since it is a call-path cost rather than a loop-admission one).

Activity

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