Пропускная способность на такт CPU: число, которое выживает при столкновении с роутером
Число из лаборатории и число с реального железа почти никогда не совпадают. Мы измерили оба, сохранили доверительные интервалы, раскрыли ловушку, которая однажды дала неверный результат, и прямо сказали, где мы проиграли.
Опубликовано 6 сентября 2026 г.
На двух физических роутерах, гоняя реальный трафик по настоящей гигабитной локальной сети, ядро нашего туннеля выдало 122 Мбит/с там, где собственная туннельная реализация ядра роутера выдала 66,8 Мбит/с — на одном и том же отправителе, при меньшей нагрузке CPU на мегабит. Ни одна из цифр сама по себе не рассказывает всю историю: двум плечам сравнения пришлось использовать разные генераторы трафика, потому что ни один генератор не работает на обеих сторонах этого сравнения. Поэтому спорить стоит о соотношении каждого плеча к собственному потолку без туннеля, который приведён в таблице ниже и говорит в нашу пользу ещё сильнее, а не слабее. Лабораторный прогон на четырёх ВМ на ноутбуке даёт более высокое число в абсолютных величинах и, по одной из осей, число менее выгодное для нас. Мы больше доверяем цифре с роутера, потому что из двух именно роутер — то место, через которое реально проходит трафик клиента.
Прочитайте это перед цифрами. Наш дата-плейн по умолчанию использует один поток. Компаратор, который мы запускали рядом с ним в лаборатории, по умолчанию использует четыре. Без выравнивания один этот флаг меняет его пропускную способность с ~789 до 1427 Мбит/с — разрыв в настройках по умолчанию, а не в датапасе — и он уже однажды привёл к публикации одной неверной цифры здесь. Каждая лабораторная цифра ниже закрепляет оба ядра на Rust за одним потоком. Реализации на Go и в ядре ОС в этой таблице работали при собственных настройках по умолчанию, которые используют больше одного потока, — если это на что-то и влияет, то скорее в их пользу против нашего одного потока. Разрыв между настройками по умолчанию «как есть» показан отдельно, один раз, чтобы он тоже не был скрыт умолчанием.
Что измерялось и на чём
Две независимые среды, потому что бенчмарк честен только в пределах той среды, в которой он
проводился — число из iperf3 и число из bandwidth-test — это не одно число под двумя именами, а
ВМ, делящая ядра с ноутбуком, — не роутер.
Железо — два роутера MikroTik, одна гигабитная локальная сеть, 2026-09-01. RB3011UiAS
(ARMv7, 2×1400 МГц) в роли отправителя, cAP ax (ARM64, 4×864 МГц) в роли получателя, RouterOS
7.24.1 на обоих. Наше ядро работало как fv, в контейнере RouterOS, в пользовательском
пространстве, против собственной реализации протокола WireGuard прямо в ядре ОС роутера —
единственный доступный на этом железе сравнимый вариант «на равных», поскольку это единственная
альтернатива, которая поставляется прямо в составе RouterOS. Два генератора, потому что ни один не
переносится между реализациями чисто: dd | nc для нашего прогона в контейнере, собственный
bandwidth-test от RouterOS для прогона в ядре — каждый несёт свой собственный потолок без туннеля
как контрольную точку, чтобы читатель мог проверить потолок, прежде чем доверять соотношению.
Лаборатория — четыре гостевые системы aarch64 Debian под QEMU/HVF на одном хосте с Apple Silicon,
2026-08-31. Одни и те же пары ключей, адреса, порты, MTU и поднятие интерфейса для каждого
бинарника под тестом: наше ядро, boringtun 0.7 (пользовательское ядро — референс протокола
WireGuard в пользовательском пространстве, от которого ответвилось наше), wireguard-go
(референсная реализация протокола WireGuard, пользовательское пространство) и собственный модуль
WireGuard ядра Linux. 15-секундные прогоны iperf3, по три замера на каждый, оба ядра на Rust
закреплены за одним потоком датапейна по причине, указанной в примечании выше.
task test:impls # lab: ours vs boringtun vs wireguard-go vs the kernel
task test:all # lab: single-hop, multi-hop, and the comparisonПрогон на роутере пока не оформлен как цель task — это fv, запущенный вручную в контейнере
RouterOS на каждом устройстве, против встроенного в RouterOS сервера протокола WireGuard. Полная
топология и каждая необработанная строка лежат в нашем собственном docs/benchmarks/results.csv.
Число, которое имеет значение: на роутере
| Показатель | наше ядро (пользовательское пространство) | собственный туннель ядра роутера |
|---|---|---|
| пропускная способность | 122 Мбит/с | 66,8 Мбит/с |
| CPU, отправитель | 65% (63–67) | 74% (70–79) |
| CPU, получатель | 18% (10–22) | 20% (18–21) |
| CPU на Мбит | 0,53 %/Мбит | 1,10 %/Мбит |
| RTT | 1,22 мс | 0,92 мс |
| резидентная память (отправитель / получатель) | 3,0 / 3,1 МБ | в ядре, неотделима |
| генератор | dd | nc | bandwidth-test |
| потолок этого генератора без туннеля | 187 Мбит/с | 220,5 Мбит/с |
| сохранено от собственного потолка | 65% | 30% |
Длина полосы ∝ Мбит/с, ось 0–140. Один и тот же отправитель RB3011 в обоих прогонах; именно он — потолок по CPU на протяжении всего теста, 65–79%, пока принимающий cAP ax простаивает у отметки около 20%.
Две независимые схемы согласуются, что и делает результат заслуживающим доверия, а не подобранным: абсолютная пропускная способность выше у нашего ядра в 1,8×, а доля, сохранённая от собственного неоспоримого потолка каждого генератора, — в 2,2×. Чего таблица не говорит: два генератора — не один и тот же инструмент, поэтому показаны оба потолка без туннеля, а не один заголовочный коэффициент, и обе цифры изменились бы, если бы отправитель принимал настоящий сетевой трафик, а не генерировал его локально. В отличие от лабораторной таблицы ниже, это единичные прогоны: проценты в скобках — это диапазон, в котором колебалась загрузка CPU за время этого прогона, а не разброс по повторным испытаниям.
Лаборатория: три ядра в пользовательском пространстве, одно в ядре ОС, потоки уравнены
| ядро | тип | TCP Мбит/с (диапазон, n=3) | пиковая память | задержка p99 |
|---|---|---|---|---|
| наше, 1 поток | пользовательское пространство, Rust | 1535 (1506–1554) | 3,5 МБ | 0,94 мс |
| boringtun, 1 поток | пользовательское пространство, Rust | 1427 (1346–1479) | 5,3 МБ | 1,15 мс |
| wireguard-go, по умолчанию | пользовательское пространство, Go | 1228 (1221–1239) | 34,8 МБ | 1,39 мс |
| WireGuard ядра ОС, по умолчанию | ядро, C | 1376 (1366–1386) | — (ядро, неотделимо) | 0,96 мс |
Как поставляется, без выравнивания: наше ядро по умолчанию использует 1 поток и выдало 1535 Мбит/с; boringtun по умолчанию использует 4 потока и выдал при такой настройке ~789 Мбит/с. Сообщить только цифру «по умолчанию против по умолчанию» значило бы повторить ту же ошибку, только в выгодном для нас направлении. Обе цифры реальны; но только одна из них изолирует датапас от количества потоков.
Стоимость CPU — вот где лаборатория оправдывает своё место в этом посте: это единственная ось, которую таблица с роутера показать не может, потому что встроенный в ядро компаратор роутера не даёт разбивки по CPU для сравнения.
Меньше — лучше. Ось 0–2,5 с/Гбит, линейная. Оба сегмента складываются слева направо; разбивка показывает, откуда берётся потеря, о которой пойдёт речь в следующем разделе.
Логарифмическая шкала, 1–50 МБ, указано отдельно, потому что это нечестный вариант по умолчанию: на линейной шкале 34,8 МБ у wireguard-go свели бы два других столбца к тонким линиям.
Где мы не победили
Два места, оба объяснены, а не спрятаны в CSV:
CPU в пользовательском режиме, в лаборатории. 0,347 с/Гбит против 0,290 у более дешёвого компаратора на Rust — единственный столбец в этой таблице, где он опережает нас. Мы читаем это так, что это куплено, а не потрачено впустую: наше ядро делает офлоад контрольной суммы TUN в обоих направлениях, что переносит работу из ядра ОС в пользовательский CPU, учитываемый как время user — а колонка системного CPU, где эта сделка окупается, у нас заметно впереди (0,549 против 0,787). Итоговый CPU всё равно побеждает у нас. Разбивка user/sys выше показана специально, чтобы эта сделка была видна, а не просто заявлена.
Время кругового пути, на роутере. 1,22 мс против 0,92 мс у туннеля в ядре ОС — единственная ось на железе, где встроенное в ядро решение побеждает безоговорочно, по обычной причине: наш путь платит за переход через veth контейнера и переключение планировщика в пользовательском пространстве, за которые путь внутри ядра не платит. Любопытно, что в лаборатории обратный порядок по задержке p99 (0,94 мс против 0,96 мс у ядра) — мы не делаем из этого никаких выводов, кроме «разные среды, разные узкие места», и говорим об этом прямо, вместо того чтобы выбирать цифру, которая выгоднее.
Что свежее, а что унаследовано
У каждой цифры выше есть дата, и даты важны: таблица с роутера — от 2026-09-01, лабораторная
таблица — от 2026-08-31. С тех пор скоординированное крупное обновление версий переместило
криптографические крейты, на которые наш дата-плейн опирается на каждом пакете, —
chacha20poly1305, blake2, curve25519-dalek, — и мы не перезапускали полное лабораторное или
роутерное сравнение на этом дереве. Цифры выше реальны и были измерены аккуратно, но на момент
публикации им шесть дней, и они относятся к дереву, которое предшествует этому обновлению. Мы
публикуем их с этой оговоркой, а не молча, и укажем дату, когда появится повторный прогон. Цифра с
роутера также не привязана в наших собственных записях к конкретному коммиту: читайте
«2026-09-01» как «начало сентября», а не как привязку к одному конкретному изменению оптимизации.
Что мы могли бы перезапустить прямо сегодня, на этой машине, без роутера или лаборатории с ВМ, — это часть пути, которую обновление крипто затронуло сильнее всего: стоимость инкапсуляции и декапсуляции, изолированная от любых сокетов, TUN и вопросов многопоточности выше по стеку.
| операция, размер пакета | наше ядро | boringtun 0.7.1 |
|---|---|---|
| инкапсуляция, 64 Б | 219,2 нс | 218,7 нс |
| инкапсуляция, 512 Б | 436,4 нс | 442,5 нс |
| инкапсуляция, 1420 Б | 938,1 нс | 968,3 нс |
| декапсуляция, 64 Б | 238,8 нс | 226,7 нс |
| декапсуляция, 512 Б | 562,1 нс | 539,2 нс |
| декапсуляция, 1420 Б | 1099,5 нс | 1069,4 нс |
Повторный прогон от 2026-09-05, Apple M3 Max,
cargo bench -p forestvpn-quantum --bench datapath_benches -- --measurement-time 3 --warm-up-time 1,
criterion 0.8.2, 100 замеров на точку. Ни сокета, ни TUN, ни потоков — одно завершённое рукопожатие,
затем инкапсуляция/декапсуляция в цикле. Результаты неоднородны по направлению и размеру пакета, всё
в пределах нескольких процентов в обе стороны, что и ожидаемо: наше ядро ответвилось от вышестоящего
проекта именно в этой части пути, и оба сейчас сидят на одном поколении примитивов RustCrypto.
Это не заявление о пропускной способности — оно говорит, что форкнутый криптографический путь
сам по себе стоит примерно столько же, сколько и у вышестоящего проекта, в пределах шума, а это и
есть то, что делает лабораторные и роутерные победы выше заслуживающими доверия: они берутся из
другого места в стеке (батчинг системных вызовов, офлоад, многопоточность), а не из более быстрого
шифра.
Как воспроизвести
task test:impls # lab: ours vs boringtun vs wireguard-go vs the kernel
task test:all # lab: single-hop, multi-hop, and the multihop cost table
cargo bench -p forestvpn-quantum --bench datapath_benches -- --measurement-time 3 --warm-up-time 1Прогон на роутере сегодня выполняется вручную: fv в контейнере RouterOS на каждом устройстве,
против собственного сервера протокола WireGuard в RouterOS.
Честно говоря о том, что это даёт: харнесс и необработанный results.csv за каждой таблицей здесь
живут в нашем дереве, которое сегодня не публично. Читайте команды выше как точное описание того,
что мы запускали, а не как то, что можно склонировать и повторить. Утверждение, меньше всего
зависящее от нас, — оно же и самое стоящее проверки: boringtun и wireguard-go публичны, iperf3
публичен, а ловушка с количеством потоков в начале этого поста (один флаг, двигающий компаратор
между примерно 789 и 1427 Мбит/с на одном хосте) воспроизводима кем угодно с этими тремя
инструментами и без единой строчки нашего кода в цепочке. Если у вас это подтвердится — уравненные
строки выше как раз те, с которыми стоит спорить. Если нет — мы хотим об этом узнать.