Never buffer instances of classes whose trace visits nothing

A class carries the collector header when any class in its hierarchy can
be on a cycle, so instances of classes whose own fields hold nothing
cycle-capable were buffered as candidates and walked by every level. Such
an object has no traced children and is never on a cycle.

Their descriptors now name scr_cyc_no_children, and they are allocated
with buf set to SCR_CYC_ACYCLIC. That value has the buffered bit set, so
a release that leaves the object alive makes no call; scr_cyc_is_buffered
is false for it, and an emitted death or publication mark takes the object
out of a candidate buffer only when buf is below -1. A pass that walks
such an object as a child restores the value when it survives.
This commit is contained in:
Chris Tate
2026-10-21 22:08:30 -05:00
parent 10820d488c
commit 4100cd3c04
12 changed files with 279 additions and 25 deletions
+7 -2
View File
@@ -244,9 +244,12 @@ export function cycleAppendHelper(): string[] {
}
/** A collector header to install: the object type's constant ScrCycType
* descriptor (its trace and teardown; see cycTypeDescriptor). */
* descriptor (its trace and teardown; see cycTypeDescriptor). `acyclic`:
* the descriptor's trace is scr_cyc_no_children, and `buf` starts out
* SCR_CYC_ACYCLIC (scr_cyc_alloc does the same on its path). */
export interface CycHeader {
type: string;
acyclic?: boolean;
}
/** The first collector statistics index of an emitted ScrCycType
@@ -391,7 +394,9 @@ function allocLines(
` %sa.cgp = getelementptr inbounds i8, ptr %sa.blk, i64 2`,
` store i16 0, ptr %sa.cgp ; ScrCycHdr.color, gen`,
` %sa.bufp = getelementptr inbounds i8, ptr %sa.blk, i64 ${CYC_BUF}`,
` store i32 0, ptr %sa.bufp ; ScrCycHdr.buf`,
cyc.acyclic === true
? ` store i32 -1, ptr %sa.bufp ; ScrCycHdr.buf = SCR_CYC_ACYCLIC`
: ` store i32 0, ptr %sa.bufp ; ScrCycHdr.buf`,
` %sa.live = load i64, ptr @scr_cyc_live`,
` %sa.live1 = add i64 %sa.live, 1`,
` store i64 %sa.live1, ptr @scr_cyc_live`,
@@ -157,6 +157,44 @@ test("ProgramLifetime hierarchies are never freed and take no reference counts",
expect(body(mangleClassRelease("Owner"))).toContain("load i32, ptr %bufp");
});
test("classes whose trace visits nothing start acyclic in a traced hierarchy", () => {
const mapper = { kind: "object" as const, className: "Mapper" };
const mod: IrModule = {
irVersion: 17,
sourceFile: loc.file,
entry: "main",
functions: [{ name: "main", params: [], returnType: VOID, locals: [], body: [], loc }],
classes: [
{ name: "Mapper", fields: [], methods: [], loc },
// A dyn field can reach anything: the hierarchy carries collector headers.
{
name: "Linked",
base: "Mapper",
fields: [
{ name: "next", type: mapper },
{ name: "hook", type: DYN },
],
methods: [],
loc,
},
{ name: "Simple", base: "Mapper", fields: [{ name: "value", type: F64 }], methods: [], loc },
],
};
const llvm = emitLlvmModule(mod, { pointerBits: 64 });
const body = (name: string): string =>
new RegExp(`^define internal [a-z]+ @${name}(?:\\.i32)?\\([^]*?^}`, "m").exec(llvm)?.[0] ?? "";
const descriptor = (cls: string): string =>
llvm.split("\n").find((line) => line.startsWith(`@${mangleClassTrace(cls)}.cyctype = `)) ?? "";
expect(descriptor("Simple")).toContain("{ ptr @scr_cyc_no_children, ");
expect(body(mangleClassNew("Simple"))).toContain("store i32 -1, ptr %sa.bufp");
expect(descriptor("Linked")).toContain(`{ ptr @${mangleClassTrace("Linked")}, `);
expect(body(mangleClassNew("Linked"))).toContain("store i32 0, ptr %sa.bufp");
// Releases share the hierarchy's possible-root step, which skips the
// call for SCR_CYC_ACYCLIC, and a death unbuffers only below -1.
expect(body(mangleClassRelease("Simple"))).toContain("icmp slt i32 %buf, 0");
expect(body(mangleClassReleaseDirect("Simple"))).toContain("icmp slt i32 %dbuf, -1");
});
test("ProgramLifetime classes pack their fields after a 32-bit header", () => {
const node = { kind: "object" as const, className: "Node" };
const leaf = { kind: "object" as const, className: "Leaf" };
+12 -3
View File
@@ -906,6 +906,15 @@ export function emitClassShapes(
})),
];
const refFields = indexedFields.filter((f) => isRefCounted(f.type));
const tracedFields = traced
? indexedFields.filter((f) => traceAdapter(host, f.type) !== null)
: [];
// A traced class whose trace visits nothing carries the collector header
// only because its hierarchy is traced: it is never on a cycle. Its
// descriptor names scr_cyc_no_children and its objects start out
// SCR_CYC_ACYCLIC, so releases never buffer them.
const acyclic =
traced && runtimePrefix === null && !isStreamRooted && tracedFields.length === 0;
const bounded =
runtimePrefix !== null ||
isStreamRooted ||
@@ -1089,11 +1098,12 @@ export function emitClassShapes(
`entry:`,
];
const cycType = `@${mangleClassTrace(cls.name)}.cyctype`;
if (acyclic) host.declare(`declare void @scr_cyc_no_children(ptr, ptr, ptr)`);
if (traced)
defs.push(
cycTypeDescriptor(
cycType,
`@${mangleClassTrace(cls.name)}`,
acyclic ? "@scr_cyc_no_children" : `@${mangleClassTrace(cls.name)}`,
`@${mangleClassGcFree(cls.name)}`,
host.nextCycKind?.() ?? 0,
),
@@ -1113,7 +1123,7 @@ export function emitClassShapes(
...emitObjectAlloc(
host,
sizeOf,
traced && !lifetime ? { type: cycType } : null,
traced && !lifetime ? { type: cycType, acyclic } : null,
storedHeaderBytes(meta),
),
);
@@ -1188,7 +1198,6 @@ export function emitClassShapes(
// exactly the complement, then free (the trace/teardown complement
// contract in scr_runtime.h).
const indexed = indexedFields;
const tracedFields = indexed.filter((f) => traceAdapter(host, f.type) !== null);
const untracedRefFields = indexed.filter(
(f) => isRefCounted(f.type) && traceAdapter(host, f.type) === null,
);
@@ -60,6 +60,9 @@ test("publication walkers mark and push inline, with the runtime for the rest",
expect(fn).toContain("icmp ult i64 %mrc, 4294967294");
expect(fn).toContain("getelementptr inbounds { ptr, i32, i32 }, ptr %mlog, i64 %mlogged");
expect(fn).toContain("store i64 -1, ptr %o");
// A headered object leaves its candidate buffer only when `buf` is below
// -1: SCR_CYC_ACYCLIC (-1) marks an object that is never in one.
expect(fn).toContain("icmp sge i32 %mbufw, -1");
// Anything else goes through scr_pub_mark.
expect(fn).toMatch(/call zeroext i1 @scr_pub_mark\(ptr %ctx, ptr %o, i1 (true|false), i32 4\)/);
// A string is a leaf, marked at once; other references go on the stack,
@@ -434,12 +434,13 @@ export class PublishWalkers {
let buf = classId || compact ? String(PUB_LOG_COUNT) : "0";
if (cyc) {
// ScrCycHdr.buf, the word before the object: its top bit while the
// object is a buffered candidate.
// object is a buffered candidate, and -1 (SCR_CYC_ACYCLIC) for an
// object that never is one.
this.host.declare(`declare i32 @scr_cyc_pub_take(ptr)`);
lines.push(
` %mbufp = getelementptr inbounds i8, ptr %o, i64 -4`,
` %mbufw = load i32, ptr %mbufp`,
` %mfree = icmp sge i32 %mbufw, 0`,
` %mfree = icmp sge i32 %mbufw, -1`,
` br i1 %mfree, label %mput, label %mtake`,
`mtake:`,
` %mix = call i32 @scr_cyc_pub_take(ptr %o)`,
+8 -7
View File
@@ -1369,7 +1369,7 @@ export function directChildReleaseLines(
lines.push(
` %${tag}.bufp = getelementptr i8, ptr ${value}, ${S} -${host.cycleBufOffset}`,
` %${tag}.buf = load i32, ptr %${tag}.bufp`,
` %${tag}.queued = icmp slt i32 %${tag}.buf, 0`,
` %${tag}.queued = icmp slt i32 %${tag}.buf, -1 ; buffered, not SCR_CYC_ACYCLIC`,
` br i1 %${tag}.queued, label %${tag}.unbuffer, label %${tag}.teardown`,
`${tag}.unbuffer:`,
` call void @scr_cyc_on_dead(ptr ${value})`,
@@ -1595,8 +1595,9 @@ function requestCycleAppend(host: ShapeHost): boolean {
/** The possible-cycle-root step of a release that left a headered object
* alive: scr_cyc_on_release with its early return inlined. Test the sign
* of `buf` (the i32 at obj-4; negative for a queued candidate or a tenured
* object), and buffer a new candidate: through the module's
* of `buf` (the i32 at obj-4; negative for a queued candidate, a young
* object, or an object that is never a candidate, SCR_CYC_ACYCLIC), and
* buffer a new candidate: through the module's
* cycleAppendHelper in optimized builds that support it, through
* scr_cyc_on_release otherwise. Every emitted release of a headered object
* shares this sequence (scr_runtime.h asserts the offsets); it ends by
@@ -1619,16 +1620,16 @@ export function cycleRootLines(host: ShapeHost, done: string): string[] {
}
/** The death of a headered object: scr_cyc_on_dead with its early return
* inlined. Only an object still in a candidate buffer
* (`buffered` nonzero) has an entry to remove; the sequence continues at
* the new block `next`. */
* inlined. Only an object still in a candidate buffer (`buf` negative and
* not SCR_CYC_ACYCLIC, which is -1) has an entry to remove; the sequence
* continues at the new block `next`. */
export function cycleDeadLines(host: ShapeHost, next: string): string[] {
const S = host.sizeType;
host.declare(`declare void @scr_cyc_on_dead(ptr)`);
return [
` %dbufp = getelementptr i8, ptr %o, ${S} -${host.cycleBufOffset}`,
` %dbuf = load i32, ptr %dbufp`,
` %dqueued = icmp slt i32 %dbuf, 0`,
` %dqueued = icmp slt i32 %dbuf, -1 ; buffered, not SCR_CYC_ACYCLIC`,
` br i1 %dqueued, label %unbuffer, label %${next}`,
`unbuffer:`,
` call void @scr_cyc_on_dead(ptr %o)`,
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/acyclic-classes-in-traced-hierarchies.ts"
],
"diags": []
}
+24 -6
View File
@@ -361,7 +361,15 @@ uint32_t scr_cyc_register(ScrCycType *type) {
}
void *scr_cyc_alloc(size_t size, ScrCycType *type) {
return scr_cyc_alloc_inline(size, type);
void *obj = scr_cyc_alloc_inline(size, type);
if (type->trace == scr_cyc_no_children) scr_cyc_hdr(obj)->buf = SCR_CYC_ACYCLIC;
return obj;
}
void scr_cyc_no_children(void *obj, ScrTraceVisit visit, void *ctx) {
(void)obj;
(void)visit;
(void)ctx;
}
void scr_cyc_free(void *obj) { scr_cyc_free_inline(obj); }
@@ -1175,7 +1183,13 @@ void scr_cyc_on_release(void *obj) {
ScrCycHdr *h = scr_cyc_hdr(obj);
/* Releasing an existing candidate cannot advance the trigger. A pass
* always re-arms after its teardowns, including any candidates they add. */
if (scr_cyc_is_buffered(h)) return;
if (scr_cyc_release_done(h)) return;
/* An object without cycle-capable children whose SCR_CYC_ACYCLIC a pass
* or the young-in nursery replaced gets it back. */
if (scr_cyc_type(h)->trace == scr_cyc_no_children) {
h->buf = SCR_CYC_ACYCLIC;
return;
}
#if SCR_CYC_INLINE_BUFFERING
if (scr_cyc_append(h, obj)) return;
#endif
@@ -1346,14 +1360,17 @@ static void scr_scan_black(void *obj, uintptr_t depth) {
ScrCycHdr *h = scr_cyc_hdr(obj);
scr_cyc_set_color(h, SCR_CYC_BLACK);
/* Drop markGray's component number: a survivor leaves the pass unbuffered
* unless it is a candidate that a limited pass did not take. */
if (!scr_cyc_is_buffered(h)) scr_cyc_clear_buffered(h);
* unless it is a candidate that a limited pass did not take, and one
* without cycle-capable children leaves it SCR_CYC_ACYCLIC again. */
const ScrCycType *type = scr_cyc_type(h);
bool acyclic = type->trace == scr_cyc_no_children;
if (!scr_cyc_is_buffered(h)) h->buf = acyclic ? SCR_CYC_ACYCLIC : 0;
if (scr_cyc_gen(h) < SCR_CYC_OLD) scr_cyc_push_work(&scr_promote, obj);
if (acyclic) return;
if (depth == SCR_CYC_WALK_DEPTH) {
scr_cyc_push_work(&scr_restore_pending, obj);
return;
}
const ScrCycType *type = scr_cyc_type(h);
SCR_CYC_CHILDREN(obj, type, scr_sb_array, scr_sb_visit, depth + 1);
}
static void scr_restore_from(void *root) {
@@ -1881,7 +1898,8 @@ static SCR_CYC_COMPACT size_t scr_cyc_settle(unsigned gen_limit, bool doomed) {
void *obj = scr_cands.v[i];
ScrCycHdr *h = scr_cyc_hdr(obj);
if (scr_cyc_color(h) == SCR_CYC_DOOMED) continue; /* being freed below */
if (!scr_cyc_is_buffered(h)) {
/* A candidate without cycle-capable children was never on a cycle. */
if (!scr_cyc_is_buffered(h) && h->buf != SCR_CYC_ACYCLIC) {
/* A survivor promoted into a level its type bypasses moves past
* it, as a release would buffer it. */
if (scr_cyc_gen(h) == SCR_CYC_MATURE && scr_cyc_bypassed(scr_cyc_type(h), SCR_CYC_MATURE))
+27 -5
View File
@@ -141,6 +141,11 @@ typedef struct ScrCycType {
* Emitted allocation paths read it and take the slow path while it is 0. */
uint32_t id;
} ScrCycType;
/* The trace of a type that holds nothing cycle-capable: it visits nothing.
* Emitted classes whose fields hold no cycle-capable value but whose
* hierarchy is traced name it (llvm/classes.ts); their objects are
* allocated with `buf` SCR_CYC_ACYCLIC. */
void scr_cyc_no_children(void *obj, ScrTraceVisit visit, void *ctx);
/* Runtime families' kinds. Emitted descriptors count from
* SCR_CYC_KIND_EMITTED (llvm/alloc.ts FIRST_EMITTED_CYC_KIND). */
enum {
@@ -169,7 +174,7 @@ enum {
* collector color (SCR_CYC_*) and generation (SCR_CYC_NURSERY..SCR_CYC_OLD,
* the walk filter), and `buf`: SCR_CYC_BUFFERED_BIT while the object sits in
* its generation's candidate buffer, with its position there below it (O(1)
* removal when rc hits 0). */
* removal when rc hits 0), or SCR_CYC_ACYCLIC. */
typedef struct ScrCycHdr {
uint16_t id;
uint8_t color;
@@ -177,13 +182,25 @@ typedef struct ScrCycHdr {
uint32_t buf;
} ScrCycHdr;
#define SCR_CYC_BUFFERED_BIT 0x80000000u
/* `buf` of an object whose type's trace is scr_cyc_no_children: it has no
* cycle-capable children, so it is never on a cycle and never a candidate.
* Such a type still carries the header when it shares a hierarchy with
* cycle-capable classes (headers are uniform per hierarchy). The value has
* the buffered bit set, so a release that leaves the object alive returns
* at the buffered test and makes no call; scr_cyc_is_buffered is false for
* it. No buffer position reaches it (scr_cyc_grow caps the buffers well
* below 2^31 entries). */
#define SCR_CYC_ACYCLIC 0xffffffffu
/* This layout is an ABI, not an implementation detail. The emitted
* allocation stores the id and zeroes the rest; every emitted release of a
* allocation stores the id and zeroes the rest (`buf` SCR_CYC_ACYCLIC for a
* class whose trace is scr_cyc_no_children); every emitted release of a
* headered object (llvm/shapes.ts cycleRootLines, used by the record, class
* and inline RC helpers) mirrors scr_cyc_on_release's early return by
* testing the sign of `buf` as an i32 at obj-4, and the emitted inline free
* reads the generation as an i8 at obj-5. Hence the assertions. */
* testing the sign of `buf` as an i32 at obj-4, the emitted death mirrors
* scr_cyc_on_dead's by testing `buf` below -1 (cycleDeadLines), and the
* emitted inline free reads the generation as an i8 at obj-5. Hence the
* assertions. */
_Static_assert(sizeof(ScrCycHdr) == 8, "LLVM backend expects an 8-byte cycle header");
_Static_assert(offsetof(ScrCycHdr, id) == 0 && sizeof(((ScrCycHdr *)0)->id) == 2,
"the emitted allocation stores the registry id as an i16 at the block start");
@@ -215,6 +232,11 @@ static inline void scr_cyc_set_color(ScrCycHdr *h, unsigned color) { h->color =
static inline unsigned scr_cyc_gen(const ScrCycHdr *h) { return h->gen; }
static inline void scr_cyc_set_gen(ScrCycHdr *h, unsigned gen) { h->gen = (uint8_t)gen; }
static inline bool scr_cyc_is_buffered(const ScrCycHdr *h) {
return (h->buf & SCR_CYC_BUFFERED_BIT) != 0 && h->buf != SCR_CYC_ACYCLIC;
}
/* A release that leaves the object alive has nothing to buffer: it is a
* candidate already, or it is never one (SCR_CYC_ACYCLIC). */
static inline bool scr_cyc_release_done(const ScrCycHdr *h) {
return (h->buf & SCR_CYC_BUFFERED_BIT) != 0;
}
static inline uint32_t scr_cyc_buf_index(const ScrCycHdr *h) {
@@ -608,7 +630,7 @@ static inline bool scr_cyc_append(ScrCycHdr *h, void *obj) {
* Most releases of long-lived containers take the first early return. */
static inline void scr_cyc_released(void *obj) {
ScrCycHdr *h = scr_cyc_hdr(obj);
if (scr_cyc_is_buffered(h)) return;
if (scr_cyc_release_done(h)) return;
#if SCR_CYC_INLINE_BUFFERING
if (scr_cyc_append(h, obj)) return;
#endif
+33
View File
@@ -1434,6 +1434,38 @@ static void check_death_brings_full_pass(void) {
free(keep);
}
/* An object whose type's trace is scr_cyc_no_children starts out
* SCR_CYC_ACYCLIC and is never buffered. A pass that reaches it from a dead
* cycle frees it with the cycle when the cycle held its last reference, and
* otherwise leaves it SCR_CYC_ACYCLIC with its outside count. */
static void check_acyclic_objects(void) {
static ScrCycType leaf_type = { &scr_cyc_no_children, &node_free, 0 };
size_t before = freed;
Node *leaf = scr_cyc_alloc(sizeof(*leaf), &leaf_type);
ScrCycHdr *h = scr_cyc_hdr(leaf);
check(h->buf == SCR_CYC_ACYCLIC && !scr_cyc_is_buffered(h), "acyclic object did not start acyclic");
leaf->rc = 2; /* an outside owner and a temporary reference */
release_live(leaf);
check(h->buf == SCR_CYC_ACYCLIC, "release buffered an acyclic object");
Node *first = make_ring(2);
first->other = leaf;
leaf->rc++;
release_live(first);
scr_cyc_pass_at(SCR_CYC_NURSERY);
check(freed == before + 2, "nursery pass did not free the ring");
check(leaf->rc == 1 && h->buf == SCR_CYC_ACYCLIC, "acyclic survivor lost its count or its mark");
Node *second = make_ring(2);
second->next->other = leaf;
leaf->rc++;
release_live(leaf); /* the outside owner lets go: only the ring holds it */
check(h->buf == SCR_CYC_ACYCLIC, "release buffered an acyclic object");
release_live(second);
scr_collect_cycles();
check(freed == before + 5, "acyclic object held only by a dead ring was not freed with it");
}
int main(void) {
check_inline_buffering();
check_old_garbage_reclaimed_while_growing();
@@ -1467,6 +1499,7 @@ int main(void) {
check_limited_pass();
check_large_old_buffer_with_nursery_candidates();
check_unjudged_backlog_follows_growth();
check_acyclic_objects();
printf("cycle collection checks passed: threshold=%zu growth_cap=%zu\n",
configured_nursery_threshold(), configured_growth_cap());
return 0;
@@ -0,0 +1,117 @@
import { publish } from "@scriptc/threads";
// Classes whose own fields hold nothing cycle-capable, in a hierarchy where
// other classes form cycles: their instances are held by dead cycles, by
// live cycles and by plain locals while collections run, and are published
// with the contexts that hold them.
abstract class Mapper {
abstract map(n: number): number;
}
class Offset extends Mapper {
by: number;
constructor(by: number) {
super();
this.by = by;
}
map(n: number): number {
return n + this.by;
}
}
class Scale extends Offset {
readonly factor: number;
constructor(by: number, factor: number) {
super(by);
this.factor = factor;
}
map(n: number): number {
return super.map(n) * this.factor;
}
}
class Context {
readonly steps: Mapper[] = [];
mapper: Mapper | undefined = undefined;
total = 0;
}
class ContextMapper extends Mapper {
readonly context: Context;
constructor(context: Context) {
super();
this.context = context;
}
map(n: number): number {
let value = n;
for (const step of this.context.steps) value = step.map(value);
return value;
}
}
class Chain extends Mapper {
readonly first: Mapper;
next: Mapper | undefined = undefined;
constructor(first: Mapper) {
super();
this.first = first;
}
map(n: number): number {
const value = this.first.map(n);
return this.next === undefined ? value : this.next.map(value);
}
}
function makeContext(i: number, shared: Mapper): Context {
const context = new Context();
context.steps.push(new Offset(i % 7), shared, new Scale(1, 2));
context.mapper = new ContextMapper(context);
context.total = context.mapper.map(i);
return context;
}
const shared = new Scale(3, 1);
const kept: Context[] = [];
let sum = 0;
for (let i = 0; i < 20000; i++) {
const context = makeContext(i, shared);
sum += context.total;
if (i % 1000 === 0) kept.push(context);
}
// A chain that loops back to itself, holding mappers that are also held
// outside it.
const outside: Mapper[] = [];
let loops = 0;
for (let i = 0; i < 5000; i++) {
const offset = new Offset(i);
const head = new Chain(offset);
const tail = new Chain(new Scale(i, 3));
head.next = tail;
tail.next = undefined;
loops += head.map(1);
tail.next = head;
if (i % 500 === 0) outside.push(offset);
}
let keptTotal = 0;
for (const context of kept) keptTotal += context.mapper!.map(1);
let outsideTotal = 0;
for (const mapper of outside) outsideTotal += mapper.map(2);
console.log(sum, loops, kept.length, keptTotal, outside.length, outsideTotal, shared.map(4));
publish(kept);
let publishedTotal = 0;
for (const context of kept) publishedTotal += context.mapper!.map(3);
console.log("published", publishedTotal);
try {
shared.by = 5;
console.log("write: ok");
} catch (e) {
console.log(`write: ${(e as Error).name}: ${(e as Error).message}`);
}
const fresh = makeContext(7, new Offset(1));
fresh.steps.push(shared);
console.log("after", fresh.mapper!.map(2), outside[3]!.map(1));
@@ -0,0 +1 @@
{ "plain": 3350, "san": 3910 }