Случайное чтение с NVMe накопителя занимает десятки микросекунд, однако в реальных системах счетчик часто показывает сотни, и разница рождается не в железе, а в раскладке очередей и прерываний. Драйвер nvme по умолчанию строит по одной аппаратной очереди на каждое ядро и разбрасывает векторы прерываний по всей машине, что дает максимум параллелизма, но не всегда минимум задержки. Для базы данных или службы с жестким SLA расклад стоит перебрать руками. Ниже разобрано, где смотреть текущую топологию, как задавать число и глубину очередей, зачем нужны очереди опроса, как привязать прерывания к ядрам и как убедиться в результате тестом fio.

Очереди NVMe и планировщик blk-mq, определяющие путь запроса к накопителю

Протокол NVMe позволяет контроллеру держать до 65535 пар очередей ввода-вывода, где каждая пара состоит из очереди отправки и очереди завершения. Отдельно существует административная пара с номером ноль для управляющих команд identify и set-feature. При загрузке драйвера ядро договаривается о числе очередей командой set-feature с кодом 0x07 и на локальном PCIe накопителе обычно поднимает по очереди на каждое активное ядро. Над всем этим работает блочный слой blk-mq: он раздает запросам теги, держит программную очередь на каждом процессоре и отправляет их в привязанную аппаратную очередь. Каждый вектор MSI-X обслуживает одну очередь, поэтому таблица прерываний служит честной картой топологии. Глубина очереди ограничена полем mqes из ответа identify controller, на массовых моделях там стоит 0x3ff, то есть до 1024 команд в одной очереди, а 16 очередей суммарно держат 16384 команды одновременно.

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

Просмотр текущей раскладки очередей, прерываний и глубины через sysfs и утилиты nvme

Прежде чем менять что-либо, стоит увидеть текущее состояние, иначе настройка превратится в гадание. Идентификацию контроллера дает утилита nvme-cli:

nvme id-ctrl /dev/nvme0 | grep -iE 'mqes|sqes|cqes'

На типичном PCIe накопителе вывод выглядит примерно так:

mqes   : 0x3ff
sqes   : 0x66
cqes   : 0x44

Поле mqes 0x3ff означает максимум 1024 команды в очереди, а младшие полубайты полей sqes и cqes кодируют размер записи: 6 дает 64 байта на команду отправки, 4 дает 16 байт на запись завершения. Фактические очереди показывает таблица прерываний:

grep nvme0q /proc/interrupts
 44: 120311 0 0 0 PCI-MSI 524288-edge nvme0q1
 45: 0 98221 0 0 PCI-MSI 524289-edge nvme0q2

Имена вида nvme0qN соответствуют очередям, вектор nvme0q0 принадлежит административной очереди, поэтому на сервере с 16 ядрами и стандартной настройкой в таблице будет 17 строк с именами nvme0q. Горячие очереди видны по столбцам счетчиков: два снимка /proc/interrupts с интервалом в 10 секунд покажут, какие ядра обслуживают накопитель. Число фактически выделенных пар читается у контроллера:

nvme get-feature /dev/nvme0 -f 0x07 -H

В ответе есть строка вида "Number of IO SQ and CQ allocated: 16", где и записан счетчик. Со стороны блочного слоя полезны два файла:

cat /sys/block/nvme0n1/queue/scheduler
cat /sys/block/nvme0n1/queue/nr_requests
[none] mq-deadline
1023

Планировщик none стоит по умолчанию, лимит nr_requests равен 1023 и повторяет число тегов одной аппаратной очереди. Глубина аппаратной очереди лежит в debugfs, смонтировать его можно командой mount -t debugfs none /sys/kernel/debug, файл /sys/kernel/debug/block/nvme0n1/hctx0/queue_depth покажет значение, обычно близкое к максимуму из mqes.

Число очередей и их глубина при подключении накопителя по сети через nvme connect

У локального PCIe накопителя число очередей задается параметрами модуля, а у сетевого, подключенного через NVMe over Fabrics, они указываются прямо при подключении командой nvme connect:

nvme connect --transport=tcp --traddr=192.168.10.20 --trsvcid=4420 \
  --nqn=nqn.2014-08.org.example:vol0 \
  --nr-io-queues=8 --nr-write-queues=2 --nr-poll-queues=2 --queue-size=1024

Параметр nr-io-queues создает обычные очереди, по умолчанию ядро просит по очереди на каждое активное ядро, и на крупном сервере это расточительно. nr-write-queues выделяет отдельные очереди только для записи, чтобы фоновая запись журнала базы данных не толкалась с чтениями, а nr-poll-queues поднимает очереди без прерываний, о них речь дальше. queue-size задает глубину, по умолчанию 1024, потолок определяется полем mqes целевого контроллера. Прикидка ресурсов проста: пара очередей на 1024 записи занимает 80 КиБ, потому что 1024 умножить на 64 байта в очереди отправки и 1024 умножить на 16 байт в очереди завершения. Двенадцать пар из примера займут меньше мегабайта памяти ядра, экономить очереди ради памяти смысла нет, а ограничить ради предсказуемости полезно: 8 потоков с глубиной 32 требуют всего 256 тегов вместо 12288 одновременных команд. При потере связи ядро пересоберет очереди с теми же параметрами, поэтому раскладка переживет и аварийные переподключения.

Очереди опроса и параметр poll_queues для чтений с минимальной задержкой

В обычной очереди контроллер после завершения команды поднимает прерывание, ядро просыпается и передает блок данных приложению. В очереди опроса прерываний нет: поток приложения сам опрашивает очередь завершений и забирает результат, экономя десятки микросекунд на каждом чтении. На локальном накопителе такие очереди включаются модульным параметром:

echo 'options nvme_core poll_queues=4' > /etc/modprobe.d/nvme-poll.conf
echo 'options nvme_core default_ps_max_latency_us=0' >> /etc/modprobe.d/nvme-poll.conf

Применяются настройки после перезагрузки, либо, если накопители свободны, после modprobe -r nvme_core и затем modprobe nvme. Вторая строка запрещает накопителю уходить в глубокие состояния питания по схеме APST, из которых пробуждение стоит миллисекунды и портит пиковые задержки. Приложение должно само попросить быстрый путь: файл открывается с флагом O_DIRECT, а запросы идут через io_uring с режимом IOPOLL или через preadv2 с флагом RWF_HIPRI. Проверить разницу удобно fio:

fio --name=poll --filename=/dev/nvme0n1 --direct=1 --rw=randread --bs=4k \
  --iodepth=16 --ioengine=io_uring --hipri=1 --time_based --runtime=30

Без O_DIRECT и hipri=1 очереди опроса простаивают, буферизованные чтения в них не попадают. Плата за скорость жесткая: ядро, где крутится опрос, занято полностью, поэтому очередей опроса ставят одну или две на NUMA узел, а не по числу ядер. Четыре очереди из примера закроют горячие потоки базы данных с запасом.

Привязка прерываний очередей к ядрам и изоляция ядер под чувствительные службы

Даже без очередей опроса задержку снижает правильная привязка обычных прерываний. Сначала находят номера векторов и узел, где живет контроллер:

grep nvme0q /proc/interrupts
cat /sys/class/nvme/nvme0/device/numa_node

Затем вектор привязывают к ядру записью в его маску:

echo 2 > /proc/irq/124/smp_affinity_list

По умолчанию ядро помечает векторы nvme как управляемые и раскладывает по всем ядрам равномерно, а служба irqbalance переносит их по своей прихоти, поэтому раскладку фиксируют. Службу останавливают совсем или запрещают ей трогать выделенные под нагрузку ядра:

systemctl disable --now irqbalance
echo 'IRQBALANCE_BANNED_CPULIST=2-7' >> /etc/default/irqbalance

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

  1. найти в /proc/interrupts номера векторов nvme0q и через numa_node определить NUMA узел контроллера;
  2. остановить службу irqbalance или внести нужные ядра в IRQBALANCE_BANNED_CPULIST, чтобы раскладка не поплыла через час работы;
  3. расставить smp_affinity_list по векторам очередей так, чтобы каждая очередь жила на ядре своего NUMA узла рядом с приложением;
  4. изолировать оставшиеся ядра параметром isolcpus=nohz,domain,2-7 в строке GRUB и запускать чувствительную службу на них через taskset -c 2-7;
  5. проверить раскладку под нагрузкой и откатить изменения, если пропускная способность заметно просела.

Изолированные ядра освобождаются от прерываний: управляемые векторы ядро распределяет только по обычным ядрам, туда же уходит большая часть фоновой работы. Остается убедиться, что очередь и приложение живут на одном NUMA узле, иначе каждый запрос будет мотать данные между узлами.

Планировщик none и mq-deadline, глубина nr_requests и объединение прерываний

Планировщик по умолчанию для NVMe это none: запрос уходит в аппаратную очередь без переупорядочивания, на чисто случайных нагрузках это лучший вариант. На смешанной нагрузке mq-deadline выигрывает, потому что держит очередь с дедлайном и отдает приоритет чтениям, сглаживая хвосты:

echo mq-deadline > /sys/block/nvme0n1/queue/scheduler
echo 2048 > /sys/block/nvme0n1/queue/nr_requests

Увеличение nr_requests дает запас на переупорядочивание, но растет и память под теги, поэтому значения выше 4096 почти никогда не нужны. Файл rq_affinity со значением 2 заставляет завершать запрос на том же процессоре, где он был отправлен, что бережет кэш и снижает миграции:

echo 2 > /sys/block/nvme0n1/queue/rq_affinity

Механизм writeback throttling притормаживает записи ради чтений, на быстрых накопителях его порог иногда только добавляет задержку, проверить и отключить можно так:

cat /sys/block/nvme0n1/queue/wbt_lat_usec
echo 0 > /sys/block/nvme0n1/queue/wbt_lat_usec

Последний регулятор лежит в самом контроллере, это объединение прерываний, feature 0x0c. Контроллер копит несколько завершений и поднимает одно прерывание на группу, экономя ресурсы процессора, но добавляя каждой команде ожидание до порога слияния:

nvme set-feature /dev/nvme0 -f 0x0c -v 0
nvme get-feature /dev/nvme0 -f 0x0c -H

Нулевое значение отключает слияние полностью, что полезно для служб с SLA в десятки микросекунд, а для потоковых задач слияние наоборот выгодно. В ряде ядер есть планировщик kyber, работающий по цели задержки, но с хорошим NVMe он почти всегда проигрывает простому none. Настройки планировщика сбрасываются после перезагрузки, поэтому их закрепляют правилом udev в файле /etc/udev/rules.d/60-nvme-scheduler.rules:

ACTION=="add|change", KERNEL=="nvme0n1", ATTR{queue/scheduler}="none", ATTR{queue/rq_affinity}="2"

Проверка результата тестом fio и сравнение задержек p50 и p99

Любое изменение из предыдущих разделов проверяется одной и той же парой прогонов, до и после:

fio --name=base --filename=/dev/nvme0n1 --direct=1 --rw=randread --bs=4k \
  --iodepth=64 --ioengine=io_uring --time_based --runtime=30 --group_reporting

Блок процентов из вывода говорит о главном:

  lat (usec)   : 100=42.31%, 250=52.10%, 500=4.98%
     clat percentiles (usec):
     |  1.00th=[   71], 50.00th=[  132], 99.00th=[  248],
     | 99.90th=[  395]

Здесь медиана 132 микросекунды, а 99-й процентиль 248, хвост здесь важнее медианы, транзакции тормозят редкими, но долгими чтениями. После включения очередей опроса и привязки прерываний тот же тест на типичном стенде дает медиану порядка 60-80 микросекунд и 99-й процентиль в районе 100-120, то есть хвост сжимается в два раза и больше. Числа зависят от модели накопителя и соседней нагрузки, поэтому сравнивать нужно одинаковые прогоны на одних условиях, а не надежды. Быструю прикидку под реальной нагрузкой дает iostat:

iostat -x 5

Колонка r_await показывает среднее время чтения в миллисекундах, а %util подсказывает, упирается ли накопитель в потолок. Отдельно стоит смотреть загрузку процессора: poll очередь делает процесс горячим на все ядро, и если чтение за микросекунду занимает целое ядро, экономика решения меняется. Попутно стоит проверить здоровье накопителя, перегретый контроллер сбрасывает скорость независимо от настроек очередей:

nvme smart-log /dev/nvme0 | grep -i temperature

Правило простое, менять по одному параметру за прогон, записывать три числа, медиану, 99-й и 99.9-й процентиль, и возвращать то, что не дало выигрыша. Раскладка очередей NVMe не требует ни нового железа, ни патчей ядра, нужны только sysfs, nvme-cli и час внимательных измерений, а наградой служит предсказуемая задержка вместо лотереи прерываний.