diff --git a/Makefile b/Makefile index faeee368f..a1ae24362 100644 --- a/Makefile +++ b/Makefile @@ -1133,6 +1133,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..2ef9ebdd0 --- /dev/null +++ b/core/numa.cc @@ -0,0 +1,178 @@ +/* + * Copyright (C) 2026 Greg Burd + * + * 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; +} + +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..ea275a301 --- /dev/null +++ b/include/osv/numa.hh @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2026 Greg Burd + * + * 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 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/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 ff66fcd93..ea43ec959 100644 --- a/modules/tests/Makefile +++ b/modules/tests/Makefile @@ -126,6 +126,7 @@ tests := tst-pthread.so misc-ramdisk.so tst-vblk.so tst-mq-smoke.so tst-bsd-evh. tst-epoll-pwait2.so \ tst-prctl.so \ tst-mmap-file-cow.so \ + tst-numa.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.cc b/tests/tst-numa.cc new file mode 100644 index 000000000..7d4a09d52 --- /dev/null +++ b/tests/tst-numa.cc @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2026 Greg Burd + * + * 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; +}