orx-parallel

repository·main·Indexed 43 days ago

https://github.com/orxfun/orx-parallel

A high-performance Rust crate for parallel computations featuring an expressive iterator API. It supports fallible computations via .into_optional() and .into_fallible(), recursive traversal with par_recursive, and memory optimization through the use_vec transformation for per-thread mutable state reuse. Version 4.0.0 includes configurable thread pool strategies, WASM support, and a customizable ParRunner trait for scheduling strategies.

Tokens
24.5K
Snippets
62
Records
98
Agent score
55%

What's inside orx-parallel

  1. Handle fallible computations with `into_optional()` and `into_fallible()`

    main

    The library provides first-class support for fallible parallel flows. You can transform a pipeline to handle Option<T> or Result<T, E> types, allowing you to write only the success path. Any failure in the pipeline will cause an early exit (short-circuiting).

    • Use .into_optional() for Option<T> pipelines.
    • Use .into_fallible() for Result<T, E> pipelines.
    use orx_parallel::*;
    
    fn total_price(rows: &[&str]) -> Option<u64> {
        rows.par()
            .map(|row| parse_qty_and_price(row)) // ← some might return None
            .into_optional() // ← ascend
            .filter(|(qty, _)| *qty >= 2) // ← focus only on success path
            .map(|(qty, unit_price)| qty * unit_price) // ← success path
            .sum()
    }
  2. Understand the orx-parallel WASM architecture layers

    main

    The WASM support is split into three distinct layers to separate computation logic from browser-specific orchestration:

    1. orx-parallel (The Core): Owns the scheduling logic, task queues, and the WasmWebPool implementation.
    2. wasm_bindings (The Bridge): A thin layer exposing the initialization API (e.g., init_wasm_parallel_runtime) to the JavaScript boundary.
    3. orx-parallel-wasm (The Orchestrator): Manages the browser-side complexity, including ParallelWorker clients, bundler adapters, and asset preparation for workers.
    4. The Browser Host: Responsible for the final deployment, including serving the application with the correct security headers (COOP/COEP).
  3. Implement a custom `ParRunner` strategy

    main

    If you are researching or developing new parallel runner strategies, you can implement the ParRunner trait.

    Existing implementations to use as references:

    • adaptive runner (found in src/runner/runner_variants/adaptive_chunk)
    • fixed runner (found in src/runner/runner_variants/fixed_chunk)

    To test your runner, you can update the DefaultRunner alias and the default_runner() wiring in src/runner/mod.rs on your branch.

  4. How use transformations work in orx-parallel

    main

    Use transformations allow you to safely and ergonomically use mutable, worker-local state within parallel pipelines. Instead of creating new state for every single item in an iterator (which causes high allocation overhead) or sharing state across threads (which causes race conditions), use transformations create exactly one instance of a variable per worker thread. This state is then made available as a mutable reference to the iterator methods (like map, filter, or for_each) during the computation. This pattern ensures memory efficiency by reducing space complexity from linear (relative to the number of items) to constant (relative to the number of threads).

    // Conceptual pattern:
    // 1. Define how to create thread-local state
    // 2. Use .use_new(), .use_vec(), or .use_slice() to inject it into the pipeline
    // 3. Access the state as the first argument in subsequent iterator methods
  5. Understand the ParRunner contract and responsibilities

    main

    A ParRunner is responsible for scheduling decisions, specifically determining how many workers to start and how large each pulled chunk should be. It is decoupled from input ownership, iterator transformations, and the thread pool itself.

    Key Responsibilities:

    • Scheduling: Deciding worker counts and chunk sizes.
    • Lifecycle Management: Responding to thread and chunk lifecycle callbacks.

    What it does NOT own:

    • ConcurrentIter: Owns concurrent access to input items.
    • Xap variants: Own iterator transformations (map, filter, flat_map).
    • ThreadPool: Owns scoped execution and worker lifecycle.

    To implement a runner, you must satisfy the ParRunner trait, which requires the runner to be Sync because shared runner state is read from worker tasks.

  6. Use `use` transformations for safe per-thread mutable state

    main

    To avoid repeated allocations in parallel pipelines, use use transformations. This allows you to lend a piece of mutable state (like a String buffer or a Vec) to each worker thread. The state is reused across multiple elements processed by the same thread, ensuring deterministic and minimized allocation behavior without requiring unsafe code in your application logic.

    Example using UseVec to reuse a String buffer:

    use orx_parallel::*;
    
    let words = vec!["Love Rust ", " Hello WORLD", "?"];
    
    // one reusable scratch buffer per thread, instead of allocating for every element
    let mut buffers = UseVec::new(|_th_idx| String::new());
    
    let greetings: Vec<String> = words
        .par()
        .use_vec(&mut buffers) // ← mutably lend it to parallel iterator
        .filter_map(|buf, w| {
            buf.clear(); // ← buf: &mut String, reused across elements on this thread
            buf.push_str(w.trim());
            buf.make_ascii_lowercase();
            buf.find(' ')
        })
        .map(|buf, space_idx| buf.chars().skip(space_idx + 1).collect())
        .collect();
    
    assert_eq!(greetings, ["rust", "world"]);
  7. How the WASM scoped execution model works

    main

    To maintain a synchronous API in Rust while executing work across asynchronous browser workers, orx-parallel uses a Scoped Execution Model.

    1. Scope Creation: For every parallel computation, a ScopeRuntime is created, tracking pending tasks and managing synchronization/panics.
    2. Task Scheduling: When run(...) is called, the task is either run immediately (if inline_only is true) or boxed as a Task and pushed to a queue.
    3. Worker Execution: Workers pop tasks from the queue, execute them using catch_unwind, and record any panics.
    4. Synchronization: The main thread waits until the pending task count reaches zero before clearing the scope and resuming any captured panics.

    This allows standard parallel iterator calls to work seamlessly in the browser as long as the runtime has been initialized.

  8. How `use_vec` reduces memory allocation in parallel loops

    main

    In orx-parallel, you can choose between an immutable design and a mutable design using the use_vec transformation.

    Immutable Design

    By default, parallel iterators (e.g., using .into_par()) follow an immutable pattern. Each iteration creates fresh data (like a Vec<usize>) and returns it.

    • Pros: Clean, direct, and easy to read.
    • Cons: Allocation grows linearly with the number of iterations and the size of the data being created. This can cause significant memory pressure in hot loops.

    use_vec Design

    The use_vec transformation allows you to provide worker-local mutable state. Instead of allocating new objects every iteration, each worker thread maintains its own persistent state (e.g., a ThreadData struct containing reusable buffers).

    • Pros: Allocation is constant relative to the number of iterations. Memory usage scales only with the number of threads and the size of the per-thread buffers.
    • Cons: Slightly more complex implementation.

    Use use_vec when your parallel computation involves heavy allocations inside the loop that can be reused by the same thread.

    // Immutable approach (High allocation)
    (0..iterations)
        .into_par()
        .map(|k| search_candidate(locations, seed))
        .min_by(...)
    
    // Mutable approach with use_vec (Low/Constant allocation)
    let mut data = UseVec::new(|_| ThreadData::new(locations.len()));
    let par = (0..iterations).into_par().num_threads(threads).use_vec(&mut data);
    par.for_each(|data, k| {
        // 'data' is worker-local and mutable
        let cost = search_candidate(locations, seed, &mut data.temp_tour);
        data.evaluate_temp_tour(cost);
    });
  9. Benchmark performance with orx-parallel-benchmarks

    main

    To perform performance checks for new or experimental runners, use the orx-parallel-benchmarks repository. Benchmarks run in separate processes to prevent interference from persistent thread pools.

    Recommended Workflow:

    1. Create a branch in orx-parallel containing your experimental runner.
    2. (Optional) Set the runner as the default in src/runner/mod.rs on your branch.
    3. In the orx-parallel-benchmarks repository, update the relevant Cargo.toml files to point to your specific branch or local path.
    4. Execute benchmarks across several workload categories to ensure broad compatibility.
    5. Compare results against built-in runners and Rayon (if a Rayon variant exists).

    Workload categories to test:

    • Uniform CPU-heavy map/reduce
    • Heterogeneous work
    • Memory-bound map/filter/collect
    • Early-exit search
    • Fallible option/result flows
    • Recursive traversal
  10. Design principles for implementing new runners

    main

    When developing or extending runners in orx-parallel, follow these design guidelines to ensure stability and API consistency:

    • API Stability: Keep the iterator API unchanged during runner experimentation.
    • Respect User Intent: Treat Params as the source of truth; always respect exact chunk sizes and thread-count caps requested by the user.
    • Iterator Efficiency: Utilize size_hint when available, but ensure that inputs with unknown lengths remain efficient.
    • Predictability over Speed: Prioritize predictable behavior before optimizing for raw execution speed.
    • Explicit Behavior: Ensure that ordered vs. arbitrary behavior is explicitly defined and verified in tests.
    • Diagnostics: Use structured diagnostics instead of relying solely on println! for debugging.
    • Encapsulation: Keep runner-specific state contained within the runner module. Do not leak scheduling or implementation details into Par, ParOption, ParResult, or collection code.
    • Validation: Always add benchmarks before promoting a new runner to the default status.
  11. Optimize memory by replacing immutable designs with use transformations

    main

    In performance-critical parallel applications, avoid 'immutable' designs that allocate new objects for every iteration (e.g., creating a new Vec inside a .map() call). This leads to $O(N)$ space complexity where $N$ is the number of iterations.

    Instead, use use_vec or use_new to manage thread-local buffers. By reusing a fixed set of buffers (one per thread), you reduce space complexity to $O(T)$ where $T$ is the number of threads, significantly reducing allocation pressure and improving cache locality.

  12. Parallelize computations with iterator ergonomics

    main

    You can parallelize standard Rust iterators by substituting sequential methods with parallel equivalents. For common collections, use .par() to convert an iterator to a parallel one. This allows you to use the same functional API (like .map(), .filter(), .min_by_key()) while executing the pipeline in parallel.

    Common substitutions:

    • iter() -> par()
    • into_iter() -> into_par()
    • iter_mut() -> par_mut()
    use orx_parallel::*;
    
    let best_tour = (0..num_tours)
        .par() // ← parallelized
        .map(|_| Tour::random(num_cities))
        .filter(|t| t.starts_at_coffee_shop())
        .min_by_key(|t| t.duration());