NUMA Memory Policies

On a Non-Uniform Memory Access (NUMA) machine, which node a page is allocated on is a performance decision — local memory is faster than remote — and NUMA memory policy is the mechanism by which userspace (and the kernel’s defaults) make that decision. A policy is a triple: a mode (MPOL_DEFAULT, MPOL_BIND, MPOL_INTERLEAVE, MPOL_WEIGHTED_INTERLEAVE, MPOL_PREFERRED, MPOL_PREFERRED_MANY, or MPOL_LOCAL), an optional set of nodes the mode acts on, and optional mode flags that modify how the node set is interpreted under cpuset changes (per the kernel numa_memory_policy.rst). Crucially, a policy is attached at one of several scopes — a hard-coded system default, a per-task default set with set_mempolicy(2), or a per-range (per-VMA) policy set with mbind(2) — and the kernel resolves the effective policy for any allocation by falling back from most-specific to least-specific scope. This note covers the system-call interface (set_mempolicy, mbind, get_mempolicy), each mode, the scope hierarchy, and the numactl(8) command-line front end, as documented for the Linux 6.12 LTS kernel (released 2024-11-17) and man-pages 6.18 (the current man-pages release at the time of writing). Memory policy is only present when the kernel is built with CONFIG_NUMA (per numa(7)).

Mental Model — Policy Resolution by Scope

The single most important idea is that there is no one “the policy” for a process. The kernel keeps policies at three (conceptually four) nested scopes, and resolves the effective policy for a specific allocation by starting at the most specific scope that covers it and falling back outward when that scope is “default.”

flowchart TD
  ALLOC["A page must be allocated<br/>(fault on a virtual address)"]
  VMA{"Does the VMA covering<br/>this address have an<br/>explicit policy?<br/>(set by mbind)"}
  TASK{"Does the task have an<br/>explicit policy?<br/>(set by set_mempolicy)"}
  SYS["System Default Policy<br/>(hard-coded: local allocation<br/>once the system is up)"]
  ALLOC --> VMA
  VMA -->|"yes"| USEVMA["Use VMA policy"]
  VMA -->|"no / MPOL_DEFAULT"| TASK
  TASK -->|"yes"| USETASK["Use task policy"]
  TASK -->|"no / MPOL_DEFAULT"| SYS

How the kernel picks the policy that governs one allocation. What it shows: MPOL_DEFAULT at any scope does not mean “local allocation” — it means “fall through to the next, more general scope.” Only the system default, at the bottom, is concretely “local allocation” (allocate on the node of the CPU that faulted). The insight to take: to reason about where a page lands, you must know which scope installed the governing policy. A per-VMA MPOL_BIND overrides a per-task MPOL_INTERLEAVE, which overrides the system local-allocation default — and get_mempolicy(2) with MPOL_F_ADDR is how you ask the kernel which one actually applies to a given address.

The Scopes in Detail

The kernel’s numa_memory_policy.rst describes the scopes “from most general to most specific.”

System Default Policy is “hard coded into the kernel.” Once the system is up and running it is local allocation — allocate on the node of the CPU that triggered the fault. (During early boot it is temporarily interleave-across-all-nodes, “so as not to overload the initial boot node with boot-time allocations.”) This is the policy every allocation falls back to when nothing more specific applies.

Task/Process Policy is the optional per-task default set with set_mempolicy(2). It “applies to the entire address space of a task” and governs every allocation not covered by a more specific scope. It is inherited across fork()/clone() (without CLONE_VM) and preserved across execve() — which lets a wrapper (like numactl) set a policy and then exec a NUMA-unaware program that inherits it. In a multithreaded process, a task policy applies only to the thread that installs it and to threads it later creates; sibling threads that already existed keep their own. And it is not retroactive: “A task policy applies only to pages allocated after the policy is installed. Any pages already faulted in… remain where they were allocated.”

VMA Policy is a per-range policy set with mbind(2) on [addr, addr+len). It governs allocations backing that range only; ranges with no explicit VMA policy fall back to the task policy. VMA policy has sharp edges spelled out in the kernel doc:

  • It “applies ONLY to anonymous pages” — anonymous mappings (MAP_ANONYMOUS), the stack and heap, and the copy-on-write pages of a MAP_PRIVATE file mapping (the policy fires at the COW write). A VMA policy on a MAP_SHARED file mapping is ignored; those page-cache pages follow task/system policy instead.
  • VMA policies are shared between threads of a process (they live in the shared address space) and inherited across fork(), but NOT across execve() — the address space is torn down and rebuilt, so “only NUMA-aware applications may use VMA policies.”
  • Installing a policy on a sub-range of an existing mapping causes the kernel to split the VMA into 2 or 3 pieces, each with its own policy (the VMA split mechanism).

Shared Policy is a fourth scope for shared memory objects (shmget()/mmap(MAP_ANONYMOUS|MAP_SHARED)): the policy attaches to the object, not to one task’s address-space range, so every task attaching to it obeys the same policy regardless of who faults the page.

Components: Modes, Node Sets, and Mode Flags

A policy is “a mode, optional mode flags, and an optional set of nodes” (kernel doc). The nodemask is a bitmask of node IDs (bit n set ⇒ node n is in the set), and maxnode bounds how many bits the kernel reads. The modes (per set_mempolicy(2) / mbind(2), with version provenance):

  • MPOL_DEFAULT — Remove any non-default policy at this scope; fall back to the next more general scope (see the mental model). nodemask must be empty (NULL/zero maxnode).

  • MPOL_BIND — A strict policy: allocations come only from nodes in nodemask; the kernel will not go off-mask even under pressure (which is what makes it strict — it will reclaim or fail rather than spill to an unlisted node). Since Linux 2.6.26 the in-mask selection picks “the node with sufficient free memory that is closest to the node where the allocation takes place” rather than strict lowest-ID-first ordering.

  • MPOL_INTERLEAVE — Spread page allocations round-robin across the nodes in nodemask, in node-ID order. This “optimizes for bandwidth instead of latency by spreading out pages and memory accesses… across multiple nodes.” The caveat: a single page’s accesses still hit one node’s bandwidth; interleave only helps a large region with a spread-out access pattern (the man page suggests “at least 1 MB”). It is the classic policy for a large shared database buffer pool.

  • MPOL_WEIGHTED_INTERLEAVE (since Linux 6.9 — present in 6.12) — Like MPOL_INTERLEAVE, but allocations are distributed across nodes in proportion to per-node weights set in /sys/kernel/mm/mempolicy/weighted_interleave/nodeN. The kernel doc’s example: nodes [0,1] weighted [5,2] ⇒ “5 pages will be allocated on node0 for every 2 pages allocated on node1.” Weights are 1–255, default-resettable by writing 0 or an empty string (per the sysfs ABI doc at v6.12). This exists for heterogeneous-memory systems (e.g. CXL-attached memory with different bandwidth than local DRAM), where uniform round-robin would underuse the higher-bandwidth tier. Weights affect only new allocations and never migrate already-placed pages.

    Uncertain automatic weight derivation (an auto mode reading bandwidth from the firmware HMAT/ACPI tables, rather than manually-written per-node weights) is available in 6.12/6.18. The v6.12 sysfs ABI doc describes only manual nodeN weights (with a "system default... set by the kernel or drivers"), with no auto knob; auto-weighting work landed in later mainline. Reason: feature timeline not pinned to 6.12/6.18 source. To resolve: grep the v6.18 tree for an auto sysfs attribute under weighted_interleave/. uncertain

    Verify: whether

  • MPOL_PREFERRED — A soft preference for one node: allocate there first, but “fall back to ‘near by’ nodes if the preferred node is low on free memory.” An empty nodemask makes it equivalent to local allocation. This is the policy to use when you want locality but cannot tolerate allocation failure.

  • MPOL_PREFERRED_MANY (since Linux 5.15 — present in 6.12) — MPOL_PREFERRED generalized to a set of preferred nodes: prefer any node in nodemask, and only on pressure across all of them spill to other nodes. “Intended to benefit page allocations where specific memory types (i.e., non-volatile, high-bandwidth, or accelerator memory) are of greater importance than node location.”

  • MPOL_LOCAL (since Linux 3.8) — Explicitly request local allocation (the node of the faulting CPU), with soft fallback to other nodes under pressure. nodemask must be empty. It differs from MPOL_DEFAULT in that MPOL_DEFAULT removes the policy (falling back to a possibly-non-local outer scope), whereas MPOL_LOCAL is a concrete local-allocation policy at this scope.

Mode Flags

A mode may be OR-ed with one optional flag, governing what happens when the task’s allowed-node set (its cpuset, see cpusets) changes underneath it:

  • MPOL_F_STATIC_NODES (since 2.6.26) — Treat nodemask as physical node IDs; do not remap it when the cpuset’s allowed nodes change. Applied to the intersection of the user mask and the cpuset; if they do not overlap, default behavior is used.
  • MPOL_F_RELATIVE_NODES (since 2.6.26) — Treat nodemask as relative to the cpuset’s allowed set, remapping it as that set changes (preserving the relative positions). Mutually exclusive with MPOL_F_STATIC_NODES.
  • MPOL_F_NUMA_BALANCING (since Linux 5.12, valid only with MPOL_BIND) — Enable automatic NUMA balancing for the task. Using it with any other mode returns EINVAL.

The System Calls

#include <numaif.h>
 
long set_mempolicy(int mode, const unsigned long *nodemask, unsigned long maxnode);
long mbind(void *addr, unsigned long len, int mode,
           const unsigned long *nodemask, unsigned long maxnode, unsigned int flags);
long get_mempolicy(int *mode, unsigned long *nodemask, unsigned long maxnode,
                   void *addr, unsigned long flags);

set_mempolicy installs the task policy. mode is one of the modes above, optionally OR-ed with a flag; nodemask/maxnode give the node set. It “defines the default policy for the thread”; the policy applies only “when a new page is allocated” — for anonymous memory, on first touch. All modes except MPOL_DEFAULT require a non-empty nodemask (and MPOL_LOCAL/MPOL_DEFAULT require an empty one).

mbind installs the VMA policy on [addr, addr+len) (addr must be page-aligned). Its flags argument controls what happens to pages already present in the range — by default mbind only affects future allocations:

  • MPOL_MF_STRICT — Fail with EIO if existing pages already violate the policy (a verification mode).
  • MPOL_MF_MOVE — Attempt to migrate existing (non-shared) pages to conform to the new policy.
  • MPOL_MF_MOVE_ALL — Migrate existing pages even if shared with other processes; requires CAP_SYS_NICE.

The migration here is exactly page migration (available via mbind since 2.6.16). Note the shared-mapping rules from the man page: MAP_SHARED file mappings ignore the mbind policy entirely; a MAP_PRIVATE file mapping’s policy fires only at COW; and SHM_HUGETLB segments only honor the policy when the page is faulted by the very process that called mbind.

get_mempolicy is the introspection call. With flags == 0 it returns the calling thread’s task policy (addr must be NULL). With MPOL_F_ADDR and a non-NULL addr, it returns the effective policy governing that address — which may be a VMA policy different from the task policy. With MPOL_F_NODE | MPOL_F_ADDR it returns the node ID where addr is currently allocated (faulting a page in if none exists). With MPOL_F_MEMS_ALLOWED (since 2.6.24) it returns the set of nodes the thread is allowed to use (its cpuset’s mems), ignoring mode. These flags have mutual-exclusion rules (MPOL_F_MEMS_ALLOWED cannot combine with MPOL_F_ADDR or MPOL_F_NODE), enforced with EINVAL.

Uncertain

Verify: the set_mempolicy_home_node(2) system call (added Linux 5.17) lets a task set a “home node” for a range to refine MPOL_BIND/MPOL_PREFERRED_MANY allocation; it is referenced in the kernel numa_memory_policy.rst API section but was not separately fetched here. Reason: out of the three core calls in scope, not independently verified against its man page. To resolve: read set_mempolicy_home_node(2) and confirm its 5.17 provenance / 6.12 presence. uncertain

Worked Examples

From userspace with the raw syscalls (via libnuma’s thin wrappers)

#include <numaif.h>
#include <numa.h>
 
/* Interleave this process's future anonymous allocations across nodes 0 and 1. */
unsigned long mask = (1UL << 0) | (1UL << 1);   /* nodes 0,1 */
set_mempolicy(MPOL_INTERLEAVE, &mask, 8 * sizeof(mask));
 
/* Bind one large buffer strictly to node 1, and migrate any pages already there. */
void *buf = mmap(NULL, len, PROT_READ|PROT_WRITE,
                 MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
unsigned long n1 = (1UL << 1);
mbind(buf, len, MPOL_BIND, &n1, 8 * sizeof(n1), MPOL_MF_MOVE);
 
/* Ask: which node is the first page of buf actually on? */
int node;
get_mempolicy(&node, NULL, 0, buf, MPOL_F_NODE | MPOL_F_ADDR);

Line by line: the first set_mempolicy makes node-0/1 round-robin the task default, so any later malloc/stack/heap fault spreads across both nodes. The mbind then overrides that for the buf range with a strict MPOL_BIND to node 1, and MPOL_MF_MOVE migrates pages that were already faulted in (e.g. under the inherited interleave policy) onto node 1. The final get_mempolicy with MPOL_F_NODE|MPOL_F_ADDR reports the node the first page of buf physically sits on (faulting it in if needed). The 8 * sizeof(mask) is maxnode — the number of bits in the mask the kernel should read.

From the command line with numactl(8)

numactl is the standard front end; it sets the policy and execs the target program, which inherits the task policy (per numactl(8)):

numactl --hardware                              # show nodes, sizes, and the distance matrix
numactl --interleave=all bigdatabase args       # round-robin across all nodes
numactl --weighted-interleave=all db args       # weighted round-robin (uses sysfs weights)
numactl --cpunodebind=0 --membind=0,1 process   # run on node 0's CPUs; allocate only on 0 or 1
numactl --preferred=1 ./prog                     # soft-prefer node 1
numactl --preferred-many=0x3 ./prog              # soft-prefer the set {0,1}
numactl --localalloc ./prog                      # MPOL_LOCAL

--interleave/-iMPOL_INTERLEAVE; --weighted-interleave/-wMPOL_WEIGHTED_INTERLEAVE; --membind/-mMPOL_BIND; --preferred/-pMPOL_PREFERRED; --preferred-many/-PMPOL_PREFERRED_MANY; --localalloc/-lMPOL_LOCAL. The separate --cpunodebind/-N and --physcpubind/-C options set CPU affinity (which CPUs the task runs on) — a distinct knob from memory policy, though commonly paired with --membind so a task runs and allocates on the same node. --balancing enables NUMA balancing alongside the policy.

You can inspect the result without code via /proc/<pid>/numa_maps, which shows, per memory range, “the effective memory policy for that memory range and on which nodes the pages have been allocated” — e.g. N0=12 N1=11 for an interleaved range (per numa(7)).

Failure Modes and Misunderstandings

MPOL_DEFAULT means local allocation.” No — it means remove the policy at this scope and fall back outward. Only the system default is concretely local allocation. If you mbind(..., MPOL_DEFAULT, ...) a range, that range starts following the task policy, which might be MPOL_INTERLEAVE. To force local allocation regardless of outer scope, use MPOL_LOCAL.

MPOL_BIND is strict and can OOM a node. Because MPOL_BIND refuses to spill off-mask, an allocation that exhausts the bound nodes triggers reclaim and, failing that, the OOM killer — even when other nodes have free memory. This is by design (it is the “strict” mode) but is a frequent production surprise. If you want locality without that risk, use MPOL_PREFERRED/MPOL_PREFERRED_MANY.

Policy is not retroactive and not remembered across swap. All scopes apply “only to pages allocated after the policy is installed”; to relocate existing pages you need mbind with MPOL_MF_MOVE(_ALL) or migrate_pages(2)/move_pages(2). And per the man-page note, “Memory policy is not remembered if the page is swapped out” — a swapped-in page is placed by the policy in effect at swap-in time.

Interleave on a MAP_SHARED file does nothing. VMA policy “applies ONLY to anonymous pages.” A common mistake is mbind-ing an interleave policy over an mmap’d shared data file and expecting the page cache to spread; it will not — those pages follow task/system policy. Use shared-memory segments (shmget/MAP_ANONYMOUS|MAP_SHARED) for shared policy to take effect.

cpuset interaction silently remaps your mask. Without MPOL_F_STATIC_NODES, the kernel remaps your nodemask when the task’s cpuset mems change — so a policy “bound to node 2” can quietly start using a different node after a container is moved between cpusets. Use MPOL_F_STATIC_NODES if you mean physical node IDs.

Alternatives and When to Choose Them

Memory policy is the explicit, static placement tool. Its main alternative is Automatic NUMA Balancing, which migrates pages toward the CPUs actually touching them at runtime by sampling minor page faults — zero configuration, adapts to changing access patterns, but adds fault and migration overhead and reacts rather than dictates. Use explicit policy when you know the placement you want (a database pinning its buffer pool, a real-time task avoiding remote latency); rely on automatic balancing for general workloads whose access patterns you do not know ahead of time. The two can coexist: MPOL_F_NUMA_BALANCING with MPOL_BIND runs balancing within the bound node set. For relocating already-placed pages outside the policy framework, migrate_pages(2) (move all of a process’s pages between node sets) and move_pages(2) (move specific pages) are the lower-level primitives.

See Also