Repository navigation
page: hole purging in large (4 MiB) pages, held back while the page is in use (by the sweep's epoch: no clock on the allocating side) - #46
Merged
Conversation
Free blocks in large (4 MiB) pages, which hold the blocks of 96 KiB up to 512 KiB, were never given back while one block of the page was live: `page->purged` has 256 bits, a 4 MiB page has 1024 OS pages of 4 KiB, so `mi_page_can_purge_holes` was false for it. A Bun.serve with 256 KiB request bodies sat on 20.8 MB of such blocks at idle, 97% of it resident (8.4 MB with nothing long-lived in those sizes; the pages are pinned by about 1 MB that every bun process allocates at start-up). The unit of the bitmap is per page now: `mi_page_purge_unit` is the OS page where the block area then needs no more than `MI_PAGE_PURGE_BITS` bits, and else the smallest power-of-two multiple of the OS page for which it does, 16 KiB for a 4 MiB page on a 4 KiB OS page. Bit k names unit k from `align_down(page_start, unit)`. Nothing changes for small and medium pages, for large pages where the OS page is 16 KiB or more, and on 32-bit: the unit is the OS page there. Everything else is reused with "OS page" read as "unit": a unit is discarded only when it lies wholly inside the block area and every block that overlaps it is free, a block is purged exactly when it overlaps a discarded unit, purged blocks are off the free lists until `_mi_page_unpurge_run`, `_mi_os_discard` never touches commit state, `_mi_page_unpurge_all` when the page goes back to the arena. That rule does not depend on how the block size relates to the unit. In practice each free block of a large page goes as a whole: the classes are 96, 112, 128, 160 .. 512 KiB, all multiples of 16 KiB, and the blocks start at the slice. Huge pages are one block and stay out. New test/test-purge-holes-large.c: the unit and its arithmetic (units of 8, 16 and 64 KiB under block sizes that are and are not multiples of them), the free blocks of three large-page classes discarded at an idle sweep and not resident after it (mincore), survivors intact, reuse (zalloc included), frees from another thread, realloc next to holes, abandoned pages, heap destroy and delete, a double free of a purged block. It is run with MIMALLOC_ALLOW_THP=0, and once more with MIMALLOC_PURGE_HOLES=0.
…al after the last allocation from it With large pages open to hole purging, a Bun.serve with 256 KiB request bodies under sustained load discarded and refaulted 3 GB in 20 s: its event loop parks between requests, a park that is 100 ms after the last sweep is swept, and the buffers it had just freed were taken again at once. Kernel instructions per request went from 115 k to 152 k, page faults from 0.9 to 8.4 per request, throughput down by about a tenth. A thread parks, and is swept, far more often than it is idle. - An allocation from a page with block_size > MI_MEDIUM_MAX_OBJ_SIZE (and more than one block) leaves the time in `page->swept_state`, tagged so that no (capacity,used) state and not MI_PAGE_SWEPT_NONE can be taken for it. It is written in `mi_malloc_generic_fallback`, the out-of-line path that every allocation above 10 KiB takes, from `_mi_clock_now_coarse` (new: CLOCK_MONOTONIC_COARSE on Linux, 4.5 ns; never ahead of `_mi_clock_now`). Nothing is added to the fast paths of mi_malloc and mi_free. (`used` does not tell a page that is in use from one that is not: it is the same at every park of a busy server.) - `_mi_page_purge_holes` leaves the free blocks of such a page, and the blocks it has not formed yet, while that time is less than `purge_holes_min_interval` ago, and notes on the tld that it left something. - `_mi_theap_sweep_parked` marks a park in which that happened MI_PARK_SWEPT_SMALL rather than done: `holes_sweep_last` makes it due again after the interval, the scavenger wakes for it, and if the thread is still in the same park that sweep takes them; then the park is done. One more sweep at the most: a large page is abandoned when it is full, so a sweep reaches most large pages, its thread's and those of others, through the abandoned map, and what is still young then is in use by others. - The inline `mi_on_thread_idle()` follows the same rule (it is called from busy loops too, at most every 100 ms, and once from a thread that has been out of work for that long). Nothing comes back for what it leaves, so `mi_on_thread_idle_pending()` tells a caller that is about to block for good to call it once more an interval later. The same server under load after this: 118 k to 124 k requests in 20 s before and after, 0.27 to 0.36 page faults per request before and 0.38 to 0.48 after, 3 to 5 MB a second discarded (pages that are not the first of their size class). At idle the free blocks are gone between 100 and 200 ms after the last request: anon 37.6 -> 17.0 MB, and 14.2 -> 6.3 MB with nothing long- lived in those sizes. Two earlier shapes of this rule did not hold: deferring in the handoff only (the inline path then purged 58 MB in 3 s of a loop that frees and takes 256 KiB buffers), and "at the second sweep in a row that finds the page untouched" (never, for a caller that sweeps once and blocks; and an abandoned page that two parked threads sweep one after the other got both within a millisecond).
On a 32-bit target (the two x86 clang-cl jobs of the CI) the 64 KiB blocks of `test_survivors(65536)` are in a large page, whose free blocks now stay for `purge_holes_min_interval` after the last allocation from it: "medium-page-is-eligible" failed there. The cases of this file are about something else, so `main` sets the interval to 0; `test-purge-holes-large.c` is where the interval is tested. No-Verification-Needed: test-only change (test/test-purge-holes.c)
… allocating side The free blocks of a large page stay while the page is in use. Until now an allocation from such a page left a reading of the (coarse) clock in `page->swept_state` and the sweep compared it with the time. Now only the sweep reads a clock. The sweep counts epochs: `mi_holes_epoch`, process-wide, moved on by an idle sweep (`_mi_page_purge_holes_epoch_advance`, the first thing in `_mi_thread_idle_work`) when the current epoch has lasted for `purge_holes_min_interval`. The time an epoch began is read after the increment that starts it, and nobody else moves the epoch until that time is stored, so that no epoch is shorter than the interval for anybody, wherever a sweeper is preempted (a sweeper that finds the move in progress waits for it, bounded). An allocation from a large page (`mi_malloc_generic_fallback`, same place and same gate as before) loads the epoch (relaxed) and stores it in `swept_state` under the same tag, only when the page does not carry that value already: a page that is allocated from all the time is written to once per epoch, and never when nothing sweeps. The sweep leaves the free blocks (and the unformed tail) of a page whose epoch is the current one or the one before, and takes them when it is two or more epochs old: the whole of the epoch in between lies between the allocation and now. Under sustained load a page is stamped again in every epoch and keeps its blocks; after the last allocation they go one to two intervals later. A sweep ends one epoch at the most, so one sweep does not take the blocks of a page that was allocated from since the sweep before it. A park that left something is swept again, `purge_holes_min_interval` after the END of the sweep before (`holes_sweep_last` is written after the work now, so that the sweep that comes back finds the epoch that this one began as old as the interval), until a sweep began two epochs on from the first sweep of the park (`tld->holes_park_epoch`, `holes_sweep_epoch`): what the thread left when it parked is two epochs old by then and was taken, what is still young was allocated from by others since. That is the second or the third sweep of a park; `MI_PARK_SWEEPS_MAX` (6) bounds it where a sweeper is held up while it moves the epoch. `park_swept` counts them. `mi_on_thread_idle_pending` for the inline sweep as before; its caller comes back an interval after the call before returned, two more times as a rule and three at the most. A caller that sweeps once and then blocks for good does not get the free blocks of its own large pages back any more (nothing tells that one sweep how long ago the allocation was): it has to use `mi_on_thread_idle_pending` or the `_start`/`_end` pair. 32 bits of the epoch are kept in the page; a page that was left for a multiple of 2^32 epochs reads as young once more and goes two epochs later. A fork that lands while another thread moves the epoch: the child starts a new epoch time (`_mi_page_purge_holes_forked_child`). `_mi_clock_now_coarse` is gone (nothing else used it), `stats.c` is as on bun-dev3-v2 again. 128 KiB malloc/free loop, Bun's flags, instructions per pair: 197.6 before the hold rule, 279.6 with the clock, 210.6 now (8 of the 13 are the gate, which was there; the stamp is a load, an or, a compare and a branch not taken). test-purge-holes-large: the two cases about the hold are in epochs now. What they check about time are lower bounds, and one upper bound against a time taken before the allocations; the first sweep after an allocation leaves the page whenever it comes, which needs no guard. New: two sweeps within the interval leave the page; a page that is allocated from before every sweep keeps its blocks over four epochs; the hold on an abandoned page; four threads that sweep without a pause next to the thread under test, which checks from clock readings around what it sees that no epoch is shorter than the interval (to the millisecond) and that a page it allocates from before each sweep keeps its blocks (both fail without the exclusion in `mi_holes_epoch_advance`, and with the start time of an epoch set early).
…e pages in a debug build With MI_ENABLE_LARGE_PAGES=0 the largest block of a medium page is 64 KiB, and a block of 64 KiB with its padding (MI_DEBUG, MI_SECURE) gets a singleton page: nothing to purge in it, so "medium-page-is-eligible" does not apply there. (It failed in that configuration on bun-dev3-v2 as well; no other one is affected.)
Jarred-Sumner
added a commit
to oven-sh/bun
that referenced
this pull request
Sep 15, 2026
Hole purging reaches the 4 MiB pages (blocks of 96 to 512 KiB): the purge bitmap's unit is per page, and the free blocks of a large page stay while the page is allocated from, told by an epoch that only the idle sweep moves. Adds mi_on_thread_idle_pending() to the public header; nothing bun binds changed.
Jarred-Sumner
added a commit
to oven-sh/bun
that referenced
this pull request
Sep 15, 2026
Hole purging reaches the 4 MiB pages (blocks of 96 to 512 KiB): the purge bitmap's unit is per page, and the free blocks of a large page stay while the page is allocated from, told by an epoch that only the idle sweep moves. Adds mi_on_thread_idle_pending() to the public header; nothing bun binds changed.
Jarred-Sumner
added a commit
to oven-sh/bun
that referenced
this pull request
Sep 15, 2026
, #44, #45, #46) (#42668) ### What does this PR do? Moves `MIMALLOC_COMMIT` from `ab13501334a8` to `69050cf3ec14be4847ca3ba6c840da821fa283b0`, the head of `bun-dev3-v2`: main's pin plus six merged PRs of the fork and nothing else, and changes the two callers in bun that the last of them asks to change. `MI_FREE_USE_PAGEMAP=1` stays. - oven-sh/mimalloc#40: the sampled profiler hooks (`mi_profiler_t`) attribute a sample to the small allocation that refilled its page, keep `bytes_since_last_sample` right when `on_alloc` raises the rate, sample a new thread's heap from its first allocation, report the frees of `mi_heap_destroy`, and give `on_free` of an aligned block the pointer that `on_alloc` got. What the heap profiler in #42626 needs. - oven-sh/mimalloc#43: an aligned request only becomes a sample through the over-allocating path (with #40 alone `mi_malloc_aligned(64, 64)` could free a sampled block inside the allocator; `MI_DEBUG` asserted). - oven-sh/mimalloc#42: the purge pull-forward of an idle thread (`mi_on_thread_idle`) goes through compare-and-swap, so it cannot undo the reset that a purge pass does first thing. 12 lines in `src/arena.c`. - oven-sh/mimalloc#44: a thread that does not sample pays for the profiler hooks in the generic path only: +0.2% of instructions on the allocator micro benchmark where they cost +1.5%. - oven-sh/mimalloc#45: a debug assertion that raced with `mi_profiler_stop` is gone. - oven-sh/mimalloc#46: **free blocks in the 4 MiB pages (blocks of 96 to 512 KiB) are handed back to the OS.** Hole purging skipped these pages (their 1024 OS pages do not fit the 256-bit purge bitmap), so what a process freed in that range stayed resident for as long as one block of the page was alive: 97% of it by `mincore`. The bitmap's unit is now per page (16 KiB for a 4 MiB page), and the free blocks of a large page stay while the page is in use, which the allocator tells without a clock on the allocating side: the idle sweep counts epochs of 100 ms or more, an allocation from a large page leaves the current epoch in the page (a load, a compare, and a store once per epoch), and the blocks go when that is two epochs ago. Without that hold a `Bun.serve` with 256 KiB bodies discarded and refaulted 3 GB in 20 s. Nothing in bun attaches a profiler yet. The fast paths of `mi_malloc` and `mi_free` are untouched by all six. `include/mimalloc.h` gains one function, `mi_on_thread_idle_pending()`, and comments; `mimalloc-stats.h` is identical, `mimalloc-profile.h` differs in comments, `mimalloc/types.h` gains fields of the internal `mi_theap_t` and `mi_tld_t`. No struct or enum that bun binds changed (`src/mimalloc_sys/mimalloc.rs`, the declarations in `BunJSCEventLoop.cpp` and `epoll_kqueue.c`). #### The callers that sweep once and then block With oven-sh/mimalloc#46 ONE call of `mi_on_thread_idle()` never takes the free blocks of a large page that the thread allocated from since its previous sweep: a sweep ends one epoch at the most. The event loop is fine: `us_loop_run_bun_tick` hands the thread's heaps to mimalloc's scavenger thread across the poll (`mi_on_thread_idle_start()` / `_end()`), and the scavenger comes back for them by itself, two or three sweeps an interval apart. Two callers swept inline once and then blocked for good, and would have kept such blocks, as they do on main: - **`src/threading/ThreadPool.rs`**: a worker swept once, after its futex wait timed out at 100 ms, and then waited without a timeout. It now brackets that second wait with `mi_on_thread_idle_start()` / `mi_on_thread_idle_end()`, and sweeps inline (once, as before) only when `_start` says there is no scavenger to hand off to. Nothing runs between the two calls but `Futex::wait`, which allocates nothing; `drain_idle_events` runs before `_start`. Only the wait that follows a timed-out one is bracketed. Bracketing every park was measured and is slower (table below: `_start` is two read-modify-writes on cache lines that every parking thread shares, and it wakes the scavenger, which costs half a context switch per task), and sweeping on every park is what the comment there warns about (13% of `vite preview` requests per second). As it is, a worker that parks between tasks never reaches `_start`, and `notify()` is what it was. - **The libuv loop on Windows** (`Bun__JSC_onBeforeWait` in `BunJSCEventLoop.cpp`, from `us_loop_run` in `libuv.c`) sweeps inline at most every 100 ms and then `uv_run(UV_RUN_ONCE)` blocks. It cannot hand off: `uv_run` dispatches the completions right after its poll inside the same call (`uv__poll` at `src/win/core.c:732`, `uv__process_reqs` at `:737`), and libuv allocates from mimalloc there (`uv_replace_allocator`), so there is no point between "about to block" and "awake" that bun's code sees without a patch to libuv. It keeps the inline sweep, and while `mi_on_thread_idle_pending()` says blocks were left, `Bun__JSC_onBeforeWait` returns 100 (ms) and `us_loop_run` arms an unref'd timer for that long after the sweep, so the hook runs again: three times at the most after JS last ran, which is the bound the fork gives. The timer does not keep the loop alive. Not changed: when no scavenger runs (`MIMALLOC_SCAVENGER=0` or a purge delay of 0; bun sets neither) the fallbacks in `us_loop_run_bun_tick` and in the pool sweep inline once, as on main, and the free blocks of large pages stay. ### How did you verify your code works? Release builds of one tree (this branch on `04823d8fad67`), Linux x64: **base** = main's pin, **pin** = this pin with the callers unchanged (the first commit), **this PR** = both commits. **Test.** `test/js/bun/jsc/heapStats-mimalloc.test.ts` gains "an idle work pool thread hands back the free blocks of its large pages": a subprocess with 4 pool threads reads 480 files of 96 to 512 KiB with `fs.promises.readFile` (the buffers are allocated on the pool threads), keeps 1 in 12, collects the rest, and polls `RssAnon` while nothing gives the pool work. Anonymous memory beyond what it started with and what it keeps has to come down to 12 MB within 2.5 s. | | free and resident at the end | | --- | --- | | base | 54 to 67 MB: fails | | pin, callers unchanged | 38 to 47 MB: fails (also with `BUN_GARBAGE_COLLECTOR_LEVEL=1` and `2`, and with the system bun 1.4.1: 50 MB) | | this PR | -1 MB, reached 350 ms into the idle: passes, 10 of 10 runs | **The park and notify path of the pool** (what the `has_swept` comment protects). 100000 `await fs.promises.stat("/")` one after the other (every call notifies a worker, which runs one task and parks), the same in batches of 8, and `await randomBytes(16)`; `perf stat` of the whole process, 8 cores, 5 interleaved runs, medians (range): | user instructions per call | base | pin | this PR | | --- | --- | --- | --- | | `stat`, sequential | 8794 (8784..8828) | 8792 (8716..8827) | 8797 (8780..8816) | | `stat`, 8 at a time | 6027 (6014..6131) | 6025 (6004..6080) | 6034 (6008..6102) | | `randomBytes(16)`, sequential | 12608 (12513..12629) | 12581 (12522..12609) | 12589 (12582..12627) | Context switches per call are 3.96 to 3.97, 0.51 to 0.53 and 3.97 in all three, kernel instructions per call overlap (41.6 k..49.9 k sequential), wall time is within the spread of the machine. Bracketing EVERY park instead (a build with a switch for it, 200000 calls, 4 interleaved runs), against the same build with the handoff after a timed-out wait only: | | after a timed-out wait (this PR) | every park | | --- | --- | --- | | `stat` sequential: user / kernel instructions, context switches per call | 8565..8601 / 45.6 k..46.7 k / 3.95..3.99 | 9193..9335 (+7%) / 49.6 k..50.7 k (+9%) / 4.2..4.55 | | `stat` 8 at a time | 5635..5659 / 8.8 k..9.3 k / 0.53..0.55 | 5771..5960 (+3%) / 10.1 k..11.6 k (+13%) / 0.63..0.75 | | `randomBytes(16)` sequential | 12145..12283 / 43.2 k..45.5 k / 3.97..3.99 | 12815..12893 (+5%) / 48.2 k..51.2 k (+11%) / 4.41..4.5 | | wall time, `stat` sequential | 11.6 s..12.7 s | 12.4 s..13.5 s (+6 to 8%) | **Allocator micro benchmarks** (mimalloc alone with bun's flags: clang 21, `-O3 -march=nehalem`, C++, `MI_FREE_USE_PAGEMAP`; user-mode instructions, the runs of one build equal to within 100): | 25 M `mi_malloc` + 25 M `mi_free` of 16..1039 bytes | instructions | | --- | --- | | before the upstream sync (`707d90be`) | 2.0731 G | | main's pin (`ab135013`) | 2.0568 G | | with oven-sh/mimalloc#40, #43, #42 (`3aa0ac9b`) | 2.0556 G (-0.06%) | | with #44, #45 (`58460467`) | 2.0305 G (-1.28%) | | this pin (`69050cf3`) | **2.0305 G (-1.28%)**, the same as `58460467` to within 400 instructions | | this pin with `MI_PROFILE=0` (not what bun builds) | 2.0244 G | (The absolute numbers are 8% below the ones this description had before: the benchmark's own object file is built differently here. All rows are from one recipe.) | 20 M `mi_malloc(128 KiB)` + `mi_free`, the generic path into a large page | instructions per pair | | --- | --- | | main's pin | 230.6 | | `58460467` | 193.6 | | this pin | 206.6 (+13.0 for the epoch stamp and its gate; -10% against main's pin) | **bun** (5 interleaved runs pinned to 8 cores; user-mode instructions and peak RSS, medians): | | base | pin | this PR | | --- | --- | --- | --- | | `bun -e 1` | 10.405 M, 27.0 MB | 10.410 M (+0.04%), 27.0 MB | 10.412 M (+0.06%), 27.0 MB | | `Bun.serve` hello, per request (20000 sequential `fetch`) | 52071, 49.0 MB | 51129 (-1.8%), 50.6 MB | 51361 (-1.4%; the spread between runs is 3%), 50.1 MB | | `Bun.serve` start and stop alone | 12.180 M | 12.179 M | 12.182 M (+0.01%) | | `JSON.stringify` + `JSON.parse` of a 200-user object, 3000 times | 5105.20 M, 44.0 MB | 5104.58 M, 44.0 MB | 5104.00 M (-0.02%), 43.5 MB | | string concat + `Buffer.from` + `split`, 300 x 5000 | 1900 M, 59.6 MB | 1912 M (+0.6%; the spread between its runs is 6.5%) , 60.5 MB | 1897 M (-0.2%), 59.7 MB | | idle after start (`Bun.sleep`), RSS / anonymous from `smaps_rollup`, binaries read into the page cache first | 28.42 MB / 3.14 | 28.45 MB / 3.05 | 28.45 MB / 3.05 | Nothing is slower by more than 0.1% outside the spread of its own runs. **The large-buffer server of oven-sh/mimalloc#46 at bun level** (`Bun.serve` that reads 256 KiB request bodies and answers 90 to 270 KB of JSON; 16 connections for 15 s, then idle; RSS / anonymous from `smaps_rollup`): | at idle | base | pin | this PR | | --- | --- | --- | --- | | 40 bodies kept, 2 s idle | 74.7 / 36.2 MB | 54.3 / 15.9 MB | 54.3 / 15.9 MB (**-20.4 MB**) | | 40 bodies kept, 15 s idle | 74.7 / 36.2 MB | 54.2 / 15.8 MB | 54.2 / 15.9 MB | | nothing kept, 15 s idle | 47.0 / 8.6 MB | 43.6 / 5.3 MB | 43.7 / 5.5 MB (-3.3 MB) | | at the end of the load, before the idle (40 kept) | 74.4 / 36.4 MB | 73.7 / 35.7 MB | 73.9 / 35.9 MB | Under sustained load (the same server, 20 s, 3 interleaved rounds; instructions and page faults of the server process from `perf stat -p`): | | requests | user instr / request | kernel instr / request | page faults / request | | --- | --- | --- | --- | --- | | base | 83.6 k .. 92.7 k | 283.9 k .. 284.5 k | 119.4 k .. 126.6 k | 0.34 .. 0.53 | | pin | 87.0 k .. 91.4 k | 284.0 k .. 284.1 k | 118.8 k .. 127.4 k | 0.50 .. 0.64 | | this PR | 80.4 k .. 91.4 k | 284.0 k .. 284.7 k | 120.9 k .. 125.3 k | 0.51 .. 0.86 | Requests and instructions per request are the same; the buffers stay while the server is busy and go when it is not. **Tests with the release build of this PR** - `test/js/bun/spawn/spawn-pipe-leak.test.ts` 25 of 25 runs, `test/js/bun/jsc/heapStats-mimalloc.test.ts` (with the new test) and `test/js/bun/gc/gc-controller-cadence.test.ts` 10 of 10 each. - 66 test files, 2165 tests pass: `require-cache`, `serve.test.ts`, all of `test/js/web/workers`, `test/js/node/worker_threads`, `test/js/node/child_process` and `test/js/node/fs` (the pool-heavy ones), `spawn-noread-leak`, `bun-file`, `bun-write`, `bundler_edgecase`, `esbuild/splitting`. The two failures need a non-root user (the root-range port in `serve.test.ts`, EPERM after dropping privileges in `child_process.test.ts`) and fail the same way with the base build. - The wider list of 113 files (bake dev, bundler, fetch, http, streams, sqlite, install, resolve, shell and others): 2485 tests pass; the two failures are in `resolve.test.ts`, want a directory that root cannot read, and fail the same way with the base build. - The new test fails with `USE_SYSTEM_BUN=1` (bun 1.4.1: 50 MB) and with the first commit alone (38 to 47 MB), and passes with both. - No debug or ASAN build was made for this revision of the PR (the earlier revisions ran `heapStats-mimalloc`, `gc-controller-cadence` and `worker.test.ts` on one at `58460467`); the new test skips itself under ASAN, where malloc is not mimalloc. **Not run here:** macOS and Windows. The Windows half of the second commit (`BunJSCEventLoop.cpp` under `OS(WINDOWS)`, `libuv.c`, `libuv.h`) was compiled by nothing on this machine: the C++ function was syntax-checked with `OS(WINDOWS)` forced on against stubs, `cargo check --target x86_64-pc-windows-msvc` passes for `bun_uws_sys`, `bun_threading` and `bun_mimalloc_sys` (the `WindowsLoop` mirror gained the field), and the rest is for CI. The fork's CI passed 23 of 23 jobs at each of the six merges (Linux x64/arm64 with ASAN, UBSAN, TSAN and guarded builds, the `MI_FREE_USE_PAGEMAP` build that matches bun's configuration, macOS, Windows x64/arm64, FreeBSD).
Jarred-Sumner
added a commit
that referenced
this pull request
Sep 15, 2026
…lone for a while Since the idle sweep discards the free blocks of large pages (#46), a thread that handles a request now and then takes every buffer of 96 KiB and up out of discarded memory again: once the two epochs of the hold are over the blocks go, and a server with five requests a second pays a page fault for every OS page of every buffer on every request. purge_holes_large_floor (4 MiB) bytes of such blocks, process-wide, are now left where they are while their page was allocated from in the last purge_holes_large_floor_epochs (256) epochs of the sweep. - The budget is one atomic sum. A thread gives its share back when its next sweep decides again, when it exits, and in the child of a fork; what it kept and has used since counts until then (stale on the high side only). - The pages of one sweep are decided together at the end of its pass over the thread's own pages, the ones that were used last first. Abandoned pages come after that for what is left; one that stays says in swept_state which thread counts it, so that the other threads that sweep its heap leave it alone and do not count it again. - Only blocks on the free list count: the part of a page that was never formed was never written to, and it is discarded as before. - The age of a page is the epoch of its last allocation, which is in swept_state already. An epoch ends only when a thread is swept, so there is no clock in the page and nothing is woken for this: a process in which no thread parks any more keeps those bytes until one does. There is no floor without an interval to count epochs in. - A thread that stays parked is looked at again by the scavenger once what it kept is old enough to go, which is when the sweeps of other threads have moved the epoch that far: it does not hold the floor against the threads that run. The idea of a floor under what the sweeps take is from #48 (purge_holes_large_floor, there a flat number of bytes that stay for good). This one counts what was written to, prefers what was used last, and lets go of it.
Jarred-Sumner
added a commit
that referenced
this pull request
Sep 15, 2026
…lone for a while Since the idle sweep discards the free blocks of large pages (#46), a thread that handles a request now and then takes every buffer of 96 KiB and up out of discarded memory again: once the two epochs of the hold are over the blocks go, and a server with five requests a second pays a page fault for every OS page of every buffer on every request. purge_holes_large_floor (4 MiB) bytes of such blocks, process-wide, are now left where they are while their page was allocated from in the last purge_holes_large_floor_epochs (256) epochs of the sweep. - The budget is one atomic sum. A thread gives its share back when its next sweep decides again, when it exits, and in the child of a fork; what it kept and has used since counts until then (stale on the high side only). - The pages of one sweep are decided together at the end of its pass over the thread's own pages, the ones that were used last first. Abandoned pages come after that for what is left; one that stays says in swept_state which thread counts it, so that the other threads that sweep its heap leave it alone and do not count it again (until a thread that had a share ends: then the first sweeper to pass counts it). - Only blocks on the free list count: the part of a page that was never formed was never written to, and it is discarded as before. - The age of a page is the epoch of its last allocation, which is in swept_state already. An epoch ends only when a thread is swept, so there is no clock in the page and nothing is woken for this: a process in which no thread parks any more keeps those bytes until one does. There is no floor without an interval to count epochs in. - A thread that stays parked is looked at again by the scavenger once what it kept is old enough to go, which is when the sweeps of other threads have moved the epoch that far: it does not hold the floor against the threads that run. The idea of a floor under what the sweeps take is from #48 (purge_holes_large_floor, there a flat number of bytes that stay for good). This one counts what was written to, prefers what was used last, and lets go of it.
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.
Problem
Free blocks in large (4 MiB) pages, which hold the blocks of about 84 KiB up to 512 KiB, are never given back while one block of the page is live. Hole purging skips these pages on a 4 KiB OS page:
page->purgedhas 256 bits and a 4 MiB page has 1024 OS pages (mi_page_can_purge_holesis false, the sweep counts the page as ineligible). Measured withmincoreover every free block in such pages, 97% of the free space is resident, and it does not move while the process is idle:Bun.serve, 256 KiB request bodies, 70k requests, 40 bodies kept, idle 60 sThe pages of the last row exist in every Bun process: they are pinned by about 1 MB of allocations made at start-up. A session of a large CLI application leaves 6.5 to 7.6 MB free in 9 to 10 such pages; what it allocates in that range (187 MB over 918 allocations in 47 s) is
VecandVectorgrowth, string builders,Uint8Arraychunks and JIT compiler temporaries, all written in full before they are freed.Change
1. The unit of the purge bitmap is per page.
mi_page_purge_unit(page)is the OS page where the block area of the page then needs at mostMI_PAGE_PURGE_BITSbits, and else the smallest power-of-two multiple of the OS page for which it does: 16 KiB for a 4 MiB page on a 4 KiB OS page. Bitkofpage->purgednames unitk, counted fromalign_down(page_start, unit). Nothing changes for small and medium pages on any OS page size, for large pages where the OS page is 16 KiB or more, and on 32-bit (a large page is 1 MiB there): the unit is the OS page in each of those.Everything else is what is there already, with "OS page" read as "unit": a unit is discarded only when it lies wholly inside the block area and every block that overlaps it is free (
mi_page_purge_holes_walk), a block is purged exactly when it overlaps a discarded unit, a purged block is off every free list until_mi_page_unpurge_runbrings its run back,_mi_os_discardnever touches commit state,_mi_page_unpurge_allruns when the page goes back to the arena, the counters and the sweep's rate limit. Nothing inmi_mallocormi_free.test_unit_arithmeticchecks the arithmetic for 8, 16 and 64 KiB units under block sizes that are and are not multiples of them, with and without an offset of the first block). In practice each free block goes as a whole: the size classes of a large page on 64-bit are 96, 112, 128, 160, 192, 224, 256, 320, 384, 448 and 512 KiB, all multiples of 16 KiB, and the blocks start at the slice (the page meta data is separate from the page for these block sizes both with the page map,MI_FREE_USE_PAGEMAP, which is how Bun builds, and with upstream's aligned meta data).[page_start, page_start + capacity*block_size)is never discarded, as before; that covers a header in front of the blocks, the blocks that are not formed yet (discarded by their own OS-page-granular mechanism, which is unchanged and already worked for large pages) and the guard page ofMI_SECURE=FULL.mi_page_unpurge_rangewrites the links when the run comes back, which touches one OS page per block.page->free_is_zerois cleared on every purge and un-purge, as before, somi_zallocof a block that was purged zeroes it like any reused block and never sees stale bytes (on macOS and Windows a discarded page may keep its contents). It does not skip the memset on Linux where the kernel already zeroed the pages: that would need per-run zero knowledge, which the mechanism does not have for any page size today.xthread_free; the bitmap is touched by the owner or by the sweep that claimed the page, as before. Abandoned large pages in the abandoned map are swept like the others; a page that was abandoned while full is still not visited (unchanged, a separate change).mi_heap_destroy,mi_heap_deleteand a page that becomes free all go through_mi_page_unpurge_all, so the discarded-bytes accounting returns to where it was (tested). Thecommittedstatistic is not involved: a discard ismadvise(MADV_DONTNEED)/MADV_FREE_REUSABLE/MEM_RESET+VirtualUnlockand never a decommit.2. The free blocks of a large page stay while the page is in use, and only the sweep reads a clock for that. With (1) alone, a
Bun.servewith 256 KiB bodies under sustained load discarded and refaulted 3 GB in 20 s: its event loop parks between requests, a park that is 100 ms after the last sweep is swept, and the buffers it had just freed were taken again at once. Kernel instructions per request went from 115 k to 152 k, page faults from 0.9 to 8.4 per request, throughput down by about a tenth. A thread parks, and is swept, far more often than it is idle.useddoes not tell a page that is in use from one that is not: it is the same at every park of a busy server.The first version of this PR had the allocating thread leave a reading of
CLOCK_MONOTONIC_COARSEin the page. That is gone: nothing on the allocating side reads a clock or makes a call, and_mi_clock_now_coarsewith its plumbing is deleted (nothing else used it;stats.cis identical tobun-dev3-v2again, so there is no macOS or Windows variant of it to get right either).The sweep owns the time instead. It counts epochs: a process-wide counter (
mi_holes_epochinpage.c) that an idle sweep moves on, in_mi_page_purge_holes_sweep_begin, when the current epoch has lasted forpurge_holes_min_interval(100 ms) by the clock that the sweep reads anyway.mi_malloc_generic_fallback, the out-of-line path that every allocation above 10 KiB takes; same place and same gate as before,block_size > MI_MEDIUM_MAX_OBJ_SIZE && page->reserved > 1): a relaxed load of the epoch, and a store of it intopage->swept_state, under the tag that no(capacity,used)state and notMI_PAGE_SWEPT_NONEcan have, only when the page does not carry that value already. A page that is allocated from all the time is written to once per epoch; when nothing sweeps (purge_holesoff, or a process that never parks) the epoch stands still and the page is written to once. There is no separate check of the option: it would cost what the load and compare cost._mi_page_purge_holes): a large page whose epoch is the current one or the one before keeps its free blocks and the blocks it has not formed yet; one that is two or more epochs old is swept. An epoch lasts for the interval at least and only a sweep ends it, so with the third epoch there to be seen, the whole of the second one lies between the allocation and now: those blocks were left alone for the interval at least.No epoch is shorter than the interval, whoever looks and wherever a sweeper is preempted; that is what the rule rests on. Any number of threads sweep at the same time (the scavenger for the parked ones, each thread that calls
mi_on_thread_idleitself).mi_holes_epoch_advance: read the time the epoch began; if that is the interval ago, claim the move with a CAS of that time to aMOVINGmark; increment the epoch; read the clock AGAIN and store that as the time the new epoch began. The epoch begins for everybody else at the increment, so its recorded beginning is never before its real one, and until it is recorded nobody else moves the epoch: a sweeper that is held up in between delays the next epoch and cannot shorten this one. (A sweeper that finds the mark waits for it, bounded, so that it sees the new epoch.) To the millisecond, as the clock is. Aforkthat lands while another thread holds the mark: the child records a new beginning (_mi_page_purge_holes_forked_child).32 bits of the epoch are kept in the page and compared modulo 2^32: a page that was left for a multiple of 2^32 epochs (13 years of sweeping at 100 ms) reads as young once more and goes two epochs later. The epoch is process-wide like the clock and the option; a page of any subprocess compares with it. A page that reaches a sweeper does so through a release that the sweeper acquired (the park, the claim of an abandoned page), so the sweeper never reads an epoch older than the one in the page; if it did, the page would read as very old and go at once, which is a page fault and not an error. Stamps are written by the owner of a page only, never while a sweep has the page.
A thread that stays parked has no next sweep, and one sweep ends one epoch at the most, so the first sweep after an allocation leaves the page however long ago the allocation was. In the handoff (
_mi_theap_sweep_parked) a park in which such blocks were left is therefore not marked as done. How that terminates:purge_holes_min_intervalafter the END of the sweep before (holes_sweep_lastis written after the work now), which is when that sweep can end the epoch that the one before began; the scavenger sleeps until then (the due time it gets back), nothing polls;tld->holes_park_epoch,holes_sweep_epoch): everything the thread left when it parked is from the epoch of that first sweep at the latest, so such a sweep saw all of it as old and took it. What it still leaves was allocated from since, by other threads (the abandoned pages that the sweep passes as well), and is for whoever sweeps after that;MI_PARK_SWEEPS_MAX= 6 whatever happens.park_sweptcounts them;The inline
mi_on_thread_idle()follows the same rule, which is what its callers need from a busy loop (Bun's Windows event loop calls it at most every 100 ms: the buffers must stay). Nothing comes back for what an inline sweep leaves, so there ismi_on_thread_idle_pending(): true when the last sweep of this thread's heaps left such blocks, for a caller that is about to block for good to callmi_on_thread_idle()again, an interval after the call before returned, while it is true: two more calls as a rule. This is a change for a caller that sweeps once and then blocks (Bun's thread pool does, after 100 ms without work): that one call gave back the large pages in the first version of this PR when the last allocation was 100 ms ago; now it never does, because nothing tells it how long ago that was, and the thread's own pages that are not full stay as they are onbun-dev3-v2until the caller uses_pendingor the_start/_endpair (full pages are abandoned and are reached by the sweeps of the other threads of the heap). None of the three workloads below shows a difference from that.Two earlier shapes of the rule did not survive independent review and are worth naming. Deferring in the handoff only left the inline path purging 58 MB in 3 s of a loop that frees and takes 256 KiB buffers (1.85 page faults per request against 0.06; now 0 discards and 0.07). "Take them at the second sweep in a row that finds the page untouched" never takes them for a caller that sweeps once and then blocks, and an abandoned page that two parked threads sweep one after the other got its two sweeps within a millisecond (with epochs both see the same epoch).
purge_holes_min_intervalhas two meanings now, both documented at the option: the least time between two sweeps of a thread's heaps in the handoff, and the least length of an epoch, which is how long the free blocks of a large page stay after the page was last allocated from (one to two of them), whoever sweeps and however often. 0 turns the hold off.Numbers
Bun release build of main (
ae7179126de5) with--local-deps=mimalloc; before =bun-dev3-v2(58460467), clock = the first version of this PR (ae0ad8a6, the stamp is a reading of the coarse clock), epoch = this branch. All three measured again in one session on the same machine (64 cores, busy: load 50 to 110). RSS and anonymous fromsmaps_rollup, free and resident from a walk of every large page withmincore(a local diagnostic, not in this PR).Bun.serve256 KiB bodies, 40 kept, idle 15 sThe epoch rule gives back what the clock rule gave back. In all three the reading 2 s into the idle is already the final one (free resident 0.0), and at the end of the load, before the idle, free resident is what it is before this PR (20.9 against 21.1 MB, 8.1 against 8.2 MB). Sampled every 50 ms in the first workload, anonymous memory goes 125.4 → 53.8 MB between 105 and 215 ms into the idle (clock: 125.4 → 54.3 between 105 and 210 ms): the last allocations of that workload are some time before it goes idle. (The last row is at 15 s and not at 60 s as in the first version of this text: by 60 s the machine had reclaimed file pages of some of the processes, 39 → 22..36 MB, which says nothing about the allocator. Anonymous at 60 s: 14.0 / 6.4 / 5.8 MB.)
Under sustained load (the same server, 16 connections, 20 s, 4 interleaved rounds; instructions and page faults of the server process from
perf stat -p). The machine was at its busiest in the first round, which is the low end of every requests column and the high end of the faults and discarded columns; the other three rounds are in brackets:("discarded under load" is the total of the run less the 22 to 27 MB that go in the 1.5 s pause at its end.) Requests per second and instructions per request are the same for the three within the spread of this machine. The epoch rule discards less under load than the clock rule did, a quarter to a half of it: a page is kept for one to two intervals after it was last allocated from and not for exactly one, so the pages of a size class that are not the first in their queue survive a gap of 100 ms. For comparison, (1) without (2), measured in an earlier session against its own baseline:
The allocating side.
mi_malloc+mi_freeof 128 KiB in a loop (every allocation takes the generic path into a large page), mimalloc built with Bun's flags (clang 21,-O3 -march=nehalem, C++,MI_FREE_USE_PAGEMAP),perf stat -e instructions:u, 5 interleaved runs of 20 M pairs each, the runs of one build equal to within 500 instructions:Of the 13, 8 are the gate (
block_size > MI_MEDIUM_MAX_OBJ_SIZE && reserved > 1, compiled without a branch), which the clock version has as well; the stamp itself ismov epoch(%rip),mov,movabs,or,cmp swept_state,je(not taken once per epoch, then amovto the page). The allocator micro benchmark (25 M malloc + 25 M free of 16..1039 bytes, same flags) executes 2.20735 G instructions in all three, to within 400: nothing is added to the fast paths ofmi_mallocandmi_free(alloc.c,free.candalloc-aligned.care untouched).The 30 s of idle after a load cost the process 14.4 M user + 20.3 M kernel instructions before, 15.4 M + 30.4 M with the clock rule and 16.0 M + 29.9 M with the epoch rule: the difference to before is the one-time
madviseof 21 MB, and the one or two sweeps more of a park are 0.6 M. When the buffers are used again they fault back in, once. The sweep of a large page walks at most 42 free blocks.Tests
New
test/test-purge-holes-large.c(also run withMIMALLOC_PURGE_HOLES=0), in the style oftest-purge-holes.c: the unit of small, medium and large pages; the unit arithmetic; the free blocks of the first, a middle and the largest large-page class are all purged at an idle sweep and, on Linux, not resident after it (mincore), while the survivors keep every byte; reuse after a purge (mi_mallocandmi_zalloc, no block handed out twice, zero where asked, writable all over, the purged blocks come back); frees from another thread into pages with holes, and the last blocks freed from there;mi_reallocshrinking in place and moving between classes next to holes; a page abandoned with free blocks in it, which the sweep of another thread discards, then used and freed from there;mi_heap_destroyandmi_heap_deletewith holes (the accounting returns to its start); a double free of a purged block. About the hold, in epochs:mi_on_thread_idle_pendingsays so; an interval later the pages that were left alone go, and the one that a block was just taken from and put back stays; a page that is allocated from before every sweep keeps its blocks over four more epochs; they go at the first sweep after it was left alone for a whole epoch; a sweep after that has nothing to do;_mi_page_purge_holes_epoch_advanceis removed and when the start of an epoch is recorded early.What these cases check about time are lower bounds, which hold on a machine of any load (the first sweep after an allocation leaves the page whenever it comes, so that needs no time at all), and upper bounds only against a reading of the clock from BEFORE the allocation in question; the one liveness check (the epoch does move) runs until it has seen it, for 10 s at the most. CMake runs the file with
MIMALLOC_ALLOW_THP=0(it checks residency withmincore). With the hold rule gutted (no stamp, or no comparison) the park and inline cases fail; with the rate limit of the epoch gutted the "two sweeps within the interval" check fails.The existing
test-purge-holes.csetspurge_holes_min_intervalto 0, which turns the hold off (on a 32-bit target its 64 KiB blocks are in a large page; the x86 clang-cl jobs of the CI found that), and no longer expects a 64 KiB block to be in a medium page where it is not (MI_ENABLE_LARGE_PAGES=0in a debug build: that configuration failed onbun-dev3-v2as well).ctestpasses in: Release, DebugMI_DEBUG=FULL, ASAN, UBSAN (C++), TSAN,MI_SECURE=ON,MI_SECURE=FULLrelease and debug,MI_GUARDED=FULL, C++ debug and release,MI_FREE_USE_PAGEMAPdebug and release and C++ release,MI_STATS=0 MI_PROFILE=0,MI_PROFILE=OFF,MI_ENABLE_LARGE_PAGES=0release and debug (Linux x64; 18 configurations). A 32-bit (-m32) compile of the library and the tests has no new warnings; on 32-bit the epoch variable is 32 bits and the time it began a 64-bit atomic likepurge_expire.For the pin in Bun
src/threading/ThreadPool.rscallsmi_on_thread_idle()once after 100 ms without work and then blocks without a timeout, and the Windows event loop calls it from a busy loop only. With this branch the pool threads should either park withmi_on_thread_idle_start()/_end()around the wait, or wake up to callmi_on_thread_idle()again whilemi_on_thread_idle_pending()(an interval after the previous call returned, three more times at the most). Without that they keep the free blocks of their own large pages that are not full, as they do today onbun-dev3-v2.