Sibling of #9259, and the mirror image of it: there the arr.length spelling was the slow one, here it is the fast one. Same underlying shape — two tiers with different admission power, and which one you get depends on how the bound is written.
The range tier admits c += a[k] but rejects any if (...) c++ body. The versioned tier admits both. So a conditional-count loop gets a packed clone only when its bound is arr.length.
Measured
Self-timed, min of 5, --no-cache --no-auto-optimize, 4096-element array, 2000 outer iterations. Every timing paired with a packed_f64.* block count from the emitted IR; all five fixtures produce node-identical output.
| bound |
body |
packed blocks |
perry |
node |
|
k < 4096 |
c += a[k] |
12 |
8 ms |
9 ms |
0.89x |
k < 4096 |
if (a[k] > 0.0) c++ |
0 |
31 ms |
10 ms |
3.10x |
k < 4096 |
if (a[k] > a[k-1]) c++ |
0 |
49 ms |
10 ms |
4.90x |
k < a.length |
if (a[k] > 0.0) c++ |
10 |
4 ms |
10 ms |
0.40x |
k < a.length |
if (a[k] > a[k-1]) c++ |
12 |
19 ms |
10 ms |
1.90x |
Rows 2 and 4 are the finding: identical body, only the bound spelling differs, 31 ms against 4 ms — a 7.75x swing that is the difference between losing to node by 3.1x and beating it by 2.5x.
function run(a: number[]): number {
let c = 0.0;
for (let r = 0; r < 2000; r++) {
for (let k = 1; k < 4096; k++) { // <-- `a.length` here is 7.75x faster
if (a[k] > 0.0) c++;
}
}
return c;
}
Where it declines
The versioned tier's body walker (expr_is_packed_f64_loop_safe) handles Expr::Compare by recursing into both operands, and integer c++ accumulators admit independently of the float set (admit_integer_update_accumulators). The range tier's body walk is narrower — its read-only mode is documented at stmt/loops.rs:1596 as taking "any number of scalar ..." statements, and an If carrying the read is not among them.
Note the versioned matcher emits no rejection for the literal-bound rows, because it never gets a candidate at all: with no arr.length in the condition there is no length hoist to classify, so it exits before any packed_loop_reject. The range matcher then declines silently. The result is a loop that falls off both tiers with no diagnostic from either — the range-matcher rejection trace in #9258 would have named it, which is a good argument for that trace.
Why this is worth fixing rather than documenting
for (let i = 0; i < N; i++) if (pred(a[i])) count++ is an extremely common shape, and a constant bound is the more natural spelling when the array length is known — which is exactly when perry does worst. Row 4 shows the tier is worth 0.40x node on this shape when it does apply, so the gap is not a small one being missed.
Two directions, in preference order:
- Widen the range tier's body walk to admit a conditional whose branches are scalar statements, matching what the versioned tier already accepts. That keeps each tier's guard unchanged and only relaxes an admission shape.
- Let the versioned tier accept a constant bound, so it can claim these loops. Larger change, and it duplicates a proof the range tier already has.
Method note
The four earlier attempts at this family (#9259 included) each began with a fixture that never reached the tier and therefore proved nothing, so every row above is paired with a block count from PERRY_LLVM_KEEP_IR=1 (path reported on stderr) rather than a timing alone. Row 1 is the positive control: it shares the literal bound with rows 2 and 3 and does get a clone, so the zeros in those rows are attributable to the body shape and not to the bound.
https://claude.ai/code/session_01Pcq6j6y57TdKSR2Zx2D187
Sibling of #9259, and the mirror image of it: there the
arr.lengthspelling was the slow one, here it is the fast one. Same underlying shape — two tiers with different admission power, and which one you get depends on how the bound is written.The range tier admits
c += a[k]but rejects anyif (...) c++body. The versioned tier admits both. So a conditional-count loop gets a packed clone only when its bound isarr.length.Measured
Self-timed, min of 5,
--no-cache --no-auto-optimize, 4096-element array, 2000 outer iterations. Every timing paired with apacked_f64.*block count from the emitted IR; all five fixtures produce node-identical output.k < 4096c += a[k]k < 4096if (a[k] > 0.0) c++k < 4096if (a[k] > a[k-1]) c++k < a.lengthif (a[k] > 0.0) c++k < a.lengthif (a[k] > a[k-1]) c++Rows 2 and 4 are the finding: identical body, only the bound spelling differs, 31 ms against 4 ms — a 7.75x swing that is the difference between losing to node by 3.1x and beating it by 2.5x.
Where it declines
The versioned tier's body walker (
expr_is_packed_f64_loop_safe) handlesExpr::Compareby recursing into both operands, and integerc++accumulators admit independently of the float set (admit_integer_update_accumulators). The range tier's body walk is narrower — its read-only mode is documented atstmt/loops.rs:1596as taking "any number of scalar ..." statements, and anIfcarrying the read is not among them.Note the versioned matcher emits no rejection for the literal-bound rows, because it never gets a candidate at all: with no
arr.lengthin the condition there is no length hoist to classify, so it exits before anypacked_loop_reject. The range matcher then declines silently. The result is a loop that falls off both tiers with no diagnostic from either — the range-matcher rejection trace in #9258 would have named it, which is a good argument for that trace.Why this is worth fixing rather than documenting
for (let i = 0; i < N; i++) if (pred(a[i])) count++is an extremely common shape, and a constant bound is the more natural spelling when the array length is known — which is exactly when perry does worst. Row 4 shows the tier is worth 0.40x node on this shape when it does apply, so the gap is not a small one being missed.Two directions, in preference order:
Method note
The four earlier attempts at this family (#9259 included) each began with a fixture that never reached the tier and therefore proved nothing, so every row above is paired with a block count from
PERRY_LLVM_KEEP_IR=1(path reported on stderr) rather than a timing alone. Row 1 is the positive control: it shares the literal bound with rows 2 and 3 and does get a clone, so the zeros in those rows are attributable to the body shape and not to the bound.https://claude.ai/code/session_01Pcq6j6y57TdKSR2Zx2D187