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: the loop-hoisting tiers only admit single-statement, call-free bodies — a 6x store-path win produces 0.000% on real programs #10741
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 Array element write from 105 instructions to 17.4 and a[i] = a[i] + 1 from 256 to 24.5. Applied to five realistic programs it moves none of them:
program
base
fix
delta
vs node
tok
839,745
839,749
+0.000%
0.28×
sim
529,908
529,908
0.000%
0.04×
graph
4,217,041
4,218,055
+0.024%
0.12×
records
1,957,614
1,957,074
−0.028%
0.45×
text
92,313
92,313
0.000%
0.77×
Per-op, fitted across two sizes. sim and text are identical to the instruction; the rest is fit noise.
Why — traced, not inferred
PERRY_PACKED_LOOP_TRACE on the fix arm:
sim — 3 × [range-loop] rejected: body_not_admissible. Its inner loop is five statements containing two ifs. 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 as number[] changes nothing — still body_not_admissible. Callgrind: 1,239 instructions per particle step for ~4 compound-assign element stores and ~6 reads.
graph — 3 × body_not_admissible, plus bound_shape_unsupported and array_kind_unknown. Its hot loop calls q.push(v) and indexes dist[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, Set and 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:
A multi-statement tier that admits stores. The blocker is the mid-iteration side exit: if the guard fails partway through an iteration, stores that already executed must not be re-executed on the fallback path. That needs either a checkpoint/rollback discipline or a proof that the guard cannot fail mid-iteration once it has passed at entry.
Admitting calls in the body. A call can invalidate the hoisted receiver head. That needs an effect analysis strong enough to prove a given callee cannot reach the array — or a cheap re-validation on return that still beats re-proving per element.
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).
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 Float64Array throughout 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
Float64Array read
6
10.2
10.6
Float64Array write
8
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.
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 package kernels are exactly the shape it describes: multi-statement loop bodies with branches and calls. Examples are the jsbn am loops (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.
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 particle step, 400 particles)
functionstepA(x: number[],y: number[],vx: number[],vy: number[],n: number): number{for(letk=0;k<n;k++)for(leti=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;}lets=0;for(leti=0;i<400;i++)s+=x[i]+y[i];returnMath.round(s*1000);}// stepF: the same body with Float64Array parameters
arrays
Perry
Node
Perry/Node
number[]
921
56
16.4×
Float64Array
623
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_TRACE for 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 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
proggeramlug commentedon Sep 19, 2026
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.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:
Float64ArrayreadFloat64ArraywriteThe 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.
perf(codegen): let `a[i] += 1` reach the loop tier `a[i] = a[i] + 1` …
docs: key the changelog fragment to PR 11053, and record the admissio…
docs: key the changelog fragment to PR 11053, and record the admissio…
docs: key the changelog fragment to PR 11053, and record the admissio…
docs: key the changelog fragment to PR 11053, and record the admissio…
proggeramlug commentedon Sep 30, 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:
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 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
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)
number[]Float64ArrayThe 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
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.perf: loop regions admit real loop bodies over arrays (#10741)
perf: loop regions admit real loop bodies over arrays (#10741)
fix(codegen): the Float64Array region guard reads the receiver's stor…