Mojo · NUMA

NUMA

from forkunion import AllocationResult, DomainAllocator, SymmetricAllocation, … 4.0.0-rc.1 · d927445f

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:

#include <vector> // `std::vector`
#include <span> // `std::span`
#include <forkunion.hpp> // `linux_numa_allocator`, `machine_topology_t`, `distributed_pool_t`
#include <numkong/spatial.h> // `nk_angular_f32`, `nk_f64_t`

namespace fu = ashvardanian::forkunion;
using floats_alloc_t = fu::linux_numa_allocator<float>;

constexpr std::size_t dimensions = 768; /// Matches most BERT-like models
static std::vector<float, floats_alloc_t> first_half(floats_alloc_t(fu::memory_domain_id_t {0}));
static std::vector<float, floats_alloc_t> second_half(floats_alloc_t(fu::memory_domain_id_t {1}));
static fu::machine_topology_t machine_topology;
static fu::distributed_pool_t distributed_pool;

/// Dynamically shards incoming vectors across 2 nodes in a round-robin fashion.
void append(std::span<float, dimensions> vector) {
    bool put_in_second = first_half.size() > second_half.size();
    if (put_in_second) second_half.insert(second_half.end(), vector.begin(), vector.end());
    else first_half.insert(first_half.end(), vector.begin(), vector.end());
}

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:

  1. Each thread finds the best match within its “slice” of a compute domain, tracking the best distance and index in a local CPU register.
  2. All threads in each compute domain atomically synchronize using a domain-local instance of search_result_t.
  3. 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 threads
struct 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.

API

StructAllocationResultPages placed on one memory domain, released when the last reference goes out of use.4
struct AllocationResult

Fields

libraryLibraryThe loader the release symbol comes from, kept alive for exactly as long as the pages.
memory_domain_idMemoryDomainIdThe domain the pages sit on, replayed on release because the C free takes it too.
pointerBytesThe base of the placed pages, aligned to at least a cache line.
allocated_bytesIntWhat was actually placed, which the release has to repeat exactly.
bytes_per_pageIntThe page size behind the pages, or zero where the entry point reported none.

Conforms to

Deinitable, Movable
__init__Takes ownership of pages a placement call has already returned.
def __init__(
    out self,
    library: Library,
    memory_domain_id: MemoryDomainId,
    address: Int,
    allocated_bytes: Int,
    bytes_per_page: Int
)

Parameters

libraryLibraryThe loader to release through, which the result then keeps alive.
memory_domain_idMemoryDomainIdThe domain the pages were placed on, needed again to free them.
addressIntThe address the C side wrote out, not yet a typed pointer.
allocated_bytesIntWhat the placement really returned, which may exceed the request.
bytes_per_pageIntThe page size behind the pages, or zero where the call reported none.
__deinit__Hands the pages back to the domain they came from, at the byte count they were placed at.
def __deinit__(deinit self)
as_pointerThe same storage, seen as the element type the caller means to put in it.
def as_pointer[dtype: DType](mut self) -> Pointer[Scalar[dtype], origin_of(self)]

The returned pointer carries this allocation's origin, so the compiler keeps the pages alive for as long as the pointer is reachable. Without that, Mojo would release the allocation after its last named use - which is this call - and the pointer would dangle before the caller ever wrote through it.

Parameters

dtypeDTypecomptimeThe element type to read the untyped pages as; nothing is converted.

Returns

A pointer borrowing this allocation, so the pages cannot be released beneath it.
count_ofHow many elements of dtype the placed pages hold.
def count_of[dtype: DType](self) -> Int

Parameters

dtypeDTypecomptimeThe element type whose width divides the placed bytes.

Returns

The count the pages fit, above what was asked for whenever page rounding rounded up.
StructDomainAllocatorPlaces pages on one memory domain, named by the operating system's id for it.6
struct DomainAllocator

Holds the id and a loader reference and nothing else, so it is cheap to copy and free of the topology that produced the id.

Fields

libraryLibraryThe loader the placement and release symbols come from.
memory_domain_idMemoryDomainIdThe operating system's own id for the domain, never a dense index into the topology.

Conforms to

Copyable, Deinitable, Equatable, ImplicitlyCopyable, Movable
__eq__Two allocators match when they name the same domain; the loader reference is not compared.
def __eq__(self, other: Self) -> Bool

Parameters

otherSelfThe allocator whose domain id is compared with this one's.

Returns

True when both place onto the same operating-system domain id.
atAn allocator for a domain the machine actually has.
def at(library: Library, memory_domain_id: MemoryDomainId) -> Self

Parameters

libraryLibraryThe loader every allocation from here is placed and released through.
memory_domain_idMemoryDomainIdAn id from Topology.memory_domain_id_at_index, never a dense index.

Returns

An allocator that outlives the topology handle the id was read from.

Raises

When the id is negative, which is how a domain the OS names none of comes back.
allocateExactly bytes on this domain.
def allocate(self, bytes: Int) -> AllocationResult

The page size is not reported by this entry point, so the result carries zero for it.

Parameters

bytesIntThe exact size to place, with no page rounding added on top of it.

Returns

Pages bound to this allocator's domain, released when the result goes out of use.

Raises

When the request is zero bytes, or the domain cannot back the placement.
allocate_at_leastAt least minimum_bytes, using the largest suitable page size, reporting both.
def allocate_at_least(self, minimum_bytes: Int) -> AllocationResult

Parameters

minimum_bytesIntThe floor on the request; huge-page rounding may hand back more.

Returns

Pages whose real size and page size both come back on the result.

Raises

When the request is zero bytes, or the domain cannot back the placement.
allocate_forRoom for count elements of dtype.
def allocate_for[dtype: DType](self, count: Int) -> AllocationResult

Parameters

dtypeDTypecomptimeThe element type whose width scales the request.
countIntHow many elements to make room for, not a byte count.

Returns

Pages sized to the product, released with the result.

Raises

When the count times the element width would wrap, or the domain refuses the placement.
allocate_for_at_leastRoom for at least minimum_count elements of dtype, on the largest suitable page.
def allocate_for_at_least[dtype: DType](self, minimum_count: Int) -> AllocationResult

Parameters

dtypeDTypecomptimeThe element type whose width scales the request.
minimum_countIntThe floor on elements; read count_of back for what the pages really fit.

Returns

Pages sized to at least the product, with the page size they used reported.

Raises

When the count times the element width would wrap, or the domain refuses the placement.
StructSymmetricAllocationOne mapping striped across every memory domain at a uniform stride.3
struct SymmetricAllocation

Backs both the replicated and the sharded views: they differ only in what the per-domain run means, not in how the storage is laid out.

Fields

libraryLibraryThe loader the unmap goes through, kept alive until it does.
baseBytesThe start of the whole mapping; the run for domain d begins d strides in.
stride_bytesIntThe page-aligned distance between runs, which rounding may lift above what was asked.
memory_domains_countIntHow many runs the mapping got, which a machine with no NUMA API collapses to one.
total_bytesIntThe whole mapping's size, which the unmap needs rather than one run's.
bytes_per_pageIntThe page size the mapping used, which is what drove the stride rounding.

Conforms to

Deinitable, Movable
__init__Maps one run per memory domain and binds each run to the domain it belongs to.
def __init__(out self, topology: Topology, bytes_per_domain: Int)

Parameters

topologyTopologyPassed straight to the C mapping, which reads the domain count off it.
bytes_per_domainIntThe usable floor per run; the stride is page-rounded up from it.

Raises

When the request is zero bytes, or the mapping cannot be placed.
__deinit__Unmaps the whole range in one call, which is why the total and not a stride is kept.
def __deinit__(deinit self)
on_memory_domainThe run of storage living on one memory domain, seen as the caller's element type.
def on_memory_domain[dtype: DType](
    mut self,
    domain: MemoryDomain
) -> Pointer[Scalar[dtype], origin_of(self)]

Parameters

dtypeDTypecomptimeThe element type to read the run as; nothing is converted.
domainMemoryDomainA dense memory-domain index, counted from zero, not an operating-system id.

Returns

A pointer into the mapping, borrowing it so the run cannot be unmapped beneath it.
StructReplicatedArrayThe same length elements held once per memory domain, so every reader is local.5
struct ReplicatedArray[dtype: DType]

Write each replica, then let every compute domain read the copy nearest to it.

Parameters

dtypeDTypecomptimeThe element type every replica holds.

Fields

storageSymmetricAllocationThe single mapping behind every replica, one run per domain.
lengthIntElements in one replica, which is the same for all of them.

Conforms to

Deinitable, Movable
__init__Adopts a mapping whose runs are already sized for one replica each.
def __init__(out self, *, var storage: SymmetricAllocation, length: Int)

Parameters

storageSymmetricAllocationvarThe mapping to take over, one run per domain.
lengthIntElements per replica, which the caller already sized the runs for.
newA replica of length elements on every memory domain.
def new(topology: Topology, length: Int) -> Self

Parameters

topologyTopologySays how many domains there are to replicate onto.
lengthIntElements in each copy, not the total held across the domains.

Returns

An array whose replicas start uninitialized; write each one before reading it.

Raises

When the length times the element width would wrap, or the mapping cannot be placed.
memory_domains_countHow many replicas the mapping produced.
def memory_domains_count(self) -> Int

Returns

The run count the mapping settled on, which is one where the machine has no NUMA API.
replicaOne domain's copy, all length elements of it.
def replica(mut self, domain: MemoryDomain) -> Pointer[Scalar[dtype], origin_of(self)]

Parameters

domainMemoryDomainA dense memory-domain index, counted from zero, not an operating-system id.

Returns

A pointer to that copy's first element, borrowing the array so it outlives the call.
atOne element of one domain's copy.
def at(mut self, domain: MemoryDomain, index: Int) -> ref[self] Scalar[dtype]

Parameters

domainMemoryDomainWhich copy to reach into; each holds the same length elements.
indexIntPosition within that copy, below length and unchecked.

Returns

A reference into that copy, so a write through it leaves the other replicas stale.
StructShardLocationWhere a logical index lives: which memory domain, and where inside that domain's shard.
struct ShardLocation

Fields

memory_domainMemoryDomainWhich shard holds the element, as a dense index.
local_indexIntPosition inside that shard, which is not the logical index it came from.

Conforms to

Copyable, Deinitable, Equatable, ImplicitlyCopyable, Movable, RegisterPassable, TrivialRegisterPassable
StructShardedArraylength elements split across the memory domains, each domain owning one contiguous run.7
struct ShardedArray[dtype: DType]

Parameters

dtypeDTypecomptimeThe element type every shard holds.

Fields

storageSymmetricAllocationThe single mapping behind every shard, one run per domain.
lengthIntElements across all the shards together, not per shard.
segmentIntElements one shard has room for, by ceiling division, so only the last one can be short.

Conforms to

Deinitable, Movable
__init__Adopts a mapping whose runs are already sized for one shard each.
def __init__(out self, *, var storage: SymmetricAllocation, length: Int, segment: Int)

Parameters

storageSymmetricAllocationvarThe mapping to take over, one run per domain.
lengthIntElements across all the shards together.
segmentIntElements each shard has room for, which is what fixes where an index lands.
newlength elements striped across every memory domain.
def new(topology: Topology, length: Int) -> Self

Parameters

topologyTopologySays how many domains the elements are split over.
lengthIntElements in total; ceiling division settles the per-domain run.

Returns

An array whose shards start uninitialized; write one before reading it.

Raises

When the per-shard count times the element width would wrap, or the mapping fails.
memory_domains_countHow many shards the mapping produced.
def memory_domains_count(self) -> Int

Returns

The run count the mapping settled on, which is one where the machine has no NUMA API.
shardOne domain's run of elements.
def shard(mut self, domain: MemoryDomain) -> Pointer[Scalar[dtype], origin_of(self)]

Parameters

domainMemoryDomainA dense memory-domain index, counted from zero, not an operating-system id.

Returns

A pointer to that run's first element, borrowing the array so it outlives the call.
length_on_memory_domainHow many elements one domain's shard actually holds; the last one may be short.
def length_on_memory_domain(self, domain: MemoryDomain) -> Int

Parameters

domainMemoryDomainWhich shard to measure, as a dense index.

Returns

Elements really present, down to zero for a domain the split ran out of work before.
location_ofWhich domain holds a logical index, and where inside that domain's shard.
def location_of(self, logical_index: Int) -> ShardLocation

Requires a non-empty array - segment is the divisor and is zero when empty.

Parameters

logical_indexIntA position in the whole array, not one within a single shard.

Returns

The domain and the offset inside it, which logical_index_of turns back again.
logical_index_ofThe inverse: a domain and a local offset back to a logical index.
def logical_index_of(self, domain: MemoryDomain, local_index: Int) -> Int

Parameters

domainMemoryDomainThe shard the offset is measured inside, as a dense index.
local_indexIntPosition within that shard, below segment.

Returns

The position in the whole array, whether or not length reaches that far.

Functions

default_domain_allocatorAn allocator for the machine's first memory domain, which every machine has.
def default_domain_allocator(topology: Topology) -> DomainAllocator

Parameters

topologyTopologyConsulted only to resolve index zero to an id, and not held afterwards.

Returns

An allocator that outlives the topology it was read from.

Raises

When the machine cannot be described well enough to name even its first domain.
local_domain_allocatorAn allocator for the memory domain nearest a compute domain - "run here, allocate here".
def local_domain_allocator(topology: Topology, domain: ComputeDomain) -> DomainAllocator

This is the pairing the index-versus-id distinction exists to protect: local_memory_of answers a dense index, and only memory_domain_id_at_index turns it into an allocator's id.

Parameters

topologyTopologyConsulted for both halves of that resolution, and not held afterwards.
domainComputeDomainA dense compute-domain index, counted from zero - where the work will run.

Returns

An allocator for the memory nearest that compute domain.

Raises

When the machine has no such compute domain, or names no memory domain beside it.