Skip to content

[JSC] CodeBlock aging: only an embedder-tagged idle collection may let counter-less optimized code go - #558

Merged
Jarred-Sumner merged 1 commit into
mainfrom
claude/aging-event-loop-turns
Sep 3, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
claude/aging-event-loop-turns

Conversation

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Follow-up to #557.

#557 let FTL code (and DFG code with no tier-up counter) age out in any full collection that found ≤ 1 MB allocated for optimizedCodeAgingQuietSeconds. That leaves one way to get a recompile cycle: a program that forces full collections itself on a timer (setInterval(() => Bun.gc(true), 30_000) is not unusual) while running hot code that allocates almost nothing between them — every forced collection would drop the FTL code and it would re-tier each time.

What

  • GCRequest::isIdle + Heap::isIdleCollection(): an embedder tags the collection it requests because the application went idle. In CodeBlock::shouldJettisonDueToOldAge, any full collection may still renew a counter-less block's lease when it sees allocation, but only an idle-tagged collection may expire it — allocation-paced, program-forced and memory-pressure collections never drop such code.
  • An idle request is not subsumedBy a pending ordinary request (it has to actually run as idle); GCRequest::dump prints the tag.
  • jsc shell: idleFullGC() = fullGC() with the tag, for tests.
  • Bun sets the tag on the full collections its GarbageCollectionController issues after BUN_IDLE_GC_SECONDS of heap quiet (Let idle FTL code age out once the heap goes quiet (WebKit#557/#558) bun#41297); nothing else does.

Tests

codeblock-aging-ftl-idle.js now checks both directions: after a quiet stretch two plain fullGC()s leave the FTL block alone and idleFullGC() drops it; allocation between idle collections keeps it; -off.js unchanged in spirit (option 0 ⇒ never). In Bun with eager TTLs: idle collections take a 40-function FTL workload from 89 CodeBlocks to 2, while BUN_IDLE_GC_SECONDS=0 + Bun.gc(true) every 0.7 s leaves 84.

…t counter-less optimized code go

Follow-up to #557. FTL code (and DFG code without a tier-up counter) aged out in any full collection that found the
mutator quiet for optimizedCodeAgingQuietSeconds. A program that forces full collections on a timer while running hot,
non-allocating code between them (setInterval(() => Bun.gc(true), 30_000) with a pooled-buffer server, say) satisfies
that and would re-jettison and re-tier its FTL code on every such collection.

Add GCRequest::isIdle / Heap::isIdleCollection(): an embedder sets it on the collection it requests because the
application went idle. Any full collection may still renew a block's lease when it sees allocation, but only an idle
one may expire it, so allocation-paced, forced and memory-pressure collections never drop such code. An idle request
is not subsumed by a pending ordinary one. The jsc shell gets idleFullGC() and the stress tests now check both that
idleFullGC() ages the code out and that plain fullGC() under the same conditions does not.
Jarred-Sumner added a commit to oven-sh/bun that referenced this pull request Sep 3, 2026
… age out idle FTL code in them, and only in them

The full collections issued after BUN_IDLE_GC_SECONDS of heap quiet now go through JSC__VM__collectAsyncIdle,
which sets GCRequest::isIdle (oven-sh/WebKit#558). JSC lets FTL code and counter-less DFG code go only in such a
collection, so a program that forces Bun.gc() on a timer while running hot code keeps its optimized code.

test: with eager JIT TTLs, the idle collections take a 40-function FTL workload from ~90 FunctionCodeBlocks to a
handful; BUN_IDLE_GC_SECONDS=0 plus Bun.gc(true) every 0.7 s leaves them in place.

@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 and didn't find any bugs. It changes when the collector may drop counter-less optimized code, so a human look at the GC-request/CodeBlock-lifetime interaction is still worthwhile.

What was reviewed:

  • GCRequest::isIdle plumbing — default-initialized to false, so every existing construction site (allocation-paced, memory-pressure, collectNow/collectSync defaults) stays non-idle as intended.
  • subsumedBy — an idle request correctly refuses to be absorbed by a pending non-idle one; the reverse (non-idle absorbed by idle) is fine since idle is strictly more capable.
  • Heap::isIdleCollection() is private but reachable from CodeBlock via the existing friend class CodeBlock, matching how totalBytesAllocated() is already accessed.
  • Stress tests updated consistently; -off.js still hard-codes 1e6 rather than testLoopCount, but that's pre-existing.
Extended reasoning...

Overview

This PR adds a bool isIdle tag to GCRequest (guarded by USE(BUN_JSC_ADDITIONS)) so the embedder can mark a full collection as one requested because the application went idle. CodeBlock::shouldJettisonDueToOldAge is tightened so that FTL / counter-less DFG blocks may only be aged out during such an idle-tagged collection — ordinary allocation-paced, program-forced, or memory-pressure collections may still renew the lease but never expire it. Supporting changes: GCRequest::subsumedBy won't let an idle request be swallowed by a pending non-idle one, GCRequest::dump prints the tag, Heap::isIdleCollection() exposes m_currentRequest.isIdle, the jsc shell gains idleFullGC(), and the two codeblock-aging-ftl-idle* stress tests are updated to exercise both the idle and non-idle paths.

Security risks

None identified. The change is entirely internal to JSC's GC/CodeBlock lifetime accounting; it adds no new external inputs, parsing, or privilege surfaces. The new jsc-shell host function idleFullGC() mirrors the existing fullGC() pattern and is only in the test shell, not the runtime API.

Level of scrutiny

Moderate-to-high. The diff is small (~60 lines of C++) and correctly #if USE(BUN_JSC_ADDITIONS)-guarded, but it alters the semantics of when optimized code is jettisoned during GC — a subtle area where mistakes surface as recompile churn or, conversely, code that never releases. Source/JavaScriptCore and JSTests are also covered by CODEOWNERS (@ WebKit/jsc-reviewers), which per policy warrants a human sign-off rather than auto-approval.

Other factors

The implementation follows the established pattern from #557: isIdleCollection() sits next to the existing private totalBytesAllocated() accessor and is reached via the pre-existing friend class CodeBlock declaration in Heap.h. GCRequest::isIdle defaults to false, so every code path that constructs a GCRequest without explicitly setting it (the collector's own scheduling, collectSync(CollectionScope::Full), memory-pressure handlers) remains non-idle — the fail-safe direction. The subsumedBy asymmetry is correct: an idle request must not be absorbed by a non-idle one (or the aging pass would silently never run), while a non-idle request being absorbed by a pending idle one is harmless since idle does strictly more. Test coverage now checks both directions (survives non-idle fullGC(), drops on idleFullGC()), and the option-off test switches to idleFullGC() so it still exercises the "never age out when option is 0" path meaningfully.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
2633cba8 autobuild-preview-pr-558-2633cba8 2026-09-03 22:15:14 UTC

@coderabbitai

coderabbitai Bot commented Sep 3, 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: Essentials

Run ID: 0aa7fc82-52e0-4394-9218-6fe8fbb4a4aa

📥 Commits

Reviewing files that changed from the base of the PR and between 2e0532b and 2633cba.

📒 Files selected for processing (7)
  • JSTests/stress/codeblock-aging-ftl-idle-off.js
  • JSTests/stress/codeblock-aging-ftl-idle.js
  • Source/JavaScriptCore/bytecode/CodeBlock.cpp
  • Source/JavaScriptCore/heap/GCRequest.cpp
  • Source/JavaScriptCore/heap/GCRequest.h
  • Source/JavaScriptCore/heap/Heap.h
  • Source/JavaScriptCore/jsc.cpp

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


Walkthrough

Changes

Idle-triggered FTL aging

Layer / File(s) Summary
Idle GC request plumbing
Source/JavaScriptCore/heap/GCRequest.h, Source/JavaScriptCore/heap/GCRequest.cpp, Source/JavaScriptCore/heap/Heap.h, Source/JavaScriptCore/jsc.cpp
GCRequest records idle-triggered collections. Idle requests remain distinct from ordinary requests. The JSC shell exposes idleFullGC(), and Heap reports the current idle state.
Idle-gated CodeBlock aging
Source/JavaScriptCore/bytecode/CodeBlock.cpp
Quietness-based FTL jettisoning now requires an idle collection. Allocation-paced, forced, and memory-pressure collections renew the lease.
FTL aging test coverage
JSTests/stress/codeblock-aging-ftl-idle.js, JSTests/stress/codeblock-aging-ftl-idle-off.js
Tests use idleFullGC() and verify FTL retention during non-idle collections and allocation activity, followed by removal after quiet idle collections.

Merge Risk: ⚪ Minimal · up to 2633c

No concrete current-head risk was established that should block merging.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly and specifically summarizes the main change: only an embedder-tagged idle collection can expire counter-less optimized code.
Description check ✅ Passed The description provides a clear explanation of the problem, implementation, embedder behavior, testing changes, and expected Bun behavior. It does not include the template's Bugzilla URL, reviewer li…
Full details: Description check

Explanation

The description provides a clear explanation of the problem, implementation, embedder behavior, testing changes, and expected Bun behavior. It does not include the template's Bugzilla URL, reviewer line, or explicit changed-file list, but the core required information is present.

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.

@Jarred-Sumner
Jarred-Sumner merged commit 40e43a8 into main Sep 3, 2026
48 checks passed
Jarred-Sumner added a commit to oven-sh/bun that referenced this pull request Sep 3, 2026
No-Verification-Needed: dependency version bump only
Jarred-Sumner added a commit that referenced this pull request Sep 4, 2026
… optimized code against the last active collection (#560)

* [JSC] CodeBlock aging: drop the per-allocation byte counter; age idle optimized code against the last collection that saw allocation

#557 added a running total in Heap::didAllocate and a per-CodeBlock MB snapshot to tell whether the mutator had been
allocating between full collections. With #558's idle tag that is more machinery than the decision needs: a collection
already knows how much was allocated since the previous one (totalBytesAllocatedThisCycle()).

- Heap stamps m_lastActiveCollectionTime at the start of any collection that finds more than
  optimizedCodeAgingQuietAllocationMB allocated since the previous one. Nothing is added to didAllocate().
- CodeBlock::shouldJettisonDueToOldAge for FTL / counter-less DFG code: only an idle-tagged collection may drop it, and
  only once neither the block's compilation nor an active collection has happened for optimizedCodeAgingQuietSeconds.
  No per-block state is written; m_leaseStartAllocatedMB is gone (CodeBlock is 4 bytes smaller again).
- An embedder's periodic no-op collections while idle (Bun ticks collectAsync() every 30 s) don't see allocation and so
  don't hold the code alive; a forced or allocation-paced collection still can never drop it.

Same behaviour end to end (Bun, express app, idle +120 s: 125 MB anon before and after; idle probe 89 -> 2 CodeBlocks,
forced-GC probe 89 -> 84); the stress test additionally checks that an idle collection right after an active one keeps
the code.

* Review: accumulate per-cycle allocation across collections for the active stamp (a steadily working mutator collected in small cycles still reads as active); guard the test's back-to-back-collections assertion on the measured gap
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