Skip to content

mimalloc: fold every thread's theap into the subproc stats aggregate - #34739

Open
robobun wants to merge 3 commits into
mainfrom
claude/farm/dbd9a0ae/mimalloc-stats-all-theaps
Open

mimalloc: fold every thread's theap into the subproc stats aggregate#34739
robobun wants to merge 3 commits into
mainfrom
claude/farm/dbd9a0ae/mimalloc-stats-all-theaps

Conversation

@robobun

@robobun robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Since #34009 moved JSC onto mimalloc, heapStats().mimalloc.pages.current (and page_bins[].current) can drift negative under an import-and-bust loop. First seen on macOS arm64 --smol (45 → 72 → -125 → -534 over 1000 iterations) and noted in #34159's side note; #34359 and #34686 work around the same drift in node-net.test.ts.

Repro

// start a Worker so another thread allocates mimalloc pages
const w = new Worker(URL.createObjectURL(new Blob([`
  const hold = [];
  for (let i = 0; i < 50000; i++) hold.push({ a: i, b: "str_" + i });
  self.postMessage("ready"); self.onmessage = () => {};
`], { type: "application/javascript" })));
await new Promise(r => { w.onmessage = r; });
const s = require("bun:jsc").heapStats({ dump: true });
let dump = 0; for (const h of s.mimallocDump.heaps) dump += h.pages.length;
console.log({ stat: s.mimalloc.pages.current, dump });  // { stat: 46, dump: 82 }

The live heap walk (mimallocDump, ground truth) counts 82 pages; the stat counter reports 46. The 36 missing pages are the Worker thread's.

Cause

mi_subproc_stats_get summed subproc->stats plus each heap->stats, after merging only the calling thread's theap into its heap (mi_heap_get_stats_mi_heap_theap_peek). The pages counter is incremented in the allocating thread's theap (arena.c:986), while a cross-thread or scavenger free of an abandoned page decrements heap->stats.pages directly via the NULL-theap branch of _mi_arenas_page_free (arena.c:1171, reached from free.c:271 and the abandoned-holes sweep at arena.c:1350/1356). Threads that never call mi_collect/mi_on_thread_idle (JSC helper threads, Worker VMs, the transpiler pool while busy) leave their +1s in an unmerged theap the aggregate never read, so every page they allocated is invisible while its eventual -1 is visible. Under churn the visible side outruns the invisible side and pages.current goes negative.

Fix

mi_heap_aggregate_visitor now reads heap->stats plus every theap on heap->theaps, under heap->theaps_lock, as a pure snapshot with no merge-and-zero side effect. Lock order subproc->heaps_lock → heap->theaps_lock matches mi_prof_set_all_theaps and the fork-prepare path in init.c. Reads of another thread's theap->stats are racy with that thread's non-atomic writes, which is fine for an advisory snapshot (aligned int64_t reads on every target Bun supports); a concurrent mi_theap_merge_stats (add then memzero, without theaps_lock) can transiently over-count by one merging theap's pending delta, which the test's upper bound tolerates.

Applied as a patch because I can't push to oven-sh/mimalloc; the same change belongs upstream there.

Why this is the right level

The alternative is to move the pages increment to heap->stats (atomic) at page-alloc time so both sides always target the same counter. That adds an atomic RMW on every fresh page alloc and still leaves the other theap-local counters (page_committed, pages_abandoned, page_bins) under-reported for the same reason. Folding all theaps into the read-side aggregate fixes every counter with no hot-path change.

Verification

# USE_SYSTEM_BUN=1 (5 runs, identical):
expect(received).toSatisfy(expected)
Received: { stat: 46, dump: 81 }
(fail) pages.current agrees with the live heap dump across threads

# bun bd test (3 runs):
(pass) pages.current agrees with the live heap dump across threads

The test brackets the counter against the live walk (dump - 5 ≤ stat ≤ dump + 10) so it catches both the pre-fix underreport and any future double-fold. All five tests in test/js/bun/jsc/heapStats-mimalloc.test.ts pass on the debug build.


no test proof · iteration 0 · The fix is a vendored-dep patch (patches/mimalloc/ + scripts/build/deps/mimalloc.ts); neither is under src/ or packages/, so the fail-before stash is a no-op and both builds have the patch applied. Fail-before/pass-after shown above with USE_SYSTEM_BUN=1 vs bun bd.

mi_subproc_stats_get (behind heapStats().mimalloc and MIMALLOC_SHOW_STATS)
summed subproc->stats plus each heap->stats, merging only the calling
thread's theap into heap->stats first. But pages.current (and page_bins)
are incremented in the allocating thread's theap while a cross-thread or
scavenger free of an abandoned page decrements heap->stats directly via
the NULL-theap path in _mi_arenas_page_free. Threads that never call
mi_collect/mi_on_thread_idle (JSC helper threads, Worker VMs) leave
their +1s sitting in an unmerged theap the aggregate never read, so the
reported pages.current underreported by exactly those pages and could go
negative under an import-and-bust loop (observed on macOS arm64 after
JSC moved onto mimalloc).

The fix is to also add every theap of each heap into the aggregate,
under heap->theaps_lock. The calling thread's theap was already
merge-and-zeroed by mi_heap_get_stats so including it again is a no-op.
Lock order subproc->heaps_lock -> heap->theaps_lock matches
mi_prof_set_all_theaps and the fork-prepare path.
@robobun

robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:07 PM PT - Jul 19th, 2026

@robobun, your commit 9ee6df2 has 1 failures in Build #75911 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34739

That installs a local version of the PR into your bun-34739 executable, so you can run:

bun-34739 --bun

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 15 seconds

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 40b3c4c2-c4c8-42b1-b924-11ac3e24404a

📥 Commits

Reviewing files that changed from the base of the PR and between 99fc2f8 and 9ee6df2.

📒 Files selected for processing (3)
  • patches/mimalloc/stats-aggregate-all-theaps.patch
  • scripts/build/deps/mimalloc.ts
  • test/js/bun/jsc/heapStats-mimalloc.test.ts

Comment @coderabbitai help to get the list of available commands.

Comment thread test/js/bun/jsc/heapStats-mimalloc.test.ts Outdated
Comment thread test/js/bun/jsc/heapStats-mimalloc.test.ts Outdated
robobun added 2 commits July 20, 2026 00:34
The visitor no longer merge-and-zeroes the caller's theap; it reads
heap->stats plus every theap under heap->theaps_lock as a side-effect-free
snapshot. Reading both inside the same lock narrows the window a
concurrent mi_theap_merge_stats can double-count through.

Test: surface stderr/exitCode before JSON.parse so a subprocess crash
reports the real diagnostic, and add an upper bound so a future
double-fold regression is caught.
@robobun

robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 9ee6df2 (build 75911): the new test and node-net.test.ts pass on every lane. Remaining red:

Ready for review.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Re-checked this against the mimalloc pin main uses now (6e891cbe, set by #38291 on top of the dev3 resync in #37367), to see whether the resync made this PR unnecessary. It did not: the underreport is still there at the current pin.

What was checked

  • mi_heap_get_stats and mi_heap_aggregate_visitor in src/stats.c are identical between the previous pin (1803341d) and 6e891cbe. The aggregate still merges only the calling thread's theap into heap->stats; the increment in _mi_arenas_page_free / page allocation still lands in the allocating thread's theap (arena.c, mi_theap_stat_increase(theap, pages, 1)), and the cross-thread free path still decrements heap->stats directly.
  • A standalone program linked against the vendored 6e891cbe source (built the way scripts/build/deps/mimalloc.ts builds it), with a second thread holding 4096 live 1000 byte blocks, reports mi_stats_get().pages.current == 2 while mi_heap_dump_json() lists 68 pages. 5/5 runs. These are the two numbers heapStats().mimalloc.pages.current and heapStats({ dump: true }) read.
  • The same program with patches/mimalloc/stats-aggregate-all-theaps.patch applied to 6e891cbe reports 68/68. The hunk applies with a 15 line offset and compiles unchanged, since heap->theaps, theaps_lock and hnext are the same at the new pin.
  • For reference, the release build of bun at the previous pin (1803341d) shows the same thing through heapStats() itself with this PR's repro: stat: 51 vs dump: 184..193 over 5 runs.

State of this branch

Two notes for whoever ports it to the fork:

  • Walking heap->theaps also folds in the detached theap_meta, which is attached to heap_main and whose stats upstream deliberately leaves out of the aggregate (see the commented out lines in mi_subproc_stats_get). In the program below that shows up as pages.current 4 vs 2 after the worker thread has exited (the two extra pages hold the dead thread's tld/theap metadata). The dump includes those pages too, so the bracket in the test is fine either way, but it is a behaviour choice to make explicitly (skip is_detached theaps or not).
  • The test in this PR only has teeth where the worker thread's allocations actually go through mimalloc, i.e. release builds with the malloc override. On a debug ASAN build (no override) the same repro gives stat: 5, dump: 6 with or without the fix, because the Worker only puts about one page in its theap. The standalone program below does not have that problem and would work as a fork-side regression test.
Standalone check against the vendored source

Build (same flags mimalloc.ts uses, from a copy of vendor/mimalloc at 6e891cbe):

clang++ -x c++ -std=gnu++23 -O2 -DNDEBUG -DMI_BUILD_RELEASE -DMI_STATIC_LIB -DMI_SKIP_COLLECT_ON_EXIT=1 \
  -DMI_NO_PROCESS_DETACH=1 -DMI_CMAKE_BUILD_TYPE=release -fvisibility=hidden -ftls-model=initial-exec -fPIC \
  -I include -c src/static.c -o static.o
clang -O2 -I include -c repro.c -o repro.o
clang++ static.o repro.o -lpthread -o repro

repro.c:

#include <mimalloc.h>
#include <mimalloc-stats.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define NBLOCKS 4096
#define BLOCK 1000

static void* blocks[NBLOCKS];
static pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t cv = PTHREAD_COND_INITIALIZER;
static int ready = 0, done = 0;

static long long pages_current(void) {
  mi_stats_t_decl(s);
  if (!mi_stats_get(&s)) { fprintf(stderr, "mi_stats_get failed\n"); exit(2); }
  return (long long)s.pages.current;
}

static long long dump_pages(void) {
  char* json = mi_heap_dump_json(false, true);
  if (json == NULL) { fprintf(stderr, "mi_heap_dump_json failed\n"); exit(2); }
  long long n = 0;
  for (const char* p = json; (p = strstr(p, "\"block_size\"")) != NULL; p += 12) n++;
  mi_free(json);
  return n;
}

static void* worker(void* arg) {
  (void)arg;
  for (int i = 0; i < NBLOCKS; i++) { blocks[i] = mi_malloc(BLOCK); memset(blocks[i], 1, BLOCK); }
  pthread_mutex_lock(&mu);
  ready = 1; pthread_cond_broadcast(&cv);
  while (!done) pthread_cond_wait(&cv, &mu);
  pthread_mutex_unlock(&mu);
  for (int i = 0; i < NBLOCKS; i++) mi_free(blocks[i]);
  return NULL;
}

int main(void) {
  void* warm = mi_malloc(64); mi_free(warm);
  const long long stat_before = pages_current();
  const long long dump_before = dump_pages();

  pthread_t t;
  pthread_create(&t, NULL, worker, NULL);
  pthread_mutex_lock(&mu);
  while (!ready) pthread_cond_wait(&cv, &mu);
  pthread_mutex_unlock(&mu);

  const long long stat = pages_current();
  const long long dump = dump_pages();

  pthread_mutex_lock(&mu);
  done = 1; pthread_cond_broadcast(&cv);
  pthread_mutex_unlock(&mu);
  pthread_join(t, NULL);
  const long long stat_after_join = pages_current();
  const long long dump_after_join = dump_pages();

  const int ok = (stat >= dump - 5) && (stat <= dump + 10);
  printf("mimalloc %d: before={stat:%lld,dump:%lld} while-thread-alive={stat:%lld,dump:%lld} after-join={stat:%lld,dump:%lld} -> %s\n",
         mi_version(), stat_before, dump_before, stat, dump, stat_after_join, dump_after_join,
         ok ? "OK (stat agrees with dump)" : "UNDERREPORTED (stat disagrees with dump)");
  return ok ? 0 : 1;
}

Output at 6e891cbe without the patch:

mimalloc 30500: before={stat:1,dump:2} while-thread-alive={stat:2,dump:68} after-join={stat:2,dump:4} -> UNDERREPORTED (stat disagrees with dump)

With stats-aggregate-all-theaps.patch applied to 6e891cbe:

mimalloc 30500: before={stat:1,dump:2} while-thread-alive={stat:68,dump:68} after-join={stat:4,dump:4} -> OK (stat agrees with dump)

(before differs by one in both because the dump allocates its own output buffer after the stats were read.)

Release build of bun at the previous pin 1803341d, running this PR's repro through heapStats():

{"stat":51,"dump":191,"binSum":51,"theapsBefore":8,"theaps":11}
{"stat":51,"dump":193,"binSum":51,"theapsBefore":8,"theaps":11}
{"stat":51,"dump":188,"binSum":51,"theapsBefore":8,"theaps":11}
{"stat":51,"dump":184,"binSum":51,"theapsBefore":8,"theaps":11}
{"stat":51,"dump":186,"binSum":51,"theapsBefore":8,"theaps":11}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant