From 8bc8fc54ca42e5e4abea07afea8ff7b0a495e423 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 17:47:26 -0500 Subject: [PATCH] Bound string reads of while loops by their counter tests With while-loop versions, `while (pos < end && s.charCodeAt(pos) === 32) pos++` took a version whose guard could never hold. The counter scan records only typed-array accesses, so the string read fell back to the statically ranged check. For a counter held in an int32 slot, that check asks for a length above 2^31 - 2. The loop paid for the guard and ran its checked copy. The counter scan now records `s.charCodeAt(i)` and `s.charAt(i)` of a string like typed-array reads. Their guard compares the counter's bound with the string's length, as for a typed array. Corpus 7302 adds while loops over counters below a string's length. --- .../backend/llvm/bytes-loop-versions.test.ts | 45 +++++++++++ .../src/backend/llvm/bytes-loop-versions.ts | 29 ++++--- .../corpus/7302-string-scan-loop-versions.ts | 76 ++++++++++++++++++- 3 files changed, 137 insertions(+), 13 deletions(-) diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts index f356b9849..805715386 100644 --- a/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts +++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts @@ -557,3 +557,48 @@ test("the guard tests a string's ASCII bit and the fast copy reads bytes", () => // The checked copy keeps the inline helper and its fallback. expect(body).toContain("@sc_str_char_code_at_int"); }); + +test("while loop counters below a string's length version its reads", () => { + // while (i < text.length && text.charCodeAt(i) === 32) i = i + 1 + const text = ref("text", STR); + const read = charCode(text, ref("i")); + const length: IrExpr = { + kind: "strIntrinsic", + method: "length", + receiver: text, + args: [], + type: F64, + loc, + }; + const loop: IrStmt & { kind: "while" } = { + kind: "while", + cond: { + kind: "logical", + op: "&&", + left: { kind: "bin", op: "<", left: ref("i"), right: length, type: BOOL, loc }, + right: { kind: "bin", op: "===", left: read, right: num(32), type: BOOL, loc }, + type: BOOL, + loc, + }, + body: [{ kind: "assign", localId: "i", value: bin("+", ref("i"), num(1)), loc }], + loc, + }; + const f = textKernel([{ kind: "varDecl", localId: "i", init: ref("k"), loc }, loop]); + const version = matchBytesLoopVersion(loop, null, { + locals: new Map(f.locals.map((l) => [l.id, l])), + invariantGlobals: new Set(), + captured: new Set(), + ranges: analyzeIntegerRanges(f), + initialized: findInitializedByteLoopBindings(f).get(loop) ?? new Set(), + proven: new Set(), + scalarStrings: new Set(), + workers: false, + owned: new Set(), + })!; + // The read is bounded by the counter's test against the length, not by + // a static range the guard could not meet. + expect(version.bounds.has(read)).toBe(true); + expect(version.receivers.map((r) => r.string)).toEqual([true]); + expect(version.bounded.map((c) => c.slack)).toEqual([0]); + expect(version.fixed).toEqual([]); +}); diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts index c73344baf..35937328f 100644 --- a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts +++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts @@ -711,17 +711,18 @@ function counterStep(s: IrStmt): { id: string; k: number; wraps: boolean } | nul return { id: s.localId, k: value.right.value, wraps }; } -/** Typed-array accesses of a while loop that happen at a counter below a - * loop-invariant bound. A counter is a number local that the loop changes - * only by positive integer increments, written as statements of the body, - * of its blocks and branches, or of nested while loops. The condition of - * the loop, an `if` or a `&&` chain that compares the counter below a bound - * (`while (pos < end && bytes[pos] === 32) pos++`) makes it below that - * bound until the next increment on the path. The counter starts at its - * entry value and only grows (a `| 0` increment never wraps when every - * bound is at most 2^31 minus the increments), so an access at `counter + - * d` is in bounds for the whole loop when the entry value is at least `-d` - * and `bound + d < length` (`+ 1` for `<=`). */ +/** Typed-array and string accesses of a while loop that happen at a + * counter below a loop-invariant bound. A counter is a number local that + * the loop changes only by positive integer increments, written as + * statements of the body, of its blocks and branches, or of nested while + * loops. The condition of the loop, an `if` or a `&&` chain that compares + * the counter below a bound (`while (pos < end && bytes[pos] === 32) + * pos++`, or `s.charCodeAt(pos)` of a string) makes it below that bound + * until the next increment on the path. The counter starts at its entry + * value and only grows (a `| 0` increment never wraps when every bound is + * at most 2^31 minus the increments), so an access at `counter + d` is in + * bounds for the whole loop when the entry value is at least `-d` and + * `bound + d < length` (`+ 1` for `<=`). */ function findWhileCounterAccesses( loop: IrStmt & { kind: "while" }, host: { @@ -831,6 +832,12 @@ function findWhileCounterAccesses( : undefined; if (index) record(e, index, clean); } + if ( + e.kind === "strIntrinsic" && + (e.method === "charCodeAt" || e.method === "charAt") && + e.args[0] + ) + record(e, e.args[0], clean); if (e.kind === "logical" && e.op === "&&") { expr(e.left, clean); expr(e.right, withFacts(clean, e.left)); diff --git a/tests/corpus/7302-string-scan-loop-versions.ts b/tests/corpus/7302-string-scan-loop-versions.ts index a8a975d86..3408e048e 100644 --- a/tests/corpus/7302-string-scan-loop-versions.ts +++ b/tests/corpus/7302-string-scan-loop-versions.ts @@ -2,8 +2,10 @@ // text, surrogate pairs and lone surrogates, empty strings, neighbors at // i - 1 and i + 1, loops that stop early or read past the end, strings built // by concatenation and slicing, a scan of a slice local, two strings read at -// one index, a string rebound inside or after the loop, nested scans and a -// module-level scan. +// one index, a string rebound inside or after the loop, nested scans, a +// module-level scan, and while loops over a counter below the length: word +// and identifier scans, peeks past the counter, a counter from a field, and +// reads at or past the end. function spaces(s: string): number { let n = 0; @@ -75,6 +77,75 @@ function rebound(s: string): number { return n; } +function words(s: string): number { + let pos = 0, + n = 0; + const end = s.length; + while (pos < end) { + while (pos < end && s.charCodeAt(pos) === 32) pos++; + if (pos >= end) break; + n++; + while (pos < end && s.charCodeAt(pos) !== 32) pos++; + } + return n; +} + +function identifiers(s: string): number { + let pos = 0, + total = 0; + while (pos < s.length) { + const c = s.charCodeAt(pos); + if (c >= 97 && c <= 122) { + const start = pos; + while (pos < s.length && s.charCodeAt(pos) >= 97 && s.charCodeAt(pos) <= 122) pos++; + total += pos - start; + } else pos = (pos + 1) | 0; + } + return total; +} + +function pairs(s: string): number { + let pos = 0, + n = 0; + while (pos < s.length) { + if (s.charCodeAt(pos) === s.charCodeAt(pos + 1)) n++; + pos += 1; + } + return n; +} + +function throughEnd(s: string): string { + let pos = 0, + sum = 0; + while (pos <= s.length) { + sum += s.charCodeAt(pos); + pos++; + } + return String(sum); +} + +class Cursor { + pos = 0; + src: string; + constructor(src: string) { + this.src = src; + } + skip(): number { + const src = this.src; + let pos = this.pos; + while (pos < src.length && src.charCodeAt(pos) !== 44) pos++; + this.pos = pos + 1; + return pos; + } +} + +function fields(s: string): string { + const cursor = new Cursor(s); + const ends: number[] = []; + while (cursor.pos < s.length) ends.push(cursor.skip()); + return ends.join(","); +} + const samples = [ "the quick brown fox jumps over the lazy dog", "", @@ -99,6 +170,7 @@ for (const s of samples) { rebound(s), slicedScan(s, 2, 9), ); + console.log(" ", words(s), identifiers(s), pairs(s), throughEnd(s), fields(s)); } let built = "";