Skip to content

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

@proggeramlug

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:

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

Activity

  1. proggeramlug commented on Sep 19, 2026

    @proggeramlug
    ContributorAuthor

    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.

  2. proggeramlug commented on Sep 30, 2026

    @proggeramlug
    ContributorAuthor

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

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

    What landed since the issue was filed

    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×
    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:

    (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.json

    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/main 5fbc2c3 (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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions