Optional array reads also match arrays whose element is itself the read T | undefined nullable-pointer union; owned copies retain and release through its NULL-tolerant entry points. A local array read borrows when its array operand is rooted at a stable parameter and its own live range preserves heap edges, and a call-argument read borrows when the call preserves them. An optional chain on such a read borrows the inline element instead of taking a second reference.
check_nursery_bypass's last phase releases WINDOW * SAMPLE dead rings of a
nursery-bypassed type and expects the sampled one in SAMPLE to complete a
nursery window, which turns the bypass off. That is exactly one window of
sampled candidates, so it held only while no older pass ran during the
phase. Under the growth cap's default of 16 (main's SCR_CYCLE_GROWTH_CAP,
with the cap's checks now running before the bypass checks) the promoted
rings trigger mature passes that also sweep sampled nursery candidates:
the nursery counted 4544 of its 8192 walks, kept the bypass, and the check
failed at the default and 64 caps (caps 1 and 2 passed).
The phase now runs a nursery pass after each release, as the mature check
already runs passes at exact levels, so every sampled candidate is walked
by its own level. The collector is unchanged. The check still fails with a
sticky bypass decision and without sampling, at caps 16 and 1.
Nearly every long-lived object becomes a cycle candidate once, soon after
it is built: the constructing frame's release leaves it held only by the
structure it was stored into. A nursery pass then walks it (about 75 ns per
object), it survives, and it is re-buffered for the mature level, whose
next pass walks it again. In tsc-ts single-threaded, nursery passes walked
6.0M objects on Excalidraw and 36.5M on Sentry and freed 7%; 99% of what
they walked was promoted. Most of that walking goes to types whose objects
never die in a young cycle: SymbolLinks, NodeLinks and Symbol were 2.0M of
Excalidraw's candidates and the collector freed none of them, while young
garbage comes from a few types (inference contexts and mappers, closures,
some arrays). Types with a collector yield under 0.5% are 42-55% of what
nursery passes walk.
So the collector now keeps per-thread statistics per type and level. Each
ScrCycType gains a `kind` index: emitted descriptors number themselves from
32 (FIRST_EMITTED_CYC_KIND / SCR_CYC_KIND_EMITTED), the runtime's own
families have fixed kinds below that, 0 keeps no statistics. For the
nursery and mature levels, a pass counts the candidates of each kind it
walks and the objects of each kind it frees. After every 8192 walked
candidates a kind bypasses that level for the next window if fewer than 1
in 128 of them were freed. A release that buffers an object at a level its
type bypasses promotes it past the level first, and a nursery pass that
re-buffers a survivor into a bypassed mature level moves it to the old
level. One candidate in 16 stays put, so the yield keeps being measured
and a type whose objects start dying young again is walked again after
one window.
Only the generation an object is judged in changes. Any assignment is
sound (a restricted pass counts edges from older objects as external), a
bypassing candidate stays buffered, and full passes walk everything they
did before, so the old level's bounds hold unchanged. Cyclic garbage made
only of objects of a bypassing type waits for the next pass at an older
level. Programs that never complete a window (8192 walked candidates of one
type) are collected exactly as before.
The descriptor layout is part of the emitted-code/runtime ABI, so the
program-object marker moves to scr_runtime_abi_v12.
Pass log (instrumented build, single-threaded, one run each, with the
preceding null-slot change): all collector passes Excalidraw 606 -> 313 ms
(nursery 435 -> 153), TypeORM 629 -> 383 ms, tsc-ts self-check 377 -> 215
ms, Sentry 7.61 -> 5.75 s (nursery 2.92 -> 1.04 s, mature 1.17 -> 1.02 s;
the full pass is unchanged at 3.6 s).
tsc-ts (Linux sandbox, interleaved, medians; 10 rounds, Sentry 5 rounds
pinned to 8 CPUs; both changes against int/best-v6): single-threaded
Excalidraw -8.0%, TypeORM -4.2%, self-check -4.3%, Sentry -5.3%;
--checkers 8 -12.2%, -0.8%, -2.3%, -1.6%. Peak RSS -0.8% to +0.6%.
Diagnostics identical.
Tests: test_cycle gains check_nursery_bypass, check_mature_bypass and
check_rebuffer_past_mature (a test-only entry point, scr_cyc_pass_at under
SCR_CYC_TESTING, runs passes at exact levels); each fails under the
corresponding mutation (no bypass, a sticky decision, no sampling, no freed
or walked counts, a bypassing candidate left unbuffered, survivors
re-buffered into a bypassed level). Corpus 4690 builds a long-lived
declaration graph large enough for both levels to bypass and lets objects
of the same classes die in cycles; under --sanitize the exit RC audit
proves they are reclaimed.
Emitted class and record trace functions passed every traced slot to the
visitor, empty ones included, and the visitor's first test dropped them.
Most traced slots of a large heap are empty (optional links, unset caches):
in tsc-ts, 56% of the child edges the nursery's markGray visited were null
(Excalidraw 18.5M of 33M per single-threaded run, Sentry 111M of 210M), and
full passes visited about as many. Each was an indirect call, and the
restoring scan made it again.
Trace functions now test each loaded slot and skip the call for null. Every
collector visitor already ignores null children and the collector is the
only caller of trace functions, so what a pass walks is unchanged.
tsc-ts (Linux sandbox, interleaved, 5 rounds, medians): single-threaded
Excalidraw -2.6%, TypeORM -2.4%, self-check -2.0%; --checkers 8 -8.6%,
-8.2%, -3.3%. Peak RSS within +-1%. tsc-ts binary +20,408 bytes (+0.09%).
Destroying an array released its metadata and freed its sparse and
property storage through calls that took the shared empty ext, so every
array paid four calls on NULL. Check the ext once and free it in one
place; truncating length likewise skips the sparse search without one.
The ext struct came from the system calloc, which every regex match result
(whose metadata lives there) paid for: regex-logs ran 1.9% slower in the
runtime suite. It now comes from scr_mem_calloc and returns through
scr_mem_free, like the array itself.
Every ScrArr carried sixteen words, seven of them (sparse slots, their
length and capacity, noncanonical numeric properties with length and
capacity, and match-result metadata) used only by sparse arrays, arrays
with numeric non-index properties and regex match results. They now live in
a ScrArrExt the array points at, allocated on first use: a dense array
shrinks from 128 to 80 bytes, and a cycle-capable one (with its 16-byte
collector header) from 144 to 96.
Runtime readers go through a shared empty ext; writers allocate it. The
backend mirror loses the seven fields, tagged-template arrays are emitted
with a NULL ext, live stream commits swap the ext pointer (which swapped all
seven fields before), and the cached typed-view refresh swaps sparse slots
and properties through scr_arr_swap_indexed_ext, keeping each array's
metadata as before. The program-object ABI marker moves to
scr_runtime_abi_v11.
A corpus program (4610-array-ext-dyn-refresh.ts, with its order baseline) pins numeric non-index properties of a dynamically typed array through its refreshed typed view, sparse slots written directly and through a live reference, truncation, and regex match metadata read directly, through `any` and after a copy.
Every release that left a capture box alive buffered it as a cycle candidate, but a box's trace visits its payload only for a closure or a class instance with a cycle header. Boxes holding numbers, booleans, strings, arrays or acyclic instances are leaves of the object graph and can never be on a garbage cycle; they still cost an enqueue and, when they die, a dequeue. On Excalidraw boxes were 18% of all buffered candidates (1.76M of 10.3M).
scr_box_release and the emitted inline release (speed builds) now test kind == SCR_BOX_FUNC || obj_trace != NULL first, mirroring the union boxes' arm_trace test.
Every cycle-capable object carried a 32-byte header: trace and teardown function pointers, a 32-bit color, the buffered flag, the generation and a 64-bit candidate index. The two function pointers are the same for every object of a type, so the header now points at one constant ScrCycType { trace, free_fn } descriptor per type, and the remaining fields pack into the second word: u8 color, u8 gen, u16 buffered, u32 buf_index (candidate buffers stop at 2^31 entries). wasm32 pads before the descriptor, so every object-relative offset is the same on all targets.
Emitted shapes define their descriptor next to the trace and teardown functions; inline allocation stores one header word; the inline release reads buffered at obj-6 and the inline free reads the generation as an i8 at obj-7. Runtime families allocate with static descriptors. The program-object ABI marker moves to scr_runtime_abi_v10.
tsc-ts (interleaved, 5 rounds): peak RSS -8% to -11% on every app and mode; single-threaded wall -0.3% to -5.5% on top of the typed-rest-spread change.
A call with a runtime-length spread went through the checked-dynamic argument pack whenever the callee's parameters could be checked back out of dyn (since 31332427b), even when the spread is trailing and lands in a typed rest parameter of its own array type. Every argument, including class instances, was converted with dynFrom, and every class reaching dynFrom gets a hidden %dynProperties field in its whole hierarchy; the generated property helpers then box the classes its fields reference.
Lower the spread operands first and keep the static completion when every runtime spread fills a typed rest with the same array type, or a T[] into an argument-widened (T | undefined)[] rest, which an interned copy reads element by element the way spread iteration does (holes and present undefined as undefined). In tsc-ts this removes the bag from Node, Symbol, Diagnostic, NodeList and 193 other classes.
Counting array appends as edge-preserving makes `keep(item, items) { items.push(item); return item; }` a borrowed-return function: it preserves every heap edge, borrows every reference parameter and returns one of them. Its borrowing body now retains only the appended element and returns the parameter +0, and the owned adapter retains the result before releasing its parameters. An append never releases an element (the slow path stores past the old length), so the caller's owner keeps the result alive.
These PRs were merged into their stack base branches instead of main. This merges perf/alloc-hugepages (with the CI fixes below) into main. Conflicts: order-parity.json (union of entries); scr_alloc.c/scr_alloc.test.ts, where main already has the worker exit trim (#858, adapted to the pre-hugepages allocator): resolved by taking the hugepages allocator and re-applying the original trim commits written on top of it (ea88b87c + 4816696d, clean apply on an identical base).
Brings in cc7c6cb7 (inline truncation decided for the host when no triple is given), which this branch predates; without it the native bootstrap's LLVM comparison with the Node seed fails on x86-64 Linux.