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.
This commit is contained in:
Chris Tate
2026-10-04 10:57:43 -05:00
parent 668eb78124
commit 87519cac07
4 changed files with 217 additions and 11 deletions
@@ -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<string, IrFunction>();
private readonly arrayPreservingFunctions: ReadonlySet<string>;
/** Manifest-bound native imports, used by ffiCall emission. */
readonly ffiByName = new Map<string, IrFfiImport>();
/** 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 = [];
@@ -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<string, IrFunction>();
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);
});
@@ -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<string, IrFunction>, unions: ReadonlyMap<string, IrUnionDef>): 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<string, I
case "toBool": case "logical": case "ternary": case "seqExpr": case "fieldGet": case "recordGet":
case "unionNarrow": case "unionIsTag": return true;
case "libCall": return e.fn === "error.nodeThrow" || isStableReceiverOperand(e, "");
case "call": return optionalArrayRead(e, functions, unions) !== null;
case "call": return preservesCall(e);
case "arrIntrinsic": return e.method === "length";
default: return false;
}
@@ -39,6 +41,36 @@ function preservesArrayElements(fn: IrFunction, functions: ReadonlyMap<string, I
});
}
/** This proves only that existing reference edges survive a synchronous
* call, not purity or absence of exceptions. Unknown effects remain unsafe.
* Scan each body once, then propagate unsafe callees through reverse edges.
* Each function is removed at most once, including recursive call groups. */
export function findArrayPreservingFunctions(functions: ReadonlyMap<string, IrFunction>, unions: ReadonlyMap<string, IrUnionDef>): Set<string> {
const safe = new Set<string>();
const callers = new Map<string, Set<string>>();
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<string, IrFunction>, unions: ReadonlyMap<string, IrUnionDef>): 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<string, IrFunction>, unions: ReadonlyMap<string, IrUnionDef>): Map<string, LocalArrayRead> {
export function findLocalArrayReads(fn: IrFunction, functions: ReadonlyMap<string, IrFunction>, unions: ReadonlyMap<string, IrUnionDef>, arrayPreservingFunctions: ReadonlySet<string>): Map<string, LocalArrayRead> {
const result = new Map<string, LocalArrayRead>();
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);
+97
View File
@@ -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))]));