Borrow final reference fields without a retain

A reference field that only its class family's constructors write, through
their own `this`, never changes outside them. While its object is held, the
field's value is held too, so `this.scanner.scan()` and
`const units = this.units` need no retain and release (and no collector
buffering on the release).

analyzeFinalFields finds such fields over the whole program. It looks through
the derived constructor's guards on `this`, and disqualifies fields that live
dynamic views can store into (the detection int32 slots already uses, now
shared as dynamicFieldStores), fields of runtime-provided or generic classes,
and every field in library builds. canBorrowCallArgument accepts a final field
of a borrowable object, except in constructors, async functions and
generators, and except for nullable-pointer fields, which have no box to lend.
This commit is contained in:
Malte Ubl
2026-10-10 15:39:27 -07:00
parent ca009a0073
commit bb4b16f026
5 changed files with 520 additions and 53 deletions
@@ -99,6 +99,7 @@ import { specializeNumericCalls } from "../../ir/numeric-call-specialization.js"
import { everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js";
import { analyzeIntegerRanges, INT32_RANGE, type IntegerRanges } from "../../ir/integer-ranges.js";
import { analyzeInt32Slots, type Int32Slots } from "../../ir/int32-slots.js";
import { analyzeFinalFields, constructorClass, type FinalFields } from "../../ir/final-fields.js";
import { findIntegerViews } from "./integer-views.js";
import { findInitializerBindings, withInitializerBindings } from "../../ir/initializer-bindings.js";
import { findConstantNumericTables, type ConstantNumericTable } from "../../ir/constant-tables.js";
@@ -734,6 +735,11 @@ export class LlEmitter {
{ slot: string; type: "i32" | "i64"; signed?: boolean; range?: { min: number; max: number } }
>();
private countedLoopsEnabled = false;
/** Whole-program final reference fields (ir/final-fields.ts). */
private finalFields!: FinalFields;
/** The current function may borrow final fields of borrowable objects:
* not a constructor (where they change), not async or a generator. */
private finalFieldBorrows = false;
integerRanges: IntegerRanges = new Map();
private byteWindowsEnabled = true;
private byteWindowEntries: ReturnType<typeof findInitializedByteLoopBindings> = new Map();
@@ -808,6 +814,7 @@ export class LlEmitter {
this.constantNumericTables = findConstantNumericTables(mod);
this.initializerBindings = findInitializerBindings(mod);
this.int32Slots = analyzeInt32Slots(mod);
this.finalFields = analyzeFinalFields(mod);
this.sizeType = options.pointerBits === 32 ? "i32" : "i64";
this.ffiExtendNarrowIntegers =
options.wasi === true || ffiExtendsNarrowIntegers(options.targetTriple);
@@ -4942,6 +4949,8 @@ export class LlEmitter {
);
this.integerLoopBindings.clear();
this.countedLoopsEnabled = this.debug === null && !fn.async && !fn.generator;
this.finalFieldBorrows =
!fn.async && !fn.generator && constructorClass(fn.name) === null && this.finalFields.size > 0;
this.byteWindowsEnabled = this.countedLoopsEnabled;
this.byteWindowEntries =
this.byteWindowsEnabled && numericFn.locals.some((l) => l.type.kind === "bytes")
@@ -5311,6 +5320,17 @@ export class LlEmitter {
// stable owner. The throwing arm's typed dummy owns nothing.
if (value.kind === "unionNarrow") return this.canBorrowCallArgument(value.value);
if (value.kind === "libCall") return value.fn === "error.nodeThrow";
// A final field never changes outside constructors: while its object is
// borrowable, so is the field's value. Nullable-pointer fields have no
// box to lend and keep their owning path.
if (
value.kind === "fieldGet" &&
this.finalFieldBorrows &&
isRefCounted(value.type) &&
this.finalFields.isFinal(value.className, value.field) &&
!this.nullableFieldGet(value)
)
return this.canBorrowReceiver(value.obj) && this.canBorrowCallArgument(value.obj);
if (value.kind === "ternary")
return (
this.canBorrowReceiver(value.then) &&
@@ -0,0 +1,129 @@
import { expect, test } from "vitest";
import { analyzeFinalFields, constructorClass } from "./final-fields.js";
import {
BOOL,
STRING,
VOID,
type IrClassDef,
type IrExpr,
type IrFunction,
type IrModule,
type IrStmt,
type IrType,
} from "./ir.js";
const loc = { file: "final-fields.ts", start: 0, end: 0 };
const obj = (className: string): IrType => ({ kind: "object", className });
const ref = (localId: string, type: IrType): IrExpr => ({ loc, kind: "varRef", localId, type });
const str = (value: string): IrExpr => ({ loc, kind: "strLit", value, type: STRING });
const set = (target: IrExpr, className: string, field: string, value: IrExpr): IrStmt => ({
loc,
kind: "fieldSet",
obj: target,
className,
field,
value,
});
const fn = (name: string, self: string | null, body: IrStmt[]): IrFunction => ({
loc,
name,
params: self ? [{ localId: "this", name: "this", type: obj(self) }] : [],
locals: self ? [{ id: "this", name: "this", type: obj(self), mutable: false }] : [],
returnType: VOID,
body,
});
const cls = (name: string, fields: string[], base?: string): IrClassDef => ({
loc,
name,
...(base ? { base } : {}),
fields: fields.map((field) => ({ name: field, type: STRING })),
});
const mod = (classes: IrClassDef[], functions: IrFunction[]): IrModule => ({
irVersion: 15,
sourceFile: "final-fields.ts",
entry: "%main",
classes,
functions: [fn("%main", null, []), ...functions],
records: [],
unions: [],
globals: [],
});
const self = (className: string): IrExpr => ref("this", obj(className));
test("constructor names map to their class", () => {
expect(constructorClass("%%m1.Parser.constructor")).toBe("%m1.Parser");
expect(constructorClass("%%m1.Parser.parse")).toBeNull();
});
test("fields written only through their constructors' this are final", () => {
const finals = analyzeFinalFields(
mod(
[cls("A", ["kept", "moved"]), cls("B", ["kept", "moved", "own"], "A")],
[
fn("%A.constructor", "A", [
set(self("A"), "A", "kept", str("a")),
set(self("A"), "A", "kept", str("twice")),
set(self("A"), "A", "moved", str("a")),
]),
// A subclass constructor initializes its base's field through a guard.
fn("%B.constructor", "B", [
set(
{
loc,
kind: "seqExpr",
stmts: [
{
loc,
kind: "if",
cond: {
loc,
kind: "unary",
op: "!",
operand: ref("superCalled", BOOL),
type: BOOL,
},
then: [
{
loc,
kind: "exprStmt",
expr: {
loc,
kind: "libCall",
fn: "error.nodeThrow",
args: [str("ReferenceError")],
type: VOID,
},
},
],
else_: null,
},
],
result: self("B"),
type: obj("B"),
},
"B",
"own",
str("b"),
),
]),
// Any write elsewhere disqualifies the field family.
fn("%A.reset", "A", [set(self("A"), "A", "moved", str("later"))]),
],
),
);
expect(finals.isFinal("A", "kept")).toBe(true);
expect(finals.isFinal("B", "kept")).toBe(true);
expect(finals.isFinal("B", "own")).toBe(true);
expect(finals.isFinal("A", "moved")).toBe(false);
expect(finals.isFinal("B", "moved")).toBe(false);
expect(finals.reasons.get("A\0moved")).toBe("written in %A.reset");
});
test("constructors writing another object, and library builds, are not final", () => {
const other = fn("%A.constructor", "A", [set(ref("peer", obj("A")), "A", "kept", str("a"))]);
other.locals.push({ id: "peer", name: "peer", type: obj("A"), mutable: false });
expect(analyzeFinalFields(mod([cls("A", ["kept"])], [other])).isFinal("A", "kept")).toBe(false);
const library = mod([cls("A", ["kept"])], []);
library.lib = { profile: "p" } as unknown as NonNullable<IrModule["lib"]>;
expect(analyzeFinalFields(library).size).toBe(0);
});
+177
View File
@@ -0,0 +1,177 @@
import { isRefCounted, type IrClassDef, type IrExpr, type IrModule, type IrStmt } from "./ir.js";
import { dynamicFieldStores } from "./int32-slots.js";
import { everyExprChild, everyStmtChild } from "./traverse.js";
/** Whole-program final reference fields.
*
* A reference-counted class field is final when every write the program can
* make to it is a constructor of its family storing through that
* constructor's own `this`. Outside the constructors such a field never
* changes, so while its object is alive the field keeps its value alive: a
* read of `o.f` may be borrowed for as long as `o` itself is (`this.scanner`
* inside a method, across any call), without a retain and a release.
* Constructors are excluded from that use, because they are where the field
* changes; so are async functions and generators, whose suspensions let
* other code (a constructor that is still running) continue.
*
* Writes the IR does not show disqualify fields up front: library builds
* (instances cross a native boundary), live dynamic views whose stores can
* name the field (see dynamicFieldStores), and classes with runtime-provided
* or generic ancestors. Generated stores only initialize fresh objects or
* tear down dead ones. */
export class FinalFields {
private readonly finals = new Set<string>();
private readonly familyOf = new Map<string, string | null>();
/** Disqualified families and the first reason, for tests. */
readonly reasons = new Map<string, string>();
constructor(private readonly classes: ReadonlyMap<string, IrClassDef> = new Map()) {}
/** The topmost class in the base chain declaring the field, and the field. */
family(className: string, field: string): string | null {
const key = `${className}\0${field}`;
const cached = this.familyOf.get(key);
if (cached !== undefined) return cached;
let owner: IrClassDef | undefined = this.classes.get(className);
let result: string | null = null;
if (owner?.fields.some((f) => f.name === field)) {
for (;;) {
const base: IrClassDef | undefined = owner.base ? this.classes.get(owner.base) : undefined;
if (!base?.fields.some((f) => f.name === field)) break;
owner = base;
}
result = `${owner.name}\0${field}`;
}
this.familyOf.set(key, result);
return result;
}
isFinal(className: string, field: string): boolean {
if (this.finals.size === 0) return false;
const family = this.family(className, field);
return family !== null && this.finals.has(family);
}
get size(): number {
return this.finals.size;
}
/* ── construction (analyzeFinalFields only) ── */
add(family: string): void {
this.finals.add(family);
}
drop(family: string, why: string): void {
if (!this.reasons.has(family)) this.reasons.set(family, why);
this.finals.delete(family);
}
}
const CONSTRUCTOR_SUFFIX = ".constructor";
/** `%C.constructor`'s class name, or null for any other function. */
export function constructorClass(fnName: string): string | null {
return fnName.startsWith("%") && fnName.endsWith(CONSTRUCTOR_SUFFIX)
? fnName.slice(1, -CONSTRUCTOR_SUFFIX.length)
: null;
}
export function analyzeFinalFields(mod: IrModule): FinalFields {
const classes = new Map((mod.classes ?? []).map((c) => [c.name, c]));
const finals = new FinalFields(classes);
if (mod.lib) return finals;
const dynamic = dynamicFieldStores(mod);
if (dynamic.all) return finals;
const genericFamilies = new Set<string>();
for (const cls of classes.values())
if (cls.genericOf !== undefined) {
genericFamilies.add(cls.name);
genericFamilies.add(cls.genericOf);
}
const plainClass = (cls: IrClassDef): boolean => {
for (let c: IrClassDef | undefined = cls; c; c = c.base ? classes.get(c.base) : undefined) {
if (c.runtime || genericFamilies.has(c.name)) return false;
if (c.base && !classes.has(c.base)) return false;
}
return true;
};
const inFamily = (className: string, family: string): boolean => {
const owner = family.slice(0, family.indexOf("\0"));
for (let c = classes.get(className); c; c = c.base ? classes.get(c.base) : undefined)
if (c.name === owner) return true;
return false;
};
for (const cls of classes.values())
for (const field of cls.fields) {
if (!isRefCounted(field.type) || field.name.startsWith("%")) continue;
const family = finals.family(cls.name, field.name)!;
if (finals.reasons.has(family)) continue;
if (!plainClass(cls)) finals.drop(family, "runtime-provided or generic class");
else if (dynamic.names.has(field.name))
finals.drop(family, "dynamic stores can name the field");
else finals.add(family);
}
if (finals.size === 0) return finals;
for (const fn of mod.functions) {
const ctor = constructorClass(fn.name);
const self = ctor !== null ? fn.params[0]?.localId : undefined;
const selfLocal = self !== undefined ? fn.locals.find((l) => l.id === self) : undefined;
// `this` must name the object under construction for the whole body.
const constructing = selfLocal !== undefined && !selfLocal.boxed && !reassigns(fn.body, self!);
const stmt = (s: IrStmt): boolean => {
if (s.kind === "fieldSet") {
const family = finals.family(s.className, s.field);
if (family !== null && finals.isFinal(s.className, s.field)) {
const own =
constructing && ctor !== null && inFamily(ctor, family) && isSelf(s.obj, self!);
if (!own) finals.drop(family, `written in ${fn.name}`);
}
}
return everyStmtChild(s, expr, stmt);
};
const expr = (e: IrExpr): boolean => everyExprChild(e, expr, stmt);
for (const s of fn.body) stmt(s);
}
return finals;
}
/** `this` itself, or behind the derived constructor's guards (`if (!super
* called) throw`), which read no reference and write nothing. */
function isSelf(e: IrExpr, self: string): boolean {
if (e.kind === "varRef") return e.localId === self;
if (e.kind !== "seqExpr") return false;
for (const s of e.stmts) {
if (s.kind !== "if" || s.else_ !== null || s.then.length !== 1) return false;
const only = s.then[0]!;
if (
only.kind !== "exprStmt" ||
only.expr.kind !== "libCall" ||
only.expr.fn !== "error.nodeThrow" ||
!only.expr.args.every((arg) => arg.kind === "numLit" || arg.kind === "strLit") ||
!throwFreeCondition(s.cond)
)
return false;
}
return isSelf(e.result, self);
}
function throwFreeCondition(e: IrExpr): boolean {
if (e.kind === "varRef" || e.kind === "boolLit") return e.type.kind === "bool";
return e.kind === "unary" && throwFreeCondition(e.operand);
}
function reassigns(body: IrStmt[], localId: string): boolean {
let found = false;
const expr = (e: IrExpr): boolean => {
if ((e.kind === "assignExpr" || e.kind === "incDec") && e.localId === localId) found = true;
return !found && everyExprChild(e, expr, stmt);
};
const stmt = (s: IrStmt): boolean => {
if (s.kind === "assign" && s.localId === localId && s.initializes !== true) found = true;
return !found && everyStmtChild(s, expr, stmt);
};
for (const s of body) if (!stmt(s)) break;
return found;
}
+67 -53
View File
@@ -271,6 +271,72 @@ function walkFunction(
for (const s of fn.body) stmt(s);
}
/** The class field names that runtime stores through live dynamic views
* (typed-ref capsules) can write: every name when `all` (islands, jsval
* values, or a dynamic store whose key is not a literal), otherwise the
* literal keys of `dyn.keySet`/`dyn.keyDelete`. A view commits a store back
* into the instance's native field slots, which the IR does not show. */
export function dynamicFieldStores(
mod: IrModule,
records = new Map((mod.records ?? []).map((r) => [r.id, r])),
unions = new Map((mod.unions ?? []).map((u) => [u.id, u])),
): { all: boolean; names: Set<string> } {
const dynTypes = new Map<IrType, boolean>();
const dynNamed = new Map<string, boolean>();
const dynish = (type: IrType): boolean => {
if (DYN_KINDS.has(type.kind)) return true;
const cached = dynTypes.get(type);
if (cached !== undefined) return cached;
let result = false;
const named =
type.kind === "record"
? `r:${type.shapeId}`
: type.kind === "union"
? `u:${type.unionId}`
: null;
if (named) {
const known = dynNamed.get(named);
if (known !== undefined) return known;
dynNamed.set(named, false);
const members =
type.kind === "record"
? [
...(records.get(type.shapeId)?.fields.map((f) => f.type) ?? []),
...(records.get(type.shapeId)?.indexValue
? [records.get(type.shapeId)!.indexValue!]
: []),
]
: type.kind === "union"
? (unions.get(type.unionId)?.arms ?? [])
: [];
result = members.some(dynish);
dynNamed.set(named, result);
} else everyTypeChild(type, (child) => !(result = dynish(child)));
dynTypes.set(type, result);
return result;
};
const dynWritten = new Set<string>();
let dynWritesAll = false;
for (const fn of mod.functions)
walkFunction(fn, {
expr: (e) => {
if (dynWritesAll) return;
if (ISLAND_KINDS.has(e.kind) || e.type.kind === "jsval") dynWritesAll = true;
else if (
e.kind === "libCall" &&
!DYN_READ_ONLY.has(e.fn) &&
e.args.some((a) => dynish(a.type))
) {
const key = e.args[1];
if ((e.fn === "dyn.keySet" || e.fn === "dyn.keyDelete") && key?.kind === "strLit")
dynWritten.add(key.value);
else dynWritesAll = true;
}
},
});
return { all: dynWritesAll, names: dynWritten };
}
export function analyzeInt32Slots(mod: IrModule): Int32Slots {
const classes = new Map((mod.classes ?? []).map((c) => [c.name, c]));
const slots = new Int32Slots(classes);
@@ -371,59 +437,7 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots {
// writes every field from the view, which holds the exact snapshot value
// of any property no store named. So reaching dynamic code disqualifies
// exactly the field names some dynamic store in the program can name.
const dynTypes = new Map<IrType, boolean>();
const dynNamed = new Map<string, boolean>();
const dynish = (type: IrType): boolean => {
if (DYN_KINDS.has(type.kind)) return true;
const cached = dynTypes.get(type);
if (cached !== undefined) return cached;
let result = false;
const named =
type.kind === "record"
? `r:${type.shapeId}`
: type.kind === "union"
? `u:${type.unionId}`
: null;
if (named) {
const known = dynNamed.get(named);
if (known !== undefined) return known;
dynNamed.set(named, false);
const members =
type.kind === "record"
? [
...(records.get(type.shapeId)?.fields.map((f) => f.type) ?? []),
...(records.get(type.shapeId)?.indexValue
? [records.get(type.shapeId)!.indexValue!]
: []),
]
: type.kind === "union"
? (unions.get(type.unionId)?.arms ?? [])
: [];
result = members.some(dynish);
dynNamed.set(named, result);
} else everyTypeChild(type, (child) => !(result = dynish(child)));
dynTypes.set(type, result);
return result;
};
const dynWritten = new Set<string>();
let dynWritesAll = false;
for (const fn of mod.functions)
walkFunction(fn, {
expr: (e) => {
if (dynWritesAll) return;
if (ISLAND_KINDS.has(e.kind) || e.type.kind === "jsval") dynWritesAll = true;
else if (
e.kind === "libCall" &&
!DYN_READ_ONLY.has(e.fn) &&
e.args.some((a) => dynish(a.type))
) {
const key = e.args[1];
if ((e.fn === "dyn.keySet" || e.fn === "dyn.keyDelete") && key?.kind === "strLit")
dynWritten.add(key.value);
else dynWritesAll = true;
}
},
});
const { all: dynWritesAll, names: dynWritten } = dynamicFieldStores(mod, records, unions);
for (const fn of mod.functions)
walkFunction(fn, {
expr: (e) => {
+127
View File
@@ -0,0 +1,127 @@
// Reference fields written only by their class's constructors may be read
// without a retain wherever their object is held. Every other field keeps
// its owning reads: a callee may replace it while the caller still uses the
// old value.
class Buffer2 {
readonly items: string[] = [];
label: string;
constructor(label: string) {
this.label = label;
}
push(item: string): number {
this.items.push(item);
return this.items.length;
}
describe(): string {
return `${this.label}:${this.items.join(",")}`;
}
}
class Owner {
// Final: written only here, twice, with a method call between the writes.
readonly primary: Buffer2;
// Not final: replaced by `rotate`.
current: Buffer2;
log: string[] = [];
constructor() {
this.primary = new Buffer2("first");
this.current = new Buffer2("c0");
this.log.push(this.peek());
this.primary = new Buffer2("primary");
this.log.push(this.peek());
}
peek(): string {
const primary = this.primary;
primary.push("peeked");
return primary.describe();
}
// The callee replaces `current` while the caller's receiver is the old one.
rotate(next: string): Buffer2 {
const old = this.current;
this.current = new Buffer2(next);
return old;
}
useCurrent(): string {
return this.current.push(this.rotate("c1").describe()) + " " + this.current.describe();
}
usePrimary(): string {
const before = this.primary.push("a");
const again = this.primary.push(this.rotate("c2").label);
return `${before} ${again} ${this.primary.describe()}`;
}
}
class Derived extends Owner {
readonly extra: Buffer2;
constructor() {
super();
this.extra = new Buffer2("extra");
this.extra.push(this.primary.describe());
}
report(): string {
return `${this.extra.describe()} / ${this.primary.describe()}`;
}
}
// A field a closure created in the constructor writes later is not final.
class Callbacks {
target: Buffer2;
readonly reset: () => void;
constructor() {
this.target = new Buffer2("t0");
this.reset = () => {
this.target = new Buffer2("t1");
};
}
run(): string {
return this.target.push(this.fire()) + " " + this.target.describe();
}
fire(): string {
this.reset();
return "fired";
}
}
// A field written through another object, outside any constructor.
class Node2 {
next: Node2 | undefined;
readonly name: string;
readonly payload: Buffer2;
constructor(name: string) {
this.name = name;
this.payload = new Buffer2(name);
}
}
function relink(a: Node2): string {
const first = a.next!;
a.next = new Node2("b2");
return first.payload.describe() + " -> " + a.next.payload.describe();
}
const owner = new Owner();
console.log(owner.log.join(" | "));
console.log(owner.useCurrent());
console.log(owner.usePrimary());
console.log(owner.current.describe());
const derived = new Derived();
console.log(derived.report());
const callbacks = new Callbacks();
console.log(callbacks.run());
const a = new Node2("a");
a.next = new Node2("b");
a.next.payload.push("x");
console.log(relink(a));
// Async methods keep owning reads across their suspensions.
class Pending {
readonly box: Buffer2 = new Buffer2("async");
async later(): Promise<string> {
const box = this.box;
box.push("before");
await Promise.resolve();
box.push("after");
return box.describe();
}
}
new Pending().later().then((text) => console.log(text));