539 Commits
Author SHA1 Message Date
Chris Tate d75d7a22f0 Prepare v0.2.10 2026-10-12 10:31:02 -05:00
Chris Tate 043c656bbd Merge main into feat/program-lifetime-publication 2026-10-12 09:38:24 -05:00
Malte Ubl e1ccfeeb84 codegen: signed conversions for array counters and lengths
Integer induction counters live in unsigned 64-bit slots, so every read
as a number was a uitofp i64 (a 6-7 instruction sequence on x86-64) and
every nested start an fptoui. The runtime's push path also round-tripped
the array length through a double for its bound check.

The values are at most 2^32 - 1: on 64-bit targets convert counters with
sitofp and loop starts with fptosi, convert array lengths and validated
indices through int64_t in scr_array.c, and test the push bound on the
integer length. 32-bit targets keep the unsigned conversions.

tsc-ts Excalidraw: uitofp i64 sequences 32.5 M -> 4.0 M, -0.66%
instructions on top of the throw-path change.
2026-10-12 06:40:50 -07:00
Malte Ubl b5e4bcb54e runtime: keep throw entry points cold and out of line
With runtime bitcode import, scr_throw_obj, scr_error_new and the other
throw entry points were inlined into the error paths of small compiled
functions. The inlined bodies needed callee-saved registers that every
call then saved and restored, and they made the helpers too large to
inline into their callers (1047 inlined throw bodies in tsc-ts).

Mark the 25 functions that only run while raising a JS exception
cold and noinline. The LLVM helper never imports noinline bodies, so
the throw paths stay calls. tsc-ts Excalidraw: -0.44% instructions.
2026-10-12 06:40:50 -07:00
Chris Tate 85736170ae Merge main into feat/program-lifetime-publication 2026-10-12 08:13:15 -05:00
Chris Tate 72cc828ab0 Merge main into perf/compact-array-header-2 2026-10-12 01:12:21 -05:00
Chris Tate b1f3b31546 Order the heap page size before huge page advice 2026-10-11 22:58:10 -05:00
Malte Ubl fa55ce6b07 Keep the RC entry points of array elements in one shared descriptor
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.
2026-10-11 19:46:48 -07:00
Chris Tate 31aaefb4c6 Merge main into perf/map-get-inline-retain 2026-10-11 21:31:11 -05:00
Chris Tate 6b708b2e3e Merge pull request #876 from vercel-labs/perf/array-growth
Size small arrays exactly and keep map storage on the runtime allocator
2026-10-11 20:14:17 -05:00
Malte Ubl 40a2c2adf2 ProgramLifetime in sanitized builds: skip the RC audit's leak report and scan instances as LeakSanitizer roots
Program-lifetime instances are never freed, and neither is anything they hold, so the RC audit of --sanitize builds reported every one of them, and every string, array and map they reference, as live at exit (4633-contract-program-lifetime failed the sanitized differential lane: 306 strings, 15 arrays, 338 objects). Like a program that abandons fibers, a program that keeps program-lifetime objects now skips the leak report with "scriptc RC audit skipped: N program-lifetime object(s) never freed", which the harnesses already filter.

Sanitized constructors call scr_obj_lifetime_note(obj), which counts the instance and registers it with LeakSanitizer as an intentional root, as publication does for published objects: once the audit no longer exits early, LeakSanitizer would otherwise report the instances the program drops. Plain builds emit nothing.
2026-10-11 17:07:03 -07:00
Malte Ubl 5af0c17c39 Merge branch 'main' into perf/array-growth 2026-10-11 16:32:10 -07:00
Malte Ubl 825ff1dcfe Merge branch 'main' into perf/locality 2026-10-11 16:32:06 -07:00
Malte Ubl 7027039809 Merge branch 'main' into perf/map-get-inline-retain 2026-10-11 16:31:53 -07:00
Malte Ubl cdf964ea85 Allocate plain map headers out of line
Map construction inlines into compiled callers, and the inline small-allocator path (with its clearing of recycled blocks) added 37 KB to tsc-ts across its constructors and init functions. A noinline helper keeps those call sites as small as the libc calloc call they had: tsc-ts text is now +5.6 KB over origin/main for this branch.
2026-10-11 16:30:23 -07:00
Malte Ubl 2a7c22ae49 Keep map storage on the runtime allocator
Acyclic map headers, entry arrays, bucket tables and number-key indices came from glibc (calloc/malloc/realloc/free) even when small: a map with one entry paid a 160-byte glibc header plus a 96-byte glibc entry block, and every growth step went through glibc realloc. They now use scr_mem_calloc/alloc/realloc/free like arrays, so blocks up to 512 bytes come from the size-class slices (inline free lists, huge pages after the slice prefix) and larger ones still go to the system through scr_mem_realloc. Nothing outside scr_map.c allocates or frees map storage.
2026-10-11 16:30:22 -07:00
Malte Ubl 6778826d8f Keep the array growth sequence; round only exact construction to its block
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.
2026-10-11 16:30:22 -07:00
Malte Ubl ead9f4ecba Size array storage to its construction count and its allocator block
Arrays constructed with a known element count (literals, slices, map/filter results) reserved at least four slots, so a one-element literal took a 48-byte block for 9 bytes of content. Construction now allocates exactly the requested slots, and any dense capacity is rounded up to fill its small-object allocator block (1 slot -> 16 B, 2-3 -> 32 B). Appends below four slots grow straight to four and double from there as before. The first storage of an array is a plain allocation with its states cleared, without the realloc, state move and sparse absorption of later growth. New capacity still starts as holes.
2026-10-11 16:30:21 -07:00
Chris Tate c9fe46d8c0 Merge main into perf/offthread-prefault 2026-10-11 18:10:02 -05:00
Chris Tate fa11424e5f Prepare v0.2.9 2026-10-11 17:25:14 -05:00
Malte Ubl eae8e02481 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	packages/compiler/test/ts7/baselines/order-parity.json
2026-10-11 13:27:25 -07:00
Malte Ubl 0f05bf368d Pre-fault only for large slices and heaps
The first version committed memory ahead for every program: the heap's
16 MiB top pad was faulted in as soon as the helper started, and every
slice past its first 256 KiB faulted the next chunk ahead. A small server
(runtime suite http-api) peaked at 20.7 MiB instead of 4.4 MiB.

- The helper now only watches the break until the heap has grown by
  32 MiB; only then does it set the top pad and fault growth in ahead.
  Smaller heaps keep glibc's defaults, including its dynamic mmap
  threshold, which any top-pad mallopt fixes.
- A slice faults ahead only once it has carved 1 MiB, so lightly used
  classes commit nothing extra (and a program that never fills one does
  not start the helper at all).
2026-10-11 13:07:04 -07:00
Malte Ubl 767c33ebdb Give glibc's large blocks transparent huge pages
Blocks above the small-object classes (array storage, hash tables, link pages, large strings) come from glibc, whose brk heap and per-block mappings stay on 4 KiB pages where THP is in madvise mode; glibc advises its own memory only under the glibc.malloc.hugetlb tunable, which a program cannot set for itself. Once the brk heap passes 32 MiB, every large allocation now advises the heap grown since the previous one (and the aligned interior of big blocks mapped outside it), and glibc's top pad is raised to 32 MiB so heap growth no longer leaves each 2 MiB range straddling the heap end when it is first touched; the mmap and trim thresholds are pinned to their adaptive maxima, which setting the pad would otherwise freeze at their defaults. Linux and glibc only.
2026-10-11 12:58:50 -07:00
Malte Ubl 2278456bbd Pre-fault allocator memory on a helper thread (Linux)
First touch of fresh memory costs the allocating thread a page fault and
a page clear. Linux executables now hand that to a helper thread:

- Size-class slices raise their bump limits in steps (256 KiB on the
  small-page prefix, half a huge page after the switch), and each step
  asks the helper to fault in the next step ahead with
  MADV_POPULATE_WRITE, which commits zeroed pages without storing, so it
  cannot race with carving. A slice holds at most about one step of
  untouched committed memory; a slice's first chunk is not faulted ahead.
- glibc's heap gets a 16 MiB top pad and the helper faults in each brk
  extension as soon as it sees the break move (checked after every
  request, then polled every 1 ms while it keeps growing; an idle program
  does not wake it).

The fast paths are unchanged; pages the helper has not reached are still
faulted by the owner. The helper masks all signals. SCR_SA_PREFAULT=0
turns it off, SCR_SA_PREFAULT_HEAP=0 keeps glibc's heap defaults; kernels
without MADV_POPULATE_WRITE (< 5.14) fall back to the old policy, and
library archives keep it.

The slice-growth allocator check now carves past several huge-page steps
and checks every fresh block reads as zero (UBSan and TSan builds run
with the helper).
2026-10-11 12:38:24 -07:00
Malte Ubl fc93d10871 Merge remote-tracking branch 'origin/perf/array-layout-main' into HEAD 2026-10-11 12:23:05 -07:00
Malte Ubl cc8392d26a Merge remote-tracking branch 'origin/perf/heap-layout-main' into HEAD 2026-10-11 12:22:56 -07:00
Malte Ubl c8430bbf0d Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	packages/compiler/test/ts7/baselines/order-parity.json
2026-10-11 12:22:49 -07:00
Malte Ubl e84adc9727 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	packages/compiler/test/ts7/baselines/order-parity.json
2026-10-11 12:22:40 -07:00
Chris Tate baeaf9c7e0 Merge pull request #854 from vercel-labs/perf/exception-flag-main
Exact per-thread pending word for worker executables, and a call-free macOS stack guard
2026-10-11 14:06:38 -05:00
Chris Tate 0ab65a79b0 Merge pull request #836 from vercel-labs/perf/parmem
Support os.availableParallelism() in static programs
2026-10-11 12:55:51 -05:00
Malte Ubl 00f88680cd Merge remote-tracking branch 'origin/main' into HEAD 2026-10-11 10:17:26 -07:00
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 796013f9d2 Merge remote-tracking branch 'origin/perf/heap-layout-main' into HEAD
# Conflicts:
#	packages/compiler/test/ts7/baselines/order-parity.json
2026-10-11 10:17:07 -07:00
Malte Ubl fca66ec655 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	packages/compiler/test/ts7/baselines/order-parity.json
2026-10-11 10:16:59 -07:00
Malte Ubl d770871069 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	packages/compiler/test/ts7/baselines/order-parity.json
2026-10-11 10:16:52 -07:00
Malte Ubl a34d018497 Merge remote-tracking branch 'origin/perf/exception-checks-main' into HEAD 2026-10-11 10:16:45 -07:00
Malte Ubl fe79aea42e Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	packages/compiler/test/ts7/baselines/order-parity.json
2026-10-11 10:15:10 -07:00
Chris Tate 2db40a34e8 Merge pull request #859 from vercel-labs/perf/worker-fd-table
Grow the descriptor table before worker threads share it
2026-10-11 12:07:56 -05:00
Chris Tate 727bdb7227 Merge pull request #849 from vercel-labs/perf/gc-widen-faster
Widen the old level's growth fraction two doublings at a time
2026-10-11 12:07:34 -05:00
Malte Ubl 554f3177ab Walk sampled bypass candidates at the nursery level in the cycle test
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.
2026-10-11 09:57:08 -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 85aa553ee4 Skip out-of-line array storage work for arrays without it
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.
2026-10-11 09:38:28 -07:00
Malte Ubl 30a4d8b284 Allocate an array's out-of-line storage from the small-object allocator
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.
2026-10-11 09:38:27 -07:00
Malte Ubl 1a14263b5f Move rarely used array storage out of line
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.
2026-10-11 09:38:27 -07:00
Malte Ubl bb478e1e6c Buffer only capture boxes that can lie on a cycle
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.
2026-10-11 09:38:26 -07:00
Malte Ubl 6f488e13aa Shrink the cycle-collector header to 16 bytes
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.
2026-10-11 09:38:26 -07:00
Malte Ubl 4a513b9fd7 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	packages/compiler/test/ts7/baselines/order-parity.json
2026-10-11 09:37:04 -07:00
Malte Ubl a1a484c9c3 Merge remote-tracking branch 'origin/perf/worker-fd-table' into HEAD 2026-10-11 09:36:55 -07:00
Malte Ubl 69aedab4f8 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-11 09:36:47 -07:00
Malte Ubl ba7c1ed2b3 Merge remote-tracking branch 'origin/perf/exception-checks-main' into HEAD
# Conflicts:
#	packages/compiler/test/ts7/baselines/order-parity.json
2026-10-11 09:36:39 -07:00