diff --git a/Makefile b/Makefile index 30a6571bb..4638be87c 100644 --- a/Makefile +++ b/Makefile @@ -1131,6 +1131,7 @@ endif objects += linux.o objects += core/commands.o objects += core/sched.o +objects += core/numa.o objects += core/mmio.o objects += core/kprintf.o ifeq ($(conf_tracepoints),1) diff --git a/core/numa.cc b/core/numa.cc new file mode 100644 index 000000000..1ffa4df63 --- /dev/null +++ b/core/numa.cc @@ -0,0 +1,188 @@ +/* + * Copyright (C) 2026 Waldemar Kozaczuk + * + * This work is open source software, licensed under the terms of the + * BSD license as described in the LICENSE file in the top-level directory. + */ + +#include +#include +#include + +#include +#include + +#include + +#if CONF_drivers_acpi +extern "C" { +#include "acpi.h" +} +#include +#endif + +namespace numa { + +static bool s_available = false; +static unsigned s_nr_nodes = 1; +// Map from a raw APIC id to the NUMA node it belongs to (from SRAT). +static std::unordered_map s_apic_to_node; +// Map from a sched cpu id to its NUMA node (resolved via APIC id). +static std::unordered_map s_cpu_to_node; +// SLIT distance matrix, row-major, s_nr_nodes x s_nr_nodes; empty if no SLIT. +static std::vector s_distances; +static std::vector s_mem_ranges; +static bool s_initialized = false; + +unsigned nr_nodes() { return s_nr_nodes; } +bool available() { return s_available; } +const std::vector& memory_ranges() { return s_mem_ranges; } + +unsigned node_of_cpu(unsigned cpu_id) +{ + auto it = s_cpu_to_node.find(cpu_id); + return it == s_cpu_to_node.end() ? 0 : it->second; +} + +int node_of_phys(uint64_t phys) +{ + for (auto& r : s_mem_ranges) { + if (phys >= r.base && phys < r.base + r.length) { + return (int)r.node; + } + } + return -1; +} + +unsigned distance(unsigned from, unsigned to) +{ + if (from == to) { + return 10; // ACPI convention: 10 == local. + } + if (!s_distances.empty() && from < s_nr_nodes && to < s_nr_nodes) { + return s_distances[from * s_nr_nodes + to]; + } + return 20; // Default remote distance when no SLIT is present. +} + +#if CONF_drivers_acpi +using boost::intrusive::get_parent_from_member; + +// Record the (apic id -> node) mapping and track the highest node seen. +static void record_cpu_affinity(uint32_t apic_id, unsigned node, unsigned& max_node) +{ + s_apic_to_node[apic_id] = node; + max_node = std::max(max_node, node); +} + +static void parse_srat() +{ + char sig[] = ACPI_SIG_SRAT; + ACPI_TABLE_HEADER* header; + if (AcpiGetTable(sig, 0, &header) != AE_OK) { + return; // No SRAT: leave the single-node fallback in place. + } + auto srat = get_parent_from_member(header, &ACPI_TABLE_SRAT::Header); + void* sub = srat + 1; + void* end = static_cast(srat) + srat->Header.Length; + unsigned max_node = 0; + + while (sub < end) { + auto s = static_cast(sub); + if (s->Length == 0) { + break; // Guard against a malformed zero-length subtable. + } + switch (s->Type) { + case ACPI_SRAT_TYPE_CPU_AFFINITY: { + auto a = get_parent_from_member(s, &ACPI_SRAT_CPU_AFFINITY::Header); + if (a->Flags & ACPI_SRAT_CPU_ENABLED) { + unsigned node = a->ProximityDomainLo | + (a->ProximityDomainHi[0] << 8) | + (a->ProximityDomainHi[1] << 16) | + (a->ProximityDomainHi[2] << 24); + record_cpu_affinity(a->ApicId, node, max_node); + } + break; + } + case ACPI_SRAT_TYPE_X2APIC_CPU_AFFINITY: { + auto a = get_parent_from_member(s, &ACPI_SRAT_X2APIC_CPU_AFFINITY::Header); + if (a->Flags & ACPI_SRAT_CPU_ENABLED) { + record_cpu_affinity(a->ApicId, a->ProximityDomain, max_node); + } + break; + } + case ACPI_SRAT_TYPE_MEMORY_AFFINITY: { + auto m = get_parent_from_member(s, &ACPI_SRAT_MEM_AFFINITY::Header); + if (m->Flags & ACPI_SRAT_MEM_ENABLED) { + s_mem_ranges.push_back(mem_range{ + m->BaseAddress, m->Length, m->ProximityDomain, + (m->Flags & ACPI_SRAT_MEM_HOT_PLUGGABLE) != 0}); + max_node = std::max(max_node, (unsigned)m->ProximityDomain); + } + break; + } + default: + break; + } + sub = static_cast(sub) + s->Length; + } + + if (!s_apic_to_node.empty() || !s_mem_ranges.empty()) { + s_available = true; + s_nr_nodes = max_node + 1; + } +} + +static void parse_slit() +{ + char sig[] = ACPI_SIG_SLIT; + ACPI_TABLE_HEADER* header; + if (AcpiGetTable(sig, 0, &header) != AE_OK) { + return; + } + auto slit = get_parent_from_member(header, &ACPI_TABLE_SLIT::Header); + uint64_t n = slit->LocalityCount; + // Only trust SLIT if it agrees with the node count we saw in SRAT. + if (n == 0 || n != s_nr_nodes) { + return; + } + s_distances.assign(slit->Entry, slit->Entry + n * n); +} + +// Resolve the (apic id -> node) map into a (sched cpu id -> node) map. +static void resolve_cpus() +{ + for (auto* c : sched::cpus) { + auto it = s_apic_to_node.find(c->arch.apic_id); + if (it != s_apic_to_node.end()) { + s_cpu_to_node[c->id] = it->second; + } + } +} +#endif + +void init() +{ + if (s_initialized) { + return; + } + s_initialized = true; + +#if CONF_drivers_acpi + parse_srat(); + if (s_available) { + parse_slit(); + resolve_cpus(); + } +#endif + + if (s_available) { + debugf("NUMA: %u node(s), %zu CPU(s) mapped, %zu memory range(s)%s\n", + s_nr_nodes, s_cpu_to_node.size(), s_mem_ranges.size(), + s_distances.empty() ? ", no SLIT" : ""); + } else { + debugf("NUMA: no SRAT, assuming a single flat node\n"); + } +} + +} diff --git a/include/osv/numa.hh b/include/osv/numa.hh new file mode 100644 index 000000000..f764be750 --- /dev/null +++ b/include/osv/numa.hh @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2026 Waldemar Kozaczuk + * + * This work is open source software, licensed under the terms of the + * BSD license as described in the LICENSE file in the top-level directory. + */ + +#ifndef OSV_NUMA_HH +#define OSV_NUMA_HH + +#include +#include +#include + +// NUMA topology discovery. +// +// This module parses the ACPI SRAT (System Resource Affinity Table) and SLIT +// (System Locality Distance Information Table) to learn the machine's NUMA +// layout: which node each CPU belongs to, which physical memory ranges belong +// to each node, and the relative distances between nodes. +// +// This is discovery only: it does not change how memory is allocated or how +// threads are scheduled. It exposes the topology so later work (a node-aware +// allocator, scheduler affinity, mbind/get_mempolicy) can use it. On a machine +// with no SRAT (the common single-node virtual machine), the whole system is +// reported as one node (node 0) containing every CPU. + +namespace numa { + +// A contiguous physical memory range assigned to a NUMA node. +struct mem_range { + uint64_t base; + uint64_t length; + unsigned node; + bool hotpluggable; +}; + +// Discover the topology by parsing SRAT/SLIT. Safe to call once, after ACPI is +// initialized and the CPUs have been enumerated (so APIC ids are known). If no +// SRAT is present, initializes a single flat node. Idempotent. +void init(); + +// Number of NUMA nodes (>= 1). +unsigned nr_nodes(); + +// True if the topology came from a real SRAT (as opposed to the synthesized +// single-node fallback). +bool available(); + +// The node a CPU (by sched cpu id) belongs to, or 0 if unknown. +unsigned node_of_cpu(unsigned cpu_id); + +// The node that owns a physical address, or -1 if it falls in no known range. +// Only meaningful when available() is true. +int node_of_phys(uint64_t phys); + +// The SLIT distance from node `from` to node `to`. Linux/ACPI convention: +// 10 == local (same node), higher == farther. Returns 10 for the diagonal and +// a default (10 local / 20 remote) when no SLIT is present. +unsigned distance(unsigned from, unsigned to); + +// The memory ranges discovered from SRAT (empty if none). +const std::vector& memory_ranges(); + +} + +#endif diff --git a/linux.cc b/linux.cc index 58a018e9e..8338262f1 100644 --- a/linux.cc +++ b/linux.cc @@ -10,6 +10,8 @@ #include #include +#include +#include #include #include #include @@ -176,23 +178,44 @@ int futex(int *uaddr, int op, int val, const struct timespec *timeout, static long get_mempolicy(int *policy, unsigned long *nmask, unsigned long maxnode, void *addr, int flags) { - // As OSv has no support for NUMA nodes, we do here the minimum possible, - // which is basically to return the same policy (MPOL_DEFAULT) and list - // of nodes (just node 0) no matter if the caller asked for the default - // policy, the allowed policy, or the policy for a specific address. + // Report the topology discovered from ACPI SRAT/SLIT (numa::). When NUMA is + // not available (no SRAT, the common single-node VM) this degrades to the + // old single-node-0 behavior. We still apply MPOL_DEFAULT everywhere: the + // allocator is not yet node-aware, so we cannot honor a real policy, but we + // can at least answer topology queries truthfully. if ((flags & MPOL_F_NODE)) { - *policy = 0; // in this case, store a node id, not a policy + // Store a node id in *policy, not a policy value. + if (!policy) { + errno = EINVAL; + return -1; + } + if (flags & MPOL_F_ADDR) { + // The node backing the given address, if we can resolve it. + int node = -1; + if (numa::available() && addr) { + auto phys = mmu::virt_to_phys_pt(addr); + node = numa::node_of_phys(phys); + } + *policy = node < 0 ? 0 : node; + } else { + // The node the calling CPU runs on. + *policy = numa::node_of_cpu(sched::cpu::current()->id); + } return 0; } if (policy) { *policy = MPOL_DEFAULT; } if (nmask) { - if (maxnode < 1) { + if (maxnode < numa::nr_nodes()) { errno = EINVAL; return -1; } - nmask[0] |= 1; + // Set a bit for every node that exists. + for (unsigned n = 0; n < numa::nr_nodes(); n++) { + nmask[n / (8 * sizeof(unsigned long))] |= + 1UL << (n % (8 * sizeof(unsigned long))); + } } return 0; } @@ -202,9 +225,11 @@ static long get_mempolicy(int *policy, unsigned long *nmask, static long set_mempolicy(int policy, unsigned long *nmask, unsigned long maxnode) { - // OSv has very minimal support for NUMA - merely exposes - // all cpus as a single node0 and cannot really apply any meaningful policy - // Therefore we implement this as noop, ignore all arguments and return success + // The topology is now discovered (numa::), but the physical allocator is + // not yet node-aware, so we cannot actually enforce a placement policy. + // Accept the call as a no-op (ignoring the requested policy) rather than + // failing, so NUMA-aware programs run unmodified. Enforcement will come + // when the allocator learns about nodes. return 0; } #endif @@ -441,12 +466,13 @@ static long sys_getcwd(char *buf, unsigned long size) #define __NR_sys_getcpu __NR_getcpu static long sys_getcpu(unsigned int *cpu, unsigned int *node, void *tcache) { + auto *c = sched::cpu::current(); if (cpu) { - *cpu = sched::cpu::current()->id; + *cpu = c->id; } if (node) { - *node = 0; + *node = numa::node_of_cpu(c->id); } return 0; diff --git a/loader.cc b/loader.cc index 6c2b406e3..5e29a86e9 100644 --- a/loader.cc +++ b/loader.cc @@ -26,6 +26,7 @@ #endif /* __x86_64__ */ #include +#include #include #include "arch.hh" #include "arch-setup.hh" @@ -824,6 +825,7 @@ void main_cont(int loader_argc, char** loader_argv) #ifdef __x86_64__ #if CONF_drivers_acpi acpi::init(); + numa::init(); #endif #endif /* __x86_64__ */ diff --git a/modules/tests/Makefile b/modules/tests/Makefile index 391dbd9a8..d17ad24c7 100644 --- a/modules/tests/Makefile +++ b/modules/tests/Makefile @@ -120,6 +120,7 @@ tests := tst-pthread.so misc-ramdisk.so tst-vblk.so tst-bsd-evh.so \ tst-fpu.so tst-preempt.so tst-tracepoint.so tst-hub.so \ misc-console.so misc-leak.so misc-readbench.so misc-mmap-anon-perf.so \ tst-mmap-file.so misc-mmap-big-file.so tst-mmap.so tst-huge.so \ + tst-numa.so tst-numa-mempolicy.so \ tst-elf-permissions.so misc-mutex.so misc-sockets.so tst-condvar.so \ tst-queue-mpsc.so tst-af-local.so tst-pipe.so tst-yield.so \ misc-ctxsw.so tst-read.so tst-symlink.so tst-openat.so \ diff --git a/tests/tst-numa-mempolicy.cc b/tests/tst-numa-mempolicy.cc new file mode 100644 index 000000000..b1795de6a --- /dev/null +++ b/tests/tst-numa-mempolicy.cc @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2026 Waldemar Kozaczuk + * + * This work is open source software, licensed under the terms of the + * BSD license as described in the LICENSE file in the top-level directory. + */ + +// Verifies that getcpu and get_mempolicy report the NUMA topology discovered by +// the numa:: module (rather than a hardcoded single node). Boot with QEMU's +// -numa options to exercise more than one node. Built and run as part of the +// OSv test image. + +#include + +#include +#include +#include +#include +#include + +#include +#include + +#define MPOL_F_NODE (1 << 0) +#define MPOL_F_ADDR (1 << 1) + +static long get_mempolicy_(int *policy, unsigned long *nmask, + unsigned long maxnode, void *addr, int flags) +{ + return syscall(SYS_get_mempolicy, policy, nmask, maxnode, addr, flags); +} + +int main() +{ + std::cerr << "Running numa-mempolicy tests\n"; + unsigned nodes = numa::nr_nodes(); + + // getcpu (via sched_getcpu, which passes a node-out pointer) must report a + // node within range and consistent with numa::node_of_cpu. + unsigned cpu = 0, node = 0; + long r = syscall(SYS_getcpu, &cpu, &node, nullptr); + assert(r == 0); + assert(node < nodes); + assert(node == numa::node_of_cpu(cpu)); + + // get_mempolicy with MPOL_F_NODE reports the current CPU's node. + int cur_node = -1; + assert(get_mempolicy_(&cur_node, nullptr, 0, nullptr, MPOL_F_NODE) == 0); + assert(cur_node >= 0 && (unsigned)cur_node < nodes); + + // The allowed-nodes mask must have exactly `nodes` bits set at minimum, and + // maxnode too small must fail with EINVAL. + unsigned long mask[16] = {}; + int policy = -1; + assert(get_mempolicy_(&policy, mask, sizeof(mask) * 8, nullptr, 0) == 0); + int bits = 0; + for (unsigned i = 0; i < sizeof(mask) * 8; i++) { + if (mask[i / (8 * sizeof(unsigned long))] & + (1UL << (i % (8 * sizeof(unsigned long))))) { + bits++; + } + } + assert((unsigned)bits == nodes); + + if (nodes > 1) { + errno = 0; + assert(get_mempolicy_(&policy, mask, 1, nullptr, 0) == -1 && + errno == EINVAL); + } + + // MPOL_F_NODE|MPOL_F_ADDR reports the node backing a given address. On a + // real NUMA machine this must be a valid node; the value depends on + // placement so we only check the range. + void *buf = malloc(4096); + assert(buf); + *(volatile char *)buf = 1; // fault it in + int addr_node = -1; + assert(get_mempolicy_(&addr_node, nullptr, 0, buf, MPOL_F_NODE | MPOL_F_ADDR) == 0); + assert(addr_node >= 0 && (unsigned)addr_node < nodes); + free(buf); + + std::cerr << " nodes=" << nodes << " current_node=" << cur_node << "\n"; + std::cerr << "numa-mempolicy tests PASSED\n"; + return 0; +} diff --git a/tests/tst-numa.cc b/tests/tst-numa.cc new file mode 100644 index 000000000..71dd9cf02 --- /dev/null +++ b/tests/tst-numa.cc @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2026 Waldemar Kozaczuk + * + * This work is open source software, licensed under the terms of the + * BSD license as described in the LICENSE file in the top-level directory. + */ + +// Verifies NUMA topology discovery. Boot with QEMU's -numa options to see more +// than one node; without them (the default) the machine is reported as a single +// flat node. Built and run as part of the OSv test image. + +#include +#include + +#include +#include + +int main() +{ + std::cerr << "Running numa tests\n"; + + // There is always at least one node. + unsigned n = numa::nr_nodes(); + assert(n >= 1); + std::cerr << " nodes=" << n << " available=" << numa::available() << "\n"; + + // Every CPU maps to a node within range. + for (auto* c : sched::cpus) { + unsigned node = numa::node_of_cpu(c->id); + assert(node < n); + } + + // Distances: the diagonal is local (10); off-diagonal is >= local. + for (unsigned a = 0; a < n; a++) { + assert(numa::distance(a, a) == 10); + for (unsigned b = 0; b < n; b++) { + assert(numa::distance(a, b) >= 10); + } + } + + // Memory ranges (if any) all name a node within range. + for (auto& r : numa::memory_ranges()) { + assert(r.node < n); + assert(r.length > 0); + } + + // When SRAT is present, every CPU should have been mapped and the node + // count should match at least one memory range or cpu affinity. + if (numa::available()) { + std::cerr << " memory ranges=" << numa::memory_ranges().size() << "\n"; + } + + std::cerr << "numa tests PASSED\n"; + return 0; +}