Skip to main content

molframe_core/parallel/
execute.rs

1//! Running block plans on one process-wide bounded worker pool.
2//!
3//! Canonical blocks are assigned dynamically inside bounded windows. Results
4//! occupy their indexed slots, so scheduling never changes reduction order.
5//! Nested operations run inline instead of multiplying the shared worker pool.
6
7use super::plan::BlockPlan;
8use crate::ExecutionContext;
9use rayon::prelude::*;
10use std::fmt;
11use std::sync::atomic::{AtomicUsize, Ordering};
12use std::sync::{Arc, Mutex, OnceLock};
13
14/// A worker thread ended without returning its blocks.
15///
16/// The panic itself has already been reported by the default hook; this is the
17/// value the caller receives so that a partial result is never mistaken for a
18/// complete one.
19#[derive(Clone, Copy, PartialEq, Eq, Debug)]
20pub struct WorkerPanicked;
21
22impl fmt::Display for WorkerPanicked {
23    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
24        formatter.write_str("a worker thread panicked")
25    }
26}
27
28impl std::error::Error for WorkerPanicked {}
29
30/// Runs `block` over every block of `plan`, returning results in block order.
31///
32/// `workers` is the caller's, never the host's (NFR-113). One worker runs
33/// serially with no thread spawned, which is what keeps a small workload from
34/// paying for a thread it does not need (NFR-110).
35///
36/// The returned order is fixed by the plan, so it is identical at any worker
37/// count. That is the whole guarantee: a caller merging these results in the
38/// order they arrive is merging them in block order.
39///
40/// # Errors
41///
42/// Returns [`WorkerPanicked`] when a worker thread ends without returning.
43#[cfg(test)]
44pub(crate) fn map_blocks<T, F>(
45    plan: BlockPlan,
46    workers: usize,
47    block: F,
48) -> Result<Vec<T>, WorkerPanicked>
49where
50    T: Send,
51    F: Fn(usize, std::ops::Range<usize>) -> T + Sync,
52{
53    SharedPool::global().map(plan, workers, block)
54}
55
56/// Runs a block plan on the pool and worker budget owned by an execution graph.
57///
58/// Concurrent and nested operations share the same bounded native pool. The
59/// fixed block plan and final block-index ordering preserve deterministic
60/// reductions independently of scheduling.
61///
62/// # Errors
63///
64/// Returns [`WorkerPanicked`] if a native task panics.
65pub fn map_blocks_in<T, F>(
66    plan: BlockPlan,
67    context: &ExecutionContext,
68    block: F,
69) -> Result<Vec<T>, WorkerPanicked>
70where
71    T: Send,
72    F: Fn(usize, std::ops::Range<usize>) -> T + Sync,
73{
74    context.executor().map(plan, context.worker_budget(), block)
75}
76
77pub(crate) struct SharedPool {
78    pub(super) pool: Option<rayon::ThreadPool>,
79    capacity: usize,
80}
81
82impl fmt::Debug for SharedPool {
83    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
84        formatter
85            .debug_struct("SharedPool")
86            .field("capacity", &self.capacity)
87            .finish_non_exhaustive()
88    }
89}
90
91impl SharedPool {
92    pub(crate) fn global() -> Arc<Self> {
93        static SHARED: OnceLock<Arc<SharedPool>> = OnceLock::new();
94        Arc::clone(SHARED.get_or_init(|| Arc::new(Self::build())))
95    }
96
97    fn build() -> Self {
98        let requested = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
99        let pool = rayon::ThreadPoolBuilder::new()
100            .num_threads(requested)
101            .thread_name(|index| format!("molframe-worker-{index}"))
102            .build()
103            .ok();
104        let capacity = pool
105            .as_ref()
106            .map_or(1, rayon::ThreadPool::current_num_threads);
107        Self { pool, capacity }
108    }
109
110    pub(crate) const fn capacity(&self) -> usize {
111        self.capacity
112    }
113
114    pub(crate) fn map<T, F>(
115        &self,
116        plan: BlockPlan,
117        workers: usize,
118        block: F,
119    ) -> Result<Vec<T>, WorkerPanicked>
120    where
121        T: Send,
122        F: Fn(usize, std::ops::Range<usize>) -> T + Sync,
123    {
124        let mut output = Vec::new();
125        let window = plan
126            .useful_workers(workers.min(self.capacity))
127            .saturating_mul(2);
128        for start in (0..plan.blocks()).step_by(window) {
129            output.extend(self.map_window(
130                plan,
131                start..start.saturating_add(window).min(plan.blocks()),
132                workers,
133                &block,
134            )?);
135        }
136        Ok(output)
137    }
138
139    pub(crate) fn map_window<T, F>(
140        &self,
141        plan: BlockPlan,
142        indices: std::ops::Range<usize>,
143        workers: usize,
144        block: &F,
145    ) -> Result<Vec<T>, WorkerPanicked>
146    where
147        T: Send,
148        F: Fn(usize, std::ops::Range<usize>) -> T + Sync,
149    {
150        let workers = workers.max(1).min(self.capacity).min(indices.len());
151        std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
152            if workers <= 1 || self.pool.is_none() || rayon::current_thread_index().is_some() {
153                return indices
154                    .filter_map(|index| plan.range(index).map(|range| block(index, range)))
155                    .collect();
156            }
157            let slots: Vec<_> = indices.clone().map(|_| Mutex::new(None)).collect();
158            let next = AtomicUsize::new(indices.start);
159            if let Some(pool) = &self.pool {
160                pool.install(|| {
161                    (0..workers).into_par_iter().for_each(|_| {
162                        loop {
163                            let index = next.fetch_add(1, Ordering::Relaxed);
164                            if index >= indices.end {
165                                break;
166                            }
167                            let Some(range) = plan.range(index) else {
168                                break;
169                            };
170                            let value = block(index, range);
171                            let mut slot = match slots[index - indices.start].lock() {
172                                Ok(slot) => slot,
173                                Err(poisoned) => poisoned.into_inner(),
174                            };
175                            *slot = Some(value);
176                        }
177                    });
178                });
179            }
180            slots
181                .into_iter()
182                .filter_map(|slot| match slot.into_inner() {
183                    Ok(value) => value,
184                    Err(poisoned) => poisoned.into_inner(),
185                })
186                .collect()
187        }))
188        .map_err(|_| WorkerPanicked)
189    }
190}
191
192#[cfg(test)]
193#[path = "execute_tests.rs"]
194mod tests;