Find your NUMA effect (first touch) and measure it honestly
Run a real systemNeeds Docker
Problem
On a machine with several NUMA nodes, each memory page lives on the node of the thread that first wrote it (first touch). If one thread initializes an array, all of it lands on that thread's node, and threads on other nodes then read it remotely. Initializing in parallel with the same split as the later scan keeps each thread's data local; interleaving spreads pages over all nodes. For bandwidth-bound scans, the measured difference is usually 10–30%, not the 2× that remote latency alone suggests.
On a two-socket Linux machine, allocate an array and scan it with all threads under three setups: (a) initialize the array with one thread, then scan with all threads; (b) initialize it in parallel with the same split as the scan; (c) interleave across nodes. The reference run is a Linux container on a single-socket laptop, so it has one NUMA node: it is the control, where all three setups should match.
The question. Does the first-touch setup change scan bandwidth, and how many NUMA nodes did the run have?
Predict
Expected: the difference is often 10–30% for bandwidth-bound scans, not the 2× that the remote-latency figure suggests. On a single-socket machine there is nothing to measure: the three setups should match.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
find-your-numa-effect-first-touch.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Find your NUMA effect: first touch. Allocates an array and scans it with all cores under three setups: (a) one thread initializes it, then all threads scan; (b) it is initialized in parallel with the same split as the scan; (c) its pages are interleaved across NUMA nodes (set_mempolicy, the call behind numactl --interleave=all). Prints the machine's NUMA nodes and the scan bandwidth of each setup. Linux only (it reads /sys and calls set_mempolicy); needs cc. """ import argparse, json, os, subprocess, tempfile p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--gb", type=float, default=1.0, help="Array size (GB).") p.add_argument("--threads", type=int, default=os.cpu_count(), help="Threads.") p.add_argument("--repeats", type=int, default=5, help="Scans per setup (the best is reported).") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() C = r""" #define _GNU_SOURCE #include <pthread.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/syscall.h> #include <time.h> #include <unistd.h> static long *arr; static size_t n; static int T; static double now(void){ struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); return t.tv_sec + t.tv_nsec / 1e9; } static void *init(void *a){ long id = (long)a; size_t lo = n * id / T, hi = n * (id + 1) / T; for (size_t i = lo; i < hi; i++) arr[i] = i; return NULL; } static volatile long sums[256]; static void *scan(void *a){ long id = (long)a; size_t lo = n * id / T, hi = n * (id + 1) / T; long s = 0; for (size_t i = lo; i < hi; i++) s += arr[i]; sums[id * 8 % 256] = s; return NULL; } static void run(void *(*f)(void *), int threads){ pthread_t th[256]; int t0 = T; if (threads == 1) T = 1; for (long i = 0; i < T; i++) pthread_create(&th[i], NULL, f, (void *)i); for (int i = 0; i < T; i++) pthread_join(th[i], NULL); T = t0; } int main(int argc, char **argv){ double gb = atof(argv[1]); T = atoi(argv[2]); int reps = atoi(argv[3]); char mode = argv[4][0]; n = (size_t)(gb * 1e9 / sizeof(long)); if (mode == 'c') { unsigned long mask = 0; FILE *f = fopen("/sys/devices/system/node/online", "r"); int a = 0, b = 0; if (f && fscanf(f, "%d-%d", &a, &b) >= 1) { if (b < a) b = a; for (int i = a; i <= b; i++) mask |= 1UL << i; } else mask = 1; if (f) fclose(f); if (syscall(SYS_set_mempolicy, 3 /* MPOL_INTERLEAVE */, &mask, sizeof(mask) * 8)) perror("set_mempolicy"); } arr = malloc(n * sizeof(long)); run(init, mode == 'a' ? 1 : T); /* first touch decides where each page lives */ double best = 1e9; for (int r = 0; r < reps; r++) { double t = now(); run(scan, T); double e = now() - t; if (e < best) best = e; } long total = 0; for (int i = 0; i < 256; i++) total += sums[i]; fprintf(stderr, "%ld\n", total); /* read the sums, so the scans cannot be removed */ printf("%f\n", n * sizeof(long) / best / 1e9); } """ d = tempfile.mkdtemp(); src = os.path.join(d, "numa.c"); exe = os.path.join(d, "numa"); open(src, "w").write(C) subprocess.run(["cc", "-O2", "-pthread", src, "-o", exe], check=True) nodes = open("/sys/devices/system/node/online").read().strip() if os.path.exists("/sys/devices/system/node/online") else "unknown" node_count = len(os.listdir("/sys/devices/system/node")) if os.path.isdir("/sys/devices/system/node") else 0 node_count = sum(1 for x in os.listdir("/sys/devices/system/node") if x.startswith("node") and x[4:].isdigit()) if node_count else 1 rows = [] for mode, label in (("a", "(a) one thread initializes, all scan"), ("b", "(b) parallel first touch, same split"), ("c", "(c) interleaved across nodes")): gbs = float(subprocess.run([exe, str(args.gb), str(args.threads), str(args.repeats), mode], capture_output=True, text=True, check=True).stdout) rows.append({"setup": label, "gb_per_s": gbs}) for r in rows: r["vs_b_pct"] = (rows[1]["gb_per_s"] / r["gb_per_s"] - 1) * 100 summary = {"numa_nodes": node_count, "nodes_online": nodes, "threads": args.threads, "spread_pct": (max(r["gb_per_s"] for r in rows) / min(r["gb_per_s"] for r in rows) - 1) * 100} print(f"NUMA nodes: {node_count} (online: {nodes}), {args.threads} threads, {args.gb:g} GB array") for r in rows: print(f"{r['setup']:<40} {r['gb_per_s']:6.2f} GB/s (scan time {r['vs_b_pct']:+.1f}% vs parallel first touch)") if args.json: with open(args.json, "w") as f: json.dump({"summary": summary, "tables": {"setups": rows}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run find-your-numa-effect-first-touch.py uv run find-your-numa-effect-first-touch.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --gb1 | Array size (GB). |
| --threadsos.cpu_count() | Threads. |
| --repeats5 | Scans per setup (the best is reported). |
Our run
Scan bandwidth
| Setup | GB/s | Scan time vs parallel first touch |
|---|---|---|
| (a) one thread initializes, all scan | 62.4 | 1.38% |
| (b) parallel first touch, same split | 63.3 | 0% |
| (c) interleaved across nodes | 63.5 | -0.451% |
NUMA nodes: 1 (online: unknown), 8 threads, 1 GB array
(a) one thread initializes, all scan 62.39 GB/s (scan time +1.4% vs parallel first touch)
(b) parallel first touch, same split 63.26 GB/s (scan time +0.0% vs parallel first touch)
(c) interleaved across nodes 63.54 GB/s (scan time -0.5% vs parallel first touch)What we found
The container had 1 NUMA node. The three setups scanned at 62.4, 63.3 and 63.5 GB/s: a spread of 1.84%, which on one node is run-to-run noise, not placement. This run cannot measure the NUMA effect itself; it shows the baseline a two-socket result has to beat.
Read it
Measure your own machine and do not inherit the number. On a two-socket server, run the same script: the difference between (a) and (b) is your first-touch penalty, and (c) shows what interleaving costs or saves for this access pattern.