Когда адаптер под DPDK обязан переваривать десятки миллионов пакетов в секунду, узкое место почти никогда не прячется там, где его ищут. Ядро приложения может быть вылизано до идеала, а трафик всё равно упирается в очередь приёма, не успевающую отдавать пакеты. Сколько очередей, каких размеров кольца и как поток размазан по очередям, решает судьбу всей системы. Ниже собран рабочий набор приёмов: запуск testpmd с точными параметрами, настройка очередей в коде, распределение пакетов по таблице reta и чтение счётчиков через xstats.
Анатомия очереди приёма в DPDK от кольца дескрипторов до буфера mbuf
Внутри каждой очереди приёма лежит кольцо дескрипторов, массив записей, указывающих на буферы mbuf в памяти. Контроллер адаптера пишет входящие пакеты прямо в эти буферы, а ядро приложения непрерывно опрашивает кольцо, забирает готовые буферы и подкладывает свежие. Размер кольца, число очередей и число обслуживаемых ядер нельзя задавать наугад: любой из трёх параметров либо даёт запас на всплески, либо превращает очередь в узкое горлышко.
Границы размеров диктует драйвер, и их видно через rte_eth_dev_info_get в поле rx_desc_lim: там nb_min, nb_max и nb_align. У ixgbe в актуальной ветке это 64 и 8192 дескриптора, у i40e диапазон от 64 до 8160 с выравниванием по 32. Библиотека ethdev проверяет запрос ещё до передачи в драйвер: за пределами диапазона или без нужного выравнивания приложение получит -EINVAL с подсказкой о допустимых числах. Ноль вместо nb_rx_desc включает значение драйвера по умолчанию, а запасной вариант самой библиотеки равен 512 дескрипторам.
Размер кольца бьёт по памяти сильнее, чем кажется. Каждый дескриптор приёма держит один mbuf со стандартным буфером в 2176 байт, из которых 2048 отведено данным и 128 под заголовок пакета. Восемь очередей по 4096 дескрипторов съедают 32768 буферов только под кольца, а это порядка 75 МБ ещё до основного пула. Кольцо выгодно делать степенью двойки: многие драйверы индексируют его по маске, и прочие числа оборачиваются лишними проверками.
Подготовка стенда с огромными страницами и привязка адаптера через dpdk-devbind.py
Профилирование начинается с чистой площадки, иначе счётчики будут врать. Первый шаг это память: DPDK требует огромные страницы, и утилита dpdk-hugepages.py делает разметку одной командой:
sudo dpdk-hugepages.py -p 1G --setup 2G
sudo dpdk-hugepages.py -s
Первая строка резервирует два гигабайта страницами по 1 ГБ, вторая показывает текущее состояние.
Второй шаг это отвязка адаптера от драйвера ядра. Что держит интерфейс, покажет вывод:
usertools/dpdk-devbind.py -s
Пометка drv=ixgbe означает драйвер ядра, а drv=vfio-pci устройство, отданное DPDK. Карта передаётся драйверу vfio-pci, модуль загружается заранее:
sudo modprobe vfio-pci
sudo usertools/dpdk-devbind.py -b vfio-pci 0000:0a:00.0
Откат делается той же утилитой через -u или привязкой обратно к ixgbe. Подвох живых серверов: устройство с активным маршрутом утилита трогать откажется, и флаг --force стоит применять, только когда ясно, что сеть отвалится прямо сейчас.
Запуск testpmd с нужным числом очередей и размерами колец дескрипторов
testpmd остаётся самым быстрым способом выжать из адаптера потолок до появления собственного приложения. Типовой запуск под четыре очереди:
sudo ./build/app/dpdk-testpmd -l 0-3 -n 4 -a 0000:0a:00.0 -- \
--interactive --rxq=4 --txq=4 --rxd=4096 --txd=1024 \
--nb-cores=4 --burst=64
Параметры --rxq и --txq задают число очередей приёма и передачи, по умолчанию 1, с диапазоном до 65535. --rxd и --txd задают глубину колец дескрипторов: в свежих выпусках DPDK по умолчанию 128 дескрипторов на приём и 512 на передачу, чего на 10G и выше катастрофически мало при всплесках, поэтому 4096 на приёме стало привычной отправной точкой. --nb-cores определяет число форвардящих ядер, по умолчанию одно; очереди распределяются между ними автоматически, а в выводе show config fwd видны потоки вида RX P=0/Q=0 -> TX P=1/Q=0 с привязкой к ядрам. Жёсткое правило: ядер не больше, чем портов, умноженных на число очередей на порту.
Пороги колец влияют на задержку сильнее размера: на приёме rxpt 8, rxht 8, rxwt 4, на передаче txpt 36, правятся через --rxpt, --rxht, --rxwt, --txpt и --rxfreet с --txfreet. Рядом --mbcache с кэшем 16 и --mbuf-size со стандартными 2048 байтами.
Конфигурация меняется и на ходу: port config all rxd 2048 переписывает глубину колец для всех портов, а port config 0 rxq 2 ring_size 4096 настраивает одну очередь и применяется после перезапуска порта или пересоздания очереди командой port 0 rxq 2 setup. Очереди останавливаются и запускаются порознь через port 0 rxq 2 stop и start, а --ring-numa-config=(0,3,0) прикрепляет кольца порта 0 к сокету 0, где 1 это приём, 2 передача, 3 обе стороны.
Настройка очередей в коде приложения через rte_eth_rx_queue_setup
Проверенная конфигурация переносится в приложение:
struct rte_eth_conf port_conf = {0};
port_conf.rxmode.mq_mode = RTE_ETH_MQ_RX_RSS;
rte_eth_dev_configure(port_id, 8, 8, &port_conf);
struct rte_mempool *mb_pool = rte_pktmbuf_pool_create("pool_rx",
65536, 256, 0, RTE_MBUF_DEFAULT_BUF_SIZE,
rte_eth_dev_socket_id(port_id));
rte_eth_rx_queue_setup(port_id, 0, 4096,
rte_eth_dev_socket_id(port_id), NULL, mb_pool);
rte_eth_tx_queue_setup(port_id, 0, 1024,
rte_eth_dev_socket_id(port_id), NULL);
rte_eth_dev_start(port_id);
Сигнатура rte_eth_rx_queue_setup принимает номер порта, номер очереди, глубину кольца nb_rx_desc, идентификатор NUMA-сокета, конфигурацию очереди и пул буферов. Сокет не мелочь, а половина производительности: кольцо и буферы обязаны жить в памяти узла, к которому подключён PCI-слот адаптера, иначе каждый пакет поедет через межпроцессорную шину. NULL вместо rte_eth_rxconf включает значения драйвера по умолчанию, а пороги pthresh, hthresh и wthresh задаются в структуре rx_conf, повторяя --rxpt и --rxht из testpmd.
Проверка глубины на входе та же: сверка с rx_desc_lim, при отказе возвращается -EINVAL. Ловушка переконфигурации: очередь нельзя пересоздать на работающем порту, сначала требуется остановка, и лишь драйверы с capability RTE_ETH_DEV_CAPA_RUNTIME_RX_QUEUE_SETUP позволяют пересоздавать остановленную очередь на лету.
Передачу настраивает rte_eth_tx_queue_setup с тем же набором аргументов, кроме пула. Поля tx_free_thresh и tx_rs_thresh управляют тем, как часто адаптер возвращает дескрипторы и выставляет бит завершения. Если передатчик тормозит на мелких пакетах, снижение tx_free_thresh до 32 заставляет драйвер освобождать их раньше и держит кольцо незабитым.
Распределение пакетов по очередям через RSS и таблицу reta
Восемь очередей бесполезны, если весь трафик валится в первую. Распределением занимается Receive Side Scaling: адаптер считает хеш от связки адресов и портов и по таблице reta переводит его в номер очереди. Размер таблицы у каждого семейства свой: классический 82599 получает 128 записей, X550, i40e и ice держат 512 записей, группами по 64, что отражено в структуре rte_eth_rss_reta_entry64:
testpmd> show port 0 rss reta 128 0xff,0xff
testpmd> show port 0 rss-hash
Первая команда печатает записи таблицы порта 0, выбирая группы масками, вторая показывает включённые хеш-функции и ключ. Перекроить таблицу можно на лету:
testpmd> port config all rss reta (0,3),(1,2),(2,1),(3,0)
testpmd> port config all rss udp
Первая строка направляет хеши 0, 1, 2 и 3 в очереди 3, 2, 1 и 0, вторая включает хеширование по UDP, незаменимое для потокового трафика, где пакеты раскидываются по транспортным связкам, а не только по IP-адресам. На стороне запуска работают --enable-rss для принудительного включения RSS, --rss-udp, добавляющий UDP к хеш-функциям, и --disable-rss для выключения. Результат виден и в приложении: при offload RTE_ETH_RX_OFFLOAD_RSS_HASH в поле hash.rss mbuf хранится хеш адаптера.
Балансировка видна в статистике. Когда очереди заняты ровно, пропускная способность растёт почти линейно с числом ядер, а когда девяносто процентов пакетов оседает в rx_q0, кольцо нулевой очереди переполняется, и счётчик потерь ползёт вверх при простаивающих ядрах. Для многопроцессорных схем таблица важна вдвойне: при разбиении очередей через --num-procs и --proc-id они режутся так, что старт равен proc_id, умноженному на число очередей и делённому на число процессов, и при включённом RSS часть пакетов уходит в лишние очереди, порождая потери.
Чтение статистики очередей через xstats и поиск узких мест
Обычная статистика порта показывает только агрегаты, а правда о производительности лежит в расширенных счётчиках:
testpmd> show port stats 0
testpmd> show port xstats 0
testpmd> clear port stats all
xstats выдают стандартный набор имён: rx_good_packets, tx_good_packets, rx_good_bytes, tx_good_bytes, rx_missed_errors, rx_errors, tx_errors, rx_mbuf_allocation_errors, плюс поочерёдные счётчики rx_q0_packets, rx_q0_bytes, tx_q0_packets и tx_q0_bytes. По этим строкам на очередь читается вся картина: где трафик оседает, кто голодает, совпадает ли вход с выходом. Нулевые значения замусоривают вывод, и set xstats-hide-zero on убирает их.
Читать счётчики стоит по трём маркерам. Рост rx_missed_errors означает, что адаптеру некуда класть пакеты: кольцо мелкое или ядро не успевает его разгребать. Ненулевые rx_mbuf_allocation_errors говорят, что пул исчерпан. Разрыв между rx_q0_packets и остальными очередями это приговор таблице reta. В неинтерактивном режиме пара --stats-period 10 и --display-xstats rx_missed_errors,rx_q0_packets печатает только избранное каждые десять секунд.
Снимать статистику можно и снаружи работающего приложения, вторым процессом DPDK:
dpdk-proc-info -p 0x1 --xstats
dpdk-proc-info -p 0x1 --show-rss-reta
dpdk-proc-info -p 0x1 --show-rx-descriptor 0:0:8
Первая команда печатает расширенные счётчики порта из маски, вторая дампит таблицу reta, третья показывает восемь дескрипторов нулевой очереди приёма от заданного смещения; нужна та же версия DPDK и те же PMD-библиотеки, что у основного процесса. Внутри testpmd есть микроскоп глубже: show rxq info 0 0 печатает параметры очереди, show port 0 rxq 0 desc used count выдаёт занятость кольца прямо сейчас, а read rxd 0 0 4 вываливает сырые байты четвёртого дескриптора. Флаги --record-core-cycles и --record-burst-stats считают такты процессора на пакет и распределение размеров пакетов в burst, показывая, работает ядро пачками по максимуму или дёргается по одному пакету.
Разбор тормозящего стенда от первых симптомов до рабочей конфигурации
Диагноз выводится из счётчиков по порядку. Типовой сценарий выглядит так: адаптер на 25G, восемь очередей, линия забита лишь частично, потери на приёме растут, а ядра загружены едва наполовину. Порядок действий одинаков для любого железа:
- убедиться, что адаптер отдан DPDK, выводом dpdk-devbind.py -s;
- сверить фактическую конфигурацию командой show config rxtx, ведь запрошенные числа не всегда совпадают с принятыми драйвером;
- остановить форвардинг и посмотреть show fwd stats all, сопоставив потоки RX порт, очередь с потерями TX-dropped;
- снять show port xstats all и разделить rx_q0_packets по очередям, увидев перекос таблицы reta;
- проверить занятость кольца через show port 0 rxq 0 desc used count на пике нагрузки;
- после каждой правки сбрасывать счётчики clear port stats all и повторять замер на том же трафике.
Перекос подтверждается: хеши занятых связок легли в нулевую очередь, и таблицу reta приходится переписать через port config all rss reta, разделив трафик. Потери из-за мелкого кольца лечатся глубиной 4096 через port config 0 rxq 0 ring_size 4096 и перезапуск порта. Если потери остаются при ровном распределении, упор не в размер, а в скорость обработки, и спасает --nb-cores либо порог avail_thresh: команда set port 0 rxq 0 avail_thresh 50 поднимет событие, когда свободных дескрипторов меньше половины, и приложение отреагирует до первых потерь.
Из экзотики пригодятся hairpin-очереди: --hairpinq=2 связывает их напрямую в адаптере, минуя память хоста.
Главный вывод прост: очереди DPDK управляемы до последнего дескриптора, и почти ни одна из этих ручек не требует пересборки приложения. Таблица reta переписывается на лету, кольца меняются командой, статистика читается без остановки трафика. Поэтому в следующий раз, когда карта на 100G отдаёт меньше, чем способна, причина найдётся по счётчику, который укажет конкретную очередь и дескриптор.