MAATRIX / Блог / Что такое NUMA и почему сервер с двумя процессорами бывает медленнее

Что такое NUMA и почему сервер с двумя процессорами бывает медленнее

MAATRIX

Вы арендовали двухпроцессорный сервер, ожидая прироста производительности в два раза, а вместо этого приложение работает нестабильно или даже медленнее, чем на одном мощном CPU. Дело не в браке железа и не в обмане провайдера — дело в архитектуре памяти, которая в двухсокетных системах устроена принципиально иначе, чем в однопроцессорных. Разберём, что такое NUMA, почему она возникает и как с ней жить, чтобы не терять производительность на пустом месте.

Почему у двух процессоров не одна общая память

В любом современном двухсокетном сервере (2P — two-socket) у каждого процессора есть собственный контроллер памяти, встроенный прямо в кристалл CPU. Это значит, что физически к каждому сокету подключена своя часть модулей DIMM — «своя» память, которую этот процессор видит напрямую и быстро.

Так было не всегда: в старых архитектурах с северным мостом (northbridge) все процессоры обращались к памяти через единый общий контроллер — это называется UMA (Uniform Memory Access), равномерный доступ. Проблема UMA в том, что при росте числа ядер и процессоров этот общий контроллер и общая шина памяти становятся узким местом — все обращения выстраиваются в одну очередь.

Начиная с AMD Opteron (середина 2000-х) и позже с Intel начиная с поколения Nehalem, производители перешли на архитектуру NUMA — Non-Uniform Memory Access, неравномерный доступ к памяти. Каждый процессор получил собственный контроллер и собственный банк физически близкой памяти. Логика простая: если CPU обращается к «своей» памяти, задержка минимальна, потому что путь короткий и не надо ни с кем делить полосу пропускания.

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

Локальный доступ и доступ через межпроцессорную шину

Когда процесс запущен на ядре CPU0 и обращается к памяти, выделенной в банке, подключённом к CPU0, — это локальный доступ (local access). Путь короткий: ядро → встроенный контроллер памяти → DIMM. Задержка минимальна, это и есть штатный, ожидаемый режим работы NUMA-системы.

Когда тот же процесс на CPU0 обращается к памяти, физически подключённой к CPU1, запрос идёт другим путём: ядро CPU0 → межпроцессорная шина (у Intel это UPI — Ultra Path Interconnect, у AMD — Infinity Fabric) → контроллер памяти CPU1 → DIMM CPU1 → и обратно тем же путём. Это удалённый доступ (remote access), и он объективно медленнее локального — путь длиннее, добавляется ещё один хоп через межпроцессорную шину, и эта шина к тому же может быть занята другим трафиком между сокетами.

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

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Что такое NUMA-промах и почему он реально замедляет приложение

NUMA-промах (NUMA miss, или remote memory access) — ситуация, когда поток выполняется на ядре одного NUMA-узла, а обращается к памяти, физически принадлежащей другому узлу. Термин условный (это не бинарное «попал/не попал», как cache miss, а континуум «локально vs удалённо»), но суть отражает точно: каждое такое обращение — упущенная возможность работать с памятью на минимальной задержке.

Откуда берутся NUMA-промахи на практике:

  • Планировщик ОС мигрировал поток. Linux-планировщик может перекинуть процесс на другое ядро для балансировки нагрузки, если ядра на исходном узле заняты. Память, которую поток уже выделил, остаётся на старом узле — поток продолжает работу с ней, но теперь уже удалённо.
  • Приложение не NUMA-aware. Большинство обычных программ (веб-серверы, интерпретаторы языков, СУБД без специальной настройки) вообще не знают о существовании NUMA-топологии. Они выделяют память там, где дала ОС, и выполняются там, где решил планировщик, — без связи между этими решениями.
  • Память выделена раньше, чем определилось место выполнения. Процесс стартовал на CPU0, выделил структуры данных в памяти узла 0, а затем основная нагрузка переместилась на ядра CPU1 — например, из-за перераспределения запросов между worker'ами.
  • Виртуализация и контейнеры без явной настройки. Гипервизор по умолчанию может размазать vCPU виртуальной машины по обоим сокетам, а память ВМ — выделить одним куском на одном узле. Тогда часть vCPU всегда работает с «чужой» памятью.

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

Какие нагрузки чувствительны к NUMA, а какие нет

NUMA — это не проблема «вообще для всех задач», а проблема конкретного профиля нагрузки. Полезно понимать, где она действительно проявляется.

Чувствительны к NUMA:

  • СУБД с большим буферным пулом в памяти (PostgreSQL с большим shared_buffers, MySQL/InnoDB с большим innodb_buffer_pool_size, Redis с большим датасетом) — если процесс базы данных и его буфер оказались на разных узлах, каждое обращение к странице кэша становится удалённым.
  • In-memory аналитика и обработка больших датасетов — задачи, где рабочий набор данных не помещается в кэш процессора и требует постоянных обращений к оперативной памяти (ETL-процессы, аналитические движки, обработка временных рядов).
  • Многопоточные HPC-задачи и научные вычисления — симуляции, матричные вычисления, где потоки активно обмениваются данными через общую память.
  • JVM-приложения с большой кучей (Java, Kotlin, Scala на серверах) — сборщик мусора и потоки JVM могут оказаться размазаны по узлам без явной настройки NUMA-affinity.

Малочувствительны или нечувствительны:

  • Обычные веб-приложения и API с небольшим объёмом рабочих данных на процесс — данные умещаются в кэш процессора, обращения к оперативной памяти редки.
  • Задачи, упирающиеся в диск или сеть, а не в память, — там задержка доступа к RAM теряется на фоне задержек ввода-вывода.
  • Множество независимых лёгких процессов (например, отдельные PHP-FPM воркеры или контейнеры) — планировщик ОС и так неплохо держит процесс и его память на одном узле, если процесс живёт достаточно долго и не мигрирует часто.

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

Диагностика: numactl и numastat

Прежде чем что-то настраивать, нужно увидеть, есть ли у вас вообще NUMA-топология и как реально распределена нагрузка. Основной инструмент в Linux — пакет numactl (в Debian/Ubuntu и RHEL-подобных ставится одноимённым пакетом).

Установка:

# Debian/Ubuntu
apt install numactl

# AlmaLinux/RHEL
dnf install numactl

Посмотреть топологию сервера — сколько NUMA-узлов, сколько ядер и памяти на каждом, и как узлы связаны между собой по задержке:

numactl --hardware

Типичный вывод для двухсокетного сервера покажет что-то вроде available: 2 nodes (0-1), список ядер (cpus) на каждом узле, объём памяти на узел (size) и таблицу node distances — относительные условные величины задержки между узлами (обычно локальный доступ обозначается как 10, а удалённый — большим числом; конкретные значения зависят от платформы, это не абсолютные наносекунды, а относительная метрика для сравнения).

Посмотреть текущую NUMA-статистику — сколько обращений к памяти было локальными, а сколько ушло на другой узел:

numastat

Ключевые поля в выводе:

  • numa_hit — обращения, которые были удовлетворены с локального узла, как и планировалось;
  • numa_miss — обращения, которые процесс пытался сделать локально, но память оказалась на другом узле (это и есть косвенный признак проблемы, хотя сама метрика на уровне ядра считается немного иначе, чем «промах» на уровне приложения);
  • other_node — обращения к памяти узла, инициированные с другого узла.

Посмотреть NUMA-статистику для конкретного процесса:

numastat -p <PID>

Команда покажет, сколько памяти процесса физически размещено на каждом NUMA-узле — если процесс запущен и в основном работает на ядрах узла 0, а значительная часть его памяти лежит на узле 1, это прямое указание на проблему.

Также полезно посмотреть, на каких ядрах реально исполняется процесс: ps -eo pid,psr,comm | grep <имя> — колонка psr покажет номер ядра, на котором процесс работал последним.

Привязка процесса к NUMA-узлу

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

Простой способ — запустить процесс с жёсткой привязкой к конкретному NUMA-узлу через numactl:

numactl --cpunodebind=0 --membind=0 /usr/bin/my-application

Здесь --cpunodebind=0 заставляет процесс выполняться только на ядрах узла 0, а --membind=0 заставляет ядро выделять память для этого процесса тоже только с узла 0. Это гарантирует, что весь трафик памяти процесса будет локальным — при условии, что процесс и его данные помещаются в ресурсы одного узла.

Более мягкий вариант — --preferred вместо --membind:

numactl --cpunodebind=0 --preferred=0 /usr/bin/my-application

Разница: --membind жёстко запрещает выделение памяти на другом узле (если память на узле 0 закончится, процесс скорее получит ошибку выделения, чем уйдёт на соседний узел), а --preferred говорит ядру «старайся брать с узла 0, но если не хватит — можно и с другого». Для процессов, которым может понадобиться больше памяти, чем есть физически на одном узле, --preferred безопаснее.

Привязку ядер уже запущенного процесса можно менять через taskset, но numactl не умеет применять политику памяти к существующему PID — для переноса уже выделенных страниц используется отдельная утилита migratepages:

# Перенести уже выделенные страницы памяти процесса с узла 1 на узел 0
migratepages <PID> 1 0

Важная оговорка: migratepages физически копирует страницы памяти между узлами, и сама эта операция стоит времени и нагрузки на межпроцессорную шину — использовать её на постоянной основе как «фоновую починку» не стоит, это разовый инструмент для конкретной ситуации, а не постоянный процесс.

Для СУБД NUMA-настройка обычно делается через запуск процесса под numactl с интерливингом, а не через параметры самой базы: PostgreSQL и MySQL, работающие потоками на ядрах разных узлов одновременно, выигрывают от того, что буферный пул размазан по всем узлам равномерно, а не концентрируется на одном. Это осознанный компромисс — жертвуем частью локальности ради предсказуемости.

numactl --interleave=all /usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/16/main

Практические рекомендации при выборе и настройке сервера

Несколько выводов, которые стоит применить, если вы работаете с двухпроцессорным сервером или выбираете конфигурацию:

СитуацияЧто делать
Одно тяжёлое NUMA-чувствительное приложение (одна большая СУБД, один аналитический движок)Рассмотреть однопроцессорный сервер с максимальной частотой и достаточным числом ядер на одном сокете — так проблема NUMA не возникает вообще
Несколько независимых приложений/ВМ на одном физическом сервереДвухпроцессорный сервер выгоден, если явно привязать каждое приложение к своему NUMA-узлу через numactl или через affinity гипервизора
СУБД, которая объективно не помещается в ресурсы одного узла по памяти или ядрамДвухпроцессорный сервер с настройкой интерливинга памяти (--interleave=all) и мониторингом через numastat
Виртуализация (KVM/Proxmox и т.п.)Настраивать vNUMA — прокидывать топологию NUMA внутрь виртуальной машины, а не просто раскидывать vCPU по хосту вслепую
VPS с несколькими vCPU у провайдераПовлиять на размещение обычно нельзя; если чувствительность к задержкам памяти критична — рассматривать выделенный сервер

Не стоит воспринимать двухпроцессорный сервер как автоматически «вдвое быстрее» одного мощного CPU той же генерации: прирост реальный для нагрузок, которые хорошо параллелятся, но для однопоточных или NUMA-наивных сценариев частота одного ядра и правильная топология важнее номинального числа сокетов — похожая логика разобрана в статьях про сравнение AMD EPYC и Intel Xeon и выбор процессора для баз данных.

Если вы используете виртуализацию поверх такого сервера, полезно свериться со статьёй о том, где виртуализация теряет производительность — NUMA там один из факторов, но не единственный. А для конфигурации с большим объёмом памяти под один процесс см. материал про серверы с терабайтом оперативной памяти.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

NUMA — это проблема только серверов с двумя физическими процессорами?

Нет, NUMA-топология появляется у любого CPU с несколькими NUMA-узлами, включая часть современных одно-сокетных процессоров с чиплетной архитектурой (например, некоторые модели AMD EPYC делят один физический процессор на несколько узлов). Проверить топологию можно командой numactl --hardware независимо от числа сокетов.

Как понять, что именно NUMA — причина просадки, а не что-то другое?

Сначала исключите более частые причины: нехватку памяти, диск, сеть, конкуренцию за CPU. Затем сравните numastat -p <PID> — если процесс работает на ядрах одного узла, а значительная доля его памяти лежит на другом, это сигнал в пользу NUMA. Финальная проверка — жёстко привязать процесс через numactl --cpunodebind --membind и сравнить поведение под той же нагрузкой.

Отключение NUMA в BIOS (Node Interleaving) поможет?

Такая опция превращает систему обратно в подобие UMA: адресное пространство размазывается по узлам циклически. Это упрощает жизнь приложениям, которые совсем не учитывают NUMA, но лишает возможности получить максимум от локального доступа там, где приложение настроено правильно. Разумнее сначала попробовать явную привязку через numactl.

Нужно ли думать про NUMA на обычном VPS с 2-4 vCPU?

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

Есть ли в PostgreSQL/MySQL встроенная поддержка NUMA «из коробки»?

Прямой NUMA-awareness на уровне планировщика запросов не реализован — они полагаются на политику размещения памяти ОС и на явную настройку через numactl при запуске. Рекомендации сообщества сводятся к интерливингу буферного пула, а не к «умному» NUMA-планированию внутри самой базы.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →