binius_utils/rayon/task_size.rs
1// Copyright 2026 The Binius Developers
2
3//! Minimum task sizes for parallel loops.
4//!
5//! Handing a slice of work to another worker costs about a microsecond.
6//! A task shorter than that loses more to the handoff than it gains.
7//!
8//! ```text
9//! min items per task = budget per task / estimated cost per item
10//! ```
11//!
12//! - Loops bound by memory traffic charge one item by the bytes it moves.
13//! - Loops bound by arithmetic charge one item by a coarse work class.
14//!
15//! Charging by bytes is what keeps a floor correct across packing widths.
16//! Doubling the scalars in a packed element halves the items a task needs.
17//!
18//! # Environment overrides
19//!
20//! - `BINIUS_TASK_TARGET_NS` sets the time budget per task, in nanoseconds.
21//! - `BINIUS_MIN_TASK_BYTES` sets the byte budget per task of a memory-bound loop.
22//!
23//! Setting both to `1` floors every loop at one item, which disables the floors for an A/B run.
24//!
25//! # Examples
26//!
27//! ```
28//! use binius_utils::rayon::prelude::*;
29//! use binius_utils::rayon::task_size::{IndexedParallelIteratorExt, WorkPerItem};
30//!
31//! let data = vec![1u64; 1 << 10];
32//!
33//! // Memory-bound: one task moves at least the byte budget.
34//! let sum: u64 = data.par_iter().with_min_task_bytes::<u64>().sum();
35//!
36//! // Arithmetic-bound: one task runs for at least the time budget.
37//! let max = data.par_iter().with_min_task(WorkPerItem::FieldMuls).max();
38//!
39//! assert_eq!((sum, max), (1 << 10, Some(&1)));
40//! ```
41
42use std::sync::OnceLock;
43
44use super::prelude::*;
45
46/// Time budget for one task, in nanoseconds.
47///
48/// Handing work to another worker costs roughly one microsecond.
49/// A budget of 100 microseconds holds that overhead near one percent.
50/// Raising it further would stop mid-size loops from splitting at all.
51const DEFAULT_TASK_TARGET_NS: u64 = 100_000;
52
53/// Byte budget for one task of a memory-bound loop.
54///
55/// One mebibyte streams in roughly the time budget above.
56/// That assumes tens of gigabytes per second of bandwidth per core.
57const DEFAULT_MIN_TASK_BYTES: usize = 1 << 20;
58
59/// Reads a budget from the environment, falling back to a default.
60///
61/// # Arguments
62///
63/// * `name` - environment variable holding the override
64/// * `default` - value used when the variable is absent or malformed
65fn env_or<T: Copy + std::str::FromStr>(name: &str, default: T) -> T {
66 std::env::var(name)
67 .ok()
68 // A budget is a tuning knob, so a typo falls back instead of taking the process down.
69 .and_then(|v| v.parse().ok())
70 .unwrap_or(default)
71}
72
73/// Time budget for one task, in nanoseconds.
74///
75/// The environment is read once, then the value is cached for the process.
76fn task_target_ns() -> u64 {
77 static V: OnceLock<u64> = OnceLock::new();
78
79 // Caching matters because the budget is read inside loops that run every round.
80 *V.get_or_init(|| env_or("BINIUS_TASK_TARGET_NS", DEFAULT_TASK_TARGET_NS))
81}
82
83/// Byte budget for one task of a memory-bound loop.
84///
85/// The environment is read once, then the value is cached for the process.
86fn min_task_bytes() -> usize {
87 static V: OnceLock<usize> = OnceLock::new();
88
89 *V.get_or_init(|| env_or("BINIUS_MIN_TASK_BYTES", DEFAULT_MIN_TASK_BYTES))
90}
91
92/// Estimated cost of processing one item of a loop bound by arithmetic.
93///
94/// A floor only has to land within an order of magnitude, so the classes are coarse.
95/// Guessing low leaves about a microsecond of handoff overhead per surplus task.
96/// Guessing high delays the split until a somewhat larger input.
97#[derive(Clone, Copy, Debug, PartialEq, Eq)]
98pub enum WorkPerItem {
99 /// A handful of packed field multiplies.
100 ///
101 /// One output word of a fractional addition is three multiplies and an add.
102 FieldMuls,
103 /// One hash compression, such as a Merkle inner node or a single message block.
104 HashCompression,
105 /// One scalar field inversion, roughly a hundred packed multiplies.
106 Inversion,
107}
108
109impl WorkPerItem {
110 /// Estimated time to process one item, in nanoseconds.
111 ///
112 /// Measured sequentially on an Apple M1 Pro, over buffers far larger than cache:
113 ///
114 /// ```text
115 /// 1 to 3 packed binary-field multiplies per word : 1.4 to 3.9 ns
116 /// one two-to-one SHA-256 compression : 30 ns
117 /// one scalar inversion : 140 ns
118 /// ```
119 ///
120 /// Landing within a factor of two on other hardware is good enough.
121 const fn ns_per_item(self) -> u64 {
122 match self {
123 Self::FieldMuls => 4,
124 Self::HashCompression => 30,
125 Self::Inversion => 150,
126 }
127 }
128}
129
130/// Items per task that together move at least the given byte budget.
131///
132/// Split from the public entry point so tests can pin the arithmetic directly.
133fn min_len_for_bytes_with(min_task_bytes: usize, item_bytes: usize) -> usize {
134 // A zero-sized item is charged one byte, since dividing by its size would trap.
135 let per_item = item_bytes.max(1);
136
137 // An item wider than the whole budget still yields one item per task.
138 (min_task_bytes / per_item).max(1)
139}
140
141/// Items per task that together run for at least the given time budget.
142///
143/// Split from the public entry point so tests can pin the arithmetic directly.
144fn min_len_for_work_with(task_target_ns: u64, ns_per_item: u64) -> usize {
145 // An item slower than the whole budget still yields one item per task.
146 (task_target_ns / ns_per_item).max(1) as usize
147}
148
149/// Minimum items per task for a loop whose cost is the bytes it moves.
150///
151/// The type parameter is the element the loop streams, usually the packed field type.
152/// A loop zipping several streams names an array type to count all of them.
153/// A three-element array charges one item for three words.
154///
155/// # Returns
156///
157/// The number of items one task must take to move the byte budget.
158pub fn min_len_for_bytes<T>() -> usize {
159 min_len_for_bytes_with(min_task_bytes(), size_of::<T>())
160}
161
162/// Minimum items per task for a loop bound by arithmetic.
163///
164/// # Arguments
165///
166/// * `work` - cost class of one item of the loop
167///
168/// # Returns
169///
170/// The number of items one task must take to fill the time budget.
171pub fn min_len_for_work(work: WorkPerItem) -> usize {
172 min_len_for_work_with(task_target_ns(), work.ns_per_item())
173}
174
175/// Elements per chunk so one chunk spans the byte budget.
176///
177/// Use this directly as the chunk size of a chunked parallel loop.
178/// The chunk size already is the floor, so no further floor is needed.
179pub fn task_chunk_len<T>() -> usize {
180 min_len_for_bytes::<T>()
181}
182
183/// Task-size adapters for parallel iterators.
184///
185/// Each adapter sets the minimum items per task from a cost model.
186/// A call site states what one item costs instead of hardcoding a count.
187pub trait IndexedParallelIteratorExt: IndexedParallelIterator {
188 /// Floors the split so one task moves at least the byte budget.
189 ///
190 /// The type parameter counts the bytes one item moves.
191 /// Use this for loops bound by memory traffic: copies, transposes, permutations.
192 #[inline]
193 fn with_min_task_bytes<T>(self) -> impl IndexedParallelIterator<Item = Self::Item>
194 where
195 Self: Sized,
196 {
197 self.with_min_len(min_len_for_bytes::<T>())
198 }
199
200 /// Floors the split so one task runs for at least the time budget.
201 ///
202 /// Use this for loops bound by arithmetic, classified by the cost of one item.
203 #[inline]
204 fn with_min_task(self, work: WorkPerItem) -> impl IndexedParallelIterator<Item = Self::Item>
205 where
206 Self: Sized,
207 {
208 self.with_min_len(min_len_for_work(work))
209 }
210}
211
212impl<I: IndexedParallelIterator> IndexedParallelIteratorExt for I {}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 #[test]
219 fn byte_floor_scales_inversely_with_item_size() {
220 // Invariant: a task moves a fixed number of bytes, whatever the item width.
221 // So the item count must fall by exactly the factor the item widens.
222 //
223 // budget 1 MiB / 16 B per item = 65536 items
224 // budget 1 MiB / 64 B per item = 16384 items
225 // → 65536 = 4 * 16384
226 let narrow = min_len_for_bytes_with(1 << 20, 16);
227 let wide = min_len_for_bytes_with(1 << 20, 64);
228 assert_eq!(narrow, 4 * wide);
229
230 // The same ratio holds through the public entry point, which measures the type.
231 assert_eq!(min_len_for_bytes::<[u8; 16]>(), 4 * min_len_for_bytes::<[u8; 64]>());
232
233 // An array type charges one item for every stream a zipped loop touches.
234 // Four streams of one word cost the same as one stream of four words.
235 assert_eq!(min_len_for_bytes::<u64>(), 4 * min_len_for_bytes::<[u64; 4]>());
236 }
237
238 #[test]
239 fn byte_floor_boundary_items() {
240 // A zero-sized item is charged one byte, so the division cannot trap.
241 // The whole budget then maps to one item per byte.
242 assert_eq!(min_len_for_bytes_with(1 << 20, 0), 1 << 20);
243 assert_eq!(min_len_for_bytes::<()>(), min_task_bytes());
244
245 // An item wider than the entire budget cannot be subdivided further.
246 //
247 // budget 16 B / 64 B per item = 0 → floored to 1
248 assert_eq!(min_len_for_bytes_with(16, 64), 1);
249 }
250
251 #[test]
252 fn work_floor_scales_inversely_with_item_cost() {
253 // Invariant: a task runs for the time budget, whatever one item costs.
254 //
255 // budget 100000 ns / 8 ns per item = 12500 items
256 assert_eq!(min_len_for_work_with(100_000, 8), 12_500);
257
258 // An item slower than the entire budget cannot be subdivided further.
259 //
260 // budget 100 ns / 200 ns per item = 0 → floored to 1
261 assert_eq!(min_len_for_work_with(100, 200), 1);
262 }
263
264 #[test]
265 fn work_classes_are_ordered_by_cost() {
266 // Filling one budget takes fewer items as each item grows more expensive.
267 // This pins the ordering of the classes, not their absolute estimates.
268 //
269 // multiplies (4 ns) < compression (30 ns) < inversion (150 ns)
270 // → item counts run the other way
271 assert!(
272 min_len_for_work(WorkPerItem::FieldMuls)
273 > min_len_for_work(WorkPerItem::HashCompression)
274 );
275 assert!(
276 min_len_for_work(WorkPerItem::HashCompression)
277 > min_len_for_work(WorkPerItem::Inversion)
278 );
279 }
280
281 #[test]
282 fn chunk_len_matches_byte_floor() {
283 // A chunked loop sizes its chunk exactly as an item-wise loop sizes its floor.
284 // Both must span one budget, so the two entry points cannot drift apart.
285 assert_eq!(task_chunk_len::<u64>(), min_len_for_bytes::<u64>());
286 }
287
288 #[test]
289 fn adapters_preserve_iteration() {
290 // The adapters constrain only how work is divided, never what it covers.
291 //
292 // Fixture state: 1000 items, floored well above 1000 by either model.
293 // → the loop runs as a single task, and every item is still visited once.
294 let data: Vec<u64> = (0..1000).collect();
295 let expected = 1000 * 999 / 2;
296
297 // Memory-bound floor: 1 MiB budget over 8-byte items.
298 let sum: u64 = data.par_iter().with_min_task_bytes::<u64>().sum();
299 assert_eq!(sum, expected);
300
301 // Arithmetic-bound floor: 100 microsecond budget over 30 ns items.
302 let sum: u64 = data
303 .par_iter()
304 .with_min_task(WorkPerItem::HashCompression)
305 .sum();
306 assert_eq!(sum, expected);
307 }
308}