Skip to content
About
Main content

NUMA awareness

Why does the thread that first touches a buffer decide its NUMA node, and how large is the effect really?

The penalty of remote memory access is physics: a remote read costs about 1.5–2.2× the latency of a local one, and the interconnect between sockets has less bandwidth. The question here is placement policy. The mechanism that decides placement is : Linux puts a physical page on the node of the thread that first writes to it, not on the node of the thread that called malloc.

Therefore the allocation path does not determine placement. The initialization path does. A single-threaded loop that zeroes the at start-up places the entire pool on one node. Every worker on every other socket then pays remote access for the life of the process. This misconfiguration is common, permanent once made, and invisible in every metric except IPC (instructions per cycle) and memory-stall counters.

// BAD: one thread touches every page first, so ALL pages land on thread 0's node.
memset(pool, 0, pool_bytes);

// BETTER: each worker initializes the slice it will later process,
// so every slice lands on that worker's own node (first touch).
#pragma omp parallel for schedule(static)
for (size_t i = 0; i < n_chunks; i++)
    memset(pool + i * CHUNK, 0, CHUNK);   // same split as the processing loop

The forced designs

  • Partition the pool per node and bind workers to the node that holds their partition. Use per-node free lists instead of one global list.
  • Allocate and initialize working memory on the thread that will consume it. This is the underlying reason that per-worker and per-thread pools are the norm, in addition to avoiding contention.
  • Interleave pages across nodes (the --interleave option of numactl) when access is truly uniform. This accepts a worse mean latency to remove bimodality (two very different speeds depending on page location). : predictability is bought with average-case work.
  • Once memory is bound, thread migration becomes a correctness-of-performance issue, so NUMA-aware memory forces NUMA-aware thread scheduling. That coupling is real. It belongs to execution and scheduling; we name it and stop.

Claim to test

The commonly quoted "~2×" is a latency ratio for remote access. It is routinely repeated as if it were the throughput or end-to-end ratio. For bandwidth-bound scan work on typical two-socket machines the observed effect is often 10–30%. Modern chiplet designs put NUMA-like boundaries inside a socket, so the topology no longer maps to sockets at all. This is current engineering reality, dependent on hardware generation, not a permanent constraint. Measure your machine; do not inherit the number.

A conflict between mechanisms

binding assumes threads stay where you put them and access is node-local. Work stealing (idle threads take tasks from busy threads) violates the first assumption by design. So two mechanisms from neighbouring parts of an engine are optimizing against each other, and neither one's metrics show it.

One-line compression: placement is decided by first touch, not by malloc. Whoever initializes a buffer owns its node for the life of the process.