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

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