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).
Summary
Element access on an ordinary JS
Arraycosts 87 instructions per read and 105 per write, against 6 and 8 for the identical arithmetic on aFloat64Array— 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 fromorigin/main 8df83f8c1+ #10717; node v26.8.1; bun 1.3.14. Output checked equal to node on every row.s += i— no array at allFloat64Arrayread:s += a[i]Float64Arraywrite:a[i] = k + iArrayread:s += a[i]Arraywrite:a[i] = k + iArrayread-modify-write:a[i] = a[i] + 1; s += a[i]Two things stand out beyond the headline:
pushcosts the same as one fromnew Array(n)(263.2M vs 268.0M). So it is not boxing of the value and not the allocation shape.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:
charCodeAt,slice,Set)Set)new Array,fill,push)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
Float64Arraywould, 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]onArray(87) against the same onFloat64Array(6), same loop, same index expression, same element values.Related: #10695 (crossover map and real-program decomposition), #10697 (string-keyed
Map).