Когда мне поставили задачу выжать из 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’а вы не выжмете и трети от железа.