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
167    /// [`BatchStrategy::WholeBatchVendor`](crate::batch::BatchStrategy::WholeBatchVendor)
168    /// is still refused at one thread instead of silently running `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`]. It resolves availability before the caller
196    /// writes anything.
197    ///
198    /// # Errors
199    ///
200    /// [`ExecError::StrategyUnavailable`] for a forced
201    /// [`BatchStrategy::WholeBatchVendor`](crate::batch::BatchStrategy::WholeBatchVendor)
202    /// on a query without a vendor batch route.
203    ///
204    /// # Examples
205    ///
206    /// ```
207    /// use tcontract_exec::{BatchQuery, Exec};
208    /// let d = Exec::serial().batch_decision(&BatchQuery::new(4, 1000.0)).unwrap();
209    /// assert_eq!((d.lanes, d.item_threads), (1, 1));
210    /// ```
211    pub fn batch_decision(&self, query: &BatchQuery) -> Result<BatchDecision, ExecError> {
212        // INVARIANT: `Exec` budgets are `NonZeroUsize` by construction.
213        let budget = NonZeroUsize::new(self.budget()).unwrap_or(NonZeroUsize::MIN);
214        crate::batch::batch_decision(self.batch_policy(), budget, query)
215    }
216
217    /// Most threads an operation may occupy (1 for a serial context).
218    pub fn budget(&self) -> usize {
219        match self {
220            Exec::Serial { .. } | Exec::SerialWithWorkspace { .. } => 1,
221            Exec::Rayon { budget, .. } => budget.get(),
222        }
223    }
224
225    /// Storage the operations on this context may share, or none.
226    ///
227    /// A pool lends its own arena to every operation that runs on it; a serial
228    /// context owns nothing, so a caller that wants reuse keeps a provider of
229    /// its own and lends it through [`Exec::serial_with_workspace`].
230    pub fn workspace(&self) -> Option<&'a dyn crate::WorkspaceProvider> {
231        match self {
232            Exec::Serial { .. } => None,
233            Exec::SerialWithWorkspace { workspace, .. } => Some(*workspace),
234            Exec::Rayon { pool, .. } => Some(pool.workspace()),
235        }
236    }
237
238    /// Whether the calling thread is a worker of this context's pool.
239    pub fn is_worker(&self) -> bool {
240        match self {
241            Exec::Serial { .. } | Exec::SerialWithWorkspace { .. } => false,
242            Exec::Rayon { pool, .. } => pool.is_worker(),
243        }
244    }
245
246    fn width(&self, k: usize) -> usize {
247        k.clamp(1, self.budget())
248    }
249
250    /// Run `op` with `min(k, budget)` threads of parallelism.
251    ///
252    /// Width one runs `op(Par::Seq)` inline on the caller. Otherwise `op`
253    /// runs inside the pool, which is entered once, or in place when the
254    /// caller is already one of its workers.
255    pub fn install<R: Send>(&self, k: usize, op: impl FnOnce(Par) -> R + Send) -> R {
256        let k = self.width(k);
257        match (self, NonZeroUsize::new(k)) {
258            (Exec::Rayon { pool, .. }, Some(n)) if k > 1 => {
259                if pool.is_worker() {
260                    pool.count_inline();
261                    op(Par::Threads(n))
262                } else {
263                    pool.count_entry();
264                    pool.tp().install(|| op(Par::Threads(n)))
265                }
266            }
267            _ => op(Par::Seq),
268        }
269    }
270
271    /// Barrier-free partition: runs `f(i)` exactly once for each `i < k`,
272    /// on at most `budget` workers in contiguous lanes.
273    ///
274    /// Tasks are not guaranteed to run concurrently, so `f` must never wait
275    /// on another index.
276    ///
277    /// # Examples
278    ///
279    /// ```
280    /// use std::sync::atomic::{AtomicUsize, Ordering};
281    /// let n = AtomicUsize::new(0);
282    /// tcontract_exec::Exec::serial().for_each_partition(5, &|_| {
283    ///     n.fetch_add(1, Ordering::Relaxed);
284    /// });
285    /// assert_eq!(n.into_inner(), 5);
286    /// ```
287    pub fn for_each_partition(&self, k: usize, f: &(dyn Fn(usize) + Sync)) {
288        if k == 0 {
289            return;
290        }
291        let lanes = self.width(k);
292        if lanes == 1 {
293            (0..k).for_each(f);
294            return;
295        }
296        self.install(lanes, |_| {
297            rayon::scope(|s| {
298                for lane in 0..lanes {
299                    s.spawn(move |_| {
300                        let lo = lane * k / lanes;
301                        let hi = (lane + 1) * k / lanes;
302                        (lo..hi).for_each(f);
303                    });
304                }
305            })
306        });
307    }
308
309    /// Co-scheduled SPMD execution: `f(t)` for every `t < width` runs
310    /// concurrently on distinct workers, so `f` may use barriers counting
311    /// `width` participants.
312    ///
313    /// Width one runs `f(0)` inline. On a pool the whole pool is dispatched
314    /// (Rayon `broadcast`); workers with index `>= width` return at once.
315    /// Broadcasts through one [`Pool`] wrapper are serialized (use one
316    /// wrapper per `ThreadPool`). A broadcast waits until every worker reaches
317    /// a scheduling point, so a worker busy in another host's long job delays
318    /// it. A panic in a barrier-free `f` propagates to the caller after every
319    /// participant has finished; if `f` panics before a barrier the other
320    /// participants wait at it forever, so barrier-bearing `f` must not panic.
321    ///
322    /// # Errors
323    ///
324    /// [`ExecError::Unavailable`] when co-scheduling cannot be guaranteed
325    /// (`Serial` with `width > 1`, or the caller is already a worker of the
326    /// pool, where an outer job may hold workers at a barrier); nothing runs
327    /// then, and the caller uses its barrier-free variant.
328    /// [`ExecError::WidthExceedsPool`] when `width` exceeds the pool, and
329    /// [`ExecError::WidthExceedsBudget`] when it exceeds the budget (the
330    /// active width is capped by the budget even though the whole pool is
331    /// dispatched).
332    ///
333    /// # Examples
334    ///
335    /// ```
336    /// use tcontract_exec::{Exec, ExecError};
337    /// assert_eq!(Exec::serial().broadcast(2, &|_| {}), Err(ExecError::Unavailable));
338    /// ```
339    pub fn broadcast(&self, width: usize, f: &(dyn Fn(usize) + Sync)) -> Result<(), ExecError> {
340        if width == 0 {
341            return Ok(());
342        }
343        match self {
344            Exec::Serial { .. } | Exec::SerialWithWorkspace { .. } => {
345                if width == 1 {
346                    f(0);
347                    Ok(())
348                } else {
349                    Err(ExecError::Unavailable)
350                }
351            }
352            Exec::Rayon { pool, budget, .. } => {
353                let size = pool.size();
354                if width > size {
355                    return Err(ExecError::WidthExceedsPool { width, pool: size });
356                }
357                if width > budget.get() {
358                    return Err(ExecError::WidthExceedsBudget {
359                        width,
360                        budget: budget.get(),
361                    });
362                }
363                if pool.is_worker() {
364                    return Err(ExecError::Unavailable);
365                }
366                if width == 1 {
367                    f(0);
368                    return Ok(());
369                }
370                // INVARIANT: the SPMD mutex serializes broadcasts on this pool, so
371                // no worker ever holds two barrier-bearing jobs; it guards no data,
372                // so a poisoned lock (panic in an earlier `f`) is safe to reuse.
373                let _guard = pool
374                    .spmd
375                    .lock()
376                    .unwrap_or_else(std::sync::PoisonError::into_inner);
377                pool.count_broadcast();
378                pool.tp().broadcast(|ctx| {
379                    let t = ctx.index();
380                    if t < width {
381                        f(t)
382                    }
383                });
384                Ok(())
385            }
386        }
387    }
388
389    /// Partition width for work estimated at `serial_ns` on one thread,
390    /// minimizing the [`WidthPolicy`](crate::WidthPolicy) cost model within the budget.
391    ///
392    /// # Examples
393    ///
394    /// ```
395    /// use tcontract_exec::{Exec, WidthPolicy};
396    /// assert_eq!(Exec::serial().width_for(1e9, &WidthPolicy::default()), 1);
397    /// ```
398    pub fn width_for(&self, serial_ns: f64, policy: &crate::WidthPolicy) -> usize {
399        crate::width::width_for(serial_ns, policy, self.budget())
400    }
401}