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: 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
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).
Partly addressed on main as of v0.5.1611 (merge train 232, dd00a00305). Recording what landed and what is still open, because the numbers in this issue are now stale in one direction and untested in another.
Three PRs landed together — they were one author stack and could not land apart:
a[i] += 1 277 → 25.5 — lets compound assignment reach the loop tier a[i] = a[i] + 1 already had (this is #10743, now closed)
That is the per-access cost this issue diagnosed, and the diagnosis was right: "a backing-store indirection resolved per access rather than hoisted". The super-additive read-modify-write result had the cause you guessed too — the derived receiver was not reused between the read and the write of the same element.
Leaving this open, deliberately. The bisect pair is fixed; the five real programs are not re-measured. Specifically:
13.5 against Float64Array's 6 is still ~2.3×, not parity. Whether the remainder is the bounds/holes/element-kind check or something else is unknown.
What would close this: re-run the five programs from the table above on v0.5.1611 against Node 26.5.1 and bun, output checked identical, on a quiet machine. If the particle simulation is still badly under 1×, that result is far more valuable than this issue's original measurement, because it would mean the per-access cost was not the dominant term after all.
Measurement is not queued on my side — flagging it so nobody assumes the landed instruction counts answered the program-level question. They answer the microbenchmark.
Current target (2026-09-30): single Array element ops now beat Node; a two-statement read-modify-write is still 206 instr/element (4.8× Node, 23× Float64Array)
Bucket row 7, generated code itself (numeric kernels), is 4.5% of equal-weight excess. It counts the instructions of the compiled JS itself, not runtime calls. It is ≥5% in 5 packages. Share of each package's excess: big.js 21, node-forge 17, bignumber.js 13, decimal.js 9, nanoid 6.
Per workload, with the hottest inline site:
workload
inline instr/iter
% of excess
hottest inline site
node-forge/sha256
2.66M
34.0
sha256.js round loop (closure 20), 2.51M
big.js/arith_chain
45.0M
21.6
big.mjs:872 for (c = new Array(j = a + b); j--;) c[j] = 0; 33.5M
decimal.js/arith_chain
688k
16.6
decimal.mjs:1908 for (i = rL; i--;) r.push(0);
node-forge/rsa_sign
3.23G
16.0
jsbn.js am-loop (closure 14), 2.60G
bignumber.js/arith_chain
743k
14.3
bignumber.mjs:2314 … zc.push(0)
node-forge/aes_cbc
5.41M
13.4
aes.js (closure 29), 4.47M
The bucket does not say which construct inside the inline code costs the instructions, whether bitwise ToInt32, element access, or loop shape. The split between #10511, #10718 and #10741 is therefore by site, not measured.
Row 13 numeric conversion (2.1%), whose largest item is untyped % → js_dynamic_mod → libm fmod (bignumber.js 18.8%, big.js 17.4%, decimal.js 9.2% of excess). It is still unfiled for lack of a package-free repro.
The node-forge figures come from 2febf42 binaries, which predate #11384.
For this issue the element-access sites are:
big.js big.mjs:872 c[j] = 0, which also shows js_dyn_index_set_strict at 35% of the package.
Codex draft Use shape-proven F64 facts in guarded loop regions #11708, Use shape-proven F64 facts in guarded loop regions: keeps ShapeId-proven numeric fields as native doubles inside a region (read4 44 → 19 instr/iter). This overlaps with the numeric work codex is already doing. Coordinate there before starting a parallel tier.
Reproducer, fresh numbers (instructions per element, 400 elements per outer iteration)
functionarrRead(a: number[],n: number){lets=0;for(letk=0;k<n;k++)for(leti=0;i<400;i++)s+=a[i];returns;}functionarrRmw(a: number[],n: number){lets=0;for(letk=0;k<n;k++)for(leti=0;i<400;i++){a[i]=a[i]+1;s+=a[i];}returns;}// arrWrite: a[i] = k + i; f64Read / f64Rmw: the same bodies on a Float64Array parameter
loop body
Perry
Node
Perry/Node
Float64Array read
6.0
32.1
0.19×
Array read s += a[i]
13.5
28.3
0.48×
Array write a[i] = k + i
16.5
36.0
0.46×
Float64Array RMW t[i] = t[i] + 1; s += t[i]
9.0
42.9
0.21×
Array RMW, same two statements
206
42.6
4.8×
The primitives this issue measured (87 / 105) are now 13.5 / 16.5, and both beat Node. The Array read-modify-write is still super-additive: 206 against 13.5 + 16.5. It has two statements in the body, and the Float64Array version of the same body costs 9. My inference is that this is #10741's admission problem; I did not trace it. The issue's five programs have not been re-run. On #10741's particle-step reproducer, number[] measures 921 instr/step vs Node 56.
Acceptance target
Array RMW ≤ 2× Node (≤ ~85 instr per element) and ≤ 3× the Float64Array version.
The single-op rows stay below Node.
No regression on big.js, bignumber.js, decimal.js and node-forge.
Package check (Linux, needs perf). Build with cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static, then run:
Run it once on a base-commit build and once on the branch, and compare instructions per iteration. --filter is a workload-id substring, and control/* always runs. For attribution, run profile --callgraph on PERRY_KEEP_SYMBOLS=1 binaries (see benchmarks/packages/PROFILE.md).
Fresh numbers were measured on origin/main5fbc2c3 (v0.5.1654) with cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static. Binaries were compiled with PERRY_NO_AUTO_OPTIMIZE=1 and compared with Node 26.5.1 on Linux x86-64 using perf stat -e instructions:u. Each figure is the median of 3 runs at two sizes of N, with per-op = ΔI/ΔN. N1 is at least 200k operations (20k calls for the factory bench), which keeps most of Node's JIT warm-up inside the constant term. Output was byte-identical to Node on every row. The #11464 figures come from benchmarks/packages/profile/callgraph.{md,json}, measured at Perry 36420d2 with auto-optimize. node-forge was measured from 2febf42 binaries. "% of excess" means the share of a package's (Perry − Node) instructions per iteration.
Activity
changelog: fragment for PerryTS#10718
perf(codegen,hir): stop re-proving a loop-invariant array receiver on…
changelog: fragment for #10718
perf(codegen): hoist the loop-invariant receiver proof for array elem…
proggeramlug commentedon Sep 20, 2026
Partly addressed on
mainas of v0.5.1611 (merge train 232,dd00a00305). Recording what landed and what is still open, because the numbers in this issue are now stale in one direction and untested in another.Three PRs landed together — they were one author stack and could not land apart:
a[i] += 1277 → 25.5 — lets compound assignment reach the loop tiera[i] = a[i] + 1already had (this is #10743, now closed)That is the per-access cost this issue diagnosed, and the diagnosis was right: "a backing-store indirection resolved per access rather than hoisted". The super-additive read-modify-write result had the cause you guessed too — the derived receiver was not reused between the read and the write of the same element.
Leaving this open, deliberately. The bisect pair is fixed; the five real programs are not re-measured. Specifically:
Float64Array's 6 is still ~2.3×, not parity. Whether the remainder is the bounds/holes/element-kind check or something else is unknown.js_array_get_f64reached through the long dispatch path rather than the existing_uncheckedfast path) is still open and plausibly on the same critical path.What would close this: re-run the five programs from the table above on v0.5.1611 against Node 26.5.1 and bun, output checked identical, on a quiet machine. If the particle simulation is still badly under 1×, that result is far more valuable than this issue's original measurement, because it would mean the per-access cost was not the dominant term after all.
Measurement is not queued on my side — flagging it so nobody assumes the landed instruction counts answered the program-level question. They answer the microbenchmark.
proggeramlug commentedon Sep 30, 2026
Current target (2026-09-30): single Array element ops now beat Node; a two-statement read-modify-write is still 206 instr/element (4.8× Node, 23× Float64Array)
Package impact (#11464)
Bucket row 7, generated code itself (numeric kernels), is 4.5% of equal-weight excess. It counts the instructions of the compiled JS itself, not runtime calls. It is ≥5% in 5 packages. Share of each package's excess: big.js 21, node-forge 17, bignumber.js 13, decimal.js 9, nanoid 6.
Per workload, with the hottest inline site:
sha256.jsround loop (closure 20), 2.51Mbig.mjs:872 for (c = new Array(j = a + b); j--;) c[j] = 0;33.5Mdecimal.mjs:1908 for (i = rL; i--;) r.push(0);jsbn.jsam-loop (closure 14), 2.60Gbignumber.mjs:2314 … zc.push(0)aes.js(closure 29), 4.47MThe bucket does not say which construct inside the inline code costs the instructions, whether bitwise ToInt32, element access, or loop shape. The split between #10511, #10718 and #10741 is therefore by site, not measured.
Two neighbouring rows:
%→js_dynamic_mod→ libmfmod(bignumber.js 18.8%, big.js 17.4%, decimal.js 9.2% of excess). It is still unfiled for lack of a package-free repro.The node-forge figures come from 2febf42 binaries, which predate #11384.
For this issue the element-access sites are:
big.mjs:872 c[j] = 0, which also showsjs_dyn_index_set_strictat 35% of the package.jsbn.js:js_dyn_index_set_strict2.09G +is_registered_buffer_slow2.92G +js_array_get_f641.34G instr/iter.push(0)fill loops in bignumber.js and decimal.js.What landed since the issue was filed
a[i] += 1. It was already in the bench(packages): Phase 3 attribution -- call chains, root-cause buckets, per-call floor #11464 build.obj[i] = von an ordinary Array stores inline, and grown or forwarded heads heal on reads. Its package deltas: node-forge/rsa_sign −50.4%, big.js −42.6%, bignumber.js −21.0%, decimal.js −16.8%.Reproducer, fresh numbers (instructions per element, 400 elements per outer iteration)
Float64ArrayreadArrayreads += a[i]Arraywritea[i] = k + iFloat64ArrayRMWt[i] = t[i] + 1; s += t[i]ArrayRMW, same two statementsThe primitives this issue measured (87 / 105) are now 13.5 / 16.5, and both beat Node. The Array read-modify-write is still super-additive: 206 against 13.5 + 16.5. It has two statements in the body, and the Float64Array version of the same body costs 9. My inference is that this is #10741's admission problem; I did not trace it. The issue's five programs have not been re-run. On #10741's particle-step reproducer,
number[]measures 921 instr/step vs Node 56.Acceptance target
ArrayRMW ≤ 2× Node (≤ ~85 instr per element) and ≤ 3× the Float64Array version.Package check (Linux, needs
perf). Build withcargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static, then run:(cd benchmarks/packages && npm ci --ignore-scripts) python3 scripts/package_bench.py compile --perry-bin-dir /tmp/pb --filter big.js --filter bignumber.js --filter decimal.js --filter node-forge python3 scripts/package_bench.py run --perry-bin-dir /tmp/pb --arms node,perry --modes instr --filter big.js --filter bignumber.js --filter decimal.js --filter node-forge --out /tmp/pb/instr.jsonRun it once on a base-commit build and once on the branch, and compare instructions per iteration.
--filteris a workload-id substring, andcontrol/*always runs. For attribution, runprofile --callgraphonPERRY_KEEP_SYMBOLS=1binaries (seebenchmarks/packages/PROFILE.md).Fresh numbers were measured on
origin/main5fbc2c3 (v0.5.1654) withcargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static. Binaries were compiled withPERRY_NO_AUTO_OPTIMIZE=1and compared with Node 26.5.1 on Linux x86-64 usingperf stat -e instructions:u. Each figure is the median of 3 runs at two sizes of N, with per-op = ΔI/ΔN. N1 is at least 200k operations (20k calls for the factory bench), which keeps most of Node's JIT warm-up inside the constant term. Output was byte-identical to Node on every row. The #11464 figures come frombenchmarks/packages/profile/callgraph.{md,json}, measured at Perry 36420d2 with auto-optimize. node-forge was measured from 2febf42 binaries. "% of excess" means the share of a package's (Perry − Node) instructions per iteration.perf: array read-modify-write in multi-statement loops takes the dens…