Overview of OhMyThreads.jl
mastertmapreduce) and a macro API (@tasks for ... end).repository·master·Indexed 41 days ago
https://github.com/juliafolds2/ohmythreads.jlA 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.
tmapreduce) and a macro API (@tasks for ... end).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)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
endfunction 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
endFalse 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.
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.map with @spawn to return values from tasks rather than modifying a pre-allocated shared buffer.OhMyThreads.treduce to handle parallel reductions safely and efficiently without manual state management.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)
endAnother 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:
Channel containing the indices or elements of the input.tmapreduce to spawn a fixed number of tasks.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
endIf 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:
:static scheduler during refactoring will cause race conditions.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
endIf 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
endCorrect (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
endThe 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:
nthreads()).Channel.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
endTo 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)
endOhMyThreads.jl is a registered package. You can add it to your Julia environment using the package manager:
] add OhMyThreadsFor maximum performance in scenarios with highly uniform workloads, you can manually chunk your data to reduce the overhead of task management.
index_chunks(collection; n = nchunks) to divide your input range into chunks.map combined with @spawn to launch one task per chunk.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