Bound while-loop counters that nested scans advance

A while loop's counter gets an integer range when the loop only adds
positive literals to it and its condition bounds it. A counter written
inside a nested loop was refused, which is every hand-written scanner:
`while (pos < end) { ... while (pos < end && isDigit(...)) pos++; ... }`.

A nested while loop may now advance the counter when its own condition
bounds it the same way and does not write it. Its passes start at most
at that bound, so it leaves the counter at its entry value or at the
highest value one of its passes reaches, and the outer loop's range
follows from there. Other nested loops, and nested loops that do not
bound the counter, still refuse it.
This commit is contained in:
Chris Tate
2026-10-21 23:49:13 -05:00
parent 393201e693
commit 956bdf4ec0
2 changed files with 130 additions and 12 deletions
+62 -12
View File
@@ -246,10 +246,15 @@ export const STRING_MAX_UNITS = 0x7fffffff;
/** Bound counters of a while loop by its condition. A counter qualifies when
* the loop (condition and body) changes it only by increments of positive
* integer literals outside nested loops, switch and exception regions, and a
* conjunct of the condition compares it below a literal, a binding the loop
* never writes, or the length of a string binding the loop never writes
* (`while (x * x + y * y <= 4 && it < MAX) it++`, `while (pos < text.length)`).
* integer literals outside switch and exception regions and loops other
* than nested while loops, and a conjunct of the condition compares it below
* a literal, a binding the loop never writes, or the length of a string
* binding the loop never writes (`while (x * x + y * y <= 4 && it < MAX)
* it++`, `while (pos < text.length)`). A nested while loop may advance the
* counter only when its own condition bounds it the same way and does not
* write it (a scanner's `while (pos < end && isDigit(...)) pos++`): its
* passes start at most at that bound, so it leaves the counter at its entry
* value or at the highest value one of its passes reaches.
* The body runs only after that conjunct held, and the condition never
* writes the counter, so every pass starts at most at the conjunct's bound.
* The pass then climbs by the increments on its path; an increment inside an
@@ -270,19 +275,29 @@ export function guardedLoopCounterFacts(
if (opaque || delta === null || delta <= 0 || delta > 2147483647) refused.add(id);
else budget.set(id, (budget.get(id) ?? 0) + delta);
};
// Nested while loops outside opaque regions, checked against their own
// conditions once the stable bounds are known.
const nested: (IrStmt & { kind: "while" })[] = [];
const expr = (e: IrExpr): boolean => {
if (e.kind === "assignExpr") write(e.localId, increment(e.localId, e.value));
if (e.kind === "incDec") write(e.localId, e.op === "+" ? 1 : null);
return everyExprChild(e, expr, stmt);
};
const condition = new Set<string>();
const stmt = (s: IrStmt): boolean => {
const bounded = s.kind === "while" && opaque === 0 && !s.labels?.length;
const region =
s.kind === "for" ||
s.kind === "while" ||
s.kind === "doWhile" ||
s.kind === "forOf" ||
s.kind === "tryCatch" ||
s.kind === "switch";
!bounded &&
(s.kind === "for" ||
s.kind === "while" ||
s.kind === "doWhile" ||
s.kind === "forOf" ||
s.kind === "tryCatch" ||
s.kind === "switch");
if (bounded) {
nested.push(s);
conditionWrites(s.cond);
}
if (region) opaque++;
if (s.kind === "assign") write(s.localId, increment(s.localId, s.value));
if (s.kind === "varDecl" || s.kind === "forOf") write(s.localId, null);
@@ -293,7 +308,6 @@ export function guardedLoopCounterFacts(
};
// The condition is evaluated before every pass; a write there would
// separate the compared value from the one the body sees.
const condition = new Set<string>();
const conditionWrites = (e: IrExpr): boolean => {
if (e.kind === "assignExpr" || e.kind === "incDec") condition.add(e.localId);
return everyExprChild(e, conditionWrites, (s) => {
@@ -304,8 +318,9 @@ export function guardedLoopCounterFacts(
});
};
conditionWrites(loop.cond);
for (const id of condition) refused.add(id);
loop.body.every(stmt);
// Nested loops' conditions are collected during the walk.
for (const id of condition) refused.add(id);
const stable = (id: string): boolean => {
const local = locals.get(id);
return local !== undefined && !local.boxed && !local.tdz && !budget.has(id) && !refused.has(id);
@@ -359,6 +374,11 @@ export function guardedLoopCounterFacts(
}
return ceiling;
};
// A nested while loop advances only the counters its condition bounds.
for (const inner of nested) {
const caps = ceilings(inner.cond);
for (const id of writtenLocals(inner.body)) if (!caps.has(id)) refused.add(id);
}
const ceiling = ceilings(loop.cond);
for (const id of budget.keys()) {
const local = locals.get(id),
@@ -416,6 +436,21 @@ function highestCount(
count = Math.max(then, else_);
} else if (s.kind === "block" && !s.labels?.length) {
count = walk(s.body, count);
} else if (
s.kind === "while" &&
!s.labels?.length &&
increments(s.cond, true) === 0 &&
ceilings(s.cond).has(id)
) {
// Every pass starts at most at the bound; the loop ends at its entry
// value or where a pass left the counter.
const cap = ceilings(s.cond).get(id)!;
const outer = highest;
highest = cap;
walk(s.body, cap);
const inner = highest;
highest = Math.max(outer, inner);
count = Math.max(count, inner);
} else {
count += increments(s, false);
highest = Math.max(highest, count);
@@ -435,6 +470,21 @@ function highestCount(
return highest;
}
/** The bindings `body` assigns or increments anywhere. */
function writtenLocals(body: readonly IrStmt[]): Set<string> {
const ids = new Set<string>();
const expr = (e: IrExpr): boolean => {
if (e.kind === "assignExpr" || e.kind === "incDec") ids.add(e.localId);
return everyExprChild(e, expr, stmt);
};
const stmt = (s: IrStmt): boolean => {
if (s.kind === "assign") ids.add(s.localId);
return everyStmtChild(s, expr, stmt);
};
body.every(stmt);
return ids;
}
/** Recognize only exits whose target cannot be inside this statement list.
* Labeled blocks, loops, switch and exception regions retain the join. */
export function integerPathFallsThrough(body: readonly IrStmt[]): boolean {
@@ -797,6 +797,74 @@ test("a while condition on a string's length bounds a counter, and guarded incre
expect(run((inside) => [observe(inside), bump, rebound]).after).toBeNull();
});
test("a nested while loop may advance a counter its own condition bounds", () => {
// while (pos < text.length) { observe(pos); while (pos < text.length && unknown === 1) pos++; pos += 2; }
const text: IrExpr = { loc, kind: "varRef", localId: "text", type: STRING };
const length: IrExpr = {
loc,
kind: "strIntrinsic",
method: "length",
receiver: text,
args: [],
type: F64,
};
const bump: IrStmt = {
loc,
kind: "exprStmt",
expr: { loc, kind: "incDec", op: "+", prefix: false, localId: "pos", type: F64 },
};
const skip: IrStmt = { loc, kind: "assign", localId: "pos", value: bin("+", ref("pos"), num(2)) };
const and = (left: IrExpr, right: IrExpr): IrExpr => ({
loc,
kind: "logical",
op: "&&",
left,
right,
type: BOOL,
});
const scan = (cond: IrExpr): IrStmt => ({ loc, kind: "while", cond, body: [bump] });
const run = (inner: IrStmt) => {
const inside = ref("pos"),
after = ref("pos");
const f = fn([
{ loc, kind: "assign", localId: "pos", value: num(0) },
{
loc,
kind: "while",
cond: condition("<", ref("pos"), length),
body: [observe(inside), inner, skip],
},
observe(after),
]);
f.locals.push(
{ id: "pos", name: "pos", type: F64, mutable: true },
{ id: "text", name: "text", type: STRING, mutable: false },
);
const ranges = analyzeIntegerRanges(f);
return { inside: ranges.get(inside), after: ranges.get(after) };
};
// Each inner pass starts below the length, so the scan stops at it at the
// latest; the skip then adds 2.
const bounded = scan(
and(condition("<", ref("pos"), length), condition("===", ref("unknown"), num(1))),
);
expect(run(bounded)).toEqual({
inside: { min: 0, max: 2147483646 },
after: { min: 0, max: 2147483649 },
});
// A nested loop that does not bound the counter, or writes it in its
// condition, leaves it unbounded.
expect(run(scan(condition("===", ref("unknown"), num(1)))).after).toBeNull();
const writing = scan(
condition(
"<",
{ loc, kind: "incDec", op: "+", prefix: true, localId: "pos", type: F64 },
length,
),
);
expect(run(writing).after).toBeNull();
});
test("an array length bounds while and for counters even when the body resizes the array", () => {
// let i = 0; while (i < items.length) { observe(i); items.push(i); i++; } observe(i);
const ARR = { kind: "array", elem: F64 } as const;