Keep loop-carried locals written from int32 element reads int32

The int32 array proof assumes its candidates' elements are int32 and
asks the integer range analysis whether every stored value is one. A
local assigned in a loop from an element read of a candidate array
(`const t = next[at]!; next[at] = at; at = t`) never closed under the
int32 bound, because the loop-carried closure had no case for element
reads, so arrays that store such cursors kept double slots and every
index converted from a double. Element reads now take the slot range in
that closure, as field reads and calls already did.

The index-cursors runtime workload walks an array of indices with such a
cursor.
This commit is contained in:
Chris Tate
2026-10-23 09:53:05 -05:00
parent dddcb339aa
commit a23ce3043c
5 changed files with 87 additions and 0 deletions
@@ -0,0 +1,6 @@
{
"entry": "workloads/index-cursors.ts",
"args": [
"20000"
]
}
@@ -0,0 +1,25 @@
// Arrays of indices walked by loop-carried cursors: each step reads the
// next index from the array and relinks the visited entries, so the
// cursor and every stored value are small integers read back from the
// array itself.
const N = 4096;
function relink(rounds: number): number {
const next: number[] = [];
for (let i = 0; i < N; i++) next.push(i + 1 < N ? i + 1 : 0);
let at = next[0]!;
let total = 0;
for (let r = 0; r < rounds; r++) {
for (let s = 0; s < N; s++) {
const t = next[at]!;
next[at] = next[t]!;
next[t] = at;
at = t;
total = (total + at) | 0;
}
}
return total;
}
const rounds = Number(process.argv[2] ?? "1");
console.log("relink", relink(rounds));
@@ -440,6 +440,37 @@ test("int32-closed locals keep the int32 range across loops and switches", () =>
expect(switched.get(afterSwitch)).toEqual(INT32);
});
test("loop-carried locals written from Int32[] element reads keep the int32 range", () => {
const arrType = { kind: "array", elem: F64 } as const;
const loop = (localId: string): { after: IrExpr; f: IrFunction } => {
const after = ref();
// `x = a[x]!` in a loop: a cursor that follows indices stored in the array.
const read: IrExpr = {
loc,
kind: "arrayGet",
arr: { loc, kind: "varRef", localId, type: arrType },
index: ref(),
type: F64,
};
const f = fn([
assign(num(0)),
{
loc,
kind: "while",
cond: condition("<", ref("unknown"), num(1)),
body: [assign(read)],
},
observe(after),
]);
return { after, f };
};
const elements = { elements: (arr: IrExpr) => arr.kind === "varRef" && arr.localId === "ints" };
const ints = loop("ints");
expect(analyzeIntegerRanges(ints.f, elements).get(ints.after)).toEqual(INT32);
const floats = loop("floats");
expect(analyzeIntegerRanges(floats.f, elements).get(floats.after) ?? null).toBeNull();
});
test("uint32-closed locals keep the uint32 range across loop back edges", () => {
// for (let i = 0; i < n; i++) { v = unknown >>> 0; acc = (acc + v) >>> 0; ... }
const loop = (start: IrExpr | null, writes: IrExpr[]) => {
@@ -480,6 +480,7 @@ export function analyzeIntegerRangeFacts(
return boxedRange(e.localId);
}
case "fieldGet":
case "arrayGet":
case "call":
return slotRange(e);
default:
@@ -77,6 +77,19 @@ function literalCounter(n: number): number {
for (let i = 0; i < n; i++) a[0] = a[0]! + 1;
return a[0]!;
}
function walk(n: number, steps: number): number {
const next: number[] = [];
for (let i = 0; i < n; i++) next.push(i + 1 < n ? i + 1 : 0);
let at = next[0]!;
let total = 0;
for (let s = 0; s < steps; s++) {
const t = next[at]!;
next[at] = at;
at = t;
total = (total + at) | 0;
}
return total;
}
function joined(n: number): string {
const a = [n | 0, 2];
return a.join(",");
@@ -91,6 +104,7 @@ console.log(
shortCounter(),
literalCounter(n),
joined(n),
walk(n | 0, 5),
);
`;
@@ -119,6 +133,16 @@ test("arrays storing only int32 elements get 4-byte slots", async () => {
expect(counts).not.toContain("popArray");
});
test("an array that stores a cursor read back from its own elements gets 4-byte slots", async () => {
const llvm = await emitLlvm(source);
const walk = functions(llvm, "walk");
expect(walk).not.toBe("");
// `at` is assigned `next[at]!` in the loop and stored back into `next`:
// it holds an int32 whenever the array's elements are int32s.
expect(walk).toMatch(/@scr_arr_new_small\(i32 7\)/);
expect(walk).toMatch(/getelementptr inbounds i32, ptr %\w+, i64 %\w+\n\s+%\w+ = load i32/);
});
test("arrays with a store that has no exact integer range, or that escape, keep doubles", async () => {
const llvm = await emitLlvm(source);
for (const name of ["fraction", "negativeZero", "joined"]) {