Thread Affinity: Pinning and Its Price
Affinity constrains which cores a thread may run on. It buys cache locality, NUMA locality and predictable latency, and it costs the scheduler's ability to balance load. It is a genuine tool for latency-critical work and a genuine way to make a machine slower if applied by reflex.
Software view, hardware view
The gap between what you wrote and what the machine does is where this whole domain lives.
What pinning actually buys
Three distinct benefits, worth separating because they apply in different situations. Cache warmth: a thread that stays on one core keeps its L1 and L2 working set and its TLB entries, avoiding the cold-miss burst described in Cache Warmth and the Real Cost of Migration. NUMA locality: a thread pinned to a node keeps accessing memory attached to that node instead of drifting to a socket where all its data is remote. Predictability: a pinned thread does not experience migration at unpredictable moments, which narrows the latency distribution even when it does not improve the mean.
That third benefit is often the real motivation and is worth stating plainly: affinity is frequently a tail-latency tool rather than a throughput tool. Mean throughput may be unchanged or slightly worse while p99 improves noticeably, because the outliers caused by migration disappear.
| Workload | Benefit of pinning | Cost of pinning |
|---|---|---|
| Long-running worker, stable working set | High — keeps caches and TLB warm | Low, if the load is even |
| Latency-critical request handler | High — removes migration outliers from the tail | Idle capacity when that core has nothing to do |
| Large parallel job on a multi-socket box | High — NUMA locality via node pinning | Poor rebalancing if partitions are uneven |
| Short-lived tasks, bursty arrival | Low — nothing warm to preserve | High — the scheduler cannot balance the bursts |
| Mixed workload sharing the machine | Low — other tenants disturb the cache anyway | High — pinned threads cannot move away from noise |
What it costs
The scheduler balances load by moving threads to idle cores. Pinning removes that ability, and the consequences are direct: if a pinned thread's core is busy while another sits idle, the work simply waits, and the machine runs below capacity. Under uneven load, a fully pinned application can be measurably slower than an unpinned one.
There is a second, subtler cost: a pinning layout encodes assumptions about the machine. Core counts, sibling numbering, node boundaries and hybrid core types differ between machines, and a mapping tuned for one can be actively wrong on another — pinning two threads to what turn out to be SMT siblings of one core, or pinning to efficiency cores on a hybrid part. A layout that helps on the development machine can hurt in production.
The honest summary: pin the small number of threads whose latency you actually care about, leave the rest to the scheduler, and re-measure whenever the hardware changes.
- Pin selectively. A few latency-critical threads, not every thread in the process.
- Pin to nodes before pinning to cores. NUMA locality is usually the larger effect and is far less brittle.
- Beware sibling numbering. Logical CPU ids are not always laid out the way you assume; verify the topology.
- Re-measure on new hardware. A pinning layout is a machine-specific assumption, not a portable optimisation.
The decision, in practice
Affinity is worth reaching for when three things hold at once: the thread is long-lived, it has a working set worth keeping warm, and its latency matters. Miss any of those and the cost usually exceeds the benefit. A short-lived task has no warmth to preserve; a streaming job whose data never fits in cache has nothing to keep; a batch job that only cares about total throughput is better served by letting the scheduler balance.
The corollary is that affinity is a poor *first* response to a performance problem. If parallel code is not scaling, the likelier causes are the ones in this module — write sharing, false sharing, bandwidth saturation — and pinning does not fix any of them. Measure the migration rate first: if threads are not actually migrating much, affinity has nothing to offer.
1// One thread pinned to each logical CPU,2// on a machine shared with other work.3for (i = 0; i < num_logical_cpus; i++)4 pin(worker[i], cpu = i);5 6// Uneven task lengths now cannot rebalance:7// worker 3 has a queue, worker 7 is idle,8// and the scheduler is not allowed to help.9// Some pairs are SMT siblings of one core.1// Pin only the latency-critical thread, and2// pin it to a node rather than a single core.3pin_to_node(latency_critical_thread, node = 0);4 5// Everything else stays schedulable, so the6// OS can still balance bursts and uneven work.7// The tail improves where it matters; the rest8// of the machine keeps its flexibility.Affinity trades scheduler flexibility for locality. Spending that trade on every thread pays the cost everywhere and collects the benefit only where warmth actually existed. Spending it on the few threads whose tail latency matters collects most of the benefit for a fraction of the cost.
Key points
- Affinity buys cache warmth, NUMA locality and predictability by restricting where a thread may run.
- It is often a tail-latency tool rather than a throughput tool — p99 improves while the mean may not.
- The cost is scheduler flexibility: pinned threads cannot move to idle cores, so uneven load leaves capacity unused.
- Pinning layouts encode machine-specific assumptions and can be actively wrong on different hardware.
- Pin selectively, prefer node-level pinning, and verify threads are actually migrating before reaching for it.
Follow the mechanism
The path through the machine, hop by hop — and the conclusions it invites that are wrong.
- 1Process → OS: an affinity mask restricts the set of logical CPUs the scheduler may place this thread on.
- 2Scheduler → placement: on each dispatch, only cores in the mask are considered, so the thread returns to the same caches.
- 3Core → private caches: the working set and TLB entries survive between slices instead of being rebuilt.
- 4Thread → NUMA node: pinning to a node keeps accesses on the local memory controller rather than crossing the link.
- 5Load imbalance → idle cores: work queued behind a pinned thread cannot migrate, so capacity elsewhere goes unused.
- • "Pinning always helps" — it helps long-lived threads with warm working sets, and hurts bursty or uneven workloads.
- • "Pin every thread for maximum performance" — that pays the flexibility cost everywhere and collects the benefit rarely.
- • "Affinity will fix my scaling problem" — write sharing and bandwidth saturation are far likelier causes and are unaffected.
- • "CPU 0 and CPU 1 are two cores" — they may be SMT siblings of one core; verify before assuming.
Consequences, controls and cost
- • Narrower latency distributions for pinned latency-critical threads.
- • Reduced throughput under uneven load, because the scheduler cannot rebalance.
- • Pathological placements when logical CPU numbering differs from the assumed layout.
- • Behaviour that changes between machines, and between bare metal and containers.
- • Pin only the threads whose latency you care about, and leave the rest schedulable.
- • Prefer NUMA-node affinity over single-core pinning: most of the benefit, far less brittleness.
- • Verify the topology and logical CPU numbering programmatically rather than hard-coding a layout.
- • Measure migration rate before pinning; if threads are not migrating, affinity has nothing to offer.
- • Measure the thread migration rate first; low migration means affinity has little to offer.
- • Compare p50 and p99 pinned versus unpinned — the tail is where the benefit usually shows.
- • Watch for idle cores alongside queued work, which is the signature of over-pinning.
- • Verify the actual topology and sibling mapping rather than assuming logical CPU numbering.
- • Locality gained is scheduling flexibility lost; under imbalance that costs throughput.
- • A pinning layout is machine-specific and can degrade or misfire on different hardware.
- • Node pinning is more robust than core pinning but gives up the last increment of cache warmth.
Scope
§224 — what these claims are specific to.
- PLATFORM-SPECIFICAffinity interfaces, CPU numbering and whether pinning is allowed differ by OS, container runtime and hypervisor. Inside containers the visible CPU set may be restricted or renumbered relative to the host.
- MICROARCH-SPECIFICOn hybrid CPUs with performance and efficiency cores, a pinning layout that ignores core type can pin latency-critical work onto efficiency cores and make things worse.
Misconceptions
Apply it
Where the rest of this lives
Work-stealing schedulers deliberately move tasks between workers to balance load, which is in direct tension with affinity. Choosing between locality and balance is a concurrency design decision; the locality half of the trade is quantified here.