Skip to content

Capture bindings that are never rebound by value - #3294

Merged
ctate merged 2 commits into
mainfrom
perf/capture-by-value
Nov 7, 2026
Merged

ctate merged 2 commits into
mainfrom
perf/capture-by-value

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

What changes

A captured binding lives in a heap box so that its function and every closure share one mutable variable. A binding that is filled once before a closure over it is created, and that nothing rebinds afterwards, needs no shared cell, because no code can observe the difference. For those bindings the closure's environment now holds a copy of the value, and a binding whose every capture is of that kind has no box at all.

function lookup(source: string) {
  const table = load(source);
  return (key: string) => table.get(key); // one closure; before: a box, a box retain, a box release per exit
}

This is an optimization of typed code, so executables change on purpose. The numbers are below.

How

  • Which captures (backend/llvm/value-captures.ts, one walk per function that has a boxed local). An environment slot receives a value when every creation site of that closure supplies a stable binding whose fill has already run there: a parameter nothing rebinds; a binding declared once with an initializer (destructuring lowers to these); a binding declared empty and assigned once by a later statement of the same statement list (nested function declarations, let x; … x = v;); a for counter that only the update clause steps (each iteration has its own binding, and the update runs on the next one's copy); or a capture the creating closure itself received by value, to any depth.
  • What keeps its box. Temporal-dead-zone bindings (a closure exists before the initialization, so every read stays a checked read); a binding captured inside its own initializer or before its one assignment (const f = wrap(() => f), the first of two mutually recursive function declarations); declarations a switch can skip; bindings a local class captures; catch bindings; anything rebound, in the function or in any body that reaches it through captures, including the jsval twin that shares a closure's environment; signature adapters, whose first capture the runtime reads as a box; debug builds.
  • The IR does not change. boxed keeps meaning shared-binding semantics and every IR analysis keeps excluding those locals. The choice is a backend storage strategy beside lazy parameter boxes and frame boxes: the emitter sees such a binding as a plain local (the override lazily boxed parameters already use), and a closure reads it with an ordinary read. A closure that is only invoked on the spot borrows the value; one that escapes takes its own reference. The copy that copyStableCaptures makes of a stable binding borrows from its source in the same scope instead of holding a second reference.
  • The runtime. A closure with captured values is laid out as header, boxes, a pointer to a constant descriptor, reference values, scalar values. ncaps still counts only boxes. The descriptor has one row per reference value with the release and trace entry points a box of that type carries today, so the closure's trace visits a value exactly when the box's trace would have: a cycle that ran closure → box → value is now closure → value and the same pass finds it. The region walk (scr_region.c) explains the same references.
  • SCRIPTC_REPR_REPORT gains one row per captured binding with its storage (box or value).

Two decisions this PR takes

  1. A new environment representation (typed value slots), rather than only copying numbers and booleans, which would have needed no runtime change but covers 2,673 of the compiler's 42,194 boxed locals.
  2. A one-time change to scr_closure.c. A reference value has to be released when its closure dies, and every closure dies in scr_closure_destroy whoever holds it, so that function tests one bit of function_kind. A release site does not know the layout of the closure it releases, so nothing gated can hold the test. Every executable links that function and grows by it once: +128 B at the median (−192 to +256 B). Trace and collector teardown of ordinary closures are untouched; value closures have their own cycle descriptor.

Proof

Base 714482d for every measured number; the branch was then rebased onto 800c240 (one import-line conflict) and the focused checks at the end were rerun there.

Differential corpus, plain and sanitized. Four new programs, each run under Node and natively: 8976-capture-values-kinds (every value kind, parameters, destructuring, assigned-once-later, three levels of nesting, mixed mutable and immutable captures), 8978-capture-values-order (closure created before the initialization and called before and after it, hoisted declarations over later bindings, recursive and mutually recursive closures, a closure inside the initializer of its own binding, declarations in switch cases, try/catch/finally), 8981-capture-values-loops (for counters with and without body writes, closures in the loop head, for … of over arrays, strings, maps, sets and generators, var in a loop, labels), 8984-capture-values-frames (generators, async functions, async generators, timers, object ↔ closure cycles). Full lanes: plain 21,524 passed, sanitized 18,296 passed. Three failures, none from this change: packages/runtime/test/regex.test.ts (its beforeAll compile hit the 60 s hook timeout) and three.js nodes under the sanitizer (20-minute test timeout) both recur in unrelated validation rounds, and the emitter bootstrap's closure capture boxes case asserted @sc_retain_box for a never-rebound binding; that case now rebinds its binding, two new cases cover the value form (64-bit and wasm32), and the bootstrap test passes plain and sanitized.

Native bootstraps. self-hosting-native-driver passes plain (commands, rebuild) and sanitized (commands, rebuild-emit, rebuild-frontend); self-hosting-llvm-emitter passes plain and sanitized, fixed point included.

Zero-cost gate, --corpus=all, 5,271 programs. 1,147 programs' LLVM changes (1,131 corpus programs, 14 of the 58 benchmark workloads, the 2 emitter fixtures) plus self/cli; 4,028 differ only by scr_closure_destroy; 96 fail on both sides (island programs, no pack in the gate). Three of the changed programs are outside the allow pattern =>|\bfunction\b|\bget\s+\w+\s*\( because their closures are in package code: 6610-npm-execa-child-mixin, 9217-npm-callable-export-props, 8211-require-resolve-computed.

  • Size of the 1,141 changed executables: 135 smaller, 1,006 larger; median +1,392 B (+0.18%), largest +1.37%, smallest −17.3%. The growth is not in the program's code, which shrinks (in 1025-async-promise-capture, sc_f_run goes from 1,124 to 1,054 bytes): it is the fixed size of the runtime paths a program links once it creates such a closure (scr_closure_new_values 297 B, its trace 163 B, its collector teardown 245 B, unwind tables). Benchmarks: 7 smaller (functional-pipeline −2,232 B, handler-tables −1,800 B), 7 larger by at most 1,408 B.
  • Compile time, 15 runs: benchmarks +0.45% [95% CI −0.05%, +1.17%], fixtures +0.27% [−0.07%, +0.61%], both within tolerance; self +0.32% [−1.91%, +2.53%].

Runtime benchmarks (bench-runtime, 62 workloads, 4 layouts × 16 runs): geomean +0.1%, 61 neutral, cyclic-graphs 12.3% faster, none slower. A round on an earlier revision of the branch gave −0.8%, with cyclic-graphs 11.3% and typed-array-scan-workers 5.5% faster.

The native compiler compiling itself (Intel Xeon @ 2.50 GHz, 8 vCPU, one sandbox, both compilers built there from the same tree with and without this change):

beforeafter
Captured bindings that have a box (of 43,472 in the emitted module)43,47210,422−76.0%
scr_box_release call sites181,70531,620−82.6%
Box allocation sites (scr_box_new, scr_box_new_obj)15,2432,438−84.0%
All call sites in the LLVM4,556,0344,352,487−4.5%
Native compiler executable108,383,576 B106,875,664 B−1.4%
Instructions, compiling ir-traversal.ts (cachegrind)7,138,169,4467,016,748,959−1.70%
Instructions, compiling integer-ranges.ts27,582,578,40326,705,751,732−3.18%
Of those, in scr_box_* (integer-ranges.ts)879,986,870472,012,379−46.4%
Wall time, compiling itself to LLVM (one run each)14:02.113:42.5−2.3%
Peak memory of that run8.05 GB7.93 GB−1.5%
Wall time, integer-ranges.ts (median of 7, interleaved)6,887 ms6,618 ms−3.9%
Wall time, ir-traversal.ts (median of 7; fastest 1,622 and 1,603 ms)1,842 ms2,054 msinside the run-to-run spread (1.6 to 2.6 s)

scr_closure_release sites rise from 139,943 to 145,478: a captured function value is now released as a closure where its box was released before. On a second machine (Xeon @ 2.90 GHz), with the build before the borrowed copy, the self-compile went from 11:07.0 to 10:24.4.

Not in this PR

  • for … of bindings of the forOf statement are copied by closures but keep the per-iteration box the loop allocates (array loops lower to a declared binding and lose it).
  • A binding assigned on every branch before any closure exists (let x; if (c) x = 1; else x = 2;) keeps its box: the rule is one assignment in the declaring statement list, not definite assignment.
  • Signature adapters keep a box for the function they forward to.
  • Closure entry copies (c' = c in a body that received c by value) still take a reference per call.

Found on main while testing, unchanged by this PR

  • A closure created in one switch case over a const declared in an earlier case, called when that case never ran, traps where Node throws ReferenceError.
  • A let x: T; read through a closure before its one assignment segfaults where Node prints undefined.

🤖 Generated with Claude Code

A captured binding lives in a heap box so that its function and every
closure share one mutable variable. A binding that is filled once before
a closure over it is created, and that nothing rebinds afterwards, needs
no shared cell: the closure's environment now holds a copy of the value,
and a binding whose every capture is of that kind has no box at all.

- backend/llvm/value-captures.ts decides, per environment slot, whether
  every creation site supplies such a binding after its fill has run:
  parameters, bindings declared once (destructuring included), bindings
  declared empty and assigned once by a later statement of the same list
  (nested function declarations), `for` counters that only the update
  clause steps, and captures the creator itself received by value.
  Temporal-dead-zone bindings, bindings captured inside their own
  initializer or before their assignment, bindings a local class
  captures and signature adapters keep their boxes.
- The IR is unchanged: `boxed` keeps meaning shared-binding semantics.
  The emitter treats such bindings as plain locals, as it already does
  for lazily boxed parameters, and a closure reads them like any local.
  The copy ir/capture-values.ts makes of a stable binding borrows from
  its source in the same scope instead of holding a second reference.
- The runtime's closure gains value slots behind its boxes, described by
  a constant table of the release and trace entry points a box of each
  value's type carries, so cycles through a closure are traced exactly
  where they were through its box, and the region walk explains the same
  references. scr_closure_destroy tests one bit.
- SCRIPTC_REPR_REPORT lists each captured binding and its storage.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@vercel

vercel Bot commented Nov 7, 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 Nov 7, 2026 2:12pm UTC

stack-callbacks.ts: main added a comment above the capture loop this
branch rewrote; keep the comment and this branch's loop.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@cramforce
cramforce requested a review from ctate November 7, 2026 12:21
@cramforce

Copy link
Copy Markdown
Contributor Author

Brought up to date with main (d2bbe4d99): merge commit 63580d991.

What conflicted. One hunk in packages/compiler/src/backend/llvm/stack-callbacks.ts: #3266 added a comment above the capture loop of a stack closure, and this branch rewrote that loop's first line (a captured value is stored where a box was). The resolution keeps main's comment and this branch's loop. emit-function.ts, emit-statements.ts, emitter.ts and expr-calls.ts, which both sides edit, merged without conflict.

Because #3266 and #3234 changed how function values are lowered and identified right beside this branch's closure layout, the merged head was proven with the full lanes rather than focused tests.

Both full test lanes on the merged head (bin/validate, 90 Linux x64 Sandbox jobs, with a libnode pack): plain 22,246 passed, 6 failed; sanitized 18,991 passed, 6 failed. The six are the same in both lanes and all fail on main itself:

The lanes include the differential corpus plain and sanitized (this PR's four programs 8976, 8978, 8981, 8984 among them), both native bootstraps (self-hosting-native-driver plain: commands and rebuild; sanitized: commands, rebuild-emit and rebuild-frontend) and the emitter bootstrap. Workspace build, pnpm lint, pnpm format:check and pnpm test:ts7 --baselines-only pass.

Not re-run on the merged head: the zero-cost gate and the runtime benchmarks. Every executable differs on purpose with this change (the description has the numbers against 714482d49), so a gate round cannot pass; the sizes, compile times and benchmark results in the description were not measured again on this merge. The self-compile table was not re-measured either.

🤖 Generated with Claude Code

@ctate
ctate merged commit 5ac1cf5 into main Nov 7, 2026
81 of 84 checks passed
@ctate
ctate deleted the perf/capture-by-value branch November 7, 2026 19:01
cramforce added a commit that referenced this pull request Nov 7, 2026
Main since the last merge: #3294, #3269, #3253, #3235. No conflict.

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce added a commit that referenced this pull request Nov 7, 2026
…3016, #3156, #3018, #2952) into train/pi-1

Conflict: expressions/narrowing.ts, imports (#3167's native-proxy import beside main's wider asserted-receiver import and #3156's erased-delete import: all kept).

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce added a commit that referenced this pull request Nov 7, 2026
Main gained #3364 (its build fix, the commit this branch had merged from the
fix branch; identical content), #3294, #3269, #3253 and #3235. One conflict,
packages/compiler/src/frontend/canon-map.ts: #3290's collapse of an array of
checked-dynamic elements first, then main's storage rule for array elements
(#3269); both kept.

Co-Authored-By: Claude Code <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — 63580d99 Deployed Nov 7, 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