NUMA Balancing: Automatic Page Migration in Modern Linux
Multi-socket servers with non-uniform memory access (NUMA) architectures create a performance dilemma: memory access speed depends on which CPU socket allocated the memory. Access local memory attached to the same socket, and you get full bandwidth. Access remote memory attached to a different socket, and you pay a latency penalty of 40-60% and consume interconnect bandwidth.
The challenge is that allocation happens at page fault time, not necessarily where the page will be accessed most frequently. A thread might allocate memory on node 0, then migrate to node 1 where it spends most of its execution time, resulting in constant remote memory access. Multiply this across hundreds of threads and the performance impact compounds.
How Automatic NUMA Balancing Works
Linux’s automatic NUMA balancing, enabled by default since kernel 3.13, treats memory placement as a continuous optimization problem rather than a set-once decision. The kernel periodically marks pages as inaccessible using the page table’s present bit, forcing a soft page fault on the next access. These faults don’t trigger actual I/O; they’re simply hooks that let the kernel observe where pages are being accessed.
When a page fault occurs, the kernel records which CPU and NUMA node triggered it. If the faulting node differs from where the page physically resides, the kernel considers migration. The decision isn’t automatic—it weighs access frequency, migration cost, and memory pressure. Migrating a 4KB page means copying data across the interconnect and updating page tables, work that only pays off if the page will be accessed many times from the new location.
The kernel maintains per-page access history using a simple but effective heuristic: it tracks the last node to fault on each page. When multiple threads on different nodes access the same page, the kernel may leave it in place rather than thrashing it back and forth. This prevents pathological cases where shared data ping-pongs between nodes.
Task Grouping and Thread Awareness
NUMA balancing doesn’t just move memory—it also considers moving threads. If a group of threads consistently access memory on a remote node, it may be cheaper to migrate the threads to that node rather than migrate all their memory. The kernel groups related threads (typically from the same process) and tracks their collective memory access patterns.
This two-way optimization—moving either pages to CPUs or CPUs to pages—is what makes automatic NUMA balancing effective for diverse workloads. A thread-heavy application with modest memory might see thread migration. A memory-intensive single-threaded application might see page migration. A database with worker threads tied to specific connection pools might see selective page migration without thread movement.
Observing and Tuning
The kernel exposes NUMA balancing statistics through /proc/vmstat. The numa_hint_faults counter shows how many scanning faults occurred, while numa_pages_migrated reveals actual migration activity. Large ratios between these numbers suggest the kernel is conservative about migration, which is generally correct—unnecessary migrations waste interconnect bandwidth.
Most workloads benefit from the default behavior, but edge cases exist. Workloads with truly random access patterns may see overhead from scanning without migration benefit. Some real-time applications disable NUMA balancing entirely to ensure deterministic memory access latency. The behavior is controlled by /proc/sys/kernel/numa_balancing, which accepts 0 (disabled), 1 (enabled), or 2 (enabled with more aggressive scanning).
When It Matters
NUMA balancing shows its value on multi-socket servers running workloads that weren’t explicitly NUMA-aware at the application level. Databases, application servers, and data processing pipelines often see 10-30% throughput improvements on NUMA hardware simply from enabling the feature. The gains are largest when threads have stable memory working sets and run long enough for migration to amortize its cost.
For containerized workloads, NUMA balancing interacts with CPU pinning and memory policies. Containers with unrestricted CPU access benefit most, since the kernel can migrate both threads and memory. Containers with strict CPU pinning lose the thread migration option, leaving only page migration.
The mechanism represents a broader shift in systems design: moving complexity from application developers to the kernel. Rather than requiring every application to be NUMA-aware, the kernel observes actual runtime behavior and optimizes accordingly. It’s not perfect—explicit placement still wins for specialized workloads—but it raises the baseline performance for the common case where no one tuned anything at all.