Когда одна сетевая карта способна принимать десятки миллионов пакетов в секунду, ни одно процессорное ядро не выдержит такой поток в одиночку. Механизм Receive Side Scaling решает это аппаратно: сетевой адаптер сам вычисляет хэш от заголовков пакета и кладёт его в одну из аппаратных очередей, а каждую очередь обслуживает своё ядро. В DPDK этот механизм даёт массу ручек настройки, и незнание их тонкостей приводит к тому, что половина ядер простаивает, пока две очереди захлёбываются трафиком. Разберём, как RSS устроен внутри, какие подводные камни ждут при конфигурации и как проверить, что распределение действительно равномерное.

Как RSS считает хэш и почему это важно понимать

Подавляющее большинство сетевых контроллеров использует хэш Тёплица: 40-байтовый секретный ключ и набор полей заголовка пакета проходят через простую битовую процедуру, и на выходе получается 32-битное значение. Для обычного TCP потока по IPv4 в хэш входят адрес источника, адрес назначения, порт источника и порт назначения. Суть в том, что все пакеты одного соединения гарантированно попадают в одну очередь, а это критично: перестановка пакетов внутри потока убила бы производительность TCP.

Младшие биты хэша не определяют очередь напрямую. Контроллер берёт, как правило, 7 младших бит, и использует их как индекс в таблице перенаправления RETA размером 128 или 512 записей в зависимости от модели адаптера. Каждая запись RETA содержит номер аппаратной очереди. Такая двухступенчатая схема позволяет перераспределять потоки, не трогая сам хэш, а просто переписывая таблицу.

Из этой механики следует важный практический вывод. Если RETA на 128 записей, а очередей 16, то одна запись таблицы не может указывать сразу на две очереди, и на каждую очередь приходится по 8 записей, и при круговом заполнении они стоят в таблице с шагом 16. При неудачном ключе нагрузка распределяется с графикой кирпичной стены: какие то ниши RETA получают больше потоков, чем соседние. Понимание этого спасает часы отладки, когда статистика показывает перекос, хотя формально всё сконфигурировано верно.

Базовая конфигурация RSS через rte_eth_dev_configure

В DPDK за RSS отвечают две структуры. В rte_eth_conf внутри rxmode устанавливают флаг mq_mode в значение RTE_ETH_MQ_RX_RSS, а структура rte_eth_rss_conf описывает ключ и типы трафика для хэширования. Поле rss_hf это битовая маска, где самые ходовые комбинации таковы: RTE_ETH_RSS_IP для хэширования только по адресам, RTE_ETH_RSS_TCP и RTE_ETH_RSS_UDP добавляют порты, RTE_ETH_RSS_L3_SRC_ONLY и DST_ONLY позволяют ограничиться одной стороной.

Отдельно стоит включить аппаратную передачу хэша в пакет. Флаг оффлоада RTE_ETH_RX_OFFLOAD_RSS_HASH заставляет контроллер класть посчитанное 32-битное значение прямо в поле hash.rss структуры mbuf, что бесплатно даёт ключ для кэширования потоков, для таблиц соединений и для балансировки на уровне приложения. Многие реализации stateful обработки вообще строят идентификатор потока именно из этого поля, и отключение оффлоада ломает половину оптимизаций выше по стеку.

Типичная ошибка новичка это включить сразу все типы, включая RTE_ETH_RSS_PORT и RTE_ETH_RSS_LEVEL_INNERMOST, не понимая последствий. Флаг PORT подмешивает номер физического порта в хэш, что при зеркальном дублировании трафика на двух адаптерах ломает симметрию. Флаги уровней туннеля определяют, какие заголовки внутри GRE или VXLAN участвуют в расчёте, и при неверном выборе весь инкапсулированный трафик может свалиться в одну очередь.

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

Ключ хэша, симметричный RSS и проблема обратного трафика

По умолчанию драйвер генерирует случайный ключ Тёплица при инициализации, и для боевых систем это плохая идея. Контроллеры разных портов или разных машин с разными ключами посчитают разные хэши для одного и того же потока. Если система строит агрегацию состояний соединений из двух зеркальных потоков SPAN, хочется, чтобы прямое и обратное направления сессии попадали на одно ядро. Случайный ключ этого гарантировать не может.

Решение называется симметричный RSS. Используют заранее известный ключ, при котором хэш от кортежа источник порт источника адрес назначения порт назначения совпадает с хэшом от обратного кортежа. Для функции Тёплица такую симметрию обеспечивают ключи, где первые 32 бита повторяются во второй половине, что сводит XOR полей к симметричному результату. В проектах вроде надёжных DPI систем и балансировщиковң ключ принято задавать явно через поле rss_key структуры rte_eth_rss_conf, передавая ту же строку байт во все экземпляры приложения.

Проверить симметричность проще всего через testpmd: запускается порт, включается вывод RSS хэша через rx_offload RSS_HASH и fwd rxonly, а затем пакеты одного потока посылаются в обе стороны. Если значения hash в метаданных mbuf совпадают, ключ подобран верно. Рассинхронизация тут это не эстетика, а грубая ошибка, после которой stateful обработка теряет половину контекста.

Таблица RETA и перераспределение потоков на горячую

Таблица перенаправления это то место, где RSS перестаёт быть статической схемой. Функции rte_eth_dev_rss_reta_query и rte_eth_dev_rss_reta_update читают и меняют таблицу без остановки порта, что позволяет реализовать горячую миграцию нагрузки между ядрами. Классический сценарий: мониторинг видит, что одна очередь загружена на 90 процентов, а соседняя на 20, и диспетчер переписывает часть записей RETA, указывая на менее занятую очередь.

Тонкость в том, что смена записи RETA действует мгновенно только на новые пакеты. Пакеты, уже лежащие в старой очереди, останутся там и будут обработаны старым ядром, поэтому при миграции кратковременно возможен локальный беспорядок пакетов внутри одного потока. Для UDP генераторов это некритично, а вот для TCP stateful анализа нужен буфер переупорядочивания на время переключения.

Размер RETA можно узнать из структуры rte_eth_dev_info в поле reta_size, и писать код, жёстко привязанный к 128 записям, это ошибка переносимости: у некоторых адаптеров Intel таблица на 512 записей, у части виртуальных устройств всего 64, а у mlx5 она эмулируется программно через таблицы форвардинга и ведёт себя тоньше. Перед распределением таблицу следует равномерно нарезать: например при 128 записях и 8 очередях заполнять её по кругу, то есть RETA[i] = i % 8: индексы 0, 1, 2, … 7 указывают на очереди 0, 1, 2, … 7, затем последовательность повторяется. Именно так таблицу по умолчанию заполняют драйверы DPDK. Блочная раскладка, когда каждая очередь получает 16 подряд идущих индексов, привязывает выбор очереди только к старшим битам индекса, а при хэшах с плохим разбросом младших битов даёт перекос.

Проверка равномерности распределения и отладка перекоса

Доверять конфигурации без статистики нельзя, и DPDK предоставляет всё необходимое для измерений. Базовый источник это счётчики очередей: rte_eth_stats возвращает q_ipackets и q_ibytes по каждой приёмной очереди, и уже по ним видно, распределён ли трафик. При здоровой RSS отклонение между очередями обычно укладывается в единицы процентов на потоке из миллионов пакетов.

Если перекос заметен, последовательность отладки известна:

  1. Прочитать ключ и маску rss_hf через rte_eth_dev_rss_hash_conf_get и убедиться, что в хэше участвуют порты, а не только адреса;
  2. Выгрузить RETA и проверить, что заполнение таблицы круговое (RETA[i] = i % число_очередей), без смещённых блоков;
  3. Снять статистику распределения потоков в самом трафике, потому что один гигантский поток между двумя адресами физически не может делиться между очередями;
  4. Проверить, не включён ли аппаратный оффлоад агрегации вроде LRO, который перед подсчётом сливает пакеты одного потока и искажает картину;
  5. Прогнать симметричный синтетический трафик из testpmd и посмотреть, включает ли распределение все очереди порта.

Причина перекоса чаще всего лежит не в RSS как таковом, а в самом трафике: туннель GRE оставляет внутри вариативность, но снаружи показывает всего два адреса, и хэш по внешним заголовкам сводит весь поток к одной очереди. Лечение это включение хэширования по внутренним заголовкам через RTE_ETH_RSS_LEVEL_INNERMOST там, где контроллер это умеет, либо переход на rte_flow с явными правилами.

Когда RSS не хватает и на помощь приходит rte_flow

RSS работает по жёстким шаблонам: конкретные поля, конкретный ключ, конкретная таблица. Как только требуется более тонкая политика, например трафик определённой подсети направить в два выделенных ядра, а остальной пустить поровну по остальным, в игру вступает API rte_flow. Правило потока с шаблоном eth followed by ipv4, действием RSS и списком очередей делает ровно то, что обычный RSS не умеет: хэширует подмножество трафика по своему набору очередей.

Тонкость в приоритетах. Все правила rte_flow образуют иерархию, и более специфичное правило с побочным действием RSS перекрывает глобальную конфигурацию порта. Если случайно добавить широкое правило с пустым шаблоном, оно перехватит вообще весь трафик, и глобальный RSS перестанет работать, что на статистике выглядит так, будто настройки слетели. Порядок при создании правил и приоритеты групп должны быть явными и документированными в коде.

Ещё одна ловушка появляется вместе с аппаратными оффлоадами. Если к правилу добавить действие MARK или COUNT, часть контроллеров молча переносит обработку в программный путь, и производительность падает с линейной до посредственной. Перед продом стоит прогнать эталонный трафик с rte_flow и без него и сравнить пакеты в секунду на пустых полях: разница иногда достигает 20-30 процентов на дешёвых адаптерах.

RSS в связке с NUMA и пропускной способностью памяти

Даже идеально настроенный RSS упирается в следующий узкий горлышко память. Каждое принятое кольцо дескрипторов живёт в памяти определённого NUMA узла, и если ядро, обслуживающее очередь, сидит в другом узле, все дескрипторы и mbuf едут через межсокетный линк. На двухсокетных системах это добавляет десятки наносекунд на пакет и способно съесть треть итоговой пропускной способности на высоких скоростях.

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

Поэтому к RSS прилагается дисциплина размещения: память под кольца rte_ring и пулы rte_mempool выделяется на сокете, к которому физически подключён PCIe слот адаптера, а потоки worker прибиваются cpuset к ядрам того же узла. Утилита lstopo показывает, какие ядра к какому узлу относятся, и её вывод это первое, что стоит смотреть на новой машине. Сочетание равномерного RSS по хэшу и правильного NUMA размещения это два условия, без которых о дальнейшей оптимизации говорить рано.

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

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