Skip to content

Commit da6926b

Browse files
committed
numa: discover NUMA topology from ACPI SRAT/SLIT
OSv had no notion of NUMA: the scheduler and allocator treat memory as flat, which leaves performance on the table on multi-socket bare-metal (the large EC2/GCP metal instances that are a target for Postgres-over-ZFS). This is the first, discovery-only step. It adds a numa:: module that parses the ACPI SRAT (System Resource Affinity Table) and SLIT (System Locality Distance Information Table) at boot, right after acpi::init(), and exposes the topology: - numa::nr_nodes() / numa::available() - numa::node_of_cpu(cpu_id) -- resolved by correlating SRAT APIC ids with the APIC ids the MADT parse recorded on each sched::cpu - numa::distance(from, to) -- from SLIT (10 == local per ACPI convention), defaulting to 10 local / 20 remote when no SLIT is present - numa::memory_ranges() -- physical ranges tagged with their node It handles the SRAT CPU-affinity, x2APIC CPU-affinity and memory-affinity subtables, and only trusts SLIT if its locality count matches the node count SRAT reported. On a machine with no SRAT (the common single-node VM) it reports one flat node and available() == false; nothing changes behavior. This intentionally does NOT yet change allocation or scheduling; it only makes the topology available so a node-aware allocator, scheduler affinity, and mbind/get_mempolicy can build on it. Add tests/tst-numa.cc validating the invariants (>= 1 node, every CPU maps in range, distance diagonal == 10 and off-diagonal >= 10, memory ranges name valid nodes). Verified on OSv under KVM both without NUMA (reports 1 flat node) and with a QEMU 2-node -numa config (reports 2 nodes, 3 memory ranges, available).
1 parent 3aba46c commit da6926b

6 files changed

Lines changed: 300 additions & 0 deletions

File tree

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1131,6 +1131,7 @@ endif
11311131
objects += linux.o
11321132
objects += core/commands.o
11331133
objects += core/sched.o
1134+
objects += core/numa.o
11341135
objects += core/mmio.o
11351136
objects += core/kprintf.o
11361137
ifeq ($(conf_tracepoints),1)

core/numa.cc

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/*
2+
* Copyright (C) 2026 Waldemar Kozaczuk
3+
*
4+
* This work is open source software, licensed under the terms of the
5+
* BSD license as described in the LICENSE file in the top-level directory.
6+
*/
7+
8+
#include <osv/numa.hh>
9+
#include <osv/sched.hh>
10+
#include <osv/debug.hh>
11+
12+
#include <unordered_map>
13+
#include <algorithm>
14+
15+
#include <osv/drivers_config.h>
16+
17+
#if CONF_drivers_acpi
18+
extern "C" {
19+
#include "acpi.h"
20+
}
21+
#include <boost/intrusive/parent_from_member.hpp>
22+
#endif
23+
24+
namespace numa {
25+
26+
static bool s_available = false;
27+
static unsigned s_nr_nodes = 1;
28+
// Map from a raw APIC id to the NUMA node it belongs to (from SRAT).
29+
static std::unordered_map<uint32_t, unsigned> s_apic_to_node;
30+
// Map from a sched cpu id to its NUMA node (resolved via APIC id).
31+
static std::unordered_map<unsigned, unsigned> s_cpu_to_node;
32+
// SLIT distance matrix, row-major, s_nr_nodes x s_nr_nodes; empty if no SLIT.
33+
static std::vector<uint8_t> s_distances;
34+
static std::vector<mem_range> s_mem_ranges;
35+
static bool s_initialized = false;
36+
37+
unsigned nr_nodes() { return s_nr_nodes; }
38+
bool available() { return s_available; }
39+
const std::vector<mem_range>& memory_ranges() { return s_mem_ranges; }
40+
41+
unsigned node_of_cpu(unsigned cpu_id)
42+
{
43+
auto it = s_cpu_to_node.find(cpu_id);
44+
return it == s_cpu_to_node.end() ? 0 : it->second;
45+
}
46+
47+
unsigned distance(unsigned from, unsigned to)
48+
{
49+
if (from == to) {
50+
return 10; // ACPI convention: 10 == local.
51+
}
52+
if (!s_distances.empty() && from < s_nr_nodes && to < s_nr_nodes) {
53+
return s_distances[from * s_nr_nodes + to];
54+
}
55+
return 20; // Default remote distance when no SLIT is present.
56+
}
57+
58+
#if CONF_drivers_acpi
59+
using boost::intrusive::get_parent_from_member;
60+
61+
// Record the (apic id -> node) mapping and track the highest node seen.
62+
static void record_cpu_affinity(uint32_t apic_id, unsigned node, unsigned& max_node)
63+
{
64+
s_apic_to_node[apic_id] = node;
65+
max_node = std::max(max_node, node);
66+
}
67+
68+
static void parse_srat()
69+
{
70+
char sig[] = ACPI_SIG_SRAT;
71+
ACPI_TABLE_HEADER* header;
72+
if (AcpiGetTable(sig, 0, &header) != AE_OK) {
73+
return; // No SRAT: leave the single-node fallback in place.
74+
}
75+
auto srat = get_parent_from_member(header, &ACPI_TABLE_SRAT::Header);
76+
void* sub = srat + 1;
77+
void* end = static_cast<void*>(srat) + srat->Header.Length;
78+
unsigned max_node = 0;
79+
80+
while (sub < end) {
81+
auto s = static_cast<ACPI_SUBTABLE_HEADER*>(sub);
82+
if (s->Length == 0) {
83+
break; // Guard against a malformed zero-length subtable.
84+
}
85+
switch (s->Type) {
86+
case ACPI_SRAT_TYPE_CPU_AFFINITY: {
87+
auto a = get_parent_from_member(s, &ACPI_SRAT_CPU_AFFINITY::Header);
88+
if (a->Flags & ACPI_SRAT_CPU_ENABLED) {
89+
unsigned node = a->ProximityDomainLo |
90+
(a->ProximityDomainHi[0] << 8) |
91+
(a->ProximityDomainHi[1] << 16) |
92+
(a->ProximityDomainHi[2] << 24);
93+
record_cpu_affinity(a->ApicId, node, max_node);
94+
}
95+
break;
96+
}
97+
case ACPI_SRAT_TYPE_X2APIC_CPU_AFFINITY: {
98+
auto a = get_parent_from_member(s, &ACPI_SRAT_X2APIC_CPU_AFFINITY::Header);
99+
if (a->Flags & ACPI_SRAT_CPU_ENABLED) {
100+
record_cpu_affinity(a->ApicId, a->ProximityDomain, max_node);
101+
}
102+
break;
103+
}
104+
case ACPI_SRAT_TYPE_MEMORY_AFFINITY: {
105+
auto m = get_parent_from_member(s, &ACPI_SRAT_MEM_AFFINITY::Header);
106+
if (m->Flags & ACPI_SRAT_MEM_ENABLED) {
107+
s_mem_ranges.push_back(mem_range{
108+
m->BaseAddress, m->Length, m->ProximityDomain,
109+
(m->Flags & ACPI_SRAT_MEM_HOT_PLUGGABLE) != 0});
110+
max_node = std::max(max_node, (unsigned)m->ProximityDomain);
111+
}
112+
break;
113+
}
114+
default:
115+
break;
116+
}
117+
sub = static_cast<void*>(sub) + s->Length;
118+
}
119+
120+
if (!s_apic_to_node.empty() || !s_mem_ranges.empty()) {
121+
s_available = true;
122+
s_nr_nodes = max_node + 1;
123+
}
124+
}
125+
126+
static void parse_slit()
127+
{
128+
char sig[] = ACPI_SIG_SLIT;
129+
ACPI_TABLE_HEADER* header;
130+
if (AcpiGetTable(sig, 0, &header) != AE_OK) {
131+
return;
132+
}
133+
auto slit = get_parent_from_member(header, &ACPI_TABLE_SLIT::Header);
134+
uint64_t n = slit->LocalityCount;
135+
// Only trust SLIT if it agrees with the node count we saw in SRAT.
136+
if (n == 0 || n != s_nr_nodes) {
137+
return;
138+
}
139+
s_distances.assign(slit->Entry, slit->Entry + n * n);
140+
}
141+
142+
// Resolve the (apic id -> node) map into a (sched cpu id -> node) map.
143+
static void resolve_cpus()
144+
{
145+
for (auto* c : sched::cpus) {
146+
auto it = s_apic_to_node.find(c->arch.apic_id);
147+
if (it != s_apic_to_node.end()) {
148+
s_cpu_to_node[c->id] = it->second;
149+
}
150+
}
151+
}
152+
#endif
153+
154+
void init()
155+
{
156+
if (s_initialized) {
157+
return;
158+
}
159+
s_initialized = true;
160+
161+
#if CONF_drivers_acpi
162+
parse_srat();
163+
if (s_available) {
164+
parse_slit();
165+
resolve_cpus();
166+
}
167+
#endif
168+
169+
if (s_available) {
170+
debugf("NUMA: %u node(s), %zu CPU(s) mapped, %zu memory range(s)%s\n",
171+
s_nr_nodes, s_cpu_to_node.size(), s_mem_ranges.size(),
172+
s_distances.empty() ? ", no SLIT" : "");
173+
} else {
174+
debugf("NUMA: no SRAT, assuming a single flat node\n");
175+
}
176+
}
177+
178+
}

include/osv/numa.hh

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Copyright (C) 2026 Waldemar Kozaczuk
3+
*
4+
* This work is open source software, licensed under the terms of the
5+
* BSD license as described in the LICENSE file in the top-level directory.
6+
*/
7+
8+
#ifndef OSV_NUMA_HH
9+
#define OSV_NUMA_HH
10+
11+
#include <cstdint>
12+
#include <vector>
13+
#include <cstddef>
14+
15+
// NUMA topology discovery.
16+
//
17+
// This module parses the ACPI SRAT (System Resource Affinity Table) and SLIT
18+
// (System Locality Distance Information Table) to learn the machine's NUMA
19+
// layout: which node each CPU belongs to, which physical memory ranges belong
20+
// to each node, and the relative distances between nodes.
21+
//
22+
// This is discovery only: it does not change how memory is allocated or how
23+
// threads are scheduled. It exposes the topology so later work (a node-aware
24+
// allocator, scheduler affinity, mbind/get_mempolicy) can use it. On a machine
25+
// with no SRAT (the common single-node virtual machine), the whole system is
26+
// reported as one node (node 0) containing every CPU.
27+
28+
namespace numa {
29+
30+
// A contiguous physical memory range assigned to a NUMA node.
31+
struct mem_range {
32+
uint64_t base;
33+
uint64_t length;
34+
unsigned node;
35+
bool hotpluggable;
36+
};
37+
38+
// Discover the topology by parsing SRAT/SLIT. Safe to call once, after ACPI is
39+
// initialized and the CPUs have been enumerated (so APIC ids are known). If no
40+
// SRAT is present, initializes a single flat node. Idempotent.
41+
void init();
42+
43+
// Number of NUMA nodes (>= 1).
44+
unsigned nr_nodes();
45+
46+
// True if the topology came from a real SRAT (as opposed to the synthesized
47+
// single-node fallback).
48+
bool available();
49+
50+
// The node a CPU (by sched cpu id) belongs to, or 0 if unknown.
51+
unsigned node_of_cpu(unsigned cpu_id);
52+
53+
// The SLIT distance from node `from` to node `to`. Linux/ACPI convention:
54+
// 10 == local (same node), higher == farther. Returns 10 for the diagonal and
55+
// a default (10 local / 20 remote) when no SLIT is present.
56+
unsigned distance(unsigned from, unsigned to);
57+
58+
// The memory ranges discovered from SRAT (empty if none).
59+
const std::vector<mem_range>& memory_ranges();
60+
61+
}
62+
63+
#endif

loader.cc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
#endif /* __x86_64__ */
2727

2828
#include <osv/sched.hh>
29+
#include <osv/numa.hh>
2930
#include <osv/barrier.hh>
3031
#include "arch.hh"
3132
#include "arch-setup.hh"
@@ -824,6 +825,7 @@ void main_cont(int loader_argc, char** loader_argv)
824825
#ifdef __x86_64__
825826
#if CONF_drivers_acpi
826827
acpi::init();
828+
numa::init();
827829
#endif
828830
#endif /* __x86_64__ */
829831

modules/tests/Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ tests := tst-pthread.so misc-ramdisk.so tst-vblk.so tst-bsd-evh.so \
122122
tst-mmap-file.so misc-mmap-big-file.so tst-mmap.so tst-huge.so \
123123
tst-prctl.so \
124124
tst-mmap-file-cow.so \
125+
tst-numa.so \
125126
tst-elf-permissions.so misc-mutex.so misc-sockets.so tst-condvar.so \
126127
tst-queue-mpsc.so tst-af-local.so tst-pipe.so tst-yield.so \
127128
misc-ctxsw.so tst-read.so tst-symlink.so tst-openat.so \

tests/tst-numa.cc

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Copyright (C) 2026 Waldemar Kozaczuk
3+
*
4+
* This work is open source software, licensed under the terms of the
5+
* BSD license as described in the LICENSE file in the top-level directory.
6+
*/
7+
8+
// Verifies NUMA topology discovery. Boot with QEMU's -numa options to see more
9+
// than one node; without them (the default) the machine is reported as a single
10+
// flat node. Built and run as part of the OSv test image.
11+
12+
#include <osv/numa.hh>
13+
#include <osv/sched.hh>
14+
15+
#include <cassert>
16+
#include <iostream>
17+
18+
int main()
19+
{
20+
std::cerr << "Running numa tests\n";
21+
22+
// There is always at least one node.
23+
unsigned n = numa::nr_nodes();
24+
assert(n >= 1);
25+
std::cerr << " nodes=" << n << " available=" << numa::available() << "\n";
26+
27+
// Every CPU maps to a node within range.
28+
for (auto* c : sched::cpus) {
29+
unsigned node = numa::node_of_cpu(c->id);
30+
assert(node < n);
31+
}
32+
33+
// Distances: the diagonal is local (10); off-diagonal is >= local.
34+
for (unsigned a = 0; a < n; a++) {
35+
assert(numa::distance(a, a) == 10);
36+
for (unsigned b = 0; b < n; b++) {
37+
assert(numa::distance(a, b) >= 10);
38+
}
39+
}
40+
41+
// Memory ranges (if any) all name a node within range.
42+
for (auto& r : numa::memory_ranges()) {
43+
assert(r.node < n);
44+
assert(r.length > 0);
45+
}
46+
47+
// When SRAT is present, every CPU should have been mapped and the node
48+
// count should match at least one memory range or cpu affinity.
49+
if (numa::available()) {
50+
std::cerr << " memory ranges=" << numa::memory_ranges().size() << "\n";
51+
}
52+
53+
std::cerr << "numa tests PASSED\n";
54+
return 0;
55+
}

0 commit comments

Comments
 (0)