Skip to main content

molframe_core/parallel/
plan.rs

1//! How work is divided, independently of how many threads will run it.
2//!
3//! The whole determinism guarantee rests on one property: block boundaries are
4//! a function of the item count and a fixed block size, never of the worker
5//! count. Partials are then merged in ascending block index, so the
6//! floating-point grouping is identical at one worker and at sixteen.
7//!
8//! Partitioning by worker count instead — dividing the items into as many
9//! contiguous pieces as there are threads — is correct for a pure map, where
10//! each item's output is independent and only the concatenation order matters.
11//! It is wrong the moment a block accumulates, because the accumulation
12//! boundaries then move with the thread count and the sums reassociate. The
13//! plan makes the distinction explicit rather than leaving each kernel to
14//! rediscover it.
15
16use std::ops::Range;
17
18/// Items per block when a caller expresses no preference.
19///
20/// Small enough that a block's working set stays cache-resident and that work
21/// distributes evenly across threads; large enough that per-block bookkeeping
22/// disappears against the work itself.
23pub const DEFAULT_BLOCK_ITEMS: usize = 64;
24
25/// A division of `0..count` into fixed-size blocks.
26///
27/// # Examples
28///
29/// ```
30/// use molframe_core::parallel::BlockPlan;
31///
32/// let plan = BlockPlan::new(10, 4);
33/// assert_eq!(plan.blocks(), 3);
34/// assert_eq!(plan.range(0), Some(0..4));
35/// assert_eq!(plan.range(2), Some(8..10));
36/// assert_eq!(plan.range(3), None);
37/// ```
38#[derive(Clone, Copy, PartialEq, Eq, Debug)]
39pub struct BlockPlan {
40    count: usize,
41    block: usize,
42}
43
44impl BlockPlan {
45    /// Divides `count` items into blocks of at most `block` items.
46    ///
47    /// A zero block size is raised to one, because a plan that cannot advance
48    /// is not a plan.
49    #[must_use]
50    pub const fn new(count: usize, block: usize) -> Self {
51        Self {
52            count,
53            block: if block == 0 { 1 } else { block },
54        }
55    }
56
57    /// Divides `count` items using [`DEFAULT_BLOCK_ITEMS`].
58    #[must_use]
59    pub const fn with_default_block(count: usize) -> Self {
60        Self::new(count, DEFAULT_BLOCK_ITEMS)
61    }
62
63    /// The number of items covered.
64    #[must_use]
65    pub const fn count(self) -> usize {
66        self.count
67    }
68
69    /// The number of items in a full block.
70    #[must_use]
71    pub const fn block(self) -> usize {
72        self.block
73    }
74
75    /// The number of blocks.
76    #[must_use]
77    pub const fn blocks(self) -> usize {
78        self.count.div_ceil(self.block)
79    }
80
81    /// Returns true when there is nothing to divide.
82    #[must_use]
83    pub const fn is_empty(self) -> bool {
84        self.count == 0
85    }
86
87    /// The half-open item range block `index` covers.
88    #[must_use]
89    pub fn range(self, index: usize) -> Option<Range<usize>> {
90        if index >= self.blocks() {
91            return None;
92        }
93        let start = index.checked_mul(self.block)?;
94        let end = start.checked_add(self.block)?.min(self.count);
95        Some(start..end)
96    }
97
98    /// Every block range, in ascending index order.
99    pub fn ranges(self) -> impl Iterator<Item = Range<usize>> {
100        (0..self.blocks()).filter_map(move |index| self.range(index))
101    }
102
103    /// How many workers can be usefully applied to this plan.
104    ///
105    /// Never more than the number of blocks, because a worker with no block
106    /// costs a thread and returns nothing.
107    #[must_use]
108    pub fn useful_workers(self, requested: usize) -> usize {
109        requested.max(1).min(self.blocks().max(1))
110    }
111}
112
113#[cfg(test)]
114#[path = "plan_tests.rs"]
115mod tests;