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 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).
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).