Commit Graph
3704 Commits
Author SHA1 Message Date
Malte Ubl af2521f688 Copy short strings inline and drop index entries of proven-ASCII strings
String construction copied every span with a libc memcpy call, and
scr_str_new scanned the copied bytes for ASCII in a second pass. Spans of
at most 16 bytes (identifiers, map keys, number text) now move as two
overlapping fixed-size words, and scr_str_new tests those same words for
ASCII, so the fact is free for short strings; longer spans keep the bulk
copy and a chunked scan that stops at the first non-ASCII byte.

A scan that proves a heap string ASCII now also drops its index-cache
entry: a proven-ASCII string never owns one, because every UTF-16 mapper
answers it by identity and re-checks the header bit after extending an
index. Releasing such a string therefore skips the cache purge, and a
large text proven ASCII once (a JSON document's length, a source file)
no longer keeps every later string release walking the cache. Immortal
strings cannot record the bit and keep their entries as before.
2026-10-09 10:10:22 -07:00
Malte Ubl c75cc167dd Append runtime-optional push values in place
`a.push(v)` where v may hold undefined at run time (an unchecked array
read or a hole-observing for-of binding, as in tsc-ts's filterType loop)
built a temporary one-element array, stored v or present undefined into
it, and spread it onto the receiver: an allocation, a growth, a copy and
two releases per append. Such pushes now evaluate every argument first,
then append each present value through the ordinary push (and its inline
dense fast path) and each missing one as a present-undefined slot at the
current length, which is what Node stores. Pure receivers and arguments
are re-read instead of copied into owned temporaries.

Call arguments read from arrays of nullable references now take the
inline dense lookup like other projections (the value is the element
pointer itself, so the path is a few loads), and scr_arr_peek_ref answers
dense in-range indices before the general lookup. compareSymbols'
`declarations[0]!` arguments no longer leave compiled code.
2026-10-09 10:10:22 -07:00
Malte Ubl 83ab6ce2a8 Encode ASCII runs eight bytes at a time in Buffer#write
buf.write(string, 'utf16le' | 'latin1' | 'ascii') decoded one UTF-8
code point per step. tsc-ts converts every source file to UTF-16 this
way (toCodeUnits), so the per-character loop showed up in the profile.
Eight-byte ASCII runs whose units all fit the remaining budget are now
widened (utf16le) or copied directly; any other position, and a tail
that would cross the budget, takes the unchanged per-code-point path, so
truncation at a unit or surrogate boundary is exactly as before.
2026-10-09 10:10:22 -07:00
Malte Ubl e23ea24605 Keep number-key map transitions out of line
The number-key insert inlined a second copy of the hashed word insert and every index transition (1.7 KB on arm64). One out-of-line word insert now serves the generic dispatcher and the number path, and entering, growing and leaving the direct index stay out of line, leaving the insert at 0.5 KB.
2026-10-09 10:10:21 -07:00
Malte Ubl 302e4da3ba Cache proven ASCII in the string header and read it inline
JavaScript string reads use UTF-16 indices over scriptc's UTF-8 storage,
so charCodeAt, length, slice, substring and indexOf looked every receiver
up in a small global index cache (scr_sidx) on each call, and a fresh
short receiver evicted an entry and rescanned its bytes. In the tsc-ts
self-check that lookup and its mapping were the largest string cost.

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

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

The self-concatenation assignment now flattens its suffix and appends
every operand to the snapshot in one call, so tsc-ts's typeListKey loop
(`key += (i === 0 ? '' : ',') + types[i].id`) appends in place without
allocating per element. Operands still evaluate left to right; converting
a number has no effects, so formatting it at the call keeps JavaScript
order. A corpus program pins Number::toString shapes, evaluation order,
self-referencing suffixes, aliases taken mid-append and Unicode text.
2026-10-09 10:10:21 -07:00
Malte Ubl 56bc1745cd Inline number-key Map/Set get and has
Number-key reads called scr_map_get_f64_ref and friends, which hashed the
key and retained reference values through the map's val_retain function
pointer. They now go through sc_map_entry_f64, a small internal IR helper
LLVM inlines at each site: with the runtime's direct index present, an
integral in-range key reads its slot and any other key is absent, with no
call; maps without the index defer to scr_map_entry_f64. The generated
code reads the entry's value slot itself and retains reference values
with the value type's own (inlinable) retain, the adapter the collection
was constructed with. Map.get with stack-boxed results, union and scalar
values, and Map/Set.has all use it.

The helper and its %ScrMapIx/%ScrMapEntry types follow the target's size
type, so wasm32 builds verify as well.
2026-10-09 10:10:21 -07:00
Malte Ubl 3955982b41 Index number-keyed Maps and Sets directly when keys are small integers
Maps and Sets with number keys used as id tables (tsc-ts keeps node and
symbol links in Map<number, Links>) hashed every key as a double. They now
keep a direct index, dense[k] = entry index + 1, while every live key is an
integer in [0, ndense) and the table stays within 8x the live count (64
slots minimum). The entries array, tombstones and iteration order are
unchanged; only the lookup index differs.

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

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

Only growth moves a map to the direct index: an append that
scr_map_reserve_append would absorb by compacting tombstones (a linear map
with a dead entry, a bucket table at most half live, no iteration active)
keeps the map's compact storage, so churn on a small number-keyed map no
longer allocates a table. While an iteration is active nothing compacts,
so a small map promotes to the direct index (integer keys) or the bucket
table (other numbers) without moving entry indices. The runtime map test's
small-storage script, which assumed bucket promotion and failed its two
live-iteration checks, now runs with integer and fractional keys and
checks both transitions, and the corpus program covers small maps growing
and clearing mid-iteration.
2026-10-09 10:10:20 -07:00
Malte Ubl d58a5c3abc Give the self-hosting layout fixture the int32 slots
emitLlvmLayouts now takes the whole-program int32 slot facts, but the
self-hosting layout fixture never passed them and only imported the
Int32Slots type, so the self-compiled stage could not represent
`Int32Slots | undefined` (SC2009). Compute them with analyzeInt32Slots
like the emitter does, so the fixture compiles statically and its native
output mirrors production int32 field storage.
2026-10-09 10:10:20 -07:00
Malte Ubl 4c425ad63a Merge pull request #791 from vercel-labs/perf/tscts/04-exc-casts
tsc-ts faster: inline exception checks in helpers and runtime, and class downcasts
2026-10-09 12:10:05 -05:00
Malte Ubl 96b2032ee1 Merge pull request #790 from vercel-labs/perf/tscts/03-rc-cycle
tsc-ts -25%: inline release fast paths and drop cycle-color writes
2026-10-09 12:09:33 -05:00
Malte Ubl faf222fc30 Merge pull request #789 from vercel-labs/perf/tscts/02-nullable-refs
tsc-ts -18%: represent nullable references as plain pointers instead of union boxes
2026-10-09 12:08:57 -05:00
Malte Ubl 8576f48924 Keep integer-proof tests meaningful under whole-program facts
The remainder and bitwise-arithmetic fixtures relied on parameters
staying arbitrary, but each was only called with integers, so the new
call-site proofs removed the floating fallbacks they count. A fractional
call per function restores the arbitrary inputs and keeps every original
expectation. The self-hosting range cases now expect the int32-closed
range where a region's writes are all int32 literals (for-of still binds
the local to unproven elements and keeps no fact).
2026-10-09 11:59:28 -05:00
Malte Ubl c9deceded9 Keep the int32 analyses inside the self-hosted subset
The natively bootstrapped emitter compiles these modules with scriptc
itself, which refuses a nested function with a defaulted parameter and
`x ?? []` over a Set or Map (a union with no static representation).
Pass havoc's entry state explicitly and guard those iterations instead.
No behavior change.
2026-10-09 11:59:28 -05:00
Malte Ubl 5d68db67b2 Carry int32 proofs into captured bindings
A local captured by a closure lives in a box shared by its declaring
function and every capturing closure, so the range analysis had no facts
for it anywhere, not even in the declaring function. int32-slots.ts now
groups the members of each box (closure captures map positionally onto
the lifted function's captures) and proves the group int32 when every
write to it anywhere is proven and it starts from a proven parameter or
an initialized declaration; ++/--, for-of and catch bindings, TDZ boxes
and class-captured bindings stay unknown. analyzeIntegerRanges reads the
proof through a new boxed-binding fact.

Corpus 4453 adds closures that read an int32 parameter, store a fraction
into a captured local, and count past int32.
2026-10-09 11:59:28 -05:00
Malte Ubl 7d1483590d Store provably int32 class fields as i32
A whole-program optimistic fixpoint (ir/int32-slots.ts) proves which
number-typed class fields only ever hold int32 values (never -0, NaN, a
fraction or an out-of-range integer), which parameters of directly called
functions receive an int32 at every call site, and which results are int32
on every return. It reuses analyzeIntegerRanges per function under the
current facts and re-analyzes readers of any disqualified slot.

Proven fields are stored as i32. Reads yield the i32 as the value's
integer view (bit tests and comparisons use it directly) and its exact
widening as the double, so every observation, including dyn views, JSON
and inspect, prints identically. Writes store the proven integer.
Parameter and result ABIs stay double; their facts only remove coercions.

Writes the IR does not show are excluded: fields that a dynamic store in
the program can name on a class reaching checked-dynamic code (live views
commit stores back), runtime-rooted and generic classes, ++/-- on the
field, and parameters of escaping, class-value-constructed or virtually
dispatched functions. Library and worker builds are untouched. The dyn
commit adapter traps rather than store a non-int32 into an i32 field.

In tsc-ts, Type.flags/objectFlags, Symbol.flags/checkFlags,
Signature.flags and ~100 other fields become i32; isSimpleTypeRelatedTo
drops from 1489 to 436 instructions and from 83 floating-point
conversions to none.

Corpus 4451-4453 pin field storage edge cases (overflow into doubles,
-0, NaN, fractions, uint32, subclass writes, enum members and reverse
mappings, JSON/inspect through live views, union discriminants), dynamic
stores (named, computed, Object.assign, Reflect.set, defineProperty), and
int32-closed locals, parameters and results; preflight/order baselines
recorded for all three.
2026-10-09 11:59:28 -05:00
Malte Ubl 94321e7736 Keep int32-closed locals and external slot facts in integer ranges
analyzeIntegerRanges discarded every loop-carried, switch- or try-written
local at the region join, so `flags |= t.flags` in a loop lost its proof.
A local that enters a region with an int32 fact (or is declared and
written only inside it) and whose every write there is int32 by
construction (bitwise results, int32 literals, other such locals, int32
slots) now keeps the full int32 range across loops, switches, try regions
and labeled blocks.

The analysis also accepts optional whole-program facts: entry ranges for
parameters, the range of every value a class field can hold, and the
range of a direct call's result. Without them behavior is unchanged.
2026-10-09 11:59:28 -05:00
Malte Ubl ce5762be0f Test checked union extractions inline for plain operands
`x!` and `u as Arm` on a union value called an interned %union.narrow
helper: a call, a payload retain, and a pending check after every use,
even though the success path is a single tag compare. Plain operands now
lower to that compare at the use (`isTag(u) ? narrow(u) : <throw>`), with
the remaining arms throwing the helper's TypeError (the shared
unionNarrowFailureMessage) through error.nodeThrow, which unwinds without
a pending test. Member reads borrow the extracted payload through the
existing checked-projection receiver path. Other operands keep the helper
so they are evaluated exactly once, and the runtime-optional source
recognizer accepts both forms.
2026-10-09 11:59:27 -05:00
Malte Ubl eb7a99172f Test class downcasts inline and borrow the checked operand
`x as Sub` lowered to a call to an interned %class.assert helper that
unwrapped a union box, retained the payload twice, compared the class-id
range, and threw on mismatch; every use paid a call, three retains, two
releases and a pending check (2,152 sites in tsc-ts). Plain operands
(locals and field reads, which isSafeToRepeat already admits) now lower
to the check itself: per compatible union arm a tag test plus, where the
arm is not already the target or a subclass, an instanceOf range test,
selecting the downcast payload, with the helper's exact TypeError as the
final arm. Other operands keep the helper so they are evaluated once.

The backend borrows through the result: instanceOf reads its operand as
a receiver instead of retaining a temporary, and downcast/upcast are
borrowable, edge-preserving projections, so `(node as Sub).field` and
method receivers on a cast no longer touch reference counts. The mismatch
arm unwinds directly (previous commit) and stays out of the hot path.

Corpus 4432 pins the Node-observable behavior of failing casts (a
TypeError before any later read, unwinding through finally blocks and
callbacks) and of successful casts from bases, subclasses and unions; a
harness test pins the exact messages, which Node cannot oracle.
2026-10-09 11:59:27 -05:00
Malte Ubl 2f3e023bc9 Unwind directly after runtime throws that always leave an exception
The compiler-resolved Node throws (error.nodeThrow, the argument and
property type throws, undeclared global reads) always leave an exception
pending, yet each was followed by the generic pending test and a branch.
tsc-ts has 12,177 such sites, mostly the mismatch arms of checked casts
and member reads of possibly-undefined values. Unwind unconditionally
instead; emission continues in a block without predecessors so the
caller's remaining instructions stay well-formed, and LLVM deletes it.
2026-10-09 11:59:27 -05:00
Malte Ubl 8d1969a313 Inline the pending-exception test in helpers and runtime units
main's emitPendingCheck (#785) already tests `scr_exc_active->kind`
inline after every may-throw call in executables. The helper and adapter
bodies the backend generates (dyn check helpers, async/generator
trampolines, inline-async wrappers, FFI callback adapters, island and
stream adapters, IPC message thunks, dynamic-key record stores) still
called `scr_exc_pending()`, an opaque call into the prebuilt runtime,
and so did every runtime C unit. On the tsc-ts self-check that function
was about 4% of self time.

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

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

Adds corpus 4431 pinning exception propagation across fiber switches,
generators, sort/map callbacks, and runtime JSON failures.
2026-10-09 11:59:27 -05:00
Malte Ubl 918024b688 Pin late-dying old cycles and weak keys held by old objects
Two corpus programs from the dropped tenuring work, reworded for main's
three-generation collector: a tree with parent back edges ages into the
old generation and then loses subtrees as dead cycles (plus temporary
trees held across passes inside a call), and weak collections keyed by
objects that age into the old generation and later die inside cycles.
Only full passes reclaim either, so the sanitized lane's exit RC audit
proves they are reclaimed rather than leaked, and the outputs pin that
live structure and live weak entries survive every pass in between.
2026-10-09 11:59:26 -05:00
Malte Ubl a32d147896 Borrow the operands of identity and instanceof tests
`a === b` on references and `x instanceof C` only read pointers (and the
vtable word), yet each operand was retained before the compare and
released after it. Use borrowableInputs for both, which keeps
left-to-right evaluation and falls back to an owned operand whenever a
later operand might release it. A corpus program pins that fallback with
an operand whose freed block the next allocation reuses.

tsc-ts self-check (median of 7): 3.06s -> 3.00s; binary -249 KB.
2026-10-09 11:59:26 -05:00
Malte Ubl ef915f4639 Stop coloring cycle candidates on retain and release
Retains stored BLACK into the cycle header so that markRoots could skip
candidates retained again after they were buffered, and every surviving
release stored PURPLE to undo that. On the tsc-ts self-check the filter
dropped almost no candidates (3.01M walked either way), while the stores
cost a header write on every retain and release of a headered object and
an instruction sequence at every inlined site. Colors now belong to the
collector alone: markRoots takes every buffered entry, scr_cyc_mark_live
and the emitted mark-live and purple stores are gone, and the release
fast path only reads `buffered`.

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

Rebased onto main's three-generation collector, the series measured
with and without this commit (tsc-ts self-check, median of 7):
2.779 s -> 2.706 s, binary -660 KB, max RSS +13 MB.
2026-10-09 11:59:26 -05:00
Malte Ubl bd08ab5787 Split emitted object releases into inline and out-of-line halves
Class and record releases were small enough to inline everywhere,
teardown calls included, and hierarchy releases always dispatched through
the vtable even when the object survived. The public release is now only
the fast path: a reference that is neither the last one nor immortal
(one compare) decrements and takes the possible-root step; NULL,
immortal and last references call the complete release, kept noinline.
In a hierarchy the program owns end to end, every member shares the rc
word and the header decision, so surviving releases no longer load the
vtable; only the last reference dispatches. Hierarchies rooted in a
runtime class keep their dispatching release.

tsc-ts self-check (median of 7): 3.57s -> 3.15s; binary +37 KB.
Variants measured and rejected: the whole hierarchy release noinline
(-2.6 MB but 9% slower than the split), handling the last reference
inline in the fast path (+0.4 to +1.1 MB and slower).
2026-10-09 11:59:26 -05:00
Malte Ubl f8d18bdc9b Inline the already-buffered check into emitted object releases
Every release of a traced class or record object that left it alive
called scr_cyc_on_release out of line, only for the runtime to store
PURPLE and return because the object was already buffered. On the tsc-ts
self-check that was 730M calls for 11M enqueues, the hottest function in
the profile. Emit the same purple store and buffered test the inline RC
helpers already mirror, and call the runtime only to enqueue.

tsc-ts self-check (median of 7): 4.14s -> 3.95s.

Landing note (this series rebased onto main after #741, #768 and #781).
The work item also tenured collector survivors (a survivor moved to a
tenured list, was never buffered again on release, and only heap-doubling
major passes rooted trial deletion at it) and backed off major passes that
freed almost nothing. main's three-generation collector with
yield-scheduled growth and backlog triggers now addresses the same cost,
re-walking a large long-lived graph for little garbage. Ported onto main's
generations (objects promoted into OLD tenured instead of re-buffered, the
full-pass schedule left to main's yield rule), tenuring measured no better
on the tsc-ts self-check: 2.706 s with this series as landed vs 2.737 s
with tenuring added (median of 7, same binary otherwise), and it gives up
main's old-backlog trigger (test_cycle's dead-mature-ring check fails).
Both the tenuring and the backoff commits are therefore dropped. The
series keeps main's scheduling unchanged and contributes the emitted
release fast paths, collector-only colors and borrowed identity operands.
The tenuring item's corpus programs stay as coverage for late-dying old
cycles and weak keys held by old objects (4413, 4414).

tsc-ts self-check on main (median of 7 or 5): previous branch 3.63 s ->
2.70 s with this series. The per-commit timings in this series were
measured on the work item's base (13633c08).
2026-10-09 11:59:26 -05:00
Malte Ubl 36350ca5dd Pin nullable and boxed union record edges in native layout emission
The record edge cases typed linked | undefined now lower to a nullable pointer released through the record's NULL-tolerant helper; a new case with a second non-unit arm keeps the tagged-box trace and release helpers pinned.
2026-10-09 11:59:24 -05:00
Malte Ubl fb23f3bf17 Give the self-hosting layout fixture the nullable-union host members
WalkerHost now carries the nullable-union registry and the record-field ABSENT test; the natively compiled layout/metadata fixture builds them the same way the emitter does.
2026-10-09 11:59:24 -05:00
Malte Ubl 9d963d3937 Borrow optional array elements into nullable-union locals
H37 let a projection-only local or argument read an optional array element through a private stack box that borrowed the element when the array was proven to outlive it. Nullable-union reads lost that borrow and retained/released each element (records-sort's comparator keys). Optional array reads now run over every union, and emitLocalArrayRead stores the borrowed element pointer, or one owned reference released from the same slot, instead of a box.
2026-10-09 11:59:24 -05:00
Malte Ubl 7106e0cdae Compare nullable strings without a tag switch
Equality of two string | undefined (or | null) values is pointer identity or, when both hold strings, a byte compare. The generic per-arm tag switch remained in comparator loops such as records-sort.
2026-10-09 11:59:24 -05:00
Malte Ubl 1287df6ad6 Keep source-level stack-box tests on tagged unions
The optional element reads these programs pin now lower to nullable pointers for class and string arms. Each program declares a Set of its element union, which keeps that union a tagged box, so the tests continue to exercise the stack-box and borrowed-payload paths.

main's optional class element test from #767 (comparisons, tests and
optional chains through stack tags) gets the same Set, plus a nullable
variant asserting that those reads build no union box at all.
2026-10-09 11:59:24 -05:00
Malte Ubl ae76fa85a3 Decide index-signature hits by presence for nullable value unions
An overflow map stores a nullable union's unit arm as NULL, which the keyed-read path took for a missing key (trapping where the result has no undefined arm, e.g. a stored null in Record<string, C | null>). Presence now comes from scr_map_has_str for nullable value unions; corpus 4413/4414 pin present-null index reads.
2026-10-09 11:59:24 -05:00
Malte Ubl 374edc9871 Pin null|undefined, string and container nullable references
Corpus 4413 (C | null | undefined), 4414 (string arms) and 4415 (array, Map and Set arms) cover discrimination, truthiness, equality, nullish operators, arrays, Map values, optional record fields through JSON/inspect/in/checked casts, unknown, captures, generators, async payloads and hot loops; preflight/order baselines recorded.
2026-10-09 11:59:24 -05:00
Malte Ubl 50eaceb5fc Extend nullable-pointer unions to null|undefined and string/container arms
`C | null | undefined` keeps the plain pointer: NULL is undefined and a
module-scope immortal sentinel is null (union-repr.ts discriminates both;
== null matches either, === null / === undefined tell them apart). The
reference arm may now also be a string, array, Map or Set. Those retains
dereference, so nullable values retain through an emitted NULL-skipping
wrapper (sc_nretain_<kind>) used both as the call target and as the
container adapter; releases were already NULL-tolerant. String arms keep
their per-arm semantics: equality compares bytes and truthiness tests the
length, so the identity and non-NULL fast paths apply to object arms only.
Missing-key Map reads, wrapNullable and nullable runtime references encode
the absent arm per union, and the remaining raw string/array box builders
(race adapter, child exit signal, os.networkInterfaces, stream done
callbacks, island any[] exits) go through the representation-aware helpers.

Stack-box unit tests keep their fixture unions boxed through a Set use
(test-boxed-unions.ts), the real reason a union stays tagged.
2026-10-09 11:59:24 -05:00
Malte Ubl 5c89681933 Read the ABSENT field state as undefined in dynamic-key record reads
A computed-key read converting a declared record field to dyn loaded the raw slot. Boxed unions hide the ABSENT state behind their undefined tag, but a nullable-pointer slot holds the module's sentinel, which the conversion would treat as an instance. loadRecordField maps it to the undefined arm like every other field read.
2026-10-09 11:59:24 -05:00
Malte Ubl 9eb77aaccf Pin nullable class and record references through every value flow
Corpus 4411 (class arms) and 4412 (record arms, optional-field absence) cover locals, narrowing, equality, fields, arrays, maps, captures, generics, widening, unknown, checked casts, JSON, inspect, async, generators and collector cycles; preflight/order baselines recorded for both.
2026-10-09 11:59:24 -05:00
Malte Ubl 3f167db7b3 Represent nullable class and record unions as plain pointers
A union of exactly one emitted class or record arm and one unit arm
(`C | undefined`, `C | null`) now lowers to the arm pointer itself: NULL is
the unit arm and no `%ScrUnion` box is allocated. The decision lives in one
registry (nullable-unions.ts) computed from the module, so in-memory and
serialized-IR emission agree. Unions the runtime inspects as boxes (Map keys,
Set elements) keep the tagged representation.

Every structural read goes through representation-aware primitives
(union-repr.ts, unionTag/unionPeek/unionNewOwned/unitInstanceRef taking the
union id), and RC/trace dispatch resolves a nullable union to its arm's
NULL-tolerant entry points, so containers, captures, fields, params,
returns and promise payloads hold the plain pointer. Optional record fields
use a module-scope immortal sentinel for the ABSENT state. Generic helper
generators (JSON and join walkers, dyn conversions and checked casts,
live stream refs, race/generator adapters, map get widening) handle both
representations; fixed library unions assert they stay boxed. The stack-box
analyses run over the boxed union table only.

On main, the checked-extraction receivers of #781 (checked-narrows.ts)
read the tag and payload through the same representation-aware
primitives, and the stack-box and optional-array-read paths of #767 run
over the boxed union table.
2026-10-09 11:59:24 -05:00
Malte Ubl 70c80aded6 Merge pull request #788 from vercel-labs/perf/tscts/01-bootstrap-union
Keep refinement intersections on the union arm they refine
2026-10-09 11:59:20 -05:00
Malte Ubl b2ac0d5ff6 Keep refinement intersections on the arm they refine
Recursive declarations own distinct shape ids even when their layouts
coincide, while the structural interning key names only the first of
them. A refinement intersection such as `Expr & { kind: "mul" }` (which
the checker reduces to `Mul & { kind: "mul" }`) was interned by
structure, so it could resolve to a lookalike declaration's shape: a
different arm of the union being narrowed. Checked assertions and type
guards then threw the stranded-arm TypeError on valid values, and
flow-narrowed assertions copied the record into the lookalike layout,
severing identity.

The natively compiled compiler hit this rebuilding itself once the
direct array-callback lowering added `read.cond as IrExpr & { kind:
"bin" }`: that type resolved to the `logical` arm's shape, so the native
bootstrap jobs failed with SC3004. main has since rewritten that
lowering to narrow by `kind` checks (a52126b8), which keeps the
bootstrap green but leaves the type-mapper bug in place: corpus 4143
still prints only its first line natively on main in both lanes.

An intersection whose mapped layout equals one of its own constituents'
layouts now keeps that constituent's shape.
2026-10-09 11:42:52 -05:00
Malte Ubl aa6026efc1 Accept array callbacks whose parameter is a supertype of the element again (#798) 2026-10-09 11:39:37 -05:00
Chris Tate cd28e4d6ca Answer undefined for out-of-range typed array reads like Node (#797) 2026-10-09 11:37:16 -05:00
Chris Tate a9d026fa0d Keep worker terminate recursion test within stack bounds (#787) 2026-10-09 10:48:05 -05:00
Chris Tate 477f97a840 Track optional values precisely through callbacks, captures and pops (#784) 2026-10-09 10:33:07 -05:00
Chris Tate 3e41bcbfd0 Make compile blockers and Node differences actionable (#786) 2026-10-09 10:30:31 -05:00
Chris Tate 96ee89412b Keep exception polls inline in worker programs (#785) 2026-10-09 10:01:27 -05:00
Chris Tate 3a0eda638e Schedule cycle collection by yield and borrow indexed reads (#781) 2026-10-09 07:55:10 -05:00
Chris Tate f5a623b0b2 Observe forwarded optional values and asserted class arms (#783) 2026-10-09 07:49:53 -05:00
Chris Tate d3dd23fa6f Reuse cached runtime objects in the native LLVM emitter test (#782) 2026-10-09 06:38:13 -05:00
Chris Tate 2476844e13 Respect runtime optional values and initialization order in more places (#775) 2026-10-09 00:23:28 -05:00
Chris Tate 20beb97cbe Read in-bounds string elements without boxing (#779)
- String element reads proven to be inside the string (counted loops, `i < text.length` guards, early exits, const length aliases) give plain strings, so one-character helpers keep plain string parameters
- Unproven, fractional, negative and out-of-range reads still give `undefined`, with new corpus programs pinning both directions
2026-10-08 22:04:41 -05:00