Repository navigation
Conversation
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>
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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>
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 bya.length - 1, or bounded byconst n = a.lengthalready 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.tsaddspreservesArrayLength, a conservative syntactic proof that a loop's condition and body never change the length of the array held by a local. The rules:try,switch, or suspension.length,getNumber,indexOf,includes,nextPresent,indexEq). It therefore cannot escape or be aliased inside the loop.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 areturnvalue.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.ir/integer-loops.ts) accept a stablea.lengthas 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.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 keepsias 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 = 3e9creates a legal sparse array with no storage, andfilledis a uint32. The cheapest sound fact is therefore a guard hoisted out of the loop.backend/llvm/dense-array-loops.tsversions 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, andfilledbound (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:undefined, sparse storage, or partially filled targets;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
filledbound, 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.
b[i] = a[i] * 2(scale)for (i = a.length - 1; i >= 0; i--) s += a[i]while (i < a.length) { s += a[i]; i++ }for (i = 0; i < a.length; i += 2)s += a[i] - a[i - 1]from 1number[](fadd latency bound)ps[i].x * ps[i].ya.lengtheach passhelper(a, i)number[]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:intern-table-mapreads +3.7% (CI +0.2% .. +4.6%). Its LLVM differs from base only by reorderedtrunc/sitofppairs and a fewsitofp i32counters. 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:The self-hosting
--emit=irbuild ofpackages/compiler/src/native/cli.tssucceeds with no diagnostics (3:22, 3.9 GB).Negative result
Converting every uint32 counter through
uitofp i32folded 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
ir/array-length-stability.test.ts,backend/llvm/dense-array-loops.test.ts, plus new cases inir/integer-loops.test.tsandir/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). Thesrc/irandsrc/backend/llvmsuites pass: 920/920.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,splicein a descending loop, a while loop that pushes, nested resizing, and pushing into a different array.5994-array-length-sparse-loops.ts:a.length = 3e9and 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.599[345]|array|loop|induction|counted): 323 programs pass.pnpm lint,pnpm format:check,pnpm test:ts7 --baselines-only(with baselines recorded for the three new fixtures), andtests/harness/self-hosting-native-driver.test.ts.What remains
g[r].lengthwithout arowbinding: the receiver is a hidden temporary, so the counter stays a double.uitofp i32a net loss.(s + a[i]) | 0reductions are bound by ToInt32 latency, not lengths (5.5 ns vs Node's 1.1 ns).🤖 Generated with Claude Code