From 87519cac07d876ec9a35f75218d2cfdfdef7904e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 4 Oct 2026 10:57:43 -0500 Subject: [PATCH] Preserve array borrowing across direct helpers - Preserve array-element borrowing across direct helpers that cannot replace references. - Retain ordinary ownership handling for mutations, callbacks, and unsupported effects. - Cover recursive calls, argument evaluation order, and exceptional cleanup. --- packages/compiler/src/backend/llvm/emitter.ts | 6 +- .../backend/llvm/local-array-reads.test.ts | 79 ++++++++++++++- .../src/backend/llvm/local-array-reads.ts | 46 +++++++-- tests/corpus/array-read-helper-effects.ts | 97 +++++++++++++++++++ 4 files changed, 217 insertions(+), 11 deletions(-) create mode 100644 tests/corpus/array-read-helper-effects.ts diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 62fbb4c77..80772c9a3 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -47,7 +47,7 @@ import { RUNTIME_ABI_MARKER } from "../runtime-abi.js"; import { computeMayThrow } from "../may-throw.js"; import { mangleArgPack, mangleAsyncSpawn, mangleClassObj, mangleClassStruct, mangleFnClosure, mangleFunction, mangleGenDrop, mangleGenSpawn, mangleGlobal, mangleLocal, mangleRecordStruct, mangleTrampoline, mangleWrapper, mangleVtInstance } from "../mangle.js"; import { BlockBuilder } from "./blocks.js"; -import { emitLocalArrayRead, findLocalArrayReads, type LocalArrayRead } from "./local-array-reads.js"; +import { emitLocalArrayRead, findArrayPreservingFunctions, findLocalArrayReads, type LocalArrayRead } from "./local-array-reads.js"; import { emitBorrowedFieldSequence } from "./borrowed-receivers.js"; import { LlvmDebugInfo } from "./debug-info.js"; import { f64Lit, ffiNativeTypeLl, ffiNativeParamLl, ffiNativeReturnLl, llvmCommentText } from "./common.js"; @@ -261,6 +261,7 @@ export class LlEmitter { private needsRetainBox = false; readonly fnByName = new Map(); + private readonly arrayPreservingFunctions: ReadonlySet; /** Manifest-bound native imports, used by ffiCall emission. */ readonly ffiByName = new Map(); /** C-ABI callback trampolines and (for raw/no-userdata callbacks) their @@ -427,6 +428,7 @@ export class LlEmitter { } } for (const u of mod.unions ?? []) this.unionsById.set(u.id, u); + this.arrayPreservingFunctions = findArrayPreservingFunctions(this.fnByName, this.unionsById); for (const r of mod.records ?? []) this.recordsById.set(r.id, r); const traced = computeTraced(mod); this.tracedShapes = traced.shapes; @@ -3307,7 +3309,7 @@ export class LlEmitter { this.integerLoopBindings.clear(); this.fieldPointerTags.clear(); this.integerArrayBindings.clear(); - this.localArrayReads = findLocalArrayReads(fn, this.fnByName, this.unionsById); + this.localArrayReads = findLocalArrayReads(fn, this.fnByName, this.unionsById, this.arrayPreservingFunctions); this.integerRanges = analyzeIntegerRanges(fn); this.chainSlots.clear(); this.finallyStack = []; diff --git a/packages/compiler/src/backend/llvm/local-array-reads.test.ts b/packages/compiler/src/backend/llvm/local-array-reads.test.ts index 7bbad05b8..7cd5e9237 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.test.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "vitest"; import { BOOL, F64, VOID, UNDEFINED_T, arrayOf, funcOf, type IrExpr, type IrFunction, type IrModule, type IrStmt, type IrType } from "../../ir/ir.js"; import { validateModule } from "../../ir/validate.js"; import { emitLlvmModule } from "./emitter.js"; -import { findLocalArrayReads } from "./local-array-reads.js"; +import { findArrayPreservingFunctions, findLocalArrayReads } from "./local-array-reads.js"; const loc = { file: "local-array.ts", start: 0, end: 0 }; const element: IrType = { kind: "record", shapeId: "cell" }; @@ -36,7 +36,9 @@ function fixture(): IrModule { functions: [{ name: "main", params: [], returnType: VOID, locals: [], body: [], loc }, producer, work] }; } function candidates(mod: IrModule) { - return findLocalArrayReads(mod.functions[2]!, new Map(mod.functions.map((f) => [f.name, f])), new Map(mod.unions!.map((u) => [u.id, u]))); + const functions = new Map(mod.functions.map((f) => [f.name, f])); + const unions = new Map(mod.unions!.map((u) => [u.id, u])); + return findLocalArrayReads(mod.functions[2]!, functions, unions, findArrayPreservingFunctions(functions, unions)); } function workBody(mod: IrModule, pointerBits: 32 | 64 = 64) { return /^define internal [^\n]*@sc_f_work\([^]*?^}/m.exec(emitLlvmModule(mod, { pointerBits }))![0]; @@ -96,3 +98,76 @@ test("unknown calls and reference stores disable array borrowing", () => { expect(candidates(mod).get("value")?.borrow).not.toBe(true); } }); + +function scalarHelper(name: string, callee?: string): IrFunction { + return { + name, params: [{ localId: "n", name: "n", type: F64 }], returnType: F64, loc, + locals: [{ id: "n", name: "n", type: F64, mutable: false }], + body: [{ kind: "return", loc, value: callee + ? { kind: "call", callee, args: [ref("n", F64)], type: F64, loc } + : { kind: "bin", op: "+", left: ref("n", F64), right: num(1), type: F64, loc } }], + }; +} + +function callHelper(mod: IrModule, callee: string, argument: IrExpr = num(2)): void { + mod.functions[2]!.body.splice(1, 0, { kind: "exprStmt", loc, expr: { kind: "call", callee, args: [argument], type: F64, loc } }); +} + +test("borrows array payloads across direct and transitive scalar helpers", () => { + const mod = fixture(); + mod.functions.push(scalarHelper("outer", "inner"), scalarHelper("inner")); + callHelper(mod, "outer"); + expect(validateModule(mod)).toEqual([]); + expect(candidates(mod).get("value")?.borrow).toBe(true); + for (const pointerBits of [32, 64] as const) { + const body = workBody(mod, pointerBits); + expect(body).toContain("@sc_f_outer("); + expect(body).not.toContain("@sc_rretain_"); + expect(body).not.toContain("@sc_rrelease_"); + } +}); + +test("propagates reference mutation through a recursive call group", () => { + const mod = fixture(); + const outer = scalarHelper("outer", "inner"); + const inner = scalarHelper("inner", "outer"); + mod.functions.push(outer, inner); + callHelper(mod, "outer"); + expect(validateModule(mod)).toEqual([]); + expect(candidates(mod).get("value")?.borrow).toBe(true); + inner.locals.push({ id: "owned", name: "owned", type: array, mutable: true }); + inner.body.unshift({ kind: "assign", localId: "owned", value: ref("owned", array), loc }); + expect(candidates(mod).get("value")?.borrow).not.toBe(true); + expect(workBody(mod)).toContain("@sc_rretain_"); +}); + +test.each(["unknown", "callback", "async", "capture", "reference store"])("rejects helpers with %s effects through callers", (effect) => { + const mod = fixture(); + const inner = scalarHelper("inner"); + mod.functions.push(scalarHelper("outer", "inner"), inner); + callHelper(mod, "outer"); + if (effect === "unknown") inner.body[0] = { kind: "return", loc, value: { kind: "call", callee: "unavailable", args: [], type: F64, loc } }; + if (effect === "callback") inner.body[0] = { kind: "return", loc, value: { kind: "callValue", callee: ref("callback", funcOf([], F64)), args: [], type: F64, loc } }; + if (effect === "async") inner.async = true; + if (effect === "capture") inner.captures = []; + if (effect === "reference store") inner.body.unshift({ kind: "arraySetLength", arr: ref("items", array), length: num(0), loc }); + expect(candidates(mod).get("value")?.borrow).not.toBe(true); +}); + +test("checks side effects in arguments even when the callee preserves references", () => { + const mod = fixture(); + mod.functions.push(scalarHelper("helper")); + callHelper(mod, "helper", { kind: "seqExpr", loc, type: F64, + stmts: [{ kind: "arraySetLength", arr: ref("a", array), length: num(0), loc }], result: num(0) }); + expect(validateModule(mod)).toEqual([]); + expect(candidates(mod).get("value")?.borrow).not.toBe(true); + expect(workBody(mod)).toContain("@sc_rretain_"); +}); + +test("iteratively propagates an unsafe leaf through a long call chain", () => { + const functions = new Map(); + for (let i = 0; i < 10000; i++) functions.set(`f${i}`, scalarHelper(`f${i}`, `f${i + 1}`)); + expect(findArrayPreservingFunctions(functions, new Map()).size).toBe(0); + functions.set("f10000", scalarHelper("f10000")); + expect(findArrayPreservingFunctions(functions, new Map()).size).toBe(10001); +}); diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts index c050a0f10..015b64f5f 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.ts @@ -12,10 +12,12 @@ export interface LocalArrayRead { borrow?: boolean; } -/** With no reference writes, user calls, callbacks or suspension, an - * unboxed array parameter owns all its elements throughout the function. - * Scalar field writes are allowed: they cannot remove an array edge. */ -function preservesArrayElements(fn: IrFunction, functions: ReadonlyMap, unions: ReadonlyMap): boolean { +/** With no reference writes, callbacks or suspension, an unboxed array + * parameter owns all its elements throughout the function. Scalar field + * writes are allowed: they cannot remove an array edge. A direct call's + * arguments are visited too, independently of the callee's guarantee. */ +function preservesArrayElements(fn: IrFunction, preservesCall: (call: IrExpr & { kind: "call" }) => boolean): boolean { + if (fn.async || fn.generator || fn.captures || fn.classCaptures) return false; return everyStmtList(fn.body, { expr: (e) => { switch (e.kind) { @@ -23,7 +25,7 @@ function preservesArrayElements(fn: IrFunction, functions: ReadonlyMap, unions: ReadonlyMap): Set { + const safe = new Set(); + const callers = new Map>(); + const unsafe: string[] = []; + for (const fn of functions.values()) { + const preserves = preservesArrayElements(fn, (call) => { + if (optionalArrayRead(call, functions, unions)) return true; + if (!functions.has(call.callee)) return false; + let incoming = callers.get(call.callee); + if (!incoming) callers.set(call.callee, incoming = new Set()); + incoming.add(fn.name); + return true; + }); + if (preserves) safe.add(fn.name); + else unsafe.push(fn.name); + } + for (let i = 0; i < unsafe.length; i++) { + const incoming = callers.get(unsafe[i]!); + if (!incoming) continue; + for (const caller of incoming) { + if (safe.delete(caller)) unsafe.push(caller); + } + } + return safe; +} + /** Recognize the complete optional-array-read body, not the helper's name. * Both operands will be evaluated once, in call argument order. */ function optionalArrayRead(call: IrExpr, functions: ReadonlyMap, unions: ReadonlyMap): LocalArrayRead | null { @@ -91,13 +123,13 @@ function projectionOnly(fn: IrFunction, id: string): boolean { return fn.body.every(stmt) && declarations === 1; } -export function findLocalArrayReads(fn: IrFunction, functions: ReadonlyMap, unions: ReadonlyMap): Map { +export function findLocalArrayReads(fn: IrFunction, functions: ReadonlyMap, unions: ReadonlyMap, arrayPreservingFunctions: ReadonlySet): Map { const result = new Map(); if (fn.async || fn.generator) return result; const locals = new Map(fn.locals.map((l) => [l.id, l])); const captures = new Set([...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId)); const params = new Set(fn.params.map((p) => p.localId)); - const borrow = preservesArrayElements(fn, functions, unions); + const borrow = arrayPreservingFunctions.has(fn.name); everyStmtList(fn.body, { expr: () => true, stmt: (node) => { if (node.kind !== "varDecl" || !node.init) return true; const local = locals.get(node.localId); diff --git a/tests/corpus/array-read-helper-effects.ts b/tests/corpus/array-read-helper-effects.ts new file mode 100644 index 000000000..63c6c2459 --- /dev/null +++ b/tests/corpus/array-read-helper-effects.ts @@ -0,0 +1,97 @@ +class Item { + value: number; + constructor(value: number) { this.value = value; } +} + +function adjust(value: number): number { return Math.abs(value) + 2; } +function nested(value: number): number { return adjust(value) * 3; } +function even(value: number): number { return value <= 0 ? 1 : odd(value - 1); } +function odd(value: number): number { return value <= 0 ? 0 : even(value - 1); } + +function read(items: Item[], index: number): number { + const item = items[index]; + const result = nested(item.value); + item.value += even(result); + return result + item.value; +} + +const shared = new Item(-3); +const items = [shared, shared, new Item(5)]; +console.log(read(items, 0), read(items, 1), read(items, 2), shared.value); +items[-1] = new Item(7); +items[0.5] = new Item(11); +items[4_000_000_000] = new Item(13); +console.log(read(items, -1), read(items, 0.5), read(items, 4_000_000_000)); +try { console.log(read(items, 17)); } +catch (error) { if (error instanceof Error) console.log(error.name, error.message); } + +function trim(items: Item[]): number { items.length = 0; return 4; } +function indirectTrim(items: Item[]): number { return trim(items); } +function liveAfterTrim(items: Item[]): number { + const item = items[0]; + const result = indirectTrim(items); + item.value += result; + return item.value; +} +console.log(liveAfterTrim([new Item(19)])); + +function recursiveTrim(items: Item[], count: number): number { + if (count === 0) return trim(items); + return recursiveTrim(items, count - 1); +} +function liveAfterRecursion(items: Item[]): number { + const item = items[0]; + return recursiveTrim(items, 3) + item.value; +} +console.log(liveAfterRecursion([new Item(23)])); + +function invoke(callback: () => number): number { return callback(); } +function liveAfterCallback(items: Item[]): number { + const item = items[0]; + const result = invoke(() => trim(items)); + return item.value + result; +} +console.log(liveAfterCallback([new Item(29)])); + +function ordered(items: Item[]): number { + const item = items[0]; + const result = nested(trim(items)); + return item.value + result; +} +console.log(ordered([new Item(31)])); + +function readMissing(items: Item[]): number { + const item = items[1]; + return nested(item.value); +} +function unwind(items: Item[]): number { + const item = items[0]; + return readMissing(items) + item.value; +} +try { console.log(unwind([new Item(37)])); } +catch (error) { if (error instanceof Error) console.log(error.name, error.message); } + +let stored = new Item(0); +function replace(items: Item[]): number { + stored = items[0]; + items[0] = new Item(41); + return 5; +} +function escaped(items: Item[]): number { + const item = items[0]; + const result = replace(items); + return item.value + result + items[0].value; +} +console.log(escaped([new Item(43)]), stored.value); + +class Holder { + item: Item; + constructor(item: Item) { this.item = item; } +} +function setChild(holder: Holder): number { holder.item = new Item(47); return 2; } +function nestedOwner(holders: Holder[]): number { + const holder = holders[0]; + const item = holder.item; + return setChild(holder) + item.value + holder.item.value; +} +console.log(nestedOwner([new Holder(new Item(53))]));