Splittable CPU placement token for the tensor4all CPU stack. threadteam is the one place
that starts tasks: kernels take an Exec token and split it, and no scheduler
type appears in any public signature.
Used by strided-rs, tcontract-rs, cpueinsum-rs, tlinalg-rs and tenferro-rs.
Design record: docs/design/placement.md.
use std::sync::atomic::{AtomicUsize, Ordering};
use threadteam::{Exec, Pool};
// The owner builds a pool once and keeps it.
let pool = Pool::builder().threads(4).build()?;
// Per call, the owner mints a token for `budget` slots of the pool.
let mut exec = Exec::new(&pool, 4)?;
// Several phases in sequence: reborrow, because entries consume the token.
let total = AtomicUsize::new(0);
exec.reborrow().for_ranges(1000, 64.try_into().unwrap(), |r| {
total.fetch_add(r.len(), Ordering::Relaxed);
});
// Independent parts: each job gets a child token with its share of the slots.
exec.fork(2, |_job, child| {
let _ = child.budget();
});
# Ok::<(), threadteam::Error>(())Exec::serial() needs no pool and runs everything inline.
The owner of the CPU resources (an engine, a contraction resource bundle) holds
a threadteam::Pool and mints Exec::new(&pool, budget) for each call, passing
the token to kernels by value. Kernels never store a token and never mint one.
| Item | Role |
|---|---|
Pool, PoolBuilder |
fixed set of workers; threads, thread_name, stack_size (default 16 MiB, sized for recursive LU), on_start, build |
Exec::new(&pool, budget) |
mint a token; the only clamp (to the pool width); budget == 0 is an error |
Exec::serial() |
the calling thread only; const |
Exec::budget, is_serial |
inspect the token (is_serial means budget 1) |
Exec::reborrow(&mut self) |
same pool and budget while the parent is exclusively borrowed |
Exec::limit(max) |
narrow to min(budget, max) |
Exec::fork(jobs, f) |
min(jobs, budget) tasks; shares differ by at most one and sum to the budget; job i gets a child token |
Exec::for_ranges(len, grain, f) |
at most budget tasks, ranges of at least grain except possibly the last |
Exec::broadcast(f) |
f(i) for i in 0..budget, all concurrent, or Err(Refused) |
Exec::can_broadcast() |
non-consuming: true exactly when broadcast would not refuse right now; ask before building an expensive setup |
Exec::carries_team() |
true when the token has team authority (minted by a Team or a child of one); team() then returns the carried team without acquisition, so a consumer that wants a TeamExec asks this and keeps external for plain tokens |
Exec::team(f) / team_with(over, f) |
reserve budget - 1 awake pool members for a scope and run barrier-bearing sections through TeamExec (run, run_with, split, fork, reborrow), or Err(Refused::Unavailable); see the design record |
Pool::into_team(width) -> Team (owns the pool; IntoTeamError gives it back), Team::exec(budget) / try_exec, Team::pool, Team::into_pool |
persistent reservation for an engine that owns its pool (Pool is not Clone); minted tokens are ordinary Execs whose fork, for_ranges, broadcast, team dispatch on the reserved members; one leader at a time (exec blocks, try_exec -> Refused::Busy); idle members park while reserved |
PoolBuilder::qos(Qos), raise_current_thread_qos(Qos) |
worker QoS on macOS (default UserInitiated); raise the leading thread without ever lowering it |
PoolBuilder::idle_spin, idle_runs_rayon, acquire_timeout; Pool::release_idle |
waiting policy of idle team members; a reserved member spins with one PAUSE per poll (never sched_yield), parks after the delay, and parks after a short spin in a root scope that has more participants than the caller's CPUs |
PoolBuilder::pin_participants(bool), Pool::pin_participants() |
opt-in placement of the members of a root team scope (Linux; off by default, which is consistent with the rule it protects): member k (participant k >= 1) pins itself onto the k-th CPU of the calling thread's affinity set, S[0] is left to the leader, and a member restores its own mask before it returns to Rayon or serves foreign work. The leader is never pinned, because a vendor library creates its thread team from the calling thread and a one-CPU caller would confine the whole provider team; a member that calls such a library directly inside a share does confine it, so use Exec::external (which restores the member's mask first) or leave pinning off. For tensor4all/faer#107; give each team its own CPU set (taskset -c), there is no CPU allocator |
DispatchCosts, WidthCosts, PoolBuilder::dispatch_costs, Exec::dispatch_cost(width), TeamExec::dispatch_cost(width) |
cost of one parallel section per kind of token (team, in pool, outside), steady state; one row per HostClass (HostClass::detect(), keyed by kernel dispatch: x86_64-avx512, x86_64-avx2, x86_64-scalar, aarch64-apple, aarch64, generic), DispatchCosts::for_class(class); measured rows for x86_64-avx512 (team anchors at widths 2, 4, 6 and 8) and aarch64-apple, a measured Zen2 team row for x86_64-avx2 (anchors at ten widths from 2 to 64; its plain kinds stay generic), every other class takes the conservative generic row (3 / 15 / 40 us at the anchors 2, 4, 6); a row holds measured anchors (WidthCosts::new, WidthCosts::flat, or WidthCosts::from_anchors for measured anchors up to 64 and beyond), interpolates between them and grows linearly above the largest one, so a width above the anchors is never charged the cost of the largest anchor; DispatchCosts::host_default() is the detected class's row; Duration::MAX when serial; override per pool |
pays_at(est, sections, cost), Exec::pays(est, sections, width), Exec::width_for(est, sections), width_for_by, PAYS |
the one rule: parallel iff est >= PAYS * sections * D(width); width_for minimises sections * D(k) + est / k |
Exec::external(f) |
enter the pool once and hand the budget as a plain width to an outside library (faer, a vendor BLAS); how it divides the width is that library's concern |
- No duplication.
Execis neitherClonenorCopy(compile-fail doctests), and every parallel entry consumes it. - Slots are conserved. Under one root token the number of tasks running at once is at most its budget at every nesting depth. A parent waiting for its children keeps no slot.
- Lazy and inline. A serial token, budget one, or one task runs inline on the calling thread and makes no scheduler call, so the process-global Rayon pool is never started implicitly.
- Placement by caller. A worker of the target pool spawns into the current scope without installing; a plain thread or a worker of another pool enters the target pool once and waits. No thread-local state exists in the crate.
broadcastrefuses withRefused::ForkChildfor a token derived fromfork(throughreborroworlimittoo), and withRefused::CallerInPoolwhen the caller is a worker of the same pool. Roots, and theirreborrows andlimits, are eligible. Callers must handleRefusedby falling back to a barrier-free path. Concurrent broadcasts on one pool queue in the same order on every worker (no interleaving); participants are the pool's workers0..budget, so blocking work of another token on those workers delays the broadcast.can_broadcastapplies the same rule through one private helper, so the two cannot disagree.- Deterministic shutdown. Dropping a
Pooljoins every worker thread: whendropreturns, all workers have exited and their thread-local destructors (including state created in anon_starthook) have run. A pool dropped from one of its own workers cannot join itself, so that drop only signals shutdown and returns without joining. Withoutparallelthere is nothing to join. - No scheduler type is public. There is no constructor from, and no accessor
returning, a scheduler pool.
scripts/check-no-public-rayon.shrunscargo public-api -p threadteam(when installed) and fails ifrayon::appears in the output.
| Feature | Effect |
|---|---|
parallel (default) |
run on a private pool. Without it the same types exist, PoolBuilder::build returns a width-1 pool and every entry runs inline |
ledger |
test support: ledger::max_live(root), live, installs, and Exec::ledger_root. Records live tasks per root token and pool installs; compiled out when off |
Consumers assert ledger::max_live(root) <= budget in their own tests, and keep
a test binary that runs serial and pooled work and then checks that
rayon::ThreadPoolBuilder::new().build_global() still succeeds.
Licensed under either of MIT or Apache-2.0 at your option.