NUMA — зачем знать про non-uniform memory access
Если у вас сервер с двумя или более CPU sockets (то есть несколькими физическими CPU чипами), вы сталкиваетесь с NUMA — Non-Uniform Memory Access. Памяти на сервере много (256 GB, 1 TB), и она физически распределена: часть «принадлежит» CPU 0, часть — CPU 1. Доступ CPU 0 к своей памяти быстрее, чем к памяти CPU 1.
Многие production-проблемы вокруг high-memory систем — из-за NUMA. Postgres медленнее на 2-socket сервере, чем ожидаешь. Spark executor работает в 2 раза быстрее на single-socket. Странные latency spikes в Redis, который не должен иметь спайков. Это всё про NUMA.
В этом уроке — что такое NUMA, как Linux её видит, как использовать numactl, и когда NUMA topology critical в data engineering.
NUMA: память локальная и удалённая
В однопроцессорной системе (один CPU socket) вся RAM подключена к этому CPU через memory controller. Время доступа примерно одинаково ко всем адресам — это UMA (Uniform Memory Access).
В многосокетных системах CPU connected через interconnect (Intel UPI, AMD Infinity Fabric). Каждый CPU имеет свой memory controller и свою «локальную» память. Доступ CPU 0 к локальной памяти — быстрый (60-100 ns). Доступ CPU 0 к памяти, физически подключенной к CPU 1 — идёт через interconnect (130-300 ns) — remote access.
Числа: остальной access обычно в 1.5-2x медленнее. Bandwidth тоже ограничен — если все CPU 0 ядра шарят память на CPU 1, interconnect забит.
# Узнать NUMA topology:
numactl --hardware
# available: 2 nodes (0-1)
# node 0 cpus: 0 1 2 3 4 5 6 7 16 17 18 19 20 21 22 23
# node 0 size: 128928 MB -- 128 GB local
# node 0 free: 100000 MB
# node 1 cpus: 8 9 10 11 12 13 14 15 24 25 26 27 28 29 30 31
# node 1 size: 130000 MB
# node 1 free: 110000 MB
# node distances:
# node 0 1
# 0: 10 21 <- 0 to 0 baseline, 0 to 1 ~2x
# 1: 21 10
node distances — относительная стоимость. 10 — baseline (local). 21 — примерно 2x медленнее (remote). Иногда показывают 32-40 для 4-socket систем.
Linux NUMA policy — куда выделять память
Когда процесс делает malloc() на NUMA-системе, где kernel физически выделяет страницы? Это решает NUMA policy:
# Запустить процесс с привязкой к node 0:
numactl --cpunodebind=0 --membind=0 ./my-app
# CPU только на node 0 cores, память только из node 0 -- максимально cache-friendly
# Только память на node 0, CPU где угодно:
numactl --membind=0 ./my-app
# Interleave (для bandwidth-heavy):
numactl --interleave=all ./my-app
# Текущая policy процесса:
numactl --show
# policy: default
# preferred node: current
# physcpubind: 0 1 2 3 ...
# cpubind: 0 1
# membind: 0 1
Когда NUMA проблема
В большинстве случаев Linux работает «достаточно хорошо» с default first-touch policy. Проблемы возникают в:
Типичный паттерн:
- Запускаете PostgreSQL с shared_buffers = 32 GB на 64-core 2-socket сервере.
- Постгрес backend process аллоцирует shared_buffers большую часть на node 0 (где запустился init процесс).
- Connection приходит на CPU 33 (node 1). Backend обрабатывает.
- Каждый доступ к shared_buffers — remote access, ~200 ns вместо 100 ns.
- Производительность в 1.5-2x хуже, чем на single-socket.
Инструменты для NUMA
numastat — статистика по node
# Stats по nodes:
numastat -m
# Per-node memory statistics
# Node 0 Node 1
# MemTotal: 131072.0 131072.0 <- 128 GB на каждом
# MemFree: 5000.0 8000.0
# MemUsed: 126072.0 123072.0
# Per-process NUMA:
numastat -p PID
# Per-node process memory usage
# Node 0 Node 1 Total
# 12000.0 8000.0 20000.0 <- 60% на node 0, 40% на node 1
Watch — сколько remote access
# perf для подсчёта remote vs local DRAM access (Intel):
perf stat -e node-loads,node-load-misses ./my-app
# Или специфичные events:
perf list | grep -i numa
# uncore_imc_X/cas_count_read - чтения memory controller
# bcc tools (eBPF):
sudo /usr/share/bcc/tools/numamove
# Показывает migration статистику
Сравнение local vs remote
# Простой benchmark:
# Запуск с обязательным local memory:
numactl --cpunodebind=0 --membind=0 sysbench memory --memory-block-size=4M --memory-total-size=10G run
# Mem operations: 5000 MB/s
# Запуск с remote (CPU node 0, memory node 1):
numactl --cpunodebind=0 --membind=1 sysbench memory --memory-block-size=4M --memory-total-size=10G run
# Mem operations: 3000 MB/s <- 1.5x slower
NUMA balancing — автоматическая миграция
Linux имеет automatic NUMA balancing — kernel сам пытается мигрировать страницы и threads, чтобы они были на одном node.
# Включён ли NUMA balancing:
sysctl kernel.numa_balancing
# 1 -- включён по умолчанию
# Отключить (для latency-critical workloads):
sudo sysctl kernel.numa_balancing=0
# Статистика NUMA balancing:
grep numa /proc/vmstat
# numa_pages_migrated
# numa_pte_updates
# numa_huge_pte_updates
Best practices для DE
1. Знайте свой топология
Перед deploy на multi-socket — проверьте, что вы знаете:
- Сколько NUMA nodes
- Сколько cores и RAM на каждом
- node distances
numactl --hardware
lscpu | grep -i numa
2. Используйте —cpunodebind для memory-bound workloads
# PostgreSQL backend:
numactl --cpunodebind=0 --membind=0 postgres -D /var/lib/postgresql/data
# Spark executor:
numactl --cpunodebind=0 --membind=0 ./spark-executor.sh
# Redis:
numactl --cpunodebind=0 --membind=0 redis-server
Это говорит kernel: запускать процесс только на cores node 0, аллокации только из node 0. Никаких remote accesses.
3. Для bandwidth-heavy — interleave
Если ваш workload — ML training или OLAP scan — больше bandwidth важнее низкого latency. --interleave=all:
numactl --interleave=all ./ml-train
# Аллокации round-robin между nodes -- утилизирует bandwidth обоих
4. Контейнеры и Kubernetes
Docker по умолчанию не учитывает NUMA. На многосокетных нодах поды могут страдать. Решения:
- Kubernetes CPU Manager с
staticpolicy +NumaAffinity(от 1.32+). - Topology Manager — балансирует pods по NUMA nodes.
- VPA (Vertical Pod Autoscaler) с NUMA awareness.
# Запуск Docker с NUMA pinning:
docker run --cpuset-cpus="0-7,16-23" --memory=64g ...
# Это привязывает к cores node 0 (если topology такой)
5. Disable NUMA для специфичных workloads
Иногда проще выключить NUMA detection вовсе. Это объединит весь RAM в один node (логически), но физически remote access останется medlennym.
# Kernel boot parameter:
# numa=off -- в /etc/default/grub GRUB_CMDLINE_LINUX
Не рекомендуется — лучше использовать numactl правильно.
Реальный пример: Spark на multi-socket
У вас 2-socket сервер, 128 cores total, 512 GB RAM (256 на каждом node). Запускаете Spark с 8 executors по 16 cores, 32 GB heap каждый.
Без NUMA awareness:
- Executor 1 запустился на node 0, аллоцировал 32 GB heap на node 0.
- Linux scheduler переносит threads executor 1 на cores node 1 (для load balancing).
- Каждый JVM access к heap — remote, 200 ns vs 100 ns.
- Производительность executor падает на 30-50%.
С NUMA pinning:
# В Spark config:
spark.executor.extraJavaOptions=-XX:+UseNUMA
# Или явно через numactl:
spark.executorEnv.NUMA_NODE=0 # для половины executors
spark.executorEnv.NUMA_NODE=1 # для остальной половины
# Wrapper script стартует executor через numactl --cpunodebind=$NUMA_NODE --membind=$NUMA_NODE
Результат: каждый executor работает только на своём node, аллокации local, performance стабильная.
Попробуй сам
# 1. Своя NUMA topology:
numactl --hardware
# Если показывает 1 node -- у вас single-socket или NUMA отключен
lscpu | grep -i numa
# NUMA node(s): 2
# NUMA node0 CPU(s): 0-15,32-47
# NUMA node1 CPU(s): 16-31,48-63
# 2. Память по nodes:
numastat -m | head -20
# 3. Запустите процесс с привязкой и сравните:
# Замерим memory bandwidth с привязкой:
time numactl --cpunodebind=0 --membind=0 \
dd if=/dev/zero of=/dev/null bs=1M count=10000
# То же самое, но remote:
# Доступ из node 0 cores, память на node 1
time numactl --cpunodebind=0 --membind=1 \
dd if=/dev/zero of=/dev/null bs=1M count=10000
# Разница может быть в 1.5-2x
# 4. NUMA balancing статус:
sysctl kernel.numa_balancing
# Если 1 -- активный, страницы мигрируют автоматически
# 5. Memory distribution процесса:
PID=$$
numastat -p $PID
# Покажет, сколько памяти на каждом node для вашего shell
# 6. Принудительно сбалансировать память на одном node:
# (для long-running процесса)
migratepages PID 0 1 # перенести страницы с node 0 на node 1
# 7. Watch NUMA-related counters:
watch -n 1 'numastat -m | head -10'