Skip to main content

tcontract_exec/
exec.rs

1use std::num::NonZeroUsize;
2
3use crate::{BatchDecision, BatchPolicy, BatchQuery, 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. Carries the
43    /// batch policy so a forced strategy is still resolved at one thread.
44    Serial {
45        /// The batch lane policy this context applies.
46        policy: BatchPolicy,
47    },
48    /// Everything on the calling thread, with caller-owned reusable scratch.
49    SerialWithWorkspace {
50        /// The caller-owned storage.
51        workspace: &'a dyn crate::WorkspaceProvider,
52        /// The batch lane policy this context applies.
53        policy: BatchPolicy,
54    },
55    /// A borrowed pool with a thread budget `<= pool.size()`.
56    Rayon {
57        /// The borrowed pool.
58        pool: &'a Pool<'a>,
59        /// Most threads an operation may occupy.
60        budget: NonZeroUsize,
61        /// The batch lane policy this context applies.
62        policy: BatchPolicy,
63    },
64}
65
66// A `&dyn WorkspaceProvider` is not `Debug`, so the derive cannot cover the
67// serial-with-workspace variant. The provider is an anonymous implementation
68// detail here, so only the variant name is shown.
69impl std::fmt::Debug for Exec<'_> {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        match self {
72            Exec::Serial { .. } => f.write_str("Serial"),
73            Exec::SerialWithWorkspace { .. } => f.write_str("SerialWithWorkspace(..)"),
74            Exec::Rayon {
75                pool,
76                budget,
77                policy,
78            } => f
79                .debug_struct("Rayon")
80                .field("pool", pool)
81                .field("budget", budget)
82                .field("policy", policy)
83                .finish(),
84        }
85    }
86}
87
88impl<'a> Exec<'a> {
89    /// The serial context.
90    pub const fn serial() -> Exec<'static> {
91        Exec::Serial {
92            policy: crate::batch::DEFAULT_BATCH_POLICY,
93        }
94    }
95
96    /// Serial execution with caller-owned reusable storage.
97    pub fn serial_with_workspace(workspace: &'a dyn crate::WorkspaceProvider) -> Self {
98        Exec::SerialWithWorkspace {
99            workspace,
100            policy: crate::batch::DEFAULT_BATCH_POLICY,
101        }
102    }
103
104    /// Use `pool` with its full size as budget and the default batch policy.
105    pub fn rayon(pool: &'a Pool<'a>) -> Self {
106        let budget = NonZeroUsize::new(pool.size()).unwrap_or(NonZeroUsize::MIN);
107        Exec::Rayon {
108            pool,
109            budget,
110            policy: BatchPolicy::default(),
111        }
112    }
113
114    /// Limit the budget; values above the pool size are clamped.
115    ///
116    /// # Errors
117    ///
118    /// [`ExecError::ZeroBudget`] when `max_threads == 0`.
119    ///
120    /// # Examples
121    ///
122    /// ```
123    /// use tcontract_exec::{Exec, ExecError};
124    /// assert_eq!(Exec::serial().with_budget(0).unwrap_err(), ExecError::ZeroBudget);
125    /// ```
126    pub fn with_budget(self, max_threads: usize) -> Result<Self, ExecError> {
127        let max = NonZeroUsize::new(max_threads).ok_or(ExecError::ZeroBudget)?;
128        Ok(match self {
129            Exec::Serial { .. } | Exec::SerialWithWorkspace { .. } => self,
130            Exec::Rayon { pool, .. } => {
131                let cap = NonZeroUsize::new(pool.size()).unwrap_or(NonZeroUsize::MIN);
132                Exec::Rayon {
133                    pool,
134                    budget: max.min(cap),
135                    policy: *self.batch_policy(),
136                }
137            }
138        })
139    }
140
141    /// The batch lane policy this context applies.
142    ///
143    /// A serial context has one thread but still carries a policy, so a forced
144    /// strategy is resolved (and can be refused) at one thread as well.
145    ///
146    /// # Examples
147    ///
148    /// ```
149    /// use tcontract_exec::{BatchPolicy, BatchStrategy, Exec};
150    /// assert_eq!(Exec::serial().batch_policy().strategy, BatchStrategy::Auto);
151    /// let tp = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap();
152    /// let pool = tcontract_exec::Pool::borrow(&tp);
153    /// let exec = Exec::rayon(&pool).with_batch_policy(BatchPolicy::new(BatchStrategy::Outer));
154    /// assert_eq!(exec.batch_policy().strategy, BatchStrategy::Outer);
155    /// ```
156    pub fn batch_policy(&self) -> &BatchPolicy {
157        match self {
158            Exec::Serial { policy } | Exec::SerialWithWorkspace { policy, .. } => policy,
159            Exec::Rayon { policy, .. } => policy,
160        }
161    }
162
163    /// Return this context with a different batch lane policy.
164    ///
165    /// A serial context has one thread, so the policy cannot change its lane
166    /// count, but it is preserved: a forced strategy is still honoured (or
167    /// refused with a typed error) at one thread instead of silently running
168    /// `Auto`.
169    ///
170    /// # Examples
171    ///
172    /// ```
173    /// use tcontract_exec::{BatchPolicy, BatchStrategy, Exec};
174    /// let exec = Exec::serial().with_batch_policy(BatchPolicy::new(BatchStrategy::Outer));
175    /// assert_eq!(exec.batch_policy().strategy, BatchStrategy::Outer);
176    /// ```
177    #[must_use]
178    pub fn with_batch_policy(self, policy: BatchPolicy) -> Self {
179        match self {
180            Exec::Serial { .. } => Exec::Serial { policy },
181            Exec::SerialWithWorkspace { workspace, .. } => {
182                Exec::SerialWithWorkspace { workspace, policy }
183            }
184            Exec::Rayon { pool, budget, .. } => Exec::Rayon {
185                pool,
186                budget,
187                policy,
188            },
189        }
190    }
191
192    /// Decide the batch route for `query` on this context's budget and policy.
193    ///
194    /// This is the one decision function every operation that owns batch lanes
195    /// calls; see [`crate::batch`]. Whether a *route* can honour the returned
196    /// decision is the route's own preflight (see
197    /// [`crate::batch::BatchDecision`]'s route contract); the decision itself
198    /// is arithmetic and never fails.
199    ///
200    /// # Examples
201    ///
202    /// ```
203    /// use tcontract_exec::{BatchQuery, Exec};
204    /// let d = Exec::serial().batch_decision(&BatchQuery::new(4, 1000.0));
205    /// assert_eq!((d.lanes, d.item_threads), (1, 1));
206    /// ```
207    pub fn batch_decision(&self, query: &BatchQuery) -> BatchDecision {
208        // INVARIANT: `Exec` budgets are `NonZeroUsize` by construction.
209        let budget = NonZeroUsize::new(self.budget()).unwrap_or(NonZeroUsize::MIN);
210        crate::batch::batch_decision(self.batch_policy(), budget, query)
211    }
212
213    /// Most threads an operation may occupy (1 for a serial context).
214    pub fn budget(&self) -> usize {
215        match self {
216            Exec::Serial { .. } | Exec::SerialWithWorkspace { .. } => 1,
217            Exec::Rayon { budget, .. } => budget.get(),
218        }
219    }
220
221    /// Storage the operations on this context may share, or none.
222    ///
223    /// A pool lends its own arena to every operation that runs on it; a serial
224    /// context owns nothing, so a caller that wants reuse keeps a provider of
225    /// its own and lends it through [`Exec::serial_with_workspace`].
226    pub fn workspace(&self) -> Option<&'a dyn crate::WorkspaceProvider> {
227        match self {
228            Exec::Serial { .. } => None,
229            Exec::SerialWithWorkspace { workspace, .. } => Some(*workspace),
230            Exec::Rayon { pool, .. } => Some(pool.workspace()),
231        }
232    }
233
234    /// Whether the calling thread is a worker of this context's pool.
235    pub fn is_worker(&self) -> bool {
236        match self {
237            Exec::Serial { .. } | Exec::SerialWithWorkspace { .. } => false,
238            Exec::Rayon { pool, .. } => pool.is_worker(),
239        }
240    }
241
242    fn width(&self, k: usize) -> usize {
243        k.clamp(1, self.budget())
244    }
245
246    /// Run `op` with `min(k, budget)` threads of parallelism.
247    ///
248    /// Width one runs `op(Par::Seq)` inline on the caller. Otherwise `op`
249    /// runs inside the pool, which is entered once, or in place when the
250    /// caller is already one of its workers.
251    pub fn install<R: Send>(&self, k: usize, op: impl FnOnce(Par) -> R + Send) -> R {
252        let k = self.width(k);
253        match (self, NonZeroUsize::new(k)) {
254            (Exec::Rayon { pool, .. }, Some(n)) if k > 1 => {
255                if pool.is_worker() {
256                    pool.count_inline();
257                    op(Par::Threads(n))
258                } else {
259                    pool.count_entry();
260                    pool.tp().install(|| op(Par::Threads(n)))
261                }
262            }
263            _ => op(Par::Seq),
264        }
265    }
266
267    /// Barrier-free partition: runs `f(i)` exactly once for each `i < k`,
268    /// on at most `budget` workers in contiguous lanes.
269    ///
270    /// Tasks are not guaranteed to run concurrently, so `f` must never wait
271    /// on another index.
272    ///
273    /// # Examples
274    ///
275    /// ```
276    /// use std::sync::atomic::{AtomicUsize, Ordering};
277    /// let n = AtomicUsize::new(0);
278    /// tcontract_exec::Exec::serial().for_each_partition(5, &|_| {
279    ///     n.fetch_add(1, Ordering::Relaxed);
280    /// });
281    /// assert_eq!(n.into_inner(), 5);
282    /// ```
283    pub fn for_each_partition(&self, k: usize, f: &(dyn Fn(usize) + Sync)) {
284        if k == 0 {
285            return;
286        }
287        let lanes = self.width(k);
288        if lanes == 1 {
289            (0..k).for_each(f);
290            return;
291        }
292        self.install(lanes, |_| {
293            rayon::scope(|s| {
294                for lane in 0..lanes {
295                    s.spawn(move |_| {
296                        let lo = lane * k / lanes;
297                        let hi = (lane + 1) * k / lanes;
298                        (lo..hi).for_each(f);
299                    });
300                }
301            })
302        });
303    }
304
305    /// Co-scheduled SPMD execution: `f(t)` for every `t < width` runs
306    /// concurrently on distinct workers, so `f` may use barriers counting
307    /// `width` participants.
308    ///
309    /// Width one runs `f(0)` inline. On a pool the whole pool is dispatched
310    /// (Rayon `broadcast`); workers with index `>= width` return at once.
311    /// Broadcasts through one [`Pool`] wrapper are serialized (use one
312    /// wrapper per `ThreadPool`). A broadcast waits until every worker reaches
313    /// a scheduling point, so a worker busy in another host's long job delays
314    /// it. A panic in a barrier-free `f` propagates to the caller after every
315    /// participant has finished; if `f` panics before a barrier the other
316    /// participants wait at it forever, so barrier-bearing `f` must not panic.
317    ///
318    /// # Errors
319    ///
320    /// [`ExecError::Unavailable`] when co-scheduling cannot be guaranteed
321    /// (`Serial` with `width > 1`, or the caller is already a worker of the
322    /// pool, where an outer job may hold workers at a barrier); nothing runs
323    /// then, and the caller uses its barrier-free variant.
324    /// [`ExecError::WidthExceedsPool`] when `width` exceeds the pool, and
325    /// [`ExecError::WidthExceedsBudget`] when it exceeds the budget (the
326    /// active width is capped by the budget even though the whole pool is
327    /// dispatched).
328    ///
329    /// # Examples
330    ///
331    /// ```
332    /// use tcontract_exec::{Exec, ExecError};
333    /// assert_eq!(Exec::serial().broadcast(2, &|_| {}), Err(ExecError::Unavailable));
334    /// ```
335    pub fn broadcast(&self, width: usize, f: &(dyn Fn(usize) + Sync)) -> Result<(), ExecError> {
336        if width == 0 {
337            return Ok(());
338        }
339        match self {
340            Exec::Serial { .. } | Exec::SerialWithWorkspace { .. } => {
341                if width == 1 {
342                    f(0);
343                    Ok(())
344                } else {
345                    Err(ExecError::Unavailable)
346                }
347            }
348            Exec::Rayon { pool, budget, .. } => {
349                let size = pool.size();
350                if width > size {
351                    return Err(ExecError::WidthExceedsPool { width, pool: size });
352                }
353                if width > budget.get() {
354                    return Err(ExecError::WidthExceedsBudget {
355                        width,
356                        budget: budget.get(),
357                    });
358                }
359                if pool.is_worker() {
360                    return Err(ExecError::Unavailable);
361                }
362                if width == 1 {
363                    f(0);
364                    return Ok(());
365                }
366                // INVARIANT: the SPMD mutex serializes broadcasts on this pool, so
367                // no worker ever holds two barrier-bearing jobs; it guards no data,
368                // so a poisoned lock (panic in an earlier `f`) is safe to reuse.
369                let _guard = pool
370                    .spmd
371                    .lock()
372                    .unwrap_or_else(std::sync::PoisonError::into_inner);
373                pool.count_broadcast();
374                pool.tp().broadcast(|ctx| {
375                    let t = ctx.index();
376                    if t < width {
377                        f(t)
378                    }
379                });
380                Ok(())
381            }
382        }
383    }
384
385    /// Partition width for work estimated at `serial_ns` on one thread,
386    /// minimizing the [`WidthPolicy`](crate::WidthPolicy) cost model within the budget.
387    ///
388    /// # Examples
389    ///
390    /// ```
391    /// use tcontract_exec::{Exec, WidthPolicy};
392    /// assert_eq!(Exec::serial().width_for(1e9, &WidthPolicy::default()), 1);
393    /// ```
394    pub fn width_for(&self, serial_ns: f64, policy: &crate::WidthPolicy) -> usize {
395        crate::width::width_for(serial_ns, policy, self.budget())
396    }
397}