Repository navigation
team route: some processes run every repetition at the 1-thread time (16T, broccoli) #107
Description
Observation (broccoli, faer#99 re-measurement, 2026-10-29T02:26Z)
qr_factor f64 512 at 16T, 20 processes. Five of them were slow on every repetition (11.8 to 13.5 ms) against 3.6 to 4.2 ms for the rest. 11.8 ms is about the 1-thread time. The slow mode belongs to the process: it hits both arms and both orders, and every repetition inside a slow process is slow. The shape matches the ~0.62 ms TeamExec section reported on #91.
A 3x process-level mode is a hazard for any single-process comparison at these budgets.
What is ruled out by the code
-
A silent fall back to
Par::Seq.utils::team::with_teampanics if the pool or the team cannot be acquired, and the team is the leader pluswidth - 1reserved members. -
A gate that varies between processes. The gates read
HostClass::detect()(cached feature detection) and a constant table,DispatchCosts::for_class. Nothing is calibrated at run time. -
"The leader takes every chunk." Every chunk is assigned statically:
- faer's
for_each_childhands blockcto childc(par_split_indices); - threadteam's
split_rawandsectionrun participant 0 on the leader and publish every other share to a fixed member slot, then join on a completion counter.
A late member delays the join, but its work cannot move to the leader. So option (2) in the monitor's note ("wait for the members' arrival before the leader takes the chunks") does not apply in that form. The leader already waits.
- faer's
Probe and what it shows so far (primerose, width 4, not 16)
The probe is faer/examples/team_mode_probe.rs on branch fix/team-serial-mode (b113f83). Build it with --features threadteam/trace. For each process it records:
- the time of each repetition, and faer's team counters;
- per repetition, from threadteam's trace: the number of publishes and shares, the members that took part, park and unpark events, the latency from a publish to a member's
SectionBegin, and the members' busy time; - per thread, from
/proc/self/task: the last CPU, CPU ticks, and voluntary and involuntary context switches; plusCpus_allowed_list.
qr_in_place 512 runs entirely through splits: 80 two-way and 90 four-way per 5 repetitions, and no flat sections.
| CPUs | result |
|---|---|
| 0-3 (shared with other users, load average ~50) | 10-120 ms. Two team threads often sat on the same CPU with ~6k-40k involuntary context switches each. Publish-to-share latency 0.2-1.4 ms on average, 3-7 ms at most. 0 park events. |
| 35-38 (idle) | 6/6 processes fast at 2.62-2.66 ms, 4 distinct CPUs. Latency 65 us on average, 260 us at most. 0 parks. One process had a transient stacked pair, 6.9k switches, in its first repetition. |
Reading so far. Within a call the members never park: they spin, then yield. The slow runs line up with team threads stacked on one CPU and preempting each other, not with a wake-from-park delay. The first repetition of every process costs ~37 ms against 2.6 ms afterwards, even on idle CPUs. The harness's 250 ms prime covers that, so it does not explain a mode that lasts the whole process.
Width 4 on idle CPUs does not reproduce the mode. It needs broccoli at 16T.
Request: run the probe on broccoli at 16T (via the monitor)
- 20 processes of
team_mode_probe 16 512 20, under the same pinning and idle guard as the faer#99 run. - Keep the full stdout of every process, slow and fast.
- Questions to answer:
- Placement: do slow processes show stacked team threads, with fewer distinct CPUs than 16 and large involuntary-switch counts?
- Parking: do any of them have park events?
- Latency: what is the publish-to-share latency in slow versus fast processes?
- CPU set: what is
Cpus_allowed_listfor 16T on that host (one L3 domain is 4 cores)?
Next
An OpenMP and OpenBLAS comparison follows in a comment, as the maintainer asked: how members are woken at region entry, how they wait between regions, the barrier, hot-team reuse, affinity, and the CPU cost on a shared host. It comes with a recommendation.
If the cause is in threadteam (member placement, or the spin and yield policy), the fix moves to tensor4all/threadteam-rs and gets an issue there.
Parent: #95
Activity
shinaoka commentedon Oct 29, 2026
How OpenMP runtimes and OpenBLAS wake members and wait, compared with threadteam
I read the sources only; no code was copied.
6aae4093ee77openmp/runtime/src)ad13d6f017a2driver/others)e805f60561b6416198e(0.1.2)Waking members, barriers, reuse, affinity
config/linux/bar.c:37-43).kmp_global.cpp:79-87). A waiter is woken withpthread_cond_signalonly if it is marked as sleeping (kmp_wait_release.h:806-819).blas_server.c:700-751).iter.c:73)kmp_sched.cpp:314-329)split_raw/section)team.c:460-488)kmp_runtime.cpp:981-1003)with_teamscopeenv.c:75)kmp_settings.cpp:6468-6483)NO_AFFINITY=1,Makefile.rule:177)How spinning is kept cheap
x86/futex.h:121-124). On aarch64 only a compiler barrier, no instruction.kmp.h:1563-1585)nops on x86_64 and ARMV8 (common.h:389-403)spin_loop()(PAUSE on x86, YIELD on aarch64)OMP_WAIT_POLICYunset, about 3 ms (env.c:2494-2508). ACTIVE: 3×10¹⁰. PASSIVE: 0, straight to the futex.kmp.h:1249)blas_server.c:160-164)idle_spin, 200 ms by defaultsched_yieldwhile spinningkmp.h:1350-1351, 1661-1673). When oversubscribed, it yields on every spin.awake.rs:60backoff)proc.c:94-100). With more threads than that, it switches to a throttled spin count: 100 with the policy unset, 1000 for ACTIVE (env.c:2512-2517,wait.h:52-55).__kmp_nth > __kmp_avail_procand then yields on every spin (kmp.h:1594-1601)nops and never sleeps (blas_server.c:764-777)backoff: spin, then yieldWhat threadteam does differently
All four runtimes assign chunks statically, never pin threads by default, and spin before they sleep. threadteam is the odd one out in three ways:
sched_yieldcalls. After 16k PAUSEs, every poll yields until the park delay (200 ms) ends, and the leader's join and barrier do the same.Recommendation
These are changes in threadteam's waiting policy, so they are tracked in a threadteam-rs issue. The order assumes broccoli confirms the stacking.
Spin cheaply and for a bounded time, without a yield phase, then park on the futex/condvar. This is libgomp's shape.
cost.rs:338-340) put a section after the members parked at about 27 us on the measured host. On idle CPUs the probe measured 65 us on average from publish to share, but that figure includes nested publishes.Throttle the spin when oversubscribed, as libgomp does. If the reserved members of all teams in the process outnumber the CPUs in
sched_getaffinity, spin only about 100 PAUSEs before parking. On a shared host this is what keeps the spin from eating other users' CPU.Keep threads unpinned by default, as all four runtimes do. Pinning would fix the stacking, but it takes CPUs away from other users and fights the caller's
taskset. If it is wanted, it should be an opt-in, likeOMP_PROC_BIND.Defaults for how fast and how long to spin:
WaitOverride, the wayGOMP_SPINCOUNTandKMP_BLOCKTIMEare;These numbers are provisional. They are what libgomp ships, and they should be checked against the broccoli measurement before they are adopted.
Not settled: whether the slow mode on broccoli is the stacking. The 16T probe run is still pending. If it is not, recommendation 1 is still justified by the CPU cost on a shared host, but the fix for #107 is something else.
Not verified
ARMV8.shinaoka commentedon Oct 29, 2026
threadteam side: tensor4all/threadteam-rs#9 (waiting policy). The fix PR waits for the broccoli 16T probe run to confirm the stacking.
shinaoka commentedon Oct 29, 2026
20 processes of
team_mode_probe 16 512 20on broccoli: 10 fast, 10 slow, and what separates themRun via the monitor, 2026-10-29, EPYC 7702P (Zen2, 64 cores, 16 MiB L3 per 4-core CCX, SMT off),
boost 0, governor
performance, probe built atb113f83with--features threadteam/trace, eachprocess pinned to CPUs 48-63 (four whole L3 domains) with a 3 s whole-domain idle gate before and
after it, agent pinned off the measured set.
Cpus_allowed_list: 48-63in every process,w=16,n=512, 20 repetitions. Every process's full stdout is kept.The mode reproduces exactly and splits the sample in half: 10 processes at 3.47-3.53 ms median,
10 at 12.39-12.47 ms.
12.4 msis the one-thread time of this call, so in the slow mode the members'work stops overlapping. Six of the ten fast processes also contain one to six individual 12.4 ms
repetitions, so the state is per repetition; a process is slow when it never leaves it.
splithistogram,below_floor[.., 40@3, 260@17], 15320What the differences say
below_floorarebyte-for-byte the same in fast and slow processes. Nothing decided differently.
park 0 unpark 0in all twenty processes, every repetition.The "members are woken from park late" reading in the issue is ruled out: the members never park.
against 56 us each (slow): the hand-off is what stretches, by the same factor as the call.
of the ten the team ends up with two threads on one CPU while one of the sixteen allowed CPUs is
idle, where all ten fast processes end on sixteen distinct CPUs. The four slow processes that still
show 16 distinct CPUs weaken it, but the probe samples each thread's CPU once, at the end, so a
stacked pair that is later rescheduled leaves no trace.
So the standing candidate is placement/oversubscription, with the caveat above: the probe cannot yet
say whether a share actually ran on a CPU shared with another share. What would answer it: sample
sched_getcpu()at the start of every share (per member, per publish) rather than once per thread,and log the leader's own CPU per share; then "two shares of one publish ran on one CPU" is directly
observable. Cheaper probe-side checks that would also separate the cases: build the pool with
width - 1members when the leader is also a participant (17 threads on 16 CPUs is the healthyoversubscription you start from), and run the same 20 processes with the pool's threads explicitly
confined to the 16 CPUs.
If the per-share CPU sample shows the shares of one publish landing on one CPU, the fix belongs in
threadteam (member placement or the spin/yield policy at section entry) rather than in faer; if it
shows clean placement and 16 us hand-offs that simply do not overlap, the next suspect is the join.
shinaoka commentedon Oct 29, 2026
Probe update for the next broccoli run (
fix/team-serial-modeat df7e803)One correction to the 17-on-16 reading
with_team(16)builds a pool of 16 workers, but the team is the leader (the calling thread) plus16 - 1reserved members. threadteam'sPool::into_team(w)acquiresw - 1members.Sthroughout the timed loop.team_mode_probe 15 ...undertaskset -c 48-63).What the probe now records (both options are environment variables)
PROBE_SAMPLE=<cpu>: a sampler thread pins itself to<cpu>, which must be outside the measured set. During each repetition it loops over/proc/self/task/*/statand reads every thread's state and CPU. Per repetition it reports:R) threads on the same CPU, meaning one of them was waiting on that CPU's run queue;This shows directly whether two team threads shared a CPU while a repetition ran, not just where each thread ended up.
PROBE_PIN=48-63: a section run on the team at setup pins participanti(the leader is 0) tolist[i]withtaskset -pc, each on its own thread. The spare worker is not a participant and is left alone.On primerose, at width 4 on idle CPUs, a repetition gives 31 samples:
Suggested broccoli runs
Use the same protocol as the 03:07Z run, with the sampler on a CPU outside 48-63 and outside the agent's set (
44below is a placeholder), 20 processes each:PROBE_SAMPLE=44 taskset -c 48-63 team_mode_probe 16 512 20PROBE_SAMPLE=44 taskset -c 48-63 team_mode_probe 15 512 20PROBE_PIN=48-63 PROBE_SAMPLE=44 taskset -c 48-63 team_mode_probe 16 512 20How to read the result
sched_yield. Pinning stays opt-in.shinaoka commentedon Oct 29, 2026
A/B/C on broccoli, 20 processes each, arms interleaved (probe at
df7e803, sampler on CPU 8)Same protocol as the 03:07Z run: EPYC 7702P (Zen2, SMT off, boost 0, governor
performance), eachprocess pinned to 48-63, 3 s whole-domain idle gate before and after, agent off the measured set,
PROBE_SAMPLE=8(outside 48-63 and outside the agent's CPUs),n=512, 20 repetitions.PROBE_PIN=48-63Reading
The slow mode is stacking, and the stacked-sample counter shows it directly. In A every slow
process spends 83-100 % of its samples with two runnable team threads on one CPU (mean 92.4 %), every
fast process far less, and the publish-to-share latency splits the same way (835 against 225 us). This
is the 03:19Z "reading 1": the members are alive, unparked, and receiving the same shares; two of them
are simply queued on one CPU, so their shares stop overlapping and the call takes the one-thread time.
The fast processes' 46.3 % stacked mean also explains the single 12.4 ms repetitions inside them.
Pinning removes it completely: C is 20/20 at 3.4-3.5 ms with zero stacked samples in all
twenty processes, on the same CPUs, same width, same work.
One correction to the suggested fix. B — one free CPU, which is option (1) of the monitor's note —
still fails 7 of 20 processes, and its slow processes are as stacked as A's (90.8 %). A spare CPU
gives the scheduler somewhere to move a displaced member but does not stop it from stacking the pair
in the first place, so "count the leader, PAUSE-then-park instead of endless
sched_yield" would notby itself remove the mode on this host. What does remove it is deterministic placement: the fourteen
extra CPUs of this host are there to be assigned, and the probe's
PROBE_PINshows the assignment isenough (0 % stacked, 0/20 slow).
So for threadteam-rs#9: participant affinity is the load-bearing part, at least when the participants
fill every allowed CPU; parking may still help the tail (the 46 % stacked samples inside fast
processes did not make them slow), but it is not what fixes A. Opt-in, as the probe keeps it, with the
caller's CPU set as the default source.
Raw output of all 60 processes is in the run dir (per process: repetition times, publish/share/park
counters, per-repetition sampler counts, per-thread CPU/ticks/context switches).
shinaoka commentedon Oct 29, 2026
Control: OpenBLAS
dgeqrf512 at 16T, 20 processes — the per-process mode does not reproduceBlocker first: the OpenMP-default control cannot be run on broccoli. The only BLAS here is
libopenblas.so.0=OpenBLAS 0.3.8 NO_LAPACKE DYNAMIC_ARCH NO_AFFINITY Zen MAX_THREADS=64(
openblas_get_config()), i.e. the pthread flavour:lddshowslibpthreadand nolibgomp,and there is no
openblas-openmpbuild anywhere on the host (/usr/lib/.../openblas-pthreadis theonly variant directory;
libgomp.so.1exists but nothing links it). SoOMP_PROC_BIND,OMP_WAIT_POLICYandGOMP_SPINCOUNThad no effect to record — they never reached a runtime. What aGNU OpenMP build would default to (proc_bind false, active waiting with
GOMP_SPINCOUNT=300000) isdocumentation, not a measurement from here, so I am not quoting it as one.
What the installed flavour gives, same protocol as the A/B/C run (20 processes,
taskset -c 48-63,3 s whole-domain idle gate before and after each,
openblas_set_num_threads(16)read back as 16,512x512 f64, 20 repetitions per process, matrix restored outside the clock), plus one 1T reference
process:
No fast/slow split: there is only the slow level. Not one process reached anything like the
3.4-3.5 ms faer's team holds in its fast mode (5.4x over its own 1T), and the process-to-process
spread is 12.55-15.24 ms instead of two clusters 3.5x apart. On this host and with this pinning,
OpenBLAS at 512 simply does not scale at 16 threads.
That is worth reading carefully in both directions:
different pool implementation, on the same CPUs, same shape, same process discipline, shows no
mode. That is consistent with the A/B/C result that the mode is about the team's own placement
under exact subscription, not about the machine being busy.
faer's slow mode (12.4 ms), so "the call took ~14 ms" tells you nothing on its own. What
distinguishes the fault is the bimodality within a fixed configuration (and it disappears under
PROBE_PIN), not the number.If the OpenMP-default control is still wanted, it needs an OpenMP-built vendor library on this host
(
libopenblas-openmp, MKL, or BLIS with OpenMP); installing one needs root, which I do not havehere. Tell me when it is available and I will run the identical 20-process protocol against it and
record the effective
OMP_*values from the process environment.placement: opt-in participant pinning, PAUSE-only reserved spin, over…
shinaoka commentedon Oct 29, 2026
threadteam-rs#10 re-checked against #107's mode (20 processes per arm, 16T, CPUs 48-63)
pin_participants(true)The slow mode is gone in both PR arms — the PAUSE-only spin and the oversubscription park do it on
their own, before any pinning — and member-only pinning removes most of the residual stacking. The
unpinned leader keeps the whole mask and landed on the CPU the members leave free, so it does not
stack with them. Full table and caveats (20 processes bounds the rate below ~14 %): comment on
tensor4all/threadteam-rs#10.
shinaoka commentedon Oct 29, 2026
OpenMP-default control: it does not stack either, and the settings it runs under
Two instruments, both with
OMP_PROC_BINDunset,taskset -c 48-63, 16 threads, 20 processes each,the same whole-domain idle gate as before.
1. libopenblas-openmp
dgeqrf512 (libopenblas0-openmpinstalled by the maintainer today;linked explicitly — rpath plus
LD_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/openblas-openmp— andopenblas_get_config()confirms… NO_AFFINITY USE_OPENMP Zen MAX_THREADS=64, threads requested 16,read back 16):
No split, no fast cluster — the same shape as the pthread build measured earlier (12.55-15.24 ms), so
this vendor code path is uniformly near its own 1T time at 512 and cannot exhibit the mode either way.
2. A
gcc -fopenmpregion probe (probes/hh107e/ompprobe.c): 20 iterations of 300 back-to-backshort regions, 16 shares of ~5 us each, every share recording
sched_getcpu(); a region is "stacked"when its shares do not occupy 16 distinct CPUs.
So the default OpenMP runtime never leaves two runnable threads of one region on one CPU across
1.2 million regions on this host, where threadteam's team spent 28-48 % of its samples stacked in the
same configuration.
The settings it runs under, and the difference to threadteam
OMP_DISPLAY_ENV=VERBOSEreports, with nothing set by me:OMP_PROC_BIND='FALSE',OMP_DYNAMIC='FALSE',OMP_WAIT_POLICY='PASSIVE',GOMP_SPINCOUNT='300000'. That is theinteresting half of the comparison: libgomp binds nothing either — its threads are free to be placed
anywhere in the mask, exactly like the team's members — and it still does not stack, because of how
its workers wait and how a region starts. Between regions a libgomp worker spins for a bounded number
of polls and then sleeps, so the OS is free to move it and there is no resident set of 16 pollers to
collapse onto fewer CPUs; a region begins with a barrier that only releases when every worker is
resident, and only then do the shares run. threadteam's reserved members instead poll continuously
across calls, so a member that the scheduler happens to deschedule shares its CPU with a peer
mid-section, and the section's join waits for it — which is precisely the 3x we measured (the slow
processes' publish-to-share latency was 835 us against 225 us). The fix in tensor4all/threadteam-rs#10
(PAUSE-only spin, and park when the scope is oversubscribed) moves threadteam towards what libgomp
does by default, which is consistent with that PR's arms showing 0/20 slow processes with pinning off.
placement: opt-in participant pinning, PAUSE-only reserved spin, over…
shinaoka commentedon Oct 29, 2026
Is the slow mode a long-lived in-share stack? Split the stacked samples and measure how long they last
Probe change (local commit on
fix/team-serial-mode, not pushed; diff and sources in the run dir):every sample now records the timestamp and each thread's state and CPU, and after each repetition the
sample is classified against the threadteam trace — a share's window is
SectionBegin..SectionEndper member, matched to the sampled thread through the participants' tids. A stacked sample (two
runnable threads on one CPU) is counted as
and the consecutive length of each kind of stack is recorded (samples and the wall time it spans).
Three arms, 16T,
taskset -c 48-63, 20 processes each, the usual guard, repetition 0 (the ~60 mswarm-up) excluded from every number below.
416198e, fast processes416198e, slow processesd3d9eca(#10), pinning offd3d9eca,pin_participants(true)Answer
Yes for (a), no for "long-lived". The slow mode tracks in-share stacking: its rate rises 5.7x
(2.5 % -> 14.2 % of samples) in the slow processes, and it is the one kind the fixed library no longer
produces — 1 sample in 6607 with pinning off, 0 with pinning on, where the old library produced it in
thousands.
What does not separate them is duration: the longest in-share stack is 19.5 ms in a fast process
against 21.4 ms in a slow one. The slow mode is many recurring short in-share stacks, not one long
residence — which is why the earlier "stacked sample %" correlated but never explained the 3x, and why
a fix that only reduces the chance of one long stack would not be enough.
And (b) alone is harmless: waiting-between-sections stacking is common in fast processes on both
libraries (40 % of samples on the old one, 3 % on the new one without pinning, with a 38 ms run in one
process) and never makes a process slow. It is what the two "stacked %" numbers in my earlier comments
were mostly counting, which also explains why that rate stayed non-zero after the fix.
Corrected summary for the issue: the mode is in-share CPU sharing on the team's members, recurring
through the call; the members waiting on one CPU between sections is normal scheduling noise.
deps: threadteam 0.1.3 (requirement and lock) for the #107 wait fix (#…
shinaoka commentedon Oct 29, 2026
Fixed by threadteam 0.1.3 (tensor4all/threadteam-rs#10), which faer now requires and locks (#120, 7cfc56e). The classification run on broccoli (06:25Z) shows that the slow mode was team threads repeatedly stacking on one CPU while a share was executing, case (a). On threadteam 0.1.3 with pinning off, the default faer uses, (a) appears in 1 of 6607 samples. With pinning on it does not appear at all. The fix is the change to how members wait: PAUSE-only reserved spin, and a yield or park when a scope is oversubscribed. Participant pinning stays an opt-in extra (
PoolBuilder::pin_participants, default off, leader never pinned).shinaoka commentedon Oct 29, 2026
Reopened: threadteam 0.1.3 still shows the mode in the configuration the harness uses
First, the version question: everything already links threadteam 0.1.3.
faer-benchmark's ownCargo.lockhas nothreadteamentry at all — the crate arrives only through the path-patchedt4a-faer, whose requirement is"0.1", so cargo resolves the newest matching release at buildtime. Confirmed on the binaries themselves (
strings … | grep threadteam):fbench(MKL arm andOpenBLAS arm) and the standalone faer driver all print
threadteam-0.1.3. So there is no lock tobump and no PR needed for it.
Against those binaries, 20 processes each, 64T on CPUs 0-63, the usual guard (another user's
processes sit at 5-13 %, so the wide cells are recorded as
over-guardwith the reading):gemmf64 128, harnessfbench(fork row, C)choleskyf64 2048, standalone driver[724, 20.7, 20.0],[126, 42.5, 72.0]So 0.1.3 does not remove it here. The interesting part is why the earlier verification said 0/20:
that run used
team_mode_probe, which acquires one team and runs 20 repetitions inside it, with awarm-up first.
fbench— and every real caller — takes the team per call (with_teamper row, 5repetitions after a 250 ms prime). The fix apparently helps a team that is already warm and held, and
not a fresh acquisition per call. That distinction is the thing to measure next: the same probe with
with_teamtaken between repetitions, and a count of how often a fresh acquisition lands stacked.cholesky2048 is the worse cell of the two, and it is also where the mode costs the most (17-74 msagainst ~12 ms, i.e. up to 6x on a call that is 27x faster at 64T in the fast mode).
probe: #107 report the spare worker (state, run time, runnable/stacke…
shinaoka commentedon Oct 29, 2026
The spare worker: what it runs, how long it spins, and a probe to measure it on broccoli
A pool of
wworkers gives a team ofwparticipants: the caller andw - 1reserved workers (threadteam-rs#12). So one worker of every pool is a spare, and awith_team(64)process runs 65 threads. Below is what that spare does, from the code (threadteam 0.1.34c73c2b, rayon-core 1.13.0), and the probe that will measure whether it matters here.What the spare runs (code reading)
Awake::claim_coldspawns exactlymissing = w - 1claim jobs on the pool (src/awake.rs,pool.inner.spawn(move || claim_job(..))). The worker that gets none never entersmember_loop, so threadteam's time-based idle spin (WaitPolicy::DEFAULT.idle_spin = 200 ms, PAUSE thenyield_nowper poll) does not apply to it. That spin applies only to a member whose reservation ended while it staysIDLE.wait_until_cold→Sleep::no_work_found(rayon-coresrc/sleep/mod.rs:88-108), every round is onefind_work()(its own deque, steal attempts on the other workers' deques, the injector) followed bythread::yield_now(). AfterROUNDS_UNTIL_SLEEPY = 32rounds it announces itself sleepy. One more round later (ROUNDS_UNTIL_SLEEPING = 33) it blocks on its condvar. The spin before sleeping is therefore 33 rounds of steal plus yield, bounded by count, not by time.new_injected_jobs/new_jobs→wake_any_threads). In a team scope that happens at the acquisition, when thew - 1claim jobs are spawned. Sections,forkandfor_rangeson a team token dispatch on the reserved members and inject nothing. So:CpuEngine,team_per_call, the earlierteam_mode_probe): the spare runs its 33 rounds at pool start and at the first acquisition, then sleeps for the pool's lifetime;with_team,fbench): every call spawnswnew threads, and every one of them, the spare included, runs the 33 rounds right as the claim jobs arrive, i.e. while the team is being acquired and the call begins.yield_nowlets another runnable thread on that CPU run, and the spare runs again only when the scheduler gives the CPU back. On a 64-CPU set where 64 participants already spin, the spare is a 65th runnable thread for those 33 rounds, and the CPU it lands on has two runnable threads.Indicative numbers (primerose, not a measurement)
primerose is saturated (load average 64-75 on 64 CPUs), so the timings below are noise. Only the spare's own CPU time is meaningful, from
schedstat, at width 4 ontaskset -c 0-5:Sat the end of every call.So the spare's CPU cost is sub-millisecond per pool. Alone, it cannot account for a 16 ms gemm 128 or a 17-74 ms cholesky 2048. It can, however, make a CPU hold two runnable threads at the moment a fresh team starts. If such a stack then persists (when
width == CPUs, every CPU already holds a spinning participant, so the scheduler gains nothing by moving one), the spare would be the trigger and the persistence the cost. That is the hypothesis the broccoli run should confirm or refute.Probe (branch
probe/107-acquire-per-call,c1d8e4c, not for merge)faer/examples/acquire_probe_107.rs,<mode> <gemm128|chol2048> <width> <reps>. Modes:heldteam_per_callCpuEngine)exec_team_per_callExec::teamper callinto_team_per_callinto_team/into_poolper callfresh_poolwith_teambuilds itwith_teamfaer::team::with_teamitselfOptions:
PIN=1,PRIME_MS,SAMPLE_CPU(a/procsampler outside the set),SPARE_WAIT=1. Per call, the probe reports:schedstat);SPARE_WAIT, the time from pool creation until every spare sleeps.The control for "does the +1 matter": on
taskset -c 0-63, width 63 (63 workers including the spare, plus the caller = 64 threads on 64 CPUs) against width 64 (65 threads), infresh_poolandteam_per_call, 20 processes each, against the 11:55Z criteria.Requested on broccoli (via the monitor):
fresh_poolandteam_per_callwithPIN=1;SAMPLE_CPUoutside the set.If the width-63 control removes the mode, the fix belongs in threadteam-rs#12: either a team that does not leave a spare, or a spare that never wakes. If it does not, the fix is about stacks that persist at
width == CPUs, independently of the spare.shinaoka commentedon Oct 29, 2026
Acquisition modes at 64T: the trigger is a pool per call, not the team acquisition (and not the spare alone)
Probe
acquire_probe_107(branchprobe/107-acquire-per-call,8fd3b4c), 64T on CPUs 0-63,PINunset unless named, 20 processes per arm forgemm128(50 calls each) and 10 forchol2048(10 calls each), the usual whole-domain guard (another user's processes kept the wide cells busy, so
most carry
over-guardand the readings are in the raw files). "slow" = a process whose median callis >3x the fastest call seen in that arm; "stacked calls" = calls where two participants shared a CPU.
gemm128f64 at 64T, 50 calls per processheld(one team for everything, the old probe)team_per_call(pool reused,team.exec(w).team(..))exec_team_per_call(Exec::new(...).team(..))into_team_per_call(into_team…into_pool)fresh_pool(new pool + team per call)with_team(literallyfaer::team::with_team)fresh_pool,PIN=1team_per_call,PIN=1chol2048f64 at 64T, 10 calls per processfresh_poolfresh_pool,PIN=1with_teamteam_per_callAt this size the mode also hits the pool-reusing arms — 10 calls are not enough for a process to
recover, and the fixed ~16 ms of the
gemm128case is a much smaller share of a 15-300 ms call.Placement and the "one free CPU" control (
gemm128, one process per arm)heldteam_per_callfresh_poolwith_teamThe +1 spare worker
The pool has
widthworkers and the team is the caller pluswidth-1members, so every configurationhere runs 65 threads on 64 CPUs, and my external sampler confirms it:
max_R_threads = 65inevery arm at 64T (
held,team_per_call,fresh_pool,with_team, both kernels), 64 only in acouple of
exec_team_per_callruns. So the spare is runnable alongside the 64 participants everywhere— and it is therefore not what separates the arms:
team_per_callhas the same 65 runnable threadsand stays at 0.02 ms, while
fresh_pool/with_teamsit at a fixed 16 ms on 20 of 20 processes.What does separate them is constructing the pool in the call. Two further data points agree:
pinning the fresh pool makes it worse (median 32 ms, and 642 ms at
chol2048, against 16/361unpinned), and with 16 CPUs of slack (
width 48on 64 CPUs, where the spare and any parked-to-spinningworker are harmless) the mode disappears entirely.
One gap I am not papering over: the per-spare state and CPU-time columns I wanted (a), and the spin
time (c), are not usable from my outside sampler — it loses the pool's threads when the process tears
them down (
threads_at_end= 1 for thechol2048runs). Getting them needs a flag inside the probethat prints the spare's own state/utime, or snapshots taken while the pool is alive. The 65-runnable
count above is the part that survived that check.
shinaoka commentedon Oct 29, 2026
threadteam-rs#14 re-check on the #107 arms: the width-64 collapse is gone in threadteam's own bench
With
threadteampatched to PR #14 head (904501e), theacquirescenario of the newbenches/wait_scenarios.rsreports, for thefresh_poolcase (whatfaer::team::with_teamdoes):4c73c2b904501eSo the 107 mode — a fixed ~16 ms per freshly created pool that the arm
fresh_pool/with_teamshowed20/20 at 64T — is what this PR removes, and it removes it under load too.
Two costs in the same table, for the record:
held_team/held_poolat width 64 go 1.48 -> 6.12 ms(4.1x slower, no change at width 48), and a call after a 10 ms gap goes 17 µs -> 100-500 µs in the
firstmetric, at both widths.Caveat on the numbers: another user's two Julia jobs ran during part of the window (load 25 in the
5-minute sample), and libgomp's own row shows the contamination (161 ms in the main pass against
5.2 ms in the PR pass). The
acquire_probe_107matrix I started for the same comparison was stoppedfor that reason and will be re-run on a quiet host; the direction above is consistent with the
fresh_pool/with_teamresult from the earlier run, but treat the absolute values as provisional.