Repository navigation
tls: the pthread key of the thread local slots is created once - #49
Merged
Merged
Conversation
Where a thread local is a pthread key (macOS, MI_TLS_MODEL_PTHREADS), the
key is created when it is first set: `mi_pthread_key_set` looked at the key,
and when it was not there yet `_mi_pthread_key_create` created one into it
and stored the value under that. The key of the thread local slots
(`threadlocal.c`, the theaps of the heaps besides the main one) is first set
when such a heap is first used, which a program can do on many threads at
once: two threads both find no key and both create one, the second replaces
the first, and the first thread's slots are under a key that nobody reads
any more. Its next `_mi_thread_local_get` finds nothing: a debug build stops
in `heap.c:mi_heap_init_theap` ("theap == _mi_thread_local_get(heap->theap)",
seen twice in test-park-handoff on the macOS Intel runners, whose
test_exit_while_swept_with_dyn_tls is eight threads that do this first), a
release build makes a second theap for the heap and leaves the first one,
with its pages, until the thread ends, and a key leaks.
`_mi_pthread_key_create_once` creates the key aside and stores it with a
compare and swap; a thread that was not first deletes its key and uses the
one that was.
test-tls-key-race: 200 child processes (there is one first time in a
process), eight threads each that make their heaps, wait for each other and
allocate from them at once. The window is a few instructions: 0 of 400
rounds hit it on a 64 core Linux machine with MI_TLS_MODEL_PTHREADS, fixed
or not. With 2 ms in front of pthread_key_create: 50 of 50 rounds stop on
the assertion before, 0 of 100 after.
Review of the previous commit. - The key of `mi_define_thread_local` is an `_Atomic(pthread_key_t)`, read with a relaxed load, as `_mi_theap_default_key` in prim-tls.c is: no cast of a plain variable to an atomic, and nothing for a thread sanitizer to report. - When `pthread_key_create` fails, the key that another thread made meanwhile is used if there is one. - `mi_debug_stall_in_pthread_key_create` (MI_DEBUG, as the other stall hooks): the first thread that comes to create a key waits for a second one. test-tls-key-race sets it: one process, eight threads, no fork (the children of the first version could not run under the thread sanitizer, and did not tell fixed from not fixed). With the last key winning, as before the previous commit, it stops on the assertions of `heap.c:mi_heap_init_theap` or `page.c:mi_page_is_valid_init` every time.
…re it is set A thread that lost its slots makes new ones and a second theap and goes on with those: what the test could see of it were the assertions of a build with MI_DEBUG >= 2, and those did not fire every time. It counts the theaps that were made now: two a thread, three for one that lost its slots (12 of 12 with the last key winning and MI_DEBUG=1; with MI_DEBUG=FULL the assertions stop it 12 of 12). With the hook, two or more threads have to have been in the creation. `mi_pthread_key_set` reads the key with acquire: a thread that sees the key also sees what `pthread_key_create` did for it.
Collaborator
Author
CostMeasured after the merge, for the record: What changed in the generated code
Instructions (G = 10^9 user instructions)
Bun (release build of the same tree, the mimalloc commit the only difference; Linux x64, so the changed path is not compiled)
Not measured: a run on Apple hardware. What is above for arm64 is the generated code, not a timing. |
Jarred-Sumner
added a commit
to oven-sh/bun
that referenced
this pull request
Sep 15, 2026
, #51): recently used large pages are left alone for a while (#42808) ## What `MIMALLOC_COMMIT` 69050cf3 → 6df7aa13, the head of `bun-dev3-v2`. Relative to main's pin that is three fork PRs: - **oven-sh/mimalloc#50**, what this PR is about: see below. - **oven-sh/mimalloc#49**: the pthread key of the allocator's thread-local slots is created once when several threads first use a second heap at the same time (macOS; the code is not compiled on Linux). Measured there: every hot path is the same instruction for instruction. - **oven-sh/mimalloc#47**: a profiler that is started again starts every thread over. Only reachable through the sampling-profiler hooks, which nothing on main calls yet; #42626 (`Bun.pprof.heap`) bumps the pin to that commit for its own sake. Whichever of the two PRs lands second keeps the newer commit, which is this one. Since #42668 the idle sweep hands back the free blocks of the allocator's 4 MiB pages (buffers of 96 KiB and up) two sweeps after the page was last allocated from, which is 100–200 ms. A server sits idle between two requests for longer than that, so it took the buffers of every request out of discarded memory again: a page fault for each 4 KiB of them, on every request. About half of that was free blocks in a page that still had a buffer in use; the other half was pages with no buffer in use at all, which were freed outright and made anew for the next request. oven-sh/mimalloc#50: a large page (its free blocks, or all of it) is left alone until it has not been allocated from for 256 epochs of the sweep, for up to 4 MiB process-wide, the pages that were used last first. There is no timer in this: an epoch ends only when some thread is swept. `MIMALLOC_PURGE_HOLES_LARGE_FLOOR=0` is the behaviour before (a bare number there is bytes: write `4MiB`, not `4096`). ## What it costs - A process in which no thread ever parks again keeps up to 4 MiB of large pages that it would have given back. Measured with the allocator alone: 24 blocks of 300 KiB, 20 freed, a few short parks and then one for good: 4460 KiB anonymous at every reading for 70 s against 1460 KiB before (3.0 MB of the 6 MB freed; a page stays or goes whole). The allocator's scavenger thread wakes for its 30 s safety timeout as before and for nothing else. - An idle server holds 0.2–1.0 MB more ten seconds after the load (3 MB after 512 KiB bodies) and the same as before a minute after. - A large application parked at a prompt: 1 MB. ## Numbers Linux x64, release builds of this tree, the mimalloc commit the only difference. `Bun.serve` that answers a request with its own body, one connection, 5 requests a second for 20 s. Minor faults of the server (`/proc/<pid>/stat`) and kernel instructions (`perf stat -e instructions:k -p`), per request: | body | main: faults | kernel instr | this PR: faults | kernel instr | |---|---|---|---|---| | 128 KiB | 65.4 | 627 K | 2.6 | 208 K | | 256 KiB | 123.9 | 1031 K | 2.5 | 375 K | | 512 KiB | 309.1 | 2331 K | 14.1 | 898 K | | `hello` | 1.1 | 128 K | 1.1 | 114 K | (No latencies: the machine was busy with other work and the median of one binary moved more from run to run than the two differ. At 512 KiB it shows through: 2.60 ms → 2.08 ms.) Idle afterwards, anonymous RSS of the server in KiB at +10 s / +70 s: 128 KiB 3128 / 2908 → 4044 / 2940; 256 KiB 3248 / 3056 → 3280 / 3044; 512 KiB 3564 / 3356 → 6624 / 3376; `hello` 2936 / 2808 → 2784 / 2780. So it holds up to 3 MB more ten seconds after the load and the same a minute after. A large `--compile`d CLI application that sits at a prompt (anonymous + file-backed RSS in MB; 10 minutes at the first prompt, a scripted session of 20 turns, 10 minutes parked; two runs each, all four started together): | | main | this PR | |---|---|---| | first prompt + 30 s | 121+95 / 122+95 | 124+67 / 124+95 | | + 2 min | 100+31 / 100+38 | 101+36 / 101+36 | | + 5 min | 96+32 / 96+38 | 97+36 / 97+37 | | + 10 min | 96+32 / 96+39 | 97+37 / 97+38 | | after the session + 30 s | 258+87 / 252+79 | 265+87 / 268+88 | | + 2 min | 190+43 / 183+44 | 197+43 / 195+44 | | + 5 min | 165+44 / 160+45 | 171+44 / 170+45 | | + 10 min | 171+51 / 166+53 | 176+52 / 176+53 | At the prompt that is 3 MB more for the first minute, 1 MB at two minutes and after. After the session these four runs are 5–10 MB apart; the same comparison with one binary and the floor switched by the environment (`…LARGE_FLOOR=0`) came out at 171 / 161 against 168 / 173 at + 5 min, so what the floor itself keeps there (3–4 MB for two minutes) is inside what two runs of one configuration differ by. Instructions (`perf stat -e instructions:u`, pinned, interleaved, median [range]): | | main | this PR | |---|---|---| | `bun -e 1` (9 runs) | 10.05 M [10.04–10.07] | 10.05 M [10.05–10.07] | | `JSON.stringify` + `JSON.parse` loop (5) | 4788.6 M [4786.5–4793.8] | 4789.3 M [4788.8–4796.6] | | allocation loop (5) | 5760.0 M [5753.6–5766.7] | 5799.9 M [5758.2–5824.1] | | the allocator alone: 50 M random `malloc`/`free` | 1.9932 G | 1.9932 G | | the allocator alone: 20 M × `malloc(128 KiB)` + `free` | 5.0313 G | 5.0313 G | (The allocation loop moves by that much between runs of one binary; its ranges overlap.) ## Test `test/js/bun/jsc/heapStats-mimalloc.test.ts`: a `Bun.serve` child that echoes 256 KiB bodies, requests 50 ms apart with the sweep's epoch shortened to 10 ms and the collector looking every 100 ms, minor faults per request read from `/proc`. 2.3–2.4 with this pin, 130–131 with main's (the bound is 20). Linux only, not ASAN.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Where a thread local is a pthread key (macOS,
MI_TLS_MODEL_PTHREADS), the key is created when it is first set:mi_pthread_key_setlooks at the key, and when it is not there yet_mi_pthread_key_createcreates one into it and stores the value under it. No synchronization between the look and the create.The key of the thread local slots (
threadlocal.c: the theaps of the heaps besides the main one hang off it) is first set when such a heap is first used, and a program can do that on many threads at once. Two threads both find no key, both create one, the second replaces the first in the variable, and the first thread's slots are now under a key that nobody reads any more:heap.c:mi_heap_init_theap,assertion: "theap == (mi_theap_t*)_mi_thread_local_get(heap->theap)". That is the failure oftest-park-handoff-eageron the macOS Intel runners, seen twice (onbun-dev3-v2itself, run 34803968831, and on profile: a theap starts over with each mi_profiler_start #47's first run): it is not the park code, the test that fails istest_exit_while_swept_with_dyn_tls, whose eight threads each domi_heap_new+mi_heap_mallocand are the first in that process to use another heap than the main one;The change
_mi_pthread_key_create_oncecreates the key aside and stores it with a compare and swap; a thread that was not first deletes its key and uses the one that was (also when its ownpthread_key_createfails).mi_pthread_key_setcalls it instead of_mi_pthread_key_create, which stays as it is for the two keys that_mi_tls_slots_initcreates under its own once.mi_define_thread_localis an_Atomic(pthread_key_t), as_mi_theap_default_keyinprim-tls.cis: read with a relaxed load where a value is read, with acquire where one is set (about once a thread).Platforms with real thread locals are not affected (the macro defines a plain
mi_decl_threadvariable there).Test
test-tls-key-race: one process, eight threads that make their heaps, wait for each other, and allocate from them at the same moment. A thread that lost its slots does not notice (it makes new ones and a second theap for its heap), so the test counts the theaps that were made: two a thread. A debug build has a hook for it like the othermi_debug_stall_in_*ones,mi_debug_stall_in_pthread_key_create: the first thread that comes to create a key waits for a second one, so both have found no key. WithMI_TLS_MODEL_PTHREADSandMI_DEBUG=FULL:MI_DEBUG=FULL: stops onheap.c:84orpage.c:259, 12 of 12;MI_DEBUG=ON: "made 17 theaps", 12 of 12A release build has no hook and only the natural window, a few instructions wide; to see the unpatched code fail there I put a
usleep(2000)in front ofpthread_key_create(not part of this PR): a forking version of the test, 8 threads in each of 50 children, failed in 50 of 50 on the assertion above, and in 0 of 100 with the compare and swap and the same sleep; without the sleep 0 of 400 either way on a 64 core Linux machine (the macOS Intel runners hit it twice in some dozens of runs).Locally, all of ctest: release 34 of 34, debug (
MI_DEBUG=FULL) 35 of 35, debug as C++ withMI_TLS_MODEL_PTHREADS35 of 35,MI_TLS_MODEL_PTHREADSdebug 35 of 35 and release 34 of 34; the test alone under the thread sanitizer with and withoutMI_TLS_MODEL_PTHREADS: no report.