From 322c225b66131e1344d802b1f3b2e914f57a16e7 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 13 Oct 2026 01:23:19 -0500 Subject: [PATCH 1/3] Store int32-closed number locals as i32 --- packages/compiler/src/backend/llvm/emitter.ts | 74 +++- .../src/backend/llvm/expr-primitives.ts | 17 + .../compiler/src/backend/llvm/int32-locals.ts | 102 ++++++ .../test/int32-local-emission.test.ts | 221 ++++++++++++ .../corpus/4860-int32-local-storage.ts.json | 6 + tests/corpus/4860-int32-local-storage.ts | 326 ++++++++++++++++++ 6 files changed, 745 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/src/backend/llvm/int32-locals.ts create mode 100644 packages/compiler/test/int32-local-emission.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4860-int32-local-storage.ts.json create mode 100644 tests/corpus/4860-int32-local-storage.ts diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 5f9569bf0..1a8ab42a6 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -81,6 +81,8 @@ import { analyzeInt32Slots, type Int32Slots } from "../../ir/int32-slots.js"; import { analyzeFinalFields, constructorClass, type FinalFields } from "../../ir/final-fields.js"; import { foldConstantGlobals } from "../../ir/constant-globals.js"; import { findIntegerViews } from "./integer-views.js"; +import { findInt32Locals, type Int32Local } from "./int32-locals.js"; +import { exactInteger, narrowInteger } from "./integer-values.js"; import { findInitializerBindings, withInitializerBindings } from "../../ir/initializer-bindings.js"; import { findConstantNumericTables, type ConstantNumericTable } from "../../ir/constant-tables.js"; import { @@ -768,6 +770,11 @@ export class LlEmitter { private byteWindowEntries: ReturnType = new Map(); bytesBounds: ReadonlySet = new Set(); integerViews = new Map(); + /** Number locals of the current function stored only as an i32 + * (int32-locals.ts); their slots are `alloca i32`. */ + int32Locals = new Map(); + /** The written values (and `++`/`--` nodes) the int32 proof covers. */ + int32LocalWrites: ReadonlySet = new Set(); /** Enclosing try-with-FINALLY regions, innermost last: a `return` * inside one runs every crossed finally (innermost first) before the * actual ret — the runtime ABI’s pending-return path, with the finally @@ -5096,6 +5103,20 @@ export class LlEmitter { this.walkBorrows = this.walkBorrowsByFunction.get(fn.name) ?? new Set(); this.integerRanges = analyzeIntegerRanges(numericFn, this.int32Slots.facts(fn.name)); this.bytesBounds = findBytesBounds(numericFn, this.integerRanges); + // Debug builds keep every number local a double for the debugger's + // variable view. Lazily boxed captured parameters keep their double, + // which the box is created from. + const int32Locals = + this.debug === null + ? findInt32Locals( + fn, + this.integerRanges, + this.int32Slots.facts(fn.name), + new Set(this.lazyCaptureBoxes.keys()), + ) + : null; + this.int32Locals = int32Locals?.locals ?? new Map(); + this.int32LocalWrites = int32Locals?.values ?? new Set(); this.chainSlots.clear(); this.finallyStack = []; this.tryStack = []; @@ -5144,6 +5165,8 @@ export class LlEmitter { const paramIds = new Set(fn.params.map((p) => p.localId)); if (this.debug === null) { for (const id of findIntegerViews(numericFn)) { + // An i32 slot is its own view. + if (this.int32Locals.has(id)) continue; const slot = B.slot(); B.entryAllocas.push( `${slot} = alloca i32 ; integer view ${this.numericLocals.get(id)!.name}`, @@ -5165,7 +5188,9 @@ export class LlEmitter { const slotTy = local.boxed || this.captureIds.has(local.id) || local.type.kind === "caught" ? "ptr" - : this.llType(local.type); + : this.int32Locals.has(local.id) + ? "i32" + : this.llType(local.type); B.entryAllocas.push(`%${mangleLocal(local.id)} = alloca ${slotTy} ; ${local.name}`); this.emitDebugLocal( local, @@ -5244,6 +5269,8 @@ export class LlEmitter { fnScope.push({ slot, type: p.type, boxed: true }); continue; } + const incoming = { name: `%p_${mangleLocal(p.localId)}`, type: p.type }; + if (this.storeInt32Local(p.localId, incoming)) continue; B.line(`store ${this.llType(p.type)} %p_${mangleLocal(p.localId)}, ptr ${slot}`); this.storeIntegerView(p.localId, { name: `%p_${mangleLocal(p.localId)}`, type: p.type }); if (isRefCounted(p.type) && !borrowed?.has(index)) fnScope.push({ slot, type: p.type }); @@ -5712,6 +5739,10 @@ export class LlEmitter { this.frames.push([]); switch (s.kind) { case "varDecl": { + if (s.init && this.int32Locals.has(s.localId)) { + this.storeInt32Local(s.localId, this.emitExpr(s.init), s.init); + break; + } const b = this.binding(s.localId); if (this.walkBorrows.has(s.localId) && b.kind === "local") { // A borrowed walk pointer: no retain now, no release at scope exit. @@ -5862,6 +5893,10 @@ export class LlEmitter { break; } case "assign": { + if (this.int32Locals.has(s.localId)) { + this.storeInt32Local(s.localId, this.emitExpr(s.value), s.value); + break; + } const owner = this.reboundOwnerSlots.get(s.localId); if (owner !== undefined && !this.walkBorrows.has(s.localId)) { // A rebound borrowed parameter: the new value moves into the owner @@ -8317,6 +8352,43 @@ export class LlEmitter { return emitToUint32(this, value, expr, uint32); } + /** Stores a number into its int32-stored local, when it is one. The + * value is proven to be in the local's range, so its low 32 bits are the + * exact value. */ + storeInt32Local(localId: string, value: LlValue, expr?: IrExpr): boolean { + const local = this.int32Locals.get(localId); + if (local === undefined) return false; + if (expr !== undefined && !this.int32LocalWrites.has(expr)) + throw new InternalCompilerError(`llvm emitter bug: unproven write of int32 local ${localId}`); + const B = this.B; + const integer = exactInteger(this, value, expr); + let bits: string; + if (integer && /^-?\d+$/.test(integer.name)) bits = String(Number(integer.name) | 0); // a literal's i32 bits + else if (integer) bits = narrowInteger(this, integer); + else if (value.uint32 !== undefined) bits = value.uint32; + else { + // An Int32 contract proved this write without a range at the value. + bits = B.tmp(); + B.line(`${bits} = ${local.signed ? "fptosi" : "fptoui"} double ${value.name} to i32`); + } + B.line(`store i32 ${bits}, ptr %${mangleLocal(localId)}`); + return true; + } + + /** The number of an int32-stored local's i32, with the i32 as its + * integer and ToUint32 forms. */ + int32LocalValue(localId: string, bits: string, type: IrType): LlValue { + const local = this.int32Locals.get(localId)!; + const number = this.B.tmp(); + this.B.line(`${number} = ${local.signed ? "sitofp" : "uitofp"} i32 ${bits} to double`); + return { + name: number, + type, + uint32: bits, + integer: { name: bits, type: "i32", signed: local.signed, range: local.range }, + }; + } + storeIntegerView(localId: string, value: LlValue, expr?: IrExpr): void { const slot = this.integerViews.get(localId); if (slot === undefined) return; diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 2ea6995e9..7469beb27 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -81,6 +81,11 @@ export function emitLiteralExpr( }, }; } + if (host.int32Locals.has(e.localId)) { + const bits = B.tmp(); + B.line(`${bits} = load i32, ptr ${host.binding(e.localId).slot}`); + return host.int32LocalValue(e.localId, bits, e.type); + } const b = host.binding(e.localId); if (b.kind === "global") host.checkGlobalTdz(e.localId); if (b.kind === "boxed") { @@ -368,6 +373,17 @@ export function emitOperatorExpr( // globals, capture boxes): read, write ±1, yield old (postfix) or // new (prefix). const b = host.binding(e.localId); + if (host.int32Locals.has(e.localId)) { + if (!host.int32LocalWrites.has(e)) + throw new InternalCompilerError(`llvm emitter bug: unproven ++/-- of ${e.localId}`); + // The written value is proven in the local's range: no wrap. + const old = B.tmp(); + const next = B.tmp(); + B.line(`${old} = load i32, ptr ${b.slot}`); + B.line(`${next} = ${e.op === "+" ? "add" : "sub"} i32 ${old}, 1`); + B.line(`store i32 ${next}, ptr ${b.slot}`); + return host.int32LocalValue(e.localId, e.prefix ? next : old, e.type); + } if (b.kind === "boxed") { const box = host.loadBox(b.slot); const old = b.local!.tdz @@ -434,6 +450,7 @@ export function emitOperatorExpr( } const b = host.binding(e.localId); const v = host.emitExpr(e.value); + if (host.storeInt32Local(e.localId, v, e.value)) return v; if (b.kind === "global") host.checkGlobalTdz(e.localId); if (b.kind === "boxed") { // box_set takes ownership of the passed reference, so hand it a diff --git a/packages/compiler/src/backend/llvm/int32-locals.ts b/packages/compiler/src/backend/llvm/int32-locals.ts new file mode 100644 index 000000000..59c7fe1ea --- /dev/null +++ b/packages/compiler/src/backend/llvm/int32-locals.ts @@ -0,0 +1,102 @@ +import type { IrExpr, IrFunction, IrStmt } from "../../ir/ir.js"; +import { + integerArithmeticRange, + withinInt32, + withinUint32, + type IntegerRange, + type IntegerRanges, + type IntegerSlotFacts, +} from "../../ir/integer-ranges.js"; +import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; + +/** How an int32-stored local holds its value: the i32 bits of an int32 + * (`signed`) or of a uint32, and the range every write lands in. */ +export interface Int32Local { + signed: boolean; + range: IntegerRange; +} + +/** Number locals whose every write is an exact int32 (or every write an + * exact uint32), so an i32 slot can be their only storage. A read widens + * the i32 back to the same double, which is exact because the range + * analysis never proves -0, NaN or a fraction; integer consumers take the + * i32 directly, and no double is carried through loops. + * + * A write qualifies through its proven range (analyzeIntegerRanges), or, + * for an Int32 contract local, through the contract, which checked every + * write with the same analysis. `++`/`--` qualify through the range of the + * value they write. A parameter also needs a proof for its incoming value + * (every call site, or its contract). For-of and catch bindings, captured, + * boxed and TDZ locals keep their ordinary storage, as do async functions + * and generators, whose locals live in frames the range analysis does not + * follow (it proves nothing there). `excluded` names further locals the + * caller reads through other storage. */ +export function findInt32Locals( + fn: IrFunction, + ranges: IntegerRanges, + facts: IntegerSlotFacts, + excluded: ReadonlySet, +): { locals: Map; values: ReadonlySet } { + const result = new Map(); + // The written values the proof covers; the emitter stores no other. + const values = new Set(); + if (fn.async || fn.generator || ranges.size === 0) return { locals: result, values }; + const captured = new Set( + [...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId), + ); + const contract = (id: string): IntegerRange | null => facts.locals?.(id) ?? null; + const writes = new Map(); + for (const local of fn.locals) { + if (local.type.kind !== "f64" || local.boxed || local.tdz) continue; + if (captured.has(local.id) || excluded.has(local.id)) continue; + writes.set(local.id, []); + } + for (const param of fn.params) { + const list = writes.get(param.localId); + if (list) list.push(facts.params?.get(param.localId) ?? contract(param.localId)); + } + const write = (id: string, range: IntegerRange | null | undefined, value?: IrExpr): void => { + const list = writes.get(id); + if (!list) return; + list.push(range ?? contract(id)); + if (value) values.add(value); + }; + const expr = (node: IrExpr): boolean => { + if (node.kind === "assignExpr") write(node.localId, ranges.get(node.value), node.value); + else if (node.kind === "incDec") { + // The analysis records the value the expression yields: the new + // value for prefix forms, the old one for postfix forms. + const yielded = ranges.get(node) ?? null; + write( + node.localId, + yielded && !node.prefix + ? integerArithmeticRange(node.op, yielded, { min: 1, max: 1 }) + : yielded, + node, + ); + } + return everyExprChild(node, expr, stmt); + }; + const stmt = (node: IrStmt): boolean => { + if (node.kind === "varDecl") { + if (node.init) write(node.localId, ranges.get(node.init), node.init); + } else if (node.kind === "assign") write(node.localId, ranges.get(node.value), node.value); + else if (node.kind === "forOf") writes.get(node.localId)?.push(null); + else if (node.kind === "tryCatch" && node.catchLocalId) + writes.get(node.catchLocalId)?.push(null); + return everyStmtChild(node, expr, stmt); + }; + fn.body.every(stmt); + for (const [id, list] of writes) { + // An unwritten local is never read as a number. + if (list.length === 0 || list.some((range) => range === null)) continue; + const range = { min: Infinity, max: -Infinity }; + for (const write of list as IntegerRange[]) { + range.min = Math.min(range.min, write.min); + range.max = Math.max(range.max, write.max); + } + if (withinInt32(range)) result.set(id, { signed: true, range }); + else if (withinUint32(range)) result.set(id, { signed: false, range }); + } + return { locals: result, values }; +} diff --git a/packages/compiler/test/int32-local-emission.test.ts b/packages/compiler/test/int32-local-emission.test.ts new file mode 100644 index 000000000..be7e9a722 --- /dev/null +++ b/packages/compiler/test/int32-local-emission.test.ts @@ -0,0 +1,221 @@ +import { mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; +import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; + +const repoRoot = join(import.meta.dirname, "../../.."); + +const source = ` +import type { Int32 } from "@scriptc/types"; +function rsync(a: Uint8Array): number { + let s1 = 0, + s2 = 0; + for (let i = 0; i < a.length; i++) { + s1 = (s1 + a[i]) & 0xffff; + s2 = (s2 + s1) & 0xffff; + } + return ((s2 << 16) | s1) >>> 0; +} +function mix(s: string): number { + let h = 5381; + for (let i = 0; i < s.length; i++) h = ((h << 5) + h + i) | 0; + return h; +} +function counters(): number { + let up = 0; + const before = up++; + const after = ++up; + let down = 3; + const was = down--; + return before * 1000 + after * 100 + was * 10 + down; +} +function unsigned(a: Uint32Array): number { + let acc = 0; + for (let i = 0; i < a.length; i++) acc = (acc + (a[i] ^ i)) >>> 0; + return acc; +} +function fraction(n: number): number { + let h = 0; + for (let i = 0; i < n; i++) h = (h + i) | 0; + h = h / 2; + return h; +} +function mixed(n: number): number { + let m = -1 | 0; + if (n > 1) m = m >>> 0; + return m; +} +function captured(words: string[]): number { + let h = 17; + const add = (w: string): void => { + for (let i = 0; i < w.length; i++) h = (Math.imul(h, 31) + w.charCodeAt(i)) | 0; + }; + for (const w of words) add(w); + return h; +} +function capturedParam(seed: number): () => number { + return () => seed; +} +function* frames(words: string[]): Generator { + let h = 0; + for (const w of words) { + for (let i = 0; i < w.length; i++) h = (Math.imul(h, 33) ^ w.charCodeAt(i)) | 0; + yield h; + } +} +async function suspended(words: string[]): Promise { + let total = 0; + for (const w of words) { + await Promise.resolve(); + total = (total + w.length) | 0; + } + return total; +} +function contract(seed: Int32, steps: Int32): Int32 { + let acc: Int32 = seed; + for (let i: Int32 = 0; i < steps; i = (i + 1) | 0) acc = (Math.imul(acc, 3) + i) | 0; + return acc; +} +const bytes = new Uint8Array([1, 2, 3, 250]); +console.log(rsync(bytes), mix("abcd"), counters(), unsigned(new Uint32Array([4294967295, 7]))); +console.log(fraction(5), mixed(3), captured(["ab", "c"]), capturedParam(3)()); +console.log([...frames(["ab", "cd"])].join(" "), contract(7, 20)); +suspended(["ab"]).then((t) => console.log(t)); +`; + +const body = (ll: string, name: string): string => + ll.match( + new RegExp(`define internal [^\\n]+ @sc_(?:b)?f_${name}\\([^\\n]*\\) #0 \\{([\\s\\S]*?)\\n\\}`), + )![1]!; + +/** Every function definition whose header or body matches. */ +const functionsWith = (ll: string, pattern: RegExp): string[] => + ll.split(/^(?=define )/m).filter((fn) => fn.startsWith("define ") && pattern.test(fn)); + +/** The blocks of a function's first loop, from its header to its exit. */ +const loopOf = (fn: string): string => { + const start = fn.search(/\nloop\.c\d+:/); + expect(start).toBeGreaterThan(0); + const end = fn.indexOf("\nloop.e", start); + return fn.slice(start, end < 0 ? undefined : end); +}; + +/** The function without pure instructions whose results nothing uses, + * repeatedly, as LLVM's dead code elimination leaves it. */ +const live = (fn: string): string => { + const pure = + /^\s*(%[\w.]+) = (?:[su]itofp|fpto[su]i|f(?:add|sub|mul|div|neg|cmp)|select|[sz]ext|trunc|add|sub|mul|and|or|xor|shl|lshr|ashr|icmp|phi|freeze|bitcast|getelementptr|load) /; + let lines = fn.split("\n"); + for (let changed = true; changed;) { + changed = false; + const text = lines.join("\n"); + lines = lines.filter((line) => { + const name = line.match(pure)?.[1]; + if (!name) return true; + const uses = text.split(new RegExp(`${name.replace(/\./g, "\\.")}(?![\\w.])`)).length - 2; + if (uses > 0) return true; + changed = true; + return false; + }); + } + return lines.join("\n"); +}; + +/** What keeps a double carrier of a local in a function's loop once dead + * values are gone: double loads and stores of local slots, and widened + * reads of a local's i32. */ +const carriers = (fn: string): string[] => { + const loop = loopOf(live(fn)); + const reads = new Set( + [...loop.matchAll(/^\s*(%[\w.]+) = load i32, ptr %sc_l_\w+$/gm)].map((m) => m[1]), + ); + return [ + ...loop.matchAll(/^\s*(?:%[\w.]+ = load double, ptr|store double [^,]+, ptr) %sc_l_\w+$/gm), + ...[...loop.matchAll(/^\s*%[\w.]+ = [su]itofp i32 (%[\w.]+) to double$/gm)].filter((m) => + reads.has(m[1]), + ), + ].map((m) => m[0].trim()); +}; + +test("int32-closed locals are stored as i32 and keep doubles only for floating consumers", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-int32-locals-")); + try { + await symlink(join(repoRoot, "node_modules"), join(dir, "node_modules")); + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const mod = deserializeModule(await readFile(outPath, "utf8")); + for (const pointerBits of [32, 64] as const) { + const ll = emitLlvmModule(mod, { pointerBits, wasi: pointerBits === 32 }); + + // Loop-carried accumulators and counters: i32 slots, no per-iteration + // conversion to or from a double, no integer view beside them. + const rsync = body(ll, "rsync"); + expect(rsync).toMatch(/alloca i32 ; s1\n/); + expect(rsync).toMatch(/alloca i32 ; s2\n/); + expect(rsync).not.toMatch(/alloca double ; s[12]\n/); + expect(rsync).not.toContain("integer view"); + expect(carriers(rsync)).toEqual([]); + const mix = body(ll, "mix"); + expect(mix).toMatch(/alloca i32 ; h\n/); + expect(mix).toMatch(/alloca i32 ; i\n/); + expect(carriers(mix)).toEqual([]); + // The result is the one double the function needs. + expect(mix).toMatch(/ = sitofp i32 %\w+ to double\n\s+ret double/); + + // ++ and -- write proven int32s in place. + const counters = body(ll, "counters"); + expect(counters).toMatch(/alloca i32 ; up\n/); + expect(counters).toMatch(/alloca i32 ; down\n/); + expect(counters.match(/ = add i32 %\w+, 1\n/g)).toHaveLength(2); + expect(counters).toMatch(/ = sub i32 %\w+, 1\n/); + + // A uint32 accumulator holds its bits and widens unsigned. + const unsigned = body(ll, "unsigned"); + expect(unsigned).toMatch(/alloca i32 ; acc\n/); + expect(carriers(unsigned)).toEqual([]); + expect(unsigned).toMatch(/ = uitofp i32 %\w+ to double\n\s+ret double/); + + // Locals that leave int32, or hold both int32 and uint32 values. + expect(body(ll, "fraction")).toMatch(/alloca double ; h\n/); + expect(body(ll, "mixed")).toMatch(/alloca double ; m\n/); + + // Captured bindings keep their boxes, inside and outside the closure. + const captured = body(ll, "captured"); + expect(captured).toMatch(/alloca ptr ; h\n/); + expect(captured).not.toMatch(/alloca i32 ; h\n/); + const closures = functionsWith(ll, /; captured h\n/); + expect(closures.length).toBeGreaterThan(0); + for (const fn of closures) expect(fn).not.toMatch(/alloca i32 ; h\n/); + expect(body(ll, "capturedParam")).not.toMatch(/alloca i32 ; seed\n/); + + // Generator and async frames keep doubles. + for (const name of ["frames", "suspended"]) { + const fns = functionsWith(ll, new RegExp(`^define [^\\n]*${name}`)); + expect(fns.length).toBeGreaterThan(0); + for (const fn of fns) expect(fn).not.toMatch(/alloca i32 ; (?:h|total)\n/); + } + + // Int32 contract locals and parameters use the same storage; the + // parameter converts once on entry. + const contract = body(ll, "contract"); + expect(contract).toMatch(/alloca i32 ; acc\n/); + expect(contract).toMatch(/alloca i32 ; seed\n/); + expect(contract).toMatch(/ = fptosi double %p_\w+ to i32\n/); + expect(carriers(contract)).toEqual([]); + } + + // Debug builds keep every number local a double for the variable view. + const debug = emitLlvmModule(mod, { debugSources: new Map([[entry, source]]) }); + expect(debug).not.toMatch(/alloca i32 ; (?:s1|h|acc)\n/); + expect(debug).toMatch(/alloca double ; s1\n/); + expect(debug).toContain("@llvm.dbg.declare"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4860-int32-local-storage.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4860-int32-local-storage.ts.json new file mode 100644 index 000000000..c82d0fd6d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4860-int32-local-storage.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4860-int32-local-storage.ts" + ], + "diags": [] +} diff --git a/tests/corpus/4860-int32-local-storage.ts b/tests/corpus/4860-int32-local-storage.ts new file mode 100644 index 000000000..20d201b5a --- /dev/null +++ b/tests/corpus/4860-int32-local-storage.ts @@ -0,0 +1,326 @@ +// Number locals whose every write is an int32 (or every write a uint32): +// string and byte hashes, checksums and PRNGs, counters that cross 2^31 +// through `| 0`, uint32 accumulators above 2^31, ++/-- counters, Int32 +// contract locals and parameters. Locals that also hold fractions, -0, NaN +// or values beyond 32 bits, and locals that closures capture or that live +// in generator and async frames, read back the same numbers. +import type { Int32 } from "@scriptc/types"; + +function show(label: string, value: number): void { + console.log(label, value, Object.is(value, -0) ? "negative zero" : ""); +} + +function words(n: number): string[] { + const out: string[] = []; + let s = 12345; + for (let i = 0; i < n; i++) { + s = (Math.imul(s, 1103515245) + 12345) | 0; + const len = 1 + ((s >>> 16) & 15); + let w = ""; + for (let j = 0; j < len; j++) { + s = (Math.imul(s, 1103515245) + 12345) | 0; + w += String.fromCharCode(97 + ((s >>> 16) % 26)); + } + out.push(w); + } + out.push("", "é漢字🙂", "x".repeat(300)); + return out; +} + +function bytes(n: number, seed: number): Uint8Array { + const out = new Uint8Array(n); + let s = seed | 0; + for (let i = 0; i < n; i++) { + s = (Math.imul(s, 1103515245) + 12345) | 0; + out[i] = s >>> 24; + } + return out; +} + +// String hashes, as a hash table or an interner computes them. +function djb2(s: string): number { + let h = 5381; + for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; + return h; +} +function javaHash(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0; + return h; +} +function sdbm(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) h = (s.charCodeAt(i) + (h << 6) + (h << 16) - h) | 0; + return h; +} +function fnv1a(s: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619); + return h >>> 0; +} + +// Checksums over bytes. +function rsyncWeak(a: Uint8Array): number { + let s1 = 0, + s2 = 0; + for (let i = 0; i < a.length; i++) { + s1 = (s1 + a[i]) & 0xffff; + s2 = (s2 + s1) & 0xffff; + } + return ((s2 << 16) | s1) >>> 0; +} +function rolling(a: Uint8Array, window: number): number { + // rsync's rolling update: drop the leaving byte, add the entering one. + let s1 = 0, + s2 = 0, + best = 0; + for (let i = 0; i < window; i++) { + s1 = (s1 + a[i]) & 0xffff; + s2 = (s2 + s1) & 0xffff; + } + for (let i = window; i < a.length; i++) { + s1 = (s1 - a[i - window] + a[i]) & 0xffff; + s2 = (s2 - window * a[i - window] + s1) & 0xffff; + const digest = ((s2 << 16) | s1) >>> 0; + if (digest > best) best = digest; + } + return best; +} +function fletcherWrap(a: Uint8Array): number { + let s1 = 0, + s2 = 0; + for (let i = 0; i < a.length; i++) { + s1 = (s1 + a[i]) | 0; + s2 = (s2 + s1) | 0; + } + return (s1 ^ s2) >>> 0; +} +function adler32(a: Uint8Array): number { + // `%` results stay ordinary numbers. + let x = 1, + y = 0; + for (let i = 0; i < a.length; i++) { + x = (x + a[i]) % 65521; + y = (y + x) % 65521; + } + return ((y << 16) | x) >>> 0; +} +function murmur3(a: Uint8Array, seed: number): number { + let h = seed | 0; + const n = a.length & ~3; + for (let i = 0; i < n; i += 4) { + let k = a[i] | (a[i + 1] << 8) | (a[i + 2] << 16) | (a[i + 3] << 24); + k = Math.imul(k, 0xcc9e2d51); + k = (k << 15) | (k >>> 17); + k = Math.imul(k, 0x1b873593); + h ^= k; + h = (h << 13) | (h >>> 19); + h = (Math.imul(h, 5) + 0xe6546b64) | 0; + } + h ^= a.length; + h ^= h >>> 16; + h = Math.imul(h, 0x85ebca6b); + h ^= h >>> 13; + h = Math.imul(h, 0xc2b2ae35); + h ^= h >>> 16; + return h >>> 0; +} +function inet(a: Uint8Array): number { + let sum = 0; + for (let i = 0; i + 1 < a.length; i += 2) sum = (sum + ((a[i] << 8) | a[i + 1])) >>> 0; + while (sum >>> 16) sum = ((sum & 0xffff) + (sum >>> 16)) >>> 0; + return ~sum & 0xffff; +} + +// PRNGs. +function xorshift32(seed: number, n: number): number { + let x = seed | 0, + acc = 0; + for (let i = 0; i < n; i++) { + x ^= x << 13; + x ^= x >>> 17; + x ^= x << 5; + acc = (acc + (x >>> 0)) >>> 0; + } + return acc; +} +function sfc32(a: number, b: number, c: number, d: number, n: number): number { + let total = 0; + for (let i = 0; i < n; i++) { + a |= 0; + b |= 0; + c |= 0; + d |= 0; + const t = (((a + b) | 0) + d) | 0; + d = (d + 1) | 0; + a = b ^ (b >>> 9); + b = (c + (c << 3)) | 0; + c = (c << 21) | (c >>> 11); + c = (c + t) | 0; + total = (total + (t >>> 0)) >>> 0; + } + return total; +} +function mulberry32(seed: number): () => number { + // The state is captured by the generator closure. + let a = seed | 0; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// Counters crossing 2^31 through `| 0`, and uint32 counters above 2^31. +function wrapping(): void { + let c = 2147483645; + const seen: number[] = []; + for (let i = 0; i < 5; i++) { + c = (c + 1) | 0; + seen.push(c); + } + console.log("wrap up", seen.join(" ")); + let d = -2147483646; + seen.length = 0; + for (let i = 0; i < 4; i++) { + d = (d - 1) | 0; + seen.push(d); + } + console.log("wrap down", seen.join(" ")); + let u = 4294967290; + seen.length = 0; + for (let i = 0; i < 8; i++) { + u = (u + 1) >>> 0; + seen.push(u); + } + console.log("uint32 wrap", seen.join(" ")); + let big = 0; + for (let i = 0; i < 40; i++) big = (big + 0x7654321f) >>> 0; + show("uint32 big", big); + show("uint32 half", big / 2); + show("uint32 string", Number(String(big))); +} + +// ++ and -- on int32 counters, read as values. +function counters(limit: number): void { + let up = 0, + down = 10; + const log: string[] = []; + for (let i = 0; i < 3; i++) { + const before = up++; + const after = ++up; + log.push(`${before}/${after}`); + } + while (down > 6) log.push(String(down--)); + let k = 0; + while (k < (limit | 0)) k++; + log.push(String(--k), String(k)); + console.log("counters", log.join(" ")); +} + +// Locals that leave int32 keep their exact values. +function leaves(n: number): void { + let h = 0; + for (let i = 0; i < n; i++) h = (h + i) | 0; + h = h * 1.5; + show("fraction", h); + let z = 7 | 0; + if (n > 0) z = -0; + show("negative zero", z); + let m = 5 | 0; + for (let i = 0; i < n; i++) m = m % 2 === 0 ? m >>> 0 : m | 0; + show("mixed", m); + let both = -1 | 0; + if (n > 2) both = both >>> 0; + show("signed then unsigned", both); + let wide = 1 | 0; + for (let i = 0; i < 40; i++) wide = wide * 2; + show("past 2^32", wide); + let nan = 3 | 0; + if (n > 1) nan = (nan * 0) / 0; + show("nan", nan); + let divided = 1 | 0; + divided = (divided / 3) | 0; + show("divided", 1 / (divided - 0)); +} + +// Captured accumulators: the closure and the function share one binding. +function captured(list: string[]): void { + let h = 17; + const mixIn = (s: string): void => { + for (let i = 0; i < s.length; i++) h = (Math.imul(h, 31) + s.charCodeAt(i)) | 0; + }; + for (const w of list) mixIn(w); + let count = 0; + const bump = (): number => ++count; + for (let i = 0; i < 5; i++) bump(); + const reads: (() => number)[] = []; + for (let i = 0; i < 3; i++) { + let local = (i * 7) | 0; + reads.push(() => local); + local = (local + 1) | 0; + } + console.log("captured", h, count, reads.map((r) => r()).join(" ")); +} + +// Generators and async functions keep their locals in frames. +function* hashes(list: string[]): Generator { + let h = 0; + for (const w of list) { + for (let i = 0; i < w.length; i++) h = (Math.imul(h, 33) ^ w.charCodeAt(i)) | 0; + yield h; + } +} +async function asyncSum(list: string[]): Promise { + let total = 0; + for (const w of list) { + await Promise.resolve(); + total = (total + djb2(w)) | 0; + } + return total; +} + +// Int32 contract locals and parameters. +function contract(seed: Int32, steps: Int32): Int32 { + let acc: Int32 = seed; + for (let i: Int32 = 0; i < steps; i = (i + 1) | 0) acc = (Math.imul(acc, 3) + i) | 0; + return acc; +} + +const list = words(400); +let a = 0, + b = 0, + c = 0, + d = 0; +for (const w of list) { + a = (a + djb2(w)) | 0; + b = (b ^ javaHash(w)) | 0; + c = (c + sdbm(w)) | 0; + d = (d ^ fnv1a(w)) >>> 0; +} +console.log("string hashes", a, b, c, d); +console.log("samples", djb2("hello"), javaHash("hello"), sdbm("hello"), fnv1a("hello")); +console.log("empty", djb2(""), javaHash(""), sdbm(""), fnv1a("")); + +const data = bytes(1 << 14, 7); +console.log("rsync", rsyncWeak(data), rolling(data, 64)); +console.log("fletcher", fletcherWrap(data), "adler32", adler32(data)); +console.log("murmur3", murmur3(data, 0), murmur3(data, -1), murmur3(data.subarray(0, 7), 42)); +console.log("inet", inet(data), inet(new Uint8Array([0xff, 0xff, 0xff, 0xff, 0x01]))); + +console.log("xorshift32", xorshift32(2463534242, 1000), xorshift32(1, 1)); +console.log("sfc32", sfc32(1, 2, 3, 4, 10000), sfc32(-1, 0x7fffffff, -2147483648, 0, 3)); +const next = mulberry32(42); +const draws: string[] = []; +for (let i = 0; i < 4; i++) draws.push(next().toFixed(6)); +console.log("mulberry32", draws.join(" ")); + +wrapping(); +counters(5); +leaves(4); +leaves(0); +captured(list.slice(0, 50)); +console.log("generator", [...hashes(["ab", "cd", "efg"])].join(" ")); +console.log("contract", contract(7, 20), contract(-2147483648, 3)); +asyncSum(list).then((total) => console.log("async", total)); From add07af8074714cc214ccbc70fd8308beceb5236 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 13 Oct 2026 01:36:52 -0500 Subject: [PATCH 2/3] Find escaping functions with typed IR walks --- .../src/backend/llvm/constant-callbacks.ts | 32 +- .../src/ir/function-references.test.ts | 340 ++++++++++++++++++ .../compiler/src/ir/function-references.ts | 26 ++ packages/compiler/src/ir/int32-slots.ts | 83 +++-- 4 files changed, 414 insertions(+), 67 deletions(-) create mode 100644 packages/compiler/src/ir/function-references.test.ts create mode 100644 packages/compiler/src/ir/function-references.ts diff --git a/packages/compiler/src/backend/llvm/constant-callbacks.ts b/packages/compiler/src/backend/llvm/constant-callbacks.ts index 17c97edb1..31e2f74db 100644 --- a/packages/compiler/src/backend/llvm/constant-callbacks.ts +++ b/packages/compiler/src/backend/llvm/constant-callbacks.ts @@ -1,5 +1,6 @@ import { isBorrowSafeMath } from "../../ir/analysis.js"; import type { IrExpr, IrFunction, IrLibFn, IrModule, IrStmt } from "../../ir/ir.js"; +import { exprFunctionReference, moduleFunctionReferences } from "../../ir/function-references.js"; import { everyStmtList } from "../../ir/traverse.js"; import type { CallLifetimes } from "./call-lifetimes.js"; @@ -38,31 +39,14 @@ export function findConstantCallbacks( ); const globals = new Map(); const writes = new Set(); - const escaped = new Set([ - mod.entry, - ...(mod.lib?.exports.map((entry) => entry.fnName) ?? []), - ]); + // A function named anywhere but as a direct call's callee can be entered + // by a generated adapter or the runtime, which a direct-call census cannot + // specialize: the entry, exports, class helpers and methods here, and + // closures and retag helpers in the bodies (collected by the walk below). + const escaped = new Set(moduleFunctionReferences(mod)); for (const cls of mod.classes ?? []) for (const method of ["constructor", ...(cls.methods ?? [])]) escaped.add(`%${cls.name}.${method}`); - // Metadata and non-call expression references can expose an entry to a - // generated adapter or runtime callback. A direct-call census cannot - // specialize those entries. Conservatively reject every such reference, - // including future metadata that names a function. - function externalReferences(value: unknown): void { - if (typeof value === "string") { - if (functions.has(value)) escaped.add(value); - } else if (Array.isArray(value)) value.forEach(externalReferences); - else if (value && typeof value === "object") { - const object = value as Record; - for (const key in object) { - if (object["kind"] === "call" && key === "callee") continue; - externalReferences(object[key]); - } - } - } - const { functions: _functions, ...metadata } = mod; - externalReferences(metadata); const incoming = new Map(); // Where each lifted function's environment is created, for resolving its // immutable captures in the creating scope. @@ -100,8 +84,9 @@ export function findConstantCallbacks( } if (expr.kind === "callValue" && expr.callee.kind === "varRef") callees.add(expr.callee.localId); + const referenced = exprFunctionReference(expr); + if (referenced !== undefined) escaped.add(referenced); if (expr.kind === "closure") { - escaped.add(expr.fnName); const sites = closureSites.get(expr.fnName) ?? []; sites.push({ owner: fn.name, captures: expr.captures }); closureSites.set(expr.fnName, sites); @@ -111,7 +96,6 @@ export function findConstantCallbacks( return true; }, }); - externalReferences(fn.body); definitions.set(fn.name, defs); constDefinitions.set(fn.name, consts); indirectCallees.set(fn.name, callees); diff --git a/packages/compiler/src/ir/function-references.test.ts b/packages/compiler/src/ir/function-references.test.ts new file mode 100644 index 000000000..fa0e64fc5 --- /dev/null +++ b/packages/compiler/src/ir/function-references.test.ts @@ -0,0 +1,340 @@ +import { expect, test } from "vitest"; +import { exprFunctionReference, moduleFunctionReferences } from "./function-references.js"; +import { functionCallFacts } from "./int32-slots.js"; +import { + F64, + VOID, + type IrExpr, + type IrFunction, + type IrModule, + type IrStmt, + type IrType, + type SrcLoc, +} from "./ir.js"; +import { everyStmtList } from "./traverse.js"; + +/* Every plain `string` field of the IR, classified. The analyses that ask + * which functions escape (constant-callbacks.ts, int32-slots.ts) read only + * the fields classified "reference" here, through function-references.ts. + * A new string field anywhere in the IR fails to compile until it is + * classified, and a new "reference" fails the tests below until the shared + * helpers report it. */ +type Role = + /** Names a module function as a value or a runtime entry. */ + | "reference" + /** A direct call's callee. */ + | "callee" + /** A class method name: the function is `%.`. */ + | "method" + /** A verified contract fact about a function, not a way to reach it. */ + | "fact" + /** Never a module function's name. */ + | "other"; + +type Leaf = IrExpr | IrStmt | IrType | SrcLoc; +type PathsOf = V extends string + ? string extends V + ? P + : never + : V extends readonly (infer E)[] + ? PathsOf + : V extends Leaf + ? never + : V extends object + ? StringFields + : never; +type StringFields = { + [K in keyof T & string]-?: PathsOf, `${P}${K}`>; +}[keyof T & string]; +type Classified = { + [K in U["kind"] as [StringFields>] extends [never] ? never : K]: Record< + StringFields>, + Role + >; +}; + +const EXPR_FIELDS: Classified = { + numLit: { spelling: "other" }, + strLit: { value: "other" }, + moduleNsRef: { moduleId: "other" }, + varRef: { localId: "other" }, + incDec: { localId: "other" }, + fieldIncDec: { className: "other", field: "other" }, + assignExpr: { localId: "other" }, + orDefault: { retag: "reference" }, + optChain: { id: "other" }, + chainRecv: { id: "other" }, + regexLit: { pattern: "other", flags: "other" }, + templateStrings: { key: "other", "cooked[]": "other" }, + call: { callee: "callee" }, + ffiCall: { import: "other" }, + closure: { fnName: "reference", "captures[]": "other" }, + new: { className: "other" }, + classRef: { className: "other", "captures[]": "other" }, + instanceOf: { className: "other" }, + virtualCall: { className: "other", method: "method" }, + fieldGet: { className: "other", field: "other" }, + recordLit: { "fields[].name": "other" }, + recordClone: { "overrides[].name": "other" }, + recordGet: { shapeId: "other", field: "other" }, + recordKeyGet: { shapeId: "other" }, + dynFrom: { fnName: "other" }, + dynCall: { calleeName: "other", "spreads[].what": "other" }, + dynInvoke: { method: "other", calleeName: "other" }, + dynHasKey: { key: "other" }, + dynDestrCheck: { spelling: "other", firstProp: "other" }, + dynIterN: { notIterableMessage: "other" }, + recordOvfKeys: { shapeId: "other" }, + recordOvfHas: { shapeId: "other" }, + recordHas: { shapeId: "other", field: "other" }, + fieldAbsent: { unionId: "other" }, + unionWrap: { unionId: "other" }, + unionRetype: { unionId: "other", fromId: "other" }, + unionFuncEq: { unionId: "other" }, + caughtTest: { className: "other" }, + caughtCheck: { className: "other" }, + unionNarrow: { unionId: "other" }, + unionDisc: { unionId: "other", field: "other" }, + unionKeyGet: { unionId: "other" }, + unionIsTag: { unionId: "other" }, + unionEq: { unionId: "other" }, + jsonStringify: { indent: "other" }, +}; + +const STMT_FIELDS: Classified = { + varDecl: { localId: "other" }, + assign: { localId: "other" }, + while: { "labels[]": "other" }, + doWhile: { "labels[]": "other" }, + switch: { "labels[]": "other" }, + for: { "labels[]": "other" }, + forOf: { localId: "other", "labels[]": "other" }, + fieldSet: { className: "other", field: "other" }, + recordSet: { shapeId: "other", field: "other" }, + recordKeySet: { shapeId: "other" }, + recordKeyDelete: { shapeId: "other" }, + break: { label: "other" }, + continue: { label: "other" }, + block: { "labels[]": "other" }, + runtimeFence: { code: "other", message: "other" }, + rethrow: { localId: "other" }, + tryCatch: { catchLocalId: "other" }, +}; + +const TYPE_FIELDS: Classified = { + object: { className: "other" }, + record: { shapeId: "other" }, + classval: { className: "other" }, + moduleNs: { moduleId: "other" }, + union: { unionId: "other" }, +}; + +const FUNCTION_FIELDS: Record>, Role> = { + name: "other", + sourceName: "other", + asyncCacheGlobal: "other", + asyncCycleCacheGlobal: "other", + "params[].name": "other", + "params[].localId": "other", + "locals[].name": "other", + "locals[].id": "other", + "captures[].name": "other", + "captures[].localId": "other", + "classCaptures[].name": "other", + "classCaptures[].localId": "other", +}; + +const MODULE_FIELDS: Record>, Role> = { + sourceFile: "other", + entry: "reference", + "classes[].name": "other", + "classes[].base": "other", + "classes[].jsName": "other", + "classes[].genericOf": "other", + "classes[].baseValueGlobal": "other", + "classes[].prototypeDataHelper": "reference", + "classes[].instancePrototypeHelper": "reference", + "classes[].localCaptures[].name": "other", + "classes[].localCaptures[].localId": "other", + "classes[].symbolFields[].field": "other", + "classes[].symbolFields[].globalId": "other", + "classes[].fields[].name": "other", + "classes[].methods[]": "method", + "classes[].abstractMethods[]": "method", + "globals[].id": "other", + "globals[].name": "other", + "globals[].initFlag": "other", + "records[].id": "other", + "records[].fields[].name": "other", + "records[].declaredOrder[]": "other", + "unions[].id": "other", + "unions[].discriminant.field": "other", + "unions[].discriminant.cases[].values[]": "other", + "ffiImports[].name": "other", + "ffiImports[].symbol": "other", + "ffiImports[].library": "other", + "ffiImports[].callbackTarget": "other", + "ffiImports[].params[].context": "other", + "ffiImports[].params[].callback.id": "other", + "ffiImports[].params[].callback.release": "other", + "ffiImports[].params[].callback.params[].context": "other", + "lib.profileName": "other", + "lib.prefix": "other", + "lib.initSymbol": "other", + "lib.sinkRegisterSymbol": "other", + "lib.collectSymbol": "other", + "lib.resultResetSymbol": "other", + "lib.callbackRegisterSymbol": "other", + "lib.callbacks[].name": "other", + "lib.callbacks[].unregisteredTrap": "other", + "lib.exports[].symbol": "other", + "lib.exports[].fnName": "reference", + "lib.exports[].inboundBytesTrap": "other", + "lib.exports[].inboundIntTrap": "other", + "lib.trapOverlays[].code": "other", + "lib.trapOverlays[].teaching": "other", + "lib.trapOverlays[].remediation": "other", + "lib.identity.buildIdSymbol": "other", + "lib.identity.abiVersionSymbol": "other", + "lib.identity.buildId": "other", + "contracts.int32Fields[]": "other", + "contracts.int32Params[]": "other", + "contracts.int32Returns[]": "fact", + "contracts.int32Locals[]": "other", + "contracts.noThrow[]": "fact", + "contracts.acyclic[]": "other", + "contracts.immortalGlobals[]": "other", + "contracts.programLifetime[]": "other", +}; + +const loc = { file: "references.ts", start: 0, end: 0 }; +const TARGET = "%m0.target"; +const target: IrFunction = { + name: TARGET, + params: [{ localId: "n", name: "n", type: F64 }], + locals: [{ id: "n", name: "n", type: F64, mutable: true }], + returnType: VOID, + body: [], + loc, +}; +const entry = (body: IrStmt[] = []): IrFunction => ({ + name: "%main", + params: [], + locals: [], + returnType: VOID, + body, + loc, +}); +const module = (body: IrStmt[] = []): IrModule => ({ + irVersion: 16, + sourceFile: loc.file, + entry: "%main", + functions: [target, entry(body)], +}); +const exprStmt = (expr: IrExpr): IrStmt => ({ kind: "exprStmt", expr, loc }); + +/** A module whose only mention of TARGET, other than its definition, is + * through the given reference field. */ +const REFERENCE_FIXTURES: Record IrModule> = { + "closure.fnName": () => + module([ + exprStmt({ + kind: "closure", + fnName: TARGET, + captures: [], + type: { kind: "func", params: [F64], ret: VOID }, + loc, + }), + ]), + "orDefault.retag": () => + module([ + exprStmt({ + kind: "orDefault", + left: { kind: "numLit", value: 0, type: F64, loc }, + right: { kind: "numLit", value: 1, type: F64, loc }, + retag: TARGET, + type: F64, + loc, + }), + ]), + entry: () => ({ ...module(), entry: TARGET }), + "classes[].prototypeDataHelper": () => ({ + ...module(), + classes: [{ name: "%m0.Cell", fields: [], prototypeDataHelper: TARGET, loc }], + }), + "classes[].instancePrototypeHelper": () => ({ + ...module(), + classes: [{ name: "%m0.Cell", fields: [], instancePrototypeHelper: TARGET, loc }], + }), + "lib.exports[].fnName": () => ({ + ...module(), + lib: { + profileName: "references", + prefix: "ref_", + initSymbol: "ref_init", + sinkRegisterSymbol: "ref_sink", + collectSymbol: null, + resultResetSymbol: null, + threadInstances: false, + exports: [{ symbol: "ref_target", fnName: TARGET, params: ["f64"], returns: "void" }], + trapOverlays: [], + }, + }), +}; + +function referencesOf(mod: IrModule): Set { + const names = new Set(moduleFunctionReferences(mod)); + for (const fn of mod.functions) + everyStmtList(fn.body, { + expr: (expr) => { + const name = exprFunctionReference(expr); + if (name !== undefined) names.add(name); + return true; + }, + stmt: () => true, + }); + return names; +} + +const referencePaths = (table: object, prefix: string): string[] => + Object.entries(table).flatMap(([key, value]) => + typeof value === "string" + ? value === "reference" + ? [prefix + key] + : [] + : referencePaths(value as object, `${key}.`), + ); + +test("every IR field that can name a function has a reference fixture", () => { + const references = [ + ...referencePaths(EXPR_FIELDS, ""), + ...referencePaths(STMT_FIELDS, ""), + ...referencePaths(TYPE_FIELDS, ""), + ...referencePaths(FUNCTION_FIELDS, "functions[]."), + ...referencePaths(MODULE_FIELDS, ""), + ]; + expect(references.sort()).toEqual(Object.keys(REFERENCE_FIXTURES).sort()); +}); + +test.each(Object.keys(REFERENCE_FIXTURES))("%s escapes its function", (path) => { + const mod = REFERENCE_FIXTURES[path]!(); + expect(referencesOf(mod).has(TARGET)).toBe(true); + const facts = functionCallFacts(mod, new Map(), (name) => [name]); + expect(facts.valueEscaped.has(TARGET)).toBe(true); +}); + +test("direct calls and look-alike strings do not escape a function", () => { + const mod = module([ + exprStmt({ kind: "call", callee: TARGET, args: [], type: VOID, loc }), + exprStmt({ kind: "strLit", value: TARGET, type: { kind: "string" }, loc }), + exprStmt({ + kind: "dynFrom", + value: { kind: "numLit", value: 0, type: F64, loc }, + fnName: TARGET, + type: { kind: "dyn" }, + loc, + }), + ]); + expect(referencesOf(mod).has(TARGET)).toBe(false); + expect(functionCallFacts(mod, new Map(), (name) => [name]).valueEscaped.has(TARGET)).toBe(false); +}); diff --git a/packages/compiler/src/ir/function-references.ts b/packages/compiler/src/ir/function-references.ts new file mode 100644 index 000000000..0342172b6 --- /dev/null +++ b/packages/compiler/src/ir/function-references.ts @@ -0,0 +1,26 @@ +import type { IrExpr, IrModule } from "./ir.js"; + +/** The function an expression names as a value rather than as a direct + * call's callee: a closure's code, or the union→union retag helper an + * `orDefault` hands its left box to. These are the only expression fields + * that hold a module function's name besides `call.callee` + * (function-references.test.ts classifies every string field of the IR). */ +export function exprFunctionReference(expr: IrExpr): string | undefined { + if (expr.kind === "closure") return expr.fnName; + if (expr.kind === "orDefault") return expr.retag; + return undefined; +} + +/** Functions the module's metadata names: the entry, library exports and + * the class prototype helpers the runtime calls. Class methods and + * constructors follow the `%.` convention instead of being + * named here; callers that need them derive them from `mod.classes`. */ +export function moduleFunctionReferences(mod: IrModule): string[] { + const names = [mod.entry]; + for (const entry of mod.lib?.exports ?? []) names.push(entry.fnName); + for (const cls of mod.classes ?? []) { + if (cls.prototypeDataHelper !== undefined) names.push(cls.prototypeDataHelper); + if (cls.instancePrototypeHelper !== undefined) names.push(cls.instancePrototypeHelper); + } + return names; +} diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index b25f3dc14..e4311e58c 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -7,8 +7,9 @@ import { type IntegerRanges, type IntegerSlotFacts, } from "./integer-ranges.js"; +import { exprFunctionReference, moduleFunctionReferences } from "./function-references.js"; import { findInitializerBindings, withInitializerBindings } from "./initializer-bindings.js"; -import { everyExprChild, everyStmtChild, everyTypeChild } from "./traverse.js"; +import { everyExprChild, everyStmtChild, everyStmtList, everyTypeChild } from "./traverse.js"; /** Whole-program int32 specialization of numeric storage. * @@ -265,18 +266,6 @@ const DYN_READ_ONLY = new Set([ "text.decodeStream", "worker.new", ]); -/** IR metadata keys whose strings never name a function. */ -const NON_REFERENCE_KEYS = new Set([ - "loc", - "source", - "name", - "sourceName", - "jsName", - "localId", - "type", - "returnType", -]); - function walkFunction( fn: IrFunction, visit: { expr?: (e: IrExpr) => void; stmt?: (s: IrStmt) => void }, @@ -480,36 +469,8 @@ export function functionCallFacts( virtualTargets: Set; runtimeVirtual: (name: string) => boolean; } { - const functions = new Map(mod.functions.map((fn) => [fn.name, fn])); - const escaped = new Set(); + const escaped = new Set(moduleFunctionReferences(mod)); const virtualTargets = new Set(); - const seenObjects = new Set(); - const scan = ( - value: unknown, - key: string | null, - parent: Record | null, - ): void => { - if (typeof value === "string") { - if (functions.has(value)) { - const direct = - key === "callee" && parent?.kind === "call" - ? true - : key === "value" && parent?.kind === "strLit"; - if (!direct) escaped.add(value); - } - return; - } - if (value === null || typeof value !== "object" || seenObjects.has(value)) return; - seenObjects.add(value); - if (Array.isArray(value)) { - for (const item of value) scan(item, null, null); - return; - } - const record = value as Record; - for (const [k, v] of Object.entries(record)) if (!NON_REFERENCE_KEYS.has(k)) scan(v, k, record); - }; - scan(mod, null, null); - if (typeof mod.entry === "string") escaped.add(mod.entry); // Virtual dispatch: a `virtualCall` can reach the method's implementation // in any class of the receiver's subtree or the one it inherits. Classes // under a runtime-provided root keep every method escaped: the runtime @@ -525,6 +486,8 @@ export function functionCallFacts( for (const fn of mod.functions) walkFunction(fn, { expr: (e) => { + const referenced = exprFunctionReference(e); + if (referenced !== undefined) escaped.add(referenced); if (e.kind !== "virtualCall") return; for (const name of subtree(e.className)) virtualTargets.add(`%${name}.${e.method}`); for (let c = classes.get(e.className); c; c = c.base ? classes.get(c.base) : undefined) @@ -825,13 +788,47 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots { queue.push(fnName); } }; + const candidateArgs = (callee: IrFunction | undefined, args: number, offset: number): boolean => { + if (!callee) return false; + for (let i = 0; i < args; i++) { + const param = callee.params[i + offset]; + if (param && slots.hasParam(callee.name, param.localId)) return true; + } + return false; + }; + const hasCandidateWrite = (fn: IrFunction): boolean => { + const box = (localId: string): boolean => slots.hasBox(slots.boxGroup(fn.name, localId)); + const returns = slots.hasReturn(fn.name); + return !everyStmtList(fn.body, { + stmt: (s) => { + if (s.kind === "assign") return !box(s.localId); + if (s.kind === "varDecl") return s.init === null || !box(s.localId); + if (s.kind === "fieldSet") { + const family = slots.family(s.className, s.field); + return !family || !slots.hasFamily(family); + } + return !(s.kind === "return" && returns); + }, + expr: (e) => { + if (e.kind === "call") return !candidateArgs(functions.get(e.callee), e.args.length, 0); + if (e.kind === "new") + return !candidateArgs(functions.get(`%${e.className}.constructor`), e.args.length, 1); + if (e.kind === "assignExpr") return !box(e.localId); + return true; + }, + }); + }; const provenByFunction = new Map(); while (queue.length) { const fn = functions.get(queue.pop()!)!; queued.delete(fn.name); const params = slots.facts(fn.name).params; if (params) for (const id of params.keys()) reads(`param:${fn.name}\0${id}`, fn.name); - const ranges: IntegerRanges = analyzeIntegerRanges(numeric(fn), slots.facts(fn.name)); + // Range facts are consulted only at writes into candidate slots; slots + // are only ever dropped, so a body without such a write needs none. + const ranges: IntegerRanges = hasCandidateWrite(fn) + ? analyzeIntegerRanges(numeric(fn), slots.facts(fn.name)) + : new Map(); const proven: IrExpr[] = []; const int32 = (e: IrExpr): boolean => withinInt32(ranges.get(e)); const args = (callee: IrFunction | undefined, list: IrExpr[], offset: number): void => { From 5df589706b9fe843d5cac145f62938825544bad2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 13 Oct 2026 02:13:46 -0500 Subject: [PATCH 3/3] Skip array state bytes below a hole-free prefix --- docs/content/docs/cli.mdx | 2 +- docs/content/docs/how-it-works.mdx | 2 +- docs/content/docs/native-objects.mdx | 2 +- examples/native-object/README.md | 2 +- packages/cli/README.md | 2 +- packages/cli/test/native-link-info.test.ts | 4 +- packages/compiler/src/backend/llvm/common.ts | 12 +- .../backend/llvm/dense-array-access.test.ts | 50 +++++ .../src/backend/llvm/dense-array-access.ts | 196 +++++++++++++---- packages/compiler/src/backend/llvm/dyn.ts | 1 + packages/compiler/src/backend/llvm/emitter.ts | 14 +- .../src/backend/llvm/expr-stream-bridges.ts | 1 + .../src/backend/llvm/local-array-reads.ts | 22 +- packages/compiler/src/backend/llvm/shapes.ts | 4 +- packages/compiler/src/backend/runtime-abi.ts | 4 +- .../src/backend/runtime-pack-targets.test.ts | 2 +- .../compiler/src/backend/runtime-pack.test.ts | 2 +- .../test/native-codegen-integration.test.ts | 4 +- .../4840-array-hole-free-prefix.ts.json | 6 + .../runtime-darwin-arm64/scripts/build.mjs | 2 +- .../runtime-pack-common/scripts/build.mjs | 2 +- packages/runtime/src/scr_array.c | 98 ++++++++- packages/runtime/src/scr_console.c | 2 +- packages/runtime/src/scr_runtime.h | 47 ++++- packages/runtime/test/test_array.c | 81 +++++++ tests/corpus/4840-array-hole-free-prefix.ts | 199 ++++++++++++++++++ .../self-hosting-native-toolchain.test.ts | 2 +- 27 files changed, 676 insertions(+), 89 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4840-array-hole-free-prefix.ts.json create mode 100644 tests/corpus/4840-array-hole-free-prefix.ts diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index 56d20d1f0..b5a3bad70 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -53,7 +53,7 @@ fib.s Different output kinds accumulate in `.scriptc/`; rebuilding a kind updates its file. -The IR and LLVM source outputs require only Node. On supported macOS, Linux, and Windows hosts, assembly and object outputs use the matching native helper installed with scriptc and do not invoke an external compiler, archiver, linker, or SDK. The object is a relocatable program object with undefined scr_* runtime symbols and a required scr_runtime_abi_v14 marker, not a standalone library. External consumption is experimental and requires the exact runtime version reported by --print=native-link-info. That option still writes the object, performs no link, and prints a versioned JSON recipe with the target, main entry, installed precompiled runtime pack, FFI inputs, and system libraries. It never reports private scriptc cache paths. See Native Program Objects for complete C-driver and direct-linker examples. --emit=exe is the default. LLVM builds emit the program object through the helper and link precompiled runtime objects; runtime development with AddressSanitizer compiles an instrumented C runtime. +The IR and LLVM source outputs require only Node. On supported macOS, Linux, and Windows hosts, assembly and object outputs use the matching native helper installed with scriptc and do not invoke an external compiler, archiver, linker, or SDK. The object is a relocatable program object with undefined scr_* runtime symbols and a required scr_runtime_abi_v15 marker, not a standalone library. External consumption is experimental and requires the exact runtime version reported by --print=native-link-info. That option still writes the object, performs no link, and prints a versioned JSON recipe with the target, main entry, installed precompiled runtime pack, FFI inputs, and system libraries. It never reports private scriptc cache paths. See Native Program Objects for complete C-driver and direct-linker examples. --emit=exe is the default. LLVM builds emit the program object through the helper and link precompiled runtime objects; runtime development with AddressSanitizer compiles an instrumented C runtime. ## scriptc run diff --git a/docs/content/docs/how-it-works.mdx b/docs/content/docs/how-it-works.mdx index d08405266..65bfbf442 100644 --- a/docs/content/docs/how-it-works.mdx +++ b/docs/content/docs/how-it-works.mdx @@ -68,7 +68,7 @@ Without `-o`, artifacts are written to `.scriptc/` next to the input. Different ### External linking -Program objects define `main` and leave selected `scr_*` runtime functions undefined. A strong reference to `scr_runtime_abi_v14` checks runtime compatibility at link time. +Program objects define `main` and leave selected `scr_*` runtime functions undefined. A strong reference to `scr_runtime_abi_v15` checks runtime compatibility at link time. `--print=native-link-info` emits the program object and a JSON recipe containing the runtime pack and link order for external builds. This object ABI is experimental and requires an exact runtime version. See [Native Program Objects](/docs/native-objects) for examples. diff --git a/docs/content/docs/native-objects.mdx b/docs/content/docs/native-objects.mdx index bd993ef62..ce1fcd53d 100644 --- a/docs/content/docs/native-objects.mdx +++ b/docs/content/docs/native-objects.mdx @@ -8,7 +8,7 @@ title: "Native Program Objects" The external object ABI is **experimental**. Its `scr_*` function and data surface may change before 1.0, so consumers must use the exact runtime-pack version reported by the same compiler installation. This is stricter than semver compatibility. -The object intentionally leaves its selected runtime symbols undefined. It also holds a strong reference to `scr_runtime_abi_v14`, which the matching runtime defines. Linking an object against a runtime with another ABI marker fails at link time with the missing versioned symbol; it cannot become a latent runtime incompatibility. +The object intentionally leaves its selected runtime symbols undefined. It also holds a strong reference to `scr_runtime_abi_v15`, which the matching runtime defines. Linking an object against a runtime with another ABI marker fails at link time with the missing versioned symbol; it cannot become a latent runtime incompatibility. ## Machine-readable link information diff --git a/examples/native-object/README.md b/examples/native-object/README.md index 80105456f..21673ef88 100644 --- a/examples/native-object/README.md +++ b/examples/native-object/README.md @@ -15,4 +15,4 @@ $ ./app-ld `cc` uses the C compiler as a linker driver. `ld` links the same precompiled runtime objects directly with the selected Apple SDK. The object defines `main`; it is a complete program object, not a library to load into another process. Use `scriptc build --lib --profile ...` for a host-callable static library. -The external object ABI is experimental. Always consume the runtime pack at the exact `runtime_pack.version` reported by the same scriptc installation. The object requires `scr_runtime_abi_v14`, so a mismatched runtime fails during the link instead of starting with an incompatible ABI. +The external object ABI is experimental. Always consume the runtime pack at the exact `runtime_pack.version` reported by the same scriptc installation. The object requires `scr_runtime_abi_v15`, so a mismatched runtime fails during the link instead of starting with an incompatible ABI. diff --git a/packages/cli/README.md b/packages/cli/README.md index ab34ad6af..138eb09b0 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -34,7 +34,7 @@ Builds use a bounded persistent cache. Unchanged source can reuse the validated - `scriptc run ` — compile and run - `scriptc coverage ` — what compiles statically, and why the rest doesn't -`scriptc build app.ts --emit=ir|llvm|asm|obj` selects serialized typed IR, textual LLVM IR, target assembly, or a relocatable program object as the one primary artifact. `--emit=exe` is the default. Assembly/object emission uses the matching helper on supported macOS, Linux, and Windows hosts (and produces WASI artifacts when that target is selected). Objects retain undefined `scr_*` runtime references plus the `scr_runtime_abi_v14` compatibility marker; they are not library archives. External consumption is experimental and requires the exact matching runtime. `scriptc build app.ts --print=native-link-info -o app.o` prints the versioned JSON target/runtime/link recipe without performing a link. `--emit=asm|obj --sanitize` is rejected until ASan pipeline parity is available. +`scriptc build app.ts --emit=ir|llvm|asm|obj` selects serialized typed IR, textual LLVM IR, target assembly, or a relocatable program object as the one primary artifact. `--emit=exe` is the default. Assembly/object emission uses the matching helper on supported macOS, Linux, and Windows hosts (and produces WASI artifacts when that target is selected). Objects retain undefined `scr_*` runtime references plus the `scr_runtime_abi_v15` compatibility marker; they are not library archives. External consumption is experimental and requires the exact matching runtime. `scriptc build app.ts --print=native-link-info -o app.o` prints the versioned JSON target/runtime/link recipe without performing a link. `--emit=asm|obj --sanitize` is rejected until ASan pipeline parity is available. For embedder-hosted modules that are not installed npm packages, coverage can map an exact bare specifier to a local declaration with repeatable `--external-types ` options. This is analysis-only: the types unblock application measurement, while runtime module uses remain reported as blockers. diff --git a/packages/cli/test/native-link-info.test.ts b/packages/cli/test/native-link-info.test.ts index bb41f8520..a497eeac8 100644 --- a/packages/cli/test/native-link-info.test.ts +++ b/packages/cli/test/native-link-info.test.ts @@ -72,7 +72,7 @@ describe.runIf(supported)("macOS arm64 native link info", () => { minimum_os: "14.0", }, program: { object, entry_symbol: "main" }, - runtime_abi: { version: 14, marker: "scr_runtime_abi_v14" }, + runtime_abi: { version: 15, marker: "scr_runtime_abi_v15" }, runtime_pack: { kind: "precompiled", package: "@scriptc/runtime-darwin-arm64", @@ -165,6 +165,6 @@ describe.runIf(supported)("macOS arm64 native link info", () => { () => null, (failure: { stderr?: string }) => failure, ); - expect(error?.stderr).toContain("scr_runtime_abi_v14"); + expect(error?.stderr).toContain("scr_runtime_abi_v15"); }); }); diff --git a/packages/compiler/src/backend/llvm/common.ts b/packages/compiler/src/backend/llvm/common.ts index c409fb87b..013d7a8ea 100644 --- a/packages/compiler/src/backend/llvm/common.ts +++ b/packages/compiler/src/backend/llvm/common.ts @@ -10,17 +10,19 @@ import type { import type { LlvmEmitterContext } from "./expr-context.js"; /** Field numbers of %ScrArr, which mirrors scr_runtime.h's ScrArr field for - * field (its static assertions pin `cyc` and `data`). */ + * field (its static assertions pin `cyc`, `filled` and `data`). */ export const SCR_ARR_FIELD = { rc: 0, len: 1, cap: 2, elem: 3, cyc: 4, - ops: 5, - data: 6, - present: 7, - ext: 8, + spare: 5, + filled: 6, + ops: 7, + data: 8, + present: 9, + ext: 10, } as const; /** Lines that decide whether the double `index` is an integer in diff --git a/packages/compiler/src/backend/llvm/dense-array-access.test.ts b/packages/compiler/src/backend/llvm/dense-array-access.test.ts index bf9eb3118..df771a1d2 100644 --- a/packages/compiler/src/backend/llvm/dense-array-access.test.ts +++ b/packages/compiler/src/backend/llvm/dense-array-access.test.ts @@ -152,6 +152,56 @@ test.each([32, 64] as const)( }, ); +test.each([32, 64] as const)( + "indices below the hole-free prefix bound skip the state bytes (%i-bit)", + (bits) => { + const read: IrExpr = { kind: "arrayGet", arr: ref("a", nums), index, type: F64, loc }; + const llvm = emitLlvmModule( + module("bound", VOID, [ + { + kind: "arraySet", + arr: ref("a", nums), + index, + value: { kind: "bin", op: "+", left: read, right: read, type: F64, loc }, + loc, + }, + { + kind: "arraySet", + arr: ref("s", words), + index, + value: { kind: "strLit", value: "x", type: STRING, loc }, + loc, + }, + ]), + { pointerBits: bits }, + ); + const fn = body(llvm, "bound"); + const size = bits === 32 ? "i32" : "i64"; + // The bound is %ScrArr field 6, an i32 compared against the index. + const bound = fn.match(/getelementptr inbounds %ScrArr, ptr .*, i32 0, i32 6$/gm) ?? []; + expect(bound.length).toBeGreaterThanOrEqual(4); + expect(fn).toMatch(/load i32, ptr /); + // The prefix blocks load or store the slot without a state access. + const blocks = (label: string): string[] => + [...fn.matchAll(new RegExp(`^${label}\\d+:\\n((?: .*\\n)*)`, "gm"))].map((m) => m[1]!); + const reads = blocks("arr\\.get\\.value"); + expect(reads).toHaveLength(2); + for (const block of reads) { + expect(block).toMatch(/load double, ptr /); + expect(block).not.toMatch(/i8/); + } + const writes = blocks("arr\\.set\\.filled"); + expect(writes).toHaveLength(2); + for (const block of writes) expect(block).not.toMatch(/i8/); + expect(writes[0]).toMatch(/store double .*, ptr /); + // The replaced string below the bound is released after the store. + expect(writes[1]).toMatch(/store ptr .*\n[^]*call void @scr_str_release\(/); + // A state-checked store at the bound extends it. + expect(fn).toMatch(new RegExp(`icmp eq ${size} .*`)); + expect(fn).toMatch(/store i32 .*, ptr /); + }, +); + test("state and presence queries answer dense slots inline", () => { const state: IrExpr = { kind: "arrayState", arr: ref("b", flags), index, type: F64, loc }; const has: IrExpr = { kind: "arrayHas", arr: ref("s", words), index, type: BOOL, loc }; diff --git a/packages/compiler/src/backend/llvm/dense-array-access.ts b/packages/compiler/src/backend/llvm/dense-array-access.ts index dd0c20588..c42a4c0f5 100644 --- a/packages/compiler/src/backend/llvm/dense-array-access.ts +++ b/packages/compiler/src/backend/llvm/dense-array-access.ts @@ -8,10 +8,16 @@ * `undefined`, sparse indices, noncanonical numeric properties (negative, * fractional, NaN, infinite, 2^32-1), and out-of-range reads branch to the * unchanged runtime call, which is marked cold. The ScrArr field indices - * (SCR_ARR_FIELD) match the C layout in scr_runtime.h. */ + * (SCR_ARR_FIELD) match the C layout in scr_runtime.h. + * + * Indices below the array's hole-free prefix bound (`filled`) skip the + * state bytes altogether: the runtime keeps every state below it a present + * value, so reads load the slot directly and writes replace it without + * touching its state. Other dense indices take the state-checked path, + * whose writes extend the bound when they continue it. */ import { isRefCounted, type IrExpr, type IrType } from "../../ir/ir.js"; -import { SCR_ARR_FIELD } from "./common.js"; +import { f64Lit, SCR_ARR_FIELD } from "./common.js"; import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; import { exactInteger, widenInteger } from "./integer-values.js"; @@ -28,6 +34,8 @@ interface DenseGuard { offset: string; /** The array length loaded by the guard, in the target's size type. */ len: string; + /** The hole-free prefix bound loaded by the guard (i32). */ + filled: string; /** Dense value and state storage base pointers. */ data: string; states: string; @@ -36,7 +44,10 @@ interface DenseGuard { /** Branch to `slow` unless `index` names a canonical array index inside * dense storage. Reads also require it below the length; writes may land * at or past the length while still inside the dense capacity (the runtime - * then extends the length over hole states exactly the same way). */ + * 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. */ function emitDenseGuard( host: LlvmEmitterContext, array: string, @@ -44,6 +55,7 @@ function emitDenseGuard( indexExpr: IrExpr | undefined, slow: string, requireBelowLen: boolean, + filledLabel: string, ): DenseGuard { const B = host.B; const size = host.sizeType; @@ -72,17 +84,8 @@ function emitDenseGuard( const lenPtr = B.tmp(), len = B.tmp(), capPtr = B.tmp(), - cap = B.tmp(); - B.line(`${lenPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 1`); - host.markMemoryPointer(lenPtr, "array:header"); - B.line(`${len} = load ${size}, ptr ${lenPtr}${host.fieldAliasAttachment(lenPtr)}`); - B.line(`${capPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 2`); - host.markMemoryPointer(capPtr, "array:header"); - B.line(`${cap} = load ${size}, ptr ${capPtr}${host.fieldAliasAttachment(capPtr)}`); - // Load the storage bases before branching: the header of a live array is - // always readable, and unconditional loads let LLVM hoist them out of - // loops whose only calls are read-only fallbacks. - const dataPtr = B.tmp(), + cap = B.tmp(), + dataPtr = B.tmp(), data = B.tmp(), statesPtr = B.tmp(), states = B.tmp(); @@ -91,17 +94,58 @@ function emitDenseGuard( ); host.markMemoryPointer(dataPtr, "array:header"); B.line(`${data} = load ptr, ptr ${dataPtr}${host.fieldAliasAttachment(dataPtr)}`); + const loadChecked = (): void => { + B.line(`${lenPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 1`); + host.markMemoryPointer(lenPtr, "array:header"); + B.line(`${len} = load ${size}, ptr ${lenPtr}${host.fieldAliasAttachment(lenPtr)}`); + B.line(`${capPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 2`); + host.markMemoryPointer(capPtr, "array:header"); + B.line(`${cap} = load ${size}, ptr ${capPtr}${host.fieldAliasAttachment(capPtr)}`); + B.line( + `${statesPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 ${SCR_ARR_FIELD.present}`, + ); + host.markMemoryPointer(statesPtr, "array:header"); + B.line(`${states} = load ptr, ptr ${statesPtr}${host.fieldAliasAttachment(statesPtr)}`); + }; + // Reads load the storage bases before branching: the header of a live + // array is always readable, and unconditional loads let LLVM hoist them + // out of loops whose only calls are read-only fallbacks. Writes load the + // fields that only the state-checked path needs inside it, which keeps + // them off a hot loop of prefix stores (that path stores to the header). + const eagerHeader = requireBelowLen; + if (eagerHeader) loadChecked(); + const filledPtr = B.tmp(), + filled = B.tmp(), + filledWide = B.tmp(); B.line( - `${statesPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 ${SCR_ARR_FIELD.present}`, + `${filledPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 ${SCR_ARR_FIELD.filled}`, ); - host.markMemoryPointer(statesPtr, "array:header"); - B.line(`${states} = load ptr, ptr ${statesPtr}${host.fieldAliasAttachment(statesPtr)}`); + host.markMemoryPointer(filledPtr, "array:header"); + B.line(`${filled} = load i32, ptr ${filledPtr}${host.fieldAliasAttachment(filledPtr)}`); + B.line(`${filledWide} = zext i32 ${filled} to i64`); const widen = (value: string): string => { if (size === "i64") return value; const t = B.tmp(); B.line(`${t} = zext i32 ${value} to i64`); return t; }; + let offset = wide; + if (size !== "i64") { + offset = B.tmp(); + 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}`); + if (integral) { + const t = B.tmp(); + B.line(`${t} = and i1 ${integral}, ${inFilled}`); + inFilled = t; + } + const check = B.newLabel("arr.check"); + B.condBr(inFilled, filledLabel, check); + B.startBlock(check); + if (!eagerHeader) loadChecked(); const conditions: string[] = []; if (integral) conditions.push(integral); const belowCap = B.tmp(); @@ -121,15 +165,21 @@ function emitDenseGuard( const dense = B.newLabel("arr.dense"); B.condBr(ok, dense, slow); B.startBlock(dense); - let offset = wide; - if (size !== "i64") { - offset = B.tmp(); - B.line(`${offset} = trunc i64 ${wide} to i32`); - } - return { offset, len, data, states }; + return { offset, len, filled, data, states }; } -function statePointer(host: LlvmEmitterContext, guard: DenseGuard): string { +/** The i32 prefix bound in the target's size type. */ +function sizedFilled(host: LlvmEmitterContext, filled: string): string { + if (host.sizeType !== "i64") return filled; + const t = host.B.tmp(); + host.B.line(`${t} = zext i32 ${filled} to i64`); + return t; +} + +function statePointer( + host: LlvmEmitterContext, + guard: Pick, +): string { const B = host.B; const statePtr = B.tmp(); B.line( @@ -139,7 +189,10 @@ function statePointer(host: LlvmEmitterContext, guard: DenseGuard): string { return statePtr; } -function valuePointer(host: LlvmEmitterContext, guard: DenseGuard): string { +function valuePointer( + host: LlvmEmitterContext, + guard: Pick, +): string { const B = host.B; const valuePtr = B.tmp(); B.line( @@ -183,12 +236,12 @@ export function emitDenseArrayGet( return t; } const slow = B.newLabel("arr.get.slow"), - join = B.newLabel("arr.get.join"); - const guard = emitDenseGuard(host, array, index, indexExpr, slow, true); + join = B.newLabel("arr.get.join"), + valueLabel = B.newLabel("arr.get.value"); + const guard = emitDenseGuard(host, array, index, indexExpr, slow, true, valueLabel); const state = loadState(host, guard); const present = B.tmp(); B.line(`${present} = icmp eq i8 ${state}, ${SCR_ARR_VALUE}`); - const valueLabel = B.newLabel("arr.get.value"); B.condBr(present, valueLabel, slow); B.startBlock(valueLabel); const ptr = valuePointer(host, guard); @@ -259,8 +312,9 @@ export function emitDenseArrayState( const ty = kind === "state" ? "double" : "i1"; host.declare(`declare ${kind === "state" ? "double" : "zeroext i1"} @${fn}(ptr, double)`); const slow = B.newLabel("arr.state.slow"), - join = B.newLabel("arr.state.join"); - const guard = emitDenseGuard(host, array, index, indexExpr, slow, true); + join = B.newLabel("arr.state.join"), + filledLabel = B.newLabel("arr.state.filled"); + const guard = emitDenseGuard(host, array, index, indexExpr, slow, true, filledLabel); const state = loadState(host, guard); const fast = B.tmp(); if (kind === "state") B.line(`${fast} = uitofp i8 ${state} to double`); @@ -269,13 +323,19 @@ export function emitDenseArrayState( B.br(fastLabel); B.startBlock(fastLabel); B.br(join); + // Below the bound every state is a present value. + B.startBlock(filledLabel); + B.br(join); B.startBlock(slow); const slowValue = B.tmp(); B.line(`${slowValue} = call ${ty} @${fn}(ptr ${array}, double ${index.name})${READ_FALLBACK}`); B.br(join); B.startBlock(join); const result = B.tmp(); - B.line(`${result} = phi ${ty} [ ${fast}, %${fastLabel} ], [ ${slowValue}, %${slow} ]`); + const filledValue = kind === "state" ? f64Lit(SCR_ARR_VALUE) : "true"; + B.line( + `${result} = phi ${ty} [ ${fast}, %${fastLabel} ], [ ${filledValue}, %${filledLabel} ], [ ${slowValue}, %${slow} ]`, + ); return result; } @@ -304,9 +364,21 @@ export function emitDenseArraySet( return; } const slow = B.newLabel("arr.set.slow"), - join = B.newLabel("arr.set.join"); - const guard = emitDenseGuard(host, array, index, indexExpr, slow, false); + join = B.newLabel("arr.set.join"), + filledLabel = B.newLabel("arr.set.filled"); + const guard = emitDenseGuard(host, array, index, indexExpr, slow, false, filledLabel); const { offset, len } = guard; + const storeValue = (valuePtr: string): void => { + if (acc === "f64") { + B.line(`store double ${value}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); + } else if (acc === "bool") { + const bits = B.tmp(); + B.line(`${bits} = zext i1 ${value} to i64`); + B.line(`store i64 ${bits}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); + } else { + B.line(`store ptr ${value}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); + } + }; const statePtr = statePointer(host, guard); const valuePtr = valuePointer(host, guard); let oldState: string | null = null; @@ -317,16 +389,26 @@ export function emitDenseArraySet( B.line(`${oldState} = load i8, ptr ${statePtr}${host.fieldAliasAttachment(statePtr)}`); B.line(`${old} = load ptr, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); } - if (acc === "f64") { - B.line(`store double ${value}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); - } else if (acc === "bool") { - const bits = B.tmp(); - B.line(`${bits} = zext i1 ${value} to i64`); - B.line(`store i64 ${bits}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); - } else { - B.line(`store ptr ${value}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); - } + storeValue(valuePtr); B.line(`store i8 ${SCR_ARR_VALUE}, ptr ${statePtr}${host.fieldAliasAttachment(statePtr)}`); + // A store at the bound continues the hole-free prefix. + const filledWide = sizedFilled(host, guard.filled), + continues = B.tmp(); + B.line(`${continues} = icmp eq ${host.sizeType} ${offset}, ${filledWide}`); + const bumpLabel = B.newLabel("arr.set.bump"), + afterBump = B.newLabel("arr.set.state"); + B.condBr(continues, bumpLabel, afterBump); + B.startBlock(bumpLabel); + const nextFilled = B.tmp(), + filledPtr = B.tmp(); + B.line(`${nextFilled} = add i32 ${guard.filled}, 1`); + B.line( + `${filledPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 ${SCR_ARR_FIELD.filled}`, + ); + host.markMemoryPointer(filledPtr, "array:header"); + B.line(`store i32 ${nextFilled}, ptr ${filledPtr}${host.fieldAliasAttachment(filledPtr)}`); + B.br(afterBump); + B.startBlock(afterBump); // Writes at or past the length extend it over the (hole) gap, exactly // like the runtime's dense store. const grows = B.tmp(); @@ -352,6 +434,20 @@ export function emitDenseArraySet( host.releaseValue(old, element); } B.br(join); + // Below the bound the slot holds a present value: replace it, leaving + // the state alone, and release a replaced reference after the store. + B.startBlock(filledLabel); + const filledValuePtr = valuePointer(host, guard); + let filledOld: string | null = null; + if (acc === "ref") { + filledOld = B.tmp(); + B.line( + `${filledOld} = load ptr, ptr ${filledValuePtr}${host.fieldAliasAttachment(filledValuePtr)}`, + ); + } + storeValue(filledValuePtr); + if (filledOld) host.releaseValue(filledOld, element); + B.br(join); B.startBlock(slow); call(" cold"); B.br(join); @@ -400,8 +496,8 @@ export function emitDenseArrayPush( states = B.tmp(); B.line(`${data} = load ptr, ptr ${dataPtr}${host.fieldAliasAttachment(dataPtr)}`); B.line(`${states} = load ptr, ptr ${statesPtr}${host.fieldAliasAttachment(statesPtr)}`); - const statePtr = statePointer(host, { offset: len, len, data, states }); - const valuePtr = valuePointer(host, { offset: len, len, data, states }); + const statePtr = statePointer(host, { offset: len, states }); + const valuePtr = valuePointer(host, { offset: len, data }); if (acc === "f64") { B.line(`store double ${value}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); } else if (acc === "bool") { @@ -416,6 +512,18 @@ export function emitDenseArrayPush( fastValue = B.tmp(); B.line(`${next} = add nuw ${size} ${len}, 1`); B.line(`store ${size} ${next}, ptr ${lenPtr}${host.fieldAliasAttachment(lenPtr)}`); + // An append to a hole-free array keeps it hole-free. + const filledPtr = header(SCR_ARR_FIELD.filled), + filled = B.tmp(), + packed = B.tmp(), + nextFilled = B.tmp(), + bumped = B.tmp(); + B.line(`${filled} = load i32, ptr ${filledPtr}${host.fieldAliasAttachment(filledPtr)}`); + const filledWide = sizedFilled(host, filled); + B.line(`${packed} = icmp eq ${size} ${filledWide}, ${len}`); + B.line(`${nextFilled} = add i32 ${filled}, 1`); + B.line(`${bumped} = select i1 ${packed}, i32 ${nextFilled}, i32 ${filled}`); + B.line(`store i32 ${bumped}, ptr ${filledPtr}${host.fieldAliasAttachment(filledPtr)}`); B.line(`${fastValue} = sitofp ${size} ${next} to double`); B.br(join); B.startBlock(slow); diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index b8491be97..3e71c05c7 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -1204,6 +1204,7 @@ export class LlDyn { { index: 2, type: host.sizeType, name: "capacity" }, { index: SCR_ARR_FIELD.data, type: "ptr" as const, name: "data" }, { index: SCR_ARR_FIELD.present, type: "ptr" as const, name: "presence" }, + { index: SCR_ARR_FIELD.filled, type: "i32" as const, name: "filled" }, ]; members.forEach((member) => { const cachedPtr = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 1a8ab42a6..b01d12c29 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1844,13 +1844,13 @@ export class LlEmitter { `%ScrFfiTable = type { ptr, ${this.sizeType}, ${this.sizeType}, ptr, i8, ptr, ptr, ${this.sizeType}, ${this.sizeType}, ${this.sizeType}, ptr, ptr }`, `%ScrRegex = type { ${this.sizeType}, ptr, ptr, ptr, double, ptr, ptr }`, // ScrArr mirrors scr_runtime.h field-for-field: { rc, len, cap, elem, - // cyc, ops, data, present, ext } (field numbers in SCR_ARR_FIELD), - // where ops points at the element type's constant RC descriptor + // cyc, spare, filled, ops, data, present, ext } (field numbers in + // SCR_ARR_FIELD), where filled bounds the hole-free prefix, ops points at the element type's constant RC descriptor // (%ScrArrOps, SCR_ELEM_REF only) and ext at the out-of-line sparse, // property and metadata storage (NULL until first used). Live dynamic // stream commits swap its dense, presence and out-of-line storage while // preserving the target object's identity. - `%ScrArr = type { ${this.sizeType}, ${this.sizeType}, ${this.sizeType}, i32, i32, ptr, ptr, ptr, ptr }`, + `%ScrArr = type { ${this.sizeType}, ${this.sizeType}, ${this.sizeType}, i8, i8, i16, i32, ptr, ptr, ptr, ptr }`, `%ScrArrOps = type { ptr, ptr, ptr }`, // The ScrMap prefix { rc, key_kind, val_kind, val_retain, val_release, // val_trace, key_retain, key_release, key_trace } — the inline RC @@ -2034,16 +2034,16 @@ export class LlEmitter { for (const [, inst] of this.templateStringsInstances) { // One immortal ScrArr per tagged-template site: a [N x ptr] data // global of interned cooked-string literals, and the ScrArr header - // over it (rc == SIZE_MAX, len == cap, SCR_ELEM_STR = 2, no REF - // entry points). Every dense slot is present; reads retain immortal - // strings — a no-op. + // over it (rc == SIZE_MAX, len == cap == filled, SCR_ELEM_STR = 2, + // no REF entry points). Every dense slot is present; reads retain + // immortal strings — a no-op. const n = inst.slots.length; const present = n === 0 ? "zeroinitializer" : `[ ${inst.slots.map(() => "i8 1").join(", ")} ]`; out.push( `@${inst.sym}_data = internal constant [${n} x ptr] [ ${inst.slots.map((s) => `ptr ${s}`).join(", ")} ]`, `@${inst.sym}_present = internal constant [${n} x i8] ${present}`, - `@${inst.sym} = internal global %ScrArr { ${this.sizeType} -1, ${this.sizeType} ${n}, ${this.sizeType} ${n}, i32 2, i32 0, ptr null, ptr @${inst.sym}_data, ptr @${inst.sym}_present, ptr null }`, + `@${inst.sym} = internal global %ScrArr { ${this.sizeType} -1, ${this.sizeType} ${n}, ${this.sizeType} ${n}, i8 2, i8 0, i16 0, i32 ${n}, ptr null, ptr @${inst.sym}_data, ptr @${inst.sym}_present, ptr null }`, ); } if (this.templateStringsInstances.size > 0) out.push(``); diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts index cdce1000c..83c28f7e0 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -178,6 +178,7 @@ export function streamTypedRefCommitAdapter( { name: "cap", index: 2, type: host.sizeType }, { name: "data", index: SCR_ARR_FIELD.data, type: "ptr" }, { name: "present", index: SCR_ARR_FIELD.present, type: "ptr" }, + { name: "filled", index: SCR_ARR_FIELD.filled, type: "i32" }, { name: "ext", index: SCR_ARR_FIELD.ext, type: "ptr" }, ]; for (const member of storageMembers) { diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts index 73b79b741..31be91490 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.ts @@ -497,6 +497,25 @@ function emitDenseReferenceArrayRead( B.startBlock(range); B.br(dense); B.startBlock(dense); + // Below the hole-free prefix bound every state is a present value. + const filledPtr = B.tmp(), + filled = B.tmp(), + belowFilled = B.tmp(), + lenLabel = B.newLabel("local.array.len"); + B.line( + `${filledPtr} = getelementptr inbounds %ScrArr, ptr ${array.name}, i32 0, i32 ${SCR_ARR_FIELD.filled}`, + ); + host.markMemoryPointer(filledPtr, "array:header"); + B.line(`${filled} = load i32, ptr ${filledPtr}${host.fieldAliasAttachment(filledPtr)}`); + let filledSized = filled; + if (host.sizeType !== "i32") { + filledSized = B.tmp(); + B.line(`${filledSized} = zext i32 ${filled} to ${host.sizeType}`); + } + B.line(`${belowFilled} = icmp ult ${host.sizeType} ${offset}, ${filledSized}`); + const valueLabel = B.newLabel("local.array.value"); + B.condBr(belowFilled, valueLabel, lenLabel); + B.startBlock(lenLabel); const lenPtr = B.tmp(), len = B.tmp(), belowLen = B.tmp(); @@ -504,8 +523,7 @@ function emitDenseReferenceArrayRead( host.markMemoryPointer(lenPtr, "array:header"); B.line(`${len} = load ${host.sizeType}, ptr ${lenPtr}${host.fieldAliasAttachment(lenPtr)}`); B.line(`${belowLen} = icmp ult ${host.sizeType} ${offset}, ${len}`); - const stateLabel = B.newLabel("local.array.state"), - valueLabel = B.newLabel("local.array.value"); + const stateLabel = B.newLabel("local.array.state"); B.condBr(belowLen, stateLabel, no); B.startBlock(stateLabel); const statesPtr = B.tmp(), diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 970a64367..ff9102b39 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -319,8 +319,8 @@ function rcHeaderTest(cycle: "arr" | "map", yes: string, no: string): string[] { if (cycle === "arr") { return [ ` %cycp = getelementptr inbounds %ScrArr, ptr %o, i32 0, i32 ${SCR_ARR_FIELD.cyc} ; cyc`, - ` %cyc = load i32, ptr %cycp`, - ` %headered = icmp ne i32 %cyc, 0`, + ` %cyc = load i8, ptr %cycp`, + ` %headered = icmp ne i8 %cyc, 0`, ` br i1 %headered, label %${yes}, label %${no}`, ]; } diff --git a/packages/compiler/src/backend/runtime-abi.ts b/packages/compiler/src/backend/runtime-abi.ts index 802e97540..eb8d85418 100644 --- a/packages/compiler/src/backend/runtime-abi.ts +++ b/packages/compiler/src/backend/runtime-abi.ts @@ -2,8 +2,8 @@ * of the link contract: a program object keeps this symbol undefined and the * matching runtime defines it, so incompatible manual links fail before the * program can start. */ -export const RUNTIME_ABI_VERSION = 14 as const; -export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v14" as const; +export const RUNTIME_ABI_VERSION = 15 as const; +export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v15" as const; /** Object consumption is useful today, but the complete scr_* surface may * still change before 1.0. The versioned marker prevents accidental mixing; diff --git a/packages/compiler/src/backend/runtime-pack-targets.test.ts b/packages/compiler/src/backend/runtime-pack-targets.test.ts index 2939d12d3..fbf21cab9 100644 --- a/packages/compiler/src/backend/runtime-pack-targets.test.ts +++ b/packages/compiler/src/backend/runtime-pack-targets.test.ts @@ -52,7 +52,7 @@ async function fixture(target: NativeTargetSpec, workers = false) { object_format: target.objectFormat, minimum_os: target.minimumOs, }, - runtime_abi: { version: 14, marker: "scr_runtime_abi_v14" }, + runtime_abi: { version: 15, marker: "scr_runtime_abi_v15" }, compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple }, macros: { executable: [], excluded: ["SCR_LIB"], sanitizer: "external-toolchain-required" }, flavors: { diff --git a/packages/compiler/src/backend/runtime-pack.test.ts b/packages/compiler/src/backend/runtime-pack.test.ts index 1ce5f6db1..4be6bf14d 100644 --- a/packages/compiler/src/backend/runtime-pack.test.ts +++ b/packages/compiler/src/backend/runtime-pack.test.ts @@ -116,7 +116,7 @@ async function fixture(target: NativeTargetSpec = MACOS_ARM64_TARGET) { object_format: target.objectFormat, minimum_os: target.minimumOs, }, - runtime_abi: { version: 14, marker: "scr_runtime_abi_v14" }, + runtime_abi: { version: 15, marker: "scr_runtime_abi_v15" }, compiler: { command: "clang", identity: "fixture clang", diff --git a/packages/compiler/test/native-codegen-integration.test.ts b/packages/compiler/test/native-codegen-integration.test.ts index d36974b8a..36f30054d 100644 --- a/packages/compiler/test/native-codegen-integration.test.ts +++ b/packages/compiler/test/native-codegen-integration.test.ts @@ -312,7 +312,7 @@ ${[0, 1, 2, 3, 4, 5].map((index) => ` call void @add${index}(i64 ${index + 2})` expect(await symbols(helperObject, ["-u"])).toEqual(await symbols(clangObject, ["-u"])); expect(await symbols(helperObject, ["-gU"])).toEqual(await symbols(clangObject, ["-gU"])); expect(await symbols(helperObject, ["-gU"])).toEqual(["0000000000000000 T _main"]); - expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v14"); + expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v15"); const clangExe = join(dir, "clang-program"); const linkDriver = join(dir, "clang-driver.c"); @@ -646,7 +646,7 @@ ${[0, 1, 2, 3, 4, 5].map((index) => ` call void @add${index}(i64 ${index + 2})` .split("\n"); expect(undefinedSymbols).toContain("_sf_scale"); expect(undefinedSymbols).toContain("_sf_callback_mix"); - expect(undefinedSymbols).toContain("_scr_runtime_abi_v14"); + expect(undefinedSymbols).toContain("_scr_runtime_abi_v15"); }); test("helper and clang object paths retain the same outbound FFI C ABI", async () => { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4840-array-hole-free-prefix.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4840-array-hole-free-prefix.ts.json new file mode 100644 index 000000000..a97c25b5a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4840-array-hole-free-prefix.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4840-array-hole-free-prefix.ts" + ], + "diags": [] +} diff --git a/packages/runtime-darwin-arm64/scripts/build.mjs b/packages/runtime-darwin-arm64/scripts/build.mjs index f21193724..123f63900 100644 --- a/packages/runtime-darwin-arm64/scripts/build.mjs +++ b/packages/runtime-darwin-arm64/scripts/build.mjs @@ -306,7 +306,7 @@ async function build() { package: packageManifest.name, version: packageManifest.version, target: RUNTIME_PACK_MATRIX.target, - runtime_abi: { version: 14, marker: "scr_runtime_abi_v14" }, + runtime_abi: { version: 15, marker: "scr_runtime_abi_v15" }, compiler: { command: compiler, identity: compilerVersion, diff --git a/packages/runtime-pack-common/scripts/build.mjs b/packages/runtime-pack-common/scripts/build.mjs index 9dd8c5912..e338d6b5c 100644 --- a/packages/runtime-pack-common/scripts/build.mjs +++ b/packages/runtime-pack-common/scripts/build.mjs @@ -402,7 +402,7 @@ async function build() { package: packageManifest.name, version: packageManifest.version, target: matrix.target, - runtime_abi: { version: 14, marker: "scr_runtime_abi_v14" }, + runtime_abi: { version: 15, marker: "scr_runtime_abi_v15" }, compiler: { command: compiler, identity: compilerVersion, diff --git a/packages/runtime/src/scr_array.c b/packages/runtime/src/scr_array.c index 1ef4c51d6..54f334a3e 100644 --- a/packages/runtime/src/scr_array.c +++ b/packages/runtime/src/scr_array.c @@ -446,6 +446,7 @@ static uint8_t scr_arr_state_at(const ScrArr *a, size_t index, uint64_t *out) { * the destination length and does not need a retain. */ static void scr_arr_store_state_owned(ScrArr *a, size_t index, uint64_t slot, uint8_t state) { + if (state != SCR_ARR_VALUE) scr_arr_cut(a, index); scr_arr_grow_for_index(a, index); if (index < a->cap) { a->data[index] = slot; @@ -470,6 +471,7 @@ static bool scr_arr_take_state(ScrArr *a, size_t index, uint64_t *out, if (index < a->cap) { uint8_t state = a->present[index]; if (state == SCR_ARR_HOLE) return false; + scr_arr_cut(a, index); *out = a->data[index]; a->present[index] = SCR_ARR_HOLE; a->data[index] = 0; @@ -493,6 +495,7 @@ static void scr_arr_replace_state_owned(ScrArr *a, size_t index, uint64_t slot, uint8_t state) { uint8_t old_state; uint64_t old = 0; + if (state != SCR_ARR_VALUE) scr_arr_cut(a, index); if (index < a->cap) { old_state = a->present[index]; if (old_state == SCR_ARR_VALUE) old = a->data[index]; @@ -516,6 +519,22 @@ static void scr_arr_replace_owned(ScrArr *a, size_t index, uint64_t slot) { scr_arr_replace_state_owned(a, index, slot, SCR_ARR_VALUE); } +/* A value was just stored at `index` (with the length already covering it): + * extend the hole-free prefix when the store continues it. */ +static void scr_arr_note_value(ScrArr *a, size_t index) { + if (index == a->filled && index < a->cap && index < a->len) a->filled = (uint32_t)index + 1; +} + +/* scr_arr_refill bounded to [0, limit): for range operations whose cost is + * proportional to the range, not to the whole array. */ +static void scr_arr_refill_to(ScrArr *a, size_t limit) { + size_t end = a->len < a->cap ? a->len : a->cap; + if (limit < end) end = limit; + size_t f = a->filled; + while (f < end && a->present[f] == SCR_ARR_VALUE) f++; + a->filled = (uint32_t)f; +} + static bool scr_arr_prop_get_state(const ScrArr *a, double key, uint64_t *out, uint8_t *state_out) { char text[64]; @@ -597,6 +616,7 @@ static ScrArrStorage scr_arr_take_storage(ScrArr *a) { a->data = NULL; a->present = NULL; a->cap = 0; + a->filled = 0; if (a->ext) { a->ext->sparse = NULL; a->ext->sparse_len = 0; @@ -620,12 +640,14 @@ static bool scr_arr_is_dense(const ScrArr *a) { static void scr_arr_move_dense(ScrArr *a, size_t to, size_t from, size_t count) { if (count == 0) return; + scr_arr_cut(a, to); memmove(a->data + to, a->data + from, count * sizeof(*a->data)); memmove(a->present + to, a->present + from, count); } static void scr_arr_clear_dense(ScrArr *a, size_t from, size_t count) { if (count == 0) return; + scr_arr_cut(a, from); memset(a->present + from, SCR_ARR_HOLE, count); memset(a->data + from, 0, count * sizeof(*a->data)); } @@ -637,6 +659,7 @@ static void scr_arr_copy_dense(ScrArr *dst, size_t to, const ScrArr *src, size_t from, size_t count, bool reverse, bool materialize_holes) { if (count == 0) return; + scr_arr_cut(dst, to); if (!reverse) { memcpy(dst->data + to, src->data + from, count * sizeof(*dst->data)); memcpy(dst->present + to, src->present + from, count); @@ -665,8 +688,10 @@ ScrArr *scr_arr_new(ScrElemKind elem, size_t initial_cap) { a->rc = 1; a->len = 0; a->cap = 0; - a->elem = elem; + a->elem = (uint8_t)elem; a->cyc = 0; + a->spare = 0; + a->filled = 0; a->ops = NULL; a->data = NULL; a->present = NULL; @@ -756,6 +781,8 @@ ScrArr *scr_arr_new_ops(const ScrArrOps *ops, size_t initial_cap) { a->cap = 0; a->elem = SCR_ELEM_REF; a->cyc = cyc; + a->spare = 0; + a->filled = 0; a->ops = ops; a->data = NULL; a->present = NULL; @@ -873,6 +900,7 @@ void scr_arr_set_len(ScrArr *a, double length) { } size_t next = (size_t)length; if (next < a->len) { + scr_arr_cut(a, next); /* Spare capacity is already empty. Repeatedly clearing a reused work * array must cost only the elements removed, not its historical peak. */ size_t dense_end = a->len < a->cap ? a->len : a->cap; @@ -922,10 +950,21 @@ void scr_arr_copy_range(ScrArr *dst, size_t dst_start, const ScrArr *src, scr_arr_copy_range_ex(dst, dst_start, src, src_start, count, reverse, false); } +static void scr_arr_copy_range_states(ScrArr *dst, size_t dst_start, const ScrArr *src, + size_t src_start, size_t count, bool reverse, + bool materialize_holes); + void scr_arr_copy_range_ex(ScrArr *dst, size_t dst_start, const ScrArr *src, size_t src_start, size_t count, bool reverse, bool materialize_holes) { if (count == 0) return; + scr_arr_copy_range_states(dst, dst_start, src, src_start, count, reverse, materialize_holes); + scr_arr_refill_to(dst, dst_start + count); +} + +static void scr_arr_copy_range_states(ScrArr *dst, size_t dst_start, const ScrArr *src, + size_t src_start, size_t count, bool reverse, + bool materialize_holes) { if (src != dst && src_start <= src->cap && count <= src->cap - src_start && dst_start <= dst->cap && count <= dst->cap - dst_start) { size_t empty = 0; @@ -980,7 +1019,11 @@ ScrArr *scr_arr_copy_within(ScrArr *a, double target, double start, double end) if (count == 0 || from == to) return scr_arr_retain(a); if (!scr_elem_is_ref(a->elem) && from < a->cap && count <= a->cap - from && to < a->cap && count <= a->cap - to) { + /* Values moved within the hole-free prefix keep it. */ + size_t f = a->filled; scr_arr_move_dense(a, to, from, count); + if (from + count <= f && to + count <= f) a->filled = (uint32_t)f; + else scr_arr_refill_to(a, to + count); return scr_arr_retain(a); } bool backwards = from < to && to - from < count; @@ -997,6 +1040,7 @@ ScrArr *scr_arr_copy_within(ScrArr *a, double target, double start, double end) scr_arr_replace_state_owned(a, to + offset, slot, state); } } + scr_arr_refill_to(a, to + count); return scr_arr_retain(a); } @@ -1008,6 +1052,7 @@ static ScrArr *scr_arr_fill_slot(ScrArr *a, uint64_t slot, uint8_t state, bool refs = scr_elem_is_ref(a->elem); if (scr_arr_dense_ok(a, until, until - from)) scr_arr_grow_dense(a, until); if (!refs && until <= a->cap) { + if (state != SCR_ARR_VALUE) scr_arr_cut(a, from); for (size_t i = from; i < until; i++) a->data[i] = slot; memset(a->present + from, state, until - from); } else { @@ -1016,6 +1061,7 @@ static ScrArr *scr_arr_fill_slot(ScrArr *a, uint64_t slot, uint8_t state, scr_arr_replace_state_owned(a, i, value, state); } } + if (state == SCR_ARR_VALUE) scr_arr_refill(a); return scr_arr_retain(a); } @@ -1161,6 +1207,7 @@ void *scr_arr_peek_ref(const ScrArr *a, double i) { if (i >= 0 && i < (double)a->cap) { size_t idx = (size_t)i; if ((double)idx == i) { + if (idx < a->filled) return scr_slot_to_ptr(a->data[idx]); if (idx >= a->len || a->present[idx] != SCR_ARR_VALUE) return NULL; return scr_slot_to_ptr(a->data[idx]); } @@ -1236,6 +1283,7 @@ static void scr_arr_set_slot(ScrArr *a, double i, uint64_t slot) { /* Unlink-then-release: a release can trigger a cycle collection, which * must never see a heap edge whose count was already given up. */ scr_arr_replace_owned(a, idx, slot); + scr_arr_note_value(a, idx); } void scr_arr_set_f64(ScrArr *a, double i, double v) { @@ -1282,6 +1330,7 @@ static double scr_arr_push_slot(ScrArr *a, uint64_t slot) { size_t idx = a->len; scr_arr_store_owned(a, idx, slot); a->len++; + scr_arr_note_value(a, idx); return SCR_ARR_LEN_F64(a->len); } @@ -1310,6 +1359,7 @@ double scr_arr_push_many(ScrArr *a, size_t count, const uint64_t *slots) { for (size_t i = 0; i < count; i++) scr_arr_store_owned(a, from + i, slots[i]); } a->len = next; + scr_arr_refill(a); return (double)next; } @@ -1322,6 +1372,7 @@ double scr_arr_push_spread(ScrArr *a, const ScrArr *src) { scr_arr_grow_dense(a, old_len + add); scr_arr_copy_dense(a, old_len, src, 0, add, false, true); a->len += add; + scr_arr_refill(a); return SCR_ARR_LEN_F64(a->len); } if (src == a) { @@ -1339,6 +1390,7 @@ double scr_arr_push_spread(ScrArr *a, const ScrArr *src) { } a->len = old_len + add; scr_arr_free_storage(&old); + scr_arr_refill(a); return SCR_ARR_LEN_F64(a->len); } a->len = old_len + add; @@ -1351,6 +1403,7 @@ double scr_arr_push_spread(ScrArr *a, const ScrArr *src) { scr_arr_store_state_owned(a, old_len + i, 0, SCR_ARR_UNDEFINED); } } + scr_arr_refill(a); return SCR_ARR_LEN_F64(a->len); } @@ -1363,6 +1416,7 @@ double scr_arr_concat_copy(ScrArr *a, const ScrArr *src) { scr_arr_grow_dense(a, old_len + add); scr_arr_copy_dense(a, old_len, src, 0, add, false, false); a->len += add; + scr_arr_refill(a); return SCR_ARR_LEN_F64(a->len); } if (src == a) { @@ -1380,6 +1434,7 @@ double scr_arr_concat_copy(ScrArr *a, const ScrArr *src) { } a->len = old_len + add; scr_arr_free_storage(&old); + scr_arr_refill(a); return SCR_ARR_LEN_F64(a->len); } a->len = old_len + add; @@ -1392,6 +1447,7 @@ double scr_arr_concat_copy(ScrArr *a, const ScrArr *src) { scr_arr_copy_index(a, old_len + SCR_AX(src)->sparse[i].index, src, SCR_AX(src)->sparse[i].index); } + scr_arr_refill(a); return SCR_ARR_LEN_F64(a->len); } @@ -1408,10 +1464,13 @@ static double scr_arr_unshift_slot(ScrArr *a, uint64_t slot) { size_t old_len = a->len; if (scr_arr_is_dense(a) && scr_arr_dense_ok(a, old_len + 1, 1)) { scr_arr_grow_dense(a, old_len + 1); + /* The prefix moves up behind the new first value. */ + size_t f = a->filled; scr_arr_move_dense(a, 1, 0, old_len); a->data[0] = slot; a->present[0] = SCR_ARR_VALUE; a->len++; + a->filled = (uint32_t)(f + 1); return SCR_ARR_LEN_F64(a->len); } ScrArrStorage old = scr_arr_take_storage(a); @@ -1426,6 +1485,7 @@ static double scr_arr_unshift_slot(ScrArr *a, uint64_t slot) { } a->len = old_len + 1; scr_arr_free_storage(&old); + scr_arr_refill(a); return SCR_ARR_LEN_F64(a->len); } @@ -1447,9 +1507,13 @@ double scr_arr_unshift_many(ScrArr *a, size_t count, const uint64_t *slots) { if (count > SCR_ARR_MAX_LENGTH - old_len) scr_arr_oom(); if (scr_arr_is_dense(a) && scr_arr_dense_ok(a, old_len + count, count)) { scr_arr_grow_dense(a, old_len + count); + size_t f = a->filled; scr_arr_move_dense(a, count, 0, old_len); memcpy(a->data, slots, count * sizeof(*slots)); memset(a->present, SCR_ARR_VALUE, count); + a->len = old_len + count; + a->filled = (uint32_t)(f + count); + return SCR_ARR_LEN_F64(a->len); } else { ScrArrStorage old = scr_arr_take_storage(a); for (size_t i = 0; i < count; i++) scr_arr_store_owned(a, i, slots[i]); @@ -1464,6 +1528,7 @@ double scr_arr_unshift_many(ScrArr *a, size_t count, const uint64_t *slots) { scr_arr_free_storage(&old); } a->len = old_len + count; + scr_arr_refill(a); return SCR_ARR_LEN_F64(a->len); } @@ -1479,6 +1544,7 @@ double scr_arr_unshift_spread(ScrArr *a, const ScrArr *src) { /* A self-spread reads the original values at their moved location. */ scr_arr_copy_dense(a, 0, src, src == a ? add : 0, add, false, true); a->len += add; + scr_arr_refill(a); return SCR_ARR_LEN_F64(a->len); } ScrArrStorage old = scr_arr_take_storage(a); @@ -1505,12 +1571,15 @@ double scr_arr_unshift_spread(ScrArr *a, const ScrArr *src) { } a->len = old_len + add; scr_arr_free_storage(&old); + scr_arr_refill(a); return SCR_ARR_LEN_F64(a->len); } ScrArr *scr_arr_reverse(ScrArr *a) { size_t len = a->len; if (scr_arr_is_dense(a)) { + /* A hole-free array stays hole-free; any other prefix is recomputed. */ + if (a->filled != len) scr_arr_cut(a, 0); for (size_t i = 0; i < len / 2; i++) { size_t j = len - i - 1; uint64_t slot; @@ -1521,6 +1590,7 @@ ScrArr *scr_arr_reverse(ScrArr *a) { a->present[i] = a->present[j]; a->present[j] = state; } + scr_arr_refill(a); return scr_arr_retain(a); } ScrArrStorage old = scr_arr_take_storage(a); @@ -1533,6 +1603,7 @@ ScrArr *scr_arr_reverse(ScrArr *a) { old.sparse[i].slot, old.sparse[i].state); } scr_arr_free_storage(&old); + scr_arr_refill(a); return scr_arr_retain(a); } @@ -1575,9 +1646,12 @@ uint8_t scr_arr_shift_state(ScrArr *a, uint64_t *slot_out) { if (scr_arr_is_dense(a)) { uint8_t state = a->present[0]; if (slot_out && state == SCR_ARR_VALUE) *slot_out = a->data[0]; + /* The prefix slides down with the tail. */ + size_t f = a->filled; scr_arr_move_dense(a, 0, 1, old_len - 1); scr_arr_clear_dense(a, old_len - 1, 1); a->len--; + a->filled = (uint32_t)(f ? f - 1 : 0); return state; } ScrArrStorage old = scr_arr_take_storage(a); @@ -1595,6 +1669,7 @@ uint8_t scr_arr_shift_state(ScrArr *a, uint64_t *slot_out) { } a->len = old_len - 1; scr_arr_free_storage(&old); + scr_arr_refill(a); if (slot_out) *slot_out = s; return state; } @@ -1644,9 +1719,15 @@ ScrArr *scr_arr_splice(ScrArr *a, double start, double deleteCount) { memcpy(out->data, a->data + from, n * sizeof(*a->data)); memcpy(out->present, a->present + from, n); } + scr_arr_refill(out); + /* The tail moves down over the removed range; values below the prefix + * bound that stay values keep it. */ + size_t f = a->filled; scr_arr_move_dense(a, from, from + n, old_len - from - n); scr_arr_clear_dense(a, old_len - n, n); a->len -= n; + if (f == old_len) a->filled = (uint32_t)a->len; + else scr_arr_refill(a); return out; } ScrArrStorage old = scr_arr_take_storage(a); @@ -1678,6 +1759,8 @@ ScrArr *scr_arr_splice(ScrArr *a, double start, double deleteCount) { } a->len = old_len - n; scr_arr_free_storage(&old); + scr_arr_refill(a); + scr_arr_refill(out); return out; } @@ -1711,11 +1794,13 @@ ScrArr *scr_arr_splice_insert(ScrArr *a, double start, double deleteCount, memcpy(removed->present, a->present + from, n); } removed->len = n; + scr_arr_refill(removed); scr_arr_grow_dense(a, next_len); scr_arr_move_dense(a, from + add, from + n, a->len - from - n); scr_arr_copy_dense(a, from, items, 0, add, false, true); if (next_len < a->len) scr_arr_clear_dense(a, next_len, a->len - next_len); a->len = next_len; + scr_arr_refill(a); scr_arr_release(snapshot); return removed; } @@ -1770,6 +1855,7 @@ void scr_arr_splice_drop(ScrArr *a, double start, double deleteCount, size_t cou } size_t old_len = a->len; scr_arr_grow_dense(a, next_len); + bool packed = a->filled == old_len; scr_arr_move_dense(a, from + count, from + n, old_len - from - n); if (count) { memcpy(a->data + from, slots, count * sizeof(*slots)); @@ -1777,6 +1863,9 @@ void scr_arr_splice_drop(ScrArr *a, double start, double deleteCount, size_t cou } if (next_len < old_len) scr_arr_clear_dense(a, next_len, old_len - next_len); a->len = next_len; + /* Values inserted into a hole-free array keep it hole-free. */ + if (packed) a->filled = (uint32_t)next_len; + else scr_arr_refill(a); for (size_t i = 0; i < kept; i++) scr_elem_release(a, removed[i]); if (removed != inline_removed) scr_mem_free(removed); } @@ -1804,6 +1893,7 @@ ScrArr *scr_arr_flat_copy(const ScrArr *a, ScrArr *out, bool flatten) { scr_arr_copy_index(out, at, a, i); } } + scr_arr_refill(out); return scr_arr_retain(out); } @@ -1942,6 +2032,7 @@ ScrArr *scr_arr_slice(ScrArr *a, double start, double end) { out->len = n; if (to <= a->cap) { scr_arr_copy_dense(out, 0, a, from, n, false, false); + scr_arr_refill(out); return out; } for (size_t i = 0; i < a->cap && i < to; i++) { @@ -1960,6 +2051,7 @@ ScrArr *scr_arr_slice(ScrArr *a, double start, double end) { SCR_AX(a)->sparse[i].state); } } + scr_arr_refill(out); return out; } @@ -2109,6 +2201,7 @@ ScrArr *scr_arr_sort_primitive(ScrArr *a, bool copy) { if (copy) out->len = a->len; if (!copy && scr_arr_is_dense(a)) { if (count) memcpy(a->data, values, count * sizeof(*values)); + scr_arr_cut(a, count); memset(a->present, SCR_ARR_VALUE, count); memset(a->present + count, SCR_ARR_UNDEFINED, undefined_count); scr_arr_clear_dense(a, count + undefined_count, a->len - count - undefined_count); @@ -2123,6 +2216,7 @@ ScrArr *scr_arr_sort_primitive(ScrArr *a, bool copy) { if (!copy) scr_arr_free_storage(&old); } free(values); + scr_arr_refill(out); return copy ? out : scr_arr_retain(a); } @@ -2368,6 +2462,7 @@ void scr_arr_sort_values(ScrArr *a, double count, ScrClosure *f, uint32_t arity) * holes, no storage-level undefined and no sparse tail. */ static bool scr_arr_dense_values(const ScrArr *a) { if (!scr_arr_is_dense(a)) return false; + if (a->filled == a->len) return true; const uint8_t *present = a->present; bool all = true; for (size_t i = 0; i < a->len; i++) all &= present[i] == SCR_ARR_VALUE; @@ -2443,6 +2538,7 @@ bool scr_arr_sort_dense(ScrArr *a, ScrClosure *f, uint32_t arity, bool owned) { } else { for (size_t i = 0; i < n; i++) scr_arr_set_slot(a, (double)i, src[i]); } + scr_arr_refill(a); free(src); return true; } diff --git a/packages/runtime/src/scr_console.c b/packages/runtime/src/scr_console.c index c461a8769..15cd0ca7e 100644 --- a/packages/runtime/src/scr_console.c +++ b/packages/runtime/src/scr_console.c @@ -126,7 +126,7 @@ static void scr_enable_utf8_console_output(void) { } #endif -void scr_runtime_abi_v14(void) {} +void scr_runtime_abi_v15(void) {} void scr_init(void) { #ifdef SCR_WORKERS diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 8e3c3a9ca..176c2be65 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -74,7 +74,7 @@ void scr_init(void); /* Program objects emitted by the bundled LLVM helper reference this symbol. * Its versioned spelling makes a mismatched manual runtime link fail before * the program can start. */ -void scr_runtime_abi_v14(void); +void scr_runtime_abi_v15(void); void scr_runtime_workers_v8(void); /* ── the trap funnel (scr_console.c; scr_library.c under -DSCR_LIB) ────── @@ -1583,7 +1583,8 @@ typedef struct ScrArr { size_t rc; /* SIZE_MAX = immortal (unused for arrays; kept per convention) */ size_t len; size_t cap; /* dense capacity; sparse entries cover indices >= cap */ - ScrElemKind elem; + /* One ScrElemKind. */ + uint8_t elem; /* 1 exactly when ops->trace is non-NULL: the element type carries a cycle * header, so the array is CYCLE-CAPABLE (an element can point back at the * array that owns it) and allocates with the hidden collector header — @@ -1591,7 +1592,15 @@ typedef struct ScrArr { * the slot storage (all elements are traced children). Arrays of acyclic * ref elements and the historic element kinds keep the lean 1-word * header. Kept beside `elem` so the RC fast paths test it with one load. */ - uint32_t cyc; + uint8_t cyc; + uint16_t spare; + /* A lower bound on the hole-free prefix: filled <= len, filled <= cap, + * and every dense state below it is SCR_ARR_VALUE. Element reads below + * it need no state check and element writes below it leave the states + * alone. Any operation that may store another state (a hole, present + * undefined) or move states below it lowers it first (scr_arr_cut); + * 0 is always valid. */ + uint32_t filled; /* SCR_ELEM_REF only; NULL for every other element kind. */ const ScrArrOps *ops; uint64_t *data; @@ -1611,15 +1620,31 @@ typedef struct ScrArrExt { size_t prop_cap; struct ScrDyn *metadata; /* heterogeneous named properties of match results */ } ScrArrExt; -/* The LLVM backend's inline retain/release read `cyc` through its mirrored - * %ScrArr type (field 4, i32) to decide mark-live / candidate buffering - * exactly like scr_arr_retain and scr_arr_release; its inline element reads - * use data (field 6) and present (field 7). */ -_Static_assert(sizeof(ScrElemKind) == sizeof(uint32_t), "ScrArr.elem is an i32 (%ScrArr field 3)"); -_Static_assert(offsetof(ScrArr, cyc) == 3 * sizeof(size_t) + sizeof(uint32_t), +/* The LLVM backend mirrors ScrArr as %ScrArr: its inline retain/release read + * `cyc` (field 4, i8) to decide mark-live / candidate buffering exactly like + * scr_arr_retain and scr_arr_release; its inline element access uses filled + * (field 6, i32), data (field 8) and present (field 9). */ +_Static_assert(offsetof(ScrArr, cyc) == 3 * sizeof(size_t) + 1, "inline RC fast paths read ScrArr.cyc as %ScrArr field 4"); -_Static_assert(offsetof(ScrArr, data) == 3 * sizeof(size_t) + 2 * sizeof(uint32_t) + sizeof(void *), - "inline element reads use ScrArr.data as %ScrArr field 6"); +_Static_assert(offsetof(ScrArr, filled) == 3 * sizeof(size_t) + 4, + "inline element access reads ScrArr.filled as %ScrArr field 6"); +_Static_assert(offsetof(ScrArr, data) == 3 * sizeof(size_t) + 8 + sizeof(void *), + "inline element reads use ScrArr.data as %ScrArr field 8"); + +/* Lower the hole-free prefix to at most `index`: call before storing a + * non-value state at `index`, or before moving states into [index, ...). */ +static inline void scr_arr_cut(ScrArr *a, size_t index) { + if (a->filled > index) a->filled = (uint32_t)index; +} + +/* Extend the hole-free prefix over populated dense states. Starts at the + * current bound, so after an append it costs only the appended range. */ +static inline void scr_arr_refill(ScrArr *a) { + size_t end = a->len < a->cap ? a->len : a->cap; + size_t f = a->filled; + while (f < end && a->present[f] == SCR_ARR_VALUE) f++; + a->filled = (uint32_t)f; +} typedef struct ScrArrSparseSlot { size_t index; diff --git a/packages/runtime/test/test_array.c b/packages/runtime/test/test_array.c index 858f088d7..a9dd3d20e 100644 --- a/packages/runtime/test/test_array.c +++ b/packages/runtime/test/test_array.c @@ -1266,6 +1266,86 @@ static void test_exact_construction(void) { scr_arr_release(refs); } +/* The hole-free prefix bound never covers a non-value state, and the + * operations that keep an array hole-free keep the bound at its length. */ +static bool filled_ok(const ScrArr *a) { + if (a->filled > a->len || a->filled > a->cap) return false; + for (size_t i = 0; i < a->filled; i++) + if (a->present[i] != SCR_ARR_VALUE) return false; + return true; +} + +static void test_filled_prefix(void) { + ScrArr *a = scr_arr_new(SCR_ELEM_F64, 0); + for (int i = 0; i < 8; i++) scr_arr_push_f64(a, i); + check(a->filled == 8 && filled_ok(a), "pushes keep the bound at the length"); + scr_arr_set_len(a, 12); + check(a->filled == 8 && filled_ok(a), "length growth leaves the bound below the holes"); + scr_arr_set_f64(a, 8, 8); + check(a->filled == 9 && filled_ok(a), "a write at the bound extends it"); + scr_arr_set_f64(a, 11, 11); + check(a->filled == 9 && filled_ok(a), "a write past a hole does not extend it"); + scr_arr_release(scr_arr_fill_f64(a, 0, 0, INFINITY)); + check(a->filled == 12 && filled_ok(a), "fill over the whole array restores it"); + scr_arr_delete(a, 3); + check(a->filled == 3 && filled_ok(a), "delete cuts the bound at the hole"); + scr_arr_set_f64(a, 3, 3); + check(a->filled == 4 && filled_ok(a), "refilling the hole continues the prefix"); + scr_arr_set_undefined(a, 1); + check(a->filled == 1 && filled_ok(a), "present undefined cuts the bound"); + scr_arr_set_len(a, 0); + check(a->filled == 0 && filled_ok(a), "truncation cuts the bound"); + scr_arr_release(a); + + ScrArr *b = scr_arr_new(SCR_ELEM_F64, 0); + for (int i = 0; i < 10; i++) scr_arr_push_f64(b, i); + check(scr_arr_shift_f64(b) == 0 && b->filled == 9 && filled_ok(b), "shift keeps a full bound"); + scr_arr_unshift_f64(b, -1); + check(b->filled == 10 && filled_ok(b), "unshift keeps a full bound"); + scr_arr_release(scr_arr_splice(b, 2, 3)); + check(b->filled == 7 && filled_ok(b), "splice removal keeps a full bound"); + double values[2] = {5, 6}; + uint64_t two[2]; + memcpy(two, values, sizeof two); + scr_arr_splice_drop(b, 1, 0, 2, two); + check(b->filled == 9 && filled_ok(b), "splice insertion keeps a full bound"); + scr_arr_release(scr_arr_reverse(b)); + check(b->filled == 9 && filled_ok(b), "reverse keeps a full bound"); + scr_arr_release(scr_arr_copy_within(b, 0, 4, 6)); + check(b->filled == 9 && filled_ok(b), "copyWithin inside the prefix keeps it"); + scr_arr_set_len(b, 12); + scr_arr_release(scr_arr_copy_within(b, 2, 10, 12)); + check(b->filled == 2 && filled_ok(b), "copyWithin of holes cuts the bound"); + ScrArr *s = scr_arr_slice(b, 0, 2); + check(s->filled == 2 && filled_ok(s), "slice of a hole-free range is hole-free"); + scr_arr_release(s); + s = scr_arr_slice(b, 0, INFINITY); + check(s->filled == 2 && filled_ok(s), "slice keeps holes out of the bound"); + scr_arr_release(s); + scr_arr_release(scr_arr_reverse(b)); + check(filled_ok(b), "reverse of a holey array recomputes the bound"); + scr_arr_release(scr_arr_sort_primitive(b, false)); + check(filled_ok(b), "sorting moves holes past the bound"); + for (size_t i = 0; i < b->len; i++) { + if (i < b->cap && b->present[i] != SCR_ARR_VALUE) break; + check(i < b->filled, "sorted values form the bound"); + } + while (scr_arr_len(b) > 0) { + scr_arr_pop_state(b, NULL); + check(filled_ok(b), "pop keeps the bound below the length"); + } + scr_arr_release(b); + + ScrArr *r = scr_arr_new(SCR_ELEM_STR, 0); + for (int i = 0; i < 4; i++) scr_arr_push_ref(r, scr_str_new("ab", 2)); + check(r->filled == 4 && filled_ok(r), "reference pushes keep the bound"); + scr_arr_delete(r, 0); + check(r->filled == 0 && filled_ok(r), "deleting the first reference empties the bound"); + scr_arr_set_ref(r, 0, scr_str_new("c", 1)); + check(r->filled == 1 && filled_ok(r), "a write at an empty bound extends it by one"); + scr_arr_release(r); +} + int main(int argc, char **argv) { if (argc > 1) { ScrArr *a = scr_arr_new(SCR_ELEM_F64, 0); @@ -1310,6 +1390,7 @@ int main(int argc, char **argv) { test_large_storage(); test_bulk_reference_ownership(); test_splice_drop(); + test_filled_prefix(); fprintf(stderr, "%ld/%ld cases passed\n", total - failed, total); return failed == 0 ? 0 : 1; diff --git a/tests/corpus/4840-array-hole-free-prefix.ts b/tests/corpus/4840-array-hole-free-prefix.ts new file mode 100644 index 000000000..666739e03 --- /dev/null +++ b/tests/corpus/4840-array-hole-free-prefix.ts @@ -0,0 +1,199 @@ +// Arrays that start hole-free and then gain holes, present undefined, or +// moved elements through every mutating operation. After each step the +// whole array is read back by index, by `in`, and by forEach (which skips +// holes), and then written again by index, so element reads and writes +// below and past the hole-free prefix all agree with JS semantics. +function show(label: string, xs: (number | undefined)[]): void { + const parts: string[] = []; + for (let i = 0; i < xs.length; i++) parts.push(i in xs ? String(xs[i]) : "_"); + let visits = 0; + xs.forEach(() => visits++); + console.log(label, xs.length, parts.join(","), "visits", visits); +} + +function showNumbers(label: string, xs: number[]): void { + const parts: string[] = []; + for (let i = 0; i < xs.length; i++) parts.push(i in xs ? String(xs[i]) : "_"); + let visits = 0; + let sum = 0; + xs.forEach((x) => { + visits++; + sum += x; + }); + console.log(label, xs.length, parts.join(","), "visits", visits, "sum", sum); +} + +function rewrite(xs: number[], from: number, to: number, base: number): void { + for (let i = from; i < to; i++) xs[i] = base + i; +} + +// A hole at `at` without `delete`: copying a hole deletes the target. +function punch(xs: T[], at: number): void { + const n = xs.length; + xs.length = n + 1; + xs.copyWithin(at, n, n + 1); + xs.length = n; +} + +// fill, then length growth, then writes into and past the old length. +const a: number[] = new Array(5).fill(1); +showNumbers("fill", a); +a[2] = 9; +showNumbers("write-in-prefix", a); +a.length = 8; +showNumbers("grow", a); +console.log("grow-in", 4 in a, 5 in a, a[6]); +a[5] = 50; +showNumbers("write-hole", a); +a[7] = 70; +showNumbers("write-last", a); +a[6] = 60; +showNumbers("close-gap", a); +a.length = 3; +showNumbers("truncate", a); +a.length = 6; +showNumbers("regrow", a); +rewrite(a, 0, 6, 100); +showNumbers("rewrite", a); + +// a hole in the middle, then the hole is refilled. +const b: number[] = [1, 2, 3, 4, 5, 6]; +punch(b, 2); +showNumbers("punch", b); +b[1] = 20; +b[4] = 40; +showNumbers("after-punch-writes", b); +b[2] = 30; +showNumbers("refill", b); +punch(b, 0); +punch(b, 5); +showNumbers("punch-ends", b); +b.push(7); +showNumbers("push-after-holes", b); +console.log("pop", b.pop(), b.pop(), b.length); +showNumbers("popped", b); + +// sparse writes past the length and appends at the length. +const c: number[] = []; +for (let i = 0; i < 4; i++) c[i] = i * 2; +showNumbers("append-by-index", c); +c[10] = 1; +showNumbers("sparse-write", c); +for (let i = 4; i < 10; i++) c[i] = -i; +showNumbers("fill-gap", c); + +// new Array(n) filled by index from the front and from the back. +const d: number[] = new Array(6); +showNumbers("new-array", d); +for (let i = 0; i < 3; i++) d[i] = i; +showNumbers("front-half", d); +for (let i = 5; i >= 3; i--) d[i] = i; +showNumbers("back-half", d); + +// present undefined in a nullable array. +const e: (number | undefined)[] = [1, 2, 3, 4]; +e[1] = undefined; +show("undefined", e); +e[1] = 5; +e[3] = undefined; +show("undefined-last", e); +e.length = 6; +show("undefined-grow", e); +e.fill(undefined, 4); +show("fill-undefined", e); +e.fill(8); +show("fill-all", e); + +// structural operations on hole-free and holey arrays. +for (const holey of [false, true]) { + const tag = holey ? "holey" : "packed"; + const make = (): number[] => { + const xs: number[] = [10, 11, 12, 13, 14, 15, 16]; + if (holey) punch(xs, 3); + return xs; + }; + const s = make(); + console.log(tag, "shift", s.shift()); + showNumbers(tag + "-shift", s); + s[0] = 1; + s[s.length - 1] = 2; + showNumbers(tag + "-shift-write", s); + const u = make(); + u.unshift(1, 2); + showNumbers(tag + "-unshift", u); + u[3] = 3; + showNumbers(tag + "-unshift-write", u); + const sp = make(); + const removed = sp.splice(1, 2); + showNumbers(tag + "-splice", sp); + showNumbers(tag + "-removed", removed); + sp.splice(2, 0, 7, 8, 9); + showNumbers(tag + "-splice-insert", sp); + sp[4] = 4; + showNumbers(tag + "-splice-write", sp); + const r = make(); + r.reverse(); + showNumbers(tag + "-reverse", r); + r[3] = 33; + showNumbers(tag + "-reverse-write", r); + const so = make(); + so.sort((x, y) => y - x); + showNumbers(tag + "-sort", so); + so[so.length - 1] = 99; + showNumbers(tag + "-sort-write", so); + const cw = make(); + cw.copyWithin(0, 3, 6); + showNumbers(tag + "-copy-within", cw); + cw[1] = 1; + showNumbers(tag + "-copy-within-write", cw); + const sl = make().slice(2, 6); + showNumbers(tag + "-slice", sl); + sl[1] = 1; + showNumbers(tag + "-slice-write", sl); + const cc = make().concat(make()); + showNumbers(tag + "-concat", cc); + cc[3] = 3; + showNumbers(tag + "-concat-write", cc); + const m = make().map((x) => x * 2); + showNumbers(tag + "-map", m); + const f = make().filter((x) => x % 2 === 0); + showNumbers(tag + "-filter", f); +} + +// references: overwrite, punch, truncate and regrow string and object arrays. +class Box { + v: number; + constructor(v: number) { + this.v = v; + } +} +const names: string[] = ["a", "b", "c", "d"].map((s) => s + s); +names[1] = "x" + names[0]; +punch(names, 2); +names.length = 6; +names[5] = "tail"; +const nameParts: string[] = []; +for (let i = 0; i < names.length; i++) nameParts.push(i in names ? names[i]! : "_"); +console.log("names", nameParts.join(",")); +const boxes: Box[] = []; +for (let i = 0; i < 5; i++) boxes.push(new Box(i)); +boxes[2] = new Box(20); +punch(boxes, 3); +boxes.length = 4; +boxes[1] = new Box(10); +let boxSum = 0; +boxes.forEach((box) => (boxSum += box.v)); +console.log("boxes", boxes.length, 3 in boxes, boxSum, boxes[0]!.v, boxes[2]!.v); + +// boolean flags: holes read as undefined (falsy) until written. +const flags: boolean[] = new Array(8).fill(false); +for (let i = 2; i < 8; i += 2) flags[i] = true; +flags.length = 10; +let truthy = 0; +for (let i = 0; i < flags.length; i++) if (flags[i]) truthy++; +console.log("flags", truthy, 9 in flags, flags.indexOf(true), flags.lastIndexOf(false)); +flags[9] = true; +flags[8] = true; +truthy = 0; +for (let i = 0; i < flags.length; i++) if (flags[i]) truthy++; +console.log("flags-written", truthy, flags.join("")); diff --git a/tests/harness/self-hosting-native-toolchain.test.ts b/tests/harness/self-hosting-native-toolchain.test.ts index 3723a1132..d5104aa82 100644 --- a/tests/harness/self-hosting-native-toolchain.test.ts +++ b/tests/harness/self-hosting-native-toolchain.test.ts @@ -96,7 +96,7 @@ for (const backend of ["llvm"] as const) { object_format: target.objectFormat, minimum_os: target.minimumOs, }, - runtime_abi: { version: 14, marker: "scr_runtime_abi_v14" }, + runtime_abi: { version: 15, marker: "scr_runtime_abi_v15" }, compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple }, macros: { executable: [], excluded: [], sanitizer: "external-toolchain-required" }, flavors: {