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;