From a7663551e3a8633f5b075ba086dc33227c108918 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 14:04:04 -0700 Subject: [PATCH 01/19] Install Polly in the managed sandbox bootstrap LLVM 22 CMake exports reference the Polly libraries, so building the LLVM helper in a sandbox started from vercel/sandbox/universal fails without libpolly-22-dev. Custom images that already contain it are unaffected. --- scripts/sandbox-bootstrap.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/sandbox-bootstrap.sh b/scripts/sandbox-bootstrap.sh index 54c06c8b1..ac0a4558a 100644 --- a/scripts/sandbox-bootstrap.sh +++ b/scripts/sandbox-bootstrap.sh @@ -30,6 +30,7 @@ sudo env DEBIAN_FRONTEND=noninteractive apt-get install --quiet=2 --yes --no-ins clang-22 \ cmake \ libclang-rt-22-dev \ + libpolly-22-dev \ libzstd-dev \ llvm-22-dev \ ninja-build \ From 1c974f015a29214c3218fdbc9975cf13aaca1a5d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 14:12:59 -0700 Subject: [PATCH 02/19] Keep guarded and in-bounds typed-array reads out of the optional ABI A typed-array read that may name a missing element lowers to a boxed `number | undefined`, and the optional-value analysis widens every parameter it reaches for all callers. Two patterns in tsc-ts's relater boxed its hottest relation methods this way, failing the build's scalar ABI check ("Boxed numeric arguments in Checker.isTypeAssignableTo"): - `const regular = this.regularTypes[id]; const sourceId = regular >= 0 ? regular : id;` The read is NaN-coded, but its occurrence in the true arm was rebuilt as `number | undefined`, so `sourceId` and the parameters of isSimpleTypeRelatedTo and structuredTypeRelatedTo were boxed. A comparison with a number that is never NaN is false for undefined and NaN alike, so an occurrence of a never-reassigned binding under such a guard is a plain, non-NaN number. The analysis now treats it as one wherever it flows. - `for (let i = 0; i < sources.length; i++) isTypeAssignableTo(sources[i], targets[i])` over two Uint32Arrays allocated with the same length. The parameters also receive a JSON field that may be NaN, so NaN-coding cannot carry them (`source === target` must tell NaN from undefined). The reads are now proven present instead: the index is a non-negative integer counter that only its own function writes, a dominating guard bounds it by the array's length or by the length of a parallel typed array allocated with the same length, and each array is a `const` fixed-length typed array referenced only through element accesses and `.length`, so no code can detach its buffer. A proven-present read lowers to the plain element read and is never optional. indexReadInBounds also accepts a read inside an `if` condition and takes an optional same-length fact. Reads the proofs do not cover keep the established lowering: an inclusive bound, a shorter parallel array, a decreasing or fractional counter, a counter a closure writes, and an array that escapes all still read undefined, as the new corpus program typed-array-present-reads checks against Node. --- .../src/frontend/lowering/nan-coded-reads.ts | 316 +++++++++++++++++- .../lowering/runtime-optional-bounds.test.ts | 24 ++ .../lowering/runtime-optional-bounds.ts | 40 ++- .../compiler/test/nan-coded-reads.test.ts | 61 ++++ tests/corpus/typed-array-present-reads.ts | 127 +++++++ 5 files changed, 552 insertions(+), 16 deletions(-) create mode 100644 tests/corpus/typed-array-present-reads.ts diff --git a/packages/compiler/src/frontend/lowering/nan-coded-reads.ts b/packages/compiler/src/frontend/lowering/nan-coded-reads.ts index 6f6a0306d..11eb4c889 100644 --- a/packages/compiler/src/frontend/lowering/nan-coded-reads.ts +++ b/packages/compiler/src/frontend/lowering/nan-coded-reads.ts @@ -2,6 +2,7 @@ import * as ts from "../ts7/adapter.js"; import { isJsSourceFile } from "../program.js"; import type { IrBytesElem } from "../../ir/ir.js"; import { isIntegerBytesElem } from "../../ir/ir.js"; +import { indexReadInBounds } from "./runtime-optional-bounds.js"; /* NaN-coded typed-array reads. * @@ -28,6 +29,13 @@ import { isIntegerBytesElem } from "../../ir/ir.js"; * neither side can also hold a genuine NaN, which the analysis tracks (an * integer element is never NaN; arithmetic and float elements may be). * + * Some occurrences are plain numbers outright. A read whose index is a + * non-negative integer counter bounded by a dominating `i < t.length` guard + * (or the length of a same-length, non-escaping typed array) is never + * undefined, so it is not NaN-coded at all. An occurrence of a never-reassigned + * NaN-coded binding under a dominating comparison with a number (`x >= 0 ? + * x : y`) is neither undefined nor NaN there. + * * Everything else keeps the established `number | undefined` lowering. */ export interface NanCodedHost { @@ -71,6 +79,8 @@ interface SlotBase { interface BindingSlot extends SlotBase { kind: "binding"; symbol: ts.Symbol; + /** Some reference writes the binding after its declaration. */ + reassigned: boolean; } interface ReturnSlot extends SlotBase { @@ -172,9 +182,61 @@ function isLoopOrIf(node: ts.Node): boolean { ); } +/** Constructors of fixed-length number typed arrays. */ +const TYPED_ARRAYS = new Set([ + "Int8Array", + "Uint8Array", + "Uint8ClampedArray", + "Int16Array", + "Uint16Array", + "Int32Array", + "Uint32Array", + "Float32Array", + "Float64Array", +]); + +function nonNegativeIntegerLiteral(node: ts.Expression): boolean { + const e = peelTransparent(node); + if (!ts.isNumericLiteral(e)) return false; + const value = Number(e.text); + return Number.isInteger(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER; +} + +/** `const a = ..., b = ...;` or two consecutive single declarations. */ +function adjacentDeclarations(a: ts.VariableDeclaration, b: ts.VariableDeclaration): boolean { + if (!ts.isVariableDeclaration(a) || !ts.isVariableDeclaration(b)) return false; + const la = a.parent; + const lb = b.parent; + if (!ts.isVariableDeclarationList(la) || !ts.isVariableDeclarationList(lb)) return false; + if (la === lb) { + const ia = la.declarations.indexOf(a); + const ib = la.declarations.indexOf(b); + return Math.abs(ia - ib) === 1; + } + const sa = la.parent; + const sb = lb.parent; + if (!ts.isVariableStatement(sa) || !ts.isVariableStatement(sb) || sa.parent !== sb.parent) + return false; + if (la.declarations.length !== 1 || lb.declarations.length !== 1) return false; + const list = sa.parent; + if ( + !ts.isBlock(list) && + !ts.isSourceFile(list) && + !ts.isCaseClause(list) && + !ts.isDefaultClause(list) + ) + return false; + const statements = (list as { statements: ts.NodeArray }).statements; + return Math.abs(statements.indexOf(sa) - statements.indexOf(sb)) === 1; +} + export class NanCodedReads { /** Typed-array element reads that can lower as NaN-coded doubles. */ private readonly reads = new Map(); + /** Typed-array element reads proven to name an existing element. */ + private readonly presentReads = new Map(); + private readonly counters = new Map(); + private readonly fixedLengths = new Map(); private readonly bindings = new Map(); private readonly notSlots = new Set(); /** Every slot's value occurrences. */ @@ -200,7 +262,7 @@ export class NanCodedReads { /** The read lowers to a double holding NaN for an invalid index. */ isNumericRead(read: ts.ElementAccessExpression): boolean { - return this.numericReads.has(read); + return this.numericReads.has(read) || this.presentReads.has(read); } /** Both operands of this equality are NaN-coded values that may stand @@ -223,13 +285,18 @@ export class NanCodedReads { return slot !== undefined && this.eligible.has(slot); } - get size(): { reads: number; slots: number } { - return { reads: this.numericReads.size, slots: this.eligible.size }; + get size(): { reads: number; slots: number; present: number } { + return { + reads: this.numericReads.size, + slots: this.eligible.size, + present: this.presentReads.size, + }; } analyze(): void { if (this.analyzed) return; this.analyzed = true; + const candidates = new Map(); for (const sf of this.files) { if (isJsSourceFile(sf) || sf.isDeclarationFile) continue; ts.walkPreorder(sf, (node) => { @@ -241,10 +308,14 @@ export class NanCodedReads { } if (ts.isElementAccessExpression(node) && this.isReadCandidate(node)) { const elem = this.host.bytesElemOf(node.expression); - if (elem) this.reads.set(node, elem); + if (elem) candidates.set(node, elem); } }); } + // A proven-present read is a plain number; only the rest may be + // undefined. The proof needs every reference, so it runs after the walk. + for (const [read, elem] of candidates) + (this.presentRead(read) ? this.presentReads : this.reads).set(read, elem); if (this.reads.size === 0) return; // Forward: the slots a read can reach. const work: ts.Expression[] = [...this.reads.keys()]; @@ -305,7 +376,7 @@ export class NanCodedReads { process.stderr.write(`nan-coded ${ok ? "yes" : "no "} ${name(slot)} ${why}\n`); } process.stderr.write( - `nan-coded reads ${this.numericReads.size}/${this.reads.size}, slots ${this.eligible.size}/${reached.size}\n`, + `nan-coded reads ${this.numericReads.size}/${this.reads.size}, present ${this.presentReads.size}, slots ${this.eligible.size}/${reached.size}\n`, ); } @@ -351,6 +422,213 @@ export class NanCodedReads { return true; } + // ---- present reads ------------------------------------------------------ + + /** `t[i]` names an existing element: `i` is a non-negative integer + * counter and a dominating guard bounds it by `t.length` (or by the + * length of a typed array that always has the same length). + * + * The guard proof only sees code written between the guard and the read. + * Code it cannot see (a getter, a conversion hook) cannot reach either + * operand: `t` is a fixed-length typed array no other code can reach, and + * only the reading function itself writes `i`. */ + private presentRead(read: ts.ElementAccessExpression): boolean { + const array = peelTransparent(read.expression); + const index = peelTransparent(read.argumentExpression); + if (!ts.isIdentifier(array) || !ts.isIdentifier(index)) return false; + const arraySymbol = this.symbolOf(array); + if (!arraySymbol || !this.fixedLength(arraySymbol)) return false; + const symbol = this.symbolOf(index); + if (!symbol || !this.nonNegativeCounter(symbol)) return false; + const decl = this.host.declarationOf(symbol); + if (!decl || this.containingFunction(decl) !== this.containingFunction(read)) return false; + return indexReadInBounds(read, { + sameLength: (array, other) => this.sameFixedLength(array, other), + }); + } + + /** A `let`/`const` binding that starts at a non-negative integer literal + * and only ever grows by non-negative integer literals (`i++`, `++i`, + * `i += 2`) or is reset to one (`i = 0`): it always holds a non-negative + * integer. Every write is in the declaring function (no closure writes). */ + private nonNegativeCounter(symbol: ts.Symbol): boolean { + const known = this.counters.get(symbol); + if (known !== undefined) return known; + this.counters.set(symbol, false); + const decl = this.host.declarationOf(symbol); + if ( + !decl || + !ts.isVariableDeclaration(decl) || + !ts.isIdentifier(decl.name) || + !decl.initializer || + !nonNegativeIntegerLiteral(decl.initializer) || + !ts.isVariableDeclarationList(decl.parent) || + !(decl.parent.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)) || + isJsSourceFile(decl.getSourceFile()) + ) + return false; + const name = decl.name; + const owner = this.containingFunction(decl); + for (const id of this.names.get(name.text) ?? []) { + if (id === name || this.symbolOf(id) !== symbol) continue; + let n: ts.Node = id; + let p = n.parent; + while (p && ts.isParenthesizedExpression(p)) { + n = p; + p = n.parent; + } + if (!p) return false; + if (this.writeOf(id) !== "read" && this.containingFunction(id) !== owner) return false; + if (ts.isBinaryExpression(p) && p.left === n && isAssignmentOperator(p.operatorToken.kind)) { + const k = p.operatorToken.kind; + if (k !== ts.SyntaxKind.EqualsToken && k !== ts.SyntaxKind.PlusEqualsToken) return false; + if (!nonNegativeIntegerLiteral(p.right)) return false; + continue; + } + if (ts.isPrefixUnaryExpression(p) || ts.isPostfixUnaryExpression(p)) { + if (p.operator === ts.SyntaxKind.MinusMinusToken) return false; + continue; + } + // Any other write position (destructuring, for-in/of) is a write of + // an unknown value. + if (this.writeOf(id) !== "read") return false; + } + this.counters.set(symbol, true); + return true; + } + + /** Two typed arrays whose lengths are equal for their whole lifetime. */ + private sameFixedLength(array: ts.Expression, other: ts.Expression): boolean { + const a = peelTransparent(array); + const b = peelTransparent(other); + if (!ts.isIdentifier(a) || !ts.isIdentifier(b)) return false; + const sa = this.symbolOf(a); + const sb = this.symbolOf(b); + if (!sa || !sb || sa === sb) return false; + const na = this.fixedLength(sa); + const nb = this.fixedLength(sb); + if (!na || !nb) return false; + const la = na.arguments![0]!; + const lb = nb.arguments![0]!; + // The same literal length. + const literal = (e: ts.Expression): number | null => { + const x = peelTransparent(e); + return ts.isNumericLiteral(x) ? Number(x.text) : null; + }; + const literalA = literal(la); + if (literalA !== null) return literalA === literal(lb); + // The same `const` binding. + const ia = peelTransparent(la); + const ib = peelTransparent(lb); + if (ts.isIdentifier(ia) && ts.isIdentifier(ib)) { + const symbol = this.symbolOf(ia); + if (!symbol || symbol !== this.symbolOf(ib)) return false; + const decl = this.host.declarationOf(symbol); + return ( + decl !== undefined && + ts.isVariableDeclaration(decl) && + ts.isVariableDeclarationList(decl.parent) && + (decl.parent.flags & ts.NodeFlags.Const) !== 0 && + this.host.isNumber(ia) + ); + } + // `xs.length` of the same `const` array or typed array, read by two + // adjacent declarations: nothing runs between the two reads that could + // change it (allocating a typed array runs no program code). + if ( + ts.isPropertyAccessExpression(ia) && + ts.isPropertyAccessExpression(ib) && + !ia.questionDotToken && + !ib.questionDotToken && + ia.name.text === "length" && + ib.name.text === "length" && + ts.isIdentifier(peelTransparent(ia.expression)) && + ts.isIdentifier(peelTransparent(ib.expression)) && + this.host.hasLength(ia.expression) + ) { + const owner = this.symbolOf(peelTransparent(ia.expression)); + if (!owner || owner !== this.symbolOf(peelTransparent(ib.expression))) return false; + const ownerDecl = this.host.declarationOf(owner); + if ( + !ownerDecl || + !( + (ts.isVariableDeclaration(ownerDecl) && + ts.isVariableDeclarationList(ownerDecl.parent) && + ownerDecl.parent.flags & ts.NodeFlags.Const) || + ts.isParameter(ownerDecl) + ) + ) + return false; + return adjacentDeclarations( + na.parent as ts.VariableDeclaration, + nb.parent as ts.VariableDeclaration, + ); + } + return false; + } + + /** The allocation `new Uint8Array(n)` of a `const` typed array whose every + * reference is an element access or `.length`: nothing can reach its + * buffer to detach it, so its length never changes. */ + private fixedLength(symbol: ts.Symbol): ts.NewExpression | null { + const known = this.fixedLengths.get(symbol); + if (known !== undefined) return known; + this.fixedLengths.set(symbol, null); + const decl = this.host.declarationOf(symbol); + if ( + !decl || + !ts.isVariableDeclaration(decl) || + !ts.isIdentifier(decl.name) || + !decl.initializer || + !ts.isVariableDeclarationList(decl.parent) || + !(decl.parent.flags & ts.NodeFlags.Const) || + // A module binding can escape through an export. + (ts.isVariableStatement(decl.parent.parent) && ts.isSourceFile(decl.parent.parent.parent)) || + isJsSourceFile(decl.getSourceFile()) || + this.host.bytesElemOf(decl.name) === null + ) + return null; + const init = peelTransparent(decl.initializer); + if ( + !ts.isNewExpression(init) || + !ts.isIdentifier(init.expression) || + !TYPED_ARRAYS.has(init.expression.text) || + !this.host.isStdlibGlobal(init.expression, init.expression.text) || + init.typeArguments || + init.arguments?.length !== 1 || + ts.isSpreadElement(init.arguments[0]!) || + !this.host.isNumber(init.arguments[0]!) + ) + return null; + const name = decl.name; + for (const id of this.names.get(name.text) ?? []) { + if (id === name || this.symbolOf(id) !== symbol) continue; + let n: ts.Node = id; + let p = n.parent; + while (p && ts.isParenthesizedExpression(p)) { + n = p; + p = n.parent; + } + if (!p) return null; + if (ts.isElementAccessExpression(p) && p.expression === n) continue; + if ( + ts.isPropertyAccessExpression(p) && + p.expression === n && + p.name.text === "length" && + !( + p.parent && + ts.isBinaryExpression(p.parent) && + p.parent.left === p && + isAssignmentOperator(p.parent.operatorToken.kind) + ) + ) + continue; + return null; + } + this.fixedLengths.set(symbol, init); + return init; + } + // ---- slots -------------------------------------------------------------- /** The function whose `return` statement contains `node`. */ @@ -461,6 +739,7 @@ export class NanCodedReads { sources: [], uses: [], arithmeticWrites: false, + reassigned: false, }; if (ts.isVariableDeclaration(decl)) { const list = decl.parent; @@ -496,6 +775,7 @@ export class NanCodedReads { for (const id of this.names.get(nameNode.text) ?? []) { if (id === nameNode || this.symbolOf(id) !== symbol) continue; const write = this.writeOf(id); + if (write !== "read") slot.reassigned = true; if (write === "read") slot.uses.push(id); else if (write === "arith") { slot.arithmeticWrites = true; @@ -746,6 +1026,7 @@ export class NanCodedReads { if (elem !== undefined) return { nce: true, nan: !isIntegerBytesElem(elem) }; } if (ts.isIdentifier(e)) { + if (this.guardedOccurrence(e)) return { nce: false, nan: false }; const symbol = this.symbolOf(e); const slot = symbol ? this.bindings.get(symbol) : undefined; if (slot && this.eligible.has(slot)) return { nce: true, nan: this.genuineNaN.has(slot) }; @@ -826,6 +1107,24 @@ export class NanCodedReads { return info !== null && !info.nan; } + /** An occurrence of a never-reassigned number binding under a dominating + * comparison with a number that is never NaN (`x >= 0 ? x : y`): the + * comparison is false for undefined and for NaN, so here the binding + * holds a plain number that is not NaN, even when the binding itself is + * NaN-coded. */ + private guardedOccurrence(use: ts.Identifier): boolean { + const symbol = this.symbolOf(use); + if (!symbol) return false; + const slot = this.bindingSlot(symbol); + return ( + slot !== null && + !slot.reassigned && + !slot.arithmeticWrites && + this.useNodes.has(use) && + this.provenPresent(use, symbol) + ); + } + /** A dominating condition compares the never-reassigned binding with a * number literal, which is false for undefined. */ private provenPresent(use: ts.Identifier, symbol: ts.Symbol): boolean { @@ -879,6 +1178,7 @@ export class NanCodedReads { if (elem !== undefined && this.numericReads.has(e)) return { nce: true, nan: !isIntegerBytesElem(elem) }; } + if (ts.isIdentifier(e) && this.guardedOccurrence(e)) return { nce: false, nan: false }; const slot = this.slotOfValue(e); if (slot) return { nce: true, nan: this.genuineNaN.has(slot) }; return { nce: false, nan: true }; @@ -900,6 +1200,10 @@ export class NanCodedReads { while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isSatisfiesExpression(e)) e = e.expression; if (ts.isNumericLiteral(e)) return { nce: false, nan: false }; + if (ts.isElementAccessExpression(e)) { + const elem = this.presentReads.get(e); + return elem === undefined ? null : { nce: false, nan: !isIntegerBytesElem(elem) }; + } if (ts.isPrefixUnaryExpression(e)) { if (e.operator === ts.SyntaxKind.TildeToken) return { nce: false, nan: false }; if (e.operator === ts.SyntaxKind.MinusToken || e.operator === ts.SyntaxKind.PlusToken) @@ -986,6 +1290,8 @@ export class NanCodedReads { /** Whether the value's consumer cannot tell undefined from NaN. */ private useSafe(value: ts.Expression): boolean { + // A plain number here, whatever consumes it. + if (ts.isIdentifier(value) && this.guardedOccurrence(value)) return true; const e = transparentParent(value); const p = e.parent; if (!p) return false; diff --git a/packages/compiler/src/frontend/lowering/runtime-optional-bounds.test.ts b/packages/compiler/src/frontend/lowering/runtime-optional-bounds.test.ts index 0b61f46f7..a1b4f97f2 100644 --- a/packages/compiler/src/frontend/lowering/runtime-optional-bounds.test.ts +++ b/packages/compiler/src/frontend/lowering/runtime-optional-bounds.test.ts @@ -80,3 +80,27 @@ test("a guarded read forwarded to a call is bounded when nothing earlier can cha }); expect(results).toEqual([true, true, false, false, true]); }); + +test("an if condition reads like a statement, and a same-length fact extends the guard", () => { + const file = parseSourceFile( + "same-length.ts", + `declare function use(...values: number[]): boolean; + function f(xs: Uint32Array, ys: Uint32Array, zs: Uint32Array) { + for (let i = 0; i < xs.length; i++) if (use(xs[i], ys[i], zs[i])) return; + for (let i = 0; i < xs.length; i++) if (use(xs[i], use(), ys[i])) return; + }`, + "ts", + ); + const results: boolean[] = []; + const facts = { + sameLength: (array: ts.Expression, other: ts.Expression) => + ts.isIdentifier(array) && + ts.isIdentifier(other) && + [array.text, other.text].sort().join() === "xs,ys", + }; + ts.walkPreorder(file, (node) => { + if (ts.isElementAccessExpression(node)) results.push(indexReadInBounds(node, facts)); + return undefined; + }); + expect(results).toEqual([true, true, false, true, false]); +}); diff --git a/packages/compiler/src/frontend/lowering/runtime-optional-bounds.ts b/packages/compiler/src/frontend/lowering/runtime-optional-bounds.ts index ccc8740af..98d0bbc49 100644 --- a/packages/compiler/src/frontend/lowering/runtime-optional-bounds.ts +++ b/packages/compiler/src/frontend/lowering/runtime-optional-bounds.ts @@ -24,12 +24,24 @@ export function indexStoreInBounds(store: ts.BinaryExpression): boolean { return boundedBefore(parent, { array, index }, [store.right]); } +/** Facts about the program that a purely syntactic guard cannot see. */ +export interface BoundsFacts { + /** `other` always has the same length as `array` (for example, two + * fixed-length typed arrays allocated with the same length), so a guard + * `i < other.length` bounds `array[i]` as well. */ + sameLength?(array: ts.Expression, other: ts.Expression): boolean; +} + /** Proves that an element read `xs[i]` (typically `xs[i]!` forwarded to a * call) names an index below the length, by the same dominating guards as * indexStoreInBounds. Everything the enclosing statement evaluates before * the read (a callee, earlier arguments, a left operand) joins the code - * that must leave the operands unchanged. */ -export function indexReadInBounds(read: ts.ElementAccessExpression): boolean { + * that must leave the operands unchanged. The proof says nothing about the + * sign or integrality of the index. */ +export function indexReadInBounds( + read: ts.ElementAccessExpression, + facts: BoundsFacts = {}, +): boolean { if (read.questionDotToken) return false; const array = peel(read.expression); const index = peel(read.argumentExpression); @@ -63,12 +75,15 @@ export function indexReadInBounds(read: ts.ElementAccessExpression): boolean { parent.parent.declarations[0] === parent && ts.isVariableStatement(parent.parent.parent) ) { - return boundedBefore(parent.parent.parent, { array, index }, before); + return boundedBefore(parent.parent.parent, { array, index, facts }, before); } else if ( - (ts.isExpressionStatement(parent) || ts.isReturnStatement(parent)) && + (ts.isExpressionStatement(parent) || + ts.isReturnStatement(parent) || + ts.isIfStatement(parent)) && parent.expression === child ) { - return boundedBefore(parent, { array, index }, before); + // An `if` condition runs before its branches, like a statement. + return boundedBefore(parent, { array, index, facts }, before); } else return false; child = parent; parent = parent.parent; @@ -138,6 +153,7 @@ function boundedBefore(statement: ts.Statement, operands: Operands, start: ts.No interface Operands { array: ts.Expression; index: ts.Expression; + facts?: BoundsFacts; } function isFunctionBoundary(node: ts.Node): boolean { @@ -180,26 +196,28 @@ function sameReference(a: ts.Expression, b: ts.Expression): boolean { return false; } -function isLengthOf(node: ts.Expression, array: ts.Expression): boolean { +function isLengthOf(node: ts.Expression, array: ts.Expression, facts?: BoundsFacts): boolean { const e = peel(node); return ( ts.isPropertyAccessExpression(e) && + !e.questionDotToken && e.name.text === "length" && - sameReference(e.expression, array) + (sameReference(e.expression, array) || facts?.sameLength?.(array, e.expression) === true) ); } /** `condition` holding proves `index < array.length`. */ -function boundedBy(condition: ts.Expression, { array, index }: Operands): boolean { +function boundedBy(condition: ts.Expression, operands: Operands): boolean { + const { array, index, facts } = operands; const e = peel(condition); if (!ts.isBinaryExpression(e)) return false; const op = e.operatorToken.kind; if (op === ts.SyntaxKind.AmpersandAmpersandToken) - return boundedBy(e.left, { array, index }) || boundedBy(e.right, { array, index }); + return boundedBy(e.left, operands) || boundedBy(e.right, operands); if (op === ts.SyntaxKind.LessThanToken) - return sameReference(e.left, index) && isLengthOf(e.right, array); + return sameReference(e.left, index) && isLengthOf(e.right, array, facts); if (op === ts.SyntaxKind.GreaterThanToken) - return isLengthOf(e.left, array) && sameReference(e.right, index); + return isLengthOf(e.left, array, facts) && sameReference(e.right, index); return false; } diff --git a/packages/compiler/test/nan-coded-reads.test.ts b/packages/compiler/test/nan-coded-reads.test.ts index 5820e8f3d..0cf1276f8 100644 --- a/packages/compiler/test/nan-coded-reads.test.ts +++ b/packages/compiler/test/nan-coded-reads.test.ts @@ -84,3 +84,64 @@ test("reads whose consumers treat undefined like NaN lower to plain numbers", as // String() observes undefined: the ordinary optional read remains. expect(calls(fn(mod, "show").body).some((c) => c.startsWith("%bytes.idxOr"))).toBe(true); }); + +const relation = ` +class Relation { + private readonly regular = new Int32Array(4); + assignable(source: number, target: number): boolean { + if (source === target) return true; + return this.related(source, target); + } + related(inputSource: number, inputTarget: number): boolean { + const regularSource = this.regular[inputSource]; + const sourceId = regularSource >= 0 ? regularSource : inputSource; + return this.simple(sourceId, inputTarget); + } + simple(sourceId: number, targetId: number): boolean { + return sourceId === targetId; + } +} +function probe(a: number, b: number): boolean { + return a === b; +} +const relation = new Relation(); +const nan = process.argv.length / 0 - process.argv.length / 0; +const ids = [1, nan, 3]; +const sources = new Uint32Array(ids.length); +const targets = new Uint32Array(ids.length); +const shorter = new Uint32Array(ids.length - 1); +for (let i = 0; i < ids.length; i++) { + sources[i] = ids[i]!; + targets[i] = ids[i]!; + relation.assignable(ids[i]!, nan); + probe(ids[i]!, nan); +} +function run(): number { + const xs = new Uint32Array(ids.length); + const ys = new Uint32Array(ids.length); + let hits = 0; + for (let i = 0; i < xs.length; i++) if (relation.assignable(xs[i], ys[i])) hits++; + for (let i = 0; i < xs.length; i++) if (probe(xs[i], shorter[i])) hits++; + return hits; +} +console.log(run(), sources.length, targets.length); +`; + +test("proven-present reads and guarded occurrences keep numeric parameters plain", async () => { + const mod = await lower(relation); + const f64 = { kind: "f64" }; + // Parallel same-length typed arrays read under `i < xs.length`. + expect( + fn(mod, "assignable") + .params.slice(1) + .map((p) => p.type), + ).toEqual([f64, f64]); + // `regularSource >= 0 ? regularSource : ...` holds a number in its true arm. + expect( + fn(mod, "simple") + .params.slice(1) + .map((p) => p.type), + ).toEqual([f64, f64]); + // `shorter` is not as long as `xs`: its read may be undefined. + expect(fn(mod, "probe").params[1]!.type.kind).toBe("union"); +}); diff --git a/tests/corpus/typed-array-present-reads.ts b/tests/corpus/typed-array-present-reads.ts new file mode 100644 index 000000000..da0f02421 --- /dev/null +++ b/tests/corpus/typed-array-present-reads.ts @@ -0,0 +1,127 @@ +// Typed-array reads proven to name an element (a non-negative counter under +// an `i < t.length` guard, including the length of a same-length parallel +// array), and NaN-coded reads under a comparison that excludes undefined, +// are plain numbers. The neighbors those proofs must not cover still read +// undefined for an index outside [0, length), like Node. +const base: number = process.argv.length; // 2 under both runtimes +const nan = base / 0 - base / 0; + +class Relation { + private readonly regular: Int32Array; + calls = 0; + constructor(size: number) { + this.regular = new Int32Array(size); + for (let i = 0; i < size; i++) this.regular[i] = i % 3 === 0 ? -1 : size - i; + } + assignable(source: number, target: number): boolean { + if (source === target) return true; + this.calls++; + return this.related(Number(source), Number(target)) !== 0; + } + private related(inputSource: number, inputTarget: number): number { + if (inputSource === inputTarget) return 1; + // Out of range or NaN: undefined, so the comparison picks the input. + const regularSource = this.regular[inputSource]; + const regularTarget = this.regular[inputTarget]; + const sourceId = regularSource >= 0 ? regularSource : inputSource; + const targetId = regularTarget >= 0 ? regularTarget : inputTarget; + if (sourceId === targetId) return 1; + return this.simple(sourceId, targetId) ? 1 : 0; + } + private simple(sourceId: number, targetId: number): boolean { + return sourceId < targetId || sourceId !== sourceId || targetId !== targetId; + } +} + +interface Query { + source: number; + target: number; +} + +function check(queries: Query[]): void { + const relation = new Relation(8); + const results: boolean[] = []; + const sources = new Uint32Array(queries.length); + const targets = new Uint32Array(queries.length); + for (let i = 0; i < queries.length; i++) { + const query = queries[i]; + sources[i] = query.source; + targets[i] = query.target; + results.push(relation.assignable(query.source, query.target)); + } + let checksum = 0; + for (let round = 0; round < 3; round++) { + for (let i = 0; i < sources.length; i++) { + if (relation.assignable(sources[i], targets[i])) checksum++; + } + } + console.log("results", results.join(",")); + console.log("checksum", checksum, "calls", relation.calls); + const lanes: number[] = []; + for (let i = 0; i < sources.length; i++) lanes.push(sources[i], targets[i]); + console.log("lanes", lanes.join(",")); +} + +check([ + { source: 1, target: 1 }, + { source: 2, target: 5 }, + { source: 3, target: 0 }, + { source: 7, target: 4 }, + { source: nan, target: nan }, + { source: nan, target: 2 }, + { source: 12, target: 3 }, + { source: -1, target: 6 }, + { source: 2.5, target: 1 }, +]); + +function show(label: string, value: number): string { + return `${label}=${typeof value}:${String(value)}`; +} + +function equal(a: number, b: number): boolean { + return a === b; +} + +// Neighbors: every read below may name a missing element. +function neighbors(n: number): void { + const long = new Uint16Array(n); + const short = new Uint16Array(n - 1); + for (let i = 0; i < n; i++) long[i] = 10 + i; + for (let i = 0; i < n - 1; i++) short[i] = 20 + i; + const out: string[] = []; + // Inclusive bound: the last read is past the end. + for (let i = 0; i <= long.length; i++) out.push(show("le" + i, long[i])); + // A shorter parallel array is not bounded by the longer one's length. + for (let i = 0; i < long.length; i++) out.push(show("par" + i, short[i])); + // A counter that goes down reaches -1. + for (let i = 1; i >= -1; i--) out.push(show("down" + i, long[i])); + // A counter that a closure also writes. + let k = 0; + const skip = (): void => { + k += 2; + }; + for (; k < long.length; k++) { + skip(); + out.push(show("skip" + k, long[k])); + } + // A fractional step is not an integer index. + let f = 0; + for (; f < long.length; f += 1.5) out.push(show("frac" + f, long[f])); + console.log(out.join(" ")); + // Undefined equals undefined even through parameters. + const missing = long[n + 3]; + const alsoMissing = short[n + 3]; + console.log("equal", equal(missing, alsoMissing), equal(long[0], long[0]), equal(missing, nan)); + // A guarded occurrence is a number; the unguarded one is still undefined. + const regular = long[n + 1]; + const picked = regular >= 0 ? regular : -7; + console.log("guard", picked, show("raw", regular), regular > 3 ? show("in", regular) : "none"); + let moved = long[0]; + if (moved >= 0) { + moved = long[n + 5]; + console.log("moved", show("m", moved)); + } +} + +neighbors(4); +neighbors(base); From 8e07b1cffc15fd5e078efc840a21315d2cd0e2ed Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 14:14:16 -0700 Subject: [PATCH 03/19] Pin that an escaping typed array keeps its optional reads Code holding the array could detach its buffer between the guard and the read, so the present-read proof must refuse it. With a genuine NaN also reaching the parameter and a consumer that observes undefined, the parameter stays `number | undefined`. --- packages/compiler/test/nan-coded-reads.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/compiler/test/nan-coded-reads.test.ts b/packages/compiler/test/nan-coded-reads.test.ts index 0cf1276f8..8d18b62f4 100644 --- a/packages/compiler/test/nan-coded-reads.test.ts +++ b/packages/compiler/test/nan-coded-reads.test.ts @@ -104,6 +104,12 @@ class Relation { function probe(a: number, b: number): boolean { return a === b; } +function escaped(a: number): boolean { + return String(a) === "0"; +} +function grab(t: Uint32Array): number { + return t.length; +} const relation = new Relation(); const nan = process.argv.length / 0 - process.argv.length / 0; const ids = [1, nan, 3]; @@ -115,6 +121,7 @@ for (let i = 0; i < ids.length; i++) { targets[i] = ids[i]!; relation.assignable(ids[i]!, nan); probe(ids[i]!, nan); + escaped(nan); } function run(): number { const xs = new Uint32Array(ids.length); @@ -122,6 +129,9 @@ function run(): number { let hits = 0; for (let i = 0; i < xs.length; i++) if (relation.assignable(xs[i], ys[i])) hits++; for (let i = 0; i < xs.length; i++) if (probe(xs[i], shorter[i])) hits++; + const zs = new Uint32Array(ids.length); + grab(zs); + for (let i = 0; i < zs.length; i++) if (escaped(zs[i])) hits++; return hits; } console.log(run(), sources.length, targets.length); @@ -144,4 +154,6 @@ test("proven-present reads and guarded occurrences keep numeric parameters plain ).toEqual([f64, f64]); // `shorter` is not as long as `xs`: its read may be undefined. expect(fn(mod, "probe").params[1]!.type.kind).toBe("union"); + // Code holding `zs` could detach its buffer: no proof. + expect(fn(mod, "escaped").params[0]!.type.kind).toBe("union"); }); From b9eb5254f08da5158af4988b89083dc38e5b6065 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 14:24:15 -0700 Subject: [PATCH 04/19] Keep the `if`-condition bound opt-in for forwarded assertions indexReadInBounds proves only `i < xs.length`, not that `i` is a non-negative integer. For `if (f(xs[i]!))` the forwarded-assertion path used to pass a missing element as undefined; accepting `if` conditions for every caller turned such reads into the dense bounds trap, which throws for a negative index where Node passes undefined (tsc-ts: isMatchableRegion and compareSymbolChains changed ABI). The `if`-condition position is now an opt-in fact, used by the present-read proof, which also proves the index a non-negative integer counter. --- .../compiler/src/frontend/lowering/nan-coded-reads.ts | 1 + .../src/frontend/lowering/runtime-optional-bounds.test.ts | 8 ++++++++ .../src/frontend/lowering/runtime-optional-bounds.ts | 6 +++++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/lowering/nan-coded-reads.ts b/packages/compiler/src/frontend/lowering/nan-coded-reads.ts index 11eb4c889..a716cb2bb 100644 --- a/packages/compiler/src/frontend/lowering/nan-coded-reads.ts +++ b/packages/compiler/src/frontend/lowering/nan-coded-reads.ts @@ -444,6 +444,7 @@ export class NanCodedReads { if (!decl || this.containingFunction(decl) !== this.containingFunction(read)) return false; return indexReadInBounds(read, { sameLength: (array, other) => this.sameFixedLength(array, other), + conditions: true, }); } diff --git a/packages/compiler/src/frontend/lowering/runtime-optional-bounds.test.ts b/packages/compiler/src/frontend/lowering/runtime-optional-bounds.test.ts index a1b4f97f2..fafacd265 100644 --- a/packages/compiler/src/frontend/lowering/runtime-optional-bounds.test.ts +++ b/packages/compiler/src/frontend/lowering/runtime-optional-bounds.test.ts @@ -93,6 +93,7 @@ test("an if condition reads like a statement, and a same-length fact extends the ); const results: boolean[] = []; const facts = { + conditions: true, sameLength: (array: ts.Expression, other: ts.Expression) => ts.isIdentifier(array) && ts.isIdentifier(other) && @@ -103,4 +104,11 @@ test("an if condition reads like a statement, and a same-length fact extends the return undefined; }); expect(results).toEqual([true, true, false, true, false]); + // Without the opt-in, a read in an `if` condition keeps its old answer. + const plain: boolean[] = []; + ts.walkPreorder(file, (node) => { + if (ts.isElementAccessExpression(node)) plain.push(indexReadInBounds(node)); + return undefined; + }); + expect(plain).toEqual([false, false, false, false, false]); }); diff --git a/packages/compiler/src/frontend/lowering/runtime-optional-bounds.ts b/packages/compiler/src/frontend/lowering/runtime-optional-bounds.ts index 98d0bbc49..3768949ab 100644 --- a/packages/compiler/src/frontend/lowering/runtime-optional-bounds.ts +++ b/packages/compiler/src/frontend/lowering/runtime-optional-bounds.ts @@ -30,6 +30,10 @@ export interface BoundsFacts { * fixed-length typed arrays allocated with the same length), so a guard * `i < other.length` bounds `array[i]` as well. */ sameLength?(array: ts.Expression, other: ts.Expression): boolean; + /** A read inside an `if` condition is bounded like one in a statement. + * Opt-in: a caller that does not also prove the index a non-negative + * integer keeps its established decision for such reads. */ + conditions?: boolean; } /** Proves that an element read `xs[i]` (typically `xs[i]!` forwarded to a @@ -79,7 +83,7 @@ export function indexReadInBounds( } else if ( (ts.isExpressionStatement(parent) || ts.isReturnStatement(parent) || - ts.isIfStatement(parent)) && + (facts.conditions === true && ts.isIfStatement(parent))) && parent.expression === child ) { // An `if` condition runs before its branches, like a statement. From 1c9071157268f663c276e359af734600ac0635d7 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 14:35:04 -0700 Subject: [PATCH 05/19] Record the preflight/order baseline for typed-array-present-reads One new entry (single-module order, no diagnostics); every existing entry is unchanged. --- packages/compiler/test/ts7/baselines/order-parity.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 1c7788be6..caf0cd373 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -11415,6 +11415,12 @@ ], "diags": [] }, + "/tests/corpus/typed-array-present-reads.ts": { + "order": [ + "/tests/corpus/typed-array-present-reads.ts" + ], + "diags": [] + }, "/tests/corpus/typed-array-unknown.ts": { "order": [ "/tests/corpus/typed-array-unknown.ts" From 67f9de094c983e7797e25b2b271fbbe372bf8f97 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 14:47:55 -0700 Subject: [PATCH 06/19] Convert lengths and in-range indices to doubles as non-negative Array, string and byte lengths, counts, capacities and range-checked indices are size_t values below 2^63, but they were converted with a plain uitofp. On x86-64 without AVX-512 an unsigned 64-bit conversion expands to a five-instruction sequence with two constant-pool loads (unpcklps/subpd/unpckhpd/addsd), and it showed up in the hottest tsc-ts checker loops (compareTypeLists, getPropertyOfTypeEx) between a length load and the conversion back to an integer. `uitofp nneg` states what is already true, so the backend emits one cvtsi2sd. arm64 converts unsigned values in one instruction either way. Byte-number indices, which admit values up to 2^64, keep the unsigned conversion. --- .../src/backend/llvm/dense-array-access.test.ts | 2 +- .../compiler/src/backend/llvm/dense-array-access.ts | 2 +- packages/compiler/src/backend/llvm/dyn.ts | 10 +++++----- packages/compiler/src/backend/llvm/emitter.ts | 10 +++++----- packages/compiler/src/backend/llvm/expr-bytes.ts | 6 +++--- packages/compiler/src/backend/llvm/expr-containers.ts | 2 +- .../compiler/src/backend/llvm/local-array-reads.ts | 4 ++-- packages/compiler/src/backend/llvm/string-reads.ts | 4 ++-- packages/compiler/src/backend/llvm/string-slices.ts | 2 +- 9 files changed, 21 insertions(+), 21 deletions(-) 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 f307fde54..92dac2afe 100644 --- a/packages/compiler/src/backend/llvm/dense-array-access.test.ts +++ b/packages/compiler/src/backend/llvm/dense-array-access.test.ts @@ -213,7 +213,7 @@ test("length assignments skip the runtime when the length is unchanged", () => { module("reset", VOID, [{ kind: "arraySetLength", arr: ref("s", words), length: index, loc }]), ); const fn = body(llvm, "reset"); - expect(fn).toMatch(/uitofp i64 .* to double/); + expect(fn).toMatch(/uitofp nneg i64 .* to double/); expect(fn).toMatch(/fcmp oeq double %p?\S*, %t\d+/); expect(fn.match(/call void @scr_arr_set_len\(/g)).toHaveLength(1); expect(fn).toMatch(/br i1 %t\d+, label %arr\.len\.done\d*, label %arr\.len\.set\d*/); diff --git a/packages/compiler/src/backend/llvm/dense-array-access.ts b/packages/compiler/src/backend/llvm/dense-array-access.ts index 88bea73ed..517658e94 100644 --- a/packages/compiler/src/backend/llvm/dense-array-access.ts +++ b/packages/compiler/src/backend/llvm/dense-array-access.ts @@ -411,7 +411,7 @@ export function emitDenseArrayPush( fastValue = B.tmp(); B.line(`${next} = add nuw ${size} ${len}, 1`); B.line(`store ${size} ${next}, ptr ${lenPtr}${host.fieldAliasAttachment(lenPtr)}`); - B.line(`${fastValue} = uitofp ${size} ${next} to double`); + B.line(`${fastValue} = uitofp nneg ${size} ${next} to double`); B.br(join); B.startBlock(slow); const slowValue = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 86ac62d8d..5912020f9 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -1668,7 +1668,7 @@ export class LlDyn { host.declare(`declare void @scr_arr_set_len(ptr, double)`); const index = B.tmp(); const length = B.tmp(); - B.line(`${index} = uitofp ${this.S} ${i} to double`); + B.line(`${index} = uitofp nneg ${this.S} ${i} to double`); B.line(`${length} = fadd double ${index}, 1.0`); B.line(`call void @scr_arr_set_len(ptr ${a}, double ${length})`); B.br(doneLabel); @@ -3409,7 +3409,7 @@ export class LlDyn { const n = this.lenOf(B, "%d"); const nd = B.tmp(); const r = B.tmp(); - B.line(`${nd} = uitofp ${host.sizeType} ${n} to double`); + B.line(`${nd} = uitofp nneg ${host.sizeType} ${n} to double`); B.line(`${r} = call ptr @scr_dyn_new_num(double ${nd})`); B.terminate(`ret ptr ${r}`); B.startBlock(lStr); @@ -3448,7 +3448,7 @@ export class LlDyn { const n2 = B.tmp(); B.line(`${n2} = call double @scr_str_utf16_len(ptr ${s})`); const idxD = B.tmp(); - B.line(`${idxD} = uitofp ${host.sizeType} ${idx} to double`); + B.line(`${idxD} = uitofp nneg ${host.sizeType} ${idx} to double`); const inR2 = B.tmp(); B.line(`${inR2} = fcmp olt double ${idxD}, ${n2}`); const lHit2 = B.newLabel("kg.ash"); @@ -3599,7 +3599,7 @@ export class LlDyn { host.declare(`declare ptr @scr_dyn_jsval_iter_n(ptr, double)`); const count = B.tmp(); const out = B.tmp(); - B.line(`${count} = uitofp ${host.sizeType} %n to double`); + B.line(`${count} = uitofp nneg ${host.sizeType} %n to double`); B.line(`${out} = call ptr @scr_dyn_jsval_iter_n(ptr %d, double ${count})`); B.terminate(`ret ptr ${out}`); B.startBlock(lNotJv); @@ -3737,7 +3737,7 @@ export class LlDyn { const index = B.tmp(); const bd = B.tmp(); const r = B.tmp(); - B.line(`${index} = uitofp ${host.sizeType} ${i} to double`); + B.line(`${index} = uitofp nneg ${host.sizeType} ${i} to double`); B.line(`${bd} = call double @scr_bytes_get(ptr ${bts}, double ${index})`); B.line(`${r} = call ptr @scr_dyn_new_num(double ${bd})`); B.line(`store ptr ${r}, ptr ${itemSlot}`); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 8ce49a03d..8b9bb381d 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1925,7 +1925,7 @@ export class LlEmitter { // The branch must dominate fptoui: NaN/out-of-range conversion // would produce poison. Fractional indices also need a fallback. ` %index = fptoui double %i to ${this.sizeType}`, - ` %roundtrip = uitofp ${this.sizeType} %index to double`, + ` %roundtrip = uitofp nneg ${this.sizeType} %index to double`, ` %integer = fcmp oeq double %roundtrip, %i`, ` br i1 %integer, label %read, label %fallback`, `read:`, @@ -2759,14 +2759,14 @@ export class LlEmitter { `entry:`, ` %ndp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 19`, ` %nd = load ${sz}, ptr %ndp`, - ` %ndf = uitofp ${sz} %nd to double`, + ` %ndf = uitofp nneg ${sz} %nd to double`, ` %lo = fcmp oge double %k, 0.0`, ` %hi = fcmp olt double %k, %ndf`, ` %in = and i1 %lo, %hi`, ` br i1 %in, label %direct, label %outside`, `direct:`, ` %i = fptoui double %k to ${sz}`, - ` %back = uitofp ${sz} %i to double`, + ` %back = uitofp nneg ${sz} %i to double`, ` %int = fcmp oeq double %back, %k`, ` br i1 %int, label %slot, label %miss`, `slot:`, @@ -5626,7 +5626,7 @@ export class LlEmitter { B.line(`${lenPtr} = getelementptr inbounds %ScrArr, ptr ${arr.name}, i32 0, i32 1`); this.markMemoryPointer(lenPtr, "array:header"); B.line(`${len} = load ${this.sizeType}, ptr ${lenPtr}${this.fieldAliasAttachment(lenPtr)}`); - B.line(`${lenNumber} = uitofp ${this.sizeType} ${len} to double`); + B.line(`${lenNumber} = uitofp nneg ${this.sizeType} ${len} to double`); B.line(`${same} = fcmp oeq double ${length.name}, ${lenNumber}`); B.condBr(same, done, resize); B.startBlock(resize); @@ -6255,7 +6255,7 @@ export class LlEmitter { B.line( `${rawLen} = load ${this.sizeType}, ptr ${lenPtr}${this.fieldAliasAttachment(lenPtr)}`, ); - B.line(`${len} = uitofp ${this.sizeType} ${rawLen} to double`); + B.line(`${len} = uitofp nneg ${this.sizeType} ${rawLen} to double`); B.line(`${inBounds} = fcmp olt double ${i}, ${len}`); cur = i; } diff --git a/packages/compiler/src/backend/llvm/expr-bytes.ts b/packages/compiler/src/backend/llvm/expr-bytes.ts index 21fd1ed32..30cea3f4f 100644 --- a/packages/compiler/src/backend/llvm/expr-bytes.ts +++ b/packages/compiler/src/backend/llvm/expr-bytes.ts @@ -122,7 +122,7 @@ export function emitBytesIndex( const nonnegative = B.tmp(); const belowLen = B.tmp(); const inRange = B.tmp(); - B.line(`${lenF64} = uitofp ${host.sizeType} ${len} to double`); + B.line(`${lenF64} = uitofp nneg ${host.sizeType} ${len} to double`); B.line(`${nonnegative} = fcmp oge double ${index}, ${f64Lit(0)}`); B.line(`${belowLen} = fcmp olt double ${index}, ${lenF64}`); B.line(`${inRange} = and i1 ${nonnegative}, ${belowLen}`); @@ -137,7 +137,7 @@ export function emitBytesIndex( const roundTrip = B.tmp(); const integral = B.tmp(); B.line(`${idx} = fptoui double ${index} to ${host.sizeType}`); - B.line(`${roundTrip} = uitofp ${host.sizeType} ${idx} to double`); + B.line(`${roundTrip} = uitofp nneg ${host.sizeType} ${idx} to double`); B.line(`${integral} = fcmp oeq double ${roundTrip}, ${index}`); B.condBr(integral, valid, invalid); @@ -177,7 +177,7 @@ export function emitBytesLength( const count = bytes && size !== 1 ? B.tmp() : len; if (count !== len) B.line(`${count} = shl ${host.sizeType} ${len}, ${Math.log2(size)}`); const out = B.tmp(); - B.line(`${out} = uitofp ${host.sizeType} ${count} to double`); + B.line(`${out} = uitofp nneg ${host.sizeType} ${count} to double`); return { name: out, type: F64 }; } diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts index 6e4886b0b..3cdce7c63 100644 --- a/packages/compiler/src/backend/llvm/expr-containers.ts +++ b/packages/compiler/src/backend/llvm/expr-containers.ts @@ -520,7 +520,7 @@ export function emitArrIntrinsic( B.line(`${p} = getelementptr inbounds %ScrArr, ptr ${r.name}, i32 0, i32 1`); host.markMemoryPointer(p, "array:header"); B.line(`${len} = load ${host.sizeType}, ptr ${p}${host.fieldAliasAttachment(p)}`); - B.line(`${t} = uitofp ${host.sizeType} ${len} to double`); + B.line(`${t} = uitofp nneg ${host.sizeType} ${len} to double`); return { name: t, type: e.type }; } case "getNumber": { diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts index 6daeb1cd0..ac546e291 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.ts @@ -348,7 +348,7 @@ function emitDenseReferenceArrayRead( B.line(`${cap} = load ${host.sizeType}, ptr ${capPtr}${host.fieldAliasAttachment(capPtr)}`); if (integerIndex) B.line(`${inRange} = icmp ult ${host.sizeType} ${integerIndex}, ${cap}`); else { - B.line(`${capNumber} = uitofp ${host.sizeType} ${cap} to double`); + B.line(`${capNumber} = uitofp nneg ${host.sizeType} ${cap} to double`); B.line(`${nonnegative} = fcmp oge double ${index.name}, 0.0`); B.line(`${belowCap} = fcmp olt double ${index.name}, ${capNumber}`); B.line(`${inRange} = and i1 ${nonnegative}, ${belowCap}`); @@ -361,7 +361,7 @@ function emitDenseReferenceArrayRead( if (integerIndex) B.br(dense); else { B.line(`${offset} = fptoui double ${index.name} to ${host.sizeType}`); - B.line(`${roundTrip} = uitofp ${host.sizeType} ${offset} to double`); + B.line(`${roundTrip} = uitofp nneg ${host.sizeType} ${offset} to double`); B.line(`${integral} = fcmp oeq double ${index.name}, ${roundTrip}`); B.condBr(integral, dense, slow); } diff --git a/packages/compiler/src/backend/llvm/string-reads.ts b/packages/compiler/src/backend/llvm/string-reads.ts index fe70d8a1b..24fa80dd9 100644 --- a/packages/compiler/src/backend/llvm/string-reads.ts +++ b/packages/compiler/src/backend/llvm/string-reads.ts @@ -45,7 +45,7 @@ export const STRING_READ_HELPERS: readonly StringReadHelper[] = [ ` br i1 %ascii, label %fast, label %slow`, `fast:`, ...loadLength(sz), - ` %n = uitofp ${sz} %len to double`, + ` %n = uitofp nneg ${sz} %len to double`, ` ret double %n`, `slow:`, ` %r = call double @scr_str_utf16_len(ptr %s)`, @@ -62,7 +62,7 @@ export const STRING_READ_HELPERS: readonly StringReadHelper[] = [ ` br i1 %ascii, label %bounds, label %slow`, `bounds:`, ...loadLength(sz), - ` %lenf = uitofp ${sz} %len to double`, + ` %lenf = uitofp nneg ${sz} %len to double`, // NaN fails both ordered comparisons; -0 passes and truncates to 0. ` %lo = fcmp oge double %i, 0.0`, ` %hi = fcmp olt double %i, %lenf`, diff --git a/packages/compiler/src/backend/llvm/string-slices.ts b/packages/compiler/src/backend/llvm/string-slices.ts index dd373cfea..59c2962ab 100644 --- a/packages/compiler/src/backend/llvm/string-slices.ts +++ b/packages/compiler/src/backend/llvm/string-slices.ts @@ -97,7 +97,7 @@ export function emitStringSliceRead(host: LlvmEmitterContext, value: Slice): LlV `${pointer} = getelementptr inbounds { ${host.sizeType}, ${host.sizeType}, i32 }, ptr ${snapshot.range}, i32 0, i32 1`, ); B.line(`${length} = load ${host.sizeType}, ptr ${pointer}`); - B.line(`${number} = uitofp ${host.sizeType} ${length} to double`); + B.line(`${number} = uitofp nneg ${host.sizeType} ${length} to double`); return { name: number, type: value.type }; } if (value.method !== "charCodeAt") return null; From 44f4d0bf1936ca52e9d83868ad210ee34cbbef9b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 15:25:49 -0700 Subject: [PATCH 07/19] Prove more fields construction-only and trace only types on a potential cycle The cycle analysis gave a header to every type that could reach a cycle, and its construction-only facts gave up on a whole hierarchy as soon as a constructor let `this` escape or a type could cross into checked-dynamic code. Four refinements, all derived from the final IR: - A constructor's stores stay construction-only up to the first top-level statement in which `this` escapes. Statements run in order, so earlier stores complete while the object is private; stores from the escaping statement on, and every store of a constructor whose ancestor let `this` escape, are writes. Compound-assignment receiver temporaries count as `this`. An all-immutable cycle is still impossible: each immutable edge a -> b is stored before a escapes while b has escaped already. - Crossing into checked-dynamic code only makes types mutable when the program contains a runtime store into a dynamic value (int32-slots' audit, now shared as dynamicFieldStores): typed-ref capsules write native fields only through such a commit. Without one, hidden property bags stay empty and add no edges. FFI operands and intrinsics other than publish stay conservative. - A type is traced iff it lies in a cycle-capable component or reaches an intrinsic reference. A type that merely holds a cycle cannot be reached back from it: its references are external counts. - Reachability is per class: a slot of static type C reaches C's subtree, not the whole hierarchy; header presence stays uniform per hierarchy. tsc-ts (e469e33): traced classes 603 -> 469 of 700, records 64 -> 10 of 202, unions 344 -> 93 of 482; scr_cyc_alloc sites 659 -> 471. Mutable hierarchy units 243 -> 4. Collector candidates on the self-check (single-threaded) 6.02M -> 5.55M. --- .../src/backend/cycle-analysis.test.ts | 164 +++++++++++++++-- .../compiler/src/backend/cycle-analysis.ts | 66 +++++-- .../compiler/src/backend/cycle-mutability.ts | 165 +++++++++++++++--- packages/compiler/src/ir/int32-slots.ts | 119 +++++++------ .../test/ts7/baselines/order-parity.json | 12 ++ tests/corpus/4601-cycle-holders.ts | 75 ++++++++ tests/corpus/4602-constructor-escape-order.ts | 66 +++++++ 7 files changed, 561 insertions(+), 106 deletions(-) create mode 100644 tests/corpus/4601-cycle-holders.ts create mode 100644 tests/corpus/4602-constructor-escape-order.ts diff --git a/packages/compiler/src/backend/cycle-analysis.test.ts b/packages/compiler/src/backend/cycle-analysis.test.ts index 4eac0ed5d..ca1ae760d 100644 --- a/packages/compiler/src/backend/cycle-analysis.test.ts +++ b/packages/compiler/src/backend/cycle-analysis.test.ts @@ -90,7 +90,7 @@ test("removes an entire acyclic dependency chain to a fixed point", () => { check(mod, []); }); -test("retains mutual cycles and every shape that can contain them", () => { +test("retains mutual cycles but not the shapes that merely hold them", () => { const mod = module(); mod.records = [ shape("outer", [ref("a")]), @@ -98,6 +98,19 @@ test("retains mutual cycles and every shape that can contain them", () => { shape("b", [ref("a")]), shape("leaf", [F64]), ]; + // Nothing in the a/b cycle can reach `outer`: its reference is an + // external count and it dies by reference counting alone. + check(mod, ["record:a", "record:b"]); +}); + +test("a holder that reaches an intrinsic reference stays traced", () => { + const mod = module(); + mod.records = [ + shape("outer", [ref("a")]), + shape("a", [ref("b")]), + shape("b", [ref("a"), funcOf([], F64)]), + ]; + // A closure can capture anything, including `outer`. check(mod, ["record:outer", "record:a", "record:b"]); }); @@ -182,7 +195,7 @@ test("map keys and set elements can close cycles without reference values", () = shape("leaf", [F64]), shape("acyclic", [mapOf(ref("leaf"), STRING), setOf(ref("leaf"))]), ]; - check(mod, ["record:key", "record:member", "record:outer"]); + check(mod, ["record:key", "record:member"]); }); test("boxed keys and nested array elements retain cycle capability", () => { @@ -247,7 +260,7 @@ test("removing one dependency keeps other branches, cycles, and intrinsic refere shape("intrinsic", [ref("leaf"), { kind: "caught" }]), shape("pruned", [mapOf(ref("leaf"), arrayOf(ref("leaf")))]), ]; - check(mod, ["record:outer", "record:cycle", "record:intrinsic"]); + check(mod, ["record:cycle", "record:intrinsic"]); }); test("hierarchies and unions participate in the same dependency graph", () => { @@ -262,12 +275,32 @@ test("hierarchies and unions participate in the same dependency graph", () => { { name: "Sibling", base: "Base", fields: [], loc }, { name: "Base", fields: [], loc }, ]; - mod.unions = [{ id: "link", arms: [{ kind: "object", className: "Sibling" }, F64] }]; + // A Base-typed arm can hold a Child, which holds the union again. + mod.unions = [{ id: "link", arms: [{ kind: "object", className: "Base" }, F64] }]; check(mod, ["object:Child", "object:Sibling", "object:Base"], ["link"]); + // A Sibling-typed arm can only hold a Sibling, which holds nothing. + mod.unions[0]!.arms = [{ kind: "object", className: "Sibling" }, F64]; + check(mod, []); mod.unions[0]!.arms = [F64, STRING]; check(mod, []); }); +test("a slot reaches the subtree of its static class, not the whole hierarchy", () => { + const mod = module(); + const base: IrType = { kind: "object", className: "Base" }; + const other: IrType = { kind: "object", className: "Other" }; + mod.classes = [ + { name: "Base", fields: [], loc }, + { name: "Leaf", base: "Base", fields: [{ name: "callback", type: funcOf([], VOID) }], loc }, + { name: "Other", base: "Base", fields: [], loc }, + { name: "HoldsOther", fields: [{ name: "other", type: other }], loc }, + { name: "HoldsBase", fields: [{ name: "base", type: base }], loc }, + ]; + // Leaf's closure headers the whole hierarchy, but an Other-typed slot + // can never hold a Leaf. + check(mod, ["object:Base", "object:Leaf", "object:Other", "object:HoldsBase"]); +}); + test("local class captures retain tracing even when the class has no fields", () => { const mod = module(); mod.classes = [{ name: "Local", localCaptures: [], fields: [], loc }]; @@ -391,20 +424,30 @@ test("field increments count as writes", () => { checkExact(mod, ["object:Tree"], ["link"]); }); -test("this escaping from the constructor makes the hierarchy mutable", () => { +/** tree(), with `escape` placed before the field stores. */ +function escapingTree(escape: IrStmt): IrModule { + const mod = tree(); + mod.functions[1]!.body.unshift(escape); + return mod; +} + +test("stores after this escapes from the constructor are writes", () => { const escape: IrStmt = { kind: "exprStmt", expr: { kind: "call", callee: "register", args: [thisRef("Tree")], type: VOID, loc }, loc, }; - checkExact(tree([escape]), ["object:Tree"], ["link"]); + checkExact(escapingTree(escape), ["object:Tree"], ["link"]); + // Stores that complete before the escape stay construction-only. + checkExact(tree([escape]), []); const capture: IrStmt = { kind: "varDecl", localId: "f.0", init: { kind: "closure", fnName: "%fn0", captures: ["this.0"], type: funcOf([], F64), loc }, loc, }; - checkExact(tree([capture]), ["object:Tree"], ["link"]); + checkExact(escapingTree(capture), ["object:Tree"], ["link"]); + checkExact(tree([capture]), []); const self: IrStmt = setThis("Tree", "left", link); (self as { value: IrExpr }).value = { kind: "unionWrap", @@ -415,6 +458,59 @@ test("this escaping from the constructor makes the hierarchy mutable", () => { loc, }; checkExact(tree([self]), ["object:Tree"], ["link"]); + // A compound assignment's receiver temporary is the same `this`. + const alias = tree(); + alias.functions[1]!.locals.push({ id: "r.0", name: "r", type: obj("Tree"), mutable: false }); + alias.functions[1]!.body.push( + { kind: "varDecl", localId: "r.0", init: thisRef("Tree"), loc }, + { + kind: "fieldSet", + obj: { kind: "varRef", localId: "r.0", type: obj("Tree"), loc }, + className: "Tree", + field: "left", + value: value(link), + loc, + }, + ); + checkExact(alias, []); +}); + +test("a subclass constructor runs after its base constructor's escape", () => { + const mod = module(); + mod.classes = [ + { name: "Base", fields: [{ name: "next", type: link }], loc }, + { + name: "Sub", + base: "Base", + fields: [ + { name: "next", type: link }, + { name: "other", type: link }, + ], + loc, + }, + ]; + mod.unions = [{ id: "link", arms: [obj("Base"), { kind: "nullT" }] }]; + const superCall: IrStmt = { + kind: "exprStmt", + expr: { + kind: "call", + callee: "%Base.constructor", + args: [{ kind: "upcast", value: thisRef("Sub"), type: obj("Base"), loc }], + type: VOID, + loc, + }, + loc, + }; + const register: IrStmt = { + kind: "exprStmt", + expr: { kind: "call", callee: "register", args: [thisRef("Base")], type: VOID, loc }, + loc, + }; + mod.functions.push( + ctor("Base", [setThis("Base", "next", link), register]), + ctor("Sub", [superCall, setThis("Sub", "other", link)]), + ); + checkExact(mod, ["object:Base", "object:Sub"], ["link"]); }); test("field reads of this inside the constructor do not escape", () => { @@ -494,8 +590,9 @@ test("derived constructors may initialize through super but not escape", () => { checkExact(method, ["object:Base", "object:Sub"], ["link"]); }); -test("an immutable edge into a mutable cycle stays traced", () => { +test("a holder of a mutable cycle needs no tracing", () => { // X.holder is constructor-only; Holder.x is rewritten later: x -> h -> x. + // Wrapper holds X, but nothing reachable from X can lead back to it. const mod = module(); mod.classes = [ { name: "X", fields: [{ name: "holder", type: obj("Holder") }], loc }, @@ -512,7 +609,7 @@ test("an immutable edge into a mutable cycle stays traced", () => { value: value({ kind: "union", unionId: "maybeX" }), loc, }); - checkExact(mod, ["object:X", "object:Holder", "object:Wrapper"], ["maybeX"]); + checkExact(mod, ["object:X", "object:Holder"], ["maybeX"]); }); test("collection edges are mutable even behind constructor-only fields", () => { @@ -522,25 +619,64 @@ test("collection edges are mutable even behind constructor-only fields", () => { checkExact(mod, ["object:Call"]); }); +/** A runtime store into a dynamic value under a computed key. */ +const dynamicStore: IrStmt = { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "dyn.keySet", + args: [value(DYN), value(STRING), value(DYN)], + type: VOID, + loc, + }, + loc, +}; + +test("without a dynamic store, crossing into checked-dynamic code writes nothing", () => { + const toDyn: IrStmt = { + kind: "exprStmt", + expr: { kind: "dynFrom", value: value(obj("Tree")), type: DYN, loc }, + loc, + }; + checkExact(tree([toDyn]), []); + checkExact(tree([{ kind: "throw", value: value(link), loc }]), []); + // Publishing only marks objects immortal; FFI and other intrinsics count. + const intrinsic = (name: "threads.publish" | "console.log"): IrStmt => ({ + kind: "exprStmt", + expr: { kind: "intrinsic", name, args: [value(obj("Tree"))], type: VOID, loc }, + loc, + }); + checkExact(tree([intrinsic("threads.publish")]), []); + checkExact(tree([intrinsic("console.log")]), ["object:Tree"], ["link"]); +}); + +test("hidden property bags hold nothing without a dynamic store", () => { + const mod = tree(); + for (const c of mod.classes!) c.fields.push({ name: "%dynProperties", type: DYN }); + checkExact(mod, []); + mod.functions[0]!.body.push(dynamicStore); + checkExact(mod, ["object:Tree"], ["link"]); +}); + test("types that can become checked-dynamic capsules are mutable", () => { const toDyn: IrStmt = { kind: "exprStmt", expr: { kind: "dynFrom", value: value(obj("Tree")), type: DYN, loc }, loc, }; - checkExact(tree([toDyn]), ["object:Tree"], ["link"]); + checkExact(tree([toDyn, dynamicStore]), ["object:Tree"], ["link"]); const thrown: IrStmt = { kind: "throw", value: value(link), loc }; - checkExact(tree([thrown]), ["object:Tree"], ["link"]); + checkExact(tree([thrown, dynamicStore]), ["object:Tree"], ["link"]); // Reached only through a boxed closure signature and a record field. - const holder = tree(); + const holder = tree([dynamicStore]); holder.records = [shape("box", [obj("Tree")])]; holder.functions[0]!.body.push({ kind: "exprStmt", expr: { kind: "dynFrom", value: value(funcOf([], ref("box"))), type: DYN, loc }, loc, }); - checkExact(holder, ["object:Tree", "record:box"], ["link"]); - const logged = tree(); + checkExact(holder, ["object:Tree"], ["link"]); + const logged = tree([dynamicStore]); logged.functions[0]!.body.push({ kind: "exprStmt", expr: { kind: "jsonStringify", value: value(arrayOf(link)), type: STRING, loc }, diff --git a/packages/compiler/src/backend/cycle-analysis.ts b/packages/compiler/src/backend/cycle-analysis.ts index f85e31636..8b6a5b210 100644 --- a/packages/compiler/src/backend/cycle-analysis.ts +++ b/packages/compiler/src/backend/cycle-analysis.ts @@ -1,5 +1,12 @@ import type { IrModule, IrType } from "../ir/ir.js"; -import { funcOf, mapOf, RUNTIME_EMITTER_CLASS, STRING, VOID } from "../ir/ir.js"; +import { + DYN_CLASS_PROPERTIES, + funcOf, + mapOf, + RUNTIME_EMITTER_CLASS, + STRING, + VOID, +} from "../ir/ir.js"; import { computeCycleMutability, fieldKey } from "./cycle-mutability.js"; /** Cycle capability for native code generation. @@ -89,13 +96,28 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set reverse: [], traced: false, }); - const units = new Map(); + // One node per class and record, plus a SUBTREE node per class with + // subclasses: a slot of static type C can hold an instance of any class + // in C's subtree, and of nothing else. Reachability is computed per + // class; tracing is then decided per hierarchy unit below. const shapes = new Map(); const unions = new Map((mod.unions ?? []).map((u) => [u.id, node()])); - for (const s of shapeDefs) { - let unit = units.get(s.unit); - if (!unit) units.set(s.unit, (unit = node())); - shapes.set(s.key, unit); + for (const s of shapeDefs) shapes.set(s.key, node()); + const children = new Map(); + for (const c of classes) + if (c.base !== undefined && shapes.has(`object:${c.base}`)) { + let list = children.get(c.base); + if (!list) children.set(c.base, (list = [])); + list.push(c.name); + } + // Created up front and linked without recursion (hierarchies can be + // thousands deep). + const subtrees = new Map([...children.keys()].map((name) => [name, node()] as const)); + const subtreeOf = (className: string): Node | undefined => + subtrees.get(className) ?? shapes.get(`object:${className}`); + for (const [name, sub] of subtrees) { + sub.edges.set(shapes.get(`object:${name}`)!, false); + for (const kid of children.get(name)!) sub.edges.set(subtreeOf(kid)!, false); } // Each field/arm is an OR of intrinsic capability and referenced nodes. const addType = (from: Node, t: IrType, mutable: boolean): void => { @@ -115,7 +137,9 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set const dependency = t.kind === "union" ? unions.get(t.unionId) - : shapes.get(t.kind === "object" ? `object:${t.className}` : `record:${t.shapeId}`); + : t.kind === "object" + ? subtreeOf(t.className) + : shapes.get(`record:${t.shapeId}`); if (dependency) from.edges.set(dependency, (from.edges.get(dependency) ?? false) || mutable); break; @@ -132,19 +156,21 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set }; for (const s of shapeDefs) { const unitMutable = mutability.all || mutability.units.has(s.unit); - for (const field of s.fields) + for (const field of s.fields) { + if (mutability.emptyPropertyBags && field.name === DYN_CLASS_PROPERTIES) continue; addType( shapes.get(s.key)!, field.type, unitMutable || mutability.fields.has(fieldKey(s.unit, field.name)), ); + } } for (const u of mod.unions ?? []) for (const arm of u.arms) addType(unions.get(u.id)!, arm, mutability.all); // Tarjan's strongly connected components, iteratively (shape chains can // be thousands deep). - const all = [...units.values(), ...unions.values()]; + const all = [...shapes.values(), ...subtrees.values(), ...unions.values()]; const stack: Node[] = []; const componentCapable: boolean[] = []; let nextIndex = 0; @@ -191,18 +217,28 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set componentCapable.push(capable); } } - // Traced: reaches a cycle-capable component (reverse reachability). + // Traced: on a potential cycle. An intrinsic node can reach anything, so + // every node that reaches one (reverse reachability) may lie on a cycle + // through it; otherwise only the members of a component with a mutable + // internal edge can. A node that merely reaches such a component holds it + // from outside: no cycle can lead back to it, so its references are + // external counts and it dies by reference counting alone. for (const n of all) for (const dependency of n.edges.keys()) dependency.reverse.push(n); - const pending = all.filter((n) => componentCapable[n.component]); - for (const n of pending) n.traced = true; + const reachesIntrinsic = new Set(all.filter((n) => n.intrinsic)); + const pending = [...reachesIntrinsic]; for (let i = 0; i < pending.length; i++) { for (const dependent of pending[i]!.reverse) { - if (dependent.traced) continue; - dependent.traced = true; + if (reachesIntrinsic.has(dependent)) continue; + reachesIntrinsic.add(dependent); pending.push(dependent); } } - const tracedShapes = new Set([...shapes].filter(([, unit]) => unit.traced).map(([key]) => key)); + for (const n of all) n.traced = componentCapable[n.component]! || reachesIntrinsic.has(n); + // Header presence is uniform across a hierarchy: a unit is traced iff any + // member class is. + const tracedUnits = new Set(); + for (const s of shapeDefs) if (shapes.get(s.key)!.traced) tracedUnits.add(s.unit); + const tracedShapes = new Set(shapeDefs.filter((s) => tracedUnits.has(s.unit)).map((s) => s.key)); const tracedUnions = new Set([...unions].filter(([, n]) => n.traced).map(([id]) => id)); return { shapes: tracedShapes, unions: tracedUnions }; } diff --git a/packages/compiler/src/backend/cycle-mutability.ts b/packages/compiler/src/backend/cycle-mutability.ts index cfde93851..67196efd7 100644 --- a/packages/compiler/src/backend/cycle-mutability.ts +++ b/packages/compiler/src/backend/cycle-mutability.ts @@ -1,5 +1,6 @@ import type { IrExpr, IrFunction, IrModule, IrStmt, IrType } from "../ir/ir.js"; import { RUNTIME_EMITTER_CLASS, RUNTIME_STREAM_CLASSES } from "../ir/ir.js"; +import { dynamicFieldStores } from "../ir/int32-slots.js"; import { everyExprChild, everyModuleNode, everyStmtChild, everyTypeChild } from "../ir/traverse.js"; /** Which shape fields can be written after their object escapes. @@ -16,15 +17,21 @@ import { everyExprChild, everyModuleNode, everyStmtChild, everyTypeChild } from * - every `fieldSet` outside a constructor's own `this`, `fieldIncDec`, and * `recordSet` makes its (unit, field) mutable; `recordKeySet` on declared * fields and `recordKeyDelete` make the whole record mutable; - * - a constructor in which `this` escapes (any use other than a field store - * target, a field read, or the receiver of the direct base constructor - * call — including closure and class captures) makes its whole hierarchy - * mutable, as does any other call to a constructor function; + * - a constructor's stores into its own `this` are construction-only up to + * the first top-level statement in which `this` escapes (any use other + * than a field store target, a field read, or the receiver of the direct + * base constructor call — including closure and class captures); stores + * from there on, and all stores of a constructor whose ancestor + * constructor let `this` escape, are writes. Any other call to a + * constructor function makes its whole hierarchy mutable; * - every type that can cross into the checked-dynamic world (operands of * `dynFrom`, including boxed closure signatures, thrown values, and * arguments of library/intrinsic/JSON/FFI calls) makes its entire type * closure mutable, because typed-ref capsules commit dynamic mutations - * straight into native fields; + * straight into native fields — unless the program contains no runtime + * store into a dynamic value at all (int32-slots' dynamicFieldStores), + * in which case no commit can happen; FFI operands and intrinsics other + * than publish always count; * - runtime-rooted hierarchies (errors, emitters, streams) are mutable; * - library builds and dynamic-island programs disable the refinement. * @@ -33,6 +40,10 @@ import { everyExprChild, everyModuleNode, everyStmtChild, everyTypeChild } from export interface CycleMutability { /** Every edge is mutable (the refinement is disabled for this module). */ all: boolean; + /** No runtime store into a dynamic value exists, so the hidden property + * bags of classes (DYN_CLASS_PROPERTIES) stay empty: they hold no + * references. */ + emptyPropertyBags: boolean; /** Units whose every field is mutable. */ units: Set; /** Individually mutable fields. */ @@ -65,7 +76,12 @@ export function computeCycleMutability( mod: IrModule, unitOfClass: (className: string) => string, ): CycleMutability { - const result: CycleMutability = { all: false, units: new Set(), fields: new Set() }; + const result: CycleMutability = { + all: false, + emptyPropertyBags: false, + units: new Set(), + fields: new Set(), + }; if (mod.lib !== undefined) return { ...result, all: true }; const classes = mod.classes ?? []; const recordsById = new Map((mod.records ?? []).map((r) => [r.id, r] as const)); @@ -89,6 +105,21 @@ export function computeCycleMutability( result.units.add(unitOfClass(c.name)); } + // A typed-ref capsule writes into its native object only when a runtime + // store into the capsule commits (dynamicFieldStores audits which helpers + // can store; a commit rewrites every field, reference fields possibly + // with fresh conversions). Without any such store in the program, + // crossing into checked-dynamic code writes nothing. FFI calls and + // intrinsics other than publish (which only marks objects immortal) stay + // conservative. + const dynStores = dynamicFieldStores(mod); + const dynamicStores = dynStores.all || dynStores.names.size > 0; + result.emptyPropertyBags = !dynamicStores; + const boundaryWrites = (e: IrExpr): boolean => + dynamicStores || + e.kind === "ffiCall" || + (e.kind === "intrinsic" && e.name !== "threads.publish"); + // Types that can become checked-dynamic values: mark their whole closure. const exposedTypes = new Set(); const expose = (root: IrType): void => { @@ -124,9 +155,42 @@ export function computeCycleMutability( } }; + // Constructor stores are construction-only until `this` first escapes: + // stores from the escaping statement on, and every store of a + // constructor whose ancestor constructor already let `this` escape, are + // ordinary writes. + const fnByName = new Map(mod.functions.map((fn) => [fn.name, fn] as const)); + const classByName = new Map(classes.map((c) => [c.name, c] as const)); + const escapePoints = new Map(); + /** The constructor's escape point; 0 without an analyzable constructor. */ + const escapePoint = (name: string): number => { + const known = escapePoints.get(name); + if (known !== undefined) return known; + const fn = fnByName.get(`%${name}.constructor`); + const cls = classByName.get(name); + const thisId = fn?.params[0]?.localId; + const base = cls?.base !== undefined ? `%${cls.base}.constructor` : null; + const point = + fn === undefined || cls === undefined || thisId === undefined + ? 0 + : constructorEscapePoint(fn, thisId, base); + escapePoints.set(name, point); + return point; + }; + const escapes = (name: string): boolean => + escapePoint(name) < (fnByName.get(`%${name}.constructor`)?.body.length ?? 1); + const ancestorEscaped = (name: string): boolean => { + for (let c = classByName.get(name)?.base; c !== undefined; c = classByName.get(c)?.base) + if (escapes(c)) return true; + return false; + }; + let privateUntil = 0; + let topLevel = 0; + + let aliases = new Set(); const isThisRef = (e: IrExpr, thisId: string | null): boolean => { if (thisId === null) return false; - if (e.kind === "varRef") return e.localId === thisId; + if (e.kind === "varRef") return aliases.has(e.localId); if (e.kind === "upcast") return isThisRef(e.value, thisId); if (e.kind === "seqExpr") return isThisRef(e.result, thisId); return false; @@ -135,9 +199,14 @@ export function computeCycleMutability( for (const fn of mod.functions) { const ctorClass = ctorOf.get(fn.name); const thisId = ctorClass !== undefined ? (fn.params[0]?.localId ?? null) : null; + aliases = thisId !== null ? constructorThisAliases(fn, thisId) : new Set(); + privateUntil = + ctorClass !== undefined && thisId !== null && !ancestorEscaped(ctorClass.name) + ? escapePoint(ctorClass.name) + : 0; const baseCtor = ctorClass?.base !== undefined ? `%${ctorClass.base}.constructor` : null; const expr = (e: IrExpr): boolean => { - if (DYNAMIC_BOUNDARY_EXPRS.has(e.kind)) { + if (DYNAMIC_BOUNDARY_EXPRS.has(e.kind) && boundaryWrites(e)) { everyExprChild( e, (child) => { @@ -171,7 +240,7 @@ export function computeCycleMutability( const stmt = (s: IrStmt): boolean => { switch (s.kind) { case "fieldSet": - if (!isThisRef(s.obj, thisId)) + if (!isThisRef(s.obj, thisId) || topLevel >= privateUntil) result.fields.add(fieldKey(unitOfClass(s.className), s.field)); break; case "recordSet": @@ -184,25 +253,58 @@ export function computeCycleMutability( result.units.add(`record:${s.shapeId}`); break; case "throw": - expose(s.value.type); + if (dynamicStores) expose(s.value.type); break; } return everyStmtChild(s, expr, stmt); }; - fn.body.every(stmt); - if (ctorClass !== undefined && thisId !== null && constructorEscapes(fn, thisId, baseCtor)) - result.units.add(unitOfClass(ctorClass.name)); + for (topLevel = 0; topLevel < fn.body.length; topLevel++) stmt(fn.body[topLevel]!); } return result; } -/** Whether `this` can be observed by anything other than the constructor's - * own field stores before construction completes. Flow-insensitive: any - * escape anywhere in the body counts. */ -function constructorEscapes(fn: IrFunction, thisId: string, baseCtor: string | null): boolean { - let escaped = false; - const isThisRef = (e: IrExpr): boolean => +/** The locals of a constructor that always hold its `this`: the parameter + * itself plus immutable locals declared with a `this` initializer and never + * assigned (the receiver temporaries of compound field assignments). */ +export function constructorThisAliases(fn: IrFunction, thisId: string): Set { + const aliases = new Set([thisId]); + const immutable = new Set(fn.locals.filter((l) => !l.mutable).map((l) => l.id)); + const assigned = new Set(); + const candidates: string[] = []; + const isThisParam = (e: IrExpr): boolean => (e.kind === "varRef" && e.localId === thisId) || + (e.kind === "upcast" && isThisParam(e.value)) || + (e.kind === "seqExpr" && isThisParam(e.result)); + const expr = (e: IrExpr): boolean => everyExprChild(e, expr, stmt); + const stmt = (s: IrStmt): boolean => { + if (s.kind === "assign") assigned.add(s.localId); + if (s.kind === "varDecl" && s.init !== null && immutable.has(s.localId) && isThisParam(s.init)) + candidates.push(s.localId); + return everyStmtChild(s, expr, stmt); + }; + fn.body.every(stmt); + for (const id of candidates) if (!assigned.has(id)) aliases.add(id); + return aliases; +} + +/** The index of the first top-level statement of a constructor body in + * which `this` can be observed by anything other than the constructor's own + * field stores and reads (the body length when it never is). Statements run + * in order, so every store in an earlier statement completes while the + * object is still private. Within and after the escaping statement nothing + * is private any more. With `selfStores`, storing `this` into one of its + * own fields is not an escape (it exposes nothing to other code, but it + * does create a cycle). */ +export function constructorEscapePoint( + fn: IrFunction, + thisId: string, + baseCtor: string | null, + options: { selfStores?: boolean } = {}, +): number { + let escaped = false; + const aliases = constructorThisAliases(fn, thisId); + const isThisRef = (e: IrExpr): boolean => + (e.kind === "varRef" && aliases.has(e.localId)) || (e.kind === "upcast" && isThisRef(e.value)) || (e.kind === "seqExpr" && isThisRef(e.result)); // The non-`this` parts of a `this`-valued wrapper (seqExpr statements). @@ -212,12 +314,15 @@ function constructorEscapes(fn: IrFunction, thisId: string, baseCtor: string | n return true; }; const expr = (e: IrExpr): boolean => { - if (e.kind === "varRef" && e.localId === thisId) { + if (e.kind === "varRef" && aliases.has(e.localId)) { escaped = true; return false; } // Closures and class references are the expressions that capture locals. - if ((e.kind === "closure" || e.kind === "classRef") && (e.captures ?? []).includes(thisId)) { + if ( + (e.kind === "closure" || e.kind === "classRef") && + (e.captures ?? []).some((id) => aliases.has(id)) + ) { escaped = true; return false; } @@ -233,10 +338,22 @@ function constructorEscapes(fn: IrFunction, thisId: string, baseCtor: string | n return everyExprChild(e, expr, stmt); }; const stmt = (s: IrStmt): boolean => { - if (s.kind === "fieldSet" && isThisRef(s.obj)) return wrapper(s.obj) && expr(s.value); + if (s.kind === "fieldSet" && isThisRef(s.obj)) { + if (options.selfStores === true) { + // `this` itself, possibly wrapped into the field's union type. + const self = s.value.kind === "unionWrap" ? s.value.value : s.value; + if (isThisRef(self)) return wrapper(s.obj) && wrapper(self); + } + return wrapper(s.obj) && expr(s.value); + } if (s.kind === "exprStmt" && isThisRef(s.expr)) return wrapper(s.expr); + // Declaring an alias is not an escape; its uses are checked like `this`. + if (s.kind === "varDecl" && aliases.has(s.localId) && s.init !== null) return wrapper(s.init); return everyStmtChild(s, expr, stmt); }; - fn.body.every(stmt); - return escaped; + for (let i = 0; i < fn.body.length; i++) { + stmt(fn.body[i]!); + if (escaped) return i; + } + return fn.body.length; } diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index ad790b904..2ef822e81 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -271,6 +271,71 @@ function walkFunction( for (const s of fn.body) stmt(s); } +/** The class and record field names checked-dynamic code can store into a + * live typed-ref capsule (a capsule commit rewrites every other field with + * its unchanged snapshot value). `all`: some runtime helper that can store + * an arbitrary property receives a dynamic value, or the program has a + * dynamic island; otherwise only literal-keyed `dyn.keySet`/`dyn.keyDelete` + * name fields, collected in `names`. */ +export function dynamicFieldStores(mod: IrModule): { all: boolean; names: Set } { + const records = new Map((mod.records ?? []).map((r) => [r.id, r])); + const unions = new Map((mod.unions ?? []).map((u) => [u.id, u])); + const dynTypes = new Map(); + const dynNamed = new Map(); + const dynish = (type: IrType): boolean => { + if (DYN_KINDS.has(type.kind)) return true; + const cached = dynTypes.get(type); + if (cached !== undefined) return cached; + let result = false; + const named = + type.kind === "record" + ? `r:${type.shapeId}` + : type.kind === "union" + ? `u:${type.unionId}` + : null; + if (named) { + const known = dynNamed.get(named); + if (known !== undefined) return known; + dynNamed.set(named, false); + const members = + type.kind === "record" + ? [ + ...(records.get(type.shapeId)?.fields.map((f) => f.type) ?? []), + ...(records.get(type.shapeId)?.indexValue + ? [records.get(type.shapeId)!.indexValue!] + : []), + ] + : type.kind === "union" + ? (unions.get(type.unionId)?.arms ?? []) + : []; + result = members.some(dynish); + dynNamed.set(named, result); + } else everyTypeChild(type, (child) => !(result = dynish(child))); + dynTypes.set(type, result); + return result; + }; + const dynWritten = new Set(); + let dynWritesAll = false; + for (const fn of mod.functions) + walkFunction(fn, { + expr: (e) => { + if (dynWritesAll) return; + if (ISLAND_KINDS.has(e.kind) || e.type.kind === "jsval") dynWritesAll = true; + else if ( + e.kind === "libCall" && + !DYN_READ_ONLY.has(e.fn) && + e.args.some((a) => dynish(a.type)) + ) { + const key = e.args[1]; + if ((e.fn === "dyn.keySet" || e.fn === "dyn.keyDelete") && key?.kind === "strLit") + dynWritten.add(key.value); + else dynWritesAll = true; + } + }, + }); + return { all: dynWritesAll, names: dynWritten }; +} + export function analyzeInt32Slots(mod: IrModule): Int32Slots { const classes = new Map((mod.classes ?? []).map((c) => [c.name, c])); const slots = new Int32Slots(classes); @@ -371,59 +436,7 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots { // writes every field from the view, which holds the exact snapshot value // of any property no store named. So reaching dynamic code disqualifies // exactly the field names some dynamic store in the program can name. - const dynTypes = new Map(); - const dynNamed = new Map(); - const dynish = (type: IrType): boolean => { - if (DYN_KINDS.has(type.kind)) return true; - const cached = dynTypes.get(type); - if (cached !== undefined) return cached; - let result = false; - const named = - type.kind === "record" - ? `r:${type.shapeId}` - : type.kind === "union" - ? `u:${type.unionId}` - : null; - if (named) { - const known = dynNamed.get(named); - if (known !== undefined) return known; - dynNamed.set(named, false); - const members = - type.kind === "record" - ? [ - ...(records.get(type.shapeId)?.fields.map((f) => f.type) ?? []), - ...(records.get(type.shapeId)?.indexValue - ? [records.get(type.shapeId)!.indexValue!] - : []), - ] - : type.kind === "union" - ? (unions.get(type.unionId)?.arms ?? []) - : []; - result = members.some(dynish); - dynNamed.set(named, result); - } else everyTypeChild(type, (child) => !(result = dynish(child))); - dynTypes.set(type, result); - return result; - }; - const dynWritten = new Set(); - let dynWritesAll = false; - for (const fn of mod.functions) - walkFunction(fn, { - expr: (e) => { - if (dynWritesAll) return; - if (ISLAND_KINDS.has(e.kind) || e.type.kind === "jsval") dynWritesAll = true; - else if ( - e.kind === "libCall" && - !DYN_READ_ONLY.has(e.fn) && - e.args.some((a) => dynish(a.type)) - ) { - const key = e.args[1]; - if ((e.fn === "dyn.keySet" || e.fn === "dyn.keyDelete") && key?.kind === "strLit") - dynWritten.add(key.value); - else dynWritesAll = true; - } - }, - }); + const { all: dynWritesAll, names: dynWritten } = dynamicFieldStores(mod); for (const fn of mod.functions) walkFunction(fn, { expr: (e) => { diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 1c7788be6..b99f04249 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15748,6 +15748,18 @@ "/tests/corpus/4596-tdz-nullable-forward-capture.ts" ], "diags": [] + }, + "/tests/corpus/4601-cycle-holders.ts": { + "order": [ + "/tests/corpus/4601-cycle-holders.ts" + ], + "diags": [] + }, + "/tests/corpus/4602-constructor-escape-order.ts": { + "order": [ + "/tests/corpus/4602-constructor-escape-order.ts" + ], + "diags": [] } } } diff --git a/tests/corpus/4601-cycle-holders.ts b/tests/corpus/4601-cycle-holders.ts new file mode 100644 index 000000000..c6f7b4b36 --- /dev/null +++ b/tests/corpus/4601-cycle-holders.ts @@ -0,0 +1,75 @@ +// Objects that hold reference cycles without being able to lie on one need +// no cycle tracking: nothing reachable from the cycle can lead back to them, +// so their references are external counts and they die by reference +// counting alone, releasing the cycle to the collector. Types that reach a +// closure, which can capture anything, stay tracked. The sanitized lane's +// RC audit fails if any cycle below leaks. + +class Ring { + peer: Ring | null = null; + value: number; + constructor(value: number) { + this.value = value; + } +} + +function ring(value: number): Ring { + const a = new Ring(value); + const b = new Ring(value + 1); + a.peer = b; + b.peer = a; + return a; +} + +// Holds a cycle; no field of Ring can reach a Wrapper. +class Wrapper { + readonly ring: Ring; + readonly label: string; + constructor(ring: Ring, label: string) { + this.ring = ring; + this.label = label; + } +} + +// Holds wrappers in a mutable array: still not on any cycle. +class Shelf { + items: Wrapper[] = []; + owner: Wrapper | null = null; +} + +// Reaches a closure, which can capture the object itself. +class Callback { + readonly ring: Ring; + run: () => number; + constructor(ring: Ring) { + this.ring = ring; + this.run = () => 0; + } +} + +interface Tagged { + ring: Ring; + tag: string; +} + +let total = 0; +for (let round = 0; round < 300; round++) { + const shelf = new Shelf(); + for (let i = 0; i < 20; i++) shelf.items.push(new Wrapper(ring(i), `w${i}`)); + shelf.owner = shelf.items[3]!; + for (const w of shelf.items) total += w.ring.peer!.value; + const cb = new Callback(ring(round)); + cb.run = () => cb.ring.value + total; + total += cb.run() > 0 ? 1 : 0; + const tagged: Tagged = { ring: ring(round), tag: "t" }; + total += tagged.ring.peer!.peer!.value; + const kept: (Wrapper | Ring)[] = [shelf.owner, ring(1)]; + total += kept.length; +} +console.log(total); + +// Release the last holder of a long-lived cycle late. +let late: Wrapper | null = new Wrapper(ring(1000), "late"); +const peerValue = late.ring.peer!.value; +late = null; +console.log(peerValue, late === null); diff --git a/tests/corpus/4602-constructor-escape-order.ts b/tests/corpus/4602-constructor-escape-order.ts new file mode 100644 index 000000000..f5307c8dc --- /dev/null +++ b/tests/corpus/4602-constructor-escape-order.ts @@ -0,0 +1,66 @@ +// A constructor that lets `this` escape only does so from a certain +// statement on. Fields it stores before that point are still construction- +// only: no other code could see the object yet, so they keep their value and +// cannot close a cycle. Stores from the escaping statement on are ordinary +// writes: they may close cycles (the sanitized lane's RC audit fails if one +// leaks) and their values may be replaced while other code reads them. + +class Item { + readonly name: string; + constructor(name: string) { + this.name = name; + } +} + +const registry: Node[] = []; + +class Node { + readonly item: Item; + readonly id: number; + peer: Node | null; + late: Item | null; + constructor(id: number, item: Item) { + this.id = id; + this.item = item; + this.peer = null; + registry.push(this); + // After the escape: these stores can see (and close cycles with) + // objects created after this one. + this.peer = registry.length > 1 ? registry[registry.length - 2]! : null; + this.late = new Item(`late${id}`); + } +} + +class Linked extends Node { + back: Node | null; + constructor(id: number) { + super(id, new Item(`linked${id}`)); + // The base constructor already let `this` escape. + this.back = registry[0] ?? null; + if (this.back !== null) this.back.peer = this; + } +} + +function useAfter(item: Item, drop: () => void): string { + drop(); + const noise: Item[] = []; + for (let i = 0; i < 64; i++) noise.push(new Item(`noise${i}`)); + return `${item.name}/${noise.length}`; +} + +let total = 0; +for (let round = 0; round < 200; round++) { + const a = new Node(round, new Item(`a${round}`)); + const b = new Linked(round + 1); + // Pre-escape field of a local owner, borrowed while every other + // reference goes away. + total += useAfter(a.item, () => { + registry.length = 0; + }).length; + // A post-escape field replaced while it is being read. + total += useAfter(b.late!, () => { + b.late = new Item("replaced"); + }).length; + total += (a.peer === null ? 0 : 1) + (b.back === a ? 1 : 0) + (a.peer === b ? 1 : 0); +} +console.log(total, registry.length); From 20b7da29e748d23e4bd6632c15bdf3648aa77b95 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 16:43:07 -0700 Subject: [PATCH 08/19] Stream serialized IR and LLVM instead of building one string The self-hosted compiler's compact IR is ~515-528M characters, a few million below V8's maximum string length (2^29 - 24). serializeModule joined it into one string, deserializeModule parsed one string, and the native bootstrap comparison read cli.ir.json and cli.ll as utf8 strings, so any compiler growth crashed --emit=ir and the bootstrap tests with "RangeError: Invalid string length". - serializeModuleParts returns the compact encoding as ordered parts (header, one part per function, remainder). serializeModule(mod, true) is their join, so the on-disk format is byte-identical. - deserializeModuleBytes splits a UTF-8 Buffer at the root object's "functions" array with a byte-level scanner and parses the header and each function separately. Malformed input falls back to the whole-document parse for its exact error. - The Node API writes --emit=ir, --emit-ir sidecars and --emit=llvm through writeTextParts (the former writeLlvmSource batching writer). - The native bootstrap harness decodes IR per function and compares LLVM as Buffers, removing ABI-check lines at the byte level. - The self-hosting serialization fixture gains a "bytes" mode so the scriptc-compiled serializer exercises both new entry points. Self-hosted compiler IR (514.6 MB, macOS arm64, Node 24.12): read old 5.85 s / 4.26 GB max RSS -> new 5.84 s / 1.36 GB write old 2.05-2.53 s / 4.48 GB -> new 2.30-2.37 s / 2.10 GB 2x module (1.03 GB): old serialize and read throw; new round-trips. The remaining per-string bound is the largest function (16.3M chars, ~33x headroom) and the header (2.3M chars). --- .../compiler/src/backend/native-codegen.ts | 33 +-- packages/compiler/src/index.ts | 23 +- packages/compiler/src/ir/serialize.test.ts | 76 +++++- packages/compiler/src/ir/serialize.ts | 246 +++++++++++++++--- packages/compiler/src/text-parts.ts | 37 +++ tests/fixtures/self-hosting/serialize.ts | 19 +- tests/harness/large-artifacts.test.ts | 30 +++ tests/harness/large-artifacts.ts | 31 +++ .../self-hosting-native-driver.test.ts | 39 ++- .../self-hosting-serialization.test.ts | 20 ++ 10 files changed, 459 insertions(+), 95 deletions(-) create mode 100644 packages/compiler/src/text-parts.ts create mode 100644 tests/harness/large-artifacts.test.ts create mode 100644 tests/harness/large-artifacts.ts diff --git a/packages/compiler/src/backend/native-codegen.ts b/packages/compiler/src/backend/native-codegen.ts index 6e91560dd..6aab5880a 100644 --- a/packages/compiler/src/backend/native-codegen.ts +++ b/packages/compiler/src/backend/native-codegen.ts @@ -2,7 +2,7 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { constants } from "node:fs"; import { createRequire } from "node:module"; -import { access, chmod, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises"; +import { access, chmod, mkdir, readFile, rename, rm, stat } from "node:fs/promises"; import { dirname, join } from "node:path"; import { promisify } from "node:util"; import { @@ -27,6 +27,7 @@ import { type NativeTargetSpec, } from "./targets.js"; import { compilerReleaseVersion } from "../library/sidecar.js"; +import { writeTextParts } from "../text-parts.js"; import { NativeCodegenError, @@ -87,39 +88,13 @@ export interface NativeCodegenOptions { importBitcode?: { paths: readonly string[]; digests: readonly string[] }; } -/** Writes LLVM text in large batches. Programs past the engine's string - * limit arrive as many parts, and writing each part separately is several - * times slower than writing the same bytes in a few large chunks. */ +/** Writes LLVM text in large batches; see `writeTextParts`. */ export async function writeLlvmSource( path: string, llvm: string | readonly string[], mode = 0o666, ): Promise { - const file = await open(path, "w", mode); - try { - const write = async (text: string): Promise => { - const bytes = Buffer.from(text, "utf8"); - for (let offset = 0; offset < bytes.length;) { - const { bytesWritten } = await file.write(bytes, offset, bytes.length - offset); - offset += bytesWritten; - } - }; - if (typeof llvm === "string") return await write(llvm); - let batch: string[] = []; - let length = 0; - for (const part of llvm) { - batch.push(part); - length += part.length; - if (length >= 16 * 1024 * 1024) { - await write(batch.join("")); - batch = []; - length = 0; - } - } - if (batch.length > 0) await write(batch.join("")); - } finally { - await file.close(); - } + await writeTextParts(path, llvm, mode); } function parseJsonObject(text: string): Record | null { diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 0b8d48430..0759a01cb 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -103,7 +103,8 @@ import { } from "./library/sidecar.js"; import { type IrModule, type SrcLoc } from "./ir/ir.js"; import { moduleRuntimeFeatures } from "./ir/runtime-features.js"; -import { serializeModule } from "./ir/serialize.js"; +import { serializeModuleParts } from "./ir/serialize.js"; +import { writeTextParts } from "./text-parts.js"; import { validateModule } from "./ir/validate.js"; import { loadProgram } from "./frontend/program-node.js"; import { runFrontend, type FrontendFactory } from "./frontend/pipeline.js"; @@ -206,7 +207,13 @@ export { } from "./compat/fetch-profile.js"; export { LIB_FN_SIGS } from "./ir/builtin-signatures.js"; export { validateModule } from "./ir/validate.js"; -export { deserializeModule, IR_VERSION, serializeModule } from "./ir/serialize.js"; +export { + deserializeModule, + deserializeModuleBytes, + IR_VERSION, + serializeModule, + serializeModuleParts, +} from "./ir/serialize.js"; export { resolveLibraryFences, type LibraryFenceDecl, @@ -670,7 +677,7 @@ async function prepareExecutableInput( if (outputKind === "ir") { await mkdir(dirname(opts.outPath), { recursive: true }); - await writeFile(opts.outPath, serializeModule(mod, true)); + await writeTextParts(opts.outPath, serializeModuleParts(mod)); return { ok: true, artifact: { kind: "ir", path: opts.outPath } }; } @@ -692,7 +699,7 @@ async function prepareExecutableInput( } if (outputKind === "llvm") { await mkdir(dirname(opts.outPath), { recursive: true }); - await writeFile(opts.outPath, llvm); + await writeTextParts(opts.outPath, llvm); } else { try { await emitNativeArtifact({ @@ -766,7 +773,7 @@ async function prepareExecutableInput( let irPath: string | undefined; if (opts.emitIr) { irPath = defaultSourcePaths.ir; - await writeFile(irPath, serializeModule(mod, true)); + await writeTextParts(irPath, serializeModuleParts(mod)); } const nativeFeatures = executableNativeFeatures( @@ -1448,7 +1455,7 @@ async function emitSemanticLibraryHit( let irPath: string | undefined; if (opts.emitIr) { irPath = join(opts.outDir, `${stem}.lib.ir.json`); - await writeFile(irPath, serializeModule(mod, true)); + await writeTextParts(irPath, serializeModuleParts(mod)); } await compileLibraryNative(profile, llvmPath, archivePath, opts.sanitize ?? false, hit.native); timing("native-archive"); @@ -1724,7 +1731,7 @@ async function compileLibraryTracked( ? Buffer.byteLength(ll) : ll.reduce((bytes, part) => bytes + Buffer.byteLength(part), 0), }); - await writeFile(llvmPath, ll); + await writeTextParts(llvmPath, ll); timing("llvm-write"); } catch (err) { if (!(err instanceof LlvmUnsupportedError)) throw err; @@ -1734,7 +1741,7 @@ async function compileLibraryTracked( let irPath: string | undefined; if (opts.emitIr) { irPath = join(opts.outDir, `${stem}.lib.ir.json`); - await writeFile(irPath, serializeModule(mod, true)); + await writeTextParts(irPath, serializeModuleParts(mod)); } const nativeFeatures = libraryNativeFeatures(mod, profile.emission); diff --git a/packages/compiler/src/ir/serialize.test.ts b/packages/compiler/src/ir/serialize.test.ts index be933514e..8345ab10a 100644 --- a/packages/compiler/src/ir/serialize.test.ts +++ b/packages/compiler/src/ir/serialize.test.ts @@ -1,6 +1,12 @@ import { expect, test } from "vitest"; import { F64, VOID, type IrStmt, type IrModule } from "./ir.js"; -import { IR_VERSION, deserializeModule, serializeModule } from "./serialize.js"; +import { + IR_VERSION, + deserializeModule, + deserializeModuleBytes, + serializeModule, + serializeModuleParts, +} from "./serialize.js"; function numbers(values: number[]): IrModule { const loc = { file: "numbers.ts", start: 0, end: 0 }; @@ -124,3 +130,71 @@ test("compact artifacts keep JSON's circular-data refusal", () => { expect(() => serializeModule(mod)).toThrow(TypeError); expect(() => serializeModule(mod, true)).toThrow(TypeError); }); + +/** A module whose strings contain every byte the function splitter must not + * mistake for structure. */ +function structuralStrings(): IrModule { + const mod = numbers([NaN, -0, 1.5, Infinity]); + const tricky = 'q"\\"],[{"functions":[]}\\, \n\t😀é\\'; + mod.sourceFile = tricky + '"functions":['; + const base = mod.functions[0]!; + mod.functions = [ + base, + { ...base, name: tricky, loc: { file: tricky, start: -0, end: 2 } }, + { ...base, name: "]", body: [] }, + { ...base, name: "\\" }, + ]; + mod.globals = [{ id: "%g.functions", name: "functions", type: F64, mutable: false }]; + return mod; +} + +test("compact parts are exactly the single-root JSON encoding", () => { + const mod = structuralStrings(); + const parts = serializeModuleParts(mod); + expect(parts.length).toBe(2 + mod.functions.length * 2 - 1); + const sentinels = (_key: string, value: unknown): unknown => + typeof value !== "number" + ? value + : Number.isNaN(value) + ? { $nonfinite: "nan" } + : value === Infinity + ? { $nonfinite: "inf" } + : value === -Infinity + ? { $nonfinite: "-inf" } + : Object.is(value, -0) + ? { $nonfinite: "-0" } + : value; + expect(parts.join("")).toBe(JSON.stringify(mod, sentinels)); + expect(serializeModule(mod, true)).toBe(parts.join("")); +}); + +test("byte decoding splits functions without being misled by string contents", () => { + const mod = structuralStrings(); + for (const text of [serializeModule(mod, true), serializeModule(mod)]) { + const decoded = deserializeModuleBytes(Buffer.from(text, "utf8")); + expect(decoded).toEqual(mod); + expect(decoded).toEqual(deserializeModule(text)); + expect(Object.is(decoded.functions[1]!.loc.start, -0)).toBe(true); + } + mod.functions = []; + expect(deserializeModuleBytes(Buffer.from(serializeModule(mod, true)))).toEqual(mod); + expect(deserializeModuleBytes(Buffer.from(serializeModule(mod)))).toEqual(mod); +}); + +test("byte decoding keeps the whole-document errors and version refusal", () => { + const text = serializeModule(structuralStrings(), true); + for (const broken of [text.slice(0, -1), text.slice(0, text.length - 40), text + "]", "[]", ""]) { + let expected: unknown; + try { + deserializeModule(broken); + } catch (error) { + expected = error; + } + expect(expected).toBeInstanceOf(Error); + expect(() => deserializeModuleBytes(Buffer.from(broken))).toThrow(); + } + const older = text.replace(`"irVersion":${IR_VERSION}`, `"irVersion":${IR_VERSION - 1}`); + expect(() => deserializeModuleBytes(Buffer.from(older))).toThrow("IR version mismatch"); + const sentinel = text.replace('"nan"', '"future"'); + expect(() => deserializeModuleBytes(Buffer.from(sentinel))).toThrow("Invalid IR number sentinel"); +}); diff --git a/packages/compiler/src/ir/serialize.ts b/packages/compiler/src/ir/serialize.ts index b156300ea..9455191d4 100644 --- a/packages/compiler/src/ir/serialize.ts +++ b/packages/compiler/src/ir/serialize.ts @@ -1,6 +1,12 @@ /* IR ↔ JSON. Numeric literals preserve JavaScript's complete number domain: - * JSON's null/zero spellings must not erase NaN, infinities or negative zero. */ -import type { IrModule } from "./ir.js"; + * JSON's null/zero spellings must not erase NaN, infinities or negative zero. + * + * Compiler-scale artifacts are larger than one host string can hold (V8 caps + * strings at 2^29 - 24 UTF-16 units, and the self-hosted compiler's IR is + * already close to it). The compact encoding is therefore produced as ordered + * text parts and decoded from bytes one function at a time; neither direction + * materializes the complete document as a single string. */ +import type { IrFunction, IrModule } from "./ir.js"; // Version 15 expands the native Error prefix with filesystem metadata. // Older modules embed incompatible subclass offsets and must be rejected. @@ -22,52 +28,65 @@ function ordinaryNumbers(root: unknown): boolean { return visit(root); } -/** Compiler artifacts use compact JSON to keep large graphs below the host's - * string size limit. API consumers can retain the readable default. */ -export function serializeModule(mod: IrModule, compact = false): string { - const replacer = (_key: string, value: unknown): unknown => { - if (typeof value !== "number") return value; - if (!Number.isFinite(value)) { - return { $nonfinite: Number.isNaN(value) ? "nan" : value > 0 ? "inf" : "-inf" }; - } - // JSON.stringify(-0) prints "0", silently losing the sign a numLit's - // f64 semantics depend on (String(-0) is "0" but 1/-0 is -Infinity) — - // the same sentinel mechanism carries it. - if (Object.is(value, -0)) { - return { $nonfinite: "-0" }; - } - return value; - }; - if (!compact) return JSON.stringify(mod, replacer, 2); +function encodeNumber(_key: string, value: unknown): unknown { + if (typeof value !== "number") return value; + if (!Number.isFinite(value)) { + return { $nonfinite: Number.isNaN(value) ? "nan" : value > 0 ? "inf" : "-inf" }; + } + // JSON.stringify(-0) prints "0", silently losing the sign a numLit's + // f64 semantics depend on (String(-0) is "0" but 1/-0 is -Infinity) — + // the same sentinel mechanism carries it. + if (Object.is(value, -0)) { + return { $nonfinite: "-0" }; + } + return value; +} + +/** The compact encoding as ordered parts: the module header up to the + * function array, one part per function (with separate comma parts), and the + * header's remainder. Concatenating the parts yields exactly + * `serializeModule(mod, true)`; writers stream them to disk in batches. */ +export function serializeModuleParts(mod: IrModule): string[] { // A replacer reads through live native views. Serializing the complete // function array through one view refreshes every function capsule for // each element, making large compiler artifacts quadratic. The sentinel // replacer is key-independent, so encode each function as its own root. - const header = JSON.stringify({ ...mod, functions: [] }, replacer); + const header = JSON.stringify({ ...mod, functions: [] }, encodeNumber); const slot = '"functions":[]'; const offset = header.indexOf(slot); - const functions = mod.functions - .map( - (fn) => (ordinaryNumbers(fn) ? JSON.stringify(fn) : JSON.stringify(fn, replacer)) ?? "null", - ) - .join(","); - return ( - header.slice(0, offset) + '"functions":[' + functions + "]" + header.slice(offset + slot.length) - ); + const parts: string[] = [header.slice(0, offset) + '"functions":[']; + for (let index = 0; index < mod.functions.length; index++) { + const fn = mod.functions[index]!; + if (index > 0) parts.push(","); + parts.push( + (ordinaryNumbers(fn) ? JSON.stringify(fn) : JSON.stringify(fn, encodeNumber)) ?? "null", + ); + } + parts.push("]" + header.slice(offset + slot.length)); + return parts; } -export function deserializeModule(json: string): IrModule { - const mod = JSON.parse(json, (_key, value: unknown) => { - if (typeof value === "object" && value !== null && "$nonfinite" in value) { - const tag = (value as { $nonfinite: string }).$nonfinite; - if (tag === "inf") return Infinity; - if (tag === "-inf") return -Infinity; - if (tag === "-0") return -0; - if (tag === "nan") return NaN; - throw new Error(`Invalid IR number sentinel: ${String(tag)}`); - } - return value; - }) as IrModule; +/** Compiler artifacts use compact JSON; API consumers can retain the + * readable default. Both return one string, so modules whose encoding + * exceeds the host's string limit must use `serializeModuleParts`. */ +export function serializeModule(mod: IrModule, compact = false): string { + if (!compact) return JSON.stringify(mod, encodeNumber, 2); + return serializeModuleParts(mod).join(""); +} + +function decodeNumber(_key: string, value: unknown): unknown { + if (typeof value === "object" && value !== null && "$nonfinite" in value) { + const tag = (value as { $nonfinite: string }).$nonfinite; + if (tag === "inf") return Infinity; + if (tag === "-inf") return -Infinity; + if (tag === "-0") return -0; + if (tag === "nan") return NaN; + throw new Error(`Invalid IR number sentinel: ${String(tag)}`); + } + return value; +} + +function checkedVersion(mod: IrModule): IrModule { if (mod.irVersion !== IR_VERSION) { throw new Error( `IR version mismatch: file has ${String(mod.irVersion)}, compiler expects ${IR_VERSION}`, @@ -75,3 +94,150 @@ export function deserializeModule(json: string): IrModule { } return mod; } + +export function deserializeModule(json: string): IrModule { + return checkedVersion(JSON.parse(json, decodeNumber) as IrModule); +} + +const QUOTE = 0x22; +const BACKSLASH = 0x5c; +const COMMA = 0x2c; +const COLON = 0x3a; +const OPEN_BRACKET = 0x5b; +const CLOSE_BRACKET = 0x5d; +const OPEN_BRACE = 0x7b; +const CLOSE_BRACE = 0x7d; +const FUNCTIONS_KEY = '"functions"'; + +function isJsonSpace(byte: number): boolean { + return byte === 0x20 || byte === 0x0a || byte === 0x0d || byte === 0x09; +} + +function skipSpace(bytes: Buffer, index: number): number { + while (index < bytes.length && isJsonSpace(bytes[index]!)) index++; + return index; +} + +/** `index` is an opening quote; returns the index after its closing quote. */ +function skipString(bytes: Buffer, index: number): number { + const length = bytes.length; + index++; + while (index < length) { + const byte = bytes[index]!; + if (byte === BACKSLASH) index += 2; + else if (byte === QUOTE) return index + 1; + else index++; + } + return length; +} + +interface FunctionSpans { + /** Offsets of the function array's brackets. */ + open: number; + close: number; + /** Byte range of each element. */ + starts: number[]; + ends: number[]; +} + +/** Element ranges of the array starting at `open`, or null when the bytes + * are not well-formed enough to split (the whole-document parse then reports + * the exact syntax error). */ +function elementSpans(bytes: Buffer, open: number): FunctionSpans | null { + const length = bytes.length; + const starts: number[] = []; + const ends: number[] = []; + let index = skipSpace(bytes, open + 1); + if (index < length && bytes[index] === CLOSE_BRACKET) return { open, close: index, starts, ends }; + while (index < length) { + const start = index; + let depth = 0; + while (index < length) { + const byte = bytes[index]!; + if (byte === QUOTE) { + index = skipString(bytes, index); + if (depth === 0) break; + continue; + } + if (byte === OPEN_BRACE || byte === OPEN_BRACKET) depth++; + else if (byte === CLOSE_BRACE || byte === CLOSE_BRACKET) { + if (depth === 0) break; + depth--; + if (depth === 0) { + index++; + break; + } + } else if (depth === 0 && (byte === COMMA || isJsonSpace(byte))) break; + index++; + } + if (index === start || index >= length) return null; + starts.push(start); + ends.push(index); + index = skipSpace(bytes, index); + if (index >= length) return null; + if (bytes[index] === CLOSE_BRACKET) return { open, close: index, starts, ends }; + if (bytes[index] !== COMMA) return null; + index = skipSpace(bytes, index + 1); + } + return null; +} + +/** Locate the root object's `"functions"` array. */ +function functionSpans(bytes: Buffer): FunctionSpans | null { + const length = bytes.length; + let depth = 0; + let index = 0; + while (index < length) { + const byte = bytes[index]!; + if (byte === QUOTE) { + const start = index; + index = skipString(bytes, index); + if (depth === 1 && index - start === FUNCTIONS_KEY.length) { + let key = true; + for (let offset = 1; offset < FUNCTIONS_KEY.length - 1; offset++) { + if (bytes[start + offset] !== FUNCTIONS_KEY.charCodeAt(offset)) { + key = false; + break; + } + } + if (key) { + let next = skipSpace(bytes, index); + if (next < length && bytes[next] === COLON) { + next = skipSpace(bytes, next + 1); + if (next < length && bytes[next] === OPEN_BRACKET) return elementSpans(bytes, next); + } + } + } + continue; + } + if (byte === OPEN_BRACE || byte === OPEN_BRACKET) depth++; + else if (byte === CLOSE_BRACE || byte === CLOSE_BRACKET) depth--; + index++; + } + return null; +} + +/** Decode a UTF-8 IR artifact (compact or readable) without materializing + * it as one string: the header and each function are parsed separately. + * Split points are ASCII structural bytes, so every range is valid UTF-8. */ +export function deserializeModuleBytes(bytes: Buffer): IrModule { + const spans = functionSpans(bytes); + if (spans === null) return deserializeModule(bytes.toString("utf8")); + const mod = checkedVersion( + JSON.parse( + bytes.toString("utf8", 0, spans.open + 1) + bytes.toString("utf8", spans.close), + decodeNumber, + ) as IrModule, + ); + const functions: IrFunction[] = []; + for (let index = 0; index < spans.starts.length; index++) { + functions.push( + JSON.parse( + bytes.toString("utf8", spans.starts[index]!, spans.ends[index]!), + decodeNumber, + ) as IrFunction, + ); + } + mod.functions = functions; + return mod; +} diff --git a/packages/compiler/src/text-parts.ts b/packages/compiler/src/text-parts.ts new file mode 100644 index 000000000..a4519b0ba --- /dev/null +++ b/packages/compiler/src/text-parts.ts @@ -0,0 +1,37 @@ +import { open } from "node:fs/promises"; + +/** Writes compiler text artifacts (LLVM, serialized IR) in large batches. + * Programs past the engine's string limit arrive as many parts, and writing + * each part separately is several times slower than writing the same bytes in + * a few large chunks. Batches stay far below the limit. */ +export async function writeTextParts( + path: string, + text: string | readonly string[], + mode = 0o666, +): Promise { + const file = await open(path, "w", mode); + try { + const write = async (chunk: string): Promise => { + const bytes = Buffer.from(chunk, "utf8"); + for (let offset = 0; offset < bytes.length;) { + const { bytesWritten } = await file.write(bytes, offset, bytes.length - offset); + offset += bytesWritten; + } + }; + if (typeof text === "string") return await write(text); + let batch: string[] = []; + let length = 0; + for (const part of text) { + batch.push(part); + length += part.length; + if (length >= 16 * 1024 * 1024) { + await write(batch.join("")); + batch = []; + length = 0; + } + } + if (batch.length > 0) await write(batch.join("")); + } finally { + await file.close(); + } +} diff --git a/tests/fixtures/self-hosting/serialize.ts b/tests/fixtures/self-hosting/serialize.ts index 0146da1a4..4654ea415 100644 --- a/tests/fixtures/self-hosting/serialize.ts +++ b/tests/fixtures/self-hosting/serialize.ts @@ -1,9 +1,19 @@ import { readFileSync } from "node:fs"; -import { deserializeModule, serializeModule } from "../../../packages/compiler/src/ir/serialize.js"; +import { + deserializeModule, + deserializeModuleBytes, + serializeModule, + serializeModuleParts, +} from "../../../packages/compiler/src/ir/serialize.js"; import { validateModule } from "../../../packages/compiler/src/ir/validate.js"; try { - const mod = deserializeModule(readFileSync(process.argv[2]!, "utf8")); + // "bytes" exercises the compiler-scale path: per-function decoding and + // compact parts, as the native compiler uses for its own IR. + const bytes = process.argv[3] === "bytes"; + const mod = bytes + ? deserializeModuleBytes(readFileSync(process.argv[2]!)) + : deserializeModule(readFileSync(process.argv[2]!, "utf8")); const errors = validateModule(mod); if (errors.length > 0) { console.log(JSON.stringify(errors)); @@ -15,7 +25,10 @@ try { const statement = mod.functions[0]!.body[0]; if (statement?.kind === "exprStmt" && statement.expr.kind === "numLit") statement.expr.value = NaN; } - console.log(serializeModule(mod, process.argv[3] === "compact")); + if (bytes) { + for (const part of serializeModuleParts(mod)) process.stdout.write(part); + process.stdout.write("\n"); + } else console.log(serializeModule(mod, process.argv[3] === "compact")); } } catch (error) { if (error instanceof Error) console.log(error.name + ": " + error.message); diff --git a/tests/harness/large-artifacts.test.ts b/tests/harness/large-artifacts.test.ts new file mode 100644 index 000000000..207ce80a2 --- /dev/null +++ b/tests/harness/large-artifacts.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "vitest"; +import { occurrences, withoutFirstLine, withoutRange } from "./large-artifacts.js"; + +test("byte-level artifact edits match their string equivalents", () => { + const text = + "x declare void @scr_runtime_abi_v3()\ndeclare void @scr_runtime_abi_vX()\n" + + "declare void @scr_runtime_abi_v12()\n call void @scr_runtime_abi_v12()\n" + + "é\n call void @scr_runtime_abi_v12()\n"; + const bytes = Buffer.from(text); + const declaration = /^declare void @scr_runtime_abi_v\d+\(\)\n/m; + const call = /^ {2}call void @scr_runtime_abi_v\d+\(\)\n/m; + const edited = withoutFirstLine( + withoutFirstLine( + bytes, + "declare void @scr_runtime_abi_v", + /^declare void @scr_runtime_abi_v\d+\(\)\n$/, + ), + " call void @scr_runtime_abi_v", + /^ {2}call void @scr_runtime_abi_v\d+\(\)\n$/, + ); + expect(edited.toString()).toBe(text.replace(declaration, "").replace(call, "")); + expect(withoutFirstLine(bytes, "missing", /^missing\n$/)).toBe(bytes); + + const line = " call void @scr_runtime_abi_v12()\n"; + const found = occurrences(bytes, line); + expect(found).toHaveLength(text.split(line).length - 1); + expect(withoutRange(bytes, found[0]!, Buffer.byteLength(line)).toString()).toBe( + text.replace(line, ""), + ); +}); diff --git a/tests/harness/large-artifacts.ts b/tests/harness/large-artifacts.ts new file mode 100644 index 000000000..c21dc11ae --- /dev/null +++ b/tests/harness/large-artifacts.ts @@ -0,0 +1,31 @@ +/** Byte-level helpers for compiler-scale text artifacts (the self-hosted + * compiler's LLVM and IR). They avoid decoding a complete artifact into one + * JavaScript string, which V8 caps at 2^29 - 24 UTF-16 units. */ + +/** Offsets of every occurrence of `needle`. */ +export function occurrences(bytes: Buffer, needle: string): number[] { + const found: number[] = []; + const width = Buffer.byteLength(needle); + for (let at = bytes.indexOf(needle); at >= 0; at = bytes.indexOf(needle, at + width)) + found.push(at); + return found; +} + +/** `bytes` without `length` bytes at `start`. */ +export function withoutRange(bytes: Buffer, start: number, length: number): Buffer { + return Buffer.concat([bytes.subarray(0, start), bytes.subarray(start + length)]); +} + +/** Remove the first complete line that starts with `prefix` and, including + * its newline, matches `line` (anchored), like `text.replace(/^...\n/m, "")`. */ +export function withoutFirstLine(bytes: Buffer, prefix: string, line: RegExp): Buffer { + for (let from = 0; ;) { + const at = bytes.indexOf(prefix, from); + if (at < 0) return bytes; + const newline = bytes.indexOf(10, at); + const stop = newline < 0 ? bytes.length : newline + 1; + if ((at === 0 || bytes[at - 1] === 10) && line.test(bytes.toString("utf8", at, stop))) + return withoutRange(bytes, at, stop - at); + from = at + 1; + } +} diff --git a/tests/harness/self-hosting-native-driver.test.ts b/tests/harness/self-hosting-native-driver.test.ts index 87ba5a4c8..b57d0b6cf 100644 --- a/tests/harness/self-hosting-native-driver.test.ts +++ b/tests/harness/self-hosting-native-driver.test.ts @@ -24,6 +24,7 @@ import { nativeBootstrapPlan, runNativeBootstrapChecks, } from "../../scripts/ci-native-bootstrap.mjs"; +import { withoutFirstLine } from "./large-artifacts.js"; import { bootstrapStep } from "./self-hosting-timing.js"; const root = join(import.meta.dirname, "../.."); @@ -435,17 +436,20 @@ test( import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { isDeepStrictEqual } from 'node:util'; - import { deserializeModule, validateModule } from ${JSON.stringify(pathToFileURL(join(root, "packages/compiler/src/index.ts")).href)}; + import { deserializeModuleBytes, validateModule } from ${JSON.stringify(pathToFileURL(join(root, "packages/compiler/src/index.ts")).href)}; + import { occurrences, withoutRange } from ${JSON.stringify(pathToFileURL(join(root, "tests/harness/large-artifacts.ts")).href)}; + // Both artifacts are compared as bytes and per-function documents: + // either can exceed V8's maximum string length. if (${ir}) { - const expected = deserializeModule(readFileSync(process.argv[1], 'utf8')); - const actual = deserializeModule(readFileSync(process.argv[2], 'utf8')); + const expected = deserializeModuleBytes(readFileSync(process.argv[1])); + const actual = deserializeModuleBytes(readFileSync(process.argv[2])); assert.ok(actual.functions.length > 1000); assert.deepEqual(validateModule(actual), []); assert.ok(isDeepStrictEqual(actual, expected), 'native self-lowering must match the Node seed'); } if (${llvm}) { - const seedLlvm = readFileSync(process.argv[3], 'utf8'); - let nativeLlvm = readFileSync(process.argv[4], 'utf8'); + const seedLlvm = readFileSync(process.argv[3]); + let nativeLlvm = readFileSync(process.argv[4]); if (${sanitize}) { // The Node sanitizer builds runtime sources directly; the native // executable path also emits the runtime ABI check. Pin that @@ -453,12 +457,13 @@ test( const declaration = 'declare void @' + ${JSON.stringify(RUNTIME_ABI_MARKER)} + '()\\n'; const call = ' call void @' + ${JSON.stringify(RUNTIME_ABI_MARKER)} + '()\\n'; for (const line of [declaration, call]) { - assert.equal(seedLlvm.split(line).length, 1); - assert.equal(nativeLlvm.split(line).length, 2); - nativeLlvm = nativeLlvm.replace(line, ''); + assert.equal(occurrences(seedLlvm, line).length, 0); + const found = occurrences(nativeLlvm, line); + assert.equal(found.length, 1); + nativeLlvm = withoutRange(nativeLlvm, found[0], Buffer.byteLength(line)); } } - assert.ok(seedLlvm === nativeLlvm, 'native LLVM emission must match the Node seed'); + assert.ok(seedLlvm.equals(nativeLlvm), 'native LLVM emission must match the Node seed'); } `, seedIr, @@ -506,12 +511,18 @@ test( ); // Executable output adds a runtime ABI check; textual LLVM emission // omits it. Compare all other output without changing either mode. - const withoutAbiCheck = (path: string): string => - readFileSync(path, "utf8") - .replace(/^declare void @scr_runtime_abi_v\d+\(\)\n/m, "") - .replace(/^ call void @scr_runtime_abi_v\d+\(\)\n/m, ""); + const withoutAbiCheck = (path: string): Buffer => + withoutFirstLine( + withoutFirstLine( + readFileSync(path), + "declare void @scr_runtime_abi_v", + /^declare void @scr_runtime_abi_v\d+\(\)\n$/, + ), + " call void @scr_runtime_abi_v", + /^ call void @scr_runtime_abi_v\d+\(\)\n$/, + ); expect( - withoutAbiCheck(seedLlvm) === withoutAbiCheck(rebuiltLlvm), + withoutAbiCheck(seedLlvm).equals(withoutAbiCheck(rebuiltLlvm)), "native compiler generations must emit identical LLVM", ).toBe(true); }; diff --git a/tests/harness/self-hosting-serialization.test.ts b/tests/harness/self-hosting-serialization.test.ts index 3051c86f1..4139cd648 100644 --- a/tests/harness/self-hosting-serialization.test.ts +++ b/tests/harness/self-hosting-serialization.test.ts @@ -178,6 +178,26 @@ for (const backend of ["llvm"] as const) { expect(deserializeModule(many.native.stdout.toString())).toEqual( deserializeModule(many.oracle.stdout.toString()), ); + // Byte decoding and compact parts, from both encodings. + for (const input of [serializeModule(manyFunctions), serializeModule(manyFunctions, true)]) { + const parts = run(input, ["bytes"]); + expect(parts.native.status, parts.native.stdout.toString()).toBe(0); + expect(parts.native.stdout.toString().trimEnd()).not.toContain("\n"); + expect(deserializeModule(parts.native.stdout.toString())).toEqual(manyFunctions); + expect(deserializeModule(parts.native.stdout.toString())).toEqual( + deserializeModule(parts.oracle.stdout.toString()), + ); + } + const truncated = run(serializeModule(manyFunctions, true).slice(0, -1), ["bytes"]); + expect(truncated.native.status).toBe(1); + expect(truncated.oracle.status).toBe(1); + const olderBytes = run( + serializeModule({ ...manyFunctions, irVersion: IR_VERSION - 1 }, true), + ["bytes"], + ); + expect(olderBytes.native.status).toBe(1); + expect(olderBytes.native.stdout).toEqual(olderBytes.oracle.stdout); + expect(olderBytes.native.stdout.toString()).toContain("IR version mismatch"); const print = numeric.functions[0]!.body[1]!; expect(print.kind).toBe("exprStmt"); if (print.kind !== "exprStmt" || print.expr.kind !== "intrinsic") From 5b8661bf8c2cef33847744e247e2bd625aa474f8 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 16:43:07 -0700 Subject: [PATCH 09/19] Keep native compiler IR and frontend cache entries out of single strings Scriptc-compiled strings stop at 2^31 - 1 UTF-8 bytes and exceeding the cap traps ("scriptc: out of memory") instead of throwing. In the native compiler the self-hosted IR was one 515 MB string, and the frontend cache entry JSON.stringify'd LLVM and IR together into one ~920 MB string, about 2.3x below the trap. - NativeExecutableInput.ir is compact IR as ~16M-char parts; every IR output is written with writeTextPartsSync. - Frontend cache entries move to scriptc.native-frontend.v3: the input metadata carries placeholders and the LLVM and IR follow as separate members, so the entry is written as parts with an incremental digest (NativeCache.writeParts). Entries a native reader could not decode as one string are neither published nor read (NativeCache.read maxBytes), turning the old trap into a cache miss. - SCRIPTC_BOOTSTRAP_IR_INPUT decodes the seed IR per function with deserializeModuleBytes. The native LLVM text remains one string (344 MB for the compiler, ~6x below the native cap). --- packages/compiler/src/native/cache.test.ts | 14 +++ packages/compiler/src/native/cache.ts | 35 +++++- packages/compiler/src/native/compiler.ts | 7 +- packages/compiler/src/native/prepare.test.ts | 25 ++++- packages/compiler/src/native/prepare.ts | 109 ++++++++++++++----- packages/compiler/src/native/text-file.ts | 41 +++++++ 6 files changed, 196 insertions(+), 35 deletions(-) create mode 100644 packages/compiler/src/native/text-file.ts diff --git a/packages/compiler/src/native/cache.test.ts b/packages/compiler/src/native/cache.test.ts index d5ac6bc6a..3ffdf2335 100644 --- a/packages/compiler/src/native/cache.test.ts +++ b/packages/compiler/src/native/cache.test.ts @@ -38,6 +38,20 @@ test("cache hits require an intact payload and digest", () => { expect(cache.read("object", key)).toBeNull(); }); +test("text parts publish one digest-checked entry and oversized reads are misses", () => { + const cache = new NativeCache(directory()); + const key = contentDigest("parts identity"); + const parts = ['{"a":', '"\u00e9é😀"', ",", "", '"b":1}']; + cache.writeParts("frontend", key, parts); + const bytes = cache.read("frontend", key); + expect(bytes?.toString("utf8")).toBe(parts.join("")); + expect(readFileSync(join(cache.root, "frontend", key + ".sha256"), "utf8").trim()).toBe( + contentDigest(parts.join("")), + ); + expect(cache.read("frontend", key, bytes!.length)).toEqual(bytes); + expect(cache.read("frontend", key, bytes!.length - 1)).toBeNull(); +}); + test("a failed cache publication does not fail the build or replace unrelated files", () => { const cache = new NativeCache(directory()); const occupied = join(cache.root, "object"); diff --git a/packages/compiler/src/native/cache.ts b/packages/compiler/src/native/cache.ts index 22741a0c4..f81311f52 100644 --- a/packages/compiler/src/native/cache.ts +++ b/packages/compiler/src/native/cache.ts @@ -11,6 +11,12 @@ import { } from "node:fs"; import { join } from "node:path"; import { resolveBuildCacheRoot } from "../backend/cache-root.js"; +import { writeTextPartsSync } from "./text-file.js"; + +/** UTF-8 bytes one native compiler string can hold (the runtime's + * SCR_STR_MAX_CAP). Exceeding it traps rather than throwing, so payloads + * decoded as one string are bounded by it up front. */ +export const NATIVE_STRING_BYTES = 0x7fffffff; export function contentDigest(value: string | Uint8Array): string { const hash = createHash("sha256"); @@ -29,9 +35,11 @@ export class NativeCache { mkdirSync(this.root, { recursive: true, mode: 0o700 }); } - read(family: string, key: string): Buffer | null { + /** Entries larger than `maxBytes` are misses. */ + read(family: string, key: string, maxBytes = Infinity): Buffer | null { try { const path = join(this.root, family, key); + if (maxBytes < Infinity && statSync(path).size > maxBytes) return null; const bytes = readFileSync(path); if (contentDigest(bytes) !== readFileSync(path + ".sha256", "utf8").trim()) return null; return bytes; @@ -77,6 +85,31 @@ export class NativeCache { } } + /** Publish text parts without joining them into one string. */ + writeParts(family: string, key: string, parts: readonly string[]): void { + let stage: string | null = null; + try { + const directory = join(this.root, family); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + stage = mkdtempSync(join(directory, ".write-")); + writeTextPartsSync(join(stage, "payload"), parts); + const hash = createHash("sha256"); + for (const part of parts) hash.update(part); + writeFileSync(join(stage, "digest"), hash.digest("hex") + "\n"); + const path = join(directory, key); + renameSync(join(stage, "payload"), path); + renameSync(join(stage, "digest"), path + ".sha256"); + } catch { + /* Cache failures never change a build's result. */ + } finally { + try { + if (stage !== null) rmSync(stage, { recursive: true, force: true }); + } catch { + /* Cleanup is best effort too. */ + } + } + } + prune(): void { try { const configured = Number(process.env["SCRIPTC_CACHE_MAX_MB"] ?? "4096"); diff --git a/packages/compiler/src/native/compiler.ts b/packages/compiler/src/native/compiler.ts index 984eb4339..57d2ab4d6 100644 --- a/packages/compiler/src/native/compiler.ts +++ b/packages/compiler/src/native/compiler.ts @@ -10,6 +10,7 @@ import { writeFileSync, } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; +import { writeTextPartsSync } from "./text-file.js"; import type { AnalyzeOptions, AnalyzeResult, @@ -320,7 +321,7 @@ export class NativeCompiler { const stem = basename(entry).replace(/\.(ts|mts|cts|js|mjs|cjs)$/, ""); const llvmPath = join(options.outDir, `${stem}.ll`); if (outputKind === "ir") { - writeFileSync(stagedOutput, prepared.ir!); + writeTextPartsSync(stagedOutput, prepared.ir!); renameSync(stagedOutput, output); return { ok: true, artifact: { kind: "ir", path: output } }; } @@ -464,7 +465,7 @@ export class NativeCompiler { let irPath: string | undefined; if (options.emitIr) { irPath = join(options.outDir, `${stem}.ir.json`); - writeFileSync(irPath, prepared.ir!); + writeTextPartsSync(irPath, prepared.ir!); } renameSync(stagedOutput, output); if (target.platform === "darwin" && (optimization !== "dev" || options.strip)) @@ -614,7 +615,7 @@ export class NativeCompiler { let irPath: string | undefined; if (options.emitIr) { irPath = join(options.outDir, `${stem}.lib.ir.json`); - writeFileSync(irPath, prepared.ir!); + writeTextPartsSync(irPath, prepared.ir!); } let sidecarPath: string | undefined; if (prepared.sidecarJson !== null) { diff --git a/packages/compiler/src/native/prepare.test.ts b/packages/compiler/src/native/prepare.test.ts index 4295e88ef..7e22450da 100644 --- a/packages/compiler/src/native/prepare.test.ts +++ b/packages/compiler/src/native/prepare.test.ts @@ -6,6 +6,7 @@ import { nativeCodegenTarget } from "../backend/targets.js"; import { runFrontend, type FrontendFactory } from "../frontend/pipeline.js"; import { loadProgram } from "../frontend/program-node.js"; import { ts7Executable } from "../frontend/ts7/rpc-api.js"; +import { deserializeModule } from "../ir/serialize.js"; import { NativeCache } from "./cache.js"; import { prepareNativeExecutable } from "./prepare.js"; import type { NativeToolchain } from "./toolchain.js"; @@ -55,7 +56,8 @@ function fixture() { loads++; return runFrontend(path, loadProgram, npmStatic, externalTypes); }; - const prepare = () => prepareNativeExecutable(entry, options, null, toolchain, frontend, cache); + const prepare = (extra: { emitIr?: boolean } = {}) => + prepareNativeExecutable(entry, { ...options, ...extra }, null, toolchain, frontend, cache); return { root, entry, dependency, cache, options, prepare, loads: () => loads }; } @@ -88,3 +90,24 @@ test("structurally invalid cached features cause a rebuild even with a valid dig expect(f.prepare().ok).toBe(true); expect(f.loads()).toBe(2); }); + +test("serialized IR travels as bounded parts through the frontend cache", () => { + const f = fixture(); + const first = f.prepare({ emitIr: true }); + expect(first.ok, JSON.stringify(first)).toBe(true); + if (!first.ok) return; + expect(Array.isArray(first.ir)).toBe(true); + const mod = deserializeModule(first.ir!.join("")); + expect(mod.functions.length).toBeGreaterThan(0); + const warm = f.prepare({ emitIr: true }); + expect(f.loads()).toBe(1); + expect(warm).toEqual(first); + const family = join(f.cache.root, "frontend"); + const key = readdirSync(family).find((name) => /^[0-9a-f]{64}$/.test(name))!; + const payload = JSON.parse(readFileSync(join(family, key), "utf8")); + expect(payload.schema).toBe("scriptc.native-frontend.v3"); + expect(payload.input.llvm).toBe(""); + expect(payload.input.ir).toBeNull(); + expect(payload.llvm).toBe(first.llvm); + expect(payload.ir).toEqual(first.ir); +}); diff --git a/packages/compiler/src/native/prepare.ts b/packages/compiler/src/native/prepare.ts index 24f339dae..69f317f28 100644 --- a/packages/compiler/src/native/prepare.ts +++ b/packages/compiler/src/native/prepare.ts @@ -25,15 +25,18 @@ import { provenanceSources } from "../frontend/provenance-registry.js"; import { prepareLibrary } from "../library/prepare.js"; import type { LibraryProfile } from "../library/library-profile.js"; import type { IrModule } from "../ir/ir.js"; -import { deserializeModule, serializeModule } from "../ir/serialize.js"; -import { contentDigest, type NativeCache } from "./cache.js"; +import { deserializeModuleBytes, serializeModuleParts } from "../ir/serialize.js"; +import { contentDigest, NATIVE_STRING_BYTES, type NativeCache } from "./cache.js"; +import { batchTextParts } from "./text-file.js"; import type { NativeToolchain } from "./toolchain.js"; import { nativeFileIdentity } from "./file-identity.js"; export interface NativeExecutableInput { ok: true; llvm: string; - ir: string | null; + /** Compact serialized IR as bounded parts (`batchTextParts`): compiler-scale + * modules exceed one native string. */ + ir: string[] | null; sidecarJson: string | null; features: NativeLinkFeatures; sources: [string, string][]; @@ -42,10 +45,62 @@ export interface NativeExecutableInput { warnings?: ScrDiagnostic[]; } +/** The two large artifacts follow the input metadata as separate members so + * an entry can be written as parts instead of one JSON string; the stored + * input carries empty placeholders (`""`, `null`) for them. */ interface FrontendCacheEntry { - schema: "scriptc.native-frontend.v2"; + schema: "scriptc.native-frontend.v3"; probes: FrontendInputSnapshot; input: NativeExecutableInput; + llvm: string; + ir: string[] | null; +} + +function frontendCacheParts(probes: FrontendInputSnapshot, input: NativeExecutableInput): string[] { + const metadata = JSON.stringify({ + schema: "scriptc.native-frontend.v3", + probes, + input: { ...input, llvm: "", ir: null }, + }); + const parts = [metadata.slice(0, metadata.length - 1) + ',"llvm":', JSON.stringify(input.llvm)]; + if (input.ir === null) parts.push(',"ir":null}'); + else { + parts.push(',"ir":['); + for (let index = 0; index < input.ir.length; index++) { + if (index > 0) parts.push(","); + parts.push(JSON.stringify(input.ir[index]!)); + } + parts.push("]}"); + } + return parts; +} + +/** Publish an entry only when a native reader can decode it as one string; + * UTF-16 length never exceeds the UTF-8 byte length. */ +function publishFrontendCache( + cache: NativeCache, + key: string, + probes: FrontendInputSnapshot, + input: NativeExecutableInput, +): void { + const parts = frontendCacheParts(probes, input); + let length = 0; + for (const part of parts) length += part.length; + if (length <= NATIVE_STRING_BYTES) cache.writeParts("frontend", key, parts); +} + +function readFrontendCache( + cache: NativeCache, + key: string, + exclusions: FrontendInputExclusions, +): NativeExecutableInput | null { + // The entry is decoded as one string; larger entries are misses. + const bytes = cache.read("frontend", key, NATIVE_STRING_BYTES); + if (bytes === null) return null; + const cached: unknown = JSON.parse(bytes.toString("utf8")); + if (!validCachedInput(cached) || !frontendInputsStillMatch(cached.probes, exclusions)) + return null; + return { ...cached.input, llvm: cached.llvm, ir: cached.ir }; } function implementationIdentity(toolchain: NativeToolchain): string { @@ -106,7 +161,7 @@ function validCachedInput(value: unknown): value is FrontendCacheEntry { const entry = value as Partial; const input = entry.input; if ( - entry.schema !== "scriptc.native-frontend.v2" || + entry.schema !== "scriptc.native-frontend.v3" || !validFrontendInputSnapshot(entry.probes) || input === undefined || input === null || @@ -115,8 +170,9 @@ function validCachedInput(value: unknown): value is FrontendCacheEntry { return false; return ( input.ok === true && - typeof input.llvm === "string" && - (input.ir === null || typeof input.ir === "string") && + typeof entry.llvm === "string" && + (entry.ir === null || + (Array.isArray(entry.ir) && entry.ir.every((part) => typeof part === "string"))) && (input.sidecarJson === null || typeof input.sidecarJson === "string") && validFeatures(input.features) && Array.isArray(input.sources) && @@ -144,7 +200,7 @@ function preparedFromIr( ): { ok: true; mod: IrModule; sourceTexts: Map; warnings: ScrDiagnostic[] } { if ((options.outputKind ?? "exe") !== "exe" || options.strip !== true) throw new Error(`${BOOTSTRAP_IR_INPUT} requires a stripped executable build`); - const mod = deserializeModule(readFileSync(path, "utf8")); + const mod = deserializeModuleBytes(readFileSync(path)); if (resolve(mod.sourceFile) !== resolve(entry)) throw new Error(`${BOOTSTRAP_IR_INPUT} was lowered from a different entry`); return { ok: true, mod, sourceTexts: new Map(), warnings: [] }; @@ -194,14 +250,10 @@ export function prepareNativeExecutable( .sort(([a], [b]) => a.localeCompare(b)), }), ); - const bytes = cache.read("frontend", key); - if (bytes !== null) { - const cached: unknown = JSON.parse(bytes.toString("utf8")); - if (validCachedInput(cached) && frontendInputsStillMatch(cached.probes, exclusions)) { - const input: NativeExecutableInput = cached.input; - timing("frontend-cache-hit"); - return input; - } + const input = readFrontendCache(cache, key, exclusions); + if (input !== null) { + timing("frontend-cache-hit"); + return input; } } catch { key = null; @@ -217,7 +269,10 @@ export function prepareNativeExecutable( ); if (!prepared.ok) return prepared; if (irInput !== "") timing("ir-input"); - const ir = outputKind === "ir" || options.emitIr ? serializeModule(prepared.mod, true) : null; + const ir = + outputKind === "ir" || options.emitIr + ? batchTextParts(serializeModuleParts(prepared.mod)) + : null; timing("ir-serialize"); const llvm = outputKind === "ir" @@ -251,8 +306,7 @@ export function prepareNativeExecutable( probes.stable && frontendInputsStillMatch(probes, exclusions) ) { - const saved: FrontendCacheEntry = { schema: "scriptc.native-frontend.v2", probes, input }; - cache.write("frontend", key, JSON.stringify(saved)); + publishFrontendCache(cache, key, probes, input); } timing("frontend-cache-publish"); return input; @@ -307,14 +361,10 @@ export function prepareNativeLibrary( .sort(([a], [b]) => a.localeCompare(b)), }), ); - const bytes = cache.read("frontend", key); - if (bytes !== null) { - const cached: unknown = JSON.parse(bytes.toString("utf8")); - if (validCachedInput(cached) && frontendInputsStillMatch(cached.probes, exclusions)) { - const input: NativeExecutableInput = cached.input; - timing("frontend-cache-hit"); - return input; - } + const input = readFrontendCache(cache, key, exclusions); + if (input !== null) { + timing("frontend-cache-hit"); + return input; } } catch { key = null; @@ -350,7 +400,7 @@ export function prepareNativeLibrary( const input: NativeExecutableInput = { ok: true, llvm, - ir: options.emitIr ? serializeModule(prepared.mod, true) : null, + ir: options.emitIr ? batchTextParts(serializeModuleParts(prepared.mod)) : null, sidecarJson: prepared.sidecarJson, features: executableLinkFeatures(prepared.mod, false), sources: [...prepared.sourceTexts], @@ -363,8 +413,7 @@ export function prepareNativeLibrary( probes.stable && frontendInputsStillMatch(probes, exclusions) ) { - const saved: FrontendCacheEntry = { schema: "scriptc.native-frontend.v2", probes, input }; - cache.write("frontend", key, JSON.stringify(saved)); + publishFrontendCache(cache, key, probes, input); } timing("frontend-cache-publish"); return input; diff --git a/packages/compiler/src/native/text-file.ts b/packages/compiler/src/native/text-file.ts new file mode 100644 index 000000000..6f8990339 --- /dev/null +++ b/packages/compiler/src/native/text-file.ts @@ -0,0 +1,41 @@ +import { closeSync, openSync, writeSync } from "node:fs"; + +/** Native compiler strings stop at 2^31 - 1 UTF-8 bytes, and exceeding that + * traps instead of throwing. Large artifacts therefore travel as bounded + * parts; this is the part size that batching aims for. */ +export const TEXT_BATCH_LENGTH = 16 * 1024 * 1024; + +/** Join consecutive small parts into batches of about TEXT_BATCH_LENGTH + * UTF-16 units, so callers hold and write few, still-bounded strings. */ +export function batchTextParts(parts: readonly string[]): string[] { + const batches: string[] = []; + let batch: string[] = []; + let length = 0; + for (const part of parts) { + batch.push(part); + length += part.length; + if (length >= TEXT_BATCH_LENGTH) { + batches.push(batch.join("")); + batch = []; + length = 0; + } + } + if (batch.length > 0) batches.push(batch.join("")); + return batches; +} + +/** Write text parts to `path` in order without joining them first. Each part + * is one write; pass batched parts (`batchTextParts`) for many small ones. */ +export function writeTextPartsSync(path: string, parts: readonly string[]): void { + const fd = openSync(path, "w"); + try { + for (const part of parts) { + const bytes = Buffer.from(part, "utf8"); + for (let offset = 0; offset < bytes.length;) { + offset += writeSync(fd, bytes, offset, bytes.length - offset); + } + } + } finally { + closeSync(fd); + } +} From 7956559ed15851d1e5314d84d7a2bccd5e36b00f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 22:41:34 -0700 Subject: [PATCH 10/19] runtime: make Map/Set clear and delete cost O(live), not O(peak) Number-keyed maps index small integer keys through a direct table sized by the largest key ever stored, and bucket tables are sized by the most entries ever held; neither shrank. scr_map_clear memset both on every call, even on an empty map, so a collection that was large once paid its peak size on every later clear. tsc-ts' Relater.reset clears a Set on every relation check: on Sentry, 36% of the CPU was that memset. - clear() resets only the direct-index slots of the entries it removes (the table is zero at every absent key), resets the bucket table only when entries pointed into it and it is proportional to them, and frees storage far larger than the entries removed, returning the map to its initial state as V8 does. Clearing an empty map does no work. - delete compacts once fewer than a quarter of at least 64 entries are live (V8's shrink point), so iteration after mass deletion is O(live). - Compaction shrinks oversized bucket tables, and after a delete or an iteration it shrinks the direct index to the surviving keys or moves keys that became sparse back to hashing. The append path keeps the current index: it holds a slot of it. Insertion order and live iteration are unchanged: nothing compacts while an iteration is active. --- packages/runtime/src/scr_map.c | 116 ++++++++++-- packages/runtime/src/scr_map.test.c | 228 +++++++++++++++++++++++ tests/corpus/4598-map-set-clear-reuse.ts | 147 +++++++++++++++ 3 files changed, 474 insertions(+), 17 deletions(-) create mode 100644 tests/corpus/4598-map-set-clear-reuse.ts diff --git a/packages/runtime/src/scr_map.c b/packages/runtime/src/scr_map.c index 064204975..6c1f4d1c8 100644 --- a/packages/runtime/src/scr_map.c +++ b/packages/runtime/src/scr_map.c @@ -518,15 +518,51 @@ static __attribute__((noinline)) void scr_map_leave_dense(ScrMap *m) { } /* Drop tombstones, preserving insertion order. Only legal when no iteration - * is active (the forEach desugar's indices would shift). */ -static void scr_map_compact(ScrMap *m) { - size_t w = 0; + * is active (the forEach desugar's indices would shift). + * + * Index tables are sized by the most entries a map has held, so a map that + * shrank keeps rebuilding (and clearing) a table sized for its peak. A + * bucket table more than eight times what the survivors need shrinks to + * twice that, leaving room to double before it grows again. With `reindex` + * the direct index also follows the surviving keys: it shrinks to cover the + * largest one, and keys that became sparse move back to hashing (at twice + * the spread that admits them, so churn cannot flip modes). The append path + * passes false: it holds a slot of the current direct index. */ +static void scr_map_compact(ScrMap *m, bool reindex) { + size_t w = 0, top = 0; + bool dense = m->ndense != 0; for (size_t r = 0; r < m->nentries; r++) { - if (m->entries[r].hash) m->entries[w++] = m->entries[r]; + if (!m->entries[r].hash) continue; + if (dense) { + size_t k = (size_t)scr_map_slot_to_f64(m->entries[r].key); + if (k > top) top = k; + } + m->entries[w++] = m->entries[r]; } m->nentries = w; - if (m->nbuckets > 0) scr_map_rebuild_buckets(m, m->nbuckets); - else if (m->ndense) scr_map_fill_dense(m); + if (m->nbuckets > 0) { + size_t want = 16; + while (want < 2 * (w + 1)) want *= 2; + scr_map_rebuild_buckets(m, m->nbuckets / 8 > want ? 2 * want : m->nbuckets); + return; + } + if (!dense) return; + if (reindex) { + if (w == 0) { + free(m->dense); + m->dense = NULL; + m->ndense = 0; + return; + } + size_t limit = scr_map_dense_limit(w); + if (top / 2 >= limit) { + scr_map_leave_dense(m); + return; + } + size_t need = scr_map_dense_size(top); + if (need < m->ndense) scr_map_dense_alloc(m, need); + } + scr_map_fill_dense(m); } static void scr_map_grow_entries(ScrMap *m) { @@ -548,7 +584,7 @@ static void scr_map_reserve_append(ScrMap *m) { if (m->ndense) { /* Directly indexed: the table is sized by keys, not entry count. */ if (m->nentries < m->ecap) return; - if (m->iter_depth == 0 && m->nlive <= m->nentries / 2) scr_map_compact(m); + if (m->iter_depth == 0 && m->nentries > 0 && m->nlive <= m->nentries / 2) scr_map_compact(m, false); if (m->nentries == m->ecap) scr_map_grow_entries(m); return; } @@ -557,7 +593,7 @@ static void scr_map_reserve_append(ScrMap *m) { m->nbuckets >= 2 * (m->nentries + 1))) return; if (m->iter_depth == 0 && m->nentries > 0 && (m->nbuckets == 0 ? m->nlive < m->nentries : m->nlive <= m->nentries / 2)) { - scr_map_compact(m); + scr_map_compact(m, false); } if (m->nentries == m->ecap) scr_map_grow_entries(m); if (m->nbuckets == 0 && m->nentries < SCR_MAP_LINEAR_LIMIT) return; @@ -701,10 +737,27 @@ void scr_map_trace_v(void *m, ScrTraceVisit visit, void *ctx) { double scr_map_size(const ScrMap *m) { return (double)m->nlive; } +/* Storage clear() keeps regardless of how many entries it removed. */ +#define SCR_MAP_CLEAR_KEEP 256 + +/* clear() costs time proportional to the entries it walks (live entries + * and tombstones appended since the last reset), never to the peak size of + * the index tables: + * - the direct index is zero exactly at the absent keys (deletes clear + * their slots), so only the live entries' slots are reset; + * - the bucket table is reset only when entries pointed into it, and only + * while it is proportional to them; + * - storage far larger than the entries just removed is freed, returning + * the map to its initial empty state the way V8 reallocates a cleared + * table. Regrowth is amortized against the insertions that need it. + * Clearing an empty map does no work beyond, once, freeing such storage. */ void scr_map_clear(ScrMap *m) { - for (size_t e = 0; e < m->nentries; e++) { + size_t walked = m->nentries; + uint32_t *dense = m->ndense ? m->dense : NULL; + for (size_t e = 0; e < walked; e++) { if (!m->entries[e].hash) continue; m->entries[e].hash = 0; + if (dense) dense[(size_t)scr_map_slot_to_f64(m->entries[e].key)] = 0; scr_map_release_key(m, m->entries[e].key); scr_map_release_val(m, m->entries[e].val); } @@ -714,12 +767,29 @@ void scr_map_clear(ScrMap *m) { * entries so its indices stay stable — entries added by the callback * after the clear append past them and ARE visited (Node-exact). */ m->nentries = 0; + if (m->ecap > SCR_MAP_CLEAR_KEEP && m->ecap / 8 > walked) { + free(m->entries); + m->entries = NULL; + m->ecap = 0; + } + } + /* Without either index, a map is a linear scan; dead entries kept for an + * active iteration are skipped by every later rebuild. */ + if (m->ndense > SCR_MAP_CLEAR_KEEP && m->ndense / 32 > walked) { + free(m->dense); + m->dense = NULL; + m->ndense = 0; } - if (m->ndense) memset(m->dense, 0, m->ndense * sizeof *m->dense); if (m->nbuckets) { - bool compact = scr_map_compact_buckets(m->nbuckets); - memset(m->buckets, compact ? 0 : 0xff, - m->nbuckets * (compact ? sizeof(uint32_t) : sizeof(size_t))); + if (m->nbuckets > SCR_MAP_CLEAR_KEEP && m->nbuckets / 8 > walked) { + free(m->buckets); + m->buckets = NULL; + m->nbuckets = 0; + } else if (walked) { + bool compact = scr_map_compact_buckets(m->nbuckets); + memset(m->buckets, compact ? 0 : 0xff, + m->nbuckets * (compact ? sizeof(uint32_t) : sizeof(size_t))); + } } } @@ -733,13 +803,25 @@ bool scr_map_has_str(const ScrMap *m, const ScrStr *key) { return scr_map_find_str(m, key) != SCR_MAP_EMPTY; } +/* Tombstones beyond this share of the entries are compacted on delete, so + * iteration and later rebuilds cost O(live) rather than O(peak). V8 shrinks + * an ordered hash table at the same occupancy. Small maps keep theirs. */ +#define SCR_MAP_SHRINK_MIN 64 + static bool scr_map_delete_found(ScrMap *m, size_t e) { if (e == SCR_MAP_EMPTY) return false; + uint64_t key = m->entries[e].key, val = m->entries[e].val; m->entries[e].hash = 0; /* bucket slot stays: probe chains intact */ m->nlive--; - if (m->ndense) m->dense[(size_t)scr_map_slot_to_f64(m->entries[e].key)] = 0; - scr_map_release_key(m, m->entries[e].key); - scr_map_release_val(m, m->entries[e].val); + if (m->ndense) m->dense[(size_t)scr_map_slot_to_f64(key)] = 0; + /* Each such compaction follows deletes of three quarters of the entries, + * so it is amortized constant per delete. It runs before the releases: + * they may drop the last reference to this map. */ + if (m->iter_depth == 0 && m->nentries >= SCR_MAP_SHRINK_MIN && m->nlive < m->nentries / 4) { + scr_map_compact(m, true); + } + scr_map_release_key(m, key); + scr_map_release_val(m, val); return true; } @@ -1245,7 +1327,7 @@ void scr_map_iter_exit(ScrMap *m) { * active they are safe to drop now (bounds memory under forEach-heavy * add/delete workloads). */ if (m->iter_depth == 0 && m->nlive <= m->nentries / 2 && m->nentries >= 16) { - scr_map_compact(m); + scr_map_compact(m, true); } } diff --git a/packages/runtime/src/scr_map.test.c b/packages/runtime/src/scr_map.test.c index d947515bb..ab8085718 100644 --- a/packages/runtime/src/scr_map.test.c +++ b/packages/runtime/src/scr_map.test.c @@ -9,6 +9,7 @@ #include #include #include +#include #define MODEL_CAP 20000 @@ -227,10 +228,237 @@ static void modes(void) { scr_map_release(set); } +/* clear() and delete must cost time proportional to the entries they + * touch, not to the largest size the map ever had. Each case grows the + * index tables to about a million slots, then times many small rounds + * against the same rounds on a fresh map: a clear that resets the whole + * table (megabytes per call), or iteration over the tombstones of the old + * peak, is hundreds of times slower and exceeds the bound early. The bound + * is relative, so machine speed and sanitizers do not matter. */ +#define BIG_KEYS (1 << 20) +#define SMALL_ROUNDS 50000 + +typedef void (*Round)(ScrMap *map, size_t round, void *ctx); + +static double cpu_seconds(void) { + return (double)clock() / CLOCKS_PER_SEC; +} + +/* CPU seconds for SMALL_ROUNDS rounds; stops early once over budget. */ +static double timed_rounds(ScrMap *map, Round round, void *ctx, double budget) { + double start = cpu_seconds(); + for (size_t r = 0; r < SMALL_ROUNDS; r++) { + round(map, r, ctx); + if (r % 1024 == 1023 && cpu_seconds() - start > budget) break; + } + return cpu_seconds() - start; +} + +static void expect_proportional(const char *what, ScrMap *fresh, ScrMap *grown, Round round, void *ctx) { + double base = timed_rounds(fresh, round, ctx, INFINITY); + double budget = 20 * base + 0.05; + double spent = timed_rounds(grown, round, ctx, budget); + if (spent > budget) { + fprintf(stderr, "%s: cost follows the peak size (%.3f s, fresh map %.3f s)\n", what, spent, base); + exit(1); + } +} + +/* keys: 0 directly indexed integers, 1 sparse integers (hashed). */ +static double cost_key(unsigned keys, size_t i) { + return keys == 0 ? (double)i : (double)i * 1000003.0; +} + +/* clear() an empty map, add up to six keys, clear() again. */ +static void number_round(ScrMap *map, size_t round, void *ctx) { + unsigned keys = *(unsigned *)ctx; + scr_map_clear(map); + size_t n = round % 7; + for (size_t i = 0; i < n; i++) scr_map_set_f64_f64(map, cost_key(keys, round + i), (double)i); + assert(scr_map_size(map) == (double)n); + scr_map_clear(map); +} + +static void clear_cost_numbers(unsigned keys) { + ScrMap *map = scr_map_new(SCR_MAP_KEY_F64, SCR_MAP_VAL_F64, NULL, NULL, NULL); + for (size_t i = 0; i < BIG_KEYS; i++) scr_map_set_f64_f64(map, cost_key(keys, i), (double)i); + assert(keys == 0 ? map->ndense >= BIG_KEYS : map->nbuckets >= 2 * BIG_KEYS); + scr_map_clear(map); + assert(scr_map_size(map) == 0 && scr_map_iter_count(map) == 0 && !scr_map_has_f64(map, cost_key(keys, 1))); + ScrMap *fresh = scr_map_new(SCR_MAP_KEY_F64, SCR_MAP_VAL_F64, NULL, NULL, NULL); + expect_proportional(keys == 0 ? "dense clear" : "hashed clear", fresh, map, number_round, &keys); + scr_map_release(fresh); + /* Storage sized for the old peak is gone; the map is reusable. */ + assert(map->ndense <= 256 && map->nbuckets <= 256 && map->ecap <= 256); + for (size_t i = 0; i < 100; i++) scr_map_set_f64_f64(map, cost_key(keys, 99 - i), (double)i); + size_t j = 0; + for (size_t i = 0; i < (size_t)scr_map_iter_count(map); i++) { + if (!scr_map_iter_live(map, (double)i)) continue; + assert(scr_map_iter_key_f64(map, (double)i) == cost_key(keys, 99 - j)); + assert(scr_map_iter_val_f64(map, (double)i) == (double)j); + j++; + } + assert(j == 100 && !scr_map_has_f64(map, cost_key(keys, 100))); + scr_map_release(map); +} + +static ScrStr *key_str(size_t i) { + char buf[32]; + int len = snprintf(buf, sizeof buf, "k%zu", i); + return scr_str_new(buf, (size_t)len); +} + +enum { STRING_KEYS = 1 << 18 }; + +static void string_round(ScrMap *map, size_t round, void *ctx) { + ScrStr **keys = ctx; + scr_map_clear(map); + size_t n = round % 7; + for (size_t i = 0; i < n; i++) scr_map_set_str_f64(map, keys[(round + i) % STRING_KEYS], (double)i); + assert(scr_map_size(map) == (double)n); + scr_map_clear(map); +} + +static void clear_cost_strings(void) { + ScrStr **keys = malloc(STRING_KEYS * sizeof *keys); + assert(keys); + for (size_t i = 0; i < STRING_KEYS; i++) keys[i] = key_str(i); + ScrMap *map = scr_map_new(SCR_MAP_KEY_STR, SCR_MAP_VAL_F64, NULL, NULL, NULL); + for (size_t i = 0; i < STRING_KEYS; i++) scr_map_set_str_f64(map, keys[i], (double)i); + assert(map->nbuckets >= 2 * STRING_KEYS); + scr_map_clear(map); + ScrMap *fresh = scr_map_new(SCR_MAP_KEY_STR, SCR_MAP_VAL_F64, NULL, NULL, NULL); + expect_proportional("string clear", fresh, map, string_round, keys); + scr_map_release(fresh); + assert(map->nbuckets <= 256 && map->ecap <= 256); + for (size_t i = 0; i < 10; i++) scr_map_set_str_f64(map, keys[i], (double)i); + for (size_t i = 0; i < 20; i++) assert(scr_map_has_str(map, keys[i]) == (i < 10)); + scr_map_release(map); + for (size_t i = 0; i < STRING_KEYS; i++) scr_str_release(keys[i]); + free(keys); +} + +/* Walk the entries the way the forEach desugar does. */ +static void iterate_round(ScrMap *map, size_t round, void *ctx) { + (void)round; + size_t count = 0; + for (size_t i = 0; i < (size_t)scr_map_iter_count(map); i++) count += scr_map_iter_live(map, (double)i); + assert(count == *(size_t *)ctx); +} + +/* Deleting almost everything compacts the entries and index tables, so + * iteration costs O(live), and keys left far apart move back to hashing + * instead of keeping a huge direct index. */ +static void delete_shrinks(void) { + ScrMap *map = scr_map_new(SCR_MAP_KEY_F64, SCR_MAP_VAL_F64, NULL, NULL, NULL); + ScrMap *fresh = scr_map_new(SCR_MAP_KEY_F64, SCR_MAP_VAL_F64, NULL, NULL, NULL); + for (size_t i = 0; i < BIG_KEYS; i++) scr_map_set_f64_f64(map, (double)i, (double)i); + for (size_t i = 0; i + 10 < BIG_KEYS; i++) assert(scr_map_delete_f64(map, (double)i)); + for (size_t i = BIG_KEYS - 10; i < BIG_KEYS; i++) scr_map_set_f64_f64(fresh, (double)i, (double)i); + assert(scr_map_size(map) == 10); + size_t live = 10; + expect_proportional("iteration after deletes", fresh, map, iterate_round, &live); + assert(scr_map_iter_count(map) <= 4 * 10 + 64); + assert(!map->ndense); /* ten keys near 2^20: hashed */ + for (size_t i = BIG_KEYS - 10; i < BIG_KEYS; i++) assert(scr_map_has_f64(map, (double)i)); + assert(!scr_map_has_f64(map, 0) && !scr_map_has_f64(map, BIG_KEYS - 11)); + scr_map_release(fresh); + scr_map_release(map); + + /* Small survivors keep a direct index sized for them. */ + map = scr_map_new(SCR_MAP_KEY_F64, SCR_MAP_VAL_F64, NULL, NULL, NULL); + for (size_t i = 0; i < BIG_KEYS; i++) scr_map_set_f64_f64(map, (double)i, (double)i); + for (size_t i = BIG_KEYS; i-- > 20;) assert(scr_map_delete_f64(map, (double)i)); + assert(scr_map_size(map) == 20 && map->ndense && map->ndense <= 64); + for (size_t i = 0; i < 40; i++) assert(scr_map_has_f64(map, (double)i) == (i < 20)); + scr_map_release(map); +} + +/* Grow-and-drain cycles against the ordered model: the map repeatedly + * grows large and is drained by random deletes, deletes in insertion + * order, clear, or deletes during an iteration that also appends, so + * compaction on delete, index shrinking, the move back to hashing and + * clear's storage release all run under the Map contract. */ +static double drain_key(unsigned flavor, size_t cycle, size_t i) { + switch (flavor) { + case 0: return (double)(next() % 4000); /* dense */ + case 1: return (double)(cycle * 3000 + i); /* a moving dense window */ + default: return (double)(next() % 64) * 1000003.0 + (double)(next() % 3); /* hashed */ + } +} + +static void fuzz_drain(unsigned flavor) { + static Model model; + model.n = 0; + ScrMap *map = scr_map_new(SCR_MAP_KEY_F64, SCR_MAP_VAL_F64, NULL, NULL, NULL); + for (size_t cycle = 0; cycle < 16; cycle++) { + size_t grow = 1 + next() % 3000; + for (size_t i = 0; i < grow; i++) { + double k = drain_key(flavor, cycle, i), v = (double)(next() % 1000); + scr_map_set_f64_f64(map, k, v); + model_set(&model, k, v); + } + check_same(map, &model); + size_t keep = next() % 40; + switch (next() % 4) { + case 0: + while (model.n > keep) { + double k = model.key[next() % model.n]; + assert(scr_map_delete_f64(map, k) && model_delete(&model, k)); + } + break; + case 1: + while (model.n > keep) { + double k = model.key[0]; + assert(scr_map_delete_f64(map, k) && model_delete(&model, k)); + } + break; + case 2: + scr_map_clear(map); + model.n = 0; + if (next() % 2) scr_map_clear(map); + break; + default: { + /* Delete the visited entry with probability 3/4 and sometimes append + * a fresh key, which the same iteration must still visit. */ + size_t cursor = 0, added = 0; + scr_map_iter_enter(map); + for (size_t i = 0; i < (size_t)scr_map_iter_count(map); i++) { + if (!scr_map_iter_live(map, (double)i)) continue; + double k = scr_map_iter_key_f64(map, (double)i); + assert(cursor < model.n && same_value_zero(k, model.key[cursor])); + if (added < 200 && next() % 8 == 0) { + double fresh = 1e7 + (double)(cycle * 1000 + added++); + scr_map_set_f64_f64(map, fresh, 1); + model_set(&model, fresh, 1); + } + if (next() % 4) { + assert(scr_map_delete_f64(map, k) && model_delete(&model, k)); + } else { + cursor++; + } + } + assert(cursor == model.n); + scr_map_iter_exit(map); + break; + } + } + check_same(map, &model); + for (size_t i = 0; i < 64; i++) probe(map, &model, drain_key(flavor, cycle, i)); + for (size_t i = 0; i < model.n; i++) probe(map, &model, model.key[i]); + } + scr_map_release(map); +} + int main(void) { modes(); iterate_while_mutating(); for (unsigned flavor = 0; flavor < 5; flavor++) fuzz(flavor); + clear_cost_numbers(0); + clear_cost_numbers(1); + clear_cost_strings(); + delete_shrinks(); + for (unsigned flavor = 0; flavor < 3; flavor++) fuzz_drain(flavor); puts("number-key maps passed"); return 0; } diff --git a/tests/corpus/4598-map-set-clear-reuse.ts b/tests/corpus/4598-map-set-clear-reuse.ts new file mode 100644 index 000000000..0a4095139 --- /dev/null +++ b/tests/corpus/4598-map-set-clear-reuse.ts @@ -0,0 +1,147 @@ +// Map and Set clear(), reuse and iteration after clear and after mass +// deletion, for number, string and object keys. The sizes cross the +// runtime's storage thresholds: maps grow far beyond their later size, so +// clear() and delete release or shrink their index tables, number keys move +// between the direct index and hashing, and every later operation must +// still follow insertion order exactly. + +class Point { + readonly x: number; + readonly y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } +} + +function summarize(label: string, all: K[], show: (k: K) => string): void { + const head = all.slice(0, 4).map(show).join(","); + const tail = all.slice(-3).map(show).join(","); + console.log(`${label}: size=${all.length} head=[${head}] tail=[${tail}]`); +} + +// ── number keys: dense table grown large, then cleared and reused ────────── +const nums = new Map(); +for (let i = 0; i < 70000; i++) nums.set(i, i * 2); +console.log("nums grown", nums.size, nums.get(69999), nums.has(70000)); +nums.clear(); +console.log("nums cleared", nums.size, nums.has(0), nums.get(5), [...nums.keys()].length); +nums.clear(); // empty clear +nums.clear(); +for (const k of [5, 3, 9, 3, 1000000, 0.5, -0, 7]) nums.set(k, k + 1); +summarize("nums reuse", [...nums.keys()], (k) => String(Object.is(k, -0) ? "-0" : k)); +console.log("nums -0 stored as", Object.is([...nums.keys()][5], -0) ? "-0" : "+0"); +console.log("nums values", [...nums.values()].join(",")); +// many small fill/clear rounds after the large one +let roundSum = 0; +for (let round = 0; round < 2000; round++) { + nums.clear(); + for (let j = 0; j < round % 9; j++) nums.set(round * 10 + j, j); + for (const [k, v] of nums) roundSum += k * 3 + v; + roundSum += nums.size; +} +console.log("nums rounds", roundSum, nums.size); + +// ── number keys: mass deletion, then iteration and lookups ──────────────── +const ids = new Set(); +for (let i = 0; i < 50000; i++) ids.add(i); +for (let i = 0; i < 49990; i++) ids.delete(i); // survivors are far from zero +summarize("ids after deletes", [...ids], (k) => String(k)); +console.log("ids lookups", ids.has(49989), ids.has(49990), ids.has(49999), ids.has(0)); +ids.add(3); +ids.add(49989); +summarize("ids re-added", [...ids], (k) => String(k)); +for (let i = 49999; i >= 20; i--) ids.delete(i); // now small keys only +summarize("ids small", [...ids], (k) => String(k)); +for (let i = 0; i < 100; i++) ids.add(i); +summarize("ids regrown", [...ids], (k) => String(k)); + +// ── stack-like add/delete like a recursion guard, cleared on every reset ── +const guard = new Set(); +let maybe = 0; +function visit(depth: number, id: number): void { + if (guard.has(id)) { + maybe++; + return; + } + guard.add(id); + if (depth > 0) { + visit(depth - 1, id * 3 + 1); + visit(depth - 1, (id * 7) % 1000); + } + guard.delete(id); +} +for (let check = 0; check < 300; check++) { + guard.clear(); + visit(check % 50 === 0 ? 12 : 3, check * 100003); +} +console.log("guard", maybe, guard.size, [...guard].length); + +// ── string keys ────────────────────────────────────────────────────────── +const words = new Map(); +for (let i = 0; i < 20000; i++) words.set(`w${i}`, i); +words.clear(); +console.log("words cleared", words.size, words.has("w1"), words.get("w2")); +for (const w of ["delta", "alpha", "charlie", "alpha", "bravo"]) words.set(w, (words.get(w) ?? 0) + 1); +summarize("words reuse", [...words.entries()], ([k, v]) => `${k}=${v}`); +for (let i = 0; i < 5000; i++) words.set(`x${i}`, i); +for (let i = 0; i < 4990; i++) words.delete(`x${i}`); +summarize("words after deletes", [...words.keys()], (k) => k); +const tags = new Set(["a", "b", "c"]); +tags.clear(); +tags.add("z"); +tags.add("a"); +console.log("tags", [...tags].join(","), tags.has("b")); + +// ── object keys ────────────────────────────────────────────────────────── +const points: Point[] = []; +for (let i = 0; i < 3000; i++) points.push(new Point(i, -i)); +const owners = new Map(); +for (const p of points) owners.set(p, `p${p.x}`); +owners.clear(); +console.log("owners cleared", owners.size, owners.has(points[0]!)); +owners.set(points[10]!, "ten"); +owners.set(points[2]!, "two"); +owners.set(points[10]!, "TEN"); +summarize("owners reuse", [...owners.entries()], ([p, s]) => `${p.x}:${s}`); +const seen = new Set(points); +for (let i = 0; i < 2995; i++) seen.delete(points[i]!); +summarize("seen after deletes", [...seen], (p) => `${p.x}/${p.y}`); + +// ── clear and delete during iteration (Node's live iteration) ──────────── +const live = new Map(); +for (let i = 0; i < 400; i++) live.set(i, `v${i}`); +const visited: number[] = []; +for (const [k] of live) { + visited.push(k); + if (k === 2) { + live.clear(); + live.set(1000, "after"); + live.set(1001, "after"); + } + if (k === 1000) live.set(1002, "late"); +} +console.log("live visited", visited.join(","), "size", live.size); + +const churn = new Set(); +for (let i = 0; i < 300; i++) churn.add(i); +let walked = 0; +churn.forEach((k) => { + walked++; + churn.delete(k); + if (k < 100) churn.add(k + 1000); +}); +summarize("churn after forEach", [...churn], (k) => String(k)); +console.log("churn walked", walked); + +const strs = new Map(); +for (let i = 0; i < 500; i++) strs.set(`s${i}`, i); +const order: string[] = []; +strs.forEach((_v, k) => { + order.push(k); + if (k === "s1") { + strs.clear(); + strs.set("fresh", 1); + } +}); +console.log("strs visited", order.join(","), [...strs.keys()].join(",")); From 175d79ff01739e4d041f4d74ff92b71b99d2032d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 22:44:45 -0700 Subject: [PATCH 11/19] test: record the TS7 preflight/order baseline for 4598-map-set-clear-reuse --- packages/compiler/test/ts7/baselines/order-parity.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 7bf630618..01beb5589 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15742,6 +15742,12 @@ "/tests/corpus/4596-tdz-nullable-forward-capture.ts" ], "diags": [] + }, + "/tests/corpus/4598-map-set-clear-reuse.ts": { + "order": [ + "/tests/corpus/4598-map-set-clear-reuse.ts" + ], + "diags": [] } } } From 714afdcfe55b4e2b58612ffad1ea1698efa33c2c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 22:45:29 -0700 Subject: [PATCH 12/19] runtime: size a cloned map's direct index for its own keys new Map(m) and new Set(s) copied the source's direct-index size, which follows the source's peak. The copy now covers its largest live key, and keys that are sparse for their count hash instead. --- packages/runtime/src/scr_map.c | 12 +++++++++--- packages/runtime/src/scr_map.test.c | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/scr_map.c b/packages/runtime/src/scr_map.c index 6c1f4d1c8..7891ec812 100644 --- a/packages/runtime/src/scr_map.c +++ b/packages/runtime/src/scr_map.c @@ -1248,9 +1248,14 @@ ScrMap *scr_map_clone(const ScrMap *source, bool keys_only) { out->entries = malloc(count * sizeof(ScrMapEntry)); if (!out->entries) scr_map_oom(); out->ecap = count; + size_t top = 0; for (size_t i = 0; i < source->nentries; i++) { const ScrMapEntry *entry = &source->entries[i]; if (!entry->hash) continue; + if (source->ndense) { + size_t k = (size_t)scr_map_slot_to_f64(entry->key); + if (k > top) top = k; + } ScrMapEntry *copy = &out->entries[out->nentries++]; *copy = *entry; if (out->key_kind == SCR_MAP_KEY_STR) scr_str_retain(scr_map_slot_to_ptr(copy->key)); @@ -1259,9 +1264,10 @@ ScrMap *scr_map_clone(const ScrMap *source, bool keys_only) { else if (out->val_kind == SCR_MAP_VAL_REF) out->val_retain(scr_map_slot_to_ptr(copy->val)); } out->nlive = count; - if (source->ndense) { - /* Same keys, so the same table size; entry indices are compacted. */ - scr_map_dense_alloc(out, source->ndense); + /* Size the copy's direct index for its own keys, not for the source's + * peak; keys that are sparse for their count hash instead. */ + if (source->ndense && top / 2 < scr_map_dense_limit(count)) { + scr_map_dense_alloc(out, scr_map_dense_size(top)); scr_map_fill_dense(out); return out; } diff --git a/packages/runtime/src/scr_map.test.c b/packages/runtime/src/scr_map.test.c index ab8085718..6c6e22e01 100644 --- a/packages/runtime/src/scr_map.test.c +++ b/packages/runtime/src/scr_map.test.c @@ -372,6 +372,22 @@ static void delete_shrinks(void) { assert(scr_map_size(map) == 20 && map->ndense && map->ndense <= 64); for (size_t i = 0; i < 40; i++) assert(scr_map_has_f64(map, (double)i) == (i < 20)); scr_map_release(map); + + /* A copy indexes its own keys, not the source's peak (here a quarter of + * the entries survive, too many to compact the source yet). */ + map = scr_map_new(SCR_MAP_KEY_F64, SCR_MAP_VAL_F64, NULL, NULL, NULL); + for (size_t i = 0; i < 4096; i++) scr_map_set_f64_f64(map, (double)i, (double)i); + for (size_t i = 1024; i < 4096; i++) assert(scr_map_delete_f64(map, (double)i)); + assert(map->ndense == 4096 && scr_map_iter_count(map) == 4096); + ScrMap *copy = scr_map_clone(map, false); + assert(copy->ndense == 1024 && scr_map_size(copy) == 1024); + double out; + for (size_t i = 0; i < 1100; i++) { + assert(scr_map_get_f64_f64(copy, (double)i, &out) == (i < 1024)); + if (i < 1024) assert(out == (double)i); + } + scr_map_release(copy); + scr_map_release(map); } /* Grow-and-drain cycles against the ordered model: the map repeatedly From 10e10061a6f892538153afa1dd336d6be09bed9d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 23:07:59 -0700 Subject: [PATCH 13/19] runtime: keep a map's storage when clear() finds no entries An empty map's tables are already empty, so clear() returns at once instead of freeing oversized storage that the next fill would regrow. Only a clear that removes entries frees storage far larger than them. The compaction test in delete is marked unlikely. --- packages/runtime/src/scr_map.c | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/scr_map.c b/packages/runtime/src/scr_map.c index 7891ec812..804396c3f 100644 --- a/packages/runtime/src/scr_map.c +++ b/packages/runtime/src/scr_map.c @@ -745,14 +745,16 @@ double scr_map_size(const ScrMap *m) { return (double)m->nlive; } * the index tables: * - the direct index is zero exactly at the absent keys (deletes clear * their slots), so only the live entries' slots are reset; - * - the bucket table is reset only when entries pointed into it, and only - * while it is proportional to them; + * - the bucket table is reset only while it is proportional to the + * entries that pointed into it; * - storage far larger than the entries just removed is freed, returning * the map to its initial empty state the way V8 reallocates a cleared * table. Regrowth is amortized against the insertions that need it. - * Clearing an empty map does no work beyond, once, freeing such storage. */ + * A map without entries has empty tables already: clearing it does no + * work and keeps its storage for the next fill. */ void scr_map_clear(ScrMap *m) { size_t walked = m->nentries; + if (walked == 0) return; uint32_t *dense = m->ndense ? m->dense : NULL; for (size_t e = 0; e < walked; e++) { if (!m->entries[e].hash) continue; @@ -785,7 +787,7 @@ void scr_map_clear(ScrMap *m) { free(m->buckets); m->buckets = NULL; m->nbuckets = 0; - } else if (walked) { + } else { bool compact = scr_map_compact_buckets(m->nbuckets); memset(m->buckets, compact ? 0 : 0xff, m->nbuckets * (compact ? sizeof(uint32_t) : sizeof(size_t))); @@ -817,7 +819,8 @@ static bool scr_map_delete_found(ScrMap *m, size_t e) { /* Each such compaction follows deletes of three quarters of the entries, * so it is amortized constant per delete. It runs before the releases: * they may drop the last reference to this map. */ - if (m->iter_depth == 0 && m->nentries >= SCR_MAP_SHRINK_MIN && m->nlive < m->nentries / 4) { + if (__builtin_expect(m->iter_depth == 0 && m->nentries >= SCR_MAP_SHRINK_MIN && + m->nlive < m->nentries / 4, 0)) { scr_map_compact(m, true); } scr_map_release_key(m, key); From 63c45c99ee2c6ea9160ecf921971da9b381bda01 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 22:18:20 -0700 Subject: [PATCH 14/19] Tell LeakSanitizer that published objects are immortal Publication (@scriptc/threads publish() and the zero-copy delivery of deeply frozen plain-data graphs) makes objects immortal: nothing frees them, and the RC audit already stops counting them. LeakSanitizer did not know, so a program that drops its last reference to a published graph reported every such object as a direct leak whenever leak detection was on (SCRIPTC_SAN=1 runs on Linux without ASAN_OPTIONS=detect_leaks=0, such as the documented local fallback). Both publication paths now mark each object they make immortal with the weak __lsan_ignore_object hook in ASan builds; ignored chunks are leak-scan roots, so the verdict on the rest of the heap is unchanged. Other builds compile the hook away. The sanitized lanes run with detect_leaks=0, so a harness test pins the contract with LeakSanitizer on (Linux, SCRIPTC_SAN=1 lane). --- packages/runtime/src/scr_message.c | 2 + packages/runtime/src/scr_publish.c | 1 + packages/runtime/src/scr_runtime.h | 20 ++++++++ tests/fixtures/publish-leaks/dropped.ts | 41 +++++++++++++++ tests/harness/publish-leaks.test.ts | 68 +++++++++++++++++++++++++ 5 files changed, 132 insertions(+) create mode 100644 tests/fixtures/publish-leaks/dropped.ts create mode 100644 tests/harness/publish-leaks.test.ts diff --git a/packages/runtime/src/scr_message.c b/packages/runtime/src/scr_message.c index 655d97219..5b27bfc4e 100644 --- a/packages/runtime/src/scr_message.c +++ b/packages/runtime/src/scr_message.c @@ -465,12 +465,14 @@ static ScrMessage *scr_message_try_publish(const ScrDyn *value) { #ifdef SCR_RC_AUDIT scr_str_live_forget(); #endif + scr_lsan_immortal(text); } else if (d->rc != SIZE_MAX) { scr_cyc_on_dead(d); /* leave the candidate and tenured buffers */ d->rc = SIZE_MAX; #ifdef SCR_RC_AUDIT scr_dyn_live_forget(); #endif + scr_lsan_immortal(d); } } message = scr_message_alloc(1, sizeof(*message)); diff --git a/packages/runtime/src/scr_publish.c b/packages/runtime/src/scr_publish.c index 819ba3f68..7ef5e50e9 100644 --- a/packages/runtime/src/scr_publish.c +++ b/packages/runtime/src/scr_publish.c @@ -282,6 +282,7 @@ void scr_publish(void *root, ScrPubFn fn) { #ifdef SCR_RC_AUDIT scr_pub_audit_forget(ctx.log[i].kind); #endif + scr_lsan_immortal(ctx.log[i].obj); } if (trace) { uint64_t t2 = scr_hrtime_ns(); diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 363f89245..efca5080e 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -2091,6 +2091,26 @@ void scr_map_live_forget(void); void scr_union_live_forget(void); #endif +/* For the same reason LeakSanitizer must not report a published object + * whose last reference is dropped (scr_message.c, scr_publish.c). Ignored + * chunks are leak-scan roots, so everything an immortal object reaches is + * covered and the verdict on the rest of the heap is unchanged. Weak: ASan + * runtimes without a leak checker leave it NULL; other builds compile the + * hook away. */ +#if defined(__has_feature) +#if __has_feature(address_sanitizer) +#define SCR_LSAN_IMMORTAL 1 +void __lsan_ignore_object(const void *p) __attribute__((weak)); +#endif +#endif +static inline void scr_lsan_immortal(const void *obj) { +#ifdef SCR_LSAN_IMMORTAL + if (__lsan_ignore_object) __lsan_ignore_object(obj); +#else + (void)obj; +#endif +} + /* ── closures ─────────────────────────────────────────────────────── * A closure is a function pointer plus the boxes of its captured bindings. * A box is one shared, refcounted variable cell: every function that diff --git a/tests/fixtures/publish-leaks/dropped.ts b/tests/fixtures/publish-leaks/dropped.ts new file mode 100644 index 000000000..51178b8ed --- /dev/null +++ b/tests/fixtures/publish-leaks/dropped.ts @@ -0,0 +1,41 @@ +import { publish } from "@scriptc/threads"; + +// Published objects are immortal: retains and releases skip them and +// nothing frees them, so dropping the last reference to one is not a leak. +// Every graph below becomes unreachable once its function returns. + +class Item { + value: number; + label: string; + next: Item | null; + self: Item | undefined; + constructor(value: number, label: string, next: Item | null) { + this.value = value; + this.label = label; + this.next = next; + } +} + +// `this` is published before its constructor returns. +class SelfPublishing { + a: number; + constructor(a: number) { + this.a = a; + publish(this); + } +} + +function dropTyped(): number { + let head: Item | null = null; + for (let i = 0; i < 100; i++) head = new Item(i, `item-${i}`, head); + head!.self = head!; + publish(head); + const labels = publish([`first-${head!.value}`, `second-${head!.next!.value}`]); + const index = publish(new Map([[`key-${head!.value}`, head!]])); + const own = new SelfPublishing(labels.length); + return head!.value + labels.length + index.size + own.a; +} + +let total = 0; +for (let round = 0; round < 3; round++) total += dropTyped(); +console.log(total); diff --git a/tests/harness/publish-leaks.test.ts b/tests/harness/publish-leaks.test.ts new file mode 100644 index 000000000..4ebfbfd28 --- /dev/null +++ b/tests/harness/publish-leaks.test.ts @@ -0,0 +1,68 @@ +/* Published objects (@scriptc/threads publish() and the zero-copy delivery + * of deeply frozen plain-data graphs) are immortal by design: nothing frees + * them, and the RC audit stops counting them. LeakSanitizer must agree, so + * the runtime marks every published object as an intentional leak-scan + * root. The sanitized lanes otherwise run with detect_leaks=0, so this pins + * the contract with LeakSanitizer explicitly on: programs that drop their + * last reference to a published graph exit cleanly. Linux only (Apple's + * ASan has no LeakSanitizer); runs in the SCRIPTC_SAN=1 lane. */ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const execFileAsync = promisify(execFile); +const repoRoot = join(import.meta.dirname, "../.."); +const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests"); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; + +const programs = [ + "tests/fixtures/publish-leaks/dropped.ts", + // Zero-copy delivery of deeply frozen plain-data graphs (scr_message.c). + "tests/corpus/native-worker-published-graphs.ts", + // Typed graphs published on both threads, including a refused publish. + "tests/corpus/native-worker-publish-classes.ts", +]; + +describe.skipIf(!sanitize || process.platform !== "linux")( + "published objects under LeakSanitizer", + () => { + test.each(programs)("%s reports no leaks", { timeout: 300_000 }, async (program) => { + const file = join(repoRoot, program); + const key = createHash("sha256") + .update(file) + .update(readFileSync(file)) + .update("san") + .digest("hex") + .slice(0, 16); + const outDir = join(cacheDir, `publish-leaks-${key}`); + mkdirSync(outDir, { recursive: true }); + const result = await compile(file, { + outPath: join(outDir, "program"), + outDir, + sanitize: true, + backend: "llvm", + }); + if (!result.ok) { + throw new Error( + "program failed to compile:\n" + + result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + ); + } + const run = await execFileAsync(result.binaryPath, [], { + env: { ...process.env, ASAN_OPTIONS: "detect_leaks=1" }, + maxBuffer: 16 * 1024 * 1024, + }).catch((error: { code?: number; stdout?: string; stderr?: string }) => error); + expect(run.stderr).toBe(""); + expect("code" in run ? run.code : 0).toBe(0); + const expected = await execFileAsync(process.execPath, [file], { + env: { ...process.env, SCRIPTC_TEST_ENV: "from-harness" }, + maxBuffer: 16 * 1024 * 1024, + }); + expect(run.stdout).toBe(expected.stdout); + }); + }, +); From 211b3f22b35de85798671eea484065b971766bcb Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 01:50:55 -0700 Subject: [PATCH 15/19] runtime: return an exiting worker thread's free pages to the system A script thread that exits parks its allocator arena with every freed block still resident, so the memory of a finished worker stays with the process until another thread claims the arena. On Linux, a parking thread that carved at least 16 MiB now hands back every page of its arena that holds only free blocks (MADV_DONTNEED), relinks the remaining free blocks in address order, and keeps the returned page runs as zeroed regions that the next owner carves before the slice frontier. It also trims glibc malloc. SCR_SA_STATS=1 reports what each park returned; SCR_SA_TRIM=0 turns it off. --- packages/runtime/src/scr_alloc.c | 300 ++++++++++++++++++++++++- packages/runtime/src/scr_alloc.test.c | 88 ++++++++ packages/runtime/src/scr_alloc.test.ts | 3 + packages/runtime/src/scr_runtime.h | 7 +- 4 files changed, 393 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/scr_alloc.c b/packages/runtime/src/scr_alloc.c index 015236785..3eb445635 100644 --- a/packages/runtime/src/scr_alloc.c +++ b/packages/runtime/src/scr_alloc.c @@ -6,9 +6,10 @@ * and the out-of-line entry points the LLVM backend calls for acyclic * records and class instances. * - * Freed blocks are recycled within their class and never returned to the - * operating system; the reservation itself costs address space only, and - * pages are committed as the bump pointers first touch them. */ + * Freed blocks are recycled within their class; the reservation itself + * costs address space only, and pages are committed as the bump pointers + * first touch them. Only an exiting worker thread returns pages, those + * holding nothing but free blocks (scr_sa_trim, Linux). */ #include "scr_runtime.h" /* Defined in every build: the emitted fast paths read it (see the layout @@ -29,6 +30,15 @@ ScrSaState scr_sa = {0}; #ifdef SCR_WORKERS #include #include +#if defined(__linux__) && defined(MADV_DONTNEED) +#define SCR_SA_TRIM 1 +#include +#include +#include +#if defined(__GLIBC__) +#include +#endif +#endif /* Per-thread arenas (contract in scr_runtime.h). The region is reserved * once; arena i spans [region + i * arena_span, + arena_span) and is split @@ -75,6 +85,13 @@ static bool scr_sa_reserve_region(void) { return atomic_load_explicit(&scr_sa_region, memory_order_acquire) != 0; } +#ifdef SCR_SA_TRIM +/* The arena's carved bytes when the current thread claimed it (see + * scr_sa_trim). */ +static size_t scr_sa_claim_carved[SCR_SA_ARENAS]; +static size_t scr_sa_carved(void); +#endif + /* Moves every block other threads freed into this arena's free lists. */ static void scr_sa_drain_remote(void) { ScrSaBlock *b = atomic_exchange_explicit(&scr_sa_remote[scr_sa_arena], NULL, memory_order_acquire); @@ -113,14 +130,287 @@ static bool scr_sa_reserve(void) { scr_sa.shift = scr_sa_region_shift; } scr_sa_drain_remote(); +#ifdef SCR_SA_TRIM + scr_sa_claim_carved[i] = scr_sa_carved(); +#endif return true; } } } +#ifdef SCR_SA_TRIM +/* Returning an exited thread's free memory (Linux). When a script thread + * exits, every page of its arena that holds only free blocks goes back to + * the system (MADV_DONTNEED, after which it reads as zero), so other + * threads can use that memory while the arena is parked. The free blocks on + * the kept pages are relinked in address order; the returned runs of pages + * become zeroed carve regions (`ScrSaHoles`), which the next thread to claim + * the arena carves before the slice's own frontier: the bump fast paths + * already rely on carved memory being zero. A thread that carved less than + * SCR_SA_TRIM_MIN, or whose arena holds less than that in free blocks, + * leaves the arena as it is. */ +#define SCR_SA_TRIM_MIN ((size_t)16 << 20) +typedef struct { + char *lo, *hi; +} ScrSaRun; +typedef struct { + ScrSaRun *runs; /* zeroed regions left to carve, carved from the end */ + size_t n, cap; + char *bump, *lim; /* the frontier while a region is carved (lim != NULL) */ +} ScrSaHoles; +static ScrSaHoles scr_sa_holes[SCR_SA_ARENAS][SCR_SA_NCLASS]; + +/* Bytes carved from the calling thread's slices: up to each frontier. */ +static size_t scr_sa_carved(void) { + size_t carved = 0; + for (unsigned c = 0; c < SCR_SA_NCLASS; c++) { + if (!scr_sa.lim[c]) continue; + const ScrSaHoles *h = &scr_sa_holes[scr_sa_arena][c]; + char *top = h->lim ? h->bump : scr_sa.bump[c]; + carved += (size_t)(top - ((char *)scr_sa.base + ((size_t)c << scr_sa.shift))); + } + return carved; +} + +/* For the slow path of class c, whose bump room is below sz: moves the + * bump pointer to the next zeroed region, or back to the frontier. */ +static void scr_sa_next_hole(unsigned c, size_t sz) { + ScrSaHoles *h = &scr_sa_holes[scr_sa_arena][c]; + if ((size_t)(scr_sa.lim[c] - scr_sa.bump[c]) >= sz || (!h->n && !h->lim)) return; + if (h->n) { + if (!h->lim) { + h->bump = scr_sa.bump[c]; + h->lim = scr_sa.lim[c]; + } + ScrSaRun r = h->runs[--h->n]; + scr_sa.bump[c] = r.lo; + scr_sa.lim[c] = r.hi; + } else { + scr_sa.bump[c] = h->bump; + scr_sa.lim[c] = h->lim; + h->bump = h->lim = NULL; + } +} + +static bool scr_sa_push_run(ScrSaHoles *h, char *lo, char *hi) { + if (h->n == h->cap) { + size_t cap = h->cap ? 2 * h->cap : 64; + ScrSaRun *runs = realloc(h->runs, cap * sizeof *runs); + if (!runs) return false; + h->runs = runs; + h->cap = cap; + } + h->runs[h->n++] = (ScrSaRun){lo, hi}; + return true; +} + +/* With SCR_SA_STATS=1: carved, free and returned bytes of the parking thread. */ +static _Thread_local size_t scr_sa_trim_stats[3]; + +/* Returns the pages of class c that hold only free blocks (see above). */ +static void scr_sa_trim_class(unsigned c, size_t page) { + ScrSaHoles *h = &scr_sa_holes[scr_sa_arena][c]; + size_t sz = (size_t)(c + 1) * SCR_SA_STEP; + char *start = (char *)scr_sa.base + ((size_t)c << scr_sa.shift); + /* Close the region being carved: its rest is still zero. */ + if (h->lim) { + char *lo = scr_sa.bump[c], *hi = scr_sa.lim[c]; + scr_sa.bump[c] = h->bump; + scr_sa.lim[c] = h->lim; + h->bump = h->lim = NULL; + if ((size_t)(hi - lo) >= sz && !scr_sa_push_run(h, lo, hi)) return; + } + char *top = scr_sa.bump[c]; + size_t nblocks = (size_t)(top - start) / sz; + if (!nblocks) return; + size_t words = (nblocks + 63) / 64; + uint64_t *bits = mmap(NULL, words * 8, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0); + if (bits == MAP_FAILED) return; + size_t nfree = 0; + for (ScrSaBlock *b = scr_sa.free[c]; b; b = b->next) { + size_t k = (size_t)((char *)b - start) / sz; + bits[k / 64] |= (uint64_t)1 << (k % 64); + nfree++; + } + /* Uncarved zeroed regions count as free; they are rebuilt below. */ + for (size_t r = 0; r < h->n; r++) { + for (char *q = h->runs[r].lo; q + sz <= h->runs[r].hi; q += sz) { + size_t k = (size_t)(q - start) / sz; + bits[k / 64] |= (uint64_t)1 << (k % 64); + } + } + scr_sa_trim_stats[0] += nblocks * sz; + scr_sa_trim_stats[1] += nfree * sz; +#define SCR_SA_BIT(k) ((bits[(k) / 64] >> ((k) % 64)) & 1) + /* Whole pages below the frontier whose blocks are all free. */ + uintptr_t first = ((uintptr_t)start + page - 1) & ~(uintptr_t)(page - 1); + uintptr_t last = (uintptr_t)top & ~(uintptr_t)(page - 1); + h->n = 0; + char *run = NULL; + for (uintptr_t pg = first; pg <= last; pg += page) { + bool empty = false; + if (pg < last) { + size_t k0 = (size_t)((char *)pg - start) / sz; + size_t k1 = (size_t)((char *)pg + page - 1 - start) / sz; + if (k1 >= nblocks) k1 = nblocks - 1; + empty = true; + for (size_t k = k0; k <= k1 && empty; k++) empty = SCR_SA_BIT(k); + } + if (empty && !run) run = (char *)pg; + if (!empty && run) { + (void)madvise(run, (size_t)((char *)pg - run), MADV_DONTNEED); + scr_sa_trim_stats[2] += (size_t)((char *)pg - run); + /* Blocks wholly inside the returned pages carve from it later. */ + char *lo = start + ((size_t)(run - start) + sz - 1) / sz * sz; + char *hi = start + ((size_t)((char *)pg - start) / sz) * sz; + if (hi > lo) (void)scr_sa_push_run(h, lo, hi); + for (char *q = lo; q < hi; q += sz) { + size_t k = (size_t)(q - start) / sz; + bits[k / 64] &= ~((uint64_t)1 << (k % 64)); + } + /* A block starting in the returned pages but ending on a kept one is + * dropped: relinking it would fault its page back in. */ + if (hi < (char *)pg && hi >= run) { + size_t k = (size_t)(hi - start) / sz; + bits[k / 64] &= ~((uint64_t)1 << (k % 64)); + } + run = NULL; + } + } +#undef SCR_SA_BIT + /* The remaining free blocks, in address order. */ + ScrSaBlock *head = NULL, **tail = &head; + for (size_t w = 0; w < words; w++) { + for (uint64_t m = bits[w]; m; m &= m - 1) { + size_t k = w * 64 + (size_t)__builtin_ctzll(m); + ScrSaBlock *b = (ScrSaBlock *)(start + k * sz); + *tail = b; + tail = &b->next; + } + } + *tail = NULL; + scr_sa.free[c] = head; + munmap(bits, words * 8); + /* The next owner carves the returned regions before the frontier. */ + if (h->n) { + h->bump = scr_sa.bump[c]; + h->lim = scr_sa.lim[c]; + ScrSaRun r = h->runs[--h->n]; + scr_sa.bump[c] = r.lo; + scr_sa.lim[c] = r.hi; + } +} + +/* Whether the calling thread carved at least SCR_SA_TRIM_MIN since it + * claimed its arena: a short-lived thread reusing the arena's free blocks + * leaves them for the next one. */ +static bool scr_sa_trim_worth(void) { + return scr_sa_carved() - scr_sa_claim_carved[scr_sa_arena] >= SCR_SA_TRIM_MIN; +} + +/* Returns the free pages of the calling thread's arena (see above). */ +static void scr_sa_trim(void) { + scr_sa_drain_remote(); + size_t free_bytes = 0; + for (unsigned c = 0; c < SCR_SA_NCLASS; c++) { + size_t n = 0; + for (ScrSaBlock *b = scr_sa.free[c]; b && n * (c + 1) * SCR_SA_STEP < SCR_SA_TRIM_MIN; + b = b->next) + n++; + free_bytes += n * (c + 1) * SCR_SA_STEP; + if (free_bytes >= SCR_SA_TRIM_MIN) break; + } + const char *stats = getenv("SCR_SA_STATS"); + const char *off = getenv("SCR_SA_TRIM"); + bool trim = free_bytes >= SCR_SA_TRIM_MIN && !(off && off[0] == '0'); + memset(scr_sa_trim_stats, 0, sizeof scr_sa_trim_stats); + if (!trim && stats && stats[0] == '1') + for (unsigned c = 0; c < SCR_SA_NCLASS; c++) { + if (!scr_sa.lim[c]) continue; + size_t sz = (size_t)(c + 1) * SCR_SA_STEP; + char *start = (char *)scr_sa.base + ((size_t)c << scr_sa.shift); + scr_sa_trim_stats[0] += (size_t)(scr_sa.bump[c] - start); + for (ScrSaBlock *b = scr_sa.free[c]; b; b = b->next) scr_sa_trim_stats[1] += sz; + } + if (trim) { + long page = sysconf(_SC_PAGESIZE); + for (unsigned c = 0; c < SCR_SA_NCLASS; c++) + if (scr_sa.lim[c]) scr_sa_trim_class(c, page > 0 ? (size_t)page : 4096); + } +#if defined(__GLIBC__) + /* Large blocks: the system allocator's free pages too. */ + if (trim) (void)malloc_trim(0); +#endif + if (stats && stats[0] == '1') + fprintf(stderr, "scr_sa: park arena %d trim %d carved %zu MB free %zu MB returned %zu MB\n", + scr_sa_arena, (int)trim, scr_sa_trim_stats[0] >> 20, scr_sa_trim_stats[1] >> 20, + scr_sa_trim_stats[2] >> 20); +} +#endif + +#ifdef SCR_SA_TRIM +/* Trims run on a detached helper thread: the parent of a worker joins it + * when it handles the worker's exit, which must not wait for the trim (at + * the end of a program every worker exits at once, and the process ends + * right after). The helper takes over the exiting thread's claim on the + * arena, so no thread claims it during the trim. */ +static _Atomic(unsigned) scr_sa_trimming; /* helpers running */ + +static void scr_sa_release(unsigned i) { + scr_sa_parked[i] = scr_sa; + memset(&scr_sa, 0, sizeof scr_sa); + scr_sa_arena = -1; + scr_sa_refused = true; + atomic_fetch_and_explicit(&scr_sa_claimed, ~(1u << i), memory_order_release); +} + +static void *scr_sa_trim_main(void *arg) { + unsigned i = (unsigned)(uintptr_t)arg; + scr_sa_arena = (int)i; + scr_sa = scr_sa_parked[i]; + scr_sa_trim(); + scr_sa_release(i); + atomic_fetch_sub_explicit(&scr_sa_trimming, 1, memory_order_release); + return NULL; +} + +void scr_sa_wait_trims(void) { + while (atomic_load_explicit(&scr_sa_trimming, memory_order_acquire)) { + struct timespec pause = {0, 1000000}; + nanosleep(&pause, NULL); + } +} +#else +void scr_sa_wait_trims(void) {} +#endif + void scr_sa_thread_park(void) { if (scr_sa_arena < 0) return; unsigned i = (unsigned)scr_sa_arena; +#ifdef SCR_SA_TRIM + if (scr_sa_trim_worth()) { + scr_sa_parked[i] = scr_sa; + atomic_fetch_add_explicit(&scr_sa_trimming, 1, memory_order_relaxed); + pthread_attr_t attr; + pthread_t helper; + bool started = pthread_attr_init(&attr) == 0; + if (started) { + started = pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED) == 0 && + pthread_create(&helper, &attr, scr_sa_trim_main, (void *)(uintptr_t)i) == 0; + pthread_attr_destroy(&attr); + } + if (started) { + /* Later frees on this thread take the foreign path into the arena's + * remote list; the helper releases the claim. */ + memset(&scr_sa, 0, sizeof scr_sa); + scr_sa_arena = -1; + scr_sa_refused = true; + return; + } + atomic_fetch_sub_explicit(&scr_sa_trimming, 1, memory_order_relaxed); + scr_sa_trim(); + } +#endif scr_sa_parked[i] = scr_sa; /* Later frees on this thread (thread-exit destructors) take the foreign * path into the parked arena's remote list. */ @@ -199,6 +489,9 @@ __attribute__((cold, noinline)) void *scr_sa_slow(size_t n, bool zero) { return b; } size_t sz = (size_t)(c + 1) * SCR_SA_STEP; +#ifdef SCR_SA_TRIM + if (scr_sa.lim[c]) scr_sa_next_hole(c, sz); +#endif char *p = scr_sa.bump[c]; if (scr_sa.lim[c] && (size_t)(scr_sa.lim[c] - p) >= sz) { scr_sa.bump[c] = p + sz; @@ -238,6 +531,7 @@ void *scr_mem_realloc(void *p, size_t n) { void *scr_mem_realloc(void *p, size_t n) { return realloc(p, n); } #ifdef SCR_WORKERS void scr_sa_thread_park(void) {} +void scr_sa_wait_trims(void) {} void scr_sa_free_foreign(void *p) { free(p); } #endif #endif diff --git a/packages/runtime/src/scr_alloc.test.c b/packages/runtime/src/scr_alloc.test.c index ea4960543..c43936c7f 100644 --- a/packages/runtime/src/scr_alloc.test.c +++ b/packages/runtime/src/scr_alloc.test.c @@ -159,6 +159,91 @@ static void check_threads(void) { } #endif +#if defined(SCR_WORKERS) && SCR_SMALL_ALLOC && defined(__linux__) +#include +#include + +/* An exiting thread returns the pages that hold only free blocks; the next + * thread to claim the arena carves the returned range again, zeroed, before + * growing the slice. */ +enum { TRIM_BLOCKS = 1 << 20, TRIM_KEEP = 4096, TRIM_SIZE = 48 }; +static unsigned char **trim_blocks; +static unsigned char *trim_kept[TRIM_BLOCKS / TRIM_KEEP]; +static uintptr_t trim_lo, trim_hi; +static size_t trim_outside; + +static size_t resident_pages(uintptr_t lo, uintptr_t hi) { + size_t page = (size_t)sysconf(_SC_PAGESIZE); + lo &= ~(uintptr_t)(page - 1); + size_t n = (hi - lo + page - 1) / page, count = 0; + unsigned char *vec = malloc(n); + assert(vec && mincore((void *)lo, n * page, vec) == 0); + for (size_t i = 0; i < n; i++) count += vec[i] & 1; + free(vec); + return count; +} + +static void *trim_allocate(void *arg) { + (void)arg; + trim_lo = UINTPTR_MAX; + trim_hi = 0; + for (size_t i = 0; i < TRIM_BLOCKS; i++) { + trim_blocks[i] = scr_mem_alloc(TRIM_SIZE); + assert(trim_blocks[i] && owned(trim_blocks[i])); + fill(trim_blocks[i], TRIM_SIZE, (unsigned char)i); + if ((uintptr_t)trim_blocks[i] < trim_lo) trim_lo = (uintptr_t)trim_blocks[i]; + if ((uintptr_t)trim_blocks[i] + TRIM_SIZE > trim_hi) trim_hi = (uintptr_t)trim_blocks[i] + TRIM_SIZE; + } + for (size_t i = 0; i < TRIM_BLOCKS; i++) { + if (i % TRIM_KEEP == 0) trim_kept[i / TRIM_KEEP] = trim_blocks[i]; + else scr_mem_free(trim_blocks[i]); + } + scr_sa_thread_park(); + return NULL; +} + +static void *trim_reuse(void *arg) { + (void)arg; + trim_outside = 0; + for (size_t i = 0; i < TRIM_BLOCKS; i++) { + unsigned char *p = scr_mem_calloc(TRIM_SIZE); + assert(p && owned(p)); + for (size_t k = 0; k < TRIM_SIZE; k++) assert(p[k] == 0); + fill(p, TRIM_SIZE, (unsigned char)i); + if ((uintptr_t)p < trim_lo || (uintptr_t)p >= trim_hi) trim_outside++; + trim_blocks[i] = p; + } + for (size_t i = 0; i < TRIM_BLOCKS; i++) scr_mem_free(trim_blocks[i]); + scr_sa_thread_park(); + return NULL; +} + +static void check_trim(void) { + trim_blocks = malloc(TRIM_BLOCKS * sizeof *trim_blocks); + assert(trim_blocks); + pthread_t t; + assert(pthread_create(&t, NULL, trim_allocate, NULL) == 0); + assert(pthread_join(t, NULL) == 0); + scr_sa_wait_trims(); /* a helper thread returns the pages */ + /* One page per kept block stays, give or take the pages at run ends. */ + size_t total = (trim_hi - trim_lo) / (size_t)sysconf(_SC_PAGESIZE); + size_t resident = resident_pages(trim_lo, trim_hi); + assert(resident < total / 8); + for (size_t k = 0; k < TRIM_BLOCKS / TRIM_KEEP; k++) + verify(trim_kept[k], TRIM_SIZE, (unsigned char)(k * TRIM_KEEP)); + assert(pthread_create(&t, NULL, trim_reuse, NULL) == 0); + assert(pthread_join(t, NULL) == 0); + scr_sa_wait_trims(); + /* Nearly every block came from the returned range: the slice did not grow. */ + assert(trim_outside < 4 * TRIM_BLOCKS / TRIM_KEEP + 256); + for (size_t k = 0; k < TRIM_BLOCKS / TRIM_KEEP; k++) { + verify(trim_kept[k], TRIM_SIZE, (unsigned char)(k * TRIM_KEEP)); + scr_mem_free(trim_kept[k]); /* remote free into the parked arena */ + } + free(trim_blocks); +} +#endif + int main(void) { check_alignment_and_zeroing(); check_realloc(); @@ -166,6 +251,9 @@ int main(void) { check_churn(); #if defined(SCR_WORKERS) && SCR_SMALL_ALLOC check_threads(); +#ifdef __linux__ + check_trim(); +#endif printf("allocator checks passed: size-class workers\n"); #else printf("allocator checks passed: %s\n", SCR_SMALL_ALLOC ? "size-class" : "system"); diff --git a/packages/runtime/src/scr_alloc.test.ts b/packages/runtime/src/scr_alloc.test.ts index c3cf5467a..8d2d7ec2f 100644 --- a/packages/runtime/src/scr_alloc.test.ts +++ b/packages/runtime/src/scr_alloc.test.ts @@ -26,6 +26,9 @@ test.each([ "-O1", "-Wall", "-Wextra", + // Linux runtime packs compile with _GNU_SOURCE, which declares the + // mincore the trim check uses; build the checks the same way. + ...(process.platform === "linux" ? ["-D_GNU_SOURCE"] : []), ...sanitize, join(import.meta.dirname, "scr_alloc.test.c"), join(import.meta.dirname, "scr_alloc.c"), diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 363f89245..d09d2dddf 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -473,8 +473,9 @@ void scr_cyc_free(void *obj); /* frees the block, header included */ * of SCR_SA_ARENAS equal slices of a single process-wide reservation, so * the owning arena of any block is address arithmetic. A thread claims an * arena on its first allocation and parks it (free lists and bump pointers - * intact, memory never unmapped) when it exits; the next thread to claim - * it continues from there. Script objects stay on their thread, so a free + * intact) when it exits; the next thread to claim it continues from there. + * On Linux an exiting thread that carved much first returns the pages of + * its arena that hold only free blocks (scr_alloc.c). Script objects stay on their thread, so a free * normally hits the caller's own arena inline. A block freed by another * thread goes to the owning arena's lock-free remote list, which the owner * drains on its slow path; published (immortal) objects are never freed, @@ -574,6 +575,8 @@ _Static_assert(offsetof(ScrSaState, span) == 8 && offsetof(ScrSaState, shift) == void scr_sa_free_foreign(void *p); /* The calling script thread is done with its arena (worker exit). */ void scr_sa_thread_park(void); +/* Waits until the arenas of exited threads are trimmed (tests). */ +void scr_sa_wait_trims(void); #define SCR_SA_FREE_FOREIGN(p) scr_sa_free_foreign(p) #else #define SCR_SA_FREE_FOREIGN(p) free(p) From 4f89dfd19dc9c669fadfa5f84597d48071fc5e76 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 02:26:33 -0700 Subject: [PATCH 16/19] tests: workers that drop tens of megabytes and later workers reusing their arenas Three workers each hold and drop about 80 MB of small objects and exit; three more then allocate as much again in the arenas the first ones returned (on Linux the first ones hand those pages back when they exit). Records the fixture's preflight/order baseline. --- .../test/ts7/baselines/order-parity.json | 6 +++ tests/corpus/native-worker-arena-reuse.ts | 47 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 tests/corpus/native-worker-arena-reuse.ts diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 7bf630618..e3e52bf2f 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -14530,6 +14530,12 @@ ], "diags": [] }, + "/tests/corpus/native-worker-arena-reuse.ts": { + "order": [ + "/tests/corpus/native-worker-arena-reuse.ts" + ], + "diags": [] + }, "/tests/corpus/native-worker-buffer-consumers.ts": { "order": [ "/tests/corpus/native-worker-buffer-consumers.ts" diff --git a/tests/corpus/native-worker-arena-reuse.ts b/tests/corpus/native-worker-arena-reuse.ts new file mode 100644 index 000000000..7a4f22e8f --- /dev/null +++ b/tests/corpus/native-worker-arena-reuse.ts @@ -0,0 +1,47 @@ +import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; + +// Workers that hold and then drop tens of megabytes of small objects, and later workers that +// allocate as much again once the first ones have exited (in scriptc on Linux, the first ones +// return those pages when they exit and the later ones reuse them). +type Item = { value: number; next: Item | undefined; tags: number[]; label: string }; + +if (isMainThread) { + for (let wave = 0; wave < 2; wave++) { + const results = await Promise.all( + [1, 2, 3].map( + (k) => + new Promise((resolve, reject) => { + let result = -1; + const worker = new Worker(new URL(import.meta.url), { workerData: wave * 10 + k }); + worker.once("message", (value: unknown) => { + result = value as number; + }); + worker.once("error", reject); + worker.once("exit", () => resolve(result)); + }), + ), + ); + console.log("wave", wave, results.join(" ")); + } +} else { + const round = workerData as number; + let items: Item[] = []; + for (let i = 0; i < 300_000; i++) + items.push({ value: i * round, next: undefined, tags: [i & 7, round, i % 13], label: "item" + (i % 97) }); + // A few items survive the rest, linked together. + const kept: Item[] = []; + for (let i = 0; i < items.length; i += 4096) kept.push(items[i]!); + for (let i = 1; i < kept.length; i++) kept[i - 1]!.next = kept[i]; + let sum = 0; + for (const item of items) sum = (sum + item.value + item.tags[2]! + item.label.length) % 1_000_003; + items = []; + let walk = 0; + for (let item: Item | undefined = kept[0]; item !== undefined; item = item.next) + walk = (walk + item.tags[0]! + item.tags[1]! + item.label.length) % 1_000_003; + // Fresh objects after the drop. + const fresh: Item[] = []; + for (let i = 0; i < 100_000; i++) fresh.push({ value: i, next: undefined, tags: [], label: "" }); + let empty = 0; + for (const item of fresh) if (item.next === undefined && item.tags.length === 0 && item.label === "") empty++; + parentPort!.postMessage((sum + walk + empty) % 1_000_003); +} From bfbff5397cd71567c317dd8875ace58f45588fbb Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 08:09:10 -0700 Subject: [PATCH 17/19] Install Polly in the custom sandbox image The custom Dockerfile.sandbox image builds the LLVM helper against the same LLVM 22 CMake exports as the managed bootstrap, which reference the Polly libraries; without libpolly-22-dev CMake configure fails. --- Dockerfile.sandbox | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile.sandbox b/Dockerfile.sandbox index f0db1c9e5..597b33445 100644 --- a/Dockerfile.sandbox +++ b/Dockerfile.sandbox @@ -24,6 +24,7 @@ RUN apt-get update \ curl \ git \ libclang-rt-22-dev \ + libpolly-22-dev \ libzstd-dev \ llvm-22-dev \ ninja-build \ From fb291122f5e2661aece8fa91864286552e5ec641 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 08:13:08 -0700 Subject: [PATCH 18/19] Expect only cycle members to be traced in the self-hosting analysis cases Since #837 a type is traced only if it lies in a cycle-capable component or reaches an intrinsic reference; a type that merely holds a cycle cannot be reached back from it. In both backend-analysis cases `record:outer` only holds `record:cycle`, so the Node oracle and the native compiler agree on ["record:cycle"] and the expectations still listed `record:outer`. --- tests/harness/self-hosting-backend-cases.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/harness/self-hosting-backend-cases.ts b/tests/harness/self-hosting-backend-cases.ts index 649c8b3fc..6f89aafd3 100644 --- a/tests/harness/self-hosting-backend-cases.ts +++ b/tests/harness/self-hosting-backend-cases.ts @@ -244,10 +244,12 @@ export function backendAnalysisCases(): BackendAnalysisCase[] { { id: "recursive", arms: [record("cycle"), { kind: "nullT" }] }, { id: "acyclic", arms: [record("chain"), F64] }, ]; + // `outer` holds the cycle but nothing reaches back to it, so only the + // cycle's own component is traced (cycle-analysis.ts). cases.push({ name: "shape and union fixed point", module: graph, - shapes: ["record:outer", "record:cycle"], + shapes: ["record:cycle"], unions: ["recursive"], }); const hierarchy = module(); @@ -343,6 +345,7 @@ export function backendAnalysisCases(): BackendAnalysisCase[] { }); // Constructor-only references are proven acyclic (cycle-analysis.ts), so // the cyclic branch closes through an array edge, which is always mutable. + // `outer` only holds that cycle, so it stays untraced like the leaf. const branch = module(); branch.records = [ { id: "leaf", fields: [{ name: "value", type: F64 }] }, @@ -358,7 +361,7 @@ export function backendAnalysisCases(): BackendAnalysisCase[] { cases.push({ name: "removing a leaf preserves another cyclic branch", module: branch, - shapes: ["record:cycle", "record:outer"], + shapes: ["record:cycle"], }); const tableId = "%g.table"; From 2077bc9ee6a7a351a0d5eea33d64f6086054f7b5 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 08:13:58 -0700 Subject: [PATCH 19/19] Spell non-negative size conversions as sitofp for clang 18 The `nneg` flag on `uitofp` is LLVM 19 syntax. Sanitized builds hand the textual module to the host clang, and Ubuntu 24.04's clang 18 rejects it ("expected type"), which failed every Linux sanitized Effect shard. These operands are size_t values below 2^63, so `sitofp` converts them identically, is accepted by every supported LLVM, and still lowers to one cvtsi2sd on x86-64 (LLVM 19+ canonicalizes it to `uitofp nneg` where it can prove the sign). --- .../src/backend/llvm/dense-array-access.test.ts | 2 +- .../compiler/src/backend/llvm/dense-array-access.ts | 2 +- packages/compiler/src/backend/llvm/dyn.ts | 10 +++++----- packages/compiler/src/backend/llvm/emitter.ts | 10 +++++----- packages/compiler/src/backend/llvm/expr-bytes.ts | 6 +++--- packages/compiler/src/backend/llvm/expr-containers.ts | 2 +- .../compiler/src/backend/llvm/local-array-reads.ts | 4 ++-- packages/compiler/src/backend/llvm/string-reads.ts | 4 ++-- packages/compiler/src/backend/llvm/string-slices.ts | 2 +- 9 files changed, 21 insertions(+), 21 deletions(-) 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 92dac2afe..4ff7b1b16 100644 --- a/packages/compiler/src/backend/llvm/dense-array-access.test.ts +++ b/packages/compiler/src/backend/llvm/dense-array-access.test.ts @@ -213,7 +213,7 @@ test("length assignments skip the runtime when the length is unchanged", () => { module("reset", VOID, [{ kind: "arraySetLength", arr: ref("s", words), length: index, loc }]), ); const fn = body(llvm, "reset"); - expect(fn).toMatch(/uitofp nneg i64 .* to double/); + expect(fn).toMatch(/sitofp i64 .* to double/); expect(fn).toMatch(/fcmp oeq double %p?\S*, %t\d+/); expect(fn.match(/call void @scr_arr_set_len\(/g)).toHaveLength(1); expect(fn).toMatch(/br i1 %t\d+, label %arr\.len\.done\d*, label %arr\.len\.set\d*/); diff --git a/packages/compiler/src/backend/llvm/dense-array-access.ts b/packages/compiler/src/backend/llvm/dense-array-access.ts index 517658e94..34428db8b 100644 --- a/packages/compiler/src/backend/llvm/dense-array-access.ts +++ b/packages/compiler/src/backend/llvm/dense-array-access.ts @@ -411,7 +411,7 @@ export function emitDenseArrayPush( fastValue = B.tmp(); B.line(`${next} = add nuw ${size} ${len}, 1`); B.line(`store ${size} ${next}, ptr ${lenPtr}${host.fieldAliasAttachment(lenPtr)}`); - B.line(`${fastValue} = uitofp nneg ${size} ${next} to double`); + B.line(`${fastValue} = sitofp ${size} ${next} to double`); B.br(join); B.startBlock(slow); const slowValue = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 5912020f9..40e959f0b 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -1668,7 +1668,7 @@ export class LlDyn { host.declare(`declare void @scr_arr_set_len(ptr, double)`); const index = B.tmp(); const length = B.tmp(); - B.line(`${index} = uitofp nneg ${this.S} ${i} to double`); + B.line(`${index} = sitofp ${this.S} ${i} to double`); B.line(`${length} = fadd double ${index}, 1.0`); B.line(`call void @scr_arr_set_len(ptr ${a}, double ${length})`); B.br(doneLabel); @@ -3409,7 +3409,7 @@ export class LlDyn { const n = this.lenOf(B, "%d"); const nd = B.tmp(); const r = B.tmp(); - B.line(`${nd} = uitofp nneg ${host.sizeType} ${n} to double`); + B.line(`${nd} = sitofp ${host.sizeType} ${n} to double`); B.line(`${r} = call ptr @scr_dyn_new_num(double ${nd})`); B.terminate(`ret ptr ${r}`); B.startBlock(lStr); @@ -3448,7 +3448,7 @@ export class LlDyn { const n2 = B.tmp(); B.line(`${n2} = call double @scr_str_utf16_len(ptr ${s})`); const idxD = B.tmp(); - B.line(`${idxD} = uitofp nneg ${host.sizeType} ${idx} to double`); + B.line(`${idxD} = sitofp ${host.sizeType} ${idx} to double`); const inR2 = B.tmp(); B.line(`${inR2} = fcmp olt double ${idxD}, ${n2}`); const lHit2 = B.newLabel("kg.ash"); @@ -3599,7 +3599,7 @@ export class LlDyn { host.declare(`declare ptr @scr_dyn_jsval_iter_n(ptr, double)`); const count = B.tmp(); const out = B.tmp(); - B.line(`${count} = uitofp nneg ${host.sizeType} %n to double`); + B.line(`${count} = sitofp ${host.sizeType} %n to double`); B.line(`${out} = call ptr @scr_dyn_jsval_iter_n(ptr %d, double ${count})`); B.terminate(`ret ptr ${out}`); B.startBlock(lNotJv); @@ -3737,7 +3737,7 @@ export class LlDyn { const index = B.tmp(); const bd = B.tmp(); const r = B.tmp(); - B.line(`${index} = uitofp nneg ${host.sizeType} ${i} to double`); + B.line(`${index} = sitofp ${host.sizeType} ${i} to double`); B.line(`${bd} = call double @scr_bytes_get(ptr ${bts}, double ${index})`); B.line(`${r} = call ptr @scr_dyn_new_num(double ${bd})`); B.line(`store ptr ${r}, ptr ${itemSlot}`); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 8b9bb381d..2b6164cf8 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1925,7 +1925,7 @@ export class LlEmitter { // The branch must dominate fptoui: NaN/out-of-range conversion // would produce poison. Fractional indices also need a fallback. ` %index = fptoui double %i to ${this.sizeType}`, - ` %roundtrip = uitofp nneg ${this.sizeType} %index to double`, + ` %roundtrip = sitofp ${this.sizeType} %index to double`, ` %integer = fcmp oeq double %roundtrip, %i`, ` br i1 %integer, label %read, label %fallback`, `read:`, @@ -2759,14 +2759,14 @@ export class LlEmitter { `entry:`, ` %ndp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 19`, ` %nd = load ${sz}, ptr %ndp`, - ` %ndf = uitofp nneg ${sz} %nd to double`, + ` %ndf = sitofp ${sz} %nd to double`, ` %lo = fcmp oge double %k, 0.0`, ` %hi = fcmp olt double %k, %ndf`, ` %in = and i1 %lo, %hi`, ` br i1 %in, label %direct, label %outside`, `direct:`, ` %i = fptoui double %k to ${sz}`, - ` %back = uitofp nneg ${sz} %i to double`, + ` %back = sitofp ${sz} %i to double`, ` %int = fcmp oeq double %back, %k`, ` br i1 %int, label %slot, label %miss`, `slot:`, @@ -5626,7 +5626,7 @@ export class LlEmitter { B.line(`${lenPtr} = getelementptr inbounds %ScrArr, ptr ${arr.name}, i32 0, i32 1`); this.markMemoryPointer(lenPtr, "array:header"); B.line(`${len} = load ${this.sizeType}, ptr ${lenPtr}${this.fieldAliasAttachment(lenPtr)}`); - B.line(`${lenNumber} = uitofp nneg ${this.sizeType} ${len} to double`); + B.line(`${lenNumber} = sitofp ${this.sizeType} ${len} to double`); B.line(`${same} = fcmp oeq double ${length.name}, ${lenNumber}`); B.condBr(same, done, resize); B.startBlock(resize); @@ -6255,7 +6255,7 @@ export class LlEmitter { B.line( `${rawLen} = load ${this.sizeType}, ptr ${lenPtr}${this.fieldAliasAttachment(lenPtr)}`, ); - B.line(`${len} = uitofp nneg ${this.sizeType} ${rawLen} to double`); + B.line(`${len} = sitofp ${this.sizeType} ${rawLen} to double`); B.line(`${inBounds} = fcmp olt double ${i}, ${len}`); cur = i; } diff --git a/packages/compiler/src/backend/llvm/expr-bytes.ts b/packages/compiler/src/backend/llvm/expr-bytes.ts index 30cea3f4f..7212f22ce 100644 --- a/packages/compiler/src/backend/llvm/expr-bytes.ts +++ b/packages/compiler/src/backend/llvm/expr-bytes.ts @@ -122,7 +122,7 @@ export function emitBytesIndex( const nonnegative = B.tmp(); const belowLen = B.tmp(); const inRange = B.tmp(); - B.line(`${lenF64} = uitofp nneg ${host.sizeType} ${len} to double`); + B.line(`${lenF64} = sitofp ${host.sizeType} ${len} to double`); B.line(`${nonnegative} = fcmp oge double ${index}, ${f64Lit(0)}`); B.line(`${belowLen} = fcmp olt double ${index}, ${lenF64}`); B.line(`${inRange} = and i1 ${nonnegative}, ${belowLen}`); @@ -137,7 +137,7 @@ export function emitBytesIndex( const roundTrip = B.tmp(); const integral = B.tmp(); B.line(`${idx} = fptoui double ${index} to ${host.sizeType}`); - B.line(`${roundTrip} = uitofp nneg ${host.sizeType} ${idx} to double`); + B.line(`${roundTrip} = sitofp ${host.sizeType} ${idx} to double`); B.line(`${integral} = fcmp oeq double ${roundTrip}, ${index}`); B.condBr(integral, valid, invalid); @@ -177,7 +177,7 @@ export function emitBytesLength( const count = bytes && size !== 1 ? B.tmp() : len; if (count !== len) B.line(`${count} = shl ${host.sizeType} ${len}, ${Math.log2(size)}`); const out = B.tmp(); - B.line(`${out} = uitofp nneg ${host.sizeType} ${count} to double`); + B.line(`${out} = sitofp ${host.sizeType} ${count} to double`); return { name: out, type: F64 }; } diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts index 3cdce7c63..a3ee2a63d 100644 --- a/packages/compiler/src/backend/llvm/expr-containers.ts +++ b/packages/compiler/src/backend/llvm/expr-containers.ts @@ -520,7 +520,7 @@ export function emitArrIntrinsic( B.line(`${p} = getelementptr inbounds %ScrArr, ptr ${r.name}, i32 0, i32 1`); host.markMemoryPointer(p, "array:header"); B.line(`${len} = load ${host.sizeType}, ptr ${p}${host.fieldAliasAttachment(p)}`); - B.line(`${t} = uitofp nneg ${host.sizeType} ${len} to double`); + B.line(`${t} = sitofp ${host.sizeType} ${len} to double`); return { name: t, type: e.type }; } case "getNumber": { diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts index ac546e291..755bea02a 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.ts @@ -348,7 +348,7 @@ function emitDenseReferenceArrayRead( B.line(`${cap} = load ${host.sizeType}, ptr ${capPtr}${host.fieldAliasAttachment(capPtr)}`); if (integerIndex) B.line(`${inRange} = icmp ult ${host.sizeType} ${integerIndex}, ${cap}`); else { - B.line(`${capNumber} = uitofp nneg ${host.sizeType} ${cap} to double`); + B.line(`${capNumber} = sitofp ${host.sizeType} ${cap} to double`); B.line(`${nonnegative} = fcmp oge double ${index.name}, 0.0`); B.line(`${belowCap} = fcmp olt double ${index.name}, ${capNumber}`); B.line(`${inRange} = and i1 ${nonnegative}, ${belowCap}`); @@ -361,7 +361,7 @@ function emitDenseReferenceArrayRead( if (integerIndex) B.br(dense); else { B.line(`${offset} = fptoui double ${index.name} to ${host.sizeType}`); - B.line(`${roundTrip} = uitofp nneg ${host.sizeType} ${offset} to double`); + B.line(`${roundTrip} = sitofp ${host.sizeType} ${offset} to double`); B.line(`${integral} = fcmp oeq double ${index.name}, ${roundTrip}`); B.condBr(integral, dense, slow); } diff --git a/packages/compiler/src/backend/llvm/string-reads.ts b/packages/compiler/src/backend/llvm/string-reads.ts index 24fa80dd9..323387d79 100644 --- a/packages/compiler/src/backend/llvm/string-reads.ts +++ b/packages/compiler/src/backend/llvm/string-reads.ts @@ -45,7 +45,7 @@ export const STRING_READ_HELPERS: readonly StringReadHelper[] = [ ` br i1 %ascii, label %fast, label %slow`, `fast:`, ...loadLength(sz), - ` %n = uitofp nneg ${sz} %len to double`, + ` %n = sitofp ${sz} %len to double`, ` ret double %n`, `slow:`, ` %r = call double @scr_str_utf16_len(ptr %s)`, @@ -62,7 +62,7 @@ export const STRING_READ_HELPERS: readonly StringReadHelper[] = [ ` br i1 %ascii, label %bounds, label %slow`, `bounds:`, ...loadLength(sz), - ` %lenf = uitofp nneg ${sz} %len to double`, + ` %lenf = sitofp ${sz} %len to double`, // NaN fails both ordered comparisons; -0 passes and truncates to 0. ` %lo = fcmp oge double %i, 0.0`, ` %hi = fcmp olt double %i, %lenf`, diff --git a/packages/compiler/src/backend/llvm/string-slices.ts b/packages/compiler/src/backend/llvm/string-slices.ts index 59c2962ab..07e645376 100644 --- a/packages/compiler/src/backend/llvm/string-slices.ts +++ b/packages/compiler/src/backend/llvm/string-slices.ts @@ -97,7 +97,7 @@ export function emitStringSliceRead(host: LlvmEmitterContext, value: Slice): LlV `${pointer} = getelementptr inbounds { ${host.sizeType}, ${host.sizeType}, i32 }, ptr ${snapshot.range}, i32 0, i32 1`, ); B.line(`${length} = load ${host.sizeType}, ptr ${pointer}`); - B.line(`${number} = uitofp nneg ${host.sizeType} ${length} to double`); + B.line(`${number} = sitofp ${host.sizeType} ${length} to double`); return { name: number, type: value.type }; } if (value.method !== "charCodeAt") return null;