mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-22 05:59:31 +08:00
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:
co-authored by
Claude Opus 5.5
parent
6ce668b213
commit
714ef5aa09
@@ -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;
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user