Memory Systems · NUMA and Affinity

Where a Memory Policy Attaches and How It Inherits

Policies belong to address ranges and to tasks, not to allocations, and the two interact at every fault; how splitting, forking, and exec change which policy is in force.

The memory policy interface has two entry points that look like they do the same thing and do not. mbind attaches a policy to a range of addresses. set_mempolicy attaches a policy to the calling task. A fault consults both, in a defined order, and the result is the policy that actually governs the page. Programs that use one and assume the other are the source of most policy bugs that survive testing.

Two scopes, one lookup

A virtual memory area carries a pointer to a shared policy object. A task carries a policy of its own. At a fault, the kernel resolves the effective policy from the most specific to the least: the area’s policy if it has one, otherwise the task’s policy, otherwise the system default. The system default is MPOL_DEFAULT, which means “the faulting node”.

The precedence explains an asymmetry that surprises people. A task that calls set_mempolicy with MPOL_BIND on node 1 and then faults a range that already has an area policy of MPOL_INTERLEAVE gets the interleave. The task’s setting never applies to that range. Conversely, a range with no area policy follows the task wherever the task goes, so two threads in the same process with different task policies faulting the same untouched range will place pages on different nodes.

MPOL_DEFAULT at the task level is not the same as “no policy”. Setting it explicitly clears the task’s policy, which restores the fall-through to the system default. Setting it on a range via mbind removes the area’s policy, which restores the fall-through to the task’s policy. The same constant means “unset at this level” in both places, and the level determines what it falls through to.

Splitting is where policies fragment

An area’s policy is attached to the area, so any operation that splits the area splits the policy association. mprotect on part of a range, munmap of a subrange, mremap with a move, and the mapping of a file at a different offset all produce multiple areas.

Each resulting area inherits the policy that was in force, so the split itself does not change placement. What changes is the bookkeeping. Adjacent areas with different policies cannot be merged by the kernel’s area-merging logic, so a program that repeatedly applies and removes policies to neighboring pages accumulates areas. The practical limit is the vm.max_map_count setting, which defaults to 65530 on current kernels; a program that fragments its address space past that limit starts failing mmap calls with ENOMEM even though memory is available.

The same fragmentation has a second cost: every fault looks up the area containing the address, and the lookup walks a tree. More areas means a deeper tree and slower faults. Neither cost is visible until a program has been running for a long time, which is why policy churn is best avoided by setting policies once on large ranges rather than repeatedly on small ones.

What fork does to a policy

A child inherits the parent’s task policy, and it inherits the parent’s area policies because it inherits the areas themselves. Pages, however, are shared copy-on-write, and their placement does not change at fork.

The combination produces a specific and common behavior. A parent that has faulted its whole heap on node 0 forks a child; the child’s task policy might be different, but every page it touches that the parent already faulted is on node 0. Only pages the child faults for the first time, whether the copy made on write or a newly grown region, are placed according to the child’s own policy. A child that writes to a large inherited buffer therefore splits its pages between the parent’s node, for the untouched ones, and its own node, for the copied ones, which is the worst of both arrangements for locality.

execve replaces the address space, and the task policy is not part of the address space, so the task policy is carried across it. This is the intended mechanism for applying placement to a program that knows nothing about memory policy: the launcher installs the policy and execs the real image. Area policies go the other way, since they are attached to regions of the address space that exec discards; a program that needs a policy on a particular range after exec has to attach one to that range itself.

Shared mappings break the model

A MAP_SHARED mapping is faulted once, for the whole system. The first process to touch a page allocates it under its own effective policy, and every other process that maps the same object gets that page at that node regardless of its policy.

There is no per-process view of placement for shared memory. Two processes on opposite nodes mapping the same shared buffer will have one of them accessing remotely, and a policy on one mapping cannot give it a private copy, because the page is one page. Moving it means migrating it for every process that maps it. This is why the placement of a shared region is a system-level decision rather than a per-process one, and why programs that care fault the region deliberately from a chosen node before any worker starts.

A policy can still be attached to the shared object rather than to a mapping. mbind accepts a shared memory segment, and the policy then belongs to the segment: every process that attaches it, and every one that faults a page of it afterward, allocates under that policy. Attaching a policy to an ordinary shared file mapping is not supported, and the call reports failure instead of silently applying it to one process’s view.

The page cache follows the same ordering. A file page is allocated when it is first brought in, on the node of the task that brought it, and it stays there until something migrates it; every later reader of that page, through any mapping, reaches it across whatever distance separates them.

Policy objects are shared, not copied

Internally, a policy is a small refcounted structure holding the mode and a nodemask. Areas that share the same policy share the object, so a fork does not copy the policy; both processes point at the same one.

Sharing the object is the reason a child created by fork inherits the parent’s placement behavior exactly: there is nothing to copy, and nothing to diverge. Changing the policy in the child allocates a new object, so the two processes diverge from that point without disturbing each other.

#define _GNU_SOURCE
#include <numaif.h>
#include <stdio.h>

/* Report the effective policy for one address, and its node mask. */
static void show_policy(void *addr)
{
    int mode;
    unsigned long nodemask[16];   /* enough for 1024 nodes */
    if (get_mempolicy(&mode, nodemask, 1024, addr, MPOL_F_ADDR) != 0) {
        perror("get_mempolicy");
        return;
    }
    printf("mode=%d first-node-word=%#lx\n", mode, nodemask[0]);
}

Querying with MPOL_F_ADDR reports the effective policy for the address, which is the resolved answer rather than the raw area setting. Querying without it reports the calling task’s policy. The two calls answer different questions, and comparing them is how a program determines whether an area policy is overriding its task setting.

The cpuset constraint sits above both

A third source of constraint is easy to overlook because it is not a memory policy at all. A cpuset cgroup restricts the CPUs a task may run on and the nodes it may allocate from, and the memory side of that restriction is enforced as an additional mask intersected with whatever policy is in force.

The consequence is that an allocation sees the smaller of two sets. A task with a bind policy over nodes 0 and 1, placed in a cpuset that allows only node 1, allocates from node 1 and never from node 0, whatever the policy asks for. A policy whose nodes are all outside the cpuset is rejected at installation with EINVAL. The exception is a policy marked as naming physical node numbers: it is accepted even when none of those nodes is permitted, and it then behaves as local allocation until the cpuset context admits one of them. Without that mark, the nodemask is interpreted relative to the cpuset, so the same numeric mask installed in two different cgroups can end up naming two different sets of nodes.

This ordering matters for containers. A container runtime that assigns a cpuset to a process, and a process that then sets its own policy, can narrow its placement but never widen it. The reverse mistake, assuming that a policy set inside a container overrides the container’s cpuset, produces allocations that silently land on the only node the cgroup permits regardless of what the policy says.

NUMA nodes that are not usable

Node state is mutable at runtime. A node can be taken offline by an administrator, typically to remove memory from a failing DIMM or to drain a partition before a hot-plug operation. The kernel’s node state machine has a distinct offline state in which the node still exists in the topology but no allocation is served from it.

A policy that names an offline node does not fail at the point of the policy call. The allocation falls through to the next node in the fallback order, which is what the fallback mechanism is supposed to do, and the result is placement that does not match the mask. A program that verifies its own placement after allocating will see pages on a node it did not ask for, with no way to distinguish that from a bug in its own policy code.

Nodes with no CPUs are a related case with a different cause. Firmware can describe memory attached to a device rather than to a processor, and such a node can be allocated from but cannot run a task. A policy that binds a range to a CPU-less node succeeds, and every access to it is remote by construction, because there is no core on that node to run locally. That is the correct arrangement for a memory-expansion device and a wrong one for anything a program intends to compute on.

Reading the state from outside

/proc/<pid>/numa_maps prints, for each area, the policy in force and the per-node page counts. The policy field shows the effective policy including the nodemask, and the counts show where the pages actually are. A mismatch between the two is the diagnosis: the policy is not what produced the placement.

Common signatures are worth memorizing. interleave with counts concentrated on one node means the pages were faulted before the policy was applied. bind:0 with pages on node 1 means the bind came after the fault, or that node 0 could not satisfy the allocation and the fallback ran. default with pages spread evenly means the area was faulted by many threads on many nodes, which is the healthy case for a parallel initialization.

The kernel’s own documentation for the policy interface is the authority on the exact ordering, and the per-node accounting that the maps file reports is produced by the same counters described in Measuring Remote Access with Hardware Counters. How the scheduler responds when a task’s pages and its CPU disagree is the subject of Automatic NUMA Balancing and Its Failure Modes.

References

  1. set_mempolicy(2) — Linux man-pages project
  2. mbind(2) — Linux man-pages project
  3. NUMA Memory Policy — Linux kernel documentation