This note covers the scheduler half of automatic NUMA balancing: how the kernel decides which NUMA node a task should run on and then pulls the task toward the memory it is actually touching. The mechanism is a feedback loop driven by NUMA hinting faults — sampled by deliberately poking page-table entries (the memory-side machinery owned by Automatic NUMA Balancing). Each hinting fault records which node holds the page that was touched; those counts accumulate in per-task and per-group arrays. Periodically task_numa_placement() reads the counts, declares the most-faulted node the task’s preferred node (p->numa_preferred_nid), and numa_migrate_preferred() tries to move the task to a CPU on that node. The deep tension this resolves is task-vs-memory placement: rather than only pushing pages toward the running task (the MM side), the scheduler also pulls the task toward its pages — and the two convergent pressures let a workload settle onto one node. The whole subsystem is gated by /proc/sys/kernel/numa_balancing and the static key sched_numa_balancing, and is enabled by default on NUMA hardware in Linux 6.12 LTS (per the task_numa_fault entry: if (!static_branch_likely(&sched_numa_balancing)) return;).
Everything below is pinned to Linux 6.12 LTS (released 2024-11-17) with the mechanism read from the v6.12kernel/sched/fair.c tree; the 6.18 LTS functions named here (task_numa_fault, task_numa_placement, numa_migrate_preferred, task_numa_migrate, sched_setnuma, migrate_swap/migrate_task_to) are present and structurally identical (verified against the v6.18 tree). Scope boundary: the page-migration side — the task_numa_work scanner that arms PTEs as PROT_NONE, do_numa_page, should_numa_migrate_memory, and migrate_misplaced_folio — belongs to Automatic NUMA Balancing and is not re-derived here. This note enters at the exact handoff in task_numa_fault() and follows the task.
Mental Model — Two Forces, One Node
Allocation places a page once, on the node of whichever CPU ran the allocating thread (first-touch policy). But the scheduler migrates threads for load balancing, working sets shift, and exec scrambles everything. A thread can end up running on node 1 while most of its working set sits on node 0, paying the interconnect tax on every load.
There are two ways to fix a task-memory mismatch, and Linux uses both at once:
Move the memory to the task — migrate the misplaced pages to the node where the task now runs. This is the MM side (Automatic NUMA Balancing).
Move the task to the memory — migrate the task to a CPU on the node that holds most of its pages. This is the scheduler side, the subject of this note.
These are not competing alternatives; they are convergent. As the scheduler nudges the task toward its dominant node and the MM nudges stray pages toward the task, the system reaches a fixed point where task and pages share a node and faults go quiet. The decision of which force to apply is implicit: the per-node fault counts tell the scheduler where the memory mass is, and it sets that as the preferred node; pages elsewhere get pulled by the MM side.
flowchart TB
FAULT["task_numa_fault(last_cpupid, mem_node, ...)<br/>called from do_numa_page (MM side)"]
ACC["accumulate per-node fault counts<br/>p->numa_faults[] (+ numa_group faults)"]
GATE{"time_after(jiffies,<br/>p->numa_migrate_retry)?"}
PLACE["task_numa_placement(p)<br/>pick max-fault node → preferred_nid"]
SETNUMA["sched_setnuma(p, max_nid)<br/>record p->numa_preferred_nid"]
PREF["numa_migrate_preferred(p)<br/>arm retry timer; if not on pref node:"]
MIG["task_numa_migrate(p)<br/>find best CPU on preferred node"]
MOVE["migrate_task_to() / migrate_swap()<br/>move task, or swap two tasks"]
FAULT --> ACC --> GATE
GATE -- yes --> PLACE --> SETNUMA
PLACE --> PREF --> MIG --> MOVE
GATE -- no --> ACC
The scheduler-side NUMA loop. What it shows: each hinting fault feeds task_numa_fault, which accumulates per-node counts and — only when the retry timer has expired — calls task_numa_placement (recompute the preferred node) then numa_migrate_preferred (try to move the task there, via task_numa_migrate, which may either move one task or swap two). The insight to take: placement is throttled and statistical — it never reacts to a single fault; it waits for enough samples and a timer, then makes at most one migration attempt, deliberately keeping task movement rare.
The Handoff: task_numa_fault → placement → migration
The seam between the MM side and the scheduler side is a single function. do_numa_page() (MM) resolves a hinting fault and calls task_numa_fault(last_cpupid, mem_node, pages, flags) in v6.12 fair.c. Walking its body:
Gate.if (!static_branch_likely(&sched_numa_balancing)) return; — when numa_balancing is disabled the whole path is a near-zero-cost static branch. if (!p->mm) return; skips kernel threads.
Allocate fault buffers lazily on first fault: p->numa_faults = kzalloc(... NR_NUMA_HINT_FAULT_BUCKETS * nr_node_ids ...). Every task that faults gets a small per-node histogram.
Classify private vs shared. Using the last_cpupid packed into the page (a truncated CPU+PID stamp), the code decides whether this task last touched the page (priv = 1) or a different task did. A shared access can pull the task into a numa_group (below) via task_numa_group().
The handoff (the load-bearing lines, v6.12 fair.c ~3216):
if (time_after(jiffies, p->numa_migrate_retry)) { task_numa_placement(p); numa_migrate_preferred(p);}
Placement and the migration attempt are rate-limited by p->numa_migrate_retry — they do not run on every fault, only after the per-task retry interval has elapsed. This is what keeps the task-placement cost bounded no matter how many faults stream in.
Record the sample.p->numa_faults[task_faults_idx(NUMA_MEMBUF, mem_node, priv)] += pages; adds to the node’s running count, plus a parallel NUMA_CPUBUF count indexed by the CPU’s node, and p->numa_faults_locality[local] += pages tracks local-vs-remote for the scan-rate feedback.
Computing the Preferred Node: task_numa_placement
task_numa_placement(p) (v6.12 fair.c ~2879) turns the raw counts into a decision. It is called under the gate above, no more than once per numa_scan_seq advance (if (p->numa_scan_seq == seq) return;). Its core loop iterates over every node, decaying the previous window’s counts (numa_faults[mem_idx] / 2) and folding in the new buffered counts, then tracks the node with the maximum faults — max_faults / max_nid. If the task is in a numa_group, it maximises over the group’s aggregated faults instead, so co-operating threads converge on one node together.
Two refinements before committing:
max_nid = numa_nearest_node(max_nid, N_CPU) — you cannot run a task on a CPU-less (memory-only) node, so the chosen node is snapped to the nearest node that has CPUs. This matters on memory-tiering / CXL configurations where some “nodes” are pure memory.
For groups, preferred_group_nid() can override max_nid to better consolidate an interleaved group.
Finally:
if (max_faults) { if (max_nid != p->numa_preferred_nid) sched_setnuma(p, max_nid);}update_task_scan_period(p, fault_types[0], fault_types[1]);
sched_setnuma(p, nid) (defined in v6.12 kernel/sched/core.c) records the new preferred node in p->numa_preferred_nid; it does so under task_rq_lock and, if the task is queued/running, dequeues and re-enqueues it so the runqueue’s nr_numa_running / nr_preferred_running counters (incremented/decremented in the fair-class enqueue path) stay accurate. Those counters tell the load balancer how many tasks are on their preferred node. The per-task fields it touches — numa_preferred_nid, numa_migrate_retry, the numa_faults array, numa_group, numa_scan_seq, and numa_faults_locality[3] — all live in task_struct (v6.12 include/linux/sched.h). update_task_scan_period() then feeds the local/remote ratio back into the scan rate — if accesses are mostly local, the scanner slows down (less overhead); if remote, it speeds up. That scan-rate machinery is shared with, and primarily documented by, Automatic NUMA Balancing; here it simply consumes the locality the scheduler observed.
Moving the Task: numa_migrate_preferred and task_numa_migrate
Knowing the preferred node is useless unless the task moves there. numa_migrate_preferred(p) (v6.12 fair.c 2624) is short and worth reading whole:
static void numa_migrate_preferred(struct task_struct *p){ unsigned long interval = HZ; if (unlikely(p->numa_preferred_nid == NUMA_NO_NODE || !p->numa_faults)) return; // no stats yet → nothing to do interval = min(interval, msecs_to_jiffies(p->numa_scan_period) / 16); p->numa_migrate_retry = jiffies + interval; // arm the retry gate if (task_node(p) == p->numa_preferred_nid) return; // already home → done task_numa_migrate(p); // otherwise, try to move}
Line by line: bail if there are no fault statistics; arm numa_migrate_retry to roughly one-sixteenth of the scan period (this is the timer the task_numa_fault gate checks, so a failed migration is retried periodically, not hammered); if the task is already on its preferred node, success — return; otherwise call task_numa_migrate().
task_numa_migrate(p) (v6.12 fair.c 2489) is where the real placement search happens. It builds a task_numa_env and:
Picks the lowest SD_NUMA scheduling domain (sd_numa) as the search scope — explicitly, per the comment, “as that would have the smallest imbalance and would be the first to start moving tasks about … we want to avoid any moving of tasks about.” It even halves the domain’s imbalance_pct into env.imbalance_pct. This is the direct dependency on Scheduling Domains and CPU Topology: the NUMA domain is the search radius.
If there is no sd_numa (e.g. a cpuset has fragmented the domain tree so the task is “trapped” in a sub-NUMA balance domain), it gives up: sched_setnuma(p, task_node(p)); return -EINVAL; — the task cannot leave, so pretend home is here.
It evaluates candidate CPUs on the preferred node (task_numa_find_cpu), scoring by how much the task weight and group weight improve (taskimp, groupimp — the fault-derived benefit of running on the destination minus the source). If the preferred node has no room, or the task is in a group spread across multiple active nodes, it scans other nodes too, only accepting a node where “both task and groups benefit.”
migrate_task_to vs migrate_swap — do not confuse with task-vs-memory
Once task_numa_migrate has a best destination CPU, it does one of two physical moves (v6.12 fair.c ~2605):
if (env.best_task == NULL) { ret = migrate_task_to(p, env.best_cpu); // destination CPU has room: just move p ...}ret = migrate_swap(p, env.best_task, env.best_cpu, env.src_cpu); // dest busy: SWAP p with best_task
migrate_task_to(p, best_cpu) — the destination CPU has spare capacity, so simply move p there. Defined in v6.12 kernel/sched/core.c, it refuses if best_cpu is outside p->cpus_ptr and otherwise hands the move to stop_one_cpu(curr_cpu, migration_cpu_stop, ...) — i.e. the actual migration is performed by the per-CPU stop task (Migration the Stop Class and the Migration Thread).
migrate_swap(p, best_task, ...) — the destination CPU is busy, but there is a task best_task over there that would itself be better off on p’s source node (or at least no worse). So the kernel swaps the two tasks: p goes to the preferred node, best_task comes back. This keeps both runqueues balanced — moving p alone would just create a new imbalance the load balancer would undo. The candidate best_task is chosen in task_numa_compare(), which prefers swapping in a task whose own preferred node is p’s source node.
This migrate_swap-vs-migrate_task_to choice is not the task-vs-memory tradeoff. It is purely how task placement resolves contention on the destination node: move one task if there is room, swap two if there is not. The task-vs-memory tradeoff is the higher-level one — pull the task to its pages (this note) versus push the pages to the task (Automatic NUMA Balancing). Keep them separate.
The xchg(&rq->numa_migrate_on, 1) guard around these moves ensures only one NUMA migration targets a given runqueue at a time, so concurrent placement attempts on many CPUs don’t stampede the same destination.
The numa_group — Co-Locating Cooperating Threads
A single task’s faults are not the whole story: threads of a process (or processes sharing memory, e.g. via shared mappings) should land on the same node so their shared pages are local to all of them. When task_numa_fault sees a shared access (a different PID’s stamp on the page), task_numa_group() joins the two tasks into a struct numa_group with its own aggregated faults[] array and total_faults. Placement then maximises over group faults, and task_numa_migrate weights group benefit (groupimp) alongside per-task benefit. The join rules are conservative: only join the bigger group (the smaller joins the larger; ties break on group address), always join threads of the same mm, and treat TNF_SHARED faults as join-worthy. This is the mechanism the original LWN coverage describes as weighting “shared faults more heavily to encourage processes sharing pages to remain on the same node.”
0 turns the whole loop off (the sched_numa_balancing static key goes false; task_numa_fault returns immediately).
1 (NORMAL) is the locality optimisation described here: “optimize page placement among different NUMA nodes to reduce remote accessing.” The doc is candid about the cost: “The unmapping of pages and trapping faults incur additional overhead that ideally is offset by improved memory locality but there is no universal guarantee. If the target workload is already bound to NUMA nodes then this feature should be disabled.”
2 (MEMORY_TIERING) optimises placement across fast and slow memory tiers (e.g. DRAM vs CXL/PMEM nodes). Note that in task_numa_fault, hinting faults on a non-top-tier node are skipped for placement purposes (if (!node_is_toptier(mem_node) && (sysctl_numa_balancing_mode & NUMA_BALANCING_MEMORY_TIERING ...)) return;) — tiering uses the faults differently.
Inspecting placement at runtime: per-task counters appear in /proc/<pid>/sched (e.g. numa_preferred_nid, numa_scan_seq, per-node numa_faults); systemic effects show in numastat and in the nr_numa_running / nr_preferred_running runqueue counters that sched_setnuma maintains. As-of 6.12/6.18 the default-enabled behaviour comes from CONFIG_NUMA_BALANCING_DEFAULT_ENABLED (see Automatic NUMA Balancing for the Kconfig dependency chain).
Failure Modes and Misunderstandings
“NUMA balancing migrates tasks instantly.” No — it is throttled at three levels: the numa_migrate_retry gate, the per-node-fault accumulation task_numa_placement needs before a node “wins,” and the numa_scan_period feedback that slows sampling once accesses are local. A task that has just moved nodes will not move again for a while.
Fighting an explicit binding. If you have already pinned a task with mbind/numactl/sched_setaffinity (CPU Affinity and sched_setaffinity) or cpusets, auto-NUMA’s pulls are wasted overhead and can conflict — the kernel doc explicitly says to disable it for node-bound workloads.
Trapped in a fragmented domain. A cpuset partition that puts a task in a balance domain with no sd_numa makes task_numa_migrate return -EINVAL immediately — the preferred node is pinned to the current node and the task never moves, even if its memory is elsewhere (Cpusets and CPU Partitioning).
Short-lived tasks pay cost, reap nothing. A task that exits before enough faults accumulate gets the scanning overhead with no placement benefit; this is part of why the feature is tunable off.
Ping-pong on truly shared, write-shared data. Pages written by threads on multiple nodes can thrash; the numa_group logic and the two-pass fault classification mitigate but do not eliminate this — numa_faults_locality[2] (migrate-fail count) feeds the scan-period backoff to dampen it.
Alternatives and When to Choose Them
Static binding — numactl --cpunodebind --membind, mbind(2), cpusets. Use when you know the layout and want determinism; then turn auto-NUMA off. Auto-NUMA is for workloads whose placement you can’t predict.
Interleaving — MPOL_INTERLEAVE spreads pages round-robin across nodes, trading worst-case locality for uniform bandwidth; appropriate for large shared structures touched uniformly. Auto-NUMA’s per-node consolidation is the opposite bet.
The plain load balancer (Scheduler Load Balancing) — balances for CPU utilisation, and is deliberately reluctant to cross far NUMA boundaries (it strips SD_BALANCE_FORK/EXEC/WAKE there, per Scheduling Domains and CPU Topology). Auto-NUMA is the only mechanism that moves a task toward its memory rather than toward an idle CPU — they pursue different objectives and run side by side.
See Also
Automatic NUMA Balancing — the memory-migration side: the PROT_NONE scanner (task_numa_work), do_numa_page, should_numa_migrate_memory, migrate_misplaced_folio (owned there, not duplicated here)
Scheduling Domains and CPU Topology — the sd_numa domain that scopes task_numa_migrate’s search; the stripped SD_BALANCE_* flags at far NUMA levels
Scheduler Load Balancing — utilisation balancing, distinct from NUMA placement; uses nr_preferred_running
NUMA Memory Model — the hardware: nodes, distance matrix, first-touch allocation