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.
This commit is contained in:
Chris Tate
2026-10-19 23:19:46 -05:00
parent 783870dec3
commit bbefa36394
5 changed files with 213 additions and 7 deletions
@@ -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,
+38 -7
View File
@@ -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;
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5685-loop-counter-offset-bounds.ts"
],
"diags": []
}
@@ -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);
@@ -0,0 +1 @@
{ "plain": 2450, "san": 2700 }