mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-31 07:23:06 +08:00
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:
@@ -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);
|
||||
|
||||
@@ -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))]));
|
||||
Reference in New Issue
Block a user