Commit Graph
3145 Commits
Author SHA1 Message Date
Malte Ubl 0207eb658c Record preflight/order baselines for corpus 4060, 4061 2026-10-08 15:34:37 -05:00
Malte Ubl 7a5bcb2dab Keep projected-parameter lookup inside the self-hosted subset
The bootstrap-seed build compiles the compiler with scriptc, which rejected
`for (const index of parameters.get(fn.name) ?? [])` in the emitter: the
`Set<number> | never[]` union has no static representation (SC2011). Read
the set once and iterate it only when present. Behavior is unchanged.
2026-10-08 15:34:37 -05:00
Malte Ubl 363a9360fd Keep copied nullable field reads and union binding copies in stack storage
A whole-value read of a nullable-pointer field allocated a heap box per
read, so list walks (`p = p.next`) regressed: 0.07 s -> 0.35 s on a 100k
list walked 200 times. Such reads now retain the instance pointer into
the current frame and wrap it in a private stack box with that
independent owner:

- local union storage accepts nullable field reads and copies of another
  binding of the same union as sources (the payload is retained; scalar
  and unit arms own nothing; retains branch on the tag because not every
  retain entry point is NULL-tolerant);
- arguments to projection-only parameters of calls and constructors.

The list walk now takes 0.05 s (baseline 0.07 s). Corpus 4061 adds a copy
that outlives a store to the field and copies across string, number,
-0 and undefined arms.
2026-10-08 15:34:37 -05:00
Malte Ubl bdd58b4705 Store nullable class fields as instance pointers and borrow constructor bodies
A class field typed `C | null` or `C | undefined` (one unit arm plus one
emitted class arm) now holds the instance pointer itself: NULL is the unit
arm. The struct slot stays `ptr`, so layouts are unchanged.

- Teardown, trace and gcFree use the slot's storage type (the class arm);
  every class retain/release/visit entry point is NULL-tolerant.
- Projections of a field read (tag tests, narrowing, truthiness, unionDisc,
  nullish, borrowed equality operands) use a private stack box over the
  borrowed pointer; whole-value reads build an owned heap box (or the
  immortal unit instance); generic borrowed receivers get that owned box.
- Stores take the payload of a wrap directly, or the retained payload of
  a borrowed union; a store of a union binding is a projection use, so
  constructor parameters stored this way are projection-only.
- `new` calls the borrowed constructor body when the lifetime analysis
  allows it, so projection-only arguments become private stack boxes.
- The dyn bridges (materialize, commit, symbol fields) convert between
  the pointer and a temporary union.

alloc-trees no longer allocates a union box per tree edge. Corpus 4061
pins tests, narrowing, nullish, optional chaining, equality with
mutation, escapes, reassignment, subclass instances, unionDisc, `any`
reads/writes, JSON, console output, cycles, and a 300k chain teardown.

macOS arm64 (H23 runner, --layouts=4 --runs=16, vs perf/harness):
alloc-trees -45.5%, records-sort -72.8%, numeric-kernels -29.4%,
ast-interp -21.3%, word-graph -12.4%; template-render +1.9% and
functional-pipeline +0.8% (both within 2%); geomean -17.6%.
2026-10-08 15:34:37 -05:00
Malte Ubl f75c3d4138 Borrow projection-only union parameters in nullish and equality
Accepting nullish and unionEq as projection uses also proves parameters
projection-only, so callers pass private stack boxes to them. The narrowed
nullish shape now always borrows a binding (no code runs between the read
and the payload extraction), and unionEq routes proven parameters through
the borrowed receiver path, so a stack box never reaches union RC entry
points. Found by the Linux corpus (free(): invalid pointer in 1535 and
others); pinned by a stack-unions unit test and corpus 4060.
2026-10-08 15:34:37 -05:00
Malte Ubl 72a9572271 Keep projected optional reads and wrapped literals in stack union boxes
Union values consumed only by projections no longer allocate heap boxes:

- closure bodies get local lifetime facts (parameters stay unproven), so
  for-of bindings and optional reads inside comparators use stack boxes;
- unionEq and the narrowed nullish shape are projection uses; their
  wrapped-literal, optional array read, and Map.get operands are emitted
  as private stack boxes;
- optional array reads consumed by tests/narrowing/comparisons use a
  stack box with the inline dense lookup; f64/bool elements are supported
  (slot bits copied, runtime state check kept for holes and sparse slots);
- scalar union payload reads are inline loads so LLVM can scalarize the
  stack boxes.

Corpus 4060 pins holes, sparse/noncanonical indices, -0/NaN/false,
reference identity, closure locals, and mutation inside ?? defaults.
2026-10-08 15:34:37 -05:00
Malte Ubl 0d0a94b86d Record preflight/order baselines for corpus array-sort-runtime-comparator, closure-union-locals 2026-10-08 15:31:04 -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 5db08546d3 Decide equal-length short string comparisons inline
Equal names are common in records-sort, and equal strings at different
addresses still reached scr_str_cmp. When the first words match and both
strings have the same length of at most 16 bytes, the word ending at that
length covers the remaining bytes: equal words answer 0, and a difference
is ordered by its first byte (ASCII-only for the UTF-16 order).
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 c5bd41b3c5 Update lifetime tests for closure-body locals and borrowing union equality
The cleanup fixture runs as a closure body; its literal binding is now a
stable immortal value without cleanup, so the test uses an owned value.
Whole-union string equality is a borrowing use, so its caller passes a
stack box instead of a heap union.
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
Malte Ubl 9d9dd52652 Keep closure-body union locals and literal equality operands on the stack
The records-sort comparator spent ~70% of its samples allocating and
freeing union boxes: `for (const key of keys)` binds `key` as
`string | undefined` (array holes), and every `key === "department"`
heap-wrapped the literal as well.

- call-lifetimes: strict union equality borrows a local operand, like
  unionIsTag/unionNarrow; closure bodies get local facts (their captured
  cells are boxed and already rejected), parameter facts stay limited to
  environment-free synchronous bodies.
- unionEq emission: a freshly wrapped operand gets a stack box.
2026-10-08 15:31:04 -05:00
Malte Ubl 7c7d80dae5 Emit serialized IR with the compile pipeline's object audit option
The serialized-IR emission test compared a non-sanitized compile, which
now passes objectAudit: false, against direct emission with the default
objectAudit: true. The extra scr_obj_alloc_note/scr_obj_free_note calls
were an option mismatch, not an IR round-trip loss. Emit from the artifact
with the option compile derives from --sanitize, and check both lanes.
2026-10-08 15:29:36 -05:00
Malte Ubl 2a38fb1981 Omit frame pointers in Linux release runtime units
Zig cc keeps frame pointers by default, so every packaged runtime function
on Linux paid push/mov/pop of %rbp, including tiny hot leaves such as
scr_exc_pending. Linux release flavors now pass -fomit-frame-pointer
(unwinding still uses .eh_frame; dev flavors, Darwin, and the sanitized
lane are unchanged).

On top of the runtime-unit alignment from the link-order change. The
separate NDEBUG change (34fbc5a4 on perf/h24-release-hygiene) is not part
of this stack: zig cc already defines NDEBUG at -O2 on Linux, and it
measured neutral on macOS.
2026-10-08 15:29:36 -05:00
Malte Ubl 18a2eddf80 Emit live-object audit notes only for --sanitize builds
scr_obj_alloc_note/scr_obj_free_note exist for the SCR_RC_AUDIT live-object
audit and are empty in release and dev runtime packs, yet every emitted
record/class new and free helper called them. The LLVM emitter takes an
objectAudit option (default on for direct emission); the production
executable and library pipelines pass it only for --sanitize builds. Runtime
TUs compile their own call sites away outside SCR_RC_AUDIT while scr_object.c
keeps the no-op symbols for objects that still reference them.
2026-10-08 15:29:36 -05:00
Malte Ubl 6902bf3cb3 Record preflight/order baselines for corpus 3150, 3151 2026-10-08 15:28:05 -05:00
Malte Ubl 7ac8f23019 Integer remainder fast path for unproven operands and inline array push
% now checks at run time whether both operands are integers (dividend
|x| <= 2^53, nonzero integer divisor) when the integer-range analysis
cannot prove it, and then uses i64 srem with JS sign-of-zero semantics;
fractions, NaN, Infinity, zero divisors and huge dividends keep frem.
Proven integers whose divisor may be zero now only check for zero.

push() appends inline while len < cap (the runtime append is exactly a
dense store at len plus len+1 there) and keeps a cold runtime call that
grows storage otherwise. Element writes were already inlined by H04.

Built on the H04 commits cherry-picked from perf/h04-array-reads
(d88b58a1, a6057a52, 9109cb5a, 657aa3fb). New corpus programs
3150-integer-remainder and 3151-array-push-writes.

Marginal vs perf/h04-array-reads, 15 runs:
| workload            | macOS   | Linux (focused) |
| numeric-kernels     | -18.3%  | -10.3%          |
| functional-pipeline |  -8.8%  |  -4.3%          |
| template-render     |  -2.9%  |  (layout, sweep -0.6%) |
| alloc-trees         |  -2.0%  |  (layout, sweep +0.3%) |
| records-sort        |  -1.5%  |  -0.3%          |
| csv-numbers         |  -0.3%  |  +0.1%          |
geomean marginal: macOS -2.8%, Linux full -0.6%.
vs perf/harness (incl. H04): macOS -14.4% (1.222x Node),
Linux -16.0% (1.160x Node). Linux .text growth <= 3.6%.
Tests: LLVM differential corpus 2008/2008 (Linux, 6 shards, release+dev),
sanitized 3140/3150/3151 pass, backend+ir unit tests pass except one
load-flaky zig COFF cache test that passes in isolation.
2026-10-08 15:28:05 -05:00
Malte Ubl d51bbc2137 Record preflight/order baselines for corpus 4290, 4291, 4292, 4293 2026-10-08 15:26:31 -05:00
Malte Ubl 5b30a0da49 Keep cycle analysis inside the self-hosted subset
The self-hosted layout stage (tests/harness/self-hosting-emission) compiles
cycle-analysis.ts with scriptc itself. The Tarjan work stack held live
Iterator objects and the constructor-escape check read `captures` through
an untyped cast; neither compiles natively. The work frames now walk a
snapshot of each node's edges by index, and the escape check reads
`captures` from the two expression kinds that carry it (closure and
classRef). Behavior is unchanged.
2026-10-08 15:26:31 -05:00
Malte Ubl e520c9f3b6 Buffer only union boxes whose trace can reach a cycle
scr_union_release buffered every union box left alive by a release as a
cycle-collector candidate, including boxes whose arm_trace is NULL (scalar
arms, and ref arms whose type the compiler proved acyclic, such as
TreeNode | null once TreeNode is constructor-only). Such a box's trace visits
nothing, so it can never be on a collectable cycle: skip the candidate
buffer for it. Deaths check the header's buffered bit inline before calling
scr_cyc_on_dead, because typed-ref cache commits swap arm contents between
boxes of one union type, so a box can have been buffered under an earlier
arm.

test_closure.c checks that string- and f64-armed boxes are never buffered,
that closure-armed boxes are, and that a closure -> box -> union -> closure
cycle is still collected under the RC audit.
2026-10-08 15:26:31 -05:00
Malte Ubl d5e42b049e Skip cycle collection for shapes whose self references are constructor-only
A reference edge that can only be stored while its owner is still under
construction points to an object that existed before the owner, so a heap
cycle made only of such edges is impossible. computeTraced now builds the
shape/union graph with per-edge mutability, finds strongly connected
components, and marks a component cycle-capable only if it holds an
intrinsic reference (closure, dyn, class value, caught, promise) or a
mutable internal edge; units that reach such a component stay traced. With
every edge mutable this is exactly the previous fixpoint.

Mutability comes from the final IR (cycle-mutability.ts), not from the TS
readonly modifier: every fieldSet outside a constructor's own this,
fieldIncDec, recordSet, recordKeySet and recordKeyDelete counts; a
constructor where this escapes (anything but a field store target, a field
read, or the receiver of the direct base constructor call, including
closure/class captures) makes its hierarchy mutable, as does any other call
of a constructor function; every type that can cross into checked-dynamic
code (dynFrom operands including boxed closure signatures, thrown values,
library/intrinsic/JSON/FFI arguments) is mutable with its whole type
closure, because typed-ref capsules commit dynamic writes into native
fields; runtime-rooted hierarchies stay mutable; library builds and
dynamic-island programs disable the refinement. Union arms are immutable
(boxes are), collection edges are always mutable.

New corpus programs pin the acyclic case (DAG sharing, a 200k-node chain,
derived constructors, records) and try to defeat the proof (constructor
escapes, readonly bypass through casts/mapped types/any/Reflect/aliases,
subclass rewrites, readonly arrays, untyped writes through capsules).
2026-10-08 15:26:31 -05:00
Malte Ubl 6d44486def Record preflight/order baselines for corpus fiber-switch-stress, fiberless-async-ordering, fiberless-async-unhandled 2026-10-08 15:22:27 -05:00
Malte Ubl 2d5aa4231a Give the fiberless park propagation a single static type
`mentionedBy.get(...) ?? []` mixes a Set<string> with an empty array
literal. scriptc compiles its own compiler, and that union has no static
representation, so the bootstrap seed build failed with SC2011 on
emitter.ts. Skip a callee without mentioners explicitly instead.

Verified with scripts/build-native-cli.mts, the build the bootstrap seed
job runs: it fails with the CI's SC2011 before this change and produces
the native CLI after it.
2026-10-08 15:22:27 -05:00
Malte Ubl 2539fd9ac3 Keep fiberless async calls out of worker programs
Upstream native workers (095b6193) start each worker thread with a NULL
scr_als_active (SCR_ALS_SLOT() supplies the main slot) and give the fiber
epilogue its own context-termination path. The fiberless frame now goes
through SCR_ALS_SLOT(), and worker programs keep the fiber path, so
termination and AsyncLocalStorage behave exactly as upstream defines them.
Ordinary executables are unchanged.
2026-10-08 15:22:27 -05:00
Malte Ubl 363fb69f07 Run await-free async functions without a fiber
An async function whose body can never park its execution context
completes before its call returns, exactly like a fiber that never
suspends. The LLVM emitter now proves that from the emitted IR: a body
qualifies when neither its own text nor that of any module function it
mentions (transitively) contains a suspension entry point (scr_await_*,
scr_module_await, scr_promise_await_settled, scr_test_sub, generator
yields, async-generator operations, fiber accessors). wasm32, programs
embedding the dynamic engine, module-evaluation bodies, dyn results, and
programs using node:test subtests (t.test awaits on the current fiber
even from sync code) keep fibers. Qualifying spawn wrappers call the body
on the caller's stack between scr_async_inline_enter/leave, which save
and restore what a fresh fiber isolates (the Error.stack frame chain and
the AsyncLocalStorage context), then settle a fresh promise from the
outcome through the same fulfill/reject paths the fiber trampoline uses.
A park or hop while a fiberless frame is on a fiber's stack traps as an
internal error rather than reordering.

In async-pipeline, loadUser and authorize become fiberless: one fiber
per request instead of five. New corpus programs pin microtask ordering,
rejections (handled and unhandled), finally, nested suspending spawns,
closures, methods, Error.stack, AsyncLocalStorage, and a fiber-switch
stress test.

Final benchmarks for the branch (three commits) vs perf/harness, runs=15.

macOS arm64 (local):
| workload | baseline ms | candidate ms | change | 95% CI | verdict | size | node ms | candidate/node |
| --- | ---: | ---: | ---: | --- | --- | ---: | ---: | ---: |
| json-records | 279.2 | 280.7 | +0.5% | -1.8% .. +3.0% | neutral | +0.0% | 230.93 | 1.22x |
| regex-logs | 305.8 | 306.0 | +0.1% | -0.8% .. +2.0% | neutral | +0.0% | 132.42 | 2.31x |
| ast-interp | 235.3 | 235.1 | -0.1% | -0.6% .. +0.4% | neutral | +0.0% | 133.55 | 1.76x |
| records-sort | 621.6 | 619.0 | -0.4% | -1.1% .. +0.6% | neutral | +0.0% | 131.56 | 4.71x |
| template-render | 202.8 | 201.9 | -0.4% | -1.7% .. +2.6% | neutral | +0.0% | 99.28 | 2.03x |
| functional-pipeline | 226.0 | 226.4 | +0.2% | -0.4% .. +0.6% | neutral | +0.0% | 120.24 | 1.88x |
| async-pipeline | 267.0 | 21.6 | -91.9% | -92.2% .. -91.4% | faster | +0.0% | 58.58 | 0.37x |
| word-graph | 417.6 | 566.8 | +35.7% | -49.6% .. +171.5% | neutral | +0.0% | 944.76 | 0.60x |
| alloc-trees | 311.1 | 311.3 | +0.1% | -4.8% .. +2.3% | neutral | +0.0% | 87.37 | 3.56x |
| numeric-kernels | 79.0 | 80.5 | +1.9% | -4.1% .. +8.4% | neutral | +0.0% | 94.05 | 0.86x |
| validate-errors | 169.9 | 169.4 | -0.3% | -12.0% .. +12.3% | neutral | +0.0% | 517.13 | 0.33x |
| csv-numbers | 379.3 | 372.8 | -1.7% | -27.8% .. +12.5% | neutral | +0.0% | 360.69 | 1.03x |
| log-summary | 180.7 | 184.0 | +1.8% | -14.4% .. +9.1% | neutral | +0.0% | 181.51 | 1.01x |
| inventory-report | 231.7 | 239.0 | +3.2% | -11.1% .. +10.9% | neutral | +0.0% | 245.91 | 0.97x |
(word-graph was host noise from concurrent agents; the immediate recheck
measured -2.0%, neutral.)

Linux x86_64 (sandbox):
| workload | baseline ms | candidate ms | change | 95% CI | verdict | size | node ms | candidate/node |
| --- | ---: | ---: | ---: | --- | --- | ---: | ---: | ---: |
| json-records | 1031.8 | 1019.7 | -1.2% | -5.5% .. +3.2% | neutral | +0.0% | 627.39 | 1.63x |
| regex-logs | 534.1 | 533.6 | -0.1% | -5.5% .. +1.9% | neutral | +0.0% | 336.44 | 1.59x |
| ast-interp | 538.1 | 536.8 | -0.2% | -1.7% .. +2.3% | neutral | +0.0% | 324.66 | 1.65x |
| records-sort | 1798.6 | 1749.9 | -2.7% | -4.8% .. +0.5% | neutral | +0.0% | 326.49 | 5.36x |
| template-render | 379.2 | 371.0 | -2.1% | -9.3% .. +1.0% | neutral | +0.0% | 287.51 | 1.29x |
| functional-pipeline | 608.5 | 596.7 | -1.9% | -3.9% .. +3.2% | neutral | +0.0% | 390.16 | 1.53x |
| async-pipeline | 250.3 | 35.9 | -85.7% | -86.2% .. -85.3% | faster | -0.0% | 123.43 | 0.29x |
| word-graph | 462.1 | 461.7 | -0.1% | -3.3% .. +2.4% | neutral | +0.0% | 690.81 | 0.67x |
| alloc-trees | 690.4 | 699.1 | +1.3% | -0.9% .. +4.1% | neutral | +0.0% | 174.67 | 4.00x |
| numeric-kernels | 176.0 | 172.8 | -1.8% | -3.4% .. +0.6% | neutral | +0.0% | 176.9 | 0.98x |
| validate-errors | 304.4 | 301.0 | -1.1% | -4.9% .. +1.6% | neutral | +0.0% | 945.13 | 0.32x |
| csv-numbers | 602.8 | 605.0 | +0.4% | -4.7% .. +1.9% | neutral | +0.0% | 696.66 | 0.87x |
| log-summary | 332.9 | 332.6 | -0.1% | -4.0% .. +1.4% | neutral | +0.0% | 384.79 | 0.86x |
| inventory-report | 601.0 | 583.5 | -2.9% | -6.5% .. +5.6% | neutral | +0.0% | 585.31 | 1.00x |
2026-10-08 15:22:27 -05:00
Malte Ubl 2c94164dd2 Keep the fiber register file inside ScrCtx
scr_ctx_swap now stores the callee-saved registers, stack pointer, and
resume address into the outgoing ScrCtx and loads the incoming one,
instead of pushing a frame onto the fiber's own stack. The register file
lives where ucontext_t kept it: inside the heap ScrFiber for fibers and on
the C stack for a resumer, so a suspended fiber's register-held pointers
stay visible to heap scanners such as LeakSanitizer (fiber stacks are
anonymous mappings they do not scan). Fresh fibers are seeded by writing
the ScrCtx fields; only the top word of the new stack is touched.
Layout asserts pin the struct offsets the assembly uses.

The switch cost is unchanged or slightly lower (async-pipeline scale 10 on
macOS arm64: 0.17-0.19 s vs 0.20-0.21 s).
2026-10-08 15:22:27 -05:00
Malte Ubl 5f12a16f8b Switch fibers with a register-only context swap
On x86_64 and AArch64 POSIX targets, fiber switches now save and restore
only the ABI's callee-saved state (scr_ctx_swap) instead of going through
swapcontext/getcontext/makecontext. swapcontext saves and restores the
process signal mask on every switch: one rt_sigprocmask syscall on glibc,
and sigprocmask + sigaltstack + getrlimit on Darwin, plus a 16 KiB memset
in Darwin's makecontext per spawn. Those dominated await-heavy programs
(async-pipeline: most samples in sigprocmask/__sigaltstack/__getrlimit on
macOS, 0.11 s of 0.23 s in the kernel on Linux). The runtime never uses a
context to change the signal mask. Other POSIX targets keep ucontext;
Windows and WASI are unchanged. The sanitizer fiber annotations wrap the
new switch exactly as they wrapped swapcontext.

The spare fiber-stack pool grows from 4 to 64 entries. With fan-out
(Promise.all over a few batches) finished and new fibers alternate in
waves wider than 4, so most spawns paid an mmap, a munmap, and fresh-page
faults. Spares are still unmapped before the loop sleeps.
2026-10-08 15:22:27 -05:00
Malte Ubl dea883ff52 Link the runtime before the program and align runtime code
Linkers lay out code in input order, and executables linked the program
object first, so any change in program size shifted every runtime
function. The vendored archives build at -Os, which drops function
alignment, so the regex interpreter (lre_exec) landed at a different
offset within its cache line after every unrelated edit and swung
template-render by up to 17% and regex-logs by up to 8% on x86-64.

Executable inputs are now runtime objects, runtime archives, the
program object, its partitions, then FFI libraries. Generated code only
references runtime-object symbols, so single-pass GNU ld still resolves
every archive member; when FFI libraries are present, non-Darwin links
list the runtime archives again after them. The native-link-info input
order follows the same plan.

Vendored archives build with -falign-functions=64 on native targets.
On x86-64 the release runtime units do too: generated loops that call
runtime helpers otherwise swing by up to 13% with the program's
position relative to 16-byte-aligned helpers (numeric-kernels). arm64
keeps its runtime units unaligned, where the size cost is higher and
the effect was not measured.

A no-op 32-byte growth of the program object moved the baseline by
+10.8% (template-render, CI +9.3..+12.9%) and is neutral with this
change on every measured workload (Linux, 15 runs).
2026-10-08 15:22:27 -05:00
Malte Ubl 167cb93440 Record preflight/order baselines for corpus regex-native-matcher, 3139 2026-10-08 15:19:12 -05:00
Malte Ubl b424092446 Walk native regex first-byte analysis with a heap worklist
Security review finding (#747): rn_first recursed into every split target
(split_next/split_goto/loop/loop_split). `visited` stops cycles but not
depth, so a flat runtime pattern with a long run of alternatives, such as
new RegExp("(?:" + "|".repeat(n) + "x)"), chains one split per
alternative and recursed once per alternative while the regex was being
constructed (scr_re_native_build runs on every compile), before any
subject is matched. Deep enough patterns could exhaust the C stack of the
thread compiling them.

The analysis is now iterative: split targets go on a heap worklist of
nops + 1 entries allocated next to `visited`. Every op is processed once,
so each split pushes at most one entry, and the accumulated first-byte set,
empty-match and anchor flags do not depend on the visiting order. If the
worklist cannot be allocated the native build fails and the regex keeps
using libregexp, like the other native-build failures. No other function in
scr_regex_native.h recurses on pattern structure.

Tests: corpus 3139 constructs runtime patterns with 30,000-alternative runs
(empty, consuming, word-bounded, capturing, nested-but-flat, global) and
matches ASCII subjects; it matches Node in the plain and sanitized lanes.
2026-10-08 15:19:12 -05:00
Malte Ubl 84e18fbce7 Run ASCII regex subjects on a native matcher translated from libregexp bytecode
libregexp compiles every non-sticky pattern with a `.*?` search prefix and
interprets its bytecode one opcode at a time, so an unanchored search pays a
backtrack push/pop and ~7 dispatches per subject position, and each `x+`
iteration pushes a backtrack state. Profiles of regex-logs and
template-render showed 65-85% of samples inside lre_exec.

scr_regex_native.h translates the compiled bytecode body (the bytecode stays
the source of truth for parsing, flags, case folding and capture numbering)
into a small program for one-byte subjects:

- single-character opcodes become 128-entry tables computed by evaluating the
  interpreter's own predicate (lre_canonicalize, lre_is_space, range pairs)
  for each ASCII code unit, so /i and /u are exact on these subjects;
- quantifiers over one character become counted loops that backtrack by
  count; greedy loops whose continuation cannot start with a byte of the
  loop's class (or is just saves + match) are possessive;
- runs of literal bytes become one memcmp;
- splits, gotos, saves, capture resets, counted group loops and empty-check
  registers keep libregexp's exact backtracking order and undo semantics;
- unanchored searches start only at bytes that can begin a match (memchr for
  a single byte) and only at 0 for non-multiline `^` patterns.

Lookaround and back references, and all non-ASCII subjects, keep lre_exec.
scr_exec is the single dispatch point, so every match loop is shared. The
program is built with the bytecode and cached on ScrRegex (new `native` slot;
the emitter's %ScrRegex type and literal initializer gain one null pointer).

Tests: packages/runtime/test/test_regex_native.c fuzzes the matcher against
lre_exec (random patterns over the subset and its bail-outs, every start
index, all capture slots; ASan+UBSan), wired into regex.test.ts; a sweep of
8 seeds x 100k patterns (~575k translated, ~70.7M checks) found no mismatch.
tests/corpus/regex-native-matcher.ts pins each construct through the public
APIs on ASCII subjects and non-ASCII twins.

Squashes 1d210a03: build the fuzz test with -D_GNU_SOURCE on Linux; the
vendored cutils.h needs CLOCK_MONOTONIC, which strict -std=c11 hides on glibc.
2026-10-08 15:19:12 -05:00
Malte Ubl 3c54f2ff60 Record preflight/order baselines for corpus 3140 2026-10-08 15:06:48 -05:00
Malte Ubl 605618eae1 Fuse Number(strings[i]) with the element read
Number() of an optional string element read no longer boxes string | undefined
into a dynamic value: plain-operand reads test the slot state and convert the
element with num.fromString, answering NaN for missing slots.
2026-10-08 15:06:48 -05:00
Malte Ubl 9ec40c8b53 Unit-test the dense array access emitter 2026-10-08 15:06:48 -05:00
Malte Ubl 312928b100 Hoistable array guards and fused truthiness of scalar element reads
Read-only fallbacks (get_f64/bool/number, state, has, borrow) carry
memory(read, inaccessiblemem: readwrite) at the call site and the guard loads
the storage bases before branching, so LLVM hoists header loads out of
loops. Conditions on number[]/boolean[] element reads no longer box
T | undefined: numbers reuse getNumber (missing is NaN, falsy like undefined),
booleans test the slot state then read the value.
2026-10-08 15:06:48 -05:00
Malte Ubl 8b0fbc03a2 Inline dense array element access fast paths
Strict element reads (scr_arr_get_f64/bool/ref, borrow), scr_arr_get_number,
scr_arr_state/has and element writes (scr_arr_set_f64/bool/ref) now check
inline for a canonical integer index inside dense storage (below cap, and
below len for reads, present-value state for strict reads) and load or
store the slot directly. Every other case calls the unchanged runtime entry
with a cold call-site attribute. Proven integers (exactInteger) skip the
double round-trip. for...of also reads the length inline.
2026-10-08 15:06:48 -05:00
Malte Ubl 198d993fed Record preflight/order baselines for corpus json-typed-parse-direct 2026-10-08 15:05:00 -05:00
Malte Ubl 0c2232deb0 Parse typed JSON straight into native records
`JSON.parse(text) as T` used to build a checked-dynamic ScrDyn tree, copy
it into the native layout with the generated dynCheck builder, and then
dispose the tree. Together that was about 68% of json-records.

For record/array targets with number, boolean and string leaves, the
backend now emits a ScrJsonSchema (field offsets in declaration order,
constructors, releases) and a wrapper. The wrapper calls the new
scr_json_parse_schema, a one-pass parser that writes directly into native
records and arrays. Whenever it declines (syntax errors anywhere, kind
mismatches, missing or duplicate declared keys, excess depth) it releases
its partial value without throwing, and the wrapper reruns the unchanged
scr_json_parse + dynCheck route. Every error message and position stays
byte-identical. The lexer helpers are shared through a quiet mode and a
factored number scanner, so values decode bit-identically.

Tests: new differential corpus program json-typed-parse-direct.ts; a
dyncheck harness case comparing typed and untyped error messages through
the fused path; white-box C tests for accept/decline and leak-freedom in
scr_json.test.c; an LLVM emission test (64-bit and wasm32).

Linux (sandbox x86_64, runs=15):
| workload | baseline ms | candidate ms | change | 95% CI | verdict | size | node ms | candidate/node |
| --- | ---: | ---: | ---: | --- | --- | ---: | ---: | ---: |
| json-records | 713.2 | 249.3 | -65.0% | -65.6% .. -64.1% | faster | +2.2% | 439.04 | 0.57x |
| regex-logs | 350.8 | 353.7 | +0.8% | -0.9% .. +2.3% | neutral | +0.0% | 238.42 | 1.48x |
| ast-interp | 379.1 | 376.8 | -0.6% | -1.8% .. +0.4% | neutral | +0.0% | 227.45 | 1.66x |
| records-sort | 1200.5 | 1207.8 | +0.6% | -2.4% .. +5.7% | neutral | +0.0% | 232.17 | 5.20x |
| template-render | 248.3 | 248.1 | -0.1% | -2.3% .. +1.0% | neutral | +0.0% | 202.16 | 1.23x |
| functional-pipeline | 402.0 | 395.3 | -1.6% | -3.8% .. +0.5% | neutral | +0.0% | 254.57 | 1.55x |
| async-pipeline | 202.4 | 201.7 | -0.3% | -1.4% .. +2.0% | neutral | +0.0% | 90.14 | 2.24x |
| word-graph | 366.9 | 348.2 | -5.1% | -12.9% .. +4.3% | neutral | +0.0% | 576.56 | 0.60x |
| alloc-trees | 513.5 | 525.5 | +2.3% | -2.1% .. +4.0% | neutral | +0.0% | 132.99 | 3.95x |
| numeric-kernels | 121.4 | 122.5 | +0.8% | -1.9% .. +3.6% | neutral | +0.0% | 135.91 | 0.90x |
| validate-errors | 206.0 | 209.2 | +1.5% | -3.6% .. +4.1% | neutral | +0.0% | 703.47 | 0.30x |
| csv-numbers | 450.9 | 448.6 | -0.5% | -2.2% .. +2.0% | neutral | +0.0% | 499.25 | 0.90x |
| log-summary | 253.5 | 252.6 | -0.3% | -1.5% .. +2.0% | neutral | +0.0% | 279.67 | 0.90x |
| inventory-report | 433.7 | 433.2 | -0.1% | -6.0% .. +3.6% | neutral | +0.0% | 413.79 | 1.05x |
geomean change -7.4%; geomean candidate/node 1.321x -> 1.223x

macOS (arm64, runs=15):
| workload | baseline ms | candidate ms | change | 95% CI | verdict | size | node ms | candidate/node |
| --- | ---: | ---: | ---: | --- | --- | ---: | ---: | ---: |
| json-records | 292.3 | 153.0 | -47.7% | -50.1% .. -46.6% | faster | +0.4% | 248.7 | 0.61x |
| (all 13 other workloads neutral; see results/h16-typed-json/local-1.json) |
geomean change -4.8%; geomean candidate/node 1.497x -> 1.425x
2026-10-08 15:05:00 -05:00
Malte Ubl 69e26b417c Define the small-object allocator state as an ordinary global
`ScrSaState scr_sa;` was a tentative definition, which the runtime build
emits as a COMMON symbol. Library localization (abi.localize_runtime)
deliberately keeps COMMON symbols global (the ASan image-registration guard
relies on that), so every runtime-localized library archive exported
scr_sa next to its declared API, failing tests/harness/library-multi.test.ts
M1 ("external definitions equal the declared set exactly").

Initialize it explicitly so it is a regular zero-initialized definition that
localization demotes like every other runtime global. No other global added
by the stack is a tentative definition. The library-multi suite passes
(it fails M1 without this change).
2026-10-08 15:01:26 -05:00
Malte Ubl 78fef71f09 Keep the small-object allocator out of worker executables
Upstream native workers (095b6193) build every runtime unit with
-DSCR_WORKERS and run one runtime instance per thread. The small-object
allocator state is process-global and unsynchronized, so worker builds
fall back to the system allocator, exactly like thread-instanced library
archives. Ordinary executables keep the allocator unchanged.
2026-10-08 15:01:26 -05:00
Malte Ubl 8a9b1948e0 Let the runtime ABI guard skip calls inside static inline header bodies 2026-10-08 15:01:26 -05:00
Malte Ubl 1477a4037d Allocate dynamic object entry and array item buffers from the small-object allocator 2026-10-08 15:01:26 -05:00
Malte Ubl 70dfdbd61e Route strings, arrays and emitted records through the small-object allocator
Move the allocator into scr_alloc.c with inline fast paths in scr_runtime.h;
strings, array headers/slot buffers, and emitted acyclic records/classes
(scr_rt_calloc/scr_rt_free) now use it. Adds scr_alloc.test.{c,ts}.
2026-10-08 15:01:26 -05:00
Malte Ubl d2461b0ef1 Allocate cycle-headered objects from a size-class free-list allocator
WIP step 1: scr_cyc_alloc/scr_cyc_free use a region-backed small-object
allocator (scr_mem_*), bypassed under SCR_RC_AUDIT/ASan.
2026-10-08 15:01:26 -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 93482f22a8 Keep the cycle collector small in static executables (#768) 2026-10-08 13:28:14 -05:00
Chris Tate 66b773fd7f Read optional elements and captured parameters without boxing (#767) 2026-10-08 12:52:53 -05:00
Chris Tate c903daefcf Back off cycle scans for long-lived object graphs (#741) 2026-10-08 11:54:45 -05:00
Chris Tate 4795ddd1dd Initialize import cycles in module evaluation order (#740) 2026-10-08 11:29:07 -05:00