A packed-f64 loop bounded by arr.length gets a fast clone. Add one non-zero-offset access to the body and it gets no packed fast path at all — not a degraded one, none. Same work, 9× slower, and it flips from beating node to 5.5× behind it.
Measurement
Four fixtures, identical output (16769025000), --no-cache --no-auto-optimize, min of 5–9 runs, self-timed:
| fixture |
bound |
body |
packed_f64.* blocks in IR |
perry |
node |
| A |
k < a.length |
s += a[k] |
10 |
8 ms |
13 ms |
| B |
k < a.length |
s += a[k] + a[k-1] |
0 |
72 ms |
13 ms |
| C |
k < 4096 |
s += a[k] + a[k-1] |
10 |
35 ms |
14 ms |
A beats node. B is the same loop plus one a[k-1] and is 9× slower than A. C is B with the bound written as a literal and is 2.06× faster than B.
Spread is tight and well clear of the effect (B: 72 73 74 74 74 75 75 81; C: 35 35 36 36 36 36 38 44).
function run(a: number[]): number {
let s = 0.0;
for (let r = 0; r < 2000; r++) {
for (let k = 1; k < a.length; k++) { // <-- literal 4096 here = 2x faster
s += a[k] + a[k - 1];
}
}
return s;
}
Mechanism
Two matchers each cover half the shape, and the combination falls between them:
lower_packed_f64_versioned_for (stmt/loops.rs:5823) handles the i < arr.length bound and publishes its fact with window_validated: false — its guard proves i itself in bounds, not an offset window.
packed_f64_loop_fact_for_index (expr/index_get/foreign_counter.rs:76) declines every non-zero offset unless allow_holes || window_validated:
if offset != 0 && !fact.allow_holes && !fact.window_validated {
return None;
}
lower_packed_f64_range_versioned_for (stmt/loops.rs:5830) is the tier that does validate the whole offset window and publishes window_validated: true — but it accepts only a literal or loop-invariant local/global bound, and per its own call-site comment runs "only after the i < arr.length matcher above declined."
So arr.length bound + offset access is covered by neither: the tier that understands the bound rejects the offset, and the tier that understands the offset rejects the bound. arr.length is the more natural way to write the loop, so the idiomatic form is the slow one.
Fixture B's IR confirms it — the loop is claimed by plen.fast / spec_public.fast, with no packed clone anywhere.
Fix direction
The versioned matcher already hoists the length and has constant offsets in hand, so the offset window is provable where it stands: for constant c, a[i+c] is in bounds when i+c < len and i+c >= 0. Either narrow the fast clone's iteration range to the intersected window and publish window_validated: true, or emit the window check in the preheader alongside the existing guard. Letting the range matcher accept an arr.length bound would also work but duplicates what the versioned tier already proves.
Worth checking whether this is the whole of the a[k ± c] gap tracked in #9258 — that investigation found the range matcher does admit a[k + 8] on a literal-bound single-loop fixture, which is consistent with this: the shape only falls off the tier when the bound is arr.length.
Method note
My first two attempts at this reproduced nothing, both because the fixture never reached the tier — a union-typed parameter pushed it onto aidx.dynamic.fast, and the correct-looking timings meant nothing. Every number above is paired with a block-label count from the emitted IR (PERRY_LLVM_KEEP_IR=1, path on stderr) so "the tier fired" is checked rather than assumed.
https://claude.ai/code/session_01Pcq6j6y57TdKSR2Zx2D187
A packed-f64 loop bounded by
arr.lengthgets a fast clone. Add one non-zero-offset access to the body and it gets no packed fast path at all — not a degraded one, none. Same work, 9× slower, and it flips from beating node to 5.5× behind it.Measurement
Four fixtures, identical output (
16769025000),--no-cache --no-auto-optimize, min of 5–9 runs, self-timed:packed_f64.*blocks in IRk < a.lengths += a[k]k < a.lengths += a[k] + a[k-1]k < 4096s += a[k] + a[k-1]A beats node. B is the same loop plus one
a[k-1]and is 9× slower than A. C is B with the bound written as a literal and is 2.06× faster than B.Spread is tight and well clear of the effect (B: 72 73 74 74 74 75 75 81; C: 35 35 36 36 36 36 38 44).
Mechanism
Two matchers each cover half the shape, and the combination falls between them:
lower_packed_f64_versioned_for(stmt/loops.rs:5823) handles thei < arr.lengthbound and publishes its fact withwindow_validated: false— its guard provesiitself in bounds, not an offset window.packed_f64_loop_fact_for_index(expr/index_get/foreign_counter.rs:76) declines every non-zero offset unlessallow_holes || window_validated:lower_packed_f64_range_versioned_for(stmt/loops.rs:5830) is the tier that does validate the whole offset window and publisheswindow_validated: true— but it accepts only a literal or loop-invariant local/global bound, and per its own call-site comment runs "only after thei < arr.lengthmatcher above declined."So
arr.lengthbound + offset access is covered by neither: the tier that understands the bound rejects the offset, and the tier that understands the offset rejects the bound.arr.lengthis the more natural way to write the loop, so the idiomatic form is the slow one.Fixture B's IR confirms it — the loop is claimed by
plen.fast/spec_public.fast, with no packed clone anywhere.Fix direction
The versioned matcher already hoists the length and has constant offsets in hand, so the offset window is provable where it stands: for constant
c,a[i+c]is in bounds wheni+c < lenandi+c >= 0. Either narrow the fast clone's iteration range to the intersected window and publishwindow_validated: true, or emit the window check in the preheader alongside the existing guard. Letting the range matcher accept anarr.lengthbound would also work but duplicates what the versioned tier already proves.Worth checking whether this is the whole of the
a[k ± c]gap tracked in #9258 — that investigation found the range matcher does admita[k + 8]on a literal-bound single-loop fixture, which is consistent with this: the shape only falls off the tier when the bound isarr.length.Method note
My first two attempts at this reproduced nothing, both because the fixture never reached the tier — a union-typed parameter pushed it onto
aidx.dynamic.fast, and the correct-looking timings meant nothing. Every number above is paired with a block-label count from the emitted IR (PERRY_LLVM_KEEP_IR=1, path on stderr) so "the tier fired" is checked rather than assumed.https://claude.ai/code/session_01Pcq6j6y57TdKSR2Zx2D187