Placing memory on a chosen domain is implemented on Linux via the mbind syscall, on FreeBSD via domainset policies, and on Windows via VirtualAllocExNuma, and degrades to a single domain elsewhere.
The portable domain_allocator_t alias picks the right backend per platform - linux_numa_allocator_t, freebsd_numa_allocator_t, windows_numa_allocator_t, or a plain aligned fallback - each an STL-compatible allocator bound to one memory_domain_id_t.
Let’s say you are building a Big Data application — brute-forcing Vector Search with the NumKong library — on a 2 dual-socket CPU system, the kind that powers production USearch deployments.
The first part of that program may be responsible for sharding the incoming stream of data between distinct memory regions.
That part, in our simple example will be single-threaded:
The concurrent part would involve spawning threads adjacent to every memory pool to find the best search_result_t.
The primary search function, in ideal world would look like this:
Each thread finds the best match within its “slice” of a compute domain, tracking the best distance and index in a local CPU register.
All threads in each compute domain atomically synchronize using a domain-local instance of search_result_t.
The main thread collects aggregates of partial results from all compute domains.
That is, however, overly complicated to implement.
Such tree-like hierarchical reductions are optimal in a theoretical sense.
Still, assuming the relative cost of spin-locking once at the end of a thread scope and the complexity of organizing the code, the more straightforward path is better.
A minimal example would look like this:
/// On each compute domain we'll synchronize the threadsstruct search_result_t { nk_f64_t best_distance {std::numeric_limits<nk_f64_t>::max()}; std::size_t best_index {0};};inline search_result_t pick_best(search_result_t const& a, search_result_t const& b) noexcept { return a.best_distance < b.best_distance ? a : b;}/// Uses all CPU threads to search for the closest vector to the @p query.search_result_t search(std::span<float, dimensions> query) { bool const need_to_spawn_threads = distributed_pool.threads_count() == 0; if (need_to_spawn_threads) { fu::status_t const harvested = machine_topology.harvest(); assert(fu::succeeded(harvested) && "Failed to harvest NUMA topology"); assert(machine_topology.memory_domains_count() == 2 && "Expected exactly 2 NUMA nodes"); fu::status_t const spawned = distributed_pool.spawn(machine_topology); assert(fu::succeeded(spawned) && "Failed to spawn NUMA pools"); } search_result_t result; fu::spin_mutex_t result_update; // ? Lighter `std::mutex` alternative w/out futexes std::size_t const first_vectors = first_half.size() / dimensions; std::size_t const total_vectors = first_vectors + second_half.size() / dimensions; auto slices = distributed_pool.for_slices(total_vectors, [&](fu::tasks_range_t tasks, fu::thread_in_domain_t) noexcept { search_result_t thread_local_result; for (std::size_t const task : tasks) { bool const in_second = task >= first_vectors; auto const &shard = in_second ? second_half : first_half; std::size_t const local_index = in_second ? task - first_vectors : task; nk_f64_t distance; nk_angular_f32(query.data(), shard.data() + local_index * dimensions, dimensions, &distance); thread_local_result = pick_best(thread_local_result, {distance, task}); } // ! Still synchronizing over a shared mutex for brevity. std::lock_guard<fu::spin_mutex_t> lock(result_update); result = pick_best(result, thread_local_result); }); slices.join(); return result;}
In a dream world, we would call distributed_pool.for_n, but there is no clean way to make the scheduling processes aware of the data distribution in an arbitrary application, so that’s left to the user.
The for_slices helper pairs each run with a fu::thread_in_domain_t naming its compute domain, but slices follow the thread count rather than the shard boundary, so the callback above picks the shard per task.
For more flexibility around building higher-level low-latency systems, there are unsafe APIs expecting you to manually “join” the broadcasted calls: unsafe_for_threads returns a generation token, is_complete polls it without blocking, and unsafe_join blocks until that generation completes.
The manual two-vector sharding above is what the symmetric allocators automate: symmetric_memory_allocator_t - fu_allocate_symmetric in C - maps one virtual range striped a slice per memory domain, and Rust, Zig, and Mojo wrap it as ShardedArray<T>, one shard per domain, and ReplicatedArray<T>, a full copy per domain for read-mostly data.
To place and coordinate pools by hand, spawn_on - fu_pool_spawn_on in C - pins a pool to a single compute domain, while locate_thread_in and threads_count_in map a global thread index to its domain and count the workers living there.
Slice d begins at the byte address ptr + d * stride_bytes - see slice - and holds count elements. Every slice is a uniform distance stride_bytes apart - the CPU analog of a GPU symmetric heap. The stride is in bytes, not elements, because it is page-aligned and a page rarely divides sizeof(value_type).
Fields
ptr
pointer_type
Base of the mapping, or nullptr on failure.
count
size_type
Usable elements per domain slice - what was requested.
A sibling of linux_numa_allocator, but instead of a block on a single node it returns a symmetric_allocation_result carrying the base pointer and the per-slice stride_bytes, so slice d of element i lives at ptr + d * stride_bytes + i * sizeof(value_type). It borrows the machine_topology it stripes across; hand it the one you harvested and let it outlive the whole mapping.
freebsd_numa_allocatorSTL-compatible allocator bound to a single memory domain on FreeBSD.
Mirrors linux_numa_allocator's huge-page ladder, but every placement is a thread-policy PREFER plus a first-touch rather than a per-mapping mbind, and larger pages are the MAP_ALIGNED_SUPER hint.
See also
linux_numa_allocator is the Linux counterpart; both satisfy the pool's allocator needs.
freebsd_symmetric_allocatorAllocator for a symmetric mapping on FreeBSD: one range striped across every domain.
Deliberately plainer than the Linux allocator: it exposes only allocate and deallocate, so dynamic_padded_array takes its ordinary allocate(total) path rather than the sized allocate_at_least one. Large pages are opt-in per allocator instance through the large_pages ctor flag; they need SeLockMemoryPrivilege first, see
See also
linux_numa_allocator is the Linux counterpart; both satisfy the pool's allocator needs.
windows_enable_lock_memory_privilege, and round every request up to GetLargePageMinimum(). The pool constructs the allocator without them, since its own state is small; a caller wanting large pages for bulk data opts in explicitly.
windows_symmetric_allocatorAllocator for a symmetric mapping on Windows - one reservation committed per node.
Reserves a range and commits each equal-stride slice on its own node via VirtualAllocExNuma. Slice d of element i then lives at ptr + d × stride_bytes + i × sizeof(value_type). Base pages only - large pages cannot be reserved then committed in slices.
Borrows the machine_topology it stripes across, which the caller must keep alive.
Mirrors linux_numa_allocator's interface so domain_allocator_t stays uniform: it ignores the memory domain and reports the base page size, letting the domain-aware code paths compile and run everywhere while degenerating to plain heap allocation. Every block is aligned to at least default_alignment_k, so the NUMA backends' page alignment and this fallback offer one alignment contract - over-aligned element types need no caller-side padding on any platform.
portable_symmetric_allocatorA heap-backed stand-in for the symmetric allocators, where domains cannot be bound.
Mirrors their allocate_at_least(size) → symmetric_allocation_result shape so the containers stay uniform, but the one contiguous block is merely default_alignment_k-aligned - no mbind, no VirtualAllocExNuma. The slice count still comes from the machine_topology, so on a machine the platform reports as single-domain the mapping is one stride wide and the same addressing ptr + d * stride_bytes + i * sizeof(value_type) holds everywhere. Borrows the topology; hand it the one you harvested and let it outlive the mapping.
A light-weight, but high-latency BLOB allocator, tied to a specific NUMA node ID: every allocation is one mmap plus mbind syscall, aligned to at least a 4 KB page size.
C++ 23 Functionality
Whenever possible, the newer allocate_at_least API reduces the number of reallocations.
For read-shared data - graph adjacency, body positions. The logical length is n; the footprint is n * memory_domains_count(). It owns memory and hands out slices - the caller fills every replica and keeps them coherent. Backed by one symmetric mapping, so replica d of element i lives at base() + d * stride_bytes() + i * sizeof(value_type).
For datasets too large to replicate. The logical length is n; the footprint is n. Each domain owns a contiguous logical segment of segment() == ceil(n / memory_domains_count()) elements, so element i lives at {i / segment(), i % segment()} - see location_of - and a sequential scan of a domain's shard is sequential in memory. It owns memory and hands out slices; the caller fills them. Backed by one symmetric mapping whose uniform stride is that segment, so a shorter trailing shard leaves slots unused.
Unlike linux_numa_allocate, which places a block on a single node, this reserves one contiguous virtual range and mbinds each equal-stride slice to its own node - a symmetric layout where the MMU then serves every slice from its local memory.
Returns
nullptr if the mapping or any slice binding failed, or the page size is unsupported.
freebsd_domain_restoreRestores the calling thread's memory-domain policy saved by freebsd_domain_prefer.
bool freebsd_domain_prefer(
memory_domain_id_t memory_domain_id,
void *saved_set,
int *saved_policy) noexcept;
FreeBSD 12 removed numa_setaffinity, so there is no per-allocation numa_alloc_onnode: the memory domain is a thread policy. The caller sets it, first-touches the region so its pages fault on the preferred domain, then restores the saved policy - the domainset analogue of Linux's per-mapping mbind.
Returns
true when the prior domainset landed in saved_set / saved_policy for restore.
freebsd_domain_allocateTries allocating and placing uninitialized memory on a specific NUMA memory_domain_id.
Larger-than-base page sizes ask for MAP_ALIGNED_SUPER - an alignment hint, since FreeBSD's superpages are transparent and have no MAP_HUGETLB-style pool. It is best-effort: a failed super-aligned map retries with base pages so the allocator stays total, matching the Linux nullptr-on-unsupported contract.
Returns
A pointer to the allocated memory, or nullptr when the allocation failed or the page size stays unsupported.
See also
linux_numa_allocate is the Linux counterpart; FreeBSD places by thread policy, not mbind.
freebsd_symmetric_allocateMaps domains * stride_bytes and PREFER-touches slice d on domain_ids[d].
The account must first be granted "Lock pages in memory" - Local Security Policy / SeLockMemoryPrivilege - typically by an admin. Call once at start-up, then construct a windows_numa_allocator with large_pages = true.
Returns
true if the privilege is now held by the process token.
Notes
This only enables a privilege the account already holds.
windows_numa_allocateAllocates uninitialized memory placed on a specific NUMA memory_domain_id on Windows.
VirtualAllocExNuma reserves and commits a range whose pages the kernel will fault in on the requested node - the Windows analogue of Linux's mbind, folded into the allocation call. Large pages need SeLockMemoryPrivilege, which windows_enable_lock_memory_privilege grants, and can still fail under memory fragmentation; a caller wanting a soft failure should retry with large_pages false.
Parameters
size_bytes
in
Number of bytes to allocate.
memory_domain_id
in
The NUMA node to place the memory on.
large_pages
in
Request MEM_LARGE_PAGES; the size is rounded up to GetLargePageMinimum().
Returns
nullptr if allocation failed, the size is zero, or NUMA memory is unavailable.
windows_symmetric_allocateReserves one range of domains * stride_bytes and commits slice d on its own node.
The Windows analogue of linux_symmetric_allocate: one VirtualAlloc(MEM_RESERVE) for the whole range, then a per-slice VirtualAllocExNuma(MEM_COMMIT, node), so each slice's pages fault in on its own node. Base pages only - large pages cannot be reserved and committed across two separate mapping steps.
Returns
nullptr if the reservation or any slice commit failed.