Merge pull request #848 from vercel-labs/perf/rc-elision-si

Borrow and move references whose owners provably outlive them
This commit is contained in:
Chris Tate
2026-10-11 12:56:40 -05:00
committed by GitHub
16 changed files with 2109 additions and 84 deletions
@@ -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;
}
@@ -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(");
+270 -22
View File
@@ -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<string, string>();
readonly referenceEffects: ReferenceEffects;
/** Field-sensitive reference writes (null in debug builds). */
referenceWrites: ReferenceWrites | null = null;
private readonly borrowedReturnKeyCache = new Map<string, string[]>();
/** 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<IrExpr, LocalArrayRead>();
@@ -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<string, Set<number>>();
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<number>();
@@ -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<string>();
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":
@@ -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),
@@ -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<IrStmt> {
const result = new Set<IrStmt>();
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;
},
});
File diff suppressed because it is too large Load Diff
@@ -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", () => {
@@ -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<string>): 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 };
}
@@ -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 () => {
@@ -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");
});
@@ -10493,24 +10493,6 @@
],
"diags": []
},
"<repo>/tests/corpus/missing-read-lambda-results.ts": {
"order": [
"<repo>/tests/corpus/missing-read-lambda-results.ts"
],
"diags": []
},
"<repo>/tests/corpus/missing-read-narrowed-storage.ts": {
"order": [
"<repo>/tests/corpus/missing-read-narrowed-storage.ts"
],
"diags": []
},
"<repo>/tests/corpus/missing-read-number-conversions.ts": {
"order": [
"<repo>/tests/corpus/missing-read-number-conversions.ts"
],
"diags": []
},
"<repo>/tests/corpus/module-fallback-functions/main.js": {
"order": [
"<repo>/tests/corpus/module-fallback-functions/platform.js",
@@ -15797,6 +15779,54 @@
],
"diags": []
},
"<repo>/tests/corpus/4630-reference-write-call-arguments.ts": {
"order": [
"<repo>/tests/corpus/4630-reference-write-call-arguments.ts"
],
"diags": []
},
"<repo>/tests/corpus/4631-reference-write-aliases.ts": {
"order": [
"<repo>/tests/corpus/4631-reference-write-aliases.ts"
],
"diags": []
},
"<repo>/tests/corpus/4632-reference-write-loops-and-walks.ts": {
"order": [
"<repo>/tests/corpus/4632-reference-write-loops-and-walks.ts"
],
"diags": []
},
"<repo>/tests/corpus/4633-reference-write-callbacks-returns.ts": {
"order": [
"<repo>/tests/corpus/4633-reference-write-callbacks-returns.ts"
],
"diags": []
},
"<repo>/tests/corpus/4634-optional-element-borrowed-returns.ts": {
"order": [
"<repo>/tests/corpus/4634-optional-element-borrowed-returns.ts"
],
"diags": []
},
"<repo>/tests/corpus/missing-read-lambda-results.ts": {
"order": [
"<repo>/tests/corpus/missing-read-lambda-results.ts"
],
"diags": []
},
"<repo>/tests/corpus/missing-read-narrowed-storage.ts": {
"order": [
"<repo>/tests/corpus/missing-read-narrowed-storage.ts"
],
"diags": []
},
"<repo>/tests/corpus/missing-read-number-conversions.ts": {
"order": [
"<repo>/tests/corpus/missing-read-number-conversions.ts"
],
"diags": []
},
"<repo>/tests/corpus/native-worker-arena-reuse.ts": {
"order": [
"<repo>/tests/corpus/native-worker-arena-reuse.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);
@@ -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));
@@ -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);
@@ -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()));
@@ -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);