Skip to content

unix: only opt mappings out of THP when the system setting is always - #22

Closed
robobun wants to merge 1 commit into
oven-sh:bun-dev3-v2from
robobun:farm/c8f9f0a1/thp-optout-only-when-always
Closed

unix: only opt mappings out of THP when the system setting is always#22
robobun wants to merge 1 commit into
oven-sh:bun-dev3-v2from
robobun:farm/c8f9f0a1/thp-optout-only-when-always

Conversation

@robobun

@robobun robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • With allow_thp off (bun builds with MI_DEFAULT_ALLOW_THP=0), unix_mmap calls madvise(MADV_NOHUGEPAGE) on every mapping it creates (src/prim/unix/prim.c:492).
  • The kernel only gives huge pages to a mapping that did not ask for them when /sys/kernel/mm/transparent_hugepage/enabled is [always]. Under [madvise] (Debian and Ubuntu default) and [never] it changes nothing and costs one syscall per mmap: 3 at process initialization (bun --version), then 1 per arena.

Fix

  • unix_detect_thp already reads that file at startup. It now also records whether the setting is [always] (unix_thp_needs_optout), and unix_mmap makes the call only then. No syscall is added.
  • If the file cannot be read (no sysfs), the flag keeps its initial value and the opt-out stays in place.
  • Trade-off: a per-size mTHP always (hugepages-*kB/enabled) under another top-level setting is no longer opted out. Reading those files costs about 3 syscalls per size, more than this saves. The kernel default for every size is never or inherit.
  • Verified: test-thp-optout (new) fails on bun-dev3-v2 on a [madvise] machine and passes with this change. [always] and a missing file still opt out (path redirected in a scratch copy). More runs in the notes.

Background

  • unix_detect_thp also feeds has_transparent_huge_pages. That flag only sets the purge granularity for allow_thp=2, so it keeps its meaning and the new flag is separate.
  • The test defines madvise itself, which also catches the calls made from the static library. The ctest entry sets MIMALLOC_ALLOW_THP=0 so the mappings made during initialization are counted too.
Notes
  • _mi_prim_mem_init runs in mi_process_init_once before the first mapping (page map, first arena), so the flag is set before unix_mmap reads it. Mappings made before that would still opt out because of the initial value.

Measured on linux x64, kernel 6.17, enabled = always [madvise] never, all mTHP sizes never (2048kB inherit):

  • test-thp-optout with MIMALLOC_ALLOW_THP=0, before: 3 MADV_NOHUGEPAGE call(s) at initialization and 1 per mi_reserve_os_memory. After: 0 and 0. With allow_thp on: 0 before and after.
  • Scratch copy with the sysfs path replaced by a temp file (not committed): [always] madvise never gives 3 and 1 (unchanged behavior), always [madvise] never and always madvise [never] give 0 and 0, file missing gives 3 and 1.
  • bun: a debug build with this prim.c copied into vendor/mimalloc and an LD_PRELOAD shim that logs MADV_NOHUGEPAGE: bun --version 3 calls to 0, bun -e 1 15 to 12. The release binary makes 7 for bun -e 1: 3 are mimalloc's, the others come from WTF OSAllocator::tryReserveUncommitted and from bun_alloc's bss arena, which are not touched here.
  • src/static.c compiles without warnings as C++ with bun's defines (clang), as C with gcc -Wpedantic, and with MADV_NOHUGEPAGE undefined.
  • Full ctest (Release, clang, -DMI_EXTRA_CPPDEFS=_GNU_SOURCE for the prof.c build issue noted in Keep the idle sweep's state on the tld instead of in __thread variables (fixes the Android SIGSEGV, oven-sh/bun#38051) #17): 18 of 21 pass. The 3 failures are the same with and without this change and are the ones listed in Keep the idle sweep's state on the tld instead of in __thread variables (fixes the Android SIGSEGV, oven-sh/bun#38051) #17: test-stress-subprocs (SIGSEGV), test-prof-adversarial (ld.so l_relocated assertion), test-purge-holes (the 2 unformed-tail subtests).
  • test-thp-optout also passes in a MI_TRACK_ASAN=ON build and in a Debug MI_DEBUG_FULL=ON build.
  • Requested in the syscall comparison thread (bun 1.3.14 vs canary), item on madvise(MADV_NOHUGEPAGE) per mimalloc mmap. Once merged, bun picks it up by bumping MIMALLOC_COMMIT in scripts/build/deps/mimalloc.ts.

With allow_thp off, unix_mmap called madvise(MADV_NOHUGEPAGE) on every
mapping it created. The kernel only backs a mapping that did not ask for
huge pages with them when /sys/kernel/mm/transparent_hugepage/enabled is
[always]. Under [madvise] (the Debian and Ubuntu default) and [never] the
call changes nothing and costs one syscall per mmap: three during process
initialization and one per arena after that.

unix_detect_thp already reads that file. It now also records whether the
setting is [always], and unix_mmap makes the madvise call only in that
case. When the file cannot be read (no sysfs), the opt-out stays in place.
The per-size mTHP settings are not read: that would take more syscalls
than it saves.

test-thp-optout interposes madvise, counts the MADV_NOHUGEPAGE calls the
allocator makes during initialization (the ctest entry runs it with
MIMALLOC_ALLOW_THP=0) and for a fresh reservation, and checks them against
the setting of the machine it runs on. With allow_thp on it expects none.
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Folded into #27, which combines #22#26 and replaces the per-reader fixes with one delete/destroy teardown protocol (pin-before-claim). The commit from this PR is carried there as-is.

Jarred-Sumner added a commit that referenced this pull request Aug 23, 2026
Heap delete/destroy: one teardown protocol (supersedes #22#26)
Jarred-Sumner added a commit to oven-sh/bun that referenced this pull request Aug 24, 2026
) (#40138)

### What does this PR do?

Bumps mimalloc to oven-sh/mimalloc#27, which combines
oven-sh/mimalloc#22#26 and replaces their per-reader fixes with one
teardown protocol for `mi_heap_delete` / `mi_heap_destroy`.

The problem those PRs were circling: a heap is torn down while a
concurrent cross-thread `mi_free`, or a thread that used the heap
earlier and still caches a theap for it, can reach it. The hole in the
middle was that the deleter claimed pages by *writing* to them
(`atomic_or` of the owned bit) with nothing pinning the page, so a
concurrent free could release the page and the slice be reused in
between. Now: detach theaps → abandon their pages as thread-exit does →
pin-then-claim every abandoned page (the same bitmap-as-pin protocol the
abandoned-page map already uses) → free theaps → free heap. Details,
contract and tests in the mimalloc PR.

Also in the bump: mimalloc#22 (THP opt-out only when the system setting
is `always` — saves a `madvise` per mmap on Debian/Ubuntu defaults),
from mimalloc#23 only the scavenger signal mask (a fault on that thread
produced no crash report), the scavenger thread starting lazily (a
single-threaded `bun -e` no longer spawns it: −1 thread, −24 syscalls),
and targeted upstream dev3 fixes (thread-locals-after-free guard, #1364,
#1371, NUMA node count). Upstream's in-progress page-meta layout rework
is deliberately *not* included.

**What this does not do:** fix the Windows corrupted-free-list crash
family (BUN-40BH and siblings). Those lists are written by Bun — #39897
(file read completing into a freed buffer, merged) and #39643 (poll
handle freed twice from a nested event loop, open) — and mimalloc is
only where the damage surfaces. #23's "validate links and cut the list"
is not taken for that reason: it would keep running past the write and
hide it.

### How did you verify your code works?

- mimalloc `ctest`: Release 23/23, Debug (`MI_DEBUG_FULL`) 24/24 (was
20/21 on the old pin), ASAN 22/22, TSAN 19/19 with 0 reports (see
mimalloc#27).
- `bun bd test`: transpiler (190/190), bundler_edgecase (138/138),
bundler_minify (43/43), css (2358 pass; 6 debug-timeout fuzz tests),
workers/serve (same 4 failures as a `main` debug build on this box).
- Release x64, n=7 interleaved, old pin vs new pin on the same Bun
commit:

| | old pin median (range) | new pin median (range) | Δ |
|---|---|---|---|
| `bun -e 1` peak RSS | 27024 KB (26564–27088) | 26016 KB (25984–26020)¹
| −3.7% |
| `bun -e 1` syscalls | 270 | 246 | −24 (no `clone3` for the scavenger,
−6 `rt_sigprocmask`, −5 `madvise`) |
| `Bun.serve` hello RSS after 200k req (c=64) | 49760 KB (48540–50040) |
47948 KB (46384–48252) | −3.6% |
| `bun build --minify --sourcemap` three.js×10 peak | 345696 KB
(340672–348928) | 343724 KB (339612–348828) | −0.6% (overlaps) |

¹ one run at 17212 KB excluded from the range as an outlier. The first
two rows come from the scavenger thread now starting on first use (first
park / first scheduled purge) instead of at process init — a change made
because the eager start aborted macOS processes that `DYLD_INSERT` the
dylib (thread created before libobjc initializes); `bun -e 1` never
needs it. Before that change the same A/B was flat (+0.1–0.3%,
overlapping), so the teardown protocol itself is RSS-neutral as
forecast.
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