Tear down self-recursive records and classes in one loop

The teardown of a type with children of its own type called itself for
each child: a call, its register saves and the segment bookkeeping per
node, 45 instructions per node in a binary tree.

A record or standalone class whose every reference field holds its own
type and that has no collector header now tears down in one loop. A child
whose last reference the dead node held goes onto a pending list linked
through its own count word, which it no longer needs, and the loop takes
the list's first node next, so nodes are visited parent first. Each node
frees its block as before (dispose observers, the RC audit, blocks of
other arenas), the calling thread's blocks go into the teardown segment
in visit order, and the segment goes onto the free list in one step, so a
structure built parent first takes the blocks in its allocation order.
Shared and immortal children keep their release. The loop needs no depth
budget. Other teardowns keep the recursive form.
This commit is contained in:
Chris Tate
2026-10-26 10:42:41 -05:00
parent 26b2fdcc67
commit 9b96314c20
6 changed files with 325 additions and 1 deletions
@@ -87,9 +87,13 @@ test("teardowns of types with children of their own type free their blocks as on
classes: [
{
name: "Linked",
// The array field keeps the recursive teardown: a type whose every
// reference field holds its own type tears down in one loop
// (iterative-teardown.test.ts).
fields: [
{ name: "next", type: linked },
{ name: "other", type: linked },
{ name: "tags", type: { kind: "array", elem: { kind: "string" } } },
],
methods: [],
loc,
@@ -45,6 +45,7 @@ import {
directDestroyTarget,
segmentPrologueLines,
segmentTeardownLines,
iterativeTeardownDefinitions,
segmentedTeardown,
usedDirectTargets,
type DirectDestroy,
@@ -1334,6 +1335,15 @@ export function emitClassShapes(
} else {
const freeBody: string[] = [];
teardown(freeBody);
// A segmented class whose every reference field holds its own type and
// that has no collector header tears down in one loop (shapes.ts
// iterativeTeardownDefinitions).
const iterative =
segmented &&
!traced &&
refFields.every(
(f) => directDestroyTarget(host, f.type, directTargets)?.destroy === destroy,
);
defs.push(
...releaseBody(
host,
@@ -1345,6 +1355,18 @@ export function emitClassShapes(
lifetime,
chained,
segmented,
false,
iterative
? iterativeTeardownDefinitions(
host,
destroy,
struct,
refFields.map((f) => f.index),
`@${mangleClassRelease(cls.name)}`,
"weak",
count,
)
: null,
),
``,
);
@@ -0,0 +1,68 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile, deserializeModule } from "../../index.js";
import { emitLlvmModule } from "./emitter.js";
async function moduleFor(source: string) {
const dir = await mkdtemp(join(tmpdir(), "scriptc-iterative-teardown-"));
try {
const entry = join(dir, "main.ts"),
output = join(dir, "main.ir.json");
await writeFile(entry, source);
const result = await compile(entry, { outDir: dir, outPath: output, outputKind: "ir" });
if (!result.ok)
throw new Error(result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n"));
return deserializeModule(await readFile(output, "utf8"));
} finally {
await rm(dir, { recursive: true, force: true });
}
}
const SOURCE = `
interface TreeNode { left: TreeNode | null; right: TreeNode | null }
class ListNode {
next: ListNode | null;
value: number;
constructor(next: ListNode | null, value: number) { this.next = next; this.value = value; }
}
interface Labeled { child: Labeled | null; label: string[] }
function tree(d: number): TreeNode { return d > 0 ? { left: tree(d - 1), right: tree(d - 1) } : { left: null, right: null }; }
function size(n: TreeNode | null): number { return n === null ? 0 : 1 + size(n.left) + size(n.right); }
let list: ListNode | null = null;
for (let i = 0; i < 10; i++) list = new ListNode(list, i);
let labeled: Labeled | null = { child: { child: null, label: ["b"] }, label: ["a"] };
console.log(size(tree(4)), list!.value, labeled!.label.length);
list = null;
labeled = null;
`;
/** The definitions whose symbols end in `suffix`. */
function definitions(ir: string, suffix: string): string[] {
return [
...ir.matchAll(new RegExp(`^define internal [^\\n]*@([\\w.$%-]+${suffix})\\(`, "gm")),
].map((m) => m[1]!);
}
test("self-recursive records and classes tear down in one loop", async () => {
const ir = emitLlvmModule(await moduleFor(SOURCE), { targetTriple: "x86_64-unknown-linux-gnu" });
const loops = definitions(ir, "_destroy_it");
// The tree record and the list class; the labeled record also holds an
// array, so it keeps the recursive teardown.
expect(loops.length).toBe(2);
expect(loops.some((name) => name.includes("ListNode"))).toBe(true);
for (const name of loops) {
const body = new RegExp(
`^define internal void @${name.replace(/[$.]/g, "\\$&")}\\(ptr %root\\)[^]*?^}`,
"m",
).exec(ir)![0];
// No call to a teardown: dead children go onto the pending list.
expect(body).not.toMatch(/call [^\n]*_destroy/);
expect(body).toContain("inttoptr");
const base = name.slice(0, -"_it".length);
expect(ir).toContain(`define internal void @${base}(ptr %o)`);
expect(ir).not.toContain(`@${base}_s(`);
}
expect(definitions(ir, "_destroy_s").length).toBe(1);
});
+129 -1
View File
@@ -35,6 +35,7 @@ import {
emitObjectAlloc,
emitObjectFree,
emitSegmentFree,
type FreeObservers,
CYC_APPEND,
cycleAppendDecls,
cycleAppendHelper,
@@ -1820,6 +1821,115 @@ function sizeLiteral(sizeType: RcCount, value: bigint): string {
return BigInt.asIntN(sizeType === "i64" ? 64 : 32, value).toString();
}
/** The teardown of a type whose reference fields all hold its own type
* (`fields`: their struct indices; `release`: the type's inlinable release;
* no collector header): one loop instead of a call per node. A child whose
* last reference the dead node held joins a pending list linked through its
* own count word, which it no longer needs; the loop takes the list's first
* node next, so nodes are visited parent first, fields in order. Each node
* releases its block as emitObjectFree does, keeping a block of the calling
* thread's arena in a segment linked through its first word, and the segment
* goes onto the free list in one step at the end (emitSegmentFree): the next
* structure built parent first takes the blocks in the order it allocates
* them, as segmentTeardownLines arranges for the recursive form. The loop
* needs no depth budget. `_d` (the direct-chain entry other teardowns call)
* and the plain entry both run it. A class that keeps its id in the count
* word counts in the low half (`count`); a dead node's whole word is free. */
export function iterativeTeardownDefinitions(
host: ShapeHost,
destroy: string,
struct: string,
fields: readonly number[],
release: string,
observers: FreeObservers,
count: RcCount = host.sizeType,
): string[] {
const S = host.sizeType;
const lines = [
`define internal void @${destroy}_it(ptr %root) ${FN_ATTRS} {`,
`entry:`,
` %sg.own = alloca ptr`,
` br label %it.node`,
`it.node:`,
` %o = phi ptr [ %root, %entry ], [ %it.top, %it.next ]`,
` %it.pend = phi ptr [ null, %entry ], [ %it.rest, %it.next ]`,
` %it.first = phi ptr [ null, %entry ], [ %it.fa, %it.next ]`,
` %it.last = phi ptr [ null, %entry ], [ %it.la, %it.next ]`,
...fields.flatMap((index, i) => [
` %it.f${i} = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${index}`,
` %it.v${i} = load ptr, ptr %it.f${i}`,
]),
` store ptr null, ptr %sg.own`,
...emitObjectFree(host, false, observers, "%sg.own"),
` br label %it.freed`,
`it.freed:`,
` %it.k = load ptr, ptr %sg.own`,
` %it.kept = icmp ne ptr %it.k, null`,
` br i1 %it.kept, label %it.app, label %it.kids`,
`it.app:`,
` %it.none = icmp eq ptr %it.first, null`,
` br i1 %it.none, label %it.appd, label %it.link`,
`it.link:`,
` store ptr %it.k, ptr %it.last`,
` br label %it.appd`,
`it.appd:`,
` %it.nf = select i1 %it.none, ptr %it.k, ptr %it.first`,
` br label %it.kids`,
`it.kids:`,
` %it.fa = phi ptr [ %it.nf, %it.appd ], [ %it.first, %it.freed ]`,
` %it.la = phi ptr [ %it.k, %it.appd ], [ %it.last, %it.freed ]`,
];
// The fields go onto the list last first, so the first field is next.
let pend = "%it.pend";
let from = "it.kids";
for (let i = fields.length - 1; i >= 0; i--) {
const v = `%it.v${i}`;
lines.push(
` %it.nn${i} = icmp ne ptr ${v}, null`,
` br i1 %it.nn${i}, label %it.chk${i}, label %it.j${i}`,
`it.chk${i}:`,
` %it.rc${i} = load ${count}, ptr ${v}`,
` %it.last${i} = icmp eq ${count} %it.rc${i}, 1`,
` br i1 %it.last${i}, label %it.push${i}, label %it.sh${i}`,
`it.push${i}:`,
` %it.pl${i} = ptrtoint ptr ${pend} to ${S}`,
` store ${S} %it.pl${i}, ptr ${v}`,
` br label %it.j${i}`,
`it.sh${i}:`,
` call void ${release}(ptr ${v}) ; a shared or immortal child`,
` br label %it.j${i}`,
`it.j${i}:`,
` %it.p${i} = phi ptr [ ${pend}, %${from} ], [ ${v}, %it.push${i} ], [ ${pend}, %it.sh${i} ]`,
);
pend = `%it.p${i}`;
from = `it.j${i}`;
}
lines.push(
` %it.empty = icmp eq ptr ${pend}, null`,
` br i1 %it.empty, label %it.done, label %it.next`,
`it.next:`,
` %it.top = getelementptr i8, ptr ${pend}, i64 0`,
` %it.lw = load ${S}, ptr %it.top`,
` %it.rest = inttoptr ${S} %it.lw to ptr`,
` br label %it.node`,
`it.done:`,
...emitSegmentFree(host, "sgf", "%it.fa", "%it.la"),
` ret void`,
`}`,
`define internal void @${destroy}_d(ptr %o, i32 %d) ${FN_ATTRS} {`,
`entry:`,
` call void @${destroy}_it(ptr %o)`,
` ret void`,
`}`,
`define internal void @${destroy}(ptr %o) ${FN_ATTRS} {`,
`entry:`,
` call void @${destroy}_it(ptr %o)`,
` ret void`,
`}`,
);
return lines;
}
/** Common NULL/immortal/decrement skeleton for ordinary object releases.
* `freeBody` owns the zero-ref teardown and must leave the current block at
* its end; traced objects also get the possible-cycle-root branch.
@@ -1838,6 +1948,7 @@ export function releaseBody(
chained = false,
segmented = false,
frozen = false,
destroyDefs: readonly string[] | null = null,
): string[] {
const S = host.sizeType;
if (bounded) host.declare(`declare void @scr_rc_destroy(ptr, ptr)`);
@@ -1845,7 +1956,9 @@ export function releaseBody(
// A freezable record's count is the word without its frozen bit.
const notFrozen = sizeLiteral(S, ~recordFrozenBit(S));
const lines = [
...(bounded ? destroyDefinitions(destroy, freeBody, chained, segmented ? host : null) : []),
...(bounded
? (destroyDefs ?? destroyDefinitions(destroy, freeBody, chained, segmented ? host : null))
: []),
...releaseFastPath(host, fnName, `${fnName}_slow`, traced, comment, immortal, S, frozen),
`define internal void @${fnName}_slow(ptr %o) noinline ${FN_ATTRS} {${comment ? ` ; ${comment}` : ""}`,
`entry:`,
@@ -2141,6 +2254,11 @@ export function emitRecordShapes(
),
);
else freeBody.push(...emitObjectFree(host, traced, observers));
// A segmented shape whose every reference field holds its own type, with
// no collector header and no frozen bit in its count, tears down in one
// loop (iterativeTeardownDefinitions).
const iterative =
segmented && !traced && shape.freezable !== true && ownType.length === refMembers.length;
defs.push(
...releaseBody(
host,
@@ -2153,6 +2271,16 @@ export function emitRecordShapes(
chained,
segmented,
shape.freezable === true,
iterative
? iterativeTeardownDefinitions(
host,
destroy,
struct,
refMembers.map((m) => m.index),
`@${mangleRecordRelease(shape.id)}`,
observers,
)
: null,
),
``,
);
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8400-iterative-teardown.ts"
],
"diags": []
}
+96
View File
@@ -0,0 +1,96 @@
// Teardown of self-recursive records and classes: shared and immortal
// children, deep chains, weak-collection keys, and trees rebuilt from freed
// blocks.
interface TreeNode {
left: TreeNode | null;
right: TreeNode | null;
}
class ListNode {
next: ListNode | null;
value: number;
constructor(next: ListNode | null, value: number) {
this.next = next;
this.value = value;
}
}
class Pair {
a: Pair | null;
b: Pair | null;
constructor(a: Pair | null, b: Pair | null) {
this.a = a;
this.b = b;
}
}
function build(depth: number): TreeNode {
return depth > 0 ? { left: build(depth - 1), right: build(depth - 1) } : { left: null, right: null };
}
function count(node: TreeNode | null): number {
if (node === null) return 0;
return 1 + count(node.left) + count(node.right);
}
// Trees torn down and rebuilt from their freed blocks.
let total = 0;
for (let depth = 0; depth <= 12; depth++) {
for (let i = 0; i < 4; i++) total += count(build(depth));
}
console.log("trees", total);
// A child shared by two parents survives the first parent's teardown.
const shared = build(3);
let left: TreeNode | null = { left: shared, right: build(2) };
let right: TreeNode | null = { left: build(1), right: shared };
left = null;
console.log("shared after first parent", count(shared), count(right));
right = null;
console.log("shared after second parent", count(shared));
// A node held from outside keeps its subtree when the tree around it dies.
let outer: TreeNode | null = build(6);
const kept = outer.left!.right!;
outer = null;
console.log("kept subtree", count(kept));
// A chain far deeper than any call stack.
let head: ListNode | null = null;
for (let i = 0; i < 2000000; i++) head = new ListNode(head, i);
let sum = 0;
for (let n = head; n !== null; n = n.next) sum += n.value % 7;
head = null;
console.log("chain", sum);
// A list whose middle node is still referenced keeps the tail from it.
let list: ListNode | null = null;
let middle: ListNode | null = null;
for (let i = 0; i < 1000; i++) {
list = new ListNode(list, i);
if (i === 500) middle = list;
}
list = null;
let tail = 0;
for (let n = middle; n !== null; n = n.next) tail++;
console.log("tail from middle", tail);
middle = null;
// Nodes used as WeakMap keys: their entries go away when the tree dies.
const weak = new WeakMap<Pair, number>();
let root: Pair | null = new Pair(new Pair(null, null), new Pair(new Pair(null, null), null));
const probe = root.b!;
weak.set(root, 1);
weak.set(root.a!, 2);
weak.set(probe, 3);
root = null;
console.log("weak probe", weak.get(probe), weak.has(probe));
// Immortal leaves shared by many nodes.
const leaf: TreeNode = { left: null, right: null };
let wide: TreeNode | null = { left: leaf, right: leaf };
for (let i = 0; i < 100; i++) wide = { left: wide, right: leaf };
console.log("wide", count(wide));
wide = null;
console.log("leaf", count(leaf));