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
Description
Activity
- added 4 commits that reference this issue
on Sep 19, 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:
reported delta #10731 element read 87 → 13.5 instructions — stops re-proving a loop-invariant array receiver on every access (HIR + codegen) #10746 element store 105 → 17.4 — the same hoist for the store path #10752 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:
- 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. - The particle simulation at 0.04× node (4.13 billion instructions against 201M) is the number that actually matters here, and nobody has re-run it. A 6.4× improvement in per-access cost does not straightforwardly become a 25× program-level win — the simulation does other things, and perf(array): js_array_get_f64 is the #1 symbol at 6.6% on a real 3,449-file type-check — codegen calls the long dispatch path, not the existing _unchecked fast path #10724 (
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.
- 13.5 against
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:
workload inline instr/iter % of excess hottest inline site node-forge/sha256 2.66M 34.0 sha256.jsround loop (closure 20), 2.51Mbig.js/arith_chain 45.0M 21.6 big.mjs:872 for (c = new Array(j = a + b); j--;) c[j] = 0;33.5Mdecimal.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.jsam-loop (closure 14), 2.60Gbignumber.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.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:
- Row 4 dynamic index get/set (5.3%: big.js 35, bignumber.js 19, decimal.js 11, node-forge 11).
- Row 13 numeric conversion (2.1%), whose largest item is untyped
%→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.js
big.mjs:872 c[j] = 0, which also showsjs_dyn_index_set_strictat 35% of the package. - node-forge/rsa_sign
jsbn.js:js_dyn_index_set_strict2.09G +is_registered_buffer_slow2.92G +js_array_get_f641.34G instr/iter. - The
push(0)fill loops in bignumber.js and decimal.js.
What landed since the issue was filed
- Merge train 232 (chore: merge train 232 (v0.5.1611) #10780, v0.5.1611) landed perf(codegen,hir): stop re-proving a loop-invariant array receiver on every element access — Array read 87 to 13.5 instructions (#10718) #10731, perf(codegen): hoist the loop-invariant receiver proof for array element stores — 105 to 17.4 instructions (#10718) #10746 and perf(codegen): let a[i] += 1 reach the loop tier a[i] = a[i] + 1 already had — 277 to 25.5 instructions (#10743) #10752: the loop-invariant receiver proof is hoisted for reads, stores and
a[i] += 1. It was already in the bench(packages): Phase 3 attribution -- call chains, root-cause buckets, per-call floor #11464 build. - perf(codegen): inline untyped Array element stores and heal forwarded heads on reads (#10513, #10514) #11588 (merged 09-27, after bench(packages): Phase 3 attribution -- call chains, root-cause buckets, per-call floor #11464): untyped
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%. - perf: loop regions — one shape guard per loop, bare accesses inside (charter step 4b) #11650 (merged 09-29), loop regions: one shape guard per loop over an unchanging receiver. Property accesses in the body become bare loads and stores, and a miss hands off to a generic copy of the loop.
- 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)
function arrRead(a: number[], n: number) { let s = 0; for (let k = 0; k < n; k++) for (let i = 0; i < 400; i++) s += a[i]; return s; } function arrRmw(a: number[], n: number) { let s = 0; for (let k = 0; k < n; k++) for (let i = 0; i < 400; i++) { a[i] = a[i] + 1; s += a[i]; } return s; } // arrWrite: a[i] = k + i; f64Read / f64Rmw: the same bodies on a Float64Array parameter
loop body Perry Node Perry/Node Float64Arrayread6.0 32.1 0.19× Arrayreads += a[i]13.5 28.3 0.48× Arraywritea[i] = k + i16.5 36.0 0.46× Float64ArrayRMWt[i] = t[i] + 1; s += t[i]9.0 42.9 0.21× ArrayRMW, same two statements206 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
ArrayRMW ≤ 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 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.- added a commit that references this issue
on Oct 3, 2026
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).