Commit Graph
8 Commits
Author SHA1 Message Date
Malte Ubl 112add3dce Merge remote-tracking branch 'origin/perf/gc-trace-null-skip-main' into HEAD
# Conflicts:
#	packages/compiler/test/ts7/baselines/order-parity.json
2026-10-11 10:17:20 -07:00
Malte Ubl def6f86a0e Let collector types bypass the generations where they never die
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.
2026-10-11 09:39:57 -07:00
Malte Ubl a9d294d1c1 Widen the old level's growth fraction two doublings at a time
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%.
2026-10-10 20:45:39 -07:00
Malte Ubl 73a99b2393 Let the cycle collector's growth fraction widen to 16
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%).
2026-10-10 14:44:14 -07:00
Malte Ubl 70dfdbd61e Route strings, arrays and emitted records through the small-object allocator
Move the allocator into scr_alloc.c with inline fast paths in scr_runtime.h;
strings, array headers/slot buffers, and emitted acyclic records/classes
(scr_rt_calloc/scr_rt_free) now use it. Adds scr_alloc.test.{c,ts}.
2026-10-08 15:01:26 -05:00
Chris Tate c08ab955ae Enforce linting and consistent source formatting
- Enforce oxlint and oxfmt for owned JavaScript and TypeScript with independent CI checks.
- Preserve compiler import boundaries and protected fixtures while cleaning up lint findings.
- Standardize source formatting and validate import, fixture, and embedded-source protections.
2026-10-05 17:51:24 -05:00
Chris Tate 0311082801 Reduce cycle traversal overhead
- Bound collector recursion and reuse scanned candidates during cycle cleanup.
- Skip redundant candidate checks and unused array storage.
- Preserve reference counts across deep, shared, and mixed-generation graphs.
2026-10-05 06:26:26 -05:00
Jimmy Miller d41b5dd3c3 Add generations to cycle detection (#30)
* runtime: pin the ScrCycHdr layout the LLVM backend hardcodes

scr_cyc_mark_live is a static inline, so there is no symbol for the LLVM
backend to call — it open-codes the retain hook as a raw `store i32 0` at
obj-16 and reaches the cycle header at obj-32. Three sites emit that
(llvm/shapes.ts, llvm/classes.ts, llvm/emitter.ts), which makes the C
struct's size and its `color` offset a real ABI between the runtime header
and hand-written IR. The stored VALUE is part of that ABI too: the emitted
store is the literal 0, not the enumerator, so SCR_CYC_BLACK's numeric value
is pinned as surely as the offsets are.

Nothing enforced any of it. llvm-runtime-abi.test.ts mechanically checks that
every `declare @scr_*` agrees with its C prototype, but that is the FUNCTION
ABI; a struct-offset skew is invisible to it, to the type system, and to the
C compiler. Narrowing `color` and putting another field in the freed bytes
compiles clean and silently zeroes that field on every retain; reordering the
color enum compiles clean and makes every retain write the wrong color.

Assert the four properties the emitted IR actually depends on, so the next
edit to this struct or that enum fails the build instead of miscompiling.

The three layout assertions bake in 64-bit pointers and size_t, which every
current target has. A 32-bit port would trip them — correctly: the backend's
hardcoded -16/-32 would need fixing too.

* runtime: collect cycles generationally

A trial-deletion pass costs O(objects reachable from its candidates), and a
candidate inside a large live structure reaches all of it. Collecting every
256 candidates therefore made total collection work quadratic in a growing
live heap: the V8 splay benchmark spent 749ms per iteration, ~99% of it in
the collector re-walking a tree that was entirely live and never garbage.

Make the collector generational, in the shape CPython uses for this same
algorithm. Each header carries a generation; a pass names the oldest one it
will walk and skips anything above it, exactly as it already skips NULL and
immortals; survivors are promoted so later nursery passes never re-walk
them. A pass then costs what was allocated since the last one rather than
the whole heap.

Restricting the walk stays sound because trial deletion is already
conservative in the safe direction: an edge from a skipped older object is
never trial-deleted, so its target keeps that count and reads as externally
referenced — the same answer the collector gives for a stack reference.
What it gives up is cycles that span generations, and the full pass (which
skips nothing, and so is exactly the old algorithm) is what finds those.

Three things that are load-bearing and were not obvious:

- A restricted pass may spare a candidate ONLY because the edge keeping it
  alive came from a generation it refused to walk. The candidate buffer is
  the collector's only root set and markRoots consumes the entry, so
  dropping such a survivor retires the one record that the object is a
  possible cycle root — and a dead cycle whose members were all spared that
  way is then invisible to every later pass, full ones included. So a
  restricted pass hands its survivors back as candidates. A full pass needs
  none of this: it walked every edge, so rc > 0 there is a genuine outside
  reference and Bacon-Rajan's own reasoning retires the candidate.

- Live data that is unlinked INTO a dead cycle grows no counter: those
  objects were tallied when they were allocated, and they are not in the
  nursery. A heap-growth trigger alone therefore never fires for a program
  that churns its long-lived structures, so the mature candidate buffer's
  own size is a second full-pass trigger.

- Restricting the walk leaves one edge accounted by NOBODY. markGray does
  not trial-delete an edge into a skipped generation, and the teardown
  contract has free_fn release exactly the UNTRACED children — because every
  traced edge was supposed to have been decremented by markGray. So freeing
  a white object that holds a traced edge into an older generation drops the
  edge while the target keeps the count, and that phantom reference reads as
  an external one to every later pass, full ones included: the target and
  everything it links to would never be reclaimable again.

  The white set pays those edges off explicitly, in two halves, because the
  walk cannot do it inline — a release there could free a survivor that
  scr_promote still points at, or cascade into the white set mid-teardown.
  Each target is PINNED with a retain while `gen` still holds the values the
  walk filtered on, and after the teardowns the pin is dropped and the edge
  given up for real. Two decrements per entry, because the pin is one of
  them; a single release would only undo the pin and leave the phantom edge
  where it was. The pin is also what makes the second decrement safe, since
  a teardown's releases of untraced children can reach a cycle-headered
  object — that is how they re-buffer survivors. One entry per skipped edge,
  so several edges sharing a target settle it once each. Nothing pinned can
  be in the white set: a skipped older object's edges were never
  trial-deleted, so anything it references was scan-blacked rather than
  whitened, by the same conservatism above.

The event loop's between-turns collection now runs one scheduled pass
instead of scr_collect_cycles(); a full sweep per turn would walk the whole
live heap every turn, which is the cost the generations exist to avoid.
scr_collect_cycles() stays the exit / session-reset sweep, where the RC
audit runs straight after and wants nothing reclaimable left.

757-cycle-cross-generation.ts covers the third point: a live list mutated
until its nodes promote, then dead nursery cycles holding traced edges into
it. Unaccounted, the sanitized lane reports 60 objects live at exit — the
whole list, held by phantom counts.

Measured, best-of-7 against the previous collector:

  splay          620.45ms -> 1.45ms   (428x)
  deltablue         1.25  -> 0.81     (1.55x)
  richards          1.196 -> 1.162    (1.03x)
  crypto/navier-stokes/raytrace/regexp   unchanged

  an LRU cache (200k entries, 200k ops)  32137ms -> 93ms   (345x)
  a large live cache + 500 event-loop turns  3095ms -> 776ms  (4.0x)

The trade is space: floating cyclic garbage is now a bounded fraction of the
live heap instead of a fixed candidate count, so peak RSS on
cyclic-garbage-heavy programs runs ~1.4x the old collector's. Bounded and
flat in run length, not a leak — verified by holding a 200k-object live heap
while churning 200k dead cycles per round for 30 rounds.

Test lanes show byte-identical failure sets against the previous collector:
Linux sanitized 164, Linux plain 528, macOS differential corpus 66 — all
pre-existing on those machines, none introduced or fixed here.

* runtime: bound scheduled mature cycle backlog

* runtime: rearm cycle trigger after candidate deaths
2026-08-08 11:32:45 -05:00