Skip to content

perf: ordinary Array element access costs 87/105 instructions per read/write vs 6/8 for Float64Array — 14x inside perry, and 0.09x node on read-modify-write #10718

Description

@proggeramlug

Summary

Element access on an ordinary JS Array costs 87 instructions per read and 105 per write, against 6 and 8 for the identical arithmetic on a Float64Array — a 14× gap inside perry, on the same loop, with the array allocated once outside it. node costs 17 and 40.

perry's loop codegen and float arithmetic are excellent — it beats node 3.75× on a bare loop and 26× on the same arithmetic held in scalars. The cost is specific to indexing a regular array, which is the most common data structure in real JavaScript.

Measured

Per element, fitted across N=500→2,500 outer iterations × 400 elements so loop setup and startup cancel. perf stat -e instructions:u, perrymaster. perry from origin/main 8df83f8c1 + #10717; node v26.8.1; bun 1.3.14. Output checked equal to node on every row.

loop body perry node vs node
s += i — no array at all 4 15 3.75× — perry wins
Float64Array read: s += a[i] 6 25 4.16× — perry wins
Float64Array write: a[i] = k + i 8 18 2.25× — perry wins
Array read: s += a[i] 87 17 0.19×
Array write: a[i] = k + i 105 40 0.38×
Array read-modify-write: a[i] = a[i] + 1; s += a[i] 334 31 0.09×

Two things stand out beyond the headline:

  • It is not element type. Integer and float arrays cost the same (268.0M vs 268.0M on the same program), and an array built by push costs the same as one from new Array(n) (263.2M vs 268.0M). So it is not boxing of the value and not the allocation shape.
  • Read-modify-write is super-additive. 334 against 87 + 105 = 192 expected, so the combination costs ~142 more than its parts. Something is being re-derived per access and the read-modify-write pattern appears to defeat it.

Why this matters more than the other open perf items

I built five realistic programs — a tokenizer, a particle simulation, a BFS over an adjacency list, a record filter/sort/dedupe, and a text report renderer — and measured all three runtimes with output checked identical:

program vs node vs bun
text rendering (string building) 1.20× 1.21× beats both
tokenizer (charCodeAt, slice, Set) 1.15× 0.65× loses to bun
records (filter / sort / Set) 0.80× 0.82× loses
graph BFS (new Array, fill, push) 0.21× 0.20× loses
particle simulation (4 numeric arrays) 0.04× 0.05× loses badly

The simulation is 4.13 billion instructions against node's 201M. It is pure numeric array work — precisely what an AOT compiler should win — and it is the worst result in the set. Rewriting its four arrays as Float64Array would, on the numbers above, turn it into a large win.

Regex (#10695) is 63% of one log-processing program. This is on the critical path of almost every program, and unlike regex it has no workaround a normal JS author would think of.

Where I would look

The per-access cost is ~14× the typed-array path for the same index arithmetic, so the suspicion is a generic element-access path — bounds/holes/element-kind checks, or a backing-store indirection resolved per access rather than hoisted — where the typed path has a specialised one. The super-additive read-modify-write result suggests whatever is derived per access is not reused between the read and the write of the same element in the same statement.

The cleanest bisect pair is in this issue: s += a[i] on Array (87) against the same on Float64Array (6), same loop, same index expression, same element values.

Related: #10695 (crossover map and real-program decomposition), #10697 (string-keyed Map).

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