diff --git a/packages/compiler/src/backend/llvm/classes.test.ts b/packages/compiler/src/backend/llvm/classes.test.ts index f472eec60..e7ba72784 100644 --- a/packages/compiler/src/backend/llvm/classes.test.ts +++ b/packages/compiler/src/backend/llvm/classes.test.ts @@ -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, diff --git a/packages/compiler/src/backend/llvm/classes.ts b/packages/compiler/src/backend/llvm/classes.ts index 5ff7477f3..5941f5d5c 100644 --- a/packages/compiler/src/backend/llvm/classes.ts +++ b/packages/compiler/src/backend/llvm/classes.ts @@ -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, ), ``, ); diff --git a/packages/compiler/src/backend/llvm/iterative-teardown.test.ts b/packages/compiler/src/backend/llvm/iterative-teardown.test.ts new file mode 100644 index 000000000..befa258e8 --- /dev/null +++ b/packages/compiler/src/backend/llvm/iterative-teardown.test.ts @@ -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); +}); diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 5b76886b2..ea429d71a 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -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, ), ``, ); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8400-iterative-teardown.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8400-iterative-teardown.ts.json new file mode 100644 index 000000000..80ad38bdf --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8400-iterative-teardown.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8400-iterative-teardown.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8400-iterative-teardown.ts b/tests/corpus/8400-iterative-teardown.ts new file mode 100644 index 000000000..69ec13824 --- /dev/null +++ b/tests/corpus/8400-iterative-teardown.ts @@ -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(); +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));