Browse by type
Fork Union is arguably the lowest-latency OpenMP-style NUMA-aware minimalistic scoped thread-pool designed for 'Fork-Join' parallelism in C++, C, and Rust, avoiding × mutexes & system calls, × dynamic memory allocations, × CAS-primitives, and × false-sharing of CPU cache-lines on the hot path 🍴
Most "thread-pools" are not, in fact, thread-pools, but rather "task-queues" that are designed to synchronize a concurrent dynamically growing list of heap-allocated globally accessible shared objects.
In C++ terms, think of it as a std::queue<std::function<void()>> protected by a std::mutex, where each thread waits for the next task to be available and then executes it on some random core chosen by the OS scheduler.
All of that is slow... and true across C++, C, and Rust projects.
Short of OpenMP, practically every other solution has high dispatch latency and noticeable memory overhead.
OpenMP, however, is not ideal for fine-grained parallelism and is less portable than the C++ and Rust standard libraries.
This is where fork_union comes in.
It's a C++ 17 library with C 99 and Rust bindings (previously Rust implementation was standalone in v1).
It supports pinning threads to specific NUMA nodes or individual CPU cores, making it much easier to ensure data locality and halving the latency of individual loads in Big Data applications.
Fork Union is dead-simple to use!
There is no nested parallelism, exception handling, or "future promises"; they are banned.
The thread pool itself has a few core operations:
try_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 slices of evenly-sized tasks.For additional flow control and tuning, following helpers are available:
sleep(microseconds) - for longer naps,terminate - to kill the threads before the destructor is called,unsafe_for_threads - to broadcast a callback without blocking,unsafe_join - to block until the completion of the current broadcast.On Linux, in C++, given the maturity and flexibility of the HPC ecosystem, it provides NUMA extensions.
That includes the linux_colocated_pool analog of the basic_pool and the linux_numa_allocator for allocating memory on a specific NUMA node.
Those are out-of-the-box compatible with the higher-level APIs.
Most interestingly, for Big Data applications, a higher-level distributed_pool class will address and balance the work across all NUMA nodes.
To integrate into your Rust project, add the following lines to Cargo.toml:
[dependencies]
fork_union = "2.3.1" # default
fork_union = { version = "2.3.1", features = ["numa"] } # with NUMA support on Linux
Or for the preview development version:
[dependencies]
fork_union = { git = "https://github.com/ashvardanian/fork_union.git", branch = "main-dev" }
A minimal example may look like this:
use fork_union as fu;
let mut pool = fu::spawn(2);
pool.for_threads(|thread_index, colocation_index| {
println!("Hello from thread # {} on colocation # {}", thread_index + 1, colocation_index + 1);
});
Higher-level APIs distribute index-addressable tasks across the threads in the pool:
pool.for_n(100, |prong| {
println!("Running task {} on thread # {}",
prong.task_index + 1, prong.thread_index + 1);
});
pool.for_slices(100, |prong, count| {
println!("Running slice [{}, {}) on thread # {}",
prong.task_index, prong.task_index + count, prong.thread_index + 1);
});
pool.for_n_dynamic(100, |prong| {
println!("Running task {} on thread # {}",
prong.task_index + 1, prong.thread_index + 1);
});
A more realistic example with named threads and error handling may look like this:
use std::error::Error;
use fork_union as fu;
fn heavy_math(_: usize) {}
fn main() -> Result<(), Box<dyn Error>> {
let mut pool = fu::ThreadPool::try_spawn(4)?;
let mut pool = fu::ThreadPool::try_named_spawn("heavy-math", 4)?;
pool.for_n_dynamic(400, |prong| {
heavy_math(prong.task_index);
});
Ok(())
}
For advanced usage, refer to the NUMA section below.
For convenience Rayon-style parallel iterators pull the prelude module and check out related examples.
To integrate into your C++ project, either just copy the include/fork_union.hpp file into your project, add a Git submodule, or CMake.
For a Git submodule, run:
git submodule add https://github.com/ashvardanian/fork_union.git extern/fork_union
Alternatively, using CMake:
FetchContent_Declare(
fork_union
GIT_REPOSITORY https://github.com/ashvardanian/fork_union
GIT_TAG v2.3.1
)
FetchContent_MakeAvailable(fork_union)
target_link_libraries(your_target PRIVATE fork_union::fork_union)
Then, include the header in your C++ code:
#include <fork_union.hpp> // `basic_pool_t`
#include <cstdio> // `stderr`
#include <cstdlib> // `EXIT_SUCCESS`
namespace fu = ashvardanian::fork_union;
int main() {
alignas(fu::default_alignment_k) fu::basic_pool_t pool;
if (!pool.try_spawn(std::thread::hardware_concurrency())) {
std::fprintf(stderr, "Failed to fork the threads\n");
return EXIT_FAILURE;
}
// Dispatch a callback to each thread in the pool
pool.for_threads([&](std::size_t thread_index) noexcept {
std::printf("Hello from thread # %zu (of %zu)\n", thread_index + 1, pool.threads_count());
});
// Execute 1000 tasks in parallel, expecting them to have comparable runtimes
// and mostly co-locating subsequent tasks on the same thread. Analogous to:
//
// #pragma omp parallel for schedule(static)
// for (int i = 0; i < 1000; ++i) { ... }
//
// You can also think about it as a shortcut for the `for_slices` + `for`.
pool.for_n(1000, [](std::size_t task_index) noexcept {
std::printf("Running task %zu of 1000\n", task_index + 1);
});
pool.for_slices(1000, [](std::size_t first_index, std::size_t count) noexcept {
std::printf("Running slice [%zu, %zu)\n", first_index, first_index + count);
});
// Like `for_n`, but each thread greedily steals tasks, without waiting for
// the others or expecting individual tasks to have same runtimes. Analogous to:
//
// #pragma omp parallel for schedule(dynamic, 1)
// for (int i = 0; i < 3; ++i) { ... }
pool.for_n_dynamic(3, [](std::size_t task_index) noexcept {
std::printf("Running dynamic task %zu of 3\n", task_index + 1);
});
return EXIT_SUCCESS;
}
For advanced usage, refer to the NUMA section below.
NUMA detection on Linux defaults to AUTO. Override with -D FORK_UNION_ENABLE_NUMA=ON or OFF.
Many other thread-pool implementations are more feature-rich but have different limitations and design goals.
taskflow/taskflow, progschj/ThreadPool, bshoshany/thread-poolvit-vit/CTPL, mtrebi/thread-pooltokio-rs/tokio, rayon-rs/rayon, smol-rs/smolThose are not designed for the same OpenMP-like use cases as fork_union.
Instead, they primarily focus on task queuing, which requires significantly more work.
Unlike the std::atomic, the std::mutex is a system call, and it can be expensive to acquire and release.
Its implementations generally have 2 executable paths:
On Linux, the latter translates to "futex" "system calls", which is expensive.
C++ has rich functionality for concurrent applications, like std::future, std::packaged_task, std::function, std::queue, std::condition_variable, and so on.
Most of those, I believe, are unusable in Big-Data applications, where you always operate in memory-constrained environments:
std::bad_alloc exception when there is no memory left and just hoping that someone up the call stack will catch it is not a great design idea for any Systems Engineering.As we focus on a simpler ~~concurrency~~ parallelism model, we can avoid the complexity of allocating shared states, wrapping callbacks into some heap-allocated "tasks", and other boilerplate code. Less work - more performance.
Once you get to the lowest-level primitives on concurrency, you end up with the std::atomic and a small set of hardware-supported atomic instructions.
Hardware implements it differently:
LOCK variants of the ADD and CMPXCHG, which act as full-blown "fences" - no loads or stores can be reordered across it.acquire, release, and acq_rel variants of each atomic instruction—such as LDADD, STADD, and CAS - which allow precise control over visibility and order, especially with the introduction of "Large System Extension" (LSE) instructions in Armv8.1.In practice, a locked atomic on x86 requires the cache line in the Exclusive state in the requester's L1 cache. This would incur a coherence transaction (Read-for-Ownership) if some other core had the line. Both Intel and AMD handle this similarly.
It makes Arm and Power much more suitable for lock-free programming and concurrent data structures, but some observations hold for both platforms. Most importantly, "Compare and Swap" (CAS) is a costly operation and should be avoided whenever possible.
On x86, for example, the LOCK ADD can easily take 50 CPU cycles, being 50x slower than a regular ADD instruction, but still easily 5-10x faster than a LOCK CMPXCHG instruction.
Once contention rises, the gap naturally widens and is further amplified by the increased "failure" rate of the CAS operation, particularly when the value being compared has already changed.
That's why, for the "dynamic" mode, we resort to using an additional atomic variable as opposed to more typical CAS-based implementations.
The thread-pool needs several atomic variables to synchronize the state.
If those variables are located on the same cache line, they will be "falsely shared" between threads.
This means that when one thread updates one of the variables, it will invalidate the cache line in all other threads, causing them to reload it from memory.
This is a common problem, and the C++ standard recommends addressing it with alignas(std::hardware_destructive_interference_size) for your hot variables.
There are, however, caveats.
The std::hardware_destructive_interference_size is generally 64 bytes on x86, matching the size of a single cache line.
But in reality, on most x86 machines, depending on the BIOS "spatial prefetcher" settings, will fetch 2 cache lines at a time starting with Sandy Bridge.
Because of these rules, padding hot variables to 128 bytes is a conservative but often sensible defensive measure adopted by Folly's cacheline_align and Java's jdk.internal.vm.annotation.Contended. 
Handling NUMA isn't trivial and is only supported on Linux with the help of the libnuma library.
It provides the mbind interface to pin specific memory regions to particular NUMA nodes, as well as helper functions to query the system topology, which are exposed via the fork_union::numa_topology template.
Let's say you are working on a Big Data application, like brute-forcing Vector Search using the SimSIMD library on a 2 dual-socket CPU system, similar to [USearch](https://github.com/unum-cloud/use
$ claude mcp add ForkUnion \
-- python -m otcore.mcp_server <graph>