mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-28 06:53:42 +08:00
e65ebfd2c703145cea5b3ffa4eec8d555cb3f00a
8
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
112add3dce |
Merge remote-tracking branch 'origin/perf/gc-trace-null-skip-main' into HEAD
# Conflicts: # packages/compiler/test/ts7/baselines/order-parity.json |
||
|
|
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. |
||
|
|
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%. |
||
|
|
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%). |
||
|
|
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}.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |