Skip to content

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
Jarred-Sumner merged 5 commits into
bun-dev3-v2from
claude/purge-holes-large-pages
Sep 14, 2026
Merged

Jarred-Sumner merged 5 commits into
bun-dev3-v2from
claude/purge-holes-large-pages

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator

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->purged has 256 bits and a 4 MiB page has 1024 OS pages (mi_page_can_purge_holes is false, the sweep counts the page as ineligible). Measured with mincore over every free block in such pages, 97% of the free space is resident, and it does not move while the process is idle:

workload (Bun, release, Linux x64) large pages live free of it resident at idle
100..500 KiB typed arrays, strings and Buffers, 1 in 12 kept, idle 10 s 33 49.2 MB 73.1 MB 71.6 MB
Bun.serve, 256 KiB request bodies, 70k requests, 40 bodies kept, idle 60 s 10 11.1 MB 21.3 MB 20.8 MB
same server, nothing kept, idle 60 s 5 1.1 MB 8.8 MB 8.4 MB

The 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 Vec and Vector growth, string builders, Uint8Array chunks 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 most 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 of page->purged names unit k, counted from align_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_run brings its run back, _mi_os_discard never touches commit state, _mi_page_unpurge_all runs when the page goes back to the arena, the counters and the sweep's rate limit. Nothing in mi_malloc or mi_free.

  • Block sizes. The rule does not depend on how the block size relates to the unit; a block that shares a unit with a live neighbour keeps that unit (test_unit_arithmetic checks 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 header, unformed tail, guard page. A unit that is not wholly inside [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 of MI_SECURE=FULL.
  • First word and zeroing. A purged block carries no free-list link (that is why it is off the lists); mi_page_unpurge_range writes the links when the run comes back, which touches one OS page per block. page->free_is_zero is cleared on every purge and un-purge, as before, so mi_zalloc of 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.
  • Other threads, abandoned pages, heap release. A free from another thread only pushes on 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_delete and a page that becomes free all go through _mi_page_unpurge_all, so the discarded-bytes accounting returns to where it was (tested). The committed statistic is not involved: a discard is madvise(MADV_DONTNEED) / MADV_FREE_REUSABLE / MEM_RESET + VirtualUnlock and never a decommit.
  • Huge pages are singletons and stay out.

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.serve with 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. 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.

The first version of this PR had the allocating thread leave a reading of CLOCK_MONOTONIC_COARSE in the page. That is gone: nothing on the allocating side reads a clock or makes a call, and _mi_clock_now_coarse with its plumbing is deleted (nothing else used it; stats.c is identical to bun-dev3-v2 again, 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_epoch in page.c) that an idle sweep moves on, in _mi_page_purge_holes_sweep_begin, when the current epoch has lasted for purge_holes_min_interval (100 ms) by the clock that the sweep reads anyway.

  • Allocation (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 into page->swept_state, under the tag that no (capacity,used) state and not MI_PAGE_SWEPT_NONE can 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_holes off, 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.
  • Sweep (_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.
  • Net effect: a busy thread puts each new epoch in its pages within the interval and keeps its buffers for good. After the last allocation the free space goes back between one and two intervals later, where something sweeps that often.

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_idle itself). 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 a MOVING mark; 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. A fork that 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:

  • it is swept again purge_holes_min_interval after the END of the sweep before (holes_sweep_last is 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;
  • until a sweep BEGAN two epochs on from the first sweep of the park (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;
  • that is the second sweep of the park when the first one ended an epoch and the third when it did not; more only where a sweeper was held up for an interval while it moved the epoch, and MI_PARK_SWEEPS_MAX = 6 whatever happens. park_swept counts them;
  • with several parked threads: a thread that was just swept is not due for an interval, so the loop claims the next one in the list that is; each costs a bounded number of sweeps per park and at most one per interval, which is what a thread that parks all the time costs already.

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 is mi_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 call mi_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 on bun-dev3-v2 until the caller uses _pending or the _start/_end pair (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_interval has 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 from smaps_rollup, free and resident from a walk of every large page with mincore (a local diagnostic, not in this PR).

at idle RSS before clock epoch anon before clock epoch free resident before clock epoch
typed arrays / strings / Buffers, 1 in 12 kept, idle 2 s 160.3 MB 89.1 88.7 125.3 54.3 53.9 71.6 MB 0.5 0.0
Bun.serve 256 KiB bodies, 40 kept, idle 15 s 77.9 MB 56.7 55.2 38.3 17.1 16.6 21.1 MB 0.0 0.0
same, nothing kept, idle 15 s 51.8 MB 45.9 46.2 15.3 6.5 6.7 8.2 MB 0.0 0.0

The 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:

requests user instr / request kernel instr / request page faults / request discards discarded under load
before 62.4 k (75.2 .. 95.9 k) 284.2 k .. 284.6 k 118.5 k .. 121.6 k 0.43 .. 1.18 98 .. 117 0.2 MB
clock 61.4 k (83.2 .. 100.3 k) 284.4 k .. 285.2 k 121.1 k .. 126.8 k 0.49 .. 1.71 234 .. 363 177 MB (90 .. 92 MB) = 7.7 (3.9 .. 4.0) MB/s
epoch 55.1 k (90.4 .. 95.7 k) 284.3 k .. 284.7 k 119.9 k .. 126.9 k 0.27 .. 1.97 162 .. 303 115 MB (20 .. 44 MB) = 5.0 (0.9 .. 1.9) MB/s

("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:

requests kernel instr / request page faults / request discards discarded
before 80.4 k .. 86.3 k 110.9 k .. 118.3 k 0.71 .. 1.03 92 .. 127 0.9 .. 1.1 MB
(1) without (2) 72.9 k .. 82.1 k 149.4 k .. 155.8 k 7.4 .. 9.3 4237 .. 5202 2.4 .. 3.0 GB

The allocating side. mi_malloc + mi_free of 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:

instructions per malloc + free against before
before 197.6
clock 279.6 +82.0
epoch 210.6 +13.0

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 is mov epoch(%rip), mov, movabs, or, cmp swept_state, je (not taken once per epoch, then a mov to 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 of mi_malloc and mi_free (alloc.c, free.c and alloc-aligned.c are 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 madvise of 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 with MIMALLOC_PURGE_HOLES=0), in the style of test-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_malloc and mi_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_realloc shrinking 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_destroy and mi_heap_delete with holes (the accounting returns to its start); a double free of a purged block. About the hold, in epochs:

  • the park handoff: the first sweep of a park takes nothing of the large pages and does not mark the park as done; the park is swept two or three times, each an interval after the one before, which takes every free block; then it is done and not swept again;
  • the inline sweep: nothing of a large page goes at the first sweep after the allocations, nor at a second one right after it (two epochs cannot end within one interval), and mi_on_thread_idle_pending says 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;
  • an abandoned page that is young is left by the sweep of another thread and taken two epochs later;
  • sweepers at the same time: four threads that allocate from a large page of their own and sweep without a pause, next to the thread under test, which does the same and checks (a) from readings of the clock that lie around its readings of the epoch that no epoch is shorter than the interval, to the millisecond, and (b) that a page it allocated from before a sweep keeps its blocks in that sweep. Both fail when the exclusion in _mi_page_purge_holes_epoch_advance is 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 with mincore). 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.c sets purge_holes_min_interval to 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=0 in a debug build: that configuration failed on bun-dev3-v2 as well).

ctest passes in: Release, Debug MI_DEBUG=FULL, ASAN, UBSAN (C++), TSAN, MI_SECURE=ON, MI_SECURE=FULL release and debug, MI_GUARDED=FULL, C++ debug and release, MI_FREE_USE_PAGEMAP debug and release and C++ release, MI_STATS=0 MI_PROFILE=0, MI_PROFILE=OFF, MI_ENABLE_LARGE_PAGES=0 release 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 like purge_expire.

For the pin in Bun

src/threading/ThreadPool.rs calls mi_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 with mi_on_thread_idle_start() / _end() around the wait, or wake up to call mi_on_thread_idle() again while mi_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 on bun-dev3-v2.

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 Jarred-Sumner changed the title page: hole purging in large (4 MiB) pages, held back while the page is in use 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) Sep 14, 2026
@Jarred-Sumner
Jarred-Sumner merged commit 69050cf into bun-dev3-v2 Sep 14, 2026
23 checks passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant