Skip to main content

phasesmith_execution/
lib.rs

1//! Explicit bounded execution contexts for native `PhaseSmith` kernels.
2
3use std::error::Error;
4use std::fmt::{Display, Formatter};
5use std::sync::Arc;
6
7use rayon::iter::{IntoParallelIterator, ParallelIterator};
8use rayon::{ThreadPool, ThreadPoolBuildError, ThreadPoolBuilder};
9
10/// Reusable, operation-owned worker pool with deterministic ordered mapping.
11///
12/// The context never installs or mutates Rayon's global pool. A one-thread
13/// context executes closures directly, which also prevents nested pools when
14/// Python already schedules independent native batches.
15#[derive(Clone)]
16pub struct ExecutionContext {
17    threads: usize,
18    pool: Option<Arc<ThreadPool>>,
19}
20
21impl ExecutionContext {
22    /// Construct a context with an exact positive worker budget.
23    ///
24    /// # Errors
25    ///
26    /// Returns [`ThreadPoolBuildError`] when Rayon cannot build the bounded
27    /// worker pool.
28    pub fn new(threads: usize) -> Result<Self, ThreadPoolBuildError> {
29        let pool = if threads <= 1 {
30            None
31        } else {
32            Some(Arc::new(
33                ThreadPoolBuilder::new()
34                    .num_threads(threads)
35                    .thread_name(|index| format!("phasesmith-native-{index}"))
36                    .build()?,
37            ))
38        };
39        Ok(Self {
40            threads: threads.max(1),
41            pool,
42        })
43    }
44
45    /// Return a zero-allocation serial context.
46    #[must_use]
47    pub const fn serial() -> Self {
48        Self {
49            threads: 1,
50            pool: None,
51        }
52    }
53
54    /// Return the exact worker budget owned by this context.
55    #[must_use]
56    pub const fn threads(&self) -> usize {
57        self.threads
58    }
59
60    /// Map independent indices while collecting results in increasing order.
61    ///
62    /// Work below `minimum_parallel_items` remains serial. Indexed Rayon
63    /// collection preserves the input order independently of completion order.
64    pub fn map_ordered<R, F>(
65        &self,
66        item_count: usize,
67        minimum_parallel_items: usize,
68        operation: F,
69    ) -> Vec<R>
70    where
71        R: Send,
72        F: Fn(usize) -> R + Send + Sync,
73    {
74        if let Some(pool) = &self.pool
75            && item_count >= minimum_parallel_items
76        {
77            return pool.install(|| (0..item_count).into_par_iter().map(operation).collect());
78        }
79        (0..item_count).map(operation).collect()
80    }
81}
82
83/// Default fixed worker budget used by scripts and applications.
84pub const DEFAULT_EXECUTION_THREADS: usize = 2;
85/// Default number of independent tasks required before parallel scheduling.
86pub const DEFAULT_MINIMUM_PARALLEL_TASKS: usize = 2;
87
88/// Invalid or unconstructable native execution policy.
89#[derive(Debug)]
90pub enum ExecutionPolicyError {
91    /// A fixed worker budget was zero.
92    InvalidThreadCount,
93    /// The parallel scheduling threshold was zero.
94    InvalidMinimumParallelTasks,
95    /// Rayon could not construct the bounded worker pool.
96    ThreadPool(ThreadPoolBuildError),
97}
98
99impl Display for ExecutionPolicyError {
100    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
101        match self {
102            Self::InvalidThreadCount => {
103                formatter.write_str("threads must be automatic or a positive integer")
104            }
105            Self::InvalidMinimumParallelTasks => {
106                formatter.write_str("minimum_parallel_tasks must be a positive integer")
107            }
108            Self::ThreadPool(error) => Display::fmt(error, formatter),
109        }
110    }
111}
112
113impl Error for ExecutionPolicyError {
114    fn source(&self) -> Option<&(dyn Error + 'static)> {
115        match self {
116            Self::ThreadPool(error) => Some(error),
117            Self::InvalidThreadCount | Self::InvalidMinimumParallelTasks => None,
118        }
119    }
120}
121
122/// Immutable worker-budget policy with one persistent bounded execution pool.
123///
124/// `requested_threads = None` selects the available logical CPU count. Fixed
125/// budgets are capped to that count. Cloned execution contexts share the same
126/// Rayon pool, so prepared phases and workflow operations do not rebuild or
127/// nest worker pools.
128#[derive(Clone)]
129pub struct ExecutionPolicy {
130    requested_threads: Option<usize>,
131    minimum_parallel_tasks: usize,
132    context: ExecutionContext,
133}
134
135impl std::fmt::Debug for ExecutionPolicy {
136    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
137        formatter
138            .debug_struct("ExecutionPolicy")
139            .field("requested_threads", &self.requested_threads)
140            .field("minimum_parallel_tasks", &self.minimum_parallel_tasks)
141            .field(
142                "context",
143                &format_args!("{} threads", self.resolved_budget()),
144            )
145            .finish()
146    }
147}
148
149impl PartialEq for ExecutionPolicy {
150    fn eq(&self, other: &Self) -> bool {
151        self.requested_threads == other.requested_threads
152            && self.minimum_parallel_tasks == other.minimum_parallel_tasks
153    }
154}
155
156impl Eq for ExecutionPolicy {}
157
158impl ExecutionPolicy {
159    /// Construct and retain the bounded execution context for this policy.
160    ///
161    /// # Errors
162    ///
163    /// Returns [`ExecutionPolicyError`] for zero budgets/thresholds or when
164    /// Rayon cannot build the resolved worker pool.
165    pub fn new(
166        requested_threads: Option<usize>,
167        minimum_parallel_tasks: usize,
168    ) -> Result<Self, ExecutionPolicyError> {
169        if requested_threads == Some(0) {
170            return Err(ExecutionPolicyError::InvalidThreadCount);
171        }
172        if minimum_parallel_tasks == 0 {
173            return Err(ExecutionPolicyError::InvalidMinimumParallelTasks);
174        }
175        let available_threads = std::thread::available_parallelism().map_or(1, usize::from);
176        let resolved_threads = requested_threads
177            .unwrap_or(available_threads)
178            .min(available_threads)
179            .max(1);
180        let context =
181            ExecutionContext::new(resolved_threads).map_err(ExecutionPolicyError::ThreadPool)?;
182        Ok(Self {
183            requested_threads,
184            minimum_parallel_tasks,
185            context,
186        })
187    }
188
189    /// Construct the bounded two-thread default policy.
190    ///
191    /// # Errors
192    ///
193    /// Returns [`ExecutionPolicyError`] when Rayon cannot build the pool.
194    pub fn bounded_default() -> Result<Self, ExecutionPolicyError> {
195        Self::new(
196            Some(DEFAULT_EXECUTION_THREADS),
197            DEFAULT_MINIMUM_PARALLEL_TASKS,
198        )
199    }
200
201    /// Return the requested fixed worker count, or `None` for automatic mode.
202    #[must_use]
203    pub const fn requested_threads(&self) -> Option<usize> {
204        self.requested_threads
205    }
206
207    /// Return the independent-task threshold for parallel scheduling.
208    #[must_use]
209    pub const fn minimum_parallel_tasks(&self) -> usize {
210        self.minimum_parallel_tasks
211    }
212
213    /// Return the resolved logical-CPU budget retained by this policy.
214    #[must_use]
215    pub const fn resolved_budget(&self) -> usize {
216        self.context.threads()
217    }
218
219    /// Return the worker count for a known number of independent tasks.
220    #[must_use]
221    pub fn worker_count(&self, task_count: usize) -> usize {
222        if task_count < self.minimum_parallel_tasks {
223            return 1;
224        }
225        self.resolved_budget().min(task_count).max(1)
226    }
227
228    /// Borrow the persistent bounded execution context.
229    #[must_use]
230    pub const fn context(&self) -> &ExecutionContext {
231        &self.context
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use std::thread;
239    use std::time::Duration;
240
241    #[test]
242    fn ordered_mapping_is_identical_across_worker_counts() {
243        let serial = ExecutionContext::serial();
244        let parallel = ExecutionContext::new(3).expect("pool");
245        let operation = |index| {
246            thread::sleep(Duration::from_micros(((7 - index) % 4) as u64));
247            index * index
248        };
249        assert_eq!(
250            serial.map_ordered(8, 2, operation),
251            parallel.map_ordered(8, 2, operation)
252        );
253        assert_eq!(parallel.threads(), 3);
254    }
255
256    #[test]
257    fn threshold_keeps_small_work_serial_and_ordered() {
258        let context = ExecutionContext::new(2).expect("pool");
259        assert_eq!(context.map_ordered(3, 4, |index| index + 1), [1, 2, 3]);
260    }
261
262    #[test]
263    fn policy_validates_resolves_and_reuses_its_context() {
264        let available = thread::available_parallelism().map_or(1, usize::from);
265        let policy = ExecutionPolicy::new(Some(available + 3), 3).expect("policy");
266        assert_eq!(policy.requested_threads(), Some(available + 3));
267        assert_eq!(policy.minimum_parallel_tasks(), 3);
268        assert_eq!(policy.resolved_budget(), available);
269        assert_eq!(policy.worker_count(2), 1);
270        assert_eq!(policy.worker_count(3), available.min(3));
271        assert_eq!(policy.context().threads(), available);
272
273        let shared_context = policy.context().clone();
274        assert_eq!(
275            policy.context().map_ordered(4, 2, |index| index * 2),
276            shared_context.map_ordered(4, 2, |index| index * 2)
277        );
278    }
279
280    #[test]
281    fn policy_rejects_zero_configuration_values() {
282        assert!(matches!(
283            ExecutionPolicy::new(Some(0), 2),
284            Err(ExecutionPolicyError::InvalidThreadCount)
285        ));
286        assert!(matches!(
287            ExecutionPolicy::new(Some(1), 0),
288            Err(ExecutionPolicyError::InvalidMinimumParallelTasks)
289        ));
290    }
291}