Merge main

This commit is contained in:
Chris Tate
2026-10-18 11:27:27 -05:00
30 changed files with 7746 additions and 5431 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,147 @@
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,215 @@
/* 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;
}
+122 -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,
@@ -229,7 +236,10 @@ import { findAppendedArrayLiterals } from "./appended-array-literals.js";
import { AmbientReceiverReaders, findConstantCallbacks } from "./constant-callbacks.js";
import {
findScalarStringSlices,
findScratchStringSlices,
findLentStringParameters,
emitStringSliceSnapshot,
type LentStringParameters,
type StringSliceSnapshot,
} from "./string-slices.js";
import { LazyCaptures } from "./lazy-captures.js";
@@ -623,6 +633,10 @@ export class LlEmitter {
private loopArrayBorrows: ReadonlySet<IrStmt> = new Set();
private scalarStringSlices = new Map<string, IrExpr & { kind: "strIntrinsic" }>();
readonly stringSlices = new Map<string, StringSliceSnapshot>();
/** Slices written into stack buffers (findScratchStringSlices). */
scratchStringSlices: ReadonlySet<IrExpr> = new Set();
/** String parameters a caller may lend scratch slices to. */
private lentStringParameters: LentStringParameters = new Map();
private localStackUnions = new Map<string, IrExpr & { kind: "unionWrap" }>();
private localStackObjects = new Map<string, StackObjectSource>();
private reassignedStackObjects = new Set<string>();
@@ -674,6 +688,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}`;
@@ -1030,6 +1059,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<
@@ -1325,6 +1362,8 @@ export class LlEmitter {
return shape !== undefined && !recordDynKeepsIdentity(shape);
},
);
this.lentStringParameters =
this.debug === null ? findLentStringParameters(this.fnByName, this.callLifetimes) : new Map();
this.constantCallbacks = findConstantCallbacks(mod, this.callLifetimes);
// Integer clones signal a stack overflow through their result, which
// needs the single-threaded guard; worker programs also poll for
@@ -6675,6 +6714,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();
@@ -6713,6 +6753,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)
@@ -6750,6 +6796,15 @@ export class LlEmitter {
);
this.scalarStringSlices =
this.debug === null ? findScalarStringSlices(fn, this.callLifetimes) : new Map();
this.scratchStringSlices =
this.debug === null
? findScratchStringSlices(
fn,
this.callLifetimes,
this.scalarStringSlices,
this.lentStringParameters,
)
: new Set();
this.mapReadLifetimes = findMapReadLifetimes(fn, this.boxedUnionsById, this.callLifetimes);
this.readNumberMapWrites = findReadNumberMapWrites(fn);
this.mapEntryFusion = this.debug === null ? findMapEntryFusions(fn) : null;
@@ -8185,7 +8240,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": {
@@ -8632,7 +8696,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
@@ -8645,6 +8714,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"),
@@ -8747,7 +8859,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();
@@ -8762,7 +8877,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) {
@@ -42,7 +42,7 @@ import {
} from "./map-number-lookup.js";
import { emitBorrowedInput } from "./borrowed-inputs.js";
import { emitFusedMapSet, rememberMapEntry } from "./map-entry-fusion.js";
import { emitStringSliceRead } from "./string-slices.js";
import { emitScratchStringSlice, emitStringSliceRead } from "./string-slices.js";
import {
STRING_CHAR_CODE_AT_DECL,
STRING_CHAR_CODE_AT_INT_MARKER,
@@ -395,6 +395,7 @@ export function emitStrIntrinsic(
false,
);
case "slice":
if (host.scratchStringSlices.has(e)) return emitScratchStringSlice(host, e, r, args);
return call(
"scr_str_slice",
"ptr (ptr, double, double)",
@@ -403,6 +404,7 @@ export function emitStrIntrinsic(
true,
);
case "substring":
if (host.scratchStringSlices.has(e)) return emitScratchStringSlice(host, e, r, args);
return call(
"scr_str_substring",
"ptr (ptr, double, double)",
@@ -575,7 +577,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`);
// An int32 counter (a dense array loop's) 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. (A uint32 counter would fold through
// `uitofp i32` too, but the zero extension it adds on x86-64 cost
// more in loops that never convert the double than it saved.)
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
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 });
}
@@ -1,7 +1,11 @@
import { expect, test } from "vitest";
import { F64, STRING, VOID, type IrExpr, type IrFunction, type IrStmt } from "../../ir/ir.js";
import { BOOL, F64, STRING, VOID, type IrExpr, type IrFunction, type IrStmt } from "../../ir/ir.js";
import { analyzeCallLifetimes } from "./call-lifetimes.js";
import { findScalarStringSlices } from "./string-slices.js";
import {
findLentStringParameters,
findScalarStringSlices,
findScratchStringSlices,
} from "./string-slices.js";
const loc = { file: "slices.ts", start: 0, end: 0 };
const ref = (id: string): IrExpr => ({ kind: "varRef", localId: id, type: STRING, loc });
function fixture(): IrFunction {
@@ -95,3 +99,146 @@ test.each(["escape", "capture", "write", "other-consumer", "suspend"])(
expect(facts(fn).size).toBe(0);
},
);
const slice = (receiver: string, start: number, end: number): IrExpr => ({
kind: "strIntrinsic",
method: "slice",
receiver: ref(receiver),
args: [
{ kind: "numLit", value: start, type: F64, loc },
{ kind: "numLit", value: end, type: F64, loc },
],
type: STRING,
loc,
});
const equals = (left: IrExpr): IrExpr => ({
kind: "strEq",
negated: false,
left,
right: { kind: "strLit", value: "GET", type: STRING, loc },
type: BOOL,
loc,
});
const parse = (value: IrExpr): IrExpr => ({
kind: "libCall",
fn: "num.fromString",
args: [value],
type: F64,
loc,
});
function lending(body: IrStmt[]): IrFunction {
return {
name: "scan",
params: [{ localId: "source", name: "source", type: STRING }],
locals: [
{ id: "source", name: "source", type: STRING, mutable: false },
{ id: "word", name: "word", type: STRING, mutable: false },
],
returnType: VOID,
loc,
body,
};
}
function scratch(fn: IrFunction, extra: IrFunction[] = []): Set<IrExpr> {
const functions = new Map([fn, ...extra].map((f) => [f.name, f]));
const lifetimes = analyzeCallLifetimes(functions);
return findScratchStringSlices(
fn,
lifetimes,
findScalarStringSlices(fn, lifetimes),
findLentStringParameters(functions, lifetimes),
);
}
test("slices read only by lending consumers use a stack buffer", () => {
const inline = slice("source", 0, 3);
const parsed = slice("source", 4, 6);
const init = slice("source", 1, 5);
const fn = lending([
{ kind: "exprStmt", expr: equals(inline), loc },
{ kind: "exprStmt", expr: parse(parsed), loc },
{ kind: "varDecl", localId: "word", init, loc },
{ kind: "exprStmt", expr: equals(ref("word")), loc },
{ kind: "exprStmt", expr: parse(ref("word")), loc },
]);
expect(scratch(fn)).toEqual(new Set([inline, parsed, init]));
});
test.each(["return", "concat", "slice-of-slice", "capture", "async"])(
"a slice local that may be kept (%s) stays owned",
(reason) => {
const init = slice("source", 1, 5);
const fn = lending([
{ kind: "varDecl", localId: "word", init, loc },
{ kind: "exprStmt", expr: equals(ref("word")), loc },
]);
if (reason === "return") fn.body.push({ kind: "return", value: ref("word"), loc });
if (reason === "concat")
fn.body.push({
kind: "exprStmt",
expr: {
kind: "strConcat",
left: { kind: "strLit", value: "", type: STRING, loc },
right: ref("word"),
type: STRING,
loc,
},
loc,
});
if (reason === "slice-of-slice")
fn.body.push({ kind: "exprStmt", expr: equals(slice("word", 0, 2)), loc });
if (reason === "capture") fn.locals[1]!.boxed = true;
if (reason === "async") fn.async = true;
expect(scratch(fn).has(init)).toBe(false);
},
);
function helper(name: string, body: IrStmt[]): IrFunction {
return {
name,
params: [{ localId: "text", name: "text", type: STRING }],
locals: [{ id: "text", name: "text", type: STRING, mutable: false }],
returnType: BOOL,
loc,
body,
};
}
const callOf = (callee: string, arg: IrExpr): IrExpr => ({
kind: "call",
callee,
args: [arg],
type: BOOL,
loc,
});
test("helper parameters read only by lending consumers accept stack slices", () => {
const recursive = helper("isGet", [
{
kind: "return",
value: {
kind: "logical",
op: "||",
left: equals(ref("text")),
right: callOf("isGet", ref("text")),
type: BOOL,
loc,
},
loc,
},
]);
const keeps = helper("keeps", [{ kind: "return", value: callOf("stores", ref("text")), loc }]);
const stores = helper("stores", [
{
kind: "exprStmt",
expr: { kind: "arrayLit", elems: [ref("text")], type: { kind: "array", elem: STRING }, loc },
loc,
},
{ kind: "return", value: { kind: "boolLit", value: true, type: BOOL, loc }, loc },
]);
const lent = slice("source", 0, 3);
const kept = slice("source", 0, 4);
const fn = lending([
{ kind: "exprStmt", expr: callOf("isGet", lent), loc },
{ kind: "exprStmt", expr: callOf("keeps", kept), loc },
]);
const functions = [recursive, keeps, stores];
expect(scratch(fn, functions)).toEqual(new Set([lent]));
const all = new Map([fn, ...functions].map((f) => [f.name, f]));
expect([...findLentStringParameters(all, analyzeCallLifetimes(all)).keys()]).toEqual(["isGet"]);
});
@@ -1,4 +1,9 @@
import { type IrExpr, type IrFunction, type IrStmt } from "../../ir/ir.js";
import {
type IrExpr,
type IrFunction,
type IrStmt,
type IrStrIntrinsicMethod,
} from "../../ir/ir.js";
import { everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js";
import { F64_INF, f64Lit, sizeToDouble } from "./common.js";
import type { CallLifetimes } from "./call-lifetimes.js";
@@ -6,6 +11,12 @@ import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
type Slice = IrExpr & { kind: "strIntrinsic" };
function isSlice(value: IrExpr): value is Slice {
return (
value.kind === "strIntrinsic" && (value.method === "slice" || value.method === "substring")
);
}
/** A local used exclusively for scalar indexing needs no copied bytes.
* Snapshot the receiver and normalized bounds at its initializer, including
* side effects in the bounds. Escapes, captures, writes, suspension, and all
@@ -111,3 +122,234 @@ export function emitStringSliceRead(host: LlvmEmitterContext, value: Slice): LlV
);
return { name: result, type: value.type };
}
/** Bytes of the stack buffer behind one scratch slice, header included. */
export const SCRATCH_SLICE_BYTES = 96;
/** String methods that read a lent receiver or argument only during their
* runtime call and never return it. Methods returning a string can return
* their unchanged receiver, so they stay on the owned path. */
const LENDING_METHODS: ReadonlySet<IrStrIntrinsicMethod> = new Set([
"length",
"charCodeAt",
"indexOf",
"includes",
"startsWith",
"endsWith",
]);
/** Numeric parsers read their text during the call. */
const LENDING_LIB_FNS: ReadonlySet<string> = new Set([
"num.fromString",
"num.parseInt",
"num.parseFloat",
]);
/** Typed string-keyed collection operations: reads probe with the key, and
* an insertion stores scr_str_scratch_keep's copy (scr_map_append). */
const LENDING_KEY_METHODS: ReadonlySet<string> = new Set(["get", "has", "set", "add", "delete"]);
function stringKeyed(value: IrExpr & { kind: "mapIntrinsic" | "setIntrinsic" }): boolean {
const receiver = value.receiver.type;
if (receiver.kind === "map")
return receiver.key.kind === "string" && receiver.value.kind !== "dyn";
if (receiver.kind === "set") return receiver.elem.kind === "string";
return false;
}
/** String parameters, by function and index, that the body reads only in
* lending positions (findLentStringParameters). */
export type LentStringParameters = ReadonlyMap<string, ReadonlySet<number>>;
/** The operands `value` reads only while it runs, keeping no reference. */
function lentOperands(value: IrExpr, parameters: LentStringParameters): readonly IrExpr[] {
switch (value.kind) {
case "strEq":
case "strCmp":
return [value.left, value.right];
case "strIntrinsic":
return LENDING_METHODS.has(value.method) ? [value.receiver, ...value.args] : [];
case "libCall":
return LENDING_LIB_FNS.has(value.fn) ? value.args.slice(0, 1) : [];
case "mapIntrinsic":
case "setIntrinsic":
return LENDING_KEY_METHODS.has(value.method) && stringKeyed(value)
? value.args.slice(0, 1)
: [];
case "call": {
const lent = parameters.get(value.callee);
return lent ? value.args.filter((_, index) => lent.has(index)) : [];
}
default:
return [];
}
}
interface LendingScan {
/** Slice operands of lending consumers, excluding any node also reached
* in an owning position. */
scratch: Set<IrExpr>;
/** Candidates read anywhere other than a lending position. */
invalid: Set<string>;
/** Direct callees, for the parameter fixpoint. */
callees: Set<string>;
}
/** One pass over `fn`: which candidate bindings only lending consumers read
* (a slice initializer of a candidate local is not itself a read), and
* which slice operands those consumers lend. A switch discriminant is only
* compared with the case tests. */
function scanLending(
fn: IrFunction,
candidates: ReadonlySet<string>,
initializers: ReadonlyMap<string, Slice>,
parameters: LentStringParameters,
): LendingScan {
const scan: LendingScan = { scratch: new Set(), invalid: new Set(), callees: new Set() };
const owned = new Set<IrExpr>();
function lent(value: IrExpr): boolean {
if (value.kind === "varRef" && candidates.has(value.localId)) return true;
if (!isSlice(value)) return expr(value);
scan.scratch.add(value);
return everyExprChild(value, expr, stmt);
}
function expr(value: IrExpr): boolean {
if (isSlice(value)) owned.add(value);
if (value.kind === "varRef" && candidates.has(value.localId)) scan.invalid.add(value.localId);
if (value.kind === "closure" || value.kind === "classRef")
for (const id of value.captures ?? []) scan.invalid.add(id);
if (value.kind === "call") scan.callees.add(value.callee);
const operands = lentOperands(value, parameters);
if (operands.length === 0) return everyExprChild(value, expr, stmt);
return everyExprChild(
value,
(child) => (operands.includes(child) ? lent(child) : expr(child)),
stmt,
);
}
function stmt(value: IrStmt): boolean {
// A candidate's single declaration initializes it with its slice.
if (value.kind === "varDecl" && value.init !== null && initializers.has(value.localId))
return everyExprChild(value.init, expr, stmt);
if (value.kind === "switch" && value.disc.type.kind === "string") {
lent(value.disc);
for (const entry of value.cases) {
if (entry.test) expr(entry.test);
entry.body.forEach(stmt);
}
return true;
}
return everyStmtChild(value, expr, stmt);
}
fn.body.forEach(stmt);
for (const node of owned) scan.scratch.delete(node);
return scan;
}
/** String parameters that every body reads only in lending positions,
* including lent arguments of other such parameters: a caller may pass a
* scratch slice to them. Start from every borrowed (unchanged) string
* parameter of a synchronous function and drop violations until nothing changes, so
* recursion through a lent parameter stays lent. A coroutine can keep its
* parameters past the caller's frame, so it never lends. */
export function findLentStringParameters(
functions: ReadonlyMap<string, IrFunction>,
lifetimes: CallLifetimes,
): Map<string, Set<number>> {
const lent = new Map<string, Set<number>>();
for (const fn of functions.values()) {
if (fn.async || fn.generator) continue;
// Borrowed parameters are the unchanged ones nothing redeclares.
const stable = lifetimes.borrowed.get(fn.name);
const indexes = new Set<number>();
fn.params.forEach((param, index) => {
if (param.type.kind === "string" && stable?.has(index)) indexes.add(index);
});
if (indexes.size > 0) lent.set(fn.name, indexes);
}
const callers = new Map<string, Set<string>>();
const pending = new Set(lent.keys());
while (pending.size > 0) {
const name = pending.values().next().value as string;
pending.delete(name);
const indexes = lent.get(name);
const fn = functions.get(name);
if (!indexes || !fn) continue;
const ids = new Set([...indexes].map((index) => fn.params[index]!.localId));
const scan = scanLending(fn, ids, new Map(), lent);
for (const callee of scan.callees) {
let set = callers.get(callee);
if (!set) callers.set(callee, (set = new Set()));
set.add(name);
}
let changed = false;
for (const index of [...indexes]) {
if (!scan.invalid.has(fn.params[index]!.localId)) continue;
indexes.delete(index);
changed = true;
}
if (!changed) continue;
if (indexes.size === 0) lent.delete(name);
const affected = callers.get(name);
if (affected) for (const caller of affected) if (lent.has(caller)) pending.add(caller);
}
return lent;
}
/** Slices whose every reader borrows them are written into a stack buffer
* instead of a heap allocation (scr_str_slice_scratch): a slice operand of
* a lending consumer, and the initializer of a stable local read only by
* lending consumers. The copy is ASCII, bounded, and immortal for retain and
* release, so it neither pins its source nor needs to be freed; longer or
* non-ASCII results keep the ordinary owned string. Each site owns its
* buffer: a site runs again only after its previous value is dead, since
* nothing can keep it. Coroutine frames do not keep stack buffers across a
* suspension, so they are left alone. */
export function findScratchStringSlices(
fn: IrFunction,
lifetimes: CallLifetimes,
scalar: ReadonlyMap<string, Slice>,
parameters: LentStringParameters = new Map(),
): Set<IrExpr> {
if (fn.async || fn.generator) return new Set();
const bindings = lifetimes.bindings.get(fn.name);
const locals = new Map<string, Slice>();
everyStmtList(fn.body, {
expr: () => true,
stmt: (stmt) => {
if (
stmt.kind === "varDecl" &&
bindings?.has(stmt.localId) &&
!scalar.has(stmt.localId) &&
stmt.init !== null &&
isSlice(stmt.init)
)
locals.set(stmt.localId, stmt.init);
return true;
},
});
const scan = scanLending(fn, new Set(locals.keys()), locals, parameters);
for (const [id, init] of locals) if (!scan.invalid.has(id)) scan.scratch.add(init);
return scan.scratch;
}
/** Write a scratch slice into its own stack buffer. The result is owned:
* releasing the scratch copy does nothing, and a heap fallback is freed. */
export function emitScratchStringSlice(
host: LlvmEmitterContext,
slice: Slice,
receiver: LlValue,
args: readonly LlValue[],
): LlValue {
const B = host.B;
const buffer = B.slot();
B.entryAllocas.push(`${buffer} = alloca [${SCRATCH_SLICE_BYTES} x i8], align 8`);
host.declare(
`declare ptr @scr_str_slice_scratch(ptr, double, double, i1 zeroext, ptr, ${host.sizeType})`,
);
const result = B.tmp();
B.line(
`${result} = call ptr @scr_str_slice_scratch(ptr ${receiver.name}, double ${args[0]?.name ?? f64Lit(0)}, double ${args[1]?.name ?? F64_INF}, i1 ${slice.method === "substring" ? "true" : "false"}, ptr ${buffer}, ${host.sizeType} ${SCRATCH_SLICE_BYTES})`,
);
return host.own({ name: result, type: slice.type });
}
@@ -0,0 +1,141 @@
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,77 @@ 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,6 @@
{
"order": [
"<repo>/tests/corpus/5993-array-length-loop-resizing.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5994-array-length-sparse-loops.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5995-array-dense-loop-boundaries.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/scratch-string-slices.ts"
],
"diags": []
}
+7 -2
View File
@@ -77,7 +77,8 @@ static inline uint64_t scr_map_hash_str(const ScrStr *k) {
uint32_t h = k->hash;
if (__builtin_expect(h != 0, 1)) return scr_map_hash_widen(h);
h = scr_map_hash_fold(k->data, k->len);
if (k->rc != SIZE_MAX) ((ScrStr *)k)->hash = h;
/* A scratch slice is a writable stack copy (scr_str_is_scratch). */
if (k->rc != SIZE_MAX || scr_str_is_scratch(k)) ((ScrStr *)k)->hash = h;
return scr_map_hash_widen(h);
#else
return scr_map_hash_bytes(k->data, k->len);
@@ -931,7 +932,11 @@ static void scr_map_append(ScrMap *m, uint64_t hash, uint64_t key, uint64_t val,
m->filter |= scr_map_filter_bits(hash);
m->nlive++;
if (m->key_kind == SCR_MAP_KEY_STR) {
scr_str_retain((ScrStr *)scr_map_slot_to_ptr(key)); /* key is borrowed */
ScrStr *k = scr_map_slot_to_ptr(key); /* key is borrowed */
if (__builtin_expect(scr_str_is_scratch(k), 0))
m->entries[idx].key = scr_map_slot_from_ptr(scr_str_scratch_keep(k));
else
scr_str_retain(k);
} else if (m->key_kind == SCR_MAP_KEY_DYN) {
const ScrDyn *d = (ScrDyn *)scr_map_slot_to_ptr(key);
if (d->kind == SCR_DYN_NUM && d->v.num == 0) {
+74 -10
View File
@@ -1999,20 +1999,34 @@ ScrStr *scr_str_slice(ScrStr *s, double start, double end) {
return scr_str_slice_units(s, e, from, to);
}
/* One slice() or substring() boundary in [0, length]: ToIntegerOrInfinity,
* then slice's end-relative negative or substring's clamp. Conversion to an
* integer truncates toward zero exactly like ToIntegerOrInfinity, so no
* libm call is needed; NaN fails both comparisons and lands on 0, and a
* negative fraction above -1 is -0, which slice treats as 0. */
static inline size_t scr_slice_unit(double v, size_t length, bool substring) {
if (v >= 0) return v >= (double)length ? length : (size_t)v;
if (substring || !(v > -(double)length)) return 0;
long long unit = (long long)v; /* in (-length, 0] */
return unit == 0 ? 0 : (size_t)((long long)length + unit);
}
/* slice()/substring() boundaries in UTF-16 units over the receiver's length. */
static void scr_str_slice_bounds(ScrStr *s, ScrSidx *e, double start, double end, bool substring,
size_t *from_out, size_t *to_out) {
size_t length = scr_sidx_len(s, e);
size_t from = scr_slice_unit(start, length, substring);
size_t to = scr_slice_unit(end, length, substring);
if (substring && from > to) { size_t swap = from; from = to; to = swap; }
*from_out = from;
*to_out = to;
}
void scr_str_slice_range(ScrStr *s, double start, double end, bool substring,
ScrStringSlice *out) {
ScrSidx *e = scr_sidx_for(s);
size_t length = scr_sidx_len(s, e);
double a = scr_to_integer_or_infinity(start), b = scr_to_integer_or_infinity(end);
size_t from, to;
if (substring) {
from = a <= 0 ? 0 : a >= (double)length ? length : (size_t)a;
to = b <= 0 ? 0 : b >= (double)length ? length : (size_t)b;
if (from > to) { size_t swap = from; from = to; to = swap; }
} else {
from = scr_slice_boundary(a, length);
to = scr_slice_boundary(b, length);
}
scr_str_slice_bounds(s, e, start, end, substring, &from, &to);
out->start = from;
out->length = to > from ? to - from : 0;
out->split = 0;
@@ -2024,6 +2038,56 @@ void scr_str_slice_range(ScrStr *s, double start, double end, bool substring,
}
}
/* Fill a scratch buffer with `units` ASCII bytes (scr_str_is_scratch). */
static inline ScrStr *scr_str_scratch_fill(ScrStr *scratch, const char *data, size_t units) {
scratch->rc = SIZE_MAX;
scratch->len = units;
scratch->cap = 0; /* below len: the scratch marker */
scratch->ascii = 1;
#if SCR_STR_HASH_CACHE
scratch->hash = 0;
#endif
scr_copy_bytes(scratch->data, data, units);
scratch->data[units] = '\0';
return scratch;
}
ScrStr *scr_str_slice_scratch(ScrStr *s, double start, double end, bool substring,
ScrStr *scratch, size_t bytes) {
size_t capacity = bytes - sizeof(ScrStr) - 1;
if (__builtin_expect(s->ascii, 1)) {
/* Units are bytes: no index entry, and the bounds are byte offsets. */
size_t length = s->len;
size_t from = scr_slice_unit(start, length, substring);
size_t to = scr_slice_unit(end, length, substring);
if (substring && from > to) { size_t swap = from; from = to; to = swap; }
size_t units = to > from ? to - from : 0;
/* Shorter results are interned statics. */
if (units >= 2 && units <= capacity) return scr_str_scratch_fill(scratch, s->data + from, units);
return scr_str_slice_units(s, NULL, from, to);
}
ScrSidx *e = scr_sidx_for(s);
size_t from, to;
scr_str_slice_bounds(s, e, start, end, substring, &from, &to);
size_t units = to > from ? to - from : 0;
if (units >= 2 && units <= capacity) {
/* Equal unit and byte counts between two character boundaries prove
* the span is ASCII: every other character takes more bytes than
* UTF-16 units. Non-ASCII results keep the owned copy and its index. */
bool first, last;
size_t from_b = scr_u16_to_byte_c(s, e, from, &first);
size_t to_b = scr_u16_to_byte_c(s, e, to, &last);
if (!first && !last && to_b - from_b == units)
return scr_str_scratch_fill(scratch, s->data + from_b, units);
}
return scr_str_slice_units(s, e, from, to);
}
ScrStr *scr_str_scratch_keep(ScrStr *s) {
if (!scr_str_is_scratch(s)) return scr_str_retain(s);
return scr_str_from_span_ascii(s->data, s->len);
}
double scr_str_slice_char_code_at(ScrStr *s, const ScrStringSlice *range, double index) {
double unit = scr_to_integer_or_infinity(index);
if (!(unit >= 0) || unit >= (double)range->length) return NAN;
+8
View File
@@ -184,6 +184,14 @@ static inline ScrStr *scr_str_retain(ScrStr *s) {
void scr_str_release(ScrStr *s); /* NULL-tolerant (uninitialized locals) */
/* A transient stack copy of a slice (scr_str_slice_scratch): immortal for
* retain and release, and valid only while its borrowing consumer runs. No
* other string has a capacity below its length. Containers that keep a
* string they were lent store scr_str_scratch_keep's copy instead. */
static inline bool scr_str_is_scratch(const ScrStr *s) {
return s->rc == SIZE_MAX && s->cap < s->len;
}
/* ── lone surrogates: canonical WTF-8 storage ─────────────────────────
* String storage is UTF-8 extended to lone UTF-16 surrogates: an unpaired
* surrogate is its generalized 3-byte encoding ED A0..BF 80..BF, while a
+10
View File
@@ -83,6 +83,16 @@ typedef struct ScrStringSlice {
void scr_str_slice_range(ScrStr *s, double start, double end, bool substring,
ScrStringSlice *out);
double scr_str_slice_char_code_at(ScrStr *s, const ScrStringSlice *range, double index);
/* A slice that only borrowing consumers read (string-slices.ts) is written
* into the caller's `bytes`-sized stack buffer as an immortal ASCII string,
* whose header marks it as a scratch copy (scr_str_is_scratch). Results
* that do not fit, are not ASCII, or are interned statics are returned as
* the ordinary +1 slice. Releasing either result is correct. */
ScrStr *scr_str_slice_scratch(ScrStr *s, double start, double end, bool substring,
ScrStr *scratch, size_t bytes);
/* The owning reference a container stores for `s`: a heap copy of a scratch
* string, else `s` retained. */
ScrStr *scr_str_scratch_keep(ScrStr *s);
/* repeat(count): count < 0 or Infinity is a JS RangeError; scriptc has no
* exceptions, so it prints "scriptc: RangeError: Invalid count value" to
+31
View File
@@ -284,6 +284,36 @@ static void test_span_keys(void) {
scr_map_release(numbers);
}
/* A scratch slice probes like any key; an insertion stores a heap copy, so
* the stored key survives the buffer's next contents. */
static void test_scratch_keys(void) {
ScrMap *m = scr_map_new(SCR_MAP_KEY_STR, SCR_MAP_VAL_F64, NULL);
ScrStr *line = S("alpha beta alpha");
_Alignas(16) unsigned char buffer[96];
ScrStr *alpha = scr_str_slice_scratch(line, 0, 5, false, (ScrStr *)buffer, sizeof buffer);
check(alpha == (ScrStr *)buffer && scr_str_is_scratch(alpha), "short ASCII slice uses the buffer");
scr_map_set_str_f64(m, alpha, 1);
double out = 0;
check(scr_map_get_str_f64(m, alpha, &out) && out == 1, "scratch key probes its entry");
ScrStr *beta = scr_str_slice_scratch(line, 6, 10, false, (ScrStr *)buffer, sizeof buffer);
check(!scr_map_has_str(m, beta), "refilled buffer probes its new text");
scr_map_set_str_f64(m, beta, 2);
ScrStr *again = scr_str_slice_scratch(line, 11, 16, false, (ScrStr *)buffer, sizeof buffer);
scr_map_set_str_f64(m, again, 3);
check(scr_map_size(m) == 2, "scratch overwrite keeps one entry");
ScrStr *first = scr_map_iter_key_str(m, 0), *second = scr_map_iter_key_str(m, 1);
ScrStr *a = S("alpha"), *b = S("beta");
check(!scr_str_is_scratch(first) && scr_str_eq(first, a), "stored key is an owned copy");
check(!scr_str_is_scratch(second) && scr_str_eq(second, b), "second stored key is a copy");
check(scr_map_get_str_f64(m, a, &out) && out == 3, "overwrite through scratch key");
scr_str_release(a);
scr_str_release(b);
scr_str_release(first);
scr_str_release(second);
scr_str_release(line);
scr_map_release(m);
}
static void test_same_value_zero(void) {
ScrMap *m = scr_map_new(SCR_MAP_KEY_F64, SCR_MAP_VAL_BOOL, NULL);
scr_map_set_f64_bool(m, 0.0 / 0.0, true); /* NaN key */
@@ -793,6 +823,7 @@ int main(void) {
test_string_entries();
test_same_value_zero();
test_span_keys();
test_scratch_keys();
test_string_hash_cache();
#ifdef SCR_RC_AUDIT
test_rc_accounting();
+32
View File
@@ -205,6 +205,38 @@ static void surrogate_asserts(void) {
}
}
}
/* Scratch slices hold the materialized text. Only ASCII results of at
* least two units use the caller's buffer, marked as scratch. */
{
ScrStr *sources[] = {two, s, scr_str_new("GET /api/users 200", 18)};
const double marks[] = {-INFINITY, -7.5, -3, -0.5, -0.0, 0, 0.5, 1,
2, 3.9, 5, 9, 40, INFINITY, NAN};
const size_t nmarks = sizeof marks / sizeof marks[0];
_Alignas(16) unsigned char buffer[96];
for (size_t k = 0; k < 3; k++) {
for (size_t a = 0; a < nmarks; a++) {
for (size_t b = 0; b < nmarks; b++) {
for (unsigned substring = 0; substring < 2; substring++) {
ScrStr *src = sources[k];
ScrStr *scratch = scr_str_slice_scratch(src, marks[a], marks[b], substring != 0,
(ScrStr *)buffer, sizeof buffer);
ScrStr *copy = substring ? scr_str_substring(src, marks[a], marks[b])
: scr_str_slice(src, marks[a], marks[b]);
bool used = scratch == (ScrStr *)buffer;
bool expected = copy->len >= 2 && scr_bytes_all_ascii(copy->data, copy->len);
total++;
if (scratch->len != copy->len || memcmp(scratch->data, copy->data, copy->len) != 0 ||
used != expected || used != scr_str_is_scratch(scratch) ||
scr_str_utf16_len(scratch) != scr_str_utf16_len(copy))
failed++;
scr_str_release(scratch);
scr_str_release(copy);
}
}
}
}
scr_str_release(sources[2]);
}
scr_str_release(two);
scr_str_release(s);
}
@@ -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");
}
+116
View File
@@ -0,0 +1,116 @@
// Slices read only by comparisons, numeric parsers, collection probes,
// switch discriminants and lent helper parameters, including the keys a
// map or set keeps after the slice that produced them is gone.
const ascii = "GET /api/users?id=42 200 17.5ms";
const mixed = "Aé水x😀yz-12.5e1 ok";
const bounds = [-Infinity, -40, -8, -2.5, -1, -0.5, -0, 0, 0.5, 1, 2, 3, 4.9, 5, 7, 10, 40, Infinity, NaN];
function compare(source: string, start: number, end: number): string {
const s = source.slice(start, end);
const t = source.substring(start, end);
const parts = [
String(s === "GET"),
String(t === "GET"),
String(source.slice(start, end) === source.substring(start, end)),
String(source.slice(start, end) < "M"),
String(source.substring(start, end) >= "a"),
String(s.length),
String(t.length),
String(s.charCodeAt(0)),
String(t.charCodeAt(t.length - 1)),
String(s.indexOf("a")),
String(t.includes("😀")),
String(s.startsWith("A")),
String(t.endsWith("z")),
];
return parts.join(",");
}
for (const source of [ascii, mixed]) {
for (const start of bounds) {
const row: string[] = [];
for (const end of bounds) row.push(compare(source, start, end));
console.log(row.join(" | "));
}
}
// Numeric parsing of slices.
const numbers = " 42|-0|0x1F|1e3|.5|Infinity|-Infinity|12px|abc|+7.25| 9 |0b11|1_000|";
let offset = 0;
while (offset < numbers.length) {
const bar = numbers.indexOf("|", offset);
const field = numbers.slice(offset, bar);
console.log(
JSON.stringify(field),
Number(field),
+numbers.substring(offset, bar),
parseInt(field),
parseInt(numbers.slice(offset, bar), 16),
parseFloat(field),
Object.is(Number(numbers.slice(offset, bar)), -0),
);
offset = bar + 1;
}
console.log(Number(mixed.slice(9, 16)), parseFloat(mixed.slice(9)), parseInt(mixed.slice(10, 12)));
// Collection probes and insertions keyed by slices. The stored keys must
// outlive every later slice written to the same site.
const counts = new Map<string, number>();
const seen = new Set<string>();
const words = "alpha beta gamma alpha delta beta alpha epsilon-with-a-much-longer-key-than-the-stack-buffer-holds-alpha zeta é-key é-key x x";
for (let round = 0; round < 3; round++) {
let start = 0;
while (start < words.length) {
let end = words.indexOf(" ", start);
if (end < 0) end = words.length;
const word = words.slice(start, end);
counts.set(word, (counts.get(word) ?? 0) + 1);
if (!seen.has(word)) seen.add(word);
if (counts.get(words.substring(start, end)) === 2) seen.delete(word);
start = end + 1;
}
}
console.log([...counts.entries()].map(([key, value]) => `${key}=${value}`).join(","));
console.log([...seen].join(","));
console.log(counts.has(ascii.slice(0, 3)), counts.has(words.slice(0, 5)), seen.has(words.slice(6, 10)));
const index = new Map<string, number>();
for (let i = 0; i + 3 <= ascii.length; i++) index.set(ascii.slice(i, i + 3), i);
console.log(index.size, index.get("api"), index.get(ascii.substring(5, 8)), [...index.keys()].slice(0, 6).join("/"));
const reads = new Map<string, string>([["api", "first"], ["users", "second"]]);
console.log(reads.get(ascii.slice(5, 8)), reads.get(ascii.slice(9, 14)) ?? "none", reads.get(ascii.slice(0, 1)) ?? "none");
// Switch discriminants.
function method(line: string): number {
switch (line.slice(0, line.indexOf(" "))) {
case "GET":
return 1;
case "POST":
return 2;
case "é":
return 3;
default:
return 0;
}
}
console.log(method(ascii), method("POST /x"), method("é y"), method("PATCH /x"), method("nospace"));
// Lent helper parameters, including recursion through a lent parameter.
function isKeyword(text: string): boolean {
return text === "let" || text === "const" || text === "return";
}
function depth(text: string, n: number): number {
if (n === 0) return text.length;
return depth(text, n - 1) + (text === "abc" ? 1 : 0);
}
function keep(text: string): string[] {
return [text];
}
const program = "let x = 1; const y = 2; return x + y; letter";
let keywords = 0;
const kept: string[][] = [];
for (let i = 0; i < program.length; i++) {
for (let j = i + 2; j <= Math.min(program.length, i + 6); j++) {
if (isKeyword(program.slice(i, j))) keywords++;
}
if (i % 9 === 0) kept.push(keep(program.slice(i, i + 4)));
}
console.log(keywords, depth("abcdef".slice(0, 3), 4), kept.map((k) => k[0]).join("|"));
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -375,7 +375,8 @@ export function integerRangeCases(): RangeCase[] {
],
},
],
[proof(cursor, 0, 80), proof(decoded, 0, 4294967295), proof(head, 0, 124)],
// Passes enter at 0, 24, 48, 72 and 96 (at most 99), so the failing test reads at most 123.
[proof(cursor, 0, 80), proof(decoded, 0, 4294967295), proof(head, 0, 123)],
(func) => {
for (const id of ["cursor", "decoded"])
func.locals.push({ id, name: id, type: F64, mutable: true });