1use std::sync::atomic::{AtomicUsize, Ordering};
27use std::sync::{Arc, OnceLock, PoisonError, RwLock};
28
29use rayon::{ThreadPool, ThreadPoolBuilder};
30
31pub const ENVIRONMENT: [&str; 3] = [
33 "TREAMS_RS_NUM_THREADS",
34 "RAYON_NUM_THREADS",
35 "OMP_NUM_THREADS",
36];
37
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub enum Source {
41 Requested,
43 Environment(&'static str),
45 Available,
47}
48
49impl Source {
50 #[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#[derive(Clone, Debug, PartialEq, Eq)]
63pub struct Budget {
64 pub threads: usize,
66 pub source: Source,
68 pub available: usize,
70 pub diagnostics: Vec<String>,
72}
73
74#[derive(Clone, Debug, PartialEq, Eq)]
76pub struct Info {
77 pub budget: Budget,
79 pub pool_threads: Option<usize>,
82 pub forked: bool,
85}
86
87pub 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 None => crate::fpenv::ieee(op),
101 }
102}
103
104pub 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#[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#[must_use]
128pub fn num_threads() -> usize {
129 match REQUESTED.load(Ordering::Acquire) {
130 0 => default_budget().threads,
131 threads => threads,
132 }
133}
134
135pub 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
145pub fn after_fork() {
150 GENERATION.fetch_add(1, Ordering::AcqRel);
151}
152
153#[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#[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
198pub(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
207pub(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
216const SEQUENTIAL_PRODUCT: usize = 4096 * 16;
220
221pub(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
242const GENERATIONS: usize = 64;
245
246static 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#[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 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
281fn 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 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 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#[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 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 fn insert(&mut self, requested: usize, pool: Arc<ThreadPool>) -> Arc<ThreadPool> {
379 if let Some(existing) = self.promote(requested) {
380 return existing;
381 }
382 self.spare = self.current.replace(Built {
384 requested,
385 pool: Arc::clone(&pool),
386 });
387 pool
388 }
389}
390
391fn 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
408pub(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
445fn parse(name: &str, raw: &str) -> Result<Option<usize>, String> {
447 let mut value = raw.trim();
448 if name == "OMP_NUM_THREADS" {
449 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)] 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 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)] 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 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 struct Reset;
602 impl Drop for Reset {
603 fn drop(&mut self) {
604 set_num_threads(None);
605 }
606 }
607
608 #[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}