Skip to content

[JSC] Honor a GC request deferred by DeferGCForAWhile at the next VM entry - #526

Open
dylan-conway wants to merge 1 commit into
mainfrom
claude/honor-deferred-gc-at-vm-entry
Open

[JSC] Honor a GC request deferred by DeferGCForAWhile at the next VM entry#526
dylan-conway wants to merge 1 commit into
mainfrom
claude/honor-deferred-gc-at-vm-entry

Conversation

@dylan-conway

Copy link
Copy Markdown
Member

Heap::collectIfNecessaryOrDefer() called under a DeferGCForAWhile scope only sets m_didDeferGCWork. ~DeferGC acts on that flag; ~DeferGCForAWhile deliberately does not, so the request waits for whatever comes next: an allocation slow path or some unrelated ~DeferGC. The extra-memory reports made while linking a CodeBlock in ScriptExecutable::prepareForExecutionImpl (the metadata table, and the baseline JIT code size when unlinked baseline code is installed) are always in that position.

This adds one more place that honors the flag: VMEntryScope's constructor, i.e. the next time the embedder enters the VM (Interpreter::executeProgram/executeCall/executeConstruct/executeEval/executeModuleProgram). JS→JS calls do not construct a VMEntryScope and are unaffected. The check is m_didDeferGCWork && !m_deferralDepth, so it does nothing while any DeferGC/DeferGCForAWhile is active, and when it fires it just runs collectIfNecessaryOrDefer(), which still only collects if the cycle's budget is exceeded. A collection at this point is nothing new for callers: the first thing executeProgram does after constructing the scope is allocate a ProgramExecutable, and the callee allocates. Gated by Options::usePollingDeferredGCWorkAtVMEntry (default on) so it can be A/B'd.

What goes wrong without it

Bun's test/cli/run/require-cache.test.ts ("files transpiled and loaded don't leak the output source code › via require() with a lot of function calls") requires and un-caches the same 100 KB CommonJS module (one function, 20 000 call statements) 500 times in a synchronous loop, does a synchronous full GC, and reads RSS. Since the 8c4fd56 upgrade it fails on Windows x64 CI nearly every run (RSS +150–250 MB); the same thing happens on Linux in roughly one run in five, before and after that upgrade, and on the pre-upgrade Windows binary as soon as anything perturbs timing (logGC=1 is enough). Traced with counters on CodeBlock/UnlinkedCodeBlock construction/destruction and on the collector:

  1. All requires share one UnlinkedFunctionCodeBlock for the module function (CodeCache), so its LLInt tier-up counter is shared and after ~150 requires it is baseline-compiled (1.9 MB of code for the 20 000 call sites). From then on every require reports ~4 MB of extra memory while linking (2 MB metadata + 1.9 MB setJITCode), all under DeferGCForAWhile.
  2. Normally the allocators were reset by a GC in the previous iteration, so the first small allocation after linking takes a slow path, sees the budget exceeded and collects: one GC per iteration, and each dead module CodeBlock is swept the next cycle. Around the tier-up a Full collection can land while a module CodeBlock is live, leaving a ~5 MB eden budget; the next iteration's 4 MB doesn't exceed it at its only slow paths, the allocators keep their free lists, and then nothing on the require path takes a slow path or runs a ~DeferGC for a long time. Measured: 32, 49, 27 consecutive iterations with no collection and bytesAllocatedThisCycle at 120–280 MB, every byte of it reported with isDeferred() true.
  3. The CodeBlock directory fills MarkedBlocks 0…3 during that window. When a collection finally runs they are all dead, but destruction happens at sweep, every later cycle restarts allocation at block 0, and steady-state demand is one or two CodeBlocks per cycle, so blocks 1…3 are never swept again. 28 dead CodeBlocks × ~2 MB of metadata stayed resident for the rest of the run, invisible to the collector's accounting, until the final collectNow(Sync, Full) swept them; libpas returns those pages ~100–250 ms later, after the test has read RSS. (With an idle run loop the incremental sweeper and scavenger clean this up in well under a second, so it is a synchronous-execution transient, not a leak.)

With the flag honored at VM entry the deferred request from step 1 is acted on within one iteration, the per-iteration cadence never breaks, and nothing accumulates.

Measurements

Linux x64, LTO release builds of Bun against this branch's base with and without the patch; "fixoff" is the patched binary with --usePollingDeferredGCWorkAtVMEntry=0, which separates the behavior change from binary layout differences. 10 interleaved runs per workload, pinned cores; time is median/min ms, RSS is peak MB, cpu is user+sys seconds.

The failure mode: require-cache fixture, 20 runs each — vanilla 3/20 over threshold (171–203 MB), fixoff 1/20, patched 0/20 (0/30 in an earlier instrumented run, where the largest GC-less allocation window dropped from 281 MB to 11 MB). Windows x64 LTO build, with logGC=1 to push it into the bad timing: unpatched 12/12 over threshold (33–182 MB), patched 0/12.

GC cycle counts with the option on vs off, 3 runs each: identical for every other workload below (m_await 7/7, m_replace 3/3, m_jsonreviver 6/6, m_serve_inproc 8/8, m_gcchurn 98/98, modload-cjs 2/2, modload-esm 0/0, npmload 2/2, ts-transpile 4/4, buntest 2/2; m_immediate 14–21 vs 12–20). The check fires often (786 times in buntest) but is a no-op unless the budget is already exceeded, so it does not add collections.

workload         vanilla              patched                     fixoff (patched binary, option off)
m_microtask       60/58   45  0.06     59/58   40  0.06  -1.9%      59/58   40  0.06  -1.6%
m_await           80/77   54  0.09     79/78   50  0.10  -0.7%      79/78   50  0.10  -1.3%
m_replace        268/260 281  0.28    268/263 276  0.29  -0.2%     266/262 276  0.29  -0.8%
m_jsonreviver    522/506 484  0.65    517/506 482  0.64  -0.9%     529/503 478  0.66  +1.4%
m_dispatch        81/78   46  0.08     79/78   42  0.08  -2.0%      83/79   42  0.08  +2.3%
m_immediate      386/367  69  0.55    377/366  65  0.53  -2.5%     384/367  65  0.54  -0.6%
m_serve_inproc   307/302  62  0.42    314/307  59  0.43  +2.4%     318/301  59  0.43  +3.9%
m_gcchurn         83/80   59  0.11     84/81   56  0.11  +0.8%      84/78   56  0.11  +0.8%
modload-cjs       94/92   81  0.11     93/91   76  0.11  -0.7%      92/91   77  0.10  -1.2%
modload-esm      120/114 101  0.14    113/110  97  0.13  -5.4%     112/108  97  0.13  -6.2%
npmload          198/189 126  0.24    194/188 124  0.23  -1.6%     190/186 124  0.23  -4.1%
ts-transpile     383/370 315  1.46    383/365 310  1.46  -0.1%     380/369 312  1.51  -1.0%
buntest (15k)    626/604 110  1.18    616/602 106  1.16  -1.5%     620/597 106  1.18  -1.0%
bunbuild          28/27   44  0.02     30/27   43  0.02  +3.5%      30/26   42  0.02  +3.5%
manymods (300)   245     122           246     117                  246     117

The m_* set is deliberately heavy on C++→JS entries, the only path this touches: 3M drained microtasks, 2M awaits, String.prototype.replace with a function (10M callbacks), JSON.parse reviver (2.4M callbacks), 1M dispatchEvent, 1M setImmediate, 30k in-process Bun.serve requests; m_gcchurn is pure-JS allocation churn. The rest: loading a 500-module CJS and ESM graph, require("typescript")+@babel/core+@babel/preset-env+lodash, ts.transpileModule on 1.5 MB, a 15 000-test bun test run, bundling three.js, requiring 300 distinct large modules. HTTP under ab -k -c50 -n150000, 10 rounds: hello-world 52.9k / 52.7k / 52.9k req/s, server CPU 1.24 / 1.25 / 1.25 s, peak RSS 56 / 51 / 51 MB; React SSR 12.45k / 12.23k / 12.19k req/s, CPU 13.27 / 13.52 / 13.55 s, peak RSS 235 / 182 / 234 MB. bun -e 0 startup 4.6±0.7 / 4.7±0.8 / 4.5±0.7 ms (order flips between rounds). Wherever patched and vanilla differ by more than ~2%, fixoff moves with patched, i.e. it is the two binaries rather than the option.

…entry

collectIfNecessaryOrDefer() under a DeferGCForAWhile scope only records
m_didDeferGCWork; unlike ~DeferGC, ~DeferGCForAWhile does not act on it,
so the request waits for the next allocation slow path or ~DeferGC. The
large extra-memory reports made while linking a CodeBlock
(ScriptExecutable::prepareForExecutionImpl: metadata table, baseline JIT
code) are always in that situation. An embedder that repeatedly enters
the VM to run freshly linked large functions can go tens of iterations
without either trigger, allocating hundreds of MB of CodeBlocks with no
collection, which then land in MarkedBlocks the allocator never revisits
and stay unswept until a synchronous full collection.

Check the pending flag when a VMEntryScope is constructed and run
collectIfNecessaryOrDefer() there. This is a point where the callee is
about to allocate anyway; it is skipped while any DeferGC scope is
active. Gated by Options::usePollingDeferredGCWorkAtVMEntry (default on).
@coderabbitai

coderabbitai Bot commented Aug 28, 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: ef4ff4ed-32f0-4538-907d-9ee70e9056f5

📥 Commits

Reviewing files that changed from the base of the PR and between f5deafe and 6473271.

📒 Files selected for processing (4)
  • Source/JavaScriptCore/heap/Heap.cpp
  • Source/JavaScriptCore/heap/Heap.h
  • Source/JavaScriptCore/runtime/OptionsList.h
  • Source/JavaScriptCore/runtime/VMEntryScopeInlines.h

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


Walkthrough

The change adds heap APIs for pending deferred GC work, adds a default-enabled polling option, and invokes deferred GC processing from VMEntryScope when the VM is entered.

Changes

Deferred GC VM-entry handling

Layer / File(s) Summary
Heap deferred-GC APIs
Source/JavaScriptCore/heap/Heap.h, Source/JavaScriptCore/heap/Heap.cpp
Heap reports pending deferred GC work outside active deferral scopes and performs it through collectIfNecessaryOrDefer().
VM-entry deferred-GC polling
Source/JavaScriptCore/runtime/OptionsList.h, Source/JavaScriptCore/runtime/VMEntryScopeInlines.h
The default-enabled usePollingDeferredGCWorkAtVMEntry option controls polling. VMEntryScope performs pending work after clearing the last exception.

Merge Risk: ⚪ Minimal · up to 64732

This localized change honors deferred garbage-collection work at VM entry to prevent temporary memory buildup, while preserving the existing budget check and providing a default-on control. No actionable merge-blocking risk remains beyond normal checks and review.

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description gives a detailed explanation of the bug, implementation, affected paths, and measurements, but it omits required template information such as the Bugzilla URL, review status, and chang… Add the bug title and Bugzilla URL, include the required "Reviewed by NOBODY (OOPS!)." line or actual review information, and list the changed paths and relevant functions using the repository template format.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: honoring deferred GC work at the next VM entry.
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.
Full details: Description check

Explanation

The description gives a detailed explanation of the bug, implementation, affected paths, and measurements, but it omits required template information such as the Bugzilla URL, review status, and changed-file/function list.

  • Fix all pre-merge checks with AI

Warning

Git: CodeRabbit could not clone the repository, so clone-backed analysis was skipped and this review may be incomplete. Verify repository clone access, such as SSH credentials, before requesting another full review. If clone access is intentionally unavailable, use path_filters to narrow the review scope.


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

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I reviewed this PR and didn't find any bugs. Because it changes GC scheduling behavior on a hot inline path and Source/JavaScriptCore is CODEOWNER-covered, a human look would still be worthwhile.

What was reviewed:

  • hasPendingDeferredGCWork() / performPendingDeferredGCWork() mirror the existing decrementDeferralDepthAndGCIfNeededSlow() pattern (clear flag, call collectIfNecessaryOrDefer()), and the !m_deferralDepth guard prevents firing under an active DeferGC/DeferGCForAWhile.
  • Checked VMEntryScope construction sites in Interpreter.cpp and DebuggerCallFrame.cpp — all already allocate immediately after the scope, so a GC at this point is not a new hazard for callers.
  • VMEntryScopeInlines.h already pulls in Heap.h and Options transitively via VM.h; performPendingDeferredGCWork is JS_EXPORT_PRIVATE and out-of-line, so the inline site stays cheap.
Extended reasoning...

Overview

This PR adds a new place where JavaScriptCore honors a deferred GC request: the VMEntryScope constructor. It introduces Heap::hasPendingDeferredGCWork() (an inline check of m_didDeferGCWork && !m_deferralDepth) and Heap::performPendingDeferredGCWork() (an out-of-line, exported function that clears the flag and calls collectIfNecessaryOrDefer()), plus a new usePollingDeferredGCWorkAtVMEntry option (default on) that gates the check in VMEntryScopeInlines.h. The motivation is that extra-memory reports made under DeferGCForAWhile in ScriptExecutable::prepareForExecutionImpl set m_didDeferGCWork but nothing acts on it until the next allocation slow path or ~DeferGC, which an embedder loop may not reach for many iterations. The new code is a near-verbatim copy of the body of decrementDeferralDepthAndGCIfNeededSlow().

Security risks

None identified. There is no untrusted input handling, no auth or permissions surface, and no data exposure. The change only affects when the collector is polled. The !m_deferralDepth guard ensures the new call site respects any active deferral scope, and collectIfNecessaryOrDefer() itself re-checks isDeferred() before doing anything, so the invariant that GC never runs while deferred is preserved. Conservative stack scanning already protects on-stack cell references at VMEntryScope construction sites, and every audited caller (Interpreter::executeProgram/executeCall/executeConstruct/executeModuleProgram, DebuggerCallFrame) already tolerates GC immediately after constructing the scope because they allocate right afterward.

Level of scrutiny

High. This touches GC scheduling in an ALWAYS_INLINE constructor on the C++→JS entry path — one of the hottest paths in the engine — and changes when collection can be triggered. GC-timing changes in JSC have historically been subtle: introducing a new safepoint requires confidence that no caller holds an unrooted cell that depends on GC not running there. The PR description addresses this convincingly and includes extensive benchmarks showing the option itself does not add collections on unaffected workloads, but this is exactly the kind of change a JSC/heap owner should sign off on.

Other factors

The changed files fall under /Source/JavaScriptCore in .github/CODEOWNERS, which routes to the JSC reviewers group; per the approval guidelines that alone means human review is required. The change is small, well-commented, option-gated for A/B, and follows the established pattern in Heap.cpp, so I have no concerns beyond the need for owner sign-off on a GC-timing change.

@github-actions

Copy link
Copy Markdown

Preview Builds

Commit Release Date
6473271a autobuild-preview-pr-526-6473271a 2026-08-28 01:13:22 UTC

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.

1 participant