Skip to content

[JSC] shrinkFootprintNow(KeepCodeInUse) can release the code of RegExps that are not in use (off by default) - #633

Open
Jarred-Sumner wants to merge 1 commit into
claude/jsc-cold-code-memoryfrom
claude/regexp-release-idle-code
Open

[JSC] shrinkFootprintNow(KeepCodeInUse) can release the code of RegExps that are not in use (off by default)#633
Jarred-Sumner wants to merge 1 commit into
claude/jsc-cold-code-memoryfrom
claude/regexp-release-idle-code

Conversation

@Jarred-Sumner

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

Copy link
Copy Markdown
Collaborator

Stacked on #628 (one commit on top of its branch; it hangs off the KeepCodeInUse shrink mode that PR adds). Off by default.

the other two follow-ups. Options::releaseIdleRegExpCodeWhenShrinkingFootprint, default off: this PR adds the
mechanism and says why it is not switched on.

Why

After an interactive session of a large bundled CLI application the executable pool holds 5.0 MB of allocated code (6.7 MB
of committed pages) two minutes after the last input, and 3.7 MB (4.7 MB) after the deep-idle
VM::shrinkFootprintNow(KeepCodeInUse) of #628. By owner: Yarr code of 580 RegExps 3.85 MB (6.6 KB each), Baseline 0.56 MB
in use + 0.60 MB cached on unlinked code blocks whose CodeBlocks are gone, DFG 0.22 MB. The pool itself is fine
(MetaAllocator decommits a page when its last allocation dies; only 1.0-1.7 MB of committed pages are partly used). What
stays is RegExp code, which KeepCodeInUse never touches (KeepCodeThatNeedsParsing drops all of it).

Measured with the option on (20 inputs, idle 240 s, 10 more inputs, idle 240 s):

off on
executable pool after each shrink, allocated / committed 3.67 / 4.70 MB 0.13 / 0.97 MB
anonymous memory at rest 3.7-6 MB lower
user-mode instructions of the whole process, five interleaved pairs 33.94 G (33.75-34.24) 34.91 G (34.46-35.34): +0.97 G, +2.9 % per wake-up

The second session compiles ~450 RegExps again. Three of them account for most of both the memory and the cost: generated
patterns of 530 KB, 169 KB and 20 KB that compile to 1.97 MB, 0.71 MB and 0.17 MB of code in two variants each; the other
570 RegExps hold 1.0 MB together. +2.9 % instructions for one wake-up is not a trade to make for every embedder, hence off
by default; an application without such outliers gets ~1 MB for close to nothing and can turn it on.

What

With the option on, the KeepCodeInUse shrink calls RegExp::deleteCode() on every RegExp in the RegExpCache that has not
matched since the last full collection began. The embedder shrinks after the program has been at rest well beyond its last
idle collection, so that means "not during the quiet period"; a RegExp on a timer keeps its code. A RegExp whose code was
dropped is in the NotCompiled state, exactly as after VM::deleteAllRegExpCode(), and compiles again from its pattern
when it next matches.

  • In use: RegExp::compileIfNecessary[MatchOnly], which every match that runs the matcher goes through first, stores the low
    byte of the heap's marking version (one per full collection) in the new RegExp::m_lastUseEpoch. One byte store per
    match, in every configuration (this is the only cost with the option off). A stale byte that happens to equal the current
    one after 256 full collections only keeps some code for another round. A match that the minimum-length filter rejects
    before it reaches the matcher does not count as a use, which errs on the side of releasing.
  • Size: the byte takes one of the three bytes of padding after m_constructionErrorCode; sizeof(RegExp) stays 80
    (checked on release and debug builds of this branch).
  • RegExpCache::deleteCodeNotUsedInCurrentFullCollectionCycle() walks the weak map under m_lock; entries whose RegExp is
    dead are skipped. The small strong cache is left alone: it keeps RegExp cells alive, not their code.

Safety

  • Optimized code: DFG and FTL never call a RegExp's Yarr code by address. RegExpExec/RegExpTest/... call operations that
    end up in RegExp::match; RegExpTestInline emits a fresh copy of the matcher into the DFG/FTL code
    (Yarr::jitCompileInlinedTest), built from the pattern; the get*MatchAddr() accessors of YarrCodeBlock are only used
    to print. Re-checked on this base, which has the first-character-bitmap and minimum-length fast paths from upstream: the
    bitmap is owned by the RegExp (m_firstCharacterBitmap), not by its code, and deleteCode() leaves it alone;
    m_minimumSize is reset to 0 by deleteCode(), which sends the run-time check in JIT code to the slow path, and a
    minimum size folded into JIT code as a constant is a property of the pattern and stays true.
  • Compiler threads do read the YarrCodeBlock's inline statistics (DFGStrengthReductionPhase, then
    compileRegExpTestInline asserts getRegExpJITCodeBlockConcurrently()), so the release only happens when
    VM::numberOfActiveJITPlans() is zero; plans are started by the mutator, i.e. not during the shrink. The
    KeepCodeThatNeedsParsing shrink gets the same guarantee from Heap::deleteAllCodeBlocks() completing all plans first.
  • Running matches: shrinkFootprintNow() refuses to run with a VMEntryScope (JS on the stack) or from inside the
    collector, and RegExp::match neither re-enters the run loop nor is called by the embedder outside of the API lock, so no
    Yarr code is on the native stack of this VM when the shrink runs. deleteCode() takes the RegExp's cellLock like every
    other state change (matchConcurrently takes it too).
  • RegExps that are not in the weak map (a pattern with a syntax error: no code) are not visited: the safe direction.
  • A RegExp that only ever runs as an inlined copy inside DFG/FTL code never updates its epoch and loses its own (unused)
    Yarr code; a later DFG compile then finds no inline statistics and does not inline until the RegExp has matched through
    the runtime once more. A missed optimization after a deep idle, not a correctness matter.

Tests

All runs against jsc shells (release, and debug + ASan with ASAN_OPTIONS=detect_stack_use_after_return=0) of this branch
(1b7bf3e5a380) and of the base (209144af8f6c), base lists retaken in the same session.

New test JSTests/stress/shrink-footprint-releases-idle-regexp-code.js (JIT and --useJIT=0): 200 RegExps match, a full
collection, ten of them match again, $vm.shrinkFootprintWhenIdle(keepCodeThatNeedsParsing, keepCodeInUse); asserts via
$vm.codeBlockCensus().regExpsWithCode that the idle ones lost their code, that the ten kept theirs, and that all of them
match and have code again afterwards. 2/2 on release, 2/2 on ASan. With the option off it fails ("idle RegExps lost their
code: 200 -> 200"), so it cannot pass vacuously.

JSTests/stress, release, all 5,849 files (197 skipped by their own directives), failures / passes:

mode this branch base difference
default 35 / 5617 36 / 5615 none here only (base's extra: int8-repeat-in-then-out-of-bounds.js, flaky)
--collectContinuously=1 36 / 5616 37 / 5614 none here only (base's extra: codeblock-aging-execution-count.js, flaky)
--releaseIdleRegExpCodeWhenShrinkingFootprint=1 36 / 5616 36 / 5615 (default) identical sets
option + --collectContinuously=1 35 / 5617 37 / 5614 (collectContinuously) none here only

JSTests/stress, debug + ASan, every fourth file (1,462; 41 skipped), option on: 15 / 1406, the same 15 as base.

Because the suite only reaches the new code through the few tests that shrink the footprint, one more run (a throwaway
driver, not part of the PR): 334 RegExp / String.prototype.{replace,match,split} stress tests executed with run() in ONE
VM, then full collection + shrinkFootprintWhenIdle(KeepCodeInUse), all 334 again, shrink again, all 334 a third time;
every file must give the same result in each pass. RegExps with code 3,280 -> 0 after each shrink -> 3,281 after the next
pass; 0 differences; release with default tiering, release with eager non-concurrent JIT
(thresholdForJITAfterWarmUp=10 thresholdForOptimizeAfterWarmUp=20 thresholdForFTLOptimizeAfterWarmUp=50)@@ASANDRIVER@@.

Not done

  • Not on by default, see above. The three outlier patterns are an application problem (being fixed there); with them gone
    the measurement should be repeated before flipping the default.
  • No interpreter-first tier for Yarr: a RegExp that comes back is JIT-compiled again on its first match.
  • The strong cache and RegExp cells themselves are untouched.

@Jarred-Sumner
Jarred-Sumner force-pushed the claude/jsc-cold-code-memory branch from 209144a to affb84c Compare September 12, 2026 06:48
@Jarred-Sumner
Jarred-Sumner force-pushed the claude/regexp-release-idle-code branch from a45f8e3 to 83d0bac Compare September 12, 2026 06:56
@github-actions

github-actions Bot commented Sep 12, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
26c7020e autobuild-preview-pr-633-26c7020e 2026-09-13 01:56:44 UTC
83d0bac1 autobuild-preview-pr-633-83d0bac1 2026-09-12 07:24:48 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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.


ALWAYS_INLINE void RegExp::compileIfNecessary(VM& vm, Yarr::CharSize charSize, std::optional<StringView> sampleString)
{
m_lastUseEpoch = static_cast<uint8_t>(vm.heap.objectSpace().markingVersion());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 (optional) compileIfNecessary[MatchOnly] now write m_lastUseEpoch unconditionally, but both are also reached on a DFG compiler thread via matchConcurrently() (RegExp.cpp:419/497) while the mutator's RegExp::match writes the same non-atomic byte without taking cellLock(): an unsynchronized concurrent write that TSan will flag even with the new option off. Fix: stamp the epoch only from the VM thread — move the store into matchInlineOnce under if constexpr (matchFrom == Yarr::MatchFrom::VMThread) for both overloads, or make m_lastUseEpoch a relaxed std::atomic<uint8_t>. Same pattern at 2 sites (RegExpInlines.h:105, :265).

Extended reasoning...

RegExp::matchConcurrently at RegExp.cpp:411 and :490 runs inside DFGStrengthReductionPhase on a JIT worklist thread, takes cellLock(), checks hasCodeFor/hasMatchOnlyCodeFor, then calls matchInline<CompilerThread>matchInlineOncecompileIfNecessary[MatchOnly] at RegExpInlines.h:151/309, which after this change stores to m_lastUseEpoch at lines 105/265 before the hasCodeFor early return. Concurrently the mutator's RegExp::match (RegExp.cpp:406/485) enters matchInlineOnce<VMThread> and performs the same store WITHOUT holding cellLock() (the fast match path is lock-free by design; only compile/deleteCode take the lock). Before this change compileIfNecessary[MatchOnly] was read-only on that shared fast path, so no writable field was touched from both threads unlocked; after it, two threads perform non-atomic stores to the same uint8_t — a C++ data race (UB) that ThreadSanitizer will report on every DFG constant-fold of a RegExp that the mutator is also matching, regardless of releaseIdleRegExpCodeWhenShrinkingFootprint. In practice both threads…

Verification: nit — the race is real, though benign in practice (both writers store the same one-byte value derived from the same VM's marking version, so no observable misbehavior; it is however technically C++ UB and TSan-visible, and it fires regardless of the new option). Compiler-thread writer: RegExp::matchConcurrently at Source/JavaScriptCore/runtime/RegExp.cpp:411/490 takes `Locker locker {… | nit —…

v(Double, warmUpMarkedBlockIdleTimeout, 10, Normal, "seconds without a MarkedBlock request before the helper thread releases what it is holding and shuts down"_s) \
v(Bool, scribbleFreeCells, false, Normal, nullptr) \
v(Bool, decommitUnusedMarkedBlockPages, true, Normal, "after sweeping a MarkedBlock, return its interior OS pages that hold no live cell to the OS (only where OS pages are smaller than a MarkedBlock)") \
v(Bool, releaseIdleRegExpCodeWhenShrinkingFootprint, false, Normal, "VM::shrinkFootprintNow(KeepCodeInUse) also drops the compiled code of RegExps that have not matched since the last full collection began"_s) \

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 nit (optional): the new option is inserted between decommitUnusedMarkedBlockPages and decommitUnusedMarkedBlockPagesAfterEdenCollections, whose description "also do it for blocks swept after an eden collection" is a continuation of the former; in --options output it now reads as a follow-on to the RegExp option. Fix: move releaseIdleRegExpCodeWhenShrinkingFootprint one line down so the two decommitUnusedMarkedBlockPages* entries stay adjacent.

Extended reasoning...

OptionsList.h:298 defines decommitUnusedMarkedBlockPages and :300 defines decommitUnusedMarkedBlockPagesAfterEdenCollections with the description "also do it for blocks swept after an eden collection (mostly young blocks that are refilled straight away)" — a sentence that only makes sense immediately after the first option's description. Line 299 now wedges the unrelated RegExp option between them, so a user reading jsc --options (which prints entries in declaration order) sees the eden-collection description directly beneath the RegExp-code option and will misread which behavior it modifies. On the base branch the pair is contiguous. Cosmetic/documentation only; no runtime effect.

Verification: nit — OptionsList.h:298 defines decommitUnusedMarkedBlockPages ("after sweeping a MarkedBlock, return its interior OS pages..."), :300 defines decommitUnusedMarkedBlockPagesAfterEdenCollections with description "also do it for blocks swept after an eden collection (mostly young blocks that are refilled straight away)" — a continuation that only reads correctly directly after :298. The…

@Jarred-Sumner
Jarred-Sumner force-pushed the claude/jsc-cold-code-memory branch from affb84c to 72ea768 Compare September 13, 2026 01:24
…ps that are not in use

After an interactive session of a large bundled CLI application the executable pool holds 5.0 MB of allocated code
(6.7 MB of committed pages) two minutes after the last input, and 3.7 MB (4.7 MB) after the deep-idle
VM::shrinkFootprintNow(KeepCodeInUse). By owner: Yarr code of 580 RegExps 3.85 MB (6.6 KB each), Baseline 0.56 MB in
use + 0.60 MB cached on unlinked code blocks whose CodeBlocks are gone (released by its lease before the shrink), DFG
0.22 MB. The pool itself is fine: MetaAllocator decommits a page when its last allocation dies and only 1.0-1.7 MB of
committed pages are partly used. What stays is RegExp code, which KeepCodeInUse never touches
(KeepCodeThatNeedsParsing drops all of it).

With Options::releaseIdleRegExpCodeWhenShrinkingFootprint (off by default) the KeepCodeInUse shrink calls
RegExp::deleteCode() on every RegExp in the RegExpCache that has not matched since the last full collection began. The
embedder shrinks after the program has been at rest well beyond its last idle collection, so that means "not during
the quiet period"; a RegExp on a timer keeps its code. A RegExp whose code was dropped is in the NotCompiled state,
exactly as after VM::deleteAllRegExpCode(), and compiles again from its pattern when it next matches.

- In use: RegExp::compileIfNecessary[MatchOnly], which every match that runs the matcher goes through first (one that
  the minimum-length filter rejects does not count), stores the low byte of the heap's marking version (one per full
  collection) in the new RegExp::m_lastUseEpoch. A stale byte that happens to equal the current one after 256 full
  collections only keeps some code for another round. The byte takes one of the three bytes of padding between
  m_constructionErrorCode and m_numSubpatterns; RegExp stays in the 80-byte size class.
- Optimized code: DFG and FTL never call a RegExp's Yarr code by address. RegExpExec/RegExpTest/... call operations
  that end up in RegExp::match; RegExpTestInline emits a fresh copy of the matcher into the DFG/FTL code
  (Yarr::jitCompileInlinedTest from SpeculativeJIT::compileRegExpTestInline and
  FTL::LowerDFGToB3::compileRegExpTestInline), built from the pattern; the get*MatchAddr() accessors of YarrCodeBlock
  are only used to print. The first-character bitmap that the fast paths in JIT code test belongs to the RegExp, not
  to its code; m_minimumSize goes back to 0, which sends their length check to the slow path, and a minimum size
  folded as a constant is a property of the pattern. So no machine code refers to what deleteCode() frees. What the
  compiler threads do read is the YarrCodeBlock's inline statistics (DFGStrengthReductionPhase, then
  compileRegExpTestInline asserts getRegExpJITCodeBlockConcurrently()), so the release only happens when
  VM::numberOfActiveJITPlans() is zero; plans are started by the mutator, i.e. not during the shrink. The
  KeepCodeThatNeedsParsing shrink gets the same guarantee from Heap::deleteAllCodeBlocks() completing all plans first.
- Running matches: shrinkFootprintNow() refuses to run with a VMEntryScope (JS on the stack), and RegExp::match
  neither re-enters the run loop nor is called by the embedder outside of the API lock, so no Yarr code is on the
  native stack of this VM when the shrink runs. deleteCode() takes the RegExp's cellLock like every other state
  change.
- RegExpCache: every RegExp is created through RegExpCache::lookupOrCreate() and, unless its pattern has a syntax
  error (no code then), is in its weak map for as long as it lives; entries whose RegExp is dead are skipped. The
  small strong cache (recently created patterns) is left alone: it only keeps RegExp cells alive, not their code.

The same application, 20 inputs, idle 240 s, 10 more inputs, idle 240 s: executable pool after each shrink 3.67 MB
allocated / 4.70 MB committed -> 0.13 / 0.97, RssAnon at rest 3.7-6 MB lower. The second session compiles ~450 RegExps
again, and that is not free: user-mode instructions of the whole process, five interleaved pairs, 33.94 G
(33.75-34.24) without and 34.91 G (34.46-35.34) with the option, +0.97 G (+2.9 %) for one wake-up. Three RegExps
account for most of both the memory and the cost: patterns of 530 KB, 169 KB and 20 KB (machine-generated) compile to
1.97 MB, 0.71 MB and 0.17 MB of code in two variants each; the other 570 RegExps hold 1.0 MB together. Hence off by
default.
@Jarred-Sumner
Jarred-Sumner force-pushed the claude/regexp-release-idle-code branch from 83d0bac to 26c7020 Compare September 13, 2026 01:25

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.


bool RegExp::wasUsedInCurrentFullCollectionCycle(VM& vm) const
{
return m_lastUseEpoch == static_cast<uint8_t>(vm.heap.objectSpace().markingVersion());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 (optional) wasUsedInCurrentFullCollectionCycle only sees uses that pass through compileIfNecessary[MatchOnly], but several hot match paths never do: RegExpObject::matchGlobal/operationRegExpMatchFastGlobalString when hasValidAtom(), the atom branch of replaceUsingRegExpSearch, split's SpecificPattern::Newlines branch, and tryTrimSpaces. A RegExp compiled once and thereafter used only via those fast paths is treated as idle and deleteCode() clears its m_atom/m_specificPattern, dropping the very fast path it is on. Fix: stamp m_lastUseEpoch on those fast-path entries too (or in recordMatch), so "matched since the last full collection" covers atom/specific-pattern matches.

Extended reasoning...
  1. /a/g.exec("a") reaches matchInlineOncecompileIfNecessary at RegExpInlines.h:105 stamps the epoch and compiles (hasCode() becomes true). 2) A full collection bumps markingVersion(). 3) "aaa".match(/a/g) enters RegExpObject::matchGlobal (RegExpObject.cpp:183) → hasValidAtom() is true → collectGlobalAtomMatches runs a pure string search and calls recordMatch(..., oneCharacterMatch=true) (RegExpObjectInlines.h:469); nothing on that path touches m_lastUseEpoch. Same for the atom branch in StringPrototypeInlines.h:747, the Newlines split fast path at RegExpPrototype.cpp:930, tryTrimSpaces at StringPrototypeInlines.h:1185, and RegExpSubstringGlobalAtomCache::collectMatches. 4) shrinkFootprintNow(KeepCodeInUse) with the option on: wasUsedInCurrentFullCollectionCycle compares the stale epoch and returns false, so deleteCode() runs and (because hasCode() is true) sets m_atom = String() and m_specificPattern = None (RegExp.cpp:516–517). 5) The next "aaa".match(/a/g) finds hasValidAtom() false and falls to collectMatchesRegExp::match

Verification: nit — the gap in the "in use" heuristic is real, but the option defaults to off and the consequence when on is a needless recompile, not incorrect behavior. The epoch is stamped in exactly two places, both added by this diff: - RegExpInlines.h:105 in compileIfNecessary: m_lastUseEpoch = static_cast<uint8_t>(vm.heap.objectSpace().markingVersion()); - RegExpInlines.h:265 in… | nit — the…

Comment on lines +132 to +136
for (auto& [key, weakHandle] : m_weakCache) {
RegExp* regExp = weakHandle.get();
if (!regExp || regExp->wasUsedInCurrentFullCollectionCycle(vm))
continue;
regExp->deleteCode();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟣 pre-existing, widened: deleteCode() clears m_atom, but RegExpCachedResult may hold m_oneCharacterMatch = true for that RegExp; reifying RegExp.$&/lastMatch then ASSERTs hasValidAtom() (debug) or does reverseFind(0) and reports the wrong position (release). The global-atom fast paths (collectGlobalAtomMatches, RegExpSubstringGlobalAtomCache) never reach compileIfNecessary, so a RegExp last matched that way since the last full GC is deleted here even though it is the current recorded match. Fix: stamp the epoch on those fast paths, or leave m_atom intact in deleteCode() (a pattern property, like m_firstCharacterBitmap).

Extended reasoning...

Slot direction (bounds/guards/hooks): the diff removes no cap, retry, or termination check and adds nothing inside a commit/finalizer/listener; the only new guard is !numberOfActiveJITPlans() at VM.cpp:1167, which is the same idiom used at Heap.cpp:2540 and, since only the mutator creates plans and it holds the API lock here, is sufficient. Fell back to free choice.

Path: let r = /a/g; r.test("xay") compiles r via matchInlineOnce → compileIfNecessaryMatchOnly (RegExpInlines.h:265 stamps the epoch, then compile()); hasCode() becomes true. fullGC() bumps MarkedSpace::m_markingVersion. "xay".match(r) reaches RegExpObject::matchGlobal → RegExpObject.cpp:183 hasValidAtom()collectGlobalAtomMatches (RegExpObjectInlines.h:409), which never calls compileIfNecessary[MatchOnly], so m_lastUseEpoch is not updated; it records m_oneCharacterMatch = true in RegExpCachedResult (RegExpObjectInlines.h:469 → RegExpGlobalDataInlines.h:41). The KeepCodeInUse shrink then walks the weak cache; wasUsedInCurrentFullCollectionCycle compares the stale byte to the new marking…

Verification: pre-existing. The mechanism is real and reachable, but the base branch already fails the same way through the sibling call one line above. Chain: - RegExp::deleteCode() clears the atom: Source/JavaScriptCore/runtime/RegExp.cpp:512-516 — if (!hasCode()) return; … m_atom = String();. - RegExpCachedResult::lastResult() depends on it under m_oneCharacterMatch:… | pre-existing — the base…

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