From 7d08a7441c96750a4ac350edc2f6d2222b15a020 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 10:23:22 -0700 Subject: [PATCH 1/5] 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 07f0142bd1..d12ef08b9e 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 e7ae6a9345..8ce49a03d3 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 2d64ae788c..21fd1ed32f 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 5a70a55a83..674e3e543f 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 0000000000..6d7b886aab --- /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 80438dbf05..a682e0401f 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 28ef1f4316..f7ac004594 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 0000000000..5f46097e51 --- /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 2/5] 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 b625b41d73..677b29e676 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 20ba4b9a70..9cea42ed9e 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 0000000000..72bce33c5c --- /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 0000000000..5820e8f3dc --- /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 7bf630618f..1c7788be61 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 0000000000..02b53cd4f0 --- /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 3/5] 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 674e3e543f..41d93317b8 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 4/5] 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 72bce33c5c..6f6a0306df 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 cc7c6cb7302aefaf4cdf6d0304ada9b01421551c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 15:11:19 -0700 Subject: [PATCH 5/5] 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 8ce49a03d3..a15782b31f 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 18c41814e9..fc36fd23de 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)/);