treams_core/
threads.rs

1//! The thread budget and the Rayon pool that runs every parallel region. treams-rs
2//! extension.
3//!
4//! Parallel regions and parallel dense linear algebra run inside [`install`], on a
5//! pool owned by this module, never on Rayon's global pool. The global pool cannot
6//! be resized once started, and a process forked after it started inherits the pool
7//! without its worker threads, so its first parallel call would wait forever. The
8//! owned pool records the process that built it: a forked child builds its own pool
9//! on first use and never touches the parent's pool, locks or threads.
10//!
11//! Each worker clears the flushing bits of its floating-point control register when
12//! it starts (`fpenv::keep_subnormals_on_worker`), so the workers keep subnormal
13//! numbers whatever the mode of the thread that builds the pool; [`crate::fpenv`]
14//! explains why the kernels need them.
15//!
16//! The thread budget comes from the first of [`set_num_threads`],
17//! `TREAMS_RS_NUM_THREADS`, `RAYON_NUM_THREADS` and `OMP_NUM_THREADS` that
18//! holds a positive integer, and otherwise from the CPUs this process may use
19//! (affinity and cgroup quota). Environment variables are read once, on first
20//! use. Empty and zero values mean "not set"; other unparsable values are
21//! ignored and reported in [`Budget::diagnostics`].
22//!
23//! Calls already on a Rayon worker (a nested region, or a Rust caller's own
24//! pool) run in place on that worker's pool.
25
26use std::sync::atomic::{AtomicUsize, Ordering};
27use std::sync::{Arc, OnceLock, PoisonError, RwLock};
28
29use rayon::{ThreadPool, ThreadPoolBuilder};
30
31/// Environment variables consulted for the default budget, in precedence order.
32pub const ENVIRONMENT: [&str; 3] = [
33    "TREAMS_RS_NUM_THREADS",
34    "RAYON_NUM_THREADS",
35    "OMP_NUM_THREADS",
36];
37
38/// Where the active thread budget came from.
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub enum Source {
41    /// [`set_num_threads`].
42    Requested,
43    /// The named entry of [`ENVIRONMENT`].
44    Environment(&'static str),
45    /// [`std::thread::available_parallelism`].
46    Available,
47}
48
49impl Source {
50    /// Stable name reported to users.
51    #[must_use]
52    pub const fn name(self) -> &'static str {
53        match self {
54            Self::Requested => "set_num_threads",
55            Self::Environment(name) => name,
56            Self::Available => "available_parallelism",
57        }
58    }
59}
60
61/// A resolved thread budget.
62#[derive(Clone, Debug, PartialEq, Eq)]
63pub struct Budget {
64    /// Number of worker threads parallel regions may use.
65    pub threads: usize,
66    /// Where `threads` came from.
67    pub source: Source,
68    /// CPUs this process may use.
69    pub available: usize,
70    /// Ignored environment values and oversubscription notes, in reading order.
71    pub diagnostics: Vec<String>,
72}
73
74/// Live state of the pool, for diagnostics.
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub struct Info {
77    /// The budget the next parallel region will use.
78    pub budget: Budget,
79    /// Workers in the current pool; `None` until a parallel region has run in
80    /// this process. Smaller than the budget only if threads could not be spawned.
81    pub pool_threads: Option<usize>,
82    /// Whether this process was forked after treams-rs was loaded; it then
83    /// builds a pool of its own.
84    pub forked: bool,
85}
86
87/// Run `op` on the treams-rs pool, or in place when already on a Rayon worker.
88///
89/// Every Rayon parallel iterator, `rayon::join`, and faer call with
90/// `Par::rayon` in this crate runs inside `install`, so that none of them
91/// starts Rayon's global pool.
92pub fn install<R: Send>(op: impl FnOnce() -> R + Send) -> R {
93    if rayon::current_thread_index().is_some() {
94        return op();
95    }
96    match pool() {
97        Some(pool) => pool.install(op),
98        // No owned worker could be spawned. Guard fallback work on this caller
99        // and any workers Rayon can still start.
100        None => crate::fpenv::ieee(op),
101    }
102}
103
104/// [`rayon::join`] on the treams-rs pool.
105pub fn join<A, B, RA, RB>(a: A, b: B) -> (RA, RB)
106where
107    A: FnOnce() -> RA + Send,
108    B: FnOnce() -> RB + Send,
109    RA: Send,
110    RB: Send,
111{
112    install(|| rayon::join(a, b))
113}
114
115/// Workers available to the calling code: the current Rayon pool's size on a
116/// worker, otherwise the treams-rs budget. Never starts a pool.
117#[must_use]
118pub fn current_num_threads() -> usize {
119    if rayon::current_thread_index().is_some() {
120        rayon::current_num_threads()
121    } else {
122        num_threads()
123    }
124}
125
126/// The budget parallel regions will use. Never starts a pool.
127#[must_use]
128pub fn num_threads() -> usize {
129    match REQUESTED.load(Ordering::Acquire) {
130        0 => default_budget().threads,
131        threads => threads,
132    }
133}
134
135/// Override the budget for this process.
136///
137/// `None` or `Some(0)` restores the environment/default resolution. A parallel
138/// region that has started finishes on its pool; later regions use the new
139/// budget. Budgets are capped at [`rayon::max_num_threads`].
140pub fn set_num_threads(threads: Option<usize>) {
141    let threads = threads.map_or(0, |threads| threads.min(rayon::max_num_threads()));
142    REQUESTED.store(threads, Ordering::Release);
143}
144
145/// Record that this process was just forked; call it only in the child.
146///
147/// The pid check already detects a forked child; this also covers a reused pid
148/// and a fork that interrupted another thread's first use of the pool.
149pub fn after_fork() {
150    GENERATION.fetch_add(1, Ordering::AcqRel);
151}
152
153/// The active budget with its source and diagnostics. Never starts a pool.
154#[must_use]
155pub fn budget() -> Budget {
156    let default = default_budget();
157    match REQUESTED.load(Ordering::Acquire) {
158        0 => default.clone(),
159        threads => {
160            let mut diagnostics = default.diagnostics.clone();
161            if threads > default.available {
162                diagnostics.push(oversubscription(
163                    "set_num_threads",
164                    threads,
165                    default.available,
166                ));
167            }
168            Budget {
169                threads,
170                source: Source::Requested,
171                available: default.available,
172                diagnostics,
173            }
174        }
175    }
176}
177
178/// Budget and pool state. Never starts a pool or claims a slot.
179#[must_use]
180pub fn info() -> Info {
181    let pid = std::process::id();
182    let generation = GENERATION.load(Ordering::Acquire);
183    let existing = SLOTS.get(generation).and_then(OnceLock::get);
184    let pool_threads = existing.filter(|slot| slot.pid == pid).and_then(|slot| {
185        let pools = slot.pools.read().unwrap_or_else(PoisonError::into_inner);
186        pools
187            .current
188            .as_ref()
189            .map(|built| built.pool.current_num_threads())
190    });
191    Info {
192        budget: budget(),
193        pool_threads,
194        forked: generation > 0 || existing.is_some_and(|slot| slot.pid != pid),
195    }
196}
197
198/// Faer parallelism for a dense operation that may use `threads` workers.
199/// The operation must run inside [`install`] unless this returns `Par::Seq`.
200pub(crate) fn faer(threads: usize) -> faer::Par {
201    match threads {
202        0 | 1 => faer::Par::Seq,
203        threads => faer::Par::rayon(threads),
204    }
205}
206
207/// Run a dense operation with `threads` faer workers: inline when sequential,
208/// otherwise on the treams-rs pool.
209pub(crate) fn dense<R: Send>(threads: usize, op: impl FnOnce(faer::Par) -> R + Send) -> R {
210    match faer(threads) {
211        faer::Par::Seq => op(faer::Par::Seq),
212        par @ faer::Par::Rayon(_) => install(|| op(par)),
213    }
214}
215
216/// Faer multiplies complex128 matrices sequentially below this M·N·K
217/// (`PAR_THRESHOLD_MNK` times the scalar size). Require at least this much
218/// work per worker as well, so small matrices do not occupy the whole pool.
219const SEQUENTIAL_PRODUCT: usize = 4096 * 16;
220
221/// Run an `m × k` by `k × n` faer product with the treams-rs budget.
222///
223/// Matrix products split their output among workers and give the same bits at
224/// every budget. A matrix-vector product (`m` or `n` equal to one) splits its inner
225/// dimension instead, which changes the last bits with the worker count; it is
226/// bound by memory bandwidth and stays sequential.
227pub(crate) fn product<R: Send>(
228    m: usize,
229    n: usize,
230    k: usize,
231    op: impl FnOnce(faer::Par) -> R + Send,
232) -> R {
233    let work = m.saturating_mul(n).saturating_mul(k);
234    let threads = if work < SEQUENTIAL_PRODUCT || m == 1 || n == 1 {
235        1
236    } else {
237        current_num_threads().min(work / SEQUENTIAL_PRODUCT)
238    };
239    dense(threads, op)
240}
241
242// Forked children advance the generation, so this bounds nested forks. Past
243// it, every parallel region builds a pool of its own.
244const GENERATIONS: usize = 64;
245
246/// Override from `set_num_threads`; zero means unset. Atomics stay readable
247/// after fork, unlike a lock another thread may have held.
248static REQUESTED: AtomicUsize = AtomicUsize::new(0);
249static GENERATION: AtomicUsize = AtomicUsize::new(0);
250static DEFAULT: OnceLock<Budget> = OnceLock::new();
251static SLOTS: [OnceLock<Slot>; GENERATIONS] = [const { OnceLock::new() }; GENERATIONS];
252
253struct Slot {
254    pid: u32,
255    pools: RwLock<Pools>,
256}
257
258// The current pool plus one spare, so alternating between two budgets (for
259// example a scoped one-thread section) does not respawn threads every time.
260#[derive(Default)]
261struct Pools {
262    current: Option<Built>,
263    spare: Option<Built>,
264}
265
266struct Built {
267    requested: usize,
268    pool: Arc<ThreadPool>,
269}
270
271fn default_budget() -> &'static Budget {
272    if let Some(budget) = DEFAULT.get() {
273        return budget;
274    }
275    // Resolve outside the cell so a fork can only interrupt a trivial store.
276    let available = std::thread::available_parallelism().map_or(1, usize::from);
277    let budget = resolve(|name| std::env::var(name).ok(), available);
278    DEFAULT.get_or_init(move || budget)
279}
280
281/// This process's slot. A forked child moves to a fresh slot instead of using
282/// (or locking, or dropping) the parent's.
283fn slot() -> Option<&'static Slot> {
284    let pid = std::process::id();
285    loop {
286        let generation = GENERATION.load(Ordering::Acquire);
287        let slot = SLOTS.get(generation)?.get_or_init(|| Slot {
288            pid,
289            pools: RwLock::new(Pools::default()),
290        });
291        if slot.pid == pid {
292            return Some(slot);
293        }
294        let _ = GENERATION.compare_exchange(
295            generation,
296            generation + 1,
297            Ordering::AcqRel,
298            Ordering::Acquire,
299        );
300    }
301}
302
303fn pool() -> Option<Arc<ThreadPool>> {
304    #[cfg(test)]
305    if UNAVAILABLE_POOL.get() {
306        return None;
307    }
308    let requested = num_threads();
309    let Some(slot) = slot() else {
310        // Past GENERATIONS nested forks: a pool for this region only.
311        return build(requested);
312    };
313    let current = slot
314        .pools
315        .read()
316        .unwrap_or_else(PoisonError::into_inner)
317        .get(requested);
318    if current.is_some() {
319        return current;
320    }
321    let promoted = slot
322        .pools
323        .write()
324        .unwrap_or_else(PoisonError::into_inner)
325        .promote(requested);
326    if promoted.is_some() {
327        return promoted;
328    }
329    // Spawn threads without holding the lock; the first pool installed wins.
330    let pool = build(requested)?;
331    Some(
332        slot.pools
333            .write()
334            .unwrap_or_else(PoisonError::into_inner)
335            .insert(requested, pool),
336    )
337}
338
339#[cfg(test)]
340thread_local! {
341    static UNAVAILABLE_POOL: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
342}
343
344/// Exercise the failed-pool fallback without exhausting the host's threads.
345#[cfg(test)]
346pub(crate) fn with_unavailable_pool<T>(work: impl FnOnce() -> T) -> T {
347    struct Restore(bool);
348    impl Drop for Restore {
349        fn drop(&mut self) {
350            UNAVAILABLE_POOL.set(self.0);
351        }
352    }
353    let _restore = Restore(UNAVAILABLE_POOL.replace(true));
354    work()
355}
356
357impl Pools {
358    fn get(&self, requested: usize) -> Option<Arc<ThreadPool>> {
359        self.current
360            .as_ref()
361            .filter(|built| built.requested == requested)
362            .map(|built| Arc::clone(&built.pool))
363    }
364
365    /// The pool for `requested`, swapping in the spare when it matches.
366    fn promote(&mut self, requested: usize) -> Option<Arc<ThreadPool>> {
367        if self
368            .spare
369            .as_ref()
370            .is_some_and(|built| built.requested == requested)
371        {
372            std::mem::swap(&mut self.current, &mut self.spare);
373        }
374        self.get(requested)
375    }
376
377    /// Make `pool` current unless another thread installed one meanwhile.
378    fn insert(&mut self, requested: usize, pool: Arc<ThreadPool>) -> Arc<ThreadPool> {
379        if let Some(existing) = self.promote(requested) {
380            return existing;
381        }
382        // Dropping the old spare ends its threads once running regions finish.
383        self.spare = self.current.replace(Built {
384            requested,
385            pool: Arc::clone(&pool),
386        });
387        pool
388    }
389}
390
391/// Build a pool, halving the size while threads cannot be spawned.
392fn build(requested: usize) -> Option<Arc<ThreadPool>> {
393    let mut threads = requested.max(1);
394    loop {
395        let result = ThreadPoolBuilder::new()
396            .num_threads(threads)
397            .thread_name(|index| format!("treams-{index}"))
398            .start_handler(|_| crate::fpenv::keep_subnormals_on_worker())
399            .build();
400        match result {
401            Ok(pool) => return Some(Arc::new(pool)),
402            Err(_) if threads > 1 => threads /= 2,
403            Err(_) => return None,
404        }
405    }
406}
407
408/// Resolve the default budget from `lookup` (an environment) and the CPUs available.
409pub(crate) fn resolve(lookup: impl Fn(&str) -> Option<String>, available: usize) -> Budget {
410    let available = available.max(1);
411    let mut diagnostics = Vec::new();
412    for name in ENVIRONMENT {
413        let Some(raw) = lookup(name) else {
414            continue;
415        };
416        match parse(name, &raw) {
417            Ok(None) => {}
418            Ok(Some(threads)) => {
419                if threads > available {
420                    diagnostics.push(oversubscription(name, threads, available));
421                } else if name == "OMP_NUM_THREADS" && threads < available {
422                    diagnostics.push(format!(
423                        "OMP_NUM_THREADS limits treams-rs to {threads} of {available} CPUs; \
424                         set TREAMS_RS_NUM_THREADS or call set_num_threads to override"
425                    ));
426                }
427                return Budget {
428                    threads,
429                    source: Source::Environment(name),
430                    available,
431                    diagnostics,
432                };
433            }
434            Err(message) => diagnostics.push(message),
435        }
436    }
437    Budget {
438        threads: available,
439        source: Source::Available,
440        available,
441        diagnostics,
442    }
443}
444
445/// A positive thread count, `None` for empty or zero, or a diagnostic.
446fn parse(name: &str, raw: &str) -> Result<Option<usize>, String> {
447    let mut value = raw.trim();
448    if name == "OMP_NUM_THREADS" {
449        // OpenMP lists one count per nesting level; the outermost applies here.
450        value = value.split(',').next().unwrap_or_default().trim();
451    }
452    if value.is_empty() {
453        return Ok(None);
454    }
455    match value.parse::<usize>() {
456        Ok(0) => Ok(None),
457        Ok(threads) => Ok(Some(threads.min(rayon::max_num_threads()))),
458        Err(_) => Err(format!(
459            "ignored {name}={raw:?}: expected a positive integer"
460        )),
461    }
462}
463
464fn oversubscription(name: &str, threads: usize, available: usize) -> String {
465    format!(
466        "{name} requests {threads} threads but this process may use {available} CPUs; \
467         oversubscription slows dense linear algebra"
468    )
469}
470
471#[cfg(test)]
472mod tests {
473    #![allow(clippy::unwrap_used)] // proptest retains failing environments.
474    use super::*;
475    use proptest::prelude::*;
476    use std::collections::HashMap;
477
478    fn environment(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
479        let map: HashMap<String, String> = pairs
480            .iter()
481            .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
482            .collect();
483        move |name| map.get(name).cloned()
484    }
485
486    fn value() -> impl Strategy<Value = Option<String>> {
487        prop_oneof![
488            Just(None),
489            (0_usize..300).prop_map(|n| Some(n.to_string())),
490            (1_usize..300).prop_map(|n| Some(format!(" {n}\t"))),
491            (1_usize..300, 1_usize..9).prop_map(|(n, m)| Some(format!("{n},{m}"))),
492            "[a-z.+-]{0,4}".prop_map(Some),
493        ]
494    }
495
496    proptest! {
497        #![proptest_config(ProptestConfig::with_cases(crate::test_support::DEFAULT_CASES))]
498
499        #[test]
500        fn resolution_takes_the_first_valid_source_in_precedence_order(
501            values in proptest::collection::vec(value(), 3),
502            available in 1_usize..512,
503        ) {
504            let pairs: Vec<(&str, &str)> = ENVIRONMENT
505                .iter()
506                .zip(&values)
507                .filter_map(|(name, value)| value.as_deref().map(|v| (*name, v)))
508                .collect();
509            let budget = resolve(environment(&pairs), available);
510            prop_assert!(budget.threads >= 1);
511            prop_assert_eq!(budget.available, available);
512            let first = ENVIRONMENT.iter().zip(&values).find_map(|(name, value)| {
513                parse(name, value.as_deref()?).ok().flatten().map(|threads| (*name, threads))
514            });
515            if let Some((name, threads)) = first {
516                prop_assert_eq!(budget.source, Source::Environment(name));
517                prop_assert_eq!(budget.threads, threads);
518                prop_assert_eq!(
519                    budget.diagnostics.iter().any(|d| d.contains("oversubscription")),
520                    threads > available
521                );
522            } else {
523                prop_assert_eq!(budget.source, Source::Available);
524                prop_assert_eq!(budget.threads, available);
525            }
526            // Every ignored, unparsable value before the chosen source is reported.
527            for (name, value) in ENVIRONMENT.iter().zip(&values) {
528                if let Some(value) = value
529                    && parse(name, value).is_err()
530                    && first.is_none_or(|(chosen, _)| {
531                        ENVIRONMENT.iter().position(|n| n == name)
532                            < ENVIRONMENT.iter().position(|n| *n == chosen)
533                    })
534                {
535                    prop_assert!(budget.diagnostics.iter().any(|d| d.contains(name)));
536                }
537            }
538        }
539    }
540
541    #[test]
542    fn parsing_accepts_only_positive_integers() {
543        assert_eq!(parse("RAYON_NUM_THREADS", " 3 "), Ok(Some(3)));
544        assert_eq!(parse("RAYON_NUM_THREADS", "0"), Ok(None));
545        assert_eq!(parse("RAYON_NUM_THREADS", ""), Ok(None));
546        assert_eq!(parse("OMP_NUM_THREADS", "8,4"), Ok(Some(8)));
547        for invalid in ["two", "-1", "2.0", "4,2"] {
548            assert!(parse("RAYON_NUM_THREADS", invalid).is_err(), "{invalid}");
549        }
550        assert!(parse("OMP_NUM_THREADS", "x,4").is_err());
551    }
552
553    #[test]
554    #[allow(clippy::cast_precision_loss)] // Matrix indices are below 512.
555    fn matrix_products_keep_their_bits_when_worker_counts_are_capped() {
556        use crate::{Complex, linalg};
557        use nalgebra::DMatrix;
558
559        let shapes = [
560            (32, 64, 32),
561            (64, 64, 64),
562            (128, 128, 128),
563            (128, 4, 256),
564            (4, 128, 512),
565            (128, 1, 512),
566            (1, 128, 512),
567        ];
568        let matrices = shapes.map(|(m, n, k)| {
569            let left = DMatrix::from_fn(m, k, |i, j| {
570                Complex::new(((i + 3 * j) as f64).sin(), ((2 * i + j) as f64).cos())
571            });
572            let right = DMatrix::from_fn(k, n, |i, j| {
573                Complex::new(((5 * i + j) as f64).cos(), ((i + 2 * j) as f64).sin())
574            });
575            (left, right)
576        });
577        let mut reference = None;
578        // Local pools leave the process-wide override to its existing test.
579        for threads in [1, 4, 16, 32] {
580            let pool = ThreadPoolBuilder::new()
581                .num_threads(threads)
582                .start_handler(|_| crate::fpenv::keep_subnormals_on_worker())
583                .build()
584                .unwrap();
585            let bits = pool.install(|| {
586                matrices
587                    .iter()
588                    .flat_map(|(left, right)| {
589                        linalg::product(left, right)
590                            .iter()
591                            .map(|value| (value.re.to_bits(), value.im.to_bits()))
592                            .collect::<Vec<_>>()
593                    })
594                    .collect::<Vec<_>>()
595            });
596            assert_eq!(&bits, reference.get_or_insert_with(|| bits.clone()));
597        }
598    }
599
600    // Restores the process-wide override even if an assertion fails.
601    struct Reset;
602    impl Drop for Reset {
603        fn drop(&mut self) {
604            set_num_threads(None);
605        }
606    }
607
608    // One test owns the process-wide override, so parallel tests elsewhere only
609    // ever observe a valid budget. Other tests may swap the current pool
610    // concurrently, so only what this test's own regions observe is asserted.
611    #[test]
612    fn install_runs_on_a_named_pool_sized_by_the_budget() {
613        let _reset = Reset;
614        for threads in [2, 3, 2, 1] {
615            set_num_threads(Some(threads));
616            assert_eq!(num_threads(), threads);
617            let (size, name, nested) = install(|| {
618                (
619                    rayon::current_num_threads(),
620                    std::thread::current().name().map(str::to_owned),
621                    install(rayon::current_num_threads),
622                )
623            });
624            assert_eq!((size, nested), (threads, threads));
625            assert!(name.unwrap().starts_with("treams-"));
626            assert_eq!(budget().source, Source::Requested);
627            assert!(info().pool_threads.is_some());
628        }
629        set_num_threads(None);
630        assert_eq!(num_threads(), default_budget().threads);
631        let (a, b) = join(rayon::current_num_threads, rayon::current_num_threads);
632        assert_eq!((a, b), (num_threads(), num_threads()));
633    }
634}