From bbefa3639465a689658139eaa847f97de1c0fc83 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 23:19:46 -0500 Subject: [PATCH] Bound loop counters compared as `i + c` with a bound The range analysis narrowed a counter only when the comparison read the counter itself (`i < n`). A comparison of the counter plus or minus an integer literal (`i + 1 < xs.length`, `1 + i < n`, `i - 1 >= 0`) now narrows it as `i < n - c` does. Against a bound within 2^52, a sum that rounds has a magnitude of at least 2^53, so every such comparison answers as the exact sum would. The counter of `for (let i = 0; i + 1 < xs.length; i++)` then lands in an i32 slot like the one of `i < xs.length - 1`, and helper calls that index arrays with it read the elements without the double index checks. --- .../compiler/src/ir/integer-ranges.test.ts | 74 +++++++++++++++ packages/compiler/src/ir/integer-ranges.ts | 45 +++++++-- .../5685-loop-counter-offset-bounds.ts.json | 6 ++ .../corpus/5685-loop-counter-offset-bounds.ts | 94 +++++++++++++++++++ .../5685-loop-counter-offset-bounds.ts.json | 1 + 5 files changed, 213 insertions(+), 7 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5685-loop-counter-offset-bounds.ts.json create mode 100644 tests/corpus/5685-loop-counter-offset-bounds.ts create mode 100644 tests/harness/corpus-costs/5685-loop-counter-offset-bounds.ts.json diff --git a/packages/compiler/src/ir/integer-ranges.test.ts b/packages/compiler/src/ir/integer-ranges.test.ts index 8e1c97f66..a8c9978f3 100644 --- a/packages/compiler/src/ir/integer-ranges.test.ts +++ b/packages/compiler/src/ir/integer-ranges.test.ts @@ -867,6 +867,80 @@ test("an array length bounds while and for counters even when the body resizes t expect(analyzeIntegerRanges(g).get(cursor)).toEqual({ min: 0, max: 4294967295 }); }); +test("a counter plus or minus a literal compared with a bound narrows the counter", () => { + // for (let i = 0; i + 1 < items.length; i++) { pair(items, i, i + 1); } + const ARR = { kind: "array", elem: F64 } as const; + const items: IrExpr = { loc, kind: "varRef", localId: "items", type: ARR }; + const length: IrExpr = { + loc, + kind: "arrIntrinsic", + method: "length", + receiver: items, + args: [], + type: F64, + }; + const inside = ref("i"), + next = bin("+", ref("i"), num(1)), + step = bin("+", ref("i"), num(1)); + const call: IrStmt = { + loc, + kind: "exprStmt", + expr: { loc, kind: "call", callee: "pair", args: [items, inside, next], type: F64 }, + }; + const f = fn([ + { + loc, + kind: "for", + init: { loc, kind: "varDecl", localId: "i", init: num(0) }, + cond: condition("<", bin("+", ref("i"), num(1)), length), + update: { loc, kind: "assign", localId: "i", value: step }, + body: [call], + }, + ]); + f.locals.push( + { id: "i", name: "i", type: F64, mutable: true }, + { id: "items", name: "items", type: ARR, mutable: false }, + ); + const ranges = analyzeIntegerRanges(f); + expect(ranges.get(inside)).toEqual({ min: 0, max: 4294967293 }); + expect(ranges.get(next)).toEqual({ min: 1, max: 4294967294 }); + expect(ranges.get(step)).toEqual({ min: 1, max: 4294967294 }); + + // if (1 + x < 10) observe(x); if (x - 2 >= 0) observe(x); with x in [0, 100] + const below = ref(), + above = ref(), + wide = ref(); + const g = fn([ + assign(bin("&", ref("unknown"), num(100))), + { + loc, + kind: "if", + cond: condition("<", bin("+", num(1), ref()), num(10)), + then: [observe(below)], + else_: null, + }, + { + loc, + kind: "if", + cond: condition(">=", bin("-", ref(), num(2)), num(0)), + then: [observe(above)], + else_: null, + }, + // A bound beyond 2^52 narrows nothing through an offset. + { + loc, + kind: "if", + cond: condition("<", bin("+", ref(), num(1)), num(2 ** 53)), + then: [observe(wide)], + else_: null, + }, + ]); + const gRanges = analyzeIntegerRanges(g); + expect(gRanges.get(below)).toEqual({ min: 0, max: 8 }); + expect(gRanges.get(above)).toEqual({ min: 2, max: 100 }); + expect(gRanges.get(wide)).toEqual({ min: 0, max: 100 }); +}); + test("whole-program slot facts seed parameters, field reads and call results", () => { const field: IrExpr = { loc, diff --git a/packages/compiler/src/ir/integer-ranges.ts b/packages/compiler/src/ir/integer-ranges.ts index 5c365e2ca..410c1b6f7 100644 --- a/packages/compiler/src/ir/integer-ranges.ts +++ b/packages/compiler/src/ir/integer-ranges.ts @@ -215,6 +215,24 @@ export interface IntegerSlotFacts { elements?: (arr: IrExpr) => boolean; } +/** A local read, or a numeric local plus or minus an integer literal + * (`i + 1`, `1 + i`, `i - 2`): the local and its offset. */ +function offsetRef(e: IrExpr): { localId: string; offset: number } | null { + if (e.kind === "varRef") return { localId: e.localId, offset: 0 }; + if (e.kind !== "bin" || (e.op !== "+" && e.op !== "-") || e.type.kind !== "f64") return null; + const literal = (x: IrExpr): number | null => + x.kind === "numLit" && Number.isSafeInteger(x.value) && !Object.is(x.value, -0) + ? x.value + : null; + const right = literal(e.right); + if (e.left.kind === "varRef" && e.left.type.kind === "f64" && right !== null) + return { localId: e.left.localId, offset: e.op === "+" ? right : -right }; + const left = literal(e.left); + if (e.op === "+" && e.right.kind === "varRef" && e.right.type.kind === "f64" && left !== null) + return { localId: e.right.localId, offset: left }; + return null; +} + /** Structured local dataflow. Calls cannot write uncaptured local slots, * but argument expressions can; unknown evaluation forms invalidate all * syntactically written slots before their children are inspected. Loops @@ -508,7 +526,7 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = { let ref = condition.left; let bound = condition.right; let op: string = condition.op; - if (ref.kind !== "varRef" && bound.kind === "varRef") { + if (offsetRef(ref) === null && offsetRef(bound) !== null) { [ref, bound] = [bound, ref]; op = ( @@ -518,18 +536,31 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = { > )[op] ?? ""; } - if (ref.kind !== "varRef") return; - const boundRange = + const target = offsetRef(ref); + if (target === null) return; + const literalBound = bound.kind === "numLit" && Number.isFinite(bound.value) ? { min: bound.value, max: bound.value } : ranges.get(bound); - if (!boundRange) return; + if (!literalBound) return; // An Int32 contract local holds an int32 even where the dataflow has // no fact for it (loop headers, after unknown regions). const previous = - facts.get(ref.localId) ?? - (eligible.has(ref.localId) ? (slots.locals?.(ref.localId) ?? undefined) : undefined); + facts.get(target.localId) ?? + (eligible.has(target.localId) ? (slots.locals?.(target.localId) ?? undefined) : undefined); if (!previous) return; + // `x + c < b` bounds x as `x < b - c` does. A sum that rounds has a + // magnitude of at least 2^53, so against a bound within 2^52 every + // comparison answers as the exact sum would. + const offset = target.offset; + if ( + offset !== 0 && + (Math.abs(offset) > 2 ** 31 || + Math.abs(literalBound.min) > 2 ** 52 || + Math.abs(literalBound.max) > 2 ** 52) + ) + return; + const boundRange = { min: literalBound.min - offset, max: literalBound.max - offset }; if (!truth) op = ( @@ -552,7 +583,7 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = { else if (max === boundRange.max) max -= 1; } if (min <= max && Number.isInteger(min) && Number.isInteger(max)) - facts.set(ref.localId, { min, max }); + facts.set(target.localId, { min, max }); } function expr(e: IrExpr, facts: Facts): IntegerRange | null { let range: IntegerRange | null = null; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5685-loop-counter-offset-bounds.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5685-loop-counter-offset-bounds.ts.json new file mode 100644 index 000000000..da2bfdbe2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5685-loop-counter-offset-bounds.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5685-loop-counter-offset-bounds.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5685-loop-counter-offset-bounds.ts b/tests/corpus/5685-loop-counter-offset-bounds.ts new file mode 100644 index 000000000..e43034496 --- /dev/null +++ b/tests/corpus/5685-loop-counter-offset-bounds.ts @@ -0,0 +1,94 @@ +// Loops whose condition compares the counter plus or minus a literal with a +// bound (`i + 1 < xs.length`, `1 + i < n`, `i - 1 >= 0`): fractional, NaN, +// infinite and negative bounds, counters past 2^31 and near 2^53, helper +// calls with the counter, string lengths and arrays that grow in the body. + +interface Point { + x: number; + y: number; +} +function pointAt(xs: number[], ys: number[], i: number): Point { + return { x: xs[i], y: ys[i] }; +} +function gap(p: Point, q: Point): number { + return Math.abs(p.x - q.x) + Math.abs(p.y - q.y); +} + +const xs: number[] = []; +const ys: number[] = []; +for (let i = 0; i < 50; i++) { + xs.push((i * 37) % 11); + ys.push((i * 53) % 7); +} + +let path = 0; +for (let i = 0; i + 1 < xs.length; i++) path += gap(pointAt(xs, ys, i), pointAt(xs, ys, i + 1)); +console.log("path", path); + +let pairs = 0; +for (let i = 0; 1 + i < xs.length; i++) pairs += xs[i]! * xs[i + 1]!; +console.log("pairs", pairs); + +let triples = 0; +for (let i = 0; i + 2 <= xs.length - 1; i += 2) triples += xs[i]! + xs[i + 1]! + xs[i + 2]!; +console.log("triples", triples); + +// Bounds that are not integers, or not finite. +function count(bound: number): number { + let n = 0; + for (let i = 0; i + 2 < bound; i++) { + n++; + if (n > 20) break; + } + return n; +} +console.log("bounds", count(7.5), count(2), count(2.0001), count(-3), count(Number.NaN), count(Infinity)); + +// Counters below zero, past int32 and near the end of exact integers. +let negative = 0; +for (let i = -5; i + 5 < 3; i++) negative += i; +console.log("negative", negative); +let wide = 0; +for (let i = 2 ** 31 - 3; i + 1 < 2 ** 31 + 3; i++) wide += i - 2 ** 31; +console.log("past int32", wide); +const exact: number[] = []; +for (let i = 2 ** 53 - 4; i + 1 < 2 ** 53; i++) exact.push(i - 2 ** 53); +console.log("near 2^53", exact.join(",")); +let half = 0; +for (let i = 0; i + 0.5 < 4; i++) half += i; +console.log("half offset", half); + +// Descending counters. +const down: number[] = []; +for (let i = 6; i - 1 >= 0; i--) down.push(i); +console.log("down", down.join(",")); +let back = 0; +for (let i = xs.length - 1; i - 1 >= 0; i--) back += xs[i]! - xs[i - 1]!; +console.log("back", back); + +// A while loop on a string's length. +const text = "abcdefghij"; +let pos = 0; +let codes = 0; +while (pos + 3 < text.length) { + codes += text.charCodeAt(pos) * text.charCodeAt(pos + 3); + pos++; +} +console.log("string", pos, codes); + +// An array that grows inside the loop: the bound is reread on every pass. +const grow = [1, 2, 3]; +let steps = 0; +for (let i = 0; i + 1 < grow.length; i++) { + if (grow.length < 12) grow.push(grow[i]! + grow[i + 1]!); + steps++; +} +console.log("grow", steps, grow.join(",")); + +// The counter after the loop, and an equality bound. +let last = 0; +for (last = 0; last + 1 < 9; last++) {} +console.log("last", last); +let hits = 0; +for (let i = 0; i < 10; i++) if (i + 3 === 7) hits += i; +console.log("equal", hits); diff --git a/tests/harness/corpus-costs/5685-loop-counter-offset-bounds.ts.json b/tests/harness/corpus-costs/5685-loop-counter-offset-bounds.ts.json new file mode 100644 index 000000000..1c0e5bd28 --- /dev/null +++ b/tests/harness/corpus-costs/5685-loop-counter-offset-bounds.ts.json @@ -0,0 +1 @@ +{ "plain": 2450, "san": 2700 }