Share virtual-slot borrowing with the self-hosted layout stage

Vtables store the borrowing body of a method whose slot borrows its
parameters, but that decision lived in private emitter state, so the
self-hosting layout fixture kept emitting owned entries and no longer
matched production (`@sc_f_...` vs `@sc_bf_...`).

Move the decision into VirtualBorrows (virtual-borrows.ts), used by the
emitter and by the fixture, which now runs the same call-lifetimes
analysis. The slot lookup narrows explicitly instead of combining a
union with `&&`/`||`, which the self-hosted subset rejects (SC1090). The
abstract-slot emission case now expects the borrowing body, since its
implementation borrows its receiver.
This commit is contained in:
Malte Ubl
2026-10-09 10:10:26 -07:00
parent a360250b38
commit fcdbe88494
4 changed files with 107 additions and 59 deletions
+13 -58
View File
@@ -284,9 +284,9 @@ import {
classFieldIndex,
classStructSym,
type LlClassMeta,
vtEntriesFor,
} from "./classes.js";
import { LlDyn, type DynHost } from "./dyn.js";
import { VirtualBorrows } from "./virtual-borrows.js";
import { LlvmUnsupportedError } from "./unsupported.js";
import { LlWalkers } from "./walkers.js";
import { isObjectArm, NULLABLE_ABSENT, NULLABLE_NULL, NullableUnions } from "./nullable-unions.js";
@@ -627,11 +627,8 @@ export class LlEmitter {
/** The class graph (buildClassGraph): preorder numbering, hierarchy
* membership, virtual slot lists. */
readonly classMeta: Map<string, LlClassMeta>;
/** Parameters every implementation of a vtable slot borrows, keyed by
* `root\0slot index`, and each implementation's slot key (see
* virtualSlotBorrowed). */
private readonly slotBorrowed = new Map<string, ReadonlySet<number>>();
private readonly implSlot = new Map<string, string>();
/** Which vtable slots borrow which parameters (virtual-borrows.ts). */
private virtualBorrows!: VirtualBorrows;
/** Class objects (classes as first-class values): className → the
* interned .name literal ref — registered during body emission, the
* statics and construct thunks assemble around the bodies. */
@@ -917,68 +914,26 @@ export class LlEmitter {
lib: rec.lib,
});
}
this.computeVirtualBorrowing(mod);
}
/** Virtual dispatch normally keeps the owned ABI: the caller passes +1
* and the method's owned entry releases what its body borrows. When every
* implementation of a slot has a borrowing body for the same parameter
* (call-lifetimes proves this per function, independent of the caller),
* the slot borrows that parameter instead: call sites pass it like a
* direct call to a borrowing body, so a receiver that survives the call
* is neither retained nor released (and, being cycle-capable, not
* buffered as a candidate either). Implementations that borrow more
* parameters than the slot get an adapter that releases the difference. */
private computeVirtualBorrowing(mod: IrModule): void {
const impls = new Map<string, Set<string>>();
const emitted = new Set((mod.classes ?? []).filter((c) => !c.runtime).map((c) => c.name));
for (const meta of this.classMeta.values()) {
if (!meta.hierarchy || !emitted.has(meta.def.name)) continue;
vtEntriesFor(meta).forEach(({ slot, impl }, index) => {
if (impl === null) return;
const key = `${meta.root.def.name}\0${index}`;
const name = `%${impl.def.name}.${slot.method}`;
let set = impls.get(key);
if (!set) impls.set(key, (set = new Set()));
set.add(name);
this.implSlot.set(name, key);
});
}
for (const [key, names] of impls) {
let common: number[] | null = null;
for (const name of names) {
const fn = this.fnByName.get(name);
const borrowed = this.callLifetimes.borrowed.get(name);
if (!fn || !borrowed || fn.captures !== undefined || fn.async || fn.generator) {
common = [];
break;
}
common = (common ?? [...borrowed]).filter((index) => borrowed.has(index));
}
if (common !== null && common.length > 0) this.slotBorrowed.set(key, new Set(common));
}
this.virtualBorrows = new VirtualBorrows(
mod,
this.classMeta,
this.fnByName,
this.callLifetimes.borrowed,
);
}
/** The borrowed parameters of a hierarchy's vtable slot (empty: owned). */
virtualSlotBorrowed(rootName: string, slotIndex: number): ReadonlySet<number> {
return this.slotBorrowed.get(`${rootName}\0${slotIndex}`) ?? NO_BORROWED;
return this.virtualBorrows.slot(rootName, slotIndex);
}
/** The vtable entry for one implementation: its owned entry for an owned
* slot, its borrowing body when that borrows exactly the slot's
* parameters, otherwise its virtual adapter (emitOwnedCallAdapter). */
/** The vtable entry for one implementation (VirtualBorrows.entry). */
virtualEntry(implFn: string): string {
const slot = this.implSlotBorrowed(implFn);
if (slot.size === 0) return mangleFunction(implFn);
const own = this.callLifetimes.borrowed.get(implFn)!;
return own.size === slot.size
? mangleBorrowedFunction(implFn)
: `${mangleBorrowedFunction(implFn)}.virtual`;
return this.virtualBorrows.entry(implFn);
}
private implSlotBorrowed(implFn: string): ReadonlySet<number> {
const key = this.implSlot.get(implFn);
return (key !== undefined && this.slotBorrowed.get(key)) || NO_BORROWED;
return this.virtualBorrows.implSlotBorrowed(implFn);
}
abiOffset(native64: number, wasm32: number): number {
@@ -0,0 +1,83 @@
import type { IrFunction, IrModule } from "../../ir/ir.js";
import { mangleBorrowedFunction, mangleFunction } from "../mangle.js";
import { vtEntriesFor, type LlClassMeta } from "./classes.js";
const NO_BORROWED: ReadonlySet<number> = new Set();
/** Virtual dispatch normally keeps the owned ABI: the caller passes +1 and
* the method's owned entry releases what its body borrows. When every
* implementation of a slot has a borrowing body for the same parameter
* (call-lifetimes proves this per function, independent of the caller), the
* slot borrows that parameter instead: call sites pass it like a direct call
* to a borrowing body, so a receiver that survives the call is neither
* retained nor released (and, being cycle-capable, not buffered as a
* candidate either). Implementations that borrow more parameters than the
* slot get an adapter that releases the difference.
*
* Shared by the emitter and the self-hosted layout stage, so both store the
* same vtable entries. */
export class VirtualBorrows {
/** Parameters every implementation of a vtable slot borrows, keyed by
* `root\0slot index`, and each implementation's slot key. */
private readonly slotBorrowed = new Map<string, ReadonlySet<number>>();
private readonly implSlot = new Map<string, string>();
constructor(
mod: IrModule,
classMeta: ReadonlyMap<string, LlClassMeta>,
private readonly functions: ReadonlyMap<string, IrFunction>,
private readonly borrowed: ReadonlyMap<string, ReadonlySet<number>>,
) {
const impls = new Map<string, Set<string>>();
const emitted = new Set((mod.classes ?? []).filter((c) => !c.runtime).map((c) => c.name));
for (const meta of classMeta.values()) {
if (!meta.hierarchy || !emitted.has(meta.def.name)) continue;
vtEntriesFor(meta).forEach(({ slot, impl }, index) => {
if (impl === null) return;
const key = `${meta.root.def.name}\0${index}`;
const name = `%${impl.def.name}.${slot.method}`;
let set = impls.get(key);
if (!set) impls.set(key, (set = new Set()));
set.add(name);
this.implSlot.set(name, key);
});
}
for (const [key, names] of impls) {
let common: number[] | null = null;
for (const name of names) {
const fn = this.functions.get(name);
const own = this.borrowed.get(name);
if (!fn || !own || fn.captures !== undefined || fn.async || fn.generator) {
common = [];
break;
}
common = (common ?? [...own]).filter((index) => own.has(index));
}
if (common !== null && common.length > 0) this.slotBorrowed.set(key, new Set(common));
}
}
/** The borrowed parameters of a hierarchy's vtable slot (empty: owned). */
slot(rootName: string, slotIndex: number): ReadonlySet<number> {
return this.slotBorrowed.get(`${rootName}\0${slotIndex}`) ?? NO_BORROWED;
}
/** The borrowed parameters of the slot an implementation fills. */
implSlotBorrowed(implFn: string): ReadonlySet<number> {
const key = this.implSlot.get(implFn);
if (key === undefined) return NO_BORROWED;
return this.slotBorrowed.get(key) ?? NO_BORROWED;
}
/** The vtable entry for one implementation: its owned entry for an owned
* slot, its borrowing body when that borrows exactly the slot's
* parameters, otherwise its virtual adapter (emitOwnedCallAdapter). */
entry(implFn: string): string {
const slot = this.implSlotBorrowed(implFn);
if (slot.size === 0) return mangleFunction(implFn);
const own = this.borrowed.get(implFn)!;
return own.size === slot.size
? mangleBorrowedFunction(implFn)
: `${mangleBorrowedFunction(implFn)}.virtual`;
}
}
+9
View File
@@ -10,6 +10,9 @@ import { NULLABLE_ABSENT, NullableUnions } from "../../../packages/compiler/src/
import { emitFieldAbsentTest } from "../../../packages/compiler/src/backend/llvm/common.js";
import { undefinedArmTag } from "../../../packages/compiler/src/ir/analysis.js";
import { analyzeInt32Slots } from "../../../packages/compiler/src/ir/int32-slots.js";
import { analyzeCallLifetimes } from "../../../packages/compiler/src/backend/llvm/call-lifetimes.js";
import { NullableRefFields } from "../../../packages/compiler/src/backend/llvm/nullable-fields.js";
import { VirtualBorrows } from "../../../packages/compiler/src/backend/llvm/virtual-borrows.js";
import { f64Lit } from "../../../packages/compiler/src/backend/llvm/common.js";
import { mangleClassObj } from "../../../packages/compiler/src/backend/mangle.js";
import { type IrType } from "../../../packages/compiler/src/ir/ir.js";
@@ -36,6 +39,7 @@ try {
const traced = computeTraced(mod);
const nullableUnions = new NullableUnions(mod);
const unionsById = new Map((mod.unions ?? []).map((union) => [union.id, union]));
let virtualBorrows: VirtualBorrows | undefined;
const host: ClassHost & WalkerHost = {
declare: (decl) => { declarations.add(decl); },
needOom: () => { needsOom = true; },
@@ -71,6 +75,8 @@ try {
B.line(`${absent} = icmp eq ptr ${value}, ${NULLABLE_ABSENT}`);
return absent;
},
// Mirrors the emitter: borrowing vtable slots store the borrowing body.
virtualEntry: (implFn) => virtualBorrows!.entry(implFn),
cstr: (text) => {
let index = strings.indexOf(text);
if (index === -1) { index = strings.length; strings.push(text); }
@@ -79,6 +85,9 @@ try {
};
const functions = new Map(mod.functions.map((fn) => [fn.name, fn]));
const graph = buildClassGraph(mod, functions);
const nullableFields = new NullableRefFields(mod.classes ?? [], nullableUnions.boxedUnions(unionsById));
const lifetimes = analyzeCallLifetimes(functions, (className, field) => nullableFields.get(className, field) !== null);
virtualBorrows = new VirtualBorrows(mod, graph, functions, lifetimes.borrowed);
const classObjects = new Map(request.classObjects.map((name) => [name, { nameSym: `@name_${mangleClassObj(name)}` }]));
const typeName = (type: IrType): string => type.kind === "void" ? "void" : type.kind === "f64" || type.kind === "date" ? "double" : type.kind === "bool" ? "i1" : "ptr";
// Mirrors the emitter: whole-program int32 fields store the exact i32.
+2 -1
View File
@@ -238,7 +238,8 @@ export function emissionCases(): EmissionCase[] {
concrete.methods = ["read"];
abstract.classes = [abstractRoot, concrete];
abstract.functions.push(method("Concrete", "read"));
add("abstract slot implementation", abstract, ["ptr null", "@sc_f__x25_Concrete_read"]);
// The implementation borrows its receiver, so the slot stores the borrowing body.
add("abstract slot implementation", abstract, ["ptr null", "@sc_bf__x25_Concrete_read"]);
const incomplete = emissionModule();
const abstractChild = cls("AbstractChild", "Abstract");