Skip to main content

concinnity_host/thread/
jobs.rs

1//! Backend-agnostic job pool for parallelising expensive per-frame CPU work.
2//!
3//! Systems run serially in the frame loop, each holding `&mut PipelineContext`.
4//! This pool does not change that: it lets a single system fan its own
5//! data-parallel work (per-skeleton pose sampling, particle update, ...) across
6//! worker threads and join before `step` returns. It is not a way to run whole
7//! systems concurrently.
8//!
9//! The pool wraps a dedicated `rayon::ThreadPool` rather than rayon's global
10//! pool so the worker count and thread names are controlled. It is process-wide
11//! and lazily built on first use via `pool()`.
12
13use std::sync::OnceLock;
14
15use rayon::prelude::*;
16
17/// A dedicated thread pool for per-frame data-parallel work.
18pub struct JobPool {
19    pool: rayon::ThreadPool,
20}
21
22impl JobPool {
23    // Build the pool at the worker count `configure` set, or the auto default
24    // (`available_parallelism() - 1`) when unconfigured. The App sizes it from
25    // its `ThreadBudget` before the first `pool()` use.
26    fn build() -> JobPool {
27        Self::with_threads(
28            CONFIGURED_THREADS
29                .get()
30                .copied()
31                .unwrap_or_else(default_threads),
32        )
33    }
34
35    // Build a pool with an explicit worker count (floored at one).
36    fn with_threads(threads: usize) -> JobPool {
37        let threads = threads.max(1);
38        let pool = rayon::ThreadPoolBuilder::new()
39            .num_threads(threads)
40            .thread_name(|i| format!("cn-job-{i}"))
41            .build()
42            .expect("failed to build job thread pool");
43        tracing::info!("JobPool: {threads} worker thread(s)");
44        JobPool { pool }
45    }
46
47    /// Number of worker threads in this pool.
48    pub fn thread_count(&self) -> usize {
49        self.pool.current_num_threads()
50    }
51
52    /// Apply `f` to every item in parallel, blocking until all are done.
53    ///
54    /// Each item must be independent: `f` runs concurrently across items in
55    /// no defined order. Inputs shorter than two items skip the pool and run
56    /// inline to avoid dispatch overhead.
57    pub fn parallel_for<T, F>(&self, items: &mut [T], f: F)
58    where
59        T: Send,
60        F: Fn(&mut T) + Send + Sync,
61    {
62        if items.len() < 2 {
63            items.iter_mut().for_each(f);
64            return;
65        }
66        self.pool.install(|| items.par_iter_mut().for_each(f));
67    }
68
69    /// Run a closure inside this pool's scope so any nested rayon
70    /// `par_iter` / `par_iter_mut` calls dispatch to JobPool's bounded thread
71    /// count (`available_parallelism() - 1`) instead of rayon's global pool
72    /// (which defaults to every core and would starve the render thread when
73    /// invoked from a worker that is itself competing for CPU).
74    ///
75    /// Used by the DirectX / Metal parallel command-buffer recording; the Vulkan
76    /// backend records single-threaded, so it is unused under `backend_vk`.
77    pub fn install<R, F>(&self, f: F) -> R
78    where
79        F: FnOnce() -> R + Send,
80        R: Send,
81    {
82        self.pool.install(f)
83    }
84}
85
86// Worker count set by `configure`, consulted by `JobPool::build` on first use.
87static CONFIGURED_THREADS: OnceLock<usize> = OnceLock::new();
88
89// Auto worker count: one per logical core, less one for the main thread,
90// floored at one.
91fn default_threads() -> usize {
92    std::thread::available_parallelism()
93        .map(|n| n.get().saturating_sub(1).max(1))
94        .unwrap_or(1)
95}
96
97/// Set the process-wide job pool's worker count. The App calls this from its
98/// `ThreadBudget` at start, before any system uses the pool. It takes effect
99/// only if called before the first `pool()` access (the pool is built once);
100/// a later call, or a value below one, is ignored/clamped.
101pub fn configure(threads: usize) {
102    let _ = CONFIGURED_THREADS.set(threads.max(1));
103}
104
105/// The process-wide job pool, built on first access.
106pub fn pool() -> &'static JobPool {
107    static POOL: OnceLock<JobPool> = OnceLock::new();
108    POOL.get_or_init(JobPool::build)
109}
110
111/// A single-worker pool: the same execution shape as `pool()` with the jobs
112/// run one at a time. The serial schedule installs solver work here so the
113/// determinism oracle exercises the identical code path minus the
114/// concurrency.
115pub fn serial_pool() -> &'static JobPool {
116    static POOL: OnceLock<JobPool> = OnceLock::new();
117    POOL.get_or_init(|| JobPool::with_threads(1))
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn pool_is_a_singleton() {
126        assert!(std::ptr::eq(pool(), pool()));
127    }
128
129    // An explicit worker count is honored (floored at one). Tested via
130    // `with_threads` directly: the process-wide `pool()` is a `OnceLock` built
131    // once, so its size cannot be asserted deterministically alongside the
132    // other tests that also touch it.
133    #[test]
134    fn with_threads_sets_the_worker_count() {
135        assert_eq!(JobPool::with_threads(3).thread_count(), 3);
136        assert_eq!(JobPool::with_threads(0).thread_count(), 1);
137    }
138
139    // The auto default always leaves at least one worker.
140    #[test]
141    fn default_threads_is_at_least_one() {
142        assert!(default_threads() >= 1);
143    }
144
145    #[test]
146    fn parallel_for_visits_every_item() {
147        let mut data: Vec<u32> = (0..10_000).collect();
148        pool().parallel_for(&mut data, |x| *x += 1);
149        assert!(data.iter().enumerate().all(|(i, &x)| x == i as u32 + 1));
150    }
151
152    #[test]
153    fn parallel_for_handles_empty_and_single() {
154        let mut empty: Vec<u32> = Vec::new();
155        pool().parallel_for(&mut empty, |x| *x += 1);
156        assert!(empty.is_empty());
157
158        let mut single = vec![41u32];
159        pool().parallel_for(&mut single, |x| *x += 1);
160        assert_eq!(single, vec![42]);
161    }
162}