From ed3c3a1b80c9aa310e68f6d13483e1faeb09e85f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:54:44 +0000 Subject: [PATCH] mimalloc: take destroyed blocks back off the malloc_* statistics in mi_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. --- .../mimalloc/heap-destroy-malloc-stats.patch | 46 ++++++++++++++++++ scripts/build/deps/mimalloc.ts | 6 +++ test/js/bun/jsc/heapStats-mimalloc.test.ts | 48 +++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 patches/mimalloc/heap-destroy-malloc-stats.patch diff --git a/patches/mimalloc/heap-destroy-malloc-stats.patch b/patches/mimalloc/heap-destroy-malloc-stats.patch new file mode 100644 index 000000000000..8629032deb5c --- /dev/null +++ b/patches/mimalloc/heap-destroy-malloc-stats.patch @@ -0,0 +1,46 @@ +mi_heap_destroy: undo the allocation statistics of the blocks it destroys. + +Every block allocation bumps malloc_normal (and, with MI_STAT>1, its +malloc_bins entry) or malloc_huge, and mi_free takes it back off again in +free.c:mi_stat_free. A block that is still in use when its heap is destroyed +is freed along with its page in arena.c:mi_heap_delete_page and never goes +through mi_free, so its increment stayed in the heap's stats forever and was +merged into the main heap's stats when the destroyed heap was freed. With a +short-lived heap per unit of work (bun_alloc::MimallocArena: one per +transpile, per bundle, per parse) heapStats().mimalloc.malloc_normal.current +grew by every destroyed heap's live bytes even though the memory was freed. +Upstream dev3 has the same gap; the v2 destroy path (_mi_heap_page_destroy) +did adjust these counters. This belongs in the oven-sh/mimalloc fork; it is +a patch here until the pin is bumped. + +--- a/src/arena.c ++++ b/src/arena.c +@@ -2715,6 +2715,28 @@ + #if MI_GUARDED + _mi_page_unguard_all(page); // remove potential interior guard pages + #endif ++ #if (MI_STAT>0) ++ // The blocks still in use are destroyed along with the page and never go through `mi_free` ++ // (`free.c:mi_stat_free`), so undo their allocation statistics here (the mirror image of ++ // `alloc.c:mi_page_malloc_zero` and `page.c:mi_huge_page_alloc`). Blocks that were free'd ++ // from other threads but not yet collected were already accounted for by `mi_stat_free`, ++ // so collect first to get an accurate `used` count. The theaps of this heap are already ++ // freed and merged at this point (`heap.c:mi_heap_free_theaps`), so update the heap statistics directly. ++ _mi_page_free_collect_no_unpurge(page, false); ++ const size_t inuse = page->used; ++ if (inuse > 0) { ++ const size_t bsize = mi_page_usable_block_size(page); ++ if (bsize <= MI_LARGE_MAX_OBJ_SIZE) { ++ mi_heap_stat_decrease((mi_heap_t*)heap, malloc_normal, bsize * inuse); ++ #if (MI_STAT>1) ++ mi_heap_stat_decrease((mi_heap_t*)heap, malloc_bins[_mi_bin(bsize)], inuse); ++ #endif ++ } ++ else { ++ mi_heap_stat_decrease((mi_heap_t*)heap, malloc_huge, mi_page_block_size(page) * inuse); ++ } ++ } ++ #endif + // destroy the page + page->used=0; // note: invariant `|local_free| + |free| == reserved - used` does not hold in this case + _mi_arenas_page_free(page, theap); diff --git a/scripts/build/deps/mimalloc.ts b/scripts/build/deps/mimalloc.ts index 1d08cc1d1b72..7799dc6d8b5b 100644 --- a/scripts/build/deps/mimalloc.ts +++ b/scripts/build/deps/mimalloc.ts @@ -24,6 +24,12 @@ export const mimalloc: Dependency = { commit: MIMALLOC_COMMIT, }), + // mi_heap_destroy frees the blocks still in use without taking them back off + // malloc_normal / malloc_bins / malloc_huge (MI_STAT builds, i.e. debug), so + // heapStats().mimalloc.malloc_normal.current grew by every destroyed + // MimallocArena's live bytes. Drop once the fix is in the pinned fork commit. + patches: ["patches/mimalloc/heap-destroy-malloc-stats.patch"], + build: cfg => { // ─── Override behavior (global malloc replacement) ─── // ASAN: OFF — ASAN interceptors must see the real malloc. diff --git a/test/js/bun/jsc/heapStats-mimalloc.test.ts b/test/js/bun/jsc/heapStats-mimalloc.test.ts index cefdd78762e5..eeeee7832007 100644 --- a/test/js/bun/jsc/heapStats-mimalloc.test.ts +++ b/test/js/bun/jsc/heapStats-mimalloc.test.ts @@ -64,6 +64,54 @@ describe("heapStats() mimalloc integration", () => { void before; }); + // The malloc_* counters are only maintained when mimalloc is built with statistics (MI_STAT, i.e. + // debug builds); release builds report 0 for all of them. + const tracksMallocStats = heapStats().mimalloc.malloc_normal.total > 0; + + test.skipIf(!tracksMallocStats)("malloc_* counters come back down when a heap is destroyed", () => { + // Bun.Transpiler#transformSync allocates everything for the call in a fresh mimalloc heap + // (bun_alloc::MimallocArena) and mi_heap_destroy()s it on return, so none of those blocks is + // ever passed to mi_free. The exports fill the heap with small blocks (malloc_normal and + // malloc_bins); a source this large also makes the call allocate blocks above mimalloc's large + // object limit (512 KiB), which are accounted separately in malloc_huge. + const source = + `/* ${Buffer.alloc(640 * 1024, "x").toString()} */\n` + + Array.from({ length: 300 }, (_, i) => `export const e${i} = { a: [${i}, "x"] };`).join("\n"); + const transpiler = new Bun.Transpiler(); + const iterations = 10; + + function counters() { + // Collect the JS garbage made by the previous heapStats() call so that only the transpiler's + // heaps are left in the deltas. + Bun.gc(true); + const m = heapStats().mimalloc; + return { + normalBytes: m.malloc_normal.current, + hugeBytes: m.malloc_huge.current, + binBlocks: m.malloc_bins.reduce((sum: number, bin: any) => sum + bin.current, 0), + heapsCreated: m.heaps.total, + heapsAlive: m.heaps.current, + }; + } + + transpiler.transformSync(source); // lazily-initialized state (the retained output buffer, ...) must not count + const before = counters(); + for (let i = 0; i < iterations; i++) transpiler.transformSync(source); + const after = counters(); + + // One heap per call, and none of them survived the call. + expect(after.heapsCreated - before.heapsCreated).toBeGreaterThanOrEqual(iterations); + expect(after.heapsAlive).toBe(before.heapsAlive); + + // Every destroyed heap used to leave its live blocks in the counters: about 940 KiB in + // malloc_normal, 2.25 MiB in malloc_huge and 2,770 blocks in malloc_bins per call (9.6 MB, + // 23.6 MB and 27,710 blocks after the loop). Each bound is below what a single call left + // behind; the slack is for whatever else the process legitimately keeps across the loop. + expect(after.normalBytes - before.normalBytes).toBeLessThan(512 * 1024); + expect(after.hugeBytes - before.hugeBytes).toBeLessThan(2 * 1024 * 1024); + expect(after.binBlocks - before.binBlocks).toBeLessThan(2000); + }); + // mimalloc tags its arena mmaps with an app-reserved VM tag (240-255). The old default, // 100, is VM_MEMORY_IOACCELERATOR, so profilers reported Bun's heap as GPU memory. test.skipIf(!isMacOS)("arena memory is tagged as application memory, not IOAccelerator", async () => {