Skip to content

CodeBlock: renew old-age TTL for LLInt/Baseline blocks that are still executing - #333

Open
robobun wants to merge 4 commits into
mainfrom
farm/d322701a/codeblock-aging-execution-count
Open

CodeBlock: renew old-age TTL for LLInt/Baseline blocks that are still executing#333
robobun wants to merge 4 commits into
mainfrom
farm/d322701a/codeblock-aging-execution-count

Conversation

@robobun

@robobun robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Problem

CodeBlock::shouldJettisonDueToOldAge discards a CodeBlock purely by wall-clock age since creation (LLInt 5s, Baseline 15s, DFG 20s, FTL 60s): at each full GC an unmarked CodeBlock older than its TTL is jettisoned even if it ran a millisecond ago, because "still in use" is only sampled by "was on the stack at GC time". In a long-running JS-heavy Bun process that re-executes the same few thousand functions every iteration (for example a large interactive TUI-style app), the whole warm working set is thrown away at every full GC and then relinked, re-profiled and re-JIT'd, over and over.

On such a workload (~5.5k linked functions, 100 iterations of the same activity) we measured ~12.5k old-age jettisons, ~2.6k of which were repeated jettisons of the same functions, and hundreds of redundant DFG recompilations.

Change

LLInt and Baseline CodeBlocks already carry an ExecutionCounter that the interpreter / baseline JIT ticks on function entry and loop back-edges (UnlinkedCodeBlock::llintExecuteCounter() for LLInt, BaselineJITData::executeCounter() for Baseline). In shouldJettisonDueToOldAge, after the TTL check, compare the live counter with a per-CodeBlock snapshot: if it moved, update the snapshot, push m_creationTime forward so the block is not considered old again for codeBlockAgingLeaseMultiplier * timeToLive (default 3x) of its tier, and return false. Optimizing-tier CodeBlocks have no cheap per-entry counter and keep the existing pure-TTL policy.

The snapshot reuses the existing float m_previousCounter field. updateActivity() in finalizeUnconditionally already uses it for the same signal behind VM::useUnlinkedCodeBlockJettisoning(); outside that mode the field was unused, and in that mode both writers store the same current count for the same tier so they agree. sizeof(CodeBlock) stays at 216. The snapshot is initialised in both CodeBlock constructors to the LLInt counter's value at creation time, so a CodeBlock that is linked and never entered does not get a spurious renewal, and the != comparison (rather than <) handles the LLInt→Baseline counter-source transition correctly.

Two new options in runtime/OptionsList.h, both tunable at runtime:

useExecutionCountForCodeBlockAging  (Bool,   default true)
codeBlockAgingLeaseMultiplier       (Double, default 3.0)

Verification

Policy

jsc --useEagerCodeBlockJettisonTiming=1 with 200 functions each called once per iteration, 60ms busy-wait then fullGC(), 50 iterations; a jettison is counted when $vm.codeBlockFor(fn) === undefined immediately after the GC.

old-age jettisons of active fns
--useExecutionCountForCodeBlockAging=0 ~10000
--useExecutionCountForCodeBlockAging=1 0

A companion probe with a separate set of never-called functions under the new policy recorded 0 jettisons of the active set while the idle set was still discarded, so genuinely one-shot CodeBlocks are still collected and this is not equivalent to forceCodeBlockLiveness.

End-to-end (Bun, macOS arm64)

Same binary, policy on vs off, large TUI workload described above:

metric effect (4 runs) effect (3 runs, m_previousCounter variant)
CPU time −9% −10%
wall time −10% −8%
libpas primary-heap live −13 MB −21 MB
old-age jettisons 12.5k → ~5k 12.8k → 7.2k
DFG compiles −12% −14%
phys_footprint neutral / slightly better neutral

This lands within noise of the forceCodeBlockLiveness=1 upper bound on CPU/wall while still discarding ~7k genuinely one-shot CodeBlocks per session; repeated jettisons of the same function drop from ~2.6k to ~130.

(I looked for oven-sh/bun-perf-tester to A/B the express / fastify / elysia server benches but could not locate that repo; happy to run them if someone points me at it.)

Tests

JSTests/stress/codeblock-aging-execution-count.js runs under useEagerCodeBlockJettisonTiming with a 60ms wait between GCs (exceeds both the 10ms LLInt and 30ms Baseline eager TTLs) and asserts that 80 actively-executed functions keep their CodeBlock across 30 full GCs while a never-called function is still discarded. The test passes in the default config, with --thresholdForJITAfterWarmUp=5 (functions at Baseline), with --useJIT=0 (LLInt only), with --useUnlinkedCodeBlockJettisoning=1 (mini-mode, shared m_previousCounter), and with --codeBlockAgingLeaseMultiplier=1.0, and fails as expected with --useExecutionCountForCodeBlockAging=0.

Ran Tools/Scripts/run-jsc-stress-tests JSTests/stress --quick: no regressions attributable to this change (every pre-existing failure on this build behaves identically with the option on vs off). Release and Debug JSCOnly configurations both compile cleanly, and the BaselineJIT counter access is guarded by #if ENABLE(JIT) so non-JIT / 32-bit configs are unaffected.

… executing

CodeBlock::shouldJettisonDueToOldAge discards a CodeBlock purely by wall-clock
age since creation (LLInt 5s, Baseline 15s, DFG 20s, FTL 60s). In a long-running
JS-heavy process that re-executes the same working set every iteration, this
throws away the entire warm set at every full GC and then pays to relink,
re-profile and re-JIT it.

LLInt and Baseline CodeBlocks already carry an ExecutionCounter that the
interpreter / baseline JIT ticks on function entry and loop back-edges. Snapshot
that counter in a new CodeBlock::m_lastExecutionCountForAging (8 bytes; sizeof
stays at the 224-byte static_assert limit) and, at the point where
shouldJettisonDueToOldAge would otherwise return true, compare the live counter
with the snapshot: if it moved, update the snapshot, push m_creationTime forward
by codeBlockAgingLeaseMultiplier * timeToLive for the tier (default 3x), and
return false. Optimizing-tier CodeBlocks have no cheap per-entry counter and keep
the existing pure-TTL policy.

Two new options in OptionsList.h:
  useExecutionCountForCodeBlockAging (Bool, default true)
  codeBlockAgingLeaseMultiplier (Double, default 3.0)

Add JSTests/stress/codeblock-aging-execution-count.js which, under
useEagerCodeBlockJettisonTiming, verifies that actively-executed functions keep
their CodeBlock across repeated full GCs while a never-called function is still
discarded.
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

The change adds configurable execution-count-based code block aging, renews aging leases when counters advance, and introduces a stress test covering continuously executed and idle functions across repeated garbage-collection cycles.

Code block aging

Layer / File(s) Summary
Aging policy and configuration
Source/JavaScriptCore/bytecode/CodeBlock.cpp, Source/JavaScriptCore/runtime/OptionsList.h
Adds execution-count and lease-multiplier options, initializes previous counters, and renews code block TTLs when execution activity advances.
Active and idle aging validation
JSTests/stress/codeblock-aging-execution-count.js
Exercises active and idle functions through repeated TTL waits and full GCs, checking active jettisons, idle disposal, and final results.

Suggested reviewers: kmiller68, hyjorc1, constellation

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers problem, change, and verification, but it omits the required bug link/template sections and Bugzilla metadata. Add the bug URL, reviewed-by line, and the remaining template fields and Bugzilla metadata required by the repository PR template.
✅ 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 is concise and accurately summarizes the main change to CodeBlock aging for active LLInt/Baseline blocks.

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.

No issues found, but this changes an on-by-default GC/JIT retention heuristic and grows sizeof(CodeBlock) to the static_assert limit — worth a human sign-off on the policy and memory tradeoff.

What was reviewed:

  • Counter-read logic mirrors the existing updateActivity() lambda; #if ENABLE(JIT) guard matches.
  • m_creationTime is only read via timeSinceCreation() in this one path, so pushing it into the future is safe.
  • Shared llintExecuteCounter across sibling CodeBlocks was flagged and ruled out — worst case is a benign extra lease for an idle sibling.
  • Snapshot initialized in both constructors so a never-entered block does not get a spurious first renewal.
Extended reasoning...

Overview

This PR modifies CodeBlock::shouldJettisonDueToOldAge to renew the old-age TTL for LLInt/Baseline CodeBlocks whose execution counter has advanced since the last check, instead of jettisoning purely on wall-clock age. It adds an 8-byte m_lastExecutionCountForAging field (bringing sizeof(CodeBlock) from 216 to 224, exactly at the existing static_assert limit), initializes it in both constructors, and adds two new runtime options (useExecutionCountForCodeBlockAging, default true; codeBlockAgingLeaseMultiplier, default 3.0). A stress test is included.

Security risks

None identified. This is a GC-time retention heuristic; it does not touch parsing, JIT codegen, auth, or any untrusted-input path. The worst-case failure mode is over-retention of CodeBlocks (higher memory) or under-retention (the old behavior).

Level of scrutiny

This warrants human review. It is a well-reasoned, well-tested, self-contained change, and the implementation mirrors the existing updateActivity() pattern in finalizeUnconditionally. However, it is an on-by-default policy change to a core JIT/GC lifecycle heuristic that trades memory retention against recompilation cost. The 8-byte per-CodeBlock growth lands exactly at the size assertion limit, and the m_creationTime mutation (to a future timestamp) is a novel use of that field. Someone familiar with JSC's memory/performance envelope should confirm the default multiplier and the size budget spend are acceptable.

Other factors

  • m_creationTime/timeSinceCreation() is only referenced by shouldJettisonDueToOldAge itself, so setting it to a future time has no side effects elsewhere.
  • The BaselineJIT counter access is correctly guarded by #if ENABLE(JIT).
  • A finder raised the shared llintExecuteCounter (per-UnlinkedCodeBlock, not per-CodeBlock) causing spurious renewals for idle siblings; verifiers concluded this is benign since the effect is bounded to one extra lease and idle siblings are rare.
  • The PR description reports stress-test and end-to-end verification with clear before/after numbers, and the option can be disabled at runtime if a regression surfaces.

… to cover Baseline TTL

Drop the new m_lastExecutionCountForAging field and store the aging snapshot in
the existing float m_previousCounter instead. updateActivity() in
finalizeUnconditionally already uses that field for the same signal behind
VM::useUnlinkedCodeBlockJettisoning(); outside that mode the field was unused,
and in that mode both writers store the same current count so they agree. This
keeps sizeof(CodeBlock) at 216 rather than spending the remaining headroom under
the 224-byte static_assert.

Bump the stress test's busy-wait from 25ms to 60ms so it exceeds the eager
Baseline TTL (30ms) as well as the LLInt TTL (10ms), making the active-function
assertion a real regression test even if the active set reaches Baseline.
@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 91ca606 addressing both points:

  1. Dropped m_lastExecutionCountForAging and reuse the existing float m_previousCounter as the snapshot. sizeof(CodeBlock) verified back at 216 (temporarily tightened the static_assert to <= 216 and it compiles). The snapshot is still initialised from the LLInt counter in both constructors. Added a short comment in shouldJettisonDueToOldAge noting the shared use with updateActivity(); I kept the refresh in the age check rather than finalizeUnconditionally, so the eden-GC gating caveat doesn't apply. Also re-ran the stress test under --useUnlinkedCodeBlockJettisoning=1 and reparsing-unlinked-codeblock.js to confirm the two writers don't interfere.
  2. Bumped the test's busy-wait to 60ms so it exceeds the eager Baseline TTL (30ms) as well as the LLInt TTL.

Re-ran the probe (200 active fns × 50 GCs, 60ms wait): OFF ≈ 10k jettisons, ON = 0; stress test passes in default / --thresholdForJITAfterWarmUp=5 / --useJIT=0 / --useUnlinkedCodeBlockJettisoning=1 / --codeBlockAgingLeaseMultiplier=1.0, and still fails with --useExecutionCountForCodeBlockAging=0.

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

Beyond the inline nits, two other concerns were checked and ruled out: (1) a CodeBlock that starts life at Baseline via a cached unlinked-code path still snapshots the LLInt counter in the constructor, but the first old-age check reads the Baseline executeCounter() instead, so the != compare against the LLInt-sourced snapshot does not grant a free renewal for a never-entered block; (2) the renewal path writing m_previousCounter before finalizeUnconditionally runs does not starve updateActivity()'s UnlinkedCodeBlock-age reset, since that path compares with < and both writers store the same current count.

Extended reasoning...

This is a default-on change to a core JSC GC/jettison heuristic (shouldJettisonDueToOldAge), so it should get a human sign-off regardless of the automated findings; I'm not approving. The three inline comments are all nit-level (float-ULP edge, eden-vs-full snapshot timing under a non-default option, shared LLInt counter aliasing) and each degrades gracefully to the pre-PR policy — none block. This note just records the two additional interactions the finder pass raised that verifiers refuted, so a human reviewer doesn't re-derive them.

Comment on lines +1334 to +1353
float currentCount = 0;
bool hasCounter = false;
switch (type) {
case JITType::InterpreterThunk:
currentCount = m_unlinkedCode->llintExecuteCounter().count();
hasCounter = true;
break;
#if ENABLE(JIT)
case JITType::BaselineJIT:
if (auto* jitData = baselineJITData()) {
currentCount = jitData->executeCounter().count();
hasCounter = true;
}
break;
#endif
default:
break;
}
if (hasCounter && currentCount != m_previousCounter) {
m_previousCounter = currentCount;

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: after deferIndefinitely() (e.g. dontJITAnytimeSoon() when !shouldJIT() / !ENABLE(JIT), or dontOptimizeAnytimeSoon() on DFG CompilationFailed), count() sits at ~INT32_MIN where the float ULP is 128, so up to ~4 function entries (+15) or ~64 loop back-edges (+1) round to the same float and the != check misses the movement. Not a regression — it just falls back to the pre-PR TTL policy for those blocks — and updateActivity() has the same limitation via the same field, but a short comment noting the ULP cliff (or comparing the raw int32_t m_counter if you ever revisit this) might save a future reader some confusion.

Extended reasoning...

What happens

ExecutionCounter::count() returns static_cast<double>(m_totalCount) + m_counter (ExecutionCounter.h:66), but the renewal path stores it into float currentCount and compares against float m_previousCounter. When the counter has been through deferIndefinitely() — which sets m_totalCount = 0, m_counter = INT32_MIN (ExecutionCounter.cpp:71-73) — count() returns -2147483648.0. In the range [2^30, 2^31) a 32-bit float has ULP 2^7 = 128, so any subsequent m_counter value in [INT32_MIN, INT32_MIN+64] rounds back to -2147483648.0f. The currentCount != m_previousCounter check at line 1352 then reports "no movement" and the block is jettisoned even though it executed.

When the counter is deferred

This state is reached routinely for exactly the blocks that are pinned to LLInt/Baseline forever:

  • LLInt-pinned: LLIntSlowPaths entryOSR/loop_osr/ret call codeBlock->dontJITAnytimeSoon() (which calls m_unlinkedCode->llintExecuteCounter().deferIndefinitely(), CodeBlock.h:617) whenever !shouldJIT() — i.e. --useJIT=0, bytecodeRangeToJITCompile filters, or !ENABLE(JIT) builds (LLIntSlowPaths.cpp:419/436/490/512/531/534). LLInt keeps incrementing m_counter via baddis after the defer (LowLevelInterpreter.asm:1581), so the counter climbs from INT32_MIN by +15/entry or +1/back-edge.
  • Baseline-pinned: setOptimizationThresholdBasedOnCompilationResult() calls dontOptimizeAnytimeSoon() (→ baselineJITData()->executeCounter().deferIndefinitely(), CodeBlock.cpp:2782-2783) after DFG CompilationFailed (CodeBlock.cpp:2860).

These are the two "stuck-in-tier-forever" populations the PR's policy is arguably most useful for.

Concrete walk-through

  1. Block is LLInt with --useJIT=0. After ~34 calls the LLInt counter crosses thresholdForJITAfterWarmUp=500, entryOSR fires, !shouldJIT()dontJITAnytimeSoon()m_totalCount=0, m_counter=-2147483648.
  2. Full GC maybe upgrade #1, TTL exceeded. currentCount = (float)(0 + -2147483648) = -2147483648.0f. Say m_previousCounter was ~-500.0f from before the defer, so != holds → renewed, snapshot set to -2147483648.0f.
  3. Block is called once in the next lease window: m_counter = -2147483633.
  4. Full GC Move stdint.h import out of __cplusplus guard in ForkExtras.h #2, TTL exceeded. count() = -2147483633.0; (float)(-2147483633.0) rounds to nearest representable float. Nearest floats are -2147483648.0f and -2147483520.0f; midpoint is -2147483584; -2147483633 is on the -2147483648 side → currentCount = -2147483648.0f == m_previousCounter. Block is jettisoned despite having run.

Any block called ≤4 times (4×15=60 < 64) or with ≤64 loop back-edges per lease window falls through this hole.

Why this is a nit, not a bug

  • Not a regression. The failure mode is exactly the pre-PR TTL jettison. Nothing gets worse than before this change.
  • Pre-existing pattern. updateActivity() in finalizeUnconditionally (CodeBlock.cpp:1854/1885) already stores count() into a float and compares via m_previousCounter < count with the identical precision cliff. The PR deliberately reuses that field to keep sizeof(CodeBlock)==216 (per the review round in the timeline), so this is aligning with an established trade-off, not introducing a new one.
  • Narrow population × low rate. !useJIT/bytecodeRangeToJITCompile are debug/testing knobs, !ENABLE(JIT) is not a Bun production config, and DFG CompilationFailed is rare. Within that population it only bites blocks called <~5 times per 3×TTL — marginal "warm" candidates.
  • Test gap is real but low-value. The stress test's 80 active functions get ~32 calls × 15 ≈ 480 < 500 before the first GC, so under --useJIT=0 entryOSR never fires and deferIndefinitely() is never reached — the test passes without exercising this path. That's worth knowing but doesn't change the severity.

Possible fix (if the author cares)

Compare the raw int32_t m_counter directly (it's what actually moves post-defer, and int32 equality has no ULP), or store the snapshot as double. Both would either diverge from updateActivity() or grow sizeof(CodeBlock), which the PR explicitly avoided — so simply adding a comment next to the float currentCount declaration noting that deferred counters near INT32_MIN may lose small deltas to float rounding is probably the right call.

Comment on lines +1329 to +1333
// The snapshot lives in m_previousCounter, which updateActivity() in
// finalizeUnconditionally also writes for UnlinkedCodeBlock aging when
// VM::useUnlinkedCodeBlockJettisoning() is enabled. Both sites store the
// same current count for the same tier, so they agree; outside that mode
// updateActivity() never touches the field.

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: under --useUnlinkedCodeBlockJettisoning=1, updateActivity() in finalizeUnconditionally can run on an eden GC (for a write-barriered CodeBlock) and overwrite m_previousCounter with the current count, while this old-age check only reaches the counter comparison on full GCs — so an eden GC after the last execution can absorb the delta and the next full GC sees currentCount == m_previousCounter and jettisons anyway. It's mini-mode only, needs the block to have been barriered (IC fill / call link), and gracefully degrades to the pre-PR policy, so not blocking — but the "both sites … agree" comment slightly overstates the harmony; might be worth noting the eden-vs-full timing caveat.

Extended reasoning...

What the interaction is. shouldJettisonDueToOldAge() now reuses m_previousCounter as its "did the block execute since I last looked" snapshot, and the comment at CodeBlock.cpp:1329-1333 says the other writer — updateActivity() inside finalizeUnconditionally() — "stores the same current count for the same tier, so they agree". That is true when both writers observe the counter within the same GC cycle, but they can also fire in different cycles. shouldJettisonDueToOldAge() runs during marking and early-returns at visitor.isMarked(this) (line 1309), so on an eden GC an old-generation CodeBlock (sticky-marked) never reaches the counter comparison. updateActivity(), on the other hand, runs in finalizeUnconditionally() and unconditionally does m_previousCounter = count; (line 1885) whenever VM::useUnlinkedCodeBlockJettisoning() is on — with no CollectionScope gate.

Concrete walk-through (mini-mode, --useUnlinkedCodeBlockJettisoning=1):

  1. Full GC N: block is past its TTL; shouldJettisonDueToOldAge sees currentCount = 100 ≠ m_previousCounter, renews the lease, writes m_previousCounter = 100. Later in the same cycle updateActivity() writes m_previousCounter = 100 again — harmless.
  2. The block executes; an LLInt slow path (IC fill, CallLinkInfo link, etc.) calls vm.writeBarrier(codeBlock), putting it in the remembered set. Counter is now 150.
  3. Eden GC: the barriered CodeBlock's visitChildren fires from the mutator mark stack, which re-adds it to the finalizer IsoCellSet (line 1234). shouldJettisonDueToOldAge early-returns at isMarked and does not update the snapshot. Then finalizeUnconditionallyupdateActivity() writes m_previousCounter = 150.
  4. The block happens to be idle in the (short) window between that eden GC and the next full GC.
  5. Full GC N+1 after the renewed lease expires: currentCount = 150 == m_previousCounterhasCounter && currentCount != m_previousCounter is false → the block is jettisoned even though it ran between the two full GCs.

Addressing the counter-argument. One reading is that updateActivity() "doesn't run on every eden GC" because finalizeUnconditionally iterates codeBlockSpaceAndSet.set (Heap.cpp:807), which is emptied each cycle at line 1889 and only repopulated by visitChildren — so on an eden GC only write-barriered CodeBlocks are in the set. That's correct, and it does narrow the window: bumping the LLInt/Baseline execution counter alone does not barrier the CodeBlock. But actively-executing LLInt/Baseline blocks are frequently barriered — LLIntSlowPaths.cpp calls vm.writeBarrier(codeBlock) at 10+ sites for IC transitions and call linking — so the scenario is reachable for exactly the warm-but-not-yet-IC-stable blocks this PR is trying to protect. The stress test only calls fullGC() explicitly with no interleaved eden collections, so it wouldn't observe this.

Why this is a nit, not a bug. It only applies under the non-default useUnlinkedCodeBlockJettisoning (mini-mode); it requires the block to have been barriered before the eden GC and idle in the eden→full window; and the failure mode is a graceful fallback to exactly the pre-PR TTL policy — no crash, no correctness issue, just a missed lease renewal. The PR author's timeline comment ("kept the refresh in the age check rather than finalizeUnconditionally, so the eden-GC gating caveat doesn't apply") shows this was considered, but that remark covers where the new refresh lives — it doesn't stop the pre-existing updateActivity() write from consuming the delta first.

Suggested tweak. Tighten the comment to something like: "…so within a full GC they agree; note that under useUnlinkedCodeBlockJettisoning, updateActivity() may also run on an eden GC for a write-barriered block and advance the snapshot before this check next runs, in which case that block falls back to the plain-TTL policy for one lease." If you wanted to close the gap entirely you could gate the m_previousCounter = count; store in updateActivity() on collectionScope == CollectionScope::Full, but given how narrow and benign this is, the comment clarification alone seems sufficient.

Comment on lines +1337 to +1340
case JITType::InterpreterThunk:
currentCount = m_unlinkedCode->llintExecuteCounter().count();
hasCounter = true;
break;

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 caveat worth a note in the comment block: llintExecuteCounter() lives on the UnlinkedCodeBlock, so when one UnlinkedCodeBlock is shared by multiple linked CodeBlocks (multi-realm, code-cache reuse, call/construct pairs), an idle sibling will observe the shared counter advance and get a lease renewal it never earned. This is over-retention only and mirrors the pre-existing updateActivity() aliasing, so not a blocker — but since the PR text frames the counter as a per-CodeBlock activity signal, a one-line acknowledgment in the comment would save the next reader the head-scratch.

Extended reasoning...

What the issue is

The InterpreterThunk branch at CodeBlock.cpp:1337-1338 reads m_unlinkedCode->llintExecuteCounter().count() and compares it against the per-CodeBlock snapshot m_previousCounter. But m_llintExecuteCounter is a field on UnlinkedCodeBlock (UnlinkedCodeBlock.h:525), and a single UnlinkedCodeBlock can back several linked CodeBlocks — the same function linked into multiple JSGlobalObjects, code-cache hits across script re-evaluations, or the CopyParsedBlock constructor for call/construct pairs. The LLInt prologue/loop-hint code increments the counter through the UnlinkedCodeBlock pointer (see LowLevelInterpreter.asm), so every linked sibling sees the same monotonically advancing value.

Concrete walk-through

  1. Function f is linked into global A → CodeBlock_A, and also into global B → CodeBlock_B. Both share UnlinkedCodeBlock_U and both constructors snapshot m_previousCounter = U.llintExecuteCounter().count() — say 0.
  2. Global A calls f in a hot loop; the LLInt ticks U.m_llintExecuteCounter up to, say, 420. Global B never calls f again.
  3. A full GC runs after CodeBlock_B's LLInt TTL (5 s / 10 ms eager) has elapsed. shouldJettisonDueToOldAge on CodeBlock_B reads currentCount = 420, compares to its snapshot 0, sees 420 != 0, updates the snapshot to 420, pushes m_creationTime forward by ~3×TTL, and returns false.
  4. As long as A keeps calling f, CodeBlock_B — which never executed after warm-up — keeps getting renewed indefinitely.

The stress test doesn't exercise this because each active/idle function is a distinct new Function(...) body with its own UnlinkedCodeBlock.

Why nothing prevents it

There is no per-linked-CodeBlock LLInt entry counter; the only LLInt-tier signal available is the shared one on UnlinkedCodeBlock. The constructor snapshot added in this PR correctly suppresses the first spurious renewal for a freshly-linked block, but once the active sibling ticks the counter past that snapshot the idle sibling will renew.

Impact

Over-retention only — no crash, no correctness regression. And it's bounded:

  • The heavy state (bytecode, metadata, profiles) lives in UnlinkedCodeBlock_U, which is already kept alive by the active CodeBlock_A. The extra retained memory is just CodeBlock_B's ~216-byte cell plus its linked metadata.
  • With JIT enabled, the active sibling typically tiers to Baseline within ~500 ticks and switches to a per-CodeBlock BaselineJITData::executeCounter(), so the shared-counter window is short in practice.
  • The identical aliasing already exists in updateActivity() at CodeBlock.cpp:1861/1881 for UnlinkedCodeBlock aging, so this PR follows an accepted precedent rather than introducing a new one.

Suggested change

No code change needed. A one-line note in the existing comment block — e.g. "The LLInt counter is per-UnlinkedCodeBlock, so an idle sibling of an active CodeBlock may also be renewed; this matches updateActivity() and is over-retention only." — would document the trade-off. Actually fixing it would require a per-CodeBlock LLInt counter and grow sizeof(CodeBlock), which is exactly what the m_previousCounter-reuse commit just avoided.

sosukesuzuki and others added 2 commits July 29, 2026 14:42
…ault it off

Default useExecutionCountForCodeBlockAging to false so CodeBlock old-age
jettisoning behaves exactly as before unless the option is opted into.

Move the two new options into a FOR_EACH_JSC_CODEBLOCK_AGING_OPTION macro
under USE(BUN_JSC_ADDITIONS), following the existing FOR_EACH_JSC_FFI_OPTION
pattern, and wrap the CodeBlock.cpp changes in the same directive so builds
without BUN_JSC_ADDITIONS keep the upstream code path untouched.

No-Verification-Needed: JSC-internal change in the WebKit fork; validated by the preview build CI rather than the pinned prebuilt used by the Bun binary
@github-actions

Copy link
Copy Markdown

Preview Builds

Commit Release Date
08667347 autobuild-preview-pr-333-08667347 2026-07-29 07:04:37 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.

2 participants