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}