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.
This commit is contained in:
Chris Tate
2026-10-22 17:47:26 -05:00
parent edb2755a9b
commit 8bc8fc54ca
3 changed files with 137 additions and 13 deletions
@@ -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([]);
});
@@ -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));
+74 -2
View File
@@ -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 = "";