Repository navigation
arena: a forced purge waits for the pass in progress instead of being dropped - #38
Conversation
… dropped Only one thread runs an arena purge pass at a time. `_mi_arenas_try_purge` takes a try-guard and returns when another thread holds it, forced or not. With the scavenger thread there is often a pass in progress: it starts one `purge_delay` after a free, and one more each time a parked thread's sweep ends in `_mi_arenas_purge_now`. A pass over a few hundred MiB holds the guard for tens of milliseconds. A `mi_collect(true)` in that window returned with nothing purged. The scavenger's pass is no substitute: it is never forced, so it leaves every arena whose delay has not passed, and it does not go back for ranges that were freed behind it. `_mi_arenas_try_purge` now reports whether it was dropped, and `_mi_arenas_collect` retries a forced purge until it had its turn (spin, then yield, as `_mi_park_leave` does). The holder takes no lock and waits for no thread under the guard, so the wait is bounded by its madvise calls. Passes that are not forced are still dropped as before. The guard moves to file scope so that the fork child handler can release it: the thread that held it across fork() is not in the child, and a forced purge there would wait forever. test-forced-purge frees 256 MiB a round, waits for the scavenger to enter an arena (`arena_purges`), forces a collect, and checks that `purged` covers all of it. Before: 3 of 4 rounds fail (1 to 4 MiB of 256 purged). After: 4 of 4.
|
One case this wait does not cover yet: process exit on Windows. The new test in #39 ends while the scavenger is still in its last pass. The Windows Debug jobs of its first CI run hung at exit for that reason (5 of 6 local runs on Windows x64). #39 now has a second commit that releases the guard at that point ( |
Bumps mimalloc to 707d90be (oven-sh/mimalloc#39, which is stacked on oven-sh/mimalloc#38). The allocator's purge thread (the scavenger) hands freed arena memory back to the OS 100 ms after the free. What a thread freed while the scavenger was in the middle of a pass could be left out for good: the arena was marked as scheduled, the scavenger was not, and no later free into that arena scheduled it again. The memory stayed resident until the event loop went idle or something forced a collection. It took a pass in which each arena had something to purge. JSC's structure heap is a mimalloc arena of its own that rarely has, which hides most of it in bun. With one arena (Malloc=1) each such free was lost. The pass now resets the schedule before it looks at the arenas, so a free behind it schedules the next pass, and puts back what is still pending at its end. The second commit of oven-sh/mimalloc#39 (707d90be) releases the purge guard in mi_process_done on Windows, where the process detach callback runs after the system terminated the purge thread and the forced collect at exit would wait for that guard without end. bun builds mimalloc with MI_NO_PROCESS_DETACH and never runs mi_process_done, so that part is for the fork's own tests.
…rd at exit on Windows) Brings in #38 and #39 from the fork branch (8565de2, 1b894af, 707d90b) on top of the merged upstream arena code. src/arena.c, src/scavenger.c, src/subproc.c and include/mimalloc/internal.h merge without overlap: upstream did not touch `_mi_arenas_try_purge`, `_mi_arenas_collect` or `mi_arena_schedule_purge` between the two bases. The one upstream change next to them is in `mi_arena_try_purge`, which now leaves an arena whose expire is 0 alone for a forced pass as well. That is compatible: a free arms the arena before it sets its purge bits, and the pass reads the arena expire with the 0 -> 0 CAS after the walk, as before. Conflicts: - CMakeLists.txt: the test list keeps `profile` in place of `prof`/`prof-adversarial` and the `MI_TRACK STREQUAL "ASAN"` condition from the upstream side, and gains `forced-purge` and `purge-behind-pass`. - src/init.c (`mi_process_done_once`): the Windows release of the purge guard goes before the heap snapshot; the call to `_mi_prof_on_exit` stays removed with the fork's profiler. test-forced-purge and test-purge-behind-pass set `minimal_purge_size` to one slice. They compare the `purged` counter with the bytes they freed, and with upstream's MI_ALLOW_THP=FULL (the Linux default of a standalone build) a pass purges aligned 2 MiB units only: each freed run keeps its edges (the runs break at the 256 MiB aligned page meta), so a release build read 252 of 256 MiB and 316 of 320 MiB.
…freed during a pass is purged by the next (#42571) Both fork PRs are merged: oven-sh/mimalloc#38 and oven-sh/mimalloc#39. The pin `707d90be` is on `bun-dev3-v2` (they were merged with merge commits, so the sha did not change). This PR now carries both bumps and both tests; it replaces #42543. ### Two fixes in the fork **1. A forced purge waits for the pass in progress (oven-sh/mimalloc#38).** - `Bun.gc(true)` ends with `mi_collect(true)` since #42428. mimalloc dropped that purge when another thread held its purge guard, and the purge thread holds it for 36 to 47 ms while it purges what was already freed. `Bun.gc(true)` then returned with all of it still resident. - `test/js/bun/spawn/spawn-pipe-leak.test.ts` fails on main because of it: `Peak RSS: warmup 46 MB -> main 312 MB (573%)`, 7 of 10 runs pass. With this pin: 25 of 25. - Only a forced collect waits. bun's one caller is `garbage_collect_from_js`. It takes 29 to 47 ms in place of 1 to 2 ms when it collides with a pass over 384 MB, and the memory is back with the OS when it returns. - Test: `test/js/bun/gc/gc-controller-cadence.test.ts`, "Bun.gc(true) returns what is free to the OS while the purge thread is at work". Fails 3 of 3 on main's release build (38 to 42 MB released, expected more than 300). **2. What is freed during a purge pass is purged by the next pass (oven-sh/mimalloc#39).** The rest of this description. ### Problem - Memory freed while mimalloc's purge thread (the scavenger) is in a pass can stay resident until the event loop goes idle or something forces a collection. With one arena (`Malloc=1`) a busy script keeps 110 of 384 MB: `{"released":262.6,"purged":274.4}`. - In the fork, `_mi_arenas_try_purge` (`src/arena.c`) keeps its old `subproc->purge_expire` during a pass, so a free in that time arms the arena only. The scavenger then clears the value, and no later free sets it again. - It reaches bun's default configuration. JSC's structure heap is a second arena that rarely purges, which hides the case of one busy thread. With several busy threads and no ticking event loop it shows each time: 8 workers that churn `ArrayBuffer`s and then spin hold 578 MB RSS on main's release build and fall to 91 MB with this pin. ### Fix - Bump `MIMALLOC_COMMIT` to `707d90be` (oven-sh/mimalloc#39, which contains oven-sh/mimalloc#38). The bump also picks up `0fecc729`, a 32-bit shift fix in `src/prof.c` that was already on `bun-dev3-v2`. - In the fork a pass now resets `subproc->purge_expire` first and puts back what is pending at its end, so a free behind the pass schedules the next one. - Verified: the new test in `test/js/bun/jsc/heapStats-mimalloc.test.ts` fails 3 of 3 on main's release build, passes 20 of 20 with this pin. `bun bd test` skips it (ASAN). ### Background - A purge returns freed mimalloc arena memory to the OS (`madvise`), 100 ms after the free. - The scavenger is the fork's thread that runs those purges. It sleeps until `subproc->purge_expire`. - The first free into an arena after a pass arms it (`arena->purge_expire`). Only that free looks at `subproc->purge_expire`. <details><summary>Notes</summary> **The two commits of oven-sh/mimalloc#39.** - `1b894afe` arena: what is freed during a purge pass is purged by the next pass (the fix) - `707d90be` init: release the purge guard at process exit on Windows **The free path (`mi_arena_schedule_purge`) is unchanged.** Only the pass and the scavenger loop change. **The defect in full.** A free arms its arena (`arena->purge_expire`) and, only if the arena was not armed yet, arms the subprocess (`subproc->purge_expire`, 0 -> set), which is what the scavenger sleeps on. A pass resets the arena expire when it goes into the arena, but it kept its old subprocess expire until its end. The first free into the arena behind the pass armed the arena again, found the subprocess expire still set, and left it alone. Then `_mi_arenas_try_purge` skipped its update (the break for `max_purge_count` also fires on the last arena, so a pass in which each arena purged ended with `all_visited = false`), and `mi_scavenger_run` set `subproc->purge_expire` to 0. The arena was armed and the subprocess was not. Each later free into that arena saw an armed arena and stopped there. The 30 s timeout of the scavenger did not help: with nothing scheduled it only waits again. **Reach in bun.** JSC registers its structure heap as an exclusive mimalloc arena (`mi_manage_os_memory_ex` in `StructureAlignedMemoryAllocator.cpp`). A pass over two arenas in which only the main one purges ends with `all_visited = true`, reads the re-armed expire of the main arena after its walk, and installs it. So the same script that loses 128 MB with one arena gets its second pass on time with two (traced on main's release build: first pass at 107 to 123 ms, second at 207 ms). It shows when both arenas purge in the same pass, when a process has more arenas (more than 1 GiB of mimalloc memory) and each purges, and with `Malloc=1`. The event loop's `mi_on_thread_idle_start` on each `epoll_wait` heals it when the sweep runs to its end. Under load the owner wakes first and the sweep stops before `_mi_arenas_purge_now`. The state is absorbing: once an arena is armed and the subprocess is not, nothing but a park or a forced collect ends it. So a low chance per pass is enough for a process whose threads stay busy. Measured with stock two arena bun (release, Linux x64): 8 workers allocate and free 1 to 8 MB `ArrayBuffer`s for 3 s (`transfer(0)` frees at once), free everything, and spin, while the main thread sits in `Atomics.wait`. After a quiet 2.5 s RSS is 578 MB on main and 91 MB with this pin (`arena_count` 2 in both). The same happens with only the one line fix for `all_visited` in the fork, which is why the pass resets the subprocess expire first. **The test.** A child frees 256 MB with `ArrayBuffer.prototype.transfer(0)` (no collection involved), spins until RSS fell by 64 MB (the purge thread is in its pass), frees 128 MB more, and spins without an `await` until RSS fell by 336 MB, for at most 2 s. It reports that drop and the growth of `heapStats().mimalloc.purged`. Both have to be at least 336 MB. The drop in RSS is taken before the last `heapStats()` call, because that call polls the allocator (`mi_collect(false)`), and a poll runs a pass that is due by itself. `Malloc=1` makes JSC allocate through `malloc`, which is mimalloc on Linux, so there is one arena. Linux only (the wait reads RSS) and not ASAN (malloc is not mimalloc there). | build | result | | --- | --- | | release build of main (`6a92015fc`, pin `6a64e1ba`) | fails 3 of 3: `{"released":262.6,"purged":274.4}`, expected >= 336 | | `bun run build:release` with this pin | passes 20 of 20, about 450 ms | | `bun bd test` with this pin (debug, ASAN) | new test skipped, the other 4 tests in the file pass | The change is under `scripts/`, so a checkout of `src/` from main does not undo it. The fail-before check needs a release build of main. **About `707d90be`.** `1b894afe` alone failed the 5 Windows jobs of the fork's CI. Its new test returns from `main` while the scavenger is in its last pass. On Windows `mi_process_done` runs in the process detach callback, after the system terminated every other thread, so the purge guard stays held, and the forced collect at exit waits for it without end (that wait is what oven-sh/mimalloc#38 adds). `707d90be` releases the guard there. bun builds mimalloc with `MI_NO_PROCESS_DETACH` and `MI_SKIP_COLLECT_ON_EXIT`, so bun does not reach that code on any platform. Fork CI at `707d90be`: 15 of 15 jobs pass (Windows x64 and arm64, mingw, macOS, Linux x64 and arm64 with ASAN, UBSAN, TSAN and guarded builds, FreeBSD). **In the fork.** New `test/test-purge-behind-pass.c`: 5 of 5 runs fail before (`266 of 320 MiB`), 30 of 30 pass after. A stress harness with 8 to 16 busy threads: 15 of 15 rounds end stuck before, with about 530 MiB of free committed ranges unpurged. After: 750 rounds, 0 stuck. Details are in oven-sh/mimalloc#39. **Effect on purge activity.** Ranges that stayed resident by accident are now purged 100 ms after the free, which is what `purge_delay` promises. **Suites run with a release build of this branch (Linux x64).** `test/js/bun/jsc/heapStats-mimalloc.test.ts`, `test/js/bun/gc/gc-controller-cadence.test.ts` (11 pass), `test/js/bun/spawn/spawn-pipe-leak.test.ts` (3 pass), `test/js/bun/spawn/spawn-noread-leak.test.ts`, `test/js/bun/jsc/bun-jsc.test.ts` (38 pass). Nothing here was run on macOS or Windows. </details> ### Now pins the merged upstream sync `MIMALLOC_COMMIT` is `ab13501334a8da2b64ab2e5f4f552c3a39b96350`: the merge of oven-sh/mimalloc#37 on `bun-dev3-v2`. It contains `707d90be` (both fixes above), so everything in this description still holds. **What it adds beyond oven-sh/mimalloc#38 and oven-sh/mimalloc#39** - microsoft/mimalloc `dev3` through v3.5.2 (301 commits): always-on statistics (`MI_STATS=1`), sampled profiler hooks (`mi_profiler_t`, `MI_PROFILE=1`), codegen work in free/zalloc/memzero, double-free detection through the padding canary. - The fork's own heap profiler is gone (`src/prof.c`, `mi_prof_*`, `MIMALLOC_PROF_*`); upstream's hooks replace it. bun referenced none of it. - `fork()`: no arena purge pass runs across it any more. That was the macOS Debug SIGBUS of a forked child in the fork's CI, and a whole class of torn purge state in the child. - `mi_heap_destroy` of a heap that still has a block on a page's thread-free list no longer reports corrupted meta-data. - Two slips in upstream's `free.c` are fixed: `mi_usable_size` always took its slow path, and an overflow could be reported as a double free. - The malloc/free fast paths update upstream's packed used count with one instruction (`decw` / `addq` on memory). **One new define: `MI_FREE_USE_PAGEMAP=1`** - Upstream's new default keeps page meta data at 256 MiB boundaries so that `mi_free` needs no page map. Every arena then has to start on such a boundary. - JSC gives mimalloc its structure heap as an arena (`mi_manage_os_memory_ex(start + 16 KiB, ...)`, under a `RELEASE_ASSERT`), and halves that reservation when address space is short. With the new default `mi_manage_os_memory_ex` refuses it once the reservation is 256 MiB or less. - Measured with a release build of the new pin without the define: `ulimit -v 3000000; bun -e 1` aborts on startup, and so does `BUN_JSC_structureHeapSizeInKB=262144`. The old pin runs both. With the default 4 GiB reservation it works but loses the first 256 MiB of structure address space. - With the define, `mi_free` looks the page up through the page map as it does today, and the structure heap works down to 64 MiB as before. The fork's CI runs this configuration (debug and release) on Linux x64 and arm64. - The aligned layout is worth another 1.1% of instructions on the malloc/free benchmark below. Getting it needs WebKit to hand mimalloc an aligned region (`mi_arena_min_alignment()`), which is a separate change. **Not changed: `MI_PROFILE` stays at its default of 1.** Nothing in bun attaches a profiler yet, and `MI_PROFILE=0` would save about 1.4% of instructions on the same benchmark. The hooks are what a `Bun.pprof.heap` API would build on, so they stay compiled in. **Bindings.** Every `mi_*` function that `src/mimalloc_sys/mimalloc.rs` and the C++ side declare is exported with an unchanged declaration. `mi_heap_area_t` has the same layout. `mi_option_t` 0..46 did not move (47 is `collect_merges_stats` now, `snapshot_on_exit` 48); bun sets option 0 only. The `heapStats().mimalloc` keys the tests read (`purged`, `page_bins`, `pages`, `committed`) are emitted as before. **Allocator micro benchmark** (25 M `mi_malloc` + 25 M `mi_free` of 16..1039 bytes, bun's compile flags, user-mode instructions): | | instructions | | --- | --- | | old pin (`707d90be`) | 2.2463 G | | this pin, `MI_FREE_USE_PAGEMAP=1` (what this PR builds) | **2.2403 G** (-0.3%) | | this pin, upstream's aligned layout | 2.2153 G (-1.4%) | **bun, release build, old pin against this commit** (same tree, only the pin and the define differ; 5 interleaved runs pinned to 8 cores; user-mode instructions and peak RSS, medians): | | old pin | this commit | | --- | --- | --- | | `bun -e 1` | 9.17 M, 25.9 MB | 9.16 M (-0.06%), 25.7 MB | | `Bun.serve` hello, per request (20000 sequential `fetch`) | 51748, 48.2 MB | 51787 (+0.08%), 48.5 MB | | `JSON.stringify` + `JSON.parse` of a 200-user object, 3000 times | 5102.3 M, 42.6 MB | 5102.9 M (+0.01%), 42.6 MB | | string concat + `Buffer.from` + `split`, 300 x 5000 | 1939.8 M, 58.9 MB | 1904.3 M (-1.8%, the spread between runs is 2%), 57.8 MB | | idle after start (`Bun.sleep`), RSS from `smaps_rollup` | 32.4 MB (anon 3.85) | 32.5 MB (anon 3.96) | Nothing is slower by more than 0.1%. **Tests with a release build of this commit (Linux x64)** - `test/js/bun/spawn/spawn-pipe-leak.test.ts`: 25 of 25 runs pass. `test/js/bun/jsc/heapStats-mimalloc.test.ts` and `test/js/bun/gc/gc-controller-cadence.test.ts`: 10 of 10 each. - 137 test files, 4280 tests pass: the two files of this PR, `spawn-noread-leak`, `require-cache`, `heap-snapshot`, `bun-jsc`, `socket-retention`, `serve.test.ts`, all of `test/js/node/child_process`, `node-net`, `bunshell`, `fetch.test.ts`, `bun-install.test.ts`, `bundler_edgecase`, all of `test/js/web/workers` and `test/js/node/worker_threads`, `test/js/bun/resolve`, `test/js/bun/jsc`, the `--compile` and bundler files, `test/bake/dev`. - 8 tests fail, and the same 8 fail with a release build of the old pin: they need a non-root user (a port below 1024 that must be refused, `EPERM` after dropping privileges, unreadable files and directories). - Small structure heaps: `BUN_JSC_structureHeapSizeInKB` = 262144, 131072, 65536 and `ulimit -v` = 3000000, 2000000, 1500000 all start. - Debug (ASAN) build: `heapStats-mimalloc`, `gc-controller-cadence`, `test/js/web/workers/worker.test.ts` pass; `child_process.test.ts` has the one root-only failure. - `src/static.c` compiles with the new define and bun's flags for macOS arm64/x64 and Windows x64/arm64 (release and debug). Nothing here was run on macOS or Windows. <!-- robobun:evidence:begin --> --- **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, test/js/bun/gc/gc-controller-cadence.test.ts <!-- robobun:evidence:end --> --------- Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
…freed during a pass is purged by the next (oven-sh#42571) Both fork PRs are merged: oven-sh/mimalloc#38 and oven-sh/mimalloc#39. The pin `707d90be` is on `bun-dev3-v2` (they were merged with merge commits, so the sha did not change). This PR now carries both bumps and both tests; it replaces oven-sh#42543. ### Two fixes in the fork **1. A forced purge waits for the pass in progress (oven-sh/mimalloc#38).** - `Bun.gc(true)` ends with `mi_collect(true)` since oven-sh#42428. mimalloc dropped that purge when another thread held its purge guard, and the purge thread holds it for 36 to 47 ms while it purges what was already freed. `Bun.gc(true)` then returned with all of it still resident. - `test/js/bun/spawn/spawn-pipe-leak.test.ts` fails on main because of it: `Peak RSS: warmup 46 MB -> main 312 MB (573%)`, 7 of 10 runs pass. With this pin: 25 of 25. - Only a forced collect waits. bun's one caller is `garbage_collect_from_js`. It takes 29 to 47 ms in place of 1 to 2 ms when it collides with a pass over 384 MB, and the memory is back with the OS when it returns. - Test: `test/js/bun/gc/gc-controller-cadence.test.ts`, "Bun.gc(true) returns what is free to the OS while the purge thread is at work". Fails 3 of 3 on main's release build (38 to 42 MB released, expected more than 300). **2. What is freed during a purge pass is purged by the next pass (oven-sh/mimalloc#39).** The rest of this description. ### Problem - Memory freed while mimalloc's purge thread (the scavenger) is in a pass can stay resident until the event loop goes idle or something forces a collection. With one arena (`Malloc=1`) a busy script keeps 110 of 384 MB: `{"released":262.6,"purged":274.4}`. - In the fork, `_mi_arenas_try_purge` (`src/arena.c`) keeps its old `subproc->purge_expire` during a pass, so a free in that time arms the arena only. The scavenger then clears the value, and no later free sets it again. - It reaches bun's default configuration. JSC's structure heap is a second arena that rarely purges, which hides the case of one busy thread. With several busy threads and no ticking event loop it shows each time: 8 workers that churn `ArrayBuffer`s and then spin hold 578 MB RSS on main's release build and fall to 91 MB with this pin. ### Fix - Bump `MIMALLOC_COMMIT` to `707d90be` (oven-sh/mimalloc#39, which contains oven-sh/mimalloc#38). The bump also picks up `0fecc729`, a 32-bit shift fix in `src/prof.c` that was already on `bun-dev3-v2`. - In the fork a pass now resets `subproc->purge_expire` first and puts back what is pending at its end, so a free behind the pass schedules the next one. - Verified: the new test in `test/js/bun/jsc/heapStats-mimalloc.test.ts` fails 3 of 3 on main's release build, passes 20 of 20 with this pin. `bun bd test` skips it (ASAN). ### Background - A purge returns freed mimalloc arena memory to the OS (`madvise`), 100 ms after the free. - The scavenger is the fork's thread that runs those purges. It sleeps until `subproc->purge_expire`. - The first free into an arena after a pass arms it (`arena->purge_expire`). Only that free looks at `subproc->purge_expire`. <details><summary>Notes</summary> **The two commits of oven-sh/mimalloc#39.** - `1b894afe` arena: what is freed during a purge pass is purged by the next pass (the fix) - `707d90be` init: release the purge guard at process exit on Windows **The free path (`mi_arena_schedule_purge`) is unchanged.** Only the pass and the scavenger loop change. **The defect in full.** A free arms its arena (`arena->purge_expire`) and, only if the arena was not armed yet, arms the subprocess (`subproc->purge_expire`, 0 -> set), which is what the scavenger sleeps on. A pass resets the arena expire when it goes into the arena, but it kept its old subprocess expire until its end. The first free into the arena behind the pass armed the arena again, found the subprocess expire still set, and left it alone. Then `_mi_arenas_try_purge` skipped its update (the break for `max_purge_count` also fires on the last arena, so a pass in which each arena purged ended with `all_visited = false`), and `mi_scavenger_run` set `subproc->purge_expire` to 0. The arena was armed and the subprocess was not. Each later free into that arena saw an armed arena and stopped there. The 30 s timeout of the scavenger did not help: with nothing scheduled it only waits again. **Reach in bun.** JSC registers its structure heap as an exclusive mimalloc arena (`mi_manage_os_memory_ex` in `StructureAlignedMemoryAllocator.cpp`). A pass over two arenas in which only the main one purges ends with `all_visited = true`, reads the re-armed expire of the main arena after its walk, and installs it. So the same script that loses 128 MB with one arena gets its second pass on time with two (traced on main's release build: first pass at 107 to 123 ms, second at 207 ms). It shows when both arenas purge in the same pass, when a process has more arenas (more than 1 GiB of mimalloc memory) and each purges, and with `Malloc=1`. The event loop's `mi_on_thread_idle_start` on each `epoll_wait` heals it when the sweep runs to its end. Under load the owner wakes first and the sweep stops before `_mi_arenas_purge_now`. The state is absorbing: once an arena is armed and the subprocess is not, nothing but a park or a forced collect ends it. So a low chance per pass is enough for a process whose threads stay busy. Measured with stock two arena bun (release, Linux x64): 8 workers allocate and free 1 to 8 MB `ArrayBuffer`s for 3 s (`transfer(0)` frees at once), free everything, and spin, while the main thread sits in `Atomics.wait`. After a quiet 2.5 s RSS is 578 MB on main and 91 MB with this pin (`arena_count` 2 in both). The same happens with only the one line fix for `all_visited` in the fork, which is why the pass resets the subprocess expire first. **The test.** A child frees 256 MB with `ArrayBuffer.prototype.transfer(0)` (no collection involved), spins until RSS fell by 64 MB (the purge thread is in its pass), frees 128 MB more, and spins without an `await` until RSS fell by 336 MB, for at most 2 s. It reports that drop and the growth of `heapStats().mimalloc.purged`. Both have to be at least 336 MB. The drop in RSS is taken before the last `heapStats()` call, because that call polls the allocator (`mi_collect(false)`), and a poll runs a pass that is due by itself. `Malloc=1` makes JSC allocate through `malloc`, which is mimalloc on Linux, so there is one arena. Linux only (the wait reads RSS) and not ASAN (malloc is not mimalloc there). | build | result | | --- | --- | | release build of main (`6a92015fc`, pin `6a64e1ba`) | fails 3 of 3: `{"released":262.6,"purged":274.4}`, expected >= 336 | | `bun run build:release` with this pin | passes 20 of 20, about 450 ms | | `bun bd test` with this pin (debug, ASAN) | new test skipped, the other 4 tests in the file pass | The change is under `scripts/`, so a checkout of `src/` from main does not undo it. The fail-before check needs a release build of main. **About `707d90be`.** `1b894afe` alone failed the 5 Windows jobs of the fork's CI. Its new test returns from `main` while the scavenger is in its last pass. On Windows `mi_process_done` runs in the process detach callback, after the system terminated every other thread, so the purge guard stays held, and the forced collect at exit waits for it without end (that wait is what oven-sh/mimalloc#38 adds). `707d90be` releases the guard there. bun builds mimalloc with `MI_NO_PROCESS_DETACH` and `MI_SKIP_COLLECT_ON_EXIT`, so bun does not reach that code on any platform. Fork CI at `707d90be`: 15 of 15 jobs pass (Windows x64 and arm64, mingw, macOS, Linux x64 and arm64 with ASAN, UBSAN, TSAN and guarded builds, FreeBSD). **In the fork.** New `test/test-purge-behind-pass.c`: 5 of 5 runs fail before (`266 of 320 MiB`), 30 of 30 pass after. A stress harness with 8 to 16 busy threads: 15 of 15 rounds end stuck before, with about 530 MiB of free committed ranges unpurged. After: 750 rounds, 0 stuck. Details are in oven-sh/mimalloc#39. **Effect on purge activity.** Ranges that stayed resident by accident are now purged 100 ms after the free, which is what `purge_delay` promises. **Suites run with a release build of this branch (Linux x64).** `test/js/bun/jsc/heapStats-mimalloc.test.ts`, `test/js/bun/gc/gc-controller-cadence.test.ts` (11 pass), `test/js/bun/spawn/spawn-pipe-leak.test.ts` (3 pass), `test/js/bun/spawn/spawn-noread-leak.test.ts`, `test/js/bun/jsc/bun-jsc.test.ts` (38 pass). Nothing here was run on macOS or Windows. </details> ### Now pins the merged upstream sync `MIMALLOC_COMMIT` is `ab13501334a8da2b64ab2e5f4f552c3a39b96350`: the merge of oven-sh/mimalloc#37 on `bun-dev3-v2`. It contains `707d90be` (both fixes above), so everything in this description still holds. **What it adds beyond oven-sh/mimalloc#38 and oven-sh/mimalloc#39** - microsoft/mimalloc `dev3` through v3.5.2 (301 commits): always-on statistics (`MI_STATS=1`), sampled profiler hooks (`mi_profiler_t`, `MI_PROFILE=1`), codegen work in free/zalloc/memzero, double-free detection through the padding canary. - The fork's own heap profiler is gone (`src/prof.c`, `mi_prof_*`, `MIMALLOC_PROF_*`); upstream's hooks replace it. bun referenced none of it. - `fork()`: no arena purge pass runs across it any more. That was the macOS Debug SIGBUS of a forked child in the fork's CI, and a whole class of torn purge state in the child. - `mi_heap_destroy` of a heap that still has a block on a page's thread-free list no longer reports corrupted meta-data. - Two slips in upstream's `free.c` are fixed: `mi_usable_size` always took its slow path, and an overflow could be reported as a double free. - The malloc/free fast paths update upstream's packed used count with one instruction (`decw` / `addq` on memory). **One new define: `MI_FREE_USE_PAGEMAP=1`** - Upstream's new default keeps page meta data at 256 MiB boundaries so that `mi_free` needs no page map. Every arena then has to start on such a boundary. - JSC gives mimalloc its structure heap as an arena (`mi_manage_os_memory_ex(start + 16 KiB, ...)`, under a `RELEASE_ASSERT`), and halves that reservation when address space is short. With the new default `mi_manage_os_memory_ex` refuses it once the reservation is 256 MiB or less. - Measured with a release build of the new pin without the define: `ulimit -v 3000000; bun -e 1` aborts on startup, and so does `BUN_JSC_structureHeapSizeInKB=262144`. The old pin runs both. With the default 4 GiB reservation it works but loses the first 256 MiB of structure address space. - With the define, `mi_free` looks the page up through the page map as it does today, and the structure heap works down to 64 MiB as before. The fork's CI runs this configuration (debug and release) on Linux x64 and arm64. - The aligned layout is worth another 1.1% of instructions on the malloc/free benchmark below. Getting it needs WebKit to hand mimalloc an aligned region (`mi_arena_min_alignment()`), which is a separate change. **Not changed: `MI_PROFILE` stays at its default of 1.** Nothing in bun attaches a profiler yet, and `MI_PROFILE=0` would save about 1.4% of instructions on the same benchmark. The hooks are what a `Bun.pprof.heap` API would build on, so they stay compiled in. **Bindings.** Every `mi_*` function that `src/mimalloc_sys/mimalloc.rs` and the C++ side declare is exported with an unchanged declaration. `mi_heap_area_t` has the same layout. `mi_option_t` 0..46 did not move (47 is `collect_merges_stats` now, `snapshot_on_exit` 48); bun sets option 0 only. The `heapStats().mimalloc` keys the tests read (`purged`, `page_bins`, `pages`, `committed`) are emitted as before. **Allocator micro benchmark** (25 M `mi_malloc` + 25 M `mi_free` of 16..1039 bytes, bun's compile flags, user-mode instructions): | | instructions | | --- | --- | | old pin (`707d90be`) | 2.2463 G | | this pin, `MI_FREE_USE_PAGEMAP=1` (what this PR builds) | **2.2403 G** (-0.3%) | | this pin, upstream's aligned layout | 2.2153 G (-1.4%) | **bun, release build, old pin against this commit** (same tree, only the pin and the define differ; 5 interleaved runs pinned to 8 cores; user-mode instructions and peak RSS, medians): | | old pin | this commit | | --- | --- | --- | | `bun -e 1` | 9.17 M, 25.9 MB | 9.16 M (-0.06%), 25.7 MB | | `Bun.serve` hello, per request (20000 sequential `fetch`) | 51748, 48.2 MB | 51787 (+0.08%), 48.5 MB | | `JSON.stringify` + `JSON.parse` of a 200-user object, 3000 times | 5102.3 M, 42.6 MB | 5102.9 M (+0.01%), 42.6 MB | | string concat + `Buffer.from` + `split`, 300 x 5000 | 1939.8 M, 58.9 MB | 1904.3 M (-1.8%, the spread between runs is 2%), 57.8 MB | | idle after start (`Bun.sleep`), RSS from `smaps_rollup` | 32.4 MB (anon 3.85) | 32.5 MB (anon 3.96) | Nothing is slower by more than 0.1%. **Tests with a release build of this commit (Linux x64)** - `test/js/bun/spawn/spawn-pipe-leak.test.ts`: 25 of 25 runs pass. `test/js/bun/jsc/heapStats-mimalloc.test.ts` and `test/js/bun/gc/gc-controller-cadence.test.ts`: 10 of 10 each. - 137 test files, 4280 tests pass: the two files of this PR, `spawn-noread-leak`, `require-cache`, `heap-snapshot`, `bun-jsc`, `socket-retention`, `serve.test.ts`, all of `test/js/node/child_process`, `node-net`, `bunshell`, `fetch.test.ts`, `bun-install.test.ts`, `bundler_edgecase`, all of `test/js/web/workers` and `test/js/node/worker_threads`, `test/js/bun/resolve`, `test/js/bun/jsc`, the `--compile` and bundler files, `test/bake/dev`. - 8 tests fail, and the same 8 fail with a release build of the old pin: they need a non-root user (a port below 1024 that must be refused, `EPERM` after dropping privileges, unreadable files and directories). - Small structure heaps: `BUN_JSC_structureHeapSizeInKB` = 262144, 131072, 65536 and `ulimit -v` = 3000000, 2000000, 1500000 all start. - Debug (ASAN) build: `heapStats-mimalloc`, `gc-controller-cadence`, `test/js/web/workers/worker.test.ts` pass; `child_process.test.ts` has the one root-only failure. - `src/static.c` compiles with the new define and bun's flags for macOS arm64/x64 and Windows x64/arm64 (release and debug). Nothing here was run on macOS or Windows. <!-- robobun:evidence:begin --> --- **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, test/js/bun/gc/gc-controller-cadence.test.ts <!-- robobun:evidence:end --> --------- Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
Problem
mi_collect(true)returns with nothing purged when the scavenger thread is in a purge pass at that moment._mi_arenas_try_purge(src/arena.c) takes a try-guard and drops the call when another thread holds it, forced or not.Bun.gc(true)callsmi_collect(true)since Bun.gc(true) and gc() return what the collection freed to the OS before they return bun#42428, and leak tests read RSS next.test/js/bun/spawn/spawn-pipe-leak.test.tsfails on bun main:Peak RSS: warmup 46 MB -> main 312 MB (573%).Fix
_mi_arenas_try_purgereturnsfalsewhen it was dropped._mi_arenas_collectretries a forced purge until it had its turn (spin, then yield, as_mi_park_leavedoes).madvisecalls only. Purges that are not forced are dropped as before._mi_process_fork_childreleases it. The thread that held it acrossfork()is not in the child.test/test-forced-purge.c, 3 of 4 rounds fail before, 4 of 4 pass after. Full ctest passes in Release and Debug.Background
madvise). A free schedules itpurge_delay(100 ms) later.src/scavenger.c) is the thread that runs the scheduled purges. It also runs one when a parked thread's sweep ends in_mi_arenas_purge_now.purge_guardlets one thread purge at a time. A pass over a few hundred MiB holds it for tens of milliseconds.Notes
How the drop was found. An instrumented bun build printed
purge SKIPPED: guard held by another thread, force=1in every batch of the bun test where RSS stayed high afterBun.gc(true), andpurge pass force=1 took 21 msin every batch where it fell. The scavenger printedpurge pass force=0 visit_all=1 took 36..47 msright after each skipped one.MIMALLOC_SCAVENGER=0gave 0 high batches in 15.MIMALLOC_PURGE_DELAY=0kept RSS under 75 MB before the collection, so the memory was already free each time.Why the scavenger is so often in a pass when bun calls
mi_collect(true). The event loop hands the main thread's heaps to the scavenger on eachepoll_wait(mi_on_thread_idle_start). The sweep ends in_mi_arenas_purge_now, which pulls each scheduled purge forward and starts a pass. The test awaits 50 child processes, wakes on the last exit, and callsBun.gc(true)a few milliseconds later.With the fix in bun (release, Linux x64): the bun test passes 15 of 15 runs, against 7 of 10 before. 60 of 60 batches read 39 to 100 MB after
Bun.gc(true). Before, 2 to 9 of 15 read 230 to 550 MB.Bun.gc(true)takes 29 to 47 ms in place of 1 to 2 ms when it collides with a pass over 384 MB.The new test also passes with ASAN (
MI_TRACK_ASAN),MI_GUARDEDandMI_SECUREbuilds. TSAN does not run in the sandbox this was developed in.test-forced-purge uses the
purgedandarena_purgescounters of the subprocess, not RSS, so it reads the same on every platform and build. Each round frees 256 MiB (64 MiB on 32-bit), waits (bounded) forarena_purgesto move, callsmi_collect(true), and checks thatpurgedgrew by at least what the round freed. On the unfixed build a round reads1 of 256 MiB.Not changed here, seen on the way. In
_mi_arenas_try_purge, a pass in which each arena had something to purge ends withall_visited = false(themax_purge_count <= 1break fires on the last arena too). The finalpurge_expireupdate is skipped, andmi_scavenger_runthen setssubproc->purge_expireto 0. A free that landed in an arena during that pass leavesarena->purge_expireset, so later frees there do not re-arm the scavenger. The ranges stay until the next_mi_arenas_purge_now(a thread parks) or a forced collect. The 30 s wait inmi_scavenger_rundoes not purge them: a standalone repro still has 64 of 320 MiB unpurged after 35 s. On the unfixed build this makes one round of the new test wait its full 10 s bound. bun's main thread parks often, which hides it. This is tracked separately.