Commit Graph
1008 Commits
Author SHA1 Message Date
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 8fd7e23868 Merge pull request #750 from vercel-labs/perf/rebased/08-h29-immutable-acyclic
Skip cycle collection for construction-only reference fields (alloc-trees -50%)
2026-10-08 15:28:01 -05:00
Malte Ubl 2df14f1550 Update self-hosting cases for construction-only acyclic proofs
Constructor-only references are now proven acyclic (cycle-analysis.ts), so
the self-hosting cases that meant to exercise the collector through such
edges no longer saw a cycle:

- emission: the nullable-union and mutually-recursive-class cases add an
  array edge (always mutable) to keep the traced path, and a new case pins
  the acyclic form of the nullable-union record.
- backend analysis: "removing a leaf preserves another cyclic branch"
  closes its cycle through an array edge.

(Split from the stack's later "Update self-hosting emission cases for
acyclic proofs and inline allocation"; the inline-allocation half moves to
the inline-allocation change.) tests/harness/self-hosting-emission.test.ts
and self-hosting-backend-analysis.test.ts pass at this point of the stack.
2026-10-08 15:26:31 -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 5a5ae45023 Merge pull request #749 from vercel-labs/perf/rebased/07-h17-async-fastpath
Switch fibers without syscalls and run non-suspending async functions inline (async 7x faster)
2026-10-08 15:26:27 -05:00
Malte Ubl 8818310e20 Merge pull request #748 from vercel-labs/perf/rebased/06-h23-layout-noise
Link the runtime first to stabilize code layout (removes up to 11% placement noise)
2026-10-08 15:24: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 2a2e84c3fc bench-runtime: add --layouts to pool runs over perturbed code layouts
--layouts=N links every executable N times with 0, 16, 32, ... bytes of
retained padding just before the program object (a SCRIPTC_LINKER
wrapper that forwards to clang) and rotates the timed runs through
those layouts, so medians and CIs cover code-placement luck. Each
layout's binary is checked against Node. The default of 1 keeps the
existing behaviour and output; per-layout medians go into the JSON.
Works with any checkout's compiler, including an unmodified baseline.

Linux x86-64 (Vercel Sandbox), H23 vs perf/harness, --layouts=4 --runs=16:

| workload | baseline ms | candidate ms | change | 95% CI | verdict | size |
| --- | ---: | ---: | ---: | --- | --- | ---: |
| json-records | 754.4 | 771.2 | +2.2% | -1.9% .. +5.6% | neutral | +1.9% |
| regex-logs | 380.6 | 384.9 | +1.1% | -9.8% .. +7.3% | neutral | +2.7% |
| ast-interp | 385.9 | 395.6 | +2.5% | -5.0% .. +10.6% | neutral | +3.1% |
| records-sort | 1257.0 | 1259.6 | +0.2% | -1.9% .. +2.8% | neutral | +3.2% |
| template-render | 250.7 | 246.1 | -1.8% | -9.9% .. +2.3% | neutral | +3.7% |
| functional-pipeline | 428.8 | 396.5 | -7.5% | -9.5% .. -4.0% | faster | +4.0% |
| async-pipeline | 204.2 | 203.6 | -0.3% | -1.9% .. +1.0% | neutral | +2.5% |
| word-graph | 325.5 | 327.3 | +0.6% | -2.5% .. +3.2% | neutral | +6.5% |
| alloc-trees | 510.1 | 509.4 | -0.1% | -2.3% .. +2.2% | neutral | +4.9% |
| numeric-kernels | 119.5 | 117.6 | -1.6% | -3.1% .. -0.1% | faster | +4.4% |
| validate-errors | 201.4 | 200.4 | -0.5% | -3.2% .. +2.1% | neutral | +3.0% |
| csv-numbers | 456.3 | 443.0 | -2.9% | -3.8% .. -0.3% | faster | +4.0% |
| log-summary | 252.9 | 255.9 | +1.2% | -0.8% .. +2.3% | neutral | +3.9% |
| inventory-report | 461.3 | 452.2 | -2.0% | -6.5% .. +0.2% | neutral | +2.8% |

geomean change: -0.7%

macOS arm64, H23 vs perf/harness, 15 runs:

| workload | baseline ms | candidate ms | change | 95% CI | verdict | size |
| --- | ---: | ---: | ---: | --- | --- | ---: |
| json-records | 265.1 | 263.8 | -0.5% | -1.1% .. -0.2% | faster | -0.0% |
| regex-logs | 297.9 | 290.9 | -2.4% | -3.2% .. -0.8% | faster | +6.3% |
| ast-interp | 228.8 | 229.5 | +0.3% | -1.0% .. +1.4% | neutral | -0.0% |
| records-sort | 595.5 | 602.9 | +1.2% | +0.6% .. +2.0% | slower | -0.0% |
| template-render | 194.7 | 193.0 | -0.9% | -2.0% .. +0.4% | neutral | +8.8% |
| functional-pipeline | 219.9 | 223.6 | +1.7% | +0.8% .. +2.3% | slower | -0.0% |
| async-pipeline | 251.3 | 250.4 | -0.3% | -1.2% .. +0.7% | neutral | -0.0% |
| word-graph | 161.6 | 161.4 | -0.1% | -0.6% .. +1.2% | neutral | -0.0% |
| alloc-trees | 275.6 | 273.7 | -0.7% | -1.5% .. +0.0% | neutral | +0.0% |
| numeric-kernels | 67.5 | 67.3 | -0.2% | -1.8% .. +0.9% | neutral | -0.0% |
| validate-errors | 144.4 | 143.3 | -0.8% | -1.4% .. +0.5% | neutral | +0.0% |
| csv-numbers | 276.3 | 276.3 | +0.0% | -0.7% .. +0.6% | neutral | +0.0% |
| log-summary | 149.9 | 151.3 | +1.0% | -0.2% .. +1.7% | neutral | +0.0% |
| inventory-report | 197.2 | 198.1 | +0.4% | -0.2% .. +1.1% | neutral | -0.0% |

geomean change: -0.1%

No-op change (+32 B program object), Linux, 15 runs: baseline
template-render +10.8% slower (CI +9.3..+12.9%); with H23 every
workload is neutral (regex-logs +1.4%, template-render +0.5%,
records-sort -0.4%). Details: results/h23-layout-noise/REPORT.md.
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 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 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 da3ebe0c03 Merge pull request #747 from vercel-labs/perf/rebased/05-h15-native-regex
Run ASCII regex subjects on a native matcher (regex workloads 2-3.7x faster)
2026-10-08 15:22:23 -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 a3df1a7ac1 Merge pull request #746 from vercel-labs/perf/rebased/04-h04-array-reads
Inline dense array element access (-15% geomean, numeric-kernels -65%)
2026-10-08 15:19:08 -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 1cb00b7fba Merge pull request #745 from vercel-labs/perf/rebased/03-h16-typed-json
Parse typed JSON straight into native records (2.9x faster)
2026-10-08 15:06:43 -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 dedb568ade Pin wrong-typed input on the schema-directed JSON.parse path
Seventeen Order inputs whose shape disagrees with the target (wrong leaf
kinds at the top level, in nested records, record arrays and string
arrays; null for object and number; missing fields; object vs array at a
field and at the root; duplicate keys; truncated JSON; 1e400 and a lone
surrogate; extra nested members; reordered keys) plus an Item[] root.
The expected lines are the output of the checked-route-only build
(perf/harness); the fast path must decline or agree on every one.
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 a37075a5bf Merge pull request #744 from vercel-labs/perf/rebased/02-h08-allocator
Add a small-object allocator to the runtime (-14% Linux, -21% macOS)
2026-10-08 15:04:57 -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 994a6b9940 Raise the Linux static hello size ceiling to 80 KiB
The tree-shaking test failed on Linux x64 with "expected 68344 to be less
than 65536". Measured with the release helper/runtime-pack pipeline in a
Linux x64 sandbox (hello = console.log("hello", "world")):

  PR    file   .text  R+E segment
  #743  64256  15794  0x3f11
  #744  68416  16050  0x4011
  #764  68520  16546  0x4221

The allocator adds 256 bytes of .text to hello: the inline scr_mem_free
fast path in the release functions hello already links (scr_str_release,
scr_arr_destroy, scr_arr_gc_free, scr_cyc_free, scr_dyn_dispose). Its
slow path and address-space reservation (scr_sa_slow, scr_sa_reserve)
are already stripped from hello. ELF segments are page aligned, and #743
left only 239 bytes before the executable segment's 16 KiB page, so 17
bytes over the boundary move every later segment, and the file, by 4 KiB.

Shrinking hello back under the old ceiling is not a reasonable trade.
At the top of the stack the executable segment is 545 bytes past the
page boundary. The extra bytes come from hot-path code the later PRs add
on purpose (number-to-string fast paths in scr_f64_to_str, inline-alloc
release paths, the direct fd write), all reached through console.log's
generic argument rendering. Outlining them to save one page would undo
the optimizations those PRs add.

The ceiling's documented intent is "several native pages of
linker-version slack" above a hello that the comment put at about 41KB.
That no longer held before this stack (64256 bytes on #743, 1.2 KiB of
slack). 80 KiB restores about three pages of slack over the stack's
largest hello and still catches lost section GC (the old always-linked
runtime was about 400KB). Symbol absence remains the primary
reachability contract. The Mach-O ceiling is unchanged.

Verified in the Linux x64 sandbox: pnpm test
tests/harness/runtime-tree-shaking.test.ts fails on #744 before this
change (68416 vs 65536) and passes after it, on #744 and on #764.
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 TateandChris Tate 08f4140293 Harden stacked CI scope and merge validation (#771)
Co-authored-by: Chris Tate <366502+ctate@users.noreply.github.com>
2026-10-08 14:57:21 -05:00
Chris TateandChris Tate 064b05a92c Run full CI for the lowest unmerged stacked PR (#770)
Co-authored-by: Chris Tate <366502+ctate@users.noreply.github.com>
2026-10-08 14:49:10 -05:00
Malte Ubl dc924efa79 Merge pull request #742 from vercel-labs/perf/rebased/harness
Add a runtime benchmark suite that times compiled programs against Node
2026-10-08 13:44:54 -05:00
Malte Ubl 1dc9e04fc5 Time Node in the interleaved loop and report candidate/Node ratios
Node is the reference the project aims to beat. A single cold oracle
launch overstated its time; it is now warmed and sampled in the same
interleaved loop as the compiled executables, and the report adds a
per-workload candidate/Node ratio and its geomean. --no-node skips it.
2026-10-08 09:38:24 -07:00
Malte Ubl 523a8271f8 Skip peak RSS when /usr/bin/time is unavailable 2026-10-08 09:38:24 -07:00
Malte Ubl 10b246d604 Add a runtime benchmark suite with Node-verified A/B timing
Fourteen application-shaped workloads (JSON, regex, parsing and class
dispatch, sorting, rendering, collection pipelines, async fan-out,
Map/Set graphs, allocation, numeric kernels, exceptions, CSV, and the
two multi-module build benchmark apps on large inputs).

bench:runtime builds each workload with one or two checkouts, refuses to
time any executable whose stdout, stderr, or exit status differs from
Node, interleaves baseline and candidate launches, and reports medians
with bootstrap 95% confidence intervals, binary size, and peak RSS.
2026-10-08 09:38:23 -07:00
Chris Tate 1f10e83720 Promote canary to main (#738)
- Preserve callback identity, receiver binding, and class references across unions and collections.
- Track record field presence independently of undefined values and improve native runtime behavior.
- Expand differential coverage and streamline CI bootstrap and compiler rebuild checks.
2026-10-08 11:00:13 -05:00
Chris Tate b9bac85441 Release v0.2.6 (#729)
- Release native workers, shared memory, and broader language and library support.
- Include runtime and compiler improvements in the release notes.
- Synchronize all published packages and the surface manifest at 0.2.6.
v0.2.6
2026-10-07 19:15:55 -05:00
Chris Tate 095b619330 Add native workers and shared memory
- Add native worker execution with isolated module state, structured messages, and lifecycle controls.
- Support shared buffers and integer atomics with coordinated runtime ownership and cancellation.
- Extend compiler, runtime-pack, differential, and compatibility coverage for native concurrency.
2026-10-07 19:07:02 -05:00