Commit Graph
466 Commits
Author SHA1 Message Date
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
Malte Ubl 3487d17348 Publish deeply frozen plain-data graphs to workers without copying
A postMessage or workerData graph whose objects and arrays are all
frozen, ordinary and hold only primitives, strings and such containers
is now shared by pointer: every node and string becomes immortal
(rc == SIZE_MAX, so retains, releases and the cycle collector skip it
and nothing frees it), strings cache their key hash first, and the
nodes leave the sender's cycle buffers. Posting an already published
root is O(1). Anything else is cloned as before; SCRIPTC_PUBLISH=0
turns publication off and SCRIPTC_PUBLISH_TRACE=1 reports each choice.

With the receiver freezing what it receives (Node's clone is not
frozen) and no identity comparison across messages, the behavior
matches Node's structured clone. Published nodes leave the RC audit's live counts, since they are never
freed, and the small-integer dyn cache becomes process-wide (filled
before main): a published graph can outlive the thread that created its
nodes, and the thread-local table died with its thread.

The new corpus program pins that
contract both ways, including a worker-published graph read after the
worker exited and a shallowly frozen graph that must still be cloned.
2026-10-10 00:57:58 -05:00
Chris Tate 1767fe7fa0 Merge main into perf/workers/01-worker-tax 2026-10-10 00:55:35 -05:00
Chris Tate 34cb02d6d1 Keep the native compiler independent of weak keys
Compiler caches keyed by programs and lowering passes move onto the
objects that own their lifetime, and the field-creation pass recognizes
its own writes by their receiver temporaries. A source test keeps weak
collections out of the compiler. Records and arrays that reach an
untyped slot through a union now keep their identity, and empty object
literals are keyed by their native object, so both work as WeakMap and
WeakSet keys instead of failing at runtime.
2026-10-09 20:26:57 -05:00
Malte Ubl c3c59ccf1c Keep the collector's per-thread state in one thread-local block
The collector's thread-locals in worker executables (disposal queue,
candidate buffers, pass vectors, generation limit and scheduling state)
were twenty separate variables. scr_rc_destroy touched four of them, and
the release and walk paths several more; on Darwin each costs a
_tlv_get_addr call per function. Worker builds now keep them in one
ScrCycThread block (macros keep the sources unchanged); ordinary and
library builds keep separate variables.

tsc-ts self-check, macOS, Worker build vs non-worker build: 1.10x ->
1.08x.
2026-10-09 17:43:51 -07:00
Malte Ubl f6ca589404 Keep the allocation paths' per-thread state in one thread-local block
In worker executables the small-object allocator state, the collector's
live and old-freed counts and the weak dispose hook were four separate
thread-locals, and every allocation or free touched them all. On
Darwin each distinct thread-local costs a _tlv_get_addr call per
function, so a runtime free made four such calls.

They now share one ScrThreadHot block (macros keep the runtime sources
unchanged; ordinary and library builds keep separate globals). The
emitted inline paths address the fields at fixed offsets from a single
llvm.threadlocal.address, which LLVM shares across a function.

tsc-ts self-check, macOS, Worker build vs non-worker build: 1.12x ->
1.10x.
2026-10-09 17:43:51 -07:00
Malte Ubl 03ae137be2 Test a process-wide exception alert in worker executables' polls
Every emitted pending check of a worker executable loaded the
thread-local active exception cell and the thread-local stop signal.
On Darwin each thread-local access is a call (_tlv_get_addr), and LLVM
does not share them across a function's checks: compareTypes made 61
such calls, and the single-threaded tsc-ts self-check spent 19% of its
time in them (a Worker build ran 1.67x slower than the same program
without Worker on macOS).

The checks now load one ordinary global, scr_exc_alert, which is
nonzero while any script thread may have an exception pending in its
active cell or must stop. Zero proves nothing is pending anywhere, so
the check falls through; otherwise the out-of-line scr_exc_pending
answers for the calling thread, observes termination and settles the
thread's share. Each thread holds at most one share: raised by throws,
rethrows, the termination sentinel, fiber switches and payload moves
into the active cell, dropped by take, clear, moves out of the active
cell and the slow path. A share is only changed by its own thread, so a
thread's raise is visible to all of its later checks. worker.terminate()
holds one more reference until the worker's script has ended, so the
worker reaches its slow path and stops. An exception in flight in one
thread costs the others slow-path calls only until it is caught.

The stop signal is runtime-private again; the active cell stays
exported for ordinary executables' inline checks.

tsc-ts self-check, macOS, Worker build vs non-worker build: 1.67x ->
1.18x (check phase 1.65x -> 1.09x).
2026-10-09 17:43:50 -07:00
Malte Ubl 3b17338ab2 Give every worker script thread its own small-object allocator
Worker executables compiled the small-object allocator out and sent
every allocation and free through the system allocator, because
several script threads would race on one set of free lists. Each script
thread now owns an arena: scr_sa is thread-local in worker builds and
describes one of 32 equal slices of a single process-wide reservation,
so the owning arena of any block is address arithmetic. A thread claims
an arena on its first allocation and parks it, free lists and bump
pointers intact, when it exits; the next thread continues from there.
Memory is never unmapped, so blocks a worker leaves behind (published
immortal objects) stay valid after it exits. A block freed by another
thread goes to its owner's lock-free remote list, drained on the owner's
slow path; realloc moves foreign blocks into the caller's arena.

The emitted constructor and release fast paths are inlined for worker
programs too, addressing the thread-local allocator state, live count
and dispose hook through llvm.threadlocal.address.

The allocator unit test gains a worker build that hands blocks across
threads and reclaims a parked arena, also under ThreadSanitizer.
2026-10-09 17:43:49 -07:00
Chris Tate 6d8eeb27be Keep the engine stack budget at half the fiber stack
Embedded engine calls entered from a fiber get a stack budget documented
as half the fiber stack, leaving the other half as margin. The budget
stayed at 128 KiB when fibers grew to 512 KiB, so it is now 256 KiB,
which restores that ratio.
2026-10-09 17:54:40 -05:00
Chris Tate bee2981308 Keep the stack guard out of localized library archives
The stack guard was a tentative definition, which some targets emit as a
common symbol. Localized library archives deliberately leave common
symbols shared, so on macOS the archive defined scr_stack_guard alongside
its declared exports. An explicit initializer makes it an ordinary
definition that localization hides like the rest of the runtime.
2026-10-09 17:40:54 -05:00
Chris Tate d1e7f9088c Give async bodies and generators 512 KiB stacks
Async functions and generators run on their own fiber stacks. The new
stack-overflow guard keeps a quarter of each stack as a reserve for
unwinding and error reporting, which cut the depth they could recurse to
from 256 KiB to 192 KiB of frames. Moderately deep recursion inside an
async body or generator then threw RangeError where Node runs it, for
example 3000 frames of an unoptimized build on arm64 macOS.

Fibers now get 512 KiB, which leaves 384 KiB after the reserve. Pages are
still committed lazily, so idle and shallow fibers cost address space, not
memory. The corpus pins recursion inside async functions and generators
next to deeper recursion on the main stack.
2026-10-09 17:33:59 -05:00
Chris Tate 3bcf761f42 Throw a catchable RangeError on deep recursion
Native executables used to terminate with a signal when recursion
exhausted the stack. Functions that can take part in unbounded recursion
now compare the stack pointer against a per-stack guard on entry and
throw Node's `RangeError: Maximum call stack size exceeded` through the
ordinary exception path, so catch handlers, finally blocks, worker error
events and the uncaught-error exit code behave as they do under Node.

- The checked set is every function on a cycle of the call graph, where
  operations that can reenter compiled code indirectly (indirect calls,
  callbacks, dynamic and library operations) reach closure targets and
  class members. Non-recursive functions, including leaf callbacks, stay
  check-free, and the may-throw analysis accounts for the new throw.
- The runtime installs the guard per context (main thread from the stack
  rlimit, workers from their thread attributes) and per async fiber, with
  a reserve for the throw, unwinding and error reporting. Worker threads
  now get an explicit 8 MiB stack.
- Scalar call results pass through the unwind path as its dummy value, so
  calls in tail position keep tail-call and recursion elimination.
2026-10-09 16:55:35 -05:00
Malte Ubl 031adc005a Move the old value into string self-concatenation
`s += x` (canonically `s = s + x`) on a string binding took a retained
snapshot of `s`, evaluated the suffix, released the binding's value so the
snapshot became unique, concatenated (appending in place), and released
the snapshot: a retain and two releases around every append. tsc-ts's
typeListKey alone made 3.4M such releases (plus 2.1M on literal temps).

When the binding is an unboxed local that the suffix does not mention,
nothing but this function's own expressions can read or write it, so
reading it after the suffix gives the left side's value. The old value now
moves into scr_str_concat_move / scr_str_concat_mixed_move, which consume
their head (appending in place when it is unique), and the result takes
its place in the binding: one runtime call instead of four. Boxed
bindings, suffixes that read the local, and expression-position updates
keep the general path.

Corpus 4562 covers aliases that must keep their value, literal heads,
number suffixes, throwing suffixes, self-reading suffixes, expression
position, captured bindings, and long ASCII and non-ASCII appends.

tsc-ts self-check (median of 9, interleaved): 1.854 -> 1.804 s (-2.7%),
check 1.471 -> 1.428 s.
2026-10-09 10:10:25 -07:00
Malte Ubl 5a51b9f0ea Inline the buffered checks of runtime and emitted collector hooks
Emitted releases of class and record objects already test `buffered`
before calling scr_cyc_on_release, but the runtime's own release paths
(arrays, maps, boxes, closures, unions, errors, promises, streams) called
it unconditionally, and every death of a headered object called
scr_cyc_on_dead, which returns at once for an object that was never
buffered. On the tsc-ts self-check 13M of 18.5M scr_cyc_on_release calls
found the object already buffered (10.7M from traced arrays,
3.2M from maps).

scr_cyc_released/scr_cyc_dead are the inline halves for runtime code,
and the emitted noinline slow paths test `buffered` before calling
scr_cyc_on_dead (cycleDeadLines). The out-of-line hooks keep their own
checks, so other callers are unaffected.

tsc-ts self-check (median of 7): 1.652 -> 1.643 s (-0.5%); in the profile
scr_cyc_on_release + scr_cyc_on_dead fall from 2.75% to under 0.5% self
time. Binary +16 KB.
2026-10-09 10:10:25 -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 c75cc167dd Append runtime-optional push values in place
`a.push(v)` where v may hold undefined at run time (an unchecked array
read or a hole-observing for-of binding, as in tsc-ts's filterType loop)
built a temporary one-element array, stored v or present undefined into
it, and spread it onto the receiver: an allocation, a growth, a copy and
two releases per append. Such pushes now evaluate every argument first,
then append each present value through the ordinary push (and its inline
dense fast path) and each missing one as a present-undefined slot at the
current length, which is what Node stores. Pure receivers and arguments
are re-read instead of copied into owned temporaries.

Call arguments read from arrays of nullable references now take the
inline dense lookup like other projections (the value is the element
pointer itself, so the path is a few loads), and scr_arr_peek_ref answers
dense in-range indices before the general lookup. compareSymbols'
`declarations[0]!` arguments no longer leave compiled code.
2026-10-09 10:10:22 -07:00
Malte Ubl af2521f688 Copy short strings inline and drop index entries of proven-ASCII strings
String construction copied every span with a libc memcpy call, and
scr_str_new scanned the copied bytes for ASCII in a second pass. Spans of
at most 16 bytes (identifiers, map keys, number text) now move as two
overlapping fixed-size words, and scr_str_new tests those same words for
ASCII, so the fact is free for short strings; longer spans keep the bulk
copy and a chunked scan that stops at the first non-ASCII byte.

A scan that proves a heap string ASCII now also drops its index-cache
entry: a proven-ASCII string never owns one, because every UTF-16 mapper
answers it by identity and re-checks the header bit after extending an
index. Releasing such a string therefore skips the cache purge, and a
large text proven ASCII once (a JSON document's length, a source file)
no longer keeps every later string release walking the cache. Immortal
strings cannot record the bit and keep their entries as before.
2026-10-09 10:10:22 -07:00
Malte Ubl 83ab6ce2a8 Encode ASCII runs eight bytes at a time in Buffer#write
buf.write(string, 'utf16le' | 'latin1' | 'ascii') decoded one UTF-8
code point per step. tsc-ts converts every source file to UTF-16 this
way (toCodeUnits), so the per-character loop showed up in the profile.
Eight-byte ASCII runs whose units all fit the remaining budget are now
widened (utf16le) or copied directly; any other position, and a tail
that would cross the budget, takes the unchanged per-code-point path, so
truncation at a unit or surrogate boundary is exactly as before.
2026-10-09 10:10:22 -07:00
Malte Ubl 302e4da3ba Cache proven ASCII in the string header and read it inline
JavaScript string reads use UTF-16 indices over scriptc's UTF-8 storage,
so charCodeAt, length, slice, substring and indexOf looked every receiver
up in a small global index cache (scr_sidx) on each call, and a fresh
short receiver evicted an entry and rescanned its bytes. In the tsc-ts
self-check that lookup and its mapping were the largest string cost.

Bit 31 of the capacity word now records that a string is proven all
ASCII, where units are bytes. A clear bit means "not known", so the fact
only needs to be exact where it is set: scr_str_new and its users prove
it while copying, concatenations combine their operands' bits, number
text, ASCII slices and repeat inherit it, emitted literals and runtime
statics carry it in their capacity word, and an index-cache scan that
proves ASCII records it. scr_str_hash_forget clears it with the cached
key hash, so every in-place mutation drops it; an in-place append that
keeps the string ASCII sets it again. Capacity stops at 2^31 - 1 bytes,
still above the UTF-8 size of any string V8 can represent.

Proven-ASCII receivers skip the index cache in every UTF-16 mapper. Sites
for length and charCodeAt call small internal helpers (string-reads.ts)
that LLVM inlines: a header load and, for an in-range index, one byte
load; everything else defers to the runtime with unchanged semantics.
For non-ASCII receivers the cache remembers its last hit (validated by
owner and tier), charCodeAt answers sequential scans from the cached
ASCII span before any other work, and appends that keep a string ASCII
no longer walk the cache.
2026-10-09 10:10:21 -07:00
Malte Ubl e23ea24605 Keep number-key map transitions out of line
The number-key insert inlined a second copy of the hashed word insert and every index transition (1.7 KB on arm64). One out-of-line word insert now serves the generic dispatcher and the number path, and entering, growing and leaving the direct index stay out of line, leaving the insert at 0.5 KB.
2026-10-09 10:10:21 -07:00
Malte Ubl 692756fd66 Format numbers straight into string concatenations
`s + n`, multi-part concatenations with number operands, and `s += a + n`
materialized each number as a temporary heap string, and a compound
suffix as a second string, before copying them into the result. A new
runtime entry, scr_str_concat_mixed, takes string and number operands
together and formats numbers directly into the result. Given a head, it
follows scr_str_concat's left-operand rule: appended in place when the
caller's borrow is its only reference, it has room, and it is not also an
operand.

The self-concatenation assignment now flattens its suffix and appends
every operand to the snapshot in one call, so tsc-ts's typeListKey loop
(`key += (i === 0 ? '' : ',') + types[i].id`) appends in place without
allocating per element. Operands still evaluate left to right; converting
a number has no effects, so formatting it at the call keeps JavaScript
order. A corpus program pins Number::toString shapes, evaluation order,
self-referencing suffixes, aliases taken mid-append and Unicode text.
2026-10-09 10:10:21 -07:00
Malte Ubl 3955982b41 Index number-keyed Maps and Sets directly when keys are small integers
Maps and Sets with number keys used as id tables (tsc-ts keeps node and
symbol links in Map<number, Links>) hashed every key as a double. They now
keep a direct index, dense[k] = entry index + 1, while every live key is an
integer in [0, ndense) and the table stays within 8x the live count (64
slots minimum). The entries array, tombstones and iteration order are
unchanged; only the lookup index differs.

-0 lands on slot 0 (SameValueZero), and NaN, fractions, negatives and keys
beyond the table are absent by construction. Inserting a key the table
cannot hold moves the map to the bucket table; whenever that table would
grow, compact integer keys move it back, so maps whose ids start far from
zero switch over once their live count catches up. scr_map_entry_f64 and
layout asserts expose the index to generated code.

A randomized C test compares every operation with an ordered reference
model across linear, direct and hashed indexes, and a corpus program pins
the JavaScript-visible semantics around the transitions.

Only growth moves a map to the direct index: an append that
scr_map_reserve_append would absorb by compacting tombstones (a linear map
with a dead entry, a bucket table at most half live, no iteration active)
keeps the map's compact storage, so churn on a small number-keyed map no
longer allocates a table. While an iteration is active nothing compacts,
so a small map promotes to the direct index (integer keys) or the bucket
table (other numbers) without moving entry indices. The runtime map test's
small-storage script, which assumed bucket promotion and failed its two
live-iteration checks, now runs with integer and fractional keys and
checks both transitions, and the corpus program covers small maps growing
and clearing mid-iteration.
2026-10-09 10:10:20 -07:00
Malte Ubl 8d1969a313 Inline the pending-exception test in helpers and runtime units
main's emitPendingCheck (#785) already tests `scr_exc_active->kind`
inline after every may-throw call in executables. The helper and adapter
bodies the backend generates (dyn check helpers, async/generator
trampolines, inline-async wrappers, FFI callback adapters, island and
stream adapters, IPC message thunks, dynamic-key record stores) still
called `scr_exc_pending()`, an opaque call into the prebuilt runtime,
and so did every runtime C unit. On the tsc-ts self-check that function
was about 4% of self time.

Those sites now share pendingTestLines, which follows emitPendingCheck's
policy: executables load the active cell and compare its i32 kind
inline; libraries and worker programs keep the runtime call (workers
also observe cross-thread termination there). Runtime units outside
thread-instanced libraries and worker executables get the same inline
test through a scr_runtime.h macro; the exported scr_exc_pending
definition stays for those lanes and for emitted slow paths. A static
assert pins the i32 kind at offset 0.

This replays the work item's inline-check commit on top of #785: its own
emitPendingCheck rewrite is dropped in favor of main's.

Adds corpus 4431 pinning exception propagation across fiber switches,
generators, sort/map callbacks, and runtime JSON failures.
2026-10-09 11:59:27 -05: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
Chris Tate cd28e4d6ca Answer undefined for out-of-range typed array reads like Node (#797) 2026-10-09 11:37:16 -05:00
Chris Tate 3e41bcbfd0 Make compile blockers and Node differences actionable (#786) 2026-10-09 10:30:31 -05:00
Chris Tate 96ee89412b Keep exception polls inline in worker programs (#785) 2026-10-09 10:01:27 -05:00
Chris Tate 3a0eda638e Schedule cycle collection by yield and borrow indexed reads (#781) 2026-10-09 07:55:10 -05:00
Chris Tate 2476844e13 Respect runtime optional values and initialization order in more places (#775) 2026-10-09 00:23:28 -05:00
Chris Tate 9131a3498c Release v0.2.7
- Synchronize all published packages and the compiler surface manifest at v0.2.7.
- Record native features, runtime performance improvements, and compatibility fixes since v0.2.6; leave Unreleased empty.
- Verify version and manifest contracts, compatibility output, and cross-target library conformance locally; use CI for full validation.
2026-10-08 21:18:31 -05:00
Chris Tate 8bd17409db Sync runtime and compiler updates into canary
- Merge main's runtime optimizations and CI changes with canary's frontend improvements.
- Preserve adaptive cycle reclamation with inline allocation and deallocation.
- Repair native bootstrap callback narrowing and emitter expectations.
2026-10-08 17:41:20 -05:00
Malte Ubl 62ff176ad7 Mask console argument tags when sizing the line buffer
scr_console_capacity compared the raw tag with SCR_ARG_STR, but string
parts of a concatenation argument carry SCR_ARG_GLUE. A glued string part
therefore reserved only 32 bytes while scr_console_render, which masks
with SCR_ARG_KIND, copied its full length, overflowing the on-stack (or
heap) line buffer for any glued part longer than 32 bytes.

Mask with SCR_ARG_KIND in the capacity loop and in the single-string
fast path, the two remaining kind tests in scr_console.c.

Tests: corpus 4296 logs template literals and `+` chains with glued
string parts of 31..70000 bytes, several long parts per argument, and
multi-byte UTF-8 parts. Before this change the --sanitize build aborted
with an AddressSanitizer stack-buffer-overflow; it now matches Node in
the plain and sanitized lanes.
2026-10-08 15:51:42 -05:00
Malte Ubl 0cd28f7d45 Pass string-concatenation console arguments as parts
console.log(`${method} ${path} ${status}`) used to build the joined string
first (one string per number, scr_str_concat_parts, then releases), and
the console then copied that string into the line it writes. The LLVM
backend now flattens a strConcat argument with stringParts and passes
each part as a ScrLogArg:

- Later parts carry SCR_ARG_GLUE, so no separating space is added.
- A number conversion passes the raw double as SCR_ARG_NUM (String()
  spelling, so -0 prints "0"; a bare number argument keeps inspect's
  "-0").
- A boolean conversion passes the bool.

The runtime renders the parts directly into the line buffer. Parts are
evaluated in source order through emitStringInputs, which applies the
same borrowing rules as concatenation. Calls that use the new tags go to
new scr_console_log_parts / scr_console_error_parts entry points, so a
program object linked against an older runtime pack fails at link time
instead of misprinting.

Tests: corpus 4295 covers -0/NaN/exponent spellings inside templates
next to bare arguments, empty parts, more than 16 parts, evaluation
order, a later part reassigning an earlier string, union and optional
values, and async code. A new co-located IR unit test checks the
emitted tags and entry points.

Linux sandbox, ns per line (median of 9 launches, 200k lines), prev =
stack tip, cand = this commit (with the previous one). All outputs
byte-identical to Node.

  probe      target  node   prev  cand
  p-str      pipe    4022   1364  1149
  p-str      file    3376    849   744
  p-str      null    2360    330   247
  p-args     pipe    6687   1441  1343
  p-args     file    5135    969   821
  p-args     null    3831    389   292
  p-write    pipe    2866   1244  1168
  p-write    file    2329    839   782
  p-write    null    1622    309   275
  p-err      pipe    4656   3253  1073
  p-err      file    3659   2099   721
  p-err      null    2749    591   219
  log-lines  pipe    5007   1476  1314
  log-lines  file    3949    965   877
  log-lines  null    3145    446   378
2026-10-08 15:51:42 -05:00
Malte Ubl c48f059071 Write console and process stream output directly to the descriptor
Every console.log/error/warn line and process.stdout/stderr.write chunk
is still submitted before the call returns, as in Node. On POSIX
executables, each one now goes out as exactly one write(2) on fd 1 or 2.
Before, it went through C stdio, which cost a copy into the stdio
buffer, one stream call per argument, separator, and newline, and a
flush. stderr is unbuffered, so console.error made one write(2) per
piece: 3 writes per line on average in the probe.

- The line renders into a 2 KiB stack buffer (heap above that). A large
  single string goes out with writev and no copy.
- Short writes continue and EINTR retries. Other errors (EAGAIN, EPIPE,
  EBADF) return errno with the rest dropped, which is what the stdio
  flush did. console still ignores them, and SIGPIPE behavior is
  unchanged.
- Runtime-internal bytes still in the C stdout buffer are flushed first
  (__fpending on Linux, the FILE fields on Darwin, an unconditional
  flush elsewhere), so stdout order and merged 2>&1 order are kept.
- Windows, WASI, and library artifacts keep the C stream path, now fed
  one formatted chunk per call.

Tests: corpus 4294 covers line sizes across the stack buffer and the
64 KiB stdio buffer. New console-io harness cases cover stdout and
stderr merged into one pipe and into one file (byte-compared with
Node), and console.log after the stdout reader closes (EPIPE).

Linux sandbox, ns per line (median of 9 launches, 200k lines; scriptc
includes ~1 ms startup). All outputs byte-identical to Node.

  probe      target  node   prev  cand
  p-str      pipe    4040   1323  1239
  p-str      file    3092    849   790
  p-str      null    2193    319   283
  p-args     pipe    6168   1440  1228
  p-args     file    4896    925   802
  p-args     null    3916    389   292
  p-write    pipe    2889   1328  1197
  p-write    file    2267    809   769
  p-write    null    1577    297   265
  p-err      pipe    4672   3253  1132
  p-err      file    3478   2042   717
  p-err      null    2614    562   232
  log-lines  pipe    4958   1490  1356
  log-lines  file    3885    966   877
  log-lines  null    2983    425   368

strace -c (pipe): p-err went from 600,000 to 200,000 write calls. The
other probes stay at one write per line.
2026-10-08 15:51:42 -05:00
Malte Ubl e7eef59ec1 Fast paths for number<->string conversion (Number/parseFloat, toFixed, two-decimal String)
String to number: Number(str), unary +, and parseFloat now convert short
decimal spellings with Clinger's exact fast path (scr_decimal_scan in
scr_number.c): significant digits m <= 2^53 and a decimal exponent within
+-22 (plus the folded range up to 22 + 15) need one correctly rounded
multiply or divide, bit-identical to strtod. ToNumber validates and
accumulates in the same pass; everything else still goes through the copied
span and strtod. Whitespace trimming in Number(), parseInt, and parseFloat
skips the UTF-8 decode for ASCII non-whitespace.

toFixed: the exact-binary-value digit generation moves from scr_lib.c into
scr_number.c (scr_f64_to_fixed). For f <= 22, mantissa * 5^f fits a
64x64->128 multiply, so the spec's n is one shift plus the dropped-half bit
whenever it fits 64 bits. Digits render two at a time instead of through
snprintf. The 16-limb bignum remains the fallback.

Number to string (H44): a non-integral x below 2^46 that is the double
nearest to m/100 prints as m/100 directly instead of running Ryu. Below 2^46
ulp(x) < 0.01, so m/100 is the unique shortest round-tripping decimal. The
correctly rounded division (double)m / 100 == x is the proof. This covers
JSON.stringify, templates, String(), join, and console output.

Tests: Node oracles for every path. number-cases.txt gains money-like values,
the 2^46 cutoff, and one-ulp neighbors. tonumber-cases.txt gains fast-path
boundaries plus 20k seeded short decimals. The new parsefloat-cases.txt pins
scr_parse_float, and the new fixed-cases.txt/test_fixed.c pin toFixed,
including the 2^64 fast-path limit and f up to 100. Fuzz modes cover all four
paths. Existing case lines are unchanged. Corpus 3170 pins the paths
end-to-end against Node.

Linux x64 sandbox, A/B against perf/stack/06-h23-layout-noise,
--layouts=4 --runs=16 (run 3 of 3; geomean -6.0% / -6.7% / -6.3%):

| workload | baseline ms | candidate ms | change | 95% CI | verdict | size | node ms | candidate/node |
| --- | ---: | ---: | ---: | --- | --- | ---: | ---: | ---: |
| json-records | 236.2 | 204.2 | -13.5% | -19.8% .. +2.1% | neutral | +0.0% | 488.62 | 0.42x |
| regex-logs | 176.2 | 168.8 | -4.2% | -10.1% .. +0.3% | neutral | +0.0% | 281.23 | 0.60x |
| ast-interp | 280.0 | 273.1 | -2.5% | -5.6% .. +1.4% | neutral | +2.8% | 273.05 | 1.00x |
| records-sort | 952.4 | 939.4 | -1.4% | -5.0% .. +5.6% | neutral | +0.0% | 289.87 | 3.24x |
| template-render | 78.9 | 77.3 | -2.1% | -6.2% .. +1.2% | neutral | +0.0% | 246.71 | 0.31x |
| functional-pipeline | 338.7 | 348.8 | +3.0% | -6.5% .. +24.0% | neutral | +0.1% | 339.84 | 1.03x |
| async-pipeline | 207.3 | 207.8 | +0.2% | -5.3% .. +3.8% | neutral | +0.0% | 105.57 | 1.97x |
| word-graph | 302.0 | 300.1 | -0.6% | -5.1% .. +12.9% | neutral | +3.0% | 600.48 | 0.50x |
| alloc-trees | 336.3 | 332.4 | -1.1% | -6.2% .. +2.6% | neutral | +0.1% | 144.73 | 2.30x |
| numeric-kernels | 49.7 | 49.8 | +0.1% | -3.4% .. +6.5% | neutral | +4.0% | 143.73 | 0.35x |
| validate-errors | 179.1 | 182.9 | +2.1% | -5.8% .. +9.1% | neutral | +0.0% | 727.78 | 0.25x |
| csv-numbers | 349.0 | 233.8 | -33.0% | -37.5% .. -26.5% | faster | +0.1% | 541.77 | 0.43x |
| log-summary | 171.6 | 135.7 | -20.9% | -23.3% .. -17.7% | faster | +1.8% | 313.2 | 0.43x |
| inventory-report | 411.9 | 384.7 | -6.6% | -18.1% .. +5.5% | neutral | +2.7% | 521.27 | 0.74x |

Size: +64..+136 bytes per executable. The +1.8..+4.0% rows are one 4 KiB
page crossing (two for inventory-report).
2026-10-08 15:49:53 -05:00
Malte Ubl 2d6a239abb Skip buffering untraced union boxes in the inline union release
The speed posture's inline scr_union_release fast path predates the
runtime change that buffers a surviving union box as a cycle candidate
only when its arm_trace can reach a cycle. It still enqueued every
surviving box: sound (an untraced box visits nothing and the runtime death
path reads the header), but it gave back that change's collector savings
under --optimization=speed. The fast path now loads arm_trace and returns
when it is NULL, exactly like scr_union_release; scr_runtime.h pins the
offset.
2026-10-08 15:46:34 -05:00
Malte Ubl 213cf287b9 Emit inline retain/release fast paths under --optimization=speed (H30)
Re-lands perf/h30-inline-rc (00051ca0, 40398773, 07972360) behind the
speed posture. With inline RC on, every mirrored runtime RC family
retains inline and releases inline down to the rc == 1 / new cycle
candidate slow path (shapes.ts INLINE_RC_FAMILIES). Release emission is
unchanged: ShapeHost.rcHelpers is null unless LlvmTargetOptions.inlineRc
is set, and every retain/release (including capture boxes) stays a
runtime call; the %ScrMapRc type is only emitted with inline RC.

- index.ts and native/prepare.ts pass inlineRc for speed executables,
  speed object/asm emission, and speed library profiles.
- scr_runtime.h keeps H30's static asserts for the mirrored header and
  trace-slot offsets (no code change; the rebuilt macOS pack's manifest is
  identical to the baseline's).
- Tests: shapes.test.ts pins release symbols and H30's speed fast paths
  and header offsets; emitter.test.ts pins that release IR is unchanged
  and speed routes releases through the helpers. The differential harness
  gains `// @optimization: speed` (and a SCRIPTC_TEST_OPTIMIZATION=speed
  lane); corpus 4032 pins every mirrored family under speed.

Release executables stay byte-identical to the stack tip (14/14
workloads, macOS).
2026-10-08 15:46:34 -05:00
Malte Ubl 14bd48fd94 Keep the timer slot index and regex constructor cache per worker
Upstream native workers (095b6193) run one runtime instance per thread and
make the timer heap thread-local (SCR_TL). The timer slot index added for
O(1) handle lookups indexes that heap, so it becomes thread-local too;
scr_timers_teardown already frees it during worker context shutdown.

The RegExp constructor cache stores retained templates that live instances
borrow and that are never evicted. Its table is thread-local, and worker
executables leave it empty (constructions compile privately as before),
because a worker context has no teardown point after every borrowing
instance. Ordinary executables are unchanged.
2026-10-08 15:44:26 -05:00
Malte Ubl 1152644a9c Write each console.error line with one write
stderr is unbuffered, so the piecewise stdio calls issued one write(2)
per argument, separator and newline (two syscalls per simple line), and
the line was not atomic against other writers sharing the descriptor.
Render the line first and emit it with a single fwrite, as Node writes
one chunk.
2026-10-08 15:44:26 -05:00
Malte Ubl 260aad64a7 Index timer handles by heap slot
clearTimeout/clearInterval, refresh, ref/unref and hasRef scanned the
whole timer heap for the handle, so arming and clearing many timers was
quadratic (1e5 cancels: 1.0 s vs Node 46 ms). An id-to-slot hash kept
exact by every heap write makes the lookup O(1); heap order is unchanged.
2026-10-08 15:44:26 -05:00
Malte Ubl 536deb5904 Cache regex subject decoding for exec/test and compiled RegExp patterns
exec/test rescanned the whole subject for ASCII (and re-converted a
non-ASCII subject to UTF-16) on every call, so lastIndex-driven exec
loops and sticky tokenizers were quadratic (1e5 matches: 7.9 s vs Node
43 ms). A single-entry cache keeps the last long subject's decoding; it
retains the subject so the address cannot be recycled and the string is
never appended in place. Only exec/test use it (no user code runs while
their input is live).

new RegExp recompiled its pattern on every construction (22x Node). A
bounded never-evicting cache keyed by (flags, source) shares bytecode
through the literal-template mechanism; invalid patterns still throw on
every construction.
2026-10-08 15:44:26 -05:00
Malte Ubl b84f31e65a Send HTTP response heads with their bodies and trim per-request server work
- scr_net: scr_net_sock_writev_native sends ordered slices with one
  sendmsg when the bytes would go out immediately (plain socket, nothing
  buffered); every other state takes the per-slice write_raw path, so
  buffering, transports, and teardown are unchanged.
- scr_http: end(data) before the head, write() before the head, chunk
  framing, and the final chunk now leave as one write (head + body +
  framing), as Node does. A typical response was a head segment plus a
  body segment; it is now one segment and one syscall.
- scr_net: stop reading after a short read (libuv UV_HANDLE_READ_PARTIAL);
  the level-triggered poller reports later bytes or EOF. Removes the
  EAGAIN read per request (0.7 per request on http-api). TLS transports
  keep draining.
- scr_http: Date header formatted once per second; head built in one
  pass without snprintf; request headers parsed with geometric array
  growth and one shared string when the name is already lowercase.

The bytes on the wire are unchanged (the runner digests every response
against Node).
2026-10-08 15:42:37 -05:00
Malte Ubl f7ac13e87f Cache string Map key hashes in the string header
On 64-bit little-endian targets ScrStr's capacity word is split into a
32-bit cap and a 32-bit cached Map key hash (header stays 24 bytes).
Heap strings fill the hash on first lookup; every in-place mutation path
(unique concat, split scratch reuse, regrow, spare reuse) clears it.
Immortal strings are never written: compiler literals and the runtime's
one-character/empty statics carry the hash precomputed (string-key-hash.ts
mirrors scr_key_hash; both sides pin the same vectors).

scr_key_hash seeds by length multiplicatively: XORing the length into the
low bits cancelled against the last byte ("ab" and "abc" collided).

Map string lookups inline the probe into each getter and reject linear
entries by hash; set specializes its probe/overwrite by lookup kind with a
shared append path.

Tests: runtime map.test.ts (pinned vectors, statics, in-place append
invalidation), string-key-hash.test.ts, corpus 3141-map-string-key-hash
(plain + sanitized; mutation-checked against both invalidation paths).
2026-10-08 15:40:53 -05:00
Malte Ubl 439964a693 Inline the small-object allocator into emitted constructors and releases
Emitted record and class constructors called scr_rt_calloc/scr_cyc_alloc
with a constant size, so the size class, the zeroing length and the
collector header setup were recomputed per object behind a call (plus an
empty scr_obj_alloc_note call and a variable-length memset). llvm/alloc.ts
now emits the fast paths inline: pop the class free list (clearing only
the bytes the helper does not store itself) or bump, with the old calls as
the cold slow path. Releases inline the weak-dispose hook check, the live
count, and the free-list push; blocks outside the reservation still go to
scr_rt_free.

The emitted IR does not know which runtime it links against (the sanitized
lane only differs at link time), so the runtime now defines scr_sa in
every build. Where the allocator is compiled out (ASan, SCR_RC_AUDIT,
wasm32, Windows, 32-bit, thread-instanced libraries) it stays all-zero and
the inline paths always take the slow path, which keeps the RC-audit
object notes. ScrSaState's layout is pinned with static asserts. Inline
emission is additionally gated off for 32-bit, WASI, Windows triples and
thread-instanced libraries.

Runtime callers with hot constant-size allocations (union boxes, arrays,
boxes, closures, dyn nodes) use new static inline twins
scr_cyc_alloc_inline/scr_cyc_free_inline; scr_cyc_live is exported.

The ABI guard now recognizes function-pointer extern data
(scr_weak_dispose_hook). New corpus program 4410 pins recycled-block
clearing, size classes, objects above the largest class, cycles and weak
observers.
2026-10-08 15:36:07 -05:00
Malte Ubl 064277f297 Prefetch upcoming reference elements in the comparator sort kernel
On the x86_64 lane 41% of records-sort samples sat on the first load of
each record header (the comparator argument retain): a merge step touches
one element it has not seen before per comparison. Prefetching the
element four positions ahead on the advancing side (and during run
detection) overlaps that miss with the current comparator call: ~4%
faster on Linux (156 vs 162 ms median), neutral on macOS.
2026-10-08 15:31:04 -05:00
Malte Ubl 5ad1b976fd Decide string comparisons inline before calling the runtime
records-sort compares interned literals (`key === "department"`, equal
departments) and names that differ in their first bytes, yet every
comparison was an out-of-line call (and memcmp through a stub).

- sc_str_eq: identity, then byte length, then scr_str_eq.
- sc_str_cmp / sc_str_cmp_u16: identity; then the first differing byte
  of one 8-byte word when both strings are that long, otherwise of the
  first bytes; the UTF-16 order is decided inline only for ASCII bytes.
  Everything else calls the runtime.

The helpers are internal module functions that LLVM inlines; they are
emitted whenever the runtime entry is declared.

The comparator sort kernel also holds one undefined-`this` window for the
whole sort (each emitted call restores the receiver stack it pushes) and
resolves the element retain function once.
2026-10-08 15:31:04 -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