Когда мне поставили задачу выжать из WireGuard максимум на bare-metal (Xeon Gold 6338, 32 ядра, две NUMA-ноды, NIC Mellanox ConnectX-6 10 Gbps), первый же iperf3 через туннель выдал 2.4 Gbps. Это при том, что без туннеля интерфейс держал линейку. Дальше — несколько недель плясок с CPU-affinity, IRQ pinning и NUMA, по итогам которых я выжал стабильные 8.5 Gbps в одну сессию и до 9.6 Gbps на нескольких параллельных. Расскажу, что сработало.
Контекст
WireGuard — это kernel-space модуль, который при шифровании делает Curve25519+ChaCha20-Poly1305. Криптография паралелится по «пирам», но для одного пира работа идёт на одном ядре. Это первый барьер: одна WG-сессия не вылезет за один CPU.
Внутри ядро WG использует workqueue, которые могут планироваться куда угодно. На NUMA-системе это значит, что данные могут летать между сокетами процессора, и это убивает performance больше, чем что-либо ещё.
Стартовая точка
iperf3 -c 10.8.0.2 -P 1 -t 30
# 2.41 Gbps
mpstat -P ALL 1 показывал, что одно ядро (рандомное) загружено на 100% в softirq, остальные простаивают. Классика.
Шаг 1: вынести IRQ NIC на нужные ядра
Сначала надо узнать, к какой NUMA-ноде прицеплена NIC:
cat /sys/class/net/eth0/device/numa_node
# 0
Окей, NIC на NUMA0. Значит, и WG, и приложения, и IRQ — всё лучше держать на NUMA0. Узнаём, какие CPU относятся к NUMA0:
numactl -H | head
# node 0 cpus: 0 2 4 6 8 10 12 14 16 18 20 22 24 26 28 30
(Hyperthreading включён, физические — чётные.)
IRQ Mellanox по дефолту размазаны по всем 32 ядрам. Прибиваем к NUMA0:
systemctl stop irqbalance
systemctl disable irqbalance
for irq in $(grep eth0 /proc/interrupts | awk '{print $1}' | tr -d ':'); do
echo 0,2,4,6,8,10,12,14 > /proc/irq/$irq/smp_affinity_list
done
irqbalance обязательно надо выключить, иначе он будет переразмещать affinity каждые несколько секунд.
После этого iperf3 в одном потоке — 3.1 Gbps. Уже лучше, но не уровень.
Шаг 2: pinning процесса iperf и приложений
taskset -c 8,10,12,14 iperf3 -c 10.8.0.2 -P 1 -t 30
# 3.4 Gbps
Чуть-чуть. Но я ещё не закрепил сам WG.
Шаг 3: WireGuard workqueue affinity
Тут начинается интересное. WG использует kernel workqueue, и у них есть cpumask:
ls /sys/devices/virtual/workqueue/
# wg-crypt-wg0/ и др.
Прибиваем их к нужным ядрам:
echo ff > /sys/devices/virtual/workqueue/wg-crypt-wg0/cpumask
# 0-7 в hex
Точнее, маска тут битовая. Для ядер 0,2,4,6,8,10,12,14 (NUMA0) маска — 0x5555:
echo 5555 > /sys/devices/virtual/workqueue/wg-crypt-wg0/cpumask
После этого iperf3 в одном потоке — 4.8 Gbps. Растёт.
Шаг 4: multi-queue NIC
Mellanox умеет combined queues, по дефолту их может быть слишком много или слишком мало. Смотрим:
ethtool -l eth0
# Combined: 8
Меняем до количества физических ядер на NUMA0:
ethtool -L eth0 combined 8
И раскидываем IRQ по очереди — одно ядро на одну очередь:
# Каждая очередь на своё ядро
i=0
for irq in $(grep eth0 /proc/interrupts | awk '{print $1}' | tr -d ':'); do
cpu_list="${cpus[$i]}"
echo $cpu_list > /proc/irq/$irq/smp_affinity_list
i=$((i+1))
done
После этого в multi-stream (-P 8) — 9.4 Gbps. В одну сессию — 5.2 Gbps.
Шаг 5: cpuset для системных процессов
Чтобы systemd-юниты, cron-задачи и прочее не мешали NUMA0, я отдал NUMA1 под «всё остальное». Через systemd это делается через CPUAffinity на специальном target:
# /etc/systemd/system/system.slice.d/cpuaffinity.conf
[Slice]
CPUAffinity=1 3 5 7 9 11 13 15 17 19 21 23 25 27 29 31
И отдельный target для WG-нагрузок на NUMA0. Плюс ядро при загрузке — параметр isolcpus или nohz_full:
GRUB_CMDLINE_LINUX="isolcpus=0,2,4,6,8,10,12,14 nohz_full=0,2,4,6,8,10,12,14 rcu_nocbs=0,2,4,6,8,10,12,14"
isolcpus — это «выключи планировщик на этих ядрах для обычных процессов». nohz_full — отключи периодический tick. rcu_nocbs — выноси RCU-callback’и на другие ядра. Эти три вместе дают честный «реалтайм-ish» режим для критических потоков.
После этого в одном потоке — 8.5 Gbps стабильно. В multi — упирается в линейку.
Sysctl, без которых грустно
net.core.rmem_max = 268435456
net.core.wmem_max = 268435456
net.core.netdev_max_backlog = 100000
net.core.netdev_budget = 600
net.core.netdev_budget_usecs = 8000
netdev_budget — это сколько пакетов NAPI может обработать за один проход. На 10 Gbps дефолтные 300 — мало.
Профилирование
perf top -g показал, что 60% CPU времени уходит в chacha20_poly1305 — это нормально, это и есть основная работа WG. Если видите там что-то другое (например, __copy_user_enhanced_fast_string на >20%) — у вас проблема не в крипто, а в DMA/копировании.
Итог
WireGuard на 10 Гбит — реально, но не на дефолтах. Главные победы дают: NUMA-локальность, multi-queue NIC, привязка WG workqueue к нужным ядрам, и isolcpus чтобы планировщик не размазывал прерывания. Если вы крутите WG на однонодовом сервере без NUMA — большинство этих шагов не нужны, дефолты вытащат. Но как только появляется два сокета — без pinning’а вы не выжмете и трети от железа.