Repository navigation
Capture bindings that are never rebound by value - #3294
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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>
|
Brought up to date with What conflicted. One hunk in 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 (
The lanes include the differential corpus plain and sanitized (this PR's four programs 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 🤖 Generated with Claude Code |
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>
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.
This is an optimization of typed code, so executables change on purpose. The numbers are below.
How
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;); aforcounter 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.const f = wrap(() => f), the first of two mutually recursive function declarations); declarations aswitchcan 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.boxedkeeps 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 thatcopyStableCapturesmakes of a stable binding borrows from its source in the same scope instead of holding a second reference.ncapsstill counts only boxes. The descriptor has one row per reference value with thereleaseandtraceentry 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_REPORTgains one row per captured binding with its storage (boxorvalue).Two decisions this PR takes
scr_closure.c. A reference value has to be released when its closure dies, and every closure dies inscr_closure_destroywhoever holds it, so that function tests one bit offunction_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(forcounters with and without body writes, closures in the loop head,for … ofover arrays, strings, maps, sets and generators,varin 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(itsbeforeAllcompile hit the 60 s hook timeout) and three.jsnodesunder the sanitizer (20-minute test timeout) both recur in unrelated validation rounds, and the emitter bootstrap'sclosure capture boxescase asserted@sc_retain_boxfor 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-driverpasses plain (commands, rebuild) and sanitized (commands, rebuild-emit, rebuild-frontend);self-hosting-llvm-emitterpasses 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) plusself/cli; 4,028 differ only byscr_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.1025-async-promise-capture,sc_f_rungoes 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_values297 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.self+0.32% [−1.91%, +2.53%].Runtime benchmarks (
bench-runtime, 62 workloads, 4 layouts × 16 runs): geomean +0.1%, 61 neutral,cyclic-graphs12.3% faster, none slower. A round on an earlier revision of the branch gave −0.8%, withcyclic-graphs11.3% andtyped-array-scan-workers5.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):
scr_box_releasecall sitesscr_box_new,scr_box_new_obj)ir-traversal.ts(cachegrind)integer-ranges.tsscr_box_*(integer-ranges.ts)integer-ranges.ts(median of 7, interleaved)ir-traversal.ts(median of 7; fastest 1,622 and 1,603 ms)scr_closure_releasesites 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 … ofbindings of theforOfstatement are copied by closures but keep the per-iteration box the loop allocates (array loops lower to a declared binding and lose it).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.c' = cin a body that receivedcby value) still take a reference per call.Found on main while testing, unchanged by this PR
switchcase over aconstdeclared in an earlier case, called when that case never ran, traps where Node throwsReferenceError.let x: T;read through a closure before its one assignment segfaults where Node printsundefined.🤖 Generated with Claude Code