Skip to content

Prove array lengths stable in index loops and version dense array loops - #1247

Open
cramforce wants to merge 7 commits into
mainfrom
opt/array-length-ranges
Open

cramforce wants to merge 7 commits into
mainfrom
opt/array-length-ranges

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

Summary

Index loops over arrays kept their counter as a double in several common shapes, and every element access at the counter tested the array's hole-free prefix on each pass. This change proves when a loop cannot change an array's length and uses that proof in two ways: the counter becomes a native integer, and a guarded fast copy of the loop drops the per-element checks so LLVM can vectorize it.

What base already handled: for (let i = 0; i < a.length; i++) (start 0, or an enclosing index plus one, step 1) already had an i64 counter (matchIntegerArrayForLoop), and LLVM already hoisted the length when the body made no calls. Loops starting at 1, bounded by a.length - 1, or bounded by const n = a.length already kept their counter as a uint32 local, because the condition is re-tested on each pass. Those loops are not slower on base. The gaps were descending loops, strided loops, while (i < a.length) loops, and the prefix test on every access.

Part 1: array length as a stable loop bound

  • ir/array-length-stability.ts adds preservesArrayLength, a conservative syntactic proof that a loop's condition and body never change the length of the array held by a local. The rules:
    • Every node must come from a whitelist of forms that run no user code: no calls, callbacks, closures, iterators, try, switch, or suspension.
    • The array binding is never written, and is read only as the receiver of an element access or a reading method (length, getNumber, indexOf, includes, nextPresent, indexEq). It therefore cannot escape or be aliased inside the loop.
    • Element writes are accepted only at a counter the condition keeps below the length. Such a write can never grow the array, even through an alias.
    • Resizing methods and writes at other indices are accepted only on unaliased local arrays (findUnaliasedLocalArrays), and never on the receiver itself. An unaliased array is a binding whose every write is a fresh literal and which is only ever a method or element receiver, or a return value.
    • push, pop, shift, unshift, splice, fill, length =, delete, writes past the end, helpers, closures, aliases and parameters that might alias the receiver all refuse the proof.
  • Counted loops (ir/integer-loops.ts) accept a stable a.length as a hoisted limit. An element access at the counter now counts as an integer consumer for loops that need no versioned copy. Strided (i += 2) and descending (i = a.length - 1; i >= 0; i--) loops get an i64 counter as a result.
  • Array lengths bound while-loop and for-loop counters in the range analysis (ir/integer-loop-facts.ts) through their range, [0, 2^32 - 1]. That bound needs no stability proof, because every evaluation of a length lies in it. while (i < a.length) { …; i++ } now keeps i as a uint32 local. A strict < bound also admits one fewer pass, which tightens secondary cursors.

Part 2: int32 lengths for dense arrays, soundly

The runtime gives no int32 invariant for array lengths: a.length = 3e9 creates a legal sparse array with no storage, and filled is a uint32. The cheapest sound fact is therefore a guard hoisted out of the loop. backend/llvm/dense-array-loops.ts versions a call-free, innermost loop whose bounding length is stable (part 1) and which indexes arrays at its counter. Before the loop, one test checks:

  • length <= 2^31 - 1, and
  • the length is at most the filled bound (the hole-free prefix) of every array indexed at the counter.

When the test passes, every counter value names a present value in dense storage. The fast copy therefore takes the prefix path unconditionally (emitDenseGuard(…, proven)), and its counter carries an int32 range, which converts to double from its low half (sitofp i32). Other cases take the unchanged loop as the slow copy:

  • holes, present undefined, sparse storage, or partially filled targets;
  • arrays longer than 2^31 - 1, which in practice are only sparse ones.

An indexed array is covered only if the loop does not rebind it, resize it, or write it at another index, and only if it is definitely assigned before the loop. Its storage, and its filled bound, therefore cannot move or shrink while the loop runs. The duplicated body is capped at 120 nodes, and nested loops are refused.

Evidence

All measurements were taken in Vercel Sandboxes (Linux x86-64, 8 vCPU). The A/B baseline is the parent commit 6ce668b21, built with its own runtime and LLVM helper.

Microbenchmarks

Each kernel is its own binary, timed in-process after warm-up, with 10k elements and 20k repetitions. Figures are ns per element, as the median of 7 runs, on an Intel Xeon @ 2.5 GHz sandbox with the final candidate.

loopbasethis PRNode 24
b[i] = a[i] * 2 (scale)1.430.301.35
for (i = a.length - 1; i >= 0; i--) s += a[i]4.451.321.53
while (i < a.length) { s += a[i]; i++ }1.901.351.53
for (i = 0; i < a.length; i += 2)0.970.710.79
s += a[i] - a[i - 1] from 11.691.341.67
sum number[] (fadd latency bound)1.401.431.56
object array ps[i].x * ps[i].y1.321.343.70
nested 2-D1.331.311.19
reads a.length each pass1.331.341.64
calls helper(a, i)2.922.921.36
pushes into a different array5.305.047.11
max of number[]1.511.551.84

An earlier run on a different (AMD) sandbox, before the int32-only conversion change, agreed on the wins: scale 0.93 → 0.18, descending 4.34 → 1.14, while 1.77 → 1.14, and step 2 0.885 → 0.567.

Runtime suite

node scripts/bench-runtime.mjs --baseline=/vercel/sandbox/prev --candidate=. --layouts=4 --runs=16:

  • Geomean change is −0.4%, and every binary's size change is +0.0%.
  • 30 of 31 workloads are neutral.
  • intern-table-map reads +3.7% (CI +0.2% .. +4.6%). Its LLVM differs from base only by reordered trunc/sitofp pairs and a few sitofp i32 counters. The previous candidate's run measured it at +0.4% (CI −2.3% .. +3.5%), so this reading is layout noise.

Self-hosted compiler

Built with scripts/build-native-cli.mts, each tree compiling its own source:

  • Binary size: 48,204,896 B → 48,407,344 B (+0.42%). This includes the two new analysis modules.
  • Wall time: 11:29 → 10:37. Base was built while the machine was partly busy, so this is not a speedup.
  • Peak RSS: 4.5 GB → 5.6 GB, a single sample.

The self-hosting --emit=ir build of packages/compiler/src/native/cli.ts succeeds with no diagnostics (3:22, 3.9 GB).

Negative result

Converting every uint32 counter through uitofp i32 folded an inlined helper's index round trip (helper(a, i): 2.95 → 1.35 ns on Intel). However, the extra zero extension slowed loops that convert the counter only for a cold fallback (max-of 1.5 → 2.3 ns, object sum 1.33 → 1.59 ns). Disabling only that conversion restored both. Only int32 counters convert from their low half now.

Tests

  • New unit tests: ir/array-length-stability.test.ts, backend/llvm/dense-array-loops.test.ts, plus new cases in ir/integer-loops.test.ts and ir/integer-ranges.test.ts. One expectation in the existing strided-loop range test tightened from 124 to 123 (the last pass enters at 99 at most). The src/ir and src/backend/llvm suites pass: 920/920.
  • New corpus programs (Node and native output byte-identical; also passing in the sanitized lane):
    • 5993-array-length-loop-resizing.ts: push in the body, strided push, length = truncation and growth, a helper that pushes, a closure that pushes, aliases and aliased parameters, writes past the end, shift, splice in a descending loop, a while loop that pushes, nested resizing, and pushing into a different array.
    • 5994-array-length-sparse-loops.ts: a.length = 3e9 and a length of 2^32 − 1, iterated partially ascending, descending, strided and with while loops, including dense-versioned loops whose guard fails; writes at the counter; holes; an array that becomes sparse or holey mid-loop; hole reads on object arrays.
    • 5995-array-dense-loop-boundaries.ts: dense, holey, partially filled, short and self-aliased targets; fractional, negative and offset bounds; <=; descending and strided loops; while loops; const n = a.length; object, boolean, string and (number | undefined)[] arrays; nested loops; early exits.
  • Full differential corpus, run as 8 shards: 64 of 2933 programs fail. The same 64 fail on base in the same sandbox; they are Node 24.15 versus the pinned 24.22 surfaces.
  • Sanitized differential subset (599[345]|array|loop|induction|counted): 323 programs pass.
  • Also passing: pnpm lint, pnpm format:check, pnpm test:ts7 --baselines-only (with baselines recorded for the three new fixtures), and tests/harness/self-hosting-native-driver.test.ts.

What remains

  • Inner loops bounded by g[r].length without a row binding: the receiver is a hidden temporary, so the counter stays a double.
  • An i32 counter slot for uint32 array loops would let an inlined callee's index conversion fold without the zero extension that made uitofp i32 a net loss.
  • (s + a[i]) | 0 reductions are bound by ToInt32 latency, not lengths (5.5 ns vs Node's 1.1 ns).
  • Pushing into a parameter array that may alias the bound keeps the per-iteration length read. That is correct and cheap.

🤖 Generated with Claude Code

cramforce and others added 4 commits October 17, 2026 14:48
Index loops over arrays left the counter a double in several common
shapes, and every element access at the counter tested the array's
hole-free prefix on each pass:

- A syntactic proof (ir/array-length-stability.ts) shows that a loop never
  changes an array's length: the body runs no user code, never rebinds the
  array or lets it escape, and only resizes local arrays that no other
  binding can hold. Element writes at a counter kept below the length are
  accepted.
- Counted loops take a stable `a.length` as their limit, and an element
  access at the counter makes an unversioned counted loop worthwhile, so
  strided (`i += 2`) and descending (`i = a.length - 1; i >= 0`) loops get
  an integer counter.
- Array lengths bound while-loop and for-loop counters by their range,
  which needs no stability proof.
- A loop whose array length is stable and whose indexed arrays keep their
  storage runs as two copies. One test before the loop (the length is at
  most 2^31 - 1 and at most each indexed array's `filled` bound) selects a
  fast copy whose element accesses at the counter skip the prefix test and
  whose counter is an int32. LLVM can vectorize that copy.
- Loop counters whose ranges fit in 32 bits convert to double from their
  low half, so an inlined callee's index conversion folds away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A uint32 counter converted through `uitofp i32` folds an inlined callee's
index conversion, but the zero extension it adds on x86-64 slowed loops
that only convert the counter for a cold fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Oct 18, 2026 1:28pm UTC

The native range analysis now admits one fewer pass for a strict bound,
so the failing condition of `for (x = 0; x < 100; x += 24)` reads at
most 123 (the last pass enters at 99 at most).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cramforce and others added 2 commits October 18, 2026 06:26
The shard harness requires measured costs for more than 90% of the corpus.
Take the costs of programs 5993-5995 from this branch's green Linux shard
logs the way scripts/ci-corpus-costs.mjs computes them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — d49fcf3c Deployed Oct 18, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant