Repository navigation
perf: the loop-hoisting tiers only admit single-statement, call-free bodies — a 6x store-path win produces 0.000% on real programs #10741
Description
Activity
Evidence that this is broader than arrays: typed arrays do not escape it either
A natural reading of this issue is "ordinary arrays are the problem, typed arrays are the workaround." They are not.
I rewrote the particle simulation using
Float64Arraythroughout and expanded assignment (vy[i] = vy[i] - 0.0098) instead of compound, i.e. avoiding both #10718's ordinary-array cost and #10743's compound-assign lowering. Identical physics, byte-identical output in all three runtimes.N perry node bun vs node vs bun 1,000 53,259,312 158,185,652 150,520,167 2.97× 2.82× 10,000 553,032,775 348,588,675 366,017,243 0.63× 0.66× 100,000 5,551,522,434 2,243,952,838 2,260,005,273 0.40× 0.40× Per particle-step, fitted 10k→100k: perry 138, node 52, bun 52 — perry 2.65× slower.
That is the striking part, because in isolation perry wins every primitive this loop uses:
primitive, isolated perry node bun Float64Arrayread6 10.2 10.6 Float64Arraywrite8 11.5 16.1 bare loop 4 7.8 7.6 The loop performs roughly 15 typed-array accesses per particle-step. At the isolated costs that predicts ~100 instructions and a comfortable win; measured, it is 138 against node's 52. node's per-access cost inside the real loop is a third of what it is in the microbenchmark; perry's is unchanged.
That is the whole issue in one measurement. A JIT that sees a five-statement branchy body still optimises across it — hoisting, scheduling, keeping values in registers across statements. perry's tiers decline the body entirely and fall back to per-statement code that is individually good and collectively unimproved.
Consequence for how this gets prioritised
Fixing ordinary-array access (#10718, #10731, #10746) and the compound-assign lowering (#10743) are all worth doing — they are real defects with real numbers. But none of them, nor all of them, makes a realistic loop competitive, because the deficit survives even when every primitive in the loop is one perry already wins.
The crossover model in #10695 still holds: perry wins below a program-specific size on its startup advantage and loses above it on throughput. This says the throughput half is gated on admitting real loop bodies, not on the cost of any individual operation.
- added 5 commits that reference this issue
on Sep 20, 2026 Current target (2026-09-30): a multi-statement, branching loop over arrays is 16.4× Node (
number[]) and 8.8× (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 package kernels are exactly the shape it describes: multi-statement loop bodies with branches and calls. Examples are the jsbn
amloops (node-forge/rsa_sign, 2.60G inline instr/iter), the sha256 round (2.51M), aes (4.47M) and the big-number digit loops. The profile does not record whether a loop tier admitted a given loop, so this mapping holds at bucket and site level only.What landed since the issue was filed
- perf(codegen): inline untyped Array element stores and heal forwarded heads on reads (#10513, #10514) #11588 (09-27) makes untyped in-bounds Array stores inline, which is the store primitive, not the admission rule.
- 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.
- Neither perf: loop regions — one shape guard per loop, bare accesses inside (charter step 4b) #11650 nor Use shape-proven F64 facts in guarded loop regions #11708 claims element accesses (
a[i]) or stores inside multi-statement bodies. That remains the open part of this issue.
Reproducer, fresh numbers (instructions per particle step, 400 particles)
function stepA(x: number[], y: number[], vx: number[], vy: number[], n: number): number { for (let k = 0; k < n; k++) for (let i = 0; i < 400; i++) { x[i] += vx[i]; y[i] += vy[i]; if (x[i] < 0 || x[i] > 100) vx[i] = -vx[i]; if (y[i] < 0 || y[i] > 100) vy[i] = -vy[i]; vy[i] += 0.01; } let s = 0; for (let i = 0; i < 400; i++) s += x[i] + y[i]; return Math.round(s * 1000); } // stepF: the same body with Float64Array parameters
arrays Perry Node Perry/Node number[]921 56 16.4× Float64Array623 71 8.8× The body has about 10 element reads and 3–5 stores per step. The single-statement primitives now cost 6–17 each (see #10718), so most of the per-step cost is outside those primitives. That points at the loop not being admitted by any tier, which is an inference, not a trace. I did not re-run
PERRY_PACKED_LOOP_TRACEfor the rejection reason on this build.Acceptance target
- Both rows ≤ 2× Node (≤ ~112 / ~142 instr per step at today's Node numbers).
- No regression on node-forge, big.js, bignumber.js and decimal.js.
- tsc and Zod flat, the real-code gate the region PRs used.
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 node-forge --filter big.js --filter bignumber.js --filter decimal.js python3 scripts/package_bench.py run --perry-bin-dir /tmp/pb --arms node,perry --modes instr --filter node-forge --filter big.js --filter bignumber.js --filter decimal.js --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
The finding
perry's loop-hoisting tiers — the ones that lift a loop-invariant array receiver proof into the preheader, and the reason #10731 took an indexed read from 87 instructions to 13.5 — only admit loop bodies that real programs do not have.
A store-side fix measured on microbenchmarks takes an
Arrayelement write from 105 instructions to 17.4 anda[i] = a[i] + 1from 256 to 24.5. Applied to five realistic programs it moves none of them:Per-op, fitted across two sizes.
simandtextare identical to the instruction; the rest is fit noise.Why — traced, not inferred
PERRY_PACKED_LOOP_TRACEon the fix arm:sim— 3 ×[range-loop] rejected: body_not_admissible. Its inner loop is five statements containing twoifs. The classic tier takes one statement; the multi-statement (dense) tier is read-only, because a mid-iteration side exit would re-execute stores that already ran. Annotating all four arrays asnumber[]changes nothing — stillbody_not_admissible. Callgrind: 1,239 instructions per particle step for ~4 compound-assign element stores and ~6 reads.graph— 3 ×body_not_admissible, plusbound_shape_unsupportedandarray_kind_unknown. Its hot loop callsq.push(v)and indexesdist[v]with a data-dependent index. A call in the body can invalidate the hoisted head, so no current tier can admit it.tok/records/text— string,Setand object work; element stores are not their cost.The general shape
The admissible set is roughly: a single-statement body, no calls, no conditionals, a statically-shaped bound. Real loop bodies are multi-statement, call things, and branch.
So the tiers deliver their full benefit exactly where a benchmark is written and not where a program is. That is why a 6× improvement on the store primitive produces 0.000% on
sim, whose inner loop is entirely element stores — it is simply five statements long.What would actually be needed
Two things, both real compiler work rather than a patch:
Why this is filed separately
Both are larger than one pass, and I would rather have this written down than have someone conclude from #10731 and the store work that array access is solved. On the primitive it is; on real programs it is not, and the gap between those two statements is this issue.
Measured on perrymaster, perry from
origin/main+ #10731, node v26.8.1, bun 1.4.2, all five programs byte-identical across runtimes.Related: #10718 (the per-element costs), #10731 (the read side, landed as far as the primitive goes), #10695 (where perry stands on real programs, and the crossover model).