Skip to content

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
oven-sh:bun-dev3-v2from
robobun:robobun/93da80f4/committed-stat-purge-debit
Open

robobun wants to merge 2 commits into
oven-sh:bun-dev3-v2from
robobun:robobun/93da80f4/committed-stat-purge-debit

Conversation

@robobun

@robobun robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

Re-land of 7408641 (reverted in 13eecae). Why the revert's objection no longer applies is under Fix.

Problem

  • On Linux the committed stat 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_zero debits nothing, then clears the dirty bits. The next allocation credits the same slices again.

Fix

  • mi_arena_purge debits the slices it un-dirties, under the condition the allocation uses to credit them: the range stays committed, was fully committed, and mi_arena_stat_on_dirty holds (an eagerly committed arena, on an OS with overcommit, not pinned).
  • 13eecae reverted 7408641 because the debit walked committed below 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 through mi_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.
  • Verified: test/test-purge-committed.c, run by ctest with the defaults, MIMALLOC_PAGE_COMMIT_ON_DEMAND=1, and MIMALLOC_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_reserve mmaps 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_dirty records which slices were handed out since the arena was mapped or since the last zero-claim purge. The purge clears it so that the next mi_zalloc of the range can skip its memset.
  • slices_committed is the commit state. On Linux release it is set for the whole of an eager arena at reserve time and stays set: MADV_DONTNEED needs 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:

  • dirty 0 to 1 with the commit bit set: credit (mi_arena_stat_touched, all four allocation branches).
  • commit 0 to 1 with the dirty bit set: credit (_mi_os_commit_ex, and the commit bits set at page free for a page committed on demand).
  • dirty 1 to 0 with the commit bit kept: debit (the zero-claim purge).
  • commit 1 to 0 with the dirty bit set: debit (_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) only run, MIMALLOC_PAGE_COMMIT_ON_DEMAND=1:

  16 x 64 KiB round 0: held 1344 KiB, after purge 320 KiB (base 1344 KiB)
  16 x 64 KiB round 7: held -5824 KiB, after purge -6848 KiB (base 1344 KiB)

First revision of this PR, MIMALLOC_ARENA_EAGER_COMMIT=0, growing blocks:

  1 MiB: held +512 KiB, after -512 KiB
 16 MiB: held +640 KiB, after -15744 KiB

…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 robobun changed the title arena: a zero-claim purge debits the committed stat the next allocation credits again arena: a zero-claim purge debits the committed stat the next allocation credits again (re-land of 74086412) Sep 13, 2026
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).
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