Двухпроцессорный сервер с 512 ГБ ОЗУ показывает странную картину: один сокет загружен на 90%, второй почти простаивает, а приложение работает медленнее, чем ожидалось по характеристикам железа. Добавление ядер не помогает. Оптимизация кода не помогает. Дело не в алгоритмах и не в конфигурации базы данных. Дело в том, что половина обращений к памяти идёт через межпроцессорный интерконнект - и каждое такое обращение стоит в два-три раза дороже локального.

NUMA (Non-Uniform Memory Access) - это не экзотика. Любой современный многосокетный сервер построен по NUMA-архитектуре: каждый процессор имеет собственную локальную память, и доступ к памяти соседнего процессора требует прохождения через интерконнект с заметным штрафом по латентности. AMD EPYC с несколькими CCD-чиплетами устроен аналогично даже внутри одного сокета. Intel с Mesh-архитектурой создаёт неравномерность доступа к памяти внутри одного процессора. NUMA везде - и вопрос не в том, столкнётесь ли вы с ней, а в том, когда.

Физическая природа неравномерного доступа к памяти

На двухсокетном сервере каждый процессор подключён к своим планками ОЗУ напрямую через встроенный контроллер памяти. Доступ к этой памяти занимает 60-80 наносекунд. Доступ к памяти второго сокета требует прохождения через интерконнект (QPI у Intel, Infinity Fabric у AMD) и обращения к удалённому контроллеру памяти. Это занимает 130-200 наносекунд - в два-три раза дольше.

На системах с четырьмя сокетами картина ещё сложнее: расстояние между узлами разное, и один узел может добраться до другого через цепочку промежуточных интерконнектов. Топология NUMA описывается через понятие "дистанции" между узлами, которую ядро читает из ACPI-таблиц при загрузке.

# Просмотреть NUMA-топологию системы
numactl --hardware

# Матрица дистанций между узлами
numactl --hardware | grep -A10 "node distances"

# Количество узлов и их память
cat /sys/devices/system/node/node*/meminfo

# Топология через lstopo (пакет hwloc)
lstopo --no-io

Вывод numactl --hardware показывает число узлов, объём памяти на каждом и матрицу дистанций. Дистанция 10 означает локальный доступ, 20 - удалённый через один переход, 30 и выше - через несколько промежуточных узлов. Эти числа напрямую коррелируют с латентностью доступа.

Алгоритм AutoNUMA и механизм hint page faults

Автоматическая балансировка NUMA появилась в ядре Linux 3.13 в 2014 году под названием AutoNUMA. Её автор - Андреа Аркангели. До этого NUMA-оптимизация требовала явных действий администратора: привязки процессов к узлам через numactl, указания политики выделения памяти в коде. AutoNUMA принесла самостоятельное обнаружение и исправление субоптимального размещения.

Алгоритм работает в три этапа. Первый - сканирование. Ядро запускает фоновый поток task scanner, который периодически обходит виртуальные адресные пространства запущенных процессов. Для каждой страницы памяти сканер временно снимает бит присутствия в таблице страниц, делая её "невидимой" для MMU. Это ключевой трюк алгоритма: страница физически никуда не перемещается, но процессор не может получить к ней доступ без обращения к ядру.

Второй этап - сбор информации. Когда процесс обращается к странице с снятым битом присутствия, процессор генерирует page fault. Ядро перехватывает это исключение в обработчике NUMA faults, фиксирует, с какого NUMA-узла произошло обращение и на каком узле физически находится страница. Если это не совпадает - страница кандидат на миграцию. Бит присутствия восстанавливается, и процесс продолжает работу с минимальной задержкой.

Третий этап - миграция. Страницы с зафиксированными удалёнными обращениями помещаются в очередь на перемещение к тому NUMA-узлу, с которого к ним чаще всего обращаются. Миграция происходит асинхронно, не мешая выполнению процесса.

# Включить или отключить автоматическую NUMA-балансировку
echo 1 > /proc/sys/kernel/numa_balancing
echo 0 > /proc/sys/kernel/numa_balancing

# Постоянно через sysctl
sysctl -w kernel.numa_balancing=1
echo "kernel.numa_balancing=1" >> /etc/sysctl.conf

# Статистика NUMA faults для конкретного процесса
cat /proc/$(pgrep myapp)/sched | grep numa

Проблема page bouncing и защита от избыточных миграций

Наивная реализация AutoNUMA приводила бы к "прыгающим" страницам: поток A на узле 0 обращается к странице, она мигрирует на узел 0. Поток B на узле 1 обращается к той же странице, она мигрирует на узел 1. Страница начинает непрерывно перемещаться между узлами, тратя больше ресурсов на миграцию, чем экономит на локальности доступа.

Для защиты от этого ядро реализует политику постепенного ужесточения требований для миграции. Первая миграция страницы происходит после одного зафиксированного удалённого обращения. Каждая последующая миграция требует вдвое большего числа удалённых обращений, чем предыдущая. Это экспоненциально снижает частоту перемещений горячих разделяемых страниц.

Для страниц, к которым обращаются потоки с разных узлов примерно поровну, AutoNUMA перестаёт мигрировать и оставляет страницу там, где она находится. Это разумное поведение: если данные одинаково нужны всем узлам, любое размещение приведёт к удалённым обращениям с половины узлов.

# Карта NUMA-размещения памяти процесса
cat /proc/$(pgrep postgres)/numa_maps | head -20

# Счётчики миграций из vmstat
grep numa /proc/vmstat

# numa_pages_migrated - страницы перемещённые AutoNUMA
# numa_hint_faults - суммарное число NUMA hint page faults
# numa_hint_faults_local - faults к локальной памяти

Отношение numa_hint_faults_local к общему числу numa_hint_faults даёт коэффициент локальности. Значение выше 90% говорит о хорошем размещении памяти. Значение ниже 70% - сигнал, что либо AutoNUMA не справляется, либо нагрузка требует ручного вмешательства.

Параметры сканера и настройка агрессивности балансировки

Task scanner имеет несколько настраиваемых параметров, определяющих баланс между точностью обнаружения нелокального доступа и накладными расходами на сканирование:

# Минимальный и максимальный период сканирования в миллисекундах
cat /proc/sys/kernel/numa_balancing_scan_period_min_ms   # 1000 по умолчанию
cat /proc/sys/kernel/numa_balancing_scan_period_max_ms   # 60000 по умолчанию

# Размер окна сканирования в МБ за один проход
cat /proc/sys/kernel/numa_balancing_scan_size_mb         # 256 по умолчанию

# Задержка перед началом сканирования нового процесса (мс)
cat /proc/sys/kernel/numa_balancing_scan_delay_ms        # 1000 по умолчанию

# Снизить период сканирования для более быстрой реакции
sysctl -w kernel.numa_balancing_scan_period_min_ms=500
sysctl -w kernel.numa_balancing_scan_period_max_ms=10000

Период сканирования адаптивный: если фиксируется много удалённых обращений (плохое размещение), ядро сокращает интервал между сканами, чтобы быстрее обнаружить и исправить ситуацию. Если большинство обращений локальные (хорошее размещение), интервал увеличивается до максимума, снижая overhead сканирования.

Накладные расходы AutoNUMA при типичной нагрузке составляют менее 1% CPU. На системах с очень большим числом потоков, активно делящих данные между NUMA-узлами, overhead может быть выше из-за частых hint page faults.

Ручное управление через numactl и политики выделения памяти

AutoNUMA хорошо работает для большинства однопоточных и умеренно многопоточных нагрузок с выраженной локальностью данных. Но для тщательно оптимизированных приложений, которые самостоятельно управляют NUMA-топологией, автоматическая балансировка создаёт конфликт интересов: ядро пытается переместить страницы туда, где оно видит обращения, а приложение уже разместило данные оптимально по своей логике.

В таких случаях AutoNUMA отключают и переходят к явному управлению через numactl:

# Запустить процесс с привязкой к NUMA-узлу 0
numactl --cpunodebind=0 --membind=0 ./myapp

# Запустить с интерливингом памяти по всем узлам
numactl --interleave=all ./myapp

# Запустить с памятью на узле 1, но CPU на любом
numactl --membind=1 ./myapp

# Привязать запущенный процесс к узлу (через taskset для CPU)
taskset -pc 0-15 $(pgrep myapp)   # CPU 0-15 обычно на сокете 0

Политика --interleave=all часто даёт лучшие результаты для задач с размытым паттерном доступа к памяти. Вместо концентрации всей памяти на одном узле (где она быстро станет узким местом по пропускной способности) данные равномерно распределяются между контроллерами памяти всех узлов. Это снижает среднюю латентность и устраняет перегрузку отдельных контроллеров.

Для длительно работающих процессов, где важна стабильность, а не скорость первоначального запуска, применяется libnuma - библиотека для явного управления NUMA-политиками прямо в коде приложения через numa_alloc_onnode() и mbind().

Диагностика NUMA-проблем и измерение локальности памяти

Обнаружить NUMA-проблему на работающей системе помогает несколько инструментов. Самый прямой способ - посмотреть статистику доступов к памяти через аппаратные счётчики:

# Измерить долю удалённых обращений к памяти
perf stat -e node-loads,node-load-misses \
  -e node-stores,node-store-misses \
  -p $(pgrep myapp) sleep 10

# Подробный NUMA-отчёт через numastat
numastat

# NUMA-статистика для конкретного процесса
numastat -p $(pgrep postgres)

# Статистика узлов из /sys
for node in /sys/devices/system/node/node*/numastat; do
  echo "=== $node ==="; cat $node
done

numastat без аргументов покажет число локальных и удалённых выделений памяти для каждого NUMA-узла с момента загрузки. Если столбец numa_foreign (память, выделенная на узле для процессов другого узла) значительно больше local_node, система испытывает NUMA-дисбаланс.

Хорошей отправной точкой при диагностике является /proc/PID/numa_maps. Каждая строка описывает регион виртуальной памяти с пометкой о политике выделения и распределении страниц по физическим узлам. Если большой анонимный регион помечен N1=8192 при том что процесс работает на узле 0, это прямое свидетельство удалённого размещения памяти.

NUMA-балансировка - это не настройка, которую включают раз и забывают. Это живой механизм с измеримыми характеристиками и конкретными компромиссами. На четырёхсокетных системах корректное размещение памяти способно удвоить производительность приложений с высокой плотностью работы с данными. Понимание того, что делает ядро автоматически, где оно ошибается и как направить его решения в нужную сторону - это разница между сервером, который работает на заявленных характеристиках, и сервером, который работает вполсилы при полностью исправном железе.