From 7d08a7441c96750a4ac350edc2f6d2222b15a020 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 10:23:22 -0700 Subject: [PATCH 01/29] Truncate inline on x86-64 and test typed-read presence without trunc Baseline x86-64 has no rounding instruction, so every llvm.trunc became a call to libm's trunc. The typed-array read helper paid that call on every read: it decided presence with `i >= 0 && i < length && Math.trunc(i) === i` before the inline element load. - Math.trunc (and the DataView offset truncation) now take an integer round trip on x86-64 targets: values of magnitude below 2^52 go through fptosi/sitofp with the sign restored by copysign (so -0.5 and -0 give -0); larger values, NaN and the infinities are already integral and keep their value. Other targets keep llvm.trunc, which is one instruction there. - The ToUint32 slow path is only reached for magnitudes above 2^53 (and non-finite values), which are integers, so its truncation was a no-op and is gone. - For integer element kinds, the read helper performs the element read that yields NaN for an invalid index (one inline index check) and answers undefined exactly when that read is NaN: an integer element is never NaN. Float element kinds keep the range and integrality test. --- .../compiler/src/backend/llvm/byte-numbers.ts | 5 +-- packages/compiler/src/backend/llvm/emitter.ts | 5 +++ .../compiler/src/backend/llvm/expr-bytes.ts | 7 ++- .../src/backend/llvm/lib-filesystem.ts | 9 +++- packages/compiler/src/backend/llvm/trunc.ts | 35 +++++++++++++++ .../src/frontend/lowering/array-values.ts | 37 +++++++++++++++ packages/compiler/src/ir/ir.ts | 5 +++ packages/compiler/test/trunc-emission.test.ts | 45 +++++++++++++++++++ 8 files changed, 139 insertions(+), 9 deletions(-) create mode 100644 packages/compiler/src/backend/llvm/trunc.ts create mode 100644 packages/compiler/test/trunc-emission.test.ts diff --git a/packages/compiler/src/backend/llvm/byte-numbers.ts b/packages/compiler/src/backend/llvm/byte-numbers.ts index 07f0142bd..d12ef08b9 100644 --- a/packages/compiler/src/backend/llvm/byte-numbers.ts +++ b/packages/compiler/src/backend/llvm/byte-numbers.ts @@ -3,6 +3,7 @@ import type { IrExpr } from "../../ir/ir.js"; import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; import { f64Lit } from "./common.js"; import { exactInteger, widenInteger } from "./integer-values.js"; +import { emitTruncF64 } from "./trunc.js"; /** Supported LLVM targets are little-endian. All memory accesses use align * 1: Buffer fields and DataView windows can start at arbitrary byte offsets. */ @@ -101,11 +102,9 @@ export function emitByteNumber( if (spec.dataView) { const nan = B.tmp(), finiteOrZero = B.tmp(); - normalized = B.tmp(); B.line(`${nan} = fcmp uno double ${offset.name}, ${offset.name}`); B.line(`${finiteOrZero} = select i1 ${nan}, double ${f64Lit(0)}, double ${offset.name}`); - host.declare("declare double @llvm.trunc.f64(double)"); - B.line(`${normalized} = call double @llvm.trunc.f64(double ${finiteOrZero})`); + normalized = emitTruncF64(host, finiteOrZero); } const cap = B.tmp(), positive = B.tmp(), diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index e7ae6a934..8ce49a03d 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -516,6 +516,10 @@ export class LlEmitter { readonly ffiExtendNarrowIntegers: boolean; readonly cycleColorOffset: number; readonly wasi: boolean; + /** The target lacks a rounding instruction at its baseline CPU (x86-64 + * before SSE4.1), so `llvm.trunc` would lower to a libm call: emit + * truncation inline through an integer round trip instead. */ + readonly inlineTrunc: boolean; /** ELF worker executables give thread-locals the executable TLS models. */ private readonly executableTls: boolean; private readonly emitLibraryIdentity: boolean; @@ -807,6 +811,7 @@ export class LlEmitter { this.ffiExtendNarrowIntegers = options.wasi === true || ffiExtendsNarrowIntegers(options.targetTriple); this.wasi = options.wasi === true; + this.inlineTrunc = /^(x86_64|amd64)\b/i.test(options.targetTriple ?? ""); this.executableTls = mod.workers === true && mod.lib === undefined && diff --git a/packages/compiler/src/backend/llvm/expr-bytes.ts b/packages/compiler/src/backend/llvm/expr-bytes.ts index 2d64ae788..21fd1ed32 100644 --- a/packages/compiler/src/backend/llvm/expr-bytes.ts +++ b/packages/compiler/src/backend/llvm/expr-bytes.ts @@ -371,15 +371,14 @@ export function emitToUint32( B.condBr(finite, finiteLabel, nonfiniteLabel); B.startBlock(finiteLabel); - host.declare(`declare double @llvm.trunc.f64(double)`); - const truncated = B.tmp(); + // Only magnitudes above 2^53 reach this block, and every such double is + // already an integer: no truncation is needed before the reduction. const residue = B.tmp(); const negative = B.tmp(); const wrapped = B.tmp(); const normalized = B.tmp(); const finiteU32 = B.tmp(); - B.line(`${truncated} = call double @llvm.trunc.f64(double ${value})`); - B.line(`${residue} = frem double ${truncated}, ${f64Lit(4294967296)}`); + B.line(`${residue} = frem double ${value}, ${f64Lit(4294967296)}`); B.line(`${negative} = fcmp olt double ${residue}, ${f64Lit(0)}`); B.line(`${wrapped} = fadd double ${residue}, ${f64Lit(4294967296)}`); B.line(`${normalized} = select i1 ${negative}, double ${wrapped}, double ${residue}`); diff --git a/packages/compiler/src/backend/llvm/lib-filesystem.ts b/packages/compiler/src/backend/llvm/lib-filesystem.ts index 5a70a55a8..674e3e543 100644 --- a/packages/compiler/src/backend/llvm/lib-filesystem.ts +++ b/packages/compiler/src/backend/llvm/lib-filesystem.ts @@ -7,6 +7,7 @@ import { arrNewCall, traceArg, vAdapters } from "./shapes.js"; import type { LlvmEmitterContext, LibCallExpr, LlValue } from "./expr-context.js"; import { f64Lit } from "./common.js"; import { emitAlwaysThrowLibCall } from "./lib-shared.js"; +import { emitTruncF64 } from "./trunc.js"; const FS_ALWAYS_THROW_SYMS: Readonly> = { "fs.mkdtempChk": "scr_fs_mkdtemp_chk", @@ -663,8 +664,12 @@ export function emitPathUrlLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll export function emitPrimitiveLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { const B = host.B; - if (e.fn === "math.floor" || e.fn === "math.trunc" || e.fn === "math.ceil") { - const intr = e.fn === "math.floor" ? "floor" : e.fn === "math.trunc" ? "trunc" : "ceil"; + if (e.fn === "math.trunc") { + const v = host.emitExpr(e.args[0]!); + return { name: emitTruncF64(host, v.name), type: e.type }; + } + if (e.fn === "math.floor" || e.fn === "math.ceil") { + const intr = e.fn === "math.floor" ? "floor" : "ceil"; const v = host.emitExpr(e.args[0]!); host.declare(`declare double @llvm.${intr}.f64(double)`); const t = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/trunc.ts b/packages/compiler/src/backend/llvm/trunc.ts new file mode 100644 index 000000000..6d7b886aa --- /dev/null +++ b/packages/compiler/src/backend/llvm/trunc.ts @@ -0,0 +1,35 @@ +/* ToIntegerOrInfinity-style truncation of a double toward zero. */ +import type { LlvmEmitterContext } from "./expr-context.js"; +import { f64Lit } from "./common.js"; + +/** `Math.trunc(value)`. Targets with a rounding instruction use + * `llvm.trunc`; on baseline x86-64 that intrinsic is a libm call, so the + * value instead takes an integer round trip. Every double of magnitude at + * least 2^52 is already an integer, and NaN and the infinities fail the + * magnitude test, so those keep their value; the round trip of a smaller + * value restores its sign so that `-0.5` and `-0` truncate to `-0`. */ +export function emitTruncF64(host: LlvmEmitterContext, value: string): string { + const B = host.B; + if (!host.inlineTrunc) { + host.declare("declare double @llvm.trunc.f64(double)"); + const out = B.tmp(); + B.line(`${out} = call double @llvm.trunc.f64(double ${value})`); + return out; + } + host.declare("declare double @llvm.fabs.f64(double)"); + host.declare("declare double @llvm.copysign.f64(double, double)"); + const magnitude = B.tmp(), + small = B.tmp(), + integer = B.tmp(), + back = B.tmp(), + signed = B.tmp(), + out = B.tmp(); + B.line(`${magnitude} = call double @llvm.fabs.f64(double ${value})`); + B.line(`${small} = fcmp olt double ${magnitude}, ${f64Lit(2 ** 52)}`); + // Poison for a large or non-finite value, which the select never picks. + B.line(`${integer} = fptosi double ${value} to i64`); + B.line(`${back} = sitofp i64 ${integer} to double`); + B.line(`${signed} = call double @llvm.copysign.f64(double ${back}, double ${value})`); + B.line(`${out} = select i1 ${small}, double ${signed}, double ${value}`); + return out; +} diff --git a/packages/compiler/src/frontend/lowering/array-values.ts b/packages/compiler/src/frontend/lowering/array-values.ts index 80438dbf0..a682e0401 100644 --- a/packages/compiler/src/frontend/lowering/array-values.ts +++ b/packages/compiler/src/frontend/lowering/array-values.ts @@ -7,6 +7,7 @@ import { type IrStmt, type IrType, JSVAL, + isIntegerBytesElem, type SrcLoc, UNDEFINED_T, typeEquals, @@ -282,6 +283,42 @@ export function lowerSafeBytesRead( type: F64, loc: at, }; + if (isIntegerBytesElem(bytesT.elem)) { + // An integer element is never NaN, so the NaN-for-invalid read + // decides presence with one inline index check (no separate range + // and integrality tests). + const v = varRef("v.0", F64, at); + lowerer.liftedFns.push({ + name, + params: [ + { localId: "b.0", name: "b", type: bytesT }, + { localId: "i.0", name: "i", type: F64 }, + ], + returnType: resultT, + locals: [ + { id: "b.0", name: "b", type: bytesT, mutable: false }, + { id: "i.0", name: "i", type: F64, mutable: false }, + { id: "v.0", name: "v", type: F64, mutable: false }, + ], + body: [ + { kind: "varDecl", localId: "v.0", init: { ...read, invalidNaN: true }, loc: at }, + { + kind: "return", + value: { + kind: "ternary", + cond: { kind: "libCall", fn: "num.isNaN", args: [v], type: BOOL, loc: at }, + then: lowerer.wrappedUndefined(resultT, at)!, + else_: lowerer.coerceToExpected(v, resultT), + type: resultT, + loc: at, + }, + loc: at, + }, + ], + loc: at, + }); + return { kind: "call", callee: name, args: [receiver, index], type: resultT, loc }; + } lowerer.liftedFns.push({ name, params: [ diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 28ef1f431..f7ac00459 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -42,6 +42,11 @@ export const BYTES_ELEMENT_SIZE: Record = { f64: 8, }; +/** Integer element kinds: a valid element read is never NaN. */ +export function isIntegerBytesElem(elem: IrBytesElem): boolean { + return elem !== "f32" && elem !== "f64"; +} + export const BYTES_ELEMENT_NAME: Record = { u8: "Uint8Array", u8c: "Uint8ClampedArray", diff --git a/packages/compiler/test/trunc-emission.test.ts b/packages/compiler/test/trunc-emission.test.ts new file mode 100644 index 000000000..5f46097e5 --- /dev/null +++ b/packages/compiler/test/trunc-emission.test.ts @@ -0,0 +1,45 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule, validateModule } from "../src/index.js"; +import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; +import type { IrModule } from "../src/ir/ir.js"; + +async function lower(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-trunc-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const mod = deserializeModule(await readFile(outPath, "utf8")); + expect(validateModule(mod)).toEqual([]); + return mod; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test("Math.trunc is inline on x86-64 and the intrinsic elsewhere", async () => { + const mod = await lower( + "const x: number = process.argv.length + 0.5;\nconsole.log(Math.trunc(x), Math.trunc(-x));\n", + ); + const x86 = emitLlvmModule(mod, { targetTriple: "x86_64-unknown-linux-gnu", pointerBits: 64 }); + expect(x86).not.toContain("@llvm.trunc.f64(double"); + expect(x86).toContain("@llvm.copysign.f64"); + const arm = emitLlvmModule(mod, { targetTriple: "arm64-apple-darwin", pointerBits: 64 }); + expect(arm).toContain("call double @llvm.trunc.f64(double"); +}); + +test("integer typed-array reads decide presence without a truncation", async () => { + const mod = await lower( + "const t = new Uint16Array(4);\nconst i: number = process.argv.length + 0.5;\nconsole.log(t[i], t[1]);\n", + ); + const helper = mod.functions.find((f) => f.name.startsWith("%bytes.idxOr")); + expect(helper).toBeDefined(); + expect(JSON.stringify(helper!.body)).not.toContain("math.trunc"); + expect(JSON.stringify(helper!.body)).toContain("num.isNaN"); +}); From d4aeeea0f538b67f71cdd819f53b46ed76560faf Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 10:32:05 -0700 Subject: [PATCH 02/29] Carry typed-array reads as plain numbers where undefined acts like NaN A typed-array element read answers undefined for an index outside [0, length), so the default lowering returns a heap-boxed `number | undefined` and widens every binding, parameter and return the read reaches. In the tsc-ts scanner that boxed every character read and turned every character predicate (isLineBreak, isIdentifierPart, ...) into a function taking a boxed union. Most consumers cannot tell undefined from NaN: relational, arithmetic and bitwise operators, truthiness, Math functions, String.fromCharCode, typed-array indexing, switch over number cases, and equality with a number that is never undefined. The new analysis (nan-coded-reads.ts) follows reads through local bindings, parameters of directly called functions and methods (no overrides, no value uses), and their returns. A slot is NaN-coded when every value it receives is a NaN-coded read or slot, or any other number (converted with ToNumber on entry), and either: - it never holds a genuine NaN (integer elements, integer constants, guarded integer arithmetic), so NaN stands for undefined exactly; any consumer that takes the value whole (an argument, a stored or returned value, a literal element, concatenation) gets the exact `number | undefined` rebuilt, and the optional-read analysis treats that occurrence as optional, so it lowers as before; or - every consumer is NaN-insensitive. Equality of two NaN-coded values that never hold a genuine NaN also answers true for two NaNs, as undefined === undefined does. Any other slot keeps the established union lowering; asserted reads (`t[i]!`) are unchanged. tsc-ts best-v1 (scriptc main + this commit vs main, Linux sandbox, Xeon 2.5 GHz, interleaved, medians of 5, diagnostics identical): | app | parse | total (single-threaded) | wall --checkers 8 | | --- | --- | --- | --- | | tRPC | 1.263 -> 0.866 s (-31.4%) | 3.035 -> 2.527 s (-16.7%) | -13.0% | | Excalidraw | 2.637 -> 1.865 s (-29.3%) | 14.99 -> 14.04 s (-6.3%) | -16.2% | | Playwright | 3.150 -> 2.202 s (-30.1%) | 12.93 -> 12.14 s (-6.1%) | -12.4% | | TypeORM | 2.562 -> 2.036 s (-20.5%) | 10.93 -> 10.46 s (-4.3%) | -8.6% | | self-check | 2.080 -> 1.529 s (-26.5%) | 7.06 -> 6.68 s (-5.4%) | -15.1% | The tsc-ts IR has 646 instead of 697 scr_union_new_f64 sites (the rest are cold), 22 instead of 168 typed-read helper calls, and 217 instead of 572 scr_union_get_f64 calls; the scanner's hot functions allocate no union boxes. Binary size -0.2%. --- .../src/frontend/lowering/lower-exprs.ts | 102 +- .../compiler/src/frontend/lowering/lowerer.ts | 63 +- .../src/frontend/lowering/nan-coded-reads.ts | 1093 +++++++++++++++++ .../compiler/test/nan-coded-reads.test.ts | 86 ++ .../test/ts7/baselines/order-parity.json | 6 + tests/corpus/typed-array-nan-coded-reads.ts | 165 +++ 6 files changed, 1511 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/nan-coded-reads.ts create mode 100644 packages/compiler/test/nan-coded-reads.test.ts create mode 100644 tests/corpus/typed-array-nan-coded-reads.ts diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index b625b41d7..677b29e67 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -362,7 +362,14 @@ export function lowerExpr(lowerer: Lowerer, expr: ts.Expression): IrExpr { } lowerExprDepth++; try { - const lowered = lowerExprInner(lowerer, expr); + let lowered = lowerExprInner(lowerer, expr); + const nanCoded = lowerer.nanCodedReads; + if (nanCoded) { + if (lowered.type.kind === "f64" && nanCoded.reboxes(expr)) + lowered = reboxNanCoded(lowerer, lowered, locOf(expr)); + else if (lowered.type.kind === "union" && nanCoded.convertsSource(expr)) + lowered = lowerOptionalNumber(lowerer, lowered, locOf(expr)); + } if (lowered.type.kind === "void" && (lowerer.typeOf(expr).flags & ts.TypeFlags.Never) !== 0) lowerer.neverValued.add(lowered); return lowered; @@ -371,6 +378,28 @@ export function lowerExpr(lowerer: Lowerer, expr: ts.Expression): IrExpr { } } +/** `number | undefined` from a NaN-coded double whose slot never holds a + * genuine NaN (nan-coded-reads.ts): NaN is exactly undefined. */ +function reboxNanCoded(lowerer: Lowerer, value: IrExpr, loc: SrcLoc): IrExpr { + const optional = lowerer.withUndefinedArm(F64); + const stmts: IrStmt[] = []; + let stable = value; + if (value.kind !== "varRef") { + const tmp = lowerer.declareHiddenLocal("%nanCoded", F64); + stmts.push({ kind: "varDecl", localId: tmp.id, init: value, loc }); + stable = varRef(tmp.id, F64, loc); + } + const result: IrExpr = { + kind: "ternary", + cond: { kind: "libCall", fn: "num.isNaN", args: [stable], type: BOOL, loc }, + then: lowerer.wrappedUndefined(optional, loc)!, + else_: lowerer.coerceToExpected(stable, optional), + type: optional, + loc, + }; + return stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: optional, loc }; +} + function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { const loc = locOf(expr); @@ -6768,7 +6797,12 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre // `t[i]!` asserts presence: the numeric element read (NaN when // invalid). Every other read answers undefined for an invalid index, // like Node. - if (ts.isNonNullExpression(expr.parent) && expr.parent.expression === expr) { + // A read whose every consumer treats undefined like NaN (see + // nan-coded-reads.ts) is the same plain numeric read. + if ( + (ts.isNonNullExpression(expr.parent) && expr.parent.expression === expr) || + lowerer.nanCodedReads?.isNumericRead(expr) === true + ) { return { kind: "bytesIntrinsic", method: "get", @@ -9670,6 +9704,65 @@ export function lowerCompoundValueToTarget( lowerer.unsupported("SC1043", expr); } +/** Equality of two NaN-coded values (nan-coded-reads.ts), each of which may + * stand for undefined and never holds a genuine NaN: Node's undefined equals + * undefined, so two NaNs compare equal here. Operands evaluate once, in + * order. */ +function lowerNanCodedEquality(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr | null { + const loc = locOf(expr); + const left = lowerer.lowerExpr(expr.left); + const right = lowerer.lowerExpr(expr.right); + if (left.type.kind !== "f64" || right.type.kind !== "f64") { + throw new InternalCompilerError("NaN-coded equality operand is not a plain number"); + } + const stmts: IrStmt[] = []; + const stable = (value: IrExpr, name: string): IrExpr => { + if (value.kind === "varRef" || value.kind === "numLit") return value; + const tmp = lowerer.declareHiddenLocal(name, F64); + stmts.push({ kind: "varDecl", localId: tmp.id, init: value, loc }); + return varRef(tmp.id, F64, loc); + }; + const a = stable(left, "%eqLeft"); + const b = stable(right, "%eqRight"); + const isNaN = (value: IrExpr): IrExpr => ({ + kind: "libCall", + fn: "num.isNaN", + args: [value], + type: BOOL, + loc, + }); + const bothUndefined: IrExpr = { + kind: "logical", + op: "&&", + left: isNaN(a), + right: isNaN(b), + type: BOOL, + loc, + }; + const op = expr.operatorToken.kind; + const negated = + op === ts.SyntaxKind.ExclamationEqualsEqualsToken || + op === ts.SyntaxKind.ExclamationEqualsToken; + const result: IrExpr = negated + ? { + kind: "logical", + op: "&&", + left: { kind: "bin", op: "!==", left: a, right: b, type: BOOL, loc }, + right: { kind: "unary", op: "!", operand: bothUndefined, type: BOOL, loc }, + type: BOOL, + loc, + } + : { + kind: "logical", + op: "||", + left: { kind: "bin", op: "===", left: a, right: b, type: BOOL, loc }, + right: bothUndefined, + type: BOOL, + loc, + }; + return stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: BOOL, loc }; +} + export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr { const loc = locOf(expr); const op = expr.operatorToken.kind; @@ -9677,6 +9770,11 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr const cacheHas = lowerRequireCacheHas(lowerer, expr); if (cacheHas) return cacheHas; + if (lowerer.nanCodedReads?.needsUndefinedEquality(expr)) { + const undefinedEquality = lowerNanCodedEquality(lowerer, expr); + if (undefinedEquality) return undefinedEquality; + } + if ( op === ts.SyntaxKind.EqualsToken || (op >= ts.SyntaxKind.FirstCompoundAssignment && op <= ts.SyntaxKind.LastCompoundAssignment) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 20ba4b9a7..9cea42ed9 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -75,6 +75,7 @@ import { ArrayElementStates } from "./runtime-optional-elements.js"; import { indexReadInBounds } from "./runtime-optional-bounds.js"; import { ArrayOwnership } from "./runtime-optional-ownership.js"; import { StringIndexBounds } from "./string-index-bounds.js"; +import { NanCodedReads } from "./nan-coded-reads.js"; import { RuntimeOptionalLocals } from "./runtime-optional-locals.js"; import { sanitizeUnregisteredClassTypes } from "./sanitize-class-types.js"; import { UnregisteredClassTypes } from "./unregistered-class-types.js"; @@ -1641,6 +1642,10 @@ export class Lowerer { /** Per-symbol result of the never-reassigned file scan * (bindingNeverReassigned — object-literal generic-method receivers). */ readonly neverReassignedCache = new Map(); + /** Typed-array element reads whose every consumer treats undefined like + * NaN, which lower to plain doubles (see nan-coded-reads.ts). Settled by + * analyzeRuntimeOptionalArrayReads before any body lowers. */ + nanCodedReads: NanCodedReads | null = null; private stringIndexBoundsCache: StringIndexBounds | null = null; /** Proofs that a string element read names an existing code unit. The * indexed-read analysis and expression lowering share one instance, so a @@ -3894,6 +3899,38 @@ export class Lowerer { } } } + const mappedOrNull = (node: ts.Node): IrType | null => + panicSafe(() => this.mapTypeOf(this.typeOf(node)), null); + const nanCoded = new NanCodedReads( + { + symbolOf, + declarationOf: (symbol) => this.checker.valueDeclarationOf(symbol), + bytesElemOf: (receiver) => { + const t = mappedOrNull(receiver); + return t?.kind === "bytes" ? t.elem : null; + }, + isNumber: (node) => mappedOrNull(node)?.kind === "f64", + isString: (node) => mappedOrNull(node)?.kind === "string", + hasLength: (node) => { + const kind = mappedOrNull(node)?.kind; + return kind === "array" || kind === "bytes"; + }, + isStdlibGlobal: (node, name) => this.isStdlibGlobal(node, name), + paramIsNumber: (fn, index) => { + const param = signatureBySymbol.get(fn)?.params[index]; + return ( + param !== undefined && + param.type.kind === "f64" && + (param.mode === "required" || param.mode === "omittable") + ); + }, + returnIsNumber: (fn) => signatureBySymbol.get(fn)?.returnType.kind === "f64", + methodOverridden: (fn) => (familyBySymbol.get(fn)?.length ?? 1) > 1, + }, + sourceFiles, + ); + nanCoded.analyze(); + this.nanCodedReads = nanCoded; const peel = (node: ts.Expression): ts.Expression => { let e = node; while ( @@ -4005,8 +4042,10 @@ export class Lowerer { if (!ts.isElementAccessExpression(e)) return false; const kind = this.mapTypeOf(this.typeOf(e.expression))?.kind; if (kind === "string") return !this.stringIndexBounds.inBounds(e); - // Typed-array reads answer undefined for an invalid index too. - return kind === "array" || kind === "bytes"; + // Typed-array reads answer undefined for an invalid index too, unless + // every consumer treats undefined like NaN. + if (kind === "bytes") return !nanCoded.isNumericRead(e); + return kind === "array"; }; const isDynamicObjectEntryRead = (node: ts.Expression): boolean => { const read = peel(node); @@ -4094,6 +4133,11 @@ export class Lowerer { ) >= 0 ) return true; + // A NaN-coded value that reaches a consumer able to observe undefined + // is rebuilt as the ordinary optional value there; every other read + // of a NaN-coded slot is a plain number. + if (nanCoded.reboxes(e)) return true; + if (nanCoded.readsNanCodedSlot(e)) return false; if (ts.isIdentifier(e)) { const symbol = symbolOf(e); return ( @@ -5372,6 +5416,21 @@ export class Lowerer { } } } + // NaN-coded slots convert optional values on entry instead of holding + // the union, so promotion never applies to them. + for (const symbol of [...optionalSymbols]) + if (nanCoded.nanCodedBinding(symbol)) optionalSymbols.delete(symbol); + for (const symbol of [...optionalReturns]) + if (nanCoded.nanCodedReturn(symbol)) optionalReturns.delete(symbol); + for (const [symbol, params] of optionalParams) { + const decl = functionDeclBySymbol.get(symbol); + for (const index of [...params]) { + const name = decl?.parameters[index]?.name; + const param = name && ts.isIdentifier(name) ? symbolOf(name) : null; + if (param && nanCoded.nanCodedBinding(param)) params.delete(index); + } + if (params.size === 0) optionalParams.delete(symbol); + } for (const [site, present] of presentSites) if (present) this.presentElementCalls.add(site); for (const [loop, present] of presentLoops) if (present) this.presentElementLoops.add(loop); const globalsById = new Map(this.globalsList.map((global) => [global.id, global])); diff --git a/packages/compiler/src/frontend/lowering/nan-coded-reads.ts b/packages/compiler/src/frontend/lowering/nan-coded-reads.ts new file mode 100644 index 000000000..72bce33c5 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/nan-coded-reads.ts @@ -0,0 +1,1093 @@ +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"; + +/* NaN-coded typed-array reads. + * + * In Node a typed-array element read `t[i]` answers undefined when `i` is not + * an integer in [0, length). The checker types the read as `number`, so the + * default lowering returns a heap-boxed `number | undefined` union and widens + * every binding, parameter and return it reaches. + * + * Many consumers cannot tell undefined from NaN: relational and arithmetic + * operators and bitwise operators convert undefined to NaN (or 0, exactly as + * NaN converts), truthiness treats both as false, `Math` functions convert + * them alike, and strict or loose equality with a number that is never + * undefined is false for both. Where every consumer of a read is of this + * kind, the read lowers to a plain double that holds NaN for an invalid + * index: no box, no widening, and no observable difference. + * + * The analysis follows values through local `let`/`const` bindings, + * parameters of directly called functions and methods, and their returns. + * Such a slot is NaN-coded when every value it receives is a NaN-coded value + * or a number that is never undefined, and every use of it is one of the + * consumers above or flows into another NaN-coded slot. Equality between two + * NaN-coded values must answer true when both stand for undefined, so it is + * lowered as `a === b || (a !== a && b !== b)`; that is exact only while + * neither side can also hold a genuine NaN, which the analysis tracks (an + * integer element is never NaN; arithmetic and float elements may be). + * + * Everything else keeps the established `number | undefined` lowering. */ + +export interface NanCodedHost { + symbolOf(node: ts.Node): ts.Symbol | null; + declarationOf(symbol: ts.Symbol): ts.Node | undefined; + /** The element kind of a typed-array receiver, or null. */ + bytesElemOf(receiver: ts.Expression): IrBytesElem | null; + /** The static IR type of the node is a plain number. */ + isNumber(node: ts.Node): boolean; + isString(node: ts.Expression): boolean; + /** The receiver is an array or a typed array (for `.length`). */ + hasLength(node: ts.Expression): boolean; + isStdlibGlobal(node: ts.Expression, name: string): boolean; + /** Parameter `index` of the callable is a plain `number` in its signature. */ + paramIsNumber(fn: ts.Symbol, index: number): boolean; + /** The callable's signature returns a plain `number`. */ + returnIsNumber(fn: ts.Symbol): boolean; + /** The method shares its dispatch slot with another class's method. */ + methodOverridden(fn: ts.Symbol): boolean; +} + +interface ValueInfo { + /** May stand for undefined (NaN-coded). */ + nce: boolean; + /** May also hold a genuine NaN. */ + nan: boolean; +} + +type Slot = BindingSlot | ReturnSlot; + +interface SlotBase { + /** Values written into the slot (initializers, assignments, arguments, + * return expressions); null marks a write of unknown value. */ + sources: (ts.Expression | null)[]; + /** Value occurrences whose consumers must be NaN-insensitive. */ + uses: ts.Expression[]; + /** Compound writes and increments store arithmetic results. */ + arithmeticWrites: boolean; +} + +interface BindingSlot extends SlotBase { + kind: "binding"; + symbol: ts.Symbol; +} + +interface ReturnSlot extends SlotBase { + kind: "return"; + symbol: ts.Symbol; +} + +function peelTransparent(node: ts.Expression): ts.Expression { + let e = node; + while ( + ts.isParenthesizedExpression(e) || + ts.isAsExpression(e) || + ts.isTypeAssertion(e) || + ts.isSatisfiesExpression(e) || + ts.isNonNullExpression(e) + ) + e = e.expression; + return e; +} + +/** The node whose consumer decides how `node`'s value is used. */ +function transparentParent(node: ts.Expression): ts.Expression { + let e: ts.Expression = node; + for (;;) { + const p = e.parent; + if ( + p && + (ts.isParenthesizedExpression(p) || + ts.isAsExpression(p) || + ts.isTypeAssertion(p) || + ts.isSatisfiesExpression(p) || + ts.isNonNullExpression(p)) && + p.expression === e + ) { + e = p; + continue; + } + return e; + } +} + +function isAssignmentOperator(kind: ts.SyntaxKind): boolean { + return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment; +} + +const RELATIONAL = new Set([ + ts.SyntaxKind.LessThanToken, + ts.SyntaxKind.GreaterThanToken, + ts.SyntaxKind.LessThanEqualsToken, + ts.SyntaxKind.GreaterThanEqualsToken, +]); +const EQUALITY = new Set([ + ts.SyntaxKind.EqualsEqualsEqualsToken, + ts.SyntaxKind.ExclamationEqualsEqualsToken, + ts.SyntaxKind.EqualsEqualsToken, + ts.SyntaxKind.ExclamationEqualsToken, +]); +/** Results never NaN-free: ToNumber of each operand may be NaN. */ +const ARITHMETIC = new Set([ + ts.SyntaxKind.MinusToken, + ts.SyntaxKind.AsteriskToken, + ts.SyntaxKind.SlashToken, + ts.SyntaxKind.PercentToken, + ts.SyntaxKind.AsteriskAsteriskToken, +]); +/** ToInt32/ToUint32 map undefined and NaN to 0 alike; results are integers. */ +const BITWISE = new Set([ + ts.SyntaxKind.AmpersandToken, + ts.SyntaxKind.BarToken, + ts.SyntaxKind.CaretToken, + ts.SyntaxKind.LessThanLessThanToken, + ts.SyntaxKind.GreaterThanGreaterThanToken, + ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken, +]); +/** Compound assignments whose operator converts undefined like NaN. */ +const NUMERIC_COMPOUND = new Set([ + ts.SyntaxKind.MinusEqualsToken, + ts.SyntaxKind.AsteriskEqualsToken, + ts.SyntaxKind.SlashEqualsToken, + ts.SyntaxKind.PercentEqualsToken, + ts.SyntaxKind.AsteriskAsteriskEqualsToken, + ts.SyntaxKind.AmpersandEqualsToken, + ts.SyntaxKind.BarEqualsToken, + ts.SyntaxKind.CaretEqualsToken, + ts.SyntaxKind.LessThanLessThanEqualsToken, + ts.SyntaxKind.GreaterThanGreaterThanEqualsToken, + ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken, +]); + +/** `Math` functions convert every argument with ToNumber. */ +const MATH_INTEGER_RESULTS = new Set(["imul", "clz32", "sign"]); + +function isLoopOrIf(node: ts.Node): boolean { + return ( + ts.isIfStatement(node) || + ts.isWhileStatement(node) || + ts.isDoStatement(node) || + ts.isForStatement(node) + ); +} + +export class NanCodedReads { + /** Typed-array element reads that can lower as NaN-coded doubles. */ + private readonly reads = new Map(); + private readonly bindings = new Map(); + private readonly notSlots = new Set(); + /** Every slot's value occurrences. */ + private readonly useNodes = new Set(); + private readonly returns = new Map(); + /** Identifiers by text across the analyzed files. */ + private readonly names = new Map(); + private readonly symbolCache = new Map(); + private readonly pneCache = new Map(); + private readonly pneReturnCache = new Map(); + private readonly fnEligibility = new Map(); + private readonly callSites = new Map(); + /** Slots that are NaN-coded after the fixed point. */ + private readonly eligible = new Set(); + private readonly genuineNaN = new Set(); + private readonly numericReads = new Set(); + private analyzed = false; + + constructor( + private readonly host: NanCodedHost, + private readonly files: readonly ts.SourceFile[], + ) {} + + /** The read lowers to a double holding NaN for an invalid index. */ + isNumericRead(read: ts.ElementAccessExpression): boolean { + return this.numericReads.has(read); + } + + /** Both operands of this equality are NaN-coded values that may stand + * for undefined: undefined equals undefined, so equal NaNs compare equal. */ + needsUndefinedEquality(expr: ts.BinaryExpression): boolean { + if (!EQUALITY.has(expr.operatorToken.kind)) return false; + const left = this.loweredInfo(expr.left); + const right = this.loweredInfo(expr.right); + return left.nce && right.nce && !left.nan && !right.nan; + } + + /** Bindings and returns that are NaN-coded (consistency checks). */ + nanCodedBinding(symbol: ts.Symbol): boolean { + const slot = this.bindings.get(symbol); + return slot !== undefined && this.eligible.has(slot); + } + + nanCodedReturn(symbol: ts.Symbol): boolean { + const slot = this.returns.get(symbol); + return slot !== undefined && this.eligible.has(slot); + } + + get size(): { reads: number; slots: number } { + return { reads: this.numericReads.size, slots: this.eligible.size }; + } + + analyze(): void { + if (this.analyzed) return; + this.analyzed = true; + for (const sf of this.files) { + if (isJsSourceFile(sf) || sf.isDeclarationFile) continue; + ts.walkPreorder(sf, (node) => { + if (ts.isIdentifier(node)) { + const list = this.names.get(node.text); + if (list) list.push(node); + else this.names.set(node.text, [node]); + return; + } + if (ts.isElementAccessExpression(node) && this.isReadCandidate(node)) { + const elem = this.host.bytesElemOf(node.expression); + if (elem) this.reads.set(node, elem); + } + }); + } + if (this.reads.size === 0) return; + // Forward: the slots a read can reach. + const work: ts.Expression[] = [...this.reads.keys()]; + const reached = new Set(); + while (work.length > 0) { + const value = work.pop()!; + const slot = this.sinkOf(value); + if (!slot || reached.has(slot)) continue; + reached.add(slot); + for (const use of slot.uses) work.push(use); + } + for (const slot of reached) this.eligible.add(slot); + // Backward: drop slots with an unsafe use or source until stable. + for (let changed = true; changed;) { + changed = false; + this.computeGenuineNaN(); + for (const slot of [...this.eligible]) { + if (this.slotOk(slot)) continue; + if (this.removals) this.removals.set(slot, this.removalReason(slot)); + this.eligible.delete(slot); + changed = true; + } + } + this.computeGenuineNaN(); + for (const read of this.reads.keys()) if (this.useSafe(read)) this.numericReads.add(read); + if (process.env["SCRIPTC_DEBUG_NAN_CODED"]) this.debugReport(reached); + } + + private readonly removals: Map | null = process.env["SCRIPTC_DEBUG_NAN_CODED"] + ? new Map() + : null; + + private removalReason(slot: Slot): string { + const badSource = slot.sources.find((s) => s === null || this.sourceInfo(s) === null); + if (badSource !== undefined) + return `source ${badSource ? badSource.getText().slice(0, 60) : ""}`; + const nan = this.genuineNaN.has(slot); + const badUse = slot.uses.find((u) => !this.useSafe(u) && (nan || !this.reboxConsumer(u))); + if (badUse) + return `${nan ? "nan-use" : "use"} ${transparentParent(badUse).parent?.getText().slice(0, 80) ?? "?"}`; + return "?"; + } + + private debugReport(reached: Set): void { + const name = (slot: Slot): string => { + const decl = this.host.declarationOf(slot.symbol); + const sf = decl?.getSourceFile(); + const where = sf && decl ? `${sf.fileName}:${decl.getStart()}` : "?"; + return `${slot.kind} ${slot.symbol.name} @ ${where}`; + }; + for (const slot of reached) { + const ok = this.eligible.has(slot); + const why = ok + ? this.genuineNaN.has(slot) + ? "(nan)" + : `(rebox ${slot.uses.filter((u) => !this.useSafe(u)).length})` + : (this.removals?.get(slot) ?? "?"); + 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`, + ); + } + + private symbolOf(node: ts.Node): ts.Symbol | null { + if (this.symbolCache.has(node)) return this.symbolCache.get(node)!; + const symbol = this.host.symbolOf(node); + this.symbolCache.set(node, symbol); + return symbol; + } + + /** An element read (never a write target) of a TypeScript file. */ + private isReadCandidate(read: ts.ElementAccessExpression): boolean { + if (read.questionDotToken) return false; + let n: ts.Node = read; + let p = n.parent; + while (p && ts.isParenthesizedExpression(p)) { + n = p; + p = n.parent; + } + // `t[i]!` asserts presence: it already lowers to the plain numeric read + // and is never optional. + if (!p || ts.isNonNullExpression(p)) return false; + if (ts.isBinaryExpression(p) && p.left === n && isAssignmentOperator(p.operatorToken.kind)) + return false; + if ( + (ts.isPrefixUnaryExpression(p) || ts.isPostfixUnaryExpression(p)) && + (p.operator === ts.SyntaxKind.PlusPlusToken || p.operator === ts.SyntaxKind.MinusMinusToken) + ) + return false; + if (ts.isDeleteExpression(p)) return false; + if ( + ts.isArrayLiteralExpression(p) || + ts.isShorthandPropertyAssignment(p) || + ts.isPropertyAssignment(p) || + ts.isSpreadElement(p) || + ts.isForOfStatement(p) || + ts.isForInStatement(p) + ) + return false; + return true; + } + + // ---- slots -------------------------------------------------------------- + + /** The function whose `return` statement contains `node`. */ + private containingFunction(node: ts.Node): ts.Node | undefined { + let n = node.parent; + while ( + n && + !ts.isFunctionLike(n) && + !ts.isClassStaticBlockDeclaration(n) && + !ts.isSourceFile(n) + ) + n = n.parent; + return n; + } + + /** The declaration of a directly called function or method, when every + * reference to it is a direct call (no value use, no overrides). */ + private eligibleFunction(symbol: ts.Symbol): ts.FunctionLikeDeclaration | null { + if (this.fnEligibility.has(symbol)) return this.fnEligibility.get(symbol)!; + this.fnEligibility.set(symbol, null); + const decl = this.host.declarationOf(symbol); + if (!decl || (!ts.isFunctionDeclaration(decl) && !ts.isMethodDeclaration(decl))) return null; + if (!decl.body || decl.asteriskToken || decl.typeParameters || !decl.name) return null; + if (!ts.isIdentifier(decl.name)) return null; + const sf = decl.getSourceFile(); + if (isJsSourceFile(sf) || sf.isDeclarationFile) return null; + const flags = ts.getCombinedModifierFlags(decl); + if (flags & (ts.ModifierFlags.Async | ts.ModifierFlags.Abstract | ts.ModifierFlags.Ambient)) + return null; + if (ts.isMethodDeclaration(decl)) { + const cls = decl.parent; + if (!ts.isClassDeclaration(cls) || (cls.heritageClauses?.length ?? 0) > 0) return null; + if (this.host.methodOverridden(symbol)) return null; + } + // Sloppy-mode `arguments` aliases parameters. + let usesArguments = false; + ts.walkPreorder(decl.body, (node) => { + if (ts.isIdentifier(node) && node.text === "arguments") usesArguments = true; + }); + if (usesArguments) return null; + const calls = this.directCalls(symbol, decl.name); + if (!calls) return null; + this.fnEligibility.set(symbol, decl); + return decl; + } + + /** Every call of the function, or null when some reference is not a + * direct, non-optional call. */ + private directCalls(symbol: ts.Symbol, declName: ts.Identifier): ts.CallExpression[] | null { + if (this.callSites.has(symbol)) return this.callSites.get(symbol)!; + const calls: ts.CallExpression[] = []; + let ok = true; + for (const id of this.names.get(declName.text) ?? []) { + if (id === declName) continue; + // Import and export specifiers only alias the function. + const parent = id.parent; + if ( + parent && + (ts.isImportSpecifier(parent) || ts.isExportSpecifier(parent) || ts.isImportClause(parent)) + ) + continue; + if (this.symbolOf(id) !== symbol) continue; + let callee: ts.Expression = id; + const p = id.parent; + if (p && ts.isPropertyAccessExpression(p) && p.name === id) { + if (p.questionDotToken) ok = false; + callee = p; + } + const call = callee.parent; + if ( + !call || + !ts.isCallExpression(call) || + call.expression !== callee || + call.questionDotToken || + call.arguments.some((a) => ts.isSpreadElement(a)) + ) { + ok = false; + break; + } + calls.push(call); + } + const result = ok ? calls : null; + this.callSites.set(symbol, result); + return result; + } + + private bindingSlot(symbol: ts.Symbol): BindingSlot | null { + const existing = this.bindings.get(symbol); + if (existing) return existing; + if (this.notSlots.has(symbol)) return null; + const slot = this.scanBindingSlot(symbol); + if (slot) this.bindings.set(symbol, slot); + else this.notSlots.add(symbol); + return slot; + } + + private scanBindingSlot(symbol: ts.Symbol): BindingSlot | null { + const decl = this.host.declarationOf(symbol); + if (!decl) return null; + const sf = decl.getSourceFile(); + if (isJsSourceFile(sf) || sf.isDeclarationFile) return null; + let nameNode: ts.Identifier; + const slot: BindingSlot = { + kind: "binding", + symbol, + sources: [], + uses: [], + arithmeticWrites: false, + }; + if (ts.isVariableDeclaration(decl)) { + const list = decl.parent; + if ( + !ts.isIdentifier(decl.name) || + !ts.isVariableDeclarationList(list) || + !(list.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)) || + ts.isForOfStatement(list.parent) || + ts.isForInStatement(list.parent) + ) + return null; + // Module bindings are globals with their own promotion rules. + if (ts.isVariableStatement(list.parent) && ts.isSourceFile(list.parent.parent)) return null; + if (!this.host.isNumber(decl.name)) return null; + nameNode = decl.name; + if (decl.initializer) slot.sources.push(decl.initializer); + } else if (ts.isParameter(decl)) { + if (!ts.isIdentifier(decl.name) || decl.dotDotDotToken || decl.questionToken) return null; + const fn = decl.parent; + if (!ts.isFunctionDeclaration(fn) && !ts.isMethodDeclaration(fn)) return null; + const fnSymbol = fn.name ? this.symbolOf(fn.name) : null; + if (!fnSymbol || !this.eligibleFunction(fnSymbol)) return null; + const index = fn.parameters.indexOf(decl); + if (index < 0 || !this.host.paramIsNumber(fnSymbol, index)) return null; + nameNode = decl.name; + if (decl.initializer) slot.sources.push(decl.initializer); + for (const call of this.callSites.get(fnSymbol) ?? []) { + const arg = call.arguments[index]; + if (arg) slot.sources.push(arg); + else if (!decl.initializer) slot.sources.push(null); + } + } else return null; + 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.uses.push(id); + else if (write === "arith") { + slot.arithmeticWrites = true; + // The compound operator reads the binding as well. + slot.uses.push(id); + } else if (write === "unknown") slot.sources.push(null); + else slot.sources.push(write); + } + for (const use of slot.uses) this.useNodes.add(use); + return slot; + } + + /** How an identifier reference touches its binding. */ + private writeOf(id: ts.Identifier): "read" | "arith" | "unknown" | ts.Expression { + let n: ts.Node = id; + let p = n.parent; + while (p && ts.isParenthesizedExpression(p)) { + n = p; + p = n.parent; + } + if (!p) return "read"; + if (ts.isBinaryExpression(p) && p.left === n && isAssignmentOperator(p.operatorToken.kind)) { + const k = p.operatorToken.kind; + if (k === ts.SyntaxKind.EqualsToken) return p.right; + if (k === ts.SyntaxKind.PlusEqualsToken) + return this.host.isNumber(p.right) ? "arith" : "unknown"; + return NUMERIC_COMPOUND.has(k) ? "arith" : "unknown"; + } + if ( + (ts.isPrefixUnaryExpression(p) || ts.isPostfixUnaryExpression(p)) && + (p.operator === ts.SyntaxKind.PlusPlusToken || p.operator === ts.SyntaxKind.MinusMinusToken) + ) + return "arith"; + if ( + ts.isArrayLiteralExpression(p) || + ts.isShorthandPropertyAssignment(p) || + ts.isSpreadElement(p) || + (ts.isPropertyAssignment(p) && p.initializer === n) || + ((ts.isForOfStatement(p) || ts.isForInStatement(p)) && p.initializer === n) + ) { + // A destructuring target, or an object literal value (a read). + return ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p) + ? this.inDestructuringTarget(p) + ? "unknown" + : "read" + : ts.isForOfStatement(p) || ts.isForInStatement(p) + ? "unknown" + : this.inDestructuringTarget(p) + ? "unknown" + : "read"; + } + return "read"; + } + + private inDestructuringTarget(node: ts.Node): boolean { + let n: ts.Node = node; + let p = n.parent; + while ( + p && + (ts.isArrayLiteralExpression(p) || + ts.isObjectLiteralExpression(p) || + ts.isPropertyAssignment(p) || + ts.isShorthandPropertyAssignment(p) || + ts.isSpreadElement(p) || + ts.isSpreadAssignment(p) || + ts.isParenthesizedExpression(p)) + ) { + n = p; + p = n.parent; + } + if (!p) return false; + if ( + ts.isBinaryExpression(p) && + p.left === n && + p.operatorToken.kind === ts.SyntaxKind.EqualsToken + ) + return true; + return (ts.isForOfStatement(p) || ts.isForInStatement(p)) && p.initializer === n; + } + + private returnSlot(symbol: ts.Symbol): ReturnSlot | null { + const existing = this.returns.get(symbol); + if (existing) return existing; + const decl = this.eligibleFunction(symbol); + if (!decl || !decl.body || !this.host.returnIsNumber(symbol)) return null; + const slot: ReturnSlot = { + kind: "return", + symbol, + sources: [], + uses: [...(this.callSites.get(symbol) ?? [])], + arithmeticWrites: false, + }; + const body = decl.body; + ts.walkPreorder(body, (node) => { + if (!ts.isReturnStatement(node) || this.containingFunction(node) !== decl) return; + slot.sources.push(node.expression ?? null); + }); + for (const use of slot.uses) this.useNodes.add(use); + this.returns.set(symbol, slot); + return slot; + } + + /** The callee of a call: a function or method symbol. */ + private calleeSymbol(call: ts.CallExpression): ts.Symbol | null { + if (call.questionDotToken) return null; + let callee = call.expression; + while (ts.isParenthesizedExpression(callee)) callee = callee.expression; + if (ts.isIdentifier(callee)) return this.symbolOf(callee); + if (ts.isPropertyAccessExpression(callee) && !callee.questionDotToken) + return this.symbolOf(callee.name); + return null; + } + + /** The slot a value flows into, through transparent wrappers and + * conditional branches; null for a direct consumer. */ + private sinkOf(value: ts.Expression): Slot | null { + const e = transparentParent(value); + const p = e.parent; + if (!p) return null; + if (ts.isConditionalExpression(p) && p.condition !== e) return this.sinkOf(p); + if (ts.isBinaryExpression(p)) { + const k = p.operatorToken.kind; + if (k === ts.SyntaxKind.CommaToken && p.right === e) return this.sinkOf(p); + if (k === ts.SyntaxKind.EqualsToken && p.right === e) { + const target = peelTransparent(p.left); + if (!ts.isIdentifier(target)) return null; + const symbol = this.symbolOf(target); + return symbol ? this.bindingSlot(symbol) : null; + } + return null; + } + if (ts.isVariableDeclaration(p) && p.initializer === e && ts.isIdentifier(p.name)) { + const symbol = this.symbolOf(p.name); + return symbol ? this.bindingSlot(symbol) : null; + } + if (ts.isReturnStatement(p)) { + const fn = this.containingFunction(p); + if (!fn || (!ts.isFunctionDeclaration(fn) && !ts.isMethodDeclaration(fn)) || !fn.name) + return null; + const symbol = this.symbolOf(fn.name); + return symbol ? this.returnSlot(symbol) : null; + } + if (ts.isCallExpression(p) && p.expression !== e) { + const index = p.arguments.indexOf(e); + const fn = this.calleeSymbol(p); + if (index < 0 || !fn) return null; + const decl = this.eligibleFunction(fn); + const param = decl?.parameters[index]; + if (!param || !ts.isIdentifier(param.name)) return null; + const symbol = this.symbolOf(param.name); + return symbol ? this.bindingSlot(symbol) : null; + } + return null; + } + + /** A slot that never holds a genuine NaN encodes undefined exactly, so + * any consumer can rebuild the union (see `reboxes`); a slot that may + * hold a genuine NaN needs NaN-insensitive consumers throughout. */ + private slotOk(slot: Slot): boolean { + for (const source of slot.sources) + if (source === null || !this.sourceInfo(source)) return false; + const nan = this.genuineNaN.has(slot); + for (const use of slot.uses) + if (!this.useSafe(use) && (nan || !this.reboxConsumer(use))) return false; + return true; + } + + /** The eligible slot whose value this occurrence reads, if any. */ + private slotOfValue(node: ts.Expression): Slot | null { + if (ts.isIdentifier(node)) { + const symbol = this.symbolOf(node); + const slot = symbol ? this.bindings.get(symbol) : undefined; + return slot && this.eligible.has(slot) && this.useNodes.has(node) ? slot : null; + } + if (ts.isCallExpression(node)) { + const fn = this.calleeSymbol(node); + const slot = fn ? this.returns.get(fn) : undefined; + return slot && this.eligible.has(slot) && this.useNodes.has(node) ? slot : null; + } + return null; + } + + /** This read of a NaN-coded slot reaches a consumer that could observe + * undefined: lowering rebuilds `number | undefined` from the double (NaN + * is undefined, exactly, since the slot never holds a genuine NaN), and + * the optional-read analysis treats the occurrence as optional, so the + * consumer lowers exactly as it does for an ordinary read. */ + reboxes(node: ts.Expression): boolean { + if (!ts.isIdentifier(node) && !ts.isCallExpression(node)) return false; + const slot = this.slotOfValue(node); + return slot !== null && !this.genuineNaN.has(slot) && !this.useSafe(node); + } + + /** A consumer that takes the value whole, so the rebuilt union lowers + * there exactly like an ordinary optional read: an argument, a stored + * or returned value, a literal element, or string concatenation. Any + * other consumer (a member access, `typeof`, `??`, ...) keeps the slot + * on the ordinary optional lowering instead. */ + private reboxConsumer(value: ts.Expression): boolean { + const e = transparentParent(value); + const p = e.parent; + if (!p) return false; + if (ts.isConditionalExpression(p)) return p.condition !== e && this.reboxConsumer(p); + if (ts.isCallExpression(p) || ts.isNewExpression(p)) return p.expression !== e; + if (ts.isVariableDeclaration(p) || ts.isReturnStatement(p)) return true; + if (ts.isArrayLiteralExpression(p) || ts.isTemplateSpan(p)) return true; + if (ts.isPropertyAssignment(p)) return p.initializer === e; + if (ts.isBinaryExpression(p)) { + const k = p.operatorToken.kind; + if (k === ts.SyntaxKind.EqualsToken) return p.right === e; + if (k === ts.SyntaxKind.PlusToken) return true; + if (k === ts.SyntaxKind.CommaToken) return p.right === e && this.reboxConsumer(p); + } + return false; + } + + private computeGenuineNaN(): void { + this.genuineNaN.clear(); + for (let changed = true; changed;) { + changed = false; + for (const slot of this.eligible) { + if (this.genuineNaN.has(slot)) continue; + let nan = slot.arithmeticWrites; + for (const source of slot.sources) { + if (nan) break; + if (source) nan = this.sourceInfo(source)?.nan ?? true; + } + if (nan) { + this.genuineNaN.add(slot); + changed = true; + } + } + } + } + + // ---- values ------------------------------------------------------------- + + /** A value allowed into a NaN-coded slot, or null. */ + private sourceInfo(node: ts.Expression): ValueInfo | null { + const e = peelTransparent(node); + if (ts.isConditionalExpression(e)) { + const a = this.sourceInfo(e.whenTrue); + const b = a && this.sourceInfo(e.whenFalse); + return a && b ? { nce: a.nce || b.nce, nan: a.nan || b.nan } : null; + } + if (ts.isElementAccessExpression(e)) { + const elem = this.reads.get(e); + if (elem !== undefined) return { nce: true, nan: !isIntegerBytesElem(elem) }; + } + if (ts.isIdentifier(e)) { + 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) }; + } + if (ts.isCallExpression(e)) { + const fn = this.calleeSymbol(e); + const slot = fn ? this.returns.get(fn) : undefined; + if (slot && this.eligible.has(slot) && this.useNodes.has(e)) + return { nce: true, nan: this.genuineNaN.has(slot) }; + } + if (ts.isBinaryExpression(e) && this.nanFreeArithmetic(e, 0)) return { nce: false, nan: false }; + const plain = this.plainNumber(e, 0); + if (plain) return plain; + // Any other number value may be undefined at run time (an optional + // read the default lowering widens): lowering converts it with + // ToNumber on entry (see `convertsSource`), so the slot may then hold + // a genuine NaN. + return this.host.isNumber(e) ? { nce: true, nan: true } : null; + } + + /** The value flows into a NaN-coded slot: lowering converts an optional + * number to NaN-for-undefined on entry. */ + convertsSource(node: ts.Expression): boolean { + const slot = this.sinkOf(node); + return slot !== null && this.eligible.has(slot); + } + + /** The occurrence reads a NaN-coded slot. */ + readsNanCodedSlot(node: ts.Expression): boolean { + return this.slotOfValue(node) !== null; + } + + /** `+`, `-` or `*` over finite literals and integer values proven present + * where they are read: the result is never NaN. A NaN-coded slot that + * never holds a genuine NaN holds integers, finite plain numbers or NaN + * for undefined, and a comparison guarding the read excludes NaN. */ + private nanFreeArithmetic(node: ts.Expression, depth: number): boolean { + if (depth > 6) return false; + const e = peelTransparent(node); + if (ts.isNumericLiteral(e)) + return Number.isFinite(Number(e.text)) && Math.abs(Number(e.text)) < 2 ** 53; + if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.MinusToken) + return this.nanFreeArithmetic(e.operand, depth + 1); + if (ts.isBinaryExpression(e)) { + const k = e.operatorToken.kind; + if (k === ts.SyntaxKind.PlusToken || k === ts.SyntaxKind.MinusToken) { + if (!this.host.isNumber(e.left) || !this.host.isNumber(e.right)) return false; + return ( + this.nanFreeArithmetic(e.left, depth + 1) && this.nanFreeArithmetic(e.right, depth + 1) + ); + } + if (k === ts.SyntaxKind.AsteriskToken) + return ( + (ts.isNumericLiteral(peelTransparent(e.left)) || + ts.isNumericLiteral(peelTransparent(e.right))) && + this.nanFreeArithmetic(e.left, depth + 1) && + this.nanFreeArithmetic(e.right, depth + 1) + ); + return false; + } + if (ts.isPropertyAccessExpression(e)) { + const info = this.plainNumber(e, 0); + return info !== null && !info.nan; + } + if (!ts.isIdentifier(e)) return false; + const symbol = this.symbolOf(e); + if (!symbol) return false; + const slot = this.bindings.get(symbol); + if (slot && this.eligible.has(slot)) { + return ( + !this.genuineNaN.has(slot) && + slot.sources.length <= 1 && + !slot.arithmeticWrites && + this.provenPresent(e, symbol) + ); + } + const info = this.plainNumber(e, 0); + return info !== null && !info.nan; + } + + /** 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 { + let child: ts.Node = use; + for (let n = use.parent; n && !ts.isFunctionLike(n); child = n, n = n.parent) { + let condition: ts.Expression | undefined; + if (ts.isIfStatement(n) && n.thenStatement === child) condition = n.expression; + else if (ts.isConditionalExpression(n) && n.whenTrue === child) condition = n.condition; + else if ( + ts.isBinaryExpression(n) && + n.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && + n.right === child + ) + condition = n.left; + if (condition && this.conditionExcludesUndefined(condition, symbol)) return true; + } + return false; + } + + private conditionExcludesUndefined(condition: ts.Expression, symbol: ts.Symbol): boolean { + const e = peelTransparent(condition); + if (!ts.isBinaryExpression(e)) return false; + const k = e.operatorToken.kind; + if (k === ts.SyntaxKind.AmpersandAmpersandToken) + return ( + this.conditionExcludesUndefined(e.left, symbol) || + this.conditionExcludesUndefined(e.right, symbol) + ); + if (!RELATIONAL.has(k) && k !== ts.SyntaxKind.EqualsEqualsEqualsToken) return false; + const left = peelTransparent(e.left); + const right = peelTransparent(e.right); + const names = (x: ts.Expression): boolean => ts.isIdentifier(x) && this.symbolOf(x) === symbol; + const literal = (x: ts.Expression): boolean => { + const info = this.plainNumber(x, 0); + return info !== null && !info.nan; + }; + return (names(left) && literal(right)) || (names(right) && literal(left)); + } + + /** How the value lowers once the analysis is final: NaN-coded (a numeric + * read or a read of a NaN-coded slot) or not. */ + private loweredInfo(node: ts.Expression): ValueInfo { + const e = peelTransparent(node); + if (ts.isConditionalExpression(e)) { + const a = this.loweredInfo(e.whenTrue); + const b = this.loweredInfo(e.whenFalse); + return { nce: a.nce || b.nce, nan: a.nan || b.nan }; + } + if (ts.isElementAccessExpression(e)) { + const elem = this.reads.get(e); + if (elem !== undefined && this.numericReads.has(e)) + return { nce: true, nan: !isIntegerBytesElem(elem) }; + } + const slot = this.slotOfValue(e); + if (slot) return { nce: true, nan: this.genuineNaN.has(slot) }; + return { nce: false, nan: true }; + } + + /** A number expression that is never undefined in Node and always lowers + * to a plain double, whatever the optional-read analysis decides. */ + private plainNumber(node: ts.Expression, depth: number): ValueInfo | null { + if (depth > 8) return null; + const cached = this.pneCache.get(node); + if (cached !== undefined) return cached; + const result = this.computePlainNumber(node, depth); + this.pneCache.set(node, result); + return result; + } + + private computePlainNumber(node: ts.Expression, depth: number): ValueInfo | null { + let e = node; + while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isSatisfiesExpression(e)) + e = e.expression; + if (ts.isNumericLiteral(e)) return { nce: false, nan: false }; + 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) + return { nce: false, nan: !ts.isNumericLiteral(e.operand) }; + return null; + } + if (ts.isBinaryExpression(e)) { + const k = e.operatorToken.kind; + if (BITWISE.has(k)) return { nce: false, nan: false }; + if (ARITHMETIC.has(k)) return { nce: false, nan: true }; + if ( + k === ts.SyntaxKind.PlusToken && + this.host.isNumber(e.left) && + this.host.isNumber(e.right) + ) + return { nce: false, nan: true }; + // A number fallback for a nullish (or falsy) left value: never + // undefined. The left value is used as is, so it may be NaN. + if ( + (k === ts.SyntaxKind.QuestionQuestionToken || k === ts.SyntaxKind.BarBarToken) && + this.host.isNumber(e) && + this.plainNumber(e.right, depth + 1) !== null + ) + return { nce: false, nan: true }; + return null; + } + if (ts.isPropertyAccessExpression(e) && !e.questionDotToken) { + const symbol = this.symbolOf(e.name); + if (symbol && symbol.flags & ts.SymbolFlags.EnumMember && this.host.isNumber(e)) + return { nce: false, nan: false }; + if ( + e.name.text === "length" && + (this.host.isString(e.expression) || this.host.hasLength(e.expression)) + ) + return { nce: false, nan: false }; + return null; + } + if (ts.isCallExpression(e) && !e.questionDotToken) { + const callee = e.expression; + if (ts.isPropertyAccessExpression(callee) && !callee.questionDotToken) { + if (this.host.isStdlibGlobal(callee.expression, "Math")) + return { nce: false, nan: !MATH_INTEGER_RESULTS.has(callee.name.text) }; + if (callee.name.text === "charCodeAt" && this.host.isString(callee.expression)) + return { nce: false, nan: true }; + } + const fn = this.calleeSymbol(e); + return fn ? this.plainReturn(fn, depth) : null; + } + if (ts.isIdentifier(e)) { + const symbol = this.symbolOf(e); + const decl = symbol ? this.host.declarationOf(symbol) : undefined; + if ( + decl && + ts.isVariableDeclaration(decl) && + decl.initializer && + ts.isVariableDeclarationList(decl.parent) && + decl.parent.flags & ts.NodeFlags.Const && + !isJsSourceFile(decl.getSourceFile()) + ) + return this.plainNumber(decl.initializer, depth + 1); + return null; + } + return null; + } + + /** A function whose every return value is a plain number. */ + private plainReturn(fn: ts.Symbol, depth: number): ValueInfo | null { + if (this.pneReturnCache.has(fn)) return this.pneReturnCache.get(fn)!; + this.pneReturnCache.set(fn, null); + const decl = this.eligibleFunction(fn); + if (!decl?.body || !this.host.returnIsNumber(fn)) return null; + let result: ValueInfo | null = { nce: false, nan: false }; + ts.walkPreorder(decl.body, (node) => { + if (!result || !ts.isReturnStatement(node) || this.containingFunction(node) !== decl) return; + const info = node.expression ? this.plainNumber(node.expression, depth + 1) : null; + result = info ? { nce: false, nan: result.nan || info.nan } : null; + }); + this.pneReturnCache.set(fn, result); + return result; + } + + // ---- uses --------------------------------------------------------------- + + /** Whether the value's consumer cannot tell undefined from NaN. */ + private useSafe(value: ts.Expression): boolean { + const e = transparentParent(value); + const p = e.parent; + if (!p) return false; + if (ts.isConditionalExpression(p)) return p.condition === e || this.useSafe(p); + if (ts.isExpressionStatement(p) || ts.isVoidExpression(p)) return true; + if (isLoopOrIf(p)) { + if (ts.isForStatement(p)) return p.condition === e || p.incrementor === e; + return true; + } + if (ts.isPrefixUnaryExpression(p)) { + return ( + p.operator === ts.SyntaxKind.MinusToken || + p.operator === ts.SyntaxKind.PlusToken || + p.operator === ts.SyntaxKind.TildeToken || + p.operator === ts.SyntaxKind.ExclamationToken + ); + } + if (ts.isBinaryExpression(p)) { + const k = p.operatorToken.kind; + const other = p.left === e ? p.right : p.left; + if (RELATIONAL.has(k) || ARITHMETIC.has(k) || BITWISE.has(k)) return true; + if (k === ts.SyntaxKind.PlusToken) return this.host.isNumber(other); + if (EQUALITY.has(k)) { + const otherInfo = this.sourceInfo(other); + if (!otherInfo) return false; + if (!otherInfo.nce) return true; + const self = this.sourceInfo(e); + return self !== null && !self.nan && !otherInfo.nan; + } + if (k === ts.SyntaxKind.AmpersandAmpersandToken || k === ts.SyntaxKind.BarBarToken) + return this.inConditionContext(p); + if (k === ts.SyntaxKind.CommaToken) return p.left === e || this.useSafe(p); + if (p.left === e) { + // Compound assignment reads of a binding: arithmetic. + return NUMERIC_COMPOUND.has(k) || k === ts.SyntaxKind.PlusEqualsToken; + } + if (k === ts.SyntaxKind.EqualsToken) { + const slot = this.sinkOf(e); + if (!slot || !this.eligible.has(slot)) return false; + // The assignment's own value flows on when it is used. + const outer = transparentParent(p); + const consumer = outer.parent; + if ( + !consumer || + ts.isExpressionStatement(consumer) || + (ts.isForStatement(consumer) && + (consumer.incrementor === outer || consumer.initializer === outer)) + ) + return true; + return this.useSafe(p); + } + return ( + NUMERIC_COMPOUND.has(k) || + (k === ts.SyntaxKind.PlusEqualsToken && this.host.isNumber(p.left)) + ); + } + if (ts.isPostfixUnaryExpression(p)) return true; + if (ts.isVariableDeclaration(p) || ts.isReturnStatement(p)) { + const slot = this.sinkOf(e); + return slot !== null && this.eligible.has(slot); + } + if (ts.isCallExpression(p) && p.expression !== e) { + const callee = p.expression; + if ( + ts.isPropertyAccessExpression(callee) && + !callee.questionDotToken && + !p.questionDotToken && + (this.host.isStdlibGlobal(callee.expression, "Math") || + (callee.name.text === "fromCharCode" && + this.host.isStdlibGlobal(callee.expression, "String"))) + ) + return true; + const slot = this.sinkOf(e); + return slot !== null && this.eligible.has(slot); + } + // A typed-array element read answers undefined for both an undefined + // and a NaN index (neither names an integer index). + if ( + ts.isElementAccessExpression(p) && + p.argumentExpression === e && + this.isReadCandidate(p) && + this.host.bytesElemOf(p.expression) !== null + ) + return true; + if (ts.isSwitchStatement(p)) { + return p.caseBlock.clauses.every( + (clause) => !ts.isCaseClause(clause) || this.plainNumber(clause.expression, 0) !== null, + ); + } + return false; + } + + /** The value of a logical expression is only tested for truthiness. */ + private inConditionContext(node: ts.Expression): boolean { + const e = transparentParent(node); + const p = e.parent; + if (!p) return false; + if (ts.isIfStatement(p) || ts.isWhileStatement(p) || ts.isDoStatement(p)) + return p.expression === e; + if (ts.isForStatement(p)) return p.condition === e; + if (ts.isConditionalExpression(p)) return p.condition === e; + if (ts.isPrefixUnaryExpression(p)) return p.operator === ts.SyntaxKind.ExclamationToken; + if (ts.isExpressionStatement(p)) return true; + if (ts.isBinaryExpression(p)) { + const k = p.operatorToken.kind; + if (k === ts.SyntaxKind.AmpersandAmpersandToken || k === ts.SyntaxKind.BarBarToken) + return this.inConditionContext(p); + } + return false; + } +} diff --git a/packages/compiler/test/nan-coded-reads.test.ts b/packages/compiler/test/nan-coded-reads.test.ts new file mode 100644 index 000000000..5820e8f3d --- /dev/null +++ b/packages/compiler/test/nan-coded-reads.test.ts @@ -0,0 +1,86 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule, validateModule } from "../src/index.js"; +import type { IrFunction, IrModule } from "../src/ir/ir.js"; + +async function lower(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-nan-coded-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const mod = deserializeModule(await readFile(outPath, "utf8")); + expect(validateModule(mod)).toEqual([]); + return mod; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +function fn(mod: IrModule, name: string): IrFunction { + const found = mod.functions.find((f) => f.name === name || f.name.endsWith(`.${name}`)); + if (!found) + throw new Error(`no function ${name}: ${mod.functions.map((f) => f.name).join(", ")}`); + return found; +} + +function calls(value: unknown): string[] { + const out: string[] = []; + const visit = (node: unknown): void => { + if (!node || typeof node !== "object") return; + if (Array.isArray(node)) return node.forEach(visit); + const n = node as { kind?: string; callee?: string; fn?: string }; + if (n.kind === "call" && n.callee) out.push(n.callee); + if (n.kind === "libCall" && n.fn) out.push(n.fn); + Object.values(node).forEach(visit); + }; + visit(value); + return out; +} + +const scanner = ` +class Cursor { + pos = 0; + readonly chars: Uint16Array; + readonly end: number; + constructor(chars: Uint16Array) { + this.chars = chars; + this.end = chars.length; + } + char(): number { + return this.pos < this.end ? this.chars[this.pos] : -1; + } +} +function isSpace(ch: number): boolean { + return ch === 32 || ch === 9; +} +function sameAt(a: Uint16Array, b: Uint16Array, i: number): boolean { + return a[i] === b[i]; +} +function show(chars: Uint16Array, i: number): string { + return String(chars[i]); +} +const chars = new Uint16Array([32, 97]); +const cursor = new Cursor(chars); +let spaces = 0; +for (; cursor.pos <= cursor.end; cursor.pos++) if (isSpace(cursor.char())) spaces++; +console.log(spaces, sameAt(chars, chars, 9), show(chars, 9)); +`; + +test("reads whose consumers treat undefined like NaN lower to plain numbers", async () => { + const mod = await lower(scanner); + expect(fn(mod, "isSpace").params[0]!.type).toEqual({ kind: "f64" }); + expect(fn(mod, "char").returnType).toEqual({ kind: "f64" }); + expect(calls(fn(mod, "char").body).some((c) => c.startsWith("%bytes.idxOr"))).toBe(false); + // Two missing elements are equal: the equality also tests for two NaNs. + const same = calls(fn(mod, "sameAt").body); + expect(same.some((c) => c.startsWith("%bytes.idxOr"))).toBe(false); + expect(same).toContain("num.isNaN"); + // String() observes undefined: the ordinary optional read remains. + expect(calls(fn(mod, "show").body).some((c) => c.startsWith("%bytes.idxOr"))).toBe(true); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 7bf630618..1c7788be6 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -11409,6 +11409,12 @@ ], "diags": [] }, + "/tests/corpus/typed-array-nan-coded-reads.ts": { + "order": [ + "/tests/corpus/typed-array-nan-coded-reads.ts" + ], + "diags": [] + }, "/tests/corpus/typed-array-unknown.ts": { "order": [ "/tests/corpus/typed-array-unknown.ts" diff --git a/tests/corpus/typed-array-nan-coded-reads.ts b/tests/corpus/typed-array-nan-coded-reads.ts new file mode 100644 index 000000000..02b53cd4f --- /dev/null +++ b/tests/corpus/typed-array-nan-coded-reads.ts @@ -0,0 +1,165 @@ +// Typed-array element reads flowing through bindings, parameters and returns +// whose consumers cannot tell undefined from NaN, next to consumers that can. +// An index that is not an integer in [0, length) reads undefined, like Node; +// the compiler may carry such reads as plain numbers only where that is +// unobservable. +const base: number = process.argv.length; // 2 under both runtimes +const text = "let x = 1; /* note */ y\u{1F600}"; +const chars = new Uint16Array(text.length); +for (let i = 0; i < text.length; i++) chars[i] = text.charCodeAt(i); + +const far = base + 100; +const negative = -base; +const fraction = base + 0.5; +const nan = base / 0 - base / 0; +const negativeZero = -0 * base; + +class Cursor { + pos = 0; + readonly end: number; + token: number | undefined = 0; + readonly data: Uint16Array; + constructor(data: Uint16Array) { + this.data = data; + this.end = data.length; + } + char(): number { + return this.pos < this.end ? this.data[this.pos] : -1; + } + peek(offset: number): number { + return this.data[this.pos + offset]; + } + codePointAt(pos: number): number { + const ch = this.data[pos]; + if (ch >= 0xd800 && ch <= 0xdbff) { + const low = this.data[pos + 1]; + if (low >= 0xdc00 && low <= 0xdfff) return (ch - 0xd800) * 0x400 + (low - 0xdc00) + 0x10000; + } + return ch; + } +} + +function isLetter(ch: number): boolean { + return (ch >= 97 && ch <= 122) || (ch >= 65 && ch <= 90); +} + +function isSpace(ch: number): boolean { + return ch === 32 || ch === 9; +} + +function kind(ch: number): string { + switch (ch) { + case 32: + return "space"; + case 61: + return "equals"; + case -1: + return "eof"; + default: + return isLetter(ch) ? "letter" : "other"; + } +} + +function describe(ch: number): string { + // String() observes undefined: the read is rebuilt as `number | undefined`. + return ch === 120 ? "x" : String(ch); +} + +function skipComment(data: Uint16Array, pos: number, end: number): number { + const view = data.subarray(pos, end); + for (let i = 0; i < view.length; i++) { + const c = view[i]; + if (c === 42 || c === 10 || c > 127) return pos + i; + } + return end; +} + +const cursor = new Cursor(chars); +const kinds: string[] = []; +for (; cursor.pos <= cursor.end; cursor.pos++) kinds.push(kind(cursor.char())); +console.log("kinds", kinds.join(",")); +cursor.pos = 0; +console.log("peek", cursor.peek(1), cursor.peek(far), cursor.peek(negative), cursor.peek(fraction)); +console.log("peek kinds", kind(cursor.peek(2)), kind(cursor.peek(far)), isSpace(cursor.peek(far))); +console.log("describe", describe(chars[4]), describe(chars[0]), describe(chars[far]), describe(chars[nan])); +console.log("code points", cursor.codePointAt(text.length - 2), cursor.codePointAt(text.length - 1), cursor.codePointAt(far)); +console.log("skip", skipComment(chars, 13, chars.length), skipComment(chars, 0, 3)); + +// Index conversions: NaN, fractions, negatives and -0. +const reads = [chars[nan], chars[fraction], chars[negative], chars[negativeZero], chars[far], chars[-0]]; +console.log("index forms", reads.join("|"), reads.map((r) => r === undefined).join(",")); +let present = 0; +for (const index of [0, negativeZero, 1.5, -1, nan, Infinity, -Infinity, 2 ** 32, chars.length - 1, chars.length]) { + const c = chars[index]; + if (c > 0) present++; +} +console.log("present", present); + +function bindings(): void { +// Equality of two reads: undefined equals undefined. +const a = chars[far]; +const b = chars[far + 1]; +const c0 = chars[0]; +console.log("equal missing", a === b, a !== b, a == b, a === c0, c0 === chars[0], chars[far] === chars[negative]); +console.log("equal mixed", a === 108, c0 === 108, c0 !== 108, a !== 108, a == null, a === undefined); +console.log("relational", a < 1, a >= 0, c0 > 100, chars[far] <= chars[far]); +console.log("arithmetic", a + 1, c0 + 1, a | 0, c0 >> 1, -a, ~a, Math.max(a, 1)); +console.log("truthy", a ? "yes" : "no", !a, c0 && 5, chars[far] || 7, a ?? -1); +console.log("typeof", typeof a, typeof c0, `${a}/${c0}`, [a, c0], JSON.stringify({ a, c0 })); + +// Stored and returned values keep undefined. +cursor.token = cursor.peek(far); +console.log("field", cursor.token === undefined); +cursor.token = cursor.char(); +console.log("field", cursor.token); +const kept: (number | undefined)[] = []; +kept.push(cursor.peek(far), cursor.peek(0)); +console.log("pushed", kept, kept.indexOf(undefined)); +function passThrough(value: unknown): string { + return value === undefined ? "undefined" : typeof value; +} +console.log("unknown", passThrough(chars[far]), passThrough(chars[1])); + +// A binding that may hold a genuine NaN next to a missing read. +const floats = new Float64Array([nan, 1.5]); +const f0 = floats[0]; +const fMissing = floats[far]; +console.log("floats", f0 === fMissing, f0, fMissing, Number.isNaN(f0), fMissing === undefined); +let counter = chars[far]; +counter++; +console.log("incremented", counter, counter === chars[far], Number.isNaN(counter)); + +} +bindings(); + +// Only NaN-insensitive consumers: the reads stay plain numbers, and equality +// of two missing reads is still true. +function equalities(offset: number): string { + const p = chars[far + offset]; + const q = chars[negative - offset]; + const r = chars[offset]; + const s = chars[offset + 1]; + const flags = [p === q, p !== q, p == q, p != q, p === r, r === s, r !== s, p < r, r > p]; + let score = 0; + if (p === q) score += 1; + if (q !== r) score += 2; + if (r === chars[offset]) score += 4; + if (p === chars[fraction]) score += 8; + switch (p) { + case 0: + score += 100; + break; + default: + score += 16; + } + return flags.join(",") + " " + score + " " + JSON.stringify(String.fromCharCode(p, r, Math.max(q, s))); +} +console.log("equalities", equalities(0), equalities(1)); + +// Views: a subarray answers undefined past its own length even when the +// underlying buffer continues. +const view = chars.subarray(2, 5); +console.log("view", view.length, view[0], view[2], view[3], view[far], view[3] === chars[far]); +let hash = 0x811c9dc5 | 0; +for (let i = 0; i <= view.length; i++) hash = Math.imul(hash ^ view[i], 0x01000193); +console.log("hash", hash >>> 0); From d1791b9165a99bae7621096476d6567cc3c304a2 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 10:40:46 -0700 Subject: [PATCH 03/29] Inline two-argument Math.min and Math.max Every two-argument Math.min/Math.max was an out-of-line call to scr_math_min/scr_math_max (94 sites in tsc-ts, including compareCodePoints and IdentifierTable.intern). LLVM's minimum/maximum intrinsics have exactly their semantics: NaN propagates and -0 orders below +0. On x86-64 they lower to minsd/maxsd with inline NaN and signed-zero handling, on arm64 to fmin/fmax. --- packages/compiler/src/backend/llvm/lib-filesystem.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/compiler/src/backend/llvm/lib-filesystem.ts b/packages/compiler/src/backend/llvm/lib-filesystem.ts index 674e3e543..41d93317b 100644 --- a/packages/compiler/src/backend/llvm/lib-filesystem.ts +++ b/packages/compiler/src/backend/llvm/lib-filesystem.ts @@ -664,6 +664,17 @@ export function emitPathUrlLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll export function emitPrimitiveLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { const B = host.B; + if (e.fn === "math.min" || e.fn === "math.max") { + // IEEE 754-2019 minimum/maximum: NaN propagates and -0 orders below +0, + // exactly Math.min/Math.max of two numbers. + const a = host.emitExpr(e.args[0]!); + const b = host.emitExpr(e.args[1]!); + const intr = e.fn === "math.min" ? "minimum" : "maximum"; + host.declare(`declare double @llvm.${intr}.f64(double, double)`); + const t = B.tmp(); + B.line(`${t} = call double @llvm.${intr}.f64(double ${a.name}, double ${b.name})`); + return { name: t, type: e.type }; + } if (e.fn === "math.trunc") { const v = host.emitExpr(e.args[0]!); return { name: emitTruncF64(host, v.name), type: e.type }; From 028622fc75dbb66e0595e391f645724fcde250ff Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 12:27:54 -0700 Subject: [PATCH 04/29] Read cached null answers without a non-null assertion The NaN-coded read analysis memoized lookups whose answer can be null (symbols, eligible functions, call sites, plain-number returns) and read them back with `map.get(key)!`. Under Node that returns the cached null, but in the natively compiled compiler `!` is a checked extraction, which threw "null is not representable" while lowering programs (self-hosting-native-frontend, the native CLI bootstrap contracts). Read the cache through an undefined check instead. --- .../src/frontend/lowering/nan-coded-reads.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/nan-coded-reads.ts b/packages/compiler/src/frontend/lowering/nan-coded-reads.ts index 72bce33c5..6f6a0306d 100644 --- a/packages/compiler/src/frontend/lowering/nan-coded-reads.ts +++ b/packages/compiler/src/frontend/lowering/nan-coded-reads.ts @@ -310,7 +310,10 @@ export class NanCodedReads { } private symbolOf(node: ts.Node): ts.Symbol | null { - if (this.symbolCache.has(node)) return this.symbolCache.get(node)!; + // A cached null is an answer: `!` would be a checked extraction in a + // native build of the compiler. + const cached = this.symbolCache.get(node); + if (cached !== undefined) return cached; const symbol = this.host.symbolOf(node); this.symbolCache.set(node, symbol); return symbol; @@ -366,7 +369,8 @@ export class NanCodedReads { /** The declaration of a directly called function or method, when every * reference to it is a direct call (no value use, no overrides). */ private eligibleFunction(symbol: ts.Symbol): ts.FunctionLikeDeclaration | null { - if (this.fnEligibility.has(symbol)) return this.fnEligibility.get(symbol)!; + const known = this.fnEligibility.get(symbol); + if (known !== undefined) return known; this.fnEligibility.set(symbol, null); const decl = this.host.declarationOf(symbol); if (!decl || (!ts.isFunctionDeclaration(decl) && !ts.isMethodDeclaration(decl))) return null; @@ -397,7 +401,8 @@ export class NanCodedReads { /** Every call of the function, or null when some reference is not a * direct, non-optional call. */ private directCalls(symbol: ts.Symbol, declName: ts.Identifier): ts.CallExpression[] | null { - if (this.callSites.has(symbol)) return this.callSites.get(symbol)!; + const known = this.callSites.get(symbol); + if (known !== undefined) return known; const calls: ts.CallExpression[] = []; let ok = true; for (const id of this.names.get(declName.text) ?? []) { @@ -962,7 +967,8 @@ export class NanCodedReads { /** A function whose every return value is a plain number. */ private plainReturn(fn: ts.Symbol, depth: number): ValueInfo | null { - if (this.pneReturnCache.has(fn)) return this.pneReturnCache.get(fn)!; + const known = this.pneReturnCache.get(fn); + if (known !== undefined) return known; this.pneReturnCache.set(fn, null); const decl = this.eligibleFunction(fn); if (!decl?.body || !this.host.returnIsNumber(fn)) return null; From 2d90508bc7de92ac57d95414cdbd9295a9f53fd1 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 11:58:47 -0700 Subject: [PATCH 05/29] Move busy small-object slices to transparent huge pages Every class slice used to opt out of transparent huge pages, so each 4 KiB the bump pointers crossed cost a page fault. Those faults (fault entry, memcg charge, LRU insertion and the page clear) were 6-14% of tsc-ts samples in the kernel. Backing the whole reservation with huge pages removes most of them, but makes the 2 MiB page around every bump pointer resident at once. A worker executable has up to 32 class slices per script thread, so tRPC at 8 checkers grew from 558 to 955 MB. A slice now starts on small pages. Once it has carved 8 MiB, the rest of its range switches to huge pages (MADV_HUGEPAGE). Only slices that already hold 8 MiB pay for a partial last huge page. The reservation is huge-page aligned, so the switch point and every slice start on a 2 MiB boundary. The slow path raises a slice's bump limit in two steps (to the switch point, then to the slice end), so the inline fast paths are unchanged. The policy is Linux-only; other targets keep one limit per slice, as before. The allocator checks now build with _GNU_SOURCE on Linux like the runtime packs, so the madvise advice is compiled in and checked (alignment, contiguous carving across the switch, VmFlags nh/hg). Measured with tsc-ts best-v1 (parallel-check + 6247795 e24057a 64d9af6 1c42c55), built with this runtime vs origin/main, on two Linux Vercel Sandboxes (8 vCPU; 5-7 interleaved runs, medians). Columns: wall, max RSS, minor faults; "all" is MADV_HUGEPAGE on the whole reservation: single-threaded wall RSS faults all: wall / RSS Excalidraw -6.1% +0.8% 331k -> 112k -10.0% / +3.5% Playwright -1.8% +1.0% 371k -> 111k -8.4% / +3.2% TypeORM -7.2% +0.9% 259k -> 75k -11.8% / +4.1% tRPC -5.0% +2.0% 101k -> 47k -9.0% / +11.3% tsc-ts self-check -2.9% +2.6% 158k -> 64k -3.1% / +8.4% --checkers 8 Excalidraw -2.9% +2.6% 1075k -> 508k -6.3% / +10.6% Playwright -10.0% +2.6% 739k -> 381k -10.3% / +12.2% TypeORM -5.9% +2.2% 372k -> 232k -10.1% / +27.7% tRPC -2.5% +2.5% 159k -> 125k -4.3% / +71.2% tsc-ts self-check -4.0% +3.9% 213k -> 172k -12.1% / +46.3% Switching at 2 or 4 MiB was up to about 1.5 points faster single-threaded but cost up to +11% RSS at 8 checkers. 16 MiB gave back most of the gain. Prefaulting small pages with MADV_POPULATE_WRITE did not reduce sys time. Collapsing the small-page prefix with MADV_COLLAPSE at the switch did not help either. The runtime benchmark suite (--layouts=4 --runs=16) shows a geomean change of -1.2%: inventory-report -9.9%, validate-errors -5.3%, log-summary -2.9%, and regex-logs +2.1%. regex-logs peaks at 6.8 MB and never reaches the switch point; with one binary the policy measures -0.9% there, so its delta comes from code layout. --- packages/runtime/src/scr_alloc.c | 99 ++++++++++++++++++-------- packages/runtime/src/scr_alloc.test.c | 61 ++++++++++++++++ packages/runtime/src/scr_alloc.test.ts | 3 + 3 files changed, 134 insertions(+), 29 deletions(-) diff --git a/packages/runtime/src/scr_alloc.c b/packages/runtime/src/scr_alloc.c index 015236785..9e08c9594 100644 --- a/packages/runtime/src/scr_alloc.c +++ b/packages/runtime/src/scr_alloc.c @@ -26,6 +26,61 @@ ScrSaState scr_sa = {0}; #if SCR_SMALL_ALLOC #include +/* Page policy. Slice pages are committed by first touch, which on 4 KiB + * pages is one fault (and page clear) per 4 KiB the bump pointers cross. + * Transparent huge pages fault 2 MiB at a time and shrink the TLB working + * set, but make the whole 2 MiB page around a bump pointer resident at once, + * so every class slice in use (per arena, in worker executables) would cost + * up to 2 MiB more than it holds. A slice therefore starts on small pages + * and moves the rest of its range to huge pages once it has carved + * SCR_SA_HUGE_AFTER bytes: only slices that already hold that much pay the + * partial last huge page. The reservation is huge-page aligned so the + * switch point and every slice start on a huge-page boundary. */ +#if defined(__linux__) && defined(MADV_HUGEPAGE) && defined(MADV_NOHUGEPAGE) +#define SCR_SA_THP 1 +#define SCR_SA_HUGE_PAGE ((size_t)2 << 20) +#define SCR_SA_HUGE_AFTER ((size_t)8 << 20) +#endif + +/* Maps span bytes of address space for slices (huge-page aligned where the + * policy above applies), or NULL. */ +static void *scr_sa_map(size_t span) { + int flags = MAP_PRIVATE | MAP_ANON; +#ifdef MAP_NORESERVE + flags |= MAP_NORESERVE; +#endif +#ifdef SCR_SA_THP + size_t pad = SCR_SA_HUGE_PAGE; +#else + size_t pad = 0; +#endif + char *p = mmap(NULL, span + pad, PROT_READ | PROT_WRITE, flags, -1, 0); + if (p == MAP_FAILED) return NULL; +#ifdef SCR_SA_THP + char *a = (char *)(((uintptr_t)p + pad - 1) & ~(uintptr_t)(pad - 1)); + if (a > p) (void)munmap(p, (size_t)(a - p)); + (void)munmap(a + span, (size_t)(p + pad - a)); /* a < p + pad */ + p = a; + (void)madvise(p, span, MADV_NOHUGEPAGE); +#endif + return p; +} + +/* The next bump limit of a class slice [start, end) whose limit is lim + * (< end): its small-page prefix ends at SCR_SA_HUGE_AFTER, where the rest + * of the slice switches to huge pages. */ +static char *scr_sa_extend(char *start, char *lim, char *end) { +#ifdef SCR_SA_THP + if ((size_t)(lim - start) < SCR_SA_HUGE_AFTER && (size_t)(end - start) > SCR_SA_HUGE_AFTER) + return start + SCR_SA_HUGE_AFTER; + if (lim > start) (void)madvise(lim, (size_t)(end - lim), MADV_HUGEPAGE); +#else + (void)start; + (void)lim; +#endif + return end; +} + #ifdef SCR_WORKERS #include #include @@ -55,15 +110,8 @@ static bool scr_sa_reserve_region(void) { for (unsigned shift = 30; shift >= 22; shift -= 2) { size_t arena = (size_t)SCR_SA_NCLASS << shift; size_t span = arena * SCR_SA_ARENAS; - int flags = MAP_PRIVATE | MAP_ANON; -#ifdef MAP_NORESERVE - flags |= MAP_NORESERVE; -#endif - void *p = mmap(NULL, span, PROT_READ | PROT_WRITE, flags, -1, 0); - if (p == MAP_FAILED) continue; -#if defined(__linux__) && defined(MADV_NOHUGEPAGE) - (void)madvise(p, span, MADV_NOHUGEPAGE); -#endif + void *p = scr_sa_map(span); + if (!p) continue; scr_sa_arena_span = arena; scr_sa_region_shift = shift; atomic_store_explicit(&scr_sa_region, (uintptr_t)p, memory_order_release); @@ -161,17 +209,8 @@ static bool scr_sa_refused; static bool scr_sa_reserve(void) { for (unsigned shift = 30; shift >= 24; shift -= 2) { size_t span = (size_t)SCR_SA_NCLASS << shift; - int flags = MAP_PRIVATE | MAP_ANON; -#ifdef MAP_NORESERVE - flags |= MAP_NORESERVE; -#endif - void *p = mmap(NULL, span, PROT_READ | PROT_WRITE, flags, -1, 0); - if (p == MAP_FAILED) continue; -#if defined(__linux__) && defined(MADV_NOHUGEPAGE) - /* Each class touches its own slice; transparent huge pages would make - * every touched slice a 2 MiB resident minimum. */ - (void)madvise(p, span, MADV_NOHUGEPAGE); -#endif + void *p = scr_sa_map(span); + if (!p) continue; scr_sa.base = (uintptr_t)p; scr_sa.shift = shift; scr_sa.span = span; @@ -198,19 +237,21 @@ __attribute__((cold, noinline)) void *scr_sa_slow(size_t n, bool zero) { if (zero) memset(b, 0, n); return b; } +#endif + /* Carve from the slice, opening it or raising its limit as needed; + * fresh pages are zero. */ size_t sz = (size_t)(c + 1) * SCR_SA_STEP; - char *p = scr_sa.bump[c]; - if (scr_sa.lim[c] && (size_t)(scr_sa.lim[c] - p) >= sz) { + char *start = (char *)scr_sa.base + ((size_t)c << scr_sa.shift); + char *end = start + ((size_t)1 << scr_sa.shift); + char *p = scr_sa.lim[c] ? scr_sa.bump[c] : start; + char *lim = scr_sa.lim[c] ? scr_sa.lim[c] : start; + while ((size_t)(lim - p) < sz && lim < end) lim = scr_sa_extend(start, lim, end); + scr_sa.lim[c] = lim; + if ((size_t)(lim - p) >= sz) { scr_sa.bump[c] = p + sz; return p; } -#endif - if (!scr_sa.lim[c]) { /* open the class slice; fresh pages are zero */ - char *p = (char *)scr_sa.base + ((size_t)c << scr_sa.shift); - scr_sa.lim[c] = p + ((size_t)1 << scr_sa.shift); - scr_sa.bump[c] = p + (size_t)(c + 1) * SCR_SA_STEP; - return p; - } + scr_sa.bump[c] = p; } /* Large or zero-sized, an exhausted slice, or no reservation. */ return zero ? calloc(1, n) : malloc(n); diff --git a/packages/runtime/src/scr_alloc.test.c b/packages/runtime/src/scr_alloc.test.c index ea4960543..ae0c92370 100644 --- a/packages/runtime/src/scr_alloc.test.c +++ b/packages/runtime/src/scr_alloc.test.c @@ -98,6 +98,64 @@ static void check_churn(void) { } } +#if SCR_SMALL_ALLOC +#include +#ifdef __linux__ +/* The VmFlags line of the mapping that contains p, or "". */ +static void vm_flags(const void *p, char *out, size_t cap) { + out[0] = '\0'; + FILE *f = fopen("/proc/self/smaps", "r"); + assert(f); + char line[512]; + bool inside = false; + while (fgets(line, sizeof line, f)) { + unsigned long lo, hi; + if (sscanf(line, "%lx-%lx ", &lo, &hi) == 2 && strchr(line, '-') < strchr(line, ' ')) { + inside = (uintptr_t)p >= lo && (uintptr_t)p < hi; + } else if (inside && !strncmp(line, "VmFlags:", 8)) { + snprintf(out, cap, "%s", line + 8); + break; + } + } + fclose(f); +} +#endif + +/* A class slice carves contiguously from its start, across the point where + * its limit is raised; on Linux the slices are huge-page aligned, the first + * 8 MiB of a slice stay on small pages and the rest may use huge pages. */ +static void check_slice_growth(void) { + enum { SZ = 496, AFTER = 8 << 20 }; + unsigned char *first = scr_mem_alloc(SZ), *prev = first; + assert(owned(first)); + uintptr_t start = scr_sa.base + ((uintptr_t)((SZ - 1) / SCR_SA_STEP) << scr_sa.shift); + assert((uintptr_t)first == start); /* a class nothing else has touched */ + size_t blocks = AFTER / SZ + 64; + for (size_t i = 1; i < blocks; i++) { + unsigned char *p = scr_mem_alloc(SZ); + assert(p == prev + SZ); + p[0] = (unsigned char)i; /* commit the pages */ + prev = p; + } +#ifdef __linux__ + assert((scr_sa.base & ((2u << 20) - 1)) == 0); + FILE *thp = fopen("/sys/kernel/mm/transparent_hugepage/enabled", "r"); + if (thp) { /* a kernel without THP refuses both advices */ + fclose(thp); + char flags[256]; + vm_flags(first, flags, sizeof flags); + assert(strstr(flags, " nh") && !strstr(flags, " hg")); + vm_flags((void *)(start + AFTER), flags, sizeof flags); + assert(strstr(flags, " hg") && !strstr(flags, " nh")); + } +#endif + /* The blocks recycle through the free list like any others. */ + for (size_t i = blocks; i-- > 0;) scr_mem_free(first + i * SZ); + assert(scr_mem_alloc(SZ) == first); + scr_mem_free(first); +} +#endif + #if defined(SCR_WORKERS) && SCR_SMALL_ALLOC #include @@ -160,6 +218,9 @@ static void check_threads(void) { #endif int main(void) { +#if SCR_SMALL_ALLOC + check_slice_growth(); /* first: its class must be untouched */ +#endif check_alignment_and_zeroing(); check_realloc(); check_system_pointers(); diff --git a/packages/runtime/src/scr_alloc.test.ts b/packages/runtime/src/scr_alloc.test.ts index c3cf5467a..187ffce51 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 exposes the + // madvise advice the page policy 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"), From a7663551e3a8633f5b075ba086dc33227c108918 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 14:04:04 -0700 Subject: [PATCH 06/29] 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 07/29] 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 08/29] 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 09/29] 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 10/29] 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 73a99b23931034885468ac96cada3de8fa38c04d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 14:44:14 -0700 Subject: [PATCH 11/29] Let the cycle collector's growth fraction widen to 16 Mature and old passes are scheduled by heap growth. While growth-triggered passes at a level keep finding nothing, the fraction they wait for widens by doublings from 1/4, and it used to stop at 1, so a large live heap that keeps growing was walked in full once per doubling. On tsc-ts those walks are most of the collector's time: in single-threaded Excalidraw, 12 growth-triggered full passes cost 2.0 s of 14.7 s, the last two walking 2.6M and 5.2M objects to free 1.4%. The cap is now 16. That is a memory trade-off: once passes stop finding garbage, the cycle-headered heap may grow to 17 times its size after the last pass at a level before the next (it was 2 times), so a program that accumulates dead cyclic garbage while passes find nothing can hold up to 16 times more of it. Yield-driven narrowing is unchanged: a pass that finds old garbage moves the fraction straight back to what its yield asks for. SCR_CYCLE_GROWTH_CAP lowers the cap for memory-sensitive programs, read like SCR_CYCLE_THRESHOLD: a power of two from 1 to 16 (other positive values round down, larger ones read as 16); 1 restores the previous schedule. test_cycle pins the cap on a heap at the growth floor: a dead cycle through the old generation must go exactly when the heap has grown the configured cap past the floor (not one doubling earlier, not later), and a pass that finds old garbage must narrow the fraction at once. cycle.test.ts runs it at the default cap and at SCR_CYCLE_GROWTH_CAP=1, 3 and 64. The large-chain widening check keeps its walk bound but no longer waits out the cap on a 266k-node structure, and now runs after the tests that assume the base schedule. Measured with tsc-ts origin/parallel-check e469e33, built with this runtime vs the parent commit, interleaved in one Linux Vercel Sandbox (8 vCPU; 5 rounds, medians; --checkers 8 repeated with 10 rounds). Diagnostics identical. "cap 1" is this build with SCR_CYCLE_GROWTH_CAP=1: single-threaded wall RSS cap 1 Excalidraw -10.1% 1283 -> 1263 +0.5% Playwright -11.0% 1446 -> 1457 +0.3% --checkers 8 Excalidraw -8.7% / -4.5% 2110 -> 2132 / 2115 -> 2125 +2.6% (10 rounds) Playwright -7.8% / -7.7% 2616 -> 2634 / 2589 -> 2678 -1.0% (10 rounds) RSS in MB (max of two runs). The runtime benchmark suite's allocation-heavy workloads (alloc-trees, word-graph, ast-interp, json-records, records-sort, functional-pipeline; --layouts=4 --runs=16) are neutral in time and peak RSS (-0.3% to +1.7%). --- packages/runtime/src/scr_cycle.c | 53 +++++++--- packages/runtime/src/scr_runtime.h | 3 + packages/runtime/test/cycle.test.ts | 22 +++-- packages/runtime/test/test_cycle.c | 148 ++++++++++++++++++---------- 4 files changed, 156 insertions(+), 70 deletions(-) diff --git a/packages/runtime/src/scr_cycle.c b/packages/runtime/src/scr_cycle.c index 1377580e8..3171d258c 100644 --- a/packages/runtime/src/scr_cycle.c +++ b/packages/runtime/src/scr_cycle.c @@ -67,7 +67,8 @@ * whose condition holds: * full, growth the live cycle-headered heap is a fraction past its size * after the last full pass. The fraction starts at 1/4 and - * is scheduled by YIELD: it widens by doublings (up to 1) + * is scheduled by YIELD: it widens by doublings (up to the + * growth cap, 16 unless SCR_CYCLE_GROWTH_CAP lowers it) * as growth-triggered full passes reclaim fewer OLD objects * relative to the growth that triggered them, and narrows * back as soon as a pass finds old garbage again (see @@ -111,10 +112,11 @@ * pass walks every buffer without skipping any generation, so it leaves no * cyclic garbage behind except what its own teardowns release for the next * pass. Between two full passes the cycle-headered heap can grow by at most - * the old level's fraction, so it never exceeds twice its size after the - * last full pass (or the growth floor); the mature level bounds younger - * garbage the same way between mature passes. That cap is reached only after - * passes have stopped finding garbage. While they find it, a level widens its + * the old level's fraction, so it never exceeds 17 times its size after the + * last full pass (or the growth floor), or twice it with + * SCR_CYCLE_GROWTH_CAP=1; the mature level bounds younger garbage the same + * way between mature passes. That cap is reached only after passes have + * stopped finding garbage. While they find it, a level widens its * fraction only in inverse proportion to its yield, so if the yield holds, * the garbage floating by its next pass stays below an eighth of the heap * once widened, inside the quarter the base fraction allows. @@ -344,15 +346,43 @@ static SCR_TL size_t scr_cyc_live_after[SCR_CYC_NGENS]; /* Per level, doublings of the growth fraction (see scr_cyc_adapt_growth), * and the run of growth-triggered passes at that level whose yield asked for - * a wider fraction than the current one. Capped at a fraction of 1: the heap - * may at most double past its size after the last pass at a level before - * the next. Only the mature and old entries are used. */ -#define SCR_CYC_GROWTH_MAX_SHIFT 2 + * a wider fraction than the current one. Only the mature and old entries are + * used. + * + * The fraction is capped at 16 (shift 6): after passes at a level stop + * finding garbage, the heap may grow to 17 times its size after the last + * pass there before the next. A lower cap bounds dead cyclic garbage more + * tightly, but a large live heap that keeps growing is then walked in full + * each time it grows by the cap, and on such heaps (a compiler checking a + * large program, say) those walks dominate collection time. The cap trades + * that walking for memory only when passes find nothing: while they find + * garbage, the yield keeps the fraction narrow. SCR_CYCLE_GROWTH_CAP lowers + * the cap for memory-sensitive programs: a power of two from 1 to 16, with + * other positive values rounded down to a power of two and larger ones read + * as 16. At 1 the heap may at most double past its size after the last pass + * at a level before the next. */ +#define SCR_CYC_GROWTH_MAX_SHIFT 6 #ifndef SCR_WORKERS static SCR_TL unsigned char scr_cyc_growth_shift[SCR_CYC_NGENS]; static SCR_TL unsigned char scr_cyc_growth_streak[SCR_CYC_NGENS]; #endif +/* Shift of the configured growth cap: 2 + log2(cap), since the base + * fraction is 1/4. */ +static unsigned scr_cyc_growth_max_shift(void) { + static SCR_TL unsigned char cached = 0; + if (cached == 0) { + const char *env = scr_getenv("SCR_CYCLE_GROWTH_CAP"); + long v = env ? strtol(env, NULL, 10) : 0; + unsigned shift = SCR_CYC_GROWTH_MAX_SHIFT; + if (v > 0) + for (shift = 2; shift < SCR_CYC_GROWTH_MAX_SHIFT && (2L << (shift - 2)) <= v;) + shift++; + cached = (unsigned char)shift; + } + return cached; +} + /* Doublings of each generation's backlog threshold earned by consecutive * passes that found almost nothing to free (see scr_cyc_backlog_threshold). * Only the mature and old entries are used. */ @@ -467,9 +497,8 @@ static size_t scr_cyc_backlog_threshold(unsigned gen) { * it still younger, and a single miss says little. */ static void scr_cyc_adapt_growth(unsigned gen, size_t growth, size_t reclaimed) { - unsigned want = 0; - while (want < SCR_CYC_GROWTH_MAX_SHIFT - && reclaimed * SCR_CYC_GROWTH_DIV < (growth >> want)) + unsigned want = 0, max = scr_cyc_growth_max_shift(); + while (want < max && reclaimed * SCR_CYC_GROWTH_DIV < (growth >> want)) want++; if (want <= scr_cyc_growth_shift[gen]) { scr_cyc_growth_shift[gen] = (unsigned char)want; diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 363f89245..fd70268a6 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -374,6 +374,9 @@ char **scr_worker_argv(void); * exit (before the RC audit), event-loop quiescence, and the per-generation * triggers. * SCR_CYCLE_THRESHOLD pins the nursery trigger to a fixed candidate count. + * SCR_CYCLE_GROWTH_CAP lowers how far past its size the heap may grow + * between passes at an older level that find nothing (16 times its size by + * default; see scr_cycle.c). * There is no concurrent or incremental collection. * * Contract for trace/teardown pairs (the compiler emits them for shapes, diff --git a/packages/runtime/test/cycle.test.ts b/packages/runtime/test/cycle.test.ts index 9d2ff257d..580986c86 100644 --- a/packages/runtime/test/cycle.test.ts +++ b/packages/runtime/test/cycle.test.ts @@ -7,7 +7,7 @@ import { expect, test } from "vitest"; const execFileAsync = promisify(execFile); const testDir = import.meta.dirname; -test("cycle traversal preserves counts, deep graphs and configured thresholds", async () => { +test("cycle traversal preserves counts, deep graphs, configured thresholds and growth caps", async () => { const buildDir = join(testDir, "build"); await mkdir(buildDir, { recursive: true }); const bin = join(buildDir, "test_cycle"); @@ -29,16 +29,22 @@ test("cycle traversal preserves counts, deep graphs and configured thresholds", UBSAN_OPTIONS: "halt_on_error=1", }; delete baseEnv.SCR_CYCLE_THRESHOLD; + delete baseEnv.SCR_CYCLE_GROWTH_CAP; - for (const [configured, expected] of [ - [undefined, "256"], - ["1", "1"], - ["2", "2"], - ["7", "7"], + for (const [threshold, cap, expected] of [ + [undefined, undefined, "threshold=256 growth_cap=16"], + ["1", undefined, "threshold=1 growth_cap=16"], + ["2", undefined, "threshold=2 growth_cap=16"], + ["7", undefined, "threshold=7 growth_cap=16"], + [undefined, "1", "threshold=256 growth_cap=1"], + ["1", "1", "threshold=1 growth_cap=1"], + [undefined, "3", "threshold=256 growth_cap=2"], + [undefined, "64", "threshold=256 growth_cap=16"], ] as const) { const env = { ...baseEnv }; - if (configured !== undefined) env.SCR_CYCLE_THRESHOLD = configured; + if (threshold !== undefined) env.SCR_CYCLE_THRESHOLD = threshold; + if (cap !== undefined) env.SCR_CYCLE_GROWTH_CAP = cap; const run = await execFileAsync(bin, [], { env }); - expect(run.stdout).toBe(`cycle collection checks passed: threshold=${expected}\n`); + expect(run.stdout).toBe(`cycle collection checks passed: ${expected}\n`); } }); diff --git a/packages/runtime/test/test_cycle.c b/packages/runtime/test/test_cycle.c index 041c95caa..4de18b1d6 100644 --- a/packages/runtime/test/test_cycle.c +++ b/packages/runtime/test/test_cycle.c @@ -20,6 +20,18 @@ static size_t configured_nursery_threshold(void) { return value > 0 ? (size_t)value : 256; } +/* How many times its size after the last full pass the heap may grow before + * the next once passes stop finding garbage: SCR_CYCLE_GROWTH_CAP rounded + * down to a power of two from 1 to 16, and 16 when unset. */ +static size_t configured_growth_cap(void) { + const char *env = getenv("SCR_CYCLE_GROWTH_CAP"); + long value = env ? strtol(env, NULL, 10) : 0; + if (value <= 0) return 16; + size_t cap = 1; + while (cap < 16 && (long)(cap * 2) <= value) cap *= 2; + return cap; +} + static void check(bool condition, const char *message) { if (condition) return; fprintf(stderr, "cycle test failed: %s\n", message); @@ -359,20 +371,16 @@ static Node *grow_chain(Node *head, size_t count) { return head; } -/* A dead ring aged into the old generation, with no outside owner left. */ -static void make_dead_old_ring(size_t count) { - Node *ring = make_ring(count); - age_to_old(ring); - release_live(ring); -} - /* Full passes over a growing retained structure that find nothing widen the * growth fraction, so the structure is not re-walked at every quarter of its - * growth; old garbage still goes before the heap has doubled past its size - * after the last full pass, and garbage found in proportion to the growth - * narrows the schedule again. */ + * growth. How far the fraction may widen, and how a productive pass narrows + * it, are pinned on a heap at the growth floor by + * check_growth_cap_bounds_floating_garbage: on a structure this size the + * default cap lets a dead cycle wait through millions of allocations. Leaves + * the fraction widened, so it runs after the tests that assume the base + * schedule. */ static void check_growth_schedule_follows_yield(void) { - enum { START = 4096, GROW = 64 * START, RING = 64 }; + enum { START = 4096, GROW = 64 * START }; size_t before = freed; Node *head = grow_chain(make_leaf(), START); scr_collect_cycles(); /* a baseline the bounds below are measured from */ @@ -385,42 +393,6 @@ static void check_growth_schedule_follows_yield(void) { check(traced - walked_before <= 7 * (size_t)GROW, "a growing retained structure was re-walked at every fraction of growth"); - /* Widened to the cap: a dead old ring still goes within one doubling. */ - make_dead_old_ring(RING); - size_t live = scr_cyc_live; - size_t grown = 0; - while (freed < before + RING && grown <= 2 * live) { - head = grow_chain(head, 1); - grown++; - } - check(freed == before + RING, "old garbage was never reclaimed after widening"); - /* A pass waits for the release trigger, so allow one nursery's worth. */ - check(grown <= live + configured_nursery_threshold(), - "old garbage outlived the doubled growth bound"); - - /* Old garbage in proportion to the growth narrows the fraction back. */ - size_t big = scr_cyc_live / 2; - before = freed; - make_dead_old_ring(big); - live = scr_cyc_live; - grown = 0; - while (freed < before + big && grown <= 2 * live) { - head = grow_chain(head, 1); - grown++; - } - check(freed == before + big, "large old garbage was never reclaimed"); - before = freed; - make_dead_old_ring(RING); - live = scr_cyc_live; - grown = 0; - while (freed < before + RING && grown <= 2 * live) { - head = grow_chain(head, 1); - grown++; - } - check(freed == before + RING, "old garbage was never reclaimed after narrowing"); - check(grown <= live / 4 + configured_nursery_threshold(), - "a productive pass did not narrow the growth fraction"); - /* Close the chain into one dead cycle: the collector reclaims it whole. */ Node *tail = head; while (tail->next) tail = tail->next; @@ -433,6 +405,81 @@ static void check_growth_schedule_follows_yield(void) { check(freed == before + length, "the closed chain was not reclaimed whole"); } +/* Grow one dead cycle until a full pass reclaims it, and return the live + * heap at the release that ran that pass. The cycle's only old member is + * also its only candidate; the young nodes appended to it are never + * buffered, so only a full pass walks or frees them, and that pass reclaims + * one old object against all of the growth, plus a dead old ring of + * `old_garbage` objects when that is nonzero. Dead one-node rings keep the + * release path running restricted passes meanwhile. The explicit sweeps in + * age_to_old leave a live heap below the growth floor, so the full pass is + * due once the heap grows past the floor by the old level's fraction. */ +static size_t grow_dead_cycle_until_full_pass(size_t old_garbage) { + enum { FLOOR = 4096 }; + size_t limit = FLOOR * 18 + 4 * configured_nursery_threshold(); + Node *garbage = NULL; + if (old_garbage) { + garbage = make_ring(old_garbage); + age_to_old(garbage); + } + Node *old = make_leaf(); + age_to_old(old); + check(scr_cyc_live < FLOOR, "live heap did not start below the growth floor"); + if (garbage) release_live(garbage); /* last outside owner gone */ + Node *first = make_leaf(); /* owned by old->other */ + first->next = old; + old->rc++; + old->other = first; + release_live(old); /* external owner gone: a dead cycle with one old root */ + size_t old_before = scr_cyc_old_freed; + for (size_t appended = 1; appended < limit; appended++) { + Node *node = make_leaf(); /* takes over old's reference to the newest */ + node->next = old->other; + old->other = node; + Node *ring = make_ring(1); + size_t live = scr_cyc_live; + release_live(ring); + if (scr_cyc_old_freed != old_before) { + check(scr_cyc_old_freed == old_before + old_garbage + 1 + && scr_cyc_live < FLOOR / 2, + "the full pass did not reclaim the dead old garbage whole"); + return live; + } + } + check(false, "a dead cycle through the old generation outlived the growth cap"); + return 0; +} + +/* Passes that keep finding nothing widen the old level's fraction one + * doubling at a time up to the configured cap and no further: once there, a + * dead cycle goes exactly when the heap has grown the cap past the floor, + * not one doubling earlier (a narrower cap) and not later (a wider or + * missing one). A pass that then finds old garbage narrows the fraction at + * once. Runs last: it leaves the fraction narrowed but not at the base. */ +static void check_growth_cap_bounds_floating_garbage(void) { + enum { FLOOR = 4096 }; + size_t cap = configured_growth_cap(); + /* Passes wait for the release trigger, and dead rings float until the + * next restricted pass, so allow two nursery's worth. */ + size_t slack = 2 * configured_nursery_threshold() + 2; + size_t rounds = 2; /* two passes asking to widen before the first doubling */ + for (size_t c = cap; c >= 1; c /= 2) rounds++; /* one per doubling up to cap */ + for (size_t i = 0; i < rounds; i++) grow_dead_cycle_until_full_pass(0); + size_t due = FLOOR + FLOOR * cap; + size_t live = grow_dead_cycle_until_full_pass(0); + check(live > due, "the growth fraction stopped widening below the cap"); + check(live <= due + slack, "the growth fraction widened past the cap"); + + /* At any cap, old garbage of an eighth of the floor or more is a yield + * that asks for at least one doubling less (this ring asks for two below + * a cap of 2 or more), and narrowing does not wait for a second pass. */ + live = grow_dead_cycle_until_full_pass(FLOOR / 2 - 64); + check(live > due && live <= due + slack, "a capped pass came early or late"); + live = grow_dead_cycle_until_full_pass(0); + check(live <= FLOOR + FLOOR * cap / 2 + slack, + "a pass that found old garbage did not narrow the growth fraction"); +} + static void check_deep_ring(void) { enum { DEPTH = 100000 }; size_t before = freed; @@ -604,7 +651,6 @@ static void check_deferred_white_restoration(void) { int main(void) { check_old_garbage_reclaimed_while_growing(); - check_growth_schedule_follows_yield(); check_older_rings_into_old(); check_cycle_spanning_mature_and_old(); check_unproductive_backlog_backs_off(); @@ -620,7 +666,9 @@ int main(void) { check_cross_generation_edges(); check_deep_cross_generation_chain(); check_teardown_rebuffers_and_immortals(); - printf("cycle collection checks passed: threshold=%zu\n", - configured_nursery_threshold()); + check_growth_schedule_follows_yield(); + check_growth_cap_bounds_floating_garbage(); + printf("cycle collection checks passed: threshold=%zu growth_cap=%zu\n", + configured_nursery_threshold(), configured_growth_cap()); return 0; } From e52688095d4b62e6747bcec58a9dd4d908ce8ded Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 14:46:53 -0700 Subject: [PATCH 12/29] Convert missing number arguments with ToNumber String.fromCharCode, String.fromCodePoint, the global isNaN and isFinite, DataView offsets and values, and typed-array slice/subarray bounds took a `number | undefined` value (an out-of-range read the checker types as number) through a checked narrow, which threw "undefined is not representable" or refused to compile. Node converts undefined with ToNumber: code unit 0, a RangeError for fromCodePoint, isNaN true, offset 0; an undefined slice end is the length. The Math argument conversion becomes a shared helper that also handles field reads and any other value lowered as a number-or-undefined union. --- .../src/frontend/lowering/builtins/strings.ts | 5 +- .../src/frontend/lowering/containers/bytes.ts | 21 +++- .../src/frontend/lowering/lower-calls.ts | 16 +++- .../src/frontend/lowering/lower-exprs.ts | 27 ++++++ .../src/frontend/lowering/lower-island.ts | 27 +----- .../test/ts7/baselines/order-parity.json | 6 ++ .../corpus/missing-read-number-conversions.ts | 95 +++++++++++++++++++ 7 files changed, 169 insertions(+), 28 deletions(-) create mode 100644 tests/corpus/missing-read-number-conversions.ts diff --git a/packages/compiler/src/frontend/lowering/builtins/strings.ts b/packages/compiler/src/frontend/lowering/builtins/strings.ts index 57e650feb..b117c24ff 100644 --- a/packages/compiler/src/frontend/lowering/builtins/strings.ts +++ b/packages/compiler/src/frontend/lowering/builtins/strings.ts @@ -3,6 +3,7 @@ import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; import { locOf } from "../../program.js"; import { lowerOptionalArgument, lowerStringSearchArgument } from "../optional-arguments.js"; +import { lowerToNumberArgument } from "../lower-exprs.js"; import { F64, type IrExpr, STRING, arrayOf, isUnitType, typeEquals } from "../../../ir/ir.js"; /** `String.fromCharCode/fromCodePoint(...codes)` on THE String global: every argument @@ -97,7 +98,9 @@ export function lowerStringStaticCall( const packed = lowerer.lowerExprExpecting(spread.expression, arrayOf(F64)); return { kind: "libCall", fn: `string.${member}`, args: [packed], type: STRING, loc }; } - const elems = call.arguments.map((a) => lowerer.lowerExprExpecting(a, F64)); + // Each code converts with ToNumber: a missing value is NaN (code unit 0 + // for fromCharCode, a RangeError for fromCodePoint), exactly Node. + const elems = call.arguments.map((a) => lowerToNumberArgument(lowerer, a)); const packed: IrExpr = { kind: "arrayLit", elems, type: arrayOf(F64), loc }; return { kind: "libCall", fn: `string.${member}`, args: [packed], type: STRING, loc }; } diff --git a/packages/compiler/src/frontend/lowering/containers/bytes.ts b/packages/compiler/src/frontend/lowering/containers/bytes.ts index 5b8285f12..255165530 100644 --- a/packages/compiler/src/frontend/lowering/containers/bytes.ts +++ b/packages/compiler/src/frontend/lowering/containers/bytes.ts @@ -32,6 +32,7 @@ import { lowerStaticallyUndefinedArgument, } from "../optional-arguments.js"; import { lowerCheckedArrayFrom } from "./array-construction.js"; +import { lowerToNumberArgument } from "../lower-exprs.js"; /** Uint8Array.prototype.toSorted. The receiver/comparator expressions are * evaluated before entering the helper; the helper snapshots with @@ -486,7 +487,16 @@ export function lowerBytesMethodCall( lowerer.noLowering(`.${name} with ${nArgs} arguments on typed arrays`, call); } const receiver = lowerer.lowerExprExpecting(access.expression, receiverIr); - const args = call.arguments.map((a) => lowerer.lowerExprExpecting(a, F64)); + // A missing start is 0 and a missing end is the length (+Infinity + // clamps to it), as for omitted arguments. + const args = call.arguments.map((a, i) => + lowerOptionalArgument(lowerer, a, F64, { + kind: "numLit", + value: i === 0 ? 0 : Infinity, + type: F64, + loc, + }), + ); // subarray is a VIEW (TypedArray.prototype.subarray aliases), and // Buffer's slice() is subarray's deprecated Node alias — resolved by // where the member is declared, the toString discipline below. Only @@ -796,7 +806,8 @@ export function lowerBytesMethodCall( lowerer.noLowering(`.${name} with ${nArgs} arguments`, call); } const receiver = lowerer.lowerExprExpecting(access.expression, receiverIr); - const args = [lowerer.lowerExprExpecting(call.arguments[0]!, F64)]; + // ToIndex(undefined) is ToIndex(NaN): a missing offset reads offset 0. + const args = [lowerToNumberArgument(lowerer, call.arguments[0]!)]; if (nArgs === 2) args.push(lowerer.lowerExprExpecting(call.arguments[1]!, BOOL)); return { kind: "bytesIntrinsic", method: dvGetter.method, receiver, args, type: F64, loc }; } @@ -829,9 +840,11 @@ export function lowerBytesMethodCall( lowerer.noLowering(`.${name} with ${nArgs} arguments`, call); } const receiver = lowerer.lowerExprExpecting(access.expression, receiverIr); + // The offset (ToIndex) and the value (ToNumber) treat a missing value + // as NaN. const args = [ - lowerer.lowerExprExpecting(call.arguments[0]!, F64), - lowerer.lowerExprExpecting(call.arguments[1]!, F64), + lowerToNumberArgument(lowerer, call.arguments[0]!), + lowerToNumberArgument(lowerer, call.arguments[1]!), ]; if (nArgs === 3) args.push(lowerer.lowerExprExpecting(call.arguments[2]!, BOOL)); return { kind: "bytesIntrinsic", method: dvSetter.method, receiver, args, type: VOID, loc }; diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 6335a317e..7db085f22 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -107,8 +107,10 @@ import { lowerProcStreamMethodCall } from "./builtins/process.js"; import { lowerReflectCall } from "./builtins/reflection.js"; import { lowerAbsenceProbe, + lowerOptionalNumber, lowerPromiseAllTupleCall, lowerPromiseRejectCall, + lowerToNumberArgument, stringWrapperToString, symbolFieldInfo, templateRawTextOf, @@ -9520,7 +9522,8 @@ export function lowerStaticNumberGlobal( ); } if (name === "isNaN") { - const x = lowerer.lowerExprExpecting(args[0]!, F64); + // The global's ToNumber turns a missing value into NaN. + const x = lowerToNumberArgument(lowerer, args[0]!); return { kind: "libCall", fn: "num.isNaN", args: [x], type: BOOL, loc }; } const radix: IrExpr = args[1] @@ -9650,6 +9653,17 @@ export function lowerStaticNumberGlobal( if (name === "isFinite" && probed?.type.kind === "f64") { return { kind: "libCall", fn: "number.isFinite", args: [probed], type: BOOL, loc }; } + // A number the checker trusts but storage may hold missing (an + // out-of-range read): the global's ToNumber makes undefined NaN. + if ( + name === "isFinite" && + probed?.type.kind === "union" && + lowerer.armTag(probed.type.unionId, UNDEFINED_T) >= 0 && + lowerer.stripUndefinedArm(probed.type).kind === "f64" + ) { + const x = lowerOptionalNumber(lowerer, probed, loc, args[0]); + return { kind: "libCall", fn: "number.isFinite", args: [x], type: BOOL, loc }; + } } return null; } diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 677b29e67..8e3dab778 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -6487,6 +6487,33 @@ export function lowerOptionalNumber( : { kind: "seqExpr", stmts: prefix, result: value, type: F64, loc }; } +/** A number argument of a builtin that converts it with ToNumber (Math + * functions, String.fromCharCode, the global isNaN/isFinite, DataView + * offsets and values). ToNumber(undefined) is NaN, so a missing value — an + * out-of-range read, a local or field holding one, or a hole — converts to + * NaN instead of trusting the checker's number type. */ +export function lowerToNumberArgument(lowerer: Lowerer, node: ts.Expression): IrExpr { + let inner = node; + while (ts.isParenthesizedExpression(inner)) inner = inner.expression; + const loc = locOf(inner); + const isOptionalNumber = (value: IrExpr): boolean => + value.type.kind === "union" && + lowerer.armTag(value.type.unionId, UNDEFINED_T) >= 0 && + lowerer.stripUndefinedArm(value.type).kind === "f64"; + const optional = ts.isIdentifier(inner) + ? runtimeOptionalStorageOperand(lowerer, inner) + : ts.isElementAccessExpression(inner) + ? lowerAbsenceProbe(lowerer, inner) + : null; + if (optional && isOptionalNumber(optional)) + return lowerOptionalNumber(lowerer, optional, loc, inner); + // A conditional builds each branch for the destination. + if (ts.isConditionalExpression(inner)) return lowerer.lowerExprExpecting(node, F64); + const value = lowerer.lowerExpr(node); + if (isOptionalNumber(value)) return lowerOptionalNumber(lowerer, value, loc, inner); + return lowerer.coerceInto(node, value, F64); +} + /** `a[i]` reads. Only f64 indices into array receivers are modeled; JS * string-key element access (`a["length"]`) and string indexing (`s[0]` * typechecks against the lib's index signature; use .charAt) stay out. */ diff --git a/packages/compiler/src/frontend/lowering/lower-island.ts b/packages/compiler/src/frontend/lowering/lower-island.ts index 5f9301c76..88c2d34df 100644 --- a/packages/compiler/src/frontend/lowering/lower-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-island.ts @@ -5,11 +5,7 @@ import { InternalCompilerError } from "../../errors.js"; * surface (Math and number/string methods under --dynamic), and the npm * package boundary fences for node_modules-declared symbols. */ import * as ts from "../ts7/adapter.js"; -import { - lowerAbsenceProbe, - lowerOptionalNumber, - runtimeOptionalStorageOperand, -} from "./lower-exprs.js"; +import { lowerToNumberArgument } from "./lower-exprs.js"; import type { Lowerer } from "./lowerer.js"; import { arrayOf, @@ -3401,23 +3397,10 @@ export function lowerIslandMethodCall( // Static numeric Math calls precede the island path. The scalar methods // use their declared arity; min/max and hypot accept their variadic forms. const staticMath = isMath ? own(STATIC_MATH_FNS, name) : undefined; - // A missing element (a hole bound by a loop, a local holding a missing - // read, or the read itself) converts to NaN, ToNumber's answer for - // undefined, instead of trusting the checker's number type. - const mathArg = (a: ts.Expression): IrExpr => { - let inner = a; - while (ts.isParenthesizedExpression(inner)) inner = inner.expression; - const optional = ts.isIdentifier(inner) - ? runtimeOptionalStorageOperand(lowerer, inner) - : ts.isElementAccessExpression(inner) - ? lowerAbsenceProbe(lowerer, inner) - : null; - return optional && - optional.type.kind === "union" && - lowerer.stripUndefinedArm(optional.type).kind === "f64" - ? lowerOptionalNumber(lowerer, optional, loc, inner) - : lowerer.lowerExprExpecting(a, F64); - }; + // A missing element (a hole bound by a loop, a local or field holding a + // missing read, or the read itself) converts to NaN, ToNumber's answer + // for undefined, instead of trusting the checker's number type. + const mathArg = (a: ts.Expression): IrExpr => lowerToNumberArgument(lowerer, a); if (staticMath && name === "hypot") { const elems = call.arguments.map((a) => ts.isSpreadElement(a) ? lowerer.lowerExprExpecting(a.expression, arrayOf(F64)) : mathArg(a), diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 1c7788be6..c46f5d49e 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -10493,6 +10493,12 @@ ], "diags": [] }, + "/tests/corpus/missing-read-number-conversions.ts": { + "order": [ + "/tests/corpus/missing-read-number-conversions.ts" + ], + "diags": [] + }, "/tests/corpus/module-fallback-functions/main.js": { "order": [ "/tests/corpus/module-fallback-functions/platform.js", diff --git a/tests/corpus/missing-read-number-conversions.ts b/tests/corpus/missing-read-number-conversions.ts new file mode 100644 index 000000000..abbe50879 --- /dev/null +++ b/tests/corpus/missing-read-number-conversions.ts @@ -0,0 +1,95 @@ +// Builtins that convert a number argument with ToNumber see a missing +// read (an out-of-range typed-array or array read the checker types as +// `number`) as NaN, ToNumber's answer for undefined: String.fromCharCode +// yields code unit 0, fromCodePoint throws a RangeError, Math answers NaN, +// isNaN is true. The missing value reaches them from a module binding, a +// local, a class field and a record field. +const base: number = process.argv.length; +const chars = new Uint16Array(4); +chars[0] = 108; +chars[1] = 66; +const nums: number[] = [7]; +const far = base + 100; + +const missing = chars[far]; +const present = chars[1]; +const missingNum = nums[far]; + +function show(label: string, value: unknown): void { + console.log(label, typeof value === "string" ? JSON.stringify(value) : value); +} + +function attempt(label: string, run: () => unknown): void { + try { + show(label, run()); + } catch (e) { + console.log(label, "threw", (e as Error).name, (e as Error).message); + } +} + +// String.fromCharCode / fromCodePoint. +show("fcc module", String.fromCharCode(chars[0], missing)); +show("fcc single", String.fromCharCode(missing)); +show("fcc present", String.fromCharCode(chars[0], present)); +show("fcc array", String.fromCharCode(missingNum, 65)); +attempt("fcp module", () => String.fromCodePoint(missing)); +show("fcp present", String.fromCodePoint(present)); + +// Math functions. +show("abs", Math.abs(missing)); +show("floor", Math.floor(missingNum)); +show("max", Math.max(missing, 1)); +show("min3", Math.min(1, 2, missing)); +show("imul", Math.imul(missing, 3)); +show("pow", Math.pow(present, 2)); +show("hypot", Math.hypot(missing, 3)); + +// The global number predicates convert; the Number statics do not. +show("isNaN", isNaN(missing)); +show("isNaN present", isNaN(present)); +show("isFinite", isFinite(missing)); +show("isFinite present", isFinite(present)); +show("Number.isNaN", Number.isNaN(missing)); +show("Number.isFinite", Number.isFinite(missing)); + +// DataView offsets (ToIndex) and values (ToNumber). +const view = new DataView(new ArrayBuffer(4)); +view.setUint8(1, 9); +show("dv get", view.getUint8(missing)); +view.setUint8(missing, 5); +show("dv set offset", view.getUint8(0)); +view.setUint8(2, missing); +show("dv set value", view.getUint8(2)); + +// Typed-array ranges: a missing start is 0, a missing end is the length. +show("slice start", chars.slice(missing).length); +show("slice end", chars.slice(1, missing).length); +show("subarray start", chars.subarray(missing).length); +show("subarray end", chars.subarray(2, missing).length); + +// The same reads held in a local, a class field and a record field. +function local(i: number, label: string): void { + let code: number | undefined = 0; + code = chars[i]; + show(`local fcc ${label}`, String.fromCharCode(97, code)); + show(`local max ${label}`, Math.max(code, 1)); + show(`local isNaN ${label}`, isNaN(code)); +} +local(far, "missing"); +local(1, "present"); + +class Cursor { + code: number | undefined = 0; +} +const cursor = new Cursor(); +cursor.code = chars[far]; +show("field fcc", String.fromCharCode(97, cursor.code)); +show("field abs", Math.abs(cursor.code)); +cursor.code = chars[1]; +show("field fcc present", String.fromCharCode(97, cursor.code)); + +const record: { code: number | undefined } = { code: 0 }; +record.code = chars[far]; +show("record fcc", String.fromCharCode(97, record.code)); +show("record max", Math.max(record.code, 1)); +show("record isNaN", isNaN(record.code)); From 0027fa32265c5427833772b180e841dc91c0216c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 14:47:12 -0700 Subject: [PATCH 13/29] Keep undefined in narrowed optional fields and locals After `obj.field = bytes[i]` on a `number | undefined` field, TypeScript narrows later reads to number, so the read extracted the number arm unchecked and an out-of-range read printed 0 instead of undefined. Locals declared `T | undefined` had the same problem inside functions (`let x: number | undefined; x = xs[i]; x + 1` gave 1, not NaN). The optional-read analysis now records instance fields declared exactly `T | undefined` that receive a possibly missing value; their narrowed reads keep the stored union, as runtime-optional record fields already do, and the value propagates into parameters and returns like other optional reads. Locals declared that way join the runtime-optional storage tracking, so bare reads keep the union until a guard proves presence. A member read through such a field throws Node's "Cannot read properties of undefined" TypeError. --- .../src/frontend/lowering/lower-exprs.ts | 31 +++++++ .../compiler/src/frontend/lowering/lowerer.ts | 46 +++++++++- .../test/ts7/baselines/order-parity.json | 6 ++ tests/corpus/missing-read-narrowed-storage.ts | 88 +++++++++++++++++++ 4 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 tests/corpus/missing-read-narrowed-storage.ts diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 8e3dab778..2bae9c82e 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -2595,6 +2595,10 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { lowerer.isRuntimeOptionalField(handled.shapeId, handled.field) ) return handled; + // A class field assigned a missing read: the checker narrows this + // read to the assigned value's bare type, but the stored undefined + // arm is real (`obj.n = bytes[i]; obj.n` is undefined in Node). + if (runtimeOptionalClassField(lowerer, handled, expr)) return handled; return lowerer.maybeNarrow(handled, expr); } // `globalThis.` that no lowering above claimed @@ -7622,6 +7626,24 @@ export function runtimeOptionalStorageOperand( return optional.value; } +/** A `T | undefined` class field read that the checker narrowed to `T` + * where the field was assigned a value that may be missing (an unchecked + * indexed read). TypeScript's assignment narrowing is not a runtime fact + * there, so the read keeps the stored union instead of extracting `T`. */ +function runtimeOptionalClassField( + lowerer: Lowerer, + value: IrExpr, + node: ts.PropertyAccessExpression, +): boolean { + if (value.kind !== "fieldGet" || value.type.kind !== "union") return false; + if (lowerer.armTag(value.type.unionId, UNDEFINED_T) < 0) return false; + if ((lowerer.unions.get(value.type.unionId)?.arms.length ?? 0) !== 2) return false; + const symbol = lowerer.checker.getSymbolAtLocation(node.name); + if (!symbol || !lowerer.runtimeOptionalAssignedSymbols.has(symbol)) return false; + const narrowed = lowerer.mapTypeOf(lowerer.typeOf(node)); + return narrowed !== null && typeEquals(narrowed, lowerer.stripUndefinedArm(value.type)); +} + /** A presence-test operand: an absence-aware element or field read, an * identifier's runtime-optional storage, or the ordinary lowering. */ export function lowerPresenceOperand(lowerer: Lowerer, node: ts.Expression): IrExpr { @@ -14407,6 +14429,15 @@ function classFieldTarget( const lowerObjectReceiver = (): IrExpr => { let obj = represented; if (obj.type.kind === "union") { + // A receiver that may hold a missing value (a field assigned an + // unchecked read): Node's member-read TypeError for the unit arm. + const present = lowerer.runtimeOptionalPropertyReceiver( + receiverNode, + obj, + receiverType, + fieldName, + ); + if (present) return present; const helper = lowerer.narrowedArmHelper(obj.type.unionId, receiverType, locOf(receiverNode)); obj = helper ? lowerer.checkedNarrowCall(helper, obj, receiverType, locOf(receiverNode)) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 9cea42ed9..c777d3ab7 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -4223,6 +4223,23 @@ export class Lowerer { if (!symbol) return false; return noteFieldSymbol(symbol, name); }; + /** An instance field of a TypeScript class whose declared storage is + * exactly `T | undefined`: an assignment from a missing read stores the + * undefined arm while the checker narrows later reads to `T`. */ + const optionalInstanceField = (symbol: ts.Symbol): boolean => { + const decl = this.checker.valueDeclarationOf(symbol); + if (!decl || !ts.isPropertyDeclaration(decl) || !ts.isIdentifier(decl.name)) return false; + if (ts.getCombinedModifierFlags(decl) & ts.ModifierFlags.Static) return false; + const sf = decl.getSourceFile(); + if (isJsSourceFile(sf) || sf.isDeclarationFile) return false; + const declared = typeOrNull(decl.name); + const mapped = declared ? this.mapTypeOf(declared) : null; + if (mapped?.kind !== "union" || this.armTag(mapped.unionId, UNDEFINED_T) < 0) return false; + const arms = this.unions.get(mapped.unionId)?.arms ?? []; + return ( + arms.length === 2 && arms.every((arm) => arm.kind === "undefinedT" || !isUnitType(arm)) + ); + }; const noteFieldSymbol = (symbol: ts.Symbol, name: string): boolean => { const set = optionalFields.get(symbol) ?? new Set(); const before = set.size; @@ -5049,6 +5066,13 @@ export class Lowerer { optionalSymbols.add(field); provenance?.note("static-field", node.left, node.right); changed = true; + } else if (field && !optionalSymbols.has(field) && optionalInstanceField(field)) { + // The checker narrows the field to the assigned read's bare + // type, but the stored value can be undefined: reads keep the + // stored union (runtimeOptionalClassField). + optionalSymbols.add(field); + provenance?.note("assigned-field", node.left, node.right); + changed = true; } else if (ts.isIdentifier(node.left.expression)) { const symbol = symbolOf(node.left.expression); if (symbol && noteFieldSymbol(symbol, node.left.name.text)) { @@ -9591,14 +9615,32 @@ export class Lowerer { /* ── scoping and captures ─────────────────────────────────────────── */ declareLocal(nameNode: ts.Node, name: string, type: IrType, mutable: boolean): IrLocal { - return declareContextLocal( + const symbol = this.checker.getSymbolAtLocation(nameNode); + const local = declareContextLocal( this.ctx, name, type, mutable, - this.checker.getSymbolAtLocation(nameNode), + symbol, bindingSource(nameNode), ); + // A binding declared `T | undefined` that receives a missing read: the + // checker narrows it to `T` after the assignment, but the stored value + // can be undefined. Track it like widened storage so bare reads keep + // the union until a guard proves presence. + if ( + symbol && + this.runtimeOptionalAssignedSymbols.has(symbol) && + type.kind === "union" && + this.armTag(type.unionId, UNDEFINED_T) >= 0 && + this.unions.get(type.unionId)?.arms.length === 2 && + !isUnitType(this.stripUndefinedArm(type)) + ) { + const root = this.runtimeOptionalRootOf(local); + this.runtimeOptionalStorageLocals.add(root); + this.runtimeOptionalLocals.add(root); + } + return local; } /** A never-returning call in a value position of type `expected`: the diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index c46f5d49e..b95c5567e 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -10493,6 +10493,12 @@ ], "diags": [] }, + "/tests/corpus/missing-read-narrowed-storage.ts": { + "order": [ + "/tests/corpus/missing-read-narrowed-storage.ts" + ], + "diags": [] + }, "/tests/corpus/missing-read-number-conversions.ts": { "order": [ "/tests/corpus/missing-read-number-conversions.ts" diff --git a/tests/corpus/missing-read-narrowed-storage.ts b/tests/corpus/missing-read-narrowed-storage.ts new file mode 100644 index 000000000..97be7214a --- /dev/null +++ b/tests/corpus/missing-read-narrowed-storage.ts @@ -0,0 +1,88 @@ +// A `T | undefined` slot assigned a missing read (an out-of-range +// typed-array or array read the checker types as `T`) holds undefined, +// even though TypeScript narrows later reads to `T`. Reads keep the stored +// value: printing shows undefined, arithmetic gives NaN, and a member read +// throws Node's TypeError. Class fields (through `obj.f` and `this.f`) and +// locals both follow it; a presence guard still narrows. +const base: number = process.argv.length; +const chars = new Uint16Array([5, 6]); +const nums: number[] = [7]; +const far = base + 100; + +function twice(n: number): number { + return n * 2; +} + +class Cursor { + token: number | undefined = 0; + peek(i: number): number { + return chars[i]; + } + advance(i: number): string { + this.token = chars[i]; + return `${this.token} ${this.token === undefined} ${this.token + 1}`; + } +} + +function fields(i: number, label: string): void { + const cursor = new Cursor(); + cursor.token = chars[i]; + console.log(label, "direct", cursor.token, cursor.token === undefined); + console.log(label, "ops", cursor.token + 1, -cursor.token, cursor.token < 3, `${cursor.token}`); + console.log(label, "defaults", cursor.token ?? "none", cursor.token || "falsy"); + console.log(label, "call", twice(cursor.token), [cursor.token]); + cursor.token = cursor.peek(i); + console.log(label, "method", cursor.token, typeof cursor.token); + cursor.token = nums[i]; + console.log(label, "array", cursor.token, String(cursor.token)); + console.log(label, "this", cursor.advance(i)); + if (cursor.token !== undefined) console.log(label, "guarded", cursor.token * 2); + else console.log(label, "guarded absent"); +} +fields(far, "missing"); +fields(0, "present"); + +function locals(i: number, label: string): void { + let code: number | undefined = 0; + code = chars[i]; + console.log(label, "local", code, code + 1, `${code}`, [code], twice(code)); + let late: number | undefined; + late = nums[i]; + console.log(label, "late", late, late === undefined, late ?? "none"); + let branch: number | undefined = 1; + if (base > 0) branch = chars[i]; + console.log(label, "branch", branch, typeof branch); + if (code !== undefined) console.log(label, "local guarded", code * 3); + const viaExpr = (code = chars[i]); + console.log(label, "assign expr", viaExpr, code); +} +locals(far, "missing"); +locals(1, "present"); + +class Node2 { + v: number; + constructor(v: number) { + this.v = v; + } +} +const pool: Node2[] = [new Node2(4)]; + +class Holder { + node: Node2 | undefined = undefined; +} + +function objects(i: number, label: string): void { + const holder = new Holder(); + holder.node = pool[i]; + console.log(label, "object field", holder.node === undefined); + try { + console.log(label, "object member", holder.node.v); + } catch (e) { + console.log(label, "object member threw", (e as Error).name, (e as Error).message); + } + let current: Node2 | undefined = new Node2(1); + current = pool[i]; + console.log(label, "object local", current === undefined); +} +objects(far, "missing"); +objects(0, "present"); From bf17c23af99f461444c15c1231f9876d3241f4bb Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 14:47:17 -0700 Subject: [PATCH 14/29] Return defaulted and missing reads from TypeScript lambdas TypeScript types `read ?? "text"` as number when the read is typed number, and `() => xs[i]` as returning number. Declared functions already widened their returns for both (the primitive result union and the optional read), but arrow functions and function expressions kept the checker's return, so the closure narrowed its result and threw "a 'string' value is not representable" or "undefined is not representable" (the scanbox `[string, () => unknown][]` probe table). Unannotated TypeScript lambdas now get the same widened return as declared functions. A const or global binding of such a lambda keeps the checker's parameters and takes the widened result, and calls through the binding count as optional or primitive-union results for the enclosing function's return. --- .../src/frontend/lowering/lower-stmts.ts | 12 +++ .../compiler/src/frontend/lowering/lowerer.ts | 73 +++++++++++++++++- .../test/ts7/baselines/order-parity.json | 6 ++ tests/corpus/missing-read-lambda-results.ts | 74 +++++++++++++++++++ 4 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 tests/corpus/missing-read-lambda-results.ts diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index ab34a1eb5..266a79d81 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -4901,6 +4901,18 @@ export function lowerVarDecl( init.type.kind === "func" ) type = init.type; + // A TypeScript lambda whose closure returns the runtime result (a missing + // read, a `??` default of another type) instead of the checker's bare + // return type: the binding keeps the checker's parameters and takes the + // closure's result. + if ( + !decl.type && + type?.kind === "func" && + init.type.kind === "func" && + (ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer)) && + lowerer.runtimeOptionalFunctionReturns.has(decl.initializer) + ) + type = { ...type, ret: init.type.ret }; if ( lowerer.implicitParamTypes !== null && init.type.kind === "dyn" && diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index c777d3ab7..184ffc8a4 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -4370,12 +4370,29 @@ export class Lowerer { return found; }; const jsLambdas: ts.FunctionLikeDeclaration[] = []; + // TypeScript lambdas keep the checker's inferred return type, which + // spells a missing read as its bare element type and `read ?? "text"` + // as a number. Their closures return the runtime result instead. + const tsLambdas: ts.FunctionLikeDeclaration[] = []; for (const sf of sourceFiles) { - if (!isJsSourceFile(sf)) continue; + const js = isJsSourceFile(sf); + if (!js && sf.isDeclarationFile) continue; ts.walkPreorder(sf, (node) => { - if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) jsLambdas.push(node); + if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) + (js ? jsLambdas : tsLambdas).push(node); }); } + /** The primitive result union of a lambda's returns (see + * optionalPrimitiveResultType), as declared functions record it. */ + const lambdaArithmeticReturn = (fn: ts.FunctionLikeDeclaration): IrType | null => { + if (fn.body && !ts.isBlock(fn.body)) + return optionalPrimitiveResultType(fn.body as ts.Expression); + for (const expression of returnsOf(fn)) { + const result = optionalPrimitiveResultType(expression); + if (result) return result; + } + return null; + }; for (const [symbol, signature] of signatureBySymbol) { const declaration = functionDeclBySymbol.get(symbol); if (!declaration || !isJsSourceFile(declaration.getSourceFile())) continue; @@ -5407,6 +5424,54 @@ export class Lowerer { for (const sf of sourceFiles) if (scanFile(sf)) changed = true; for (const [symbol, decl] of functionDeclBySymbol) if (scanReturns(symbol, decl)) changed = true; + for (const fn of tsLambdas) { + // An annotated return is the author's contract, as for parameters. + if (fn.type) continue; + const mapped = this.mapTypeOf(this.typeOf(fn)); + if (mapped?.kind !== "func") continue; + const previous = this.runtimeOptionalFunctionReturnType(fn, mapped.ret); + // Async, generator and void bodies keep their own result shapes. + if ( + previous.kind === "promise" || + previous.kind === "generator" || + previous.kind === "void" + ) + continue; + const arithmetic = lambdaArithmeticReturn(fn); + const next = + arithmetic && + (previous.kind === "f64" || previous.kind === "string" || previous.kind === "bool") + ? arithmetic + : bodyReturnsOptional(fn) + ? this.runtimeOptionalType(previous) + : previous; + if (!typeEquals(previous, next)) { + this.runtimeOptionalFunctionReturns.set(fn, next); + provenance?.note("lambda-return", fn, fn.body); + changed = true; + } + // Calls through the lambda's own binding (`const get = () => xs[i]; + // get()`) produce the same result as calls of a declared function. + const binding = fn.parent; + if ( + next !== previous && + ts.isVariableDeclaration(binding) && + binding.initializer === fn && + !binding.type && + ts.isIdentifier(binding.name) + ) { + const symbol = symbolOf(binding.name); + if (symbol && next.kind === "union" && this.armTag(next.unionId, UNDEFINED_T) >= 0) { + if (!optionalReturns.has(symbol)) { + optionalReturns.add(symbol); + changed = true; + } + } else if (symbol && !arithmeticReturns.has(symbol)) { + arithmeticReturns.set(symbol, next); + changed = true; + } + } + } for (const fn of jsLambdas) { if (!bodyReturnsOptional(fn)) continue; const mapped = this.mapTypeOf(this.typeOf(fn)); @@ -5517,11 +5582,11 @@ export class Lowerer { const symbol = symbolOf(node.name); if (!symbol) return; if ( - isJsSourceFile(sf) && !node.type && !hasJsTypeAnnotation(node) && node.initializer && - (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)) + (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)) && + (isJsSourceFile(sf) || this.runtimeOptionalFunctionReturns.has(node.initializer)) ) { const global = this.globalsBySymbol.get(symbol); if (global?.type.kind === "func") diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index b95c5567e..9f0b46a52 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -10493,6 +10493,12 @@ ], "diags": [] }, + "/tests/corpus/missing-read-lambda-results.ts": { + "order": [ + "/tests/corpus/missing-read-lambda-results.ts" + ], + "diags": [] + }, "/tests/corpus/missing-read-narrowed-storage.ts": { "order": [ "/tests/corpus/missing-read-narrowed-storage.ts" diff --git a/tests/corpus/missing-read-lambda-results.ts b/tests/corpus/missing-read-lambda-results.ts new file mode 100644 index 000000000..0d9f047d2 --- /dev/null +++ b/tests/corpus/missing-read-lambda-results.ts @@ -0,0 +1,74 @@ +// A missing read (an out-of-range typed-array or array read the checker +// types as `number`) defaulted with `??` or `||` to a string, or returned +// as is, gives a lambda a result its inferred return type does not spell: +// TypeScript types `read ?? "text"` and `() => xs[i]` as number. The +// closure returns the runtime value, through bindings, callbacks taking +// `unknown`, calls of the binding, and the enclosing function's return. +const base: number = process.argv.length; +const chars = new Uint16Array(4); +chars[0] = 108; +const nums: number[] = [7]; +const far = base + 100; +const a = chars[far]; +const c0 = chars[0]; + +// The scanbox probe table: every arrow is typed `() => number` by +// inference and stored in a `() => unknown` slot. +const probes: [string, () => unknown][] = [ + ["plus", () => a + 1], + ["or", () => a | 0], + ["max", () => Math.max(a, 1)], + ["fcc", () => JSON.stringify(String.fromCharCode(c0, a))], + ["and", () => c0 && 5], + ["or2", () => chars[far] || 7], + ["nullish", () => a ?? "dflt"], + ["nullish present", () => c0 ?? "dflt"], + ["tern", () => (a ? "yes" : "no")], + ["bang", () => !a], + ["concat", () => a + "s"], + ["missing", () => nums[far]], +]; +for (const [name, f] of probes) { + try { + console.log(name, f()); + } catch (e) { + console.log(name, "threw", (e as Error).message); + } +} + +function take(f: () => unknown): unknown { + return f(); +} +console.log("take nullish", take(() => a ?? "d1")); +console.log("take missing", take(() => nums[far])); + +const bound = () => a ?? "d2"; +console.log("bound", bound()); +const block = () => { + return a ?? "d3"; +}; +console.log("block", block()); +const expression = function () { + return a || "d4"; +}; +console.log("expression", expression()); +const read = () => nums[far]; +console.log("read", read(), read() === undefined); + +function viaBinding(i: number): number { + const get = () => nums[i]; + return get(); +} +console.log("via binding", viaBinding(far), viaBinding(0)); + +function declared() { + return a ?? "d5"; +} +console.log("declared", declared()); + +class Lexer { + peek(i: number) { + return chars[i] ?? "eof"; + } +} +console.log("method", new Lexer().peek(far), new Lexer().peek(0)); From 67f9de094c983e7797e25b2b271fbbe372bf8f97 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 14:47:55 -0700 Subject: [PATCH 15/29] 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 cc7c6cb7302aefaf4cdf6d0304ada9b01421551c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 15:11:19 -0700 Subject: [PATCH 16/29] Decide inline truncation for the host when no triple is given The Node-hosted CLI emits with an empty target triple (the host), while the native CLI passes its toolchain's triple. Inline truncation keyed on the triple text alone, so on x86-64 Linux the Node seed emitted llvm.trunc and the native compiler emitted the inline sequence, and the native bootstrap's LLVM comparison with the Node seed failed (self-hosting-native-driver, rebuild-emit). An empty triple now means the host architecture, as it already does for executable TLS; WASI keeps the intrinsic. The DataView emission test accepts either truncation form, since it runs on hosts of both kinds. --- packages/compiler/src/backend/llvm/emitter.ts | 9 ++++++++- packages/compiler/test/byte-number-emission.test.ts | 3 ++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 8ce49a03d..a15782b31 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -811,7 +811,14 @@ export class LlEmitter { this.ffiExtendNarrowIntegers = options.wasi === true || ffiExtendsNarrowIntegers(options.targetTriple); this.wasi = options.wasi === true; - this.inlineTrunc = /^(x86_64|amd64)\b/i.test(options.targetTriple ?? ""); + // An empty triple targets the host, as for executableTls below: the + // Node-hosted CLI passes none, the native CLI passes its toolchain's, and + // both must emit the same module for the same target. + this.inlineTrunc = + !this.wasi && + (options.targetTriple + ? /^(x86_64|amd64)\b/i.test(options.targetTriple) + : process.arch === "x64"); this.executableTls = mod.workers === true && mod.lib === undefined && diff --git a/packages/compiler/test/byte-number-emission.test.ts b/packages/compiler/test/byte-number-emission.test.ts index 18c41814e..fc36fd23d 100644 --- a/packages/compiler/test/byte-number-emission.test.ts +++ b/packages/compiler/test/byte-number-emission.test.ts @@ -45,7 +45,8 @@ test("numeric byte pipelines retain checked fallbacks and use field widths on bo expect(wide).toContain("phi i64"); expect(wide).toContain("@llvm.bswap.i64"); const offsets = body(ll, "offsets"); - expect(offsets).toContain("@llvm.trunc.f64"); + // Truncation is the intrinsic, or inline on an x86-64 host (trunc.ts). + expect(offsets).toMatch(/@llvm\.trunc\.f64|@llvm\.copysign\.f64/); expect(offsets).toContain("@scr_dataview_set"); expect(offsets).toContain("@scr_dataview_get"); expect(offsets).toMatch(/icmp ule i(?:32|64)/); From 44f4d0bf1936ca52e9d83868ad210ee34cbbef9b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 15:25:49 -0700 Subject: [PATCH 17/29] 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 18/29] 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 19/29] 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 20/29] 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 21/29] 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 22/29] 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 23/29] 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 24/29] 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 25/29] 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 26/29] 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 27/29] 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 28/29] 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 29/29] 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;