Skip to main content

turbo_tasks/
parallel.rs

1//! Parallel for each and map using tokio tasks.
2//!
3//! This avoid the problem of sleeping threads with mimalloc when using rayon in combination with
4//! tokio. It also avoid having multiple thread pools.
5//!
6//! See also: <https://pwy.io/posts/mimalloc-cigarette/>
7
8use std::{
9    env, io,
10    num::NonZeroUsize,
11    sync::{Arc, OnceLock},
12    thread,
13};
14
15use crate::{
16    scope_bounded::scope_bounded,
17    util::{Chunk, good_chunk_size, into_chunks},
18};
19
20/// Returns the recommended amount of parallelism for the current process. Typically the number of
21/// available CPU cores.
22///
23/// This wraps [`std::thread::available_parallelism`] with a couple extras:
24///
25/// - If the `TURBO_TASKS_AVAILABLE_PARALLELISM` env var is set, overrides the value. Panics if this
26///   env var fails to parse.
27/// - The resolved value is cached in a [`OnceLock`]
28pub fn available_parallelism() -> Result<NonZeroUsize, Arc<io::Error>> {
29    static CACHED: OnceLock<Result<NonZeroUsize, Arc<io::Error>>> = OnceLock::new();
30    CACHED
31        .get_or_init(|| {
32            if let Ok(raw) = env::var("TURBO_TASKS_AVAILABLE_PARALLELISM") {
33                Ok(raw.parse::<NonZeroUsize>().unwrap_or_else(|err| {
34                    panic!("Invalid TURBO_TASKS_AVAILABLE_PARALLELISM={raw:?}: {err}")
35                }))
36            } else {
37                thread::available_parallelism().map_err(Arc::new)
38            }
39        })
40        .clone()
41}
42
43struct Chunked {
44    chunk_size: usize,
45    chunk_count: usize,
46}
47
48fn get_chunked(len: usize) -> Option<Chunked> {
49    if len <= 1 {
50        return None;
51    }
52    let chunk_size = good_chunk_size(len);
53    let chunk_count = len.div_ceil(chunk_size);
54    if chunk_count <= 1 {
55        return None;
56    }
57    Some(Chunked {
58        chunk_size,
59        chunk_count,
60    })
61}
62
63pub fn for_each<'l, T, F>(items: &'l [T], f: F)
64where
65    T: Sync,
66    F: Fn(&'l T) + Send + Sync,
67{
68    let Some(Chunked {
69        chunk_size,
70        chunk_count,
71    }) = get_chunked(items.len())
72    else {
73        for item in items {
74            f(item);
75        }
76        return;
77    };
78    let f = &f;
79    let _results = scope_bounded(chunk_count, |scope| {
80        for chunk in items.chunks(chunk_size) {
81            scope.spawn(move || {
82                for item in chunk {
83                    f(item);
84                }
85            })
86        }
87    });
88}
89
90/// Like [`for_each`], but calls `f` once per chunk instead of once per item. A slice too small to
91/// be chunked is passed to `f` as a whole on the calling thread, and an empty slice doesn't call
92/// `f`. Uses the same chunk sizing as [`for_each`].
93pub fn for_each_chunk<'l, T, F>(items: &'l [T], f: F)
94where
95    T: Sync,
96    F: Fn(&'l [T]) + Send + Sync,
97{
98    if items.is_empty() {
99        return;
100    }
101    let Some(Chunked {
102        chunk_size,
103        chunk_count,
104    }) = get_chunked(items.len())
105    else {
106        f(items);
107        return;
108    };
109    let f = &f;
110    let _results = scope_bounded(chunk_count, |scope| {
111        for chunk in items.chunks(chunk_size) {
112            scope.spawn(move || f(chunk))
113        }
114    });
115}
116
117pub fn for_each_owned<T>(items: Vec<T>, f: impl Fn(T) + Send + Sync)
118where
119    T: Send + Sync,
120{
121    let Some(Chunked {
122        chunk_size,
123        chunk_count,
124    }) = get_chunked(items.len())
125    else {
126        for item in items {
127            f(item);
128        }
129        return;
130    };
131    let f = &f;
132    let _results = scope_bounded(chunk_count, |scope| {
133        for chunk in into_chunks(items, chunk_size) {
134            scope.spawn(move || {
135                // SAFETY: Even when f() panics we drop all items in the chunk.
136                for item in chunk {
137                    f(item);
138                }
139            })
140        }
141    });
142}
143
144pub fn try_for_each<'l, T, E>(
145    items: &'l [T],
146    f: impl (Fn(&'l T) -> Result<(), E>) + Send + Sync,
147) -> Result<(), E>
148where
149    T: Sync,
150    E: Send + 'static,
151{
152    let Some(Chunked {
153        chunk_size,
154        chunk_count,
155    }) = get_chunked(items.len())
156    else {
157        for item in items {
158            f(item)?;
159        }
160        return Ok(());
161    };
162    let f = &f;
163    scope_bounded(chunk_count, |scope| {
164        for chunk in items.chunks(chunk_size) {
165            scope.spawn(move || {
166                for item in chunk {
167                    f(item)?;
168                }
169                Ok(())
170            })
171        }
172    })
173    .collect::<Result<(), E>>()
174}
175
176pub fn try_for_each_mut<'l, T, E>(
177    items: &'l mut [T],
178    f: impl (Fn(&'l mut T) -> Result<(), E>) + Send + Sync,
179) -> Result<(), E>
180where
181    T: Send + Sync,
182    E: Send + 'static,
183{
184    let Some(Chunked {
185        chunk_size,
186        chunk_count,
187    }) = get_chunked(items.len())
188    else {
189        for item in items {
190            f(item)?;
191        }
192        return Ok(());
193    };
194    let f = &f;
195    scope_bounded(chunk_count, |scope| {
196        for chunk in items.chunks_mut(chunk_size) {
197            scope.spawn(move || {
198                for item in chunk {
199                    f(item)?;
200                }
201                Ok(())
202            })
203        }
204    })
205    .collect::<Result<(), E>>()
206}
207
208pub fn try_for_each_owned<T, E>(
209    items: Vec<T>,
210    f: impl (Fn(T) -> Result<(), E>) + Send + Sync,
211) -> Result<(), E>
212where
213    T: Send + Sync,
214    E: Send + 'static,
215{
216    let Some(Chunked {
217        chunk_size,
218        chunk_count,
219    }) = get_chunked(items.len())
220    else {
221        for item in items {
222            f(item)?;
223        }
224        return Ok(());
225    };
226    let f = &f;
227    scope_bounded(chunk_count, |scope| {
228        for chunk in into_chunks(items, chunk_size) {
229            scope.spawn(move || {
230                for item in chunk {
231                    f(item)?;
232                }
233                Ok(())
234            })
235        }
236    })
237    .collect::<Result<(), E>>()
238}
239
240pub fn map_collect<'l, Item, PerItemResult, Result>(
241    items: &'l [Item],
242    f: impl Fn(&'l Item) -> PerItemResult + Send + Sync,
243) -> Result
244where
245    Item: Sync,
246    PerItemResult: Send + Sync + 'l,
247    Result: FromIterator<PerItemResult>,
248{
249    let Some(Chunked {
250        chunk_size,
251        chunk_count,
252    }) = get_chunked(items.len())
253    else {
254        return Result::from_iter(items.iter().map(f));
255    };
256    let f = &f;
257    scope_bounded(chunk_count, |scope| {
258        for chunk in items.chunks(chunk_size) {
259            scope.spawn(move || chunk.iter().map(f).collect::<Vec<_>>())
260        }
261    })
262    .flatten()
263    .collect()
264}
265
266pub fn map_collect_owned<'l, Item, PerItemResult, Result>(
267    items: Vec<Item>,
268    f: impl Fn(Item) -> PerItemResult + Send + Sync,
269) -> Result
270where
271    Item: Send + Sync,
272    PerItemResult: Send + Sync + 'l,
273    Result: FromIterator<PerItemResult>,
274{
275    let Some(Chunked {
276        chunk_size,
277        chunk_count,
278    }) = get_chunked(items.len())
279    else {
280        return Result::from_iter(items.into_iter().map(f));
281    };
282    let f = &f;
283    scope_bounded(chunk_count, |scope| {
284        for chunk in into_chunks(items, chunk_size) {
285            scope.spawn(move || chunk.map(f).collect::<Vec<_>>())
286        }
287    })
288    .flatten()
289    .collect()
290}
291
292pub fn map_collect_chunked_owned<'l, Item, PerItemResult, Result>(
293    items: Vec<Item>,
294    f: impl Fn(Chunk<Item>) -> PerItemResult + Send + Sync,
295) -> Result
296where
297    Item: Send + Sync,
298    PerItemResult: Send + Sync + 'l,
299    Result: FromIterator<PerItemResult>,
300{
301    let Some(Chunked {
302        chunk_size,
303        chunk_count,
304    }) = get_chunked(items.len())
305    else {
306        let len = items.len();
307        return Result::from_iter(into_chunks(items, len).map(f));
308    };
309    let f = &f;
310    scope_bounded(chunk_count, |scope| {
311        for chunk in into_chunks(items, chunk_size) {
312            scope.spawn(move || f(chunk))
313        }
314    })
315    .collect()
316}
317
318#[cfg(test)]
319mod tests {
320    use std::{
321        panic::{AssertUnwindSafe, catch_unwind},
322        sync::atomic::{AtomicI32, Ordering},
323    };
324
325    use super::*;
326
327    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
328    async fn test_parallel_for_each() {
329        let input = vec![1, 2, 3, 4, 5];
330        let sum = AtomicI32::new(0);
331        for_each(&input, |&x| {
332            sum.fetch_add(x, Ordering::SeqCst);
333        });
334        assert_eq!(sum.load(Ordering::SeqCst), 15);
335    }
336
337    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
338    async fn test_parallel_try_for_each() {
339        let input = vec![1, 2, 3, 4, 5];
340        let result = try_for_each(&input, |&x| {
341            if x % 2 == 0 {
342                Ok(())
343            } else {
344                Err(format!("Odd number {x} encountered"))
345            }
346        });
347        assert!(result.is_err());
348        assert_eq!(result.unwrap_err(), "Odd number 1 encountered");
349    }
350
351    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
352    async fn test_parallel_try_for_each_mut() {
353        // Match `good_chunk_size`'s target chunk count so every item gets its own chunk. This makes
354        // the exact mutation assertion independent of the machine's reported parallelism while
355        // still verifying that all chunks start even when earlier chunks return errors.
356        let item_count = available_parallelism().map_or(16, |count| count.get() * 4);
357        let max_value = i32::try_from(item_count).unwrap();
358        let mut input: Vec<_> = (1..=max_value).collect();
359        let result = try_for_each_mut(&mut input, |x| {
360            *x += 10;
361            if *x % 2 == 0 {
362                Ok(())
363            } else {
364                Err(format!("Odd number {} encountered", *x))
365            }
366        });
367        assert!(result.is_err());
368        assert_eq!(result.unwrap_err(), "Odd number 11 encountered");
369        assert_eq!(input, (11..=max_value + 10).collect::<Vec<_>>());
370    }
371
372    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
373    async fn test_parallel_for_each_owned() {
374        let input = vec![1, 2, 3, 4, 5];
375        let sum = AtomicI32::new(0);
376        for_each_owned(input, |x| {
377            sum.fetch_add(x, Ordering::SeqCst);
378        });
379        assert_eq!(sum.load(Ordering::SeqCst), 15);
380    }
381
382    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
383    async fn test_parallel_map_collect() {
384        let input = vec![1, 2, 3, 4, 5];
385        let result: Vec<_> = map_collect(&input, |&x| x * 2);
386        assert_eq!(result, vec![2, 4, 6, 8, 10]);
387    }
388
389    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
390    async fn test_parallel_map_collect_owned() {
391        let input = vec![1, 2, 3, 4, 5];
392        let result: Vec<_> = map_collect_owned(input, |x| x * 2);
393        assert_eq!(result, vec![2, 4, 6, 8, 10]);
394    }
395
396    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
397    async fn test_parallel_map_collect_owned_many() {
398        let input = vec![1; 1000];
399        let result: Vec<_> = map_collect_owned(input, |x| x * 2);
400        assert_eq!(result, vec![2; 1000]);
401    }
402
403    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
404    // Relies on `catch_unwind` catching, which needs unwinding; wasm is panic = abort.
405    #[cfg_attr(
406        target_family = "wasm",
407        ignore = "no unwinding on wasm: std is built panic=abort, so catch_unwind cannot catch"
408    )]
409    async fn test_panic_in_scope() {
410        let result = catch_unwind(AssertUnwindSafe(|| {
411            let mut input = vec![1; 1000];
412            input[744] = 2;
413            for_each(&input, |x| {
414                if *x == 2 {
415                    panic!("Intentional panic");
416                }
417            });
418            panic!("Should not get here")
419        }));
420        assert!(result.is_err());
421        assert_eq!(
422            result.unwrap_err().downcast_ref::<&str>(),
423            Some(&"Intentional panic")
424        );
425    }
426}