Skip to content

mimalloc: a park wakes the scavenger only while the thread is not on its schedule (oven-sh/mimalloc#57) - #44324

Draft
robobun wants to merge 1 commit into
mainfrom
robobun/12c6d455/mimalloc-park-wake-when-due
Draft

robobun wants to merge 1 commit into
mainfrom
robobun/12c6d455/mimalloc-park-wake-when-due

Conversation

@robobun

@robobun robobun commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Merge after #41512, oven-sh/mimalloc#56 and oven-sh/mimalloc#57, with the pin at the merge commit.

Problem

  • Each turn of the event loop wakes mimalloc's scavenger thread. 500 turns of a 1 ms timer issue 500 FUTEX_WAKE calls and 503 scavenger turns, for 5 sweeps.
  • us_loop_run_bun_tick calls mi_on_thread_idle_start before every poll, which calls _mi_scavenger_wake each time (vendor/mimalloc/src/theap.c:381). mimalloc paces a thread's sweeps (100 ms), not its wakes.

Fix

  • Bump mimalloc to theap: a park wakes the scavenger only while the thread is not on its schedule mimalloc#57. The scavenger puts a thread on its schedule when it skips a park inside the 100 ms window. Such a thread parks without waking.
  • The same 500 turns issue 10 wakes, for 5 sweeps. The gain is CPU time, mostly the scavenger thread's: 30.1 us per timer turn (38.5 before).
  • Verified: test/js/bun/jsc/heapStats-mimalloc.test.ts (fails 6 of 6 with main's pin), gc-controller-cadence.test.ts and 7 leak suites.
  • Self-reviewed: 17 concerns raised, 15 addressed (Notes).

Background

Downsides

  • A loop that never blocks still wakes the scavenger in 15% to 90% of its turns (59% to 98% before).
  • A loaded Bun.serve gains no requests per second or p99. Its loop thread blocks twice as often (busy machine).
  • A park that wakes costs 9 more instructions, the binary 400 more bytes.
Notes

Findings

Builds

Three release builds of one tree of bun (f595f8c, this branch before two edits of a comment and the test). Only the mimalloc checkout differs:

Linux x64, 16 CPUs of a shared machine with a load average of 250 to 780. Counts are exact. Times come from interleaved rounds, and counters against main is the noise floor: two builds that behave the same.

What mimalloc counted (Bun.unsafe.mimallocDump(), 5 interleaved runs)

Wakes that parks issued / turns of the scavenger / sweeps of a parked thread.

workload counters this PR
1000 setImmediate turns 462..592 / 462..592 / 0 0 / 1 / 0
500 turns of setTimeout(fn, 1) 500 / 503..504 / 5 10 / 17 / 5
1000 fs.promises.stat 736..988 / 737..989 / 0 0 / 1 / 0
100 crypto.pbkdf2 95..100 / 96..101 / 0 0 / 1 / 0
Bun.serve, static route, 20000 requests on 16 connections 9018..13338 / 9018..13339 / 3 85..176 / 88..180 / 3
Bun.serve, handler function, same load 1366..2442 / 1370..2452 / 3..6 71..129 / 81..137 / 3..4
Bun.serve with no client for 40 s (2 runs) 31 / 34 / 31 31 / 34 / 31
the same with one idle keep-alive connection (6 runs) 60..63 / 78..85 / 51..55 49..55 / 63..80 / 47..51

CPU time of the process for one turn (bench/snippets/event-loop-turn.mjs, 10 interleaved rounds)

Microseconds, median (range). The last two columns are the median of the differences round by round.

kind main counters this PR this PR below counters counters minus main
immediate 4.32 (3.73..4.61) 4.23 (3.33..5.24) 2.37 (2.21..2.45) 10 of 10, -1.82 -0.03
immediate-50us 58.73 (57.88..59.37) 58.91 (57.65..59.30) 56.59 (54.53..58.25) 10 of 10, -2.32 +0.14
immediate-500us 517.53 (515.00..519.64) 517.37 (515.78..519.72) 515.29 (512.30..516.34) 10 of 10, -2.75 +0.04
timeout-0 36.01 (32.34..37.95) 36.69 (34.63..41.65) 25.85 (24.30..28.26) 10 of 10, -10.16 +0.30
timer-1ms 38.47 (35.97..42.54) 40.49 (39.00..42.02) 30.08 (27.46..32.09) 10 of 10, -10.57 +2.18
fs-stat 29.06 (21.99..33.70) 30.02 (22.93..36.43) 22.74 (20.43..27.52) 10 of 10, -7.09 +2.27
fetch 83.82 (73.73..89.16) 88.84 (72.82..101.42) 68.19 (60.90..74.74) 10 of 10, -21.29 +5.24
serve 12.96 (11.74..13.79) 12.75 (11.11..13.41) 10.68 (9.95..11.45) 10 of 10, -1.73 -0.51
serve-busy 8.33 (7.71..8.88) 8.54 (8.23..9.20) 8.34 (8.07..8.80) 6 of 10, -0.17 +0.17

How often the scavenger thread went to sleep in those runs (the kernel's count), median (range):

kind (turns) main counters this PR
immediate (20000) 6209 (4166..8050) 5854 (2956..12469) 1 (1..6)
immediate-50us (20000) 18169 (14546..19671) 18186 (11847..19618) 10159 (2921..17930)
immediate-500us (4000) 3905 (3729..3996) 3872 (3782..3992) 2968 (1348..3514)
timeout-0 (2000) 2000 (1973..2013) 1998 (1939..2006) 67 (65..67)
timer-1ms (2000) 1997 (1955..2010) 2002 (1934..2005) 68 (65..68)
fs-stat (2000) 1931 (1119..1988) 1922 (908..1991) 4 (2..4)
fetch (2000) 7316 (5606..7953) 7413 (4050..7966) 10 (6..12)
serve (20000) 11720 (7474..13788) 9777 (5950..13861) 163 (11..324)
serve-busy (80000) 1882 (1631..2249) 2066 (1559..2461) 36 (29..91)

Memory

15 of each 16 buffers of 16 KiB are freed (120 MB), then the loop keeps turning. 8 interleaved runs.

main counters this PR
1 ms timer loop: ms until the free memory is back at the OS 12187 (11747..12537) 12441 (1821..12739) 12368 (1735..12821)
sweeps in that time 125 (19..128) 124 (18..126)
wakes in that time 11361 (1663..11715) 246 (36..251)
loop that never blocks, 50 us of JavaScript per turn: MB still resident after 4 s 136..141 51, and 135..140 in 7 runs 16, and 135..140 in 7 runs
sweeps in those 4 s 18 (10..25) 22 (11..33)

With MIMALLOC_SCAVENGER=0 the last loop keeps 141 to 144 MB. A sweep of a loop that never blocks ends with the poll, before and now.

Cost of one call, and size

Instructions of the standalone library (clang -O3, C++, MI_FREE_USE_PAGEMAP), stepped with gdb from the first instruction of the call to its return, 60 calls each:

main counters this PR
mi_on_thread_idle_start that wakes 76, 3 atomic read-modify-writes, 1 syscall 82, 4, 1 85, 4, 1
mi_on_thread_idle_start on the schedule 45, 2, 0
mi_on_thread_idle_end 27, 2, 0 27, 2, 0 27, 2, 0

size of the release binary: text 80676555 (main), 80676683 (counters), 80676955 (this PR). Data (110424) and bss (1822992) do not change.

The test

The new test counts how often the scavenger thread goes to sleep in 500 turns of a 1 ms timer. The bound is 125.

  • Release build with the pin of main: 391 to 505, fails 6 of 6. bun 1.4.2: 481 to 503.
  • Release build of this PR: 15 to 19, passes 12 of 12. Debug build with ASAN: 23 to 26, passes 4 of 4.
  • A check that restores src/ and packages/ of main cannot show the failure: the fix is the pin in scripts/build/deps/mimalloc.ts.

Other suites with the release build of this PR: spawn-pipe-leak 3 pass, fetch-leak 31 pass, node-tls-getpeercert-leak 2 pass, socket-retention 7 pass, net-mongodb-pattern-leak 2 pass, zlib/leak 8 pass, heapStats-mimalloc 9 pass. gc-controller-cadence has 1 timeout ("the idle collections drop the warmed-up code") and streams-leak has 1 ("Absolute memory usage remains relatively constant when reading and writing to a pipe"). Both also time out with the pin of main on this machine.

Requests per second and latency

Bun.serve with a handler function, 16 keep-alive connections with one request in flight on each, 4 s, 10 interleaved rounds. The client is the same binary for both builds and runs on 6 other CPUs. The machine was busy (load average 220 to 340). This is not the run on a quiet machine that the review asked for.

main this PR
server on 1 CPU: requests per second 129044 (113735..149448) 123931 (109672..164250)
p99, us 637 (359..966) 717 (284..1119)
CPU per request, us: all threads / loop thread / scavenger 6.58 / 6.47 / 0.11 6.46 / 6.46 / 0.00
times the loop thread went to sleep 806 (680..1283) 1558 (1268..2176)
times the scavenger went to sleep 54668 (40125..83186) 146 (137..154)
server on 4 CPUs: requests per second 124015 (105799..134020) 114771 (107330..146949)
p99, us 788 (482..1027) 953 (575..1319)
CPU per request, us: all threads / loop thread / scavenger 6.89 / 6.75 / 0.14 6.70 / 6.70 / 0.00
times the loop thread went to sleep 1532 (1038..2927) 1656 (1176..2690)

This PR has more requests per second in 4 of 10 rounds, on 1 CPU and on 4. The differences are inside the spread of the rounds. The loop thread reaches its poll sooner, finds no request waiting more often, and blocks: about twice as often on 1 CPU.

CPU time by thread

One turn of a 1 ms timer, time on a CPU from /proc/self/task/*/schedstat, 4 interleaved runs of 4000 turns:

main this PR
loop thread, us 19.7 to 23.8 17.9 to 20.5
scavenger thread, us 5.5 to 9.2 0.8 to 0.9

Most of the CPU time that this PR saves is the scavenger thread's.

What stays

A thread that is due wakes the scavenger for each park until the scavenger finds it parked. A poll that returns at once is over before the scavenger runs, so a loop that never blocks and works between its polls keeps a part of its wakes. Two ways to remove them, neither in this PR: the gate above, or a short schedule in mimalloc for a thread that the scavenger cannot find parked.

The pin

eab09015 to 81041984 is 4 commits: oven-sh/mimalloc#34 (7b4eb150 and its merge 3a13a5ff, already on bun-dev3-v2, what #41512 bumps to), oven-sh/mimalloc#56 (counters) and oven-sh/mimalloc#57. The counters build has the first two, so the tables show oven-sh/mimalloc#57 alone. After the two PRs merge, the pin moves to the merge commit and the tests run again.

Related

Self-review

Two reviews: the first raised 8 concerns, the second 9.

  1. The pin is the head of an open PR of the fork: this PR is a draft until that merges.
  2. The pin carries os: no address hint for OS allocations above 1 GiB mimalloc#34: said above, and the tables exclude it.
  3. Overlap with Remove libuv on Windows #42819: said above.
  4. One rule of the allocator change had thin evidence and no test: measured again (theap: a park wakes the scavenger only while the thread is not on its schedule mimalloc#57), a test added there, and the rule made smaller.
  5. A model of the interleavings was cited and is not in the repository: no longer cited. The tests with hooks and 8 mutants are the evidence.
  6. The bun half was not pushed: it is now, and CI passed on the other targets.
  7. The description said what maintainers believed about the cost: it now quotes c094bd9 and deps: mimalloc to the head of bun-dev3-v2 (oven-sh/mimalloc#40, #42, #43, #44, #45, #46) #42668.
  8. Rejected: the allocator test file is skipped on Windows. It was before, the Windows loop does not hand off, and no Windows machine was available.
  9. A sweep that the owner cuts short uses up the 100 ms window, and the second look of the scavenger exists because of that: the rule stays, and theap: a park wakes the scavenger only while the thread is not on its schedule mimalloc#57 says so in its description and at the code.
  10. to 13. No requests per second and no p99, and the description did not say where the gain is: measured above on a busy machine, and both descriptions say that the gain is CPU time, most of it the scavenger thread's, and that a loaded loop blocks more often.
  11. and 15. The header of the allocator and the Rust binding said that the pair is for every wait. ThreadPool.rs and Remove libuv on Windows #42819 do not follow that. The header now states the cost and the precondition only, and the Rust comment is the one of main.
  12. Merge order: in the first line of this description. The two PRs of the fork merge with no other PR between them.
  13. Rejected: to drop subproc->paced_count, which the review called optional. The end of a thread takes its mark off on a thread that is not the scavenger, so the count is shared. It costs 8 bytes and one load for each turn of the scavenger.

Not measured

strace, perf and valgrind are not in the container. The counts come from mimalloc's counters, the kernel's per-thread context switch counts and gdb. Not run here: macOS, Windows, aarch64, musl. The CI of the fork passed 23 of 23 jobs at 81041984 (Linux x64 and arm64 with ASAN, UBSAN and TSAN, macOS, Windows, FreeBSD).


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/jsc/heapStats-mimalloc.test.ts

@robobun

robobun commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: draft. The pin is the head of oven-sh/mimalloc#57, which is open. Merge order: #41512, then oven-sh/mimalloc#56 and oven-sh/mimalloc#57, then this PR with the pin at the merge commit. The tests run again after the pin moves.

How to reproduce the problem

bun test test/js/bun/jsc/heapStats-mimalloc.test.ts -t "scavenger thread for every turn"

On a release build of main, and on bun 1.4.2, the scavenger thread goes to sleep 391 to 505 times in 500 turns of a 1 ms timer (the bound of the test is 125). With this PR it is 15 to 19 times, and 23 to 26 on a debug build with ASAN.

The fix is the mimalloc pin in scripts/build/deps/mimalloc.ts. A checkout that keeps scripts/ of this branch and takes src/ and packages/ from main still has the fix, so the test passes there too.

bench/snippets/event-loop-turn.mjs prints the CPU time and the sleeps of the threads for each kind of turn.

CI: the diff is green. Build 122759 has one red test, test/cli/install/migration/complex-workspace.test.ts ("Failed to install", alpine x64), which this diff does not touch. The same test failed once and passed on retry on debian aarch64 in that build. The build before it, with the same allocator code, passed on all lanes.

Comment thread src/mimalloc_sys/mimalloc.rs Outdated
…its schedule

The event loop hands the heaps of its thread to mimalloc's scavenger around
every poll (us_loop_run_bun_tick). Each hand-off woke the scavenger: a futex
wake on the loop thread and a turn of the scavenger, which found that the
thread was not due. mimalloc paced the sweeps of a thread (100 ms) and not
its wakes.

Bump mimalloc to oven-sh/mimalloc#57 (which contains oven-sh/mimalloc#56).
The scavenger puts a thread on its schedule when it passes a park of that
thread over inside its window, and a park of a thread on the schedule
issues no wake. The call in the event loop does not change.

- packages/bun-usockets: the comment at the hand-off said that it costs a
  compare-and-swap per tick. It says what it costs now, and why a tick whose
  poll cannot block hands off too.
- test/js/bun/jsc/heapStats-mimalloc.test.ts: 500 turns of a 1 ms timer put
  the scavenger thread to sleep 15 to 26 times. It was once for each turn.
- bench/snippets/event-loop-turn.mjs: CPU time and sleeps of the threads for
  one turn of the loop, by the kind of turn.

The pin also carries oven-sh/mimalloc#34, which is on bun-dev3-v2.
@robobun
robobun force-pushed the robobun/12c6d455/mimalloc-park-wake-when-due branch from 1fa1c26 to 1ed2410 Compare October 2, 2026 07:01

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant