numa: report real topology from getcpu and get_mempolicy - #1438
Conversation
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 large multi-socket bare-metal hosts). 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).
With NUMA topology now discovered (numa:: module), make the topology-query
syscalls report it instead of a hardcoded single node:
- sys_getcpu() now fills the node-out argument with numa::node_of_cpu() for the
calling CPU rather than always 0.
- get_mempolicy():
- MPOL_F_NODE returns the calling CPU's node.
- MPOL_F_NODE | MPOL_F_ADDR returns the node backing the given address,
resolved via a page-table walk (virt_to_phys_pt) and numa::node_of_phys().
- the allowed-nodes mask now sets a bit for every discovered node (not just
node 0), and rejects a maxnode smaller than the node count with EINVAL.
set_mempolicy() stays a no-op: the topology is known but the physical allocator
is not yet node-aware, so a placement policy cannot be enforced. Its comment is
updated to say so; enforcement will come with the node-aware allocator.
Adds numa::node_of_phys() to map a physical address to its node.
On a machine with no SRAT everything degrades to the previous single-node-0
behavior.
Add tests/tst-numa-mempolicy.cc checking getcpu's node is in range and matches
numa::node_of_cpu, get_mempolicy's MPOL_F_NODE and allowed-mask (bit count ==
node count, maxnode-too-small EINVAL), and the MPOL_F_ADDR path. Verified on OSv
under KVM single-node and with a QEMU 2-node -numa config.
Depends on the "numa: discover NUMA topology from ACPI SRAT/SLIT" change.
There was a problem hiding this comment.
Pull request overview
This PR wires the newly introduced NUMA topology discovery (numa:: from the base PR) into Linux-compat syscalls so that getcpu()/get_mempolicy() report real node information (instead of hardcoded node 0), and adds tests to validate the behavior under both single-node and multi-node QEMU/KVM configs.
Changes:
- Add
core/numa.cc+include/osv/numa.hhto discover NUMA topology from ACPI SRAT/SLIT and expose node/distance/memory-range queries. - Update
sys_getcpu()andget_mempolicy()to return node information derived fromnuma::node_of_cpu()/numa::node_of_phys(). - Add NUMA topology and mempolicy tests and hook them into the tests module build.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/tst-numa.cc | New test validating basic NUMA discovery invariants (nodes, CPU mapping, distances, ranges). |
| tests/tst-numa-mempolicy.cc | New test validating getcpu node reporting and get_mempolicy node/mask behavior (including MPOL_F_ADDR path). |
| modules/tests/Makefile | Adds new NUMA tests to the tests image/build list. |
| Makefile | Builds the new NUMA core implementation (core/numa.o). |
| loader.cc | Initializes NUMA topology after ACPI init at boot. |
| linux.cc | Implements NUMA-aware behavior in get_mempolicy() and sys_getcpu(). |
| include/osv/numa.hh | Public NUMA discovery API. |
| core/numa.cc | ACPI SRAT/SLIT parsing + topology storage/query implementation. |
Suppressed comments (2)
core/numa.cc:127
- After switching
subto a byte pointer (needed to avoidvoid*arithmetic), the loop increment should advance the byte cursor directly. As written,sub = static_cast<void*>(sub) + s->Length;is also invalid C++ pointer arithmetic.
sub = static_cast<void*>(sub) + s->Length;
core/numa.cc:94
- The SRAT subtable walk only guards against
Length == 0, but doesn't verify thatLengthstays within the SRAT table bounds. A malformed SRAT could makesubstep pastendand lead to out-of-bounds reads.
if (s->Length == 0) {
break; // Guard against a malformed zero-length subtable.
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| void* sub = srat + 1; | ||
| void* end = static_cast<void*>(srat) + srat->Header.Length; | ||
| unsigned max_node = 0; | ||
|
|
||
| while (sub < end) { | ||
| auto s = static_cast<ACPI_SUBTABLE_HEADER*>(sub); |
There was a problem hiding this comment.
The void*+length arithmetic compiles under OSv's GCC (the GNU extension treats void* as char*), which is why this branch already builds and boots on KVM; but I will switch to explicit char* / static_cast to keep it standard C++ and avoid relying on the extension.
| for (auto& r : s_mem_ranges) { | ||
| if (phys >= r.base && phys < r.base + r.length) { | ||
| return (int)r.node; | ||
| } | ||
| } |
There was a problem hiding this comment.
Will use a subtraction-based range check (phys - r.base < r.length) in node_of_phys() to avoid a base+length overflow on a malformed range.
| 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); |
There was a problem hiding this comment.
Will bound the SLIT parse: validate LocalityCount against header->Length before reading n*n entries, so a malformed/oversized table cannot read past the buffer.
| if (!s_apic_to_node.empty() || !s_mem_ranges.empty()) { | ||
| s_available = true; | ||
| s_nr_nodes = max_node + 1; | ||
| } |
There was a problem hiding this comment.
Fair - proximity domain IDs can be sparse/large. I will map observed domains to a dense node index rather than using max_node+1 directly, so nr_nodes() reflects the real node count.
| 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; |
There was a problem hiding this comment.
Same as the get_mempolicy guard on the sibling PR: I will make the MPOL_F_NODE|MPOL_F_ADDR path return EFAULT on an unmapped user address instead of asserting in virt_to_phys_pt().
| tst-numa.so \ | ||
| tst-numa.so tst-numa-mempolicy.so \ |
There was a problem hiding this comment.
Will keep a single tst-numa.so entry and add tst-numa-mempolicy.so alongside it.
What
With NUMA topology now discovered (the
numa::module from the base PR), makethe topology-query syscalls report it instead of a hardcoded single node.
How
sys_getcpu()now fills the node-out argument withnuma::node_of_cpu()forthe calling CPU rather than always 0.
get_mempolicy():MPOL_F_NODEreturns the calling CPU's node.MPOL_F_NODE | MPOL_F_ADDRreturns the node backing the given address,resolved via a page-table walk (
virt_to_phys_pt) andnuma::node_of_phys().node 0) and rejects a
maxnodesmaller than the node count withEINVAL.set_mempolicy()stays a no-op: the topology is known but the physicalallocator is not yet node-aware, so a placement policy cannot be enforced. Its
comment is updated to say so; enforcement will come with the node-aware
allocator.
Adds
numa::node_of_phys()to map a physical address to its node.On a machine with no SRAT everything degrades to the previous single-node-0
behavior.
Testing
tests/tst-numa-mempolicy.ccchecks getcpu's node is in range and matchesnuma::node_of_cpu, get_mempolicy'sMPOL_F_NODEand allowed-mask (bit count== node count, maxnode-too-small
EINVAL), and theMPOL_F_ADDRpath. Verifiedon OSv under KVM single-node and with a QEMU 2-node
-numaconfig.Note
Depends on #1418 (numa: discover NUMA topology from ACPI SRAT/SLIT). Until that
merges, this PR's diff shows both commits.
(Recreated from #1419, which GitHub auto-closed when its branch was rebased onto current master. Same change, rebased and verified on master 3aba46c.)