OhMyThreads.jl

repository·master·Indexed 41 days ago

https://github.com/juliafolds2/ohmythreads.jl

A lightweight Julia package for user-friendly, task-based multithreading optimized for data-parallel workloads. It provides a function API with higher-order functions like `tmapreduce` and `treduce`, as well as a macro API featuring `@tasks` and `@set`. The package includes built-in protections against race conditions caused by boxed variables via `@localize` and `@allow_boxed_captures`, and offers configurable scheduling strategies including `:dynamic` and `:static` schedulers.

Tokens
8.6K
Snippets
30
Records
39
Agent score
52%

What's inside OhMyThreads.jl

  1. Overview of OhMyThreads.jl

    master
    OhMyThreads.jl is a simple package designed for user-friendly, task-based multithreaded calculations in Julia, with a primary focus on data parallelism. It provides two main ways to perform parallel computations: an API of higher-order functions (such as tmapreduce) and a macro API (@tasks for ... end).
  2. Optimize load balancing with `scheduler` and `ntasks`

    master

    When the workload per element is non-uniform (some elements take longer to compute than others), you can use different scheduling strategies to improve performance:

    • :dynamic (DynamicScheduler): The default scheduler. It divides the workload into tasks that are dynamically distributed among threads. This provides load balancing but may increase allocations.
    • :static (StaticScheduler): Does not provide load balancing. It divides the work upfront, which is faster in terms of overhead/allocations but can lead to thread idling if some tasks are much heavier than others.

    You can fine-tune the granularity of the dynamic scheduler by increasing the ntasks parameter, which creates more tasks with smaller workloads.

    using OhMyThreads
    
    # Using dynamic scheduling with fine-grained tasks
    compute_juliaset_parallel!(img; ntasks=N, scheduler=:dynamic)
    
    # Using static scheduling (no load balancing)
    compute_juliaset_parallel!(img; scheduler=:static)
  3. Avoid race conditions when using shared buffers in parallel

    master

    When using parallel functions like tmap, avoid using a single pre-allocated buffer (e.g., a temporary matrix) defined outside the parallel loop. Multiple tasks will attempt to mutate this shared variable simultaneously, leading to non-deterministic results and race conditions.

    Incorrect approach (Race Condition):

    function matmulsums_race(As, Bs)
        N = size(first(As), 1)
        C = Matrix{Float64}(undef, N, N) # Shared buffer
        tmap(As, Bs) do A, B
            mul!(C, A, B) # Multiple tasks writing to C at once
            sum(C)
        end
    end
    function matmulsums_race(As, Bs)
        N = size(first(As), 1)
        C = Matrix{Float64}(undef, N, N)
        tmap(As, Bs) do A, B
            mul!(C, A, B)
            sum(C)
        end
    end
  4. Avoid False Sharing in parallel code

    master

    False Sharing is a performance issue that occurs when multiple CPU cores operate on independent data elements that reside within the same cache line. Even if the data is logically independent (no race conditions), the hardware's cache coherency mechanism forces costly synchronization between cores, leading to significant slowdowns.

    How to avoid False Sharing

    1. Use Task-Local Accumulators: Instead of frequently updating a shared array (e.g., psums[c] += value), perform the computation using a local variable within the task and write the final result to the shared state only once at the end of the task.
    2. Avoid Shared State Modification: Use patterns like map with @spawn to return values from tasks rather than modifying a pre-allocated shared buffer.
    3. Use High-Level Abstractions: Use OhMyThreads.treduce to handle parallel reductions safely and efficiently without manual state management.
  5. Translate Base.Threads.@spawn to OhMyThreads

    master

    To replicate the behavior of Base.Threads.@spawn (which spawns individual tasks), you can use OhMyThreads.@spawn, or use @tasks with chunking=false. In tforeach, pass chunking=false to avoid grouping iterations into chunks.

    Note that OhMyThreads.@spawn is available as a direct replacement for the Base.Threads version.

    # Base.Threads
    using Base.Threads: @spawn
    @sync for i in 1:10
        @spawn println(i)
    end
    
    # OhMyThreads (@tasks variant)
    using OhMyThreads: @tasks
    @tasks for i in 1:10
        @set chunking=false
        println(i)
    end
    
    # OhMyThreads (tforeach variant)
    using OhMyThreads: tforeach
    tforeach(1:10; chunking=false) do i
        println(i)
    end
    
    # OhMyThreads (@spawn variant)
    using OhMyThreads: @spawn
    @sync for i in 1:10
        @spawn println(i)
    end
  6. Implement safe per-thread storage using a flipped `Channel` approach

    master

    Another safe pattern is to put the input elements into a Channel and spawn a limited number of tasks (e.g., nthreads()) that each maintain their own long-lived task-local buffer.

    Pattern:

    1. Create a Channel containing the indices or elements of the input.
    2. Use tmapreduce to spawn a fixed number of tasks.
    3. Each task creates one local buffer and iterates over the Channel using map(chnl) do i ... end.

    Note: This approach is non-deterministic in terms of output order, so you may need to sort results if order matters.

    Optimization: For large inputs, use OhMyThreads.ChannelLike(range) instead of a standard Channel to avoid the overhead of copying elements into the channel.

    using OhMyThreads: tmapreduce
    
    function matmulsums_perthread_channel_flipped(As, Bs; ntasks = nthreads())
        N = size(first(As), 1)
        # Put input indices into a channel
        chnl = Channel{Int}(length(As); spawn = true) do chnl
            for i in 1:length(As)
                put!(chnl, i)
            end
        end
    
        # Spawn a limited number of tasks
        tmapreduce(vcat, 1:ntasks; chunking=false) do _ 
            local C = Matrix{Float64}(undef, N, N) # One buffer per task
            map(chnl) do i 
                A = As[i]
                B = Bs[i]
                mul!(C, A, B)
                sum(C)
            end
        end
    end
  7. Use `scheduler = :static` to prevent task migration

    master

    If you must use threadid() to access per-thread buffers, you can mitigate the risk of task migration by opting out of dynamic scheduling. Setting scheduler = :static (or scheduler = StaticScheduler()) ensures tasks are statically assigned to threads and remain "sticky" to them.

    Warning: This approach is still not recommended because:

    1. It is fragile; removing the :static scheduler during refactoring will cause race conditions.
    2. It is non-composable; calling other multithreaded functions inside a static region can lead to thread oversubscription.
    3. It can be memory inefficient if maxthreadid() is significantly larger than the actual number of threads used.
    using Base.Threads: threadid
    
    function matmulsums_perthread_static(As, Bs)
        N = size(first(As), 1)
        # Use maxthreadid to avoid out-of-bounds
        num_to_store() = isdefined(Threads, :maxthreadid) ? Threads.maxthreadid() : Threads.nthreads()
        Cs = [Matrix{Float64}(undef, N, N) for _ in 1:num_to_store()]
    
        # scheduler = :static is REQUIRED for correctness here
        tmap(As, Bs; scheduler = :static) do A, B
            C = Cs[threadid()]
            mul!(C, A, B)
            sum(C)
        end
    end
  8. Implement task-local parallel summation manually

    master

    If you are implementing parallel summation manually, avoid updating a shared array inside your innermost loop. Instead, use a task-local accumulator to perform partial sums and write to the shared array only once per task.

    Incorrect (causes False Sharing):

    for i in idcs
        psums[c] += data[i] # Frequent modification of shared state
    end

    Correct (Task-Local):

    @spawn begin
        local s = zero(eltype(data))
        for i in idcs
            s += data[i]
        end
        psums[c] = s # Single modification at the end
    end
  9. Implement safe per-thread storage using `Channel`

    master

    The recommended safe way to manage a pool of reusable buffers for parallel tasks is to use a Channel. This avoids the risks of task migration and threadid() indexing.

    Pattern:

    1. Pre-allocate a fixed number of buffers (e.g., nthreads()).
    2. Put them into a Channel.
    3. Inside the parallel loop (tmap), take! a buffer from the channel, use it, and then put! it back when finished.

    This approach is highly efficient and works well with dynamic load balancing (when ntasks > nthreads()).

    function matmulsums_perthread_channel(As, Bs; nbuffers = nthreads(), kwargs...)
        N = size(first(As), 1)
        chnl = Channel{Matrix{Float64}}(nbuffers)
        foreach(1:nbuffers) do _
            put!(chnl, Matrix{Float64}(undef, N, N))
        end
    
        tmap(As, Bs; kwargs...) do A, B
            C = take!(chnl) # Safely acquire buffer
            mul!(C, A, B)
            result = sum(C)
            put!(chnl, C)   # Safely return buffer
            result
        end
    end
  10. Translate Base.Threads.@threads to OhMyThreads

    master

    To replace the standard Base.Threads.@threads loop with OhMyThreads, you can use the @tasks macro or the tforeach function.

    For static scheduling (equivalent to @threads :static), use @set scheduler=:static inside an @tasks block or pass scheduler=:static as a keyword argument to tforeach.

    # Base.Threads
    using Base.Threads: @threads
    @threads for i in 1:10
        println(i)
    end
    
    # OhMyThreads (@tasks variant)
    using OhMyThreads: @tasks
    @tasks for i in 1:10
        println(i)
    end
    
    # OhMyThreads (tforeach variant)
    using OhMyThreads: tforeach
    tforeach(1:10) do i
        println(i)
    end
    
    # Static scheduling in OhMyThreads
    @tasks for i in 1:10
        @set scheduler=:static
        println(i)
    end
    
    # or
    tforeach(1:10; scheduler=:static) do i
        println(i)
    end
  11. Manual parallelization using `index_chunks` and `@spawn`

    master

    For maximum performance in scenarios with highly uniform workloads, you can manually chunk your data to reduce the overhead of task management.

    1. Use index_chunks(collection; n = nchunks) to divide your input range into chunks.
    2. Use map combined with @spawn to launch one task per chunk.
    3. Use fetch to retrieve the results from the spawned tasks.

    This approach is often faster than tmapreduce because it performs larger, task-local sequential computations rather than many small individual tasks.

    using OhMyThreads: @spawn, index_chunks
    
    function mc_parallel_manual(N; nchunks = nthreads())
        # Divide 1:N into chunks and spawn a task for each chunk
        tasks = map(index_chunks(1:N; n = nchunks)) do idcs
            @spawn mc(length(idcs))
        end
        # Aggregate results
        pi = sum(fetch, tasks) / nchunks
        return pi
    end