Files
scriptc/packages/compiler/src/backend/llvm/stack-objects.ts
T
Chris Tate ef394d0de9 Name the destination of every ownership transfer in the LLVM emitter
moveTemp, retainValue and releaseValue are replaced by forms that state
where a reference goes or comes from: toHeap and toFrame, retainHeap and
retainFrame, releaseHeap and releaseFrame. A heap reference is one that
heap memory holds: a field, an array or map element, a capture box, a
union box, a module global, a promise or generator slot, the exception
cell or another runtime structure. A frame reference is one that a
local, a parameter, a return value, a stack object or a temporary holds.
Sites whose destination depends on the binding (a local or a module
global) or on the function (a coroutine's return fulfills its promise)
choose at emission time.

adoptHeap marks the runtime results whose +1 the runtime made from heap
memory and that become frame values: capture box reads, generator in and
out slots, and await results. The statement-frame release is renamed
releaseTemps.

Both forms of each operation emit what the old call emitted, so the
generated IR is unchanged.
2026-10-19 04:09:13 -05:00

330 lines
13 KiB
TypeScript

import {
VOID,
isRefCounted,
typeEquals,
type IrExpr,
type IrFunction,
type IrType,
} from "../../ir/ir.js";
import {
completeRecordLiteral,
constructorInitializesFields,
frameRecordShape,
resultEntryName,
scalarStandaloneClass,
} from "./record-results.js";
import { everyStmtList } from "../../ir/traverse.js";
import { mangleClassStruct, mangleRecordStruct } from "../mangle.js";
import { undefinedArmTag } from "../../ir/analysis.js";
import { classFieldIndex } from "./classes.js";
import type { CallLifetimes } from "./call-lifetimes.js";
import { emitCallExpr, emitConstruction } from "./expr-calls.js";
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
import { emitRecordLitFields } from "./expr-primitives.js";
import { SHAPE_WORD_INDEX } from "./nullable-unions.js";
import { llFieldType } from "./shapes.js";
export type StackObjectSource = IrExpr & { kind: "recordLit" | "recordClone" | "new" | "call" };
/** A field of a frame object that owns a reference: an entry-block pointer
* to its slot, and the field's type for the release. */
export interface StackObjectField {
name: string;
ptr: string;
type: IrType;
}
/** Records the owners of a frame object's reference fields before anything
* stores them: a lexical scope for bindings, a statement frame for
* temporaries. */
export type StackObjectFieldOwner = (fields: readonly StackObjectField[]) => void;
/** A record literal, spread copy, `new` or result-slot producer call whose
* object can live in the frame's own storage: a closed record shape, or a
* standalone scalar class whose constructor never lets `this` escape
* (record-results.ts). The caller proves that every use of the value only
* projects it. Record fields may own references; the frame releases them
* (stackObjectFields). Published shapes keep heap storage: their field
* guards treat the immortal header as a frozen object. */
export function canStackObject(host: LlvmEmitterContext, e: IrExpr): e is StackObjectSource {
// A ThreadSlots key keeps its slot index for its lifetime and has its
// values released when it is freed: it lives on the heap.
if (e.type.kind === "object" && host.classMeta.get(e.type.className)?.threadSlot === true)
return false;
if (e.type.kind === "record" && host.publishedTypes?.recordGuarded(e.type.shapeId) === true)
return false;
// A producer with a result-slot entry builds its fresh result in the
// caller's storage.
if (e.kind === "call") return host.fnByName.has(resultEntryName(e.callee));
if (e.kind === "recordLit") {
if (e.type.kind !== "record") return false;
const shape = host.recordsById.get(e.type.shapeId);
return frameRecordShape(shape) && completeRecordLiteral(e, shape);
}
// A same-shape spread (`{ ...source, field: value }`) copies the source's
// fields; the frontend builds it only for plain declared-field records.
if (e.kind === "recordClone")
return e.type.kind === "record" && frameRecordShape(host.recordsById.get(e.type.shapeId));
if (e.kind !== "new") return false;
const meta = host.classMeta.get(e.className);
return (
meta !== undefined &&
scalarStandaloneClass(
meta.def,
host.fnByName.get(`%${e.className}.constructor`),
meta.hierarchy,
host.programLifetime.has(e.className),
host.inlineUnions,
)
);
}
/** Whether a frame object of this type has fields that own references. */
function ownsReferences(host: LlvmEmitterContext, type: IrType): boolean {
return (
type.kind === "record" &&
host
.recordShape(type.shapeId)
.fields.some((field) => isRefCounted(field.type) && !host.inlineUnions.record(field.type))
);
}
/** Locals declared directly in a switch case. The cases share one scope
* whose exit runs whichever case was entered, so a scope-owned field would
* be released without its declaration having run. */
function caseScopedLocals(fn: IrFunction): Set<string> {
const ids = new Set<string>();
everyStmtList(fn.body, {
expr: () => true,
stmt: (stmt) => {
if (stmt.kind === "switch")
for (const c of stmt.cases)
for (const s of c.body) if (s.kind === "varDecl") ids.add(s.localId);
return true;
},
});
return ids;
}
/** Locals initialized once by a stack-eligible object and only projected
* afterwards (CallLifetimes.locals): field reads, and arguments to
* parameters that are themselves only projected. */
export function findLocalStackObjects(
host: LlvmEmitterContext,
fn: IrFunction,
lifetimes: CallLifetimes,
): Map<string, StackObjectSource> {
const result = new Map<string, StackObjectSource>();
const safe = lifetimes.locals.get(fn.name);
if (!safe?.size || fn.async || fn.generator) return result;
const caseScoped = caseScopedLocals(fn);
everyStmtList(fn.body, {
expr: () => true,
stmt: (stmt) => {
if (
stmt.kind === "varDecl" &&
safe.has(stmt.localId) &&
stmt.init &&
canStackObject(host, stmt.init) &&
!(caseScoped.has(stmt.localId) && ownsReferences(host, stmt.init.type))
)
result.set(stmt.localId, stmt.init);
return true;
},
});
return result;
}
/** Reassigned locals declared with an initializer, whose every write
* builds a stack-eligible object of the binding's own type, and whose uses
* only project the value
* (CallLifetimes.projectedLocals). The binding keeps one slot; each
* assignment builds its value in fresh storage, which may read the old
* value, and then copies it into the binding's slot. */
export function findReassignedStackObjects(
host: LlvmEmitterContext,
fn: IrFunction,
lifetimes: CallLifetimes,
): Set<string> {
const result = new Set<string>();
const projected = lifetimes.projectedLocals.get(fn.name);
const stable = lifetimes.locals.get(fn.name);
if (!projected?.size || fn.async || fn.generator) return result;
const types = new Map(fn.locals.map((local) => [local.id, local.type]));
for (const id of projected) {
const type = types.get(id);
if (!stable?.has(id) && (type?.kind === "record" || type?.kind === "object")) result.add(id);
}
// The binding must be declared by its own initializer: a loop that binds
// it (`for...of`) would point it at each element, not at the slot.
const initialized = new Set<string>();
everyStmtList(fn.body, {
expr: () => true,
stmt: (stmt) => {
if (stmt.kind === "forOf") result.delete(stmt.localId);
if (stmt.kind === "varDecl" && stmt.init) initialized.add(stmt.localId);
if ((stmt.kind === "varDecl" || stmt.kind === "assign") && result.has(stmt.localId)) {
const value = stmt.kind === "varDecl" ? stmt.init : stmt.value;
if (
!value ||
!canStackObject(host, value) ||
!typeEquals(value.type, types.get(stmt.localId)!)
)
result.delete(stmt.localId);
}
return true;
},
});
const caseScoped = caseScopedLocals(fn);
for (const id of result)
if (!initialized.has(id) || (caseScoped.has(id) && ownsReferences(host, types.get(id)!)))
result.delete(id);
return result;
}
export function stackObjectStruct(type: IrType): string {
return type.kind === "record"
? `%${mangleRecordStruct(type.shapeId)}`
: `%${mangleClassStruct((type as IrType & { kind: "object" }).className)}`;
}
/** The reference fields of the frame object in `slot`, addressed from the
* entry block so that every cleanup path can reach them. Inline union
* fields copy bits and own nothing; class objects in frame storage are
* scalar. */
export function stackObjectFields(
host: LlvmEmitterContext,
type: IrType,
slot: string,
): readonly StackObjectField[] {
if (type.kind !== "record") return [];
const cached = host.stackObjectFieldPtrs.get(slot);
if (cached) return cached;
const struct = stackObjectStruct(type);
const base = host.nullableUnions.fieldBase(type.shapeId);
const fields: StackObjectField[] = [];
host.recordShape(type.shapeId).fields.forEach((field, index) => {
if (!isRefCounted(field.type) || host.inlineUnions.record(field.type)) return;
const ptr = host.B.slot();
host.B.entryAllocas.push(
`${ptr} = getelementptr inbounds ${struct}, ptr ${slot}, i64 0, i32 ${index + base}`,
);
fields.push({ name: field.name, ptr, type: field.type });
});
host.stackObjectFieldPtrs.set(slot, fields);
return fields;
}
/** Copy a same-shape spread's source fields into `slot`, retaining the
* references, then store the overrides in source order. The source is
* read once, before any override evaluates, as the heap clone helper
* reads it; an overridden field is never copied, so its slot stays empty
* until the override initializes it. */
function emitRecordCloneFields(
host: LlvmEmitterContext,
e: IrExpr & { kind: "recordClone" },
slot: string,
): void {
const B = host.B;
const shapeId = (e.type as IrType & { kind: "record" }).shapeId;
const overridden = new Set(e.overrides.map((override) => override.name));
const source = host.emitReadReceiver(e.source);
for (const field of host.recordShape(shapeId).fields) {
if (overridden.has(field.name)) continue;
const from = host.recordFieldPtr(source.name, shapeId, field.name);
const to = host.recordFieldPtr(slot, shapeId, field.name);
const fieldTy = llFieldType(from.type, from.inline);
const v = B.tmp();
B.line(`${v} = load ${fieldTy}, ptr ${from.ptr}`);
const stored = isRefCounted(from.type) && !from.inline ? host.retainFrame(v, from.type) : v;
B.line(`store ${fieldTy} ${stored}, ptr ${to.ptr}`);
}
const initialized = new Set<string>();
for (const override of e.overrides) {
const v = host.emitFieldValue(override.value, host.recordFieldInline(shapeId, override.name));
const { ptr, type, inline } = host.recordFieldPtr(slot, shapeId, override.name);
// The first store fills an empty slot; a repeated name replaces it.
if (initialized.has(override.name)) host.replaceField(ptr, type, inline, v);
else host.initField(ptr, type, inline, v);
initialized.add(override.name);
}
}
/** Build the object in an entry-block slot (or `into`, an existing one).
* The immortal reference count makes every retain and release a no-op;
* the proof that the value is only projected keeps the pointer from
* outliving the frame. Reference fields start empty and are handed to
* `owner` before anything is stored, so an exception while the object is
* being built releases exactly the fields stored so far. */
export function emitStackObject(
host: LlvmEmitterContext,
e: StackObjectSource,
owner: StackObjectFieldOwner,
into?: string,
): LlValue {
const B = host.B;
const type = e.type;
const struct = stackObjectStruct(type);
let slot = into;
if (slot === undefined) {
slot = B.slot();
B.entryAllocas.push(`${slot} = alloca ${struct}, align 8`);
}
// A literal's interned string fields are immortal and need no owner. A
// binding's own slot (`into`) keeps an owner for every field: later
// assignments copy arbitrary values into it.
const literals =
into === undefined && e.kind === "recordLit"
? new Set(e.fields.filter((f) => f.value.kind === "strLit").map((f) => f.name))
: new Set<string>();
const fields = stackObjectFields(host, type, slot).filter((f) => !literals.has(f.name));
if (fields.length > 0) {
for (const field of fields) B.line(`store ptr null, ptr ${field.ptr}`);
owner(fields);
}
// Literals store every field; constructors that store every field
// before reading one need no zeroed storage either.
if (type.kind === "object") {
const meta = host.classMeta.get(type.className)!;
const ctor = host.fnByName.get(`%${type.className}.constructor`)!;
if (!constructorInitializesFields(meta.def, ctor)) {
B.line(`store ${struct} zeroinitializer, ptr ${slot}`);
// Inline union fields start undefined, as the class's `new` sets them.
for (const field of meta.def.fields) {
const tag = undefinedArmTag(field.type, host.unionsById);
if (tag < 0 || !host.inlineUnions.classField(type.className, field.name)) continue;
const p = B.tmp();
B.line(
`${p} = getelementptr inbounds ${struct}, ptr ${slot}, i64 0, i32 ${classFieldIndex(meta, field.name).index}, i32 0`,
);
B.line(`store i32 ${tag}, ptr ${p}`);
}
}
}
B.line(`store ${host.sizeType} -1, ptr ${slot}`);
if (type.kind === "record") {
// A tagged shape's word names it to every shape union's tag read.
const shapeNumber = host.nullableUnions.shapeNumber(type.shapeId);
if (shapeNumber !== 0) {
const p = B.tmp();
B.line(
`${p} = getelementptr inbounds ${struct}, ptr ${slot}, i64 0, i32 ${SHAPE_WORD_INDEX}`,
);
B.line(`store i32 ${shapeNumber}, ptr ${p}`);
}
}
if (e.kind === "call") {
// The entry writes the result into the slot it receives last.
emitCallExpr(
host,
{ ...e, callee: resultEntryName(e.callee), type: VOID },
false,
false,
host.markImmortal({ name: slot, type }),
);
} else if (e.kind === "recordLit") emitRecordLitFields(host, e, slot);
else if (e.kind === "recordClone") emitRecordCloneFields(host, e, slot);
else emitConstruction(host, e, slot, host.fnByName.get(`%${e.className}.constructor`)!);
return { name: slot, type: e.type };
}