Skip to content

Land the approved performance stack (#829, #830, #832, #833, #835, #837, #839) - #863

Merged
ctate merged 27 commits into
mainfrom
land/approved-stack
Oct 11, 2026
Merged

ctate merged 27 commits into
mainfrom
land/approved-stack

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

These PRs were approved and merged, but into their stack base branches (stacked PRs weren't retargeted to main after their base merged), so their code never reached main:

This PR merges perf/alloc-hugepages (which contains all of them via your merge commits) into main, plus three small CI fixes on top of your merges:

Merge conflicts with main: order-parity.json (union of entries), and scr_alloc.c/scr_alloc.test.ts, because main already has the worker exit trim (#858) adapted to the pre-hugepages allocator. Resolved by taking the hugepages allocator and re-applying the original trim commits that were written on top of it (clean apply on an identical base). Allocator and cycle-collector tests pass locally.

Not included: #841 and #843–#845, which were merged into the branches of #840 and #842; they depend on those still-open PRs and will land with them. After this merges, the open PRs that target stack branches (#836, #838, #842, #849) need retargeting to main.

cramforce and others added 27 commits October 10, 2026 14:03
Baseline x86-64 has no rounding instruction, so every llvm.trunc became
a call to libm's trunc. The typed-array read helper paid that call on
every read: it decided presence with `i >= 0 && i < length &&
Math.trunc(i) === i` before the inline element load.

- Math.trunc (and the DataView offset truncation) now take an integer
  round trip on x86-64 targets: values of magnitude below 2^52 go through
  fptosi/sitofp with the sign restored by copysign (so -0.5 and -0 give
  -0); larger values, NaN and the infinities are already integral and
  keep their value. Other targets keep llvm.trunc, which is one
  instruction there.
- The ToUint32 slow path is only reached for magnitudes above 2^53 (and
  non-finite values), which are integers, so its truncation was a no-op
  and is gone.
- For integer element kinds, the read helper performs the element read
  that yields NaN for an invalid index (one inline index check) and
  answers undefined exactly when that read is NaN: an integer element is
  never NaN. Float element kinds keep the range and integrality test.
A typed-array element read answers undefined for an index outside
[0, length), so the default lowering returns a heap-boxed
`number | undefined` and widens every binding, parameter and return the
read reaches. In the tsc-ts scanner that boxed every character read and
turned every character predicate (isLineBreak, isIdentifierPart, ...)
into a function taking a boxed union.

Most consumers cannot tell undefined from NaN: relational, arithmetic
and bitwise operators, truthiness, Math functions, String.fromCharCode,
typed-array indexing, switch over number cases, and equality with a
number that is never undefined. The new analysis (nan-coded-reads.ts)
follows reads through local bindings, parameters of directly called
functions and methods (no overrides, no value uses), and their returns.
A slot is NaN-coded when every value it receives is a NaN-coded read or
slot, or any other number (converted with ToNumber on entry), and either:

- it never holds a genuine NaN (integer elements, integer constants,
  guarded integer arithmetic), so NaN stands for undefined exactly; any
  consumer that takes the value whole (an argument, a stored or returned
  value, a literal element, concatenation) gets the exact
  `number | undefined` rebuilt, and the optional-read analysis treats
  that occurrence as optional, so it lowers as before; or
- every consumer is NaN-insensitive.

Equality of two NaN-coded values that never hold a genuine NaN also
answers true for two NaNs, as undefined === undefined does. Any other
slot keeps the established union lowering; asserted reads (`t[i]!`) are
unchanged.

tsc-ts best-v1 (scriptc main + this commit vs main, Linux sandbox,
Xeon 2.5 GHz, interleaved, medians of 5, diagnostics identical):

| app | parse | total (single-threaded) | wall --checkers 8 |
| --- | --- | --- | --- |
| tRPC | 1.263 -> 0.866 s (-31.4%) | 3.035 -> 2.527 s (-16.7%) | -13.0% |
| Excalidraw | 2.637 -> 1.865 s (-29.3%) | 14.99 -> 14.04 s (-6.3%) | -16.2% |
| Playwright | 3.150 -> 2.202 s (-30.1%) | 12.93 -> 12.14 s (-6.1%) | -12.4% |
| TypeORM | 2.562 -> 2.036 s (-20.5%) | 10.93 -> 10.46 s (-4.3%) | -8.6% |
| self-check | 2.080 -> 1.529 s (-26.5%) | 7.06 -> 6.68 s (-5.4%) | -15.1% |

The tsc-ts IR has 646 instead of 697 scr_union_new_f64 sites (the rest
are cold), 22 instead of 168 typed-read helper calls, and 217 instead of
572 scr_union_get_f64 calls; the scanner's hot functions allocate no
union boxes. Binary size -0.2%.
Every two-argument Math.min/Math.max was an out-of-line call to
scr_math_min/scr_math_max (94 sites in tsc-ts, including
compareCodePoints and IdentifierTable.intern). LLVM's minimum/maximum
intrinsics have exactly their semantics: NaN propagates and -0 orders
below +0. On x86-64 they lower to minsd/maxsd with inline NaN and
signed-zero handling, on arm64 to fmin/fmax.
The NaN-coded read analysis memoized lookups whose answer can be null
(symbols, eligible functions, call sites, plain-number returns) and read
them back with `map.get(key)!`. Under Node that returns the cached null,
but in the natively compiled compiler `!` is a checked extraction, which
threw "null is not representable" while lowering programs
(self-hosting-native-frontend, the native CLI bootstrap contracts). Read
the cache through an undefined check instead.
Every class slice used to opt out of transparent huge pages, so each
4 KiB the bump pointers crossed cost a page fault. Those faults (fault
entry, memcg charge, LRU insertion and the page clear) were 6-14% of
tsc-ts samples in the kernel. Backing the whole reservation with huge
pages removes most of them, but makes the 2 MiB page around every bump
pointer resident at once. A worker executable has up to 32 class slices
per script thread, so tRPC at 8 checkers grew from 558 to 955 MB.

A slice now starts on small pages. Once it has carved 8 MiB, the rest
of its range switches to huge pages (MADV_HUGEPAGE). Only slices that
already hold 8 MiB pay for a partial last huge page. The reservation is
huge-page aligned, so the switch point and every slice start on a 2 MiB
boundary. The slow path raises a slice's bump limit in two steps (to the
switch point, then to the slice end), so the inline fast paths are
unchanged. The policy is Linux-only; other targets keep one limit per
slice, as before. The allocator checks now build with _GNU_SOURCE on
Linux like the runtime packs, so the madvise advice is compiled in and
checked (alignment, contiguous carving across the switch, VmFlags nh/hg).

Measured with tsc-ts best-v1 (parallel-check + 6247795 e24057a 64d9af6
1c42c55), built with this runtime vs origin/main, on two Linux Vercel
Sandboxes (8 vCPU; 5-7 interleaved runs, medians). Columns: wall, max
RSS, minor faults; "all" is MADV_HUGEPAGE on the whole reservation:

  single-threaded   wall    RSS     faults         all: wall / RSS
  Excalidraw        -6.1%   +0.8%   331k -> 112k   -10.0% / +3.5%
  Playwright        -1.8%   +1.0%   371k -> 111k    -8.4% / +3.2%
  TypeORM           -7.2%   +0.9%   259k ->  75k   -11.8% / +4.1%
  tRPC              -5.0%   +2.0%   101k ->  47k    -9.0% / +11.3%
  tsc-ts self-check -2.9%   +2.6%   158k ->  64k    -3.1% / +8.4%

  --checkers 8
  Excalidraw        -2.9%   +2.6%  1075k -> 508k    -6.3% / +10.6%
  Playwright       -10.0%   +2.6%   739k -> 381k   -10.3% / +12.2%
  TypeORM           -5.9%   +2.2%   372k -> 232k   -10.1% / +27.7%
  tRPC              -2.5%   +2.5%   159k -> 125k    -4.3% / +71.2%
  tsc-ts self-check -4.0%   +3.9%   213k -> 172k   -12.1% / +46.3%

Switching at 2 or 4 MiB was up to about 1.5 points faster single-threaded
but cost up to +11% RSS at 8 checkers. 16 MiB gave back most of the gain.
Prefaulting small pages with MADV_POPULATE_WRITE did not reduce sys time.
Collapsing the small-page prefix with MADV_COLLAPSE at the switch did not
help either. The runtime benchmark suite (--layouts=4 --runs=16) shows a
geomean change of -1.2%: inventory-report -9.9%, validate-errors -5.3%,
log-summary -2.9%, and regex-logs +2.1%. regex-logs peaks at 6.8 MB and
never reaches the switch point; with one binary the policy measures -0.9%
there, so its delta comes from code layout.
A typed-array read that may name a missing element lowers to a boxed
`number | undefined`, and the optional-value analysis widens every
parameter it reaches for all callers. Two patterns in tsc-ts's relater
boxed its hottest relation methods this way, failing the build's scalar
ABI check ("Boxed numeric arguments in Checker.isTypeAssignableTo"):

- `const regular = this.regularTypes[id]; const sourceId = regular >= 0
  ? regular : id;` The read is NaN-coded, but its occurrence in the true
  arm was rebuilt as `number | undefined`, so `sourceId` and the
  parameters of isSimpleTypeRelatedTo and structuredTypeRelatedTo were
  boxed. A comparison with a number that is never NaN is false for
  undefined and NaN alike, so an occurrence of a never-reassigned binding
  under such a guard is a plain, non-NaN number. The analysis now treats
  it as one wherever it flows.

- `for (let i = 0; i < sources.length; i++) isTypeAssignableTo(sources[i],
  targets[i])` over two Uint32Arrays allocated with the same length. The
  parameters also receive a JSON field that may be NaN, so NaN-coding
  cannot carry them (`source === target` must tell NaN from undefined).
  The reads are now proven present instead: the index is a non-negative
  integer counter that only its own function writes, a dominating guard
  bounds it by the array's length or by the length of a parallel typed
  array allocated with the same length, and each array is a `const`
  fixed-length typed array referenced only through element accesses and
  `.length`, so no code can detach its buffer. A proven-present read
  lowers to the plain element read and is never optional.

indexReadInBounds also accepts a read inside an `if` condition and takes
an optional same-length fact. Reads the proofs do not cover keep the
established lowering: an inclusive bound, a shorter parallel array, a
decreasing or fractional counter, a counter a closure writes, and an
array that escapes all still read undefined, as the new corpus program
typed-array-present-reads checks against Node.
Code holding the array could detach its buffer between the guard and the
read, so the present-read proof must refuse it. With a genuine NaN also
reaching the parameter and a consumer that observes undefined, the
parameter stays `number | undefined`.
indexReadInBounds proves only `i < xs.length`, not that `i` is a
non-negative integer. For `if (f(xs[i]!))` the forwarded-assertion path
used to pass a missing element as undefined; accepting `if` conditions for
every caller turned such reads into the dense bounds trap, which throws for
a negative index where Node passes undefined (tsc-ts: isMatchableRegion and
compareSymbolChains changed ABI). The `if`-condition position is now an
opt-in fact, used by the present-read proof, which also proves the index a
non-negative integer counter.
One new entry (single-module order, no diagnostics); every existing entry
is unchanged.
Mature and old passes are scheduled by heap growth. While growth-triggered
passes at a level keep finding nothing, the fraction they wait for widens by
doublings from 1/4, and it used to stop at 1, so a large live heap that keeps
growing was walked in full once per doubling. On tsc-ts those walks are most
of the collector's time: in single-threaded Excalidraw, 12 growth-triggered
full passes cost 2.0 s of 14.7 s, the last two walking 2.6M and 5.2M objects
to free 1.4%.

The cap is now 16. That is a memory trade-off: once passes stop finding
garbage, the cycle-headered heap may grow to 17 times its size after the last
pass at a level before the next (it was 2 times), so a program that
accumulates dead cyclic garbage while passes find nothing can hold up to 16
times more of it. Yield-driven narrowing is unchanged: a pass that finds old
garbage moves the fraction straight back to what its yield asks for.

SCR_CYCLE_GROWTH_CAP lowers the cap for memory-sensitive programs, read like
SCR_CYCLE_THRESHOLD: a power of two from 1 to 16 (other positive values round
down, larger ones read as 16); 1 restores the previous schedule.

test_cycle pins the cap on a heap at the growth floor: a dead cycle through
the old generation must go exactly when the heap has grown the configured cap
past the floor (not one doubling earlier, not later), and a pass that finds
old garbage must narrow the fraction at once. cycle.test.ts runs it at the
default cap and at SCR_CYCLE_GROWTH_CAP=1, 3 and 64. The large-chain widening
check keeps its walk bound but no longer waits out the cap on a 266k-node
structure, and now runs after the tests that assume the base schedule.

Measured with tsc-ts origin/parallel-check e469e33, built with this runtime
vs the parent commit, interleaved in one Linux Vercel Sandbox (8 vCPU; 5
rounds, medians; --checkers 8 repeated with 10 rounds). Diagnostics identical.
"cap 1" is this build with SCR_CYCLE_GROWTH_CAP=1:

  single-threaded   wall     RSS            cap 1
  Excalidraw        -10.1%   1283 -> 1263   +0.5%
  Playwright        -11.0%   1446 -> 1457   +0.3%

  --checkers 8
  Excalidraw        -8.7% / -4.5%   2110 -> 2132 / 2115 -> 2125   +2.6% (10 rounds)
  Playwright        -7.8% / -7.7%   2616 -> 2634 / 2589 -> 2678   -1.0% (10 rounds)

RSS in MB (max of two runs). The runtime benchmark suite's allocation-heavy
workloads (alloc-trees, word-graph, ast-interp, json-records, records-sort,
functional-pipeline; --layouts=4 --runs=16) are neutral in time and peak RSS
(-0.3% to +1.7%).
String.fromCharCode, String.fromCodePoint, the global isNaN and isFinite,
DataView offsets and values, and typed-array slice/subarray bounds took a
`number | undefined` value (an out-of-range read the checker types as
number) through a checked narrow, which threw "undefined is not
representable" or refused to compile. Node converts undefined with
ToNumber: code unit 0, a RangeError for fromCodePoint, isNaN true, offset
0; an undefined slice end is the length.

The Math argument conversion becomes a shared helper that also handles
field reads and any other value lowered as a number-or-undefined union.
After `obj.field = bytes[i]` on a `number | undefined` field, TypeScript
narrows later reads to number, so the read extracted the number arm
unchecked and an out-of-range read printed 0 instead of undefined. Locals
declared `T | undefined` had the same problem inside functions
(`let x: number | undefined; x = xs[i]; x + 1` gave 1, not NaN).

The optional-read analysis now records instance fields declared exactly
`T | undefined` that receive a possibly missing value; their narrowed
reads keep the stored union, as runtime-optional record fields already
do, and the value propagates into parameters and returns like other
optional reads. Locals declared that way join the runtime-optional
storage tracking, so bare reads keep the union until a guard proves
presence. A member read through such a field throws Node's "Cannot read
properties of undefined" TypeError.
TypeScript types `read ?? "text"` as number when the read is typed number,
and `() => xs[i]` as returning number. Declared functions already widened
their returns for both (the primitive result union and the optional
read), but arrow functions and function expressions kept the checker's
return, so the closure narrowed its result and threw "a 'string' value is
not representable" or "undefined is not representable" (the scanbox
`[string, () => unknown][]` probe table).

Unannotated TypeScript lambdas now get the same widened return as
declared functions. A const or global binding of such a lambda keeps the
checker's parameters and takes the widened result, and calls through the
binding count as optional or primitive-union results for the enclosing
function's return.
Array, string and byte lengths, counts, capacities and range-checked indices are size_t values below 2^63, but they were converted with a plain uitofp. On x86-64 without AVX-512 an unsigned 64-bit conversion expands to a five-instruction sequence with two constant-pool loads (unpcklps/subpd/unpckhpd/addsd), and it showed up in the hottest tsc-ts checker loops (compareTypeLists, getPropertyOfTypeEx) between a length load and the conversion back to an integer. `uitofp nneg` states what is already true, so the backend emits one cvtsi2sd. arm64 converts unsigned values in one instruction either way. Byte-number indices, which admit values up to 2^64, keep the unsigned conversion.
The Node-hosted CLI emits with an empty target triple (the host), while
the native CLI passes its toolchain's triple. Inline truncation keyed on
the triple text alone, so on x86-64 Linux the Node seed emitted
llvm.trunc and the native compiler emitted the inline sequence, and the
native bootstrap's LLVM comparison with the Node seed failed
(self-hosting-native-driver, rebuild-emit). An empty triple now means
the host architecture, as it already does for executable TLS; WASI keeps
the intrinsic. The DataView emission test accepts either truncation
form, since it runs on hosts of both kinds.
…al cycle

The cycle analysis gave a header to every type that could reach a cycle,
and its construction-only facts gave up on a whole hierarchy as soon as a
constructor let `this` escape or a type could cross into checked-dynamic
code. Four refinements, all derived from the final IR:

- A constructor's stores stay construction-only up to the first top-level
  statement in which `this` escapes. Statements run in order, so earlier
  stores complete while the object is private; stores from the escaping
  statement on, and every store of a constructor whose ancestor let `this`
  escape, are writes. Compound-assignment receiver temporaries count as
  `this`. An all-immutable cycle is still impossible: each immutable edge
  a -> b is stored before a escapes while b has escaped already.
- Crossing into checked-dynamic code only makes types mutable when the
  program contains a runtime store into a dynamic value (int32-slots'
  audit, now shared as dynamicFieldStores): typed-ref capsules write native
  fields only through such a commit. Without one, hidden property bags
  stay empty and add no edges. FFI operands and intrinsics other than
  publish stay conservative.
- A type is traced iff it lies in a cycle-capable component or reaches an
  intrinsic reference. A type that merely holds a cycle cannot be reached
  back from it: its references are external counts.
- Reachability is per class: a slot of static type C reaches C's subtree,
  not the whole hierarchy; header presence stays uniform per hierarchy.

tsc-ts (e469e33): traced classes 603 -> 469 of 700, records 64 -> 10 of
202, unions 344 -> 93 of 482; scr_cyc_alloc sites 659 -> 471. Mutable
hierarchy units 243 -> 4. Collector candidates on the self-check
(single-threaded) 6.02M -> 5.55M.
Move busy small-object slices to transparent huge pages
Keep guarded and in-bounds typed-array reads out of the optional ABI
Let the cycle collector's growth fraction widen to 16
Keep missing reads undefined through number arguments, narrowed slots and lambda results
Convert lengths and in-range indices to doubles as non-negative
Brings in cc7c6cb (inline truncation decided for the host when no triple is given), which this branch predates; without it the native bootstrap's LLVM comparison with the Node seed fails on x86-64 Linux.
…ases

Since #837 a type is traced only if it lies in a cycle-capable component or reaches an intrinsic reference; a type that merely holds a cycle cannot be reached back from it. In both backend-analysis cases `record:outer` only holds `record:cycle`, so the Node oracle and the native compiler agree on ["record:cycle"] and the expectations still listed `record:outer`.
The `nneg` flag on `uitofp` is LLVM 19 syntax. Sanitized builds hand the textual module to the host clang, and Ubuntu 24.04's clang 18 rejects it ("expected type"), which failed every Linux sanitized Effect shard. These operands are size_t values below 2^63, so `sitofp` converts them identically, is accepted by every supported LLVM, and still lowers to one cvtsi2sd on x86-64 (LLVM 19+ canonicalizes it to `uitofp nneg` where it can prove the sign).
…, #839)

These PRs were merged into their stack base branches instead of main. This merges perf/alloc-hugepages (with the CI fixes below) into main. Conflicts: order-parity.json (union of entries); scr_alloc.c/scr_alloc.test.ts, where main already has the worker exit trim (#858, adapted to the pre-hugepages allocator): resolved by taking the hugepages allocator and re-applying the original trim commits written on top of it (ea88b87c + 4816696d, clean apply on an identical base).
@vercel

vercel Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Oct 11, 2026 3:58pm UTC

@ctate
ctate merged commit 6cc426c into main Oct 11, 2026
81 of 84 checks passed

This branch was successfully deployed

1 active deployment
Preview — 5c3f6f80 Deployed Oct 11, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants