From 06b0bb293155e30a42f0f2821c40ad6644dce386 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 17 Oct 2026 12:13:11 -0700 Subject: [PATCH 1/7] Write borrowed string slices into stack buffers A slice read only by lending consumers (string equality and ordering, length/charCodeAt/indexOf/includes/startsWith/endsWith, Number/unary plus/parseInt/parseFloat, typed string-keyed Map/Set get/has/set/add/ delete keys, string switch discriminants, and string parameters their callee only lends) is written by scr_str_slice_scratch into a per-site stack buffer as an immortal ASCII ScrStr marked as scratch (cap < len), instead of a heap allocation. Stable locals initialized by such a slice qualify when every read lends. Map/Set insertion stores a heap copy of a scratch key, and probes cache the key hash in the scratch header, so the counting idiom allocates only for new keys. Longer, non-ASCII, and interned results keep the owned slice; coroutines and debug builds are unchanged. Slice bounds are normalized without a libm trunc call. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/backend/llvm/emitter.ts | 18 ++ .../src/backend/llvm/expr-containers.ts | 4 +- .../src/backend/llvm/string-slices.test.ts | 151 ++++++++++- .../src/backend/llvm/string-slices.ts | 244 +++++++++++++++++- .../corpus/scratch-string-slices.ts.json | 6 + packages/runtime/src/scr_map.c | 9 +- packages/runtime/src/scr_string.c | 84 +++++- packages/runtime/src/scr_string.h | 8 + packages/runtime/src/scr_string_methods.h | 10 + packages/runtime/test/test_map.c | 31 +++ packages/runtime/test/test_string.c | 32 +++ tests/corpus/scratch-string-slices.ts | 116 +++++++++ 12 files changed, 697 insertions(+), 16 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/scratch-string-slices.ts.json create mode 100644 tests/corpus/scratch-string-slices.ts diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 09bf4979c..de661be9e 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -219,7 +219,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"; @@ -569,6 +572,10 @@ export class LlEmitter { private loopArrayBorrows: ReadonlySet = new Set(); private scalarStringSlices = new Map(); readonly stringSlices = new Map(); + /** Slices written into stack buffers (findScratchStringSlices). */ + scratchStringSlices: ReadonlySet = new Set(); + /** String parameters a caller may lend scratch slices to. */ + private lentStringParameters: LentStringParameters = new Map(); private localStackUnions = new Map(); private localStackObjects = new Map(); private reassignedStackObjects = new Set(); @@ -1245,6 +1252,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 @@ -6373,6 +6382,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; diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts index a2d5fd35a..d5d192e89 100644 --- a/packages/compiler/src/backend/llvm/expr-containers.ts +++ b/packages/compiler/src/backend/llvm/expr-containers.ts @@ -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, @@ -391,6 +391,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)", @@ -399,6 +400,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)", diff --git a/packages/compiler/src/backend/llvm/string-slices.test.ts b/packages/compiler/src/backend/llvm/string-slices.test.ts index 53cca6417..b2656e560 100644 --- a/packages/compiler/src/backend/llvm/string-slices.test.ts +++ b/packages/compiler/src/backend/llvm/string-slices.test.ts @@ -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 { + 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"]); +}); diff --git a/packages/compiler/src/backend/llvm/string-slices.ts b/packages/compiler/src/backend/llvm/string-slices.ts index 71a26e2f3..ae73f1ea1 100644 --- a/packages/compiler/src/backend/llvm/string-slices.ts +++ b/packages/compiler/src/backend/llvm/string-slices.ts @@ -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 = new Set([ + "length", + "charCodeAt", + "indexOf", + "includes", + "startsWith", + "endsWith", +]); + +/** Numeric parsers read their text during the call. */ +const LENDING_LIB_FNS: ReadonlySet = 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 = 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>; + +/** 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; + /** Candidates read anywhere other than a lending position. */ + invalid: Set; + /** Direct callees, for the parameter fixpoint. */ + callees: Set; +} + +/** 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, + initializers: ReadonlyMap, + parameters: LentStringParameters, +): LendingScan { + const scan: LendingScan = { scratch: new Set(), invalid: new Set(), callees: new Set() }; + const owned = new Set(); + 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, + lifetimes: CallLifetimes, +): Map> { + const lent = new Map>(); + 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(); + 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>(); + 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, + parameters: LentStringParameters = new Map(), +): Set { + if (fn.async || fn.generator) return new Set(); + const bindings = lifetimes.bindings.get(fn.name); + const locals = new Map(); + 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 }); +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/scratch-string-slices.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/scratch-string-slices.ts.json new file mode 100644 index 000000000..5145c33d1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/scratch-string-slices.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/scratch-string-slices.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_map.c b/packages/runtime/src/scr_map.c index 8e34533ad..14181968c 100644 --- a/packages/runtime/src/scr_map.c +++ b/packages/runtime/src/scr_map.c @@ -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) { diff --git a/packages/runtime/src/scr_string.c b/packages/runtime/src/scr_string.c index 6f3529777..43cf21b0c 100644 --- a/packages/runtime/src/scr_string.c +++ b/packages/runtime/src/scr_string.c @@ -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; diff --git a/packages/runtime/src/scr_string.h b/packages/runtime/src/scr_string.h index e4bf09995..425dfaabf 100644 --- a/packages/runtime/src/scr_string.h +++ b/packages/runtime/src/scr_string.h @@ -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 diff --git a/packages/runtime/src/scr_string_methods.h b/packages/runtime/src/scr_string_methods.h index 537747530..62354142e 100644 --- a/packages/runtime/src/scr_string_methods.h +++ b/packages/runtime/src/scr_string_methods.h @@ -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 diff --git a/packages/runtime/test/test_map.c b/packages/runtime/test/test_map.c index 6eee2d6bb..8899974a8 100644 --- a/packages/runtime/test/test_map.c +++ b/packages/runtime/test/test_map.c @@ -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(); diff --git a/packages/runtime/test/test_string.c b/packages/runtime/test/test_string.c index 55c49af7e..e49d8eb8b 100644 --- a/packages/runtime/test/test_string.c +++ b/packages/runtime/test/test_string.c @@ -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); } diff --git a/tests/corpus/scratch-string-slices.ts b/tests/corpus/scratch-string-slices.ts new file mode 100644 index 000000000..487883d6e --- /dev/null +++ b/tests/corpus/scratch-string-slices.ts @@ -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(); +const seen = new Set(); +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(); +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([["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("|")); From 714ef5aa09f02c2b4487ea1faf60600d08aec0aa Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 17 Oct 2026 14:48:38 -0700 Subject: [PATCH 2/7] Prove array lengths stable in index loops and version dense array loops Index loops over arrays left the counter a double in several common shapes, and every element access at the counter tested the array's hole-free prefix on each pass: - A syntactic proof (ir/array-length-stability.ts) shows that a loop never changes an array's length: the body runs no user code, never rebinds the array or lets it escape, and only resizes local arrays that no other binding can hold. Element writes at a counter kept below the length are accepted. - Counted loops take a stable `a.length` as their limit, and an element access at the counter makes an unversioned counted loop worthwhile, so strided (`i += 2`) and descending (`i = a.length - 1; i >= 0`) loops get an integer counter. - Array lengths bound while-loop and for-loop counters by their range, which needs no stability proof. - A loop whose array length is stable and whose indexed arrays keep their storage runs as two copies. One test before the loop (the length is at most 2^31 - 1 and at most each indexed array's `filled` bound) selects a fast copy whose element accesses at the counter skip the prefix test and whose counter is an int32. LLVM can vectorize that copy. - Loop counters whose ranges fit in 32 bits convert to double from their low half, so an inlined callee's index conversion folds away. Co-Authored-By: Claude Opus 5.5 --- .../src/backend/llvm/dense-array-access.ts | 18 +- .../backend/llvm/dense-array-loops.test.ts | 131 +++++++++ .../src/backend/llvm/dense-array-loops.ts | 218 +++++++++++++++ packages/compiler/src/backend/llvm/emitter.ts | 108 +++++++- .../src/backend/llvm/expr-containers.ts | 11 +- .../src/backend/llvm/expr-primitives.ts | 24 +- .../src/backend/llvm/integer-values.ts | 7 +- .../src/ir/array-length-stability.test.ts | 129 +++++++++ .../compiler/src/ir/array-length-stability.ts | 259 ++++++++++++++++++ .../compiler/src/ir/integer-loop-facts.ts | 29 +- .../compiler/src/ir/integer-loops.test.ts | 108 ++++++++ packages/compiler/src/ir/integer-loops.ts | 58 +++- .../compiler/src/ir/integer-ranges.test.ts | 55 +++- .../corpus/5960-array-length-loop-resizing.ts | 167 +++++++++++ .../corpus/5961-array-length-sparse-loops.ts | 133 +++++++++ .../5962-array-dense-loop-boundaries.ts | 173 ++++++++++++ 16 files changed, 1599 insertions(+), 29 deletions(-) create mode 100644 packages/compiler/src/backend/llvm/dense-array-loops.test.ts create mode 100644 packages/compiler/src/backend/llvm/dense-array-loops.ts create mode 100644 packages/compiler/src/ir/array-length-stability.test.ts create mode 100644 packages/compiler/src/ir/array-length-stability.ts create mode 100644 tests/corpus/5960-array-length-loop-resizing.ts create mode 100644 tests/corpus/5961-array-length-sparse-loops.ts create mode 100644 tests/corpus/5962-array-dense-loop-boundaries.ts diff --git a/packages/compiler/src/backend/llvm/dense-array-access.ts b/packages/compiler/src/backend/llvm/dense-array-access.ts index aeaf3e70f..cffa8ee94 100644 --- a/packages/compiler/src/backend/llvm/dense-array-access.ts +++ b/packages/compiler/src/backend/llvm/dense-array-access.ts @@ -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") { diff --git a/packages/compiler/src/backend/llvm/dense-array-loops.test.ts b/packages/compiler/src/backend/llvm/dense-array-loops.test.ts new file mode 100644 index 000000000..d9e18d7ef --- /dev/null +++ b/packages/compiler/src/backend/llvm/dense-array-loops.test.ts @@ -0,0 +1,131 @@ +import { expect, test } from "vitest"; +import { BOOL, F64, arrayOf, type IrExpr, type IrLocal, type IrStmt } from "../../ir/ir.js"; +import { countedDenseShape, matchDenseArrayLoop, type DenseLoopShape } from "./dense-array-loops.js"; + +const loc = { file: "dense-array-loops.ts", start: 0, end: 0 }; +const ARR = arrayOf(F64); +const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc }); +const ref = (localId: string): IrExpr => ({ kind: "varRef", localId, type: F64, loc }); +const arr = (localId: string): IrExpr & { kind: "varRef" } => ({ + kind: "varRef", + localId, + type: ARR, + loc, +}); +const method = ( + receiver: string, + name: Extract["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([ + ["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(); +}); diff --git a/packages/compiler/src/backend/llvm/dense-array-loops.ts b/packages/compiler/src/backend/llvm/dense-array-loops.ts new file mode 100644 index 000000000..5009e6c82 --- /dev/null +++ b/packages/compiler/src/backend/llvm/dense-array-loops.ts @@ -0,0 +1,218 @@ +/* Dense array loops: a versioned copy of an index loop over an array whose + * length the loop never changes. + * + * `for (let i = 0; i < a.length; i++) b[i] = a[i] * 2` reads its bound from + * `a`, and every element access at the counter normally tests the index + * against the array's hole-free prefix bound (`filled`, see + * dense-array-access.ts) before it touches the slot. When the loop provably + * never changes `a.length` (ir/array-length-stability.ts) and never moves or + * cuts the storage of the arrays it indexes, one test before the loop covers + * every pass: if `a.length` is at most each indexed array's `filled` bound, + * every counter value the loop can reach (at least 0, below `a.length`) + * names a present value in dense storage. The fast copy then reads and + * replaces those slots directly, which leaves LLVM a loop without + * per-element branches (vectorizable where the body allows it). The same + * test bounds `a.length` by 2^31 - 1, so the fast copy's counter is an int32. + * Arrays longer than that are sparse in practice (a dense one would need + * 16 GB of slots) and take the ordinary copy, as does every array with a + * hole, present undefined or sparse storage below `a.length`. */ + +import type { IrExpr, IrLocal, IrStmt } from "../../ir/ir.js"; +import type { IntegerRange } from "../../ir/integer-ranges.js"; +import { preservesArrayLength } from "../../ir/array-length-stability.js"; +import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; +import type { LlvmEmitterContext } from "./expr-context.js"; +import { SCR_ARR_FIELD } from "./common.js"; + +export const INT32_MAX = 2147483647; + +export interface DenseArrayLoop { + /** The loop's counter binding. */ + counter: string; + /** The array whose length bounds the counter. */ + length: IrExpr & { kind: "varRef" }; + /** Arrays whose accesses at the counter are proven to be inside the + * hole-free prefix in the fast copy, with a read of each binding. */ + arrays: ReadonlyMap; + /** 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, + unaliased: ReadonlySet, + initialized: ReadonlySet, +): DenseArrayLoop | null { + const { counter, length } = shape; + if (shape.range.min < 0 || !loop.cond) return null; + let nodes = 0, + nested = false; + const candidates = new Map(); + // Arrays written, resized, or indexed other than at the counter by an + // element write. + const disturbed = new Set(); + 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(); + 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; +} diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 2c3a71457..634cf8d79 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -55,6 +55,13 @@ import { matchIntegerBytesForLoop, matchIntegerCountedForLoop, } from "../../ir/integer-loops.js"; +import { findUnaliasedLocalArrays } from "../../ir/array-length-stability.js"; +import { + countedDenseShape, + emitDenseLoopGuard, + matchDenseArrayLoop, + type DenseArrayLoop, +} from "./dense-array-loops.js"; import { emitCountedLoopLimit } from "./counted-loops.js"; import { emitSafeIntegerCheck, @@ -638,6 +645,21 @@ export class LlEmitter { return this.sizeType === "i64" ? ", !range !{i64 0, i64 4294967296}" : ""; } + /** Whether an element access at `index` of `arr` lies in the hole-free + * prefix: the counter of a dense array loop's fast copy indexing one of + * the arrays its entry test covered. */ + provenDenseIndex(arr: IrExpr, index: IrExpr): boolean { + const loop = this.denseLoop; + return ( + loop !== null && + arr.kind === "varRef" && + loop.arrays.has(arr.localId) && + index.kind === "varRef" && + index.localId === loop.counter && + this.integerLoopBindings.has(loop.counter) + ); + } + fieldAliasAttachment(ptr: string): string { const tag = this.fieldPointerTags.get(ptr); return tag === undefined ? "" : `, !tbaa !${tag}`; @@ -991,6 +1013,14 @@ export class LlEmitter { * immortal from construction (classes.ts programLifetimeClass). */ readonly programLifetime: ReadonlySet; private numericLocals = new Map(); + /** Local arrays of the current function that no other binding can hold + * (ir/array-length-stability.ts). */ + private unaliasedArrays: ReadonlySet = new Set(); + /** Dense array loops (dense-array-loops.ts) run in this function. */ + private denseLoopsEnabled = false; + private denseLoopEntries: ReturnType = new Map(); + /** The fast copy of a dense array loop being emitted. */ + private denseLoop: DenseArrayLoop | null = null; private captureIds = new Set(); /** Active induction bindings retain their width independently of size_t. */ integerLoopBindings = new Map< @@ -6553,6 +6583,7 @@ export class LlEmitter { const initializerBindings = this.initializerBindings.get(fn.name) ?? []; const numericFn = withInitializerBindings(fn, initializerBindings); this.numericLocals = new Map(numericFn.locals.map((l) => [l.id, l])); + this.unaliasedArrays = findUnaliasedLocalArrays(fn); this.borrowedParameters.clear(); this.referenceWrites?.enter(fn); this.reboundOwnerSlots.clear(); @@ -6591,6 +6622,12 @@ export class LlEmitter { this.finalFieldBorrows = !fn.async && !fn.generator && constructorClass(fn.name) === null && this.finalFields.size > 0; this.byteWindowsEnabled = this.countedLoopsEnabled; + this.denseLoopsEnabled = this.countedLoopsEnabled; + this.denseLoop = null; + this.denseLoopEntries = + this.denseLoopsEnabled && numericFn.locals.some((l) => l.type.kind === "array") + ? findInitializedByteLoopBindings(fn) + : new Map(); this.byteWindowEntries = this.byteWindowsEnabled && numericFn.locals.some((l) => l.type.kind === "bytes") ? findInitializedByteLoopBindings(fn) @@ -8030,7 +8067,16 @@ export class LlEmitter { const acc = elemAccess(s.arr.type.elem); this.emitPublishedArrayGuard(arr.name, s.arr.type, 0, idx.name); if (acc === "ref") this.moveTemp(v); - emitDenseArraySet(this, arr.name, idx, s.index, acc, s.arr.type.elem, v.name); + emitDenseArraySet( + this, + arr.name, + idx, + s.index, + acc, + s.arr.type.elem, + v.name, + this.provenDenseIndex(s.arr, s.index), + ); break; } case "arraySetLength": { @@ -8447,7 +8493,12 @@ export class LlEmitter { (this.debug === null ? matchIntegerBytesForLoop(s, this.numericLocals) : null); const countedLoop = !integerLoop && this.countedLoopsEnabled - ? matchIntegerCountedForLoop(s, this.numericLocals, this.integerRanges) + ? matchIntegerCountedForLoop( + s, + this.numericLocals, + this.integerRanges, + this.unaliasedArrays, + ) : null; const window = countedLoop && this.byteWindowsEnabled @@ -8460,6 +8511,49 @@ export class LlEmitter { this.byteWindowEntries.get(s) ?? new Set(), ) : null; + const denseShape = !this.denseLoopsEnabled + ? null + : integerArrayLoop + ? { + counter: integerArrayLoop.localId, + length: integerArrayLoop.limitReceiver as IrExpr & { kind: "varRef" }, + range: { min: 0, max: 4294967294 }, + } + : countedLoop && !countedLoop.guarded + ? countedDenseShape(s, countedLoop) + : null; + const dense = + denseShape && !window + ? matchDenseArrayLoop( + s, + denseShape, + this.numericLocals, + this.unaliasedArrays, + this.denseLoopEntries.get(s) ?? new Set(), + ) + : null; + if (dense) { + const valid = emitDenseLoopGuard(this, dense); + const fast = B.newLabel("dense.loop.fast"), + slow = B.newLabel("dense.loop.slow"), + done = B.newLabel("dense.loop.done"); + B.condBr(valid, fast, slow); + this.denseLoopsEnabled = false; + try { + B.startBlock(slow); + this.emitStmt(s); + B.br(done); + B.startBlock(fast); + this.denseLoop = dense; + this.emitStmt(s); + B.br(done); + } finally { + this.denseLoop = null; + this.denseLoopsEnabled = true; + } + B.startBlock(done); + break; + } if (window) { const valid = emitByteWindowGuard(this, window); const fast = B.newLabel("bytes.window.fast"), @@ -8562,7 +8656,10 @@ export class LlEmitter { this.integerLoopBindings.set(integerLoop.localId, { slot: integerSlot, type: this.sizeType, - range: { min: 0, max: integerArrayLoop ? 4294967294 : Number.MAX_SAFE_INTEGER - 1 }, + range: + this.denseLoop?.counter === integerLoop.localId + ? this.denseLoop.range + : { min: 0, max: integerArrayLoop ? 4294967294 : Number.MAX_SAFE_INTEGER - 1 }, }); } else if (countedLoop) { integerSlot = B.slot(); @@ -8577,7 +8674,10 @@ export class LlEmitter { slot: integerSlot, type: "i64", signed: true, - range: countedLoop.range, + range: + this.denseLoop?.counter === countedLoop.localId + ? this.denseLoop.range + : countedLoop.range, }); countedLimit = emitCountedLoopLimit(this, countedLoop); } else if (s.init) { diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts index a2d5fd35a..1b0f1e638 100644 --- a/packages/compiler/src/backend/llvm/expr-containers.ts +++ b/packages/compiler/src/backend/llvm/expr-containers.ts @@ -571,7 +571,16 @@ export function emitArrIntrinsic( ? host.constantNumericTables.get(e.receiver.localId) : undefined; if (!table && elem.kind === "f64") - return { name: emitDenseArrayNumber(host, r.name, index, e.args[0]), type: e.type }; + return { + name: emitDenseArrayNumber( + host, + r.name, + index, + e.args[0], + host.provenDenseIndex(e.receiver, e.args[0]!), + ), + type: e.type, + }; host.declare(`declare double @scr_arr_get_number(ptr, double)`); const t = B.tmp(); B.line( diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index aaae4c35c..664689e91 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -64,16 +64,25 @@ export function emitLiteralExpr( const integer = B.tmp(); const number = B.tmp(); B.line(`${integer} = load ${integerSlot.type}, ptr ${integerSlot.slot}`); - // A 64-bit induction value is a safe integer, so its sign bit is - // clear and the signed conversion is exact: one cvtsi2sd instead of - // x86-64's multi-instruction unsigned 64-bit conversion. - const signedConversion = integerSlot.signed === true || integerSlot.type === "i64"; - B.line( - `${number} = ${signedConversion ? "sitofp" : "uitofp"} ${integerSlot.type} ${integer} to double`, - ); const uint32 = integerSlot.type === "i32" ? integer : B.tmp(); if (integerSlot.type !== "i32") B.line(`${uint32} = trunc ${integerSlot.type} ${integer} to i32`); + // A counter whose range fits 32 bits converts from its low half, so + // LLVM folds a later `fptosi` of the double (an inlined callee's + // index) back to the integer. Otherwise a 64-bit induction value is + // a safe integer, so its sign bit is clear and the signed conversion + // is exact: one cvtsi2sd instead of x86-64's multi-instruction + // unsigned 64-bit conversion. + const range = integerSlot.range; + const signedConversion = integerSlot.signed === true || integerSlot.type === "i64"; + if (range && range.min >= -2147483648 && range.max <= 2147483647) + B.line(`${number} = sitofp i32 ${uint32} to double`); + else if (range && range.min >= 0 && range.max <= 4294967295) + B.line(`${number} = uitofp i32 ${uint32} to double`); + else + B.line( + `${number} = ${signedConversion ? "sitofp" : "uitofp"} ${integerSlot.type} ${integer} to double`, + ); return { name: number, type: e.type, @@ -851,6 +860,7 @@ export function emitContainerExpr( elem, `scr_arr_get_${acc}`, true, + host.provenDenseIndex(e.arr, e.index), ); return host.own({ name: t, type: e.type }); } diff --git a/packages/compiler/src/backend/llvm/integer-values.ts b/packages/compiler/src/backend/llvm/integer-values.ts index 63e279d04..53e07462e 100644 --- a/packages/compiler/src/backend/llvm/integer-values.ts +++ b/packages/compiler/src/backend/llvm/integer-values.ts @@ -93,11 +93,16 @@ export function integerNumber( // `x | 0`), while a 64-bit source is not known to convert exactly. So a // counter written `i = (i + 1) | 0` stays an integer instead of taking a // double round trip on every iteration. + // A uint32 (an array index counter) converts from its low half unsigned, + // which LLVM likewise folds back through a later `fptosi` to i64. const int32 = range.min >= -2147483648 && range.max <= 2147483647; + const unsigned = !int32 && range.min >= 0 && range.max <= 4294967295; host.B.line( int32 ? `${number} = sitofp i32 ${uint32} to double` - : `${number} = sitofp i64 ${name} to double`, + : unsigned + ? `${number} = uitofp i32 ${uint32} to double` + : `${number} = sitofp i64 ${name} to double`, ); return { name: number, type, uint32, integer: { name, type: "i64", signed: true, range } }; } diff --git a/packages/compiler/src/ir/array-length-stability.test.ts b/packages/compiler/src/ir/array-length-stability.test.ts new file mode 100644 index 000000000..87e510a3f --- /dev/null +++ b/packages/compiler/src/ir/array-length-stability.test.ts @@ -0,0 +1,129 @@ +import { expect, test } from "vitest"; +import { findUnaliasedLocalArrays, preservesArrayLength } from "./array-length-stability.js"; +import { BOOL, F64, arrayOf, type IrExpr, type IrFunction, type IrLocal, type IrStmt } from "./ir.js"; + +const loc = { file: "array-length-stability.ts", start: 0, end: 0 }; +const ARR = arrayOf(F64); +const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc }); +const ref = (localId: string): IrExpr => ({ kind: "varRef", localId, type: F64, loc }); +const arr = (localId: string): IrExpr => ({ kind: "varRef", localId, type: ARR, loc }); +const method = ( + receiver: string, + name: Extract["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([ + ["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 = 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); +}); diff --git a/packages/compiler/src/ir/array-length-stability.ts b/packages/compiler/src/ir/array-length-stability.ts new file mode 100644 index 000000000..2ac196d30 --- /dev/null +++ b/packages/compiler/src/ir/array-length-stability.ts @@ -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([ + "length", + "getNumber", + "indexEq", + "indexOf", + "includes", + "nextPresent", +]); + +/** String methods that read an immutable string and run no user code. */ +const READING_STRING_METHODS = new Set(["length", "charCodeAt", "codePointAt"]); + +/** Expressions that compute from their operands and run no user code. Their + * children are checked separately. */ +const PLAIN_EXPRESSIONS = new Set([ + "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([ + "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([ + ...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 { + const candidates = new Set(); + 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(); + 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, + unaliased: ReadonlySet, +): 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))); +} diff --git a/packages/compiler/src/ir/integer-loop-facts.ts b/packages/compiler/src/ir/integer-loop-facts.ts index 4c1a1ef1f..84fabf956 100644 --- a/packages/compiler/src/ir/integer-loop-facts.ts +++ b/packages/compiler/src/ir/integer-loop-facts.ts @@ -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. diff --git a/packages/compiler/src/ir/integer-loops.test.ts b/packages/compiler/src/ir/integer-loops.test.ts index 0f7b9ae13..75968b262 100644 --- a/packages/compiler/src/ir/integer-loops.test.ts +++ b/packages/compiler/src/ir/integer-loops.test.ts @@ -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 }, + }); +}); diff --git a/packages/compiler/src/ir/integer-loops.ts b/packages/compiler/src/ir/integer-loops.ts index c854a99c5..3ba186376 100644 --- a/packages/compiler/src/ir/integer-loops.ts +++ b/packages/compiler/src/ir/integer-loops.ts @@ -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, 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, ranges: IntegerRanges = new Map(), + unaliased: ReadonlySet | 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)[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") diff --git a/packages/compiler/src/ir/integer-ranges.test.ts b/packages/compiler/src/ir/integer-ranges.test.ts index e9d1797d1..0d0762d16 100644 --- a/packages/compiler/src/ir/integer-ranges.test.ts +++ b/packages/compiler/src/ir/integer-ranges.test.ts @@ -334,7 +334,8 @@ test("filtered strided loops retain decoded values and bound secondary cursors", const ranges = analyzeIntegerRanges(f); expect(ranges.get(cursor)).toEqual({ min: 0, max: 80 }); expect(ranges.get(decoded)).toEqual({ min: 0, max: 4294967295 }); - expect(ranges.get(conditionRead)).toEqual({ min: 0, max: 124 }); + // The last pass enters at most at 99, so the failing test reads at most 123. + expect(ranges.get(conditionRead)).toEqual({ min: 0, max: 123 }); for (const mutation of ["fraction", "repeat", "overflow", "counter"] as const) { const changed = structuredClone(f); const body = (changed.body[1] as IrStmt & { kind: "for" }).body; @@ -795,6 +796,58 @@ test("a while condition on a string's length bounds a counter, and guarded incre expect(run((inside) => [observe(inside), bump, rebound]).after).toBeNull(); }); +test("an array length bounds while and for counters even when the body resizes the array", () => { + // let i = 0; while (i < items.length) { observe(i); items.push(i); i++; } observe(i); + const ARR = { kind: "array", elem: F64 } as const; + const items: IrExpr = { loc, kind: "varRef", localId: "items", type: ARR }; + const length: IrExpr = { loc, kind: "arrIntrinsic", method: "length", receiver: items, args: [], type: F64 }; + const push: IrStmt = { + loc, + kind: "exprStmt", + expr: { loc, kind: "arrIntrinsic", method: "push", receiver: items, args: [ref("i")], type: F64 }, + }; + const bump: IrStmt = { + loc, + kind: "exprStmt", + expr: { loc, kind: "incDec", op: "+", prefix: false, localId: "i", type: F64 }, + }; + const inside = ref("i"), + after = ref("i"); + const f = fn([ + { loc, kind: "assign", localId: "i", value: num(0) }, + { loc, kind: "while", cond: condition("<", ref("i"), length), body: [observe(inside), push, bump] }, + observe(after), + ]); + f.locals.push( + { id: "i", name: "i", type: F64, mutable: true }, + { id: "items", name: "items", type: ARR, mutable: false }, + ); + const ranges = analyzeIntegerRanges(f); + expect(ranges.get(inside)).toEqual({ min: 0, max: 4294967294 }); + expect(ranges.get(after)).toEqual({ min: 0, max: 4294967295 }); + // A secondary cursor of `for (let j = 0; j < items.length; j++) k++` stays a uint32. + const cursor = ref("k"); + const loop: IrStmt = { + loc, + kind: "for", + init: { loc, kind: "varDecl", localId: "j", init: num(0) }, + cond: condition("<", ref("j"), length), + update: { loc, kind: "assign", localId: "j", value: bin("+", ref("j"), num(1)) }, + body: [ + push, + observe(cursor), + { loc, kind: "assign", localId: "k", value: bin("+", ref("k"), num(1)) }, + ], + }; + const g = fn([{ loc, kind: "assign", localId: "k", value: num(0) }, loop]); + g.locals.push( + { id: "j", name: "j", type: F64, mutable: true }, + { id: "k", name: "k", type: F64, mutable: true }, + { id: "items", name: "items", type: ARR, mutable: false }, + ); + expect(analyzeIntegerRanges(g).get(cursor)).toEqual({ min: 0, max: 4294967295 }); +}); + test("whole-program slot facts seed parameters, field reads and call results", () => { const field: IrExpr = { loc, diff --git a/tests/corpus/5960-array-length-loop-resizing.ts b/tests/corpus/5960-array-length-loop-resizing.ts new file mode 100644 index 000000000..a1d5f6d11 --- /dev/null +++ b/tests/corpus/5960-array-length-loop-resizing.ts @@ -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()); diff --git a/tests/corpus/5961-array-length-sparse-loops.ts b/tests/corpus/5961-array-length-sparse-loops.ts new file mode 100644 index 000000000..589c1df60 --- /dev/null +++ b/tests/corpus/5961-array-length-sparse-loops.ts @@ -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"); +} diff --git a/tests/corpus/5962-array-dense-loop-boundaries.ts b/tests/corpus/5962-array-dense-loop-boundaries.ts new file mode 100644 index 000000000..5efad78ec --- /dev/null +++ b/tests/corpus/5962-array-dense-loop-boundaries.ts @@ -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(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"); +} From 39d2a05f880479d4abc05fede0cd0e1ff01db3d1 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 17 Oct 2026 19:03:50 -0700 Subject: [PATCH 3/7] Renumber the array length loop corpus programs past main's numbers Co-Authored-By: Claude Opus 5.5 --- ...length-loop-resizing.ts => 5993-array-length-loop-resizing.ts} | 0 ...y-length-sparse-loops.ts => 5994-array-length-sparse-loops.ts} | 0 ...nse-loop-boundaries.ts => 5995-array-dense-loop-boundaries.ts} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename tests/corpus/{5960-array-length-loop-resizing.ts => 5993-array-length-loop-resizing.ts} (100%) rename tests/corpus/{5961-array-length-sparse-loops.ts => 5994-array-length-sparse-loops.ts} (100%) rename tests/corpus/{5962-array-dense-loop-boundaries.ts => 5995-array-dense-loop-boundaries.ts} (100%) diff --git a/tests/corpus/5960-array-length-loop-resizing.ts b/tests/corpus/5993-array-length-loop-resizing.ts similarity index 100% rename from tests/corpus/5960-array-length-loop-resizing.ts rename to tests/corpus/5993-array-length-loop-resizing.ts diff --git a/tests/corpus/5961-array-length-sparse-loops.ts b/tests/corpus/5994-array-length-sparse-loops.ts similarity index 100% rename from tests/corpus/5961-array-length-sparse-loops.ts rename to tests/corpus/5994-array-length-sparse-loops.ts diff --git a/tests/corpus/5962-array-dense-loop-boundaries.ts b/tests/corpus/5995-array-dense-loop-boundaries.ts similarity index 100% rename from tests/corpus/5962-array-dense-loop-boundaries.ts rename to tests/corpus/5995-array-dense-loop-boundaries.ts From cf872eaba51a4fb060bdc4bf2f474a4431b5e5df Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 17 Oct 2026 19:43:56 -0700 Subject: [PATCH 4/7] Convert only int32 loop counters from their low half A uint32 counter converted through `uitofp i32` folds an inlined callee's index conversion, but the zero extension it adds on x86-64 slowed loops that only convert the counter for a cold fallback. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/backend/llvm/expr-primitives.ts | 10 +++++----- packages/compiler/src/backend/llvm/integer-values.ts | 7 +------ 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 664689e91..e237f8f13 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -67,18 +67,18 @@ export function emitLiteralExpr( const uint32 = integerSlot.type === "i32" ? integer : B.tmp(); if (integerSlot.type !== "i32") B.line(`${uint32} = trunc ${integerSlot.type} ${integer} to i32`); - // A counter whose range fits 32 bits converts from its low half, so - // LLVM folds a later `fptosi` of the double (an inlined callee's + // 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. + // 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 if (range && range.min >= 0 && range.max <= 4294967295) - B.line(`${number} = uitofp i32 ${uint32} to double`); else B.line( `${number} = ${signedConversion ? "sitofp" : "uitofp"} ${integerSlot.type} ${integer} to double`, diff --git a/packages/compiler/src/backend/llvm/integer-values.ts b/packages/compiler/src/backend/llvm/integer-values.ts index 53e07462e..63e279d04 100644 --- a/packages/compiler/src/backend/llvm/integer-values.ts +++ b/packages/compiler/src/backend/llvm/integer-values.ts @@ -93,16 +93,11 @@ export function integerNumber( // `x | 0`), while a 64-bit source is not known to convert exactly. So a // counter written `i = (i + 1) | 0` stays an integer instead of taking a // double round trip on every iteration. - // A uint32 (an array index counter) converts from its low half unsigned, - // which LLVM likewise folds back through a later `fptosi` to i64. const int32 = range.min >= -2147483648 && range.max <= 2147483647; - const unsigned = !int32 && range.min >= 0 && range.max <= 4294967295; host.B.line( int32 ? `${number} = sitofp i32 ${uint32} to double` - : unsigned - ? `${number} = uitofp i32 ${uint32} to double` - : `${number} = sitofp i64 ${name} to double`, + : `${number} = sitofp i64 ${name} to double`, ); return { name: number, type, uint32, integer: { name, type: "i64", signed: true, range } }; } From 1e5079a23ab3354f3d67cc76624774050104de30 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 17 Oct 2026 19:44:08 -0700 Subject: [PATCH 5/7] Format the array length loop changes and record their order baselines Co-Authored-By: Claude Opus 5.5 --- .../backend/llvm/dense-array-loops.test.ts | 28 +++++++++++++---- .../src/backend/llvm/dense-array-loops.ts | 5 +--- .../src/ir/array-length-stability.test.ts | 30 +++++++++++++------ .../compiler/src/ir/integer-ranges.test.ts | 25 ++++++++++++++-- .../5993-array-length-loop-resizing.ts.json | 6 ++++ .../5994-array-length-sparse-loops.ts.json | 6 ++++ .../5995-array-dense-loop-boundaries.ts.json | 6 ++++ 7 files changed, 84 insertions(+), 22 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5993-array-length-loop-resizing.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5994-array-length-sparse-loops.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5995-array-dense-loop-boundaries.ts.json diff --git a/packages/compiler/src/backend/llvm/dense-array-loops.test.ts b/packages/compiler/src/backend/llvm/dense-array-loops.test.ts index d9e18d7ef..405ad67e7 100644 --- a/packages/compiler/src/backend/llvm/dense-array-loops.test.ts +++ b/packages/compiler/src/backend/llvm/dense-array-loops.test.ts @@ -1,6 +1,10 @@ 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"; +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); @@ -16,9 +20,15 @@ const method = ( receiver: string, name: Extract["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]); +): 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), @@ -49,7 +59,11 @@ function loop(body: IrStmt[]): IrStmt & { kind: "for" } { body, }; } -const shape: DenseLoopShape = { counter: "i", length: arr("a"), range: { min: 0, max: 4294967294 } }; +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); @@ -73,7 +87,9 @@ 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 }]), + 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(); diff --git a/packages/compiler/src/backend/llvm/dense-array-loops.ts b/packages/compiler/src/backend/llvm/dense-array-loops.ts index 5009e6c82..271a5ddf8 100644 --- a/packages/compiler/src/backend/llvm/dense-array-loops.ts +++ b/packages/compiler/src/backend/llvm/dense-array-loops.ts @@ -137,10 +137,7 @@ export function matchDenseArrayLoop( 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) - ) + if (s.arr.kind === "varRef" && (s.index.kind !== "varRef" || s.index.localId !== counter)) disturbed.add(s.arr.localId); } return everyStmtChild(s, expr, stmt); diff --git a/packages/compiler/src/ir/array-length-stability.test.ts b/packages/compiler/src/ir/array-length-stability.test.ts index 87e510a3f..44e461ca2 100644 --- a/packages/compiler/src/ir/array-length-stability.test.ts +++ b/packages/compiler/src/ir/array-length-stability.test.ts @@ -1,6 +1,14 @@ 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"; +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); @@ -11,7 +19,14 @@ const method = ( receiver: string, name: Extract["method"], args: IrExpr[] = [], -): IrExpr => ({ kind: "arrIntrinsic", method: name, receiver: arr(receiver), args, type: F64, loc }); +): 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 => ({ @@ -74,9 +89,9 @@ test("resizing methods, length stores, calls and escapes refuse the proof", () = 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([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); }); @@ -119,10 +134,7 @@ test("fresh local arrays read only as receivers have no alias", () => { 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 }), - ]; + 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); diff --git a/packages/compiler/src/ir/integer-ranges.test.ts b/packages/compiler/src/ir/integer-ranges.test.ts index 0d0762d16..8e1c97f66 100644 --- a/packages/compiler/src/ir/integer-ranges.test.ts +++ b/packages/compiler/src/ir/integer-ranges.test.ts @@ -800,11 +800,25 @@ test("an array length bounds while and for counters even when the body resizes t // 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 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 }, + expr: { + loc, + kind: "arrIntrinsic", + method: "push", + receiver: items, + args: [ref("i")], + type: F64, + }, }; const bump: IrStmt = { loc, @@ -815,7 +829,12 @@ test("an array length bounds while and for counters even when the body resizes t 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] }, + { + loc, + kind: "while", + cond: condition("<", ref("i"), length), + body: [observe(inside), push, bump], + }, observe(after), ]); f.locals.push( diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5993-array-length-loop-resizing.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5993-array-length-loop-resizing.ts.json new file mode 100644 index 000000000..e4cf027ce --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5993-array-length-loop-resizing.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5993-array-length-loop-resizing.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5994-array-length-sparse-loops.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5994-array-length-sparse-loops.ts.json new file mode 100644 index 000000000..6289e8a21 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5994-array-length-sparse-loops.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5994-array-length-sparse-loops.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5995-array-dense-loop-boundaries.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5995-array-dense-loop-boundaries.ts.json new file mode 100644 index 000000000..d0afcf505 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5995-array-dense-loop-boundaries.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5995-array-dense-loop-boundaries.ts" + ], + "diags": [] +} From 0dc6e05e82f3a9d23238c7d71236cb236e4bcf90 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 17 Oct 2026 20:47:16 -0700 Subject: [PATCH 6/7] Expect the tighter strided-loop bound in the self-hosted range cases The native range analysis now admits one fewer pass for a strict bound, so the failing condition of `for (x = 0; x < 100; x += 24)` reads at most 123 (the last pass enters at 99 at most). Co-Authored-By: Claude Opus 5.5 --- tests/harness/self-hosting-range-cases.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/harness/self-hosting-range-cases.ts b/tests/harness/self-hosting-range-cases.ts index 05d6a1fe6..ed2b24ca9 100644 --- a/tests/harness/self-hosting-range-cases.ts +++ b/tests/harness/self-hosting-range-cases.ts @@ -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 }); From d49fcf3c6674d20f1b86faa291c0555bb6a980de Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 06:26:10 -0700 Subject: [PATCH 7/7] Record CI costs for the array length loop corpus programs The shard harness requires measured costs for more than 90% of the corpus. Take the costs of programs 5993-5995 from this branch's green Linux shard logs the way scripts/ci-corpus-costs.mjs computes them. Co-Authored-By: Claude Opus 5.5 --- tests/harness/corpus-costs.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 88b9415bd..dd3a0b00a 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -1913,6 +1913,9 @@ "5990-worker-uncloneable.mjs": 3283, "5991-worker-async-dispose.mjs": 5699, "5992-worker-thread-name-lifetime.mjs": 2687, + "5993-array-length-loop-resizing.ts": 3187, + "5994-array-length-sparse-loops.ts": 3292, + "5995-array-dense-loop-boundaries.ts": 2596, "600-closures-basic.ts": 2349, "601-closures-loops.ts": 1788, "602-closures-identity-recursion.ts": 2372, @@ -4611,6 +4614,9 @@ "5990-worker-uncloneable.mjs": 22432, "5991-worker-async-dispose.mjs": 25768, "5992-worker-thread-name-lifetime.mjs": 28932, + "5993-array-length-loop-resizing.ts": 4127, + "5994-array-length-sparse-loops.ts": 2815, + "5995-array-dense-loop-boundaries.ts": 4171, "600-closures-basic.ts": 2534, "601-closures-loops.ts": 2297, "602-closures-identity-recursion.ts": 2427,