Repository navigation
[WTF][JSC] An idle thread releases its free memory again while the allocator holds some of it back - #911
Draft
robobun wants to merge 1 commit into
Draft
[WTF][JSC] An idle thread releases its free memory again while the allocator holds some of it back#911robobun wants to merge 1 commit into
robobun wants to merge 1 commit into
Conversation
…locator holds some of it back One call to mi_on_thread_idle() does not return the free blocks of a large page (blocks of 96 KiB and up) that the thread allocated from a moment ago. mimalloc asks a caller that blocks for good to call again, 100 ms later and 3 more times at the most. AutomaticThread and Atomics.wait made one call and blocked. From its second idle period on, a compiler thread kept the large blocks of its last compile until it exited after 10 s. WTF::IdleFreeMemoryRelease now holds that protocol: the interval, the number of releases, and whether one is due. AutomaticThread and the wait in WaiterListManager release through it. The single-call function is gone. bmalloc::api::scavengeThisThreadOnIdle() returns whether the allocator held memory back in pages of the calling thread (mi_on_thread_idle_pending_own). mi_on_thread_idle_pending() is also true for the pages that other threads use. A collector helper thread that came back for those took the buffers of the JS thread.
|
Preview build of 3572343: |
This was referenced Oct 26, 2026
robobun
added a commit
to oven-sh/bun
that referenced
this pull request
Oct 27, 2026
…Kit to 61e155bcef main is on WebKit 982277c9dd now. WEBKIT_VERSION is that commit plus oven-sh/WebKit#911. The mimalloc pin comes from the branch this one is based on.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Merge after oven-sh/bun#47042 (it pins oven-sh/mimalloc#62), or a WebKit bump fails to link. Tests: oven-sh/bun#47013.
Problem
AutomaticThreadandAtomics.waitcallmi_on_thread_idle()once, then block (AutomaticThread.cpp:277,WaiterListManager.cpp:152). One call leaves the free blocks of 96 KiB and up that the thread just used.Fix
WTF::IdleFreeMemoryReleaseholds mimalloc's rule for both callers. While the allocator holds back memory in the thread's own pages, the thread releases again 100 ms later, at most 4 times.Atomics.waitat 2.6 to 3.3 (19 to 21 before).Background
mi_on_thread_idle_pending()as the key. It is also true for other threads' pages: a busy JS thread took 2564 faults per round, not 69.Downsides
Atomics.waitwaits for it: 45 of 96 timeouts came over 2 ms late after large frees (4 of 96 before).Notes
The order.
mi_on_thread_idle_pending_own.This repository's CI cannot see a missing export: the jsc shell and testFFI link
ExternalMimallocShims.cpp, which answersfalsethere, so they keep one release per idle period. A bun that links this WebKit against a mimalloc without the export fails on an undefinedmi_on_thread_idle_pending_owninlibbmalloc.a.#585 changes the two
waitUntilcalls of the same loop inwaitSyncImplto the monotonic clock. This PR does not change those calls. The hunks are next to each other, so the PR that merges second needs a rebase of that hunk. Both changes stay as they are.What one call leaves. mimalloc moves a process-wide sweep epoch at most once per 100 ms, and only a sweep moves it. An idle sweep leaves the free blocks of a large page that was allocated from in the current epoch or in the one before. A thread that never made a release has its large pages freed at once, so the first burst of compiles in a process returns most of its memory with one call (0 to 12 MiB stay). Each later burst does not.
mi_on_thread_idle_pending()tells the caller that a sweep left such blocks, and mimalloc.h asks a caller that blocks for good to call again 100 ms later, 3 more times at the most. bun's own idle threads do that since oven-sh/bun#42668. These two callers did not: the single call was written before mimalloc got that rule.Why the own-pages answer. A sweep also passes the abandoned pages of the heap (a large page is abandoned when it is full), which are the pages that other threads allocate from.
mi_on_thread_idle_pending()is true for those as well. With that key the GC helper threads came back after every collection (84 second releases after 97 first ones), every such sweep moved the epoch, and a JS thread that takes 10 MiB of 256 KiB buffers again every 300 ms without parking found them given back: 2564 minor faults per round where it takes 69.mi_on_thread_idle_pending_own()is true only for pages the calling thread owns.What still follows up on the any-page answer. mimalloc's
mi_on_thread_idle_start()/_end()pair (bun's thread pool and event loops park with it) and bun's Windows event loop. This PR does not change them. The self-review measured what that leaves: with one thread-pool task or one Worker timer in every round, the JS thread of the buffer loop still takes 2113 to 2561 faults per round (64 without).The two callers.
AutomaticThread::start: while a release is due and more than 100 ms of the timeout are left, the thread waits 100 ms, leaves on a notify, and releases with the lock dropped andm_isReleasingFreeMemoryset, as before.poll()still runs once per notify. A thread withm_timeoutof 100 ms or less does nothing new. The budget is reset only beforework().WaiterListManager::waitSyncImpl: the release runs with the list lock dropped, as before. The next one is due 100 ms after the call returned. The loop testsisOnList()and termination under the lock again after each release.Which threads follow up. Wasm compiler threads after a compile, and a thread in
Atomics.waitthat freed large blocks right before it. GC helper, collector and warm-up threads do not: 0 of 109 idle releases in 14 rounds of the buffer loop, and 0 of 1510 in the self-review's census. JIT worklist threads are the one other class that does: in that census they were told "held back" in 72 of 287 first releases and made 41 follow-ups. They return 7.5 to 11 MiB after very large compiles, and 0 to 3 MiB (inside the spread) in prettier, an SSR server, webpack and a TypeScript transpile loop. No test covers that arm.Measurements. Release builds of bun for Linux x64, 12 CPUs, load average under 2. "This PR" is bun
c643ea8c2awith WebKit982277c9dd3e(bun's pin) plus this commit, as WebKit's CI built it, and mimalloc639adf50. "Before" is the same bun with WebKit982277c9dd3e. A line marked (old pin) was measured on680ef47cec2cplus this commit and was not repeated.compile-rss.test.ts: the first burst and two later bursts have to end under 10 MiB): 350 of 350 runs pass on 12, 2 and 1 CPUs and beside 12 and 24 busy loops (0 of 40 before). A later burst is under 10 MiB 219 ms after the collection (p90 233 ms).Atomics.waitafter 46 MiB of 96 to 480 KiB blocks were freed on that thread: 2.6 to 3.3 MiB stay, reached 258 to 309 ms into the wait (18.8 to 20.8 MiB for the whole wait before).m_isWaiting = trueto the next poll, 2 of them alignment nops (23 before), 4 conditional branches (4), 2 calls (2), no allocator call..text+64 bytes,.dataand.bssunchanged.A server under load. Express 4.18 with React 18 server rendering,
ab -c 32, 5 runs of 6000 requests on each build, the builds in turn. The spread between runs is larger than the difference between the builds.Costs in
Atomics.wait. Every wait of more than 200 ms can get a second release. The callers areAtomics.waitand wasmmemory.atomic.wait.AutomaticThreadpasses over a thread that is in a release.Atomics.waithas no other thread to wake.The floor. mimalloc leaves up to 4 MiB of free large blocks alone, process-wide, for the pages that were used last (
purge_holes_large_floor). After a burst those are pages of the compiler threads, and a thread gives its share back only at its next sweep or when it exits. Probe: a JS thread takes 3 MiB of 256 KiB buffers every 500 ms and parks in between, and a burst of 6 compiles runs once. With this change it faults the buffers in again (768 faults) on each of the 19 uses until the compiler threads exit, in 6 of 6 runs. Before, it did so in 4 of 6. On the previous pin the counts were 13 of 13 and 5 of 13. The scavenger gives the share of a parked thread back afterpurge_holes_large_floor_epochs. Nothing does that for a thread that callsmi_on_thread_idle().Late frees, and what was weighed for them. Memory that another thread frees into an idle thread's pages after that thread's last release stays until the thread works again or exits. With the modules held for 250 ms after their compile, 17 to 28 MiB stay (30 to 42 MiB before). Three mechanisms would return it. None is in this PR.
mi_thread_done()after the last release): late frees stay at about 0 MiB and the floor is free after one round.test/js/node/worker_threads/worker_heap_snapshot_gc.test.tsin bun crashes in 6 of 6 runs with it (0 of 6 without).AutomaticThreadConditionthat neither clearsm_isWaitingnor restarts the timeout, and a call from the module's destructor. That is a change of its own.Not removed.
purge_holes_min_interval, now in one place (IdleFreeMemoryRelease::intervalInMilliseconds).Tests. The tests are in oven-sh/bun#47013, because the effect needs bun's mimalloc:
test/js/bun/wasm/compile-rss.test.ts(the first burst and later bursts),test/js/web/atomics.test.ts(retention inAtomics.wait, and a notify that arrives during the second release),test/js/bun/jsc/heapStats-mimalloc.test.ts(GC helper threads leave the buffers of a busy thread). Mutations of this change, each built: withoutidleRelease.reset()the wasm test fails 10 of 10 runs. Without theisOnList()test after a release the second notify test fails 6 of 6 runs. Withmi_on_thread_idle_pending()as the key the helper test fails 16 of 16 runs. The three mutants are builds of the previous pin.The branches. This branch is based on
40b30d27fband merges intomain(bd40ac037e) without a conflict.robobun/1a8bcc9f/idle-thread-releases-again-on-982277c9is bun's pin982277c9dd3eplus this commit (61e155bcefa3). Its CI run published the prerelease that oven-sh/bun#47013 pins until this merges.