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}