512 Commits
Author SHA1 Message Date
Chris Tate fa11424e5f Prepare v0.2.9 2026-10-11 17:25:14 -05: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
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 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 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
Malte Ubl d56348effd Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	packages/compiler/test/ts7/baselines/order-parity.json
2026-10-11 09:36:22 -07:00
Malte Ubl 9ae2acdd39 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-11 09:35:37 -07:00
Malte Ubl cc13a7eff9 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-11 09:35:13 -07:00
Malte Ubl 5c3f6f80e0 Land the approved performance stack (#829, #830, #832, #833, #835, #837, #839)
These PRs were merged into their stack base branches instead of main. This merges perf/alloc-hugepages (with the CI fixes below) into main. Conflicts: order-parity.json (union of entries); scr_alloc.c/scr_alloc.test.ts, where main already has the worker exit trim (#858, adapted to the pre-hugepages allocator): resolved by taking the hugepages allocator and re-applying the original trim commits written on top of it (ea88b87c + 4816696d, clean apply on an identical base).
2026-10-11 08:57:42 -07:00
Chris Tate c6822dbbf6 Merge pull request #858 from vercel-labs/perf/worker-exit-trim
runtime: return an exiting worker thread's free pages to the system
2026-10-11 10:51:46 -05:00
Malte Ubl 50bc1d2240 Read cgroup files with one read, as libuv does
os.availableParallelism() follows libuv's uv_available_parallelism, whose uv__slurp reads each /proc and cgroup file with a single read and fails on a read error; the helper looped until the buffer filled and ignored read errors, so a long /proc/self/cgroup could resolve a controller Node does not see. A cgroup CPU quota below one CPU truncates to 0 and stays ignored rather than clamped to 1: libuv 1.51 (Node 24) through 1.52 applies a quota only when it is positive, and Node then answers the affinity count.
2026-10-11 08:26:08 -07:00
Malte Ubl a310e02f73 Merge the fixed perf/alloc-hugepages into perf/parmem 2026-10-11 08:17:24 -07:00
Malte Ubl 3e83a7b38c Declare scr_detached_exit in scr_runtime.h
The emitter defines @scr_detached_exit for --detached-exit executables, and the runtime ABI test requires every scr_* symbol the backend can emit to have a prototype or extern data declaration in the header.
2026-10-11 08:13:26 -07:00
Chris Tate 003265a1df Merge pull request #852 from vercel-labs/fix/publish-leaks
Tell LeakSanitizer that published objects are immortal
2026-10-11 09:10:50 -05:00
Malte Ubl 211b3f22b3 runtime: return an exiting worker thread's free pages to the system
A script thread that exits parks its allocator arena with every freed
block still resident, so the memory of a finished worker stays with the
process until another thread claims the arena. On Linux, a parking thread
that carved at least 16 MiB now hands back every page of its arena that
holds only free blocks (MADV_DONTNEED), relinks the remaining free blocks
in address order, and keeps the returned page runs as zeroed regions that
the next owner carves before the slice frontier. It also trims glibc
malloc. SCR_SA_STATS=1 reports what each park returned; SCR_SA_TRIM=0
turns it off.
2026-10-11 07:08:22 -07:00
Malte Ubl 6a9475e931 Free the address space after process.exit off the critical path (--detached-exit)
`scriptc build|run --detached-exit` opts an executable into handing the address-space teardown after process.exit to a helper process on Linux. The helper shares the address space (CLONE_VM), blocks signals, closes every descriptor but the read end of a pipe and waits for EOF on it. The kernel releases an exiting process's memory (exit_mm) before its files (exit_files), so EOF means the helper holds the last reference; it then exits and the kernel frees the pages there instead of before the parent sees the exit (30-40 ms per GB of 4 KiB pages; tsc-ts 20-110 ms). The same latency mold hides with --fork.

It is a build-time choice, not a run-time knob, because the helper keeps the executable mapped (overwriting the binary right after it exits can fail with ETXTBSY) and the memory charged to the cgroup for up to ~100 ms, and the teardown CPU time leaves the parent's rusage. The program object defines `scr_detached_exit = 1`, overriding the runtime's weak default of 0, so no runtime flavor or linker flag is added. The option is part of the early executable cache and route keys; executables only, no effect on other platforms.
2026-10-11 07:05:29 -07:00
Malte Ubl ea7ec561da Grow the descriptor table before worker threads share it
Every worker mailbox is a pipe. Once a process passes 64 descriptors the kernel grows its descriptor table, and while other threads share the table expand_fdtable first waits a full RCU grace period. tsc-ts on a 16-vCPU host stalled 11-25 ms on the thread starting its 14th checker (16 parse threads plus 13 checkers), on the critical path. Worker builds now grow the table to 256 entries at load, while the process has one thread, which never waits; the table never shrinks.
2026-10-11 06:42:16 -07:00
Malte Ubl a8c9b45a0a Read the macOS worker stack guard from a thread-specific-data slot
In worker executables scr_stack_guard is thread-local, and on Darwin every
thread-local access calls the variable's TLV thunk. Every stack-checked
function paid that call on entry; in tsc-ts the checks were about 12% of
the instructions retired on macOS.

The runtime now also keeps the running stack's guard in a pthread key's
thread-specific-data slot, written wherever scr_stack_guard changes
(thread start, fiber switches). libpthread stores a thread's values in an
array at its thread pointer, indexed by key; scr_stack_guard_init probes
that layout once and exports the slot's byte offset. Emitted checks in
Darwin worker executables load the guard from TPIDRRO_EL0 (masked) plus
the offset on arm64, or %gs plus the offset on x86-64: no call. If the
probe fails the offset stays zero and the checks call
scr_stack_guard_get from a cold block. Other targets are unchanged.
2026-10-11 06:39:43 -07:00
Malte Ubl bdba4a7a1c Test an exact per-thread pending word in ELF worker executables
Worker programs on Linux tested a process-wide alert word after every
call that may throw and called scr_exc_pending on its slow path to answer
for the calling thread. Each of the ~55k slow-path calls in tsc-ts counted
against LLVM's inline cost, and while any thread had an exception in
flight every other thread took the slow path.

ELF worker executables (the emitter's executableTls targets) now give
every script thread an exact pending word in the executable's static TLS
block, and the emitted check is one thread-pointer-relative load and a
branch to the unwind, with no out-of-line call:

- bit 0 mirrors "active cell kind != NONE". Only the owner thread writes
  it, at every change of its active cell (throws, rethrows, take/clear,
  fiber switches, payload moves, the termination sentinel). The uncaught
  printer now settles it after resetting the cell.
- bit 1 requests the context's stop. worker.terminate() sets it through a
  pointer the worker registers under a mutex while its script runs (a
  terminate before registration is found through the cancel flag), and
  scr_context_stop sets it for process.exit() and fatal paths. The stop
  bit counts as the stop signal in scr_context_quiet/checkpoint.

A set stop bit unwinds while the active cell may still be clear, so every
consumer of an unwind installs the TERMINATE sentinel before inspecting
the cell: emitted try regions test the bit before entering a handler and
call scr_context_checkpoint, and fiber completion, the uncaught handlers
and exit listeners checkpoint instead of only testing
scr_context_stopping. The runtime's out-of-line scr_exc_pending already
checkpoints. Darwin and Windows keep the alert word (a Darwin thread-local
access is a call).

New corpus programs pin terminate() inside catch and finally bodies, with
exceptions stashed or propagating, inside generator and async bodies and
runtime callbacks, and before the worker starts, plus every way an
exception is raised, moved, consumed or discarded on the main thread and
in a worker, each followed by a may-throw call that must not unwind.
2026-10-11 06:39:42 -07:00
Malte Ubl 63c45c99ee Tell LeakSanitizer that published objects are immortal
Publication (@scriptc/threads publish() and the zero-copy delivery of
deeply frozen plain-data graphs) makes objects immortal: nothing frees
them, and the RC audit already stops counting them. LeakSanitizer did not
know, so a program that drops its last reference to a published graph
reported every such object as a direct leak whenever leak detection was
on (SCRIPTC_SAN=1 runs on Linux without ASAN_OPTIONS=detect_leaks=0, such
as the documented local fallback). Both publication paths now mark each
object they make immortal with the weak __lsan_ignore_object hook in ASan
builds; ignored chunks are leak-scan roots, so the verdict on the rest of
the heap is unchanged. Other builds compile the hook away.

The sanitized lanes run with detect_leaks=0, so a harness test pins the
contract with LeakSanitizer on (Linux, SCRIPTC_SAN=1 lane).
2026-10-11 06:39:41 -07:00
Malte Ubl 10e10061a6 runtime: keep a map's storage when clear() finds no entries
An empty map's tables are already empty, so clear() returns at once
instead of freeing oversized storage that the next fill would regrow.
Only a clear that removes entries frees storage far larger than them.
The compaction test in delete is marked unlikely.
2026-10-11 06:39:40 -07:00
Malte Ubl 714afdcfe5 runtime: size a cloned map's direct index for its own keys
new Map(m) and new Set(s) copied the source's direct-index size, which
follows the source's peak. The copy now covers its largest live key, and
keys that are sparse for their count hash instead.
2026-10-11 06:39:40 -07:00
Malte Ubl 7956559ed1 runtime: make Map/Set clear and delete cost O(live), not O(peak)
Number-keyed maps index small integer keys through a direct table sized
by the largest key ever stored, and bucket tables are sized by the most
entries ever held; neither shrank. scr_map_clear memset both on every
call, even on an empty map, so a collection that was large once paid its
peak size on every later clear. tsc-ts' Relater.reset clears a
Set<number> on every relation check: on Sentry, 36% of the CPU was that
memset.

- clear() resets only the direct-index slots of the entries it removes
  (the table is zero at every absent key), resets the bucket table only
  when entries pointed into it and it is proportional to them, and frees
  storage far larger than the entries removed, returning the map to its
  initial state as V8 does. Clearing an empty map does no work.
- delete compacts once fewer than a quarter of at least 64 entries are
  live (V8's shrink point), so iteration after mass deletion is O(live).
- Compaction shrinks oversized bucket tables, and after a delete or an
  iteration it shrinks the direct index to the surviving keys or moves
  keys that became sparse back to hashing. The append path keeps the
  current index: it holds a slot of it.

Insertion order and live iteration are unchanged: nothing compacts while
an iteration is active.
2026-10-11 06:39:39 -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 4b325150fa Support os.availableParallelism() in static programs
The runtime answers like libuv 1.51's uv_available_parallelism, which
Node's os.availableParallelism() returns: on Linux the CPUs in the
process's affinity mask, lowered to a smaller cgroup (v2 cpu.max or v1
CFS) CPU quota; on Darwin hw.activecpu, hw.logicalcpu or hw.ncpu; on
Windows GetSystemInfo's processor count; at least 1. WASI answers 1, as
the dynamic island does.

Parallel programs (tsc-ts --checkers) need it to size their thread pools.
Corpus 4598 pins the value against the host's Node.
2026-10-10 14:45:28 -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 25e3ece9d5 Access live array references one element at a time
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.
2026-10-10 14:24:13 -07:00
Malte Ubl 2d90508bc7 Move busy small-object slices to transparent huge pages
Every class slice used to opt out of transparent huge pages, so each
4 KiB the bump pointers crossed cost a page fault. Those faults (fault
entry, memcg charge, LRU insertion and the page clear) were 6-14% of
tsc-ts samples in the kernel. Backing the whole reservation with huge
pages removes most of them, but makes the 2 MiB page around every bump
pointer resident at once. A worker executable has up to 32 class slices
per script thread, so tRPC at 8 checkers grew from 558 to 955 MB.

A slice now starts on small pages. Once it has carved 8 MiB, the rest
of its range switches to huge pages (MADV_HUGEPAGE). Only slices that
already hold 8 MiB pay for a partial last huge page. The reservation is
huge-page aligned, so the switch point and every slice start on a 2 MiB
boundary. The slow path raises a slice's bump limit in two steps (to the
switch point, then to the slice end), so the inline fast paths are
unchanged. The policy is Linux-only; other targets keep one limit per
slice, as before. The allocator checks now build with _GNU_SOURCE on
Linux like the runtime packs, so the madvise advice is compiled in and
checked (alignment, contiguous carving across the switch, VmFlags nh/hg).

Measured with tsc-ts best-v1 (parallel-check + 6247795 e24057a 64d9af6
1c42c55), built with this runtime vs origin/main, on two Linux Vercel
Sandboxes (8 vCPU; 5-7 interleaved runs, medians). Columns: wall, max
RSS, minor faults; "all" is MADV_HUGEPAGE on the whole reservation:

  single-threaded   wall    RSS     faults         all: wall / RSS
  Excalidraw        -6.1%   +0.8%   331k -> 112k   -10.0% / +3.5%
  Playwright        -1.8%   +1.0%   371k -> 111k    -8.4% / +3.2%
  TypeORM           -7.2%   +0.9%   259k ->  75k   -11.8% / +4.1%
  tRPC              -5.0%   +2.0%   101k ->  47k    -9.0% / +11.3%
  tsc-ts self-check -2.9%   +2.6%   158k ->  64k    -3.1% / +8.4%

  --checkers 8
  Excalidraw        -2.9%   +2.6%  1075k -> 508k    -6.3% / +10.6%
  Playwright       -10.0%   +2.6%   739k -> 381k   -10.3% / +12.2%
  TypeORM           -5.9%   +2.2%   372k -> 232k   -10.1% / +27.7%
  tRPC              -2.5%   +2.5%   159k -> 125k    -4.3% / +71.2%
  tsc-ts self-check -4.0%   +3.9%   213k -> 172k   -12.1% / +46.3%

Switching at 2 or 4 MiB was up to about 1.5 points faster single-threaded
but cost up to +11% RSS at 8 checkers. 16 MiB gave back most of the gain.
Prefaulting small pages with MADV_POPULATE_WRITE did not reduce sys time.
Collapsing the small-page prefix with MADV_COLLAPSE at the switch did not
help either. The runtime benchmark suite (--layouts=4 --runs=16) shows a
geomean change of -1.2%: inventory-report -9.9%, validate-errors -5.3%,
log-summary -2.9%, and regex-logs +2.1%. regex-logs peaks at 6.8 MB and
never reaches the switch point; with one binary the policy measures -0.9%
there, so its delta comes from code layout.
2026-10-10 14:03:22 -07:00
Chris Tate c67e08a9cf Prepare v0.2.8
- Synchronize published packages and the compiler surface manifest at v0.2.8, and bump the runtime ABI marker to v9.
- Add release notes for worker graph sharing, catchable deep recursion, parallel code generation, runtime performance, and compatibility fixes.
2026-10-10 10:08:39 -05:00
Malte Ubl 8406d3fcd9 Publish strings as leaves and record their ASCII fact first
A published string can never record facts in its header later, so
publication proves ASCII before marking (every UTF-16 index mapper
answers ASCII strings by identity) and primes the Map key hash only for
short strings: hashing every source text cost more than the rest of the
walk, and the map code already skips caching into immortal strings.
Strings are marked when pushed instead of going through the worklist.
SCRIPTC_PUBLISH_TRACE reports object counts and walk/commit times.
2026-10-10 01:00:36 -05:00
Malte Ubl bf508eb70f Publish typed object graphs to workers by reference (@scriptc/threads)
The new @scriptc/threads package exports publish(value) and
sharesPublishedGraphs. Node.js runs its JavaScript: publish validates
the whole graph (plain objects, arrays, class instances, Maps, Sets and
primitives other than symbols), then deep-freezes it, and published Maps
and Sets reject mutation. scriptc builds lower both exports to
intrinsics and never compile the package body.

In scriptc, publish makes the graph immutable and immortal: a
compiler-emitted walker per static type (llvm/publish.ts) marks every
object rc == SIZE_MAX through a runtime worklist (scr_publish.c), so
retains, releases and the collector skip it and nothing frees it, and
the objects leave the collector's buffers. Hierarchy members dispatch on
the concrete class; arrays, maps, sets, strings and dyn subgraphs have
runtime walkers. Static types over-approximate a graph, so an
unpublishable type gets a refusing walker that fails only when a value
is present, and every mark is logged so a refusal restores the whole
graph, exactly like the Node implementation's validate-first order.

A typed-ref capsule over a published object travels through postMessage
and workerData as a pointer (a fresh capsule in the receiver; vtables,
adapters and type keys are process-wide); immortal dyn graphs inside a
message are shared the same way. Iterating a published map no longer
writes its iteration depth.

Stores into the static types a program publishes (computed by
backend/publication.ts, a class with its whole hierarchy) carry a guard
that throws Node's frozen-object TypeErrors, and may-throw counts them.
The corpus program pins the contract against Node: shared and cyclic
class graphs with arrays, maps, sets and records read identically on
both threads, every write form fails with Node's message, unpublished
instances of the same classes stay writable, a refused publish leaves
its graph unchanged, and a worker-published graph reaches the main
thread. packages/threads/README.md documents the contract and the
differences (prototypes and identity across messages).

Each mark records its object kind, so a successful publish takes the
objects out of the RC audit's live counts (immortal objects are never
freed).

Dates (time values) and process streams (descriptors) are scalars in
scriptc, so their walkers refuse a date field or root directly instead
of pushing its bits as a pointer; the refusal corpus program pins the
statically typed refusals (date, function, RegExp, typed array and Error
values in class and record fields, and a date root) against Node.

Main lowers a discarded or union-typed `xs.pop()` to a length
truncation; those stores now carry a `pop` marker so a published array
fails with pop's own TypeError ("Cannot delete property 'N'", or the
length message for an empty array, whose `length = 0` store the emitter
drops unless the array type can be published).
2026-10-10 01:00:36 -05:00