Repository navigation
Own worker threads: a process-wide reserve of std::thread workers instead of a private Rayon pool (#15) - #16
Conversation
…dth, foreign load) with Rayon and libgomp references Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…en sleep) for members, joins and barriers; scheduling pressure throttles (faer#107) Every team wait is now an episode: spin for the pool's budget of polls (PoolBuilder::spin_polls, a count like GOMP_SPINCOUNT; 12288 on x86-64, ~300 us on Zen 3), sixteen yields, then sleep until woken. The leader's join and every barrier sleep on one pool-wide parker, woken by the last member's decrement, the last arrival and a cancellation; a member that finds no work parks on its slot as before. A spinner that sees a >200 us gap between its clock reads was descheduled: it ends its spin and the pool starts its waits throttled (100 polls) for 100 ms. Idle members released by a scoped team return to Rayon after the spin budget or idle_spin, whichever comes first. The spare worker (threadteam-rs#12) stays: removing it breaks external. loom: the last member waking a sleeping leader, consecutive barrier rounds, a cancellation waking a sleeper (each fails when its wake is removed). Design: docs/design/waiting.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le only on repeated preemptions; wait counters and an interleaved A/B script evo: the first section after a 1 ms / 10 ms gap was 2.3x / 10.8x slower than 0.1.3 with the 12288-poll budget (members asleep). broccoli: held_team and held_pool at width 64 were 4x slower than 0.1.3 on a mildly shared host, consistent with one observed preemption throttling the whole pool for 100 ms (every section then sleeps and pays futex wakes). Now a single preemption ends only its waiter's spin; four within 100 ms throttle the pool. Both values, the hold and the throttled budget are benchmark knobs (PoolBuilder::wait_tuning, hidden), and Pool::wait_counters (hidden) counts sleeps, parks and observed preemptions, printed under every bench row. scripts/bench-ab.sh alternates two commits (and head variants) for shared hosts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…resh-pool phases in the bench broccoli (15:51Z): under foreign load held_team / held_pool were 3.6-4.1x slower than 0.1.3 with the pool-wide throttle (every section then woke ~63 sleeping members). A member that sees itself descheduled now moves its own slot's deadline: its next waits start throttled for the hold, the others keep spinning. The pool-wide throttle is a benchmark knob (--pressure-events N, off by default). The fresh_pool rows of the bench now also time build, acquire, release and drop separately, to locate the 60.7 ms of a one-off pool on a quiet host. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d hold as a knob; libgomp held team and load in the A/B runs The monitor's criteria (quiet hosts within 10 % of the base; under load no worse than libgomp) favour c0c024b: with a per-thread 100 ms hold (2773c36), a member preempted once on a quiet broccoli slept at every section for the hold, 1.79x / 1.21x slower held teams. The pool throttle after 4 preemptions in 100 ms is the default again; --own-hold-ms N keeps the per-thread variant for comparison. wait_omp.c gains libgomp's held team (acquire/held_team: calls of 50 x 20 us sections on the hot team), its own load generator (WAIT_OMP_LOAD) and scenario selection; scripts/bench-ab.sh runs it every round with OMP=1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…preemption detection off for the libgomp-like comparison evo (d430347): 300000 polls close the 10 ms gap; accepted as the OpenMP default (criterion d). Under load the throttle made held teams 3.45x slower than libgomp, which detects nothing. PoolBuilder::preempt_gap (hidden; bench --preempt-gap-us) sets the gap that counts as a preemption; 0 turns detection and every throttle off, so the next runs compare that against the current default and against a long-preemption-only threshold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…00); detection and the pool throttle only during a new pool's warm-up (200 ms) broccoli (e76036c, 16:58Z): with detection off, held teams met (b) and (c) (held_team 0.91x libgomp under load, quiet within 10 %), but a pool built per call collapsed (87.6 ms): freshly spawned threads start stacked and spinners never free a CPU. Detection with the pool throttle (c0c024b) kept the faer#107 arms at zero. So a pool detects for its first 200 ms (gap 200 us, pool throttle after 4 preemptions in 100 ms) and not after: with_team pools live in that window, held pools run as libgomp. Knobs: preempt_gap (after the warm-up, default 0) and warm_up (hidden; --preempt-gap-us, --warm-ms). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pressure knobs) The own-threads design (#15) keeps #14's staged waits with its libgomp-type default (spin 300000 polls, 16 yields, futex) and removes the preemption detection, the pool throttle on preemptions, the warm-up window and their hidden knobs: worker reuse, not a waiting heuristic, addresses the one-call pool. Static oversubscription still throttles to 100 polls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sed per Pool, a native batch queue and broadcast list, cooperative waits (#15) threadteam no longer runs on a private Rayon ThreadPool. A Pool leases its workers from a process-wide reserve and returns them on drop, so a pool built after another one was dropped (faer's with_team, once per call) reuses running threads instead of spawning fresh ones: the faer#107 one-call collapse came from fresh-thread stacking, which no waiting policy fixed (#14). Pools with a thread name, an on_start hook, a stack size or a QoS of their own get dedicated workers that their drop joins, as before. - Slot state word (RESERVED/LENT/BUSY/DETACH): a team reserves only free workers; a worker wins BUSY before it takes work; release and end-of-work never erase each other's bits or DETACH. - Plain fork/for_ranges/external publish a stack batch on the pool's queue (claims under the lock, last claim unlinks, owner frees after completion); a worker caller runs its own batch, any waiting worker serves its own pool meanwhile (cross-pool chains progress), a plain thread sleeps. - Broadcast: publication-ordered list, each worker runs its shares in order, no lock held while waiting. - Pool drop: detach, wait for every acknowledgement but the dropping worker's own; workers return to the reserve before acknowledging. Workers take the leasing caller's CPU set. Reserve cap: the largest width leased. - Panics in tasks and shares are caught, every claim completes, the first payload is resumed after the wait. - Rayon: opt-in feature 'rayon' (Exec::external_rayon on a private pool); idle_runs_rayon is a documented no-op. - loom: slot state races, a batch with two claimers and a sleeping owner, a broadcast to two workers, an idle worker against a publication (12 models). Strict alloc-free test incl. broadcast and wake after park. New tests: reuse (spawn counter), dedicated workers, A->B->A, broadcast order, task panic, affinity on lease; the self-drop test is kept (task published directly). Design: docs/design/own-threads.md (pi-council + gpt pre-review). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A worker marks its slot attached only after it read the slot's section generation, so a leader's first section is never taken as already seen. - Nested waits save and restore the `helping` flag, so `wake_for_work` still wakes an outer wait after an inner one returned. - A worker with a broadcast share on its stack takes no further share: a later broadcast run above an unfinished one could deadlock at a barrier. - A broadcast cursor moves past every publication when nothing is left for the worker, so a broadcast unlinked before it looked does not keep it awake forever. - Only reused workers take the leasing caller's CPU set; a dedicated worker keeps what its `on_start` hook set. - Pool and Team shutdown docs distinguish shared and dedicated workers; the unused shutdown flag and `acquire`'s pool argument are gone. Each fix has a regression test that fails or hangs with the fix undone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s apart Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd backtrace Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
evo acceptance run for head Conditions were the same as on #14:
Verdict: (b) passes and (d) passes. Every quiet-host cell is within 1.7 % of the base, and the 10 ms gap matches the base with the default settings, no knob needed. The one-off pool (
|
| width | gap | base | head 78b59a1 |
ratio (first / call) | libgomp |
|---|---|---|---|---|---|
| 6 | 0 | 5.9 / 57.7 | 5.9 / 58.5 | 1.00 / 1.014 | - |
| 6 | 100 µs | 5.9 / 57.7 | 5.9 / 58.4 | 1.00 / 1.012 | - |
| 6 | 1 ms | 5.9 / 57.9 | 5.9 / 58.5 | 1.00 / 1.010 | - |
| 6 | 10 ms | 5.9 / 57.7 | 5.9 / 58.4 | 1.00 / 1.012 | - |
| 8 | 0 | 6.1 / 59.6 | 6.0 / 59.5 | 0.98 / 0.998 | 6.6 / 64.3 |
| 8 | 100 µs | 6.2 / 59.6 | 6.0 / 59.5 | 0.97 / 0.998 | 6.5 / 64.2 |
| 8 | 1 ms | 6.1 / 59.5 | 6.1 / 59.6 | 1.00 / 1.002 | 6.7 / 64.4 |
| 8 | 10 ms | 6.0 / 59.1 | 6.1 / 59.7 | 1.017 / 1.010 | 15.6 / 73.3 |
acquire --calls 40, 5 rounds (µs)
| width | arm | base | head | ratio | libgomp |
|---|---|---|---|---|---|
| 6 | held_team | 1038.5 | 1040.7 | 1.002 | - |
| 6 | held_pool | 1045.5 | 1043.3 | 0.998 | - |
| 6 | fresh_pool, call | 1038.3 | 1042.0 | 1.004 | - |
| 6 | fresh_pool, build to drop | 1227.9 | 1060.4 | 0.864 | - |
| 8 | held_team | 1048.1 | 1048.0 | 1.000 | 1069.7 |
| 8 | held_pool | 1054.3 | 1052.1 | 0.998 | - |
| 8 | fresh_pool, call | 1046.8 | 1047.5 | 1.001 | - |
| 8 | fresh_pool, build to drop | 1307.7 | 1074.7 | 0.822 | - |
These are the head's fresh_pool phases at widths 6 / 8, in µs:
| phase | width 6 | width 8 | on #14 1f2145b (6 / 8) |
|---|---|---|---|
| build | 6.7 | 11.9 | 95 / 127 |
| acquire | 4.5 | 3.6 | 14 / 20 |
| release | 0.6 | 0.8 | 1.0 / 1.4 |
| drop | 5.0 | 8.3 | 78 / 116 |
Reusing the reserve's workers takes the one-off overhead from 183 to 264 µs down to about 17 to 25 µs. Tail outliers of 22 to 37 ms on single calls appear on both sides, as on #14. They show only in max, and one round of the head's width-8 fresh_pool has p90 6.1 ms; no median moves.
sections, 5 rounds (per call, µs)
| width | work × 200 | base | head | ratio | libgomp |
|---|---|---|---|---|---|
| 6 | 5 µs | 1151.8 | 1162.2 | 1.009 | - |
| 6 | 20 µs | 4151.1 | 4165.7 | 1.004 | - |
| 6 | 50 µs | 10155.7 | 10163.5 | 1.001 | - |
| 8 | 5 µs | 1186.6 | 1185.7 | 0.999 | 1279.2 |
| 8 | 20 µs | 4186.6 | 4185.1 | 1.000 | 4278.1 |
| 8 | 50 µs | 10186.1 | 10184.2 | 1.000 | 10277.7 |
Against the criteria
- (b) passes. On the quiet host,
held_teamandheld_poolare 0.998 to 1.002 of the base, andgaps0 to 1 ms are 0.97 to 1.014. Both are within 10 %.sectionsis within 1 % as well. - (d) passes. The 10 ms gap is 1.00 / 1.017 on the first section and 1.012 / 1.010 on the call at widths 6 / 8, with the default 300000-poll budget. libgomp is 15.6 / 73.3 µs on the same cell.
- Against libgomp: the head is 7 to 10 % faster on the 0 to 1 ms gaps and on
sections5 µs. It is 2.6× faster on the 10 ms gap, and 2 % faster onheld_team. - (a) and (c) need the broccoli runs; this run does not cover them.
Raw rows and the waits per call lines are in shinaoka-advisor/work/runs/threadteam-16/ (gaps10, acquire5, sections5, .csv and .log).
|
Note on the run above: the head moved to |
On macOS a std `Mutex` allocates on its first lock, and `enter` built two per call (`external` on a plain token): the strict alloc-free test failed there with two allocations per window. The one-task batch runs its task exactly once and the caller reads the result after completion, so a single-use cell is enough. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI green at
|
Acceptance runs on broccoli — head measured:
|
| arm | kernel | base (0.1.3) | head 78b59a1 |
|---|---|---|---|
held |
gemm128 | 0.02 | 0.02 |
team_per_call |
gemm128 | 0.02 | 0.02 |
fresh_pool |
gemm128 | 15.38 (6/6, min 15.37) | 0.03 (min 0.02, max 0.04) |
with_team |
gemm128 | 15.38 (6/6) | 0.02 |
held |
chol2048 | 12.93 | 13.92 |
fresh_pool |
chol2048 | 106.17 | 17.96 |
with_team |
chol2048 | 94.30 | 14.88 (one 80.7 outlier in 5) |
(a) met. No process in any arm is in the slow mode: the per-call pool is 0.03 ms at gemm128 against
15.38 ms on 0.1.3 (a ~500x improvement, and better than any #14 head so far — d430347 was 0.06 ms), and
chol2048 comes down from 106/94 ms to 18/15 ms.
(b) quiet, µs per call
| arm | base | libgomp | head | head/base |
|---|---|---|---|---|
held_team |
1 493 | 1 682 | 1 499 | +0.4 % |
held_pool |
1 575 | – | 1 652 | +4.9 % |
fresh_pool total |
131 788 | – | 13 508 | 0.10 |
(b) met — both held arms inside 10 %, and held_team sits at libgomp's level (1 499 against 1 682).
fresh_pool phases at the new head (µs per call): build 139 (was 1 967 with a Rayon pool),
acquire 11 528, call 1 572, release 13, drop 115 (was 1 366) — the quiet-host 60-80 ms
that survived every #14 head is gone: building a pool no longer starts a Rayon runtime, and a one-call
pool's teardown is 0.1 ms rather than 1.4 ms.
(c) under --load 8:10, µs per call
| arm | base | libgomp | head | head/libgomp |
|---|---|---|---|---|
held_team |
1 561 | 1 687 | 1 481 | 0.88x |
held_pool |
2 088 | – | 1 620 | 0.78x base |
fresh_pool call |
80 970 | – | 1 843 | – |
fresh_pool total |
132 692 | – | 16 114 | 0.12x base |
gaps under load, first section: head 17.0 µs (1 ms gap) / 15.6 µs (10 ms) against base 16.3 / 16.9 and
libgomp 19.3 / 73.2.
(c) met — held_team is 0.88x libgomp, i.e. faster than the reference, and the gap wake is the same as
0.1.3's spin and four times better than libgomp at a 10 ms gap.
Verdict
| criterion | d430347 (#14) |
1f2145b (#14) |
78b59a1 (#16) |
|---|---|---|---|
| (a) no slow mode in any arm | met (0.06 ms) | not met (11.01 ms) | met (0.02-0.03 ms, 6/6 clean) |
| (b) quiet held within 10 % | met (+2.1 / +5.2 %) | held_pool +123 % |
met (+0.4 / +4.9 %) |
| (c) loaded within libgomp | not met (3.45x) | not met (3.33x) | met (0.88x) |
All three criteria are met on this host, and the two cells that pulled against each other for five #14
rounds stop pulling: the own-thread pool makes a one-call pool cheap to build and to drop (139 / 115 µs
instead of 1 967 / 1 366 µs), which is what the fresh-pool arms needed, without any preemption throttle in
the held path.
Two notes for the record: one with_team/chol2048 process in the head set took 80.7 ms once (the other
four 13.7-14.9 ms), so that arm's tail is not zero; and held/chol2048 is 13.92 against 12.93 ms on 0.1.3
(+7.7 %), which is inside the spread but is the only cell that moved the wrong way.
Breaking under 0.x semver: Pool drop returns shared workers to a process-wide reserve instead of joining them, `external` on a team token no longer runs inside Rayon (`external_rayon` behind the new `rayon` feature), `parallel` no longer pulls Rayon, and `idle_runs_rayon` is a no-op. README gains an "Upgrading from 0.1" section. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…th; libgomp's waits (#21) main returns to the Rayon version of #19 (9dfcbdb): src/, tests/, docs/ and scripts/ as on that commit. A pool with default thread settings keeps its Rayon pool in a process-wide per-width cache on drop and the next pool of that width reuses the threads; waits follow libgomp (spin_polls, 16 yields, sleep). The own-threads version (#16) stays on `develop` (maintainer decision 2026-10-30); docs/design/own-threads.md leaves main. README "Upgrading from 0.1" rewritten for the Rayon version. Closes #20. Supersedes #19. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Replaces the private Rayon
ThreadPoolwith threadteam's ownstd::threadworkers. Design: #15 anddocs/design/own-threads.md. Maintainer direction (2026-10-29): after #14's iterations on the Rayon base (e76036c,1f2145b) did not meet (a), (b) and (c) together, move to own threads, and keep Rayon interop as an opt-in layer.Closes #15. Supersedes #14, which stays a draft as the measurement record; its commits (the wait benchmarks
a3a9320and libgomp's waits) are the base of this branch. Refs #13, #12.What changes
625a593). libgomp's waits only: spin 300000 polls, yield 16 times, futex. Preemption detection, warm-up and the pressure knobs from Waits like the OpenMP runtimes: bounded poll spin, yields, then sleep; wait benchmarks (faer#107) #14 are gone.0e259b5).Pool::build(no implicit global pool). A pool with default thread settings leases workers from it and returns them on drop. A pool built per call (faerwith_team) reuses running threads instead of spawning fresh ones.thread_name,on_start, a stack size or a QoS of its own gets dedicated workers, which its drop joins, as before (tcontract's C API).fork,for_rangesandexternalpublish a stack-allocated batch on the pool's intrusive queue; no allocation.ThreadId; no thread-locals.RESERVED,LENT,BUSY,DETACH), checked with loom.parallelno longer pulls Rayon.rayonis a separate opt-in withExec::external_rayon.d6fe3f6). The gpt-6.1-sol review of the implementation found three deadlocks, an endless wake-up and an affinity regression; all are fixed. Each has a regression test that fails or hangs with its fix undone (docs/design/own-threads.md, "Post-review resolutions").bench-ab.shsummary (78b59a1). Parses params that hold commas (thesectionsrows) and keeps the Rayon reference rows apart from threadteam's.Decisions for the maintainer
PooldropPooldropped on its own workerdrop_from_a_worker_of_the_pool_does_not_hangis kept (shared and dedicated)idle_runs_rayonidle_spin,release_idle,resident_membersexternalon a team tokenexternal_rayon(featurerayon)parallelThese are unchanged:
check-no-public-rayon.sh);Execis neitherClonenorCopy;Verification (primerose: EPYC 7713P, 64 CPUs, shared and loaded, load average about 35)
d6fe3f6:deny bans, no-public-rayon,cargo doc(-D warnings) and theaarch64-apple-darwincheck;taskset -c 0-2:idle_clock,oversubscribed,team,own_threadsandteam_owner, five times each.tests/own_threads.rscovers reuse (a spawn counter), dedicated workers with hooks, A → B → A, broadcast order, task panics, the affinity of a reused worker, anon_startpin kept on a dedicated worker, and a waiting broadcast share that does not start a later broadcast;t4a-faer --lib: 580 passed;tenferro-cpu: pass;alloc_counts::the_pool_route_adds_a_constant. It failed in 2 of 4 runs on this branch (batch 2 = 99 allocations) and in 0 of 3 on 0.1.3, and passed 5 of 5 times with--test-threads=1(0 allocations).gemm_path_allocations_are_a_pinned_upper_boundin the same file does not takeserialised(), so its gemm allocations land in the window.a3a9320(base, 0.1.3 waits) againstd6fe3f6,OMP=1, width 16 on CPUs 48-63, 6 rounds. Medians of the round medians in µs. Loaded host, so indicative only:The fresh_pool phases on head are build 29 µs, acquire 2.9 ms (the first call wakes reused workers), release 1.4 µs and drop 20 µs. In the 10 ms gap the head sleeps after its spin budget, like libgomp, where the base spins indefinitely: that is criterion (d).
Not yet done: the acceptance runs (criteria (a)-(d), as on #14)
broccoli (64 threads):
(a):
acquire_probe_107, every arm, on tensor4all/faerprobe/107-acquire-per-call(base, threadteam 0.1.3) againstprobe/107-acquire-per-call-own(this branch; it readsPROBE_SPIN_POLLSonly).evo (quiet):
No publication. The monitor merges.
🤖 Generated with Claude Code