Skip to content

[JSC] Cap the per-thread cached AssemblerBuffer size - #476

Merged
Jarred-Sumner merged 1 commit into
mainfrom
jsc/cap-cached-assembler-buffer
Aug 21, 2026
Merged

[JSC] Cap the per-thread cached AssemblerBuffer size#476
Jarred-Sumner merged 1 commit into
mainfrom
jsc/cap-cached-assembler-buffer

Conversation

@sosukesuzuki

Copy link
Copy Markdown
Member

AssemblerDataImpl (assembler/AssemblerBuffer.h) writes machine code into a buffer that grows 1.5× per grow(). When the compile finishes, the destructor hands the buffer to threadSpecificAssemblerData(), and the next AssemblerDataImpl on that thread borrows it back. Both directions go through takeBufferIfLarger(), so the cached buffer only ever grows — there is no path that shrinks it. Once a thread has compiled one very large function, that function's full buffer stays resident until the thread exits. BranchCompactionLinkBuffer (LinkBuffer.cpp) has the same pattern for the ARM64 branch-compaction copy.

This matters for Bun because single-file bundles routinely contain a handful of enormous functions — a bundler's module wrapper, generated tables, a top-level that runs once but contains an init loop. Such a function gets Baseline-compiled through loop_osr (there is no size gate on the Baseline tier, unlike maximumOptimizationCandidateBytecodeCost for DFG), its scratch buffer grows to a few MB, and that allocation is then pinned for the life of the process even though the function never compiles again (m_unlinkedBaselineCode keeps the result).

Change

  • New Options::maximumCachedAssemblerBufferSize, default 1 MB. In ~AssemblerDataImpl and ~BranchCompactionLinkBuffer, a buffer above the limit is freed instead of being donated to the thread-specific slot. 0 restores the unbounded behaviour, so the two can be A/B'd on one binary with BUN_JSC_maximumCachedAssemblerBufferSize=0.
  • Buffers at or below the limit are cached exactly as before, so the common case — hundreds of KB at most for normal functions — is unchanged.

Cost

Only a compile whose buffer exceeds the limit pays anything: on the next oversized compile the buffer has to be re-malloc'd and grown from the ~1 MB cached one (about 4–6 realloc steps at 1.5×). That is microseconds against a multi-MB code-generation pass, and in the bundles I looked at the oversized compile happens once or twice per process.

Measurement

Microbenchmark: a one-shot function with a hot loop followed by 60k straight-line statements, Baseline code 12.4 MB (BUN_JSC_logJIT=1), BUN_JSC_useConcurrentJIT=0, macOS arm64, physical footprint (proc_pid_rusage) after Bun.gc(true):

maximumCachedAssemblerBufferSize footprint delta
0 (unbounded, old behaviour) +159.7 MB / +158.4 MB
1 MB (new default) +147.2 MB / +147.1 MB

The 11.5 MB difference is the scratch buffer that is now released. For reference, a @babel/standalone bundle pins a 10.6 MB buffer the same way (its wrapper function's Baseline code is 7.6 MB).

No change to generated code; only whether a scratch buffer is kept.

Upstream

Nothing here is Bun-specific, so I plan to send the same change to upstream WebKit. Landing it here first so Bun picks it up without waiting on that.

takeBufferIfLarger() only ever grows the ThreadSpecific slot, so one huge
Baseline compile pins its scratch buffer to the thread for the life of the
process. Add Options::maximumCachedAssemblerBufferSize (default 1 MB, 0 =
unbounded) and free anything larger instead of caching it. Same for
BranchCompactionLinkBuffer on ARM64.
Comment on lines +188 to +199
// A one-off huge compile would otherwise pin its scratch buffer to this thread forever.
unsigned cacheLimit = Options::maximumCachedAssemblerBufferSize();
if (!cacheLimit || m_capacity <= cacheLimit) {
if constexpr (type == AssemblerDataType::Code)
threadSpecificAssemblerData()->takeBufferIfLarger(*this);
#if ENABLE(JIT_SIGN_ASSEMBLER_BUFFER)
if constexpr (type == AssemblerDataType::Hashes)
threadSpecificAssemblerHashes()->takeBufferIfLarger(*this);
if constexpr (type == AssemblerDataType::Hashes)
threadSpecificAssemblerHashes()->takeBufferIfLarger(*this);
#else
static_assert(type != AssemblerDataType::Hashes);
static_assert(type != AssemblerDataType::Hashes);
#endif
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor: the constructor unconditionally moves the cached buffer out of threadSpecificAssemblerData() (resetting the slot to InlineCapacity=128), so when an oversized compile skips donation here the slot is left at 128 bytes — not "~1 MB" as the Cost section says. The very next compile on that thread starts from 128 bytes and needs ~20+ grow() steps, not 4–6. No correctness impact and still microseconds, but you may want to either correct the description before upstreaming or donate back a buffer capped at cacheLimit.

Extended reasoning...

What this is

The PR's Cost section says: "on the next oversized compile the buffer has to be re-malloc'd and grown from the ~1 MB cached one (about 4–6 realloc steps at 1.5×)". That implies the thread-specific cache retains a ~1 MB buffer after an oversized compile. For AssemblerDataImpl it does not — the cache is drained to 128 bytes.

Step-by-step trace

Assume the thread-specific slot currently holds a ~900 KB buffer from prior compiles.

  1. Constructor (AssemblerDataImpl()): initializes this to m_inlineBuffer / InlineCapacity (128), then calls this->takeBufferIfLarger(*threadSpecificAssemblerData()).
  2. takeBufferIfLarger (lines 168–184): the slot is not inline and 900 KB > 128, so it moves the slot's buffer into this and resets the slot: other.m_buffer = other.m_inlineBuffer; other.m_capacity = InlineCapacity;. The thread-specific slot is now at 128 bytes.
  3. The compile runs and grow()s past 1 MB — say to 12 MB.
  4. Destructor: cacheLimit = 1 MB, m_capacity = 12 MB > cacheLimit, so the if body is skipped and clear() frees the 12 MB buffer. Nothing is donated back.
  5. The slot remains at 128 bytes (from step 2).
  6. Next compile on this thread: the constructor calls takeBufferIfLarger on the slot; other.isInlineBuffer() is true so it returns immediately. This compile starts from 128 bytes. Reaching 1 MB takes ~22 grow() calls at 1.5×; reaching 12 MB takes ~28 — not the 4–6 the description states.

Why the description's model doesn't hold here

The description reads as if the cap leaves the cache at ~1 MB and only the excess is dropped. In practice, because the constructor always drains the slot (it takes ownership, not a copy), skipping the destructor donation leaves the slot empty. So the behaviour is "cap = drain to InlineCapacity on any oversized compile", not "cap = clamp to 1 MB".

BranchCompactionLinkBuffer is different: its constructor only takes the cached buffer when threadSpecific->size() >= size; otherwise it mallocs fresh and leaves the cache intact. So for that class, an oversized compile does leave the ~1 MB cache in place, and the description holds. The discrepancy is only in AssemblerDataImpl.

Impact

  • Correctness: none — generated code is unchanged.
  • Memory: the stated savings are real (arguably slightly better, since even the ~1 MB isn't retained after an oversized compile).
  • Perf: the compile immediately following an oversized one pays ~20 extra reallocs instead of ~4–6. That is still microseconds against a multi-MB codegen pass. In the PR's own workload ("oversized compile happens once or twice per process"), a normal-sized compile will typically run in between and refill the cache before the next oversized one anyway.

Suggested fix

Either is fine:

  • Adjust the description before upstreaming to say the cache is emptied (not capped at ~1 MB) after an oversized AssemblerDataImpl compile, so the next compile starts from InlineCapacity.
  • Or have the destructor donate back a bounded buffer when over the limit — e.g. realloc down to cacheLimit and then donate, or only skip donation if the slot is already ≥ cacheLimit. That would make the code match the described "cap" semantics.

Not blocking — flagging mainly because the PR is headed upstream and the cost model in the description doesn't match AssemblerDataImpl's actual behaviour.

@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 99193974-2c0d-43ad-b820-6b4cd416fd68

📥 Commits

Reviewing files that changed from the base of the PR and between 0f966e8 and ab8e6b3.

📒 Files selected for processing (3)
  • Source/JavaScriptCore/assembler/AssemblerBuffer.h
  • Source/JavaScriptCore/assembler/LinkBuffer.cpp
  • Source/JavaScriptCore/runtime/OptionsList.h

Included review availability: 4 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 5 reviews per hour.


Walkthrough

Changes

The change adds maximumCachedAssemblerBufferSize with a 1 MB default. Assembler and link-buffer destructors now use the configured limit when deciding whether to return buffers to thread-local caches.

Assembler buffer cache limits

Layer / File(s) Summary
Cache limit option
Source/JavaScriptCore/runtime/OptionsList.h
Defines maximumCachedAssemblerBufferSize as an unsigned JSC option with a 1 MB default.
Destructor cache policy
Source/JavaScriptCore/assembler/AssemblerBuffer.h, Source/JavaScriptCore/assembler/LinkBuffer.cpp
Checks buffer capacity against the configured limit before caching code, hash, and link buffers. Oversized or disabled-cache cases use the existing cleanup path.

Merge Risk: ⚪ Minimal · up to ab8e6

The change bounds otherwise permanently retained assembler scratch buffers while preserving existing caching below the limit; no actionable merge-blocking risk remains after normal checks and review.

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description thoroughly explains the problem, implementation, costs, measurements, and changed behavior, but it omits the required Bugzilla link and review status. Add the associated Bugzilla URL, review status, and the template-formatted changed-file and function list.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding a cap for per-thread cached assembler buffers.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


Comment @coderabbitai help to get the list of available commands.

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