Prove array lengths stable in index loops and version dense array loops

Index loops over arrays left the counter a double in several common
shapes, and every element access at the counter tested the array's
hole-free prefix on each pass:

- A syntactic proof (ir/array-length-stability.ts) shows that a loop never
  changes an array's length: the body runs no user code, never rebinds the
  array or lets it escape, and only resizes local arrays that no other
  binding can hold. Element writes at a counter kept below the length are
  accepted.
- Counted loops take a stable `a.length` as their limit, and an element
  access at the counter makes an unversioned counted loop worthwhile, so
  strided (`i += 2`) and descending (`i = a.length - 1; i >= 0`) loops get
  an integer counter.
- Array lengths bound while-loop and for-loop counters by their range,
  which needs no stability proof.
- A loop whose array length is stable and whose indexed arrays keep their
  storage runs as two copies. One test before the loop (the length is at
  most 2^31 - 1 and at most each indexed array's `filled` bound) selects a
  fast copy whose element accesses at the counter skip the prefix test and
  whose counter is an int32. LLVM can vectorize that copy.
- Loop counters whose ranges fit in 32 bits convert to double from their
  low half, so an inlined callee's index conversion folds away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-17 14:48:38 -07:00
co-authored by Claude Opus 5.5
parent 6ce668b213
commit 714ef5aa09
16 changed files with 1599 additions and 29 deletions
@@ -47,7 +47,8 @@ interface DenseGuard {
* then extends the length over hole states exactly the same way). An index
* below the hole-free prefix bound branches to `filledLabel` first; both
* that block and the returned (state-checked) dense block may use the
* guard's values. */
* guard's values. A `proven` index is known to lie below the bound (a dense
* array loop's fast copy, dense-array-loops.ts) and always takes it. */
function emitDenseGuard(
host: LlvmEmitterContext,
array: string,
@@ -56,6 +57,7 @@ function emitDenseGuard(
slow: string,
requireBelowLen: boolean,
filledLabel: string,
proven = false,
): DenseGuard {
const B = host.B;
const size = host.sizeType;
@@ -137,8 +139,10 @@ function emitDenseGuard(
B.line(`${offset} = trunc i64 ${wide} to i32`);
}
// The bound never exceeds the length or the dense capacity.
let inFilled = B.tmp();
B.line(`${inFilled} = icmp ult i64 ${wide}, ${filledWide}`);
// The proof covers the counter's integer value only.
const below = proven && integral === null;
let inFilled = below ? "true" : B.tmp();
if (!below) B.line(`${inFilled} = icmp ult i64 ${wide}, ${filledWide}`);
if (integral) {
const t = B.tmp();
B.line(`${t} = and i1 ${integral}, ${inFilled}`);
@@ -272,6 +276,7 @@ export function emitDenseArrayGet(
element: IrType,
getter: string,
retain: boolean,
proven = false,
): string {
const B = host.B;
const ty = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr";
@@ -285,7 +290,7 @@ export function emitDenseArrayGet(
const slow = B.newLabel("arr.get.slow"),
join = B.newLabel("arr.get.join"),
valueLabel = B.newLabel("arr.get.value");
const guard = emitDenseGuard(host, array, index, indexExpr, slow, true, valueLabel);
const guard = emitDenseGuard(host, array, index, indexExpr, slow, true, valueLabel, proven);
const state = loadState(host, guard);
const present = B.tmp();
B.line(`${present} = icmp eq i8 ${state}, ${SCR_ARR_VALUE}`);
@@ -331,6 +336,7 @@ export function emitDenseArrayNumber(
array: string,
index: LlValue,
indexExpr: IrExpr | undefined,
proven = false,
): string {
return emitDenseArrayGet(
host,
@@ -341,6 +347,7 @@ export function emitDenseArrayNumber(
{ kind: "f64" },
"scr_arr_get_number",
false,
proven,
);
}
@@ -399,6 +406,7 @@ export function emitDenseArraySet(
acc: ArrayAccess,
element: IrType,
value: string,
proven = false,
): void {
const B = host.B;
const ty = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr";
@@ -413,7 +421,7 @@ export function emitDenseArraySet(
const slow = B.newLabel("arr.set.slow"),
join = B.newLabel("arr.set.join"),
filledLabel = B.newLabel("arr.set.filled");
const guard = emitDenseGuard(host, array, index, indexExpr, slow, false, filledLabel);
const guard = emitDenseGuard(host, array, index, indexExpr, slow, false, filledLabel, proven);
const { offset, len } = guard;
const storeValue = (valuePtr: string): void => {
if (acc === "f64") {
@@ -0,0 +1,131 @@
import { expect, test } from "vitest";
import { BOOL, F64, arrayOf, type IrExpr, type IrLocal, type IrStmt } from "../../ir/ir.js";
import { countedDenseShape, matchDenseArrayLoop, type DenseLoopShape } from "./dense-array-loops.js";
const loc = { file: "dense-array-loops.ts", start: 0, end: 0 };
const ARR = arrayOf(F64);
const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc });
const ref = (localId: string): IrExpr => ({ kind: "varRef", localId, type: F64, loc });
const arr = (localId: string): IrExpr & { kind: "varRef" } => ({
kind: "varRef",
localId,
type: ARR,
loc,
});
const method = (
receiver: string,
name: Extract<IrExpr, { kind: "arrIntrinsic" }>["method"],
args: IrExpr[] = [],
): IrExpr => ({ kind: "arrIntrinsic", method: name, receiver: arr(receiver), args, type: F64, loc });
const read = (receiver: string, index = ref("i")): IrExpr =>
method(receiver, "getNumber", [index]);
const write = (receiver: string, value: IrExpr, index = ref("i")): IrStmt => ({
kind: "arraySet",
arr: arr(receiver),
index,
value,
loc,
});
const locals = new Map<string, IrLocal>([
["i", { id: "i", name: "i", type: F64, mutable: true }],
["a", { id: "a", name: "a", type: ARR, mutable: false }],
["b", { id: "b", name: "b", type: ARR, mutable: true }],
["c", { id: "c", name: "c", type: ARR, mutable: false }],
]);
/** for (let i = 0; i < a.length; i++) body */
function loop(body: IrStmt[]): IrStmt & { kind: "for" } {
return {
kind: "for",
loc,
init: { kind: "varDecl", localId: "i", init: num(0), loc },
cond: { kind: "bin", op: "<", left: ref("i"), right: method("a", "length"), type: BOOL, loc },
update: {
kind: "assign",
localId: "i",
value: { kind: "bin", op: "+", left: ref("i"), right: num(1), type: F64, loc },
loc,
},
body,
};
}
const shape: DenseLoopShape = { counter: "i", length: arr("a"), range: { min: 0, max: 4294967294 } };
const match = (body: IrStmt[], initialized = new Set(["a", "b"])) =>
matchDenseArrayLoop(loop(body), shape, locals, new Set(), initialized);
test("arrays indexed at the counter of a length-preserving loop get an int32 fast copy", () => {
const scale = write("b", {
kind: "bin",
op: "*",
left: read("a"),
right: num(2),
type: F64,
loc,
});
const dense = match([scale]);
expect([...dense!.arrays.keys()].sort()).toEqual(["a", "b"]);
expect(dense!.range).toEqual({ min: 0, max: 2147483646 });
// A binding not definitely assigned before the loop is not read by the guard.
expect([...match([scale], new Set())!.arrays.keys()]).toEqual(["a"]);
});
test("loops that may resize, rebind or move storage keep the ordinary copy only", () => {
expect(match([{ kind: "exprStmt", expr: method("a", "push", [num(1)]), loc }])).toBeNull();
expect(match([write("a", num(0), ref("unknown"))])).toBeNull();
expect(
match([{ kind: "exprStmt", expr: { kind: "call", callee: "f", args: [], type: F64, loc }, loc }]),
).toBeNull();
// A nested loop would be duplicated too.
expect(match([{ kind: "exprStmt", expr: read("a"), loc }, loop([])])).toBeNull();
// No access at the counter: nothing to prove.
expect(match([{ kind: "exprStmt", expr: read("a", num(0)), loc }])).toBeNull();
// Copying the bounding array into another binding lets it escape.
expect(
match([
{ kind: "exprStmt", expr: read("a"), loc },
{ kind: "assign", localId: "b", value: arr("a"), loc },
]),
).toBeNull();
// A rebound array is not covered, but the bounding array still is.
const rebound = match([
{ kind: "exprStmt", expr: read("b"), loc },
{ kind: "exprStmt", expr: read("a"), loc },
{ kind: "assign", localId: "b", value: arr("c"), loc },
]);
expect([...rebound!.arrays.keys()]).toEqual(["a"]);
});
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 },
});
const minus = (offset: number): IrExpr => ({
kind: "bin",
op: "-",
left: method("a", "length"),
right: num(offset),
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"), true)).toBeNull();
expect(ascending(minus(1), 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");
expect(descending(0)).toBeNull();
});
@@ -0,0 +1,218 @@
/* Dense array loops: a versioned copy of an index loop over an array whose
* length the loop never changes.
*
* `for (let i = 0; i < a.length; i++) b[i] = a[i] * 2` reads its bound from
* `a`, and every element access at the counter normally tests the index
* against the array's hole-free prefix bound (`filled`, see
* dense-array-access.ts) before it touches the slot. When the loop provably
* never changes `a.length` (ir/array-length-stability.ts) and never moves or
* cuts the storage of the arrays it indexes, one test before the loop covers
* every pass: if `a.length` is at most each indexed array's `filled` bound,
* every counter value the loop can reach (at least 0, below `a.length`)
* names a present value in dense storage. The fast copy then reads and
* replaces those slots directly, which leaves LLVM a loop without
* per-element branches (vectorizable where the body allows it). The same
* 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`. */
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 { LlvmEmitterContext } from "./expr-context.js";
import { SCR_ARR_FIELD } from "./common.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" };
/** 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" }>;
/** The counter's range on every pass of the fast copy. */
range: IntegerRange;
}
/** The fast copy duplicates the loop; keep the duplicated code small. */
const MAX_LOOP_NODES = 120;
/** How a matched integer loop bounds its counter below `length.length`. */
export interface DenseLoopShape {
counter: string;
length: IrExpr & { kind: "varRef" };
/** 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;
}
/** `length.length - c` (c an integer literal, 0 when absent). */
function lengthOffset(e: IrExpr): { receiver: IrExpr & { kind: "varRef" }; offset: number } | null {
let offset = 0;
if (
e.kind === "bin" &&
e.op === "-" &&
e.right.kind === "numLit" &&
Number.isSafeInteger(e.right.value)
) {
offset = e.right.value;
e = e.left;
}
return e.kind === "arrIntrinsic" &&
e.method === "length" &&
e.args.length === 0 &&
e.receiver.kind === "varRef" &&
e.receiver.type.kind === "array"
? { receiver: e.receiver, offset }
: null;
}
/** 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). */
export function countedDenseShape(
loop: IrStmt & { kind: "for" },
counted: {
localId: string;
start: IrExpr;
limit: IrExpr;
step: number;
inclusive: boolean;
range: IntegerRange;
},
): 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;
return { counter: counted.localId, length: bound.receiver, range: counted.range };
}
// The start is evaluated once, before the loop; the counter only falls.
const start = lengthOffset(counted.start);
if (!start || start.offset < 1 || loop.init?.kind !== "varDecl") return null;
return { counter: counted.localId, length: start.receiver, range: counted.range };
}
/** Match the fast copy of a loop of the given shape. `initialized` holds the
* bindings definitely assigned when the loop starts; `unaliased` the local
* arrays no other binding holds. */
export function matchDenseArrayLoop(
loop: IrStmt & { kind: "for" },
shape: DenseLoopShape,
locals: ReadonlyMap<string, IrLocal>,
unaliased: ReadonlySet<string>,
initialized: ReadonlySet<string>,
): DenseArrayLoop | null {
const { counter, length } = shape;
if (shape.range.min < 0 || !loop.cond) return null;
let nodes = 0,
nested = false;
const candidates = new Map<string, IrExpr & { kind: "varRef" }>();
// Arrays written, resized, or indexed other than at the counter by an
// element write.
const disturbed = new Set<string>();
const access = (arr: IrExpr, index: IrExpr): void => {
if (arr.kind === "varRef" && index.kind === "varRef" && index.localId === counter)
candidates.set(arr.localId, arr);
};
const expr = (e: IrExpr): boolean => {
if (++nodes > MAX_LOOP_NODES) return false;
if (e.kind === "assignExpr" || e.kind === "incDec") disturbed.add(e.localId);
if (e.kind === "arrayGet") access(e.arr, e.index);
if (e.kind === "arrIntrinsic") {
if (e.method === "getNumber" && e.args[0]) access(e.receiver, e.args[0]);
else if (e.method !== "length" && e.receiver.kind === "varRef")
disturbed.add(e.receiver.localId);
}
return everyExprChild(e, expr, stmt);
};
const stmt = (s: IrStmt): boolean => {
if (++nodes > MAX_LOOP_NODES) return false;
if (s.kind === "for" || s.kind === "while" || s.kind === "doWhile" || s.kind === "forOf")
nested = true;
if (s.kind === "assign" || s.kind === "varDecl") disturbed.add(s.localId);
if (s.kind === "arraySet") {
access(s.arr, s.index);
if (
s.arr.kind === "varRef" &&
(s.index.kind !== "varRef" || s.index.localId !== counter)
)
disturbed.add(s.arr.localId);
}
return everyStmtChild(s, expr, stmt);
};
if (!loop.body.every(stmt) || !expr(loop.cond) || nested) return null;
if (
!preservesArrayLength(
[loop.cond, ...loop.body],
{ receiver: length.localId, inRange: counter },
locals,
unaliased,
)
)
return null;
const arrays = new Map<string, IrExpr & { kind: "varRef" }>();
for (const [id, ref] of candidates) {
const local = locals.get(id);
if (
!local ||
local.type.kind !== "array" ||
local.boxed ||
local.tdz ||
disturbed.has(id) ||
(id !== length.localId && !initialized.has(id))
)
continue;
arrays.set(id, ref);
}
if (arrays.size === 0) return null;
return {
counter,
length,
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. */
export function emitDenseLoopGuard(host: LlvmEmitterContext, loop: DenseArrayLoop): string {
const B = host.B;
const size = host.sizeType;
const header = (array: string, field: number): string => {
const ptr = B.tmp();
B.line(`${ptr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 ${field}`);
host.markMemoryPointer(ptr, "array:header");
return ptr;
};
const receiver = host.emitStableReceiver(loop.length, []);
const lenPtr = header(receiver.name, 1),
len = B.tmp(),
small = B.tmp();
B.line(
`${len} = load ${size}, ptr ${lenPtr}${host.fieldAliasAttachment(lenPtr)}${host.arrayLengthRange}`,
);
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 filledPtr = header(array.name, SCR_ARR_FIELD.filled),
filled = B.tmp(),
covered = B.tmp(),
both = B.tmp();
B.line(`${filled} = load i32, ptr ${filledPtr}${host.fieldAliasAttachment(filledPtr)}`);
let bound = filled;
if (size === "i64") {
bound = B.tmp();
B.line(`${bound} = zext i32 ${filled} to i64`);
}
B.line(`${covered} = icmp ule ${size} ${len}, ${bound}`);
B.line(`${both} = and i1 ${valid}, ${covered}`);
valid = both;
}
return valid;
}
+104 -4
View File
@@ -55,6 +55,13 @@ import {
matchIntegerBytesForLoop,
matchIntegerCountedForLoop,
} from "../../ir/integer-loops.js";
import { findUnaliasedLocalArrays } from "../../ir/array-length-stability.js";
import {
countedDenseShape,
emitDenseLoopGuard,
matchDenseArrayLoop,
type DenseArrayLoop,
} from "./dense-array-loops.js";
import { emitCountedLoopLimit } from "./counted-loops.js";
import {
emitSafeIntegerCheck,
@@ -638,6 +645,21 @@ export class LlEmitter {
return this.sizeType === "i64" ? ", !range !{i64 0, i64 4294967296}" : "";
}
/** Whether an element access at `index` of `arr` lies in the hole-free
* prefix: the counter of a dense array loop's fast copy indexing one of
* the arrays its entry test covered. */
provenDenseIndex(arr: IrExpr, index: IrExpr): boolean {
const loop = this.denseLoop;
return (
loop !== null &&
arr.kind === "varRef" &&
loop.arrays.has(arr.localId) &&
index.kind === "varRef" &&
index.localId === loop.counter &&
this.integerLoopBindings.has(loop.counter)
);
}
fieldAliasAttachment(ptr: string): string {
const tag = this.fieldPointerTags.get(ptr);
return tag === undefined ? "" : `, !tbaa !${tag}`;
@@ -991,6 +1013,14 @@ export class LlEmitter {
* immortal from construction (classes.ts programLifetimeClass). */
readonly programLifetime: ReadonlySet<string>;
private numericLocals = new Map<string, IrLocal>();
/** Local arrays of the current function that no other binding can hold
* (ir/array-length-stability.ts). */
private unaliasedArrays: ReadonlySet<string> = new Set();
/** Dense array loops (dense-array-loops.ts) run in this function. */
private denseLoopsEnabled = false;
private denseLoopEntries: ReturnType<typeof findInitializedByteLoopBindings> = new Map();
/** The fast copy of a dense array loop being emitted. */
private denseLoop: DenseArrayLoop | null = null;
private captureIds = new Set<string>();
/** Active induction bindings retain their width independently of size_t. */
integerLoopBindings = new Map<
@@ -6553,6 +6583,7 @@ export class LlEmitter {
const initializerBindings = this.initializerBindings.get(fn.name) ?? [];
const numericFn = withInitializerBindings(fn, initializerBindings);
this.numericLocals = new Map(numericFn.locals.map((l) => [l.id, l]));
this.unaliasedArrays = findUnaliasedLocalArrays(fn);
this.borrowedParameters.clear();
this.referenceWrites?.enter(fn);
this.reboundOwnerSlots.clear();
@@ -6591,6 +6622,12 @@ export class LlEmitter {
this.finalFieldBorrows =
!fn.async && !fn.generator && constructorClass(fn.name) === null && this.finalFields.size > 0;
this.byteWindowsEnabled = this.countedLoopsEnabled;
this.denseLoopsEnabled = this.countedLoopsEnabled;
this.denseLoop = null;
this.denseLoopEntries =
this.denseLoopsEnabled && numericFn.locals.some((l) => l.type.kind === "array")
? findInitializedByteLoopBindings(fn)
: new Map();
this.byteWindowEntries =
this.byteWindowsEnabled && numericFn.locals.some((l) => l.type.kind === "bytes")
? findInitializedByteLoopBindings(fn)
@@ -8030,7 +8067,16 @@ export class LlEmitter {
const acc = elemAccess(s.arr.type.elem);
this.emitPublishedArrayGuard(arr.name, s.arr.type, 0, idx.name);
if (acc === "ref") this.moveTemp(v);
emitDenseArraySet(this, arr.name, idx, s.index, acc, s.arr.type.elem, v.name);
emitDenseArraySet(
this,
arr.name,
idx,
s.index,
acc,
s.arr.type.elem,
v.name,
this.provenDenseIndex(s.arr, s.index),
);
break;
}
case "arraySetLength": {
@@ -8447,7 +8493,12 @@ export class LlEmitter {
(this.debug === null ? matchIntegerBytesForLoop(s, this.numericLocals) : null);
const countedLoop =
!integerLoop && this.countedLoopsEnabled
? matchIntegerCountedForLoop(s, this.numericLocals, this.integerRanges)
? matchIntegerCountedForLoop(
s,
this.numericLocals,
this.integerRanges,
this.unaliasedArrays,
)
: null;
const window =
countedLoop && this.byteWindowsEnabled
@@ -8460,6 +8511,49 @@ export class LlEmitter {
this.byteWindowEntries.get(s) ?? new Set(),
)
: null;
const denseShape = !this.denseLoopsEnabled
? null
: integerArrayLoop
? {
counter: integerArrayLoop.localId,
length: integerArrayLoop.limitReceiver as IrExpr & { kind: "varRef" },
range: { min: 0, max: 4294967294 },
}
: countedLoop && !countedLoop.guarded
? countedDenseShape(s, countedLoop)
: null;
const dense =
denseShape && !window
? matchDenseArrayLoop(
s,
denseShape,
this.numericLocals,
this.unaliasedArrays,
this.denseLoopEntries.get(s) ?? new Set(),
)
: null;
if (dense) {
const valid = emitDenseLoopGuard(this, dense);
const fast = B.newLabel("dense.loop.fast"),
slow = B.newLabel("dense.loop.slow"),
done = B.newLabel("dense.loop.done");
B.condBr(valid, fast, slow);
this.denseLoopsEnabled = false;
try {
B.startBlock(slow);
this.emitStmt(s);
B.br(done);
B.startBlock(fast);
this.denseLoop = dense;
this.emitStmt(s);
B.br(done);
} finally {
this.denseLoop = null;
this.denseLoopsEnabled = true;
}
B.startBlock(done);
break;
}
if (window) {
const valid = emitByteWindowGuard(this, window);
const fast = B.newLabel("bytes.window.fast"),
@@ -8562,7 +8656,10 @@ export class LlEmitter {
this.integerLoopBindings.set(integerLoop.localId, {
slot: integerSlot,
type: this.sizeType,
range: { min: 0, max: integerArrayLoop ? 4294967294 : Number.MAX_SAFE_INTEGER - 1 },
range:
this.denseLoop?.counter === integerLoop.localId
? this.denseLoop.range
: { min: 0, max: integerArrayLoop ? 4294967294 : Number.MAX_SAFE_INTEGER - 1 },
});
} else if (countedLoop) {
integerSlot = B.slot();
@@ -8577,7 +8674,10 @@ export class LlEmitter {
slot: integerSlot,
type: "i64",
signed: true,
range: countedLoop.range,
range:
this.denseLoop?.counter === countedLoop.localId
? this.denseLoop.range
: countedLoop.range,
});
countedLimit = emitCountedLoopLimit(this, countedLoop);
} else if (s.init) {
@@ -571,7 +571,16 @@ export function emitArrIntrinsic(
? host.constantNumericTables.get(e.receiver.localId)
: undefined;
if (!table && elem.kind === "f64")
return { name: emitDenseArrayNumber(host, r.name, index, e.args[0]), type: e.type };
return {
name: emitDenseArrayNumber(
host,
r.name,
index,
e.args[0],
host.provenDenseIndex(e.receiver, e.args[0]!),
),
type: e.type,
};
host.declare(`declare double @scr_arr_get_number(ptr, double)`);
const t = B.tmp();
B.line(
@@ -64,16 +64,25 @@ export function emitLiteralExpr(
const integer = B.tmp();
const number = B.tmp();
B.line(`${integer} = load ${integerSlot.type}, ptr ${integerSlot.slot}`);
// A 64-bit induction value is a safe integer, so its sign bit is
// clear and the signed conversion is exact: one cvtsi2sd instead of
// x86-64's multi-instruction unsigned 64-bit conversion.
const signedConversion = integerSlot.signed === true || integerSlot.type === "i64";
B.line(
`${number} = ${signedConversion ? "sitofp" : "uitofp"} ${integerSlot.type} ${integer} to double`,
);
const uint32 = integerSlot.type === "i32" ? integer : B.tmp();
if (integerSlot.type !== "i32")
B.line(`${uint32} = trunc ${integerSlot.type} ${integer} to i32`);
// A counter whose range fits 32 bits converts from its low half, so
// LLVM folds a later `fptosi` of the double (an inlined callee's
// index) back to the integer. Otherwise a 64-bit induction value is
// a safe integer, so its sign bit is clear and the signed conversion
// is exact: one cvtsi2sd instead of x86-64's multi-instruction
// unsigned 64-bit conversion.
const range = integerSlot.range;
const signedConversion = integerSlot.signed === true || integerSlot.type === "i64";
if (range && range.min >= -2147483648 && range.max <= 2147483647)
B.line(`${number} = sitofp i32 ${uint32} to double`);
else if (range && range.min >= 0 && range.max <= 4294967295)
B.line(`${number} = uitofp i32 ${uint32} to double`);
else
B.line(
`${number} = ${signedConversion ? "sitofp" : "uitofp"} ${integerSlot.type} ${integer} to double`,
);
return {
name: number,
type: e.type,
@@ -851,6 +860,7 @@ export function emitContainerExpr(
elem,
`scr_arr_get_${acc}`,
true,
host.provenDenseIndex(e.arr, e.index),
);
return host.own({ name: t, type: e.type });
}
@@ -93,11 +93,16 @@ export function integerNumber(
// `x | 0`), while a 64-bit source is not known to convert exactly. So a
// counter written `i = (i + 1) | 0` stays an integer instead of taking a
// double round trip on every iteration.
// A uint32 (an array index counter) converts from its low half unsigned,
// which LLVM likewise folds back through a later `fptosi` to i64.
const int32 = range.min >= -2147483648 && range.max <= 2147483647;
const unsigned = !int32 && range.min >= 0 && range.max <= 4294967295;
host.B.line(
int32
? `${number} = sitofp i32 ${uint32} to double`
: `${number} = sitofp i64 ${name} to double`,
: unsigned
? `${number} = uitofp i32 ${uint32} to double`
: `${number} = sitofp i64 ${name} to double`,
);
return { name: number, type, uint32, integer: { name, type: "i64", signed: true, range } };
}
@@ -0,0 +1,129 @@
import { expect, test } from "vitest";
import { findUnaliasedLocalArrays, preservesArrayLength } from "./array-length-stability.js";
import { BOOL, F64, arrayOf, type IrExpr, type IrFunction, type IrLocal, type IrStmt } from "./ir.js";
const loc = { file: "array-length-stability.ts", start: 0, end: 0 };
const ARR = arrayOf(F64);
const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc });
const ref = (localId: string): IrExpr => ({ kind: "varRef", localId, type: F64, loc });
const arr = (localId: string): IrExpr => ({ kind: "varRef", localId, type: ARR, loc });
const method = (
receiver: string,
name: Extract<IrExpr, { kind: "arrIntrinsic" }>["method"],
args: IrExpr[] = [],
): IrExpr => ({ kind: "arrIntrinsic", method: name, receiver: arr(receiver), args, type: F64, loc });
const length = (receiver: string): IrExpr => method(receiver, "length");
const read = (receiver: string, index: IrExpr): IrExpr => method(receiver, "getNumber", [index]);
const plus = (left: IrExpr, right: IrExpr): IrExpr => ({
kind: "bin",
op: "+",
left,
right,
type: F64,
loc,
});
const stmt = (expr: IrExpr): IrStmt => ({ kind: "exprStmt", expr, loc });
const set = (receiver: string, index: IrExpr, value: IrExpr): IrStmt => ({
kind: "arraySet",
arr: arr(receiver),
index,
value,
loc,
});
const cond: IrExpr = { kind: "bin", op: "<", left: ref("i"), right: length("a"), type: BOOL, loc };
const sum: IrStmt = {
kind: "assign",
localId: "s",
value: plus(ref("s"), read("a", ref("i"))),
loc,
};
const locals = new Map<string, IrLocal>([
["i", { id: "i", name: "i", type: F64, mutable: true }],
["s", { id: "s", name: "s", type: F64, mutable: true }],
["a", { id: "a", name: "a", type: ARR, mutable: false }],
["b", { id: "b", name: "b", type: ARR, mutable: false }],
["out", { id: "out", name: "out", type: ARR, mutable: false }],
]);
const preserves = (
body: IrStmt[],
inRange: string | null = "i",
unaliased: ReadonlySet<string> = new Set(),
scope = locals,
): boolean => preservesArrayLength([cond, ...body], { receiver: "a", inRange }, scope, unaliased);
test("reads, local arithmetic and element writes at the in-range counter keep the length", () => {
expect(preserves([sum])).toBe(true);
expect(preserves([set("a", ref("i"), num(1)), set("b", ref("i"), read("a", ref("i")))])).toBe(
true,
);
expect(preserves([stmt(method("a", "indexOf", [num(3)]))])).toBe(true);
});
test("writes past the counter, without an in-range counter, or to the counter can grow it", () => {
expect(preserves([set("a", plus(ref("i"), num(1)), num(1))])).toBe(false);
expect(preserves([set("b", ref("s"), num(1))])).toBe(false);
expect(preserves([set("a", ref("i"), num(1))], null)).toBe(false);
expect(preserves([{ kind: "assign", localId: "i", value: num(0), loc }, sum])).toBe(false);
});
test("resizing methods, length stores, calls and escapes refuse the proof", () => {
for (const name of ["push", "pop", "shift", "unshift", "splice", "fill"] as const)
expect(preserves([stmt(method("a", name, [num(1)]))])).toBe(false);
// An unrelated array may alias the receiver.
expect(preserves([stmt(method("b", "push", [num(1)]))])).toBe(false);
expect(preserves([{ kind: "arraySetLength", arr: arr("a"), length: num(0), loc }])).toBe(false);
expect(preserves([{ kind: "arrayDelete", arr: arr("a"), index: ref("i"), loc }])).toBe(false);
expect(preserves([stmt({ kind: "call", callee: "f", args: [], type: F64, loc })])).toBe(false);
expect(
preserves([stmt({ kind: "call", callee: "f", args: [arr("a")], type: F64, loc })]),
).toBe(false);
expect(preserves([{ kind: "varDecl", localId: "b", init: arr("a"), loc }])).toBe(false);
expect(preserves([{ kind: "assign", localId: "a", value: arr("b"), loc }])).toBe(false);
});
test("an unaliased local array other than the receiver may grow", () => {
const push = stmt(method("out", "push", [read("a", ref("i"))]));
expect(preserves([push], "i", new Set(["out"]))).toBe(true);
expect(preserves([set("out", ref("s"), num(1))], "i", new Set(["out"]))).toBe(true);
expect(preserves([push])).toBe(false);
// The receiver itself is never resized, even when no alias exists.
expect(preserves([stmt(method("a", "push", [num(1)]))], "i", new Set(["a"]))).toBe(false);
});
test("captured receivers have no local proof", () => {
const boxed = new Map(locals);
boxed.set("a", { ...locals.get("a")!, boxed: true });
expect(preserves([sum], "i", new Set(), boxed)).toBe(false);
});
function fn(body: IrStmt[], params: string[] = []): IrFunction {
return {
loc,
name: "f",
params: params.map((localId) => ({ localId, type: ARR })),
returnType: ARR,
locals: [...locals.values()],
body,
} as IrFunction;
}
const fresh: IrExpr = { kind: "arrayLit", elems: [], type: ARR, loc };
test("fresh local arrays read only as receivers have no alias", () => {
const body: IrStmt[] = [
{ kind: "varDecl", localId: "out", init: fresh, loc },
stmt(method("out", "push", [num(1)])),
{ kind: "return", value: arr("out"), loc },
];
expect([...findUnaliasedLocalArrays(fn(body))]).toEqual(["out"]);
// Parameters, copies into another binding, and arguments escape.
expect(findUnaliasedLocalArrays(fn(body, ["out"])).size).toBe(0);
const copied = [...body, { kind: "varDecl", localId: "b", init: arr("out"), loc } as IrStmt];
expect(findUnaliasedLocalArrays(fn(copied)).has("out")).toBe(false);
const passed = [
...body,
stmt({ kind: "call", callee: "g", args: [arr("out")], type: F64, loc }),
];
expect(findUnaliasedLocalArrays(fn(passed)).has("out")).toBe(false);
const rebound = [...body, { kind: "assign", localId: "out", value: arr("a"), loc } as IrStmt];
expect(findUnaliasedLocalArrays(fn(rebound)).has("out")).toBe(false);
});
@@ -0,0 +1,259 @@
import type { IrExpr, IrFunction, IrLocal, IrStmt } from "./ir.js";
import { isBorrowSafeMath } from "./analysis.js";
import { byteNumberAccess } from "./byte-numbers.js";
import { everyExprChild, everyStmtChild } from "./traverse.js";
/** Array methods that read the receiver without changing its length or its
* slot states and run no user code. */
const READING_ARRAY_METHODS = new Set<string>([
"length",
"getNumber",
"indexEq",
"indexOf",
"includes",
"nextPresent",
]);
/** String methods that read an immutable string and run no user code. */
const READING_STRING_METHODS = new Set<string>(["length", "charCodeAt", "codePointAt"]);
/** Expressions that compute from their operands and run no user code. Their
* children are checked separately. */
const PLAIN_EXPRESSIONS = new Set<string>([
"numLit",
"strLit",
"boolLit",
"unitLit",
"varRef",
"bin",
"unary",
"incDec",
"toBool",
"logical",
"ternary",
"strEq",
"strCmp",
"fieldGet",
"recordGet",
"unionNarrow",
"unionIsTag",
"unionDisc",
"unionWrap",
"upcast",
"downcast",
"instanceOf",
"nullish",
"seqExpr",
]);
/** Statements whose own effect is a local write or control flow inside the
* loop. Their children are checked separately. */
const PLAIN_STATEMENTS = new Set<string>([
"varDecl",
"assign",
"exprStmt",
"if",
"block",
"break",
"continue",
"for",
"while",
"doWhile",
]);
/** Array methods whose result is never the receiver itself (unlike
* `reverse`, `fill` or `copyWithin`, which return it). */
const CONTAINED_RECEIVER_METHODS = new Set<string>([
...READING_ARRAY_METHODS,
"push",
"pop",
"shift",
"unshift",
"splice",
"join",
"slice",
]);
/** Local arrays that no other binding can hold: every write of the local
* stores a fresh array literal and every read of it is the receiver of an
* element access or array method (or the value of a `return`, which ends the
* activation). No alias of such an array exists, so mutating it cannot change
* the length of an array held by any other binding. */
export function findUnaliasedLocalArrays(fn: IrFunction): ReadonlySet<string> {
const candidates = new Set<string>();
const captured = new Set(
[...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId),
);
for (const local of fn.locals)
if (local.type.kind === "array" && !local.boxed && !local.tdz && !captured.has(local.id))
candidates.add(local.id);
for (const param of fn.params) candidates.delete(param.localId);
const written = new Set<string>();
const fresh = (value: IrExpr | null): boolean =>
value !== null && (value.kind === "arrayLit" || value.kind === "arrayNewLen");
const receiver = (e: IrExpr): void => {
if (e.kind !== "varRef") expr(e);
};
const expr = (e: IrExpr): boolean => {
switch (e.kind) {
case "varRef":
candidates.delete(e.localId);
return true;
case "assignExpr":
candidates.delete(e.localId);
break;
case "arrIntrinsic":
if (!CONTAINED_RECEIVER_METHODS.has(e.method)) break;
receiver(e.receiver);
e.args.forEach(expr);
return true;
case "arrayGet":
case "arrayHas":
case "arrayState":
receiver(e.arr);
expr(e.index);
return true;
}
return everyExprChild(e, expr, stmt);
};
const stmt = (s: IrStmt): boolean => {
switch (s.kind) {
case "varDecl":
case "assign": {
const value = s.kind === "varDecl" ? s.init : s.value;
if (fresh(value)) written.add(s.localId);
else if (value) candidates.delete(s.localId);
if (value) expr(value);
return true;
}
case "arraySet":
receiver(s.arr);
expr(s.index);
expr(s.value);
return true;
case "return":
if (s.value) receiver(s.value);
return true;
case "forOf":
case "tryCatch":
if (s.kind === "forOf") candidates.delete(s.localId);
if (s.kind === "tryCatch" && s.catchLocalId) candidates.delete(s.catchLocalId);
break;
}
return everyStmtChild(s, expr, stmt);
};
fn.body.every(stmt);
// A binding with no visible write takes its value some other way.
for (const id of candidates) if (!written.has(id)) candidates.delete(id);
return candidates;
}
export interface ArrayLengthLoop {
/** The local holding the array whose length must not change. */
receiver: string;
/** A binding that holds a value below the array's length wherever the
* loop's condition, body or update reads it (the induction variable of
* `for (let i = 0; i < a.length; i++)` when the body never writes it, as
* long as the length itself has not changed). An element write at this
* index replaces an existing element or sets a non-index property. */
inRange: string | null;
}
/** Whether running `nodes` (a loop's condition, body and update) can never
* change the length of the array held by local `loop.receiver`. The proof is
* syntactic and conservative: every expression and statement must be one
* that runs no user code (no calls, callbacks, closures, iterators,
* suspension or exception regions); the binding is never written and only
* ever read as the receiver of an element access or a reading array method,
* so the array cannot escape to code that resizes it; and nothing in the
* nodes resizes any array that may be the same one. Element writes are
* accepted at the in-range binding (no array grows by them: an alias of the
* receiver has the same length) and anywhere in an unaliased local array.
* Array methods that resize an array are accepted only on unaliased local
* arrays other than the receiver. */
export function preservesArrayLength(
nodes: readonly (IrExpr | IrStmt)[],
loop: ArrayLengthLoop,
locals: ReadonlyMap<string, IrLocal>,
unaliased: ReadonlySet<string>,
): boolean {
const local = locals.get(loop.receiver);
if (!local || local.type.kind !== "array" || local.boxed || local.tdz) return false;
const separate = (e: IrExpr): boolean =>
e.kind === "varRef" && e.localId !== loop.receiver && unaliased.has(e.localId);
// A receiver position: the receiver binding itself is read there without
// escaping.
const receiver = (e: IrExpr): boolean =>
(e.kind === "varRef" && e.localId === loop.receiver) || expr(e);
const written = (id: string): boolean => id === loop.receiver || id === loop.inRange;
const expr = (e: IrExpr): boolean => {
switch (e.kind) {
case "varRef":
return e.localId !== loop.receiver;
case "assignExpr":
case "incDec":
return !written(e.localId) && everyExprChild(e, expr, stmt);
case "arrayGet":
case "arrayHas":
case "arrayState":
return receiver(e.arr) && expr(e.index);
case "arrIntrinsic":
if (READING_ARRAY_METHODS.has(e.method)) return receiver(e.receiver) && e.args.every(expr);
return separate(e.receiver) && e.args.every(expr);
case "strIntrinsic":
return (
READING_STRING_METHODS.has(e.method) &&
e.receiver.type.kind === "string" &&
expr(e.receiver) &&
e.args.every(expr)
);
case "bytesIntrinsic": {
const numeric = byteNumberAccess(e);
return (
(numeric ? !numeric.write : e.method === "get" || e.method === "length") &&
expr(e.receiver) &&
e.args.every(expr)
);
}
case "libCall":
return (
isBorrowSafeMath(e.fn) &&
e.args.every((arg) => arg.type.kind === "f64") &&
e.args.every(expr)
);
case "strConcat":
return (
e.left.type.kind === "string" &&
e.right.type.kind === "string" &&
expr(e.left) &&
expr(e.right)
);
default:
return PLAIN_EXPRESSIONS.has(e.kind) && everyExprChild(e, expr, stmt);
}
};
const stmt = (s: IrStmt): boolean => {
switch (s.kind) {
case "varDecl":
case "assign":
if (written(s.localId)) return false;
break;
case "arraySet":
return (
(separate(s.arr) ||
(s.index.kind === "varRef" &&
loop.inRange !== null &&
s.index.localId === loop.inRange)) &&
receiver(s.arr) &&
expr(s.index) &&
expr(s.value)
);
case "return":
return s.value === null || expr(s.value);
default:
if (!PLAIN_STATEMENTS.has(s.kind)) return false;
}
return everyStmtChild(s, expr, stmt);
};
return nodes.every((node) => ("type" in node ? expr(node as IrExpr) : stmt(node as IrStmt)));
}
+26 -3
View File
@@ -118,6 +118,9 @@ export function boundedIntegerLoopFacts(
stable(e.receiver.localId)
)
return { min: 0, max: Number.MAX_SAFE_INTEGER };
// Every evaluation of an array length lies in its range, so the
// condition bounds each pass even when the body resizes the array.
if (isArrayLength(e)) return ARRAY_LENGTH_RANGE;
return null;
}
let step: IntegerRange;
@@ -134,12 +137,15 @@ export function boundedIntegerLoopFacts(
if (step.min <= 0 || step.max > 2147483647) return result;
}
const limit = bound(loop.cond.right);
if (!limit || limit.max < start.min) return result;
const distance = limit.max - start.min;
// The counter is an integer, so a strict bound admits passes up to one
// below the limit.
const last = limit && (loop.cond.op === "<" ? limit.max - 1 : limit.max);
if (!limit || last === null || last < start.min) return result;
const distance = last - start.min;
const iterations = Math.floor(distance / step.min) + 1;
if (!Number.isSafeInteger(distance) || !Number.isSafeInteger(iterations)) return result;
// Header facts also cover the failing final condition and zero iterations.
const maximum = Math.max(start.max, limit.max + step.max);
const maximum = Math.max(start.max, last + step.max);
if (Number.isSafeInteger(maximum)) result.set(counter, { min: start.min, max: maximum });
for (const [id, hi] of upper) {
const range = initial.get(id);
@@ -154,6 +160,20 @@ export function boundedIntegerLoopFacts(
return result;
}
/** Every length an array can have: SCR_ARR_MAX_LENGTH (scr_array.c). */
export const ARRAY_LENGTH_RANGE: IntegerRange = { min: 0, max: 4294967295 };
/** `a.length` of a typed array value (an ordinary JS array). */
function isArrayLength(e: IrExpr): boolean {
return (
e.kind === "arrIntrinsic" &&
e.method === "length" &&
e.args.length === 0 &&
e.type.kind === "f64" &&
e.receiver.type.kind === "array"
);
}
/** The most UTF-16 code units a runtime string holds: SCR_STR_MAX_CAP
* (scr_runtime.h) caps a string's UTF-8 bytes, and a UTF-16 length never
* exceeds the UTF-8 byte length. */
@@ -241,6 +261,9 @@ export function guardedLoopCounterFacts(
stable(e.receiver.localId)
)
return { min: 0, max: STRING_MAX_UNITS };
// An array length can change on any pass, but never leaves its range,
// which is all a ceiling needs.
if (isArrayLength(e)) return ARRAY_LENGTH_RANGE;
return null;
};
// The bounds a condition's conjuncts put on counters while it holds.
@@ -208,3 +208,111 @@ test("a string read at the counter is an integer consumer", () => {
s.body = [read(ref("limit"))];
expect(matchIntegerCountedForLoop(s, withText)).toBeNull();
});
test("a stable array length bounds a counted loop; a resizing body keeps it a condition", () => {
const ARR = arrayOf(F64);
const array = (localId: string): IrExpr => ({ kind: "varRef", localId, type: ARR, loc });
const length: IrExpr = {
kind: "arrIntrinsic",
method: "length",
receiver: array("a"),
args: [],
type: F64,
loc,
};
const read = (index: IrExpr): IrExpr => ({
kind: "arrIntrinsic",
method: "getNumber",
receiver: array("a"),
args: [index],
type: F64,
loc,
});
// for (let i = 0; i < a.length; i += 2) a[i];
const strided = (body: IrStmt[]): IrStmt & { kind: "for" } => {
const s = counted(length);
s.update = {
kind: "assign",
localId: "i",
value: { kind: "bin", op: "+", left: ref("i"), right: number(2), type: F64, loc },
loc,
};
s.body = [{ kind: "exprStmt", expr: read(ref("i")), loc }, ...body];
return s;
};
const ranges = new Map([[length, { min: 0, max: 4294967295 }]]);
expect(matchIntegerCountedForLoop(strided([]), locals, ranges, new Set())).toMatchObject({
localId: "i",
guarded: false,
range: { min: 0, max: 4294967294 },
});
// Without the alias facts there is no stability proof.
expect(matchIntegerCountedForLoop(strided([]), locals, ranges)).toBeNull();
const push: IrStmt = {
kind: "exprStmt",
expr: {
kind: "arrIntrinsic",
method: "push",
receiver: array("a"),
args: [number(1)],
type: F64,
loc,
},
loc,
};
expect(matchIntegerCountedForLoop(strided([push]), locals, ranges, new Set())).toBeNull();
// An element write at the counter stays below the length; one past it can grow the array.
const write = (index: IrExpr): IrStmt => ({
kind: "arraySet",
arr: array("a"),
index,
value: number(0),
loc,
});
expect(
matchIntegerCountedForLoop(strided([write(ref("i"))]), locals, ranges, new Set()),
).not.toBeNull();
const past = { kind: "bin", op: "+", left: ref("i"), right: number(1), type: F64, loc } as const;
expect(matchIntegerCountedForLoop(strided([write(past)]), locals, ranges, new Set())).toBeNull();
});
test("a descending loop from an array's last index takes an integer counter for its reads", () => {
const ARR = arrayOf(F64);
const length: IrExpr = {
kind: "arrIntrinsic",
method: "length",
receiver: { kind: "varRef", localId: "a", type: ARR, loc },
args: [],
type: F64,
loc,
};
const start: IrExpr = { kind: "bin", op: "-", left: length, right: number(1), type: F64, loc };
const s = loop(start);
s.cond = { kind: "bin", op: ">=", left: ref("i"), right: number(0), type: BOOL, loc };
s.update = {
kind: "assign",
localId: "i",
value: { kind: "bin", op: "-", left: ref("i"), right: number(1), type: F64, loc },
loc,
};
s.body = [
{
kind: "exprStmt",
expr: {
kind: "arrIntrinsic",
method: "getNumber",
receiver: { kind: "varRef", localId: "a", type: ARR, loc },
args: [ref("i")],
type: F64,
loc,
},
loc,
},
];
const ranges = new Map([[start, { min: -1, max: 4294967294 }]]);
expect(matchIntegerCountedForLoop(s, locals, ranges)).toMatchObject({
step: -1,
guarded: false,
range: { min: 0, max: 4294967294 },
});
});
+51 -7
View File
@@ -3,6 +3,7 @@ import type { IntegerRange, IntegerRanges } from "./integer-ranges.js";
import { everyExprChild, everyStmtChild, everyStmtList } from "./traverse.js";
import { byteNumberAccess } from "./byte-numbers.js";
import { preservesArrayLength } from "./array-length-stability.js";
/** A canonical byte loop whose induction variable is mathematically an
* unsigned integer for every body entry. Backends may keep this binding in
@@ -47,15 +48,17 @@ function constantStep(update: IrStmt | null, id: string): number | null {
}
/** Pure initial values may be evaluated again on a fallback path. Bounds
* additionally have to remain invariant throughout the loop. Array length
* is allowed only in the initializer; its mutable value is never hoisted
* from a condition. Typed-array lengths are fixed for a given receiver. */
* additionally have to remain invariant throughout the loop. An array length
* is hoisted from a condition only when `stableLength` proves the loop never
* changes it; otherwise it is allowed only in the initializer. Typed-array
* lengths are fixed for a given receiver. */
function stableLoopValue(
value: IrExpr,
locals: ReadonlyMap<string, IrLocal>,
body: IrStmt[],
invariant: boolean,
counter: string,
stableLength: (receiver: string) => boolean = () => false,
): boolean {
switch (value.kind) {
case "numLit":
@@ -77,16 +80,16 @@ function stableLoopValue(
value.op === "&" ||
value.op === ">>>" ||
value.op === "|") &&
stableLoopValue(value.left, locals, body, invariant, counter) &&
stableLoopValue(value.right, locals, body, invariant, counter)
stableLoopValue(value.left, locals, body, invariant, counter, stableLength) &&
stableLoopValue(value.right, locals, body, invariant, counter, stableLength)
);
case "bytesIntrinsic":
case "arrIntrinsic":
return (
(value.method === "length" ||
(value.kind === "bytesIntrinsic" && value.method === "byteLength")) &&
(value.kind === "bytesIntrinsic" || !invariant) &&
value.receiver.kind === "varRef" &&
(value.kind === "bytesIntrinsic" || !invariant || stableLength(value.receiver.localId)) &&
stableLoopValue(value.receiver, locals, body, invariant, counter)
);
default:
@@ -103,6 +106,7 @@ export function matchIntegerCountedForLoop(
stmt: IrStmt & { kind: "for" },
locals: ReadonlyMap<string, IrLocal>,
ranges: IntegerRanges = new Map(),
unaliased: ReadonlySet<string> | null = null,
): IntegerCountedForLoop | null {
const init = stmt.init;
if (init?.kind !== "varDecl" || !init.init) return null;
@@ -135,7 +139,38 @@ export function matchIntegerCountedForLoop(
op = ({ "<": ">", "<=": ">=", ">": "<", ">=": "<=" } as Record<string, string>)[op] ?? "";
}
if (step > 0 ? op !== "<" && op !== "<=" : op !== ">" && op !== ">=") return null;
if (!stableLoopValue(limit, locals, stmt.body, true, local.id)) return null;
// An ascending counter compared below `a.length - c` holds an index below
// the length on every pass while that length is unchanged.
const belowLength = (receiver: string): boolean => {
let offset = 0,
length = limit;
if (
length.kind === "bin" &&
length.op === "-" &&
length.right.kind === "numLit" &&
Number.isFinite(length.right.value)
) {
offset = length.right.value;
length = length.left;
}
return (
step > 0 &&
length.kind === "arrIntrinsic" &&
length.method === "length" &&
length.receiver.kind === "varRef" &&
length.receiver.localId === receiver &&
(op === "<" ? offset >= 0 : offset >= 1)
);
};
const stableLength = (receiver: string): boolean =>
unaliased !== null &&
preservesArrayLength(
[cond, ...stmt.body],
{ receiver, inRange: belowLength(receiver) ? local.id : null },
locals,
unaliased,
);
if (!stableLoopValue(limit, locals, stmt.body, true, local.id, stableLength)) return null;
const inclusive = op === "<=" || op === ">=";
const guardLimit = step > 0 ? 2 ** 53 - (inclusive ? 1 : 0) : -(2 ** 53) + (inclusive ? 1 : 0);
const boundRange =
@@ -178,10 +213,19 @@ export function matchIntegerCountedForLoop(
// A string read at the counter indexes with the integer directly.
if (e.kind === "strIntrinsic" && e.method === "charCodeAt" && e.args[0] && counter(e.args[0]))
useful = true;
// So does an array element read; only a loop that needs no versioned
// copy takes the integer counter for it.
if (
!guarded &&
((e.kind === "arrayGet" && counter(e.index)) ||
(e.kind === "arrIntrinsic" && e.method === "getNumber" && e.args[0] && counter(e.args[0])))
)
useful = true;
return (!guarded || ++cost <= 80) && everyExprChild(e, expr, bodyStmt);
};
const bodyStmt = (s: IrStmt): boolean => {
if (s.kind === "bytesSet" && counter(s.index)) useful = true;
if (!guarded && s.kind === "arraySet" && counter(s.index)) useful = true;
if (
guarded &&
(s.kind === "for" || s.kind === "forOf" || s.kind === "while" || s.kind === "doWhile")
@@ -334,7 +334,8 @@ test("filtered strided loops retain decoded values and bound secondary cursors",
const ranges = analyzeIntegerRanges(f);
expect(ranges.get(cursor)).toEqual({ min: 0, max: 80 });
expect(ranges.get(decoded)).toEqual({ min: 0, max: 4294967295 });
expect(ranges.get(conditionRead)).toEqual({ min: 0, max: 124 });
// The last pass enters at most at 99, so the failing test reads at most 123.
expect(ranges.get(conditionRead)).toEqual({ min: 0, max: 123 });
for (const mutation of ["fraction", "repeat", "overflow", "counter"] as const) {
const changed = structuredClone(f);
const body = (changed.body[1] as IrStmt & { kind: "for" }).body;
@@ -795,6 +796,58 @@ 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("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;
const items: IrExpr = { loc, kind: "varRef", localId: "items", type: ARR };
const length: IrExpr = { loc, kind: "arrIntrinsic", method: "length", receiver: items, args: [], type: F64 };
const push: IrStmt = {
loc,
kind: "exprStmt",
expr: { loc, kind: "arrIntrinsic", method: "push", receiver: items, args: [ref("i")], type: F64 },
};
const bump: IrStmt = {
loc,
kind: "exprStmt",
expr: { loc, kind: "incDec", op: "+", prefix: false, localId: "i", type: F64 },
};
const inside = ref("i"),
after = ref("i");
const f = fn([
{ loc, kind: "assign", localId: "i", value: num(0) },
{ loc, kind: "while", cond: condition("<", ref("i"), length), body: [observe(inside), push, bump] },
observe(after),
]);
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: 4294967294 });
expect(ranges.get(after)).toEqual({ min: 0, max: 4294967295 });
// A secondary cursor of `for (let j = 0; j < items.length; j++) k++` stays a uint32.
const cursor = ref("k");
const loop: IrStmt = {
loc,
kind: "for",
init: { loc, kind: "varDecl", localId: "j", init: num(0) },
cond: condition("<", ref("j"), length),
update: { loc, kind: "assign", localId: "j", value: bin("+", ref("j"), num(1)) },
body: [
push,
observe(cursor),
{ loc, kind: "assign", localId: "k", value: bin("+", ref("k"), num(1)) },
],
};
const g = fn([{ loc, kind: "assign", localId: "k", value: num(0) }, loop]);
g.locals.push(
{ id: "j", name: "j", type: F64, mutable: true },
{ id: "k", name: "k", type: F64, mutable: true },
{ id: "items", name: "items", type: ARR, mutable: false },
);
expect(analyzeIntegerRanges(g).get(cursor)).toEqual({ min: 0, max: 4294967295 });
});
test("whole-program slot facts seed parameters, field reads and call results", () => {
const field: IrExpr = {
loc,
@@ -0,0 +1,167 @@
// Index loops bounded by an array's length whose bodies resize that array,
// directly, through an alias, a helper or a closure: the bound is re-read on
// every pass, exactly like Node.
function pushInBody(): string {
const a = [1, 2, 3];
const seen: number[] = [];
for (let i = 0; i < a.length; i++) {
seen.push(a[i]);
if (a.length < 6) a.push(a[i] * 10);
}
return `${seen.join(",")} | ${a.length}`;
}
function stridedPush(): string {
const a = [0, 1, 2, 3];
let visits = 0;
for (let i = 0; i < a.length; i += 2) {
visits++;
if (a.length < 9) a.push(i);
}
return `${visits} ${a.join(",")}`;
}
function truncateInBody(): string {
const a = [5, 6, 7, 8, 9, 10];
let sum = 0;
for (let i = 0; i < a.length; i++) {
sum += a[i];
if (i === 2) a.length = 4;
}
return `${sum} ${a.length}`;
}
function growLengthInBody(): string {
const a = [1, 2];
let holes = 0;
for (let i = 0; i < a.length; i++) {
if (a[i] === undefined) holes++;
if (i === 0) a.length = 5;
}
return `${holes} ${a.length}`;
}
function helperPush(xs: number[], v: number): void {
if (xs.length < 7) xs.push(v);
}
function pushThroughHelper(): string {
const a = [1, 2, 3];
let sum = 0;
for (let i = 0; i < a.length; i++) {
sum += a[i];
helperPush(a, i + 100);
}
return `${sum} ${a.length}`;
}
function pushThroughClosure(): string {
const a = [1, 2, 3];
const grow = (v: number): void => {
if (a.length < 6) a.push(v);
};
let sum = 0;
for (let i = 0; i < a.length; i++) {
sum += a[i];
grow(i);
}
return `${sum} ${a.length}`;
}
function pushThroughAlias(): string {
const a = [1, 2, 3];
const alias = a;
let sum = 0;
for (let i = 0; i < a.length; i++) {
sum += a[i];
if (alias.length < 8) alias.push(i * 2);
}
return `${sum} ${a.length}`;
}
function aliasedParameters(a: number[], out: number[]): string {
// `out` may be `a` itself.
let n = 0;
for (let i = 0; i < a.length; i++) {
n++;
if (out.length < 10) out.push(a[i]);
}
return `${n} ${a.length} ${out.length}`;
}
function writePastEnd(): string {
const a = [1, 2, 3];
let passes = 0;
for (let i = 0; i < a.length; i++) {
passes++;
if (i + 1 === a.length && a.length < 6) a[i + 1] = i;
}
return `${passes} ${a.join(",")}`;
}
function shiftInBody(): string {
const a = [1, 2, 3, 4, 5, 6];
const seen: number[] = [];
for (let i = 0; i < a.length; i++) {
seen.push(a[i]);
a.shift();
}
return `${seen.join(",")} | ${a.join(",")}`;
}
function spliceInDescendingLoop(): string {
const a = [1, 2, 3, 4, 5, 6, 7];
for (let i = a.length - 1; i >= 0; i--) if (a[i] % 2 === 0) a.splice(i, 1);
return a.join(",");
}
function whilePush(): string {
const a = [3, 1];
let i = 0;
let sum = 0;
while (i < a.length) {
sum += a[i];
if (a.length < 5) a.push(sum);
i++;
}
return `${sum} ${i} ${a.join(",")}`;
}
function nestedResize(): string {
const rows = [[1], [2, 3], [4, 5, 6]];
let total = 0;
for (let r = 0; r < rows.length; r++) {
const row = rows[r];
for (let c = 0; c < row.length; c++) {
total += row[c];
if (row.length < 3) row.push(c);
}
}
return `${total} ${rows.map((row) => row.length).join(",")}`;
}
function pushIntoOther(): string {
const a = [2, 4, 6];
const out: number[] = [];
for (let i = 0; i < a.length; i++) out.push(a[i] + 1);
for (let i = 0; i < out.length; i += 2) out.push(-1);
return `${out.join(",")}`;
}
console.log(pushInBody());
console.log(stridedPush());
console.log(truncateInBody());
console.log(growLengthInBody());
console.log(pushThroughHelper());
console.log(pushThroughClosure());
console.log(pushThroughAlias());
const shared = [7, 8, 9];
console.log(aliasedParameters(shared, shared));
console.log(aliasedParameters([7, 8, 9], []));
console.log(writePastEnd());
console.log(shiftInBody());
console.log(spliceInDescendingLoop());
console.log(whilePush());
console.log(nestedResize());
console.log(pushIntoOther());
@@ -0,0 +1,133 @@
// Index loops over sparse arrays with lengths beyond 2^31, holes, and arrays
// that become sparse while a loop runs. Dense-loop fast paths must leave all
// of these on the general path.
function partialAscending(a: number[], limit: number): string {
let sum = 0;
let passes = 0;
for (let i = 0; i < a.length; i++) {
if (passes++ === limit) break;
sum += a[i] === undefined ? -1 : a[i];
}
return `${passes} ${sum}`;
}
function partialDescending(a: number[], limit: number): string {
let last = -1;
let passes = 0;
for (let i = a.length - 1; i >= 0; i--) {
if (passes++ === limit) break;
last = i;
}
return `${passes} ${last}`;
}
function stridedFromEnd(a: number[]): string {
const hits: number[] = [];
for (let i = a.length - 1; i >= 0 && hits.length < 3; i -= 1000000000) hits.push(i);
return hits.join(",");
}
function whileWithLimit(a: number[], limit: number): number {
let i = 0;
let present = 0;
while (i < a.length && i < limit) {
if (a[i] !== undefined) present++;
i++;
}
return present * 1000 + i;
}
function writeAtCounter(a: number[], limit: number): string {
for (let i = 0; i < a.length && i < limit; i++) a[i] = i * 2;
return `${a.length} ${a[0]} ${a[limit - 1]} ${a[limit]}`;
}
function holeSum(a: number[]): string {
let sum = 0;
let nan = 0;
for (let i = 0; i < a.length; i++) {
const v = a[i];
if (Number.isNaN(v + 0)) nan++;
else sum += v;
}
return `${sum} ${nan}`;
}
function firstValues(a: number[]): string {
let s = 0;
for (let i = 0; i < a.length; i++) {
if (i === 4) break;
s += a[i];
}
let t = 0;
for (let i = a.length - 1; i >= 0; i--) {
if (i < a.length - 2) break;
t += a[i];
}
let u = 0;
for (let i = 0; i < a.length; i += 2) {
if (i > 2) break;
u += a[i];
}
return `${s} ${t} ${u}`;
}
const huge: number[] = [1, 2, 3];
huge.length = 3e9;
console.log(huge.length);
console.log(partialAscending(huge, 5));
console.log(firstValues(huge), firstValues([1, 2, 3, 4, 5]));
console.log(partialDescending(huge, 4));
console.log(stridedFromEnd(huge));
console.log(whileWithLimit(huge, 6));
console.log(writeAtCounter(huge, 4));
console.log(huge.length);
const edge: number[] = [];
edge[4294967294] = 7;
console.log(edge.length, partialDescending(edge, 2), partialAscending(edge, 3));
const holey = [1, 2];
holey[3] = 4;
holey[5] = 6;
console.log(holeSum(holey));
const sparse: number[] = [1, 2];
sparse[10] = 5;
console.log(holeSum(sparse));
// Becomes sparse mid-loop: a write far past the end through another binding.
function sparseMidLoop(): string {
const a = [1, 2, 3, 4];
const b = a;
let sum = 0;
for (let i = 0; i < a.length; i++) {
if (i === 1) b[3000000000] = 9;
if (i > 6) break;
sum += a[i] === undefined ? 100 : a[i];
}
return `${sum} ${a.length}`;
}
console.log(sparseMidLoop());
function holeMidLoop(): string {
const a = [1, 2, 3, 4, 5];
let sum = 0;
for (let i = 0; i < a.length; i++) {
if (i === 1) a.length = 3;
if (i === 2) a.length = 5;
sum += a[i] === undefined ? 100 : a[i];
}
return `${sum}`;
}
console.log(holeMidLoop());
const objects: { v: number }[] = [{ v: 1 }, { v: 2 }, { v: 3 }];
objects.length = 5;
try {
let total = 0;
for (let i = 0; i < objects.length; i++) total += objects[i].v;
console.log(total);
} catch (e) {
console.log(e instanceof TypeError ? "TypeError" : "other");
}
@@ -0,0 +1,173 @@
// Index loops whose array length the loop never changes, run over dense,
// holey, partially filled, aliased and short arrays, with fractional,
// negative and offset bounds, ascending, strided and descending counters.
interface Point {
x: number;
y: number;
}
function sum(a: number[]): number {
let s = 0;
for (let i = 0; i < a.length; i++) s += a[i];
return s;
}
function scale(a: number[], b: number[]): string {
for (let i = 0; i < a.length; i++) b[i] = a[i] * 2;
return `${b.length}:${b.join(",")}`;
}
function inPlace(a: number[]): string {
for (let i = 0; i < a.length; i++) a[i] = a[i] + 1;
return a.join(",");
}
function strided(a: number[], step: number): string {
let s = 0;
if (step === 2) for (let i = 0; i < a.length; i += 2) s += a[i];
else for (let i = 1; i < a.length; i += 3) s += a[i];
return `${s}`;
}
function descending(a: number[]): string {
const out: number[] = [];
for (let i = a.length - 1; i >= 0; i--) out.push(a[i]);
return out.join(",");
}
function descendingPairs(a: number[]): number {
let s = 0;
for (let i = a.length - 2; i > 0; i -= 2) s += a[i] - a[i + 1];
return s;
}
function offsetBounds(a: number[]): string {
let inner = 0;
for (let i = 1; i < a.length - 1; i++) inner += a[i];
let inclusive = 0;
for (let i = 0; i <= a.length - 1; i++) inclusive += a[i];
let diffs = 0;
for (let i = 0; i < a.length - 1; i++) diffs += a[i + 1] - a[i];
return `${inner} ${inclusive} ${diffs}`;
}
function fractionalBounds(a: number[]): string {
const seen: number[] = [];
for (let i = 0.5; i < a.length; i++) seen.push(a[i] === undefined ? -1 : 1);
for (let i = -2; i < a.length - 3; i++) seen.push(a[i] === undefined ? -2 : a[i]);
for (let i = 0; i < a.length - 0.5; i++) seen.push(i);
return seen.join(",");
}
function whileLoop(a: number[]): number {
let i = 0;
let s = 0;
while (i < a.length) {
s += a[i] * i;
i++;
}
return s;
}
function constLength(a: number[]): number {
const n = a.length;
let s = 0;
for (let i = 0; i < n; i++) s += a[i];
return s;
}
function points(ps: Point[]): string {
let s = 0;
for (let i = 0; i < ps.length; i++) s += ps[i].x * ps[i].y;
return `${s}`;
}
function flags(bs: boolean[]): number {
let n = 0;
for (let i = 0; i < bs.length; i++) if (bs[i]) n++;
return n;
}
function names(ss: string[]): string {
let out = "";
for (let i = ss.length - 1; i >= 0; i--) out += ss[i];
return out;
}
function maybe(xs: (number | undefined)[]): string {
let s = 0;
let missing = 0;
for (let i = 0; i < xs.length; i++) {
const v = xs[i];
if (v === undefined) missing++;
else s += v;
}
return `${s} ${missing}`;
}
function nested(g: number[][]): number {
let s = 0;
for (let r = 0; r < g.length; r++) {
const row = g[r];
for (let c = 0; c < row.length; c++) s += row[c] * (r + 1);
}
return s;
}
function counterAsNumber(a: number[]): string {
let mixed = 0;
for (let i = 0; i < a.length; i++) mixed += i / 2 + (i | 0) + a[i] * -0;
return `${mixed} ${Object.is(mixed, -0)}`;
}
function earlyExit(a: number[], target: number): number {
for (let i = 0; i < a.length; i++) if (a[i] === target) return i;
return -1;
}
const dense = [1, 2, 3, 4, 5, 6, 7, 8];
console.log(sum(dense), sum([]), sum([0.5, -0.25]));
console.log(scale(dense, new Array<number>(8).fill(0)));
console.log(scale(dense, [9, 9]));
console.log(scale([1, 2, 3], []));
const self = [1, 2, 3, 4];
console.log(scale(self, self));
console.log(inPlace([10, 20, 30]));
console.log(strided(dense, 2), strided(dense, 3));
console.log(descending(dense), descending([]));
console.log(descendingPairs(dense), descendingPairs([1]));
console.log(offsetBounds(dense), offsetBounds([4]), offsetBounds([]));
console.log(fractionalBounds([1, 2, 3, 4, 5]));
console.log(whileLoop(dense));
console.log(constLength(dense));
console.log(points([{ x: 1, y: 2 }, { x: 3, y: 4 }]));
console.log(flags([true, false, true, true]));
console.log(names(["a", "b", "c"]));
console.log(maybe([1, undefined, 3]));
console.log(nested([[1, 2], [3], [], [4, 5, 6]]));
console.log(counterAsNumber([1, 2, 3]));
console.log(earlyExit(dense, 5), earlyExit(dense, 50));
// Holes and partially filled storage take the checked path.
const holey = [1];
holey[2] = 3;
holey[4] = 5;
console.log(sum(holey), descending(holey), whileLoop(holey));
const target = [0, 0, 0];
target[4] = 0;
console.log(scale(dense.slice(0, 5), target));
const gap: number[] = [];
gap[3] = 1;
console.log(sum(gap), scale([1, 2, 3, 4], gap));
const shortened = [1, 2, 3, 4, 5, 6];
shortened.length = 3;
shortened.length = 6;
console.log(sum(shortened), constLength(shortened));
const holeyPoints: Point[] = [{ x: 1, y: 1 }];
holeyPoints[2] = { x: 2, y: 2 };
try {
console.log(points(holeyPoints));
} catch (e) {
console.log(e instanceof TypeError ? "TypeError" : "other");
}