Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

NUMA Scheduling

Introduction

On NUMA (Non-Uniform Memory Access) architectures, the time to access memory depends on which CPU accesses which memory. Memory directly attached to a CPU’s node is “local” (fast, ~100ns), while memory on another node is “remote” (slower, ~150-300ns). The NUMA-aware scheduler in Linux ensures that tasks run on CPUs close to their memory, dramatically improving performance on multi-socket and chiplet-based systems.

Modern servers with multiple CPU sockets, AMD EPYC chiplets, and even Intel’s hybrid architectures all exhibit NUMA characteristics. Without NUMA-aware scheduling, performance can degrade by 20-40% for memory-intensive workloads.

NUMA Architecture

Hardware Topology

graph TB
    subgraph "NUMA Node 0"
        CPU0["CPU 0-15<br>(Cores 0-15)"]
        MEM0["Local Memory<br>64GB DDR5"]
        CPU0 --- MEM0
    end
    subgraph "NUMA Node 1"
        CPU1["CPU 16-31<br>(Cores 16-31)"]
        MEM1["Local Memory<br>64GB DDR5"]
        CPU1 --- MEM1
    end
    MEM0 <-->|"Interconnect<br>(QPI/UPI/Infinity Fabric<br>~150-300ns latency)"| MEM1

    style MEM0 fill:#38a169,color:#fff
    style MEM1 fill:#38a169,color:#fff
    style CPU0 fill:#3182ce,color:#fff
    style CPU1 fill:#3182ce,color:#fff

NUMA Distance Matrix

The SLIT (System Locality Information Table) from ACPI defines the relative distance between NUMA nodes. The kernel reads this at boot:

/* arch/x86/kernel/acpi/numa.c (simplified) */
void __init acpi_numa_slit_init(struct acpi_table_slit *slit)
{
    int i, j;
    for (i = 0; i < slit->locality_count; i++) {
        for (j = 0; j < slit->locality_count; j++) {
            /* Distance from node i to node j */
            int d = slit->entry[i * slit->locality_count + j];
            /* Store in numa_distance[] matrix */
            numa_distance[i][j] = d;
        }
    }
}

The distance is relative: 10 = local (same node), higher values = proportionally slower. The kernel uses this for fallback allocation decisions.

# Show NUMA nodes
numactl --hardware
# available: 2 nodes (0-1)
# node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
# node 0 size: 65536 MB
# node 0 free: 32768 MB
# node 1 cpus: 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
# node 1 size: 65536 MB
# node 1 free: 48000 MB
# node distances:
# node   0   1
#   0:  10  21
#   1:  21  10

# Detailed topology
lstopo --no-io --of txt
# Or:
lscpu | grep -i numa
# NUMA node(s):        2
# NUMA node0 CPU(s):   0-15
# NUMA node1 CPU(s):   16-31

# Show distance matrix
cat /sys/devices/system/node/node*/distance
# 10 21
# 21 10

# Check NUMA memory info per node
cat /sys/devices/system/node/node0/meminfo
# Node 0 MemTotal:    67108864 kB
# Node 0 MemFree:     33554432 kB
# Node 0 MemUsed:     33554432 kB

Multi-Socket vs Chiplet NUMA

Modern CPUs exhibit NUMA characteristics even within a single socket:

graph TB
    subgraph "AMD EPYC Single Socket (4 CCDs)"
        subgraph "NUMA Node 0 (CCD 0-1)"
            C0["Cores 0-15<br>L3: 32MB"]
            M0["Memory Channel 0-1"]
        end
        subgraph "NUMA Node 1 (CCD 2-3)"
            C1["Cores 16-31<br>L3: 32MB"]
            M1["Memory Channel 2-3"]
        end
        C0 <-->|"Infinity Fabric"| C1
    end
# Check if NPS (NUMA nodes per socket) is configured
# AMD EPYC: NPS setting in BIOS determines topology
# NPS1: All cores in one NUMA node (flat)
# NPS2: 2 NUMA nodes per socket
# NPS4: 4 NUMA nodes per socket

# Detect actual topology
numactl --hardware | head -3
# NPS1: available: 1 nodes (0)
# NPS2: available: 2 nodes (0-1)
# NPS4: available: 4 nodes (0-3)

Scheduling Domains

The Linux scheduler organizes CPUs into a hierarchy of scheduling domains. Each domain represents a set of CPUs that share certain properties (caches, NUMA nodes, physical packages). The scheduler uses this hierarchy for load balancing decisions.

Domain Hierarchy

graph TD
    NUMA["NUMA Domain<br>All CPUs in system<br>Slowest migration"]
    NUMA --> MC0["MC Domain Node 0<br>CPUs 0-15 on socket 0<br>Medium migration"]
    NUMA --> MC1["MC Domain Node 1<br>CPUs 16-31 on socket 1<br>Medium migration"]
    MC0 --> SMT0["SMT Domain<br>Hyper-thread pairs<br>Fastest migration"]
    MC1 --> SMT1["SMT Domain<br>Hyper-thread pairs<br>Fastest migration"]

    style NUMA fill:#d69e2e,color:#fff
    style MC0 fill:#2b6cb0,color:#fff
    style MC1 fill:#2b6cb0,color:#fff
    style SMT0 fill:#38a169,color:#fff
    style SMT1 fill:#38a169,color:#fff

Scheduling Domain Kernel Structures

/* include/linux/sched/sd_flags.h — domain flags */
#define SD_SHARE_CPUCAPACITY   0x0001  /* SMT: share CPU capacity */
#define SD_SHARE_PKG_RESOURCES 0x0002  /* MC: share package resources */
#define SD_NUMA                0x0004  /* NUMA domain */
#define SD_SHARE_POWERDOMAIN   0x0008  /* Share power domain */

/* kernel/sched/topology.c — domain build */
struct sched_domain *build_sched_domain(struct sched_domain_topology_level *tl,
                                         const struct cpumask *cpu_map,
                                         struct sched_domain_attr *attr,
                                         struct sched_domain *child,
                                         int cpu)
{
    struct sched_domain *sd;

    sd = *per_cpu_ptr(d.sd, cpu);
    sd->flags = tl->flags;
    sd->span_weight = cpumask_weight(tl->mask(cpu));

    /* NUMA domains have higher imbalance tolerance */
    if (sd->flags & SD_NUMA)
        sd->imbalance_pct = 125;  /* Allow 25% imbalance before migrating */

    return sd;
}

Inspecting Scheduling Domains

# View scheduling domain information
cat /proc/sys/kernel/sched_domain/cpu0/domain0/name
# SMT
cat /proc/sys/kernel/sched_domain/cpu0/domain1/name
# MC
cat /proc/sys/kernel/sched_domain/cpu0/domain2/name
# NUMA

# Domain parameters
ls /proc/sys/kernel/sched_domain/cpu0/domain0/
# busy_factor        cache_nice_tries  imbalance_pct
# max_interval       min_interval      name
# newidle_idx        wake_idx          forkexec_idx

# Balance interval (ms)
cat /proc/sys/kernel/sched_domain/cpu0/domain0/min_interval
# 4
cat /proc/sys/kernel/sched_domain/cpu0/domain0/max_interval
# 400

# Imbalance percentage (higher = less eager to migrate)
cat /proc/sys/kernel/sched_domain/cpu0/domain2/imbalance_pct
# 125  (NUMA domain: more tolerant of imbalance)

Domain Load Balancing Intervals

Each domain level has different balance intervals:

Domainmin_intervalmax_intervalBehavior
SMT1ms4msVery frequent balancing
MC4ms64msModerate balancing
NUMA8ms400msInfrequent, expensive balancing

The scheduler uses exponential backoff: if a balance attempt finds nothing to migrate, the interval doubles up to max_interval.

NUMA Balancing

Linux implements Automatic NUMA Balancing (since Linux 3.8) using a mechanism called NUMA hinting faults. The kernel periodically unmaps pages and notes which CPU faults on them, building a picture of which nodes access which memory.

How NUMA Balancing Works

sequenceDiagram
    participant Task
    participant Kernel
    participant Node0 as NUMA Node 0
    participant Node1 as NUMA Node 1

    Kernel->>Kernel: Periodic PTE scan
    Kernel->>Node1: Clear access bit on page (on Node 1)
    Task->>Node1: Access page → NUMA fault
    Kernel->>Kernel: Record: task on CPU0, page on Node1
    Note over Kernel: After N faults on same page...
    Kernel->>Node0: Migrate page to Node 0
    Task->>Node0: Access page → local hit!

The NUMA balancing mechanism uses PROT_NONE PTE entries to detect page access patterns:

  1. Scan phase: The kernel periodically changes PTE permissions to PROT_NONE (no access) on random pages
  2. Fault phase: When the task accesses the page, a NUMA hinting fault occurs (not a real fault — the page is valid)
  3. Accounting: The kernel records which NUMA node faulted and which node the page is on
  4. Migration decision: If the page is accessed frequently from a remote node, it’s migrated locally
  5. Task migration: If most of a task’s memory is on another node, the task may be migrated
/* mm/mprotect.c — simplified NUMA hinting fault */
static vm_fault_t do_numa_page(struct vm_fault *vmf)
{
    struct page *page = vmf->page;
    int nid = page_to_nid(page);           /* Node where page lives */
    int cpu = smp_processor_id();          /* CPU that faulted */
    int last_nid = numa_pages_allocated[nid];

    /* Record the fault for later migration decisions */
    task_numa_fault(vmf->vma, vmf->address, nid, cpu);

    /* Restore proper PTE permissions */
    /* ... */

    return 0;
}

NUMA Balancing Scan Algorithm

The kernel scans a task’s memory at a rate proportional to its working set size:

/* kernel/sched/fair.c — task_numa_work() */
static void task_numa_work(struct callback_head *work)
{
    struct task_struct *p = current;
    struct mm_struct *mm = p->mm;
    unsigned long nr_pte_updates = 0;
    long runtime = p->se.sum_exec_runtime;

    /* Scan rate: proportional to task's memory footprint */
    /* Scan size: numa_balancing_scan_size_mb (default 256MB) */
    unsigned long pages_to_scan = numa_balancing_scan_size_mb *
                                   (1024 * 1024 / PAGE_SIZE);

    /* Walk VMAs and mark pages for NUMA hinting */
    walk_page_range(mm, start, end, &numa_walk_ops, &nr_pte_updates);

    /* Re-arm the scan timer with adaptive period */
    /* Period adapts based on how many faults were observed */
}

Configuring NUMA Balancing

# Enable/disable NUMA balancing (enabled by default)
cat /proc/sys/kernel/numa_balancing
# 1

echo 0 > /proc/sys/kernel/numa_balancing  # Disable
echo 1 > /proc/sys/kernel/numa_balancing  # Enable

# NUMA balancing settings (Linux 5.8+)
# Scan delay in milliseconds
cat /proc/sys/kernel/numa_balancing_scan_delay_ms
# 1000

# Scan period range
cat /proc/sys/kernel/numa_balancing_scan_period_min_ms
# 1000
cat /proc/sys/kernel/numa_balancing_scan_period_max_ms
# 60000

# Scan size (MB per scan)
cat /proc/sys/kernel/numa_balancing_scan_size_mb
# 256

# Promote/demote thresholds
cat /proc/sys/kernel/numa_balancing_promote_rate_limit_MBps
# 65536

Adaptive Scan Period

The scan period adapts based on observed NUMA behavior:

graph TD
    SCAN["Scan task memory"] --> FAULT{"How many NUMA<br>hinting faults?"}
    FAULT -->|"Few faults<br>(stable placement)"| SLOWER["Increase scan period<br>Scan less often"]
    FAULT -->|"Many faults<br>(unstable placement)"| FASTER["Decrease scan period<br>Scan more often"]
    SLOWER --> SCAN
    FASTER --> SCAN
  • Few faults → memory is well-placed → scan less often (save CPU)
  • Many faults → memory is misplaced → scan more often (migrate faster)

Monitoring NUMA Balancing

# NUMA event counters
grep -i numa /proc/vmstat
# numa_hit 12345678          ← Local allocation succeeded
# numa_miss 234567           ← Had to allocate on another node
# numa_foreign 123456        ← Another node's local memory used
# numa_interleave 8901       ← Interleaved allocations
# numa_local 12000000        ← Pages allocated locally
# numa_other 567890          ← Pages allocated remotely

# NUMA balancing stats
cat /proc/vmstat | grep numa_
# numa_pte_updates 45678     ← PTEs updated for NUMA
# numa_hint_faults 12345     ← Total hint faults
# numa_hint_faults_local 10000  ← Faults on local pages
# numa_pages_migrated 2345   ← Pages migrated between nodes

# Per-task NUMA stats
cat /proc/<pid>/numa_maps
# 00400000 default file=/usr/bin/myapp mapped=100 N0=80 N1=20
# 7f1234000000 anon dirty=50 active=45 N0=45 N1=5
# N0=80 means 80 pages on node 0, N1=20 means 20 pages on node 1

# Detailed per-VMA info
cat /proc/<pid>/numa_maps | column -t

Interpreting numa_maps

# numa_maps output format:
# <address> <policy> <anon>=<pages> <dirty>=<pages> <active>=<pages> N0=<p> N1=<p> ...

# Example analysis:
cat /proc/$(pidof postgres)/numa_maps
# 00400000 default file=/usr/lib/postgresql/14/bin/postgres mapped=100 N0=90 N1=10
# → 90% local, 10% remote — good placement

# 7f1234000000 anon dirty=500 N0=100 N1=400
# → 80% remote — might need migration or numactl pinning

# Policy field values:
# default    → MPOL_DEFAULT (use process default)
# bind:0     → MPOL_BIND (pinned to node 0)
# interleave → MPOL_INTERLEAVE (spread across nodes)
# preferred:1 → MPOL_PREFER (prefer node 1, fallback allowed)

Memory Placement Policies

Using numactl

# Run with memory interleaved across all nodes
numactl --interleave=all ./myapp

# Bind to node 0 (CPU and memory)
numactl --cpunodebind=0 --membind=0 ./myapp

# Bind to specific CPUs
numactl --cpubind=0-7 --membind=0 ./myapp

# Preferred node (fallback allowed)
numactl --preferred=0 ./myapp

# Local allocation (default)
numactl --localalloc ./myapp

# Complex: bind CPUs, interleave memory
numactl --cpunodebind=0 --interleave=0,1 ./myapp

# Check NUMA policy of running process
cat /proc/<pid>/numa_maps | head -5
# Shows memory layout and which nodes pages are on

Memory Policies in Code

#include <numaif.h>
#include <numa.h>
#include <stdlib.h>

int main() {
    /* Initialize libnuma */
    if (numa_available() < 0) {
        fprintf(stderr, "NUMA not available\n");
        return 1;
    }

    /* Set memory policy for the process */
    unsigned long nodemask = 1 << 0;  /* Node 0 */
    set_mempolicy(MPOL_BIND, &nodemask, sizeof(nodemask) * 8);

    /* Allocate memory — now goes to node 0 */
    void *ptr = malloc(1024 * 1024 * 512);  /* 512MB */
    memset(ptr, 0, 1024 * 1024 * 512);

    /* Or per-allocation policy */
    unsigned long target_node = 1;
    void *ptr2 = numa_alloc_onnode(1024 * 1024, target_node);

    /* Interleave allocation across nodes */
    set_mempolicy(MPOL_INTERLEAVE, NULL, 0);
    /* All subsequent allocations are interleaved */

    numa_free(ptr2, 1024 * 1024);
    return 0;
}
# Compile: gcc -lnuma numademo.c -o numademo

Memory Policy Kernel Implementation

/* mm/mempolicy.c — simplified set_mempolicy() */
SYSCALL_DEFINE3(set_mempolicy, int, mode, unsigned long __user *, nmask,
                unsigned long, maxnode)
{
    struct mempolicy *new;

    switch (mode) {
    case MPOL_DEFAULT:
        new = NULL;  /* Remove explicit policy */
        break;
    case MPOL_BIND:
        /* Only allocate from specified nodes */
        new = mpol_new(mode, nmask);
        break;
    case MPOL_PREFER:
        /* Prefer specified node, fallback to others */
        new = mpol_new(mode, nmask);
        break;
    case MPOL_INTERLEAVE:
        /* Round-robin page allocation across nodes */
        new = mpol_new(mode, nmask);
        break;
    }

    /* Apply to all future allocations */
    current->mempolicy = new;
    return 0;
}

Memory Tiering (Linux 5.15+)

Modern systems with multiple memory tiers (DRAM + CXL/persistent memory) use NUMA-based tiering:

graph TB
    subgraph "Fast Tier (DRAM)"
        N0["NUMA Node 0<br>DRAM: 128GB<br>Latency: ~80ns"]
        N1["NUMA Node 1<br>DRAM: 128GB<br>Latency: ~80ns"]
    end
    subgraph "Slow Tier (CXL/PMEM)"
        N2["NUMA Node 2<br>CXL Memory: 512GB<br>Latency: ~300ns"]
    end
    N0 <-->|"Hot pages stay"| N0
    N1 <-->|"Hot pages stay"| N1
    N2 -->|"Cold pages demoted"| N2
    N2 -->|"Hot pages promoted"| N0

    style N0 fill:#38a169,color:#fff
    style N1 fill:#38a169,color:#fff
    style N2 fill:#d69e2e,color:#fff
# Check memory tiers
cat /sys/devices/system/node/node*/meminfo | grep -i tier

# Node 0: Fast tier (DRAM)
# Node 1: Slow tier (CXL/persistent memory)

# Auto-promotion settings
echo 1 > /proc/sys/kernel/numa_balancing  # Enable

# Migration threshold (pages accessed more than this get promoted)
cat /proc/sys/kernel/numa_balancing_promote_rate_limit_MBps

NUMA and Cgroup cpuset

Cgroups v2’s cpuset controller can pin tasks to specific NUMA nodes:

# Create a cpuset cgroup pinned to NUMA node 0
mkdir /sys/fs/cgroup/numa_node0
echo "0-15" > /sys/fs/cgroup/numa_node0/cpuset.cpus
echo "0" > /sys/fs/cgroup/numa_node0/cpuset.mems

# Move a process into the cgroup
echo $PID > /sys/fs/cgroup/numa_node0/cgroup.procs

# The process can only use CPUs 0-15 and memory from node 0
graph LR
    subgraph "Cgroup: db_server"
        PROC["PostgreSQL<br>PID 1234"]
    end
    PROC -->|"cpuset.cpus = 0-15"| CPU["NUMA Node 0 CPUs"]
    PROC -->|"cpuset.mems = 0"| MEM["NUMA Node 0 Memory"]

Practical Performance Tuning

Case Study: Database Server

# Bad: Database processes scattered across nodes
pg_start  # Runs on whatever CPU the scheduler picks

# Good: Pin PostgreSQL to node 0
numactl --cpunodebind=0 --membind=0 pg_start

# Or for specific shared_buffers allocation
numactl --interleave=all pg_start  # Better for shared buffers

# Check current NUMA distribution
numastat -p postgres
# Per-node process memory usage (MB)
#                  Node 0   Node 1    Total
# ---------------  ------   ------   ------
# postgres           4096        0     4096

Case Study: In-Memory Database (Redis/Memcached)

# Redis: single-threaded, pin to one NUMA node
numactl --cpunodebind=0 --membind=0 redis-server

# For multi-instance: one instance per NUMA node
numactl --cpunodebind=0 --membind=0 redis-server --port 6379
numactl --cpunodebind=1 --membind=1 redis-server --port 6380

Identifying NUMA Issues

# Watch for NUMA misses in real-time
watch -n 1 'grep -E "numa_(hit|miss|local|other)" /proc/vmstat'

# High miss ratio indicates problem
# numa_miss / (numa_hit + numa_miss) > 0.1 = investigate!

# Check which processes have remote memory
for pid in $(pgrep myapp); do
    echo "PID $pid:"
    numastat -p $pid 2>/dev/null | grep -E "Total|Other"
done

# Find processes with significant remote allocation
numastat | grep -E "numa_miss|numa_foreign"

System-wide NUMA Statistics

# Per-node summary
numastat
# node0           node1
# numa_hit      12345678    11234567
# numa_miss       234567      345678
# numa_foreign     345678      234567
# interleave_hit    8901        8901
# local_node     12000000    10900000
# other_node       567890      678901

# Per-process NUMA hit/miss
numastat -c | head -20

NUMA-Aware Application Design Patterns

graph TD
    subgraph "Pattern 1: Partitioned Workers"
        W1["Worker 0<br>allocates on Node 0<br>runs on CPU 0-7"]
        W2["Worker 1<br>allocates on Node 1<br>runs on CPU 8-15"]
    end
    subgraph "Pattern 2: First-Touch"
        FT["Thread 0 allocates → Node 0 local<br>Thread 1 allocates → Node 1 local<br>(default Linux policy)"]
    end
    subgraph "Pattern 3: Interleaved Shared"
        SH["Shared buffer<br>interleave=all<br>spreads across nodes"]
    end

NUMA and the Scheduler

The scheduler’s NUMA placement decisions are influenced by several tunables:

# Scheduler migration cost (nanoseconds)
cat /proc/sys/kernel/sched_migration_cost_ns
# 500000  (0.5ms — includes cache warm-up time)

# NUMA balance interval
cat /proc/sys/kernel/sched_numa_balancing_period_min_ms
# 1000

# Threshold for task migration
cat /proc/sys/kernel/sched_numa_balancing_migrate_deferred
# 1  (defer migration to avoid bouncing)

# Preferred node for new tasks
cat /proc/sys/kernel/sched_numa_prefer_sibling
# 0

NUMA Task Placement Decision

When a task wakes up, the scheduler considers NUMA topology:

graph TD
    WAKE["Task wakes up"] --> PREV{"Previous CPU<br>idle?"}
    PREV -->|Yes| RUNPREV["Run on previous CPU<br>(cache warm)"]
    PREV -->|No| SAME{"Same NUMA node<br>idle CPU available?"}
    SAME -->|Yes| RUNLOCAL["Run on same node<br>(memory local)"]
    SAME -->|No| OTHER{"Other node<br>idle CPU available?"}
    OTHER -->|Yes| RUNREMOTE["Run on other node<br>(memory remote)"]
    OTHER -->|No| BALANCE["Load balancer picks<br>least loaded CPU"]

    style RUNPREV fill:#38a169,color:#fff
    style RUNLOCAL fill:#3182ce,color:#fff
    style RUNREMOTE fill:#d69e2e,color:#fff

Preventing NUMA Bouncing

NUMA “bouncing” occurs when a task or page frequently migrates between nodes:

# Increase imbalance tolerance (less eager migration)
echo 150 > /proc/sys/kernel/sched_domain/cpu0/domain2/imbalance_pct

# Defer migrations
echo 1 > /proc/sys/kernel/sched_numa_balancing_migrate_deferred

# Increase migration cost estimate
echo 1000000 > /proc/sys/kernel/sched_migration_cost_ns  # 1ms

# Disable automatic NUMA balancing entirely (if using manual pinning)
echo 0 > /proc/sys/kernel/numa_balancing

References

  • Process Priorities — CPU scheduling priority
  • Deadline Scheduling — Real-time scheduling
  • Cgroups — cpuset controller for NUMA pinning
  • Scheduling Domains — Domain hierarchy details
  • Memory Tiering — Hot/cold page migration