The bootstrap-seed build rejected `"receiver" in expr` / `"args" in expr`
on the IR expression union in constant-callbacks.ts (SC1090: `in` on an
object union). A switch over the six data-intrinsic kinds now returns their
operands (receiver first; none for mapNew/setNew, as before) and replaces
the DATA_INTRINSICS set. Behavior is unchanged.
The full emitter inlines the small-object allocator on 64-bit host targets,
so the self-hosting layout fixture's host enables inlineAlloc for 64-bit
requests (as emitLlvmModule does), and the recursive-records case no longer
asserts the width-specific free path (64-bit pushes onto the allocator's
free list; 32-bit calls scr_cyc_free).
(Split from the stack's later "Update self-hosting emission cases for
acyclic proofs and inline allocation"; the acyclic-proof half moves to the
construction-only acyclic change.)
Upstream native workers (095b6193) compile the runtime with -DSCR_WORKERS,
which makes scr_cyc_live and scr_weak_dispose_hook thread-local, and the
small-object allocator is compiled out there. The inline allocation paths
address those as ordinary globals, so worker programs keep the out-of-line
runtime calls, exactly like thread-instanced libraries.
A worker-threads test pins that worker LLVM references neither the
allocator state nor the thread-local counters, and that worker programs
keep the fiber path for async bodies (the entry 07 adaptation).
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.
`new Item()` now calls the borrowed constructor body, so no retain transfers the fresh instance into it: the snapshot body retains twice, not three times.
layouts.ts and classes.ts imported NullableRefFields type-only, so the
self-hosted layout stage never saw the class declaration and rejected
`NullableRefFields | undefined`. They now import the class itself, and the
field record names the object variant directly instead of an intersection
type.
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.
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.
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%.
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.
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.
The suite only had single-process CPU workloads. Add the two largest
real-world categories:
- http-api (kind "server"): a node:http JSON API in the static subset
(routing, query parsing, seeded catalog, per-cart state, typed JSON
bodies, ETag/If-None-Match, auth and request-id headers), driven by
clients/http-load.mjs, a raw-socket keep-alive HTTP/1.1 load client
that is identical for every server. 64 connections, 2 client threads,
4,000 untimed warmup and 40,000 measured requests on loopback. The
per-connection request schedule is seeded and server state is
partitioned per connection, so the response digest is deterministic.
Correctness compares server stdout, server stderr (minus the PORT
line), exit status, and client stdout with Node byte-for-byte. The
timed sample is the measured-phase wall time; a second table reports
req/s, p50/p90/p99 latency, client CPU, and peak RSS.
- cli-config (process, launches: 20): a deploy-plan CLI that reads a
~110 KB JSON manifest, validates, interpolates env values, and orders
80 services into dependency waves. Each sample is the median of 20
launches, so startup dominates.
The runner stays backward compatible: process workloads behave as
before, the JSON report keeps its fields and adds kind, launches,
category, server, node_peak_rss_bytes, and geomean_vs_node_by_category,
and the summary prints per-category geomeans (cpu, startup, server).
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.
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).
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.
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.
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.
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.
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.
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.
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.
% 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.
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.
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.
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.
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).
`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.
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).
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.