Skip to content

theap: mi_on_thread_idle_pending_own, for a thread that comes back only for its own large pages - #62

Open
robobun wants to merge 2 commits into
oven-sh:bun-dev3-v2from
robobun:robobun/1a8bcc9f/idle-pending-own
Open

robobun wants to merge 2 commits into
oven-sh:bun-dev3-v2from
robobun:robobun/1a8bcc9f/idle-pending-own

Conversation

@robobun

@robobun robobun commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

The caller is oven-sh/WebKit#911. oven-sh/bun#47042 moves bun's pin to this change first. Nothing calls the export until then.

Problem

  • mi_on_thread_idle_pending() is also true for an abandoned large page (src/page.c:1362). The buffers of the threads that run are in those pages, and every sweep passes them.
  • JavaScriptCore's collector helpers go idle after each collection. If each sweeps again while it is true, an epoch ends every 100 ms. A JS thread that reuses 10 MiB of buffers every 300 ms then takes 2564 minor faults per round, not 69.

Fix

  • New mi_on_thread_idle_pending_own(): true when one page that the last sweep left is a page of the calling thread.
  • A second flag in mi_tld_t, set and cleared where holes_sweep_deferred is.
  • What a sweep takes and mi_on_thread_idle_pending() do not change.
  • Verified: test-purge-holes-large. Each wrong answer from the new function fails a case (Notes). ctest: 41 of 41 pass.

Background

Downsides

  • .text +139 bytes (bun's flags, Linux x64). 697 of 703 code sections are byte-identical, mi_malloc and mi_free among them. sizeof(mi_tld_t) stays 248.
  • A sweep that leaves a young large page makes one more store.
Notes

Size. .text goes from 265,546 to 265,685 bytes.

Where the flag is set. _mi_page_purge_holes_free_page_stays (only a page of the thread comes there, from mi_theap_page_purge_holes) and _mi_page_purge_holes when !mi_page_is_abandoned(page). The abandoned pass (mi_arena_page_purge_holes_at) claims a page but leaves its thread id as it is, so the test holds there.

Where it is cleared. With holes_sweep_deferred: mi_on_thread_idle, _mi_theap_sweep_parked, and the fork child (subproc.c).

The tests (test/test-purge-holes-large.c).

  • floor-free-page now asks the new function for a young large page with no block in use. That is the store a thread comes back for when all of its blocks were freed. The large floor has to be on for it, and main() turns the floor off for the other cases.
  • A new case runs the follow-up rule of mimalloc.h on a helper thread, beside a thread that takes its large buffers again every round. With mi_on_thread_idle_pending() as the key (the control) the helper discards the buffers in every round. With the new function it discards none.
  • Mutations, each with the rest of the suite green:
the change the case that fails
no store for the all-free young page (page.c:1243) floor-free-page
the new function returns the any-page flag abandoned-young, and the helper case
the new function returns false recent-allocation, floor-free-page

What keeps the any-page answer. _mi_theap_sweep_parked (the sweeps that the scavenger makes for a thread parked with mi_on_thread_idle_start()) and bun's Windows event loop. Both are for threads that run, whose buffers the abandoned pages hold.

The sections that differ (clang 23, -O3 -march=nehalem, src/static.c as bun compiles it): _mi_page_purge_holes_free_page_stays +2 bytes, _mi_page_purge_holes +30, _mi_thread_idle_work +32, mi_on_thread_idle +2, _mi_theap_sweep_parked +2, _mi_process_fork_child +0, and the new function.

The measurement in bun. Each round takes 40 buffers of 256 KiB, fills them, frees them with transfer(0), runs a collection, and computes for 300 ms without a large allocation. Minor faults of the JS thread while it fills the buffers, median of 8 rounds, 3 runs each, release build, Linux x64:

JavaScriptCore's idle threads faults per round
sweep once (today) 68, 68, 69
sweep again while mi_on_thread_idle_pending() 2564, 2564, 2564
sweep again while mi_on_thread_idle_pending_own() 69, 69, 69

With the third rule one round in 14 still takes about 1600 faults: the round after the JIT compiles the loop. A JIT thread then has large pages of its own, comes back for them, and its sweeps end epochs for every page.

CI. Test (extra, macos-latest, c) failed once on the first commit (step Debug, macOS, TLS fixed, ctest exit code 8) and passes on the current head with the other 22 checks. I could not read the log of the failed job. The same configuration passes on Linux x64.

…ly for its own large pages

mi_on_thread_idle_pending is also true when the sweep left the free blocks
of an abandoned large page of the thread's heaps. Such a page is one that
became full, which is where the buffers of the threads that run are, and
every thread that sweeps passes all of them.

A caller that is one of many helper threads (the compiler and collector
threads of a runtime) and calls mi_on_thread_idle again while that is true
ends an epoch per interval. A thread that takes its buffers again every few
intervals then finds them given back each time.

mi_on_thread_idle_pending_own is true only when one of the pages that the
sweep left is a page of the thread itself. What a sweep takes does not
change, and mi_on_thread_idle_pending does not change.
@robobun

robobun commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

The caller is oven-sh/WebKit#911: AutomaticThread and Atomics.wait release again while mi_on_thread_idle_pending_own() is true. oven-sh/bun#47013 pins both and has the tests.

@robobun

robobun commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

One job is red: Test (extra, macos-latest, c), step Debug, macOS, TLS fixed, ctest exit code 8. I could not get the log of that job and I cannot re-run it.

What I checked:

  • The same configuration on Linux x64 (-DCMAKE_BUILD_TYPE=Debug -DMI_TLS_MODEL_FIXED=ON): ctest passes 41 of 41, and test-purge-holes-large passes 55 of 55 runs, 40 of them beside 36 busy loops on 12 CPUs.
  • The macOS arm64 basic jobs (Debug, Release, Secure) and Test (extra, macos-15-intel, c) pass with the same tests.

Can someone with access re-run the job, or paste the name of the failing test?

…fers of a thread that runs

A case with a thread that takes its large buffers again every round and a
helper thread that goes idle after each round, with mi_on_thread_idle_pending
as the control: following up on the any-page answer discards the buffers in
every round, following up on the own-pages answer discards none.

floor-free-page checks the own-pages answer for a young large page with no
block in use, which is the store a thread whose blocks were all freed comes
back for.

mimalloc.h says that the _start/_end pair stays on the any-page answer.
@robobun

robobun commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

The job that was red passes on the new head (639adf50): all 23 checks are green. I did not find what failed on 8feeaf1a.

The new commit adds tests and one paragraph in mimalloc.h. Nothing under src/ changes.

  • floor-free-page asks the new function for a young large page with no block in use.
  • A new case runs the follow-up rule on a helper thread, beside a thread that takes its large buffers again every round. mi_on_thread_idle_pending() is the control.

oven-sh/bun#47042 moves bun's pin to this branch alone. Nothing calls the export until oven-sh/WebKit#911.

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