Skip to content

Drop guards on private constructor stores and borrow construction-only fields - #838

Merged
ctate merged 25 commits into
mainfrom
perf/static-immutability
Oct 12, 2026
Merged

ctate merged 25 commits into
mainfrom
perf/static-immutability

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

Two uses of the construction-only facts outside the collector, in backend/immutability.ts:

  • Publication guards. A store into the constructor's own this that is not yet observable cannot target a published object. The conditions: the store comes before the constructor's escape point, no ancestor constructor escapes, and the constructor is never invoked directly on an existing object. Such a store loses its rc == SIZE_MAX guard. It also stops seeding may-throw, so constructors that could only throw through guards lose their pending checks at every new.
  • Borrowed reads. x.f (an object, record, array or string) is borrowed as a call argument, receiver or stable alias when f is construction-only and x is itself borrowable. Nothing can replace and release it while x lives. This does not apply inside constructors. Module constants (immutable, not TDZ-checked, assigned at most once) are borrowable too.

Nothing is frozen or marked at run time. Object.isFrozen, publication errors and store order are unchanged.

Measured on tsc-ts, both PRs against #830, Linux sandbox, interleaved medians:

single-threaded (6 runs)   base     new     change
Excalidraw                 8.62 s   8.34 s  -3.2%
Playwright                 7.83 s   7.71 s  -1.5%
tsc-ts self-check          4.36 s   4.22 s  -3.0%
tRPC                       1.63 s   1.58 s  -3.1%
TypeORM                    6.68 s   6.50 s  -2.5%
--checkers 8 (10 runs): -2.3% .. +0.7% (neutral)

Static counts: published-field guards 6,528 → 4,462, sc_release calls −11%, sc_retain calls −16%. Binary 23.68 → 22.96 MB.

Executed on the self-check: guards 96.1M → 60.8M, retains −9.6%, releases −7.7%.

Removing every publication guard (unsound, measurement only) stays within noise, so the time comes from borrowing and tracing, not from the guards.

Runtime suite (--layouts=4 --runs=16): geomean −1.9%. ast-interp −4.4%, validate-errors −3.8% and regex-logs −1.7% are faster. template-render is +1.4% slower. The rest are neutral, and sizes are unchanged.

Tests: new immutability.test.ts unit tests, and an adjusted string-lifetimes test (its record field was never written, so the borrow is sound; the test now adds a writer to keep covering the snapshot path). New corpus programs:

  • 4598-publish-constructor-stores: escaping constructors must still throw Node's frozen-object errors.
  • 4599-construction-only-field-borrows: callees drop every other reference before using a borrowed field; a field written by a method; a constructor reading its own field while a later operand replaces it.
  • 4600-construction-only-fields-dynamic-writes: writes through any and structural casts while the field is being read.

All pass the plain and sanitized lanes. Preflight/order baselines are recorded for the five new fixtures.

Known risk: borrowed field reads rely on every field write being visible in the IR or in the untyped-store audit; a runtime helper writing class fields directly would become a use-after-free. Stacked on the cycle-tracing PR.

…y fields

Two uses of the construction-only facts outside the collector
(backend/immutability.ts):

- Publication guards: a store into the constructor's own `this` that lies
  before the constructor's escape point, in a constructor no ancestor of
  which lets `this` escape and that is never invoked directly on an
  existing object, cannot target a published object. It loses its
  rc == SIZE_MAX guard and no longer seeds may-throw, so constructors that
  could only throw through guards lose their pending checks at every `new`.
  Storing `this` into one of its own fields does not expose it.
- Borrowed reads: `x.f` holding an object, record, array or string is
  borrowed as a call argument, receiver or stable alias when `f` is
  construction-only and `x` is itself borrowable (unchanged local,
  borrowed parameter, or such a projection): nothing can replace and
  release it while `x` lives. Not inside constructors, which may still
  write their own fields. Module constants (immutable, not TDZ-checked,
  assigned at most once) are borrowable too.

No object is frozen or marked at run time; publication errors, store order
and reflection are unchanged.

tsc-ts (e469e33), static: published-field guards 6,528 -> 4,462,
sc_release calls 146,056 -> 129,756, sc_retain calls 26,215 -> 22,123,
scr_arr_release 29,865 -> 25,617, binary 23.68 -> 22.96 MB (both commits).
Self-check single-threaded, executed: guards 96.1M -> 60.8M, retains
273.5M -> 247.2M, releases 340.3M -> 314.0M.

Linux sandbox, interleaved medians, both commits against
perf/alloc-hugepages:

  single-threaded (6 runs)   base     new     change
  Excalidraw                 8.62 s   8.34 s  -3.2%
  Playwright                 7.83 s   7.71 s  -1.5%
  tsc-ts self-check          4.36 s   4.22 s  -3.0%
  tRPC                       1.63 s   1.58 s  -3.1%
  TypeORM                    6.68 s   6.50 s  -2.5%

  --checkers 8 (10 runs): -2.3% .. +0.7% (neutral)

Runtime suite (--layouts=4 --runs=16): geomean -1.9%; ast-interp -4.4%,
validate-errors -3.8%, regex-logs -1.7% faster, template-render +1.4%
slower, the rest neutral; sizes unchanged.
@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 12, 2026 6:10am UTC

@vercel-security-reviewer

Copy link
Copy Markdown

Security review details

ReferenceEffects answers one bit per function: does anything remove any
heap edge? Any reference store in the transitive callees (including a
constructor's stores into its own fresh `this` behind every `new`, a
closure call, or atomics in symbol-id allocation) disabled borrowing of
field-read and rebound-local arguments, walk locals, borrowed returns and
loop element borrows.

backend/llvm/reference-writes.ts keeps the location instead: per-function
transitive write sets over class fields (keyed by hierarchy root), record
fields, array elements, map entries, boxes, globals and the current
function's own locals, with key 0 as an unknown heap write. Locals stay
exact under unknown writes: no callee can rebind a caller's unboxed local.
Constructor stores into their own `this` drop out of `new`/`super`
summaries (checked: constructors run only through `new` and `super`).
Indirect calls resolve closure targets through function-typed parameters,
captures and adapters, otherwise the union of all closure bodies; runtime
library families that run no user code count as inert.

Uses:
- arguments of borrowed parameters (direct, `new`, virtual) and of
  projection-only array-read parameters borrow when neither the callee
  nor a later argument writes a location they depend on;
- stable locals initialized by a frame-unowned projection borrow when the
  rest of their block writes none of its locations; optional element
  reads into projection-only locals likewise;
- loop element borrows need only an iteration that writes no array
  element (nor the array's binding);
- walk locals and borrowed returns are admitted in bodies whose whole
  write set misses the walked locations.

tsc-ts e469e33 self-check (single-threaded, executed, non-null and
non-immortal RC operations in emitted code, measurement build):
505.3M -> 307.6M (-39%) before return moves (next commit).
Output identical. Build time unchanged (98.5 s vs 99.9 s, Linux).
`return x` of a local whose slot is an owned scope entry of the return
type loaded the value, retained it for the caller and released the local
at scope exit. Outside finally regions the return now takes over the
local's reference: no retain, and the local's entry is skipped by the
return's releases.

tsc-ts e469e33 self-check, executed non-null non-immortal RC operations
in emitted code: 320.2M -> 307.6M (-3.9%) on top of the previous commit.
Safe index reads of possibly-missing elements lower to lifted helpers
(%arr.idxOr.N) that returned an owned element. Walks now admit required
element reads of walked arrays (an index whose computation removes no
edge), so these helpers qualify as borrowed returns: their body lends the
element its array keeps alive (borrowing the optional read directly), and
receivers consume the call's result without a retain/release pair, as for
any borrowed-return call. Paged lookups such as
`this.pages[id >> 8]?.[id & 255]` no longer retain the page array.

Corpus 4630-4633 renumbered from 4620-4623 (checkerhot uses 4620/4621);
4634 pins paged lookups and a read followed by a removal.
…ubset

The natively compiled compiler rejected compound assignments to typed-array
elements, Uint32Array.some, a chained Set.add and a structural Pick of
ImmutableFields; bit operations now go through small helpers. The dense
reference-read test truncates its array first: a bare element return is
now lent as a borrowed return.
Two expectations predate the reference-write analysis and return moves on this branch. `keep(item, items) { items.push(item); return item; }` now qualifies as a borrowed-return function: its write set (array elements) misses the walked parameter, so the body retains only the appended element, returns the parameter +0 while the caller still owns it, and the owned adapter retains the result before releasing its parameters. In `snapshot`, `return saved` moves the saved alias's owned scope entry into the result rather than retaining it again and releasing the local at scope exit, leaving one retain.
… element

The reference-write analysis borrows an argument when neither the callee nor a later argument writes a location it depends on. A later argument that only throws a fresh Error writes no array element, so `read(items[0], fail())` now borrows the element from the borrowed parameter's array and has nothing to release on any edge; the test pins that. The exception-edge release is now exercised by a later argument that pops the array before throwing, which still retains the snapshot and releases it before the exceptional return.
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`.
# Conflicts:
#	packages/compiler/test/ts7/baselines/order-parity.json
# Conflicts:
#	packages/compiler/test/ts7/baselines/order-parity.json
@cramforce
cramforce changed the base branch from perf/trace-cycle-candidates to main October 11, 2026 16:37
# Conflicts:
#	packages/compiler/src/backend/llvm/emitter.ts
#	packages/compiler/test/ts7/baselines/order-parity.json
Borrow and move references whose owners provably outlive them
# Conflicts:
#	packages/compiler/src/backend/llvm/emitter.ts
#	packages/compiler/test/ts7/baselines/order-parity.json
ctate and others added 3 commits October 11, 2026 13:08
f3cfa80 asserted that the canonical byte loop contains no `fptosi double`
at all, meaning no exact-index round trip. The fixture also coerces its
running sum with ToUint32, whose inline fast path legitimately uses
`fptosi double ... to i64`, so the test fails on main (aaccbb3) as well;
#865 merged before the shards that run it. Assert instead that no
`bytes.index` check block is emitted, which keeps the original intent
(no unproven-index path for a proven in-bounds induction index).
# Conflicts:
#	packages/compiler/test/ts7/baselines/order-parity.json
@ctate
ctate merged commit c5c6657 into main Oct 12, 2026
83 checks passed
@ctate
ctate deleted the perf/static-immutability branch October 12, 2026 06:20

This branch was successfully deployed

1 active deployment
Preview — 2ea30ede Deployed Oct 12, 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