diff --git a/packages/compiler/src/backend/llvm/borrowed-inputs.ts b/packages/compiler/src/backend/llvm/borrowed-inputs.ts index af921a105..16839afd6 100644 --- a/packages/compiler/src/backend/llvm/borrowed-inputs.ts +++ b/packages/compiler/src/backend/llvm/borrowed-inputs.ts @@ -1,4 +1,5 @@ import type { IrExpr } from "../../ir/ir.js"; +import type { Writes } from "./reference-writes.js"; import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; /** For a runtime input whose ABI borrows rather than consumes ownership. @@ -14,18 +15,33 @@ export function emitBorrowedInput(host: LlvmEmitterContext, value: IrExpr): LlVa * while every operation preserves existing owners. Compute suffix facts * once, keeping left-to-right evaluation and the owning fallback for each * argument independently. The consumer must borrow all inputs and preserve - * reference edges itself; mutating runtime operations use emitBorrowedInput. */ + * reference edges itself; mutating runtime operations use emitBorrowedInput. + * With `consumerWrites` (the locations the consumer may overwrite), a + * projection also borrows when neither the consumer nor any later operand + * can write a location its read depends on (ReferenceWrites). */ export function borrowableInputs( host: LlvmEmitterContext, inputs: readonly IrExpr[], consumerPreserves = true, + consumerWrites?: Writes, ): boolean[] { const result: boolean[] = new Array(inputs.length); let preserves = consumerPreserves; + const writes = host.referenceWrites; + let suffix: Writes = + consumerWrites !== undefined + ? consumerWrites + : consumerPreserves + ? null + : (writes?.heapAll ?? null); for (let i = inputs.length - 1; i >= 0; i--) { const value = inputs[i]!; - result[i] = host.canBorrowCallArgument(value) || (preserves && host.canBorrowReceiver(value)); + result[i] = + host.canBorrowCallArgument(value) || + (preserves && host.canBorrowReceiver(value)) || + (writes !== null && host.canBorrowReceiver(value) && host.unwrittenRead(value, suffix)); preserves = preserves && host.referenceEffects.preserves(value); + if (writes !== null) suffix = writes.union([suffix, writes.expr(value)]); } return result; } @@ -36,3 +52,21 @@ export function emitBorrowedInputs(host: LlvmEmitterContext, inputs: readonly Ir borrowed[index] ? host.emitReadReceiver(value) : host.emitExpr(value), ); } + +/** The writes after each input: the consumer's plus every later input's + * (null entries: nothing; an empty array without ReferenceWrites). */ +export function inputSuffixWrites( + host: LlvmEmitterContext, + inputs: readonly IrExpr[], + consumerWrites: Writes, +): Writes[] { + const writes = host.referenceWrites; + if (writes === null) return []; + const out: Writes[] = new Array(inputs.length); + let suffix = consumerWrites; + for (let i = inputs.length - 1; i >= 0; i--) { + out[i] = suffix; + suffix = writes.union([suffix, writes.expr(inputs[i]!)]); + } + return out; +} diff --git a/packages/compiler/src/backend/llvm/dense-array-access.test.ts b/packages/compiler/src/backend/llvm/dense-array-access.test.ts index 4ff7b1b16..8101008a5 100644 --- a/packages/compiler/src/backend/llvm/dense-array-access.test.ts +++ b/packages/compiler/src/backend/llvm/dense-array-access.test.ts @@ -77,7 +77,17 @@ test.each([32, 64] as const)("strict scalar reads check dense storage inline (%i test("reference reads retain inline and keep a cold +1 runtime fallback", () => { const read: IrExpr = { kind: "arrayGet", arr: ref("s", words), index, type: STRING, loc }; - const llvm = emitLlvmModule(module("word", STRING, [{ kind: "return", value: read, loc }])); + // An element removal in the body keeps the read from being lent as a + // borrowed return: the result must own its reference. + const truncate: IrStmt = { + kind: "arraySetLength", + arr: ref("s", words), + length: { kind: "numLit", value: 1, type: F64, loc }, + loc, + }; + const llvm = emitLlvmModule( + module("word", STRING, [truncate, { kind: "return", value: read, loc }]), + ); const fn = body(llvm, "word"); expect(fn).toMatch(/icmp ne ptr .*, null/); expect(fn).toContain("@scr_str_retain_v("); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 752dd6391..8f65bb939 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -159,9 +159,10 @@ import { } from "./map-read-lifetimes.js"; import { emitBorrowedFieldSequence, guardedValue } from "./borrowed-receivers.js"; import { emitBorrowedInput } from "./borrowed-inputs.js"; -import { analyzeWalks, findWalkBorrows } from "./walk-borrows.js"; +import { analyzeWalks, findWalkBorrows, type WalkBorrowHost } from "./walk-borrows.js"; import { findReboundParameters } from "./rebound-parameters.js"; import { ReferenceEffects } from "./reference-effects.js"; +import { ReferenceWrites, type Writes } from "./reference-writes.js"; import { LlvmDebugInfo } from "./debug-info.js"; import { StackCallbacks } from "./stack-callbacks.js"; import { findLoopArrayBorrows } from "./loop-array-borrows.js"; @@ -583,6 +584,12 @@ export class LlEmitter { * fiberless-async analysis scans it for suspension entry points. */ private readonly fnDefText = new Map(); readonly referenceEffects: ReferenceEffects; + /** Field-sensitive reference writes (null in debug builds). */ + referenceWrites: ReferenceWrites | null = null; + private readonly borrowedReturnKeyCache = new Map(); + /** The statement list being emitted and the current index, for blocks + * whose bindings end with the list (emitBlock and function bodies). */ + private stmtCursor: { stmts: IrStmt[]; index: number } | null = null; readonly optionalArrayReads: OptionalArrayReads; readonly checkedNarrows: CheckedNarrows; callArrayReads = new Map(); @@ -891,6 +898,15 @@ export class LlEmitter { (call) => this.optionalArrayReads.get(call) !== null, mod.classes ?? [], ); + if (this.debug === null) + this.referenceWrites = new ReferenceWrites( + this.fnByName, + mod.classes ?? [], + (call) => this.optionalArrayReads.get(call) !== null, + new Set((mod.globals ?? []).map((g) => g.id)), + { unions: mod.unions, records: mod.records }, + this.immutableFields, + ); const walkParameters = new Map>(); if (this.debug === null) { const host = { @@ -898,8 +914,9 @@ export class LlEmitter { borrowsWithoutOwning: (e: IrExpr) => this.borrowsWithoutOwning(e), }; for (const fn of this.fnByName.values()) { - if (!this.referenceEffects.functions.has(fn.name)) continue; - const walks = findWalkBorrows(fn, host); + const preserving = this.referenceEffects.functions.has(fn.name); + if (!preserving && this.referenceWrites === null) continue; + const walks = findWalkBorrows(fn, preserving ? host : this.unwrittenWalkHost(fn, host)); if (walks.size === 0) continue; this.walkBorrowsByFunction.set(fn.name, walks); const indexes = new Set(); @@ -4948,6 +4965,7 @@ export class LlEmitter { const numericFn = withInitializerBindings(fn, initializerBindings); this.numericLocals = new Map(numericFn.locals.map((l) => [l.id, l])); this.borrowedParameters.clear(); + this.referenceWrites?.enter(fn); this.reboundOwnerSlots.clear(); this.projectedParameters.clear(); const projectedParameterIndexes = this.callLifetimes.parameters.get(fn.name); @@ -4988,6 +5006,7 @@ export class LlEmitter { this.callLifetimes, this.referenceEffects, this.optionalArrayReads, + this.referenceWrites, ) : new Set(); this.localArrayReads = findLocalArrayReads( @@ -5192,7 +5211,7 @@ export class LlEmitter { } if (this.stackChecks?.has(fn.name)) this.emitStackCheck(); if (this.workerEntryPolls?.has(fn.name)) this.emitPendingCheck(); - this.emitStmts(fn.body); + this.emitStmts(fn.body, true); // Implicit exit of a void function: release the function scope unless // the body already terminated its final block (return, or a throw // whose unwind released everything down to depth 0). @@ -5395,15 +5414,202 @@ export class LlEmitter { : this.immutableFields.recordField(e.shapeId, e.field); } + /** A projection read whose locations nothing writes while a borrowed + * read of it is in use (ReferenceWrites), given the writes of that + * extent. The root binding itself must stay alive across it too, which + * its own location (`l:`, `g:`, `bx`) covers. */ + unwrittenRead(e: IrExpr, writes: Writes): boolean { + const rw = this.referenceWrites; + if (rw === null) return false; + const keys = rw.readKeys(e, (call) => this.borrowedReturnKeys(call.callee)); + return keys !== null && !rw.conflicts(keys, writes); + } + + /** A stable binding initialized by a projection can borrow it when the + * rest of its block (its whole lifetime) writes none of the projection's + * locations: the source edge then keeps the value alive, and uses that + * escape still retain their own copies. */ + private unwrittenAlias(s: IrStmt & { kind: "varDecl" }): boolean { + const cursor = this.stmtCursor; + const rw = this.referenceWrites; + if (rw === null || s.init === null || cursor === null || cursor.stmts[cursor.index] !== s) + return false; + if ( + !this.plainReference(s.init.type) || + !this.canBorrowReceiver(s.init) || + !this.unownedWalk(s.init) + ) + return false; + const writes = rw.union([rw.expr(s.init), rw.stmts(cursor.stmts.slice(cursor.index + 1))]); + return this.unwrittenRead(s.init, writes); + } + + /** An optional element read into a projection-only local can borrow the + * element when the rest of the block writes no array element and none of + * the locations keeping the array alive (which must itself be read + * without a frame owner). */ + private unwrittenLocalArrayRead(s: IrStmt & { kind: "varDecl" }, read: LocalArrayRead): boolean { + const cursor = this.stmtCursor; + const rw = this.referenceWrites; + if (rw === null || s.init === null || cursor === null || cursor.stmts[cursor.index] !== s) + return false; + if (!this.unownedWalk(read.array)) return false; + const keys = rw.readKeys(read.array); + if (keys === null || rw.expr(read.index) !== null) return false; + const writes = rw.union([rw.expr(s.init), rw.stmts(cursor.stmts.slice(cursor.index + 1))]); + return !rw.conflicts(["a", ...keys], writes); + } + + /** An optional element read passed to a projection-only parameter can + * borrow the element when the callee and later arguments (`writes`) + * write no array element and nothing keeping the array alive. */ + unwrittenCallArrayRead(read: LocalArrayRead, writes: Writes): boolean { + const rw = this.referenceWrites; + if (rw === null) return false; + const keys = rw.readKeys(read.array); + if (keys === null || rw.expr(read.index) !== null) return false; + return !rw.conflicts(["a", ...keys], writes); + } + + /** `return x` of a local whose slot is an owned scope entry of the same + * type: the return can take over the local's reference. */ + private movableReturn( + value: IrExpr | null, + ): { scope: LlScopeEntry[]; entry: LlScopeEntry; index: number } | null { + if (value === null || value.kind !== "varRef" || !isRefCounted(value.type)) return null; + if (this.currentBorrowedReturn || this.currentWasiCoro !== null) return null; + const binding = this.binding(value.localId); + if (binding.kind !== "local" || binding.local === undefined || binding.local.boxed) return null; + if (!typeEquals(binding.type, value.type) || !typeEquals(this.currentReturnType, value.type)) + return null; + for (const scope of this.scopes) { + const index = scope.findIndex((entry) => entry.slot === binding.slot); + if (index < 0) continue; + const entry = scope[index]!; + if (entry.boxed || entry.lazy || !typeEquals(entry.type, value.type)) return null; + return { scope, entry, index }; + } + return null; + } + + /** A walk host for a body that may write the heap: each walk must read + * only locations the whole body (with its callees) never writes; + * rebinding the walk locals themselves releases nothing. */ + private unwrittenWalkHost(fn: IrFunction, host: WalkBorrowHost): WalkBorrowHost { + return { + ...host, + unwritten: (value, candidates) => + this.referenceWrites?.unwrittenWalk(fn, value, candidates, (call) => + this.borrowedReturnKeys(call.callee), + ) ?? false, + }; + } + + /** emitReadReceiver yields a plain pointer for `e` that no statement + * frame owns, so it can outlive the statement (walk-borrows' walks, from + * any unboxed plain-pointer binding, plus element reads). */ + private unownedWalk(e: IrExpr): boolean { + switch (e.kind) { + case "varRef": { + const binding = this.binding(e.localId); + if (binding.kind === "global") return this.plainReference(binding.type); + return ( + binding.kind === "local" && + binding.local !== undefined && + !binding.local.boxed && + !binding.local.tdz && + this.plainReference(binding.local.type) && + !this.stringSlices.has(e.localId) && + !this.storedSplits.has(e.localId) && + !this.localUnionStorage.has(e.localId) + ); + } + case "fieldGet": + case "recordGet": + return this.borrowsWithoutOwning(e) && this.unownedWalk(e.obj); + case "unionNarrow": + case "downcast": + case "upcast": + return this.borrowsWithoutOwning(e) && this.unownedWalk(e.value); + case "unionWrap": + if (!this.borrowsWithoutOwning(e)) return false; + return e.value.kind === "unitLit" || this.unownedWalk(e.value); + case "ternary": { + const arm = (v: IrExpr): boolean => + (v.kind === "libCall" && v.fn === "error.nodeThrow") || this.unownedWalk(v); + return this.borrowsWithoutOwning(e) && arm(e.then) && arm(e.else_); + } + case "call": + return ( + this.borrowsWithoutOwning(e) && + e.args.every((arg) => !isRefCounted(arg.type) || this.unownedWalk(arg)) + ); + case "arrayGet": + return ( + !this.isStackUnionSource(e) && + this.optionalArrayReads.get(e) === null && + this.unownedWalk(e.arr) + ); + case "seqExpr": { + const guarded = guardedValue(e); + return guarded !== null && this.unownedWalk(guarded); + } + default: + return false; + } + } + + /** Every location a borrowed-return callee's walks can read (its body's + * field, record and element reads, through nested borrowed returns). */ + private borrowedReturnKeys(callee: string): string[] | null { + if (!this.borrowedReturns.has(callee)) return null; + const known = this.borrowedReturnKeyCache.get(callee); + if (known) return known; + const rw = this.referenceWrites!; + const keys = new Set(); + const seen = new Set([callee]); + const pending = [callee]; + while (pending.length > 0) { + const fn = this.fnByName.get(pending.pop()!); + if (!fn) continue; + everyStmtList(fn.body, { + stmt: () => true, + expr: (e) => { + if (e.kind === "fieldGet") keys.add(rw.fieldKey(e.className, e.field)); + else if (e.kind === "recordGet") { + keys.add(`r:${e.field}`); + keys.add("r:*"); + } else if (e.kind === "arrayGet") keys.add("a"); + else if (e.kind === "call" && this.borrowedReturns.has(e.callee) && !seen.has(e.callee)) { + seen.add(e.callee); + pending.push(e.callee); + } + return true; + }, + }); + } + const result = [...keys]; + this.borrowedReturnKeyCache.set(callee, result); + return result; + } + // ── statements ────────────────────────────────────────────────────────── - private emitStmts(stmts: IrStmt[]): void { - for (const s of stmts) { - // Statements after a terminator are unreachable (dead code after - // return/break/continue). Skip them so no dropped SSA definition - // can leak forward. - if (this.B.isTerminated()) return; - this.emitStmt(s); + private emitStmts(stmts: IrStmt[], scoped = false): void { + const saved = this.stmtCursor; + const cursor = scoped ? { stmts, index: 0 } : null; + try { + for (let i = 0; i < stmts.length; i++) { + // Statements after a terminator are unreachable (dead code after + // return/break/continue). Skip them so no dropped SSA definition + // can leak forward. + if (this.B.isTerminated()) return; + if (cursor) cursor.index = i; + this.stmtCursor = cursor; + this.emitStmt(stmts[i]!); + } + } finally { + this.stmtCursor = saved; } } @@ -5416,7 +5622,7 @@ export class LlEmitter { const scope: LlScopeEntry[] = []; this.scopes.push(scope); setup?.(scope); - this.emitStmts(stmts); + this.emitStmts(stmts, true); const ended = endsWithJump(stmts); this.scopes.pop(); if (!ended) this.releaseScope(scope); @@ -5476,7 +5682,13 @@ export class LlEmitter { } const localRead = this.localArrayReads.get(s.localId); if (localRead) { - const owner = emitLocalArrayRead(this, localRead, b.slot); + const owner = emitLocalArrayRead( + this, + !localRead.borrow && this.unwrittenLocalArrayRead(s, localRead) + ? { ...localRead, borrow: true } + : localRead, + b.slot, + ); if (owner) this.scopes[this.scopes.length - 1]!.push(owner); break; } @@ -5565,7 +5777,7 @@ export class LlEmitter { b.local && isRefCounted(b.type) && this.stableCallBindings.has(s.localId) && - this.canBorrowCallArgument(s.init) + (this.canBorrowCallArgument(s.init) || this.unwrittenAlias(s)) ) { const v = this.emitReadReceiver(s.init); B.line(`store ptr ${v.name}, ptr ${b.slot}`); @@ -6466,10 +6678,29 @@ export class LlEmitter { // the actual ret. The parked value owns a synthetic slot-backed // scope entry during each copy so a throwing finally releases it. let v: LlValue | null = null; - if (s.value !== null && this.currentBorrowedReturn) { + const moved = this.finallyStack.length === 0 ? this.movableReturn(s.value) : null; + if (moved !== null) { + // Returning an owned local hands its reference to the caller: no + // retain now and no release of the local below. + const t = B.tmp(); + B.line(`${t} = load ptr, ptr ${moved.entry.slot}`); + v = { name: t, type: s.value!.type }; + moved.scope.splice(moved.scope.indexOf(moved.entry), 1); + this.releaseForJump(0, 0); + moved.scope.splice(moved.index, 0, moved.entry); + } else if (s.value !== null && this.currentBorrowedReturn) { // A borrowed-return body hands back its walk without a reference - // (it has no try statements, so no finally can intervene). - v = this.emitReadReceiver(s.value); + // (it has no try statements, so no finally can intervene). An + // optional element read lends the element its array keeps alive. + const read = this.optionalArrayReads.get(s.value); + if (read) { + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca ptr`); + emitLocalArrayRead(this, { ...read, borrow: true }, slot); + const t = B.tmp(); + B.line(`${t} = load ptr, ptr ${slot}`); + v = { name: t, type: s.value.type }; + } else v = this.emitReadReceiver(s.value); } else if (s.value !== null) { v = this.emitExpr(s.value); this.moveTemp(v); @@ -6501,7 +6732,7 @@ export class LlEmitter { this.scopes = savedScopes; this.finallyStack = savedFinally; this.tryStack = savedTry; - } else { + } else if (moved === null) { this.releaseForJump(0, 0); } if (!B.isTerminated()) { @@ -7375,6 +7606,10 @@ export class LlEmitter { if (result.owner) this.ownSlot(result.owner.slot, result.owner.type); return result.value; } + // A borrowed-return helper (such as an optional element read) lends its + // result like any borrowed-return call. + if (e.kind === "call" && this.borrowedReturns.has(e.callee) && this.canBorrowReceiver(e)) + return emitBorrowedResultCall(this, e); const arrayRead = this.optionalArrayReads.get(e); if (arrayRead) return emitProjectedArrayRead(this, arrayRead); const narrow = this.checkedNarrows.get(e); @@ -7698,17 +7933,20 @@ export class LlEmitter { const indexes = this.callLifetimes.borrowed.get(fn.name); return new Set(fn.params.filter((_, i) => indexes?.has(i)).map((p) => p.localId)); }; - const hostFor = (fn: IrFunction) => { + const hostFor = (fn: IrFunction): WalkBorrowHost => { const allowed = borrowedIds(fn); - return { + const host = { pointerLocal: (local: IrLocal) => pointer(local.type), borrowsWithoutOwning: (e: IrExpr) => this.borrowsWithoutOwning(e), borrowedReturn: (callee: string) => this.borrowedReturns.has(callee), parameterAllowed: (id: string) => allowed.has(id), }; + return this.referenceEffects.functions.has(fn.name) ? host : this.unwrittenWalkHost(fn, host); }; - const preserving = [...this.fnByName.values()].filter((fn) => - this.referenceEffects.functions.has(fn.name), + // With ReferenceWrites, a body that writes none of its walks' locations + // qualifies like one that preserves every edge. + const preserving = [...this.fnByName.values()].filter( + (fn) => this.referenceEffects.functions.has(fn.name) || this.referenceWrites !== null, ); const candidates = preserving.filter((fn) => { if (!pointer(fn.returnType) || fn.async || fn.generator || fn.captures) return false; @@ -7728,6 +7966,8 @@ export class LlEmitter { if (this.borrowedReturns.has(fn.name)) continue; if (!analyzeWalks(fn, hostFor(fn)).returnsWalk) continue; this.borrowedReturns.add(fn.name); + // Nested borrowed returns extend the locations a caller depends on. + this.borrowedReturnKeyCache.clear(); changed = true; } } @@ -7752,6 +7992,14 @@ export class LlEmitter { return this.nullableFieldGet(e) === null; case "recordGet": return true; + // emitReadReceiver lends a required element of a borrowed array when + // computing the index removes no edge. + case "arrayGet": + return ( + e.arr.type.kind === "array" && + this.optionalArrayReads.get(e) === null && + this.referenceEffects.preserves(e.index) + ); case "unionNarrow": return this.nullableUnions.has(e.unionId); case "downcast": diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts index d707de555..afd2ae7b0 100644 --- a/packages/compiler/src/backend/llvm/expr-calls.ts +++ b/packages/compiler/src/backend/llvm/expr-calls.ts @@ -25,7 +25,13 @@ import { f64Lit, ffiNativeTypeLl, ffiNativeParamLl, ffiNativeReturnLl } from "./ import { canStackUnion, emitStackUnion } from "./stack-unions.js"; import { emitCallArrayRead } from "./local-array-reads.js"; import { emitStackMapRead } from "./map-read-lifetimes.js"; -import { borrowableInputs, emitBorrowedInput, emitBorrowedInputs } from "./borrowed-inputs.js"; +import { + borrowableInputs, + emitBorrowedInput, + emitBorrowedInputs, + inputSuffixWrites, +} from "./borrowed-inputs.js"; +import type { Writes } from "./reference-writes.js"; export function emitCallExpr( host: LlvmEmitterContext, @@ -58,12 +64,18 @@ export function emitCallExpr( const borrowed = host.callLifetimes.borrowed.get(e.callee); const projected = host.callLifetimes.parameters.get(e.callee); const inputs = borrowed - ? borrowableInputs(host, e.args, host.referenceEffects.functions.has(e.callee)) + ? borrowableInputs( + host, + e.args, + host.referenceEffects.functions.has(e.callee), + host.referenceWrites?.summary(e.callee), + ) : []; // Borrowed snapshots live through this call, not through the whole // enclosing expression. Otherwise a large initializer accumulates // owners and repeats their cleanup at every later throwing call. if (borrowed) host.frames.push([]); + let suffixes: Writes[] | undefined; const args = e.args.map((a, index) => { if (borrowed?.has(index)) { // A heap-borrowing helper may retain or return its argument. @@ -73,7 +85,18 @@ export function emitCallExpr( const callback = host.stackCallbacks.emit(host, a); if (callback) return callback; const read = host.callArrayReads.get(a); - if (read) return emitCallArrayRead(host, read); + if (read) { + suffixes ??= inputSuffixWrites( + host, + e.args, + host.referenceWrites?.summary(e.callee) ?? null, + ); + const lend = + !read.borrow && + index < suffixes.length && + host.unwrittenCallArrayRead(read, suffixes[index]!); + return emitCallArrayRead(host, lend ? { ...read, borrow: true } : read); + } const mapRead = host.mapReadLifetimes.arguments.get(a); if (mapRead) { const result = emitStackMapRead(host, mapRead); @@ -739,6 +762,7 @@ export function emitCallExpr( host, e.args, host.referenceEffects.functions.has(ctorName), + host.referenceWrites?.constructWrites(e.className), ); host.frames.push([]); const args = e.args.map((a, i) => { @@ -951,7 +975,15 @@ export function emitCallExpr( // (LlEmitter.computeVirtualBorrowing): those are passed like borrowed // arguments of a direct call, owned snapshots living in this frame. const borrowed = host.virtualSlotBorrowed(meta.root.def.name, slotIdx); - const inputs = borrowed.size > 0 ? borrowableInputs(host, e.args, false) : []; + const inputs = + borrowed.size > 0 + ? borrowableInputs( + host, + e.args, + false, + host.referenceWrites?.virtualWrites(e.className, e.method), + ) + : []; if (borrowed.size > 0) host.frames.push([]); const args = e.args.map((a, index) => borrowed.has(index) && inputs[index] ? host.emitReadReceiver(a) : host.emitExpr(a), diff --git a/packages/compiler/src/backend/llvm/loop-array-borrows.ts b/packages/compiler/src/backend/llvm/loop-array-borrows.ts index 75435e7a9..07ae3ed9f 100644 --- a/packages/compiler/src/backend/llvm/loop-array-borrows.ts +++ b/packages/compiler/src/backend/llvm/loop-array-borrows.ts @@ -3,18 +3,22 @@ import { everyStmtList } from "../../ir/traverse.js"; import type { CallLifetimes } from "./call-lifetimes.js"; import type { ReferenceEffects } from "./reference-effects.js"; import type { OptionalArrayReads } from "./local-array-reads.js"; +import type { ReferenceWrites } from "./reference-writes.js"; /** Limit a borrow to a loop whose complete iteration preserves heap edges. * Initialization and unrelated work elsewhere in the function may mutate. * The array remains owned by its unchanged local or global binding; the * iteration binding can only project it or forward it to proven consumers. * Callbacks, suspension, capture, reference writes, and escaping uses keep - * the ordinary owned read. Missing elements retain the runtime's checks. */ + * the ordinary owned read. Missing elements retain the runtime's checks. + * With ReferenceWrites, an iteration that writes no array element (nor the + * binding holding the array) suffices, whatever else it writes. */ export function findLoopArrayBorrows( fn: IrFunction, lifetimes: CallLifetimes, effects: ReferenceEffects, optionalReads?: OptionalArrayReads, + writes?: ReferenceWrites | null, ): ReadonlySet { const result = new Set(); if (fn.async || fn.generator) return result; @@ -45,19 +49,28 @@ export function findLoopArrayBorrows( !local.tdz && projected?.has(loop.localId) && bindings?.has(loop.localId) && - effects.preservesScope(loop.body) && - effects.preserves(loop.iterable) + ((effects.preservesScope(loop.body) && effects.preserves(loop.iterable)) || + // The loop owns the evaluated array; only element removals can + // release an element it lends. + (writes && !writes.conflicts(["a"], writes.stmts(loop.body)))) ) result.add(loop); return true; } if (loop.kind !== "for" && loop.kind !== "while" && loop.kind !== "doWhile") return true; - if ( - !effects.preservesScope(loop.body) || - (loop.cond && !effects.preserves(loop.cond)) || - (loop.kind === "for" && loop.update && !effects.preservesScope([loop.update])) - ) - return true; + const preserving = + effects.preservesScope(loop.body) && + (!loop.cond || effects.preserves(loop.cond)) && + (loop.kind !== "for" || !loop.update || effects.preservesScope([loop.update])); + const loopWrites = + preserving || !writes + ? null + : writes.union([ + writes.stmts(loop.body), + loop.cond ? writes.expr(loop.cond) : null, + loop.kind === "for" && loop.update ? writes.stmt(loop.update) : null, + ]); + if (!preserving && !writes) return true; everyStmtList(loop.body, { expr: () => true, stmt: (stmt) => { @@ -71,7 +84,12 @@ export function findLoopArrayBorrows( return true; const source = stmt.init.kind === "arrayGet" ? stmt.init.arr : optionalReads?.get(stmt.init)?.array; - if (source && stableSource(source)) result.add(stmt); + if (!source || !stableSource(source)) return true; + if (preserving) result.add(stmt); + else { + const keys = writes!.readKeys(source); + if (keys && !writes!.conflicts(["a", ...keys], loopWrites)) result.add(stmt); + } return true; }, }); diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts new file mode 100644 index 000000000..fac6fd35c --- /dev/null +++ b/packages/compiler/src/backend/llvm/reference-writes.ts @@ -0,0 +1,1095 @@ +import { + isRefCounted, + type IrClassDef, + type IrExpr, + type IrFunction, + type IrRecordShape, + type IrStmt, + type IrType, + type IrUnionDef, +} from "../../ir/ir.js"; +import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; +import { borrowsMapMutationReceiver, borrowsMapReadInputs } from "./map-read-lifetimes.js"; +import { preservesRegexInputs, VirtualTargets } from "./reference-effects.js"; +import { borrowsStringInputs } from "./string-lifetimes.js"; +import { preservesDynTest } from "./checked-value-lifetimes.js"; +import { isStableReceiverOperand } from "../../ir/analysis.js"; +import type { ImmutableFields } from "../immutability.js"; + +type Call = IrExpr & { kind: "call" }; + +/** A set of reference locations, as a bitset over the interned keys; `null` + * is the empty set. Key 0 (`*`) is an unknown heap write: it covers every + * heap location but no local of the current function. */ +export type Writes = Uint32Array | null; + +/** Every heap location (key 0). */ +const HEAP_KEY = "*"; + +/** Array element slots of every reference array share one location: arrays + * are covariant, so element types do not separate them. */ +const ARRAY_KEY = "a"; +/** Entries of typed maps and sets. */ +const MAP_KEY = "m"; +/** Captured-binding boxes. */ +const BOX_KEY = "bx"; +/** Record fields written through a computed key. */ +const ANY_RECORD_KEY = "r:*"; + +/** The pseudo-function standing for every indirect call target. */ +const ANY_FUNCTION = "%anyFunctionValue"; + +/** Value-producing kinds that only move an existing function value. */ +const FUNCTION_MOVES = new Set([ + "closure", + "selfRef", + "varRef", + "fieldGet", + "recordGet", + "arrayGet", + "call", + "virtualCall", + "callValue", + "ternary", + "logical", + "nullish", + "orDefault", + "unionNarrow", + "seqExpr", + "assignExpr", + "optChain", + "chainRecv", + "upcast", + "downcast", + "mapIntrinsic", + "setIntrinsic", + "arrIntrinsic", + "recordKeyGet", + "unionKeyGet", +]); + +/** Runtime library families that run no user code and never write a + * program reference (scalars, strings, byte buffers, files, clocks). */ +const INERT_LIB_PREFIXES = [ + "math.", + "num.", + "number.", + "string.", + "str.", + "bigint.", + "buffer.", + "arrayBuffer.", + "sharedArrayBuffer.", + "atomics.", + "perf.", + "date.", + "text.", + "path.", + "fs.", + "stats.", + "spawnRes.", +]; +const INERT_LIB_CALLS = new Set([ + "dyn.compare", + "error.new", + "error.nodeThrow", + "dyn.typedRefIs", + "dyn.bagGet", + "json.parse", + "process.stderrWrite", + "process.stdoutWrite", + "process.envGet", + "process.envPairs", + "process.cwd", + "process.argv", + "process.platform", + "process.execPath", + "process.isTTY", + "worker.data", + "worker.isMainThread", + "worker.parentPort", + "worker.root", + "cp.spawnSyncOpts", +]); + +/** Array methods that never release an existing element or run user code. */ +const PRESERVING_ARRAY_METHODS = new Set([ + "length", + "getNumber", + "indexEq", + "push", + "pushSpread", + "concatSpread", + "unshift", + "unshiftSpread", + "indexOf", + "includes", + "nextPresent", + "join", + "slice", + "flatCopy", + "flatOne", + "toReversed", + "toSpliced", + "with", + "withUndefined", + "toSortedPrimitive", + // Permutes primitive payloads in place: every element keeps its owner. + "sortPrimitive", +]); + +/** Which heap reference locations evaluating code may overwrite or remove. + * + * ReferenceEffects answers one bit (does anything remove any edge?). This + * analysis keeps the location, so a borrow that reads `x.f` only conflicts + * with code that can write field `f` of x's hierarchy (or array elements, + * map entries, a global or a captured box, for those reads), not with + * every reference store. Locations: + * + * - `f:.` class fields (all classes of a hierarchy + * that name the field share it, so redeclarations alias); + * - `r:` record fields, plus `r:*` for computed-key writes and + * deletions (every record read also depends on `r:*`); + * - `a` reference array elements, `m` map/set entries, `bx` boxes; + * - `g:` module globals, `l:` the current function's own locals + * (dropped from summaries: a callee cannot rebind its caller's locals); + * - ALL for anything unknown: dynamic code, callbacks, suspension, unknown + * runtime operations. + * + * Stores a constructor makes into its own `this` write a fresh object that + * no borrow outside the construction can reach, so `new` (and `super` + * calls from constructors) use a constructor summary without them, as long + * as constructors are invoked only through `new` and `super`. + * + * Facts belong to one finalized module and one emission. */ +export class ReferenceWrites { + private readonly keys = new Map(); + private readonly roots = new Map(); + private readonly fieldOwners = new Map(); + private readonly classes = new Map(); + private readonly virtualTargets: VirtualTargets; + private readonly summaries = new Map(); + private readonly exprCache = new Map(); + private readonly stmtCache = new Map(); + private words = 0; + /** The unknown heap write alone. */ + heapAll: Uint32Array = new Uint32Array(1).fill(1); + /** Constructors only ever run on fresh objects. */ + private readonly freshConstructors: boolean; + private readonly unions: ReadonlyMap; + private readonly records: ReadonlyMap; + private readonly inertJson = new Map(); + /** Closure bodies an indirect call can reach. */ + private readonly functionValues = new Set(); + private callTargets!: CallTargets; + /** Some function value comes from outside the module's closures. */ + private foreignFunctionValues = false; + private current: IrFunction | null = null; + private currentBoxed = new Set(); + private currentLocals = new Set(); + private constructors: ReadonlySet = new Set(); + private inConstructor = false; + + constructor( + private readonly functions: ReadonlyMap, + classes: readonly IrClassDef[], + private readonly intrinsicCall: (call: Call) => boolean, + private readonly globals: ReadonlySet, + types: { + unions?: readonly IrUnionDef[] | undefined; + records?: readonly IrRecordShape[] | undefined; + } = {}, + /** Fields no code writes once their object is constructed + * (immutability.ts, from cycle-mutability's construction-only facts). */ + private readonly immutable: ImmutableFields | null = null, + ) { + this.key(HEAP_KEY); + this.unions = new Map((types.unions ?? []).map((u) => [u.id, u])); + this.records = new Map((types.records ?? []).map((r) => [r.id, r])); + for (const cls of classes) this.classes.set(cls.name, cls); + this.virtualTargets = new VirtualTargets(classes); + const constructors = new Set(classes.map((cls) => `%${cls.name}.constructor`)); + this.constructors = constructors; + this.freshConstructors = [...functions.values()].every((fn) => + fn.body.every((s) => this.onlySuperCalls(fn, s, constructors)), + ); + this.callTargets = new CallTargets(functions, this.virtualTargets); + this.solve(constructors); + } + + /** Every direct call of a constructor is a `super` call from a + * constructor on its own `this`. */ + private onlySuperCalls(fn: IrFunction, s: IrStmt, constructors: ReadonlySet): boolean { + const thisId = constructors.has(fn.name) ? fn.params[0]?.localId : undefined; + const expr = (e: IrExpr): boolean => { + if (e.kind === "call" && constructors.has(e.callee)) { + const self = e.args[0]; + if (thisId === undefined || !self || !isThisRef(self, thisId)) return false; + } + return everyExprChild(e, expr, stmt); + }; + const stmt = (node: IrStmt): boolean => everyStmtChild(node, expr, stmt); + return stmt(s); + } + + private key(k: string): number { + let id = this.keys.get(k); + if (id === undefined) { + id = this.keys.size; + this.keys.set(k, id); + } + return id; + } + + /** The hierarchy root of a class (runtime bases included). */ + private root(className: string): string { + let r = this.roots.get(className); + if (r !== undefined) return r; + r = className; + for (let cls = this.classes.get(className); cls?.base !== undefined;) { + r = cls.base; + cls = this.classes.get(cls.base); + } + this.roots.set(className, r); + return r; + } + + fieldKey(className: string, field: string): string { + return `f:${this.root(className)}.${field}`; + } + + // ── phase 1: per-function local writes and call edges ───────────────── + + private solve(constructors: ReadonlySet): void { + interface Node { + local: Set; + edges: Set; + } + const nodes = new Map(); + const scan = (fn: IrFunction, external: boolean): Node => { + const node: Node = { local: new Set(), edges: new Set() }; + if (fn.async || fn.generator) { + node.local.add(0); + return node; + } + const thisId = external && constructors.has(fn.name) ? (fn.params[0]?.localId ?? null) : null; + const boxed = boxedLocals(fn); + const sink: WriteSink = { + add: (k) => { + // Interned for queries; a callee's locals are private to it. + const id = this.key(k); + if (!k.startsWith("l:")) node.local.add(id); + }, + all: () => node.local.add(0), + call: (callee, fresh) => { + if (callee === ANY_FUNCTION) { + node.edges.add(ANY_FUNCTION); + return true; + } + if (!this.functions.has(callee)) return false; + node.edges.add( + fresh && this.freshConstructors && constructors.has(callee) ? `${callee}#ext` : callee, + ); + return true; + }, + }; + const expr = (e: IrExpr): boolean => { + this.nodeWrites(e, sink, boxed, fn, constructors); + if (e.kind === "closure") this.functionValues.add(e.fnName); + else if ( + e.type?.kind === "func" && + !FUNCTION_MOVES.has(e.kind) && + !(e.kind === "libCall" && e.fn === "error.nodeThrow") + ) + this.foreignFunctionValues = true; + return everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => { + if (thisId !== null && s.kind === "fieldSet" && isThisRef(s.obj, thisId)) { + // A store into the fresh `this`: no outside borrow can reach it. + } else this.stmtWrites(s, sink, boxed); + return everyStmtChild(s, expr, stmt); + }; + fn.body.every(stmt); + return node; + }; + for (const fn of this.functions.values()) { + nodes.set(fn.name, scan(fn, false)); + if (this.freshConstructors && constructors.has(fn.name)) + nodes.set(`${fn.name}#ext`, scan(fn, true)); + } + // Indirect calls reach any closure body; values made elsewhere + // (dynamic conversions) reach unknown code. + nodes.set(ANY_FUNCTION, { + local: new Set(this.foreignFunctionValues ? [0] : []), + edges: new Set([...this.functionValues].filter((name) => this.functions.has(name))), + }); + this.words = (this.keys.size + 31) >>> 5; + this.heapAll = new Uint32Array(this.words); + this.heapAll[0] = 1; + // Tarjan's SCCs, iteratively; components come out callees first. + const index = new Map(); + const low = new Map(); + const onStack = new Set(); + const stack: string[] = []; + let counter = 0; + for (const start of nodes.keys()) { + if (index.has(start)) continue; + const work: { name: string; edges: Iterator }[] = []; + const enter = (name: string): void => { + index.set(name, counter); + low.set(name, counter++); + stack.push(name); + onStack.add(name); + work.push({ name, edges: nodes.get(name)!.edges.values() }); + }; + enter(start); + while (work.length > 0) { + const top = work[work.length - 1]!; + const next = top.edges.next(); + if (!next.done) { + const callee = next.value; + if (!nodes.has(callee)) continue; + if (!index.has(callee)) enter(callee); + else if (onStack.has(callee)) + low.set(top.name, Math.min(low.get(top.name)!, index.get(callee)!)); + continue; + } + work.pop(); + if (work.length > 0) { + const parent = work[work.length - 1]!.name; + low.set(parent, Math.min(low.get(parent)!, low.get(top.name)!)); + } + if (low.get(top.name) !== index.get(top.name)) continue; + const members: string[] = []; + for (;;) { + const m = stack.pop()!; + onStack.delete(m); + members.push(m); + if (m === top.name) break; + } + const bits = new Uint32Array(this.words); + const inComponent = new Set(members); + for (const m of members) { + const node = nodes.get(m)!; + for (const id of node.local) setBit(bits, id); + for (const callee of node.edges) { + if (inComponent.has(callee)) continue; + const s = this.summaries.get(callee); + if (s === undefined || s === null) continue; + orInto(bits, s); + } + } + // An unknown heap write subsumes the precise heap keys. + const summary: Writes = (bits[0]! & 1) !== 0 ? this.heapAll : anyBit(bits) ? bits : null; + for (const m of members) this.summaries.set(m, summary); + } + } + } + + /** Writes of one expression node itself (children are separate). */ + private nodeWrites( + e: IrExpr, + sink: WriteSink, + boxed: ReadonlySet, + fn: IrFunction | null, + constructors: ReadonlySet, + ): void { + switch (e.kind) { + case "call": { + const fresh = + fn !== null && + constructors.has(e.callee) && + constructors.has(fn.name) && + e.args[0] !== undefined && + isThisRef(e.args[0], fn.params[0]?.localId ?? ""); + if (!sink.call(e.callee, fresh) && !this.intrinsicCall(e)) sink.all(); + return; + } + case "new": + if (!sink.call(`%${e.className}.constructor`, true)) sink.all(); + return; + case "virtualCall": { + const targets = this.virtualTargets.targets(e.className, e.method); + if (targets === null) sink.all(); + else for (const t of targets) if (!sink.call(t, false)) sink.all(); + return; + } + case "assignExpr": + case "incDec": + if (isRefCounted(e.type)) sink.add(this.localKey(e.localId, boxed)); + return; + case "callValue": { + const targets = fn !== null ? this.callTargets.of(e.callee, fn) : null; + if (targets === null) { + if (!sink.call(ANY_FUNCTION, false)) sink.all(); + } else for (const t of targets) if (!sink.call(t, false)) sink.all(); + return; + } + case "arrIntrinsic": + if (!PRESERVING_ARRAY_METHODS.has(e.method)) { + // The comparator sort orders a private snapshot. + if (e.method === "sortValues") { + const comparator = e.args[1]; + const targets = + fn !== null && comparator !== undefined ? this.callTargets.of(comparator, fn) : null; + if (targets === null) { + if (!sink.call(ANY_FUNCTION, false)) sink.all(); + } else for (const t of targets) if (!sink.call(t, false)) sink.all(); + } + sink.add(ARRAY_KEY); + } + return; + case "mapIntrinsic": + case "setIntrinsic": + if (borrowsMapReadInputs(e)) return; + if (borrowsMapMutationReceiver(e)) sink.add(MAP_KEY); + else if (e.receiver.type.kind === "map" || e.receiver.type.kind === "set") { + // Typed collections: copies and iteration bookkeeping run no + // user code; leaving an iteration may compact removed entries. + if (e.method === "iterEnter" || e.method === "iterExit") sink.add(MAP_KEY); + else if ( + e.method !== "clone" && + e.method !== "keySet" && + e.method !== "valueSet" && + e.method !== "toArray" + ) + sink.all(); + } else sink.all(); + return; + case "intrinsic": + // A TDZ test reads; publication only makes objects immortal. + if (e.name !== "module.tdzCheck" && e.name !== "threads.publish") sink.all(); + return; + case "dynFrom": + case "dynArrLit": + case "dynObjLit": + return; + case "dynCheck": + // Identity and kind tests of class and primitive targets. + if (!this.checkedWithoutCode(e.type)) sink.all(); + return; + case "jsonStringify": + if (!this.jsonInert(e.value.type)) sink.all(); + return; + case "bytesNew": + case "bytesIntrinsic": + return; + case "strIntrinsic": + if (!borrowsStringInputs(e.method)) sink.all(); + return; + case "regexIntrinsic": + if (!preservesRegexInputs(e.method)) sink.all(); + return; + case "libCall": + if (!this.inertLibCall(e)) sink.all(); + return; + case "toString": + if (!this.primitive(e.operand.type)) sink.all(); + return; + case "dynTest": + if (!preservesDynTest(e.test)) sink.all(); + return; + // Reads, tests, arithmetic and fresh allocations. + case "numLit": + case "boolLit": + case "strLit": + case "unitLit": + case "varRef": + case "bin": + case "unary": + case "toBool": + case "logical": + case "nullish": + case "ternary": + case "seqExpr": + case "fieldGet": + case "recordGet": + case "unionNarrow": + case "unionIsTag": + case "unionWrap": + case "downcast": + case "upcast": + case "instanceOf": + case "strConcat": + case "strEq": + case "strCmp": + case "arrayGet": + case "arrayHas": + case "arrayState": + case "unionEq": + case "unionFuncEq": + case "dynScalarEq": + case "caughtTest": + case "caughtNarrow": + case "caughtCheck": + case "classRef": + case "recordLit": + case "arrayLit": + case "arrayNewLen": + case "closure": + case "mapNew": + case "setNew": + case "regexLit": + case "recordHas": + case "recordKeyGet": + case "recordOvfHas": + case "recordOvfKeys": + case "fieldAbsent": + case "unionDisc": + case "unionKeyGet": + case "fieldIncDec": + case "optChain": + case "chainRecv": + case "orDefault": + case "instanceOfValue": + case "promiseVoidWiden": + return; + default: + sink.all(); + } + } + + /** Writes of one statement node itself. */ + private stmtWrites(s: IrStmt, sink: WriteSink, boxed: ReadonlySet): void { + switch (s.kind) { + case "assign": + if (isRefCounted(s.value.type)) sink.add(this.localKey(s.localId, boxed)); + return; + case "fieldSet": + if (isRefCounted(s.value.type)) sink.add(this.fieldKey(s.className, s.field)); + return; + case "recordSet": + if (isRefCounted(s.value.type)) sink.add(`r:${s.field}`); + return; + case "recordKeySet": + case "recordKeyDelete": + sink.add(ANY_RECORD_KEY); + return; + case "arraySet": + case "arraySetUndefined": + case "arrayDelete": + case "arraySetLength": + sink.add(ARRAY_KEY); + return; + case "varDecl": + case "exprStmt": + case "return": + case "throw": + case "runtimeFence": + case "if": + case "for": + case "forOf": + case "while": + case "doWhile": + case "block": + case "switch": + case "break": + case "continue": + case "bytesSet": + case "tryCatch": + case "rethrow": + return; + default: + sink.all(); + } + } + + /** Library calls that run no user code: inert families without callback + * arguments, and an Error options bag that is a typed record (no + * accessors) or absent. */ + private inertLibCall(e: IrExpr & { kind: "libCall" }): boolean { + if (e.fn === "error.nodeThrow" || isStableReceiverOperand(e, "")) return true; + if (e.fn === "error.newOptions") + return e.args.every( + (arg) => + arg.type.kind !== "dyn" || + (arg.kind === "dynFrom" && + (arg.value.type.kind === "record" || this.primitive(arg.value.type))), + ); + if (!INERT_LIB_CALLS.has(e.fn) && !INERT_LIB_PREFIXES.some((p) => e.fn.startsWith(p))) + return false; + return e.args.every( + (arg) => + arg.type.kind !== "func" && + (arg.type.kind !== "dyn" || + (e.fn !== "dyn.compare" && e.fn.startsWith("dyn.")) || + // A wrapped primitive has no methods to call back into. + (arg.kind === "dynFrom" && this.primitive(arg.value.type))), + ); + } + + /** Numbers, booleans, strings and unit values (and unions of them). */ + private primitive(t: IrType): boolean { + switch (t.kind) { + case "f64": + case "bool": + case "string": + case "undefinedT": + case "nullT": + return true; + case "union": + return this.unions.get(t.unionId)?.arms.every((arm) => this.primitive(arm)) ?? false; + default: + return false; + } + } + + /** A dynamic-to-typed check that only tests identity or kind. */ + private checkedWithoutCode(t: IrType): boolean { + if (t.kind === "object" || this.primitive(t)) return true; + if (t.kind !== "union") return false; + return ( + this.unions + .get(t.unionId) + ?.arms.every((arm) => arm.kind === "object" || this.primitive(arm)) ?? false + ); + } + + /** JSON.stringify of this type calls no toJSON or accessor: primitives + * and records/arrays/unions of them (class instances may define both). */ + private jsonInert(t: IrType): boolean { + if (this.primitive(t)) return true; + const key = JSON.stringify(t); + const known = this.inertJson.get(key); + if (known !== undefined) return known; + this.inertJson.set(key, true); // recursive shapes: assume, then confirm + let result: boolean; + if (t.kind === "array") result = this.jsonInert(t.elem); + else if (t.kind === "record") + result = this.records.get(t.shapeId)?.fields.every((f) => this.jsonInert(f.type)) ?? false; + else if (t.kind === "union") + result = this.unions.get(t.unionId)?.arms.every((arm) => this.jsonInert(arm)) ?? false; + else result = false; + this.inertJson.set(key, result); + return result; + } + + private localKey(id: string, boxed: ReadonlySet, locals?: ReadonlySet): string { + if (this.globals.has(id)) return `g:${id}`; + // A binding of another function (never expected) counts as a box. + if (boxed.has(id) || (locals !== undefined && !locals.has(id))) return BOX_KEY; + return `l:${id}`; + } + + // ── queries ──────────────────────────────────────────────────────────── + + /** Start answering expression queries for one function body. */ + enter(fn: IrFunction): void { + this.current = fn; + this.currentBoxed = boxedLocals(fn); + this.currentLocals = new Set(fn.locals.map((local) => local.id)); + this.inConstructor = this.constructors.has(fn.name); + for (const p of fn.params) this.currentLocals.add(p.localId); + this.exprCache.clear(); + this.stmtCache.clear(); + } + + private readonly bodyWrites = new Map(); + + /** Whether a walk of `fn` reads only locations its whole body never + * writes; the candidate walk locals' own rebinding is exempt. */ + unwrittenWalk( + fn: IrFunction, + value: IrExpr, + candidates: ReadonlySet, + call: (e: Call) => readonly string[] | null, + ): boolean { + if (this.current !== fn) this.enter(fn); + const keys = this.readKeys(value, call); + if (keys === null) return false; + let body = this.bodyWrites.get(fn.name); + if (body === undefined) { + body = this.stmts(fn.body); + this.bodyWrites.set(fn.name, body); + } + return !this.conflicts( + keys.filter((k) => !(k.startsWith("l:") && candidates.has(k.slice(2)))), + body, + ); + } + + /** The transitive writes of a call to a module function (`#ext` for a + * constructor reached through `new`). */ + summary(name: string): Writes { + const known = this.summaries.get(name); + return known === undefined ? this.heapAll : known; + } + + constructWrites(className: string): Writes { + const ctor = `%${className}.constructor`; + if (!this.functions.has(ctor)) return this.heapAll; + return this.summary(this.freshConstructors ? `${ctor}#ext` : ctor); + } + + virtualWrites(className: string, method: string): Writes { + const targets = this.virtualTargets.targets(className, method); + if (targets === null) return this.heapAll; + return this.union(targets.map((t) => this.summary(t))); + } + + /** Writes of evaluating an expression, with its operands. */ + expr(e: IrExpr): Writes { + const known = this.exprCache.get(e); + if (known !== undefined) return known; + const parts: Writes[] = []; + const sink = this.querySink(parts); + this.nodeWrites(e, sink, this.currentBoxed, this.current, new Set()); + everyExprChild( + e, + (child) => (parts.push(this.expr(child)), true), + (child) => (parts.push(this.stmt(child)), true), + ); + const result = this.union(parts); + this.exprCache.set(e, result); + return result; + } + + stmt(s: IrStmt): Writes { + const known = this.stmtCache.get(s); + if (known !== undefined) return known; + const parts: Writes[] = []; + this.stmtWrites(s, this.querySink(parts), this.currentBoxed); + everyStmtChild( + s, + (child) => (parts.push(this.expr(child)), true), + (child) => (parts.push(this.stmt(child)), true), + ); + const result = this.union(parts); + this.stmtCache.set(s, result); + return result; + } + + stmts(list: readonly IrStmt[]): Writes { + return this.union(list.map((s) => this.stmt(s))); + } + + private querySink(parts: Writes[]): WriteSink { + return { + add: (k) => parts.push(this.bitsOf([k])), + all: () => parts.push(this.heapAll), + call: (callee, fresh) => { + if (callee === ANY_FUNCTION) { + parts.push(this.summary(ANY_FUNCTION)); + return true; + } + if (!this.functions.has(callee)) return false; + const ctor = fresh && callee.endsWith(".constructor") && this.freshConstructors; + parts.push(this.summary(ctor ? `${callee}#ext` : callee)); + return true; + }, + }; + } + + union(parts: readonly Writes[]): Writes { + let out: Uint32Array | null = null; + for (const p of parts) { + if (p === null) continue; + if (out === null) out = new Uint32Array(this.words); + orInto(out, p); + } + return out; + } + + /** A bitset over known keys; keys nothing writes are dropped. */ + bitsOf(keys: readonly string[]): Writes { + let out: Uint32Array | null = null; + for (const k of keys) { + const id = this.keys.get(k); + if (id === undefined || id >= this.words * 32) continue; + if (out === null) out = new Uint32Array(this.words); + setBit(out, id); + } + return out; + } + + /** Whether writes may touch any of the read keys. */ + conflicts(reads: readonly string[], writes: Writes): boolean { + if (writes === null) return false; + const heap = (writes[0]! & 1) !== 0; + for (const k of reads) { + if (heap && !k.startsWith("l:")) return true; + const id = this.keys.get(k); + if (id === undefined || id >= this.words * 32) continue; + if ((writes[id >>> 5]! & (1 << (id & 31))) !== 0) return true; + } + return false; + } + + /** The locations whose writes could release the object a borrowed read + * of `e` yields, or null when `e` is not such a projection. `call` + * resolves borrowed-return callees to the locations their walks read. */ + readKeys(e: IrExpr, call?: (e: Call) => readonly string[] | null): string[] | null { + const out: string[] = []; + const walk = (node: IrExpr): boolean => { + switch (node.kind) { + case "varRef": + out.push(this.localKey(node.localId, this.currentBoxed, this.currentLocals)); + return true; + case "fieldGet": + // A construction-only field of an existing object never changes; + // only a constructor can still be writing it (its own `this`). + if (this.inConstructor || !this.immutable?.classField(node.className, node.field)) + out.push(this.fieldKey(node.className, node.field)); + return walk(node.obj); + case "recordGet": + if (this.inConstructor || !this.immutable?.recordField(node.shapeId, node.field)) + out.push(`r:${node.field}`, ANY_RECORD_KEY); + return walk(node.obj); + case "arrayGet": + out.push(ARRAY_KEY); + return this.expr(node.index) === null && walk(node.arr); + case "unionNarrow": + case "upcast": + case "downcast": + return walk(node.value); + case "unionWrap": + return node.value.kind === "unitLit" || walk(node.value); + case "ternary": + return this.expr(node.cond) === null && armWalk(node.then) && armWalk(node.else_); + case "seqExpr": { + // Guarded projections: `(check, value)` where the checks only + // read and throw. + if (this.stmts(node.stmts) !== null) return false; + return walk(node.result); + } + case "strLit": + return true; + case "call": { + const keys = call?.(node) ?? null; + if (keys === null) return false; + out.push(...keys); + return node.args.every((arg) => !isRefCounted(arg.type) || walk(arg)); + } + default: + return false; + } + }; + const armWalk = (node: IrExpr): boolean => + (node.kind === "libCall" && node.fn === "error.nodeThrow") || walk(node); + return walk(e) ? out : null; + } +} + +interface WriteSink { + add(key: string): void; + all(): void; + /** False when the callee is not a module function. */ + call(callee: string, fresh: boolean): boolean; +} + +function isThisRef(e: IrExpr, thisId: string): boolean { + if (e.kind === "varRef") return e.localId === thisId; + if (e.kind === "upcast") return isThisRef(e.value, thisId); + return false; +} + +/** Locals living in boxes: declared boxed or captured from an enclosing + * function. */ +function boxedLocals(fn: IrFunction): Set { + const out = new Set(); + for (const local of fn.locals) if (local.boxed) out.add(local.id); + for (const c of fn.captures ?? []) out.add(c.localId); + for (const c of fn.classCaptures ?? []) out.add(c.localId); + return out; +} + +/** The closure bodies a function value can be, when it flows only from + * closure expressions through stable locals and the function-typed + * parameters of functions that are only called directly (null: any + * function value). A small flow analysis over parameter positions: each + * position collects the targets every direct, virtual and `new` call site + * passes, to a fixpoint. */ +class CallTargets { + private readonly sites = new Map(); + private readonly opaque = new Set(); + private readonly params = new Map | null>(); + private readonly definitions = new Map>(); + /** Functions creating each closure body. */ + private readonly creators = new Map(); + + constructor( + private readonly functions: ReadonlyMap, + virtualTargets: VirtualTargets, + ) { + const site = (callee: string, caller: IrFunction, args: readonly IrExpr[]): void => { + let list = this.sites.get(callee); + if (!list) this.sites.set(callee, (list = [])); + list.push({ caller, args }); + }; + for (const fn of functions.values()) { + if (fn.captures !== undefined || fn.classCaptures !== undefined) this.opaque.add(fn.name); + const expr = (e: IrExpr): boolean => { + if (e.kind === "call") site(e.callee, fn, e.args); + else if (e.kind === "new") site(`%${e.className}.constructor`, fn, [e, ...e.args]); + else if (e.kind === "virtualCall") { + const targets = virtualTargets.targets(e.className, e.method); + for (const t of targets ?? []) site(t, fn, e.args); + } else if (e.kind === "closure") { + this.opaque.add(e.fnName); + let list = this.creators.get(e.fnName); + if (!list) this.creators.set(e.fnName, (list = [])); + list.push(fn); + } + return everyExprChild(e, expr, stmt); + }; + const stmt = (st: IrStmt): boolean => everyStmtChild(st, expr, stmt); + fn.body.every(stmt); + } + // Fixpoint over function-typed parameter positions. + const positions: [IrFunction, number][] = []; + for (const fn of functions.values()) + fn.params.forEach((p, i) => { + if (p.type.kind === "func") { + positions.push([fn, i]); + this.params.set(`${fn.name}#${i}`, new Set()); + } + }); + for (let changed = true; changed;) { + changed = false; + for (const [fn, i] of positions) { + const key = `${fn.name}#${i}`; + const known = this.params.get(key); + if (known === null) continue; + const sites = this.sites.get(fn.name); + let next: Set | null = new Set(known!); + if (this.opaque.has(fn.name) || !sites || this.assigned(fn, fn.params[i]!.localId)) + next = null; + else + for (const { caller, args } of sites) { + const arg = args[i]; + const t = arg === undefined ? null : this.resolve(arg, caller, 0); + if (t === null) { + next = null; + break; + } + for (const name of t) next.add(name); + } + if (next === null || next.size !== known!.size) { + this.params.set(key, next); + changed = true; + } + } + } + } + + of(callee: IrExpr, fn: IrFunction): readonly string[] | null { + const t = this.resolve(callee, fn, 0); + return t === null ? null : [...t]; + } + + private resolve(e: IrExpr, fn: IrFunction, depth: number): ReadonlySet | null { + if (depth > 8) return null; + switch (e.kind) { + case "closure": + return new Set([e.fnName]); + case "ternary": + case "logical": + case "nullish": + case "orDefault": { + const arms = e.kind === "ternary" ? [e.then, e.else_] : [e.left, e.right]; + const out = new Set(); + for (const arm of arms) { + if (arm.type.kind !== "func") { + if (arm.kind === "libCall" && arm.fn === "error.nodeThrow") continue; + if (e.kind === "ternary") return null; + continue; // the tested operand of `a ?? f`, `a || f` + } + const t = this.resolve(arm, fn, depth + 1); + if (t === null) return null; + for (const name of t) out.add(name); + } + return out; + } + case "varRef": { + if ((fn.captures ?? []).some((c) => c.localId === e.localId)) { + // A captured binding nothing rebinds holds what it held in every + // function creating this closure. + const creators = this.creators.get(fn.name); + if (!creators || this.assigned(fn, e.localId)) return null; + const out = new Set(); + for (const creator of creators) { + const t = this.resolve(e, creator, depth + 1); + if (t === null) return null; + for (const name of t) out.add(name); + } + return out; + } + const index = fn.params.findIndex((p) => p.localId === e.localId); + if (index >= 0) { + if (this.assigned(fn, e.localId)) return null; + return this.params.get(`${fn.name}#${index}`) ?? null; + } + const def = this.definition(fn, e.localId); + return def ? this.resolve(def, fn, depth + 1) : null; + } + case "call": { + // A function value returned by a module function: whatever every + // return yields (adapters return closures over their argument). + const callee = this.functions.get(e.callee); + if (!callee || callee.async || callee.generator) return null; + const out = new Set(); + let ok = true; + let any = false; + const stmt = (st: IrStmt): boolean => { + if (st.kind === "return") { + any = true; + const t = st.value ? this.resolve(st.value, callee, depth + 1) : null; + if (t === null) return (ok = false); + for (const name of t) out.add(name); + } + return everyStmtChild(st, () => true, stmt); + }; + callee.body.every(stmt); + return ok && any ? out : null; + } + default: + return null; + } + } + + /** The single initializer of a never-assigned, unboxed local. */ + private definition(fn: IrFunction, id: string): IrExpr | null { + let defs = this.definitions.get(fn); + if (!defs) { + defs = new Map(); + const boxed = new Set(fn.locals.filter((l) => l.boxed).map((l) => l.id)); + const counts = new Map(); + const expr = (e: IrExpr): boolean => { + if (e.kind === "assignExpr" || e.kind === "incDec") counts.set(e.localId, 2); + return everyExprChild(e, expr, stmt); + }; + const stmt = (st: IrStmt): boolean => { + if (st.kind === "varDecl") { + counts.set(st.localId, (counts.get(st.localId) ?? 0) + 1); + defs!.set(st.localId, st.init); + } else if (st.kind === "assign") counts.set(st.localId, 2); + return everyStmtChild(st, expr, stmt); + }; + fn.body.every(stmt); + for (const [local, n] of counts) if (n !== 1 || boxed.has(local)) defs.set(local, null); + this.definitions.set(fn, defs); + } + return defs.get(id) ?? null; + } + + private assigned(fn: IrFunction, id: string): boolean { + this.definition(fn, id); + const defs = this.definitions.get(fn)!; + return defs.has(id); + } +} + +function setBit(bits: Uint32Array, id: number): void { + const w = id >>> 5; + bits[w] = bits[w]! | (1 << (id & 31)); +} + +function orInto(bits: Uint32Array, other: Uint32Array): void { + for (let w = 0; w < bits.length; w++) bits[w] = bits[w]! | other[w]!; +} + +function anyBit(bits: Uint32Array): boolean { + for (let w = 0; w < bits.length; w++) if (bits[w] !== 0) return true; + return false; +} diff --git a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts index 39b8cfe06..bfcaeeb6c 100644 --- a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts +++ b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts @@ -167,18 +167,23 @@ test("string operands preserve their left-to-right snapshot when a later assignm expect(facts(f).parameters.has("replace")).toBe(false); }); -test("a later global write keeps mutable string arguments owned", () => { +test("a callee's global write cannot replace the caller's mutable string locals", () => { const source = fn("source", [], str("next")); source.body.unshift({ kind: "assign", localId: "%g.changed", value: str("effect"), loc }); const compare = fn("compare", ["text"], equal(ref("text"), call("source", [], STRING))); - // A later write anywhere in this function rules out parameter borrowing. + // A write anywhere in this function rules out parameter borrowing, but + // the rebound local itself owns its value across the later operand. compare.body.unshift({ kind: "assign", localId: "text", value: str("first"), loc }); const module = mod(source, compare); module.globals = [{ id: "%g.changed", name: "changed", type: STRING, mutable: true }]; const ir = body(module, "sc_f_compare"); const sourceCall = ir.indexOf("@sc_f_source"); expect(sourceCall).toBeGreaterThan(0); - expect(ir.slice(0, sourceCall)).toContain("@scr_str_retain_v"); + expect(ir.slice(0, sourceCall)).not.toContain("@scr_str_retain_v"); + // Reading the global the later operand replaces still takes a snapshot. + compare.body = [ret(equal(ref("%g.changed"), call("source", [], STRING)))]; + const global = body(module, "sc_bf_compare"); + expect(global.slice(0, global.indexOf("@sc_f_source"))).toContain("@scr_str_retain_v"); }); test("immutable lexical owners survive arbitrary later operands", () => { @@ -214,24 +219,22 @@ test("reference fields borrow across preserving calls but snapshot across writes source.locals.push({ id: "changed", name: "changed", type: STRING, mutable: true }); source.body.unshift({ kind: "assign", localId: "changed", value: str("effect"), loc }); expect(body(module, "sc_bf_compare")).not.toContain("@scr_str_retain_v"); + // A global write replaces no record field: the projection still borrows. source.body.unshift({ kind: "assign", localId: "%g.changed", value: str("effect"), loc }); - module.globals = [{ id: "%g.changed", name: "changed", type: STRING, mutable: true }]; - // The field is never written after its record literal, so no call can - // replace (and release) it while the holder lives. + module.globals = [ + { id: "%g.changed", name: "changed", type: STRING, mutable: true }, + { id: "%g.record", name: "record", type: record, mutable: true }, + ]; expect(body(module, "sc_bf_compare")).not.toContain("@scr_str_retain_v"); - // Once the program writes the field, a reference-writing call snapshots it. - const writer = fn("writer", [], str("written")); - writer.params = [{ localId: "target", name: "target", type: record }]; - writer.locals = [{ id: "target", name: "target", type: record, mutable: false }]; - writer.body.unshift({ + // Writing the projected field of any record does replace it. + source.body.unshift({ kind: "recordSet", - obj: ref("target", record), + obj: ref("%g.record", record), shapeId: "text", field: "value", - value: str("written"), + value: str("effect"), loc, }); - module.functions.push(writer); expect(body(module, "sc_bf_compare")).toContain("@scr_str_retain_v"); f.body = [ret(equal(str("constant"), projection))]; expect(body(module, "sc_bf_compare")).not.toContain("@scr_str_retain_v"); @@ -358,7 +361,7 @@ test.each(methodCases)( }, ); -test("string search snapshots both receiver and needle before a numeric argument writes globally", () => { +test("string search borrows rebound locals but snapshots a global a later argument writes", () => { const position = fn("position", [], num(0)); position.body.unshift({ kind: "assign", localId: "%g.changed", value: str("effect"), loc }); const search = fn( @@ -377,9 +380,16 @@ test("string search snapshots both receiver and needle before a numeric argument const searchCall = ir.indexOf("@scr_str_index_of"); expect(start).toBeGreaterThan(0); expect(searchCall).toBeGreaterThan(start); - // Literals need no owner, but both reads take independent snapshots. - expect(ir.slice(0, start).match(/@scr_str_retain_v/g)).toHaveLength(2); - expect(ir.slice(searchCall)).toContain("@scr_str_release"); + // The rebound locals own their values across the call: no snapshots. + expect(ir.slice(0, start)).not.toContain("@scr_str_retain_v"); + // A receiver read from the global the numeric argument replaces does. + search.body = [ + ret(intrinsic(ref("%g.changed"), "indexOf", [ref("needle"), call("position", [], F64)], F64)), + ]; + const global = body(module, "sc_bf_search"); + const call2 = global.indexOf("@sc_f_position"); + expect(global.slice(0, call2).match(/@scr_str_retain_v/g)).toHaveLength(1); + expect(global.slice(global.indexOf("@scr_str_index_of"))).toContain("@scr_str_release"); }); test("global string ownership cannot be inferred from an immutable-looking reference", () => { diff --git a/packages/compiler/src/backend/llvm/walk-borrows.ts b/packages/compiler/src/backend/llvm/walk-borrows.ts index 7ba048573..dc3e03c19 100644 --- a/packages/compiler/src/backend/llvm/walk-borrows.ts +++ b/packages/compiler/src/backend/llvm/walk-borrows.ts @@ -17,6 +17,11 @@ export interface WalkBorrowHost { /** Parameters that may become candidates; once calling conventions are * fixed, only borrowed parameters qualify. Defaults to every parameter. */ parameterAllowed?(localId: string): boolean; + /** For bodies that may write the heap: whether a walk only reads + * locations the whole body never writes, given the current candidates + * (whose own rebinding releases nothing). Absent: the body preserves + * every edge. */ + unwritten?(value: IrExpr, candidates: ReadonlySet): boolean; } export interface WalkFacts { @@ -34,7 +39,9 @@ export interface WalkFacts { * calling convention. * * Soundness rests on the whole body preserving heap edges (the caller - * passes only functions in ReferenceEffects.functions): neither the body + * passes only functions in ReferenceEffects.functions, or supplies + * `unwritten` to admit only walks over locations the body never writes; + * the argument below then applies to those locations): neither the body * nor anything it calls removes a field, element or global reference, so * every object reachable from a parameter at entry stays reachable (and * alive) until the function returns. Such a local therefore needs no @@ -133,6 +140,10 @@ export function analyzeWalks(fn: IrFunction, host: WalkBorrowHost): WalkFacts { case "fieldGet": case "recordGet": return host.borrowsWithoutOwning(e) && walk(e.obj); + // A required element of a walked array (the read still traps when the + // element is missing). + case "arrayGet": + return host.borrowsWithoutOwning(e) && walk(e.arr); case "unionNarrow": case "downcast": case "upcast": @@ -155,12 +166,20 @@ export function analyzeWalks(fn: IrFunction, host: WalkBorrowHost): WalkFacts { return false; } }; - const walkOrThrow = (e: IrExpr): boolean => - (e.kind === "libCall" && e.fn === "error.nodeThrow") || walk(e); + const isThrow = (e: IrExpr): boolean => e.kind === "libCall" && e.fn === "error.nodeThrow"; + const walkOrThrow = (e: IrExpr): boolean => isThrow(e) || walk(e); for (let changed = true; changed;) { changed = false; for (const id of candidates) { - if (definitions.get(id)!.every((value) => value === null || walk(value))) continue; + if ( + definitions + .get(id)! + .every( + (value) => + value === null || (walk(value) && host.unwritten?.(value, candidates) !== false), + ) + ) + continue; candidates.delete(id); changed = true; } @@ -169,6 +188,11 @@ export function analyzeWalks(fn: IrFunction, host: WalkBorrowHost): WalkFacts { const returnsWalk = !guarded && returns.length > 0 && - returns.every((value) => value !== null && walkOrThrow(value)); + returns.every( + (value) => + value !== null && + walkOrThrow(value) && + (isThrow(value) || host.unwritten?.(value, candidates) !== false), + ); return { locals: result, returnsWalk }; } diff --git a/packages/compiler/test/call-lifetimes.test.ts b/packages/compiler/test/call-lifetimes.test.ts index dde71c67c..646e901c9 100644 --- a/packages/compiler/test/call-lifetimes.test.ts +++ b/packages/compiler/test/call-lifetimes.test.ts @@ -329,11 +329,15 @@ console.log(alias(new Item()).value, snapshot(new Item()).value); // The rebound parameter keeps borrowing (its new value lives in an owner // slot), but the alias saved before the rebinding must own its value. // `new Item()` calls the borrowed constructor body, so no retain transfers - // the fresh instance into it: the saved alias and the returned value. + // the fresh instance into it. The saved alias retains once, and `return + // saved` moves that owned scope entry into the result instead of retaining + // it again and releasing the local at scope exit: the only releases are the + // rebound parameter's owner slot (before the store and at exit). const snapshot = body(llvm, "sc_bf_snapshot"); - expect(snapshot.match(/@sc_retain_Item/g)).toHaveLength(2); + expect(snapshot.match(/@sc_retain_Item/g)).toHaveLength(1); expect(snapshot).toContain("@sc_bf__x25_Item_constructor"); - expect(snapshot).toContain("@sc_release_Item"); + expect(snapshot.match(/@sc_release_Item/g)).toHaveLength(2); + expect(snapshot).toMatch(/(%t\d+) = load ptr, ptr %sc_l_saved_0\n[^]*ret ptr \1\n/); }); test("unchanged captured parameters are boxed when a closure first needs them", async () => { diff --git a/packages/compiler/test/string-and-array-lifetimes.test.ts b/packages/compiler/test/string-and-array-lifetimes.test.ts index 4f59d8bbc..183a1a426 100644 --- a/packages/compiler/test/string-and-array-lifetimes.test.ts +++ b/packages/compiler/test/string-and-array-lifetimes.test.ts @@ -217,15 +217,39 @@ test("a throwing later source argument releases the retained snapshot on its exc const module = await lower(` class Item { value = 7; } function read(item: Item, ignored: number): number { return item.value + ignored; } +function fail(items: Item[]): number { items.pop(); throw new Error("failed"); } +function work(items: Item[]): number { return read(items[0], fail(items)); } +try { console.log(work([new Item()])); } catch {} +`); + // The later argument removes the element, so the snapshot must own it. + const work = body(emitLlvmModule(module), "sc_bf_work"); + const failure = work.indexOf("@sc_bf_fail"); + expect(failure).toBeGreaterThan(0); + expect(work.slice(0, failure)).toContain("@sc_retain_Item"); + expect(work.slice(failure)).toMatch(PENDING_CHECK); + const exceptionEdge = work.slice(failure, work.indexOf("@sc_bf_read")); + expect(exceptionEdge).toContain("@sc_release_Item"); + expect(work).not.toContain("@scr_union_release"); +}); + +test("a throwing later source argument that writes no element leaves the snapshot borrowed", async () => { + const module = await lower(` +class Item { value = 7; } +function read(item: Item, ignored: number): number { return item.value + ignored; } function fail(): number { throw new Error("failed"); } function work(items: Item[]): number { return read(items[0], fail()); } try { console.log(work([new Item()])); } catch {} `); + // Neither the callee nor the later argument writes an array element (the + // Error constructor's stores into its own fresh object do not count), so + // the borrowed parameter's array keeps the element alive and no edge, + // normal or exceptional, owes a release. const work = body(emitLlvmModule(module), "sc_bf_work"); const failure = work.indexOf("@sc_f_fail"); expect(failure).toBeGreaterThan(0); expect(work.slice(failure)).toMatch(PENDING_CHECK); - expect(work.slice(failure)).toContain("@sc_release_Item"); + expect(work).not.toContain("@sc_retain_Item"); + expect(work).not.toContain("@sc_release_Item"); expect(work).not.toContain("@scr_union_release"); }); diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index d4a9f1be3..424c85882 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -10493,24 +10493,6 @@ ], "diags": [] }, - "/tests/corpus/missing-read-lambda-results.ts": { - "order": [ - "/tests/corpus/missing-read-lambda-results.ts" - ], - "diags": [] - }, - "/tests/corpus/missing-read-narrowed-storage.ts": { - "order": [ - "/tests/corpus/missing-read-narrowed-storage.ts" - ], - "diags": [] - }, - "/tests/corpus/missing-read-number-conversions.ts": { - "order": [ - "/tests/corpus/missing-read-number-conversions.ts" - ], - "diags": [] - }, "/tests/corpus/module-fallback-functions/main.js": { "order": [ "/tests/corpus/module-fallback-functions/platform.js", @@ -15797,6 +15779,54 @@ ], "diags": [] }, + "/tests/corpus/4630-reference-write-call-arguments.ts": { + "order": [ + "/tests/corpus/4630-reference-write-call-arguments.ts" + ], + "diags": [] + }, + "/tests/corpus/4631-reference-write-aliases.ts": { + "order": [ + "/tests/corpus/4631-reference-write-aliases.ts" + ], + "diags": [] + }, + "/tests/corpus/4632-reference-write-loops-and-walks.ts": { + "order": [ + "/tests/corpus/4632-reference-write-loops-and-walks.ts" + ], + "diags": [] + }, + "/tests/corpus/4633-reference-write-callbacks-returns.ts": { + "order": [ + "/tests/corpus/4633-reference-write-callbacks-returns.ts" + ], + "diags": [] + }, + "/tests/corpus/4634-optional-element-borrowed-returns.ts": { + "order": [ + "/tests/corpus/4634-optional-element-borrowed-returns.ts" + ], + "diags": [] + }, + "/tests/corpus/missing-read-lambda-results.ts": { + "order": [ + "/tests/corpus/missing-read-lambda-results.ts" + ], + "diags": [] + }, + "/tests/corpus/missing-read-narrowed-storage.ts": { + "order": [ + "/tests/corpus/missing-read-narrowed-storage.ts" + ], + "diags": [] + }, + "/tests/corpus/missing-read-number-conversions.ts": { + "order": [ + "/tests/corpus/missing-read-number-conversions.ts" + ], + "diags": [] + }, "/tests/corpus/native-worker-arena-reuse.ts": { "order": [ "/tests/corpus/native-worker-arena-reuse.ts" diff --git a/tests/corpus/4630-reference-write-call-arguments.ts b/tests/corpus/4630-reference-write-call-arguments.ts new file mode 100644 index 000000000..6a2160bd2 --- /dev/null +++ b/tests/corpus/4630-reference-write-call-arguments.ts @@ -0,0 +1,119 @@ +// Field reads and rebound locals passed to borrowing callees: borrowed only +// when neither the callee nor a later argument can replace them. Each +// "replaces" case makes the field the value's last owner, frees it inside +// the call, churns the allocator and then reads the argument again. + +class Name { + text: string; + parts: string[]; + constructor(text: string) { + this.text = text; + this.parts = text.split(""); + } +} + +class Holder { + name: Name | undefined; + other: Name | undefined = undefined; + items: Name[] = []; + constructor(name: Name) { + this.name = name; + } +} + +function churn(): number { + let total = 0; + for (let i = 0; i < 200; i++) total += new Name("churn-" + i).parts.length; + return total; +} + +function describe(n: Name | undefined): string { + if (n === undefined) return "none"; + return n.text + ":" + n.parts.length; +} + +// Writes an unrelated field: the caller may lend `h.name`. +function touchOther(h: Holder, n: Name | undefined): string { + h.other = new Name("other"); + churn(); + return describe(n); +} + +// Replaces the very field the caller passed: the argument must be owned. +function replaceName(h: Holder, n: Name | undefined): string { + h.name = new Name("replacement"); + churn(); + return describe(n); +} + +// Removes array elements: an element argument must be owned. +function dropItems(h: Holder, n: Name): string { + h.items.length = 0; + churn(); + return describe(n); +} + +function compose(a: string, b: string): string { + return a + "|" + b; +} + +function later(h: Holder): string { + h.name = new Name("later"); + churn(); + return "later"; +} + +// A later argument replaces the field an earlier argument read. +function laterArgument(h: Holder): string { + return compose(describe2(h.name), later(h)); +} +function describe2(n: Name | undefined): string { + churn(); + return describe(n); +} + +// A rebound parameter passed along: no callee can rebind the caller's local. +function rebound(n: Name, h: Holder): string { + if (n.text.length > 3) n = h.name ?? n; + return replaceAndDescribe(n, h) + " " + describe(n); +} +function replaceAndDescribe(n: Name, h: Holder): string { + h.name = undefined; + churn(); + return describe(n); +} + +// A constructor's stores into its own fresh object replace nothing the +// caller lends; a constructor storing into another object does. +class Attach { + label: Name; + constructor(h: Holder, label: Name) { + h.name = undefined; + churn(); + this.label = new Name("own-" + label.text); + } +} +function construct(h: Holder): string { + const a = new Attach(h, h.name!); + return a.label.text; +} + +const h1 = new Holder(new Name("first")); +console.log(touchOther(h1, h1.name)); +const h2 = new Holder(new Name("second")); +console.log(replaceName(h2, h2.name), describe(h2.name)); +const h3 = new Holder(new Name("third")); +h3.items.push(new Name("item0"), new Name("item1")); +console.log(dropItems(h3, h3.items[1]!)); +const h4 = new Holder(new Name("fourth")); +console.log(laterArgument(h4), describe(h4.name)); +const h5 = new Holder(new Name("fifth-name")); +console.log(rebound(new Name("sixth"), h5)); +const h6 = new Holder(new Name("seventh")); +console.log(construct(h6), describe(h6.name)); +let sum = 0; +for (let i = 0; i < 50; i++) { + const h = new Holder(new Name("loop" + i)); + sum += touchOther(h, h.name).length + replaceName(h, h.name).length; +} +console.log("sum", sum); diff --git a/tests/corpus/4631-reference-write-aliases.ts b/tests/corpus/4631-reference-write-aliases.ts new file mode 100644 index 000000000..4d5e04ecb --- /dev/null +++ b/tests/corpus/4631-reference-write-aliases.ts @@ -0,0 +1,116 @@ +// `const t = o.f` borrows the field only when the rest of its block writes +// none of the locations keeping the value alive; returning or storing the +// alias still takes its own reference. + +class Leaf { + text: string; + constructor(text: string) { + this.text = text; + } +} + +class Box { + leaf: Leaf; + spare: Leaf | undefined = undefined; + list: Leaf[] = []; + constructor(leaf: Leaf) { + this.leaf = leaf; + } +} + +function churn(): number { + let total = 0; + for (let i = 0; i < 200; i++) total += new Leaf("x" + i).text.length; + return total; +} + +// No write to `leaf` in the block: borrowed. +function readOnly(b: Box): string { + const t = b.leaf; + b.spare = new Leaf("spare"); + churn(); + return t.text + "/" + b.spare.text; +} + +// The block replaces the field: the alias must own its value. +function replaced(b: Box): string { + const t = b.leaf; + b.leaf = new Leaf("new"); + churn(); + return t.text + "->" + b.leaf.text; +} + +// The replacement happens in a callee. +function swap(b: Box): void { + b.leaf = new Leaf("swapped"); + churn(); +} +function replacedInCallee(b: Box): string { + const t = b.leaf; + swap(b); + return t.text; +} + +// Element aliases and removals. +function element(b: Box): string { + const first = b.list[0]!; + b.list.pop(); + b.list.length = 0; + churn(); + return first.text; +} + +// The alias escapes into a store and a return value. +function escape(b: Box, other: Box): Leaf { + const t = b.leaf; + other.spare = t; + b.leaf = new Leaf("gone"); + churn(); + return t; +} + +// Aliases in loop bodies are fresh per iteration; `continue` ends one. +function loop(boxes: Box[]): string { + const out: string[] = []; + for (let i = 0; i < boxes.length; i++) { + const t = boxes[i]!.leaf; + if (i % 2 === 0) { + out.push(t.text); + continue; + } + boxes[i]!.leaf = new Leaf("odd" + i); + churn(); + out.push(t.text); + } + return out.join(","); +} + +// A switch case's bindings live for the whole switch. +function sw(b: Box, k: number): string { + let s = ""; + switch (k) { + case 0: { + const t = b.leaf; + b.leaf = new Leaf("case0"); + churn(); + s = t.text; + break; + } + default: + s = b.leaf.text; + } + return s; +} + +console.log(readOnly(new Box(new Leaf("ro")))); +console.log(replaced(new Box(new Leaf("old")))); +console.log(replacedInCallee(new Box(new Leaf("callee")))); +const withList = new Box(new Leaf("l")); +withList.list.push(new Leaf("e0"), new Leaf("e1")); +console.log(element(withList)); +const other = new Box(new Leaf("other")); +console.log(escape(new Box(new Leaf("escaped")), other).text, other.spare?.text); +const boxes: Box[] = []; +for (let i = 0; i < 6; i++) boxes.push(new Box(new Leaf("b" + i))); +console.log(loop(boxes), boxes.map((b) => b.leaf.text).join(" ")); +console.log(sw(new Box(new Leaf("sw0")), 0), sw(new Box(new Leaf("sw1")), 1)); diff --git a/tests/corpus/4632-reference-write-loops-and-walks.ts b/tests/corpus/4632-reference-write-loops-and-walks.ts new file mode 100644 index 000000000..98fcdbad3 --- /dev/null +++ b/tests/corpus/4632-reference-write-loops-and-walks.ts @@ -0,0 +1,116 @@ +// Loop element borrows, optional element reads, parent walks and borrowed +// returns in bodies that write the heap: borrowed only while nothing the +// body (or a callee) writes can release the lent value. + +class Item { + name: string; + parent: Item | undefined; + note: string | undefined = undefined; + constructor(name: string, parent: Item | undefined) { + this.name = name; + this.parent = parent; + } +} + +function churn(): number { + let total = 0; + for (let i = 0; i < 100; i++) total += new Item("c" + i, undefined).name.length; + return total; +} + +// The body writes another field of each element: elements stay lent. +function annotate(items: Item[]): string { + const names: string[] = []; + for (const it of items) { + it.note = "seen:" + it.name; + names.push(it.name); + } + return names.join(","); +} + +// The body removes elements from the iterated array: elements are owned. +function drain(items: Item[]): string { + const names: string[] = []; + for (const it of items) { + items.length = 0; + churn(); + names.push(it.name); + } + return names.join(","); +} + +// Indexed loop with an optional element read and an unrelated write. +function indexed(items: Item[], log: string[]): string { + let s = ""; + for (let i = 0; i < items.length + 1; i++) { + const it = items[i]; + log.push("i" + i); + s += it === undefined ? "-" : it.name; + } + return s; +} + +// Indexed loop replacing the element it just read. +function replaceEach(items: Item[]): string { + let s = ""; + for (let i = 0; i < items.length; i++) { + const it = items[i]; + items[i] = new Item("r" + i, undefined); + churn(); + s += it === undefined ? "-" : it.name; + } + return s; +} + +// A parent walk in a body that writes notes: the walk borrows. +function rootWithNotes(it: Item): string { + let p = it; + while (p.parent !== undefined) { + p.note = "walked"; + p = p.parent; + } + return p.name; +} + +// A walk whose body detaches parents: the walk owns its steps. +function detach(it: Item): string { + let p = it; + let s = ""; + while (p.parent !== undefined) { + const next = p.parent; + p.parent = undefined; + churn(); + s += next.name; + p = next; + } + return s; +} + +// A borrowed return from a body that writes elsewhere, consumed after a +// call that writes notes. +function topOf(it: Item, log: string[]): Item { + log.push("top"); + let p = it; + while (p.parent !== undefined) p = p.parent; + return p; +} +function useTop(it: Item, log: string[]): string { + const top = topOf(it, log); + annotate([it]); + return top.name + ":" + (it.note ?? ""); +} + +const chain: Item[] = []; +let prev: Item | undefined = undefined; +for (let i = 0; i < 8; i++) { + prev = new Item("n" + i, prev); + chain.push(prev); +} +const log: string[] = []; +console.log(annotate(chain.slice()), chain[3]!.note); +console.log(drain(chain.slice(0, 4))); +console.log(indexed(chain.slice(0, 3), log), log.length); +console.log(replaceEach(chain.slice(0, 3))); +console.log(rootWithNotes(chain[7]!), chain[4]!.note); +console.log(useTop(chain[6]!, log), log.join("")); +console.log(detach(chain[7]!), chain[7]!.parent, chain[2]!.parent); diff --git a/tests/corpus/4633-reference-write-callbacks-returns.ts b/tests/corpus/4633-reference-write-callbacks-returns.ts new file mode 100644 index 000000000..8ba1e8991 --- /dev/null +++ b/tests/corpus/4633-reference-write-callbacks-returns.ts @@ -0,0 +1,98 @@ +// Closure calls resolved through callback parameters, adapters and +// captures, and owned locals moved into return values. + +class Cell { + value: string; + next: Cell | undefined = undefined; + constructor(value: string) { + this.value = value; + } +} + +class Store { + cell: Cell; + constructor(cell: Cell) { + this.cell = cell; + } +} + +function churn(): number { + let n = 0; + for (let i = 0; i < 100; i++) n += new Cell("k" + i).value.length; + return n; +} + +function show(c: Cell | undefined): string { + return c === undefined ? "none" : c.value; +} + +// The callback a caller passes replaces the field the caller lends. +function withCallback(s: Store, c: Cell, f: (s: Store) => void): string { + f(s); + churn(); + return show(c); +} +function each(cells: Cell[], f: (c: Cell) => string): string { + return cells.map(f).join(","); +} + +// Captured state written by the callback. +function capture(s: Store): string { + let seen = s.cell; + const record = (c: Cell): string => { + seen = c; + return c.value; + }; + const listed = each([new Cell("a"), new Cell("b")], record); + return listed + " " + seen.value; +} + +// Returning owned locals: plain, from a loop, and through a finally. +function build(n: number): Cell { + const head = new Cell("head" + n); + let cur = head; + for (let i = 0; i < n; i++) { + const c = new Cell("c" + i); + cur.next = c; + cur = c; + } + return head; +} +function pick(cells: Cell[], want: string): Cell | undefined { + for (const c of cells) { + const found = c; + if (found.value === want) return found; + } + return undefined; +} +function guarded(n: number): Cell { + const c = new Cell("guarded" + n); + try { + if (n > 1) return c; + return new Cell("small"); + } finally { + c.value += "!"; + } +} + +const s1 = new Store(new Cell("one")); +console.log( + withCallback(s1, s1.cell, (s) => { + s.cell = new Cell("replaced"); + }), + s1.cell.value, +); +const s2 = new Store(new Cell("two")); +console.log(withCallback(s2, s2.cell, () => {}), s2.cell.value); +console.log(capture(new Store(new Cell("start")))); +let walk: Cell | undefined = build(3); +const parts: string[] = []; +while (walk !== undefined) { + parts.push(walk.value); + walk = walk.next; +} +console.log(parts.join(">")); +const cells = [new Cell("x"), new Cell("y"), new Cell("z")]; +console.log(show(pick(cells, "y")), show(pick(cells, "w"))); +console.log(guarded(2).value, guarded(0).value); +console.log(each(cells, (c) => c.value.toUpperCase())); diff --git a/tests/corpus/4634-optional-element-borrowed-returns.ts b/tests/corpus/4634-optional-element-borrowed-returns.ts new file mode 100644 index 000000000..dab04a34b --- /dev/null +++ b/tests/corpus/4634-optional-element-borrowed-returns.ts @@ -0,0 +1,47 @@ +// Optional element reads (`arr[i]` of `T | undefined` elements) lower to +// helpers that now lend the element their array keeps alive: paged lookups +// borrow, and reads followed by removals still own what they keep. + +class Link { + name: string; + constructor(name: string) { + this.name = name; + } +} + +class Pages { + pages: (Link[] | undefined)[] = []; + get(id: number): Link { + let link = this.pages[id >> 3]?.[id & 7]; + if (link === undefined) { + let page = this.pages[id >> 3]; + if (page === undefined) { + page = []; + this.pages[id >> 3] = page; + } + link = new Link("l" + id); + page[id & 7] = link; + } + return link; + } +} + +function first(links: Link[]): string { + const head = links[0]; + return head === undefined ? "none" : head.name; +} + +const shared: Link[] = [new Link("x"), new Link("y")]; +function readThenClear(): string { + const kept = shared[1]; + shared.length = 0; + let churn = 0; + for (let i = 0; i < 100; i++) churn += new Link("c" + i).name.length; + return (kept === undefined ? "-" : kept.name) + churn; +} + +const pages = new Pages(); +const seen: string[] = []; +for (let i = 0; i < 40; i++) seen.push(pages.get((i * 7) % 19).name); +console.log(seen.join(" ")); +console.log(first([]), first([new Link("a")]), readThenClear(), shared.length);