Repository navigation
Move rarely used array storage out of line - #856
Merged
Merged
Conversation
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This was referenced Oct 11, 2026
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.
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.
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.
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%).
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 ScrCycType descriptors now carry collector statistics indices from ShapeHost.nextCycKind, counted from FIRST_EMITTED_CYC_KIND. The llvm-layouts.ts host lacked the hook, so its class descriptors and vtables stamped kind 0 and the native layout emission diverged from the production emitter's.
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.
cramforce
force-pushed
the
perf/heap-layout-main
branch
from
October 11, 2026 17:04
51de889 to
4fd8d5d
Compare
cramforce
force-pushed
the
perf/array-layout-main
branch
from
October 11, 2026 17:04
e791e60 to
85aa553
Compare
# Conflicts: # packages/compiler/test/ts7/baselines/order-parity.json
…to HEAD # Conflicts: # packages/compiler/test/ts7/baselines/order-parity.json
Let collector types bypass the generations where they never die
Skip null slots in emitted trace functions
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Branch
perf/array-layout-main, stacked onperf/heap-layout-main(open after it). Three commits:scr_runtime_abi_v11)Every
ScrArrcarried sixteen words. Seven of them (sparse slots with their length and capacity, noncanonical numeric properties with length and capacity, and regex match metadata) are used only by sparse arrays, arrays with numeric non-index properties and match results; a heap census of a tsc-ts check of Excalidraw found 1.10M live cycle-capable arrays and not one with any of the seven set. They now live in aScrArrExtthat the array points at, allocated on first use: a dense array shrinks from 128 to 80 bytes, a cycle-capable one (with its 16-byte collector header) from 144 to 96.SCR_AX), writers allocate it (scr_arr_ext); the conversion was compiler-checked (the read accessor is const, so every write site failed to compile until converted).%ScrArrmirror loses the seven fields; tagged-template arrays are emitted with a NULL ext; live stream commits swap the ext pointer (they swapped all seven fields before); the cached typed-view refresh (llvm/dyn.ts) swaps sparse slots and properties through the newscr_arr_swap_indexed_ext, keeping each array's metadata as before.scr_mem_callocand returns throughscr_mem_free(commit 2; the first version used the systemcalloc, which every regex match result paid for).4610-array-ext-dyn-refresh.ts(properties through a refreshed typed view, sparse slots directly and through a live reference, truncation, match metadata directly, throughanyand after a copy) with its order baseline.Measurements (from the overnight runs, not re-measured on
main)Together with the tsc-ts PRs of
heap-int-fields-3, on the integration base (Linux sandbox, interleaved, n=8, medians, diagnostics and exit codes identical): tsc-ts single-threaded −2% to −5%,--checkers 8−4% to −10%, peak RSS −10% to −13% (Excalidraw: 1t −3.5%, c8 −9.7%, RSS −10.1% / −10.8%).Runtime suite (
bench-runtime.mjs --layouts=4 --runs=16, these three commits against their base): geomean −0.0%; every workload neutral except regex-logs −4.3% (faster); peak RSS json-records −8.3%, http-api −6.8%, log-lines −6.1%, ast-interp +3.8% (2.9 MiB). Binary size +88 B typical, +4 KiB page steps on six workloads.Validation on this branch (macOS, focused)
packages/runtime/test,packages/runtime/src, ASan + RC audit lanes): 60 files, 421 tests pass (2 skipped).-t 'array|json|map|regex|spread|sort|slice|splice|concat|4610': 376/376 pass.