Repository navigation
Compile large closure environments in linear time - #1284
Merged
Merged
Conversation
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>
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bun's AOT corpus has a program,
classes-and-closures/large-environment.ts(656 KB): one factory declares 8,701letbindings, 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:
LazyCaptureswalked the body again for each capture it checked. It also rebuilt a map of all locals for each capture at each creation site.int32-slotswalked the declaring function once per box to find the box's initializer.CaptureFlowscanned 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.optnonebodies.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.For
large-environment.ts: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()plussum()) runs 6% slower: 0.95 s → 1.01 s. That is the cost of leaving those bodies unoptimized.Regression checks:
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
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'sReferenceError. 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.optnonethreshold in the emitter.🤖 Generated with Claude Code