Skip to content

Compile large closure environments in linear time - #1284

Merged
ctate merged 3 commits into
mainfrom
opt/compile-closures
Oct 18, 2026
Merged

ctate merged 3 commits into
mainfrom
opt/compile-closures

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

Bun's AOT corpus has a program, classes-and-closures/large-environment.ts (656 KB): one factory declares 8,701 let bindings, and the two methods it returns read and rebind all of them. scriptc took 227 s to compile it, at 777 MB peak RSS. About 46 s went to LLVM IR emission and about 174 s to native code generation. Compile time grew quadratically with the number of captured bindings.

There were two causes. This PR fixes both.

1. Capture analyses ran once per binding over the whole function

Three IR analyses repeated work proportional to the function's size for every captured binding:

  • LazyCaptures walked the body again for each capture it checked. It also rebuilt a map of all locals for each capture at each creation site.
  • int32-slots walked the declaring function once per box to find the box's initializer.
  • CaptureFlow scanned every capture edge, capture list and entry copy once per binding.

Each function's facts are now collected in one walk and indexed: rebound bindings, unchanged parameters, initialized declarations, edges grouped by source, and capture, parameter and entry-copy sets. Hoisted and TDZ initializers are found in one pass. The results do not change: on the baseline compiler's source (503 MB of LLVM), the new compiler's output is byte-identical to the baseline compiler's.

2. LLVM received every box pointer as a value live across the whole function

A captured binding lives in a box. Each box pointer becomes an SSA value that stays live from the binding to its last use, and every one of these values crosses all the calls in between. This happens in the factory and in each method that receives the environment. With thousands of these values, the inliner, instruction selection and greedy register allocation all grow quadratically.

The fix applies to functions holding at least 512 capture boxes (boxed locals plus captures). The self-hosted compiler's largest function holds fewer than 100. Such a function is now emitted with two changes:

  • noinline optnone. Nothing is inlined into it, and code generation uses its fast paths.
  • Volatile box slots. Loads and stores of its box slots are volatile, so no IR pass promotes them to values. The native helper registers no pass instrumentation, so its IR pipeline would still run mem2reg on optnone bodies.

Each use reloads its box pointer from the frame. Boxes, environments and semantics are unchanged. Only the machine code of those functions is unoptimized.

Results

Linux sandbox, 8 vCPU. Bootstrap CLI, default release build, SCRIPTC_NO_CACHE=1. The generated shape is N number bindings plus N/20 objects and N/20 strings, captured by a reader method and a writer method.

Nbefore: emit / codegen / wallafter: emit / codegen / wall
5000.26 / 1.50 / 2.98 s0.14 / 0.66 / 2.06 s
1,0000.80 / 3.90 / 6.06 s0.20 / 1.07 / 2.56 s
2,0002.34 / 12.5 / 16.6 s0.32 / 1.65 / 3.58 s
4,0009.53 / 39.1 / 51.3 s0.53 / 3.33 / 6.54 s
8,00048.4 / 160 / 214 s1.12 / 8.57 / 15.9 s

For large-environment.ts:

  • Compile: 226.5 s → 16.4 s.
  • Peak RSS: 777 MB → 543 MB.
  • Binary: 1.86 MB → 1.89 MB.
  • Runtime: 0.18 s before and after, with output identical to Node.

The remaining compile time is mostly tsgo checker requests (about 4 s at 8,000 bindings) and lowering, both close to linear.

A microbenchmark that only calls into 1,050-capture methods (200,000 times bump() plus sum()) runs 6% slower: 0.95 s → 1.01 s. That is the cost of leaving those bodies unoptimized.

Regression checks:

  • Self-hosted compiler: the compiler builds itself in 360.6 s before and 357.0 s / 363.6 s after. The binary goes from 54,070,512 B to 54,075,864 B; the difference comes from this PR's added source.
  • Runtime suite: bench-runtime --layouts=4 --runs=16, baseline = parent commit. All 31 workloads are neutral, geomean change is -0.1%, and every binary size is unchanged. Ordinary programs emit the same IR.

Tests

  • Corpus 5973-large-closure-environment.ts: a 600-binding factory that crosses the threshold. It covers mutation shared across methods, closures created per loop iteration, a nested closure, and a forward-captured read in its TDZ that throws Node's ReferenceError. It runs under Node and compiled: release, dev and sanitized.
  • tests/harness/closure-environment-scaling.test.ts: compiles the generated shape at 500 and 4,000 bindings and asserts that post-frontend compile time grows less than 16× for 8× the bindings. Linear growth measures about 5×; the baseline fails at 24.0× (2.4 s → 56.6 s). The test is skipped in the sanitized lane, where uncached builds rebuild the runtime and the fixed cost hides growth.
  • Unit tests: cover volatile slot rendering and the optnone threshold in the emitter.

🤖 Generated with Claude Code

cramforce and others added 3 commits October 17, 2026 19:40
Three analyses did work proportional to the whole function once per
captured binding, so a factory with thousands of bindings shared by its
methods spent most of its compile time in them (48 s of LLVM emission
at 8,000 bindings):

- LazyCaptures walked the body again for every capture it checked and
  rebuilt a locals map for every capture of every creation site. Each
  function's rebound bindings are now collected in one walk and the
  unchanged-parameter sets are memoized.
- int32-slots walked the declaring function once per box to find its
  initializer and scanned capture lists per member. Both are now
  per-function sets built on first use.
- CaptureFlow scanned all capture edges, capture lists and entry copies
  per binding and searched statement lists per hoisted or TDZ binding.
  Edges are grouped by source, captures, parameters and entry copies are
  indexed, and hoisted and TDZ initializers are found in one pass.

The results are unchanged: the candidate compiler emits byte-identical
LLVM for the self-hosted compiler's source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every box pointer of a captured binding is a value live from its
binding to its last use. A factory whose methods share thousands of
bindings hands LLVM thousands of such ranges, each crossing every call
in between, in the factory and in each method that receives them, and
the inliner, instruction selection and register allocation grow
quadratically: Bun's large-environment AOT program (8,701 captured
bindings) spent about three minutes in native code generation.

A function holding at least 512 capture boxes (the self-hosted compiler's
largest holds under 100) is now emitted `noinline optnone`, and its box
slots are loaded and stored volatile so no IR pass promotes them, which
the native helper's pipeline would otherwise still do for optnone
bodies. Each use reloads its box pointer from the frame. Boxes,
environments and semantics are unchanged; only those bodies' machine
code is unoptimized.

large-environment.ts now compiles in 16 s instead of 227 s (peak RSS
543 MB instead of 777 MB) and runs in the same 0.18 s. Corpus 5973 pins
such a factory's behavior (shared mutation, per-iteration closures, a
nested closure, a forward-captured read in its TDZ), and a harness test
bounds compile-time growth between 500 and 4,000 bindings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er ASan

The order-parity baseline for the new corpus program records its module
order and empty preflight diagnostics. Uncached sanitized builds rebuild
the runtime for every compile, which takes longer than either measured
size, so the compile-time growth test runs in the plain lane only.

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

vercel Bot commented Oct 18, 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 18, 2026 1:24pm UTC

@cramforce
cramforce requested a review from ctate October 18, 2026 13:45
@ctate
ctate merged commit 8c1d94e into main Oct 18, 2026
66 checks passed
@ctate
ctate deleted the opt/compile-closures branch October 18, 2026 13:52

This branch was successfully deployed

1 active deployment
Preview — 9118cbc2 Deployed Oct 18, 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