Сколько на самом деле стоит путь пакета через ядро Linux, и сколько из этой стоимости снимают io_uring, AF_XDP и DPDK? Чтобы ответить на этот вопрос, автор собрал библиотеку, в которой один и тот же цикл отправки и приёма работает поверх семи разных сетевых бэкендов, и прогнал её на двух очень разных стендах: на домашнем десктопе с двумя 10-гигабитными портами сетевой карты Intel 82599, соединёнными AOC-кабелем, и на паре инстансов AWS c6in.4xlarge. Результаты оказались показательными.
Интерфейс у всех бэкендов один — выбор пути сводится к строке в конфиге, поэтому сравнение честное: одно и то же приложение, одни и те же размеры пачек, одинаковые опции сокета там, где сокет есть. Отправитель шлёт сообщения фиксированной частотой, не дожидаясь ответов (open loop), отражатель возвращает каждое сообщение тем же бэкендом. Сообщения по 100 байт, одно на пакет (для TCP — одна запись с 2-байтовым префиксом длины). Частоты: 20k, 100k, 200k, 400k, 800k и 1.6M сообщений в секунду, по 5 секунд на точку после прогрева. Все цифры — это round trip (RTT): два прохода через стек и два пересечения провода. Часы одни, на одном хосте, синхронизация между машинами не нужна. Отправитель и отражатель крутят busy poll, каждый на своём изолированном физическом ядре. Если бэкенд не держит заданную частоту (получено меньше 99% или потеряно больше 0.1%), в таблице вместо задержки стоит (saturated, насыщение) с фактической частотой и потерями: дальше этой точки задержка показывает только глубину очереди.
Локальный стенд — это один Core i7-3820 (Sandy Bridge-E, 2012 год), оба конца на одном хосте. Сетевая карта Intel 82599ES (ixgbe), порт 0 в порт 1 оптическим AOC-кабелем, второй порт в своём network namespace. Настройка cpuset на лету, без перезагрузки, одна очередь на порт, IRQ на отдельных ядрах. Для AF_XDP использовался native, только copy mode (ENA не предложил zero-copy). Для DPDK — ядро 23.11, ena PMD, vfio-pci, write-combining BAR. На AWS — пара c6in.4xlarge, cluster placement group, чтобы инстансы стояли физически рядом, в одном сегменте сети дата-центра.
Чтобы понимать, откуда берутся микросекунды, полезно держать перед глазами весь путь пакета. Ниже он для случая «одно приложение отправляет UDP-пакет, другое на соседней машине его читает». По ходу отмечено, где каждый бэкенд входит на этот путь и где с него сходит. Расшифровки к схеме включают syscall, SQE/CQE, SQPOLL, UMEM, XSK, ARP, qdisc, doorbell, MMIO, PCIe, DMA, offload, TSO, VLAN, FIFO, MAC, FCS, PCS, SerDes, PHY, cut-through, store-and-forward, Nitro, VPC, RSS, Toeplitz, ntuple, Flow Director, бит DD, DDIO, LLC, DRAM, TPH, ITR, MSI-X, hardirq/softirq, NAPI, XDP, BPF, XSKMAP, struct sk_buff, GRO, tc ingress, netfilter, task_work, опцию сокета, при которой чтение само опрашивает очередь карты.
Несколько вещей видно из схемы и потом видно в цифрах. Прерывание и softirq стоят дорого не столько по тактам, сколько по месту, где они выполняются. Если IRQ очереди приходится на гиперпоток-сосед (второй логический CPU того же физического ядра при Hyper-Threading) ядра, которое крутит busy poll, они конкурируют за одно физическое ядро. На 82599 перенос IRQ с отдельного ядра на соседний гиперпоток добавил 3 мкс к p50 и 5 мкс к p99, а DPDK этого не заметил, потому что у него прерываний нет.
RSS и правила классификации решают, в какую очередь попадёт пакет. Для AF_XDP это критично: сокет XSK привязан к одной очереди, и если пакет ушёл в другую, программа XDP его в сокет не перенаправит. Поэтому на стендах ровно одна очередь.
Модерация прерываний по умолчанию копит пакеты десятки микросекунд, чтобы снизить число прерываний. Для задержек её выключают, для пропускной способности оставляют.
На схеме есть строка «DMA-запись кадра в буфер». Классически это запись в DRAM: карта пишет пакет в память, контроллер памяти инвалидирует соответствующие строки в кэшах, и первое же обращение процессора к заголовку пакета становится промахом в память (DRAM), на современных Xeon и EPYC это порядка 90–110 нс до памяти своего NUMA-узла, до чужого узла больше. На пакет таких промахов несколько: дескриптор, заголовки, данные. При миллионах пакетов в секунду именно они, а не код стека, часто съедают бюджет.
Intel Data Direct I/O (DDIO) появился в семействе Xeon E5 (Sandy Bridge-EP, первые модели вышли в марте 2012) и Xeon E7 v2 и включён по умолчанию. Сейчас он есть в Xeon Scalable всех поколений, Xeon 6 и рабочих станциях Xeon W-2100/2200/3200, а в Xeon E и W-1200/1300 его нет. Идея простая: запись от PCIe-устройства идёт прямо в последний уровень кэша (LLC) процессора, минуя DRAM. На запись (NIC → CPU): если строка уже есть в LLC, она обновляется на месте (write update). Если нет, выделяется новая (write allocate), но только в ограниченной части LLC. Сначала Intel говорила о 10% LLC, позднее в документах фигурируют 2 канала (way) ассоциативности по умолчанию. Остальной кэш остаётся приложениям. На чтение (CPU → NIC): когда карта читает TX-дескриптор или пакет, который процессор только что записал, данные отдаются из LLC, без обращения к DRAM. Работает прозрачно: ни драйвер, ни сетевая карта ничего не должны поддерживать. Работает для карты, подключённой к тому же процессору. Если карта висит на PCIe-линиях одного сокета, а пакеты разбирает поток на другом, выигрыш теряется, и добавляется межсокетный трафик по UPI. Отсюда классическое правило: поток, который обрабатывает очередь, и её память должны быть на NUMA-узле карты.
Есть оборотная сторона, её называют leaky DMA. Доля LLC под DDIO небольшая, несколько мегабайт. Если RX-кольцо большое, а приложение отстаёт, новые пакеты вытесняют из этой доли ещё не обработанные, те уходят в DRAM, и процессор читает их уже с промахом. Получается, что увеличение кольца ради защиты от потерь при всплесках ухудшает задержку в установившемся режиме. Прикидка на коде из этой статьи: DPDK-бэкенд держит 1024 RX-дескриптора по ~2 КБ, то есть около 2 МБ, это сопоставимо с долей DDIO. AF_XDP держит 8192 кадра по 4 КБ в fill ring, это 32 МБ, и под нагрузкой с отставанием часть из них гарантированно окажется вне кэша. Отдельно это не измерялось, но логика ясна.
Теперь о самих бэкендах. В статье рассматриваются семь путей: классические сокеты (вероятно, UDP и TCP), io_uring (тот же сокет через io_uring), AF_XDP (XDP-сокет), DPDK (драйвер в пространстве пользователя), а также, судя по контексту, ещё какие-то варианты (возможно, разные режимы одного и того же). Все они реализуют один интерфейс отправки/приёма, что позволяет менять бэкенд строкой конфига.
Результаты: на локальном стенде с Intel 82599 и на AWS c6in.4xlarge с ENA картина ожидаемо различается. DPDK показывает минимальные задержки и максимальную устойчивость к частотам вплоть до 1.6M сообщений в секунду, поскольку обходит ядро полностью и работает в пространстве пользователя, опрашивая очередь карты. AF_XDP с native-режимом и copy mode также показывает высокую производительность, но упирается в особенности ядра и размеры колец. io_uring, будучи надстройкой над сокетами, снижает стоимость системных вызовов, но не устраняет накладные расходы самого сетевого стека. Классические сокеты начинают насыщаться раньше, особенно на высоких частотах, и их задержки растут из-за конкуренции за ядро и обработки прерываний.
На AWS с виртуальной картой ENA результаты несколько сглаживаются: разница между бэкендами сокращается, но DPDK всё равно остаётся впереди. Важно, что ENA не предложила zero-copy для AF_XDP, поэтому использовался copy mode, что добавляет дополнительные копирования. Также на AWS сказывается виртуализация Nitro, которая добавляет свои накладные расходы на пути пакета.
Главный вывод: выбор сетевого бэкенда — это всегда компромисс между задержкой, пропускной способностью, сложностью и стоимостью владения. DPDK даёт максимальный контроль и минимальные задержки, но требует специальных карт, выделенных ядер и аккуратной работы с памятью. AF_XDP — это компромиссный вариант, позволяющий оставаться в экосистеме Linux и использовать XDP-фильтры, но с ограничениями по очередям и возможным влиянием leaky DMA. io_uring — удобный способ снизить стоимость системных вызовов для обычных сокетов, но он не решает проблему самого ядра. Классические сокеты остаются самым простым и переносимым вариантом, но платят за это задержкой и насыщением на высоких нагрузках.
Исследование подчёркивает важность понимания аппаратных особенностей: DDIO, NUMA, размера колец, модерации прерываний. Даже на одном и том же железе настройка IRQ может добавить микросекунды к задержке. А увеличение RX-кольца ради защиты от потерь может ухудшить задержку из-за вытеснения из DDIO-кэша.
Код исследовательский, прошёл прогоны на нескольких стендах, известных открытых проблем не осталось, но это не production-ready решение. Если захотите перенести его в прод, нужно полноценно тестировать на своих нагрузках, своём железе и своих ядрах.
Какой сетевой бэкенд используете вы в своих проектах и какие задержки считаете приемлемыми? Поделитесь опытом в комментариях.