Collect the page when heap meta data is freed into it - #31
Open
robobun wants to merge 1 commit into
Open
Conversation
mimalloc abandons a page as soon as it is full. A full abandoned page is not in the arena's abandoned bitmap, so no allocation finds it. The only way back is a free into the page: the free claims the page and collects it (frees it when it is empty, reclaims it into the current theap, or re-maps it once enough blocks are free). `_mi_free_subproc_safe` skipped that step (`allow_collect=false`) because a collect must not cross sub-processes. `mi_heap_free` frees every `mi_heap_t` and `mi_arena_pages_t` through it. Inside the same sub-process the skip is a leak: the freed block sits on the thread-free list of an abandoned, unmapped page, the `used` count of the page never drops, and the page is never freed or reused. The leak needs enough heaps alive at the same time to fill a page with their meta data. From then on every destroyed heap strands its `mi_heap_t` (7 KiB) and, when the heap allocated, its `mi_arena_pages_t` (about 150 KiB for a 1 GiB arena). Bun creates one heap per `bun_alloc::Arena`, for example one per `Bun.Transpiler`, and the GC destroys them in batches. About 150 KiB of RSS per instance never came back. Collect when the page belongs to the current sub-process. The page is not owned at that point, so its sub-process comes from its arena, not from `page->heap` (the rule at `mi_page_heap`); the helper that `mi_stat_free` already had for this moves out of `MI_STAT` and is shared. A free across sub-processes keeps the old behavior. The new test-heap-burst-destroy creates 200 heaps with one block each, destroys them all, and repeats. It counts the bytes in use in the main heap over every page (`mi_heap_visit_blocks`). Without the fix the count grows by about 170 KiB per destroyed heap, and by about 6 KiB for a heap that never allocated. With it the count does not move.
robobun
force-pushed
the
robobun/4b8e7416/collect-on-heap-metadata-free
branch
from
September 1, 2026 09:51
ec302b1 to
04ced98
Compare
This was referenced Sep 1, 2026
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
mi_heap_destroy(andmi_heap_delete) leaks the meta data of the heap once enough heaps were alive at the same time. The leak is about 7 KiB per destroyed heap for themi_heap_t, and about 150 KiB once the heap had allocated, for itsmi_arena_pages_t(the per-arena page bitmaps of a 1 GiB arena). The leak is unbounded: 20 rounds of 200 heaps leave 700 MB in use (see the numbers below)._mi_free_subproc_safe(src/free.c:280), whichmi_heap_free(src/heap.c:232,src/heap.c:258) uses for both blocks. It frees withallow_collect=false. A full page is abandoned and unmapped, and a block freed into it without a collect is never found again: the block sits on the page's thread-free list,page->usednever drops, and the page is never freed or reused.Fix
_mi_free_subproc_safecollects when the page belongs to the current sub-process. The page is not owned at that point, so its sub-process comes from its arena (page->memid), not frompage->heap(the rule atmi_page_heapininternal.h).mi_stat_freealready had that helper underMI_STAT; it moves out and is shared asmi_page_subproc_unowned. A free across sub-processes keepsallow_collect=false, which is the case the function exists for.mi_freetakes for a block of another thread: claim the page, fold the thread-free list intoused, then free, reclaim, or re-map it (mi_free_try_collect_mt). The reclaim step already refuses to run on a terminating thread (_mi_thread_is_initialized) and on a theap of another heap (_mi_page_associated_theap_peek).test/test-heap-burst-destroy.c(new). Without the fix: 170,276 B in use per destroyed heap, 6,444 B per destroyed empty heap. With it: 0 B. The wholectestsuite passes in aMI_DEBUG_FULL=ONbuild (25 tests) and the new test,test-api,test-stress-subprocs, andtest-heap-teardownpass in a release build.Background
_mi_page_abandon,mi_free_try_collect_mt).pages_abandonedbitmap, so an allocation can find it. A page abandoned while full is not mapped, because it has nothing to hand out._mi_arenas_page_try_reabandon_to_mappedmaps it once it drops below 7/8 used.mi_heap_tandmi_arena_pages_tare allocated from the main heap of the sub-process (_mi_heap_new_for_subproc,mi_arena_pages_alloc). They fill pages of the main heap like any other blocks do, one 64 KiB page per 9 heaps and one 4 MiB page per 28 arena page sets.mi_subproc_new) own separate arenas and heaps._mi_free_subproc_safeexists so that a sub-process teardown can free a block that lives in the pages of another sub-process without collecting a page of that other sub-process.Notes
Upstream
dev3has the same code (_mi_free_subproc_safecame with "wip: initial fixed subprocesses", 360d796, which replacedmi_free(arena_pages)andmi_free(heap)inmi_heap_freewith the subproc-safe variant).test-heap-burst-destroyoutput without the fix (MI_DEBUG_FULL build):With the fix:
The same with 1000 live heaps and 10 rounds in a release build: 8047 KiB before, 8028 KiB after.
How Bun hits it:
bun_alloc::Arenais onemi_heap_new/mi_heap_destroypair.new Bun.Transpiler({ define: { A: '"x"' } })creates one and allocates into it. The GC finalizes hundreds of instances in one batch, so the heaps are destroyed while hundreds are alive, and a release build of Bun leaked about 150 KiB of RSS per instance with no bound (10,000 instances: 1.45 GB). The Bun side is oven-sh/bun#41100: a pin bump to this PR's head (draft until this merges) plus a test that destroys 200 heaps per round and checks thatpages_abandonedand RSS stay flat.