Производительность сетевых бэкендов в Linux

Сколько на самом деле стоит путь пакета через ядро Linux, и сколько из этой стоимости снимают io_uring, AF_XDP и DPDK? Собрал библиотеку, в которой один и тот же цикл отправки и приёма работает поверх семи разных путей, и прогнал её на двух очень разных стендах: на домашнем десктопе с двумя 10-гигабитными портами сетевой карты Intel 82599, соединёнными AOC-кабелем, и на паре инстансов AWS c6in.4xlarge.
По итогам челленджа от Spectral::Technologies решил оформить часть решения в отдельный опенсорс-проект, а заодно систематизировать накопившуюся информацию по сетевым бэкендам в Linux и поделиться ею. Код исследовательский, прошёл прогоны на нескольких стендах: физическая машина с картой Intel и драйвером ixgbe и AWS с ENA — виртуальной сетевой картой AWS, плюс пара veth, виртуальных Ethernet-интерфейсов, между network namespace. Известных открытых проблем не осталось, но это не production-ready решение. Если захотите перенести его в прод, его нужно полноценно тестировать на своих нагрузках, своём железе и своих ядрах.
Содержание
Что измеряем и как
Путь пакета от отправителя до приложения, DDIO и инъекция в кэш у AMD
Бэкенды: как устроены и как работают в Linux
udp: sendmmsg/recvmmsg
uring: тот же сокет через io_uring
xdp: AF_XDP и lwIP
dpdk: драйвер в пространстве пользователя
tcp: ядерный TCP
Результаты: локальный стенд и AWS
DPDK: как обходят ядро, какие нужны карты, почему они столько стоят
Выводы и рекомендации
Ссылки
1. Что измеряем и как
Интерфейс у всех бэкендов один:
#include <dgram_io/backend.h>
dgram_io::Config cfg;
cfg.kind = "dpdk"; // или udp | uring | xdp | tcp | tcp-dpdk | tcp-xdp
cfg.dst_ip = "10.0.0.2";
cfg.port = 5000;
std::string err;
auto net = dgram_io::make_backend(cfg, &err); // nullptr + err при ошибке
net->queue(payload, len); // копирует, буфер свободен сразу
net->flush(); // пачка уходит здесь
dgram_io::RxPacket rx[64];
int n = net->rx(rx, 64); // не блокирует; указатели живут до следующего rx()
Выбор пути сводится к строке в конфиге, поэтому сравнение получается честным: одно и то же приложение, одни и те же размеры пачек, одинаковые опции сокета там, где сокет есть.
Методика:
bin/loadgenотправляет сообщения с фиксированной частотой, не дожидаясь ответов (open loop).bin/echo --role serverна другой стороне отражает каждое сообщение тем же бэкендом.Сообщения по 100 байт, одно на пакет (для TCP одна запись с 2-байтовым префиксом длины).
Частоты: 20k, 100k, 200k, 400k, 800k и 1.6M сообщений в секунду, по 5 секунд на точку после прогрева.
Все цифры — это round trip (время туда и обратно, RTT): два прохода через стек и два пересечения провода. Часы одни, на одном хосте, синхронизация между машинами не нужна.
Отправитель и отражатель крутят busy poll (непрерывно опрашивают очередь приёма вместо ожидания прерывания), каждый на своём изолированном физическом ядре.
Если бэкенд не держит заданную частоту (получено меньше 99% или потеряно больше 0.1%), в таблице вместо задержки стоит
sat.(saturated, насыщение) с фактической частотой и потерями: дальше этой точки задержка показывает только глубину очереди.p50 и p99 в таблицах — это медиана и 99-й перцентиль задержки.
Стенды:
локальный | AWS | |
|---|---|---|
машины | один Core i7-3820 (Sandy Bridge-E, 2012 год), оба конца на одном хосте | 2 x c6in.4xlarge, cluster placement group |
NIC | Intel 82599ES (ixgbe), порт 0 в порт 1 оптическим AOC-кабелем, второй порт в своём network namespace | ENA, отдельный ENI |
ядро | 5.14 (AlmaLinux 9.7) | 7.0 (Ubuntu 24.04) |
изоляция | cpuset на лету, без перезагрузки |
|
настройка NIC | одна очередь на порт, | одна очередь, |
AF_XDP | native, zero-copy и copy mode | native, только copy mode (ENA не предложил zero-copy) |
DPDK | 25.11, ixgbe PMD, vfio-pci | 23.11, ena PMD, vfio-pci, write-combining BAR |
Пояснения к таблице:
cluster placement group: размещение инстансов AWS физически рядом, в одном сегменте сети дата-центра;
AOC (active optical cable): кабель с впаянными оптическими трансиверами на концах;
ENI (Elastic Network Interface): сетевой интерфейс AWS, который подключается к инстансу; второй ENI нужен, чтобы отдать его DPDK и не потерять доступ по основному;
cpuset: механизм cgroup, ограничивающий, на каких CPU могут работать процессы;
isolcpus,nohz_full,rcu_nocbs: параметры загрузки ядра, которые убирают с выбранных ядер планировщик общих задач, тик таймера и обработку RCU (read-copy-update, механизм синхронизации ядра);IRQ (interrupt request): аппаратное прерывание, здесь от очереди сетевой карты;
rx-usecs: сколько микросекунд карта копит пакеты перед прерыванием (модерация прерываний), 0 значит прерывать сразу;native: XDP-программа выполняется в драйвере, а не в общем (generic) пути ядра;
zero-copy и copy mode: карта пишет пакет сразу в память приложения или ядро копирует его туда (подробнее в разделе про AF_XDP);
PMD (poll mode driver): драйвер карты в DPDK, работающий опросом, без прерываний;
vfio-pci: драйвер ядра, который отдаёт PCI-устройство целиком процессу в пространстве пользователя;
BAR (base address register): область регистров и памяти устройства, отображаемая в адресное пространство; write-combining позволяет процессору склеивать записи в неё в крупные транзакции.
2. Путь пакета от отправителя до приложения
Чтобы понимать, откуда берутся микросекунды, полезно держать перед глазами весь путь. Ниже он для случая «одно приложение отправляет UDP-пакет, другое на соседней машине его читает». По ходу отмечено, где каждый бэкенд входит на этот путь и где с него сходит.
ОТПРАВИТЕЛЬ
приложение
│ udp: sendmmsg(), tcp: send() ── syscall
│ uring: SQE в общее кольцо, io_uring_enter() (или SQPOLL-поток ядра)
│ xdp: кадр пишется в UMEM, дескриптор в TX-кольцо XSK, sendto() как «пинок»
│ dpdk: PMD сам пишет дескриптор, ядра на пути нет
▼
ядро (только udp / uring / tcp)
сокет → UDP/TCP → IP → neighbour (ARP) → qdisc → драйвер
▼
TX-кольцо дескрипторов в RAM
драйвер пишет tail-указатель в регистр NIC (doorbell, MMIO по PCIe)
▼
СЕТЕВАЯ КАРТА ОТПРАВИТЕЛЯ
DMA-чтение дескриптора, затем DMA-чтение данных пакета по PCIe
offload: контрольные суммы IP/UDP/TCP, TSO, вставка VLAN
TX FIFO → MAC (преамбула, FCS) → PCS (кодирование 64b/66b) → SerDes → трансивер
▼
ЛИНИЯ
сериализация: 100 байт полезной нагрузки = 166 байт на проводе ≈ 133 нс на 10G
распространение: около 5 нс на метр
[коммутатор: cut-through сотни нс, store-and-forward больше;
в облаке здесь Nitro-карта, инкапсуляция VPC и сеть провайдера]
▼
СЕТЕВАЯ КАРТА ПОЛУЧАТЕЛЯ
PHY → MAC: проверка FCS, фильтр по MAC-адресу и VLAN
парсер заголовков
классификация: RSS (хэш Toeplitz по адресам и портам → таблица косвенности
→ номер очереди) или точное правило (ntuple, Flow Director, rte_flow)
offload: проверка контрольных сумм, аппаратная метка времени, снятие VLAN
RX FIFO → DMA-запись кадра в буфер, заранее выложенный драйвером в RX-кольцо,
запись дескриптора обратно (writeback, бит DD)
[Intel DDIO: запись попадает в LLC, а не в DRAM;
AMD Zen 5: инъекция в L2 через PCIe TPH, если карта её умеет]
модерация прерываний (rx-usecs / ITR) → MSI-X прерывание на CPU из smp_affinity
▼
═════════════════ здесь пути расходятся ═════════════════
dpdk: прерывания нет. Поток приложения крутит rte_eth_rx_burst(), видит бит DD
в дескрипторе и получает mbuf. Всё.
ядро: hardirq → NAPI schedule → softirq NET_RX → napi_poll драйвера
│
├─ XDP-хук (BPF-программа, до выделения sk_buff)
│ XDP_REDIRECT в XSKMAP → AF_XDP:
│ zero-copy: NIC уже положил кадр прямо в UMEM приложения
│ copy mode: ядро копирует кадр в UMEM
│ дескриптор в RX-кольцо XSK → приложение читает кольцо без syscall
│
└─ XDP_PASS: sk_buff → GRO → tc ingress → netfilter → IP → UDP/TCP
→ поиск сокета → очередь приёма сокета
→ udp/tcp: recvmmsg()/recv() копирует данные в буфер приложения
(с SO_BUSY_POLL сам recv опрашивает очередь NIC)
→ uring: task_work дописывает CQE, приложение читает кольцо
Расшифровки к схеме, по порядку появления:
syscall: системный вызов, переход из приложения в ядро;
SQE (submission queue entry) и CQE (completion queue entry): запись в очереди заявок и в очереди завершений io_uring; SQ и CQ сами эти очереди;
SQPOLL: режим io_uring, в котором очередь заявок разбирает отдельный поток ядра, и приложению не нужен системный вызов;
UMEM: область памяти приложения, разбитая на кадры, из которой AF_XDP берёт буферы под пакеты;
XSK (XDP socket): сам сокет AF_XDP; TX и RX: передача и приём;
ARP (Address Resolution Protocol): определение MAC-адреса по IP-адресу; neighbour: подсистема ядра, которая этим занимается;
qdisc (queueing discipline): очередь и планировщик отправки пакетов в ядре;
doorbell: запись в регистр карты, сообщающая, что появились новые дескрипторы; MMIO (memory-mapped I/O): доступ к регистрам устройства как к памяти;
PCIe (PCI Express): шина, к которой подключена карта;
DMA (direct memory access): карта сама читает и пишет оперативную память, без участия процессора;
offload: работа, которую карта берёт на себя вместо процессора; TSO (TCP segmentation offload): карта сама режет большой буфер на TCP-сегменты; VLAN: метка виртуальной сети в заголовке Ethernet;
FIFO: буфер «первым пришёл, первым вышел» внутри карты;
MAC (media access control): уровень Ethernet, который формирует кадр; FCS (frame check sequence): контрольная сумма кадра; PCS (physical coding sublayer): кодирование битов для линии, в 10G Ethernet — 64b/66b; SerDes: преобразование параллельных данных в последовательный поток для линии и обратно; PHY: физический уровень целиком;
cut-through и store-and-forward: коммутатор начинает пересылать кадр, прочитав только заголовок, или сначала принимает его целиком;
Nitro: собственные карты AWS, через которые идёт сеть инстанса; VPC (virtual private cloud): виртуальная сеть AWS, пакеты в ней инкапсулируются;
RSS (receive side scaling): распределение входящих пакетов по очередям карты по хэшу заголовков; Toeplitz: стандартная хэш-функция для этого (спецификация Microsoft);
ntuple, Flow Director,
rte_flow: точные правила «такой-то поток в такую-то очередь» в ядре (ethtool -N), в картах Intel и в DPDK соответственно;бит DD (descriptor done): флаг в дескрипторе, который карта ставит, когда закончила с ним работу;
DDIO, LLC, DRAM, TPH: разобраны в следующем подразделе;
ITR (interrupt throttling rate): то же, что модерация прерываний, в терминах Intel; MSI-X: способ доставки прерываний сообщениями по PCIe, по отдельному прерыванию на очередь;
smp_affinity: на каких CPU разрешено обрабатывать данное прерывание;mbuf: буфер пакета в DPDK;
hardirq и softirq: обработчик прерывания и отложенная обработка в ядре, в которой идёт основная работа сетевого стека; NAPI (New API): механизм, при котором драйвер после прерывания забирает пакеты опросом, пачкой;
XDP (eXpress Data Path): хук в драйвере, где BPF-программа видит кадр до того, как ядро начало его обрабатывать; BPF (eBPF): байткод, который ядро проверяет и исполняет внутри себя; XSKMAP: таблица BPF, в которой лежат сокеты AF_XDP;
sk_buff: структура ядра, описывающая пакет в стеке; GRO (generic receive offload): склейка нескольких входящих сегментов одного потока в один большой; tc ingress: точка, где работают фильтры traffic control на входе; netfilter: подсистема фильтрации (iptables, nftables);task_work: отложенная работа, которую ядро выполняет в контексте задачи; через неё io_uring публикует результат приёма;
SO_BUSY_POLL: опция сокета, при которой чтение само опрашивает очередь карты.
Несколько вещей, которые видно из этой схемы и потом видно в цифрах.
Прерывание и softirq стоят дорого не столько по тактам, сколько по месту, где они выполняются. Если IRQ очереди приходится на гиперпоток-сосед (второй логический CPU того же физического ядра при Hyper-Threading) ядра, которое крутит busy poll, они конкурируют за одно физическое ядро. На 82599 перенос IRQ с отдельного ядра на соседний гиперпоток добавил 3 мкс к udp и 5 мкс к uring, а DPDK этого не заметил, потому что у него прерываний нет.
RSS и правила классификации решают, в какую очередь попадёт пакет. Для AF_XDP это критично: сокет XSK привязан к одной очереди, и если пакет ушёл в другую, программа XDP его в сокет не перенаправит. Поэтому на стендах ровно одна очередь.
Модерация прерываний (ethtool -C ... rx-usecs) по умолчанию копит пакеты десятки микросекунд, чтобы снизить число прерываний. Для задержек её выключают, для пропускной способности оставляют.
DDIO: куда на самом деле пишет DMA
На схеме есть строка «DMA-запись кадра в буфер». Классически это запись в DRAM: карта пишет пакет в память, контроллер памяти инвалидирует соответствующие строки в кэшах, и первое же обращение процессора к заголовку пакета становится промахом в память (DRAM), на современных Xeon и EPYC это порядка 90–110 нс до памяти своего NUMA-узла (NUMA, non-uniform memory access: в многопроцессорной машине у каждого процессора своя память, к чужой доступ медленнее; замеры Chips and Cheese для Sapphire Rapids), до чужого узла больше. На пакет таких промахов несколько: дескриптор, заголовки, данные. При миллионах пакетов в секунду именно они, а не код стека, часто съедают бюджет.
Intel Data Direct I/O (DDIO) появился в семействе Xeon E5 (Sandy Bridge-EP, первые модели вышли в марте 2012) и Xeon E7 v2 и включён по умолчанию (страница Intel). Сейчас он есть в Xeon Scalable всех поколений, Xeon 6 и рабочих станциях Xeon W-2100/2200/3200, а в Xeon E и W-1200/1300 его нет (матрица поддержки Intel). Идея простая: запись от PCIe-устройства идёт прямо в последний уровень кэша (LLC) процессора, минуя DRAM.
На запись (NIC → CPU): если строка уже есть в LLC, она обновляется на месте (write update). Если нет, выделяется новая (write allocate), но только в ограниченной части LLC. Сначала Intel говорила о 10% LLC, позднее в документах фигурируют 2 канала (way) ассоциативности по умолчанию. Остальной кэш остаётся приложениям.
На чтение (CPU → NIC): когда карта читает TX-дескриптор или пакет, который процессор только что записал, данные отдаются из LLC, без обращения к DRAM.
Работает прозрачно: ни драйвер, ни сетевая карта ничего не должны поддерживать.
Работает для карты, подключённой к тому же процессору. Если карта висит на PCIe-линиях одного сокета, а пакеты разбирает поток на другом, выигрыш теряется, и добавляется межсокетный трафик по UPI (Ultra Path Interconnect, шина между процессорами Intel). Отсюда классическое правило: поток, который обрабатывает очередь, и её память должны быть на NUMA-узле карты (
/sys/class/net/<if>/device/numa_node,--socket-memв EAL, слое инициализации DPDK).
Подробный разбор механики, с замерами и настройками, есть в статье Farshin и др. Reexamining Direct Cache Access to Optimize I/O Intensive Applications for Multi-hundred-gigabit Networks (USENIX ATC 2020), дальше я опираюсь в основном на неё.
Есть оборотная сторона, её называют leaky DMA (термин из статьи ResQ, NSDI 2018). Доля LLC под DDIO небольшая, несколько мегабайт. Если RX-кольцо большое, а приложение отстаёт, новые пакеты вытесняют из этой доли ещё не обработанные, те уходят в DRAM, и процессор читает их уже с промахом. Получается, что увеличение кольца ради защиты от потерь при всплесках ухудшает задержку в установившемся режиме. Прикидка на коде из этой статьи: DPDK-бэкенд держит 1024 RX-дескриптора по ~2 КБ, то есть около 2 МБ, это сопоставимо с долей DDIO. AF_XDP держит 8192 кадра по 4 КБ в fill ring, это 32 МБ, и под нагрузкой с отставанием часть из них гарантированно окажется вне кэша. Отдельно я это не измерял. Про i7-3820 на локальном стенде Intel ничего не сообщает: это тот же кристалл, что Xeon E5, но в списках поддержки DDIO настольных Core i7 нет, и что происходит на этом процессоре, я не проверял.
Что с этим можно делать:
смотреть на попадания:
pcm-pcieиз Intel PCM (Performance Counter Monitor, утилиты для аппаратных счётчиков процессора) делит записи и чтения PCIe на попавшие в кэш и промахнувшиеся (pcm-iioпоказывает пропускную способность по устройствам, но не попадания DDIO);держать RX-кольцо и пул буферов настолько маленькими, насколько позволяют всплески трафика, а не «с запасом»;
долю LLC под DDIO можно изменить через MSR (model-specific register, служебный регистр процессора)
IIO LLC WAYS(0xC8B). На Skylake-SP по умолчанию там 0x600, два бита, то есть два канала; каналы можно добавлять, но два исходных не убираются. Это не описано в обычных руководствах, поэтому менять на проде без замеров не стоит;DDIO можно выключить глобально или для отдельного PCIe root port (корневого порта шины, к которому подключено устройство) через регистры конфигурации (подробности в той же статье ATC 2020). Нужно это редко; самый известный повод был в 2019 году, когда атака NetCAT (CVE-2019-11184) показала, что через DDIO и RDMA (remote direct memory access, запись в память удалённой машины по сети без участия её процессора) по времени доступа к кэшу можно восстанавливать моменты нажатия клавиш в SSH-сессии на соседней машине.
Аналог у AMD. До Zen 5 у EPYC ничего похожего не было: DMA шла в память, а L3 у AMD разбит по CCX (core complex, группа ядер с общим L3), и общего LLC, куда можно было бы писать для всех, нет. В EPYC 9005 (Turin, Zen 5) появилась Smart Data Cache Injection, SDCI (whitepaper AMD). Она построена на стандартном механизме PCIe TPH (TLP Processing Hints): устройство помечает запись DMA (в терминах PCIe это TLP, transaction layer packet) тегом (steering tag), который говорит, какому ядру адресованы данные, и процессор кладёт их в L2 ядра в том CCX, где работает поток-потребитель. Есть и вариант инъекции в L3, для него в Linux добавлена настройка через resctrl (интерфейс ядра для разделения кэша между задачами), SDCIAE, SDCI allocation enforcement (LWN).
Со стороны Linux нужны три вещи: поддержка TPH в ядре, таблица тегов от платформы и драйвер, который эти теги ставит. Поддержку TPH написали инженеры AMD, она вошла в ядро 6.13 (документация, cover letter на LWN); теги для ядер ядро получает от прошивки через ACPI (интерфейс описания платформы, который отдаёт BIOS/UEFI). Первым драйвером, который этим пользуется, стал Broadcom bnxt_en в 6.15, затем mlx5 для RDMA в 6.17.
Intel DDIO | AMD SDCI (PCIe TPH) | |
|---|---|---|
поколения | Xeon с E5 (2012) | EPYC 9005 (Zen 5) |
куда пишется | общий LLC сокета, выделенная доля | L2 конкретного ядра (есть вариант с L3) |
что нужно от устройства | ничего | поддержка TPH и steering tags в карте и драйвере |
что нужно от ОС | ничего | ядро 6.13+ и драйвер с поддержкой TPH (bnxt_en с 6.15) |
включение | по умолчанию | драйвером, по очередям |
То есть DDIO работает везде, но грубо, а TPH точнее, но требует поддержки по всей цепочке. Проверить, умеет ли карта TPH, можно через lspci -vvv: в выводе должна быть capability Transaction Processing Hints (так её называет lspci, в спецификации PCIe и в ядре это TLP Processing Hints). Отключить TPH в ядре целиком можно параметром pci=notph.
Для сравнения, у Arm похожая функция называется cache stashing. Она заложена в протокол интерконнекта AMBA CHI (Coherent Hub Interface, протокол когерентной шины внутри чипов Arm) и есть в серверных ядрах Neoverse (слайды Neoverse N1 с Hot Chips 2019).
Практический вывод для выбора железа под обход ядра. На Intel заметную часть выигрыша DPDK на мелких пакетах даёт DDIO, и он есть без дополнительных усилий. На EPYC до Zen 5 этого механизма нет, а на Zen 5 он заработает только с картой и драйвером, которые умеют TPH. Поэтому одни и те же цифры DPDK на Intel и AMD сравнивать напрямую нельзя.
3. Бэкенды
Сначала коротко, что это за механизмы и откуда они взялись. Они появились в разное время и под разные задачи, и это многое объясняет в цифрах ниже.
бэкенд | механизм | в Linux с | зачем появился |
|---|---|---|---|
| UDP-сокет, | сокеты с первых версий; | универсальный сетевой API; пакетные вызовы — чтобы не платить за syscall на каждую датаграмму |
| тот же сокет через io_uring | 5.1 (2019), сетевые операции с 5.3, multishot recv с 6.0 | асинхронный ввод-вывод без syscall на каждую операцию; замена Linux AIO |
| AF_XDP | XDP 4.8 (2016), AF_XDP 4.18 (2018), zero-copy 4.20 (2018) | быстрая обработка пакетов, оставаясь внутри ядра и его модели безопасности |
| драйвер в пространстве пользователя | DPDK: Intel, 2010; сообщество dpdk.org с 2013 | обработка пакетов на скорости линии для телекома и сетевых устройств на обычных серверах |
| TCP-сокет ядра | с первых версий | надёжный поток «из коробки» |
| lwIP поверх AF_XDP или DPDK | lwIP с 2001 года | TCP там, где ядерного стека на пути нет |
udp: sendmmsg / recvmmsg
Что это. UDP (RFC 768, 1980) — протокол датаграмм без соединения, подтверждений и повторной отправки: пакет либо дошёл, либо нет, и порядок не гарантирован. Доступ к нему из приложения идёт через BSD-сокеты, API из 4.2BSD 1983 года, который с тех пор почти не изменился. Классические sendto()/recvfrom() передают одну датаграмму за системный вызов, и на сотнях тысяч пакетов в секунду переход в ядро и обратно становится заметной частью стоимости. Поэтому в Linux добавили пакетные варианты: recvmmsg() в 2.6.33 и sendmmsg() в 3.0 (man recvmmsg, man sendmmsg). Они принимают массив сообщений и обрабатывают его за один вызов.
Для чего. Это путь по умолчанию для всего, что шлёт датаграммы: DNS, QUIC, RTP/WebRTC, игровые серверы, биржевые рассылки по multicast. Он не требует ни прав root, ни особого железа, работает на любой карте и в любом контейнере, и вся инфраструктура ядра (firewall, маршрутизация, tcpdump, счётчики) продолжает работать. Именно поэтому он база для сравнения: всё остальное оправдано, только если заметно лучше этого.
Как устроен бэкенд. Обычный UDP-сокет ядра, но отправка и приём пачками: sendmmsg отправляет до 16 датаграмм за один системный вызов, recvmmsg принимает до 32.
void flush() override {
int off = 0;
while (off < q_) {
const int r = sendmmsg(fd_, mm_ + off, q_ - off, 0);
if (r < 0) {
if (errno == EINTR) continue;
// sendmmsg сообщает ошибку первой датаграммы, которую не смог отправить.
// Считаем её потерянной и идём дальше, а не выбрасываем всю пачку.
++send_errs_;
++off;
continue;
}
off += r;
}
q_ = 0;
}
Как это работает в ядре. Каждый вызов проходит всю цепочку сокет → UDP → IP → qdisc → драйвер для каждой датаграммы в пачке. Пачка экономит на переходах user/kernel, но не на обработке самих пакетов. На приёме пакет проходит прерывание, softirq, выделение sk_buff, netfilter, поиск сокета и копирование в буфер пользователя.
Что помогает:
net.core.busy_pollиbusy_read(илиSO_BUSY_POLLна сокете):recvmmsgсам опрашивает очередь NIC вместо ожидания softirq. На стендах стояло 50 мкс.Большой
SO_RCVBUF: на приёме 16 МБ. Это переживает паузы планировщика, но под перегрузкой превращается в очередь на десятки миллисекунд.Подключённый (
connect()) сокет для unicast (адресной отправки одному получателю): ядро пропускает поиск маршрута на каждую отправку.
Одна деталь, на которой легко потерять данные незаметно: если датаграмма больше буфера, recvmmsg возвращает обрезанный кусок с флагом MSG_TRUNC. Бэкенд считает такие пакеты и выбрасывает их, иначе обрезанный пакет выглядел бы как повреждённый, и его молча «чинил» бы слой восстановления потерь выше.
TCP-вариант этого бэкенда описан ниже в разделе tcp.
uring: тот же сокет через io_uring
Что это. io_uring — интерфейс асинхронного ввода-вывода, который Jens Axboe добавил в Linux 5.1 в 2019 году (LWN: Ringing in a new asynchronous I/O API). Он пришёл на смену Linux AIO (io_submit), который был неудобен и по-настоящему асинхронным оставался в основном для файлов с O_DIRECT: с буферизованным вводом-выводом вызов мог блокироваться. Приложение и ядро делят две кольцевые очереди в общей памяти: в очередь заявок (SQ) приложение кладёт операции, из очереди завершений (CQ) забирает результаты. Один вызов io_uring_enter() отправляет сразу много заявок, а в режиме SQPOLL поток ядра забирает их сам, и системных вызовов нет совсем.
Сначала io_uring был про диски, сетевые операции (sendmsg/recvmsg) появились в 5.3 (io_uring_enter(2) перечисляет версии для каждой операции). Для сети важнее более поздние функции: кольца буферов, из которых ядро само выбирает, куда положить принятые данные (5.19), multishot-приём, когда одна заявка выдаёт результат на каждый пришедший пакет (6.0), отправка без копирования SEND_ZC (6.0) и NAPI busy poll (6.9).
Для чего. io_uring хорош там, где много соединений и много мелких операций: веб-серверы, прокси, базы данных, хранилища. Он снимает стоимость системных вызовов и позволяет обслуживать тысячи сокетов одним потоком без epoll. Задержку одного пакета он, как видно ниже, не улучшает: стек ядра под ним тот же. Обратная сторона — поверхность атаки. Google сообщала, что за год 60% эксплойтов ядра, присланных в их программу kCTF VRP, использовали io_uring; в ChromeOS и на своих продакшн-серверах Google его отключила, а приложениям Android закрыла доступ к нему фильтром seccomp (блог Google Security). Отсюда и sysctl io_uring_disabled, про который ниже.
Как устроен бэкенд. Сокет ровно тот же, что у udp (общий код в src/udp_socket.h, чтобы сравнивались способы пересечения границы ядра, а не опции сокета). Меняется то, как приложение общается с ядром.
Отправка: queue() копирует данные в один из 256 слотов и готовит SENDMSG SQE, flush() делает один io_uring_submit на всю пачку. С SQPOLL отправку забирает поток ядра, и flush() становится записью в общую память без системного вызова.
Приём: один multishot RECVMSG, взведённый один раз, с кольцом предоставленных буферов (provided buffer ring). Каждая датаграмма попадает в свой буфер и порождает CQE. Пустой rx() сводится к чтению хвоста CQ, без syscall, в отличие от пустого recvmmsg.
void arm_rx() {
io_uring_sqe* e = sqe();
io_uring_prep_recvmsg_multishot(e, fixed_ ? 0 : fd_, &rx_msg_, 0);
target(e);
e->flags |= IOSQE_BUFFER_SELECT; // буфер выбирает ядро из кольца
e->buf_group = kBufGroup;
io_uring_sqe_set_data64(e, kRxTag);
rx_armed_ = true;
}
Буферы, отданные приложению, возвращаются ядру в начале следующего rx() двумя записями в память. Если все буферы заняты, ядро завершает multishot с ENOBUFS (данные ждут в буфере сокета, не теряются), и следующий rx() взводит его заново.
Как это работает в ядре и почему это медленнее udp на наших тестах. Стек тот же, но путь приёма длиннее: прерывание, softirq, затем task_work, который публикует CQE. Busy poll сокета, который есть у recvmmsg, здесь не срабатывает. В итоге при малой нагрузке uring отстаёт от udp на 2.9 мкс на 82599 и на 17 мкс на AWS. В ядре 6.9 появился NAPI busy poll для io_uring, то есть опрос очереди карты прямо из io_uring (io_uring_register_napi, LWN). В бэкенде он пока не используется, это одна из следующих вещей для проверки.
С SQPOLL есть ловушка. Без флага IORING_SETUP_SQ_AFF ядро не привязывает поток SQ к конкретному ядру: он может работать на любом онлайн-CPU, и на каком окажется, решает планировщик (io_uring_sqpoll(7)). В нашем прогоне на 82599 процесс был прибит к одному ядру и крутил busy poll, и round trip с SQPOLL вырос с 30 мкс до миллисекунд: поток SQ получал время, только когда его вытесняли. Поэтому бэкенд при создании отказывается от такой конфигурации и просит явно указать uring_sqpoll_cpu на другом ядре. Учтите и обратное ограничение: ядро с SQ_AFF откажет (EINVAL), если указанный CPU не входит в cpuset процесса.
Ещё практическое: в RHEL 9 и производных kernel.io_uring_disabled=2 по умолчанию, io_uring выключен целиком (release notes RHEL 9.3). В upstream этот sysctl появился в 6.6 и по умолчанию равен 0 (документация). Multishot recvmsg (одна заявка на приём, которая выдаёт результат на каждую пришедшую датаграмму) доступен с ядра 6.0, кольца предоставленных буферов с 5.19.
TCP поверх io_uring в библиотеке не реализован. Технически тот же multishot recv с кольцом буферов работает и для потоковых сокетов, но выигрыша по задержке ждать не стоит по той же причине: стек и путь приёма не меняются.
xdp: AF_XDP
Что это. В 2014 году в Linux 3.18 появился eBPF (bpf(2)): байткод, который ядро проверяет верификатором и исполняет внутри себя, в том числе JIT-компилируя в машинный код. В 2016 году в Linux 4.8 (kernelnewbies) на его основе сделали XDP, eXpress Data Path: точку в драйвере сетевой карты, где BPF-программа видит кадр раньше, чем ядро выделит под него sk_buff, и решает его судьбу. Программа может выбросить кадр (XDP_DROP), отправить обратно (XDP_TX), передать в обычный стек (XDP_PASS) или перенаправить (XDP_REDIRECT). На XDP построены фильтрация DDoS в Cloudflare, балансировщик Katran в Facebook и часть датапата Cilium.
AF_XDP добавили в Linux 4.18 в 2018 году инженеры Intel Björn Töpel и Magnus Karlsson (LWN: Accelerating networking with AF_XDP); zero-copy для i40e и ixgbe появился в 4.20, флаг need_wakeup в 5.4, приём кадров из нескольких буферов в 6.6 (kernelnewbies). Идея — дать приложению сырые кадры почти с той же скоростью, что у DPDK, но не отбирая карту у ядра: интерфейс остаётся в системе, остальной трафик идёт обычным путём, работают права, namespaces и инструменты ядра.
Для чего. Приём и отправка пакетов на высокой скорости там, где нельзя или не хочется отдавать карту целиком: пакетные брокеры и сенсоры, генераторы трафика, программные коммутаторы (у Open vSwitch и DPDK есть AF_XDP-порты), транспорты с низкой задержкой на машине, которая должна оставаться обычной машиной.
Как устроен бэкенд. AF_XDP — это тип сокета, который получает кадры прямо из драйвера, до того как ядро построит sk_buff. Сокет (XSK) привязывается к одной очереди одной сетевой карты. Какие кадры в него попадут, решает маленькая BPF-программа на XDP-хуке драйвера. Всё остальное (ARP, ICMP, то есть ping и служебные сообщения, ssh, чужие порты) идёт в обычный стек, так что машина остаётся доступной.
Фильтр для датаграммного бэкенда:
SEC("xdp")
int xdp_udp_filter(struct xdp_md* ctx) {
void* data = (void*)(long)ctx->data;
void* end = (void*)(long)ctx->data_end;
struct ethhdr* eth = data;
if ((void*)(eth + 1) > end) return XDP_PASS;
if (eth->h_proto != bpf_htons(ETH_P_IP)) return XDP_PASS;
struct iphdr* ip = (void*)(eth + 1);
if ((void*)(ip + 1) > end) return XDP_PASS;
if (ip->ihl != 5) return XDP_PASS;
if (ip->protocol != IPPROTO_UDP) return XDP_PASS;
struct udphdr* udp = (void*)(ip + 1);
if ((void*)(udp + 1) > end) return XDP_PASS;
__u32 key = 0;
__u32* port = bpf_map_lookup_elem(&port_map, &key);
if (!port || udp->dest != (__be16)*port) return XDP_PASS;
return bpf_redirect_map(&xsks_map, ctx->rx_queue_index, XDP_PASS);
}
Третий аргумент bpf_redirect_map — это действие на случай, если сокета в карте нет. Здесь это XDP_PASS, поэтому программа, оставшаяся после упавшего процесса, безвредна: пакеты просто идут по обычному пути.
Со стороны приложения XSK — это четыре кольца в общей памяти и область UMEM, разбитая на кадры:
fill ring: приложение отдаёт ядру пустые кадры под приём;
RX ring: ядро возвращает заполненные;
TX ring: приложение отдаёт кадры на отправку;
completion ring: ядро возвращает отправленные.
Режимов два. В zero-copy драйвер выкладывает кадры UMEM прямо в RX-кольцо карты, и NIC пишет пакет по DMA сразу в память приложения. В copy mode ядро копирует кадр из своего буфера в UMEM. Zero-copy поддерживают не все драйверы: из Intel это ixgbe, i40e, ice, igc; из NVIDIA mlx5. Драйвер ENA в основном ядре умеет только copy mode, это и показал наш прогон на AWS; zero-copy есть в отдельном драйвере Amazon из amzn-drivers, где он появился в r2.7.0, потом был помечен экспериментальным и снова включён в r2.13.0.
Системные вызовы на этом пути остаются только как «пинки»: sendto() с нулевой длиной, чтобы драйвер забрал TX-кольцо, и recvfrom(), чтобы разбудить заснувший драйвер при флаге need_wakeup. В copy mode need_wakeup выставлен почти всегда, и безусловный пинок на каждом проходе цикла стоил бы syscall на итерацию, поэтому бэкенд пинает TX только если что-то отправлено, а RX не чаще раза на 64 пустых опроса.
Находка, которая стоила пары дней. В zero-copy режиме ixgbe вычисляет размер RX-буфера 82599 из размера кадра UMEM минус 256 байт headroom (зарезервированное место перед пакетом, куда XDP-программа может дописать заголовки) и округляет вниз до целых килобайт. С привычными 2048-байтными кадрами получается 1024 байта. Кадр длиннее требует второго дескриптора, который zero-copy путь ixgbe не обрабатывает, и кадр теряется так, что его не видно ни в одном счётчике XSK. Датаграммы по 100 байт этого не замечают. tcp-xdp заметил, как только lwIP начал склеивать записи в полноразмерные сегменты: такой сегмент терялся при каждой ретрансмиссии, соединение вставало, lwIP его обрывал. Теперь кадр UMEM 4096 байт (после округления 3072), и оба XDP-бэкенда при старте отказываются от max_datagram, который не влезает в один RX-буфер.
Ещё два момента, про которые надо помнить:
привязка XSK может сбросить устройство (ixgbe пишет «Multiqueue Disabled»), линк переобучается на обоих концах провода секунду-две, и всё, что отправлено в это время, пропадает. Бэкенд ждёт carrier (признак поднятого линка);
XSK TX не дополняет короткие кадры до 60 байт, как это делает ядро. Для UDP со 100-байтной нагрузкой это неважно, для TCP критично (ниже).
TCP поверх AF_XDP: tcp-xdp
Ядро в этом режиме пакетов соединения не видит, поэтому TCP нужен свой. Используется lwIP.
Что такое lwIP
lwIP (lightweight IP) — компактная реализация стека TCP/IP на C. Её написал Adam Dunkels в лаборатории CNA Шведского института информатики (SICS) около 2001 года, он же автор ещё более компактного uIP. Сейчас проект развивается сообществом на Savannah (основной мейнтейнер Simon Goldschmidt), лицензия BSD, последний релиз 2.2.1 вышел в феврале 2025 года (зеркало на GitHub). Стек рассчитан, как сказано в его README, на системы с десятками килобайт свободной RAM и около 40 КБ кода и есть во множестве встраиваемых платформ: в SDK Espressif для ESP32, в STM32Cube, в разных RTOS.
У lwIP три уровня API:
raw API: колбэки, вызываемые прямо из стека, без потоков и очередей, самый быстрый и самый низкоуровневый;
netconn: последовательный API поверх отдельного потока стека;
сокеты в стиле BSD поверх netconn.
Что в нём есть: IPv4 и IPv6, ARP, ICMP, UDP, TCP с масштабированием окна, быстрой повторной отправкой, очередью сегментов, пришедших не по порядку, и отправкой SACK, DHCP, DNS, IGMP. Чего нет: многопоточности внутри стека, современного управления перегрузкой вроде CUBIC или BBR (в lwIP классическая схема в духе Reno), offload на карту.
Почему он здесь, а не F-Stack или ядро. Нужен был стек, который целиком живёт в цикле опроса приложения и ничего не требует от окружения. lwIP в режиме NO_SYS=1 ровно такой: без потоков, без блокировок, весь стек выполняется внутри нашего цикла. Мы отдаём ему кадры через netif->input(), он вызывает наши колбэки, таймеры крутятся через sys_check_timeouts(). Он небольшой, его легко прочитать целиком и отладить, у него подробная статистика, и он одинаково встаёт поверх любого транспорта кадров. Цена — настройка: значения по умолчанию рассчитаны на микроконтроллер, окно без масштабирования ограничено 64 КБ, и почти все размеры пришлось поднять (ниже). Для продакшна на DPDK чаще берут F-Stack или VPP, у которых стек ближе к серверному.
Как lwIP подключён
Транспорт кадров для стека абстрактный, один и тот же lwIP работает поверх XSK и поверх DPDK:
class LwipFrameIo {
public:
virtual bool tx_frame(const uint8_t* data, uint32_t len) = 0;
virtual void tx_kick() {}
virtual int rx_frames(int max,
void (*deliver)(void*, const uint8_t*, uint32_t),
void* ctx) = 0;
};
Что пришлось учесть именно для AF_XDP:
у клиента эфемерный локальный порт, поэтому фильтр пропускает TCP, если наш порт совпадает с портом источника или назначения. Фильтр только по назначению выбрасывал бы все ответы сервера;
lwIP сам делает ARP, поэтому фильтр отдаёт ARP в сокет, а не ядру. Ядро на этом интерфейсе перестаёт видеть ARP, так что вешать это на общий интерфейс не стоит;
чистый ACK (подтверждение без данных) — это 54 байта, ARP-запрос — 42. Без дополнения до 60 байт соединение даже не открывается, а снаружи это выглядит как мёртвый линк;
ядро не видит сегменты соединения, поэтому и не отвечает на них RST (сброс соединения), как ответило бы на пакет в незнакомый сокет.
Настройки lwIP подобраны под одно долгоживущее соединение на 10G-линке с RTT около 10 мкс: масштабирование окна включено, окна и буфер отправки по 1 МБ, таймер TCP 25 мс вместо 250, контрольные суммы программные (транспорты кадров не просят offload, а программные корректны на любой карте), статистика включена со 32-битными счётчиками. Со стандартными 16-битными ранний прогон показывал отправителя, который «передал» 1822 сегмента при 198433 кадрах на счётчике карты.
dpdk: драйвер в пространстве пользователя
Что это. DPDK, Data Plane Development Kit, — набор библиотек, который переносит драйвер сетевой карты в пространство пользователя. Его создала Intel в 2010 году, чтобы на обычных x86-серверах можно было обрабатывать пакеты со скоростью, которая раньше требовала специализированных сетевых процессоров. DPDK с самого начала распространялся под свободной лицензией (библиотеки под BSD-3-Clause, модули ядра под GPL), в 2013 году 6WIND создала вокруг него открытое сообщество на dpdk.org, а в 2017 году проект перешёл в Linux Foundation (история проекта). Сейчас его поддерживают все крупные производители карт и облака.
Идея в том, чтобы убрать с пути пакета всё, что не нужно конкретному приложению: системные вызовы, прерывания, sk_buff, общий стек, копирование между ядром и пользователем. Поток приложения прибит к ядру CPU и в цикле опрашивает кольцо карты. Вместе с драйверами DPDK даёт библиотеки для всего вокруг: пулы буферов, lock-free кольца между ядрами, хэш-таблицы, классификаторы, маршрутизацию LPM, QoS, криптографию.
Для чего. NFV (сетевые функции телеком-операторов на обычных серверах: 4G/5G user plane, BRAS, файрволы), программные коммутаторы и маршрутизаторы (Open vSwitch с DPDK, FD.io VPP), балансировщики нагрузки, генераторы трафика (TRex, Pktgen-DPDK), системы захвата и анализа трафика, торговые системы. Общий признак — приложение, которое занимается только пакетами и ради этого готово выделить ядра CPU и порт карты целиком.
Как устроен бэкенд. DPDK забирает сетевую карту у ядра целиком. Устройство перепривязывается к vfio-pci, его регистры отображаются в адресное пространство процесса, память под пакеты выделяется в hugepages (страницы памяти по 2 МБ или 1 ГБ вместо 4 КБ), а драйвер (poll-mode driver, PMD) работает внутри приложения. В цикле нет прерываний, системных вызовов и кода ядра. Подробнее о механике в разделе 5.
Приём в бэкенде:
int rx(RxPacket* out, int max) override {
// mbuf'ы, выданные в прошлый раз, освобождаются сейчас: их время вышло.
if (held_n_ > 0) {
rte_pktmbuf_free_bulk(held_, held_n_);
held_n_ = 0;
}
rte_mbuf* bufs[kRxBatch];
const uint16_t n = rte_eth_rx_burst(port_.id, 0, bufs, max);
int emitted = 0;
for (uint16_t i = 0; i < n; ++i) {
held_[held_n_++] = bufs[i];
if (arp_reply(bufs[i])) continue;
pkt::View v;
if (pkt::parse(rte_pktmbuf_mtod(bufs[i], const uint8_t*),
rte_pktmbuf_pkt_len(bufs[i]), &v) &&
v.dst_port_be == tmpl_.src_port_be) {
out[emitted++] = {v.payload, v.len, v.from};
} else {
++rx_filtered_; // BPF-фильтра нет: мы видим каждый кадр на порту
}
}
return emitted;
}
Под DPDK нет ядра, а значит нет ARP, нет маршрутизации, нет ICMP. Заголовки Ethernet/IP/UDP собираются вручную из шаблона (pkt.h), MAC получателя задаётся в конфиге. Бэкенд отвечает на ARP-запросы о своём адресе: без этого пир со своим IP-стеком, например аппаратный UDP-источник, отправит ARP, не получит ответа и не пошлёт ничего, а со стороны это неотличимо от неработающей карты.
Отправка копирует нагрузку в свежий mbuf и отдаёт пачку в rte_eth_tx_burst. Если TX-кольцо полное, бэкенд ограниченное время повторяет попытку, а потом выбрасывает пачку и считает её: при флапе (кратковременном падении) линка приложение не должно зависнуть внутри flush().
Полезное отличие от AF_XDP в zero-copy: учёт потерь в PMD полный. Всё, что приложение не получило, видно в rte_eth_stats (imissed: карта не нашла свободного дескриптора, ierrors: ошибки приёма, rx_nombuf: не хватило mbuf).
TCP поверх DPDK: tcp-dpdk
Тот же lwIP, тот же код обвязки, что у tcp-xdp, меняется только транспорт кадров: mbuf на входе, mbuf на выходе. Приём копирует кадр в pbuf (буфер пакета lwIP) и сразу освобождает mbuf, отправка копирует готовый сегмент в mbuf и дополняет его до 60 байт.
Отправитель, у которого закрылось окно, сам крутит стек: принимает ACK и выталкивает очередь, пока место не освободится или не выйдет лимит ожидания. Лимит в миллисекундах, заметно больше, чем нужно для слива полного буфера отправки на 10G, так что отказ означает, что пир перестал читать, а не просто медленный.
Готовые стеки TCP поверх DPDK, если lwIP не подходит: F-Stack (стек FreeBSD), Seastar, VPP host stack (VPP, Vector Packet Processing, программный маршрутизатор проекта FD.io), mTCP (исследовательский, не развивается примерно с 2020 года и собирается со старым DPDK).
tcp: ядерный TCP
Что это. TCP (RFC 793, 1981; актуальная сводная версия RFC 9293, 2022) — протокол надёжного упорядоченного потока байтов с подтверждениями, повторной отправкой, управлением потоком и перегрузкой. TCP в ядре Linux — одна из самых проработанных реализаций: CUBIC по умолчанию с 2.6.19, BBR как опция с 4.9 (kernelnewbies), TSO/GRO, автонастройка буферов, zero-copy отправка через MSG_ZEROCOPY. За это платят длиной пути: каждый сегмент проходит через управление перегрузкой, таймеры, очереди сокета и планировщик.
Для чего. Практически всё, что требует доставки: HTTP, базы данных, репликация, брокеры сообщений, биржевые шлюзы по протоколу FIX. Для задач с низкой задержкой TCP обычно не любят за повторные отправки и Nagle, но, как видно из замеров, при правильных настройках ядерный TCP под нагрузкой держится лучше, чем ожидаешь.
Как устроен бэкенд. Тот же интерфейс датаграмм поверх потока. Границы сообщений в TCP нет, поэтому каждая запись получает префикс длины [u16 len], а приёмник собирает записи через границы recv() (stream.h, два байта на запись, 0.14% при 1400-байтных записях).
Настройки, которые делают TCP-сокет транспортом с низкой задержкой, а не трубой для bulk-передачи, применяются одинаково на обоих концах:
void tune(int fd, const Config& cfg, bool sending) {
const int one = 1;
if (cfg.tcp_nodelay) // Nagle держал бы маленькую запись в ожидании компании
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
// Отложенные ACK на приёмнике тормозят окно отправителя на малых скоростях.
setsockopt(fd, IPPROTO_TCP, TCP_QUICKACK, &one, sizeof(one));
const int buf = 16 << 20;
if (sending)
setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &buf, sizeof(buf));
else
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &buf, sizeof(buf));
if (cfg.tcp_busy_poll_us) {
const int us = static_cast<int>(cfg.tcp_busy_poll_us);
setsockopt(fd, SOL_SOCKET, SO_BUSY_POLL, &us, sizeof(us));
}
set_nonblock(fd);
}
Потерь в привычном смысле здесь нет, вместо них обратное давление. Когда буфер сокета заполнен, send() возвращает EAGAIN, неотправленные байты остаются в промежуточном буфере бэкенда, а когда заполнен и он, queue() возвращает false, и копится уже очередь вызывающего. Запись никогда не режется пополам: половина записи в потоке рассинхронизировала бы получателя до конца соединения. При нескольких получателях запись уходит всем или никому, чтобы повторная попытка не дала дубликат.
Nagle стоит больше, чем выбор стека. Алгоритм Нейгла (Nagle) придерживает маленькие записи, пока не придёт подтверждение предыдущих, чтобы отправлять меньше мелких пакетов. В транспорте, из которого вырос проект, выключенный TCP_NODELAY на том же tcp-dpdk дал 40.0 мкс p50 вместо 4.98 и 78.8 мкс p99 вместо 9.8. Это хуже ядерного TCP. На потоке стобайтных сообщений каждая запись маленькая, и алгоритм придерживает почти всё. Поэтому tcp_nodelay в библиотеке включён по умолчанию.
4. Результаты
В ячейке p50 / p99 round trip в микросекундах.
Локально: 82599, порт в порт
бэкенд | 20k | 100k | 200k | 400k | 800k | 1.6M |
|---|---|---|---|---|---|---|
dpdk | 7.4 / 8.1 | 7.4 / 7.8 | 7.4 / 7.8 | 7.5 / 8.0 | 7.7 / 8.7 | 7.9 / 9.9 |
tcp-dpdk | 8.3 / 9.6 | 8.3 / 9.8 | 8.4 / 10.4 | 8.5 / 11.9 | 8.6 / 14.4 | 10.2 / 20.5 |
xdp (zero-copy) | 12.8 / 15.9 | 12.1 / 25.3 | 12.1 / 17.1 | 14.4 / 19.1 | 14.7 / 20.9 | 16.7 / 23.4 |
xdp (copy) | 11.4 / 13.2 | 11.3 / 18.1 | 11.4 / 21.9 | 12.5 / 17.2 | 14.9 / 21.4 | 25.9 / 77.0 |
tcp-xdp (zero-copy) | 13.9 / 16.8 | 13.2 / 26.8 | 14.7 / 25.4 | 15.1 / 20.9 | 16.9 / 23.6 | 18.6 / 27.3 |
tcp-xdp (copy) | 12.8 / 15.1 | 13.8 / 19.9 | 13.2 / 25.2 | 15.5 / 22.4 | 17.9 / 25.3 | 19.9 / 29.1 |
udp | 17.2 / 18.9 | 17.0 / 21.3 | 19.6 / 29.3 | 37.0 / 59.5 | sat. 613k, 50% | sat. 621k, 50% |
tcp | 18.9 / 23.4 | 19.0 / 25.9 | 24.0 / 36.9 | 23.4 / 37.6 | 25.0 / 38.4 | 28.7 / 46.7 |
uring | 20.1 / 25.2 | 19.5 / 31.5 | 24.3 / 39.1 | 66.1 / 152.0 | sat. 430k, 0% | sat. 427k, 0% |
uring + SQPOLL | 20.5 / 24.5 | 20.6 / 28.1 | 29.0 / 46.6 | sat. 400k, 76% | sat. 594k, 85% | sat. 591k, 85% |
DPDK держит 7.4–7.9 мкс во всём диапазоне нагрузки, а нагрузка в нём меняется в 80 раз. Ядерный udp начинает с 17 мкс и перестаёт справляться после 400k. Все пути в обход ядра держат 1.6M без потерь, из ядерных только tcp.
Любопытная деталь: на малой нагрузке copy mode AF_XDP чуть быстрее zero-copy (11.4 против 12.8 мкс), а на 1.6M zero-copy заметно стабильнее (16.7 против 25.9, и p99 23 против 77). Копирование 100 байт почти ничего не стоит, а выигрыш zero-copy проявляется, когда растёт поток.
AWS: пара c6in.4xlarge
бэкенд | 20k | 100k | 200k | 400k | 800k | 1.6M |
|---|---|---|---|---|---|---|
dpdk | 17.4 / 22.8 | 18.8 / 30.0 | 32.2 / 44.1 | sat. 377k, 0% | sat. 377k, 0.1% | sat. 379k, 0% |
tcp-dpdk | 18.4 / 24.3 | 18.9 / 27.4 | 38.3 / 53.5 | 78.0 / 115.8 | 2035 / 2229 | 2545 / 2723 |
xdp (copy) | 29.8 / 38.3 | 29.2 / 41.6 | 41.4 / 56.7 | sat. 371k, 0.3% | sat. 388k, 0.1% | sat. 386k, 0.1% |
tcp-xdp (copy) | 31.7 / 52.6 | 31.9 / 54.4 | 47.1 / 69.9 | 89.9 / 124.4 | sat. 513k, 0% | 1226 / 1349 |
udp | 21.0 / 25.1 | 22.6 / 29.7 | 37.4 / 50.0 | sat. 400k, 12% | sat. 562k, 60% | sat. 561k, 60% |
tcp | 21.4 / 26.7 | 23.3 / 30.7 | 40.8 / 56.4 | 42.5 / 56.1 | 42.0 / 56.1 | 52.8 / 71.2 |
uring | 38.4 / 42.9 | 38.2 / 54.9 | 82.9 / 117.9 | sat. 400k, 8% | sat. 569k, 71% | sat. 568k, 71% |
uring + SQPOLL | 34.1 / 38.8 | 34.6 / 49.2 | 54.2 / 74.6 | sat. 400k, 9% | sat. 566k, 68% | sat. 577k, 71% |
Две ячейки при повторе не совпали: uring на 200k во второй раз дал 53.9 мкс p50, tcp-xdp на 800k в повторе удержал частоту с 0.98 мс p50.
Картина другая. Все датаграммные пути, включая DPDK, упираются примерно в 380k round trip в секунду. Тот же цикл DPDK на процессоре 2012 года делает 1.6M. Значит, упор не в цикле, а в виртуальной сети: одна очередь ENA, один поток (5-tuple: адреса, порты и протокол, по которым сеть различает потоки), запись дескриптора LLQ (low latency queue, режим ENA, в котором дескриптор и заголовок пакета пишутся прямо в память карты) на каждый пакет. Какой из этих факторов главный, я не разделял, для этого нужен прогон с несколькими очередями. Счётчики превышения лимитов AWS (pps_allowance_exceeded, bw_in/out_allowance_exceeded, описание) за сессию не сдвинулись ни на одной машине.
Выигрыш DPDK на AWS при малой нагрузке 3.6 мкс против ядерного UDP (17.4 против 21.0), на 82599 почти 10 мкс (7.4 против 17.2). Большая часть задержки в облаке приходится на путь через Nitro и сеть VPC, и никакой бэкенд на хосте её не убирает.
За пределом производительности пути ведут себя по-разному. DPDK на AWS держит очередь 6–7 мс без потерь (tx_drop=0, hw_imissed=0), AF_XDP 17–19 мс и меньше 0.3% потерь, ядерный UDP превращает 64-мегабайтный буфер сокета в очередь на 20–150 мс, а потом теряет половину.
TCP и упаковка
Потоковые бэкенды доходят до 1.6M сообщений в секунду на обоих стендах, потому что поток кладёт несколько сообщений в один сегмент. На AWS tcp-dpdk передал 8.8M записей в 2.24M кадрах. Ядерный TCP упаковывает лучше всех: на AWS это единственный путь, который держит 1.6M с p50 меньше 60 мкс, локально он несёт до 9.4 сообщения на сброс на верхней частоте. lwIP начинает упаковывать, только когда уже отстал, и на AWS tcp-dpdk и tcp-xdp держат частоту, но очередь растёт до миллисекунд начиная с 800k. Локально tcp-dpdk до этого не доходит.
Во что обходится TCP
Одно измерение из транспорта, из которого выделена библиотека. Воспроизвести его средствами этого репозитория напрямую нельзя (там свой пакетный слой и своя схема измерения, цифры односторонние, от метки производителя до потребителя), но оно хорошо показывает, где стоимость. 200k сообщений на точку, 36 точек, ноль потерь в каждой, p50 в микросекундах:
частота | dpdk | tcp-dpdk | xdp | tcp-xdp | udp | tcp |
|---|---|---|---|---|---|---|
200k/с | 4.19 | 4.98 | 7.17 | 13.32 | 9.75 | 20.08 |
800k/с | 4.20 | 5.14 | 10.48 | 15.92 | 12.56 | 27.22 |
1.6M/с | 4.58 | 5.97 | 12.74 | 26.29 | 14.54 | 27.72 |
Один и тот же lwIP добавляет 0.8 мкс поверх DPDK и 6.2 мкс поверх AF_XDP, а ядерный TCP добавляет 10.3 мкс поверх ядерного UDP. Стоимость протокола и стоимость ядра — это разные величины, и вторая на порядок больше первой.
5. DPDK
Как DPDK обходит ядро
Обычный драйвер сетевой карты живёт в ядре: он владеет регистрами устройства, кольцами дескрипторов и прерываниями, а приложение получает данные через сокеты. DPDK переносит драйвер в процесс приложения. Для этого нужны три вещи.
Доступ к регистрам. Устройство отвязывается от штатного драйвера и привязывается к vfio-pci (dpdk-devbind.py -b vfio-pci 0000:03:00.0). VFIO (Virtual Function I/O, подсистема ядра для безопасной передачи устройств в пространство пользователя) даёт процессу отобразить BAR-области устройства в своё адресное пространство, после чего запись tail-указателя в регистр карты становится обычной записью в память.
Память, которую видит устройство. Карта пишет пакеты по DMA, ей нужен адрес, который не сдвинется. DPDK берёт память из hugepages (2 МБ или 1 ГБ), закрепляет её и через IOMMU (блок трансляции адресов для устройств, аналог MMU для DMA) отображает в адресное пространство устройства (IOVA, I/O virtual address). Без IOMMU есть режим enable_unsafe_noiommu_mode: он работает, но устройство может писать в любую физическую память, так что это только для машин, где процесс DPDK и так всем владеет. На обоих стендах использовался именно no-IOMMU; на AWS это вариант, который описан в документации ENA PMD.
Драйвер в user space. PMD знает формат дескрипторов конкретной карты: выкладывает буферы в RX-кольцо, проверяет бит готовности в дескрипторе, пишет TX-дескрипторы и doorbell. Прерываний нет, поток приложения прибит к ядру и крутит rte_eth_rx_burst() постоянно, поэтому ядро CPU загружено на 100% даже без трафика.
Над этим EAL (Environment Abstraction Layer): инициализация hugepages, привязка потоков к ядрам, пулы mbuf, работа с PCI. В бэкенде EAL стартует на том ядре, к которому процесс уже прибит через taskset, чтобы размещение по ядрам задавалось в одном месте.
Особняком стоят карты NVIDIA/Mellanox. PMD mlx5 работает по бифуркационной модели: карта остаётся у ядерного драйвера mlx5_core, интерфейс в системе не пропадает, а DPDK через rdma-core (пользовательские библиотеки RDMA; verbs и DevX — это их программные интерфейсы к карте) получает свои очереди и правила классификации. Перепривязывать к vfio не нужно, и трафик, который не забрал DPDK, продолжает идти в ядро.
Ещё один вариант без отвязки от ядра — PMD af_xdp: DPDK-приложение поверх XSK. Скорость будет уровня AF_XDP, зато код можно писать под DPDK API и потом переехать на нативный PMD.
С какими картами работает
Полный список в документации DPDK, там же таблица поддерживаемых функций по каждому PMD. Основные семейства:
производитель | PMD | карты |
|---|---|---|
Intel | ixgbe, i40e, ice, igb, igc, e1000 | 82599/X520/X540/X550, X710/XL710/XXV710, E810, I210/I350, I225/I226 |
NVIDIA (Mellanox) | mlx4, mlx5 | ConnectX-3 (mlx4), ConnectX-4…7, BlueField |
Broadcom | bnxt | NetXtreme-C/E, P-серия |
AMD (Solarflare, Xilinx) | sfc | SFN8000, X2 |
Marvell | cnxk, qede | OCTEON, FastLinQ (бывший QLogic) |
Chelsio | cxgbe | T5, T6 |
облака и виртуализация | ena, gve, mana, netvsc, virtio, vmxnet3, iavf | AWS, GCP, Azure, KVM, VMware, SR-IOV VF Intel |
программные | af_xdp, af_packet, pcap, tap, memif, ring | любая карта через ядро, тесты |
SR-IOV (single root I/O virtualization) позволяет одной физической карте представиться несколькими виртуальными функциями (VF), которые отдаются виртуальным машинам или процессам.
Потребительские карты в этом списке представлены слабо. Для Realtek с DPDK 24.11 есть PMD r8169, он покрывает RTL8125, RTL8126, RTL8127 и RTL8168 (release notes 24.11), но по возможностям это уровень бытовой карты.
Потребительские и дорогие карты: в чём разница
Грубо карты делятся на три класса.
Потребительские и встроенные (Realtek, Intel I219, I225/I226; 1–2.5G; от 10 до 50 долларов). Одна или несколько очередей, минимум аппаратных фильтров, скромные буферы, ограниченные offload. Для DPDK и AF_XDP zero-copy обычно не годятся или годятся с оговорками. Для обычной сети их достаточно.
Серверные массовые (Intel X710/E810, NVIDIA ConnectX-5/6/7, Broadcom P-серия; 10–100G; от 150 до 1500 долларов, а 82599 на вторичном рынке стоит копейки). Десятки и сотни очередей, RSS с настраиваемой таблицей, тысячи правил точной классификации, SR-IOV, TSO и LRO (large receive offload, склейка входящих сегментов на карте), аппаратные метки времени для PTP (Precision Time Protocol, синхронизация часов по сети с точностью до наносекунд), поддержка DPDK и AF_XDP zero-copy из коробки. Это основная рабочая лошадка для DPDK, и для большинства задач с обходом ядра их хватает.
Специализированные с низкой задержкой (AMD Solarflare X2522/X2541 и новые X4522/X4542, AMD Alveo X3522, Cisco Nexus SmartNIC, бывшие ExaNIC, NVIDIA ConnectX с VMA/XLIO, карты на FPGA (программируемых логических матрицах) Napatech и другие; от 1–2 до 10+ тысяч долларов). Здесь платят за другое:
Свой стек в обход ядра, совместимый с BSD-сокетами (стандартным API
socket(),send(),recv()). Приложение запускается черезLD_PRELOAD(подгрузка библиотеки, перехватывающей функции libc), и егоsocket()/send()/recv()обслуживает библиотека в процессе, которая работает с очередями карты напрямую. Переписывать код не нужно, и это главная причина, по которой такие карты покупают.Аппаратная защита для многих user-space очередей. Карта сама проверяет, что процесс пишет только в свои дескрипторы и свою память, поэтому непривилегированные приложения могут безопасно владеть очередями, а трафик, который им не адресован, идёт в ядро. У массовой карты под DPDK такой изоляции нет, процесс владеет устройством или VF целиком.
Задержка в самой карте. Cut-through внутри NIC (отправка начинается до того, как весь кадр пришёл по PCIe, например CTPIO, cut-through programmed I/O, у Solarflare), передача коротких пакетов записью процессора прямо в регистр карты вместо DMA-чтения (PIO, programmed I/O), минимальные внутренние буферы. Производители заявляют половину round trip порядка микросекунды и ниже.
Точное время: метки времени на каждый пакет с наносекундным разрешением, вход PPS (pulse per second, секундная метка от GPS-приёмника или эталона), синхронизация PTP, что нужно для регуляторной отчётности в трейдинге.
FPGA в части карт, которую можно перепрограммировать под свою логику, например фильтрацию или разбор биржевого протокола прямо на карте.
Почему дорого. Рынок маленький (в основном биржевая торговля, телеком, захват трафика), а разработка большая: собственный ASIC (специализированная микросхема) или FPGA, собственный драйвер, собственный TCP/UDP стек, совместимый с сокетами, поддержка и сертификация. Код стеков при этом часто открыт: у AMD OpenOnload (GPLv2 и BSD) с ef_vi и TCPDirect (MIT) лежат на GitHub. Платными остаются поддержка и сопровождаемые сборки (EnterpriseOnload с подпиской), а исторически полные возможности Onload и TCPDirect требовали карт в исполнении -PLUS или лицензии.
Какие карты позволяют обходить ядро из коробки
AMD Solarflare (X2, X4) и Alveo X3:
OpenOnload / Onload — стек TCP/UDP в процессе, подменяет сокеты через
LD_PRELOAD, приложение не меняется;ef_vi (EtherFabric virtual interface) — низкоуровневый API сырых кадров, аналог DPDK от производителя;
TCPDirect — zero-copy API поверх ef_vi для минимальной задержки;
DPDK PMD
sfc;CTPIO на картах начиная с X2: пакет уходит с шины PCIe в порт с минимальной буферизацией (описание в документации ef_vi);
в OpenOnload с 2020 года есть поддержка обычных карт через AF_XDP, но по README проекта это поддерживаемая сообществом разработка, пока не релизного качества.
NVIDIA Mellanox ConnectX:
VMA и выросший из него XLIO (документация) — ускорение сокетов через
LD_PRELOAD; VMA продолжает выпускаться;RDMA verbs и RoCE (RDMA over Converged Ethernet) — нулевое копирование между памятью двух машин, без участия CPU получателя;
raw packet QP (queue pair, пара очередей отправки и приёма в RDMA) через verbs — сырые кадры без DPDK;
DPDK
mlx5в бифуркационном режиме;на DPU (data processing unit, карта с собственными процессорными ядрами) BlueField — DOCA (SDK NVIDIA для DPU) и обработка на Arm-ядрах самой карты.
Cisco:
Nexus SmartNIC (бывшие Exablaze ExaNIC; Cisco купила Exablaze в феврале 2020, например, K35-S — это бывшая ExaNIC X10) с exanic-software: libexanic для сырых кадров и exasock для ускорения сокетов через
LD_PRELOAD;VIC (Virtual Interface Card, серверные карты Cisco UCS) с usNIC (user-space NIC) — доступ из пространства пользователя через libfabric/verbs, ориентирован на MPI (Message Passing Interface) в HPC (high performance computing, суперкомпьютерные вычисления); провайдер
usnicв libfabric.
Intel:
собственного стека в обход ядра с
LD_PRELOADу Intel нет;DPDK (его создала Intel в 2010 году, с 2017 проект живёт в Linux Foundation; поддержка карт Intel самая полная);
AF_XDP zero-copy в ixgbe, i40e, ice, igc;
ADQ (Application Device Queues) на E810 и E830 — выделенные группы очередей под приложение плюс busy poll, ускоряет обычные ядерные сокеты без переписывания;
Flow Director и ntuple-фильтры для направления потоков в нужные очереди.
Chelsio:
TOE (TCP offload engine) — TCP целиком на карте;
WireDirect — user-space UDP и TCP на картах T5/T6: WD-UDP работает без изменения приложения, WD-TOE и WD-QP отдают очереди в пространство пользователя и могут потребовать доработки.
Napatech:
FPGA-карты для захвата трафика без потерь, с наносекундными метками времени, с API DPDK и libpcap.
Как дорабатывать код под конкретную карту
DPDK даёт общий API, но карты под ним разные, и переносимый код получается только если спрашивать карту о её возможностях, а не предполагать их. Что пришлось или придётся учитывать:
Offload. rte_eth_dev_info_get() возвращает rx_offload_capa и tx_offload_capa. Контрольные суммы IPv4/UDP/TCP, TSO, приём пакета в несколько буферов (scatter), пакет из цепочки mbuf (multi-seg) есть не везде. Бэкенд не просит ни одного offload, поэтому работает на любом PMD, но теряет на этом такты. Для прода стоит включать то, что карта умеет, и оставлять программный путь для остальных.
MTU (maximum transmission unit, наибольший размер IP-пакета на линке). Одни PMD берут MTU из rte_eth_dev_configure, другие требуют отдельный rte_eth_dev_set_mtu, третьи отказывают во втором. Бэкенд пробует оба и продолжает работу при отказе избыточного вызова:
conf.rxmode.mtu = opt.mtu;
int ret = rte_eth_dev_configure(p->id, 1, 1, &conf);
// Одни PMD берут MTU из configure и отклоняют отдельный set, другим нужен
// явный. Пробуем и идём дальше в любом случае.
if (ret == 0 && conf.rxmode.mtu > RTE_ETHER_MTU) {
const int m = rte_eth_dev_set_mtu(p->id, conf.rxmode.mtu);
if (m != 0)
fprintf(stderr, "set_mtu(%u) refused (%s); relying on configure\n",
conf.rxmode.mtu, rte_strerror(-m));
}
Кольца. Минимальное и максимальное число дескрипторов и кратность у каждой карты свои (rx_desc_lim, tx_desc_lim), число очередей тоже. Размер RX-кольца стоит выбирать с оглядкой на DDIO (раздел 2): кольцо, которое не помещается в долю LLC, защищает от потерь, но добавляет промахи в память. Память пулов выделяйте на NUMA-узле карты (rte_eth_dev_socket_id()), иначе DDIO не сработает.
Линк. Каждый rte_eth_dev_start сбрасывает 82599, и на DAC-кабеле (direct attach copper, медный кабель с разъёмами SFP+) переобучение линка — лотерея: хорошая попытка поднимает линк за несколько секунд, неудачная не сходится никогда. Бэкенд ждёт 20 секунд и сдаётся, дальше лучше перезапустить процесс. Трафик, отправленный в опущенный линк, теряется молча.
Короткие кадры. Ядро дополняет кадры до 60 байт, PMD нет, и карта на другой стороне короткий кадр может выбросить. Для TCP (ACK 54 байта) и ARP (42 байта) без этого соединение не откроется.
Классификация. RSS и rte_flow поддерживаются в разном объёме: какие поля можно матчить, сколько правил, какие действия. Код, который направляет потоки по очередям, придётся проверять на каждой модели.
Специфика отдельных PMD:
ENA (AWS): режим LLQ, где дескриптор и заголовок пакета записываются прямо в память карты; для него нужен BAR с write-combining, а на инстансах 6-го поколения и новее LLQ обязателен. В основном ядре
vfio-pciwrite-combining не умеет, Amazon даёт скрипт сборки vfio-pci с WC в amzn-drivers; альтернатива — igb_uio (устаревший драйвер DPDK) сwc_activate=1(документация ENA PMD). Лимиты AWS на pps (пакеты в секунду) и на поток действуют и для DPDK: один поток (5-tuple) ограничен 5 Гбит/с вне cluster placement group и 10 Гбит/с внутри неё, с ENA Express до 25 Гбит/с в одной зоне доступности (документация AWS);mlx5: нужен rdma-core, привязка к vfio не нужна, ядерный интерфейс остаётся жив;
ice (E810): при старте загружается DDP-пакет (Dynamic Device Personalization, описание протоколов для парсера карты) из
/lib/firmware/intel/ice/ddp. Без него инициализация по умолчанию падает; с параметромsafe-mode-support=1карта стартует в safe mode, но без RSS, offload контрольных сумм, Flow Director и туннелей (документация);i40e, ice: поддержка отдельных функций зависит от версии прошивки карты;
расширенные счётчики (xstats,
rte_eth_xstats_get) называются по-разному у разных PMD, и мониторинг под каждую карту пишется отдельно.
Метки времени. API для аппаратных меток (rte_eth_timesync_*, динамическое поле mbuf) есть, но поддержка и точность сильно различаются.
И наконец, DPDK ничего не знает про IP. ARP, маршрутизация, ICMP, фрагментация, TCP остаются на вас или на стороннем стеке.
6. Выводы и рекомендации
Выводы по измерениям:
На реальном железе пути в обход ядра дают плоскую задержку. DPDK держит 7.4–7.9 мкс от 20k до 1.6M сообщений в секунду, ядерный UDP начинает с 17 мкс и перестаёт справляться после 400k.
В облаке выигрыш меньше. На AWS разница DPDK и ядерного UDP при малой нагрузке 3.6 мкс, и все датаграммные пути упираются примерно в 380k round trip в секунду на один поток с одной очередью.
Стоимость TCP — в основном стоимость ядра. lwIP поверх DPDK добавляет меньше микросекунды, ядерный TCP поверх ядерного UDP около 10.
Ядерный TCP с
TCP_NODELAYотлично упаковывает сообщения под нагрузкой и держит высокие частоты без очереди.io_uring не ускоряет приём мелких UDP-пакетов по задержке. Его выигрыш в другом: пустой опрос без системного вызова и меньше вызовов на отправку.
Раскладка прерываний меняет задержку ядерных путей на 3–5 мкс, на DPDK не влияет.
Что выбирать:
задача | с чего начать |
|---|---|
обычный сервис, задержка десятки микросекунд устраивает |
|
поток сообщений с высокой частотой, нужна доставка | ядерный |
нужен обход ядра, но машина должна оставаться обычной машиной | AF_XDP: фильтр забирает только свой порт, остальное идёт в ядро |
минимальная и плоская задержка на своём железе, есть выделенное ядро CPU и выделенный порт | DPDK, для TCP lwIP или F-Stack поверх |
нужен обход ядра без переписывания приложения | карта с собственным стеком: Solarflare Onload, NVIDIA VMA/XLIO, Cisco exasock |
много соединений, важна эффективность в простое | io_uring |
Практические советы, которые проверены на этих стендах:
Выключите Nagle. На потоке мелких сообщений он стоит больше, чем выбор стека.
Не сажайте IRQ сетевой очереди на гиперпоток-сосед ядра, которое крутит busy poll.
Выключайте модерацию прерываний (
ethtool -C <if> rx-usecs 0), если важна задержка, а не загрузка CPU.Для AF_XDP проверяйте, что максимальный кадр влезает в один RX-буфер драйвера в zero-copy. Потеря длинных кадров в ixgbe не видна ни в одном счётчике.
Для DPDK и AF_XDP дополняйте короткие кадры до 60 байт.
В облаке сначала проверьте лимиты виртуальной сети (pps на поток, число очередей), а потом уже переписывайте датапат (код, через который идут пакеты).
Меряйте под нагрузкой в open loop. Ping-pong одного пакета не показывает, где путь начинает копить очередь или терять.
Держите поток обработки, память пакетов и сетевую карту на одном NUMA-узле: DDIO работает только для локального сокета. Не раздувайте RX-кольца без необходимости.
Сравнивая Intel и AMD, помните про DDIO: на EPYC до Zen 5, а на Zen 5 без поддержки PCIe TPH в карте и драйвере пакеты идут через DRAM.
Смотрите счётчики, а не только задержку:
rte_eth_stats,XDP_STATISTICS,/proc/net/snmp, статистику lwIP. Задержка без счётчиков не отличает медленный путь от пути, который теряет и ретрансмитит.
И главное: все эти цифры получены на двух конкретных стендах, с сообщениями по 100 байт, одним сообщением на пакет и MTU 1500. Другие размеры сообщений, пакетирование на уровне приложения, другие карты и другие ядра сдвинут каждую границу. Поэтому библиотека и сделана так, чтобы путь переключался строкой в конфиге: прогнать свою нагрузку по всем бэкендам на своём железе быстрее, чем спорить о чужих цифрах.
Статус проекта
Код исследовательский. Он проверен на нескольких стендах: ixgbe порт в порт, AWS c6in с ENA, veth в network namespace. Известных открытых проблем нет, найденные при прогонах ошибки исправлены и описаны в BENCHMARKS.md. Тем не менее это не production-ready решение: для прода его нужно полноценно тестировать на собственных нагрузках, железе и ядрах.
Чего в библиотеке нет намеренно: надёжной доставки поверх датаграмм, упорядочивания, контроля перегрузки. Она перемещает пакеты и считает, что с ними произошло. Восстановление потерь делается уровнем выше, например кодом Рида — Соломона из соседнего проекта rsfec, который будет раскрыт позже. Платформа Linux и x86-64: ядерные бэкенды переносимы, AF_XDP и DPDK нет.
Воспроизвести локальный стенд можно одной командой при наличии карты с двумя портами, соединёнными кабелем (от root, около 20 минут, всё возвращается на место при выходе):
scripts/get_lwip.sh && make all example
sudo DRV_IF=enp3s0f0 PEER_IF=enp3s0f1 scripts/wire_bench.sh
scripts/bench_report.py bench/results/<дата>-ixgbe-irq-house
AWS-стенд поднимается terraform скриптами, в случае интереса могу поделиться.
Буду рад замерам на других картах, особенно E810, ConnectX и Solarflare, и на других облаках. Issues и pull requests открыты.
7. Ссылки
Проект:
dgram-io, методика и результаты в BENCHMARKS.md
Ядро Linux:
NAPI, там же про busy polling
Efficient IO with io_uring, Jens Axboe (у сайта может быть просрочен сертификат)
DPDK:
DDIO и кэш:
Intel PCM, утилиты
pcm-iioиpcm-pcieNetCAT, атака через DDIO
Farshin et al., Reexamining Direct Cache Access…, USENIX ATC 2020
ResQ, NSDI 2018, leaky DMA
TCP в пространстве пользователя:
Карты и стеки производителей:
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.