Repository navigation
arena: a zero-claim purge debits the committed stat the next allocation credits again (re-land of 74086412) - #41
Open
robobun wants to merge 2 commits into
Conversation
…xt allocation credit again On an OS with overcommit, `mi_arena_reserve` does not count an eagerly committed arena as committed. `mi_arena_try_alloc_at` credits `committed` for a fully committed range by the slices it hands out for the first time, which it reads from the dirty bits. A zero-claim purge (`MADV_DONTNEED` on Linux) keeps the range committed, so `_mi_os_purge_zero` debits nothing, and then clears the dirty bits. The next allocation of the range credits the same slices again. Every purge/reuse cycle grew `committed` by the size of the range, forever: 16 x 1 MiB blocks, freed and taken again, read 18 MiB, then 35, then 51, 149 MiB after 8 rounds. bun printed 28 GB "committed" against 0.6 GB resident after 8.8 days (oven-sh/bun#42652), and test-stress showed 15 GiB against a 171 MiB peak RSS. The purge now debits the slices it un-dirties, under the condition the allocation uses to credit them: the range stays committed, was fully committed, and the OS has overcommit. An allocation that does not ask for commit (`page_commit_on_demand`) on a range that is committed already did not credit its slices, and nothing commits them on demand later. With the debit alone that walks `committed` below zero (13eecae reverted 7408641, the debit alone, for a debit without a credit). It credits them now, through the same `mi_arena_stat_touched`. test/test-purge-committed.c takes 16 blocks of 1 MiB, 64 KiB, and 4 MiB through 8 purge/reuse cycles each: `committed` has to read the same at the top of every cycle, come back to where it started after the purge (Linux), and never go negative. It runs once with the defaults and once with `MIMALLOC_PAGE_COMMIT_ON_DEMAND=1`. Before: 3 of 3 cycles fail (149 MiB, 159 MiB, 749 MiB after 8 rounds). After: all pass, in Release and Debug, with `MIMALLOC_ARENA_EAGER_COMMIT=0` too. test-stress: peak commit 244 MiB, peak RSS 173 MiB.
…it bits alone The debit in `mi_arena_purge` mirrors the credit of `mi_arena_stat_touched`, but the credit was conditional on the range being fully committed and the debit was not. In an arena that is not committed up front (`MIMALLOC_ARENA_EAGER_COMMIT=0`), or after a purge that decommits (a Debug build), a range can be committed in part: the commit counts the slices it commits, the slices that were committed and purged before it count nothing, and the purge of the whole range then debits all of them. Blocks of 1, 2, 4, ... 32 MiB, each freed and purged before the next, read 18 MiB below zero after six rounds. Such an arena is now counted on its commit bits alone, as upstream does: `mi_arena_stat_on_dirty` gates both the credit and the debit on `memid.initially_committed`. For an eagerly committed arena, a range that is committed in part counts the clean slices among its committed ones at the allocation, and an allocation that takes such a range without commit stops counting only the slices that were counted. `test-purge-committed` starts with the growing blocks on a fresh arena, and ctest runs it with `MIMALLOC_ARENA_EAGER_COMMIT=0` too.
robobun
added a commit
to oven-sh/bun
that referenced
this pull request
Sep 13, 2026
robobun
added a commit
to oven-sh/bun
that referenced
this pull request
Sep 13, 2026
…imalloc#41) On Linux mimalloc counts an arena slice as committed the first time it hands it out. A purge returns the slice to the OS with MADV_DONTNEED, which keeps it committed, and then forgets that it was handed out so that the next mi_zalloc can skip its memset. The next allocation counted the slice again, and nothing counted it down. `committed` grew by the size of every free-and-reuse cycle, forever: 16 x 1 MB freed and taken again 8 times read 18 MB, 36, 53, 141 MB against 30 MB resident. That is the number the crash report prints as `Commit` (28 GB after 8.8 days in #42652) and `heapStats().mimalloc.committed`. Bump to oven-sh/mimalloc ac33e6a7 (oven-sh/mimalloc#41, two commits on top of the ab135013 that #42571 pinned): the purge debits the slices it forgets, under the condition the allocation uses to credit them, and an arena that is not committed up front is counted on its commit bits alone. Test in test/js/bun/jsc/heapStats-mimalloc.test.ts: 8 rounds of 16 x 1 MB, freed and purged by the purge thread. `committed` has to hold one round's worth at the top of each round and come back down after the purge. Fails on 1.4.2 (35 MB after round 1, 141 MB after round 7), passes with the bump (20 MB held, 3 MB after).
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.
Re-land of 7408641 (reverted in 13eecae). Why the revert's objection no longer applies is under Fix.
Problem
committedstat grows by the size of every purge/reuse cycle and never comes down. 16 x 1 MiB blocks, freed and taken again, read 18 MiB, then 35, 51, and 149 MiB after 8 rounds. bun printed 28 GB "committed" against 0.6 GB resident after 8.8 days (abort() after 8.8 days in a standalone executable: Commit 28 GB with RSS 0.6 GB (4.4k spawns, idle-heavy) bun#42652). test-stress reported 15 GiB against a 171 MiB peak RSS.mi_arena_try_alloc_at(src/arena.c) credits a fully committed range by the slices it hands out for the first time (the dirty bits). A zero-claim purge (MADV_DONTNEED) keeps the range committed, so_mi_os_purge_zerodebits nothing, then clears the dirty bits. The next allocation credits the same slices again.Fix
mi_arena_purgedebits the slices it un-dirties, under the condition the allocation uses to credit them: the range stays committed, was fully committed, andmi_arena_stat_on_dirtyholds (an eagerly committed arena, on an OS with overcommit, not pinned).committedbelow zero: a slice debited twice and credited once. Two paths did that. An allocation that does not ask for commit (page_commit_on_demand) on a committed range credited nothing. And a range committed in part (MIMALLOC_ARENA_EAGER_COMMIT=0, or a Debug build, where a purge decommits) credited only the slices it committed while the purge debited the whole range. Both now credit what the purge debits: the first throughmi_arena_stat_touched, the second by counting the clean slices among the committed ones. An arena that is not committed up front is counted on its commit bits alone, as upstream does.test/test-purge-committed.c, run by ctest with the defaults,MIMALLOC_PAGE_COMMIT_ON_DEMAND=1, andMIMALLOC_ARENA_EAGER_COMMIT=0. Before: 4 of 4 checks fail. After: pass in Release and Debug (MI_DEBUG_FULL), full ctest passes in both. test-stress: peak commit 250 MiB, peak RSS 180 MiB.Background
mi_arena_reservemmaps an arena read-write up front on Linux (eager commit). The OS backs a page the first time it is written, so the reserve is not counted as committed. A slice is counted when it is handed out for the first time.slices_dirtyrecords which slices were handed out since the arena was mapped or since the last zero-claim purge. The purge clears it so that the nextmi_zallocof the range can skip its memset.slices_committedis the commit state. On Linux release it is set for the whole of an eager arena at reserve time and stays set:MADV_DONTNEEDneeds no recommit. The decommit path debits on that bitmap, so it debits nothing here.Notes
The stat for a slice of an eagerly committed arena under overcommit reads: counted if the commit bit and the dirty bit are both set. Every transition of that pair is accounted for:
mi_arena_stat_touched, all four allocation branches)._mi_os_commit_ex, and the commit bits set at page free for a page committed on demand)._mi_os_purge_*when it decommits, and the no-commit allocation of a range committed in part, which now skips the slices that were not counted).An arena that is not committed up front is counted on its commit bits alone. On Linux release a purge keeps those set, so its stat is a high-water mark: bounded by the arena size, never below zero. The test requires the stat to come back down after a purge only where it can: Linux, with eager commit.
(A) onlyrun,MIMALLOC_PAGE_COMMIT_ON_DEMAND=1:First revision of this PR,
MIMALLOC_ARENA_EAGER_COMMIT=0, growing blocks: