mm: add node-preferring page allocation (alloc_page_on_node) - #1441
mm: add node-preferring page allocation (alloc_page_on_node)#1441gburd wants to merge 3 commits into
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.
First incremental step of a node-aware allocator, on top of the NUMA topology discovery from the earlier commits. Adds memory::alloc_page_on_node(node), which prefers a page whose physical memory lies on the requested NUMA node, falling back to a normal allocation when that node has no free memory in the global page-range allocator (or when NUMA is not available / the node is out of range). Implementation layers on top of the existing allocator without restructuring it: page_range_allocator::alloc_page_from_node() walks the free lists, finds a range whose physical address resolves (via numa::node_of_phys()) to the requested node, and carves one page from it the same way alloc() does; if none is found it returns nullptr and alloc_page_on_node() falls back. This is deliberately a best-effort hint, not full per-node pools: OSv drains much of physical memory into per-CPU L1/L2 pools that are not yet node-partitioned, so once a node's global ranges are exhausted the allocator falls back. It gives correct placement while node-local global memory is available and never fails or misplaces a page. Full per-node L1/L2 pools are future work (roadmap B2.2-full); the scheduler affinity and mbind/set_mempolicy enforcement build on this hint. Add tests/tst-numa-alloc.cc: basic allocation and writability, out-of-range and negative node fall back cleanly, and (with QEMU -numa) node-0 allocations land on node 0 while other nodes fall back cleanly with no crash or misplacement. Passes on OSv under KVM single-node and with a 2-node -numa config; tst-mmap (the shared allocator path) unaffected. Depends on the NUMA topology-discovery and getcpu/get_mempolicy changes.
There was a problem hiding this comment.
Pull request overview
This PR introduces initial NUMA awareness in OSv by adding NUMA topology discovery (via ACPI SRAT/SLIT), exposing that topology through a new numa:: API, updating Linux-compat syscalls to report real NUMA topology, and adding a best-effort node-preferring single-page allocator (memory::alloc_page_on_node()), plus tests to validate behavior.
Changes:
- Add
core/numa.cc+include/osv/numa.hhfor NUMA topology discovery and query APIs, and initialize it during boot. - Update
getcpu/get_mempolicyreporting and addmemory::alloc_page_on_node(node)backed by a node-preferring global-range scan with fallback. - Add NUMA-focused tests and wire them into the tests module build.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/tst-numa.cc | New test validating NUMA discovery invariants (nodes/cpu mapping/distances/ranges). |
| tests/tst-numa-mempolicy.cc | New test validating getcpu and get_mempolicy NUMA reporting behavior. |
| tests/tst-numa-alloc.cc | New test validating memory::alloc_page_on_node() placement and fallbacks. |
| modules/tests/Makefile | Adds the new NUMA tests to the tests module list. |
| Makefile | Links in the new core/numa.o object. |
| loader.cc | Initializes NUMA after ACPI initialization on x86_64. |
| linux.cc | Updates sys_getcpu and get_mempolicy to report discovered NUMA topology. |
| include/osv/pagealloc.hh | Declares memory::alloc_page_on_node(int node). |
| include/osv/numa.hh | New public header defining the NUMA discovery/query interface. |
| core/numa.cc | Implements SRAT/SLIT parsing, CPU/node and phys/node mappings, and distance queries. |
| core/mempool.cc | Adds page_range_allocator::alloc_page_from_node() and memory::alloc_page_on_node(). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| tst-numa.so \ | ||
| tst-numa.so tst-numa-mempolicy.so \ | ||
| tst-numa.so tst-numa-mempolicy.so tst-numa-alloc.so \ |
There was a problem hiding this comment.
Will consolidate the duplicate tst-numa.so entries into a single line in modules/tests/Makefile.
| // 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); | ||
| } |
There was a problem hiding this comment.
Genuine - a bad user pointer should not be able to trip an assert in virt_to_phys_pt(). I will guard the MPOL_F_NODE|MPOL_F_ADDR path so an unmapped address returns EFAULT instead of reaching the translation assert.
| // 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))); | ||
| } |
There was a problem hiding this comment.
Correct - the nmask output should be cleared before OR-ing the allowed-node bits so a caller's non-zero-initialized buffer does not corrupt the result. Will zero it first.
What
First incremental step of a node-aware allocator, on top of the NUMA topology
discovery (#1418) and getcpu/get_mempolicy (#1419) work. Adds
memory::alloc_page_on_node(node), which prefers a page whose physical memorylies on the requested NUMA node, falling back to a normal allocation when that
node has no free memory in the global page-range allocator (or when NUMA is
unavailable / the node is out of range).
How
Layers on top of the existing allocator without restructuring it:
page_range_allocator::alloc_page_from_node()walks the free lists, finds arange whose physical address resolves (via
numa::node_of_phys()) to therequested node, and carves one page the same way
alloc()does; if none isfound it returns
nullptrandalloc_page_on_node()falls back.This is deliberately a best-effort hint, not full per-node pools: OSv drains
much of physical memory into per-CPU L1/L2 pools that are not yet
node-partitioned, so once a node's global ranges are exhausted the allocator
falls back. It gives correct placement while node-local global memory is
available and never fails or misplaces a page. Full per-node L1/L2 pools are
future work; the scheduler affinity and
mbind/set_mempolicyenforcementbuild on this hint.
Testing
tests/tst-numa-alloc.cc: basic allocation and writability, out-of-range andnegative node fall back cleanly, and (with QEMU
-numa) node-0 allocations landon node 0 while other nodes fall back cleanly with no crash or misplacement.
Passes on OSv under KVM single-node and with a 2-node
-numaconfig;tst-mmap(the shared allocator path) unaffected.
Note
Depends on #1418 and #1419 (this branch stacks on them); until they merge the
diff shows their commits too.
(Recreated from #1430, which GitHub auto-closed when its branch was rebased onto current master. Same change, rebased and verified on master 3aba46c.)