Skip to content

mimalloc: take destroyed blocks back off the malloc_* statistics in mi_heap_destroy - #39076

Open
robobun wants to merge 1 commit into
mainfrom
farm/3c16ae0e/mimalloc-heap-destroy-stats
Open

mimalloc: take destroyed blocks back off the malloc_* statistics in mi_heap_destroy#39076
robobun wants to merge 1 commit into
mainfrom
farm/3c16ae0e/mimalloc-heap-destroy-stats

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • On debug builds, require("bun:jsc").heapStats().mimalloc.malloc_normal.current (and malloc_bins[i].current, malloc_huge.current) only ever grows in any workload that creates and destroys mimalloc heaps, even though the memory is freed. 10 Bun.Transpiler#transformSync calls: malloc_normal.current +9,620,240, malloc_huge.current +23,592,960, malloc_bins +27,710 blocks, while heaps.current is unchanged and heapStats({ dump: true }) shows nothing left alive. Bun destroys a heap per transpile, bundle and parse (bun_alloc::MimallocArena drop/reset is mi_heap_destroy), so the counter is useless for leak hunting: a dev server leak was measured at +2.2 MB per rebuild with it, of which about 2 MB was this.
  • Cause: mi_heap_destroy frees every page of the heap through mi_heap_delete_page (vendor/mimalloc/src/arena.c:2714, the heap_target == NULL branch), which sets page->used = 0 and frees the page. The blocks that were still in use never go through mi_free, and free.c:mi_stat_free is the only place the increments made at allocation (alloc.c:mi_page_malloc_zero, page.c:mi_huge_page_alloc) are taken back off. The leftovers stay in heap->stats, and mi_heap_free merges them into the main heap's stats (heap.c:207), which is what mi_stats_get_json reports from then on.
  • Upstream dev3 has the same gap; mimalloc v2's destroy path (_mi_heap_page_destroy) did adjust these counters.
  • Only debug builds are affected: the counters are maintained only when mimalloc is built with statistics (MI_STAT, which MI_DEBUG turns on); release builds report 0 for them.

Fix

  • patches/mimalloc/heap-destroy-malloc-stats.patch, applied to the pinned oven-sh/mimalloc commit (6e891cbe, the fork's current head) through the existing patches: mechanism. I can't push to the fork, so this takes the same route as mimalloc: fold every thread's theap into the subproc stats aggregate #34739; the patch is to be dropped when the pin moves to a fork commit that carries the fix (the patches: comment says so, and git apply will most likely refuse it at that point anyway). Hunk-wise it is independent of mimalloc: fold every thread's theap into the subproc stats aggregate #34739 (arena.c here, stats.c there); only the patches: line in mimalloc.ts will need a trivial merge.
  • The destroy branch now collects the page (_mi_page_free_collect_no_unpurge) and then decrements malloc_normal by usable block size * used and malloc_bins[bin] by used (or malloc_huge by the block size for a huge page) on heap->stats.
  • Why this is right: it is the exact inverse of what allocation added for each block, using the same sizes and bins as mi_stat_free, so each block's increment is now undone exactly once: by mi_free if it was freed, by the destroy otherwise. The collect first is what makes used mean "never freed": a block freed from another thread sits on the page's thread-free list with used still counting it, but mi_stat_free already accounted for it on the freeing thread. The adjustment goes to heap->stats because the heap's theaps have already been freed and merged into it when the pages are visited (mi_heap_free_theaps runs first in _mi_heap_force_destroy), which is also where this path already accounts for the page counters (_mi_arenas_page_free with theap == NULL). The whole addition is under #if MI_STAT > 0, like mi_stat_free, so release builds compile the same code as before. malloc_requested is deliberately left alone: mi_free does not decrement it either, it is cumulative by design.
  • Test: test/js/bun/jsc/heapStats-mimalloc.test.ts, "malloc_* counters come back down when a heap is destroyed". Runs 10 transformSync calls, checks via heaps.total/heaps.current that a heap was created and destroyed per call, and bounds the growth of all three counters below what a single call used to leave behind. Skipped on release builds, where the counters are not compiled in.
  • Fail before / pass after, on debug builds of the same source with and without the patch (the release binary cannot show this bug, so USE_SYSTEM_BUN=1 skips the test instead of failing it):
    • without the patch: malloc_normal +9,620,240 (test fails there; the malloc_huge +23,592,960 and malloc_bins +27,710 assertions fail independently as well), identical across 3 runs
    • with the patch: all three deltas are exactly 0 across 3 runs; the test passes
    • the handoff's repro (transformSync x 50): +10.9 MB before, 0 after; a Worker that transpiles and exits: +406 KB per Worker before, flat after
    • test/js/bun/jsc/ with and without the patch: same results apart from the new test (the remaining failures in both are DOMJIT tests hitting their 5 s timeout on a heavily loaded box)
  • Nothing in this PR lives under src/, so the stash-based fail-before check has nothing to stash; the proof is the unpatched debug build above.

Background

  • A mimalloc heap (mi_heap_t) is a separate set of pages. mi_heap_destroy releases all of its pages at once without freeing blocks individually; Bun's bun_alloc::MimallocArena wraps one heap and calls it from Drop and reset().
  • A theap is a thread's view of a heap; allocations update the stats of the allocating thread's theap. When a heap is destroyed its theaps are merged into heap->stats, and heap->stats into the main heap's stats, which heapStats().mimalloc (mi_stats_get_json) aggregates.
  • MI_STAT is mimalloc's compile-time statistics level: 2 in Bun's debug builds (MI_DEBUG=3 in scripts/build/deps/mimalloc.ts), 0 in release. malloc_normal and malloc_huge are maintained at level 1 and up, malloc_bins at level 2.
  • page->used counts blocks handed out and not yet collected back. A block freed by a thread other than the page's owner is pushed onto the page's thread-free list and only leaves used when the owner collects the page; its statistics are adjusted at free time regardless.
Numbers behind the test bounds (debug build, 10 calls of the test's source)
unpatched: {"heaps_created":10,"heaps_alive_delta":0,"malloc_normal_delta":9620240,"malloc_huge_delta":23592960,"malloc_bins_blocks_delta":27710}
patched:   {"heaps_created":10,"heaps_alive_delta":0,"malloc_normal_delta":0,"malloc_huge_delta":0,"malloc_bins_blocks_delta":0}

Per call that is about 940 KiB of normal blocks, 2.25 MiB of huge blocks and 2,770 blocks; the test bounds are 512 KiB, 2 MiB and 2,000. A JS-only loop with Bun.gc(true) before each reading moves none of the three counters, so the slack is only for things the process legitimately keeps across the loop.

Observed along the way and left alone here: committed.current goes negative (-1 MB to -27 MB) once transpiler.test.js has run in the same process, with and without this patch, which makes the file's first test fail when the two files share a process (CI runs them separately). It is a subproc-level counter this patch does not touch.

…i_heap_destroy

Blocks that are still in use when their heap is destroyed are freed with
their page in mi_heap_delete_page and never go through mi_free, so the
malloc_normal / malloc_bins / malloc_huge increments made when they were
allocated stayed behind and were merged into the main heap's statistics.
Every bun_alloc::MimallocArena that went out of scope therefore grew
heapStats().mimalloc.malloc_normal.current by its live bytes (about 940 KiB
per Bun.Transpiler#transformSync call on a debug build), although the
memory itself was freed.

Applied as a patch on the pinned oven-sh/mimalloc commit; the destroy
branch now collects the page and decrements the counters for the blocks
that are still in use, mirroring free.c:mi_stat_free. Only MI_STAT builds
(debug) compile the new code; release builds are unchanged.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 11 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b8f4a0cb-7c4e-4407-9279-3321a396b4ca

📥 Commits

Reviewing files that changed from the base of the PR and between 88a6398 and ed3c3a1.

📒 Files selected for processing (3)
  • patches/mimalloc/heap-destroy-malloc-stats.patch
  • scripts/build/deps/mimalloc.ts
  • test/js/bun/jsc/heapStats-mimalloc.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced on a debug build of main (bun bd, mimalloc pin 6e891cbe): 10 Bun.Transpiler#transformSync calls leave heapStats().mimalloc at malloc_normal.current +9,620,240, malloc_huge.current +23,592,960 and +27,710 blocks across malloc_bins, with heaps.current unchanged and nothing left in the live dump. The release binary cannot show it (the counters are not compiled in there), so the new test is skipped under USE_SYSTEM_BUN=1 and the fail-before run is the unpatched debug build.
  • Fix: patches/mimalloc/heap-destroy-malloc-stats.patch (debug-only code path, see the description). With it the same loop moves all three counters by exactly 0.
  • Test: test/js/bun/jsc/heapStats-mimalloc.test.ts, "malloc_* counters come back down when a heap is destroyed": fails on the unpatched debug build, passes with the patch.
  • Nothing in this PR is under src/, so the automated stash-based fail-before check has nothing to stash for it.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this and found no bugs — the stat decrements mirror mi_stat_free exactly (same bsize <= MI_LARGE_MAX_OBJ_SIZE split, same mi_page_usable_block_size/mi_page_block_size sizing, same _mi_bin), the collect-before-count handles the thread-free double-accounting case, and the whole block is under #if MI_STAT>0 so release builds are unchanged. Still, this patches the allocator's heap-destroy path and adds a _mi_page_free_collect_no_unpurge call there; a maintainer familiar with the mimalloc fork should give it a look (and decide whether it should land in oven-sh/mimalloc directly rather than as a build-time patch).

What was reviewed:

  • Patch context matches the pinned arena.c at 2714; mi_heap_stat_decrease((mi_heap_t*)heap, ...) and the const-cast are the existing pattern in the same function.
  • Verified mi_heap_free_theaps runs before _mi_heap_destroy_pages in _mi_heap_force_destroy, so writing to heap->stats (not a theap) is correct.
  • Test: bounds are well below the single-call unpatched deltas, precondition is asserted via heaps.total/heaps.current, and skipIf(!tracksMallocStats) handles release builds.
Extended reasoning...

Overview

Adds a build-time patch to the vendored mimalloc (patches/mimalloc/heap-destroy-malloc-stats.patch, wired via patches: in scripts/build/deps/mimalloc.ts) that makes mi_heap_delete_page's destroy branch decrement malloc_normal / malloc_bins / malloc_huge for the blocks it destroys without going through mi_free. Also adds a regression test in test/js/bun/jsc/heapStats-mimalloc.test.ts that drives 10 transformSync calls (each creates and destroys a MimallocArena) and bounds the counter growth.

Security risks

None identified. This is debug-build-only allocator statistics accounting; the added code is entirely inside #if (MI_STAT>0), which is off in release builds. No user-controlled input reaches the new code.

Level of scrutiny

Medium-high. The effect is debug-diagnostics-only, but the mechanism is a patch inserted into mimalloc's mi_heap_destroy page-freeing path, and it adds a _mi_page_free_collect_no_unpurge(page, false) call there (to make page->used reflect only never-freed blocks). I traced that: the page is owned and about to have used=0 set and be freed via _mi_arenas_page_free, so collecting the thread-free list first should be harmless — but this is exactly the kind of allocator-internals reasoning a maintainer who owns the fork should confirm. I cross-checked the decrement logic against free.c:mi_stat_free (lines 727–741), alloc.c:mi_page_malloc_zero (63–76), and page.c:mi_huge_page_alloc (1939): the patch is the exact batched inverse.

Other factors

  • The patches: mechanism is established (libarchive, boringssl, lsquic, etc. all use it), and the PR follows the repo's convention of a small patch with an explanatory header. No upstream issue link is included in the header, though the description notes upstream dev3 has the same gap.
  • The test follows repo conventions (Buffer.alloc(n, fill).toString() instead of repeat, warm-up call for lazy state, Bun.gc(true) before each reading, skipIf for the release-build case where the counters aren't compiled in). The bounds (512 KiB / 2 MiB / 2,000 blocks) are below what one unpatched call leaked (~940 KiB / 2.25 MiB / 2,770), so the test can't pass on an unpatched debug build.
  • The PR notes it will need a trivial patches: array merge with #34739 and that the patch should be dropped once the fix lands in the fork — a maintainer may prefer to land it there directly.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks. On the two open points:

  • Landing it in oven-sh/mimalloc directly: that is the better home, but I can't push there, so this PR carries it as a patch the same way mimalloc: fold every thread's theap into the subproc stats aggregate #34739 does; whoever next syncs the fork can fold it in. To make sure the patch does not outlive that (or silently keep applying on top of a differently shaped fork fix), I'm adding a scripts/build/workarounds.ts entry that trips configure as soon as the mimalloc pin moves off 6e891cbe, with the cleanup instructions, which is the convention that file documents for temporary patches.
  • Upstream reference: there is no upstream issue; the patch header describes the bug and notes that upstream dev3 has the same gap. The workarounds entry will point at this PR.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:05 AM PT - Aug 15th, 2026

@robobun, your commit ed3c3a1 is building: #98167

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:14 PM PT - Aug 15th, 2026

🔄 @robobun, the build for your commit ed3c3a1b (Build #98167) was cancelled — waiting for the next build...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant