Skip to content

Waits like the OpenMP runtimes: bounded poll spin, yields, then sleep; wait benchmarks (faer#107) - #14

Closed
shinaoka wants to merge 7 commits into
mainfrom
feat/wait-openmp
Closed

shinaoka wants to merge 7 commits into
mainfrom
feat/wait-openmp

Conversation

@shinaoka

Copy link
Copy Markdown
Member

Makes every team wait staged, the way the OpenMP runtimes wait: a bounded poll spin, a few yields, then sleep. It also adds the wait benchmarks that judge the change. Design: #13 and docs/design/waiting.md. Trigger: tensor4all/faer#107. Maintainer decision (2026-10-29): 「threadteam の待ち方を OpenMP に近づける」, fixed on the Rayon base first.

Closes #13. Refs #12 (the spare worker stays; see below).

Commits

  1. benches (a3a9320):
    • benches/wait_scenarios.rs covers short sections (5-50 µs × 200), the first section after gaps of 0 / 100 µs / 1 ms / 10 ms, acquisition (team held / pool held / a new pool per call, also timed from build to drop), nested splits into pairs, width = CPUs against three quarters, a foreign-load generator (--load K:P), and a Rayon scope reference.
    • benches/omp/wait_omp.c is the libgomp reference.
    • scripts/bench-waits.sh runs one checkout's passes into docs/design/waiting-data/<host>.csv.
    • The README section "Wait benchmarks" gives the evo and broccoli procedures.
  2. waits (904501e):
    • Staged waits. A reserved member waiting for work, the leader in a section's join, and a participant in a barrier each spin for PoolBuilder::spin_polls polls (a count, like GOMP_SPINCOUNT; 12288 on x86-64, about 300 µs on Zen 3), then yield 16 times, then sleep.
    • Where they sleep. Joins and barriers sleep on one pool-wide Parker, woken by the last member's decrement, the last arrival and Section::fail. The member reads the pool's parker before its final decrement and never touches the section afterwards.
    • Scheduling pressure. A spinner that sees more than 200 µs between its clock reads (one per 64 polls) ends its spin, and the pool's waits start throttled at 100 polls for 100 ms. This is a heuristic; it only shortens active waiting.
    • Idle members. A member released by a scoped team returns to Rayon after the spin budget or idle_spin, whichever comes first.
    • Spare worker. Kept. Removing it breaks Exec::external, whose installed callback runs on it (into_team(width) needs a pool of width workers: one worker always parks when the leader is an outside thread #12 comment); broccoli also showed the spare does not separate the slow arms.
    • Rayon. Nothing Rayon manages changed.

Verification (primerose: EPYC 7713P, 64 CPUs, saturated by other users)

  • Local gate:

    • fmt and clippy (all features, no default features) pass;
    • cargo test passes with default features, all features (twice) and no default features;
    • loom: 7 models, including the three new ones (a leader asleep in a join, consecutive barrier rounds, a cancellation waking a sleeper); each new model deadlocks when its wake is removed;
    • deny bans, no-public-rayon, cargo doc and the aarch64-apple-darwin check pass;
    • idle_clock, oversubscribed and team pass five times under taskset -c 0-2.
  • New tests:

    • Waiter unit tests (deterministic deadlines);
    • the root-scope budget choice (src/tests/placement.rs);
    • sleeping-path stress tests and a panic waking sleepers in a barrier (tests/team.rs);
    • an allocation-free block for the sleeping path;
    • idle_clock rewritten for count-based episodes.
  • Downstream, with threadteam patched to this branch:

    • faer t4a-faer --lib: 580 passed;
    • tcontract-rs workspace and tlinalg-rs workspace: pass, including c_abi once the C-API library is built, which is that test's own precondition;
    • tenferro tenferro-cpu: pass.
  • Bench, base against head, width 6 on the loaded host (median per call; not an acceptance run):

    • sections 5 µs × 200: 776 ms → 14.5 ms (ideal 1 ms);
    • first section after a 1 ms gap: 10.9 ms → 47 µs;
    • nested: 742 ms → 38 ms (ideal 2 ms);
    • pool held, members per call: 246 ms → 2.5 ms;
    • new pool per call: 394 ms → 62 ms.

    The full table is in docs/design/waiting.md. Empty-section dispatch: 519 → 530 ns at width 2 and 767 → 862 ns at width 6, under drifting load.

  • faer probe, 16 threads on the loaded host:

    • gemm 128, team per call: 9.7 → 0.11 ms;
    • gemm 128, fresh pool: 11.7 → 0.80 ms;
    • chol 2048: 307 → 52 ms.

Not yet done: the acceptance runs

  • evo (quiet): scripts/bench-waits.sh <label> "" none on a3a9320 (base) and on this head. sections, gaps and nested must not regress.
  • broccoli (64 threads, foreign load):
    • taskset -c 0-63 scripts/bench-waits.sh <label> 64,48 8:10 on both commits;
    • the faer#107 arms of acquire_probe_107: tensor4all/faer branch probe/107-acquire-per-call (base, threadteam 0.1.3) against probe/107-acquire-per-call-wait (this head).

🤖 Generated with Claude Code

shinaoka and others added 2 commits October 29, 2026 22:58
…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>
@shinaoka

Copy link
Copy Markdown
Member Author

Acceptance measurement on evo (quiet, boost=0 checked before every pass), as the README's "Wait benchmarks" section describes: taskset -c 4-11 scripts/bench-waits.sh <label> 8,4,2 none, observer on 0-3,12-15. Base is a3a9320 (your benches commit, old waits), head is 904501e, so both sides run the same harness.

Verdict: sections, acquire and nested are unchanged. gaps regresses by design, and that is the one thing to decide on.

scenario metric cells median head/base min max
sections (200 x 5/20/50 us) per call 9 1.003 1.002 1.031
acquire (50 x 20 us, held team / held pool / fresh pool) per call 9 1.004 1.002 1.007
acquire fresh pool build to drop 3 1.012 0.996 1.013
nested (100 pair splits) per call 2 1.007 1.005 1.007
gaps, gap 0 / 100 us first section 6 1.01 1.007 1.033
gaps, gap 1 ms first section 3 2.30 1.99 2.63
gaps, gap 10 ms first section 3 10.8 10.5 12.5
gaps, gap 1 ms whole call 3 1.15 1.11 1.19
gaps, gap 10 ms whole call 3 2.02 1.99 2.32

The wake latency at width 8 goes from 6.0 us to 16.1 us after a 1 ms gap and to 75.0 us after a 10 ms gap (width 2: 5.5 to 11.1 and 58.1; width 4: 5.7 to 13.2 and 61.5). That matches the design: 12288 polls is about 300 us of spinning here, so a gap below the budget still finds spinning members (0 and 100 us do not move) and a gap above it finds them asleep. On the saturated host your own run showed the opposite direction (10.9 ms to 47 us), so the crossover is the spin budget and not a defect. Whether that trade is wanted is the question: the README says gaps must not regress, and on a quiet host it does.

Downstream (faer): t4a-faer at main 7e5e9e3 linked against the registry threadteam 0.1.3 versus the same faer with threadteam patched to 904501e, through fbench (campaign protocol, two process sets, f64 128/256, both orders):

budget cells median head/base min max
4T 12 0.997 0.916 (cholesky 128 f) 1.038 (lu 128 c)
8T 12 1.002 0.996 1.037 (lu 128 c)

qr_factor and cholesky are at 1.00 +-1%; lu in column-major order is consistently +2 to +3.7% at both budgets and both sizes, in both process sets. No regression beyond that.

One caveat on the method: my first pass ran while another user's job was busy on the host (load average 15 to 18, abeinsum), which made the 8T cells wildly bimodal (40 to 65 ms against 0.7 ms for the same cell). That pass is discarded and kept in the run directory; every number above is from the quiet pass that followed.

@shinaoka

Copy link
Copy Markdown
Member Author

Broccoli acceptance run: main 4c73c2b vs PR 904501e — scripts/bench-waits.sh collected, but the host was not quiet

Ran the README's broccoli procedure on both checkouts, with the PR's bench sources copied into a main
worktree so both arms use the same harness: taskset -c 0-63 scripts/bench-waits.sh <label> 64,48 8:10
(unloaded pass and load pass), plus the libgomp driver. 153 rows per arm, CSV in the run dir.

Read the numbers with the host caveat first. Another user's two Julia jobs were running for part of
the window (load average 25 in the 5-minute sample taken right after the run). That is not a
footnote — the libgomp row shows it: gaps 64 gap=10000us call is 161 ms on the main pass and
5.2 ms on the PR pass
, and libgomp cannot be affected by this PR at all. So parts of the "main is
slow" column are the host, not the library.

acquire scenario (the faer#107 usage), µs per call

load width arm main PR PR/main
none 64 fresh_pool (call) 131 660.9 6 152.1 0.05
none 64 fresh_pool (total) 186 722.8 10 966.8 0.06
8x10% 64 fresh_pool (call) 622 849.5 6 950.6 0.01
8x10% 64 fresh_pool (total) 672 589.9 11 778.7 0.02
none 64 held_team 1 476.2 6 118.8 4.14
none 64 held_pool 1 564.5 6 593.7 4.21
none 48 held_team 1 406.3 1 429.2 1.02
none 48 held_pool 1 438.3 1 332.5 0.93
8x10% 48 fresh_pool (call) 2 291.2 6 354.3 2.77
8x10% 48 fresh_pool (total) 11 798.3 10 139.4 0.86

The faer#107 collapse is gone at width 64 — fresh_pool 131.7 ms -> 6.2 ms unloaded and 622.8 ->
7.0 ms under load, i.e. the criterion "width 64 under load is no longer slow" holds by 21-90x. What
the PR costs is visible in the same table: held_team/held_pool at width 64 become 4.1-4.2x
slower
(1.48 -> 6.12 ms), while at width 48 they do not move (1.02 / 0.93) — the bounded spin pays a
wake per section when the participants fill every CPU, and not when there is slack.

gaps scenario, threadteam (µs, first = first section of the call)

load width param metric main PR PR/main
none 64 gap=1000us first 17.1 144.0 8.4
none 64 gap=10000us first 17.2 496.3 28.8
8x10% 64 gap=10000us first 16.0 148.0 9.2
none 48 gap=10000us first 14.2 361.5 25.4
none 48 gap=1000us first 15.4 116.3 7.6

This is the trade the PR makes explicit: 0.1.3 kept every member spinning (park after 200 ms), so a
call after a 10 ms gap paid ~17 µs; the PR's bounded poll parks them, so the same call pays 100-500 µs
to wake. At width 48 the same cost appears (25x), so it is not a full-subscription effect — it is the
price of not keeping 48-64 threads spinning between calls.

The faer#107 arms (acquire_probe_107, 64T, 20 processes per arm)

The matrix was stopped early: by then the host was saturated (guard reading 100 % on all 64 CPUs for
consecutive cells), and a number taken in that window is not evidence. What completed before the stop,
with the caveat that it too falls inside the busy window:

arm main: processes / median PR: processes / median
held, gemm128 50 calls 20, 8.00 ms, 10 slow 19, 0.10 ms, 2 slow

Verdict so far, and what is missing

  • fresh_pool/with_team at width 64: fixed, 21-90x, in threadteam's own bench.
  • held_team/held_pool at width 64 and any call after a gap: slower (4x and 100-500 µs of wake).
    Width 48 is unaffected for the held arms, so the README's "width 48 does not regress" holds for those
    and not for the gap rows.
  • The acquire_probe_107 comparison (four arms x two kernels x 20 processes) is incomplete: it
    needs a quiet host. I will re-run it and post the full table then; the acceptance decision should not
    rest on this pass.

…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>
@shinaoka

Copy link
Copy Markdown
Member Author

After the acceptance runs: c0c024b

The acceptance runs found two problems.

  • evo: the first section after a 1 ms / 10 ms gap was 2.3x / 10.8x slower than 0.1.3 (6 → 16 → 75 µs).
  • broccoli: at width 64, held_team / held_pool were 4.1-4.2x slower than 0.1.3 (1.48 → 6.12 ms, no load generator, but the host was shared).

Changes

  • Default budget, 12288 → 131072 polls (about 3 ms on Zen 3, the order of libgomp's default). With 300 µs, members fell asleep within a 1 ms gap and the next call paid futex wakes.
  • The pool throttles only on repeated preemptions. Before, one observed preemption throttled every wait of the pool to 100 polls for 100 ms. On a mildly shared host that is enough to make every section of a width-64 team sleep and wake: about 63 futex wakes per 20 µs section, which is the order of the 6 ms. Now a single preemption ends only its own waiter's spin, and four within 100 ms throttle the pool.
    • On saturated primerose, throttling only the waiter that saw a preemption (--pressure-hold-ms 0) left acquire as slow as 0.1.3, so the pool-wide throttle stays.
  • Diagnostics. Pool::wait_counters (hidden) counts join sleeps, barrier sleeps, member parks and observed preemptions. The bench prints them under every threadteam row (waits per call: ...), so the broccoli rows show whether the hypothesis holds: many preemptions seen and join sleeps in held_team.
  • Tuning flags for the bench, this version only: --spin-polls, --throttled-polls, --pressure-hold-ms, --pressure-events (via the hidden PoolBuilder::wait_tuning).
  • scripts/bench-ab.sh <base> <head> <rounds> [args] builds two commits and alternates them, so a drift of other users' load falls on both sides. HEAD_VARIANTS adds head runs with tuning flags. The base is a3a9320, the bench commit on the 0.1.3 waits; main has no bench.

Requested runs

broccoli, at a quiet time (20 rounds):

taskset -c 0-63 scripts/bench-ab.sh a3a9320 c0c024b 20 --scenario acquire --widths 64 --calls 40 --impl threadteam
HEAD_VARIANTS='--pressure-events 1;--pressure-events 8;--spin-polls 12288' \
  taskset -c 0-63 scripts/bench-ab.sh a3a9320 c0c024b 10 --scenario acquire --widths 64 --calls 40 --impl threadteam --load 8:10
  • The first run answers whether held_team / held_pool at width 64 is a real regression.
  • The second checks that the loaded fresh_pool fix survives the threshold of 4. On saturated primerose, --pressure-events 1 was faster for fresh_pool (11 ms against 119 ms, noisy), because a pool built for one call sees few preemptions before it ends. If broccoli shows the same, the threshold is the trade-off to settle between the two arms.
  • Then the faer#107 arms of acquire_probe_107 on tensor4all/faer branch probe/107-acquire-per-call-wait, now at c0c024b.

evo:

scripts/bench-ab.sh a3a9320 c0c024b 10 --scenario gaps
HEAD_VARIANTS='--spin-polls 12288;--spin-polls 300000' scripts/bench-ab.sh a3a9320 c0c024b 5 --scenario gaps,sections

Only one --scenario value works at a time: gaps, then sections.

Verification of c0c024b (primerose)

  • Local gate: passes (fmt, clippy for both feature sets, tests in three configurations, loom 7, deny, no-public-rayon, doc, the aarch64 check, and taskset -c 0-2 five times).
  • New unit test: a single preemption throttles only its waiter; a burst of four within the window throttles the pool; observations spread wider than the window never add up.
  • Primerose sweeps: they swing from run to run under its saturation; for example --spin-polls 12288 was the best held_team in one run and among the worst in the next. They are not used to choose defaults.

@shinaoka

Copy link
Copy Markdown
Member Author

evo, quiet host (load < 1, no other job), boost=0 checked, observer on 0-3,12-15, measured on 4-11. The exact commands from your note, so the default widths of the affinity set (8 and 6), with scripts/bench-ab.sh interleaving the two sides.

gaps, a3a9320 against c0c024b, 10 rounds (median of the 10 round medians, us)

width gap base, first head, first ratio base, call head, call ratio
6 0 9.2 8.4 0.91 89.2 85.3 0.96
6 100 us 15.5 9.6 0.62 100.8 68.0 0.68
6 1 ms 17.8 11.8 0.66 103.8 73.4 0.71
6 10 ms 24.1 47.7 1.98 94.7 119.0 1.26
8 0 23.7 7.0 0.30 235.6 70.8 0.30
8 100 us 11.4 11.0 0.96 74.3 74.1 1.00
8 1 ms 14.6 12.6 0.86 78.2 77.5 0.99
8 10 ms 24.4 49.7 2.03 100.1 130.1 1.30

The 131072-poll default fixed the 100 us / 1 ms regressions from the first acceptance run, and the short-section case is now clearly ahead (width 8 at gap 0: 23.7 to 7.0 us per first section, 236 to 71 us per call). The one left is the 10 ms gap, where the ~3 ms budget expires: 24 to 48 us for the first section, 1.26 to 1.30 for the call.

gaps with spin variants, 5 rounds (median of 5, us; first section / whole call)

width gap base head default --spin-polls 12288 --spin-polls 300000
6 1 ms 15.3 / 97.3 11.1 / 72.2 25.1 / 84.1 11.1 / 72.2
6 10 ms 24.1 / 93.9 47.5 / 121.5 57.9 / 132.7 25.2 / 91.7
8 0 23.7 / 235.7 7.0 / 70.6 7.1 / 70.9 7.1 / 70.9
8 1 ms 16.4 / 80.1 13.4 / 77.3 18.9 / 83.6 12.8 / 77.2
8 10 ms 24.2 / 100.1 49.6 / 131.4 61.6 / 144.0 27.2 / 99.9

--spin-polls 300000 (about 7 ms here) removes the last regression: the first section after a 10 ms gap is 25.2 and 27.2 us against the base's 24.1 and 24.2, the call is 0.98 and 1.00 of the base, and it keeps the width-8 gap-0 win (7.1 us). --spin-polls 12288 is the worst of the three on that cell, as in the first acceptance run.

sections with the same variants, 5 rounds (per call, us)

width work base head default --spin-polls 12288 --spin-polls 300000
6 5 us 1284.7 1286.3 1292.1 1289.7
6 20 us 4286.2 4285.2 4285.1 4292.1
6 50 us 10282.2 10284.3 10287.1 10291.8
8 5 us 1397.2 1405.7 1409.9 1409.1
8 20 us 4397.4 4406.4 4413.4 4410.2
8 50 us 10391.5 10404.3 10409.8 10410.2

Every variant is within 1.009 of the base: a longer spin budget costs nothing on the short-section scenario, so the 10 ms gap cell is the only thing a budget sweep has to decide.

Raw rows and the waits per call lines are in shinaoka-advisor/work/runs/threadteam-14/ab/ (gaps10.csv, gapsvar.csv, sectionsvar.csv and their logs).

@shinaoka

Copy link
Copy Markdown
Member Author

c0c024b re-run on broccoli: the held-arm regression is gone unloaded, fresh_pool is fixed under load, and the held arms still pay the throttle where it fires

Commands exactly as in the 6500370837 comment, scripts/bench-ab.sh alternating base a3a9320 and head
c0c024b so any drift hits both sides. Host: EPYC 7702P, taskset -c 0-63, no boost, no other load
generator unless stated. Another user's two Julia jobs were present earlier in the window and gone by the
time of the runs (their residue — two cursor-server threads and the codex monitor at 3-4 % — stayed);
the load average is not a usable indicator here because our own 64 spinning members dominate it
(samples 45-50 during a run).

(1) width 64, acquire, 20 interleaved rounds, no load generator, µs per call

arm base a3a9320 (median of 20) head c0c024b head/base
held_team 1 500 1 545 1.03
held_pool 1 590 1 696 1.07
fresh_pool (call) 94 299 60 704 0.64
fresh_pool (total) 142 208 73 956 0.52

The 4.1-4.2x held_team / held_pool regression is gone: 1.03 / 1.07 against the earlier 4.1 /
4.2, which is what the 12288 -> 131072 poll budget was for. fresh_pool is only 1.6x better in this
pass, and I do not believe that column from this pass: the same binary and the same scenario measured
6.6 ms under a deliberate 8x10 % load in the next table, so something else was on the host during this
one.

(2) width 64, acquire under --load 8:10, 10 interleaved rounds, µs per call

arm base head --pressure-events 1 --pressure-events 8 --spin-polls 12288
fresh_pool (call) 85 819 6 619 6 580 13 756 8 998
fresh_pool (total) 132 096 19 878 15 931 27 430 14 481
held_pool 1 689 6 924 6 962 7 477 7 417
held_team 1 562 5 600 5 981 5 962 5 662

Under load the fix holds where it matters: fresh_pool goes 85.8 ms -> 6.6 ms (13x), and
--pressure-events 1 is marginally better again (6.58 ms), the same ordering primerose saw.
And the held arms pay 3.6-4.1x (1.56-1.69 -> 5.6-6.9 ms) as soon as preemptions are observed, which
is exactly the throttle: a single throttled waiter makes each of the 50 sections of the call sleep.
Neither --pressure-events 1 nor 8 nor a 12288 budget changes the held arms, so the threshold is not
what governs them — the pool-wide throttle is.

(3) acquire_probe_107 (faer#107 branch probe/107-acquire-per-call-wait), 64T, threadteam at c0c024b vs 4c73c2b

arm kernel main 4c73c2b c0c024b
held gemm128, 50 calls 0.02 ms 0.05 ms
team_per_call gemm128 0.02 ms 0.05 ms
fresh_pool gemm128 15.99 ms 0.06 ms
with_team gemm128 16.00 ms 0.05 ms
held chol2048, 10 calls 13.08 ms 15.08 ms
team_per_call chol2048 12.29 ms 13.17 ms
fresh_pool chol2048 124.15 ms 59.59 ms
with_team chol2048 146.17 ms 42.30 ms

10 processes per arm for gemm128, 5 for chol2048, ≥3x-of-fastest counted as the mode: no process
in any c0c024b arm is in the mode
. The gemm cell that was 841x slow is at 0.06 ms — the per-call pool
construction no longer costs ~16 ms. chol2048 improves 2-3.5x but stays 3-4x above the held level,
because at that size the call itself is 13-15 ms and the per-call fixed cost still shows.

Two notes on the probe branch itself, since they cost me a build: faer/examples/acquire_probe_107.rs
there calls faer::team::with_team, which this faer revision does not have (no pub use utils::team
either), and the crate's examples/bench.toml disables example auto-discovery, so the file needs an
explicit [[example]] entry. I built it with the with_team arm re-expressed as a pool+team per call
(which is what that helper does) — the measurement above is unaffected, but the branch as committed does
not compile.

Verdict from this host

  • unloaded: held arms at parity (1.03 / 1.07) — the regression reported from the first acceptance run is
    fixed by the budget change;
  • loaded: fresh_pool fixed 13x, held arms 3.6-4.1x slower through the pool-wide throttle, and the
    threshold variants do not move that;
  • the faer#107 arms: fixed at 64T, with no process in the mode.

If the loaded held-arm cost matters (it is the tenferro CpuEngine shape), the next thing to try is the
pool-wide throttle itself under foreign preemption, which is what --pressure-hold-ms 0 was for on
primerose, and which this host can test now that we have a load generator that is under our control.

@shinaoka

Copy link
Copy Markdown
Member Author

Correction to the fresh_pool column of table (1), and the load profile

The load logger I ran alongside starts at 00:16:55 with 0.33 and reads 45-50 during the benches,
so that rise is our own 64 spinning members, not another user — the two Julia jobs I mentioned were gone
by then. That makes the quiet-host fresh_pool number in table (1) a measurement, not contamination:
60.7 ms per call on a quiet host against 6.6 ms under 8x10 % load. Backwards at first sight, and the
explanation that fits the mechanism is that a freshly built pool's 64 members spin for the new 131072-poll
budget, so the drop at the end of the call has 64 spinners to stop; under load the preemption throttle
parks them, and the teardown is cheap. The total column says the same: 74.0 ms quiet against 19.9 ms
loaded for the same arm.

If that is right, fresh_pool has a quiet-host cost the loaded runs hide — which is worth one
--scenario acquire --throttled-polls sweep on a quiet host before choosing the throttle for the
one-call-pool case.

…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>
@shinaoka

Copy link
Copy Markdown
Member Author

After the 15:51Z broccoli runs: 2773c36

(1) Under load, held_team / held_pool were 3.6-4.1x slower than 0.1.3

Cause. The pool-wide throttle: once one waiter saw a preemption (or four within 100 ms), every wait of the pool started with 100 polls, so each 20 µs section woke about 63 sleeping members.

Change: the throttle is per thread.

  • A member that sees itself descheduled ends its spin, and moves its own slot's deadline to now + 100 ms. Its next waits start throttled; the other members keep their full spin.
  • The leader's join and the barriers end their own spin on a preemption, without a lasting deadline (the leader is not a slot).
  • The pool-wide throttle is off by default and stays a benchmark knob: --pressure-events N, a pool throttle after N preemptions within the hold.
  • On saturated primerose (load average above the CPU count) the pool-wide setting was faster, since every waiter is preempted there anyway. broccoli is the host that matters.
  • Unit test: a member's preemption throttles that member's later waits, and not another thread's.

(2) On a quiet host, fresh_pool took 60.7 ms

Reading of the drop path. Dropping the team (into_pool or Team drop) releases its members with idle_spin 0:

  • the state goes to IDLE and the slot's parker is woken;
  • a spinning member sees the state change on its next poll, and a parked one is woken;
  • the member leaves its loop at once (idle_spin elapsed), then idles as a Rayon worker (33 yield rounds, then sleep);
  • Pool drop then terminates Rayon and joins the threads.

Nothing in that path waits for a spin budget, so it does not look like the cause. The bench now times the phases of a one-off pool separately, so the next quiet run locates the 60.7 ms:

  • fresh_pool rows build, acquire, call, release, drop and total;
  • the waits per call counters under them.

On loaded primerose at width 8 the phases were build 0.6 ms, acquire 21 ms, release 8 µs and drop 6 ms. That host is saturated, so this does not decide the quiet case.

Requested runs

broccoli:

HEAD_VARIANTS='--pressure-events 4' \
  taskset -c 0-63 scripts/bench-ab.sh a3a9320 2773c36 10 --scenario acquire --widths 64 --calls 40 --impl threadteam --load 8:10
taskset -c 0-63 scripts/bench-ab.sh a3a9320 2773c36 10 --scenario acquire --widths 64 --calls 40 --impl threadteam

Then the faer#107 probe: tensor4all/faer probe/107-acquire-per-call-wait, now at 2773c36.

evo:

scripts/bench-ab.sh a3a9320 2773c36 10 --scenario gaps
scripts/bench-ab.sh a3a9320 2773c36 5 --scenario acquire --calls 40 --impl threadteam

The second run carries the fresh_pool phases.

Local gate on 2773c36

  • fmt and clippy (both feature sets) pass.
  • Tests pass with default features, all features and no default features.
  • loom 7 models, deny bans, no-public-rayon and doc pass.

@shinaoka

Copy link
Copy Markdown
Member Author

evo, quiet host (load 0.2 to 1.3 during the runs, no other user's job at the start), boost=0 checked, observer on 0-3,12-15, measured on 4-11, the exact commands from your note (scripts/bench-ab.sh, default widths of the affinity set, so 8 and 6).

gaps, a3a9320 against 2773c36, 10 rounds (median of the 10 round medians, us)

width gap base, first head, first ratio base, call head, call ratio
6 0 5.9 5.9 1.00 57.8 58.2 1.01
6 100 us 5.9 6.0 1.00 57.8 58.1 1.01
6 1 ms 5.9 6.0 1.01 57.6 58.4 1.01
6 10 ms 5.9 47.5 8.09 57.6 101.1 1.76
8 0 6.1 6.1 1.00 59.2 59.5 1.01
8 100 us 6.1 6.1 1.00 59.4 59.7 1.01
8 1 ms 6.1 6.1 1.00 59.6 59.7 1.00
8 10 ms 6.0 51.3 8.50 59.1 106.5 1.80

Up to 1 ms the two sides are identical within 1%, which is better than c0c024b measured here two hours ago (that run had 0.62 to 0.86 at 100 us and 1 ms, on a base that was itself noisy at 11 to 18 us). The 10 ms gap is the remaining cell: 6.0 to 51.3 us on the first section, 1.76 to 1.80 on the call, and it is the cell where --spin-polls 300000 closed the gap in the previous round.

acquire --calls 40 --impl threadteam, 5 rounds (us)

width arm base head ratio
6 held_team 1039.3 1040.9 1.002
6 held_pool 1047.4 1050.2 1.003
6 fresh_pool 1038.1 1042.0 1.004
8 held_team 1048.3 1051.1 1.003
8 held_pool 1056.2 1058.0 1.002
8 fresh_pool 1046.8 1050.6 1.004
8 fresh_pool, build to drop 1307.3 1326.0 1.014

Nothing to fix on this host, and no 60.7 ms fresh_pool here (the largest maximum in the fresh_pool call rows is 47.7 ms on one call, in a median of 1047 us; the total maximum is 47.7 ms against a 1325 us median, so one call in a round still pays it, on the base as well).

The fresh_pool phases (head only; the base bench at a3a9320 predates them), median of the 5 round medians:

width build acquire call release drop total
6 96.0 13.3 1042.1 1.0 74.0 1229.6
8 127.0 20.4 1052.1 1.4 116.6 1326.0

So a one-off pool costs 96 to 127 us to build, 13 to 20 us to acquire and 74 to 117 us to drop: 183 to 264 us of the 1230 to 1326 us total, the rest is the 40 sections. The 60.7 ms reading from the primerose run is not a phase cost on a quiet host; the one outlier in these rows is a single call's maximum (47.7 ms), which the base shows too.

Raw rows and the waits per call lines: shinaoka-advisor/work/runs/threadteam-14/ab2/.

@shinaoka

Copy link
Copy Markdown
Member Author

2773c36 on broccoli: the per-thread throttle does not remove the loaded held-arm cost, and the quiet fresh_pool cost is in the call, not the drop

Commands as in the 6501736773 comment, scripts/bench-ab.sh a3a9320 2773c36 10 at width 64, acquire,
--calls 40, load pass first with HEAD_VARIANTS='--pressure-events 4', then the unloaded pass, then the
faer#107 probe. taskset -c 0-63; a 5 s load logger ran alongside: 0.16 at the start, 45-50 during the
benches (our own 64 spinning members), and no other user's job — the earlier Julia jobs are gone.

(1) Under --load 8:10, µs per call

arm base a3a9320 head 2773c36 head --pressure-events 4
held_team 1 499 5 773 (3.85x) 5 609 (3.74x)
held_pool 1 676 8 496 (5.07x) 7 077 (4.22x)
fresh_pool call 53 181 16 626 6 756
fresh_pool total 103 513 30 391 19 851

The per-thread throttle did not help the loaded held arms — 3.85x and 5.07x here, against the 3.6-4.1x
that the pool-wide throttle gave in the c0c024b run, and --pressure-events 4 only moves held_pool
from 5.07x to 4.22x. So the pool-wide throttle was not the whole cause either: with throttling per thread
the sections still wake sleepers, and the numbers say so. fresh_pool does improve (53.2 -> 16.6 ms, and
6.8 ms with the pool throttle re-enabled), so the two cases pull in opposite directions.

Counter lines from this pass (waits per call), for the record: held_team 0.2 join sleeps / 11.8
barrier sleeps / 40.9 member parks / 0.1 preemptions; held_pool 1.6 / 0.0 / 229.4 / 5.0;
fresh_pool 9.7 / 0.0 / 424.1 / 1.55. The held arms' parks per call are the thing to drive to zero; they
are not zero even with --pressure-events 4.

(2) Unloaded, µs per call

arm base head
held_team 1 505 2 689 (1.79x)
held_pool 1 518 1 833 (1.21x)
fresh_pool call 73 445 62 924
fresh_pool total 127 433 76 067

The unloaded held arms regressed relative to c0c024b's parity (1.03 / 1.07 -> 1.79 / 1.21), so the
per-thread throttle costs something where the pool-wide one did not.

(3) fresh_pool phases (new in this version), µs per call

phase unloaded loaded loaded, --pressure-events 4
build 1 979 1 989 1 956
acquire 9 765 9 764 9 735
call 62 924 16 626 6 756
release 14 19 96
drop 1 591 1 535 1 409
total 76 067 30 391 19 851

The quiet-host 60.7 ms is the call, not the drop (62.9 ms of 76.1 ms; release is 14 µs and drop
1.6 ms). That contradicts the reading in the 6501736773 comment — nothing in the drop path waits, agreed,
and the time is spent in the 50 sections of the first call on a freshly built pool, i.e. 1.26 ms per
20 µs section. The acquire phase at 9.8 ms per call is the second largest and is also outside the drop.

(4) faer#107 arms (probe/107-acquire-per-call-wait @ 2773c36), 64T

arm kernel main 4c73c2b 2773c36
held gemm128 0.02 ms 0.02 ms
team_per_call gemm128 0.02 ms 0.02 ms
fresh_pool gemm128 15.99 ms 0.05 ms
with_team gemm128 16.00 ms 0.05 ms
held chol2048 13.08 ms 23.77 ms (min 12.66)
fresh_pool chol2048 124.15 ms 65.51 ms
with_team chol2048 146.17 ms 59.07 ms

The gemm128 fix holds at 0.05 ms with no process in the mode. chol2048 stays 3-5x above the held
level, and its held median (23.8 ms against a 12.7 ms minimum) says that cell was swinging during this
pass, so treat that row as provisional.

Two things I would fix before the next round: the loaded held-arm regression needs a cause that survives
per-thread throttling (the parks per call above are the measurement to chase), and the quiet fresh_pool
call needs the same phase treatment inside the call (per-section latency on a fresh pool) since the drop
is now excluded.

…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>
@shinaoka

Copy link
Copy Markdown
Member Author

Against the acceptance criteria: d430347

Criteria (monitor, 2026-10-30), also in docs/design/waiting.md:

  • (a) the faer#107 slow mode is zero in every arm;
  • (b) on quiet hosts, held_team, held_pool and gaps (0-1 ms) are within 10 % of the base;
  • (c) under load, no worse than libgomp;
  • (d) the 10 ms gap is tuned with the spin budget (300000 polls is acceptable as the OpenMP default).

Change

  • The pool threshold (c0c024b) is the default again.
    • With 2773c36's per-thread 100 ms hold, a member preempted once on a quiet broccoli slept at every section for the hold: held teams were 1.79x / 1.21x slower, against 1.03 / 1.07 with the threshold.
    • The pool is throttled after 4 preemptions within 100 ms; an isolated preemption ends only its waiter's spin.
  • The per-thread variant stays as a knob: --own-hold-ms N, off by default.
  • libgomp gets the held team. benches/omp/wait_omp.c:
    • acquire/held_team: calls of 50 sections of 20 µs on the hot team, back to back;
    • a load generator of its own (WAIT_OMP_LOAD=K:P) and scenario selection.
  • scripts/bench-ab.sh with OMP=1 runs libgomp in every round, with the same --scenario, --widths, --calls and --load. libgomp, base and head (with variants) then land in one table, with and without load.

Smoke on saturated primerose, width 6, one round (not a measurement): held_team base 294 ms, libgomp 301 ms, head 7.5 ms.

Requested runs (same shape on both hosts)

broccoli:

# (b) quiet and (c) under load, with libgomp in every round
OMP=1 HEAD_VARIANTS='--own-hold-ms 100' taskset -c 0-63 scripts/bench-ab.sh a3a9320 d430347 10 --scenario acquire --widths 64 --calls 40 --impl threadteam
OMP=1 HEAD_VARIANTS='--own-hold-ms 100' taskset -c 0-63 scripts/bench-ab.sh a3a9320 d430347 10 --scenario acquire --widths 64 --calls 40 --impl threadteam --load 8:10
OMP=1 taskset -c 0-63 scripts/bench-ab.sh a3a9320 d430347 10 --scenario gaps --widths 64 --calls 40 --impl threadteam --load 8:10

Then (a), the faer#107 probe: probe/107-acquire-per-call-wait, now at d430347.

evo:

# (b), (d)
OMP=1 HEAD_VARIANTS='--spin-polls 300000' scripts/bench-ab.sh a3a9320 d430347 10 --scenario gaps --impl threadteam
OMP=1 scripts/bench-ab.sh a3a9320 d430347 5 --scenario acquire --calls 40 --impl threadteam

If --spin-polls 300000 closes the 10 ms gap without hurting the loaded rows, it becomes the default (criterion d).

Local gate on d430347

All of it passes:

  • fmt and clippy (both feature sets);
  • tests with default features, all features (twice) and no default features;
  • loom 7 models, deny bans, no-public-rayon, doc, the aarch64 check;
  • taskset -c 0-2 five times.

The unit tests cover:

  • an isolated preemption does not throttle the pool;
  • a burst of four does;
  • by default there is no per-thread deadline;
  • with the knob, the per-thread deadline acts.

@shinaoka

Copy link
Copy Markdown
Member Author

evo, quiet host (boost=0 checked, observer on 0-3,12-15, measured on 4-11), the note's evo commands with OMP=1, so libgomp sits in every round. Default widths of the affinity set, so 8 and 6 for threadteam, and width 8 for the libgomp driver.

gaps, 10 rounds (median of the 10 round medians, us; first section / whole call)

width gap base head d430347 head --spin-polls 300000 libgomp
6 0 5.9 / 57.7 6.0 / 58.2 6.0 / 58.3 -
6 100 us 5.9 / 57.8 6.0 / 58.4 6.0 / 58.4 -
6 1 ms 5.9 / 57.9 6.0 / 58.4 6.0 / 58.4 -
6 10 ms 5.8 / 57.6 47.2 / 104.9 5.8 / 57.8 -
8 0 6.1 / 59.3 6.1 / 59.8 6.1 / 59.8 6.6 / 64.3
8 100 us 6.2 / 59.6 6.1 / 59.8 6.1 / 59.8 6.5 / 64.2
8 1 ms 6.1 / 59.3 6.1 / 59.8 6.1 / 59.8 6.7 / 64.5
8 10 ms 6.0 / 59.0 50.8 / 106.1 6.0 / 59.3 15.4 / 73.2
  • Criterion (b) holds: every gap up to 1 ms, and every call, is within 1 % of the base at both widths.
  • Criterion (d) is answered: --spin-polls 300000 makes the 10 ms gap identical to the base (first section 5.8 / 6.0, call 57.8 / 59.3, ratios 1.002 and 0.998), and it costs nothing in the other cells (0.98 to 1.01 everywhere).
  • libgomp is 6 to 10 % slower than the head on the 0 to 1 ms cells (6.6 against 6.1 us first section, 64.3 against 59.8 call) and faster only on the 10 ms gap (15.4 against 50.8), which the 300000 budget removes (6.0 against libgomp's 15.4).

acquire --calls 40 --impl threadteam, 5 rounds (us)

width arm base head libgomp
6 held_team 1038.0 1041.4 (1.003) -
6 held_pool 1045.6 1048.5 (1.003) -
6 fresh_pool 1038.8 1041.5 (1.003) -
8 held_team 1048.9 1051.1 (1.002) 1073.8 (1.024)
8 held_pool 1054.0 1058.5 (1.004) -
8 fresh_pool 1047.1 1048.6 (1.001) -
8 fresh_pool, build to drop 1301.6 1319.3 (1.014) -

The head is within 0.4 % of the base in every threadteam arm; libgomp's held team is 2.4 % behind the head on the same calls (1073.8 against 1051.1). The fresh_pool phases on the head: build 95.4 / 127.1 us, acquire 13.7 / 20.0, call 1041.5 / 1048.6, release 1.0 / 1.4, drop 77.9 / 116.4, total 1229.5 / 1319.3 at widths 6 / 8. No 60.7 ms one-off pool here.

Rows and the waits per call counters: shinaoka-advisor/work/runs/threadteam-14/ab3/.

@shinaoka

Copy link
Copy Markdown
Member Author

d430347 against the three acceptance criteria on broccoli

Commands exactly as in the 6502177949 comment, OMP=1 HEAD_VARIANTS='--own-hold-ms 100',
scripts/bench-ab.sh a3a9320 d430347 10, width 64, acquire, --calls 40, quiet then --load 8:10,
then gaps under load, then the faer#107 probe at d430347. taskset -c 0-63, load logger alongside
(0.27 at the start, 45-50 during the benches — our own members — and no other user's job).

acquire, width 64, µs per call

arm base a3a9320 libgomp head d430347 head --own-hold-ms 100
quiet held_team 1 491 1 688 1 522 (+2.1 %) 2 002 (+34 %)
quiet held_pool 1 581 – 1 663 (+5.2 %) 1 748 (+11 %)
loaded held_team 1 554 1 693 5 842 (3.45x libgomp) 5 937
loaded held_pool 1 550 – 7 036 (4.54x base) 7 225
quiet fresh_pool total 188 061 – 95 118 81 938
loaded fresh_pool total 132 042 – 20 131 20 043

fresh_pool phases (µs per call), quiet / loaded: build 1 967 / 1 967, acquire 9 773 / 9 773,
call 81 773 / 6 726, release 14 / 94, drop 1 348 / 1 431.

gaps under load, µs

param metric base libgomp head
gap=1000us first 17.3 19.2 98.8
gap=10000us first 16.8 73.3 115.8
gap=1000us call 159.2 189.5 961.3
gap=10000us call 157.8 245.4 1 083.0

faer#107 probe (probe/107-acquire-per-call-wait @ d430347), 64T, 10 processes per arm (5 for chol2048)

arm kernel main 4c73c2b d430347 mode
held gemm128 0.02 ms 0.05 ms (min 0.02, max 0.06) 0
team_per_call gemm128 0.02 ms 0.02 ms 0
fresh_pool gemm128 15.99 ms 0.06 ms 0
with_team gemm128 16.00 ms 0.06 ms 0
held chol2048 13.08 ms 13.39 ms 0
fresh_pool chol2048 124.15 ms 55.39 ms 0
with_team chol2048 146.17 ms 29.02 ms 0

Verdict against the three criteria

  • (a) the #107 slow mode is zero in every arm — met. No process of any d430347 arm is in the mode
    (60 processes over two kernels), fresh_pool/with_team at 0.06 ms at 64T, chol2048 no longer
    collapses (55/29 ms against 124/146).
  • (b) quiet held_team / held_pool within 10 % of base — met. 1 522 vs 1 491 (+2.1 %) and 1 663 vs
    1 581 (+5.2 %); the --own-hold-ms 100 knob is the worse setting here (+34 % / +11 %), which is why it
    is off by default.
  • (c) under load, within libgomp — not met. held_team is 5 842 µs against libgomp's 1 693, i.e.
    3.45x slower, and the gaps rows are worse than libgomp too (first section 98.8 vs 19.2 µs at a
    1 ms gap, 115.8 vs 73.3 at 10 ms). held_pool has no libgomp counterpart in this table but is 4.5x its
    own base.

So (a) and (b) are closed and (c) is open, with the same signature as before: the loaded held team pays
per-section waits that libgomp does not. The waits per call counters in ab-load.log are the place to
look next; on this host the pool threshold is not aggressive enough under foreign preemption, and the
per-thread hold makes the quiet case worse rather than the loaded one better.

…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>
@shinaoka

Copy link
Copy Markdown
Member Author

Next comparison: no preemption detection (libgomp-like), e76036c

Change

  • Default spin: 300000 polls (GOMP_SPINCOUNT; criterion d, closed the 10 ms gap on evo).
  • A knob for the preemption detection: PoolBuilder::preempt_gap (hidden), bench flag --preempt-gap-us N.
    • 0 turns the detection off, and with it every throttle (pool and per thread): every wait spins 300000 polls, yields 16 times and sleeps, as libgomp's waits do.
    • A larger value (for example 2000) keeps the throttle only for long preemptions; this is the fallback if (a) is lost without detection.
    • The default stays 200 µs until the runs decide.
  • Branches for criterion (a), the faer probe, which takes no flags:
    • tensor4all/threadteam-rs feat/wait-openmp-nodetect (ad5bc95): this head with the gap default set to 0. It is probe only and not for merge (4 unit tests that assert detection fail there, as expected);
    • tensor4all/faer probe/107-acquire-per-call-wait-nodetect, which points at that branch;
    • probe/107-acquire-per-call-wait now points at e76036c.

Requested runs (one table per host: base, head, head without detection, head with a 2 ms gap, libgomp)

broccoli:

V='--preempt-gap-us 0;--preempt-gap-us 2000'
OMP=1 HEAD_VARIANTS="$V" taskset -c 0-63 scripts/bench-ab.sh a3a9320 e76036c 10 --scenario acquire --widths 64 --calls 40 --impl threadteam
OMP=1 HEAD_VARIANTS="$V" taskset -c 0-63 scripts/bench-ab.sh a3a9320 e76036c 10 --scenario acquire --widths 64 --calls 40 --impl threadteam --load 8:10
OMP=1 HEAD_VARIANTS="$V" taskset -c 0-63 scripts/bench-ab.sh a3a9320 e76036c 10 --scenario gaps --widths 64 --calls 40 --impl threadteam --load 8:10

(a): the faer#107 arms of acquire_probe_107 on probe/107-acquire-per-call (base), probe/107-acquire-per-call-wait (head) and probe/107-acquire-per-call-wait-nodetect.

evo:

OMP=1 HEAD_VARIANTS='--preempt-gap-us 0' scripts/bench-ab.sh a3a9320 e76036c 10 --scenario gaps --impl threadteam
OMP=1 HEAD_VARIANTS='--preempt-gap-us 0' scripts/bench-ab.sh a3a9320 e76036c 5 --scenario acquire --calls 40 --impl threadteam

Decision rule

  • If the variant without detection keeps (a), that configuration becomes the default (gap 0, spin 300000).
  • Otherwise, the gap is raised so that only long preemptions throttle.

Local gate on e76036c

  • fmt and clippy (both feature sets) pass.
  • Tests pass with default features, all features (twice) and no default features.
  • loom 7 models, deny bans, no-public-rayon, doc and the aarch64 check pass.
  • taskset -c 0-2 five times passes.
  • New unit test: with the gap at 0, a descheduled spinner keeps its full budget and records nothing.

@shinaoka

Copy link
Copy Markdown
Member Author

evo, quiet host (boost=0 checked, observer on 0-3,12-15, measured on 4-11), the note's evo commands with OMP=1, so libgomp sits in every round; default widths of the affinity set, so 8 and 6 for threadteam, 8 for the libgomp driver.

gaps, 10 rounds (median of the 10 round medians, us; first section / whole call)

width gap base head e76036c head --preempt-gap-us 0 libgomp
6 0 6.0 / 57.8 5.9 / 58.0 5.9 / 58.0 -
6 100 us 6.0 / 57.9 6.0 / 58.1 6.0 / 58.1 -
6 1 ms 5.9 / 57.8 6.0 / 58.2 6.0 / 58.2 -
6 10 ms 5.9 / 57.8 5.8 / 57.9 5.8 / 57.9 -
8 0 6.1 / 59.5 6.1 / 59.8 6.1 / 59.7 6.5 / 64.1
8 100 us 6.1 / 59.7 6.1 / 59.7 6.1 / 59.8 6.5 / 64.1
8 1 ms 6.1 / 59.2 6.1 / 59.7 6.1 / 59.8 6.7 / 64.4
8 10 ms 6.0 / 59.1 6.0 / 59.4 6.0 / 59.3 15.4 / 73.2
  • Criterion (d) is closed: the 10 ms gap is base-identical at both widths (1.002 and 1.000 on the first section, 1.002 and 1.005 on the call), and every gap up to 1 ms stays within 1 % of the base, so (b) holds for gaps as well.
  • Against libgomp on the same cells: 6 to 10 % faster on the 0 to 1 ms gaps (6.1 against 6.5 to 6.7 us first section, 59.7 against 64.1 to 64.4 call) and 2.5 times faster on the 10 ms one (6.0 against 15.4).
  • Turning preemption detection off changes nothing measurable here (--preempt-gap-us 0 is within 0.2 us, 0.4 %, of the default in every one of these cells), which is what a quiet host should show; the decision between the two belongs to criterion (a), the faer probe.

acquire --calls 40 --impl threadteam, 5 rounds (us)

width arm base head e76036c head --preempt-gap-us 0 libgomp
6 held_team 1037.3 1038.6 1040.1 -
6 held_pool 1045.6 1046.5 1048.6 -
6 fresh_pool 1039.6 1041.5 1040.2 -
6 fresh_pool, build to drop 1226.2 1231.0 1220.4 -
8 held_team 1049.8 1047.9 1048.6 1071.0
8 held_pool 1054.2 1054.4 1056.3 -
8 fresh_pool 1046.2 1048.8 1048.6 -
8 fresh_pool, build to drop 1305.9 1313.4 1324.4 -

Every threadteam side is within 0.5 % of the base, the head is marginally faster on the width-8 held team (1047.9 against 1049.8), and libgomp's held team is 2.2 % behind it (1071.0). With detection off, fresh_pool total is 0.9 % up at width 8 and 0.9 % down at width 6, i.e. nothing.

Rows and the waits per call counters: shinaoka-advisor/work/runs/threadteam-14/ab4/.

@shinaoka

Copy link
Copy Markdown
Member Author

e76036c against (a)/(b)/(c) on broccoli: (a) is back to failing, (b) and (c) depend on the knob

Commands as in the 6502608592 comment, OMP=1 HEAD_VARIANTS='--preempt-gap-us 0;--preempt-gap-us 2000',
bench-ab.sh a3a9320 e76036c 10, width 64, acquire, --calls 40, quiet / --load 8:10 / gaps under
load, then the faer#107 arms. taskset -c 0-63, load logger alongside.

(a) faer#107 arms, 64T, acquire_probe_107, 6 processes per arm (gemm128, 50 calls)

arm base probe/107-acquire-per-call (main 0.1.3) probe/107-acquire-per-call-wait @ e76036c …-wait-nodetect @ e76036c
held 0.02 ms 0.11 ms 0.11 ms
team_per_call 0.02 ms 0.11 ms (min 0.02) 0.11 ms (min 0.02)
fresh_pool 15.99 ms 11.30 ms (6/6, min 11.29 max 11.31) 11.30 ms (6/6)
with_team 16.00 ms 11.30 ms (6/6) 11.30 ms (6/6)

(a) is not met: with the 300 000-poll default the freshly built pool is back at the one-thread time —
11.30 ms in every call of every process, against 0.06 ms at d430347 (and 0.05 ms at 2773c36). The
held and team_per_call arms are fine (0.11 ms, with 0.02 ms minima).

The two probe branches give byte-identical binaries and identical numbers (probe/107-acquire-per-call-wait
and …-wait-nodetect differ in nothing the probe compiles: its source never calls preempt_gap_us, and
grep preempt finds no use in it), so the "nodetect" arm is not actually measured as such here — the knob
lives in the bench (--preempt-gap-us) and the probe would have to set it through
PoolBuilder::preempt_gap_us(0).

(b) quiet, µs per call

arm base libgomp head --preempt-gap-us 0 --preempt-gap-us 2000
held_team 1 489 1 692 1 538 (+3.2 %) 1 506 (+1.1 %) 1 513 (+1.6 %)
held_pool 1 524 – 3 598 (+136 %) 1 632 (+7.1 %) 1 668 (+9.4 %)
fresh_pool total 166 040 – 114 228 131 877 130 862

(b) passes for held_team with every setting, and fails for held_pool with the default (+136 %);
the knob variants bring held_pool to +7 % / +9 %.

(c) under --load 8:10, µs per call

arm base libgomp head --preempt-gap-us 0 --preempt-gap-us 2000
held_team 1 567 1 694 6 097 (3.60x libgomp) 1 541 (0.91x) 1 601 (0.95x)
held_pool 1 552 – 6 934 (4.47x base) 1 673 (+7.8 %) 2 643 (+70 %)
fresh_pool call 78 438 – 5 996 87 620 39 109
fresh_pool total 131 059 – 20 440 112 510 62 781

gaps under load, first section: head 98.2 µs (1 ms gap) and 114.6 µs (10 ms); with
--preempt-gap-us 0 18.4 / 17.9 µs against libgomp's 19.5 / 72.6 µs; with 2000 98.0 / 18.2 µs.

(c) passes only with preemption detection off (--preempt-gap-us 0): held_team 1 541 µs, i.e. 0.91x
libgomp, held_pool +7.8 %, and the gap rows match libgomp. The default is 3.6x libgomp on held_team.
That same setting costs fresh_pool its fix (call 87.6 ms against 6.0 ms).

Where this leaves the three criteria

criterion d430347 e76036c default e76036c + --preempt-gap-us 0
(a) no slow mode in any arm met (0.06 ms) not met (11.30 ms, 6/6) not measured as such (probe does not set it)
(b) quiet held within 10 % met (+2.1 / +5.2 %) held_team met, held_pool +136 % met (+1.1 / +7.1 %)
(c) loaded within libgomp not met (3.45x) not met (3.60x) met (0.91x on held_team, gaps match)

So the two hosts' requirements now point at each other: d430347's pool threshold satisfies (a) and (b)
but not (c); e76036c with detection off satisfies (b) and (c) but gives up (a). What is still missing is
a setting that gives the fresh-pool case the wake it needs without throttling the held team's sections —
and the probe would need to expose the knob to measure that arm, which is a one-line
PROBE_PREEMPT_GAP_US in acquire_probe_107.

…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>
@shinaoka

Copy link
Copy Markdown
Member Author

libgomp's waits by default, detection only while a pool is new: 1f2145b

The direction (monitor, after 16:58Z) is the libgomp-type default, since held pools are the main path (tenferro CpuEngine; libgomp's and libomp's hot teams), and a fix limited to new pools for the fresh-pool collapse.

Change

  • Default after a pool's warm-up: no preemption detection. Every wait spins 300000 polls (GOMP_SPINCOUNT), yields 16 times and sleeps. This is the configuration that met (b) and (c) on broccoli.
  • Warm-up: the first 200 ms after a pool is built.
    • Waiters detect preemptions (gap 200 µs), and the pool throttles after 4 within 100 ms, i.e. c0c024b, which kept (a) at zero.
    • A pool built for one call (with_team, fresh_pool) lives inside that window. A held pool pays it once and runs as libgomp afterwards.
    • After the warm-up, a wait's first clock read finds detection off and stops reading the clock, so the steady state costs nothing in the poll loop.
    • Why new pools: freshly spawned threads start stacked (the scheduler places them before it knows their load), and without detection the spinners never free a CPU to pull one over (87.6 ms).
  • Knobs (hidden; bench flags --preempt-gap-us, --warm-ms):
    • PoolBuilder::preempt_gap sets detection after the warm-up (default 0);
    • PoolBuilder::warm_up sets the warm-up length (default 200 ms; 0 is pure libgomp type).
  • The faer probe takes the settings. acquire_probe_107 on probe/107-acquire-per-call-wait (now at 1f2145b) reads PROBE_SPIN_POLLS, PROBE_PREEMPT_GAP_US, PROBE_WARM_MS and PROBE_PRESSURE_EVENTS into its PoolBuilder, so (a) is measured per configuration. Every mode reads them except with_team, which builds its pool inside faer with the defaults; with the new defaults that is the configuration under test.
    • The -nodetect branches (threadteam feat/wait-openmp-nodetect, faer probe/107-acquire-per-call-wait-nodetect) are superseded: the same configuration is PROBE_WARM_MS=0 on the main probe branch.
  • Docs. docs/design/waiting.md records the configurations tried and what each did against the criteria.

Requested runs: (a), (b) and (c) in one table per host

broccoli (V = the warm-up variants):

V='--warm-ms 0;--warm-ms 1000'
OMP=1 HEAD_VARIANTS="$V" taskset -c 0-63 scripts/bench-ab.sh a3a9320 1f2145b 10 --scenario acquire --widths 64 --calls 40 --impl threadteam
OMP=1 HEAD_VARIANTS="$V" taskset -c 0-63 scripts/bench-ab.sh a3a9320 1f2145b 10 --scenario acquire --widths 64 --calls 40 --impl threadteam --load 8:10
OMP=1 HEAD_VARIANTS="$V" taskset -c 0-63 scripts/bench-ab.sh a3a9320 1f2145b 10 --scenario gaps --widths 64 --calls 40 --impl threadteam --load 8:10

(a): acquire_probe_107, every arm, on probe/107-acquire-per-call (base) and on probe/107-acquire-per-call-wait with the defaults and with PROBE_WARM_MS=0.

evo:

OMP=1 HEAD_VARIANTS='--warm-ms 0' scripts/bench-ab.sh a3a9320 1f2145b 10 --scenario gaps --impl threadteam
OMP=1 HEAD_VARIANTS='--warm-ms 0' scripts/bench-ab.sh a3a9320 1f2145b 5 --scenario acquire --calls 40 --impl threadteam

Next step if this does not settle it

If (a), (b) and (c) are not all met within this round or the next, I take the maintainer's instruction: the Rayon base is the limit, and I move to own threads. That means a pi-council design, then the implementation, tests and benchmarks, up to a PR.

Local gate on 1f2145b

All of it passes:

  • fmt and clippy (both feature sets);
  • tests with default features, all features (twice) and no default features;
  • loom 7 models, deny bans, no-public-rayon, doc and the aarch64 check;
  • taskset -c 0-2 five times.

New unit test: a fresh pool detects during its warm-up, and detects nothing afterwards by default.

@shinaoka

Copy link
Copy Markdown
Member Author

evo, quiet host (boost=0 checked, observer on 0-3,12-15, measured on 4-11), the note's evo commands with OMP=1, so libgomp is in every round; default widths of the affinity set, so 8 and 6 for threadteam, 8 for the libgomp driver.

gaps, 10 rounds (median of the 10 round medians, us; first section / whole call)

width gap base head 1f2145b head --warm-ms 0 libgomp
6 0 5.9 / 57.6 5.9 / 58.1 6.0 / 58.4 -
6 100 us 5.9 / 57.6 6.0 / 58.2 6.0 / 58.2 -
6 1 ms 5.9 / 57.8 5.9 / 58.2 6.0 / 58.3 -
6 10 ms 5.9 / 57.7 5.9 / 57.9 5.9 / 57.9 -
8 0 6.1 / 59.2 6.1 / 59.6 6.1 / 59.8 6.5 / 64.2
8 100 us 6.2 / 59.6 6.1 / 59.6 6.1 / 59.7 6.5 / 64.3
8 1 ms 6.1 / 59.2 6.1 / 59.7 6.1 / 59.7 6.7 / 64.3
8 10 ms 6.0 / 59.2 6.0 / 59.3 6.0 / 59.3 15.5 / 73.3

Base-identical at every gap and both widths: the largest deviation is 0.4 us on a first section (6.2 against 6.1) and 0.8 us on a call, so (b) and (d) both hold with the new default. libgomp is 6 to 10 % slower on the 0 to 1 ms cells and 2.6 times slower on the 10 ms one.

The warm-up makes no difference on this host (--warm-ms 0, i.e. no detection at all, is within 0.3 us of the default in every cell here). A quiet host never preempts a waiter, so nothing to detect; the warm-up's value has to be decided by (a) and (c) on the loaded runs.

acquire --calls 40 --impl threadteam, 5 rounds (us)

width arm base head 1f2145b head --warm-ms 0 libgomp
6 held_team 1039.3 1040.7 1039.7 -
6 held_pool 1044.6 1047.3 1048.3 -
6 fresh_pool 1038.9 1041.7 1040.6 -
6 fresh_pool, build to drop 1224.4 1225.7 1228.2 -
8 held_team 1047.0 1046.9 1047.3 1069.2
8 held_pool 1053.7 1053.5 1053.8 -
8 fresh_pool 1046.7 1049.6 1048.4 -
8 fresh_pool, build to drop 1306.6 1313.7 1316.7 -

Every threadteam side is within 0.5 % of the base; the head matches the base bit for bit on the width-8 held team (1046.9 against 1047.0) and libgomp's held team is 2.1 % behind it. fresh_pool phases on the head: build 90.5 / 126.5 us, acquire 13.2 / 20.0, drop 75.4 / 113.0 at widths 6 and 8.

Rows and the waits per call counters: shinaoka-advisor/work/runs/threadteam-14/ab5/.

@shinaoka

Copy link
Copy Markdown
Member Author

1f2145b (detection during a new pool's warm-up) against (a)/(b)/(c) on broccoli

Commands as in the 6503337243 comment, OMP=1 HEAD_VARIANTS='--warm-ms 0;--warm-ms 1000',
bench-ab.sh a3a9320 1f2145b 10, width 64, acquire, --calls 40, quiet / --load 8:10 / gaps under
load, then acquire_probe_107 on probe/107-acquire-per-call-wait with the defaults and with
PROBE_WARM_MS=0. taskset -c 0-63, load logger alongside.

(a) faer#107 arms, 64T, 6 processes per arm (gemm128, 50 calls)

arm base probe/107-acquire-per-call (0.1.3) 1f2145b defaults 1f2145b PROBE_WARM_MS=0
held 0.02 ms 0.09 ms (min 0.02) 0.02 ms
team_per_call 0.02 ms 0.10 ms (min 0.02) 0.02 ms
fresh_pool 15.99 ms 11.01 ms (6/6, min 11.00) 10.81 ms (6/6)
with_team 16.00 ms 11.01 ms (6/6) 11.01 ms (6/6)

(a) is not met. The warm-up window does not bring the one-call pool back: 11.01 ms in every call of
every process, the same as with the window switched off (10.81 ms) and only 1.45x better than 0.1.3's
15.99 ms. That is consistent with the reason the comment itself gives — a pool built for one call sees few
preemptions before it ends, so its 64 members spend the call spinning at 300 000 polls and the sections
wait on them — but on this host the outcome is that the warm-up detection never fires when it is needed.

(b) quiet, µs per call

arm base libgomp head --warm-ms 0 --warm-ms 1000
held_team 1 508 1 685 1 513 (+0.3 %) 1 484 (−1.6 %) 3 583 (+138 %)
held_pool 1 619 – 3 610 (+123 %) 1 597 (−1.4 %) 5 502 (+240 %)
fresh_pool total 146 940 – 97 492 131 298 94 004

held_team passes with the default, held_pool does not (+123 %); --warm-ms 0 passes both. The reason
fits the design: a bench invocation at these widths runs for well under 200 ms, so the whole held-pool
run sits inside the warm-up and pays detection all the way, which is exactly what the window was meant to
avoid for a held pool.

(c) under --load 8:10, µs per call

arm base libgomp head --warm-ms 0 --warm-ms 1000
held_team 1 543 1 693 5 637 (3.33x libgomp) 1 543 (0.91x) 6 347
held_pool 2 090 – 6 963 (3.33x base) 1 673 (−20 %) 7 015
fresh_pool call 74 578 – 5 883 74 173 6 140
fresh_pool total 125 054 – 20 763 98 962 20 660

gaps under load, first section: head 98.0 / 110.8 µs, --warm-ms 0 18.5 / 18.2 µs (libgomp 19.3 /
74.4), --warm-ms 1000 97.4 / 118.1.

(c) is not met with the default (3.33x libgomp on held_team, and the gap rows 5x libgomp at a 1 ms
gap); --warm-ms 0 meets it exactly as in the previous round, and loses the fresh_pool fix in exchange.

Verdict

criterion d430347 2773c36 e76036c 1f2145b default 1f2145b --warm-ms 0
(a) no slow mode in any arm met (0.06 ms) met (0.05) not met (11.30) not met (11.01, 6/6) not met (10.81)
(b) quiet held within 10 % met (+2.1/+5.2 %) not met (1.79/1.21x) held_team met, held_pool +136 % held_team met (+0.3 %), held_pool +123 % met (−1.6/−1.4 %)
(c) loaded within libgomp not met (3.45x) not met (3.85x) not met (3.60x) / met at gap 0 not met (3.33x) met (0.91x)

The warm-up window as specified does not reproduce d430347's (a) and does not reach e76036c's (b)/(c)
on this host, because both of the cells we can measure here are shorter than the window: a held pool never
leaves the warm-up within a run, and a one-call pool ends inside it before it has seen enough preemptions to
throttle. If the window is meant to distinguish the two, the distinguishing quantity has to be something a
one-call pool reaches (call count, or sections completed) rather than wall time — a time window cannot
separate two cases that both live entirely inside it.

d430347's pool threshold is still the only configuration measured here that has (a) at zero, and it is
3.45x libgomp under load; the only configuration with (b)+(c) is --warm-ms 0 / no detection, which has
(a) at 11 ms. That trade is where the design stands after five rounds on this host.

@shinaoka
shinaoka marked this pull request as draft October 29, 2026 17:28
shinaoka added a commit that referenced this pull request Oct 29, 2026
…sed per Pool; libgomp's waits (#15, faer#107) (#16)

* benches: wait scenarios (short sections, gaps, pool held vs fresh, width, foreign load) with Rayon and libgomp references

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* waits: staged like the OpenMP runtimes (bounded poll spin, yields, then 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>

* waits: libgomp-like default budget (131072 polls, ~3 ms), pool throttle 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>

* waits: a preemption throttles the thread that saw it, not the pool; fresh-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>

* waits: back to the pool threshold (c0c024b) as the default, per-thread 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>

* waits: spin 300000 polls (GOMP_SPINCOUNT) by default; a knob to turn 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>

* waits: libgomp's waits by default (no preemption detection, spin 300000); 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>

* waits: libgomp's waits only (no preemption detection, no warm-up, no 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>

* own worker threads: a process-wide reserve of std::thread workers leased 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>

* fix(own-threads): post-review deadlocks, cursor, affinity and docs

- 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>

* bench-ab: parse params that hold commas; keep the Rayon reference rows apart

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* alloc_free: a failing window reports each allocation's size, thread and backtrace

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sched): `enter` hands its closure over without a Mutex

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>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@shinaoka

Copy link
Copy Markdown
Member Author

Superseded by #16 (own worker threads), merged after the broccoli and evo acceptance runs met (a)-(d). This draft stays as the measurement record of the Rayon-based iterations.

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.

Waits like the OpenMP runtimes: bounded poll spin, yields, then sleep in every team wait (faer#107)

1 participant