Skip to content

Collect the page when heap meta data is freed into it - #31

Open
robobun wants to merge 1 commit into
oven-sh:bun-dev3-v2from
robobun:robobun/4b8e7416/collect-on-heap-metadata-free
Open

Collect the page when heap meta data is freed into it#31
robobun wants to merge 1 commit into
oven-sh:bun-dev3-v2from
robobun:robobun/4b8e7416/collect-on-heap-metadata-free

Conversation

@robobun

@robobun robobun commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • mi_heap_destroy (and mi_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 the mi_heap_t, and about 150 KiB once the heap had allocated, for its mi_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).
  • The cause is _mi_free_subproc_safe (src/free.c:280), which mi_heap_free (src/heap.c:232, src/heap.c:258) uses for both blocks. It frees with allow_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->used never drops, and the page is never freed or reused.

Fix

  • _mi_free_subproc_safe collects 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 from page->heap (the rule at mi_page_heap in internal.h). mi_stat_free already had that helper under MI_STAT; it moves out and is shared as mi_page_subproc_unowned. A free across sub-processes keeps allow_collect=false, which is the case the function exists for.
  • Correct because inside one sub-process this is the same path a plain mi_free takes for a block of another thread: claim the page, fold the thread-free list into used, 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).
  • Verified: 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 whole ctest suite passes in a MI_DEBUG_FULL=ON build (25 tests) and the new test, test-api, test-stress-subprocs, and test-heap-teardown pass in a release build.

Background

  • A theap is a thread's view of a heap. A page belongs to one theap while that thread allocates from it. When the page is full, the theap abandons it: the page has no owner, and only a free into it can bring it back (_mi_page_abandon, mi_free_try_collect_mt).
  • An abandoned page is "mapped" when it is in the heap's pages_abandoned bitmap, 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_mapped maps it once it drops below 7/8 used.
  • mi_heap_t and mi_arena_pages_t are 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.
  • Sub-processes (mi_subproc_new) own separate arenas and heaps. _mi_free_subproc_safe exists 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 dev3 has the same code (_mi_free_subproc_safe came with "wip: initial fixed subprocesses", 360d796, which replaced mi_free(arena_pages) and mi_free(heap) in mi_heap_free with the subproc-safe variant).

test-heap-burst-destroy output without the fix (MI_DEBUG_FULL build):

heap-burst-destroy: 200 live heaps x 20 rounds
  main heap in use: 34867 KiB before, 700007 KiB after (170276 B per destroyed heap)
  main heap in use after 4000 empty heaps: 725179 KiB (6444 B per destroyed heap)
  rss: 55096 KiB before, 719980 KiB after

With the fix:

heap-burst-destroy: 200 live heaps x 20 rounds
  main heap in use: 1610 KiB before, 1610 KiB after (0 B per destroyed heap)
  main heap in use after 4000 empty heaps: 1610 KiB (0 B per destroyed heap)
  rss: 55180 KiB before, 67468 KiB after

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::Arena is one mi_heap_new/mi_heap_destroy pair. 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 that pages_abandoned and RSS stay flat.

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