Skip to content

Counted loops lose packed-array admission to any abrupt statement — break/continue/return/throw all 6x, even when never taken #9151

Description

@proggeramlug

Summary

A counted loop over a packed array loses its fast path when the body contains any abrupt-completion statement — break, continue, return or throw — whether or not it is ever taken. All four cost the same 6x against perry's own straight-line loop, and ~8.5x against node.

A plain conditional is not the trigger: an if whose body only assigns is fully fast. And continue does not leave the loop at all, which is the datum that rules out "early exit" as the explanation.

Repro

const arr: number[] = [];
for (let i = 0; i < 512; i++) arr.push(i * 3);
const R = 40_000;
let sink = 0;

// body shapes, all inside `for (let r = 0; r < R; r++) for (let i = 0; i < arr.length; i++) { … }`
//   base        l = arr[i];
//   break       l = arr[i]; if (l < 0) break;
//   continue    l = arr[i]; if (l < 0) continue;
//   return      l = arr[i]; if (l < 0) return -1;
//   throw       l = arr[i]; if (l < 0) throw new Error("x");
//   if_assign   l = arr[i]; if (l < 0) { l = 0; }
//   if_sink     l = arr[i]; if (l < 0) { sink = 1; }
//   outer_break l = arr[i];   … with `if (l < 0) break;` in the OUTER loop instead

arr holds only non-negative values, so no branch is ever taken in any variant.

Measurement

Quiet Mac mini, node v26.5.1, ns/op, best of 3:

inner-loop body perry node
base (straight line) 0.78 0.55
if (l < 0) { l = 0; } 0.46 0.56
if (l < 0) { sink = 1; } 0.51 0.54
break in the outer loop 0.50 0.54
if (l < 0) break; 4.74 0.54
if (l < 0) continue; 4.75 0.55
if (l < 0) return -1; 4.76 0.56
if (l < 0) throw …; 4.76 0.56

The array is a bare local throughout, so no receiver or property-lookup effect is involved. The four abrupt forms landing on the same number to within 0.02 ns suggests one predicate rejecting all four rather than four separate causes.

Node is flat across every variant, so the fast path is not inherently incompatible with abrupt control flow.

Where it is

crates/perry-codegen/src/stmt/loops.rs:5011, in stmt_is_packed_f64_loop_safe, which lumps every abrupt statement into one rejection arm:

Stmt::Return(_)
| Stmt::Throw(_)
| Stmt::Break
| Stmt::Continue
| Stmt::LabeledBreak(_)
| Stmt::LabeledContinue(_)
| Stmt::While { .. }
| Stmt::DoWhile { .. }
| Stmt::For { .. }
| Stmt::Try { .. }
| Stmt::Switch { .. } => false,

It is one predicate, not two, which is what the four identical timings were telling us. The caller at loops.rs:4792 requires every body statement to pass it in order to set read_body_is_safe; when that fails and there is no store kind and no ordinary hoist, admission returns None at loops.rs:4796 and the loop falls to the generic path.

The relaxation this predicate guards is documented just above the call site:

A call-free READ body earns the same relaxation the store arm below documents: the entry guard revalidates the actual receiver/layout and the matched body cannot call out or invalidate it … A wrong static hint is one failed guard -> slow clone, never a wrong answer.

By that argument break, continue, LabeledBreak and LabeledContinue look admissible as written: none of them calls out, mutates the array, or invalidates the entry guard. Return(Some(expr)) and Throw(expr) need their operand checked (a throw new Error(…) does construct), but a bare return does not. Two other places in the same file already treat these statements as harmless for the analogous question — stmt_array_length_effect (loops.rs:7439) and stmt_preserves_array_length (loops.rs:7872) both answer Preserves/true for exactly this set.

The remaining question is not whether the body is safe but whether the emitted fast clone can carry a mid-loop exit edge. There is a related constraint in stmt/stable_packed_loop.rs, which rejects separately with break_replays_current_iteration:

A fast-loop break reaches that clone's exit block. Live-length versions use the same block to enter the generic continuation, so replaying the current iteration would duplicate preceding effects.

That is a shared-block problem — a user break and a guard deopt land on one block meaning two different things ("leave the loop" vs "resume generically at this index") — so a fix likely has to give the user edge its own block before relaxing the predicate above.

Why it matters

Find-first, any/all and early-out scans are the natural shape here, and they are exactly the loops whose exit is rarely taken — so the penalty falls hardest on loops that never actually exit early.

Found via

Isolated while measuring #9149 (the loop-invariant property-array hoist), which takes holder.arr[i] in an early-return loop from 21.59 to 3.59 ns; this is the residual. The two are independent — the table above uses a bare local with no hoisting involved.

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