From c7328dd3fd91666e9e548b253e8a93ab99c1ff01 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 23 Sep 2026 23:17:54 -0500 Subject: [PATCH] perf: avoid boxing numeric array reads in arithmetic --- .../src/frontend/lowering/lower-containers.ts | 48 ++++++++++----- .../src/frontend/lowering/lower-exprs.ts | 6 +- .../compiler/test/numeric-array-reads.test.ts | 46 +++++++++++++++ .../corpus/2966-numeric-array-read-fusion.ts | 58 +++++++++++++++++++ 4 files changed, 144 insertions(+), 14 deletions(-) create mode 100644 packages/compiler/test/numeric-array-reads.test.ts create mode 100644 tests/corpus/2966-numeric-array-read-fusion.ts diff --git a/packages/compiler/src/frontend/lowering/lower-containers.ts b/packages/compiler/src/frontend/lowering/lower-containers.ts index 4dc351594..4cbae73ed 100644 --- a/packages/compiler/src/frontend/lowering/lower-containers.ts +++ b/packages/compiler/src/frontend/lowering/lower-containers.ts @@ -1427,28 +1427,25 @@ function arrayUnionHofHelper( return { kind: "call", callee: helper, args: [snapshot, fnArg], type: arrayOf(outElem), loc }; } -/** An OOB-SAFE indexed read: `xs[i]` - * answers the interned `elem | undefined` union — the element when `i` - * is an integer in [0, len), JS's property-miss undefined otherwise — - * instead of the trap divergence 4 documents for program code. - * Package JS is inference-typed, guard-style code (`registeredArguments - * .slice(-1)[0]`, commander's last-element probe), and the trap would - * fire on working Node idioms; program files keep the documented trap - * (their annotations can prove bounds). Existing element unions retag - * into the canonical union with an added undefined arm. */ +/** An ordinary indexed read: values retain their element type, while + * holes, missing properties, and present undefined yield undefined. + * Numeric consumption may instead return the scalar ToNumber result, + * avoiding a temporary union without changing the array's storage. */ export function lowerSafeIndexRead( lowerer: Lowerer, arr: IrExpr & { type: { kind: "array" } }, index: IrExpr, loc: SrcLoc, + numeric = false, ): IrExpr | null { const elem = arr.type.elem; if (elem.kind === "void" || elem.kind === "dyn") return null; - const resultT = arrayValueType(lowerer, elem); - const key = `idxOr:${typeKey(elem)}`; + if (numeric && elem.kind !== "f64") throw new InternalCompilerError("numeric index read requires f64 storage"); + const resultT = numeric ? F64 : arrayValueType(lowerer, elem); + const key = `${numeric ? "idxNumber" : "idxOr"}:${typeKey(elem)}`; let name = lowerer.arrHofHelpers.get(key); if (!name) { - name = `%arr.idxOr.${lowerer.arrHofHelpers.size}`; + name = `%arr.${numeric ? "idxNumber" : "idxOr"}.${lowerer.arrHofHelpers.size}`; lowerer.arrHofHelpers.set(key, name); const arrT = arr.type; lowerer.liftedFns.push({ @@ -1462,13 +1459,38 @@ function arrayUnionHofHelper( { id: "a.0", name: "a", type: arrT, mutable: false }, { id: "i.0", name: "i", type: F64, mutable: false }, ], - body: [{ kind: "return", value: arrayValueRead(lowerer, varRef("a.0", arrT, loc), varRef("i.0", F64, loc), elem, loc), loc }], + body: [{ + kind: "return", + value: numeric ? { + kind: "ternary", + cond: { + kind: "bin", op: "===", + left: { kind: "arrayState", arr: varRef("a.0", arrT, loc), index: varRef("i.0", F64, loc), type: F64, loc }, + right: numLit(1, loc), type: BOOL, loc, + }, + then: { kind: "arrayGet", arr: varRef("a.0", arrT, loc), index: varRef("i.0", F64, loc), type: F64, loc }, + else_: { kind: "bin", op: "/", left: numLit(0, loc), right: numLit(0, loc), type: F64, loc }, + type: F64, loc, + } : arrayValueRead(lowerer, varRef("a.0", arrT, loc), varRef("i.0", F64, loc), elem, loc), + loc, + }], loc, }); } return { kind: "call", callee: name, args: [arr, index], type: resultT, loc }; } +/** Fuse only our own f64-array read helper with immediate ToNumber. + * Reuse its arguments verbatim so the receiver and index still evaluate + * exactly once, in order, before the read. Do not specialize user calls, + * union-element arrays, or optional values stored in locals. */ +export function tryLowerNumericIndexRead(lowerer: Lowerer, operand: IrExpr, loc: SrcLoc): IrExpr | null { + if (operand.kind !== "call" || operand.callee !== lowerer.arrHofHelpers.get(`idxOr:${typeKey(F64)}`)) return null; + const [arr, index] = operand.args; + if (operand.args.length !== 2 || arr?.type.kind !== "array" || arr.type.elem.kind !== "f64" || index?.type.kind !== "f64") return null; + return lowerSafeIndexRead(lowerer, arr as IrExpr & { type: { kind: "array" } }, index, loc, true); +} + /** Backward-compatible name for the npm-static probe path. */ export const lowerNpmStaticSafeIndexRead = lowerSafeIndexRead; diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 890fea30d..bcc8c088a 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -15,7 +15,7 @@ import { cjsClassExprWholeExportOf, cjsExportAssignmentOf, cjsExportDiscardReaso import { ARRAY_METHODS, builtinConstLit, builtinFenceHintOf, builtinModuleConstOf, builtinModulesArrayLit, builtinModuleFnOf, COMPOUND_ASSIGN_OPS, CompoundOp, ISLAND_SURFACE, isChildSurfaceMember, MAP_METHODS, NARROW_FIRST, SET_METHODS, STR_METHODS, UNSUPPORTED_EXPR, sideEffectFreeOptionValue, stdlibGlobalNameOf } from "./surfaces.js"; import { UNSUPPORTED, blockedBindingUseDiag, requiresDynamicPackageDiag, unsupportedDiag } from "../../diagnostics/diagnostic.js"; import { PoisonError, dynUndefinedExpr, jsFuncNameOf, neverTaintedJsType, nodeThrowExpr, own } from "./lowerer.js"; -import { lowerNpmStaticSafeIndexRead, lowerSafeIndexRead, strCharsCall } from "./lower-containers.js"; +import { lowerNpmStaticSafeIndexRead, lowerSafeIndexRead, strCharsCall, tryLowerNumericIndexRead } from "./lower-containers.js"; import { arrayValueRead, arrayValueStore } from "./array-values.js"; import { npmStaticPackageOfPath } from "../npm-static.js"; import { unsupportedModuleFeatureOf } from "../builtin-modules.js"; @@ -4066,6 +4066,10 @@ export function lowerOptionalNumber( ): IrExpr { if (operand.type.kind !== "union" || lowerer.armTag(operand.type.unionId, UNDEFINED_T) < 0) return operand; const directNumber = lowerer.stripUndefinedArm(operand.type).kind === "f64"; + if (directNumber) { + const scalarRead = tryLowerNumericIndexRead(lowerer, operand, loc); + if (scalarRead) return scalarRead; + } const checkerNumber = narrowedNode !== undefined && lowerer.mapTypeOf(lowerer.typeOf(narrowedNode))?.kind === "f64"; if (!directNumber && (!checkerNumber || lowerer.armTag(operand.type.unionId, F64) < 0)) return operand; const undefTag = lowerer.armTag(operand.type.unionId, UNDEFINED_T); diff --git a/packages/compiler/test/numeric-array-reads.test.ts b/packages/compiler/test/numeric-array-reads.test.ts new file mode 100644 index 000000000..af21347cb --- /dev/null +++ b/packages/compiler/test/numeric-array-reads.test.ts @@ -0,0 +1,46 @@ +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"; + +test("ordinary numeric-array arithmetic has no reachable union allocation", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-numeric-array-reads-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, [ + "function blend(a: number[], i: number, j: number): number {", + " return a[i] + (a[j] - a[i]) * 0.5;", + "}", + "console.log(blend([1, 3], 0, 1));", + "", + ].join("\n")); + 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([]); + const visited = new Set(); + let arrayReads = 0; + function visit(value: unknown): void { + if (value === null || typeof value !== "object") return; + if (Array.isArray(value)) { value.forEach(visit); return; } + const node = value as { kind?: string; callee?: string }; + expect(node.kind).not.toBe("unionWrap"); + if (node.kind === "arrayGet") arrayReads++; + if (node.kind === "call" && node.callee) visitFunction(node.callee); + Object.values(value).forEach(visit); + } + function visitFunction(name: string): void { + if (visited.has(name)) return; + visited.add(name); + const fn = mod.functions.find((f) => f.name === name); + expect(fn, `missing function ${name}`).toBeDefined(); + visit(fn!.body); + } + visitFunction(mod.entry); + expect(arrayReads).toBeGreaterThan(0); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/corpus/2966-numeric-array-read-fusion.ts b/tests/corpus/2966-numeric-array-read-fusion.ts new file mode 100644 index 000000000..c06960238 --- /dev/null +++ b/tests/corpus/2966-numeric-array-read-fusion.ts @@ -0,0 +1,58 @@ +// Immediate numeric reads avoid boxing but retain JavaScript array and +// evaluation-order semantics, including holes and non-index properties. +const values: number[] = [-0, 1.5, NaN, Infinity, -Infinity]; +console.log("ieee", 1 / (values[0] * 1), values[1] + 2, values[2] * 3, values[3] - 1, values[4] / 2); +console.log("operators", values[1] ** 2, values[1] % 1, values[1] | 4, values[1] << 2); +console.log("missing", values[20] + 1, values[20] * 0, values[20] < 1, values[20] | 7); +console.log("observable", values[20] === undefined, values[20] === values[21], values[20] ?? 9); +console.log("concat", values[20] + "!", "value:" + values[1]); + +values.length = 8; +values[6] = values[20]; +console.log("states", values[5] + 1, values[6] + 1, 5 in values, 6 in values); +values[5] = 12; +values[1] = values[20]; +console.log("mutated", values[5] - 2, values[1] * 2); +values.length = 1; +console.log("truncated", values[5] + 1); + +values[-1] = 4; +values[0.5] = 5; +values[4294967295] = 6; +values[NaN] = 7; +values[Infinity] = 8; +console.log("properties", values[-1] + 1, values[0.5] * 2, values[4294967295] - 1, values[NaN] / 2, values[Infinity] + 1); +values[-1] = values[20]; +console.log("property-missing", values[-1] * 2, values[-2] + 1, values[-Infinity] + 1); +const sparse: number[] = []; +sparse[1000000] = 9; +console.log("sparse", sparse[1000000] * 2, sparse[999999] + 1); + +let trace = ""; +const original = [10]; +const replacement = [100]; +let current = original; +function receiver(): number[] { trace += "R"; return current; } +function index(): number { trace += "I"; current = replacement; original[0] = 12; return 0; } +function rhs(): number { trace += "V"; original[0] = 40; return 3; } +const result = receiver()[index()] + rhs(); +console.log("order", result, trace, original[0], current[0]); + +trace = ""; +function temporary(): number[] { trace += "T"; return [6]; } +function zero(): number { trace += "Z"; return 0; } +console.log("temporary", temporary()[zero()] * temporary()[zero()], trace); +trace = ""; +console.log("short-circuit", false && temporary()[zero()] > 0, true || temporary()[zero()] > 0, trace); +function fail(): number { trace += "F"; throw new Error("index"); } +try { console.log(temporary()[fail()] + rhs()); } catch { console.log("throw", trace); } + +// Wider unions, saved reads, and user functions keep their tagged value. +const saved = values[20]; +values[20] = 4; +console.log("saved", saved === undefined, saved + 1, values[20] + 1); +const mixed: (number | string)[] = [3, "x"]; +const first = mixed[0]; +if (typeof first === "number") console.log("mixed", first + 1); +function userRead(a: number[], i: number): number { return a[i]; } +console.log("user-call", userRead(values, 0) * 1);