Skip to content

perf: hoist loop-invariant Buffer length load out of the bounds-check in bracket reads #52

Description

@proggeramlug

Context

Follow-up to #47. With inline ldrb / strb for Buffer bracket access now in place, the bounds check is still re-loading the buffer length from memory on every single read. Disassembly of the image_conv blur inner loop (post-#47/#48):

0x100000c38  ldr   w13, [x21]      ; ← len = *src, reloaded every iter
0x100000c3c  cmp   w13, w14
0x100000c40  b.hi  .oob
0x100000c44  fadd  d17, d16, d8    ; next index
0x100000c48  fcvtzs w14, d17
0x100000c4c  cmp   w13, w14
0x100000c50  b.hi  .oob
...
0x100000c68  ldr   x15, [sp, #0xe8]
0x100000c6c  strb  w13, [x15, w14, uxtw]
0x100000c80  ldr   w13, [x21]      ; ← len reloaded AGAIN

The issue

x21 is &src[0].header.length — the BufferHeader.length u32 at offset 0. This is loop-invariant because:

  • src is bound to a const global Buffer.alloc(N)
  • N is a module-level const
  • The only code paths that would change the length are Buffer.concat / buf.slice assignments, none of which happen in the blur loop

Yet we see ldr w13, [x21] every single time a bounds check fires — 200M+ times per blur iteration. That's an L1-hitting load so not catastrophic, but it's dead weight and it blocks LLVM from CSE'ing the check.

Proposal

Two things, either independently useful:

  1. Tag the length load with !invariant.load (LLVM metadata) when the buffer pointer is known not to be the target of concat/slice/reassign in the live range. LLVM's GVN + LICM passes will hoist it to the preheader automatically.

  2. Emit the length to a SSA local at function entry for buffers declared at module scope or in non-escaping local scope, and let subsequent bracket accesses use the SSA value instead of reloading. Bypasses LLVM needing to see the invariance.

The cleaner fix is (1) — gives LLVM all the information and lets it decide. (2) is a fallback if the analysis for "safe to mark invariant" is too conservative.

Impact

Third-order compared to #49/#50 (microarchitectural, not algorithmic) but it's the reason the per-access overhead is 4 instructions instead of 2 — and with #50 unblocking autovectorization, the vector paths won't generate if the bounds check blocks them. Worth landing in the same cluster.

Scope

Any inline buffer/typed-array access emitted by #47. Also applies to arr.length reads in ordinary Array<T> bracket access.

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