From b98e23db31eaf49948e8e8388ed8acf8794c7b99 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 16 Oct 2026 05:17:41 -0500 Subject: [PATCH 1/2] Keep value names distinct in publication walkers with more than ten pushes The inline push named the incremented stack length `%len1`, which for push 0 is the same name as push 10's loaded length `%len10`. Walkers of classes with more than ten pushed fields were invalid IR. The incremented length is now `%lennext`. --- .../compiler/src/backend/llvm/publish.test.ts | 18 ++++ packages/compiler/src/backend/llvm/publish.ts | 4 +- .../corpus/5682-publish-wide-objects.ts.json | 6 ++ tests/corpus/5682-publish-wide-objects.ts | 85 +++++++++++++++++++ 4 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5682-publish-wide-objects.ts.json create mode 100644 tests/corpus/5682-publish-wide-objects.ts diff --git a/packages/compiler/src/backend/llvm/publish.test.ts b/packages/compiler/src/backend/llvm/publish.test.ts index d7f29f2f9..c2d55804c 100644 --- a/packages/compiler/src/backend/llvm/publish.test.ts +++ b/packages/compiler/src/backend/llvm/publish.test.ts @@ -82,3 +82,21 @@ test("32-bit walkers log the count without the 64-bit escapes", () => { expect(fn).not.toContain("4294967294"); expect(fn).toContain("getelementptr inbounds { ptr, i32, i32 }, ptr %mlog, i32 %mlogged"); }); + +test("walkers of classes with more than ten pushed fields keep their value names distinct", () => { + const wide: IrModule = { + ...mod, + classes: [ + { + name: "Node", + fields: Array.from({ length: 12 }, (_, i) => ({ name: `link${i}`, type: node })), + methods: [], + loc, + }, + ], + }; + const fn = walker(emitLlvmModule(wide, { pointerBits: 64, objectAudit: false })); + const defined = [...fn.matchAll(/^ {2}(%[\w.]+) = /gm)].map((m) => m[1]); + expect(defined.length).toBeGreaterThan(12 * 8); + expect(new Set(defined).size).toBe(defined.length); +}); diff --git a/packages/compiler/src/backend/llvm/publish.ts b/packages/compiler/src/backend/llvm/publish.ts index de4e953fa..c884339af 100644 --- a/packages/compiler/src/backend/llvm/publish.ts +++ b/packages/compiler/src/backend/llvm/publish.ts @@ -449,8 +449,8 @@ export class PublishWalkers { ` store ptr ${v}, ptr %slot${id}`, ` %slotfn${id} = getelementptr inbounds { ptr, ptr }, ptr %slot${id}, i32 0, i32 1`, ` store ptr ${fn}, ptr %slotfn${id}`, - ` %len1${id} = add ${S} %len${id}, 1`, - ` store ${S} %len1${id}, ptr %lenp${id}`, + ` %lennext${id} = add ${S} %len${id}, 1`, + ` store ${S} %lennext${id}, ptr %lenp${id}`, ]; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5682-publish-wide-objects.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5682-publish-wide-objects.ts.json new file mode 100644 index 000000000..224860a49 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5682-publish-wide-objects.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5682-publish-wide-objects.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5682-publish-wide-objects.ts b/tests/corpus/5682-publish-wide-objects.ts new file mode 100644 index 000000000..5353ee8d5 --- /dev/null +++ b/tests/corpus/5682-publish-wide-objects.ts @@ -0,0 +1,85 @@ +import { publish } from "@scriptc/threads"; + +// publish() over objects with more than ten reference fields: links to +// other objects, strings, arrays and absent links. A published object +// rejects writes to any of its fields and to the objects they reach; a +// refused object (a field holds a function) stays writable. + +class Leaf { + label: string; + constructor(label: string) { + this.label = label; + } +} + +class Wide { + a: Leaf; + b: Leaf; + c: Leaf | undefined; + d: Leaf; + e: string; + f: Leaf[]; + g: Leaf; + h: Leaf | undefined; + i: Leaf; + j: string; + k: Leaf; + l: Leaf; + m: Wide | undefined = undefined; + hook: (() => string) | undefined = undefined; + constructor(tag: string) { + this.a = new Leaf(`${tag}.a`); + this.b = new Leaf(`${tag}.b`); + this.c = tag.length > 2 ? new Leaf(`${tag}.c`) : undefined; + this.d = new Leaf(`${tag}.d`); + this.e = `${tag}.e`; + this.f = [new Leaf(`${tag}.f0`), new Leaf(`${tag}.f1`)]; + this.g = new Leaf(`${tag}.g`); + this.h = undefined; + this.i = new Leaf(`${tag}.i`); + this.j = `${tag}.j`; + this.k = new Leaf(`${tag}.k`); + this.l = this.a; + } +} + +function describe(w: Wide): string { + const parts = [w.a.label, w.b.label, w.c?.label ?? "-", w.d.label, w.e]; + for (const leaf of w.f) parts.push(leaf.label); + parts.push(w.g.label, w.h?.label ?? "-", w.i.label, w.j, w.k.label, w.l.label); + if (w.m) parts.push(`[${describe(w.m)}]`); + return parts.join(" "); +} + +function attempt(what: string, run: () => void): void { + try { + run(); + console.log(`${what}: ok`); + } catch (e) { + console.log(`${what}: ${(e as Error).name}: ${(e as Error).message}`); + } +} + +const outer = new Wide("out"); +outer.m = new Wide("in"); +publish(outer); +console.log(describe(outer)); +attempt("write a leaf field", () => { + outer.k.label = "changed"; +}); +attempt("write a nested object field", () => { + outer.m!.j = "changed"; +}); +attempt("write an array element", () => { + outer.f[1] = new Leaf("changed"); +}); + +const refused = new Wide("ref"); +refused.m = new Wide("hooked"); +refused.m.hook = () => "hook"; +attempt("publish with a function field", () => { + publish(refused); +}); +refused.m.e = "rewritten"; +refused.l.label = "rewritten"; +console.log(describe(refused)); From df74099468710c0517de5818c6b7383974369e6c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 16 Oct 2026 07:01:01 -0500 Subject: [PATCH 2/2] Free released regions without a pass over the whole heap Every release of a LifetimeRegion, and every retry at region creation, ran scr_collect_cycles(): full cycle-collector passes over the thread's heap, to a fixpoint, before checking any region, in case dead cycles of ordinary objects held counted references into it. Freeing a region ran them again whenever the owned ordinary objects left counts behind after the region released its fields. Next to a large heap of candidates (a type checker beside per-file emit regions) those passes cost far more than the regions. A retry now checks the due regions first. A region whose references are all explained frees without a pass; only when one would be kept does the heap get the full pass, after which every due region is judged as before. The first check leaves a region it cannot free as it was, so the backoff, the kept report and SCRIPTC_REGION_DEBUG see only the check after the pass. It keeps what its walk found: a region whose counted references the pass did not change is judged from that, without walking it again. A pass that freed a dead cycle referring only to an owned object leaves the count unchanged; such a region waits for its next attempt. After a region releases its instances' fields, what can still hold counts is the owned ordinary objects' cycles. Each has a member that lost a reference in those releases and is buffered as a candidate, and the release walk entered every cycle member. The cycle collector gains a pass limited to a set of candidates (scr_cyc_collect_set): it takes the set's members out of the buffers up to a given generation, reading only the buffers' pointers, so the candidates it leaves are not touched, and it walks at full depth without moving the schedule's baselines. A dead object such a pass reaches through a candidate it did not take leaves its buffer before it is freed. Freeing a region runs these passes over the walk's objects from the youngest buffer out, and the whole heap only if counts remain. SCRIPTC_REGION_STATS also prints, per thread at exit, how many full passes over the heap regions needed. --- benchmarks/regions/README.md | 1 + benchmarks/regions/emit.ts | 102 +++++++++++++++++ benchmarks/regions/run.mjs | 2 + docs/content/docs/experimental.mdx | 7 +- packages/runtime/src/scr_cycle.c | 50 ++++++++- packages/runtime/src/scr_rc.h | 20 ++++ packages/runtime/src/scr_region.c | 142 ++++++++++++++++++++---- packages/runtime/src/scr_region.test.ts | 20 +++- packages/runtime/test/test_cycle.c | 49 ++++++++ tests/harness/lifetime-regions.test.ts | 15 ++- 10 files changed, 373 insertions(+), 35 deletions(-) create mode 100644 benchmarks/regions/emit.ts diff --git a/benchmarks/regions/README.md b/benchmarks/regions/README.md index b37f22aa4..457f059be 100644 --- a/benchmarks/regions/README.md +++ b/benchmarks/regions/README.md @@ -6,6 +6,7 @@ Programs that build generations of `ProgramLifetime` objects and free each gener | --- | --- | | `generations.ts` | Per-file syntax trees and binder symbols in one region per file, checker state (types, signatures, node and symbol links in paged tables, literal and union caches) in one region per generation. `edit` re-parses one file per generation, `all` re-parses every file. | | `compiler.ts` | A tokenizer, recursive-descent parser, binder and checker over generated sources, rebuilt from scratch in one region per build, like a watch mode that does not reuse syntax trees. | +| `emit.ts` | A large live graph of ordinary objects with parent links, standing in for a type checker, and a per-file emit that builds its output in a region released right after. | ```console $ node benchmarks/regions/run.mjs --baseline=../scriptc-main --runs=7 diff --git a/benchmarks/regions/emit.ts b/benchmarks/regions/emit.ts new file mode 100644 index 000000000..630708361 --- /dev/null +++ b/benchmarks/regions/emit.ts @@ -0,0 +1,102 @@ +// Per-file regions next to a large live heap: a checker-like graph of ordinary objects with +// parent links (cycle-collector candidates that stay alive) and, for each file, an emit that +// builds ProgramLifetime nodes and ordinary objects they own (some cyclic) in a region that is +// released right after. +// +// emit [plain] +// +// `plain` never constructs a region. +import type { ProgramLifetime } from "@scriptc/types"; +import { LifetimeRegion } from "@scriptc/experimental"; + +class Type { + readonly id: number; + parent: Type | undefined; + readonly members: Type[] = []; + constructor(id: number, parent: Type | undefined) { + this.id = id; + this.parent = parent; + parent?.members.push(this); + } +} + +class Scope { + readonly parent: Scope | undefined; + readonly children: Scope[] = []; + readonly names = new Map(); + constructor(parent: Scope | undefined) { + this.parent = parent; + parent?.children.push(this); + } +} + +class OutNode implements ProgramLifetime { + readonly kind: number; + readonly kids: OutNode[] = []; + scope: Scope | undefined = undefined; + text: string; + constructor(kind: number, text: string) { + this.kind = kind; + this.text = text; + } +} + +const typeCount = Number(process.argv[2] ?? "400000"); +const files = Number(process.argv[3] ?? "200"); +const nodesPerFile = Number(process.argv[4] ?? "2000"); +const plain = process.argv[5] === "plain"; + +// The checker: one tree of types, each holding its parent and its members, so a walk +// from any of them reaches all of them. +const roots: Type[] = []; +let id = 0; +let level: Type[] = [new Type(id++, undefined)]; +while (id < typeCount) { + const next: Type[] = []; + for (const t of level) for (let k = 0; k < 4 && id < typeCount; k++) next.push(new Type(id++, t)); + roots.push(...next); + level = next; +} + +function lookUp(t: Type): number { + let depth = 0; + for (let p: Type | undefined = t; p !== undefined; p = p.parent) depth++; + return depth; +} + +function emit(file: number): number { + const top = new Scope(undefined); + const root = new OutNode(0, `file${file}`); + let scope = top; + let sum = 0; + const seen: Type[] = []; + for (let i = 0; i < nodesPerFile; i++) { + const node = new OutNode(i % 7, `n${i}`); + root.kids.push(node); + if (i % 50 === 0) scope = new Scope(scope); + node.scope = scope; + scope.names.set(node.text, node); + // Reads of the checker: temporaries that leave its types buffered. + const t = roots[(file * 31 + i) % roots.length]!.members[i % 4]; + if (t !== undefined) { + sum += lookUp(t); + seen.push(t); + } + } + return sum + root.kids.length + seen.length; +} + +const t0 = performance.now(); +let total = 0; +for (let f = 0; f < files; f++) { + if (plain) { + total += emit(f); + continue; + } + const region = new LifetimeRegion(); + total += region.run(() => emit(f)); + region.release(); +} +const ms = performance.now() - t0; +console.log(`emitted ${files} files, checksum ${total}`); +console.log(`emit ${ms.toFixed(1)} ms per-file ${(ms / files).toFixed(2)} ms`); diff --git a/benchmarks/regions/run.mjs b/benchmarks/regions/run.mjs index 88f973bab..1f1c4609f 100644 --- a/benchmarks/regions/run.mjs +++ b/benchmarks/regions/run.mjs @@ -37,6 +37,8 @@ const CONFIGS = [ // A tokenizer, parser, binder and checker rebuilt in one region per build. { name: "compiler-watch", program: "compiler.ts", args: ["200", "60", "10"] }, { name: "compiler-one-shot", program: "compiler.ts", args: ["400", "60", "1"] }, + // Per-file emit regions next to a large live heap of ordinary objects. + { name: "emit-files", program: "emit.ts", args: ["400000", "200", "2000"] }, ]; const work = join(tmpdir(), `scriptc-region-bench-${process.pid}`); diff --git a/docs/content/docs/experimental.mdx b/docs/content/docs/experimental.mdx index 64481634f..ac5891ec9 100644 --- a/docs/content/docs/experimental.mdx +++ b/docs/content/docs/experimental.mdx @@ -64,6 +64,7 @@ scriptc region 1: freed 87381 objects, 87381 owned objects, 2 chunks (4.0 MiB); build 3: 87381 nodes scriptc region 2: freed 87381 objects, 87381 owned objects, 2 chunks (4.0 MiB); cycles 0.0 ms, walk 8.9 ms, free 2.4 ms scriptc regions: 3 created, 2 released, 2 freed, 0 pinned, 0 kept +scriptc region heap passes: 0 ``` ### API @@ -99,9 +100,11 @@ Each thread has its own regions and its own default region. A region belongs to ### Cost -Programs that never construct a `LifetimeRegion` compile exactly as they would without the package. In programs that do, every retain and release of a `ProgramLifetime` instance that belongs to a region updates a count of the region chunk that holds it. Instances of the default region are not counted, and until the program creates its first region, a retain or release only tests whether counting has started. Freeing a region visits each of its instances and the ordinary objects they own. +Programs that never construct a `LifetimeRegion` compile exactly as they would without the package. In programs that do, every retain and release of a `ProgramLifetime` instance that belongs to a region updates a count of the region chunk that holds it. Instances of the default region are not counted, and until the program creates its first region, a retain or release only tests whether counting has started. -`SCRIPTC_REGION_STATS=1` prints one line per release attempt, freed, `kept` (with the number of references the check could not explain) or `pinned` (published), and a summary per thread at exit. +Freeing a region visits each of its instances and the ordinary objects they own. A region that the check would keep may be kept only by unreachable cycles of ordinary objects that refer into it, so before keeping it scriptc runs the cycle collector over the thread's heap and checks again. + +`SCRIPTC_REGION_STATS=1` prints one line per release attempt, freed, `kept` (with the number of references the check could not explain) or `pinned` (published), and at exit a summary per thread and the number of those cycle-collector passes over the thread's heap. ## InternTable diff --git a/packages/runtime/src/scr_cycle.c b/packages/runtime/src/scr_cycle.c index 655c5979f..0b87068ca 100644 --- a/packages/runtime/src/scr_cycle.c +++ b/packages/runtime/src/scr_cycle.c @@ -378,6 +378,10 @@ static SCR_TL unsigned scr_gen_limit = SCR_CYC_OLD; #endif static size_t scr_cyc_pass(unsigned gen_limit); +/* A limited pass (scr_cyc_collect_set) takes only the candidates in this + * set from the buffers up to `scr_cyc_take_gen`; NULL takes them all. */ +static SCR_TL const ScrCycSet *scr_cyc_take = NULL; +static SCR_TL unsigned scr_cyc_take_gen = SCR_CYC_OLD; /* ── when to collect ──────────────────────────────────────────────────── */ @@ -1046,6 +1050,9 @@ static SCR_CYC_COMPACT size_t scr_cyc_settle(unsigned gen_limit) { void *obj = scr_white.v[i]; ScrCycHdr *h = scr_cyc_hdr(obj); if (scr_cyc_color(h) != SCR_CYC_WHITE) continue; + /* A limited pass can reach candidates it did not take: out of their + * buffers before they are freed. */ + if (scr_cyc_take && scr_cyc_is_buffered(h)) scr_cyc_on_dead(obj); scr_cyc_set_color(h, SCR_CYC_DOOMED); if (gen_limit < SCR_CYC_OLD) scr_cyc_freed(scr_cyc_type(h), gen_limit); else scr_cyc_mature_dead += scr_cyc_gen(h) == SCR_CYC_MATURE; @@ -1130,10 +1137,14 @@ static SCR_CYC_COMPACT size_t scr_cyc_settle(unsigned gen_limit) { scr_xgen.n = 0; freed += scr_xg_freed; - if (gen_limit >= SCR_CYC_MATURE) scr_cyc_live_after[SCR_CYC_MATURE] = scr_cyc_live; - if (gen_limit >= SCR_CYC_OLD) { - scr_cyc_live_after[SCR_CYC_OLD] = scr_cyc_live; - scr_cyc_scheduled_mature_age = 0; + /* A limited pass walked only some of the candidates: the schedule's + * baselines stay those of the last pass over them all. */ + if (scr_cyc_take == NULL) { + if (gen_limit >= SCR_CYC_MATURE) scr_cyc_live_after[SCR_CYC_MATURE] = scr_cyc_live; + if (gen_limit >= SCR_CYC_OLD) { + scr_cyc_live_after[SCR_CYC_OLD] = scr_cyc_live; + scr_cyc_scheduled_mature_age = 0; + } } /* markRoots drained buffers in bulk and teardowns moved the count around; * resync from the buffers themselves and re-arm. */ @@ -1156,7 +1167,27 @@ static size_t scr_cyc_pass(unsigned gen_limit) { * can re-buffer underneath us. Candidates ABOVE the limit keep their * slots and wait for a pass at their own level. */ scr_cands.n = 0; - for (unsigned g = 0; g <= gen_limit; g++) { + if (scr_cyc_take) { + /* A limited pass: only the set's candidates leave their buffers. The + * scan reads the buffers' pointers alone, so the candidates it leaves, + * which may be most of a large heap's, are not touched. */ + for (unsigned g = 0; g <= scr_cyc_take_gen; g++) { + ScrVec *b = &scr_roots[g]; + for (size_t i = 0; i < b->n;) { + void *obj = b->v[i]; + if (!scr_cyc_set_has(scr_cyc_take, obj)) { + i++; + continue; + } + void *last = b->v[--b->n]; + b->v[i] = last; + if (last != obj) scr_cyc_set_buffered(scr_cyc_hdr(last), (uint32_t)i); + scr_cyc_clear_buffered(scr_cyc_hdr(obj)); + scr_cyc_push(&scr_cands, obj); + } + } + } + for (unsigned g = 0; g <= gen_limit && !scr_cyc_take; g++) { for (size_t i = 0; i < scr_roots[g].n; i++) { void *obj = scr_roots[g].v[i]; ScrCycHdr *h = scr_cyc_hdr(obj); @@ -1198,6 +1229,15 @@ static size_t scr_cyc_pass(unsigned gen_limit) { size_t scr_cyc_pass_at(unsigned gen) { return scr_cyc_pass(gen); } #endif +size_t scr_cyc_collect_set(const ScrCycSet *set, unsigned gen) { + if (scr_collecting) return 0; + scr_cyc_take = set; + scr_cyc_take_gen = gen; + size_t freed = scr_cyc_pass(SCR_CYC_OLD); + scr_cyc_take = NULL; + return freed; +} + void scr_collect_cycles(void) { /* The full pass, run to a fixpoint. A teardown's releases can drop the * last reference to a further cycle, so one pass does not always drain diff --git a/packages/runtime/src/scr_rc.h b/packages/runtime/src/scr_rc.h index 044c5f275..2e28025a2 100644 --- a/packages/runtime/src/scr_rc.h +++ b/packages/runtime/src/scr_rc.h @@ -428,6 +428,26 @@ void scr_rc_destroy(void *obj, void (*destroy)(void *)); * that walks it finds it externally referenced. */ void scr_cyc_on_release(void *obj); void scr_cyc_on_dead(void *obj); +/* A set of object pointers for scr_cyc_collect_set: open addressing over + * `mask + 1` slots (a power of two), NULL marking an empty slot. */ +typedef struct { + void **slots; + size_t mask; +} ScrCycSet; +static inline size_t scr_cyc_set_slot(const void *obj, size_t mask) { + return (size_t)(((uintptr_t)obj >> 3) * 0x9E3779B97F4A7C15ull >> 20) & mask; +} +static inline bool scr_cyc_set_has(const ScrCycSet *set, const void *obj) { + for (size_t j = scr_cyc_set_slot(obj, set->mask);; j = (j + 1) & set->mask) { + if (set->slots[j] == obj) return true; + if (set->slots[j] == NULL) return false; + } +} +/* One full-depth pass over only the candidates in `set`, taken from the + * buffers of generations up to `gen` (the others keep their slots and are + * not touched); returns how many objects it freed. It leaves the + * schedule's baselines alone. */ +size_t scr_cyc_collect_set(const ScrCycSet *set, unsigned gen); /* Publication's buffer hooks (scr_publish.c): take a buffered object out * of its buffer (returns its index), put it back at `index`, and close the * slots taken since the last fill that are still empty. */ diff --git a/packages/runtime/src/scr_region.c b/packages/runtime/src/scr_region.c index 57c4e2bd0..685c46c36 100644 --- a/packages/runtime/src/scr_region.c +++ b/packages/runtime/src/scr_region.c @@ -112,6 +112,12 @@ typedef struct ScrRegion { int64_t kept_counted; size_t retry_at; unsigned backoff; + /* The first check of a retry could not free it (scr_rgn_retry): what that + * walk found, for the check after the heap pass. */ + bool held; + int64_t held_counted, held_explained; + size_t held_black, held_owned; + double held_walk_ms; } ScrRegion; typedef struct { @@ -176,6 +182,8 @@ struct ScrRgnWalk { size_t singles; /* owned objects walked without an entry */ size_t *hash; /* open addressing: entry index + 1 */ size_t cap_hash; + void **owned; /* the entries' objects, as a set (scr_rgn_owned_set) */ + size_t cap_owned; ScrRgnEdge *edges; size_t nedges, cap_edges; ScrRgnWork *work; @@ -206,6 +214,7 @@ typedef struct { double cycle_ms; /* the cycle pass before the next attempt (stats) */ struct { size_t created, released, freed, pinned, leaked, attempts; + size_t heap_passes; /* full cycle passes run for regions */ } stats; } ScrRgnThread; @@ -517,6 +526,26 @@ static size_t scr_rgn_find(ScrRgnWalk *w, const void *p) { return j; } +/* The objects the walk entered, as a set for scr_cyc_collect_set. */ +static ScrCycSet scr_rgn_owned_set(ScrRgnWalk *w) { + size_t cap = 16; + while (cap < w->nents * 2) cap <<= 1; + if (cap > w->cap_owned) { + free(w->owned); + w->owned = calloc(cap, sizeof *w->owned); + if (!w->owned) scr_trap("scriptc: out of memory\n"); + w->cap_owned = cap; + } else + memset(w->owned, 0, cap * sizeof *w->owned); + ScrCycSet set = {w->owned, cap - 1}; + for (size_t i = 0; i < w->nents; i++) { + size_t j = scr_cyc_set_slot(w->ents[i].obj, set.mask); + while (set.slots[j]) j = (j + 1) & set.mask; + set.slots[j] = w->ents[i].obj; + } + return set; +} + static void scr_rgn_rehash(ScrRgnWalk *w) { size_t cap = w->cap_hash ? w->cap_hash * 2 : (size_t)1 << 12; free(w->hash); @@ -938,12 +967,40 @@ static void scr_rgn_report(const ScrRegion *r, const ScrRgnWalk *w, int64_t unex #endif /* One release attempt; true when `r` was freed. */ -static bool scr_rgn_try_free(ScrRgnThread *t, ScrRegion *r) { +/* `tentative`: an attempt before the retry's cycle pass. A region it cannot + * free is left as it was, for the attempt after the pass to judge. */ +/* A region an attempt could not free: when it is attempted again. */ +static void scr_rgn_keep(ScrRgnThread *t, ScrRegion *r, int64_t counted, int64_t explained, size_t black, size_t owned, + double cycles, double walk_ms) { + if (r->kept && counted == r->kept_counted) { + if (r->backoff < SCR_RGN_MAX_BACKOFF) r->backoff++; + } else r->backoff = 0; + r->kept = true; + r->kept_counted = counted; + r->retry_at = t->allocated + ((r->bytes > SCR_RGN_RETRY_BYTES ? r->bytes : SCR_RGN_RETRY_BYTES) << r->backoff); + if (scr_rgn_stats()) + fprintf(stderr, + "scriptc region %llu: kept: %lld references counted, %lld explained; %zu of %zu owned objects referenced from outside; cycles %.1f ms, walk %.1f ms\n", + (unsigned long long)r->id, (long long)counted, (long long)explained, black, owned, cycles, walk_ms); +} + +static bool scr_rgn_try_free(ScrRgnThread *t, ScrRegion *r, bool tentative) { bool stats = scr_rgn_stats(); uint64_t t0 = stats ? scr_hrtime_ns() : 0; double cycles = t->cycle_ms; t->cycle_ms = 0; t->stats.attempts++; + bool held = r->held; + r->held = false; + /* Kept on the first check, and the heap pass dropped no reference into + * it: the walk would find what that check found. A pass that freed a dead + * cycle referring only to an owned object leaves the count as it was; + * such a region waits for its next attempt. SCRIPTC_REGION_DEBUG walks + * again for its report. */ + if (held && !tentative && scr_rgn_counted(r) == r->held_counted && !scr_rgn_debug()) { + scr_rgn_keep(t, r, r->held_counted, r->held_explained, r->held_black, r->held_owned, cycles, r->held_walk_ms); + return false; + } ScrRgnWalk *w = &t->walk; w->region = r; w->internal = 0; @@ -983,16 +1040,16 @@ static bool scr_rgn_try_free(ScrRgnThread *t, ScrRegion *r) { int64_t explained = w->internal + scr_rgn_settle(w, &black); uint64_t t1 = stats ? scr_hrtime_ns() : 0; if (explained != counted) { - if (r->kept && counted == r->kept_counted) { - if (r->backoff < SCR_RGN_MAX_BACKOFF) r->backoff++; - } else r->backoff = 0; - r->kept = true; - r->kept_counted = counted; - r->retry_at = t->allocated + ((r->bytes > SCR_RGN_RETRY_BYTES ? r->bytes : SCR_RGN_RETRY_BYTES) << r->backoff); - if (stats) - fprintf(stderr, - "scriptc region %llu: kept: %lld references counted, %lld explained; %zu of %zu owned objects referenced from outside; cycles %.1f ms, walk %.1f ms\n", - (unsigned long long)r->id, (long long)counted, (long long)explained, black, w->nents + w->singles, cycles, scr_rgn_ms(t0, t1)); + if (tentative) { + r->held = true; + r->held_counted = counted; + r->held_explained = explained; + r->held_black = black; + r->held_owned = w->nents + w->singles; + r->held_walk_ms = stats ? scr_rgn_ms(t0, t1) : 0; + return false; + } + scr_rgn_keep(t, r, counted, explained, black, w->nents + w->singles, cycles, stats ? scr_rgn_ms(t0, t1) : 0); #ifdef SCR_RGN_DEBUG if (debug) scr_rgn_report(r, w, counted - explained, debug); #else @@ -1008,7 +1065,26 @@ static bool scr_rgn_try_free(ScrRgnThread *t, ScrRegion *r) { if (scr_weak_dispose_hook) SCR_RGN_EACH(r, s, o) scr_weak_dispose(o); SCR_RGN_EACH(r, s, o) s->cls->release_fields(o); int64_t left = scr_rgn_counted(r); + /* What can still count references is cycles of the owned objects: each + * has a member that lost a reference here, which leaves it buffered as a + * candidate (the walk entered every cycle member). Passes over those + * candidates come first, from the youngest buffer out (objects made during + * the region's runs are usually young, while a large heap keeps most of + * its candidates in the older buffers), and the whole heap only if counts + * remain. The set may hold the addresses of owned objects already freed; + * it is only compared with buffered candidates. */ if (left != 0) { + ScrCycSet owned = scr_rgn_owned_set(w); + for (unsigned gen = SCR_CYC_NURSERY; left != 0 && gen <= SCR_CYC_OLD;) { + /* A pass that frees nothing at this depth widens to the next + * generation's buffer; one that frees something may have left new + * candidates through its teardowns, so it goes again. */ + if (scr_cyc_collect_set(&owned, gen) == 0) gen++; + left = scr_rgn_counted(r); + } + } + if (left != 0) { + t->stats.heap_passes++; scr_collect_cycles(); left = scr_rgn_counted(r); } @@ -1046,28 +1122,44 @@ static bool scr_rgn_due(const ScrRegion *r, const ScrRgnThread *t) { return !r->kept || t->allocated >= r->retry_at || scr_rgn_counted(r) != r->kept_counted; } -/* Tries every condemned region that is due. */ +/* Attempts every condemned region that is due; true if one was kept. */ +static bool scr_rgn_try_due(ScrRgnThread *t, bool tentative) { + bool kept = false; + for (size_t i = 0; i < t->nregions;) { + ScrRegion *r = t->regions[i]; + if (scr_rgn_due(r, t)) { + if (scr_rgn_try_free(t, r, tentative)) { + /* The table shrank, and the freed region's references may have + * kept another one: the regions it held now count fewer + * references. */ + i = 0; + kept = false; + continue; + } + kept = true; + } + i++; + } + return kept; +} + +/* Tries every condemned region that is due. A region whose references all + * come from its own objects and what they own frees without a cycle pass; + * one that is kept may be kept by dead cycles of ordinary objects, which + * hold counted references into it until the collector frees them, so only + * then does the thread's heap get a full pass before the region is judged. */ static void scr_rgn_retry(ScrRgnThread *t) { bool any = false; for (size_t i = 0; i < t->nregions && !any; i++) any = scr_rgn_due(t->regions[i], t); t->checked = t->allocated; if (!any) return; if (atomic_load_explicit(&scr_rgn_index_state, memory_order_acquire) != 2) scr_rgn_index_build(); - /* Dead cycles of ordinary objects can hold counted references into a - * condemned region until the collector frees them. */ + if (!scr_rgn_try_due(t, true)) return; uint64_t t0 = scr_rgn_stats() ? scr_hrtime_ns() : 0; + t->stats.heap_passes++; scr_collect_cycles(); if (t0) t->cycle_ms = scr_rgn_ms(t0, scr_hrtime_ns()); - for (size_t i = 0; i < t->nregions;) { - ScrRegion *r = t->regions[i]; - if (scr_rgn_due(r, t) && scr_rgn_try_free(t, r)) { - /* The table shrank, and the freed region's references may have kept - * another one: the regions it held now count fewer references. */ - i = 0; - continue; - } - i++; - } + scr_rgn_try_due(t, false); } /* ── program-facing entry points ─────────────────────────────────────── */ @@ -1124,6 +1216,7 @@ static void scr_rgn_thread_exit(void) { for (size_t i = 0; i < t->nregions; i++) condemned += t->regions[i]->state == SCR_RGN_CONDEMNED; fprintf(stderr, "scriptc regions: %zu created, %zu released, %zu freed, %zu pinned, %zu kept\n", t->stats.created, t->stats.released, t->stats.freed, t->stats.pinned, condemned); + fprintf(stderr, "scriptc region heap passes: %zu\n", t->stats.heap_passes); } ScrRegion *all[1] = {t->dflt}; for (size_t i = 0; i <= t->nregions; i++) { @@ -1142,6 +1235,7 @@ static void scr_rgn_thread_exit(void) { free(t->stack); free(t->walk.ents); free(t->walk.hash); + free(t->walk.owned); free(t->walk.edges); free(t->walk.work); #ifdef SCR_RGN_DEBUG diff --git a/packages/runtime/src/scr_region.test.ts b/packages/runtime/src/scr_region.test.ts index aba917f18..cfadac91e 100644 --- a/packages/runtime/src/scr_region.test.ts +++ b/packages/runtime/src/scr_region.test.ts @@ -21,8 +21,9 @@ const OWNED_ATTEMPTS = [ "6 freed", "7 freed", "8 kept", - "8 freed", + // 9 frees on the check before the pass over the heap that 8 needs. "9 freed", + "8 freed", "10 kept", "12 freed", "11 freed", @@ -50,13 +51,25 @@ const RETAINED_STDOUT = [ "a published object pins its region", ]; -function attempts(stderr: string): { attempts: string[]; summaries: string[]; other: string[] } { - const out = { attempts: [] as string[], summaries: [] as string[], other: [] as string[] }; +function attempts(stderr: string): { + attempts: string[]; + summaries: string[]; + heapPasses: number[]; + other: string[]; +} { + const out = { + attempts: [] as string[], + summaries: [] as string[], + heapPasses: [] as number[], + other: [] as string[], + }; for (const line of stderr.split("\n")) { if (line === "") continue; const m = /^scriptc region (\d+): (freed|kept|pinned)\b/.exec(line); if (m) out.attempts.push(`${m[1]} ${m[2]}`); else if (line.startsWith("scriptc regions: ")) out.summaries.push(line); + else if (line.startsWith("scriptc region heap passes: ")) + out.heapPasses.push(Number(line.slice("scriptc region heap passes: ".length))); else out.other.push(line); } return out; @@ -178,6 +191,7 @@ describe.skipIf(process.platform === "win32")("counted lifetime regions", () => expect(stats.summaries).toEqual( Array(4).fill("scriptc regions: 20 created, 20 released, 20 freed, 0 pinned, 0 kept"), ); + expect(stats.heapPasses).toEqual([0, 0, 0, 0]); expect(stats.other).toEqual([]); } finally { await rm(dir, { recursive: true, force: true }); diff --git a/packages/runtime/test/test_cycle.c b/packages/runtime/test/test_cycle.c index 59504281f..763760b00 100644 --- a/packages/runtime/test/test_cycle.c +++ b/packages/runtime/test/test_cycle.c @@ -2,6 +2,7 @@ #include #include +#include typedef struct Node Node; struct Node { @@ -1099,6 +1100,53 @@ static void check_publication_takes_candidates(void) { check(immortal_traced == 0, "a pass walked a published ring"); } +/* scr_cyc_collect_set: a full-depth pass over the candidates in a set. The + * others keep their slots for ordinary passes, unless the walk from a taken + * one reaches them, in which case a dead one is freed and leaves its + * buffer. */ +static void *taken_slots[8]; +static ScrCycSet taken = {taken_slots, 7}; +static void take(void *obj) { + size_t j = scr_cyc_set_slot(obj, taken.mask); + while (taken.slots[j]) j = (j + 1) & taken.mask; + taken.slots[j] = obj; +} + +static void check_limited_pass(void) { + enum { RING = 16 }; + /* The setup buffers four candidates that must stay buffered until the + * limited pass; lower thresholds run scheduled passes in between. */ + if (configured_nursery_threshold() < 8) return; + scr_collect_cycles(); + size_t before = freed; + /* Candidates the filter refuses: a dead ring it must leave, and a ring + * whose last outside reference is from a dead ring it takes. */ + Node *older = make_ring(RING); + release_live(older); + Node *reached = make_ring(RING); + Node *fresh = make_ring(RING); + fresh->other = reached; + reached->rc++; + release_live(reached); /* its owner's */ + release_live(fresh); + /* A live candidate it takes. */ + Node *live = make_ring(RING); + live->rc++; + release_live(live); + take(fresh); + take(live); + check(scr_cyc_collect_set(&taken, SCR_CYC_NURSERY) == 2 * RING, + "limited pass did not free the taken ring and what it reached"); + check(freed - before == 2 * RING, "limited pass freed a live or refused candidate"); + check(scr_cyc_collect_set(&taken, SCR_CYC_OLD) == 0, "a second limited pass freed something"); + memset(taken_slots, 0, sizeof taken_slots); + scr_collect_cycles(); + check(freed - before == 3 * RING, "the refused dead ring was not left for the next pass"); + release_live(live); + scr_collect_cycles(); + check(freed - before == 4 * RING, "the live ring was not collected once dead"); +} + int main(void) { check_old_garbage_reclaimed_while_growing(); check_older_rings_into_old(); @@ -1125,6 +1173,7 @@ int main(void) { check_rebuffer_past_mature(); check_allocation_growth_runs_passes(); check_publication_takes_candidates(); + check_limited_pass(); printf("cycle collection checks passed: threshold=%zu growth_cap=%zu\n", configured_nursery_threshold(), configured_growth_cap()); return 0; diff --git a/tests/harness/lifetime-regions.test.ts b/tests/harness/lifetime-regions.test.ts index 0a82cde92..7eb294482 100644 --- a/tests/harness/lifetime-regions.test.ts +++ b/tests/harness/lifetime-regions.test.ts @@ -25,6 +25,9 @@ interface Stats { summaries: string[]; /** SCRIPTC_REGION_DEBUG's report lines, in order. */ report: string[]; + /** Per thread, the cycle-collector passes over its heap that releases + * needed, sorted. */ + heapPasses: number[]; stdout: string; } @@ -48,11 +51,13 @@ async function run( env: { ...process.env, SCRIPTC_REGION_STATS: "1", ...env }, timeout: 120_000, }); - const stats: Stats = { attempts: [], summaries: [], report: [], stdout }; + const stats: Stats = { attempts: [], summaries: [], report: [], heapPasses: [], stdout }; for (const line of stderr.split("\n")) { if (line === "" || line.startsWith("scriptc RC audit skipped:")) continue; const attempt = /^scriptc region (\d+): (freed|kept|pinned)\b/.exec(line); + const passes = /^scriptc region heap passes: (\d+)$/.exec(line); if (attempt) stats.attempts.push(`${attempt[1]} ${attempt[2]}`); + else if (passes) stats.heapPasses.push(Number(passes[1])); else if (line.startsWith("scriptc regions: ")) stats.summaries.push(line.slice(17)); else if ( /^scriptc region \d+: -?\d+ references from outside keep it:$/.test(line) || @@ -64,6 +69,7 @@ async function run( throw new Error(`unexpected stderr line: ${line}`); } stats.summaries.sort(); + stats.heapPasses.sort((a, b) => a - b); return stats; } finally { await rm(outDir, { recursive: true, force: true }); @@ -129,6 +135,12 @@ describe.skipIf(process.platform === "win32")("lifetime regions", { timeout: 240 expect(stats.attempts, program).toEqual( Array.from({ length: regions }, (_, i) => `${i + 1} freed`), ); + // Owned cycles go without a pass over the heap. In 5017 each region is + // also referred to by a dead cycle outside it, which only such a pass + // finds. + expect(stats.heapPasses, program).toEqual([ + program === "5017-lifetime-region-cycles.ts" ? regions : 0, + ]); } }); @@ -158,5 +170,6 @@ describe.skipIf(process.platform === "win32")("lifetime regions", { timeout: 240 expect(publish.attempts).toContain("1 pinned"); const workers = await run("5022-lifetime-region-workers.ts"); expect(workers.summaries).toEqual([summary(6, 6), summary(8, 8), summary(8, 8), summary(8, 8)]); + expect(workers.heapPasses).toEqual([0, 0, 0, 0]); }); });