Repository navigation
Land the approved performance stack (#829, #830, #832, #833, #835, #837, #839) - #863
Merged
Merged
Conversation
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
Trace only types that can lie on a cycle
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).
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
These PRs were approved and merged, but into their stack base branches (stacked PRs weren't retargeted to
mainafter their base merged), so their code never reachedmain:This PR merges
perf/alloc-hugepages(which contains all of them via your merge commits) intomain, plus three small CI fixes on top of your merges:5ed8842dmergesperf/typed-array-readsintoperf/alloc-hugepages: the branch predated Carry typed-array reads as plain numbers where undefined acts like NaN #829's CI fixcc7c6cb7(inline truncation decided for the host when no triple is given), which every child branch needs for the native-bootstrap test.fb291122updates theself-hosting-backend-analysisexpectation for Trace only types that can lie on a cycle #837: only cycle members are traced now, sorecord:outeris correctly untraced.2077bc9e(merged infac59cfe): Convert lengths and in-range indices to doubles as non-negative #839 emitteduitofp nneg, LLVM 19 syntax that the sanitized Effect job's clang 18 can't parse; the same conversion is now spelledsitofp(equivalent for these non-negative values).Merge conflicts with
main:order-parity.json(union of entries), andscr_alloc.c/scr_alloc.test.ts, becausemainalready 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.