Skip to content

tsc-ts -8.6%: borrow checker hot paths (walks, wraps, casts, borrowed returns) - #806

Merged
ctate merged 9 commits into
mainfrom
perf/tscts/10-checker-hot-paths
Oct 10, 2026
Merged

ctate merged 9 commits into
mainfrom
perf/tscts/10-checker-hot-paths

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

tsc-ts self-check (median of 7, interleaved, identical diagnostics): 2.014 s → 1.841 s (−8.6%), check phase 1.433 s → 1.269 s (−11.4%), instructions retired 31.2 G → 26.7 G (−14.7%), binary 16.50 → 15.51 MB (−6.0%), max RSS +12 MB (+1.9%). Runtime suite: all 17 workloads neutral, geomean −1.2%, size unchanged.

The checker's hot functions (compareTypes/compareNodes, getSourceFileOfNode, isTypeRelatedTo, sameLiteralValue, maybeTypeOfKind) spent most of their compiled time on reference counting for values that stay reachable from their arguments. This PR proves more of those values borrowed:

  • Nullable wraps, element reads, preserving virtual calls. Wraps into T | undefined borrow when their payload does; required element reads borrow the element. Reference-effect analysis now covers switch, primitive formatting and virtual calls whose every override preserves references.
  • Checked-cast aliases. const x = a as Sub borrows a.
  • Walk locals and walk parameters. In functions that never remove a heap reference, locals and parameters defined only by projections of borrowed parameters (p = p.parent) hold borrowed pointers.
  • Borrowed returns. Such functions return walks of their parameters without a reference; direct callers consume the result in place or retain it after the exception check, and owned adapters retain it. Implementations of borrowing vtable slots (tsc-ts -5%: borrow field-store receivers, virtual parameters and array projections #799) are excluded, because virtual dispatch reaches their borrowing body directly.
  • Rebound parameters with an owner slot. Parameters reassigned by plain statements keep the borrowed convention; assigned values live in a separate owner slot.
  • One exception-cell load per synchronous body. Pending-exception checks read only the cell's kind; every fiber switch restores the caller's cell before control returns. Async functions, generators, workers and libraries keep their existing checks.
After Instructions Step
main 31.24 G
wraps, element reads, cast aliases 29.08 G −6.9%
walk locals and parameters 27.47 G −5.5%
exception-cell load 27.33 G −0.5%
borrowed returns 26.83 G −1.8%
rebound parameters 26.66 G −0.6%

Tests

Corpus 4541–4546 pin each behavior under Node and as native binaries in both lanes, including mutating callees, mutating overrides, throwing accessors, and virtual accessors whose results outlive every other owner (without the vtable exclusion the sanitized lane reports a heap-use-after-free). Checked locally on this branch: compiler typecheck, lint, format, pnpm test:ts7 --baselines-only, the native CLI compiling itself with no diagnostics, self-hosting-emission and inheritance tests, and the backend unit tests (512 tests).

A wrap into a nullable-pointer union is the payload pointer itself (or an
immortal unit constant), so it can borrow exactly when its payload can,
as a receiver and as a call argument. tsc-ts widens many `Type` and
`Node` parameters to `T | undefined` (they may receive unchecked array
reads), so `compareTypes(c, t1, t2)` wrapped `t1` for every identity test
and call and retained and released it each time.

Required array element reads may borrow as receivers while the borrowed
array owns the element and the index preserves heap edges (array
operands already borrow; this borrows the element itself).

Reference-effect analysis now sees through switch statements, class
references, primitive formatting and virtual calls: a virtual call
preserves edges when every implementation reachable from its static class
(the inherited one plus every override below it) does. Runtime classes
keep dispatch opaque.
`const x = a as LiteralType` lowers to a ternary whose failing arm always
throws and whose successful arm narrows and casts the unchanged source.
The alias projects the same stable owner, so it can borrow like a plain
copy of the binding instead of retaining at the declaration and releasing
at scope exit. Call arguments of the same shape borrow too.

tsc-ts's sameLiteralValue (two casts per call, on the relation hot path)
loses all of its reference counting.
A local whose every definition projects an unwritten parameter or another
such local through plain field reads, casts, nullable narrows/wraps and
checked ternaries (the parent walk `p = p.parent`) now holds a borrowed
pointer: no retain at the definition, no release when it is rebound or
leaves scope. This is sound only in functions whose whole body preserves
heap edges (ReferenceEffects.functions): nothing they run removes a field,
element or global reference, so every object reachable from a parameter
at entry stays alive until the function returns. Whole-value uses still
retain their own copies.

The unreachable-completion fence after a `for (;;)` loop throws a fresh
constant error and preserves edges like `throw`; it used to make every
such function look mutating.

tsc-ts's getSourceFileOfNode loop did two increments and two full
decrements (with cycle-candidate checks) per parent step; it now only
loads and tests the parent pointer.
A parameter that is rebound only by statement assignments of walk
projections (`source = (source as LiteralType).regularType`) used to
become an owned parameter: every caller retained the argument, the callee
released it on exit, and each rebinding retained the new value and
released the old. In an edge-preserving function its incoming value is the
caller's borrow and every later value is reachable from the parameters, so
it can stay borrowed like any walk local.

The walk analysis now runs once per module before call lifetimes, which
merge these parameters into the borrowed set. Unchanged-parameter users
(stable call arguments and aliases, frame-long array element borrows)
still only see parameters that are never rebound.

tsc-ts's isTypeRelatedTo, which normalizes fresh literal types this way
on entry, no longer touches reference counts for its type arguments.
Every inline pending-exception check in an executable loaded the
runtime's active-cell pointer and then its kind: two dependent loads
after each call that may throw, because LLVM must assume any call can
change the pointer. Fiber switches restore the active cell before control
returns to a synchronous frame (stack switches, eager spawns and
generator resumes all swap back to the caller's cell), so the pointer is
one value for the whole invocation. Synchronous IR function bodies now
load it once in the entry block and each check reads only the kind.

The flag lives on the body's BlockBuilder, so adapters and runtime
helpers keep the per-check load. Async functions, generators and worker
executables (whose checks also fold in the context stop signal) keep the
existing form; libraries still test through scr_exc_pending.
An edge-preserving function whose every return is a walk of its borrowed
parameters (accessors such as `node.children`, `t.types`, parent walks,
or a call to another such function) now returns its result at +0 from the
borrowing body. The result stays reachable from the arguments, so direct
callers that consume it as a receiver, a walk, or a borrowable argument
use it in place; every other direct caller tests the pending exception
and then retains it, before the argument snapshots that keep it reachable
are released. The owned adapter retains a present result (a throwing body
returns a null dummy, which string and array retains do not accept) before
releasing its parameters. Candidates borrow every reference parameter,
have no try statements and return a plain reference (instances, records,
arrays, strings, nullable-pointer unions).

Implementations of a vtable slot that borrows parameters are excluded:
virtual dispatch reaches their borrowing body (or its virtual adapter)
directly and its callers own the result. Without this, corpus 4545's
virtual accessors hit a heap-use-after-free in the sanitized lane.

The set grows to a fixpoint, since a call to a borrowed-return function
with walk arguments is itself a walk; walk facts are then recomputed with
the final set, admitting only parameters whose convention borrows.
Walk locals now cover every plain reference type, not only instances.

In tsc-ts, compareNodes -> sourceFileOf -> getSourceFileOfNode and
maybeTypeOfKind's loop over typesOf(t) no longer touch reference counts.
A reference parameter that the body rebinds made the whole parameter
owned: every caller retained the argument and the callee released it on
exit, even when the rebinding never ran. tsc-ts's isTypeRelatedTo starts
with `if (isFreshLiteralType(source)) source = ...regularType` and was the
largest single source of Type releases.

When every write is a plain statement assignment, the parameter now keeps
borrowing the caller's argument; each assigned value moves into a
separate owner slot that the next rebinding releases and replaces and
that the function scope releases on every exit. Reads use the parameter
slot, so whole-value uses still retain. Expression-position writes,
captures, loop and catch bindings, suspending bodies and string
parameters (whose in-place append needs a uniquely owned binding) keep
the owned convention. Unchanged-parameter users (stable call arguments,
aliases, frame-long element borrows) still exclude these parameters.

The call-lifetimes package test now expects `snapshot` to keep its
borrowing body: the alias saved before the rebinding still owns its value.
@vercel

vercel Bot commented Oct 9, 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 10, 2026 5:25am UTC

Comment on lines +7330 to +7332
for (const fn of preserving) {
const walks = analyzeWalks(fn, hostFor(fn)).locals;
if (walks.size > 0) this.walkBorrowsByFunction.set(fn.name, walks);

@vercel vercel Bot Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A reassigned borrowed parameter that only walked an unborrowed parameter stays in callLifetimes.borrowed but is dropped from the final walk-borrow set and never marked rebound, so its reassignment takes the generic assign path and releases the caller's +0 borrow (refcount underflow / use-after-free).

Fix on Vercel

Borrowing checked-cast aliases taught canBorrowCallArgument to accept a
checked projection: the always-throwing `error.nodeThrow` call, a
ternary whose failing arm throws, and the narrow of its successful arm.
A borrowed use still evaluates them, but emitDiscarded also used that
predicate to skip a discarded sequence's result as a pure read, so a
statement like `String.prototype.trim.call(undefined)` lost its
TypeError (corpus 3040, 3042, 3043 and 2113 on CI).

Give the discard path its own predicate that only skips effect-free
reads, as before.
@cramforce

Copy link
Copy Markdown
Contributor Author

Pushed Keep throwing checked projections in discarded statements: the cast-alias borrowing commit made emitDiscarded skip discarded statements whose value is a checked projection, dropping their throw (corpus 3040/3042/3043/2113). Verified locally: those programs and native-worker-stack* match Node again; corpus 4541–4546 pass in both lanes; the native CLI still compiles itself with no diagnostics. The library-multi M1 failure also reproduces on current main locally (not from this PR).

@ctate
ctate merged commit cbbdf15 into main Oct 10, 2026
81 of 83 checks passed

This branch was successfully deployed

1 active deployment
Preview — 69b15234 Deployed Oct 10, 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