Scheduling Domains and CPU Topology

A scheduling domain (struct sched_domain) is the kernel’s model of one level of the CPU hardware hierarchy — a set of CPUs over which the scheduler is allowed to balance load — and the scheduling domains form a tree, built bottom-up from the tightest level (sibling hyperthreads sharing an execution core) out to the loosest (whole NUMA nodes connected by a slow interconnect). Each domain is partitioned into scheduling groups (struct sched_group); balancing happens between groups of a domain, never between bare CPUs, so the unit of comparison at every level is a group’s aggregate load. The whole point of the structure is cost-awareness: migrating a task between two hyperthreads of the same core is nearly free (they share L1/L2 cache), so the scheduler does it eagerly and often; migrating a task across NUMA nodes throws away its cache footprint and forces remote-memory accesses, so the scheduler does it reluctantly and rarely. The per-level SD_* flags encode exactly which balancing behaviours are allowed at each level, and that is how one data structure makes “balance aggressively here, conservatively there” fall out automatically (per the v6.12 Documentation/scheduler/sched-domains.rst: “A sched domain’s span means ‘balance process load among these CPUs’”).

Everything below is pinned to Linux 6.12 LTS (released 2024-11-17), with the data structures and default topology table read from the v6.12 source tree, and the 6.18 LTS (released 2025-11-30) differences called out where they exist. The two material 6.x changes for this note: the default-topology table was refactored to use an SDTL_INIT() macro in 6.18 (the levels are identical), and the /proc/schedstat format went from version 16 (6.12) to version 17 (6.18). Both are detailed below and verified against both trees.

Mental Model — A Tree of “Where May I Balance?”

A modern server is not a flat bag of CPUs. Two hardware threads on one physical core share the core’s caches and ALUs. Several cores on one chip share a Last-Level Cache (LLC, the L3). Several chips plug into one socket and one local memory bank to form a NUMA node. Multiple nodes connect over an interconnect (Intel UPI, AMD Infinity Fabric) where reaching another node’s memory costs more. Moving a runnable task between two CPUs costs more the further apart they sit in this hierarchy, because more of the task’s warm cache is left behind and more of its memory becomes remote.

The scheduler captures this with a per-CPU tree of sched_domains. For CPU i, the base (lowest) domain spans the CPUs closest to i (its SMT siblings); its ->parent spans a wider set (the LLC / chip); the parent of that spans the NUMA node; and the topmost spans the whole machine. The rule, stated in the v6.12 documentation, is that “A domain’s span MUST be a superset of its child’s span” and the chain “MUST be NULL terminated” (sched-domains.rst). Each domain carries SD_* flags that say what balancing is permitted at that level. The load balancer (see Scheduler Load Balancing) walks this tree from the base upward, and the higher it climbs, the rarer and more expensive the migrations it is willing to perform.

flowchart TB
  subgraph NUMA["NUMA domain (SD_NUMA, SD_SERIALIZE)<br/>span = all CPUs; groups = one per node"]
    subgraph PKG0["PKG domain — node 0<br/>span = node 0 CPUs"]
      subgraph MC0["MC domain (SD_SHARE_LLC)<br/>span = LLC-sharing cores"]
        SMT0["SMT domain<br/>(SD_SHARE_CPUCAPACITY)<br/>cpu0,cpu1 = 2 groups"]
        SMT1["SMT domain<br/>cpu2,cpu3 = 2 groups"]
      end
    end
    subgraph PKG1["PKG domain — node 1"]
      MC1["MC domain ..."]
    end
  end
  SMT0 --> MC0 --> PKG0 --> NUMA
  PKG1 --> NUMA

The per-CPU sched_domain tree for one CPU on a two-node SMT machine. What it shows: the base SMT domain (two hyperthreads of a core, each its own group), its MC parent (cores sharing the L3 / LLC), the PKG parent (the whole node), and the NUMA top spanning both nodes with one group per node. The insight to take: balancing always happens between the groups of one domain, and the flags get more restrictive as you climb — SD_SHARE_CPUCAPACITY at SMT, SD_SHARE_LLC at MC, SD_SERIALIZE/SD_NUMA at the top — so the same walk-up-the-tree code naturally balances hard within a node and gingerly across nodes.

The Two Structures: Domains and Groups

A sched_domain answers “which CPUs may I balance over, and how?” Its key fields, from the v6.12 include/linux/sched/topology.h and sd_init() in kernel/sched/topology.c:

  • span — the cpumask of CPUs this domain covers. “A domain’s span MUST be a superset of its child’s span.”
  • ->parent / ->child — the up/down links forming the tree, NULL-terminated at the top.
  • ->groups — a pointer into a circular, singly linked list of sched_groups. “The union of cpumasks of these groups MUST be the same as the domain’s span,” and “The group pointed to by the ->groups pointer MUST contain the CPU to which the domain belongs.”
  • flags — the SD_* bitset (below).
  • balance_interval, min_interval/max_interval, busy_factor — how often this level rebalances. sd_init() sets min_interval = sd_weight (the number of CPUs the domain spans), max_interval = 2*sd_weight, and busy_factor = 16 — so a domain that spans more CPUs balances less often, and a busy CPU stretches its interval by up to 16×, throttling expensive high-level balancing when the system is loaded.
  • imbalance_pct — how lopsided two groups must be before a task moves. sd_init() defaults it to 117 (a group must be ~17% busier to justify a pull), tightens it to 110 for SMT (SD_SHARE_CPUCAPACITY), and keeps 117 for an LLC-sharing level.

A sched_group is the unit of comparison. The documentation is explicit: “Balancing within a sched domain occurs between groups. That is, each group is treated as one entity. The load of a group is defined as the sum of the load of each of its member CPUs, and only when the load of a group becomes out of balance are tasks moved between groups.” So at the SMT level each hyperthread is its own one-CPU group; at the MC level each core (an SMT domain’s worth of CPUs) is one group; at the NUMA level each node is one group. Groups carry their own capacity accounting in a shared sched_group_capacity so the balancer can compare “load per unit of compute,” not raw load — important on the asymmetric cores discussed in Capacity-Aware and Energy-Aware Scheduling. Because group data is “read only … after [it has] been set up,” groups can be shared between the per-CPU domains of CPUs in the same span; the exception is overlapping NUMA groups, where SD_OVERLAP is set and sharing is disallowed.

The Default Topology Table

The domain tree is generated from a flat, ordered description: the sched_domain_topology array. The generic default in v6.12 kernel/sched/topology.c is, bottom-up:

static struct sched_domain_topology_level default_topology[] = {
#ifdef CONFIG_SCHED_SMT
	{ cpu_smt_mask,         cpu_smt_flags,     SD_INIT_NAME(SMT) },
#endif
#ifdef CONFIG_SCHED_CLUSTER
	{ cpu_clustergroup_mask, cpu_cluster_flags, SD_INIT_NAME(CLS) },
#endif
#ifdef CONFIG_SCHED_MC
	{ cpu_coregroup_mask,   cpu_core_flags,    SD_INIT_NAME(MC) },
#endif
	{ cpu_cpu_mask,                            SD_INIT_NAME(PKG) },
	{ NULL, },
};

Reading it line by line:

  • Each entry is a topology level: a function that returns the mask of CPUs at that level for a given CPU, a function that returns the level’s extra SD_* flags, and a name.
  • SMT (cpu_smt_mask) — sibling hardware threads of one physical core. Its flags (cpu_smt_flags() in topology.h) are SD_SHARE_CPUCAPACITY | SD_SHARE_LLC: the siblings literally share the core’s execution units and its caches.
  • CLS (cpu_clustergroup_mask, gated by CONFIG_SCHED_CLUSTER) — a cluster of cores sharing an intermediate cache or fabric tag (e.g. an L2 cluster on some Arm and Intel hybrid parts). Flags SD_CLUSTER | SD_SHARE_LLC.
  • MC (cpu_coregroup_mask) — the Multi-Core level: all cores sharing the Last-Level Cache. Flags SD_SHARE_LLC only.
  • PKG (cpu_cpu_mask) — the package: everything up to a NUMA node. It has no extra topology flags (the entry’s flags slot is empty).

Uncertain

Verify: the claim that the top intra-machine level was renamed from DIE to PKG (and that SD_SHARE_PKG_RESOURCES was renamed to SD_SHARE_LLC) and the exact release in which that happened. Reason: the v6.12 and v6.18 trees both already use PKG and SD_SHARE_LLC, but the task brief and many older docs/articles use DIE and SD_SHARE_PKG_RESOURCES; I did not consult the git history to pin the rename to a specific release. The current 6.12/6.18 names (PKG, SD_SHARE_LLC) are verified directly from both source trees and are not in doubt — only the rename date is. To resolve: git log --oneline -- kernel/sched/topology.c around the rename commits, or the LWN coverage of the change. uncertain

Where do the NUMA levels come from? They are not in default_topology[]. At boot, sched_init_numa() discovers the NUMA distance matrix and appends one topology level per distinct internode distance, building a tl[] array whose final entries are a NODE level and then one NUMA level per distance, with SD_INIT_NAME(NODE) / SD_INIT_NAME(NUMA) and the cpu_numa_flags() flag SD_NUMA (v6.12 topology.c, the tl[i] = (struct sched_domain_topology_level){ ... SD_INIT_NAME(NUMA) } loop around line 1985). This is why a four-node mesh with two distance classes gets two NUMA domain levels: near nodes and far nodes balance on different (slower) schedules.

The masks themselves come from the architecture’s CPU topology layer (struct cpu_topology cpu_topology[], populated per-CPU as the kernel parses ACPI/Device-Tree topology). cpu_smt_mask(), cpu_coregroup_mask(), etc. read that data, so the same generic builder produces correct trees on x86, arm64, and others; an architecture that needs a non-standard hierarchy overrides it by building its own sched_domain_topology_level array and calling set_sched_topology() at init (v6.12 topology.c).

Uncertain

Verify: that cpu_smt_mask/cpu_coregroup_mask/cpu_clustergroup_mask read from the arch-populated cpu_topology[] array on the architectures a reader cares about. Reason: these helpers are defined per-architecture (x86 and arm64 populate cpu_topology differently, and x86 historically used cpu_sibling_map-style masks); I verified the generic topology builder and the topology.h flag helpers from v6.12 but did not pull each arch’s topology.c. The level structure and flags are verified; only the per-arch mask plumbing is described generically. To resolve: read arch/x86/kernel/smpboot.c / arch/arm64/kernel/topology.c for the concrete population. uncertain

6.18 difference (verified): the same table appears in v6.18 but written with a macro — SDTL_INIT(tl_smt_mask, cpu_smt_flags, SMT), SDTL_INIT(tl_cls_mask, cpu_cluster_flags, CLS), SDTL_INIT(tl_mc_mask, cpu_core_flags, MC), SDTL_INIT(tl_pkg_mask, NULL, PKG) (v6.18 topology.c). The levels (SMT/CLS/MC/PKG + appended NUMA) and their flags are unchanged; only the spelling of the table moved to SDTL_INIT.

The SD_* Flags — Behaviour Per Level

The flags are declared in include/linux/sched/sd_flags.h, which is identical between v6.12 and v6.18 (verified). Each flag carries metaflags that say how it propagates through the tree: SDF_SHARED_CHILD (“if a domain has this flag, all of its children should too” — for shared-resource flags that hold from the base upward) and SDF_SHARED_PARENT (“all of its parents should have it” — for properties that only appear above some level). The load-balancing flags also carry SDF_NEEDS_GROUPS, meaning they are pointless unless the domain has more than one group to balance between. The important flags:

  • SD_BALANCE_NEWIDLE — when a CPU is about to go idle, pull work from this domain. Set from the base up to cpuset.sched_relax_domain_level. This is the aggressive balancing that keeps cores busy; restricting how high it reaches is the main cpuset tuning knob.
  • SD_BALANCE_FORK / SD_BALANCE_EXEC — pick a good CPU for a newly forked / exec’d task at this level. exec is a natural place to migrate because the task has just thrown away its cache.
  • SD_BALANCE_WAKE / SD_WAKE_AFFINE — wakeup-time placement: whether to consider running a waking task on the waker’s CPU (cache-hot) versus its previous CPU. This is the seam into Wakeup Balancing and select_task_rq.
  • SD_SHARE_CPUCAPACITY — members share execution capacity (SMT siblings). Triggers the tighter imbalance_pct = 110 and SMT-specific load math.
  • SD_SHARE_LLC — members share the Last-Level Cache. (This is the flag formerly named SD_SHARE_PKG_RESOURCES; see the uncertainty note above.) Sets cache_nice_tries = 1 so a cache-hot task is given one extra chance to not be migrated.
  • SD_CLUSTER — members share a cluster (intermediate cache / fabric tag).
  • SD_NUMA — this domain crosses NUMA nodes. In sd_init() it sets cache_nice_tries = 2 (try twice to avoid migrating a cache-hot task), clears SD_PREFER_SIBLING, and sets SD_SERIALIZE.
  • SD_SERIALIZE — “Only a single load balancing instance” may run at this level at a time, system-wide — set for all NUMA levels above NODE, because cross-node balancing touching many runqueues must not run concurrently on every CPU.
  • SD_PREFER_SIBLING — spread tasks to sibling groups (keep groups evenly filled) rather than packing one group; cleared at NUMA levels and below SD_ASYM_CPUCAPACITY domains.
  • SD_ASYM_CPUCAPACITY / SD_ASYM_CPUCAPACITY_FULL — members have different CPU capacities (big.LITTLE); see Capacity-Aware and Energy-Aware Scheduling. Set top-down to the first level where asymmetry (or all unique capacities) becomes visible.
  • SD_ASYM_PACKING — “Place busy tasks earlier in the domain,” used where some CPUs are preferred (e.g. higher-frequency cores).
  • SD_OVERLAP — set when a NUMA level’s groups overlap, which forbids sharing those groups between CPUs.

The crucial NUMA detail in sd_init(): for far NUMA levels — when sched_domains_numa_distance[level] > node_reclaim_distance — the kernel additionally strips SD_BALANCE_EXEC | SD_BALANCE_FORK | SD_WAKE_AFFINE. In plain terms: across a distant NUMA boundary the scheduler refuses to place a forking/execing/waking task purely for load reasons, because the locality cost outweighs the balance benefit. That single conditional is most of why “tasks rarely jump nodes.” How a task is then deliberately pulled toward its memory is the separate mechanism in NUMA Balancing and Task Placement.

Observing It — /proc/schedstat and the Debug Tree

Two facilities expose the live topology. First, /proc/schedstat (and per-task /proc/<pid>/schedstat), gated by CONFIG_SCHEDSTATS plus the schedstats=enable boot parameter or the /proc/sys/kernel/sched_schedstats / debugfs toggle. Its first line is version N; in v6.12 that is version 16, and in v6.18 it is version 17 — both verified from SCHEDSTAT_VERSION in kernel/sched/stats.c. After per-CPU lines, one domainN <cpumask> ... line is emitted per domain per CPU, where the first field is the bitmask of CPUs the domain operates over and the rest are per-sched_balance_rq() counters, grouped by idle state (idle / busy / newly-idle) — these are the raw load-balancing tallies analysed in Scheduler Load Balancing. The v6.12 doc notes “domain0 is the most tightly focused domain … the highest numbered one … arbitrates balancing across all the cpus.” A sample, version 16:

version 16
timestamp 4309876512
cpu0 0 0 152344 ...            # sched_yield / schedule / ttwu stats for cpu0
domain0 00000003 12 4 0 ... 0  # cpumask 0x3 = cpu0+cpu1 (SMT pair); idle/busy/newidle counters
domain1 0000000f 8 2 0 ... 0   # cpumask 0xf = cpu0-3 (MC); fewer balances, wider span

Format diff to know (verified from the v6.18 doc): version 17 removed the single lb_imbalance field and replaced it with four — lb_imbalance_load, lb_imbalance_util, lb_imbalance_task, lb_imbalance_misfit — and from v17 onward the domain line prints the domain’s name (SMT/MC/PKG/NUMA) rather than just a number. So a parser written for 6.12’s output will mis-read 6.18’s columns; always branch on the version line.

Second, the domain debug dump: build with CONFIG_SCHED_DEBUG and either add sched_verbose to the kernel cmdline or flip /sys/kernel/debug/sched/verbose. This “enables an error checking parse of the sched domains … It also prints out the domain structure in a visual format” (v6.12 sched-domains.rst) — invaluable for confirming the kernel built the hierarchy you expect on a new box. Note that the human-readable domain .name field itself only exists under CONFIG_SCHED_DEBUG (the SD_INIT_NAME macro is a no-op otherwise, per topology.h), so on a production kernel without that option the names are absent.

Failure Modes and Misunderstandings

  • “Balancing happens between CPUs.” No — it happens between groups of a domain. On an SMT box the base-level groups are single CPUs so it looks per-CPU, but at MC and above a group is many CPUs and the balancer compares group loads. Reasoning at the wrong granularity is the most common confusion.
  • cpusets fragment the tree. A cpuset with sched_load_balance off, or a partitioned cpuset.cpus.partition, splits the domain tree into disjoint subtrees so balancing never crosses the partition. This is a feature for isolation (Cpusets and CPU Partitioning, CPU Isolation isolcpus and nohz_full) but a trap if unintended: tasks “trapped” in a small balance domain cannot be migrated out, and as the NUMA path notes, the kernel won’t even try.
  • Expecting NUMA migrations from the load balancer. The plain load balancer is deliberately reluctant to cross far NUMA boundaries (the stripped SD_BALANCE_* flags). If a task ends up far from its memory, it is the NUMA-balancing path (NUMA Balancing and Task Placement) — not the load balancer — that pulls it back.
  • Stale topology after CPU hotplug. Domains are rebuilt on hotplug; transiently, /proc/schedstat and the debug dump can show a tree that no longer matches lscpu. Re-read after the hotplug settles.
  • Missing CONFIG_SCHED_CLUSTER. On cluster-cache hardware without this option, the CLS level is absent and the scheduler can’t exploit the shared intermediate cache, costing locality on those parts.

Scheduling domains are the static map; they don’t move tasks themselves. The actors that consult the map are: the periodic load balancer (Scheduler Load Balancing), which walks the tree on the tick; wakeup balancing (Wakeup Balancing and select_task_rq), which uses SD_WAKE_AFFINE and the LLC domain to place a waking task near cache-hot data; capacity/energy-aware placement (Capacity-Aware and Energy-Aware Scheduling), which uses SD_ASYM_CPUCAPACITY to handle big.LITTLE; and NUMA balancing (NUMA Balancing and Task Placement), which uses the sd_numa domain to pull a task toward its memory. The migration thread / stop class (Migration the Stop Class and the Migration Thread) is what physically performs a forced migration once the balancer decides one. For constraining the tree rather than reading it, see cpusets and CPU isolation above.

See Also