Медленный отклик сервера под сетевой нагрузкой часто объясняется не слабым процессором, а тем, как доставляются уведомления от устройств. Устаревшая схема с общими линиями заставляет обработчик опрашивать всех соседей по линии, современная через MSI и MSI-X адресует каждое сообщение конкретному вектору и конкретному ядру. От того, куда эти векторы разложены, зависят и задержка, и равномерность загрузки ядер. Ниже разобраны обе технологии, чтение возможностей адаптера через lspci, просмотр векторов в /proc/interrupts, ручная привязка через smp_affinity_list и служба irqbalance, а в конце два показательных случая: сетевой адаптер и NVMe-накопитель.
Три способа сообщить процессору о готовности устройства
Исторически первым способом были четыре линии INTA, INTB, INTC и INTD на общей шине. Несколько устройств могли сидеть на одной линии, и обработчик при срабатывании обязан опросить каждого участника, потому что неизвестно, кто именно поднял сигнал. Линия уровневая, сигнал держится до подтверждения, и на загруженной машине чтение регистров всех соседей съедает заметное время.
MSI заменил линию обычной записью в память. Устройство пишет по адресу в области регистров local APIC, на x86 база 0xfee00000, номер целевого процессора лежит в битах 12-19 адреса, а сам вектор в битах 0-7 поля данных. Запись доставляется как posted write по PCIe и попадает прямо в контроллер прерываний. Опрос соседей уходит, задержка падает, а поток сообщений можно разложить по ядрам. Одна операция MSI несёт один вектор, поле Multiple Message Capable разрешает устройству запросить блок из 1, 2, 4, 8, 16 или 32 сообщений.
MSI-X снимает последние ограничения. Таблица векторов лежит в памяти устройства через один из BAR, каждая запись занимает 16 байт: 64-битный адрес, 32-битные данные и управляющее слово с битом маскирования. Число записей до 2048, поэтому каждый аппаратный канал получает собственный вектор: отдельные очереди приёма, передачи, ошибки, административные команды. Рядом с таблицей живёт битовая карта PBA, по которой устройство и драйвер сверяют необработанные сообщения. Отдельный выигрыш MSI-X проявляется в виртуализации: проброс устройства в гостевую машину через VFIO опирается на remapping прерываний в IOMMU, и именно таблица MSI-X позволяет гостю получать собственные векторы на каждую очередь. На системах без remapping VFIO требует параметра vfio_iommu_type1.allow_unsafe_interrupts=1, что прямо помечено небезопасным.
Чтение возможностей MSI и MSI-X в выводе lspci
Полную картину по устройству показывает lspci -vvv. Для адаптера с обеими возможностями фрагмент выглядит так:
Capabilities: [70] MSI: Enable+ Count=1/8 Maskable- 64bit+
Address: 00000000fee00000 Data: 0041
Capabilities: [a0] MSI-X: Enable+ Count=17 Masked- Vector table: BAR=4 offset=00000000
Поле Count у MSI записывается парой "включено из запрошенного максимума", запись Count=1/8 означает, что драйвер взял один вектор из восьми доступных. Enable показывает, работает ли механизм сейчас, Maskable сообщает о поддержке маскирования на уровне всей функции. Address и Data раскрывают фактическое сообщение: вектор 0x41, то есть 65 в десятичной записи, уходит на базу 0xfee00000. У MSI-X поле Count одно число, это размер таблицы, а Vector table с полями BAR и offset говорит, где именно лежит таблица. Переключать Enable вручную не нужно, состоянием управляет драйвер при инициализации. Состояние Masked у MSI-X относится к общему биту маскирования функции, а пофлаговое маскирование отдельных записей живёт в самой таблице: драйвер выключает вектор при закрытии очереди и включает при создании, не трогая соседей.
Если адаптер работает через старую линию, вывод lspci -v покажет строку вида Interrupt: pin A routed to IRQ 17, и этот номер в /proc/interrupts будет делиться между всеми устройствами той же линии. Переключение на MSI-X видно по исчезновению общего номера и появлению отдельных векторов: адаптеры, чьи драйверы поддерживают оба режима, по умолчанию выбирают MSI-X.
Отображение векторов прерываний в /proc/interrupts и sysfs
Каждый вектор MSI-X виден в /proc/interrupts отдельной строкой с собственным счётчиком на каждое ядро:
128: 984123 0 0 0 PCI-MSI 0000:01:00.0 enp2s0-TxRx-0
129: 0 154200 0 0 PCI-MSI 0000:01:00.0 enp2s0-TxRx-1
130: 0 0 203998 0 PCI-MSI 0000:01:00.0 enp2s0-TxRx-2
Слева динамический номер вектора, далее по колонке на каждое ядро, справа тип, адрес устройства и имя обработчика, которое присвоил драйвер. Номера у MSI выдаются выше статических линий по мере регистрации, поэтому на разных машинах отличаются. Быстрый фильтр по интересующему адаптеру:
grep -E "enp2s0|nvme0q" /proc/interrupts
Каталог /sys/kernel/irq с подкаталогом на каждый номер даёт то же в машиночитаемом виде: файл actions показывает имя обработчика, файл chip раскрывает, через какой контроллер идёт вектор. При включённом remapping прерываний в IOMMU chip содержит IR-PCI-MSI, без него просто PCI-MSI. Ещё один полезный файл, affinity_hint: туда драйвер кладёт рекомендацию по раскладке, и его содержимое обычно совпадает с тем, что служба irqbalance считает оптимальным.
Сами векторы только поднимают флаги, основная работа сетевого стека идёт в мягких обработчиках, и их раскладку видно в /proc/softirqs. Колонки NET_RX и NET_TX показывают, на каких ядрах реально вертится сетевой стек. После перекомпоновки векторов стоит сравнить обе таблицы: если векторы сидят на одних ядрах, а NET_RX растёт на других, где-то в цепочке осталась старая привязка RPS.
Ручная привязка векторов к ядрам через smp_affinity_list
Привязка делается записью в файл нужного вектора:
systemctl stop irqbalance
echo 2 > /proc/irq/128/smp_affinity_list
echo 2-5 > /proc/irq/129/smp_affinity_list
Файл smp_affinity_list принимает списки и диапазоны, файл smp_affinity несёт ту же информацию шестнадцатеричной маской: запись f0 закрепляет вектор за ядрами с четвёртого по седьмое. Значение по умолчанию на машине с восемью ядрами выглядит как ff, на больших системах маска разрастается до ffffffff,ffffffff, поэтому списочная форма практичнее. Изменение действует мгновенно, очередной вызов обработчика пойдёт на заданное ядро, что видно по росту счётчика в нужной колонке /proc/interrupts.
Узел NUMA, к которому принадлежит устройство, виден в выводе lspci -v строкой NUMA node, а файл /sys/bus/pci/devices/0000:01:00.0/numa_node отдаёт то же число. Для двухпроцессорного сервера стоит держать векторы адаптера на ядрах его узла: лишние пересылки между узлами стоят и времени, и кешей. Отдельного внимания заслуживает файл spurious в каталоге /proc/irq/<номер>: он копит счётчики событий, на которые никто не ответил. Растущий счётчик при активном трафике подсказывает, что обработчик перепривязан на ядро, которое подолгу сидит вне маски, либо что маскирование вектора застряло во включённом состоянии.
Две тонкости. Сначала останавливается служба irqbalance, иначе через десять секунд она вернёт свою раскладку и работа пойдёт насмарку. И не каждый вектор можно перепривязать: у управляемых ядром векторов, например у очередей NVMe, запись отклоняется, их раскладку ведёт подсистема blk-mq при создании очередей. Подсказку, куда вектор хотел бы попасть, даёт affinity_hint:
cat /proc/irq/128/affinity_hint
Чтобы польза от привязки не растворилась, приложение стоит посадить на те же ядра через taskset -c, тогда и вектор, и данные обработчика останутся в кешах одного ядра.
Служба irqbalance и её политика распределения векторов
Служба irqbalance раз в десять секунд пересчитывает раскладку векторов по ядрам, учитывая нагрузку, NUMA-расстояние до устройства и запреты администратора. Для смешанных нагрузок это разумное поведение по умолчанию: вектор уезжает от перегруженного ядра к свободному без участия человека.
Поведение настраивается переменными окружения. В Debian и Ubuntu их место в файле /etc/default/irqbalance, в RHEL и производных в /etc/sysconfig/irqbalance:
IRQBALANCE_BANNED_CPULIST="0-3"
ONESHOT=yes
Первая переменная исключает ядра из раскладки, их обычно бронируют под обработку сети вручную или под задачи реального времени. Старая переменная IRQBALANCE_BANNED_CPUS с шестнадцатеричной маской тоже поддерживается для совместимости, но на системах с десятками ядер маска превращается в ffffffff,ffffffff, и в ней легко ошибиться. Списковая форма IRQBALANCE_BANNED_CPULIST читается без калькулятора: значение 2 исключает одно ядро, значение 0-3 сразу четыре. ONESHOT приказывает сделать один проход при старте и завершиться, компромисс для систем, где важна исходная раскладка, но не нужен постоянный пересчёт. Полный отказ от службы делается через systemctl disable --now irqbalance, после чего раскладка остаётся той, что задана руками.
Разбор на примере сетевого адаптера и NVMe-накопителя
Сетевой адаптер с аппаратным RSS заводит по вектору на канал приёма-передачи. Число каналов смотрится и меняется через ethtool:
ethtool -l eth0
ethtool -L eth0 combined 8
После установки восьми каналов в /proc/interrupts появляются строки enp2s0-TxRx-0 до enp2s0-TxRx-7. Раскладка по ядрам с шагом один даёт предсказуемую картину:
for i in $(seq 0 7); do
irq=$(grep "enp2s0-TxRx-$i" /proc/interrupts | cut -d: -f1)
echo $((i+2)) > /proc/irq/$irq/smp_affinity_list
done
Контроль ведётся через watch -n1 "grep TxRx /proc/interrupts": при потоке трафика счётчики должны расти строго на закреплённых ядрах. Рост в чужой колонке означает, что irqbalance снова перетасовал векторы.
С NVMe иначе. Имена nvme0q1, nvme0q2 и дальше соответствуют очередям ввода-вывода, а их векторы управляются ядром: blk-mq привязывает очередь к ядру, где живёт контекст, и сторонняя перезапись отклоняется. Локальность здесь обеспечивается привязкой самого приложения: процесс, работающий на ядре 3, попадает на очередь nvme0q3, её вектор обслуживает то же ядро, и путь от пакета до данных остаётся в пределах одного узла NUMA.
Автоматизировать посадку приложения на те же ядра удобнее всего через systemd. В юните службы строка CPUAffinity=2 3 4 5 прижимает процесс к нужным ядрам сразу после старта, а разовый запуск через systemd-run --scope -p AllowedCPUs=2-5 ./benchmark даёт тот же эффект без правки юнитов. Связка вектор плюс процесс на одном ядре удерживает горячие структуры обработчика в кеше первого уровня и убирает лишние пересылки между узлами NUMA.
Типичные ошибки настройки векторов и их диагностика
Практика повторяет один и тот же набор промахов:
- привязка векторов при работающей службе irqbalance, которая через десять секунд возвращает свою раскладку, поэтому сначала systemctl stop irqbalance;
- попытка перепривязать управляемый вектор очереди NVMe, запись в smp_affinity_list отклоняется ядром, раскладку ведёт blk-mq;
- сбор всех векторов на ядро 0, мягкие обработчики выстраиваются в очередь на одном ядре, задержки растут у всей системы;
- включение pci=nomsi для быстрых экспериментов на рабочем сервере, вся периферия уезжает на общие линии с опросом соседей и потерей скорости;
- отсутствие привязки самого приложения к ядрам векторов, кеши процессора перетираются чужими данными, выигрыш от MSI-X тает.
Диагностика сводится к короткому набору. grep по /proc/interrupts отделяет векторы нужного устройства, cat /proc/irq/<номер>/smp_affinity_list подтверждает фактическую привязку, счётчики по колонкам ядер отвечают, дошла ли настройка до железа. lspci -vvv замыкает картину, показывая размер таблицы MSI-X и фактическое сообщение MSI. Для массовой проверки удобен однострочник, который печатает вектор, устройство и имя обработчика одним списком:
grep PCI-MSI /proc/interrupts | awk '{print $1, $(NF-1), $NF}'
Правильная раскладка векторов не требует пересборки ядра или драйверов: всё решается файлами в /proc/irq, переменными службы irqbalance и парой команд ethtool. Один вечер на настройку обычно превращается в ровную нагрузку по ядрам и предсказуемую задержку под нагрузкой.