Every live entry's hash sets two bits of a new 64-bit ScrMap.filter, so a
lookup whose bits are not both set is absent without loading the bucket
table or the entries. String-keyed lookups in tsc-ts are mostly misses
(Map.get 67% on Excalidraw, has 87%), and the filter rejects 84-88% of
the misses on non-empty maps. Deletions leave their bits; rebuilds,
compaction and clear recompute the filter from the live entries.
Lookups also return the entry index straight from the probe instead of
reading the bucket again. The filter sits after nlive, so the backend's
%ScrMapIx fields for entries and the direct index move by one.
Every SCR_ELEM_REF array stored its element type's retain, release and trace entry points in its own header (24 bytes), so ScrArr was 80 bytes: an 80-byte block for arrays of acyclic elements and a 96-byte block for cycle-capable ones. The three pointers now live in one constant ScrArrOps descriptor per element type, which the compiler emits once per module (@sc_arrops.*) and the runtime keeps static, and the header holds a pointer to it plus a 4-byte `cyc` flag beside `elem` that the inline RC fast paths test. ScrArr is 64 bytes: arrays take 64- and 80-byte blocks.
scr_arr_new_ref(retain, release, trace, cap) becomes scr_arr_new_ops(ops, cap); scr_set_to_arr_ref takes the result's descriptor from its caller instead of building an array from the set's key adapters. The backend names the %ScrArr fields in SCR_ARR_FIELD. The layout change bumps the runtime ABI marker to scr_runtime_abi_v13.
Rounding every dense capacity up to its allocator block changed the growth sequence (a 48-byte block holds 5 slots, so storage went 48/96/192/384 B instead of 48/80/144/288/576 B), which moved array storage into other size classes: +3.6% simulated L1 misses on the tsc-ts self-check and +2.6%/+4.3% wall on the self-check and TypeORM. Growth is back to 4, 8, 16, ...; only exactly sized construction fills its block. Adds a runtime test for exact construction followed by appends, index writes and length extension over the spare capacity.
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.
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 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.
Map.get of a reference value under a string key called scr_map_get_str_ref,
which retained the hit through the collection's value adapter: an indirect
tail call into an out-of-line retain function on every hit (in tsc-ts the
symbol tables' Symbol and Type retains, 0.4% of samples, were only reached
that way). Number keys already read the entry and retain with the value
type's own retain. String keys now do the same: scr_map_entry_str returns the
entry, and the generated code loads the value slot and retains it inline.
Both the ordinary and the stack-boxed (local) reads take the entry path.
Corpus 4651 keeps string-keyed results alive past delete, overwrite, clear
and the map itself for instances, records, arrays, nested maps, cycles and
union values; dropping the inline retain makes it (and 523) fail under ASan.
Full passes are scheduled by heap growth, and the fraction they wait for
widens while growth-triggered passes keep finding nothing. Widening went one
doubling per pass after two passes in a row had asked for it, so a heap that
grows for a whole run never reached the cap of 16 that #833 introduced: on
tsc-ts, runs with seven full passes widened to 9x at the sixth pass, and the
seventh still walked 2.7-3.1M objects (294-393 ms) late in the run.
The old level now widens two doublings per qualifying pass, never past what
the pass's yield asks for, so passes that keep finding nothing reach the cap
on the fourth pass instead of the sixth, while the heap is still small.
Narrowing is unchanged: a pass that finds old garbage moves the fraction
straight to what its yield asks for, and widening still waits for two passes
in a row. The mature level keeps a doubling per pass: a mature pass walks only
what was promoted since the last one, so its total walking hardly depends on
how passes are spaced, and narrower spacing lets less young garbage float.
The memory bound is #833's: once passes stop finding garbage, the heap may
grow to 17 times its size after the last full pass before the next. It is
now reached after four such passes instead of six. Jumping straight to the
cap after two was also measured; it reaches the bound at heaps of a few
thousand objects and is not better on tsc-ts at --checkers 8.
test_cycle pins the schedule from where an old-garbage ring narrows the
fraction: the pass after it does not widen, the second pass in a row that
finds nothing reaches the cap (one doubling per pass fails here at caps 1
and 16), and a capped pass comes exactly at the cap. cycle.test.ts also runs
SCR_CYCLE_GROWTH_CAP=8.
Measured with tsc-ts origin/parallel-check e469e33 (--optimization=speed)
against the parent commit, interleaved in Linux Vercel Sandboxes (8 vCPU),
medians; diagnostics and exit codes identical. The build is the next
commit's with its parallel walk off (SCR_CYCLE_PARALLEL=0), which runs this
commit's collector. RSS in MB:
single-threaded (10 rounds) wall RSS
Excalidraw 7.94 -> 7.32 -7.8% 1263 -> 1280
Playwright 7.09 -> 6.90 -2.7% 1457 -> 1470
TypeORM 6.67 -> 6.39 -4.1% 1014 -> 1046
tsc-ts self-check 4.33 -> 4.03 -6.8% 626 -> 622
--checkers 8 (10 rounds, second host)
Excalidraw 2.56 -> 2.55 -0.6% 2120 -> 2200
Playwright 2.46 -> 2.39 -3.0% 2649 -> 2686
TypeORM 1.96 -> 1.85 -5.4% 1270 -> 1315
tsc-ts self-check 1.04 -> 1.04 +0.5% 742 -> 744
Pass log (instrumented build, one single-threaded run each): full-pass time
337 -> 89 ms (self-check), 426 -> 130 ms (TypeORM), 323 -> 104 ms
(Excalidraw); the largest full pass now walks 1.1-1.3M objects early in the
run, and total collector time drops by 27-35%.
Mature and old passes are scheduled by heap growth. While growth-triggered
passes at a level keep finding nothing, the fraction they wait for widens by
doublings from 1/4, and it used to stop at 1, so a large live heap that keeps
growing was walked in full once per doubling. On tsc-ts those walks are most
of the collector's time: in single-threaded Excalidraw, 12 growth-triggered
full passes cost 2.0 s of 14.7 s, the last two walking 2.6M and 5.2M objects
to free 1.4%.
The cap is now 16. That is a memory trade-off: once passes stop finding
garbage, the cycle-headered heap may grow to 17 times its size after the last
pass at a level before the next (it was 2 times), so a program that
accumulates dead cyclic garbage while passes find nothing can hold up to 16
times more of it. Yield-driven narrowing is unchanged: a pass that finds old
garbage moves the fraction straight back to what its yield asks for.
SCR_CYCLE_GROWTH_CAP lowers the cap for memory-sensitive programs, read like
SCR_CYCLE_THRESHOLD: a power of two from 1 to 16 (other positive values round
down, larger ones read as 16); 1 restores the previous schedule.
test_cycle pins the cap on a heap at the growth floor: a dead cycle through
the old generation must go exactly when the heap has grown the configured cap
past the floor (not one doubling earlier, not later), and a pass that finds
old garbage must narrow the fraction at once. cycle.test.ts runs it at the
default cap and at SCR_CYCLE_GROWTH_CAP=1, 3 and 64. The large-chain widening
check keeps its walk bound but no longer waits out the cap on a 266k-node
structure, and now runs after the tests that assume the base schedule.
Measured with tsc-ts origin/parallel-check e469e33, built with this runtime
vs the parent commit, interleaved in one Linux Vercel Sandbox (8 vCPU; 5
rounds, medians; --checkers 8 repeated with 10 rounds). Diagnostics identical.
"cap 1" is this build with SCR_CYCLE_GROWTH_CAP=1:
single-threaded wall RSS cap 1
Excalidraw -10.1% 1283 -> 1263 +0.5%
Playwright -11.0% 1446 -> 1457 +0.3%
--checkers 8
Excalidraw -8.7% / -4.5% 2110 -> 2132 / 2115 -> 2125 +2.6% (10 rounds)
Playwright -7.8% / -7.7% 2616 -> 2634 / 2589 -> 2678 -1.0% (10 rounds)
RSS in MB (max of two runs). The runtime benchmark suite's allocation-heavy
workloads (alloc-trees, word-graph, ast-interp, json-records, records-sort,
functional-pipeline; --layouts=4 --runs=16) are neutral in time and peak RSS
(-0.3% to +1.7%).
A static array passed where a dynamically typed array is expected (for
example a `number[]` given to an `(object | undefined)[]` or `any[]`
parameter) travels as a live typed reference. Every operation on that
reference refreshed its dyn view by rebuilding the entire array, so
`xs[i]`, `xs.length`, `xs[i] = v`, `i in xs`, `Object.hasOwn`, each
`for...of` step, each `for...in` key probe, and `push`/`pop`/`at` all
cost O(n), and any loop over the array was quadratic: 10k elements took
12 s and 20k took 53 s on Linux. Writes also re-converted and swapped the
whole native storage on every store.
The native array is already the source of truth: each refresh rebuilds
the view from it and keeps only the prototype and symbol properties. Live
array capsules now carry per-element hooks (ScrDynTypedArrayOps), emitted
per array type next to the existing snapshot adapter and bound when the
capsule is built. The runtime answers length and presence from the native
array and converts exactly one element per read, writes an in-range or
appended element in place, and steps array iterators without a refresh.
Holes and out-of-range reads, which can reach the prototype chain, keep
using the view. A value the element type rejects, a write that would
grow the array by more than one element, a multi-value push, and `at`
with a coercible argument also keep the snapshot path, so their
diagnostics and atomicity are unchanged.
The capsule payload stays 80 bytes: the key length and the two flags now
share one 32-bit word, which frees the slot for the hooks pointer on both
64- and 32-bit targets.
Linux, 100k elements: the index loop went from beyond a 120 s timeout to
7 ms; Node takes 0.5 ms.
Test: test_json.c live_array_ref_tests. Corpus:
4598-live-array-ref-element-access.
A statement-position `a.splice(start, deleteCount, ...items)` built a
one-element (or n-element) items array, then scr_arr_splice_insert built
the removed-elements array that the caller released straight away: three
or more allocations per sorted insert in tsc-ts's insertType. When the
result is discarded and the items are plain evaluated values, the emitter
now evaluates receiver, start, count, and items in order and calls
scr_arr_splice_drop with the owned value slots. It applies the same
clamping and state shifts on dense receivers, releases removed values
only after the receiver is consistent again, and keeps the general
implementation for sparse receivers and sparse-sized results.
Maps and Sets with number keys used as id tables (tsc-ts keeps node and
symbol links in Map<number, Links>) hashed every key as a double. They now
keep a direct index, dense[k] = entry index + 1, while every live key is an
integer in [0, ndense) and the table stays within 8x the live count (64
slots minimum). The entries array, tombstones and iteration order are
unchanged; only the lookup index differs.
-0 lands on slot 0 (SameValueZero), and NaN, fractions, negatives and keys
beyond the table are absent by construction. Inserting a key the table
cannot hold moves the map to the bucket table; whenever that table would
grow, compact integer keys move it back, so maps whose ids start far from
zero switch over once their live count catches up. scr_map_entry_f64 and
layout asserts expose the index to generated code.
A randomized C test compares every operation with an ordered reference
model across linear, direct and hashed indexes, and a corpus program pins
the JavaScript-visible semantics around the transitions.
Only growth moves a map to the direct index: an append that
scr_map_reserve_append would absorb by compacting tombstones (a linear map
with a dead entry, a bucket table at most half live, no iteration active)
keeps the map's compact storage, so churn on a small number-keyed map no
longer allocates a table. While an iteration is active nothing compacts,
so a small map promotes to the direct index (integer keys) or the bucket
table (other numbers) without moving entry indices. The runtime map test's
small-storage script, which assumed bucket promotion and failed its two
live-iteration checks, now runs with integer and fractional keys and
checks both transitions, and the corpus program covers small maps growing
and clearing mid-iteration.
Retains stored BLACK into the cycle header so that markRoots could skip
candidates retained again after they were buffered, and every surviving
release stored PURPLE to undo that. On the tsc-ts self-check the filter
dropped almost no candidates (3.01M walked either way), while the stores
cost a header write on every retain and release of a headered object and
an instruction sequence at every inlined site. Colors now belong to the
collector alone: markRoots takes every buffered entry, scr_cyc_mark_live
and the emitted mark-live and purple stores are gone, and the release
fast path only reads `buffered`.
tsc-ts self-check (median of 7): 3.15s -> 3.06s, max RSS 614 -> 600 MB,
binary -727 KB.
Rebased onto main's three-generation collector, the series measured
with and without this commit (tsc-ts self-check, median of 7):
2.779 s -> 2.706 s, binary -660 KB, max RSS +13 MB.