Skip to content

packed-f64: an arr.length-bounded loop loses the fast path entirely if the body has any a[k ± c] access (9x, 8ms -> 72ms) #9259

Description

@proggeramlug

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:

  1. 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.
  2. 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;
    }
  3. 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

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