phasesmith_execution/
lib.rs1use 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#[derive(Clone)]
16pub struct ExecutionContext {
17 threads: usize,
18 pool: Option<Arc<ThreadPool>>,
19}
20
21impl ExecutionContext {
22 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 #[must_use]
47 pub const fn serial() -> Self {
48 Self {
49 threads: 1,
50 pool: None,
51 }
52 }
53
54 #[must_use]
56 pub const fn threads(&self) -> usize {
57 self.threads
58 }
59
60 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
83pub const DEFAULT_EXECUTION_THREADS: usize = 2;
85pub const DEFAULT_MINIMUM_PARALLEL_TASKS: usize = 2;
87
88#[derive(Debug)]
90pub enum ExecutionPolicyError {
91 InvalidThreadCount,
93 InvalidMinimumParallelTasks,
95 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#[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 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 pub fn bounded_default() -> Result<Self, ExecutionPolicyError> {
195 Self::new(
196 Some(DEFAULT_EXECUTION_THREADS),
197 DEFAULT_MINIMUM_PARALLEL_TASKS,
198 )
199 }
200
201 #[must_use]
203 pub const fn requested_threads(&self) -> Option<usize> {
204 self.requested_threads
205 }
206
207 #[must_use]
209 pub const fn minimum_parallel_tasks(&self) -> usize {
210 self.minimum_parallel_tasks
211 }
212
213 #[must_use]
215 pub const fn resolved_budget(&self) -> usize {
216 self.context.threads()
217 }
218
219 #[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 #[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}