Version dense array loops bounded by an invariant limit (#1816)

This commit is contained in:
Chris Tate
2026-10-22 07:42:33 -05:00
committed by GitHub
8 changed files with 291 additions and 40 deletions
@@ -1,5 +1,6 @@
import { expect, test } from "vitest";
import { BOOL, F64, arrayOf, type IrExpr, type IrLocal, type IrStmt } from "../../ir/ir.js";
import type { IntegerCountedForLoop } from "../../ir/integer-loops.js";
import {
countedDenseShape,
matchDenseArrayLoop,
@@ -111,16 +112,23 @@ test("loops that may resize, rebind or move storage keep the ordinary copy only"
expect([...rebound!.arrays.keys()]).toEqual(["a"]);
});
const counted = (
fields: Pick<IntegerCountedForLoop, "start" | "limit" | "step" | "inclusive" | "range">,
): IntegerCountedForLoop => ({
localId: "i",
guarded: false,
limitGuarded: false,
startGuarded: false,
guardLimit: 0,
...fields,
});
test("counted shapes need a non-negative counter strictly below the length", () => {
const ascending = (limit: IrExpr, inclusive: boolean, min = 0) =>
countedDenseShape(loop([]), {
localId: "i",
start: num(min),
limit,
step: 1,
inclusive,
range: { min, max: 100 },
});
countedDenseShape(
loop([]),
counted({ start: num(min), limit, step: 1, inclusive, range: { min, max: 100 } }),
);
const minus = (offset: number): IrExpr => ({
kind: "bin",
op: "-",
@@ -129,19 +137,58 @@ test("counted shapes need a non-negative counter strictly below the length", ()
type: F64,
loc,
});
expect(ascending(method("a", "length"), false)?.length.localId).toBe("a");
expect(ascending(minus(1), true)?.length.localId).toBe("a");
expect(ascending(method("a", "length"), false)?.length?.localId).toBe("a");
expect(ascending(minus(1), true)?.length?.localId).toBe("a");
expect(ascending(method("a", "length"), true)).toBeNull();
expect(ascending(minus(1), false, -1)).toBeNull();
// Another invariant limit bounds the counter itself; the entry test
// compares it with every indexed array's hole-free prefix.
const limited = ascending(ref("n"), false);
expect(limited?.length).toBeNull();
expect(limited?.limit?.limit).toEqual(ref("n"));
expect(ascending(ref("n"), false, -1)).toBeNull();
const descending = (offset: number) =>
countedDenseShape(loop([]), {
localId: "i",
start: minus(offset),
limit: num(0),
step: -1,
inclusive: true,
range: { min: 0, max: 100 },
});
expect(descending(1)?.length.localId).toBe("a");
countedDenseShape(
loop([]),
counted({
start: minus(offset),
limit: num(0),
step: -1,
inclusive: true,
range: { min: 0, max: 100 },
}),
);
expect(descending(1)?.length?.localId).toBe("a");
expect(descending(0)).toBeNull();
});
test("a loop to another invariant limit covers each array whose length it keeps", () => {
const limited: DenseLoopShape = {
counter: "i",
length: null,
limit: counted({
start: num(0),
limit: ref("n"),
step: 1,
inclusive: false,
range: { min: 0, max: 100 },
}),
range: { min: 0, max: 100 },
};
const matchLimited = (body: IrStmt[], unaliased = new Set<string>()) =>
matchDenseArrayLoop(loop(body), limited, locals, unaliased, new Set(["a", "b"]));
const copy = write("b", read("a"));
const dense = matchLimited([copy]);
expect([...dense!.arrays.keys()].sort()).toEqual(["a", "b"]);
expect(dense!.length).toBeNull();
expect(dense!.limit?.limit).toEqual(ref("n"));
// An array the loop may resize is not covered; neither is one it may
// share storage with.
const resizing = [
{ kind: "exprStmt" as const, expr: read("a"), loc },
{ kind: "exprStmt" as const, expr: method("b", "getNumber", [ref("i")]), loc },
{ kind: "exprStmt" as const, expr: method("b", "push", [num(1)]), loc },
];
expect([...matchLimited(resizing, new Set(["b"]))!.arrays.keys()]).toEqual(["a"]);
expect(matchLimited(resizing)).toBeNull();
});
@@ -15,22 +15,33 @@
* test bounds `a.length` by 2^31 - 1, so the fast copy's counter is an int32.
* Arrays longer than that are sparse in practice (a dense one would need
* 16 GB of slots) and take the ordinary copy, as does every array with a
* hole, present undefined or sparse storage below `a.length`. */
* hole, present undefined or sparse storage below `a.length`.
*
* A counted loop with another invariant limit, such as `j < n` after
* `const n = a.length` or `j < 5`, is covered the same way: the test bounds
* the loop's exclusive limit by 2^31 - 1 and by each indexed array's
* `filled`, and the loop must provably never change any of their lengths. */
import type { IrExpr, IrLocal, IrStmt } from "../../ir/ir.js";
import type { IntegerRange } from "../../ir/integer-ranges.js";
import { preservesArrayLength } from "../../ir/array-length-stability.js";
import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
import type { IntegerCountedForLoop } from "../../ir/integer-loops.js";
import type { LlvmEmitterContext } from "./expr-context.js";
import { loadArrCount, SCR_ARR_FIELD } from "./common.js";
import { emitCountedLoopLimit } from "./counted-loops.js";
export const INT32_MAX = 2147483647;
export interface DenseArrayLoop {
/** The loop's counter binding. */
counter: string;
/** The array whose length bounds the counter. */
length: IrExpr & { kind: "varRef" };
/** The array whose length bounds the counter, or null for a counted loop
* bounded by `limit`. */
length: (IrExpr & { kind: "varRef" }) | null;
/** The ascending counted loop whose invariant limit bounds the counter,
* when `length` is null. */
limit: IntegerCountedForLoop | null;
/** Arrays whose accesses at the counter are proven to be inside the
* hole-free prefix in the fast copy, with a read of each binding. */
arrays: ReadonlyMap<string, IrExpr & { kind: "varRef" }>;
@@ -39,12 +50,15 @@ export interface DenseArrayLoop {
}
/** The fast copy duplicates the loop; keep the duplicated code small. */
const MAX_LOOP_NODES = 120;
const MAX_LOOP_NODES = 160;
/** How a matched integer loop bounds its counter below `length.length`. */
export interface DenseLoopShape {
counter: string;
length: IrExpr & { kind: "varRef" };
/** The array whose length bounds the counter, or null for `limit`. */
length: (IrExpr & { kind: "varRef" }) | null;
/** An ascending counted loop bounded by another invariant limit. */
limit?: IntegerCountedForLoop;
/** The counter's proven range on every pass of the ordinary loop; the
* fast copy keeps its minimum, which must be at least 0. */
range: IntegerRange;
@@ -73,22 +87,18 @@ function lengthOffset(e: IrExpr): { receiver: IrExpr & { kind: "varRef" }; offse
/** The array bounding a counted loop's counter below its length: an
* ascending loop compared below `a.length - c`, or a descending one starting
* at `a.length - c` (c >= 1). */
* at `a.length - c` (c >= 1). Another ascending loop is bounded by its own
* invariant limit. */
export function countedDenseShape(
loop: IrStmt & { kind: "for" },
counted: {
localId: string;
start: IrExpr;
limit: IrExpr;
step: number;
inclusive: boolean;
range: IntegerRange;
},
counted: IntegerCountedForLoop,
): DenseLoopShape | null {
if (counted.range.min < 0) return null;
if (counted.step > 0) {
const bound = lengthOffset(counted.limit);
if (!bound || bound.offset < (counted.inclusive ? 1 : 0)) return null;
if (!bound)
return { counter: counted.localId, length: null, limit: counted, range: counted.range };
if (bound.offset < (counted.inclusive ? 1 : 0)) return null;
return { counter: counted.localId, length: bound.receiver, range: counted.range };
}
// The start is evaluated once, before the loop; the counter only falls.
@@ -143,9 +153,11 @@ export function matchDenseArrayLoop(
return everyStmtChild(s, expr, stmt);
};
if (!loop.body.every(stmt) || !expr(loop.cond) || nested) return null;
const nodesOfLoop = [loop.cond, ...loop.body];
if (
length !== null &&
!preservesArrayLength(
[loop.cond, ...loop.body],
nodesOfLoop,
{ receiver: length.localId, inRange: counter },
locals,
unaliased,
@@ -161,7 +173,14 @@ export function matchDenseArrayLoop(
local.boxed ||
local.tdz ||
disturbed.has(id) ||
(id !== length.localId && !initialized.has(id))
(id !== length?.localId && !initialized.has(id))
)
continue;
// Without a bounding array, the entry test reads each array's `filled`
// once: the loop must not change any of their lengths.
if (
length === null &&
!preservesArrayLength(nodesOfLoop, { receiver: id, inRange: counter }, locals, unaliased)
)
continue;
arrays.set(id, ref);
@@ -170,13 +189,15 @@ export function matchDenseArrayLoop(
return {
counter,
length,
limit: length === null ? (shape.limit ?? null) : null,
arrays,
range: { min: shape.range.min, max: Math.min(shape.range.max, INT32_MAX - 1) },
};
}
/** The fast copy's entry test: the bounding length is at most 2^31 - 1 and
* at most every indexed array's hole-free prefix bound. */
/** The fast copy's entry test: the bounding length (or the counted loop's
* exclusive limit) is at most 2^31 - 1 and at most every indexed array's
* hole-free prefix bound. */
export function emitDenseLoopGuard(host: LlvmEmitterContext, loop: DenseArrayLoop): string {
const B = host.B;
const size = host.sizeType;
@@ -186,7 +207,29 @@ export function emitDenseLoopGuard(host: LlvmEmitterContext, loop: DenseArrayLoo
host.markMemoryPointer(ptr, "array:header");
return ptr;
};
const receiver = host.emitStableReceiver(loop.length, []);
if (loop.length === null) {
// A limit at or below the start runs no pass, and passes the test.
const limit = emitCountedLoopLimit(host, loop.limit!);
const small = B.tmp();
B.line(`${small} = icmp sle i64 ${limit}, ${INT32_MAX}`);
let valid = small;
for (const ref of loop.arrays.values()) {
const array = host.emitStableReceiver(ref, []);
const filledPtr = header(array.name, SCR_ARR_FIELD.filled),
filled = B.tmp(),
wide = B.tmp(),
covered = B.tmp(),
both = B.tmp();
B.line(`${filled} = load i32, ptr ${filledPtr}${host.fieldAliasAttachment(filledPtr)}`);
B.line(`${wide} = zext i32 ${filled} to i64`);
B.line(`${covered} = icmp sle i64 ${limit}, ${wide}`);
B.line(`${both} = and i1 ${valid}, ${covered}`);
valid = both;
}
return valid;
}
const length = loop.length;
const receiver = host.emitStableReceiver(length, []);
const lenPtr = header(receiver.name, SCR_ARR_FIELD.len),
len = B.tmp(),
small = B.tmp();
@@ -194,7 +237,7 @@ export function emitDenseLoopGuard(host: LlvmEmitterContext, loop: DenseArrayLoo
B.line(`${small} = icmp ule ${size} ${len}, ${INT32_MAX}`);
let valid = small;
for (const [id, ref] of loop.arrays) {
const array = id === loop.length.localId ? receiver : host.emitStableReceiver(ref, []);
const array = id === length.localId ? receiver : host.emitStableReceiver(ref, []);
const filledPtr = header(array.name, SCR_ARR_FIELD.filled),
filled = B.tmp(),
covered = B.tmp(),
@@ -586,6 +586,24 @@ export function emitBorrowedArrayRead(
const B = host.B;
const array = host.emitStableReceiver(read.arr, [read.index]);
const loopIndex = host.emitIntegerLoopIndex(read.index);
if (loopIndex !== null && host.provenDenseIndex(read.arr, read.index)) {
// In a dense array loop's fast copy, the entry test proved the counter
// below the array's hole-free prefix: the slot holds a present value.
const dataPtr = B.tmp(),
data = B.tmp(),
valuePtr = B.tmp(),
value = B.tmp();
B.line(
`${dataPtr} = getelementptr inbounds %ScrArr, ptr ${array.name}, i32 0, i32 ${SCR_ARR_FIELD.data}`,
);
host.markMemoryPointer(dataPtr, "array:header");
B.line(`${data} = load ptr, ptr ${dataPtr}${host.fieldAliasAttachment(dataPtr)}`);
B.line(`${valuePtr} = getelementptr inbounds i64, ptr ${data}, ${host.sizeType} ${loopIndex}`);
host.markMemoryPointer(valuePtr, "array:elements");
B.line(`${value} = load ptr, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`);
B.line(`store ptr ${value}, ptr ${slot}`);
return;
}
const index = host.emitExpr(read.index);
// A proven integer index (an int32 local, say) needs no double
// round-trip; a negative one compares above the capacity as unsigned.
@@ -167,3 +167,29 @@ test("fresh local arrays read only as receivers have no alias", () => {
const rebound = [...body, { kind: "assign", localId: "out", value: arr("a"), loc } as IrStmt];
expect(findUnaliasedLocalArrays(fn(rebound)).has("out")).toBe(false);
});
test("number and boolean writes to class fields run no user code", () => {
const obj: IrExpr = {
kind: "varRef",
localId: "o",
type: { kind: "object", className: "P" },
loc,
};
const scope = new Map(locals).set("o", {
id: "o",
name: "o",
type: { kind: "object", className: "P" },
mutable: false,
});
const fieldSet = (value: IrExpr): IrStmt => ({
kind: "fieldSet",
obj,
className: "P",
field: "x",
value,
loc,
});
expect(preserves([fieldSet(read("a", ref("i")))], "i", new Set(), scope)).toBe(true);
// A reference stored into a field releases the old one.
expect(preserves([fieldSet(arr("b"))], "i", new Set(), scope)).toBe(false);
});
@@ -264,6 +264,15 @@ export function preservesArrayLength(
);
case "return":
return s.value === null || expr(s.value);
case "fieldSet":
// A number or boolean stored into a class field: accessors are
// calls, not field writes, and the old value holds no reference to
// release, so the write runs no user code.
return (
(s.value.type.kind === "f64" || s.value.type.kind === "bool") &&
expr(s.obj) &&
expr(s.value)
);
default:
if (!PLAIN_STATEMENTS.has(s.kind)) return false;
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7320-array-dense-loop-invariant-limits.ts"
],
"diags": []
}
@@ -0,0 +1,101 @@
// Index loops bounded by an invariant limit other than the array's own length
// (a local copy of a length, a literal, an inclusive bound), over arrays the
// loop never resizes: class instances whose number and boolean fields the loop
// writes, and number arrays that are dense, short, holey or partially filled.
class Body {
x: number;
v = 0;
hit = false;
next: Body | null = null;
constructor(x: number) {
this.x = x;
}
}
function pairs(bodies: Body[]): string {
const n = bodies.length;
for (let i = 0; i < n; i++) {
const bi = bodies[i]!;
let vi = bi.v;
for (let j = i + 1; j < n; j++) {
const bj = bodies[j]!;
const d = bi.x - bj.x;
const f = 1 / (d * d + 1);
vi -= d * f;
bj.v += d * f;
bj.hit = f > 0.1;
}
bi.v = vi;
}
return bodies.map((b) => `${b.v.toFixed(6)}${b.hit ? "*" : ""}`).join(" ");
}
function firstThree(bodies: Body[]): number {
let s = 0;
for (let j = 0; j < 3; j++) s += bodies[j]!.x;
return s;
}
function inclusive(bodies: Body[]): number {
const last = bodies.length - 1;
let s = 0;
for (let j = 1; j <= last; j++) s += bodies[j]!.x * j;
return s;
}
function linked(bodies: Body[]): string {
const n = bodies.length;
// A reference field write: the loop keeps the ordinary copy.
for (let j = 0; j < n - 1; j++) bodies[j]!.next = bodies[j + 1]!;
let count = 0;
for (let b: Body | null = bodies[0]!; b !== null; b = b.next) count++;
return `${count}`;
}
function sumTo(a: number[], limit: number): string {
const n = limit;
let s = 0;
let missing = 0;
for (let i = 0; i < n; i++) {
const v = a[i];
if (v === undefined) missing++;
else s += v;
}
return `${s} ${missing}`;
}
function scaleTo(a: number[], out: number[]): string {
const n = a.length;
for (let i = 0; i < n; i++) out[i] = a[i] * 2;
return `${out.length}:${out.join(",")}`;
}
function window(a: number[]): number {
let s = 0;
for (let i = 2; i < 6; i++) s += a[i] * i;
return s;
}
const bodies = [new Body(0), new Body(1.5), new Body(-2), new Body(4), new Body(0.25)];
console.log(pairs(bodies));
console.log(pairs(bodies));
console.log(firstThree(bodies), inclusive(bodies), inclusive([new Body(7)]));
console.log(linked([new Body(1), new Body(2), new Body(3)]));
const dense = [1, 2, 3, 4, 5, 6, 7, 8];
console.log(sumTo(dense, 8), sumTo(dense, 5), sumTo(dense, 0), sumTo(dense, -3));
// Limits past the length or the filled prefix take the checked loop.
console.log(sumTo(dense, 10), sumTo([], 2));
const holey = [1];
holey[3] = 4;
console.log(sumTo(holey, 4), sumTo(holey, 1));
const shortened = [1, 2, 3, 4, 5, 6];
shortened.length = 3;
shortened.length = 6;
console.log(sumTo(shortened, 6), sumTo(shortened, 3));
console.log(scaleTo(dense, new Array<number>(8).fill(0)));
console.log(scaleTo(dense, [9, 9]));
console.log(scaleTo([1, 2, 3], []));
console.log(window(dense), window([1, 2, 3, 4, 5, 6]));
@@ -0,0 +1 @@
{ "plain": 2600, "san": 31600 }