Skip to content

[JSC] A jettison for a dead weak reference counts toward the reoptimization back-off - #634

Open
Jarred-Sumner wants to merge 1 commit into
mainfrom
claude/jettison-backoff-weak-references
Open

[JSC] A jettison for a dead weak reference counts toward the reoptimization back-off#634
Jarred-Sumner wants to merge 1 commit into
mainfrom
claude/jettison-backoff-weak-references

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

What

DFG and FTL code refers weakly to the structures, prototypes and other cells it was specialized on. When one of
them dies the collector jettisons the code (Profiler::JettisonDueToWeakReference), the baseline CodeBlock is
installed again, optimizeAfterWarmUp() re-arms its counter and the function tiers up again. That recompile has
always been as eager as the first compile: CodeBlock::jettison() only bumps the reoptimization retry counter when
the caller passes CountReoptimization, and ScriptExecutable::jettisonCodeBlockEdgeIfDead() never did. A function
whose optimized code keeps dying this way (it checks the structure of objects whose prototype is created per request,
per render, per task) is compiled, runs for a few seconds, dies in the next full collection, and is compiled again at
full price, for as long as the process lives.

With this change such a jettison counts as a reoptimization while the function's retry counter is below
Options::weakReferenceJettisonReoptimizationLimit() (new, default 4; 0 restores the old behaviour). Each counted
death doubles the execution count the next DFG compile waits for, and through baseline->adjustedCounterValue() the
FTL tier-up threshold too, exactly like a jettison for exiting too often.

The diff is one call site (ScriptExecutableInlines.h) and one option.

Why (measurements)

A large bundled CLI application (interactive terminal UI, ~10,000 linked CodeBlocks, ~3,000 of them JIT-compiled),
100-turn session paced like a person types and a server streams (14 minutes, 114 to 116 G user instructions), perf
samples joined with the JIT dump and a per-compile log:

  • the compiler thread is 23 % of the process's instructions (26.3 of 115 G): FTL 14 to 15.7 G, DFG 10.6 G;
  • 49 % of the DFG compile instructions (5.2 of 10.6 G) and 12 to 18 % of FTL's are repeat compiles of a function
    that had been compiled in that tier before;
  • what preceded the repeat: a jettison for a dead weak reference 2.7 G (DFG) + 1.3 G (FTL), a jettison for OSR exits
    1.3 + 1.0 G, the code dying with its CodeBlock 0.85 G, old age 0.2 G. Only the OSR-exit bucket is rate-limited today;
  • 39 functions account for all ~350 weak-reference jettisons (optimized and OSR-entry code); 25 of them died this way 5 to 37 times in 14 minutes.
    One function of 8.9 k bytecode cost was DFG-compiled 5 times at ~100 M instructions each, one FTL-compiled 4 times
    at 160 to 250 M, each copy living 9 to 23 s.

Same session, with every weak-reference jettison counted (the first version of this change, no limit):

before after
process instructions:u 114.8 G 112.1 G (-2.4 %)
main thread 72.7 G 73.2 G (+0.7 %)
compiler thread 26.3 G 23.1 G (-3.2 G)
repeat compiles, DFG / FTL 5.2 / 3.1 G 3.2 / 1.6 G
weak-reference jettisons, DFG / FTL 308 / 23 151 / 10
time per turn, render time per session, peak and resting memory unchanged

Re-measured with this change (whole-process perf stat -e instructions:u, variants interleaved, each on its own copy of
the binary, option toggled by environment). All application numbers in this text are from the application's build,
which also carries #628; the shell numbers and the test runs are from main plus this commit:

paced 100-turn session limit 0 (old behaviour) limit 4 (default) limit 6 no limit (100)
round 1 116.01 G 113.87 G 114.21 G 113.33 G
round 2 115.80 G 114.09 G 112.56 G 113.13 G

| mean | 115.9 G | 114.0 G (-1.7 %) | 113.4 G (-2.2 %) | 113.2 G (-2.3 %) |

(One more pair with the first version, no limit: 116.07 G -> 112.67 G, -2.9 %. Two runs of the same variant differ by up to 0.7 G.)
Time per turn is the stream in every variant (890 to 905 s for the 100 turns); peak anonymous memory 239 to 248 MB in all.
An unpaced burst of 20 turns (one 5 s burst from cold; it has almost no weak-reference jettisons) is within noise:
20.92 / 20.39 / 21.15 G before, 20.46 / 20.46 / 20.10 G after (3 interleaved rounds); peak anonymous memory
198 to 227 MB in both.

Which jettison reasons count

reason produced by recompile follows? counts
JettisonDueToOSRExit triggerReoptimizationNow yes already (unchanged)
JettisonDueToBaselineLoopReoptimizationTrigger, ...OnOSREntryFail baseline loop trigger finds optimized code that should be reoptimized / OSR entry keeps failing yes already (unchanged)
JettisonDueToUnprofiledWatchpoint CodeBlockJettisoningWatchpoint, DFG::AdaptiveStructureWatchpoint, DFG::AdaptiveInferredPropertyValueWatchpoint yes already (unchanged)
JettisonDueToWeakReference jettisonCodeBlockEdgeIfDead: the executable is marked, its optimizing block is not, and it did not age out yes: the baseline alternative is kept alive by visitCodeBlockEdge precisely so that it can be reinstalled, and jettison() re-arms it now, up to the limit
JettisonDueToOldAge same function, block aged out only if the function is called again after not running for its whole lease no: the retry counter lives on the baseline CodeBlock, and for an aged-out optimizing block visitCodeBlockEdge deliberately does not keep the alternative alive, so the counter's owner dies in the same collection. Counting would be a no-op. These recompiles are also rate-limited by construction (at most one per lease) and measured at 0.2 G of 5.2 G
JettisonDueToVMTraps, JettisonDueToDebuggerBreakpoint, ...Stepping termination / debugger yes, but these are one-off events, not a treadmill no

Why a limit, and why 4

The retry counter never decays, it is shared with OSR-exit reoptimizations, and it also scales the number of OSR
exits tolerated before optimized code is reoptimized (adjustedExitCountThreshold). Weak-reference deaths say
nothing about the quality of the speculation, and in a long-lived process there is no bound on how many of them a
function sees: uncapped, a request handler whose optimized code dies in every full collection would, a few dozen
collections later, wait 2^18 times the normal count and effectively stay in baseline code for good, having
tolerated up to 2^18 x 100 exits on the way. With the limit the worst case is a 16x threshold and a 16x exit
tolerance: a function that is hot recompiles within seconds of the death, a function that barely reached the
threshold between two collections (the ones that make up the repeat-compile bucket above) does not.
The counter itself cannot overflow: the count goes through CodeBlock::countReoptimization(), which clamps at
reoptimizationRetryCounterMax (derived from the largest shift that keeps the threshold inside int32; the field is
a uint16_t). A limit above that maximum just means "no limit".
In the measured session a limit of 4 keeps about three quarters of what counting without a limit saves (-1.7 % against -2.3 %
of the process's instructions), 6 all of it. 4 is the cautious choice: after a death a hot function is back in DFG code after 16x
and back in FTL code after 16x the usual wait, not 64x; the option is there to move it.

This cannot cause a miscompile

No code generation is touched: not the DFG, not B3/FTL, not the baseline JIT, not OSR exit or entry, not what the
collector marks or when it jettisons. The only effect is the value of m_reoptimizationRetryCounter on a baseline
CodeBlock after a jettison that already happened, i.e. the constant adjustedCounterValue() multiplies the
execution-count threshold with. A compile that happens later than before compiles the same bytecode with at least as
much profiling. Every value the counter can now take it could already take (an OSR-exit reoptimization increments
the same field through the same function).

Test

JSTests/stress/weak-reference-jettison-counts-toward-reoptimization-backoff.js: two functions (one checks the
structure of an object whose prototype nothing else refers to, one loads an inherited property from such a prototype)
are called until they run in DFG code, the object is dropped, fullGC() kills the code through the dead prototype,
nine times. The number of calls each life needed is the threshold, read out directly:

limit 0   (old behaviour): 100, 18, 18, 18, 18, 18, 18, 18, 18          retry count 0
limit 4   (default):       100, 35, 67, 134, 268, 268, 268, 268, 268    retry count 4
limit 100 (no limit):      100, 35, 67, 134, 268, 536, 1072, 2203, 4405 retry count 9

The test asserts the doubling up to the limit and the plateau after it (within a factor of 1.5) and the retry count,
in all three configurations. If optimized code survives a collection (something else kept the object alive), the
attempt is discarded and repeated with a fresh function.

Testing

All on main (cf1b36e) plus this commit; base = a shell built from main itself.

  • JSTests/stress, release, 5,832 files x default / no-cjit-validate (validateBytecode, validateGraphAtEachPhase,
    useConcurrentJIT=false, thresholdForJITAfterWarmUp=100, scribbleFreeCells) / eager thresholds
    (thresholdForJITAfterWarmUp=10, thresholdForOptimizeAfterWarmUp=20) / useEagerCodeBlockJettisonTiming /
    collectContinuously: 34 to 36 failures per mode, the same files as the base shell in every mode (ICU/date,
    ensure-crash, eval-func-decl, ...), apart from two tests that flip on a loaded machine in both shells
    (int8-repeat-in-then-out-of-bounds.js, written for a synchronous compiler, failed in default here and in
    collectContinuously on the base; class-fields-to-property-key-const-string-ftl.js failed once on the base only), and the new
    test, which the base shell fails (it does not know the option).
  • ASAN + assertions subset (1,520 files whose names match call / profile / llint / osr / tier / inline / ic- / construct /
    spread / iterator / for-of / super / closure / scope / varargs / apply / module / function / tdz / jettison / weak / reoptimiz,
    x default / eager jettison timing / eager thresholds / no concurrent JIT / collectContinuously): 17 to 18 failures per mode
    (+ 3 timeouts under collectContinuously), the same files as the base ASAN shell except intl-datetimeformat-language-change-mid-construction.js
    (a worker-timing test; failed once here, passes 3 of 3 alone on both shells) and activation-sink-osrexit-default-value-tdz-error.js
    under collectContinuously (at the 20-minute limit on both; the base run finished it just under, it is on the base's timeout list in an earlier run).
  • The new test: release and ASAN, the three limits, and additionally under no-cjit-validate, eager thresholds, eager
    jettison timing, collectContinuously, useFTLJIT=0, useJIT=0 (exits early). One combination fails: limit 100 with
    eager thresholds, where a threshold of two calls does not double cleanly nine times; the run lines never combine them.
  • Driven in a release build of Bun on the application's engine tree (main thread, a Worker, concurrent JIT on and off, the
    three limits): same sequence as the shell (28, 55, 109, 201, 201, ... calls per life at the default limit).

…zation back-off

DFG and FTL code refers weakly to the structures, prototypes and other cells
it was specialized on. When one of them dies the collector jettisons the code
(Profiler::JettisonDueToWeakReference), the baseline CodeBlock is installed
again and the function tiers up again. That recompile was as eager as the
first compile: only jettisons for OSR exits, for the baseline loop trigger and
for unprofiled watchpoints passed CountReoptimization. A function whose
optimized code checks the structure of objects with a short-lived prototype
is compiled, dies in the next full collection and is compiled again at full
price for as long as the process lives. In a 14 minute session of a large
bundled CLI application half of the DFG compile time was repeat compiles, and
the largest part of those followed a weak-reference jettison.

ScriptExecutable::jettisonCodeBlockEdgeIfDead() now passes CountReoptimization
for such a jettison while the function's reoptimization retry counter is below
Options::weakReferenceJettisonReoptimizationLimit() (default 4; 0 restores the
old behaviour). Each counted death doubles the execution count the next DFG
and FTL compiles wait for, like a jettison for exiting too often. The limit
is there because the counter never decays and also scales the number of OSR
exits tolerated before reoptimization: unbounded, a function whose code dies
in every full collection of a long-lived process would end up waiting forever.
The count goes through CodeBlock::countReoptimization(), so the existing clamp
at reoptimizationRetryCounterMax applies.

Jettisons for old age are not counted: the counter lives on the baseline
CodeBlock, which an aged-out optimizing block does not keep alive, so it dies
in the same collection.

No code generation changes; a later compile sees the same bytecode with at
least as much profiling.

* JSTests/stress/weak-reference-jettison-counts-toward-reoptimization-backoff.js: Added.
* Source/JavaScriptCore/runtime/OptionsList.h:
* Source/JavaScriptCore/runtime/ScriptExecutableInlines.h:
(JSC::ScriptExecutable::jettisonCodeBlockEdgeIfDead):
@github-actions

Copy link
Copy Markdown

Preview Builds

Commit Release Date
2a12cf2c autobuild-preview-pr-634-2a12cf2c 2026-09-12 07:56:58 UTC

@Jarred-Sumner
Jarred-Sumner marked this pull request as ready for review September 12, 2026 08:17

@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. The runtime change is tiny and well-contained, but it adjusts a core JIT tier-up heuristic and both changed paths are covered by CODEOWNERS (@ WebKit/jsc-reviewers), so a human look is still worthwhile — particularly on the default of 4 and on whether the new stress test meets the JSTests <200ms / testLoopCount rules across its three runDefault configurations.

What was reviewed:

  • jettisonCodeBlockEdgeIfDead: shouldJettisonDueToWeakReference gates on isOptimizingJIT, so baselineAlternative() is always valid here; jettison() already dereferences it on the CountReoptimization path, so no new lifetime hazard.
  • Counter overflow: countReoptimization() clamps at reoptimizationRetryCounterMax, and the new limit only gates whether to increment, so setting it to 100 cannot overflow the uint16_t.
  • Option plumbing: added alongside reoptimizationRetryCounterMax in OptionsList.h with Unsigned type and a description string; 0 correctly restores prior behavior since the comparison is strict <.
Extended reasoning...

Overview

The PR changes one call site in Source/JavaScriptCore/runtime/ScriptExecutableInlines.h: when an optimizing CodeBlock is jettisoned because a weakly-referenced cell died, the jettison now passes CountReoptimization (instead of the implicit DontCountReoptimization default) while the baseline alternative's retry counter is below a new option weakReferenceJettisonReoptimizationLimit (default 4). A new option is added to OptionsList.h, and a new stress test in JSTests/stress/ exercises the behavior across three option values (default, 0, 100) with --useConcurrentJIT=false, checking both the retry counter value and the roughly-doubling call count per life.

Security risks

None identified. No code generation, parsing, or memory-management logic is touched. The only effect is the value of m_reoptimizationRetryCounter on a baseline CodeBlock after a jettison that already happens today; every value it can now reach was already reachable via OSR-exit reoptimizations, and countReoptimization() clamps at reoptimizationRetryCounterMax. shouldJettisonDueToWeakReference returns true only for optimizing JIT code, so baselineAlternative() walks a valid chain (and jettison() itself already calls baselineAlternative()->countReoptimization() on this path, so no new dereference is introduced).

Level of scrutiny

Medium-to-high. The mechanical diff is three lines plus an option, and the reasoning in the PR description is thorough and internally consistent with the code I read (adjustedCounterValue shifts by the retry counter; numberOfDFGCompiles is retry count plus one when optimized code is installed, which matches the test's numberOfDFGCompiles(subject) !== reoptimizationRetryCount(subject) check after GC). However, this is a JIT tier-up heuristic with process-wide performance implications, the default value (4) is a judgment call backed by measurements on one workload, and both Source/JavaScriptCore/ and JSTests/ are CODEOWNER-covered. That combination puts it outside the "simple/mechanical" bar for auto-approval.

Other factors

The new stress test hard-codes maximumCallsPerLife = 1 << 20 and runs 2 scenarios × 9 lives × 3 configurations, with the limit=100 configuration doubling up to 2^7× the base threshold by life 8. JSTests/README.md requires new stress tests to run in under 200ms in all configurations and to use testLoopCount for iteration sizing; the test's design (measuring actual tier-up call counts) makes testLoopCount a poor fit, but the 200ms budget is worth confirming with --report-execution-time. The test also depends on conservative-stack-scanning behavior (clobberStack) and retries up to 3 times per scenario, which is a reasonable pattern for GC-timing-sensitive tests but adds to the case for a human sanity check.

@coderabbitai

coderabbitai Bot commented Sep 12, 2026

Copy link
Copy Markdown

Review Change StackReview 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: d06027b1-2b32-4781-92d0-25ac2d2c16e2

📥 Commits

Reviewing files that changed from the base of the PR and between cf1b36e and 2a12cf2.

📒 Files selected for processing (3)
  • JSTests/stress/weak-reference-jettison-counts-toward-reoptimization-backoff.js
  • Source/JavaScriptCore/runtime/OptionsList.h
  • Source/JavaScriptCore/runtime/ScriptExecutableInlines.h

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


Walkthrough

The runtime adds a limit for weak-reference jettison reoptimization counting. A stress test covers structure- and prototype-based weak references, garbage collection, recompilation, retry counts, and capped backoff.

Changes

Weak-reference reoptimization backoff

Layer / File(s) Summary
Runtime retry-limit handling
Source/JavaScriptCore/runtime/OptionsList.h, Source/JavaScriptCore/runtime/ScriptExecutableInlines.h
Adds weakReferenceJettisonReoptimizationLimit with default value 4. Weak-reference jettisoning counts toward reoptimization backoff only while the retry counter is below the configured limit.
Weak-reference stress scenarios
JSTests/stress/weak-reference-jettison-counts-toward-reoptimization-backoff.js
Adds structure- and prototype-based weak-reference scenarios. The test drives subjects to optimized execution and clears stale references before garbage collection.
Recompilation backoff validation
JSTests/stress/weak-reference-jettison-counts-toward-reoptimization-backoff.js
Runs repeated object lifetimes, checks optimized-code recompilation, and verifies approximately doubling delays until the configured retry limit applies.

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 2a12c

The retry-limit behavior and stress coverage align with the intended weak-reference reoptimization backoff change.

🚥 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 change, rationale, measurements, affected jettison reasons, and testing. However, it does not include the required Bugzilla bug title and URL, revie… Add the associated Bugzilla URL and bug title, include the required review line such as "Reviewed by NOBODY (OOPS!)." or the actual reviewer, and add the changed paths with relevant functions. Retain the existing technical explanation and t…
✅ Passed checks (3 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 concisely describes the main change: dead weak-reference jettisons now count toward reoptimization back-off.
Full details: Description check

Explanation

The description gives a detailed explanation of the change, rationale, measurements, affected jettison reasons, and testing. However, it does not include the required Bugzilla bug title and URL, review status, or the template's explicit changed-file and function list.

Resolution

Add the associated Bugzilla URL and bug title, include the required review line such as "Reviewed by NOBODY (OOPS!)." or the actual reviewer, and add the changed paths with relevant functions. Retain the existing technical explanation and testing details.

  • 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.

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