Rayon: Simple work-stealing parallelism for Rust

repository·main·Indexed 12 days ago

https://github.com/rayon-rs/rayon

A lightweight data-parallelism library for Rust that converts sequential computations into parallel ones while guaranteeing data-race freedom. Rayon 1.12.0 provides high-level parallel iterators, manual task splitting via `join` and `scope`, and custom `ThreadPool` configurations. It utilizes a work-stealing algorithm to balance loads across logical cores and requires rustc 1.85.0 or greater.

Tokens
18.5K
Snippets
53
Records
77
Agent score
93%

What's inside Rayon

  1. Understand the `Sleep` module and thread management

    main

    The sleep module in rayon-core governs the lifecycle of worker threads, specifically determining when they should transition between active, idle, and sleeping states. This system is designed to minimize CPU usage when no work is available while ensuring that new work can quickly wake threads to prevent deadlocks.

    Thread States

    • Active: The thread is currently executing a job.
    • Idle: The thread is searching for work (stealing from other threads or checking the global injector queue).
    • Sleeping: The thread is blocked on a condition variable, waiting to be awoken.

    Threads transition from Active $\rightarrow$ Idle $\rightarrow$ Sleeping as they fail to find work. A thread can also enter a Sleepy state during its idle phase, where it signals it is about to sleep but performs one final search for work before actually blocking.

  2. How `with_producer` and `ProducerCallback` work in Rayon

    main

    In Rayon's parallel iterator implementation, with_producer is the mechanism used to convert a parallel iterator into a Producer. This is essential when reaching the start of an iterator chain or when coordinating multiple inputs (e.g., in zip()).

    Because the Producer type often contains lifetimes that are local to the with_producer call, Rayon cannot use standard Rust closures (FnOnce) to implement this. Instead, it uses a dedicated callback trait called ProducerCallback.

    Key Concepts:

    • with_producer: A method on IndexedParallelIterator that initiates the conversion to a producer via a callback.
    • ProducerCallback: A trait used instead of closures to allow the with_producer signature to remain generic over the producer type without needing to name it explicitly in the trait definition. This bypasses lifetime issues associated with associated types.
    • The Pattern: To implement a combinator (like map), you must create a wrapper Producer (e.g., MapProducer) and a manual Callback struct that implements ProducerCallback to wrap the base producer with your new logic.
    // The conceptual pattern for implementing a parallel iterator combinator
    impl<I, F> IndexedParallelIterator for Map<I, F>
        where I: IndexedParallelIterator,
              F: MapOp<I::Item>,
    {
        fn with_producer<CB>(self, callback: CB) -> CB::Output
            where CB: ProducerCallback<Self::Item>
        {
            // 1. Wrap the provided callback and necessary data into a manual Callback struct
            self.base.with_producer(Callback { callback: callback, map_op: self.map_op })
    
            struct Callback<CB, F> {
                callback: CB,
                map_op: F,
            }
    
            // 2. Implement ProducerCallback for the manual Callback struct
            impl<T, F, CB> ProducerCallback<T> for Callback<CB, F>
                where F: MapOp<T>,
                      CB: ProducerCallback<F::Output>
            {
                type Output = CB::Output;
    
                fn callback<P>(self, base: P) -> CB::Output
                    where P: Producer<Item=T>
                {
                    // 3. Wrap the base producer with the new logic (e.g., MapProducer)
                    let producer = MapProducer { base: base, map_op: &self.map_op };
                    // 4. Pass the wrapped producer to the original callback
                    self.callback.callback(producer)
                }
            }
        }
    }
  3. How Rayon's parallel abstractions work

    main

    Rayon provides different levels of abstraction depending on the required control over parallelism:

    1. Parallel Iterators: The simplest way to parallelize. They automatically handle data splitting and task distribution, dynamically adapting for maximum performance.
    2. join and scope: Functions that provide more flexibility for creating custom parallel tasks manually.
    3. Custom Thread Pools: For maximum control, you can instantiate your own ThreadPool instead of using Rayon's default global thread pool.
  4. How Rayon prevents deadlocks during sleep

    main

    A critical risk in thread pools is a race condition where a thread (Thread A) decides to sleep just as another thread (Thread B) posts new work. If Thread B sees no 'sleeping' threads, it won't wake anyone, and Thread A will sleep indefinitely, potentially causing a deadlock if the work was an external job.

    Rayon mitigates this using two sequentially consistent (seq-cst) fences:

    1. Post-Injection Fence: Occurs after work is pushed to the injection queue but before the counters (including the number of sleeping threads) are read. This ensures that if a thread reads the counters, it is also capable of seeing the newly posted work.
    2. Pre-Sleep Fence: Occurs after the number of sleeping threads is incremented but before the thread performs its final check of the injection queue. This ensures that any work posted by an external thread after the increment is visible to the thread's final check.
  5. Understand the relationship between rayon and rayon-core

    main

    Rayon is composed of two main parts: rayon and rayon-core.

    • rayon-core: Contains the stable, foundational APIs such as join, scope, and the ThreadPool implementation. It also manages the global thread pool.
    • rayon: The primary crate for end-users. It mirrors all APIs from rayon-core and adds high-level features like parallel iterators.

    Best Practice: Users should generally interact with the rayon crate rather than rayon-core directly. For example, use rayon::join instead of rayon_core::join to ensure consistent usage patterns.

  6. Data-race freedom in Rayon

    main

    Rayon's APIs are designed to guarantee data-race freedom. If your code compiles, it typically behaves the same way as the sequential version.

    Caveat on Side Effects: If your iterator performs side effects (e.g., writing to disk or sending messages through a Rust channel), those side effects may occur in a different order than they would in a sequential iterator.

  7. How the Jobs Event Counter (JEC) works

    main

    The Jobs Event Counter (JEC) is an atomic counter used by 'sleepy' threads to detect new work in a lightweight way without expensive synchronization. It uses the low bit of the counter to signal state:

    • Even (low bit 0): No new work has been posted since the last thread became sleepy.
    • Odd (low bit 1): New work has been posted.

    Workflow

    1. When work is posted: The system checks the JEC. If it is even, it is incremented by one to make it odd.
    2. When a thread gets sleepy: The thread reads the JEC and remembers the value. If the counter is odd, it increments it to make it even.
    3. Before sleeping: The thread compares the current JEC to its remembered final_value. If they match, it assumes no new work arrived and proceeds to sleep.
  8. How Rayon balances work using work stealing

    main

    Rayon uses a work stealing algorithm to dynamically balance parallelism.

    When you call join(a, b) from a worker thread W:

    1. W places task b into its local work queue, making it available for other threads to 'steal'.
    2. W immediately begins executing task a.
    3. If another thread becomes idle, it will look through other threads' queues and 'steal' task b to execute it.
    4. Once W finishes a, it checks if b was stolen. If not, W executes b itself.

    This ensures that all threads remain busy as long as there is work available in any queue.

  9. Best practices for using RwLock and Mutex in parallel

    main

    When replacing RefCell with RwLock or Mutex in parallel code, ensure you hold the lock for the entire duration of a logical transaction.

    Anti-pattern: Performing multiple small borrows/locks inside a loop. This allows other threads to intervene between iterations, potentially changing the underlying data (e.g., changing the length of a Vec) and causing logic errors or index out-of-bounds errors.

    Recommended: Acquire a single lock/borrow that covers the entire loop or operation to ensure consistency and improve efficiency.

    // ANTI-PATTERN: Small, frequent borrows
    for i in 0 .. handle.borrow().len() {
        let data = handle.borrow()[i];
        println!("{}", data);
    }
    
    // RECOMMENDED: Single borrow for the whole transaction
    let vec = handle.borrow();
    for data in vec.iter() {
        println!("{}", data);
    }
  10. Configure the number of Rayon threads

    main

    By default, Rayon spawns a number of threads equal to the number of logical cores available on the system (including hyperthreading).

    You can change this behavior in two ways:

    1. Environment Variable: Set RAYON_NUM_THREADS to your desired thread count.
    2. Programmatic Configuration: Use the ThreadPoolBuilder::build_global method to configure the global thread pool.
    // Example of programmatic configuration (conceptual)
    rayon::ThreadPoolBuilder::new().num_threads(4).build_global().unwrap();
  11. Using Rayon with WebAssembly

    main

    By default, Rayon falls back to sequential iteration when building for WebAssembly, meaning it will run on a single CPU core without requiring changes to your code.

    To enable proper multithreading support in WebAssembly, you must use an adapter like wasm-bindgen-rayon and configure your project to handle the differences between WebAssembly threads and native platform threads.