Skip to content

Carry typed-array reads as plain numbers where undefined acts like NaN - #829

Merged
ctate merged 5 commits into
fix/lowerer-init-orderfrom
perf/typed-array-reads
Oct 11, 2026
Merged

ctate merged 5 commits into
fix/lowerer-init-orderfrom
perf/typed-array-reads

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

A typed-array read outside [0, length) answers undefined, so scriptc lowered every t[i] to a heap-boxed number | undefined and widened every binding, parameter and return it reached. In the tsc-ts scanner that boxed every character read and made every character predicate take a boxed union.

  • NaN-coded reads. A new frontend analysis (nan-coded-reads.ts) follows reads through locals, parameters of directly called functions and methods, and their returns.
    • Where every consumer treats undefined like NaN (relational, arithmetic and bitwise operators, truthiness, Math.*, String.fromCharCode, typed-array indexing, numeric switch, equality with a number that is never undefined), the read is a plain double holding NaN for an invalid index.
    • A slot that never holds a genuine NaN encodes undefined exactly. Consumers that take the value whole (arguments, stored or returned values, literal elements, concatenation) get the exact number | undefined rebuilt, so they lower as before.
    • Equality of two such values also tests for two NaNs, because undefined === undefined is true.
    • Everything else keeps the existing lowering; asserted reads (t[i]!) are unchanged.
  • Inline truncation on x86-64. Math.trunc and DataView offsets use an integer round trip instead of a libm trunc call. The read helper decides presence for integer element kinds with one inline index check. A truncation that did nothing in the cold ToUint32 path is removed.
  • Inline Math.min/Math.max via llvm.minimum/llvm.maximum, which match the JS semantics for NaN and −0.

tsc-ts, Linux, medians of 5, diagnostics identical: parse tRPC −31%, Excalidraw −29%, Playwright −30%, TypeORM −21%, self-check −27%; total single-threaded −4% to −17%; wall at 8 checkers −9% to −16%. Runtime benchmark suite geomean +0.4% to +0.6% (all within noise), executable sizes unchanged, tsc-ts binary −0.2%.

New corpus program typed-array-nan-coded-reads.ts pins: invalid, NaN, fractional, negative and −0 indexes; reads flowing through bindings, parameters and returns; rebuilt undefined at consumers that observe it; equality of two missing reads; float NaN elements next to missing reads; genuine-NaN slots; subarray views reading past their own length. Plus compiler tests for the IR shape and for inline truncation.

Second of a three-PR stack, on top of the TDZ fix.

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.
@vercel

vercel Bot commented Oct 10, 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 1:23am UTC

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.
@ctate
ctate merged commit 95ce00a into fix/lowerer-init-order Oct 11, 2026
82 of 84 checks passed

This branch was successfully deployed

1 active deployment
Preview — cc7c6cb7 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