Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions patches/mimalloc/heap-destroy-malloc-stats.patch
Original file line number Diff line number Diff line change
@@ -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);
6 changes: 6 additions & 0 deletions scripts/build/deps/mimalloc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
48 changes: 48 additions & 0 deletions test/js/bun/jsc/heapStats-mimalloc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading