diff --git a/packages/compiler/src/backend/c/exprs.ts b/packages/compiler/src/backend/c/exprs.ts index 38bcac4da..7f4481d46 100644 --- a/packages/compiler/src/backend/c/exprs.ts +++ b/packages/compiler/src/backend/c/exprs.ts @@ -1706,6 +1706,10 @@ function emitContainerExpr( switch (method) { case "length": return emitter.newTemp(e.type, `scr_arr_len(${r.name})`); + case "getNumber": { + const index = emitter.emitExpr(e.args[0]!); + return emitter.newTemp(e.type, `scr_arr_get_number(${r.name}, ${index.name})`); + } case "nextPresent": { const start = emitter.emitExpr(e.args[0]!); return emitter.newTemp(e.type, `scr_arr_next_present(${r.name}, ${start.name})`); diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts index eff100365..6bdad10f7 100644 --- a/packages/compiler/src/backend/llvm/expr-containers.ts +++ b/packages/compiler/src/backend/llvm/expr-containers.ts @@ -198,6 +198,13 @@ export function emitArrIntrinsic(host: LlvmEmitterContext, e: IrExpr & { kind: " B.line(`${t} = call double @scr_arr_len(ptr ${r.name})`); return { name: t, type: e.type }; } + case "getNumber": { + const index = host.emitExpr(e.args[0]!); + host.declare(`declare double @scr_arr_get_number(ptr, double)`); + const t = B.tmp(); + B.line(`${t} = call double @scr_arr_get_number(ptr ${r.name}, double ${index.name})`); + return { name: t, type: e.type }; + } case "nextPresent": { const start = host.emitExpr(e.args[0]!); host.declare(`declare double @scr_arr_next_present(ptr, double)`); diff --git a/packages/compiler/src/frontend/lowering/lower-containers.ts b/packages/compiler/src/frontend/lowering/lower-containers.ts index 4cbae73ed..fb6d95820 100644 --- a/packages/compiler/src/frontend/lowering/lower-containers.ts +++ b/packages/compiler/src/frontend/lowering/lower-containers.ts @@ -1428,24 +1428,20 @@ function arrayUnionHofHelper( } /** 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. */ + * holes, missing properties, and present undefined yield undefined. */ 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; - 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)}`; + const resultT = arrayValueType(lowerer, elem); + const key = `idxOr:${typeKey(elem)}`; let name = lowerer.arrHofHelpers.get(key); if (!name) { - name = `%arr.${numeric ? "idxNumber" : "idxOr"}.${lowerer.arrHofHelpers.size}`; + name = `%arr.idxOr.${lowerer.arrHofHelpers.size}`; lowerer.arrHofHelpers.set(key, name); const arrT = arr.type; lowerer.liftedFns.push({ @@ -1459,21 +1455,7 @@ function arrayUnionHofHelper( { id: "a.0", name: "a", type: arrT, mutable: false }, { id: "i.0", name: "i", type: F64, mutable: false }, ], - 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, - }], + body: [{ kind: "return", value: arrayValueRead(lowerer, varRef("a.0", arrT, loc), varRef("i.0", F64, loc), elem, loc), loc }], loc, }); } @@ -1488,7 +1470,10 @@ export function tryLowerNumericIndexRead(lowerer: Lowerer, operand: IrExpr, loc: 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); + // The intrinsic owns the evaluated receiver through index evaluation, + // then borrows it for one slot lookup. No extra helper parameter or + // separate state/getter expressions need to retain the array again. + return { kind: "arrIntrinsic", method: "getNumber", receiver: arr, args: [index], type: F64, loc }; } /** Backward-compatible name for the npm-static probe path. */ diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index ffaf84c67..edaeae7fd 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1570,6 +1570,10 @@ export type IrStmt = * the fresh array. */ export type IrArrIntrinsicMethod = | "length" + /** Internal ToNumber(a[index]) for f64-backed arrays: one numeric index, + * returning the stored number or NaN for a hole/undefined/missing key. + * Borrows the receiver and never traps on missing values. */ + | "getNumber" | "push" | "pushSpread" | "concatSpread" diff --git a/packages/compiler/src/ir/validate.test.ts b/packages/compiler/src/ir/validate.test.ts new file mode 100644 index 000000000..7a5bb43dd --- /dev/null +++ b/packages/compiler/src/ir/validate.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "vitest"; +import { BOOL, F64, STRING, VOID, arrayOf, type IrExpr, type IrModule } from "./ir.js"; +import { deserializeModule, serializeModule } from "./serialize.js"; +import { validateModule } from "./validate.js"; + +const loc = { file: "numeric-read.ts", start: 0, end: 0 }; + +function numericReadModule(overrides: Partial = {}): IrModule { + const read: IrExpr = { + kind: "arrIntrinsic", method: "getNumber", + receiver: { kind: "arrayLit", elems: [], type: arrayOf(F64), loc }, + args: [{ kind: "numLit", value: 0, type: F64, loc }], + type: F64, loc, ...overrides, + }; + return { + irVersion: 11, sourceFile: loc.file, entry: "main", + functions: [{ name: "main", params: [], locals: [], returnType: VOID, body: [{ kind: "exprStmt", expr: read, loc }], loc }], + }; +} + +test("numeric array-read intrinsic validates and round-trips", () => { + const mod = numericReadModule(); + expect(validateModule(mod)).toEqual([]); + expect(deserializeModule(serializeModule(mod))).toEqual(mod); +}); + +test.each([ + [{ receiver: { kind: "arrayLit", elems: [], type: arrayOf(STRING), loc } }, "requires f64 elements"], + [{ args: [] }, "0 args, expected 1"], + [{ args: [{ kind: "strLit", value: "0", type: STRING, loc }] }, "arg 0: expected f64"], + [{ type: BOOL }, "must be f64"], +] satisfies [Partial, string][])("numeric array-read intrinsic rejects malformed IR %#", (overrides, message) => { + expect(validateModule(numericReadModule(overrides)).some((error) => error.message.includes(message))).toBe(true); +}); diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index d78457b13..9296d6ff1 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -2719,7 +2719,7 @@ function validateFunction( ? { argTypes: e.args.map(() => elem), result: F64 } : e.method === "pushSpread" || e.method === "concatSpread" || e.method === "unshiftSpread" ? { argTypes: [e.receiver.type], result: F64 } - : e.method === "nextPresent" + : e.method === "nextPresent" || e.method === "getNumber" ? { argTypes: [F64], result: F64 } : e.method === "pop" ? { argTypes: [], result: e.type } // union-checked below @@ -2746,6 +2746,9 @@ function validateFunction( : e.method === "shift" ? { argTypes: [], result: e.type } // union-checked below : { argTypes: [], result: F64 }; // length + if (e.method === "getNumber" && elem.kind !== "f64") { + err(`arrIntrinsic getNumber requires f64 elements, got ${elem.kind}`, e.loc); + } if ( e.method === "join" && elem.kind !== "f64" && elem.kind !== "string" && elem.kind !== "bool" && diff --git a/packages/compiler/test/numeric-array-reads.test.ts b/packages/compiler/test/numeric-array-reads.test.ts index af21347cb..bde79415d 100644 --- a/packages/compiler/test/numeric-array-reads.test.ts +++ b/packages/compiler/test/numeric-array-reads.test.ts @@ -3,8 +3,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "vitest"; import { compile, deserializeModule, validateModule } from "../src/index.js"; +import { emitCModule } from "../src/backend/c/c-emitter.js"; +import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; -test("ordinary numeric-array arithmetic has no reachable union allocation", async () => { +test("ordinary numeric-array arithmetic uses one lookup per read without boxing", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-numeric-array-reads-")); try { const entry = join(dir, "main.ts"); @@ -25,9 +27,11 @@ test("ordinary numeric-array arithmetic has no reachable union allocation", asyn 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 }; + const node = value as { kind?: string; callee?: string; method?: string }; expect(node.kind).not.toBe("unionWrap"); - if (node.kind === "arrayGet") arrayReads++; + expect(node.kind).not.toBe("arrayState"); + expect(node.kind).not.toBe("arrayGet"); + if (node.kind === "arrIntrinsic" && node.method === "getNumber") arrayReads++; if (node.kind === "call" && node.callee) visitFunction(node.callee); Object.values(value).forEach(visit); } @@ -39,7 +43,14 @@ test("ordinary numeric-array arithmetic has no reachable union allocation", asyn visit(fn!.body); } visitFunction(mod.entry); - expect(arrayReads).toBeGreaterThan(0); + expect(arrayReads).toBe(3); + const reachable = { ...mod, functions: mod.functions.filter((fn) => visited.has(fn.name)) }; + const c = emitCModule(reachable); + const llvm = emitLlvmModule(reachable); + expect(c.match(/scr_arr_get_number\(/g)).toHaveLength(3); + expect(llvm.match(/call double @scr_arr_get_number\(/g)).toHaveLength(3); + expect(c).not.toContain("scr_arr_state("); + expect(llvm).not.toContain("@scr_arr_state("); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/packages/runtime/src/scr_array.c b/packages/runtime/src/scr_array.c index 3ad79f1fc..157eb18e3 100644 --- a/packages/runtime/src/scr_array.c +++ b/packages/runtime/src/scr_array.c @@ -766,6 +766,19 @@ double scr_arr_get_f64(ScrArr *a, double i) { return scr_slot_to_f64(scr_arr_require_slot(a, i)); } +double scr_arr_get_number(const ScrArr *a, double i) { + size_t idx; + uint64_t slot; + uint8_t state; + if (scr_arr_valid_index(i, &idx)) { + if (idx >= a->len) return NAN; + state = scr_arr_state_at(a, idx, &slot); + } else if (!scr_arr_prop_get_state(a, i, &slot, &state)) { + return NAN; + } + return state == SCR_ARR_VALUE ? scr_slot_to_f64(slot) : NAN; +} + bool scr_arr_get_bool(ScrArr *a, double i) { return scr_arr_require_slot(a, i) != 0; } diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index cb73ac9f5..ce3cf3ff4 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -1009,6 +1009,8 @@ double scr_math_max(double a, double b); double scr_math_random(void); double scr_arr_get_f64(ScrArr *a, double i); /* trap missing/hole */ +/* ToNumber(a[i]) for f64 storage: borrows a; missing/hole/undefined -> NaN. */ +double scr_arr_get_number(const ScrArr *a, double i); bool scr_arr_get_bool(ScrArr *a, double i); /* trap missing/hole */ void *scr_arr_get_ref(ScrArr *a, double i); /* trap missing/hole; +1 */ diff --git a/packages/runtime/test/test_array.c b/packages/runtime/test/test_array.c index 550e09a27..0460f819f 100644 --- a/packages/runtime/test/test_array.c +++ b/packages/runtime/test/test_array.c @@ -64,6 +64,38 @@ static void test_f64_basics(void) { scr_arr_release(a); } +static void test_numeric_read(void) { + ScrArr *a = scr_arr_new(SCR_ELEM_F64, 0); + check(isnan(scr_arr_get_number(a, 0)), "numeric read of empty array"); + scr_arr_set_f64(a, 0, -0.0); + check(signbit(scr_arr_get_number(a, -0.0)), "numeric read preserves signed zero"); + scr_arr_set_f64(a, 3, INFINITY); + check(isnan(scr_arr_get_number(a, 1)), "numeric read of dense hole"); + check_f64(scr_arr_get_number(a, 3), INFINITY, "numeric read of infinity"); + scr_arr_set_undefined(a, 2); + check(isnan(scr_arr_get_number(a, 2)) && scr_arr_has(a, 2), + "numeric read of present undefined keeps presence"); + scr_arr_set_f64(a, 4294967294.0, -INFINITY); + check_f64(scr_arr_get_number(a, 4294967294.0), -INFINITY, + "numeric read of last sparse array index"); + check(isnan(scr_arr_get_number(a, 4294967293.0)), "numeric read of sparse hole"); + scr_arr_set_undefined(a, 4294967294.0); + check(isnan(scr_arr_get_number(a, 4294967294.0)), "numeric read of sparse undefined"); + double keys[] = {-1, 0.5, 4294967295.0, NAN, INFINITY, -INFINITY}; + for (size_t i = 0; i < sizeof keys / sizeof *keys; i++) { + check(isnan(scr_arr_get_number(a, keys[i])), "numeric read of missing property"); + scr_arr_set_f64(a, keys[i], (double)i + 10); + check_f64(scr_arr_get_number(a, keys[i]), (double)i + 10, + "numeric read of ordinary numeric property"); + scr_arr_set_undefined(a, keys[i]); + check(isnan(scr_arr_get_number(a, keys[i])) && scr_arr_has(a, keys[i]), + "numeric read of undefined numeric property"); + } + scr_arr_set_f64(a, 0, NAN); + check(isnan(scr_arr_get_number(a, 0)), "numeric read of stored NaN"); + scr_arr_release(a); +} + static void test_bool(void) { ScrArr *a = scr_arr_new(SCR_ELEM_BOOL, 2); scr_arr_push_bool(a, true); @@ -592,6 +624,7 @@ int main(int argc, char **argv) { } test_f64_basics(); + test_numeric_read(); test_bool(); test_unshift_reverse(); test_str_rc(); diff --git a/tests/corpus/2967-numeric-array-read-lifetime.ts b/tests/corpus/2967-numeric-array-read-lifetime.ts new file mode 100644 index 000000000..5000c8b49 --- /dev/null +++ b/tests/corpus/2967-numeric-array-read-lifetime.ts @@ -0,0 +1,34 @@ +// Retain the evaluated receiver until a side-effecting index finishes, +// including when that index replaces the array's only other reference. +let current: number[] = [12, -0]; +let calls = 0; +function replace(): number { + calls++; + current = [90]; + return 0; +} +console.log("replace", current[replace()] + 1, current[0], calls); + +function nested(): number { + const before = current[replace()] * 2; + current = [30]; + return before > 0 ? 0 : 1; +} +console.log("nested", current[nested()] - 1, current[0], calls); + +let trace = ""; +function temporary(): number[] { trace += "R"; return [7]; } +function fail(): number { trace += "I"; throw new Error("index"); } +function rhs(): number { trace += "V"; return 1; } +try { console.log(temporary()[fail()] + rhs()); } catch { console.log("throw", trace); } + +// Cover the array-index boundary without allocating dense 2^32 storage. +const sparse: number[] = [-0]; +sparse[4294967294] = Infinity; +sparse[4294967295] = -Infinity; +console.log("boundary", sparse.length, sparse[4294967294] + 1, sparse[4294967295] - 1, sparse[4294967293] * 2); +console.log("zero-index", 1 / (sparse[-0] * 1)); +sparse[4294967294] = sparse[2]; +console.log("undefined", sparse[4294967294] + 1, 4294967294 in sparse); +sparse.length = 1; +console.log("truncate", sparse[4294967294] + 1, sparse[4294967295] - 1);