Skip to content

placement: opt-in member pinning, PAUSE-only reserved spin, oversubscribed park (#9) - #10

Merged
shinaoka merged 1 commit into
mainfrom
feat/9-placement
Oct 29, 2026
Merged

shinaoka merged 1 commit into
mainfrom
feat/9-placement

Conversation

@shinaoka

@shinaoka shinaoka commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Part of #9.

tensor4all/faer#107's A/B/C run on broccoli (03:36Z) identified the slow mode: two team threads stacked on one CPU.

arm slow processes
A: width 16 on 16 CPUs 11 / 20
B: one CPU left free 7 / 20
C: participants pinned one per CPU 0 / 20

This PR adds the pinning, opt-in, plus the waiting changes requested with it.

Changes

  • Opt-in member placement
    • API: PoolBuilder::pin_participants(bool) / Pool::pin_participants(). The default is off. The private PIN_PARTICIPANTS_DEFAULT in src/pool.rs is the one-line switch, waiting for the maintainer's decision.
    • Mapping: at the entry of a root team scope, the entering thread reads its own CPU set S (sched_getaffinity). Member k (k >= 1) pins itself to the k-th CPU of S, wrapping modulo |S|. Root scopes are Exec::team acquisitions and the root token minted by a persistent Team, which is faer's with_team path.
    • The leader (participant 0, the calling thread) is never pinned, not even for the scope. Vendor BLAS/LAPACK teams are created from the caller and inherit its mask, and a one-CPU mask confines the whole provider team (tenferro-rs PERFORMANCE_TIPS.md: "Do not assign one CPU per worker", measured 3-20x slower dgemm/LU at 4T). S[0] is left to the leader without being enforced. Arm C of faer#107 pinned the leader too, so the broccoli re-check with this PR must confirm that member-only pinning removes the stacked mode.
    • Self-pinning: every affinity change is made by the member itself, in a pin section that replaces the acknowledgement rendezvous. No thread changes another thread's mask.
    • Restore: a member saves its own previous mask in its slot and restores it on its own thread when its reservation ends, when it is lent to Rayon, and before it serves foreign work through external (including as the leader of a fork child).
    • Vendor libraries: a pinned member that creates an OpenMP / OpenBLAS team directly inside a share confines that team to its CPU. The rustdoc says so and points to Exec::external, or to keeping pinning off.
    • Platforms: Linux only, with a stack cpu_set_t and no allocation. Other OSes accept the option and do nothing.
    • Best effort: a failed syscall leaves that thread as it was. Placement assumes each team (process) has its own CPU set.
  • Reserved spin without yield: reserved members polling for a section, and join / barrier, spin with one spin_loop() per poll (PAUSE on x86, YIELD/ISB on aarch64). backoff (spin, then yield_now) stays on acquisition and the idle paths. Members still park after the park delay through the unchanged Parker.
  • Oversubscription:
    • A root scope is oversubscribed when its participants exceed |S| of the thread that enters it, recomputed at every root-scope entry. The flag is carried into every section, including reborrows, splits and fork children.
    • In an oversubscribed scope, members park after a short spin (the park delay is set to zero and the park check fires after about 64 polls of a fresh idle episode), and join / barrier spin about 100 times, then yield_now.
  • Unchanged: the park/wake handshake (src/park.rs) and section publication; the loom models pass as they were.
  • Not in this PR: waiting policy: no sched_yield phase, a short park delay between calls, and a throttled spin when oversubscribed #9's proposal 2, a park delay of about 1 ms between calls.

Tests

  • tests/pinning.rs:
    • each member on its own CPU of the caller's set;
    • the caller's mask is identical before the scope, inside a share run by participant 0, and after the scope (acquisition path, persistent-team root token twice, and an unwinding scope on both paths);
    • wrap-around (width 4 on a 2-CPU mask: member k gets {S[k % 2]} and the leader keeps the full set);
    • pinning off is the default and changes no mask;
    • a lent member restores before serving Rayon;
    • a member leading a fork child restores before external.
  • tests/oversubscribed.rs: members of an oversubscribed root scope park (nested split included), and the thread that enters the scope, not the mint, decides the park delay. Both use a 60 s park delay and wait for the parks, so reaching them proves the zero delay with no wall-clock bound.
  • tests/idle_clock.rs is now order-based. It failed on macOS CI because its wall-clock premise ("a section every 5 ms") broke on a 3-core runner. For every ParkEnter, it checks that the time since the member's own last SectionEnd / ParkExit / Enter is at least the delay; it then waits for every member to park after the last section, and checks that a section wakes them all.
  • src/tests/placement.rs: the flag survives reborrows, splits and fork children; a fitting scope is not oversubscribed; the scope takes its pressure from the entering thread.
  • Allocation windows:
    • tests/team_alloc_free.rs: mint, use and drop of a persistent-team token with pinning on make 0 allocations.
    • tests/alloc_free.rs: an Exec::team scope allocates the same with pinning on as with pinning off. It already allocated its id Vec before this change.
  • Negative controls: each new test fails when its fix is reverted.

Verification (primerose)

The AGENTS.md pre-push checklist passes:

  • cargo fmt --check
  • cargo clippy --all-targets, with --all-features and with --no-default-features, both -D warnings
  • cargo test: 171 passed (default), 185 (--all-features, three runs), 99 (--no-default-features)
  • idle_clock and oversubscribed under taskset -c 0-2: ten runs, all passing
  • loom loom_park: 4 passed
  • cargo deny check bans
  • scripts/check-no-public-rayon.sh
  • cargo doc
  • cargo check --all-targets --target aarch64-apple-darwin

Independent pre- and post-implementation reviews (gpt-6.1-sol) were done. The pre-review's two BLOCKERs (pinning other threads by tid, and restoring through a lent job) led to the self-pinning design. The post-review's two IMPORTANT findings are fixed here: foreign work on a pinned fork-child leader, and oversubscription taken at mint instead of at scope entry. The leader is unpinned per the monitor's constraint (tenferro's vendor-team rule).

Acceptance in #9 that is still open:

  • the faer#107 probe on broccoli at 16T with pinning on (members only) shows no stacked mode in 20 processes;
  • an idle team costing no CPU after the park delay;
  • no regression in the steady-state dispatch rows.

No version bump: publishing is not part of this PR.

🤖 Generated with Claude Code

…subscribed park (#9)

Implements the decided design for #9 on top of the
broccoli A/B/C measurement in tensor4all/faer#107 (pinning removes the 3x
process mode: 0 of 20 slow with the participants pinned, 11 of 20 unpinned,
7 of 20 with one spare CPU).

A. Opt-in placement. `PoolBuilder::pin_participants(bool)` (one private
   `PIN_PARTICIPANTS_DEFAULT: bool = false` switch), `Pool::pin_participants()`
   getter, `src/pin.rs`. At the start of a root team scope (`Exec::team_with`
   on the acquisition path and on the carried path of a root token minted by a
   `Team`) the leader reads its own mask S and runs one pin section through the
   section machinery (it replaces `acknowledge`): member k (participant k >= 1)
   pins itself to the k-th set CPU of S, wrapping modulo |S|. **The leader is
   never pinned** and its mask is never changed, not even for the scope: a
   vendor library builds its thread team from the thread that calls it, and a
   one-CPU caller silently confines the whole provider team (tenferro-rs
   PERFORMANCE_TIPS.md: 3-20x slower dgemm/LU), so S[0] is left to the leader
   without enforcing it; a pinned member must not build such a team directly
   inside a share (use `Exec::external`, which restores its mask first, or keep
   pinning off). Only the thread being moved changes its affinity, with
   `sched_setaffinity(0, ...)`. A member saves its previous mask in its own slot
   (UnsafeCell<cpu_set_t> + a thread-owned flag, SAFETY comment) the first time
   it is pinned and restores it itself in `member_loop` before it leaves
   `RESERVED`, before it serves Rayon while lent, and (`sched::unpin_worker`)
   when it is itself the thread that runs foreign work, e.g. the member that
   leads a `fork` child and calls `external`. Best effort everywhere; Linux only
   (accepted and inert elsewhere); `libc` is now also a Linux dependency (no
   libc type is public); stack `cpu_set_t` only, so the path allocates nothing.
   The pin section is tagged by a bool on `Section`; its closure captures the
   mask.

B. Reserved spin without yield: `spin_wait` (one PAUSE per poll) replaces the
   shared `backoff` in the reserved `gen` poll and in `Section::join`/
   `Section::barrier` when the scope is not oversubscribed. `backoff` stays for
   acquisition, the cold claim, `IDLE` and the leaving states.

C. Oversubscription: a root scope whose participants exceed |S| (Linux
   affinity count, else `available_parallelism`) sets the members' park delay to
   zero for its reservation and resets `polls`/`spins` per idle episode (64
   polls before the park check); `join`/`barrier` spin ~100 times then yield per
   poll. Root pressure and the park delay are decided on the thread that enters
   the scope — at `Exec::team_with` on both paths and, for a `Team`, also at
   `Team::exec`, so a token minted on one CPU set and entered on another is
   paced and placed by the entering thread's set. The flag is carried through
   `TeamAuth`, `TeamExec`, reborrow/limit/split/fork children and every
   `Section`.

D. `src/park.rs`, the handshake and publication are untouched; the loom models
   pass unchanged.

Tests: `tests/pinning.rs` (member placement on distinct CPUs, wrap-around on a
2-CPU caller mask, the leader's mask identical before, inside and after the
scope for an acquisition and for a persistent-team root token and after a panic
in a share, persistent members across two scopes, pinning off, restoration while
lent by `external`, and a member-led `fork` child calling `external`),
`tests/oversubscribed.rs` (a 2-CPU mask and a 1-CPU scope entry, both with a
policy park delay far above the hang guard, so parking at all proves the scope's
zero delay; nested split included), `src/tests/placement.rs` (flag propagation,
entry-time pressure), and pinning-on allocation windows in `tests/alloc_free.rs`
and `tests/team_alloc_free.rs`. `tests/idle_clock.rs` now checks the order of
each member's own trace events instead of a wall-clock premise ("sections every
5 ms" breaks on a slow runner once members no longer yield): never a park less
than the delay after the member's own last share, wake-up or start; every member
parks once no section arrives; a following section wakes every parked member.
Docs: `README.md`, `docs/design/placement.md` (new "Participant placement
(opt-in)" and "How a reserved member and a joining leader wait" sections).

Co-Authored-By: Claude Opus 5.0 <noreply@anthropic.com>
@shinaoka shinaoka changed the title placement: opt-in participant pinning, PAUSE-only reserved spin, oversubscribed park (#9) placement: opt-in member pinning, PAUSE-only reserved spin, oversubscribed park (#9) Oct 29, 2026
@shinaoka

Copy link
Copy Markdown
Member Author

Broccoli re-check of the placement fix: 20 processes per arm, 16T on CPUs 48-63

team_mode_probe (tensor4all/faer fix/team-serial-mode df7e803) built against this PR's head
(d8b4c5c) by pointing the workspace's threadteam dependency at the checkout, with the probe's
sampler enabled (PROBE_SAMPLE=8, outside the measured set). Arms interleaved, per-process
whole-L3-domain idle guard, EPYC 7702P (SMT off, boost 0, governor performance), 16 512 20.

arm slow processes (≥6 ms) per-process median (fast / slow) stacked samples, mean per process
1. threadteam main 416198e (0.1.2) 5 / 20 3.54 / 12.39 ms 47.8 %
2. this PR, pin_participants off 0 / 20 3.53 / – 28.4 %
3. this PR, pin_participants(true) 0 / 20 3.46 / – 3.4 %

Reading

  • The waiting change alone already removes the slow mode. Arm 2 keeps placement exactly as it was
    (nothing pinned) and still gets 0/20 slow processes, where main got 5/20 here (11/20 in the 03:07Z
    run — the rate varies, the mode does not occur at all in the PR's arms). PAUSE-only reserved spin
    plus the oversubscription park is doing the work that the pinning was expected to do.
  • Member pinning removes the residual stacking: 28.4 % -> 3.4 % of samples show two runnable
    threads of one section on one CPU. Arm 3's per-process detail looks like a healthy layout: members
    take S[1..] (49-63 here) and the unpinned leader lands on the one CPU they leave free (48), so the
    process reports 17 threads on 17 distinct CPUs.
  • The leader is not the problem the issue feared. It is never pinned by this API, it kept the whole
    48-63 mask in all 20 processes, and it did not stack with a member in the sampling; the 3.4 %
    residual is the leader or the sampler briefly overlapping a member, not a pair living on one CPU.
  • 20 processes is a coarse rate estimate. 0/20 bounds the mode's rate below ~14 % at 95 %
    confidence at this width on this host, which is enough to say the fix works and not enough to say it
    is zero. If a tighter number is wanted, 100 processes of arm 2 and arm 3 would give it; the runs are
    ~0.3 s each.

Recommendation from this side: the pinning is a net win but it is arm 2 that makes the mode
disappear, so PIN_PARTICIPANTS_DEFAULT can stay false without giving the mode back, and the
pinning can be adopted on its own merits (2.5 % stacked against 28 %) as an opt-in.

Cross-reference: the same numbers are in tensor4all/faer#107.

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.

1 participant