Merge main

This commit is contained in:
Chris Tate
2026-10-16 07:54:59 -05:00
14 changed files with 502 additions and 54 deletions
+1
View File
@@ -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
+102
View File
@@ -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 <checker-types> <files> <nodes-per-file> [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<string, OutNode>();
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`);
+2
View File
@@ -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}`);
+5 -2
View File
@@ -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
@@ -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);
});
@@ -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}`,
];
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5682-publish-wide-objects.ts"
],
"diags": []
}
+63 -22
View File
@@ -389,6 +389,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 ──────────────────────────────────────────────────── */
@@ -1057,6 +1061,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;
@@ -1141,10 +1148,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. */
@@ -1198,25 +1209,46 @@ static size_t scr_cyc_pass(unsigned gen_limit) {
* almost none, and it cost a header store per retain). Drain the buffers
* before walking: tracing only adjusts counts and worklists, so nothing
* can re-buffer underneath us. Candidates ABOVE the limit keep their
* slots and wait for a pass at their own level.
*
* The fullest root buffer becomes the candidate list instead of being
* copied into it, so a pass never holds two buffers of every candidate;
* the generation gets the previous candidate buffer, empty. The other
* buffers are appended after it. */
unsigned largest = 0;
for (unsigned g = 1; g <= gen_limit; g++)
if (scr_roots[g].n > scr_roots[largest].n) largest = g;
ScrVec previous = scr_cands;
scr_cands = scr_roots[largest];
scr_roots[largest] = previous;
scr_roots[largest].n = 0;
for (size_t i = 0; i < scr_cands.n; i++) {
ScrCycHdr *h = scr_cyc_hdr(scr_cands.v[i]);
scr_cyc_clear_buffered(h);
if (gen_limit < SCR_CYC_OLD) scr_cyc_walked(scr_cyc_type(h), gen_limit);
* slots and wait for a pass at their own level. */
if (scr_cyc_take) {
scr_cands.n = 0;
/* 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);
}
}
} else {
/* A full pass: the fullest root buffer becomes the candidate list
* instead of being copied into it, so a pass never holds two buffers
* of every candidate; the generation gets the previous candidate
* buffer, empty. The other buffers are appended after it. */
unsigned largest = 0;
for (unsigned g = 1; g <= gen_limit; g++)
if (scr_roots[g].n > scr_roots[largest].n) largest = g;
ScrVec previous = scr_cands;
scr_cands = scr_roots[largest];
scr_roots[largest] = previous;
scr_roots[largest].n = 0;
for (size_t i = 0; i < scr_cands.n; i++) {
ScrCycHdr *h = scr_cyc_hdr(scr_cands.v[i]);
scr_cyc_clear_buffered(h);
if (gen_limit < SCR_CYC_OLD) scr_cyc_walked(scr_cyc_type(h), gen_limit);
}
}
for (unsigned g = 0; g <= gen_limit; g++) {
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);
@@ -1258,6 +1290,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
+20
View File
@@ -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. */
+118 -24
View File
@@ -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
+17 -3
View File
@@ -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 });
+49
View File
@@ -2,6 +2,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
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");
}
/* A full pass whose fullest candidate buffer is the old generation's (more
* entries than a pass keeps between passes), with nursery candidates
* beside it: every dead ring in either buffer is reclaimed, the live rings
@@ -1165,6 +1213,7 @@ int main(void) {
check_rebuffer_past_mature();
check_allocation_growth_runs_passes();
check_publication_takes_candidates();
check_limited_pass();
check_large_old_buffer_with_nursery_candidates();
printf("cycle collection checks passed: threshold=%zu growth_cap=%zu\n",
configured_nursery_threshold(), configured_growth_cap());
+85
View File
@@ -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));
+14 -1
View File
@@ -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]);
});
});