31 Commits
Author SHA1 Message Date
Malte UblandClaude Opus 5.5 4aa5d6cbf8 Give dense arrays head room so shift, splice(0, n) and unshift runs cost amortized O(1)
shift() moved every remaining element down one slot, making queue drains
quadratic (100k elements: 0.85 s; 1M: no result in 30 s). Dense arrays of
8-byte slots with their own allocation now remove front elements by
advancing data/present and lowering cap; the vacated slots stay in the
allocation as head room. A flag in `spare` marks it and its size is
implied by the layout (present == data + cap + head), so every reader,
including inline element access in emitted code, is unchanged. Growth
moves the elements back to the start of the allocation (in place only
when that leaves half the moved count free, else geometrically), a
drained array starts over at the front, unshift reuses head room, and an
array unshift already had to move once reserves head room for half its
length. scr_arr_outline drops head room before compiled storage swaps.
Booleans, inline slots, sparse arrays and static arrays keep the old
behavior.

Scaling probes: shift/BFS/splice(0,1)/deque drains and unshift runs go
from exponent ~2.0 to ~1.0 (shift 1e5 847 ms -> 1.6 ms); runtime bench
geomean 1.0045 (neutral).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:13:53 -07:00
Chris Tate ab0d4f0cd3 Give small exact arrays room for one append and keep growth on constant slot sizes
Exact constructions (literals, slice, concat results) round their request
to 16 bytes before the size class, so arrays of 2, 4 and 6 slots get one
spare slot and an append right after a copy no longer reallocates. Arrays
of 8-byte slots with their own storage and no sparse entries grow below
the dense cutoff on a path with a constant slot size, and dense copies of
8-byte slots use a constant size.
2026-10-15 20:14:59 -05:00
Chris Tate d2a84e4a41 Write typed JSON.stringify arrays from their slots and append fixed text inline
The emitted JSON writers read each array element through scr_arr_state
and scr_arr_get_ref, retaining and releasing it, and appended every
bracket, comma and key through a runtime call. Arrays whose indices below
the length all hold values now serialize from their slots
(scr_arr_dense_slots) with borrowed elements; other arrays keep the
element walk. Fixed text is stored inline when it fits the buffer's spare
capacity, booleans write their literal inline, and numbers are formatted
directly into the buffer.
2026-10-14 18:05:50 -05:00
Chris Tate 23124ccdb0 Lower typed-array sort and toSorted 2026-10-14 11:15:37 -05:00
Chris Tate 6fe81f0ce6 Sort by key for recognized numeric comparators 2026-10-14 06:48:48 -05:00
Chris Tate e932714bfa Shrink the cycle-collector object header to 8 bytes 2026-10-14 04:53:07 -05:00
Chris Tate ea6428bad6 Keep the first elements of small appended arrays in the array's block 2026-10-13 23:36:22 -05:00
Chris Tate 947f665896 Store boolean arrays as one byte per element 2026-10-13 18:45:47 -05:00
Chris Tate 6790082742 Keep hole-free prefix and int32 parameter upkeep off hot paths 2026-10-13 09:11:57 -05:00
Chris Tate 5df589706b Skip array state bytes below a hole-free prefix 2026-10-13 02:13:46 -05:00
Chris Tate b10c0af911 Sort dense arrays without the comparator sort snapshot 2026-10-12 18:01:14 -05:00
Chris Tate 72cc828ab0 Merge main into perf/compact-array-header-2 2026-10-12 01:12:21 -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
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 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 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 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 de2100515a Splice in place when the removed elements are discarded
A statement-position `a.splice(start, deleteCount, ...items)` built a
one-element (or n-element) items array, then scr_arr_splice_insert built
the removed-elements array that the caller released straight away: three
or more allocations per sorted insert in tsc-ts's insertType. When the
result is discarded and the items are plain evaluated values, the emitter
now evaluates receiver, start, count, and items in order and calls
scr_arr_splice_drop with the owned value slots. It applies the same
clamping and state shifts on dense receivers, releases removed values
only after the receiver is consistent again, and keeps the general
implementation for sparse receivers and sparse-sized results.
2026-10-09 10:10:23 -07:00
Malte Ubl ef915f4639 Stop coloring cycle candidates on retain and release
Retains stored BLACK into the cycle header so that markRoots could skip
candidates retained again after they were buffered, and every surviving
release stored PURPLE to undo that. On the tsc-ts self-check the filter
dropped almost no candidates (3.01M walked either way), while the stores
cost a header write on every retain and release of a headered object and
an instruction sequence at every inlined site. Colors now belong to the
collector alone: markRoots takes every buffered entry, scr_cyc_mark_live
and the emitted mark-live and purple stores are gone, and the release
fast path only reads `buffered`.

tsc-ts self-check (median of 7): 3.15s -> 3.06s, max RSS 614 -> 600 MB,
binary -727 KB.

Rebased onto main's three-generation collector, the series measured
with and without this commit (tsc-ts self-check, median of 7):
2.779 s -> 2.706 s, binary -660 KB, max RSS +13 MB.
2026-10-09 11:59:26 -05:00
Malte Ubl bf10875927 Sort comparator snapshots in the runtime without per-move reference counting
After the union fix, records-sort spent most of its time in the IR merge
sort's element moves: every src[i] read retained the swapped `src` array
and the element (scr_arr_get_ref), every dst[k] write released the old
slot, and `dst` grew from an empty array.

scr_arr_sort_values runs the IR helper's exact algorithm (same comparator
call order) over borrowed slot copies of the private, compacted snapshot:

- comparator calls use the callValue ABI (owned element arguments, an
  undefined `this` window, pending-exception check);
- the array keeps owning every element and is untouched while user code
  runs, so the cycle collector and a throwing comparator see the original
  snapshot; the sorted permutation is published only after success;
- f64 and reference-counted element types qualify (isSortValuesElement);
  unions with an undefined arm and checked/island values keep the IR.

The new internal arrIntrinsic `sortValues` is validated, may throw, and
is not reference-preserving.
2026-10-08 15:31:04 -05:00
Malte Ubl ecd9643eb0 Remove the large-array cliff (1.2M-element fill and update: 10.4 s to 0.03 s) (#743)
* Remove the large-array cliff past 2^20 elements

Arrays longer than 2^20 kept every element beyond the cutoff in a sorted
side store, and each indexed write to an existing entry deleted and
reinserted it, so fill + indexed read/write loops went quadratic
(n=1.2M: 10.4s on macOS arm64, 12.6s on Linux x64).

- Rewrite existing sparse entries in place.
- Let dense storage grow past the cutoff for contiguous extension of a
  populated prefix, bulk range writes, and sparse tails that reach 3/16
  density, migrating side-store entries so index < cap stays dense.
- Make the side store a deque with front slack so descending first-touch
  writes and prefix migration are amortized O(1).
- Bound range-derived slice/splice capacity hints for hole runs.

scr_arr_new now reserves the live count, so a 2^20+3 element Set
converts into dense storage instead of a sparse tail; the white-box
test_lib check that pinned the old policy now asserts dense storage.

* Share immortal boolean union boxes

Maybe-undefined reads of boolean[] elements (`if (composite[i])` in a
sieve) box the element in a boolean|undefined union and free it at once:
one calloc/free pair per read, about a quarter of numeric-kernels samples
at large scales. Unions are immutable and boolean arms own no payload, so
low tags now return headerless immortal instances, the same convention as
the compiler's static unit-arm constants.

* Record preflight/order baselines for corpus 3138
2026-10-08 15:01:21 -05:00
Chris Tate 4ec55cdf64 Accelerate default primitive array ordering (#721)
- Sort primitive arrays with stable native ordering and pure conversion keys.
- Preserve sparse semantics and argument effects while avoiding callback overhead.
- Verify Unicode ordering and ownership across native and WASI paths.
2026-10-07 08:15:08 -05:00
Chris Tate f22013f177 Improve array storage and bulk transformations
- Share dense array storage and move owned ranges without rebuilding containers.
- Batch array construction and insertion while preserving evaluation order and bounded temporary storage.
- Lower indexed mutations natively and preserve typed-array reversal across numeric element types.
2026-10-06 01:05:40 -05:00
Chris Tate addc2418ab Streamline native string construction
- Combine bounded string concatenations while preserving evaluation and ownership.
- Build array joins and raw strings directly in their final storage.
- Support literal null and undefined string conversions with preserved effects.
2026-10-05 07:32:26 -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
Chris Tate 668eb78124 Optimize native array loops and preserve live tuple iteration
- Reduce allocation and reference-management overhead in native array loops.
- Enable vectorization while preserving numeric behavior.
- Preserve live tuple iteration and cover reference lifetimes and missing values.
2026-10-04 10:38:10 -05:00
Chris Tate d2195d8b6e Optimize terminal startup and memory use (#464)
- Reduce allocation overhead in array comparisons and string operations.
- Reuse fiber stacks and release cached memory while idle.
- Read terminal dimensions directly from native streams.
2026-09-26 10:02:56 -05:00
Chris Tate 6242053fc9 perf: fuse numeric array reads into one runtime lookup (#399) 2026-09-24 00:03:04 -05:00
Chris Tate 94fd361b54 Implement sparse native arrays and missing-value parity (#297)
Represent ordinary JavaScript arrays with hole, value, and explicit-undefined states; add sparse side storage and carry missing values through the frontend, native backends, runtime, and Node-facing consumers.
2026-09-12 22:39:07 -05:00
Chris Tate b3d3101550 Add static array unshift and reverse (#107)
- Lower unshift and reverse through the C and LLVM backends.
- Preserve spread, mutation, evaluation-order, and reference-counting semantics.
- Add runtime, differential, and surface-manifest coverage.
2026-08-09 14:25:53 -05:00
Chris Tate 23d5918a53 init 2026-07-22 18:08:04 -05:00