Skip to content

page: the first purge_holes_large_floor bytes of free blocks in large pages stay - #48

Closed
Jarred-Sumner wants to merge 1 commit into
bun-dev3-v2from
claude/purge-holes-large-floor
Closed

Jarred-Sumner wants to merge 1 commit into
bun-dev3-v2from
claude/purge-holes-large-floor

Conversation

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

After #46, an idle sweep takes every free block of a large page two epochs after the last allocation from it. A server that gets a request now and then takes those buffers again after that, and faults every OS page of them in again, on every request.

Now the first purge_holes_large_floor bytes of free blocks in large pages stay (default 8 MiB, 0 = none, MIMALLOC_PURGE_HOLES_LARGE_FLOOR in KiB). What is above the floor goes as before.

  • Whole pages: a large page keeps its free blocks if they fit under what is left of the floor.
  • Process-wide: a pool of threads keeps 8 MiB together, not each. A tld gives its share back when its next sweep begins, when it goes away, and in a forked child.
  • Such a page does not set holes_sweep_deferred, so nothing comes back for it.
  • The option is last in the enum: nothing is renumbered.

The cost this removes

Bun.serve, body in = body out, 1 connection, Linux x64, bun 368a6f728 (before #46) vs 7d792a538 (with #46):

body rate p50 before #46 p50 with #46 faults/request before faults/request with #46
128 KiB 5 req/s 0.26 ms 0.51 ms 1 115
256 KiB 5 req/s 0.26 ms 0.65 ms 1 195
512 KiB 5 req/s 0.63 ms 1.26 ms 91 375
256 KiB 50 req/s 0.19 ms 0.19 ms 3 12
256 KiB 500 req/s 0.15 ms 0.15 ms 10 7

Bodies under 128 KiB and of 1 MiB and more are equal at every rate. The memory that #46 gives back in these cases is 3 to 4 MB, which is under the floor.

Tests

  • test-purge-holes-large: new floor case. 60 blocks of 320 KiB, 40 freed, floor 4 MiB: 32 purged and 2.5 MiB kept; a second sweep gives the same; all 40 purged with floor 0; the share of a thread that ends comes back.
  • The other cases of test-purge-holes-large and test-purge-holes set the floor to 0, as they do for the interval.
  • macOS arm64: debug 31/31, release 32/32. Linux x64 release: 32/32.

Not measured yet in a bun build: that needs the MIMALLOC_COMMIT bump.

… pages stay

After the hold of two epochs, an idle sweep took every free block of a large
page. A server with a request now and then (5 requests a second, bodies of
128 KiB to 512 KiB) faulted its buffers in again on every request: twice the
median latency for 4 MiB of memory.

Now the first purge_holes_large_floor bytes (8 MiB, 0 for none) stay, a whole
page at a time. The count is process-wide so that a pool of threads keeps 8 MiB
together and not each. A tld gives its share back when its next sweep begins
and when it goes away.
Jarred-Sumner added a commit that referenced this pull request Sep 15, 2026
…lone for a while

Since the idle sweep discards the free blocks of large pages (#46), a thread that handles a request now and then
takes every buffer of 96 KiB and up out of discarded memory again: once the two epochs of the hold are over the
blocks go, and a server with five requests a second pays a page fault for every OS page of every buffer on every
request.

purge_holes_large_floor (4 MiB) bytes of such blocks, process-wide, are now left where they are while their page was
allocated from in the last purge_holes_large_floor_epochs (256) epochs of the sweep.
- The budget is one atomic sum. A thread gives its share back when its next sweep decides again, when it exits, and in
  the child of a fork; what it kept and has used since counts until then (stale on the high side only).
- The pages of one sweep are decided together at the end of its pass over the thread's own pages, the ones that were
  used last first. Abandoned pages come after that for what is left; one that stays says in swept_state which thread
  counts it, so that the other threads that sweep its heap leave it alone and do not count it again.
- Only blocks on the free list count: the part of a page that was never formed was never written to, and it is
  discarded as before.
- The age of a page is the epoch of its last allocation, which is in swept_state already. An epoch ends only when a
  thread is swept, so there is no clock in the page and nothing is woken for this: a process in which no thread parks
  any more keeps those bytes until one does. There is no floor without an interval to count epochs in.
- A thread that stays parked is looked at again by the scavenger once what it kept is old enough to go, which is when
  the sweeps of other threads have moved the epoch that far: it does not hold the floor against the threads that run.

The idea of a floor under what the sweeps take is from #48 (purge_holes_large_floor, there a flat number of bytes
that stay for good). This one counts what was written to, prefers what was used last, and lets go of it.
Jarred-Sumner added a commit that referenced this pull request Sep 15, 2026
…lone for a while

Since the idle sweep discards the free blocks of large pages (#46), a thread that handles a request now and then
takes every buffer of 96 KiB and up out of discarded memory again: once the two epochs of the hold are over the
blocks go, and a server with five requests a second pays a page fault for every OS page of every buffer on every
request.

purge_holes_large_floor (4 MiB) bytes of such blocks, process-wide, are now left where they are while their page was
allocated from in the last purge_holes_large_floor_epochs (256) epochs of the sweep.
- The budget is one atomic sum. A thread gives its share back when its next sweep decides again, when it exits, and in
  the child of a fork; what it kept and has used since counts until then (stale on the high side only).
- The pages of one sweep are decided together at the end of its pass over the thread's own pages, the ones that were
  used last first. Abandoned pages come after that for what is left; one that stays says in swept_state which thread
  counts it, so that the other threads that sweep its heap leave it alone and do not count it again (until a thread
  that had a share ends: then the first sweeper to pass counts it).
- Only blocks on the free list count: the part of a page that was never formed was never written to, and it is
  discarded as before.
- The age of a page is the epoch of its last allocation, which is in swept_state already. An epoch ends only when a
  thread is swept, so there is no clock in the page and nothing is woken for this: a process in which no thread parks
  any more keeps those bytes until one does. There is no floor without an interval to count epochs in.
- A thread that stays parked is looked at again by the scavenger once what it kept is old enough to go, which is when
  the sweeps of other threads have moved the epoch that far: it does not hold the floor against the threads that run.

The idea of a floor under what the sweeps take is from #48 (purge_holes_large_floor, there a flat number of bytes
that stay for good). This one counts what was written to, prefers what was used last, and lets go of it.
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

#50 takes the idea of this PR (a floor under what the idle sweeps take from large pages) further, and is meant to replace it. What it does differently, from measuring this branch in a real server (Bun.serve echoing 256 KiB bodies, 5 requests a second; minor faults per request: 125 on bun-dev3-v2, 94–105 with this PR at 8 MiB, 4.5 with #50 at 4 MiB):

  • mi_page_large_resident_free counts the part of the page that was never formed. A 4 MiB page with two free blocks of 256 KiB counts for about 3.7 MiB, so two or three pages use up the 8 MiB with memory that nobody ever wrote to. page: a few MB of recently used large pages are left alone for a while (no timer) #50 counts only blocks on the free list.
  • Most of the faults are not free blocks in a page that is still in use: they are large pages with no block in use, which are freed where their last block is freed or by the sweep's collect, and made and faulted in again at the next request. A floor on free blocks does not reach those; page: a few MB of recently used large pages are left alone for a while (no timer) #50 holds such pages by the same rule.
  • A flat floor stays for good: in a large bundled CLI application parked at its prompt this PR costs 1–2 MB for ever (97 MB against 95–96 MB anonymous). In page: a few MB of recently used large pages are left alone for a while (no timer) #50 a page stays only while it was allocated from in the last 256 epochs of the sweep (no clock, nothing is woken), and the pages that were used last get the floor first.
  • One trap for whoever tests either: MIMALLOC_PURGE_HOLES_LARGE_FLOOR=8192 in the environment is 8192 bytes (a bare number is bytes for every size option); 8192KiB or 8MiB is what the description means.

Numbers and the rule are in #50.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

Closing as superseded by #50 (merged; its message credits this PR for the idea). #50 keeps the same thing this did, a small budget of large-page memory that the hole purge leaves alone, but ages it in sweep epochs (no timer), prefers the most recently used pages, counts only blocks that are actually on a free list, and covers wholly free large pages, which is where most of the refaults came from. Measured on a 5 req/s server with 256 KiB bodies: 124 -> 2.5 page faults per request with #50, against about a 20% reduction with this version. The branch is left in place.

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