Skip to main content

tcontract_exec/
exec.rs

1use std::num::NonZeroUsize;
2
3use crate::{ExecError, Pool};
4
5/// Parallelism granted to an operation by [`Exec::install`].
6///
7/// # Examples
8///
9/// ```
10/// assert_eq!(tcontract_exec::Par::Seq.threads(), 1);
11/// ```
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum Par {
14    /// Run serially on the current thread.
15    Seq,
16    /// Up to this many workers of the pool the closure runs in.
17    Threads(NonZeroUsize),
18}
19
20impl Par {
21    /// Worker count (1 for `Seq`).
22    pub fn threads(self) -> usize {
23        match self {
24            Par::Seq => 1,
25            Par::Threads(n) => n.get(),
26        }
27    }
28}
29
30/// Execution context passed to every operation that may run in parallel.
31///
32/// # Examples
33///
34/// ```
35/// use tcontract_exec::Exec;
36/// assert_eq!(Exec::serial().budget(), 1);
37/// assert!(!Exec::serial().is_worker());
38/// ```
39#[derive(Clone, Copy)]
40#[non_exhaustive]
41pub enum Exec<'a> {
42    /// Everything on the calling thread, with call-local scratch.
43    Serial,
44    /// Everything on the calling thread, with caller-owned reusable scratch.
45    ///
46    /// A serial context owns no storage, so a caller that wants a serial
47    /// steady state to allocate nothing keeps a provider alive across calls
48    /// and lends it here.
49    SerialWithWorkspace(&'a dyn crate::WorkspaceProvider),
50    /// A borrowed pool with a thread budget `<= pool.size()`.
51    Rayon {
52        /// The borrowed pool.
53        pool: &'a Pool<'a>,
54        /// Most threads an operation may occupy.
55        budget: NonZeroUsize,
56    },
57}
58
59// A `&dyn WorkspaceProvider` is not `Debug`, so the derive cannot cover the
60// serial-with-workspace variant. The provider is an anonymous implementation
61// detail here, so only the variant name is shown.
62impl std::fmt::Debug for Exec<'_> {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            Exec::Serial => f.write_str("Serial"),
66            Exec::SerialWithWorkspace(_) => f.write_str("SerialWithWorkspace(..)"),
67            Exec::Rayon { pool, budget } => f
68                .debug_struct("Rayon")
69                .field("pool", pool)
70                .field("budget", budget)
71                .finish(),
72        }
73    }
74}
75
76impl<'a> Exec<'a> {
77    /// The serial context.
78    pub const fn serial() -> Exec<'static> {
79        Exec::Serial
80    }
81
82    /// Serial execution with caller-owned reusable storage.
83    pub fn serial_with_workspace(workspace: &'a dyn crate::WorkspaceProvider) -> Self {
84        Exec::SerialWithWorkspace(workspace)
85    }
86
87    /// Use `pool` with its full size as budget.
88    pub fn rayon(pool: &'a Pool<'a>) -> Self {
89        let budget = NonZeroUsize::new(pool.size()).unwrap_or(NonZeroUsize::MIN);
90        Exec::Rayon { pool, budget }
91    }
92
93    /// Limit the budget; values above the pool size are clamped.
94    ///
95    /// # Errors
96    ///
97    /// [`ExecError::ZeroBudget`] when `max_threads == 0`.
98    ///
99    /// # Examples
100    ///
101    /// ```
102    /// use tcontract_exec::{Exec, ExecError};
103    /// assert_eq!(Exec::serial().with_budget(0).unwrap_err(), ExecError::ZeroBudget);
104    /// ```
105    pub fn with_budget(self, max_threads: usize) -> Result<Self, ExecError> {
106        let max = NonZeroUsize::new(max_threads).ok_or(ExecError::ZeroBudget)?;
107        Ok(match self {
108            Exec::Serial | Exec::SerialWithWorkspace(_) => self,
109            Exec::Rayon { pool, .. } => {
110                let cap = NonZeroUsize::new(pool.size()).unwrap_or(NonZeroUsize::MIN);
111                Exec::Rayon {
112                    pool,
113                    budget: max.min(cap),
114                }
115            }
116        })
117    }
118
119    /// Most threads an operation may occupy (1 for a serial context).
120    pub fn budget(&self) -> usize {
121        match self {
122            Exec::Serial | Exec::SerialWithWorkspace(_) => 1,
123            Exec::Rayon { budget, .. } => budget.get(),
124        }
125    }
126
127    /// Storage the operations on this context may share, or none.
128    ///
129    /// A pool lends its own arena to every operation that runs on it; a serial
130    /// context owns nothing, so a caller that wants reuse keeps a provider of
131    /// its own and lends it through [`Exec::serial_with_workspace`].
132    pub fn workspace(&self) -> Option<&'a dyn crate::WorkspaceProvider> {
133        match self {
134            Exec::Serial => None,
135            Exec::SerialWithWorkspace(workspace) => Some(*workspace),
136            Exec::Rayon { pool, .. } => Some(pool.workspace()),
137        }
138    }
139
140    /// Whether the calling thread is a worker of this context's pool.
141    pub fn is_worker(&self) -> bool {
142        match self {
143            Exec::Serial | Exec::SerialWithWorkspace(_) => false,
144            Exec::Rayon { pool, .. } => pool.is_worker(),
145        }
146    }
147
148    fn width(&self, k: usize) -> usize {
149        k.clamp(1, self.budget())
150    }
151
152    /// Run `op` with `min(k, budget)` threads of parallelism.
153    ///
154    /// Width one runs `op(Par::Seq)` inline on the caller. Otherwise `op`
155    /// runs inside the pool, which is entered once, or in place when the
156    /// caller is already one of its workers.
157    pub fn install<R: Send>(&self, k: usize, op: impl FnOnce(Par) -> R + Send) -> R {
158        let k = self.width(k);
159        match (self, NonZeroUsize::new(k)) {
160            (Exec::Rayon { pool, .. }, Some(n)) if k > 1 => {
161                if pool.is_worker() {
162                    pool.count_inline();
163                    op(Par::Threads(n))
164                } else {
165                    pool.count_entry();
166                    pool.tp().install(|| op(Par::Threads(n)))
167                }
168            }
169            _ => op(Par::Seq),
170        }
171    }
172
173    /// Barrier-free partition: runs `f(i)` exactly once for each `i < k`,
174    /// on at most `budget` workers in contiguous lanes.
175    ///
176    /// Tasks are not guaranteed to run concurrently, so `f` must never wait
177    /// on another index.
178    ///
179    /// # Examples
180    ///
181    /// ```
182    /// use std::sync::atomic::{AtomicUsize, Ordering};
183    /// let n = AtomicUsize::new(0);
184    /// tcontract_exec::Exec::serial().for_each_partition(5, &|_| {
185    ///     n.fetch_add(1, Ordering::Relaxed);
186    /// });
187    /// assert_eq!(n.into_inner(), 5);
188    /// ```
189    pub fn for_each_partition(&self, k: usize, f: &(dyn Fn(usize) + Sync)) {
190        if k == 0 {
191            return;
192        }
193        let lanes = self.width(k);
194        if lanes == 1 {
195            (0..k).for_each(f);
196            return;
197        }
198        self.install(lanes, |_| {
199            rayon::scope(|s| {
200                for lane in 0..lanes {
201                    s.spawn(move |_| {
202                        let lo = lane * k / lanes;
203                        let hi = (lane + 1) * k / lanes;
204                        (lo..hi).for_each(f);
205                    });
206                }
207            })
208        });
209    }
210
211    /// Co-scheduled SPMD execution: `f(t)` for every `t < width` runs
212    /// concurrently on distinct workers, so `f` may use barriers counting
213    /// `width` participants.
214    ///
215    /// Width one runs `f(0)` inline. On a pool the whole pool is dispatched
216    /// (Rayon `broadcast`); workers with index `>= width` return at once.
217    /// Broadcasts through one [`Pool`] wrapper are serialized (use one
218    /// wrapper per `ThreadPool`). A broadcast waits until every worker reaches
219    /// a scheduling point, so a worker busy in another host's long job delays
220    /// it. A panic in a barrier-free `f` propagates to the caller after every
221    /// participant has finished; if `f` panics before a barrier the other
222    /// participants wait at it forever, so barrier-bearing `f` must not panic.
223    ///
224    /// # Errors
225    ///
226    /// [`ExecError::Unavailable`] when co-scheduling cannot be guaranteed
227    /// (`Serial` with `width > 1`, or the caller is already a worker of the
228    /// pool, where an outer job may hold workers at a barrier); nothing runs
229    /// then, and the caller uses its barrier-free variant.
230    /// [`ExecError::WidthExceedsPool`] when `width` exceeds the pool, and
231    /// [`ExecError::WidthExceedsBudget`] when it exceeds the budget (the
232    /// active width is capped by the budget even though the whole pool is
233    /// dispatched).
234    ///
235    /// # Examples
236    ///
237    /// ```
238    /// use tcontract_exec::{Exec, ExecError};
239    /// assert_eq!(Exec::serial().broadcast(2, &|_| {}), Err(ExecError::Unavailable));
240    /// ```
241    pub fn broadcast(&self, width: usize, f: &(dyn Fn(usize) + Sync)) -> Result<(), ExecError> {
242        if width == 0 {
243            return Ok(());
244        }
245        match self {
246            Exec::Serial | Exec::SerialWithWorkspace(_) => {
247                if width == 1 {
248                    f(0);
249                    Ok(())
250                } else {
251                    Err(ExecError::Unavailable)
252                }
253            }
254            Exec::Rayon { pool, budget } => {
255                let size = pool.size();
256                if width > size {
257                    return Err(ExecError::WidthExceedsPool { width, pool: size });
258                }
259                if width > budget.get() {
260                    return Err(ExecError::WidthExceedsBudget {
261                        width,
262                        budget: budget.get(),
263                    });
264                }
265                if pool.is_worker() {
266                    return Err(ExecError::Unavailable);
267                }
268                if width == 1 {
269                    f(0);
270                    return Ok(());
271                }
272                // INVARIANT: the SPMD mutex serializes broadcasts on this pool, so
273                // no worker ever holds two barrier-bearing jobs; it guards no data,
274                // so a poisoned lock (panic in an earlier `f`) is safe to reuse.
275                let _guard = pool
276                    .spmd
277                    .lock()
278                    .unwrap_or_else(std::sync::PoisonError::into_inner);
279                pool.count_broadcast();
280                pool.tp().broadcast(|ctx| {
281                    let t = ctx.index();
282                    if t < width {
283                        f(t)
284                    }
285                });
286                Ok(())
287            }
288        }
289    }
290
291    /// Partition width for work estimated at `serial_ns` on one thread,
292    /// minimizing the [`WidthPolicy`](crate::WidthPolicy) cost model within the budget.
293    ///
294    /// # Examples
295    ///
296    /// ```
297    /// use tcontract_exec::{Exec, WidthPolicy};
298    /// assert_eq!(Exec::serial().width_for(1e9, &WidthPolicy::default()), 1);
299    /// ```
300    pub fn width_for(&self, serial_ns: f64, policy: &crate::WidthPolicy) -> usize {
301        let budget = self.budget();
302        // NaN or small work stays serial.
303        if budget == 1 || serial_ns.is_nan() || serial_ns < policy.serial_below_ns {
304            return 1;
305        }
306        let cost = |k: usize| {
307            if k == 1 {
308                serial_ns
309            } else {
310                policy.entry_base_ns + policy.entry_per_thread_ns * k as f64 + serial_ns / k as f64
311            }
312        };
313        (1..=budget)
314            .min_by(|&a, &b| cost(a).total_cmp(&cost(b)))
315            .unwrap_or(1)
316    }
317}