Skip to content

os: no address hint for OS allocations above 1 GiB - #34

Merged
Jarred-Sumner merged 1 commit into
oven-sh:bun-dev3-v2from
robobun:no-hint-for-huge-os-allocs
Sep 16, 2026
Merged

Jarred-Sumner merged 1 commit into
oven-sh:bun-dev3-v2from
robobun:no-hint-for-huge-os-allocs

Conversation

@robobun

@robobun robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Every new WebAssembly.Memory in bun 1.4.x on Linux leaks about 4 KiB of RSS that is never returned (Every new WebAssembly.Memory leaks ~4 KB of RSS that GC never returns (1.4.0 through 1.4.3-canary; 1.3.14 is flat) bun#41459). Bun 1.3.14, on mimalloc v2 and libpas, was flat. A release build of bun main with the current pin (2014682) still ends the issue's repro at +85 MB after 20000 memories.
  • A Wasm fast-memory reservation is a 4 GiB mi_malloc_aligned. Blocks above arena_max_object_size are mapped from the OS directly, and _mi_os_get_aligned_hint gives each one an address from a counter that only moves forward. Each new 512 MiB region of the 2-level page map allocates a 64 KiB sub-map (mi_page_map_alloc_submap_at), and sub-maps are only freed in _mi_page_map_unsafe_destroy. 64 KiB of committed memory, 4 KiB of it resident, stays behind per alloc/free cycle.

Fix

  • Return NULL from _mi_os_get_aligned_hint for sizes above 1 GiB. The OS then picks the address and hands back the range that was just unmapped, so the same sub-map is reused.
  • mimalloc v2 had this cap unconditionally. Upstream 82c1688 (When mi_os_prim_alloc_aligned falls back on overallocation, it drops allow_large microsoft/mimalloc#1290) relaxed it so that arena reservations above 1 GiB keep a hint, and v3 only has a 32 GiB cap in secure mode. The comment in the code points at that so the next dev3 sync resolves it on purpose.
  • The first version of this PR no longer applied: the dev3 sync of 2026-09-11 rewrote the function. This is the same cap on the new function, on top of bun-dev3-v2 (eab0901).
  • New test test/test-huge-cycle.c: it allocates and frees 6 blocks above mi_arena_max_object_size() (and above 1 GiB), two at a time, and counts the 512 MiB regions in which a block starts. Without the cap: 6 regions. With it: 2. Checked locally in Release, Release C++, Debug, Debug FULL, Secure, Guarded, and with Bun's defines (MI_FREE_USE_PAGEMAP=1). It is left out of sanitizer builds: a debug build reads every fresh block back byte by byte, which is too slow for blocks above 1 GiB under a sanitizer.

Verification

  • Standalone loop against src/static.c with Bun's defines (MI_STATIC_LIB, MI_BUILD_RELEASE, MI_FREE_USE_PAGEMAP=1, MI_DEFAULT_ALLOW_THP=0), 2000 cycles of mi_malloc_aligned(4 GiB + 64 KiB, 4096) + mi_free. Before: +9168 kB RSS and +129024 kB committed (64.5 KiB per cycle). After: +0 kB, the same address every cycle. With 200 blocks live at a time over 20000 cycles: +152 kB RSS. 1 GiB blocks show no growth either way.
  • Release build of bun main (b841a68a6) pinned to this commit (deps: mimalloc to the head of bun-dev3-v2 (oven-sh/mimalloc#34, #52): no address hint for OS allocations above 1 GiB bun#41512): the issue's repro goes from +85.0 MB to +4.4 MB after 20000 memories, idle and gc.
  • ctest passes here (35 of 35, Release).

robobun added a commit to oven-sh/bun that referenced this pull request Sep 6, 2026
Bumps oven-sh/mimalloc from 6a64e1b to cc71a21 (oven-sh/mimalloc#34).

Each WebAssembly.Memory reserves 4 GiB of address space through
mi_malloc_aligned. mimalloc placed every huge allocation at a new
hinted address, and each new 512 MiB region of the 2-level page map
cost a 4 KiB sub-map that is never freed. The fork commit skips the
hint above 1 GiB so the OS reuses the range that was just unmapped.

Fixes #41459
A block above arena_max_object_size is mapped from the OS and unmapped
again on free. _mi_os_get_aligned_hint gave each of those mappings an
address from a counter that only moves forward, so each one landed in a
512 MiB part of the address space that the page map had not seen before.
The 2-level page map commits a 64 KiB sub-map for each such part and
keeps it until the process ends: 64 KiB committed and 4 KiB resident per
alloc/free cycle. JavaScriptCore reserves 4 GiB this way for every
WebAssembly memory (oven-sh/bun#41459).

Return no hint above 1 GiB, as mimalloc v2 did. The OS then gives the
range that was just unmapped to the next mapping of that size, and the
sub-map is used again. Upstream relaxed the v2 cap in 82c1688
(microsoft#1290). v3 only has a cap of 32 GiB, in secure mode.

test-huge-cycle counts the 512 MiB parts in which 6 huge blocks start:
6 without the cap, 2 with it.
@robobun
robobun force-pushed the no-hint-for-huge-os-allocs branch from cc71a21 to 7b4eb15 Compare September 16, 2026 02:27
@Jarred-Sumner
Jarred-Sumner merged commit 3a13a5f into oven-sh:bun-dev3-v2 Sep 16, 2026
23 checks passed
robobun added a commit to oven-sh/bun that referenced this pull request Sep 16, 2026
Bumps oven-sh/mimalloc from 2014682 to 7b4eb15, the head of
oven-sh/mimalloc#34. That is bun-dev3-v2 (which adds oven-sh/mimalloc#52
over the current pin) plus one commit.

Each WebAssembly.Memory reserves 4 GiB of address space through
mi_malloc_aligned. mimalloc placed every huge allocation at a new
hinted address, and each new 512 MiB region of the 2-level page map
cost a 64 KiB sub-map, 4 KiB of it resident, that is never freed. The
fork commit skips the hint above 1 GiB so the OS reuses the range that
was just unmapped.

Fixes #41459
robobun added a commit to oven-sh/bun that referenced this pull request Sep 16, 2026
… no address hint for OS allocations above 1 GiB

Bumps oven-sh/mimalloc from 2014682 to 3a13a5f.

- os: no address hint for OS allocations above 1 GiB (oven-sh/mimalloc#34)
- page: a collect that is not forced leaves a large page with no block in
  use to the sweeps (oven-sh/mimalloc#52)

Each WebAssembly.Memory reserves 4 GiB of address space through
mi_malloc_aligned. mimalloc placed every huge allocation at a new
hinted address, and each new 512 MiB region of the 2-level page map
cost a 4 KiB sub-map that is never freed. Skipping the hint above
1 GiB lets the OS reuse the range that was just unmapped.

Fixes #41459
robobun added a commit to oven-sh/bun that referenced this pull request Sep 30, 2026
…its schedule

The event loop hands the heaps of its thread to mimalloc's scavenger around
every poll (us_loop_run_bun_tick). Each hand-off woke the scavenger: a futex
wake on the loop thread and a turn of the scavenger, which found that the
thread was not due. mimalloc paced the sweeps of a thread (100 ms) and not
its wakes.

Bump mimalloc to oven-sh/mimalloc#57 (which contains oven-sh/mimalloc#56).
The scavenger puts a thread on its schedule when it passes a park of that
thread over inside its window, and a park of a thread on the schedule
issues no wake. The call in the event loop does not change.

- packages/bun-usockets: the comment at the hand-off said that it costs a
  compare-and-swap per tick. It says what it costs now, and why a tick whose
  poll cannot block hands off too.
- src/mimalloc_sys: the same for the doc comment of mi_on_thread_idle_start.
- test/js/bun/jsc/heapStats-mimalloc.test.ts: 500 turns of a 1 ms timer put
  the scavenger thread to sleep 14 to 32 times. It was once for each turn.
- bench/snippets/event-loop-turn.mjs: CPU time and sleeps of the threads for
  one turn of the loop, by the kind of turn.

The pin also carries oven-sh/mimalloc#34, which is on bun-dev3-v2.
robobun added a commit to oven-sh/bun that referenced this pull request Sep 30, 2026
…its schedule

The event loop hands the heaps of its thread to mimalloc's scavenger around
every poll (us_loop_run_bun_tick). Each hand-off woke the scavenger: a futex
wake on the loop thread and a turn of the scavenger, which found that the
thread was not due. mimalloc paced the sweeps of a thread (100 ms) and not
its wakes.

Bump mimalloc to oven-sh/mimalloc#57 (which contains oven-sh/mimalloc#56).
The scavenger puts a thread on its schedule when it passes a park of that
thread over inside its window, and a park of a thread on the schedule
issues no wake. The call in the event loop does not change.

- packages/bun-usockets: the comment at the hand-off said that it costs a
  compare-and-swap per tick. It says what it costs now, and why a tick whose
  poll cannot block hands off too.
- src/mimalloc_sys: the same for the doc comment of mi_on_thread_idle_start.
- test/js/bun/jsc/heapStats-mimalloc.test.ts: 500 turns of a 1 ms timer put
  the scavenger thread to sleep 15 to 26 times. It was once for each turn.
- bench/snippets/event-loop-turn.mjs: CPU time and sleeps of the threads for
  one turn of the loop, by the kind of turn.

The pin also carries oven-sh/mimalloc#34, which is on bun-dev3-v2.
robobun added a commit to oven-sh/bun that referenced this pull request Oct 2, 2026
…its schedule

The event loop hands the heaps of its thread to mimalloc's scavenger around
every poll (us_loop_run_bun_tick). Each hand-off woke the scavenger: a futex
wake on the loop thread and a turn of the scavenger, which found that the
thread was not due. mimalloc paced the sweeps of a thread (100 ms) and not
its wakes.

Bump mimalloc to oven-sh/mimalloc#57 (which contains oven-sh/mimalloc#56).
The scavenger puts a thread on its schedule when it passes a park of that
thread over inside its window, and a park of a thread on the schedule
issues no wake. The call in the event loop does not change.

- packages/bun-usockets: the comment at the hand-off said that it costs a
  compare-and-swap per tick. It says what it costs now, and why a tick whose
  poll cannot block hands off too.
- src/mimalloc_sys: the same for the doc comment of mi_on_thread_idle_start.
- test/js/bun/jsc/heapStats-mimalloc.test.ts: 500 turns of a 1 ms timer put
  the scavenger thread to sleep 15 to 26 times. It was once for each turn.
- bench/snippets/event-loop-turn.mjs: CPU time and sleeps of the threads for
  one turn of the loop, by the kind of turn.

The pin also carries oven-sh/mimalloc#34, which is on bun-dev3-v2.
robobun added a commit to oven-sh/bun that referenced this pull request Oct 2, 2026
…its schedule

The event loop hands the heaps of its thread to mimalloc's scavenger around
every poll (us_loop_run_bun_tick). Each hand-off woke the scavenger: a futex
wake on the loop thread and a turn of the scavenger, which found that the
thread was not due. mimalloc paced the sweeps of a thread (100 ms) and not
its wakes.

Bump mimalloc to oven-sh/mimalloc#57 (which contains oven-sh/mimalloc#56).
The scavenger puts a thread on its schedule when it passes a park of that
thread over inside its window, and a park of a thread on the schedule
issues no wake. The call in the event loop does not change.

- packages/bun-usockets: the comment at the hand-off said that it costs a
  compare-and-swap per tick. It says what it costs now, and why a tick whose
  poll cannot block hands off too.
- test/js/bun/jsc/heapStats-mimalloc.test.ts: 500 turns of a 1 ms timer put
  the scavenger thread to sleep 15 to 26 times. It was once for each turn.
- bench/snippets/event-loop-turn.mjs: CPU time and sleeps of the threads for
  one turn of the loop, by the kind of turn.

The pin also carries oven-sh/mimalloc#34, which is on bun-dev3-v2.
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.

2 participants