Skip to main content

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}