Repository navigation
Waits like the OpenMP runtimes: bounded poll spin, yields, then sleep; wait benchmarks (faer#107) - #14
Waits like the OpenMP runtimes: bounded poll spin, yields, then sleep; wait benchmarks (faer#107)#14shinaoka wants to merge 7 commits into
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>
|
Acceptance measurement on evo (quiet, Verdict:
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 Downstream (faer):
One caveat on the method: my first pass ran while another user's job was busy on the host (load average 15 to 18, |
Broccoli acceptance run: main
|
| 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_teamat width 64: fixed, 21-90x, in threadteam's own bench.held_team/held_poolat 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_107comparison (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>
After the acceptance runs:
|
|
evo, quiet host (load < 1, no other job),
|
| 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).
|
| 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_poolfixed 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.
Correction to the
|
…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>
After the 15:51Z broccoli runs:
|
|
evo, quiet host (
|
| 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/.
|
| 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>
Against the acceptance criteria:
|
|
evo, quiet host (
|
| 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 300000makes 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/.
|
| 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
d430347arm is in the mode
(60 processes over two kernels),fresh_pool/with_teamat 0.06 ms at 64T,chol2048no longer
collapses (55/29 ms against 124/146). - (b) quiet
held_team/held_poolwithin 10 % of base — met. 1 522 vs 1 491 (+2.1 %) and 1 663 vs
1 581 (+5.2 %); the--own-hold-ms 100knob is the worse setting here (+34 % / +11 %), which is why it
is off by default. - (c) under load, within libgomp — not met.
held_teamis 5 842 µs against libgomp's 1 693, i.e.
3.45x slower, and thegapsrows 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_poolhas 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>
Next comparison: no preemption detection (libgomp-like),
|
|
evo, quiet host (
|
| 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
gapsas 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 0is 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/.
|
| 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>
libgomp's waits by default, detection only while a pool is new:
|
|
evo, quiet host (
|
| 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/.
|
| 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.
…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>
|
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. |
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
a3a9320):benches/wait_scenarios.rscovers 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 Rayonscopereference.benches/omp/wait_omp.cis the libgomp reference.scripts/bench-waits.shruns one checkout's passes intodocs/design/waiting-data/<host>.csv.904501e):PoolBuilder::spin_pollspolls (a count, likeGOMP_SPINCOUNT; 12288 on x86-64, about 300 µs on Zen 3), then yield 16 times, then sleep.Parker, woken by the last member's decrement, the last arrival andSection::fail. The member reads the pool's parker before its final decrement and never touches the section afterwards.idle_spin, whichever comes first.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.Verification (primerose: EPYC 7713P, 64 CPUs, saturated by other users)
Local gate:
cargo testpasses with default features, all features (twice) and no default features;deny bans, no-public-rayon,cargo docand theaarch64-apple-darwincheck pass;idle_clock,oversubscribedandteampass five times undertaskset -c 0-2.New tests:
Waiterunit tests (deterministic deadlines);src/tests/placement.rs);idle_clockrewritten for count-based episodes.Downstream, with threadteam patched to this branch:
t4a-faer --lib: 580 passed;c_abionce the C-API library is built, which is that test's own precondition;tenferro-cpu: pass.Bench, base against head, width 6 on the loaded host (median per call; not an acceptance run):
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:
Not yet done: the acceptance runs
scripts/bench-waits.sh <label> "" noneona3a9320(base) and on this head.sections,gapsandnestedmust not regress.taskset -c 0-63 scripts/bench-waits.sh <label> 64,48 8:10on both commits;acquire_probe_107: tensor4all/faer branchprobe/107-acquire-per-call(base, threadteam 0.1.3) againstprobe/107-acquire-per-call-wait(this head).🤖 Generated with Claude Code