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/// Spreads a bake's independent rows across the process-wide job pool.
87///
88/// The environment-map convolutions decompose into rows that share nothing --
89/// each reads only the immutable source and writes only its own texels -- so
90/// fanning them out buys wall clock without changing a byte. Handed to
91/// `concinnity_core::bake` wherever a build has a pool behind it; a caller
92/// without one uses `Serial` instead.
93pub struct PoolRows;
94
95impl concinnity_core::bake::environment_map::RowScheduler for PoolRows {
96    fn run<T: Send>(&self, items: &mut [T], compute: &(dyn Fn(&mut T) + Send + Sync)) {
97        pool().parallel_for(items, compute);
98    }
99}
100
101// Worker count set by `configure`, consulted by `JobPool::build` on first use.
102static CONFIGURED_THREADS: OnceLock<usize> = OnceLock::new();
103
104// Auto worker count: one per logical core, less one for the main thread,
105// floored at one.
106fn default_threads() -> usize {
107    std::thread::available_parallelism()
108        .map(|n| n.get().saturating_sub(1).max(1))
109        .unwrap_or(1)
110}
111
112/// Set the process-wide job pool's worker count. The App calls this from its
113/// `ThreadBudget` at start, before any system uses the pool. It takes effect
114/// only if called before the first `pool()` access (the pool is built once);
115/// a later call, or a value below one, is ignored/clamped.
116pub fn configure(threads: usize) {
117    let _ = CONFIGURED_THREADS.set(threads.max(1));
118}
119
120/// The process-wide job pool, built on first access.
121pub fn pool() -> &'static JobPool {
122    static POOL: OnceLock<JobPool> = OnceLock::new();
123    POOL.get_or_init(JobPool::build)
124}
125
126/// A single-worker pool: the same execution shape as `pool()` with the jobs
127/// run one at a time. The serial schedule installs solver work here so the
128/// determinism oracle exercises the identical code path minus the
129/// concurrency.
130pub fn serial_pool() -> &'static JobPool {
131    static POOL: OnceLock<JobPool> = OnceLock::new();
132    POOL.get_or_init(|| JobPool::with_threads(1))
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn pool_is_a_singleton() {
141        assert!(std::ptr::eq(pool(), pool()));
142    }
143
144    // An explicit worker count is honored (floored at one). Tested via
145    // `with_threads` directly: the process-wide `pool()` is a `OnceLock` built
146    // once, so its size cannot be asserted deterministically alongside the
147    // other tests that also touch it.
148    #[test]
149    fn with_threads_sets_the_worker_count() {
150        assert_eq!(JobPool::with_threads(3).thread_count(), 3);
151        assert_eq!(JobPool::with_threads(0).thread_count(), 1);
152    }
153
154    // The auto default always leaves at least one worker.
155    #[test]
156    fn default_threads_is_at_least_one() {
157        assert!(default_threads() >= 1);
158    }
159
160    #[test]
161    fn parallel_for_visits_every_item() {
162        let mut data: Vec<u32> = (0..10_000).collect();
163        pool().parallel_for(&mut data, |x| *x += 1);
164        assert!(data.iter().enumerate().all(|(i, &x)| x == i as u32 + 1));
165    }
166
167    #[test]
168    fn parallel_for_handles_empty_and_single() {
169        let mut empty: Vec<u32> = Vec::new();
170        pool().parallel_for(&mut empty, |x| *x += 1);
171        assert!(empty.is_empty());
172
173        let mut single = vec![41u32];
174        pool().parallel_for(&mut single, |x| *x += 1);
175        assert_eq!(single, vec![42]);
176    }
177}