diff --git a/packages/compiler/src/ir/integer-loop-facts.ts b/packages/compiler/src/ir/integer-loop-facts.ts index 23982aa07..b075ef29a 100644 --- a/packages/compiler/src/ir/integer-loop-facts.ts +++ b/packages/compiler/src/ir/integer-loop-facts.ts @@ -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(); 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(); 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 { + const ids = new Set(); + 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 { diff --git a/packages/compiler/src/ir/integer-ranges.test.ts b/packages/compiler/src/ir/integer-ranges.test.ts index 32caea672..b495eb814 100644 --- a/packages/compiler/src/ir/integer-ranges.test.ts +++ b/packages/compiler/src/ir/integer-ranges.test.ts @@ -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;