C++ · Quickstart

Quickstart

#include <forkunion/types.hpp>#include <forkunion/flat.hpp>#include <forkunion/capabilities.hpp>#include <forkunion/logging.hpp> 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 C++ project, either copy the c/ and include/ directories into your project and add both to the include path, add a Git submodule, or CMake. The forkunion.hpp umbrella is a list of #includes; the implementation lives beside it in include/forkunion/, one header per concern, so a platform backend can be read or replaced on its own. For a Git submodule, run:

git submodule add https://github.com/ashvardanian/ForkUnion.git extern/forkunion

Alternatively, using CMake:

FetchContent_Declare(
    forkunion
    GIT_REPOSITORY https://github.com/ashvardanian/ForkUnion
    GIT_TAG v4.0.0-rc.1
)
FetchContent_MakeAvailable(forkunion)
target_link_libraries(your_target PRIVATE forkunion::header)

Then, include the header in your C++ code:

#include <forkunion.hpp>    // `flat_pool_t`
#include <cstdio>           // `stderr`
#include <cstdlib>          // `EXIT_SUCCESS`

namespace fu = ashvardanian::forkunion;

int main() {
    alignas(fu::default_alignment_k) fu::flat_pool_t pool;
    if (fu::failed(pool.spawn(fu::allowed_cores_count()))) {
        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, fu::thread_in_domain_t) noexcept {
        std::printf("Running task %zu of 1000\n", task + 1);
    });
    pool.for_slices(1000, [](fu::tasks_range_t range, fu::thread_in_domain_t) noexcept {
        std::printf("Running run [%zu, %zu)\n", range.first, range.first + range.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, fu::thread_in_domain_t) noexcept {
        std::printf("Running dynamic task %zu of 3\n", task + 1);
    });
    return EXIT_SUCCESS;
}

For advanced usage, refer to the NUMA section below. Every kernel and ISA facility the library uses is detected by default. CMake pins the four kernel facilities with an AUTO/ON/OFF tri-state like -D FORKUNION_WITH_PLACE_MEMORY_ON_DOMAIN=ON or -D FORKUNION_WITH_PLACE_THREADS_BY_AFFINITY=OFF. Finer preprocessor gates - like the cache-line hints FORKUNION_WITH_DEMOTE_CACHE_LINES, which pushes a just-written line away from the core via x86 CLDEMOTE or Arm DC CVAC, and FORKUNION_WITH_PROMOTE_CACHE_LINES, which pulls a line in with write intent via x86 PREFETCHW, Arm PRFM PSTL1KEEP, or RISC-V prefetch.w - accept the same overrides as compile definitions. Call fu_comptime_capabilities() to see what survived the build, fu_runtime_capabilities() to see what the machine underneath actually offers, and fu_name_capabilities() to render either mask as text - every binding exposes the same trio, Rust and Mojo spelling it runtime_capabilities() and Zig runtimeCapabilities with runtimeCapabilitiesString to render, alongside a version accessor: fu_version_major/_minor/_patch in C, version() in Rust, Zig, and Mojo, and the FORKUNION_VERSION_* macros in C++.

Every all-caps name starts with the full project name, FORKUNION_. A trailing _ marks a name as internal: it may change in any release, and nothing outside this repository may define or test it. A name without it is a public contract, either a switch you may set or a value you may read.

FamilyFormExample
ISA capability bitFORKUNION_TARGET_<CAPABILITY>FORKUNION_TARGET_X86_PAUSE
Dispatch modeFORKUNION_RUNTIME_DISPATCH
Optional featureFORKUNION_WITH_<FEATURE>FORKUNION_WITH_TOPOLOGY
Permission for a libertyFORKUNION_ALLOW_<LIBERTY>FORKUNION_ALLOW_UNSAFE
Architecture factFORKUNION_ARCH_<ARCH>_FORKUNION_ARCH_X8664_
Operating-system factFORKUNION_OS_<OS>_FORKUNION_OS_LINUX_
Toolchain factFORKUNION_HAS_<FEATURE>_FORKUNION_HAS_INLINE_ASM_

Architectures are spelled X8664, ARM64, RISCV64 and WASM. Every name in these families is always defined, as 0 or 1, and tested with #if, never with defined(...).

API

Types

status_tWhy a call failed.
types.hpp:734 ↗
enum status_t

Success is 0; every failure is negative.

Every enumerator takes its value from fu_status_t in forkunion/forkunion.h, so the C++ mirror and the ABI cannot drift - there is nothing to keep in step and nothing to assert.

Causes are collapsed on purpose where the remedy is the same, and split where it differs.

Statuses fall into four categories, with no interruptions to report. Resources: bad_alloc_k, thread_refused_k, topology_unavailable_k, permission_denied_k, library_missing_k and symbol_missing_k. Input: invalid_argument_k. State: capacity_exhausted_k, config_mismatch_k, already_spawned_k, not_spawned_k, unsupported_k and contended_k. Defects: unknown_k. Any verb that allocates, binds a device or touches a file may also return a Resources status. A verb's own block lists only what its contract adds.

Values

success_kfu_success_kThe call completed and any output holds a meaningful value.
unknown_kfu_unknown_kNo reason was reported, or one this build does not name.
bad_alloc_kfu_bad_alloc_kAn allocation or mapping failed; a smaller request may succeed.
capacity_exhausted_kfu_capacity_exhausted_kA fixed ceiling was reached, so unlike bad_alloc_k a smaller request will not help.
invalid_argument_kfu_invalid_argument_kAny argument fault - a zero count, an out-of-range index, a byte count that would wrap - as one code, because the remedy is the same.
config_mismatch_kfu_config_mismatch_kThe handles or the pool kind cannot serve this call together.
already_spawned_kfu_already_spawned_kThe pool is already spawned; terminate it first.
not_spawned_kfu_not_spawned_kThe pool was never spawned; spawn it first.
thread_refused_kfu_thread_refused_kThe OS declined to create a thread - a resource limit, or permissions.
topology_unavailable_kfu_topology_unavailable_kThe machine could not be described; transient if its CPU set changed mid-probe.
permission_denied_kfu_permission_denied_kA privileged operation was declined.
unsupported_kfu_unsupported_kThis build or this machine has no such facility.
library_missing_kfu_library_missing_kBinding-only: nothing to load, or a different major version.
symbol_missing_kfu_symbol_missing_kBinding-only: the loaded core is missing a symbol.
contended_kfu_contended_kThe pool is running another dispatch: another caller's, or the one this call runs inside.

Notes

A refused affinity is not here. The pool still partitions work by domain when the kernel declines to pin; all_threads_pinned() is where that is reported.

caller_exclusivity_tDefines the in- and exclusivity of the calling thread in for the executing task.
types.hpp:827 ↗
enum caller_exclusivity_t

This enum affects how the join is performed. If the caller is inclusive, 1/Nth of the call will be executed by the calling thread, as opposed to workers, and the join will happen inside of the calling scope.

Values

caller_inclusive_k0
caller_exclusive_k1

See also

caller_inclusive_k and caller_exclusive_k

capabilities_tDescribes all the special library features, both those compiled in and those found here.
types.hpp:865 ↗
enum capabilities_t

One bit per facility, and two accessors ask two questions of the same bit. comptime_capabilities() reports whether the code for a facility was built: capability_place_huge_pages_on_domain_k there means we compiled the path that asks the kernel for them. runtime_capabilities() reports whether the facility is present on this machine: the same bit means the kernel is offering them now.

Neither implies the other. A binary that built capability_place_memory_on_domain_k runs well on a single-node box, where the runtime accessor never sets that bit; and a machine with four NUMA nodes reports none of them to a build that left the topology out. A facility is usable here only when both accessors agree: the honest set is comptime_capabilities() & runtime_capabilities().

Values

capabilities_unknown_k0No facility at all.
capability_x86_pause_k1 << 0The PAUSE spin hint, on every x86 since the Pentium 4: a short pipeline stall that keeps a busy-wait from flooding the load ports and eases the sibling hardware thread.
capability_x86_tpause_k1 << 1TPAUSE sleeps the core until a deadline rather than spinning - the WAITPKG feature, the CPUID leaf 7, subleaf 0, ECX bit 5, on Sapphire Rapids, Alder Lake and their successors.
capability_arm64_yield_k1 << 2The YIELD hint, on every AArch64: releases the pipeline to a sibling hardware thread and costs nothing where there is none.
capability_arm64_wfet_k1 << 3WFET sleeps the core until a deadline or an event on the monitored line - FEAT_WFxT, Armv8.7, read from ID_AA64ISAR2_EL1 on Linux and hw.optional.arm.FEAT_WFxT on Apple.
capability_riscv64_pause_k1 << 4The PAUSE spin hint of the Zihintpause extension - encoded as a FENCE every hart accepts, so it is reported on every RISC-V.
capability_riscv64_wrs_k1 << 5WRS.STO sleeps the hart until a reservation breaks or a short timeout - the Zawrs extension, attested by the kernel's hwprobe.
capability_os_threads_k1 << 6Own the raw OS thread handle instead of a std::thread - the substrate every thread lever below stands on.
capability_topology_k1 << 7Enumerate this machine's cores, compute domains, and memory domains - the root every placement needs.
capability_place_threads_by_affinity_k1 << 8Bind a thread to a set of cores, choosing where it runs.
capability_place_threads_by_core_class_k1 << 9Steer a thread onto a class of core at creation, choosing where it runs.
capability_reschedule_threads_by_class_k1 << 10Reclass a thread's scheduler to sleep or wake it, choosing when it runs.
capability_place_memory_on_domain_k1 << 11Place a buffer's pages on a chosen memory domain.
capability_place_huge_pages_on_domain_k1 << 12Place larger-than-base pages on a chosen memory domain.
capability_huge_transparent_pages_k1 << 13The kernel promotes base pages to huge pages on its own.
capability_colocate_pools_on_domain_k1 << 14Compile the domain-aware colocated_pool and distributed_pool.
capability_x86_cldemote_k1 << 15CLDEMOTE moves a just-written line from this core's private caches toward the shared LLC and retains it there.
capability_arm64_dc_cvac_k1 << 16DC CVAC cleans a dirty line to the coherency point, AArch64's nearest thing to a demote: the next claimer's snoop finds a clean line, forcing no dirty intervention.
capability_riscv64_zicbom_k1 << 17The kernel enabled user-mode Zicbom cache-block management - senvcfg.CBCFE - attested through hwprobe, the only sound signal, since a compile-time +zicbom proves nothing about the kernel.
capability_x86_cmpccxadd_k1 << 18CMPCCXADD, the conditional atomic add - CPUID leaf 7, subleaf 1, EAX bit 7, on Sierra Forest, Diamond Rapids and their successors: a bounded claim as one instruction instead of a compare-exchange loop.
capability_x86_raoint_k1 << 19RAO-INT aadd, aand, aor and axor - CPUID leaf 7, subleaf 1, EAX bit 3, on Grand Ridge: no-return atomics executed at the shared cache rather than pulling the line; weakly ordered, so relaxed only.
capability_arm64_lse_k1 << 20Armv8.1 FEAT_LSE: swp, cas, ldadd and the no-return st* forms - one instruction per read-modify-write instead of a load-exclusive loop.
capability_arm64_rcpc_k1 << 21Armv8.3 FEAT_LRCPC: ldapr, the RCpc acquiring load that needn't wait for the core's earlier release stores the way RCsc ldar may.
capability_riscv64_zacas_k1 << 22Zacas: amocas, compare-and-swap as one instruction instead of an lr and sc loop, attested by the kernel's hwprobe.
capability_riscv64_atomic_k1 << 23The A extension: lr, sc and the amo* read-modify-writes, the RISC-V baseline every Linux ABI guarantees, so the detector sets it unconditionally.
capability_arm64_prfm_k1 << 24The AArch64 PRFM prefetch hints, base ISA that can never fault, so the detector reports them on every AArch64 core.
capability_riscv64_zicbop_k1 << 25The Zicbop prefetches, prefetch.r and prefetch.w, encoded as ORI hints every hart accepts, so the detector reports them on every RISC-V core.
capability_riscv64_zabha_k1 << 26Zabha: the byte and halfword amo forms, and with Zacas the byte and halfword amocas, attested by the kernel's hwprobe.
capability_any_yield_kcapability_x86_pause_k | capability_x86_tpause_k | capability_arm64_yield_k | capability_arm64_wfet_k | capability_riscv64_pause_k | capability_riscv64_wrs_kComposite mask of every busy-wait waiter bit above, to enumerate the ones a machine offers in one intersection with runtime_capabilities().

See also

pool_kind_tWhich of the three pool shapes an instance is, independent of the waiter it uses.
types.hpp:1094 ↗
enum pool_kind_t

This is the shape discriminator, not a capability: capability_colocate_pools_on_domain_k says the colocated and distributed shapes were compiled, while this says which one a pool is.

Values

unknown_k0No pool constructed - the shape is undecided until a spawn builds one.
flat_kNo domain awareness - one worker set over all cores, as if the machine were uniform.
colocated_kPinned to a single compute domain, so its workers share a cache and a memory domain.
distributed_kSpans every compute domain, replicating hot state per memory domain.
limited_arrayA fixed-capacity array with inline storage, so it never allocates.
types.hpp:1478 ↗
template <typename value_type_,
          std::size_t capacity_>
class limited_array

Sized for the small, bounded lists a machine hands us - the huge page sizes of a NUMA node, the processor groups of a Windows box. Refuses to grow past capacity_k rather than truncating in silence, because a list quietly cut short is a topology quietly misreported.

Fields

capacity_kstd::size_t

See also

dynamic_array when the count is only known at runtime.

dynamic_padded_arrayA dynamic_array with elements at a caller-chosen stride, each on its own cache line.
types.hpp:1684 ↗
template <typename object_type_,
          typename allocator_type_>
class dynamic_padded_array

Move-only and owning like std::unique_ptr<T[]>, but it spaces objects by stride() bytes rather than sizeof(T) and honours over-alignment, so an alignas(128) pool cell never false-shares and never lands in under-aligned storage. It also accepts an allocate_at_least allocator, the way the NUMA backends hand back more bytes than asked.

See also

dynamic_array for the packed counterpart at natural alignment.

tasks_rangeA half-open [first, first + count) run of task indices, the "what work" of a dispatch.
types.hpp:2059 ↗
template <typename index_type_ = std::size_t>
struct tasks_range

Iterable, so a callback reads for (auto task : range) rather than rebuilding the bounds. The exclusive end is first + count; an idle thread receives a range with count == 0.

Fields

firstindex_tThe first task index in the run.
countindex_tHow many tasks the run covers; zero means an idle thread.
indexed_splitSplits a range of tasks into fair-sized runs for each thread.
types.hpp:2096 ↗
template <typename index_type_ = std::size_t>
struct indexed_split

The first (tasks % threads) runs have size ceil(tasks / threads). The remaining tasks - (tasks % threads) runs have size floor(tasks / threads). Has the convenient added property that the difference between the largest and smallest run size is at most 1, which can be used in some ordering algorithms.

See also

coprime_permutation_rangeIterator range over integers using a stride that is co-prime with length.
types.hpp:2155 ↗
template <typename index_type_ = std::size_t>
struct coprime_permutation_range
  • Constant-time dereference: two integer ops and a branchless wrap-around.
  • Every value appears exactly once before end().
coprime_permutation_range<> perm(start, length, seed);
for (auto v : perm) steal_from(v);
invoke_for_slicesWraps the metadata needed for for_slices APIs for broadcast_join compatibility.
types.hpp:2277 ↗
template <typename fork_type_,
          typename index_type_>
class invoke_for_slices
invoke_for_nWraps the metadata needed for for_n APIs for broadcast_join compatibility.
types.hpp:2293 ↗
template <typename fork_type_,
          typename index_type_>
class invoke_for_n
dynamic_claimOne thread's private cursor into its own slice of a for_n_dynamic dispatch.
types.hpp:2323 ↗
template <typename index_type_ = std::size_t>
struct dynamic_claim

A single shared counter serializes an entire dispatch: only one core may own its cache line at a time, so no dispatch retires tasks faster than that line circulates. Handing every thread its own cursor turns the common claim into an uncontended read-modify-write on a line nobody else touches, which is roughly fifty times cheaper. The line is only shared once a thread runs dry and starts helping a neighbour, which is exactly when the extra cost is worth paying.

Pad these to a full cache line - two cursors sharing a line would reintroduce the very traffic the split exists to avoid.

Fields

nextstd::atomic<index_type_>Next task in this slice; only ever grows, and may overshoot end by threads.
endindex_type_One past this slice's last task; written once before the dispatch, then read-only.

See also

invoke_for_n_dynamicWraps the metadata needed for for_n_dynamic APIs for broadcast_join compatibility.
types.hpp:2405 ↗
template <typename pool_type_,
          typename fork_type_,
          typename index_type_>
class invoke_for_n_dynamic

Scheduling Logic

Tasks are split into one contiguous slice per thread. A thread first drains its own slice, then walks the others in a coprime_permutation_range order and drains theirs, one task per claim. Claiming one task at a time is what preserves the makespan guarantee of greedy list scheduling: a thread can never be handed a batch of tasks that turn out to be expensive, because it is never handed a batch. Claiming from a private cursor is what makes that guarantee affordable.

Probing the neighbours in a coprime order rather than linearly keeps two drained threads from descending on the same victim, which would serialize them on one line for no reason.

Overflow Considerations

If we run a default for-loop at 1 Billion times per second on a 64-bit machine, then every 585 years of computational time we will wrap around the std::size_t capacity for the task index. In case we n + thread >= std::size_t(-1), a simple condition won't be enough. Alternatively, we can make sure, that each thread can do at least one increment of a cursor without worrying about the overflow. The way to achieve that is to preprocess the trailing threads of elements externally, before entering this loop!

That trailing reservation also bounds the cursors. Every thread visits a given slice exactly once - each thread's coprime walk is a permutation of all the slices, beginning with its own - and a visit overshoots by at most one increment, since drain_claim leaves the moment it reads >= end and its read-only probe skips already-drained slices without any increment at all. A cursor therefore never passes end + threads.

Two regimes bound that. When n > threads the last slice ends at n - threads, so no cursor passes n. When n <= threads every slice is empty and end == 0, so no cursor passes threads, which may exceed n but must be representable anyway for the pool to have spawned. Either way max(cursor) == max(n, threads), and no index type can wrap.

See also

invoke_distributed_for_n_dynamic, which applies the same trick one level up, across compute domains, so a thread exhausts local work before touching a remote node's memory.

dummy_pool_tA zero-setup thread-pool that runs every task on the calling thread.
types.hpp:2565 ↗
struct dummy_pool_t

A drop-in serial executor - it satisfies is_pool and is_unsafe_pool and offers the same scheduling surface as flat_pool, namely for_threads, for_n, for_n_dynamic and for_slices, but with one thread on one compute domain, no spawn, and no allocation. unsafe_for_threads runs the fork synchronously as thread 0; everything else composes through broadcast_join exactly as the real pools do. Useful as a serial baseline and as the default executor for the domain-aware containers.

broadcasted_noop_tDoes nothing on every thread, the default fork for a broadcast_join needing only a join.
flat.hpp:763 ↗
struct broadcasted_noop_t
log_capabilities_tLogs a capability mask as a two-row tree, CPU bits then OS facilities, by capability_name.
logging.hpp:306 ↗
struct log_capabilities_t
Classdynamic_arrayAn owning, allocator-aware array whose size is fixed once, at resize.7
types.hpp:1525 ↗
template <typename value_type_,
          typename allocator_type_ = std::allocator<value_type_>>
class dynamic_array

See also.

limited_array for bounded counts, dynamic_padded_array when elements want their own line.

See also

limited_array for bounded counts, dynamic_padded_array when elements want their own line.

dynamic_array
types.hpp:1553 ↗
constexpr dynamic_array() noexcept=default;
dynamic_array(allocator_type_ const & allocator) noexcept;
dynamic_array(dynamic_array && other) noexcept;
dynamic_array(dynamic_array const &)=delete;
operator=
types.hpp:1560 ↗
dynamic_array & operator=(dynamic_array && other) noexcept;
dynamic_array & operator=(dynamic_array const &)=delete;
~dynamic_array
types.hpp:1573 ↗
~dynamic_array() noexcept;
resizeReallocates to new_size value-initialized elements, discarding any prior contents.
types.hpp:1589 ↗
status_t resize(std::size_t const new_size) noexcept;

Returns

false on allocation failure, leaving the array empty rather than half-built.

resize_uninitializedLike resize, but skips the zero-fill so the caller controls the first touch.
types.hpp:1612 ↗
status_t resize_uninitialized(std::size_t const new_size) noexcept;

Notes

Trivial value types only - nothing is constructed, so every element must be written before it is read.

See also

reserveGrows capacity to at least new_capacity, preserving the live elements.
types.hpp:1626 ↗
status_t reserve(std::size_t const new_capacity) noexcept;
push_backAppends value, doubling capacity when full.
types.hpp:1647 ↗
status_t push_back(value_t const & value) noexcept;
Classflat_poolMinimalistic STL-based non-resizable thread-pool for simultaneous blocking tasks.44
flat.hpp:128 ↗
template <typename allocator_type_ = portable_aligned_allocator<std::thread>,
          typename micro_yield_type_ = standard_yield_t,
          template<typename> class atomic_ref_template_ = standard_atomic_ref,
          typename cache_hints_type_ = standard_cache_hints_t,
          typename index_type_ = std::size_t,
          std::size_t alignment_ = default_alignment_k>
class flat_pool

This thread-pool can't:

  • dynamically resize: all threads must be stopped and re-initialized to grow/shrink.
  • re-enter: a dispatch from inside its own fork, or beside another caller's, is refused.
  • copy/move: the threads depend on the address of the parent structure.
  • handle exceptions: you must try-catch them yourself and return void.
  • stop early: assuming the user can do it better, knowing the task granularity.
  • overflow: as all APIs are aggressively tested with smaller index types.

This allows this thread-pool to be extremely lightweight and fast, without heap allocations and no expensive abstractions. It only uses std::thread and std::atomic, but avoids std::function, std::future, std::promise, std::condition_variable, that bring unnecessary overhead.

Repeated operations are performed with a "weak" memory model, to leverage in-hardware support for atomic fence-less operations on Arm and IBM Power architectures. Most atomic counters use the "acquire-release" model, and some going further to "relaxed" model.

A minimal example, similar to #pragma omp parallel in OpenMP:

#include <cstdio>         // `std::printf`
#include <cstdlib>        // `EXIT_FAILURE`, `EXIT_SUCCESS`
#include <forkunion.hpp>  // `flat_pool_t`

namespace fu = ashvardanian::forkunion;
int main() {
    fu::flat_pool_t pool; // ? Alias to `fu::flat_pool<>` template
    if (fu::failed(pool.spawn(fu::allowed_cores_count()))) return EXIT_FAILURE;
    pool.for_threads([](std::size_t i) noexcept { std::printf("Hi from thread %zu\n", i); });
    return EXIT_SUCCESS;
}

Unlike OpenMP, however, separate thread-pools can be created isolating work and resources. This is handy when some logic has to be split between "performance" & "efficiency" cores, between different NUMA nodes, between GUI and background tasks, etc. It may look like this:

#include <cstdio>         // `std::printf`
#include <cstdlib>        // `EXIT_FAILURE`, `EXIT_SUCCESS`
#include <forkunion.hpp>  // `flat_pool_t`

using fu = ashvardanian::forkunion;
int main() {
    fu::flat_pool_t first_pool, second_pool;
    if (!first_pool.spawn(2) || !second_pool.spawn(2, fu::caller_exclusive_k)) return EXIT_FAILURE;
    auto broadcast = second_pool.for_threads([](std::size_t i) noexcept { poll_ssd(i); });
    first_pool.for_threads([](std::size_t i) noexcept { poll_nic(i); });
    broadcast.join(); // ! Wait for the second pool to finish
    return EXIT_SUCCESS;
}

Concurrency Model

Three roles interact with a pool:

  • the dispatcher - the one thread whose dispatch the pool admitted: it dispatches, polls, and joins, while any other dispatch, nested or concurrent, is refused with the zero token; that spawn and terminate overlap no dispatch is contractual and not enforced;
  • the contributors - threads executing one slice each per generation: all workers, plus the calling thread itself on caller_inclusive_k pools, whose slice runs inside unsafe_join;
  • the pollers - any threads calling is_complete, which is a read-only probe.

All synchronization is built from three cache-line-aligned atomics and no mutexes: one compare-and-swap per dispatch, one fetch_sub per contributor, and plain loads and stores:

  • epoch_ - the generation clock: odd while a fork is in flight, even when idle; stepped once by the dispatcher on dispatch and once by the last contributor on completion, so every generation advances it by exactly two, each step a store by the clock's only writer;
  • threads_to_sync_ - the countdown identifying the last contributor - the only thread allowed to make the completion step; zero exactly when no contributor runs, so a dispatch claims the pool by exchanging it from zero;
  • mood_ - the lifecycle switch between spinning, sleeping, and exiting workers.

Four synchronization edges keep the non-atomic fork state safe:

  1. claim: the dispatcher exchanges the countdown from zero with an acquire, so the fork state it writes next follows every read of the previous one, then waits out the previous completion step, so a dispatch never races it and generation tokens are always odd;
  2. publish: the dispatcher releases the dispatch step; contributors acquire it and see the fork state and the countdown;
  3. completion chain: every contributor decrements the countdown with acq_rel, chaining each contributor's writes into the last one;
  4. completion edge: the last contributor releases the completion step, so any acquire-load observing it sees all contributors' results - is_complete included.

On caller_inclusive_k pools the calling thread owes a slice that only runs inside unsafe_join, so is_complete stays false until then: the poll-then-join pattern is reserved for caller_exclusive_k pools.

See also

allocator_tAllocator the pool rebinds to its worker cells - the pool's one allocation.
flat.hpp:132 ↗
using allocator_t = allocator_type_;
micro_yield_tFunctor spinning threads while they wait on an atomic.
flat.hpp:135 ↗
using micro_yield_t = micro_yield_type_;
atomic_refReference reading and writing the words the pool synchronizes through.
flat.hpp:139 ↗
template <typename value_type_>
using atomic_ref = atomic_ref_template_<value_type_>;
cache_hints_tFunctor demoting and promoting individual cache lines.
flat.hpp:142 ↗
using cache_hints_t = cache_hints_type_;
index_tUnsigned counter width; narrow it below std::size_t only to debug wrap-around.
flat.hpp:152 ↗
using index_t = index_type_;
epoch_index_tNumber of previous API calls, in [0, UINT_MAX).
flat.hpp:160 ↗
using epoch_index_t = index_t;

Notes

At the fu8_t and fu16_t debug widths a worker stalled across exactly 2^bits epochs would alias its last_epoch - astronomically unlikely at size_t.

generation_tToken returned from unsafe_for_threads; always odd, or zero for a refused dispatch.
flat.hpp:163 ↗
using generation_t = epoch_index_t;
thread_index_tCore index, or thread ID, in [0, threads_count).
flat.hpp:166 ↗
using thread_index_t = index_t;
compute_domain_index_tDense index in [0, compute_domains_count).
flat.hpp:169 ↗
using compute_domain_index_t = index_t;
indexed_split_tHalf-open range of tasks handed to one thread.
flat.hpp:172 ↗
using indexed_split_t = indexed_split<index_t>;
thread_in_domain_tOne thread paired with the compute domain it runs on.
flat.hpp:175 ↗
using thread_in_domain_t = thread_in_domain<index_t>;
claim_tOne private cursor per thread.
flat.hpp:178 ↗
using claim_t = dynamic_claim<index_t>;
worker_cell_allocator_tThe pool's allocator rebound to the padded per-thread cells.
flat.hpp:205 ↗
using worker_cell_allocator_t = typename std::allocator_traits<allocator_t>::template rebind_alloc<worker_cell_t>;
worker_cells_tThe pool's single buffer: one worker cell per alignment_k stride.
flat.hpp:208 ↗
using worker_cells_t = dynamic_padded_array<worker_cell_t, worker_cell_allocator_t>;
punned_fork_context_tPointer to the on-stack fork lambda.
flat.hpp:211 ↗
using punned_fork_context_t = void *;
trampoline_tWraps the lambda's operator().
flat.hpp:214 ↗
using trampoline_t = void(*)(punned_fork_context_t, thread_index_t);
kind_kTags this pool as the domain-agnostic shape.
flat.hpp:145 ↗
pool_kind_t kind_k;
alignment_kAlignment isolating the pool's atomics onto their own cache lines.
flat.hpp:148 ↗
std::size_t alignment_k;
flat_pool
flat.hpp:260 ↗
flat_pool(flat_pool &&)=delete;
flat_pool(flat_pool const &)=delete;
flat_pool(allocator_t const & alloc = {}) noexcept;
operator=
flat.hpp:262 ↗
flat_pool & operator=(flat_pool &&)=delete;
flat_pool & operator=(flat_pool const &)=delete;
~flat_pool
flat.hpp:266 ↗
~flat_pool() noexcept;
memory_usageEstimates the amount of memory managed by this pool handle and internal structures.
flat.hpp:272 ↗
std::size_t memory_usage() const noexcept;

Notes

This API is not synchronized.

is_lock_freeChecks if the thread-pool's core synchronization points are lock-free.
flat.hpp:275 ↗
bool is_lock_free() const noexcept;
memory_domain_idReturns the memory domain this thread-pool is pinned to.
flat.hpp:283 ↗
memory_domain_id_t memory_domain_id() const noexcept;

Returns

-1, since this pool is not memory-domain-aware.

first_threadReturns the first thread index in the thread-pool.
flat.hpp:289 ↗
thread_index_t first_thread() const noexcept;
thread_index_t first_thread(
    index_t compute_domain_index) const noexcept;

Returns the first global thread index of a compute_domain.

Returns

0, since this pool is not built for compute-domain or distributed topologies.

unsafe_dynamic_claim_refExposes a thread's private claim cursor.
flat.hpp:292 ↗
claim_t & unsafe_dynamic_claim_ref(
    thread_index_t const thread) noexcept;

Use with caution.

threads_countReturns the number of threads in the thread-pool, including the main thread.
flat.hpp:301 ↗
thread_index_t threads_count() const noexcept;
thread_index_t threads_count(
    index_t compute_domain_index) const noexcept;

Returns the number of threads in one NUMA-specific local compute_domain.

Returns

The thread count: 0 if uninitialized, 1 if only the main thread is used.

Notes

  • This API is not synchronized.
  • Shape parity with distributed_pool: generic callers - the C ABI's visit and the distributed invokers - call pool.threads_count(domain) on every pool kind.
caller_exclusivityReports if the current calling thread will be used for broadcasts.
flat.hpp:307 ↗
caller_exclusivity_t caller_exclusivity() const noexcept;

Notes

This API is not synchronized.

spawnCreates a thread-pool with the given number of threads.
flat.hpp:320 ↗
status_t spawn(
    thread_index_t const threads,
    caller_exclusivity_t const exclusivity = caller_inclusive_k) noexcept;

Parameters

threadsinThe number of threads to be used.
exclusivityinShould we count the calling thread as one of the threads?

Notes

This is the de-facto constructor - you only call it again after terminate.

Statuses

for_threadsExecutes a fork function in parallel on all threads.
flat.hpp:396 ↗
template <is_thread_callback<index_t> fork_type_>
broadcast_join<flat_pool, fork_type_> for_threads(
    fork_type_ && fork) noexcept;

Parameters

forkinThe callback object, receiving the thread index as an argument.

Returns

broadcast_join synchronization point that waits in the destructor.

Notes

Even in the caller_exclusive_k mode, can be called from just one thread!

See also

For advanced resource management, consider unsafe_for_threads and unsafe_join.

terminateStops all threads and deallocates the thread-pool after the last call finishes.
flat.hpp:416 ↗
void terminate() noexcept;

When and how not to use this function:

  • as a synchronization point between concurrent tasks.

When and how to use this function:

  • as a de-facto destructor, to stop all threads and deallocate the pool.
  • when you want to restart with a different number of threads.

Notes

  • Can be called from any thread, after the last dispatch was joined.
  • Must spawn again to re-use the pool.
napParks "workers" on the waiter's monitor until the next dispatch or terminate.
flat.hpp:450 ↗
void nap() noexcept;

Wakes without a syscall. Waiters with no monitor keep spinning on their hint.

Notes

Callable only between tasks on a single thread; no synchronization runs.

See also

sleep for long idle periods, where the kernel takes the core back.

sleepBlocks "workers" in the kernel until the next dispatch or terminate.
flat.hpp:470 ↗
void sleep(std::size_t const wake_up_periodicity_micros = 0) noexcept;

Useful in batch processing when we clearly know the next task won't arrive for a while, so power can be saved at the price of syscalls in that dispatch. Falls back to nap where the kernel can't wait on a word.

It may also be used in a high-level Python or JavaScript library offloading some parallel operations to an underlying C++ engine, where latency is irrelevant.

Parameters

wake_up_periodicity_microsinLongest a worker stays blocked before re-checking, or 0 to leave the wake to the dispatch alone.

Notes

Callable only between tasks on a single thread; no synchronization runs.

for_slicesDistributes n similar-duration calls between threads in slices, not per index.
flat.hpp:491 ↗
template <is_slice_callback<index_t> fork_type_ = dummy_lambda_t>
broadcast_join<flat_pool, invoke_for_slices<fork_type_, index_t>> for_slices(
    index_t const n,
    fork_type_ && fork) noexcept;

Parameters

ninThe total length of the range to split between threads.
forkinThe callback, receiving a tasks_range_t and a thread_in_domain_t.
for_nDistributes n similar duration calls between threads.
flat.hpp:509 ↗
template <is_task_callback<index_t> fork_type_ = dummy_lambda_t>
broadcast_join<flat_pool, invoke_for_n<fork_type_, index_t>> for_n(
    index_t const n,
    fork_type_ && fork) noexcept;

Designed for a "balanced" workload where all threads do roughly equal work, and fork runs n times, with each thread receiving a slice of consecutive tasks.

Parameters

ninThe number of times to call the fork.
forkinThe callback object, receiving a task index and a thread_in_domain_t.

See also

  • for_n_dynamic for a more dynamic workload.
  • for_slices if you prefer to receive workload slices over individual indices.
for_n_dynamicExecutes uneven tasks on all threads, greedying for work.
flat.hpp:522 ↗
template <is_task_callback<index_t> fork_type_ = dummy_lambda_t>
broadcast_join<flat_pool, invoke_for_n_dynamic<flat_pool, fork_type_, index_t>> for_n_dynamic(
    index_t const n,
    fork_type_ && fork) noexcept;

Parameters

ninThe number of times to call the fork.
forkinThe callback object, receiving a task index and a thread_in_domain_t.

See also

for_n for a more "balanced" evenly-splittable workload.

unsafe_for_threadsExecutes a fork function in parallel on all threads, not waiting for the result.
flat.hpp:539 ↗
template <is_thread_callback<index_t> fork_type_>
generation_t unsafe_for_threads(fork_type_ & fork) noexcept;

Parameters

forkinThe callback reference, receiving the thread index as an argument.

Returns

A generation_t token identifying this dispatch, or the zero token when the pool refused it: another dispatch runs, another caller's or the one this call runs inside.

See also

Use in conjunction with unsafe_join.

is_completeReturns true if the generation identified by generation has completed.
flat.hpp:583 ↗
bool is_complete(generation_t generation) const noexcept;

On caller_inclusive_k pools this only turns true once unsafe_join runs the caller's slice, so poll-then-join applies only to caller_exclusive_k pools.

Notes

A true result synchronizes with all contributors: their writes are visible.

unsafe_joinBlocks the calling thread until the generation identified by generation finishes.
flat.hpp:593 ↗
void unsafe_join(generation_t generation) noexcept;
void unsafe_join() noexcept;

Blocks the calling thread until the currently broadcasted task finishes.

Idempotent, returning immediately for already-joined or stale generations.

Notes

On caller_inclusive_k pools, first executes the calling thread's slice.

compute_domains_countNumber of individual sub-pools with the same NUMA-locality and QoS.
flat.hpp:634 ↗
index_t compute_domains_count() const noexcept;

Returns

1, as this pool spans a single compute domain.

compute_domain_index_ofThe topology's compute domain behind our only one, so the identity.
flat.hpp:637 ↗
index_t compute_domain_index_of(
    index_t compute_domain_index) const noexcept;
thread_local_indexConverts a global_thread_index to a local thread index within a compute_domain.
flat.hpp:665 ↗
thread_index_t thread_local_index(
    thread_index_t global_thread_index,
    index_t compute_domain_index) const noexcept;

Returns

Same value as global_thread_index, as we only support one compute_domain.

make_mutexHelper function to create a spin mutex with same yield characteristics.
flat.hpp:478 ↗
spin_mutex<micro_yield_t, alignment_k> make_mutex() noexcept;
worker_cell_tEverything the pool keeps per thread, on a cache line of its own.
flat.hpp:195 ↗
struct worker_cell_t

The claim cursor must not share a line with anything, or the dynamic scheduler reintroduces the very coherence traffic that giving each thread a private cursor exists to remove. Rather than allocate a second array beside std::thread, both live in one padded cell, so the pool still performs exactly one allocation - in spawn, never on a dispatch path.

Cells are indexed by thread index, so on inclusive pools cell 0 belongs to the caller and holds no std::thread, costing a cell but giving claim and worker one index.

Fields

claimclaim_tThis thread's private dynamic_claim cursor for for_n_dynamic.
workerstd::threadThe worker thread; default-constructed, and left so for the caller's own cell.

Notes

Separation comes from the buffer's stride, not from an alignas on this type. A std::allocator only promises __STDCPP_DEFAULT_NEW_ALIGNMENT__, so over-aligning the cell would placement-new it into storage that cannot satisfy the request.

Structbroadcast_joinA synchronization point that waits for all threads to finish the last fork.6
types.hpp:2494 ↗
template <typename pool_type_,
          typename fork_type_>
struct broadcast_join

The fork is dispatched at construction, so the workers start immediately and the caller may overlap its own work. The join call, or the destructor, waits for completion, and on caller_inclusive_k pools first runs the calling thread's own slice, so only caller_exclusive_k pools may poll is_complete before it.

The pool admits one dispatch at a time, so it refuses this one while another runs: another caller's, or the one whose fork this broadcast is created in. A refused broadcast runs nothing, joins nothing, keeps generation at zero, and is never retried, not even by the destructor.

You don't have to explicitly handle the return value and wait on it. According to the C++ standard, the destructor of the broadcast_join will be called in the end of the for_threads-calling expression.

The object is immovable: the pool holds a pointer to the fork_ member for the lifetime of the broadcast, so the object must never relocate. Guaranteed C++17 copy elision still allows returning it by value from for_threads.

Notes

You don't have to explicitly call any of the APIs, it's like std::jthread ;)

broadcast_join
types.hpp:2511 ↗
broadcast_join(pool_t & pool_ref, fork_t && f) noexcept;
broadcast_join(broadcast_join &&)=delete;
broadcast_join(broadcast_join const &)=delete;
fork_refThe wrapped fork; only read it after join.
types.hpp:2515 ↗
fork_t & fork_ref() noexcept;
generationThe generation token of this broadcast; odd once dispatched, zero once refused.
types.hpp:2518 ↗
generation_t generation() const noexcept;
is_completeNon-blocking check; can only turn true before join on caller-exclusive pools.
types.hpp:2521 ↗
bool is_complete() const noexcept;
~broadcast_join
types.hpp:2527 ↗
~broadcast_join() noexcept;
operator=
types.hpp:2530 ↗
broadcast_join & operator=(broadcast_join &&)=delete;
broadcast_join & operator=(broadcast_join const &)=delete;

Functions

succeededWhether status reports success.
types.hpp:784 ↗
bool succeeded(status_t status) noexcept;
failedWhether status reports failure.
types.hpp:787 ↗
bool failed(status_t status) noexcept;
status_to_stringStatic, English description of status.
types.hpp:794 ↗
char const * status_to_string(status_t status) noexcept;

Never returns nullptr.

Notes

The fall-through sits after the switch rather than in a default: label, so adding an enumerator without a text here is a -Wswitch warning instead of silent prose.

capability_nameLower-case name of one capability bit, or nullptr for composite and unknown ones.
types.hpp:1121 ↗
char const * capability_name(capabilities_t const capability) noexcept;

Notes

The one source of truth for capability names: the string builders and the C ABI both defer here.

See also

capability_named, its inverse.

capability_namedThe single capability bit named name, or capabilities_unknown_k if none matches.
types.hpp:1158 ↗
capabilities_t capability_named(char const *name) noexcept;

Notes

The inverse of capability_name; it defers to that one table, so the two cannot drift.

bytes_for_elementsBytes occupied by count elements of element_bytes each.
types.hpp:1204 ↗
std::size_t bytes_for_elements(
    std::size_t count,
    std::size_t element_bytes) noexcept;

Returns

0 where the product would wrap, which every caller already refuses alongside a zero-element request - so the guard and the arithmetic cannot drift apart.

copy_bytesByte-wise memcpy of from into to, written as an explicit loop.
types.hpp:1218 ↗
template <typename value_type_>
void copy_bytes(value_type_ const *from, value_type_ *to) noexcept;

Not std::memcpy: the wait monitors that call this - arm64_wfet_t and riscv64_wrs_t - are pinned to a narrower target(...) than -march=native, where the fortified always_inline memcpy cannot inline - "target specific option mismatch". Reading and writing through unsigned char keeps it well-defined.

min_ofThe smaller of first and second, without <algorithm> or the windows.h min macro.
types.hpp:1228 ↗
template <typename value_type_>
value_type_ min_of(value_type_ first, value_type_ second) noexcept;
max_ofThe larger of first and second, the max twin of min_of.
types.hpp:1234 ↗
template <typename value_type_>
value_type_ max_of(value_type_ first, value_type_ second) noexcept;
div_ceilCeiling of value divided by divisor - how many buckets of that size a value needs.
types.hpp:1242 ↗
std::size_t div_ceil(std::size_t value, std::size_t divisor) noexcept;

Notes

divisor must be non-zero; overflow of value near the type maximum is not guarded.

round_up_to_multipleSmallest multiple of multiple that is not less than value.
types.hpp:1250 ↗
std::size_t round_up_to_multiple(
    std::size_t value,
    std::size_t multiple) noexcept;

Notes

multiple must be non-zero; overflow of value near the type maximum is not guarded.

round_up_to_pow2Smallest power of two that is not less than value; one for zero.
types.hpp:1255 ↗
std::size_t round_up_to_pow2(std::size_t value) noexcept;
split_mixThe SplitMix64 avalanche - a well-mixed pure function of the counter.
types.hpp:1268 ↗
std::uint64_t split_mix(std::uint64_t const counter) noexcept;

A counter-based generator instead of a stateful one: every draw is independent, so parallel consumers need no shared state, and a sequence is reproducible from indices alone. The same constants drive the benchmark generators in bench/, ported bit-identically to Rust and Zig.

dense_rankDense-ranks count items by an ascending integer key, writing each item's 0-based rank.
types.hpp:1292 ↗
template <typename key_type_, typename assign_type_>
std::size_t dense_rank(
    std::size_t count,
    key_type_ const & key,
    assign_type_ const & assign) noexcept;

key(index) must read a stable source and assign(index, rank) write a different field, so ranking in place never corrupts a not-yet-ranked item whose key repeats. Used to turn raw CPU capacities into compute levels and raw memory tiers into memory levels.

Returns

The number of distinct keys, at least 1 when count > 0.

drain_claimDrains whatever is left of one thread's slice into fork - the shared core of both the flat and the distributed for_n_dynamic invokers, so its invariants live in one place.
types.hpp:2345 ↗
template <typename index_type_, typename locator_type_, typename fork_type_, typename cache_hints_type_>
void drain_claim(
    dynamic_claim<index_type_> & claim,
    locator_type_ const & at,
    fork_type_ & fork,
    cache_hints_type_ cache_hints) noexcept;
template <typename pool_type_, typename index_type_, typename locator_type_, typename fork_type_>
void drain_claim(
    pool_type_ & pool,
    index_type_ const slice,
    locator_type_ const & at,
    fork_type_ & fork) noexcept;

Drains what is left of the slice thread's claim in pool, whether or not we own it.

A read-only probe first: a drained slice is skipped in Shared state - no dirtying add, no line migration - which is the entire cost of visiting an empty neighbour once a small dispatch runs dry. A live slice is promoted with write intent while the probe's branch resolves, claimed one task at a time - the makespan guarantee of greedy list scheduling - and demoted once our overshooting add has dirtied it, so the next visitor snoops the LLC instead of this core.

See also

The dynamic_claim overload above, where the probe and the overshoot invariants live.

cpu_capabilitiesThe instruction-level bits this CPU and its kernel offer: the spin hint, the monitored wait, the cache-line hint and the atomics of the ISA in hand.
capabilities.hpp:970 ↗
capabilities_t cpu_capabilities() noexcept;

See also

ram_capabilities for the memory side; together they form runtime_capabilities.

ram_capabilitiesThe memory-placement facilities this machine offers - NUMA and huge/large pages.
capabilities.hpp:1023 ↗
capabilities_t ram_capabilities() noexcept;

See also

cpu_capabilities for the busy-wait side; together they form runtime_capabilities.

runtime_capabilitiesWhich features this machine turned out to offer, probing the CPU and the memory system.
capabilities.hpp:1084 ↗
capabilities_t runtime_capabilities() noexcept;

See also

comptime_capabilities for what this build is able to ask for in the first place.

comptime_capabilitiesWhich kernel facilities this unit was built to use, one bit per FORKUNION_WITH_*.
capabilities.hpp:1099 ↗
capabilities_t comptime_capabilities() noexcept;

Consult it before reaching for a domain-aware API. Without capability_colocate_pools_on_domain_k there is no colocated_pool to spawn and no linux_numa_allocator to construct, and a caller has no other way to tell that apart from a machine that merely has one compute domain.

These are the same facility bits runtime_capabilities reports, asked the other way: this says the code was compiled, that says the machine offers it, and a facility is usable only where both agree.

See also

runtime_capabilities for what the machine underneath turned out to offer.

Constants

default_alignment_kDefines variable alignment to avoid false sharing.
types.hpp:1195 ↗
std::size_t default_alignment_k;

The C++ STL way to do it is to use std::hardware_destructive_interference_size if available:

#if defined(__cpp_lib_hardware_interference_size)
static constexpr std::size_t default_alignment_k = std::hardware_destructive_interference_size;
#else
static constexpr std::size_t default_alignment_k = alignof(std::max_align_t);
#endif

That however results into all kinds of ABI warnings with GCC, and suboptimal alignment choice, unless you hard-code –param hardware_destructive_interference_size=64 or disable the warning with -Wno-interference-size.

So the width is FORKUNION_DEFAULT_ALIGNMENT, picked from the target. Only alignas rests on this guess; a per-thread stride takes destructive_interference_bytes(), which asks the machine.

See also

is_dynamic_invokerWhether fork_type_ is one of our for_n_dynamic invokers, whose cursors the pool rewinds right after admitting the dispatch.
types.hpp:2467 ↗
bool is_dynamic_invoker;

The cursors live in the pool, shared by every dispatch, so a refused one must not touch them. Matched by type rather than by a member name, so no user fork is ever asked to rewind.

TypesMember types4
dynamic_array::value_typevalue_t
broadcast_join::pool_tpool_type_
broadcast_join::fork_tfork_type_
broadcast_join::generation_ttypename pool_t::generation_t