Proxmox: привязка CPU к виртуальным машинам

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

В рабочих окружениях мы обычно имеем дело с серверами на 32 и больше ядер. Чтобы уместить такое количество ядер в одном сокете, производители используют сложные схемы построения CPU. А для работы с большими объёмами оперативки - Non-Uniform Memory Access (NUMA). Эта архитектура ускоряет доступ к памяти за счёт разделения её на несколько узлов, где у каждого узла есть свой кусок памяти и свои ядра процессора.

Чтобы было понятнее, возьмём для примера архитектуру AMD Zen 4 (EPYC 9004). Она изначально рассчитана на работу с кучей ядер и поддерживает огромные объёмы памяти. В такой системе NUMA делит ресурсы на отдельные узлы, где у каждого узла есть собственная память и выделенные CPU-ядра.

AMD Zen 4 architecture

В этом примере у нас есть 4 NUMA-узла. Каждый узел напрямую подключён к своему набору слотов памяти. На картинке видно, что у каждого узла по 6 слотов, то есть всего получаем 24 слота (6 слотов × 4 узла).

NUMA-узел включает 8 ядер. Каждое ядро имеет свои L1d и L1i кеши, кеш L2, а также общий L3 кеш для всех 8 ядер.

AMD EPYC 9354 32-Core Processor, информация о NUMA-узлах и кешах:

# lscpu
Caches (sum of all):
  L1d:   1 MiB (32 instances)
  L1i:                   1 MiB (32 instances)
  L2:                    32 MiB (32 instances)
  L3:                    256 MiB (8 instances)
NUMA:
  NUMA node(s):          4
  NUMA node0 CPU(s):     0-7,32-39
  NUMA node1 CPU(s):     8-15,40-47
  NUMA node2 CPU(s):     16-23,48-55
  NUMA node3 CPU(s):     24-31,56-63

Итак, у нас один CPU сокет с 32 ядрами и 64 потоками.

  • Чип содержит 4 NUMA-узла.
  • Каждый NUMA-узел имеет 8 ядер.
  • У каждого ядра - 2 потока.
  • Каждый NUMA-узел подключён к своим слотам памяти DDR5 и имеет собственный L3 кеш.

Linux предоставляет утилиту numactl, чтобы показать информацию о NUMA-узлах:

# numactl -Hs
available: 4 nodes (0-3)
node 0 cpus: 0 1 2 3 4 5 6 7 32 33 34 35 36 37 38 39
node 0 size: 128654 MB
node 0 free: 12659 MB
node 1 cpus: 8 9 10 11 12 13 14 15 40 41 42 43 44 45 46 47
node 1 size: 129019 MB
node 1 free: 12883 MB
node 2 cpus: 16 17 18 19 20 21 22 23 48 49 50 51 52 53 54 55
node 2 size: 129019 MB
node 2 free: 12683 MB
node 3 cpus: 24 25 26 27 28 29 30 31 56 57 58 59 60 61 62 63
node 3 size: 128977 MB
node 3 free: 13114 MB
node distances:
node   0   1   2   3
  0:  10  12  12  12
  1:  12  10  12  12
  2:  12  12  10  12
  3:  12  12  12  10

Если один узел обращается к памяти другого узла, это всегда медленнее, чем доступ к «своей» памяти. В таблице выше видно задержки: 10 - это доступ к памяти своего узла, 12 - к памяти соседних.

Чтобы добиться максимальной производительности, приложение должно выполняться на том же NUMA-узле, где выделена его память. А чтобы выжать максимум из кеша, ему стоит работать на тех же ядрах, где расположены кеши L1 и L2.

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

Чтобы избежать NUMA-миграций и поднять производительность VM, можно использовать CPU affinity, то есть жёстко привязать виртуалку к конкретным ядрам и NUMA-узлам. Даже если Linux-планировщик будет гонять процесс по разным ядрам, он всё равно останется в рамках одного NUMA-узла, сохраняя быстрый доступ к кешу и памяти.

Известные облачные провайдеры, когда вы выбираете тип виртуальной машины, фактически уже подбирают оптимальное размещение на физическом сервере. По такому же принципу в Proxmox есть возможность настроить CPU affinity.

Конфигурация VM

  1. Создаём виртуалку с нужной конфигурацией.
  2. Проверяем архитектуру CPU и NUMA-узлы:
lscpu | grep NUMA

Ожидаемый вывод должен показать NUMA-узлы и ядра:

NUMA node(s):   4
NUMA node0 CPU(s):                  0-7,32-39
NUMA node1 CPU(s):                  8-15,40-47
NUMA node2 CPU(s):                  16-23,48-55
NUMA node3 CPU(s):                  24-31,56-63

У нас 4 NUMA-узла, в каждом по 8 ядер и 8 потоков.

Если нужно «привязать» виртуалку к конкретному NUMA-узлу, например к первому, то используем affinity 0-7,32-39.

Файл конфигурации VM в Proxmox должен содержать параметры numa, cpu и affinity:

cores: 16
cpu: cputype=host
affinity: 0-7,32-39
numa: 1
numa0: cpus=0-15,hostnodes=0,memory=114688,policy=bind

Здесь мы создаём виртуалку с 16 ядрами и 112 ГБ памяти.

  • cpu: host - используем тип CPU «host» для максимальной производительности.
  • affinity: 0-7,32-39 - привязка VM к ядрам NUMA-узла 0.
  • numa: 1 - включаем поддержку NUMA внутри виртуалки.
  • numa0 - указываем, что у VM есть NUMA-узел 0 с 16 ядрами, привязанный к NUMA-узлу 0 на хосте, и с памятью 114688 MB, выделенной в том же NUMA-узле.

На стороне VM видим один NUMA-узел с 16 ядрами:

# lscpu | grep NUMA
NUMA node(s):   1
NUMA node0 CPU(s):                    0-15

Итог

Использование CPU affinity для виртуальных машин в продакшене реально важно.

Это позволяет приложению внутри VM не прыгать между разными NUMA-узлами. Оно остаётся в рамках одного NUMA-узла и использует один и тот же L3 кеш. При этом планировщик Linux внутри виртуалки тоже старается держать процессы на тех же CPU-ядрах, чтобы эффективно использовать кеши L1 и L2.