from forkunion import Library, Pool, BroadcastJoin, …4.0.0-rc.1 · d927445f
ForkUnion is dead-simple to use!
There is no nested parallelism, exception handling, or “future promises”; they are banned.
A pool runs one dispatch at a time and refuses any other with the contended status, including one from inside its own callbacks.
The thread pool itself has a few core operations:
spawn to initialize worker threads, and
for_threads to launch a blocking callback on all threads.
Higher-level APIs for index-addressable tasks are also available:
for_n - for individual evenly-sized tasks,
for_n_dynamic - for individual unevenly-sized tasks,
for_slices - for contiguous runs of evenly-sized tasks, one per worker.
For additional flow control and tuning, following helpers are available:
nap - to park workers on the CPU’s address monitor, waking with no syscall,
sleep(microseconds) - to block workers in the kernel for long idle periods, with 0 waiting for the next dispatch,
terminate - to kill the threads before the destructor is called,
unsafe_for_threads - to broadcast a callback without blocking, returning a generation token,
unsafe_join - to block until the completion of a broadcasted generation,
is_complete - to poll a generation token for completion without blocking.
Every dispatch is identified by a generation token.
On caller_exclusive_k pools you can dispatch, overlap your own work, poll is_complete, and join - the classic poll-then-join pattern.
On caller_inclusive_k pools the calling thread owes one slice of the work, which only runs inside unsafe_join, so completion can’t be reached by polling alone.
The same rule shapes the RAII guard returned by for_threads and, in C++, the for_n family: the workers start at the guard’s construction on every pool, and on inclusive pools the caller’s own slice runs at join or destruction.
On caller_exclusive_k pools thread 0 may also fork from inside its own slice, at any depth: such a fork waits for every other slice of the one enclosing it, runs on all threads, and returns complete.
So unsafe_for_threads can hand a whole multi-step call to thread 0, which forks the rest of the pool as it goes, while the caller only polls a status word the call stores at its end.
That is how one manager thread keeps several exclusive pools busy with no queue and no extra thread.
A join on such a pool waits for whatever dispatch runs, since the call may end before its submitter stores the token.
In C++ it provides NUMA extensions.
That includes the colocated_pool analog of the flat_pool and the domain_allocator_t for allocating memory in a specific memory domain.
Those are out-of-the-box compatible with the higher-level APIs.
Most interestingly, for Big Data applications, a higher-level distributed_pool class addresses and balances the work across all compute domains.
domain_allocator_t is backed by linux_numa_allocator_t, freebsd_numa_allocator_t, or windows_numa_allocator_t.
To integrate into your Mojo project, let Pixi build the package from source — there is no channel to add and no linker flag to pass:
[workspace]preview = ["pixi-build"][dependencies]forkunion = {git = "https://github.com/ashvardanian/ForkUnion", tag = "v4.0.0-rc.1"}
Vendoring works too and needs no manifest: Mojo resolves a source package ahead of a compiled one, so mojo run -I <path-to>/mojo is enough once libforkunion.so is on the loader path.
Then import and use in your code:
from forkunion import ComputeDomain, Library, Pool, SyncMutPointer, TasksRange, ThreadInDomain, Topology@fieldwise_initstruct Output(ImplicitlyCopyable, TrivialRegisterPassable): """The output array every kernel below writes into.""" var values: SyncMutPointer[Int64]def double(task: Int, at: ThreadInDomain, mut output: Output): output.values.at(task) = Int64(task * 2)def announce(thread_index: Int, compute_domain_index: Int, mut output: Output): print(t"thread {thread_index} on compute domain {compute_domain_index}")def sweep(tasks: TasksRange, at: ThreadInDomain, mut output: Output): for task in tasks: output.values.at(task) += 1def main() raises: var library = Library() var topology = Topology(library) var pool = Pool(topology, threads=4, name="demo") var results = List[Int64](length=1000, fill=0) var output = Output(SyncMutPointer(results.unsafe_ptr().unsafe_origin_cast[MutUntrackedOrigin]())) pool.for_threads[announce](output) # once per worker, OpenMP-style parallel pool.for_n[double](1000, output) # evenly split, OpenMP-style parallel for pool.for_n_dynamic[double](1000, output) # claimed as threads free up, for uneven work pool.for_slices[sweep](1000, output) # one contiguous run per worker print(t"results[7] = {results[7]}") # 15: doubled to 14, then swept once
The callback is a compile-time parameter and the state it needs is a separate runtime argument.
Mojo cannot hand a capturing closure to C at all, so a callback carries no captures — the scratch is the only channel, and its type and origin are inferred from the argument, leaving a call site to name only the work.
Fallible calls raise Error and nothing else, and a call raises if and only if it can fail - a refusal is never folded into an Optional the caller has to interpret.
The non-blocking dispatch is a context manager, so the join cannot be forgotten and the pool cannot be torn down while its workers are still running:
with pool.unsafe_for_threads[work](scratch) as dispatch: while not dispatch.is_complete(): # exclusive pools only prepare_next_batch()
The Topology handle reports the hardware topology, to size and place work:
var topology = Topology(library)for index in range(topology.compute_domains_count()): var domain = ComputeDomain(index) var cores = topology.logical_cores_count_in(domain) var level = topology.compute_level_in(domain) print(t"domain {index}: {cores} cores, level {level}, allocate on {topology.local_memory_of(domain)}")
Handles release themselves, so there is no defer to forget — though Mojo destroys a value after its last use rather than at the end of the scope, which is earlier than a Zig defer fires.
Unlike max.algorithm.parallelize, which takes every hardware thread and offers no way to ask for fewer, ForkUnion’s pool width is an argument.
On a machine you are sharing, that is the difference between a speedup and a slowdown.
API
StructLibraryA reference to the loaded core, cheap to copy and shared by everything it creates.3
struct Library
Every handle carries one, which is what keeps the symbols resolved for as long as anything can
still call into them. It is a loader reference, not a topology reference - the C API is
explicit that a pool never retains the topology it spawned from.
Fields
shared
ArcPointer[_Loaded]
The handle and its symbols, refcounted so the last holder closes the handle.
Conforms to
Copyable, Deinitable, ImplicitlyCopyable, Movable
__init__Opens the shared object and resolves it, which every handle then shares.
def __init__(out self)
Raises
With LIBRARY_MISSING, when nothing is on the loader path or its major version differs.
symbolsThe resolved entry points, borrowed rather than copied on every call.
A reference that stays valid for as long as this Library is held.
versionThe loaded library's own version, which is also the cheapest check that loading worked.
def version(self) -> Tuple[Int, Int, Int]
Returns
The major, minor, and patch numbers the shared object reports for itself.
StructPoolA spawned pool. Released when the last reference to it goes out of use.17
struct Pool
Every query re-asks the C core rather than caching, so the answers stay correct across a
terminate followed by a fresh spawn with a different width or exclusivity.
Use it as a context manager. The workers start here; on an inclusive pool the caller owes
its own slice, which runs at the join, so only an exclusive pool can be polled before it.
Parameters
ScratchAnyType
comptime
The scratch type, inferred from the argument.
pool_originImmOrigin
comptime
This pool's borrow, inferred from self and carried on into the guard.
scratch_originMutOrigin
comptime
The scratch's origin, inferred with the scratch and carried on too.
workdef(Int, Int, mut Scratch) thin -> None
comptime
The callback, taking a pool-wide thread index, a compute-domain index, and the scratch.
scratchScratch
The state the callback reads and writes, borrowed until the join rather than the return.
Returns
A guard naming the in-flight generation, which joins when its with block ends.
Raises
Error if the pool was never spawned, or the core refuses the dispatch.
StructBroadcastJoinOne in-flight generation, joined when the with block ends.3
Both origins are load-bearing, because Mojo destroys a value after its last named use and the
dispatch outlives that point.
Parameters
pool_originImmOrigin
comptime
The pool's borrow, without which it would be torn down while its workers were still running - and a terminate mid-flight trips an assertion inside the core.
scratch_originMutOrigin
comptime
The scratch's borrow, keeping the storage the workers write through alive until the join, which a raw context pointer cannot express.
StructCapabilitiesThe bit set of spin hints and placement facilities a build or a machine offers.3
struct Capabilities
One bit per facility, and two accessors ask two questions of the same bit. comptime_capabilities
reports whether the code was built with a path; runtime_capabilities reports whether the machine
offers it now. Neither implies the other.
Fields
bits
UInt32
One bit per facility, laid out as the C fu_capability_t enumerators are.
Values
NONE
Capabilities(UInt32(0))
Nothing detected, or nothing compiled in.
X86_PAUSE
Capabilities(UInt32(1))
The PAUSE spin hint, on every x86 since the Pentium 4.
X86_TPAUSE
Capabilities(UInt32(2))
TPAUSE sleeps the core until a deadline. Needs the WAITPKG feature.
ARM64_YIELD
Capabilities(UInt32(4))
The YIELD hint, on every AArch64.
ARM64_WFET
Capabilities(UInt32(8))
WFET sleeps the core until a deadline or an event. Needs FEAT_WFxT.
RISCV64_PAUSE
Capabilities(UInt32(16))
The PAUSE spin hint, from the Zihintpause extension.
RISCV64_WRS
Capabilities(UInt32(32))
WRS.STO sleeps the hart until a reservation breaks. Needs the Zawrs extension.
OS_THREADS
Capabilities(UInt32(64))
Own the raw OS thread handle instead of a std::thread.
TOPOLOGY
Capabilities(UInt32(128))
Enumerate this machine's cores, compute domains, and memory domains.
PLACE_THREADS_BY_AFFINITY
Capabilities(UInt32(256))
Bind a thread to a set of cores, choosing where it runs.
PLACE_THREADS_BY_CORE_CLASS
Capabilities(UInt32(512))
Steer a thread onto a class of core at creation.
RESCHEDULE_THREADS_BY_CLASS
Capabilities(UInt32(1024))
Reclass a thread's scheduler to sleep or wake it.
PLACE_MEMORY_ON_DOMAIN
Capabilities(UInt32(2048))
Place a buffer's pages on a chosen memory domain.
PLACE_HUGE_PAGES_ON_DOMAIN
Capabilities(UInt32(4096))
Place larger-than-base pages on a chosen memory domain.
HUGE_TRANSPARENT_PAGES
Capabilities(UInt32(8192))
The kernel promotes base pages to huge pages on its own.
COLOCATE_POOLS_ON_DOMAIN
Capabilities(UInt32(16384))
The domain-aware colocated and distributed pools are compiled.
X86_CLDEMOTE
Capabilities(UInt32(32768))
CLDEMOTE moves a just-written line toward the shared LLC.
ARM64_DC_CVAC
Capabilities(UInt32(65536))
DC CVAC cleans a dirty line to the coherency point.
RISCV64_ZICBOM
Capabilities(UInt32(131072))
User-mode Zicbom cache-block management, attested through hwprobe.
X86_CMPCCXADD
Capabilities(UInt32(262144))
CMPCCXADD, the conditional atomic add.
X86_RAOINT
Capabilities(UInt32(524288))
RAO-INT aadd and its family, executed at the shared cache.
ARM64_LSE
Capabilities(UInt32(1048576))
Armv8.1 FEAT_LSE: one instruction per read-modify-write.
ARM64_RCPC
Capabilities(UInt32(2097152))
Armv8.3 FEAT_LRCPC: LDAPR acquires, cheaper than LDAR's.
RISCV64_ZACAS
Capabilities(UInt32(4194304))
RISC-V Zacas, the amocas compare-and-swap.
RISCV64_ATOMIC
Capabilities(UInt32(8388608))
The RISC-V A extension's lr, sc and amo words.
ARM64_PRFM
Capabilities(UInt32(16777216))
PRFM, AArch64's prefetch hint.
RISCV64_ZICBOP
Capabilities(UInt32(33554432))
RISC-V Zicbop, the prefetch.w hint.
RISCV64_ZABHA
Capabilities(UInt32(67108864))
RISC-V Zabha, byte and halfword atomics.
ANY_YIELD
Capabilities(UInt32(63))
Every busy-wait waiter bit, to enumerate the ones a machine offers.
ALL
Capabilities(UInt32(4294967295))
All-ones allow-mask: hand to a pool to disable capability filtering.
__contains__Whether every bit of other is set here.
def __contains__(self, other: Self) -> Bool
Parameters
otherSelf
The facilities being asked about, one bit each.
Returns
True only when all of them are present, so a multi-bit mask asks for all of them.
__and__The intersection, to keep only what a build and a machine both offer.
def __and__(self, other: Self) -> Self
Parameters
otherSelf
The facilities to keep.
Returns
A set holding only the bits both sides had.
__or__The union, to build an allow-mask out of the named facilities.
def __or__(self, other: Self) -> Self
Parameters
otherSelf
The facilities to add.
Returns
A set holding every bit either side had.
StructTasksRangeA half-open [first, first + count) run of task indices - the "what work" of a slice dispatch.4
struct TasksRange
Iterable, so a callback reads for task in range rather than rebuilding the bounds. An idle
thread receives a range with count == 0, which every dispatch still calls exactly once.
Fields
first
Int
The first task index in the run.
count
Int
How many tasks the run covers; zero means an idle thread.
The count, which is zero on the thread a dispatch left idle.
__iter__Walks the task indices, so a callback loops over the run rather than rebuilding it.
def __iter__(self) -> _SequentialRange
Returns
A range over the half-open span, empty for an idle thread.
endOne past the last task index, so first..end() is the half-open span.
def end(self) -> Int
Returns
The exclusive upper bound, which equals first when the run is empty.
is_emptyWhether the run covers no tasks at all.
def is_empty(self) -> Bool
Returns
True on the idle thread every dispatch still calls exactly once.
StructIndexedSplitSplits a range of tasks into fair-sized runs, minimizing the spread across threads.2
struct IndexedSplit
The first tasks % threads runs get one extra task; the rest get the floor. Mirrors the C++
indexed_split. See https://lemire.me/blog/2025/05/22/dividing-an-array-into-fair-sized-chunks/
Fields
quotient
Int
The floor of tasks over threads, which every run is at least this long.
remainder
Int
How many of the leading runs take one task more than the floor.