Перейти к содержанию

Глава 20: Оптимизация NUMA

Что такое NUMA?

Современные многосокетные серверы не имеют единой плоской шины памяти. Каждый процессорный сокет владеет частью физической RAM. Эта архитектура называется Non-Uniform Memory Access (NUMA). Доступ CPU к своей локальной памяти быстрый. Доступ CPU к памяти другого сокета облагается штрафом по задержке.

        Сокет 0                                   Сокет 1
  +--------------------+    Шина QPI / UPI     +--------------------+
  |  CPU ядра 0-19     |<-------------------->  |  CPU ядра 20-39    |
  |  L1/L2/L3 кэш     |  (штраф 30-50%)       |  L1/L2/L3 кэш     |
  +--------+-----------+                       +--------+-----------+
  +--------+-----------+                       +--------+-----------+
  |  Локальная DRAM    |                       |  Локальная DRAM    |
  |  Нода 0: 64 GiB   |                       |  Нода 1: 64 GiB   |
  |  дистанция: 10     |   удалённая: 21       |  дистанция: 10     |
  +--------------------+                       +--------------------+

Ключевые термины:

  • Нода: NUMA-домен -- один сокет плюс прикреплённая к нему память
  • Локальный доступ: CPU читает память своей ноды (дистанция = 10)
  • Удалённый доступ: CPU читает память другой ноды (дистанция = 21+)
  • numa_hit: Аллокация выполнена с запрошенной ноды
  • numa_miss: Аллокация ушла на другую ноду
  • numa_foreign: Другая нода аллоцировала из памяти этой ноды

Почему это важно

Шина Штраф по задержке Где встречается
Intel QPI ~30-40% Xeon E5/E7 v1-v4
Intel UPI ~30-50% Xeon Scalable (Skylake+)
AMD Infinity Fabric ~20-40% EPYC (Rome, Milan, Genoa)
4-сокетный QPI ~60-100% (2 хопа) 4S/8S серверы корпоративного класса

База данных, выполняющая 10M запросов/сек со штрафом 40% за удалённый доступ -- это разница между выполнением SLA и его нарушением. Неправильное размещение NUMA -- один из главных убийц производительности, который люди упускают из виду, потому что "на сервере достаточно RAM".

Объяснение матрицы дистанций

Каждая нода предоставляет /sys/devices/system/node/nodeN/distance:

# cat /sys/devices/system/node/node0/distance
10 21 31 31
  • 10 = локальная (собственная), всегда 10. Это базовый уровень.
  • 21 = один хоп. Соотношение: 21/10 = 2.1x от локальной задержки.
  • 31 = два хопа (4-сокетный или мульти-чиплетный EPYC). Штраф 3.1x.

Как melisai обнаруживает проблемы

MemoryCollector.parseNUMAStats() читает четыре источника на каждую ноду:

Источник Путь Предоставляет
meminfo /sys/devices/system/node/nodeN/meminfo MemTotalBytes, MemFreeBytes
numastat /sys/devices/system/node/nodeN/numastat numa_hit, numa_miss, numa_foreign
distance /sys/devices/system/node/nodeN/distance Стоимость хопов между нодами
cpulist /sys/devices/system/node/nodeN/cpulist CPU, принадлежащие этой ноде

Также собираются два sysctl: vm.zone_reclaim_mode и kernel.sched_numa_balancing.

Вычисление MissRatio

total := node.NumaHit + node.NumaMiss
if total > 0 {
    node.MissRatio = float64(node.NumaMiss) / float64(total) * 100
}

Вычисляется для каждой ноды. Движок аномалий берёт максимум по всем нодам:

Серьёзность Порог Значение
Warning > 5% Кросс-нодовые аллокации -- нужно исследовать
Critical > 20% Интенсивный кросс-нодовый трафик -- исправить немедленно

Пример JSON-вывода

{
  "zone_reclaim_mode": 0,
  "sched_numa_balancing": 1,
  "numa_nodes": [
    {
      "node": 0, "mem_total_bytes": 68719476736, "mem_free_bytes": 12884901888,
      "numa_hit": 48291035, "numa_miss": 127, "numa_foreign": 18442,
      "miss_ratio": 0.0003, "distance": [10, 21], "cpus": "0-19"
    },
    {
      "node": 1, "mem_total_bytes": 68719476736, "mem_free_bytes": 34359738368,
      "numa_hit": 31204871, "numa_miss": 18442, "numa_foreign": 127,
      "miss_ratio": 0.059, "distance": [21, 10], "cpus": "20-39"
    }
  ]
}

У ноды 1 -- 18 442 промаха: что-то на ноде 1 тянет память с ноды 0. Подтверждается значением numa_foreign=18442 на ноде 0.

Диагностические примеры

Одно-нодовый сервер -- нет проблем

{ "numa_nodes": [
    { "node": 0, "numa_hit": 92456123, "numa_miss": 0, "miss_ratio": 0,
      "distance": [10], "cpus": "0-7" }
]}

Одна NUMA-нода, дистанция [10], ноль промахов. Нечего исправлять.

Двухсокетный с высоким miss ratio

{ "numa_nodes": [
    { "node": 0, "mem_free_bytes": 4294967296,
      "numa_hit": 112847291, "numa_miss": 891204, "miss_ratio": 0.78,
      "distance": [10, 21], "cpus": "0-23" },
    { "node": 1, "mem_free_bytes": 102005473280,
      "numa_hit": 18294102, "numa_miss": 24017893, "miss_ratio": 56.8,
      "distance": [21, 10], "cpus": "24-47" }
]}

Нода 1: 56.8% miss ratio. Крупное приложение работает на CPU 24-47, но его память была аллоцирована на ноде 0. Каждый доступ пересекает шину UPI. На ноде 0 -- 4 ГиБ свободно, на ноде 1 -- 102 ГиБ свободно: приложение израсходовало всю RAM ноды 0, несмотря на то что работает на ноде 1.

melisai срабатывает: severity: critical, metric: numa_miss_ratio, value: 56.8.

Несбалансированная память, низкий miss ratio

{ "numa_nodes": [
    { "node": 0, "mem_free_bytes": 1073741824,
      "numa_hit": 98102344, "numa_miss": 2104, "cpus": "0-23" },
    { "node": 1, "mem_free_bytes": 120259084288,
      "numa_hit": 5291033, "numa_miss": 41, "cpus": "24-47" }
]}

Miss ratio низкий, но на ноде 0 -- 1 ГиБ свободно, а на ноде 1 -- 112 ГиБ свободно. Все нагрузки привязаны к ноде 0. Когда свободная память закончится, ядро начнёт либо reclaim (kswapd), либо удалённую аллокацию. Оба варианта плохи. Обычно это означает, что все сервисы запускались по умолчанию на группе ядер CPU 0.

Исправление проблем NUMA

1. numactl -- привязка процессов

# Запуск PostgreSQL на CPU ноды 0 и памяти ноды 0
numactl --cpunodebind=0 --membind=0 /usr/lib/postgresql/16/bin/postgres

# Проверка размещения
numastat -p <pid>

Для systemd-сервисов:

# /etc/systemd/system/postgresql.service.d/numa.conf
[Service]
CPUAffinity=0-23
NUMAPolicy=bind
NUMAMask=0

2. sched_numa_balancing

Ядро автоматически мигрирует страницы на ноду, с которой к ним чаще всего обращаются. Работает путём пометки страниц как недоступных, перехвата page fault и миграции в сторону обращающегося CPU.

sysctl -w kernel.sched_numa_balancing=1
echo 'kernel.sched_numa_balancing=1' >> /etc/sysctl.d/99-numa.conf

Используйте для серверов общего назначения с множеством мелких сервисов. Избегайте для нагрузок реального времени, где накладные расходы на миграцию (page fault, TLB flush) неприемлемы.

3. zone_reclaim_mode

# 0 = аллоцировать удалённо перед reclaim локального кэша (по умолчанию, рекомендуется)
# 1 = выполнить reclaim локального кэша перед удалённой аллокацией
sysctl -w vm.zone_reclaim_mode=0

Оставьте 0. Освобождение page cache (штраф 2x) лучше, чем повторное чтение с диска (штраф 1000x+). Устанавливайте 1 только для HPC-нагрузок, где локальность памяти важнее I/O.

4. Чередование для баз данных

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

Разделяемые буферы, к которым обращаются бэкенды со всех нод, выигрывают от чередования: каждый поток получает ~50% локального доступа, предсказуемую задержку. Привязка даёт локальным потокам отличную задержку, а удалённым -- ужасную.

Матрица принятия решений

Нагрузка Политика Причина
Крупная БД (PostgreSQL, MySQL) --interleave=all Все бэкенды обращаются к буферному пулу
Redis (однопоточный) --cpunodebind=N --membind=N Один поток, одна нода
JVM-приложение --cpunodebind=N --membind=N Куча приватна для процесса
Мульти-сервисный хост sched_numa_balancing=1 Слишком много процессов для привязки
HPC / MPI Явная привязка по рангу Каждый ранг владеет своими данными
GPU-нагрузка Привязка к NUMA-ноде GPU См. ниже

NUMA и контейнеры

Kubernetes управляет топологией NUMA через Topology Manager:

Политика Поведение
none Без учёта NUMA (по умолчанию)
best-effort Попытка выравнивания, планирование в любом случае если невозможно
restricted Отклонение пода при невозможности выравнивания по NUMA
single-numa-node Все ресурсы с одной NUMA-ноды
topologyManagerPolicy: "single-numa-node"
topologyManagerScope: "pod"

Для баз данных на NUMA-оборудовании в K8s single-numa-node + гарантированный QoS-класс -- это минимум. Без этого kubelet может разместить CPU пода на ноде 0, а его hugepages на ноде 1.

NUMA-статистика системного уровня от melisai в сочетании с метриками контейнеров (Глава 7) позволяет соотнести высокий miss ratio с назначением CPU контейнеров.

NUMA и GPU

GPU подключаются к определённому корневому комплексу PCIe на определённой NUMA-ноде. Структура GPUDevice в melisai фиксирует это:

type GPUDevice struct {
    NUMANode    int    `json:"numa_node"`
    // ...
}

Всегда привязывайте GPU-нагрузки к NUMA-ноде GPU:

cat /sys/bus/pci/devices/0000:41:00.0/numa_node   # => 1
numactl --cpunodebind=1 --membind=1 python train.py

Кросс-NUMA доступ к GPU штрафует cudaMemcpy, peer-to-peer трансферы по PCIe и препроцессинг на стороне CPU. См. Главу 18 -- Мониторинг GPU для сбора метрик GPU.

Ключевые выводы

  1. Miss ratio > 5% = предупреждение. Что-то аллоцирует на неправильной ноде.
  2. Miss ratio > 20% = критическое. Штраф 30-50% задержки на значительную долю обращений.
  3. Матрица дистанций показывает стоимость. 10=локальная, 21=2.1x, 31=3.1x.
  4. numactl для выделенных нагрузок. Привяжите CPU и память к одной ноде.
  5. Чередование для разделяемых данных. --interleave=all для буферных пулов БД.
  6. zone_reclaim_mode=0 правильно почти для всех.
  7. GPU имеют привязку к NUMA. Привязывайте к ноде GPU.
  8. K8s нуждается в Topology Manager. По умолчанию none -- без учёта NUMA.

Далее: Глава 21 -- Чек-лист настройки для production