Полмиллиона RPS на GB10: узким местом была сеть, а не машина
Мы решили проверить, как быстро плоскость данных нашего облака может идти на NVIDIA GB10. Ответ научил нас очередям приёма NIC больше, чем собственному коду, и более чем удвоил пропускную способность на том же проводе.

На этой странице
Мы строим облако на одном бинарнике Go: один артефакт поднимает шлюз ИИ, коммерцию, IAM и KMS и обслуживает api.hanzo.ai. Его HTTP-поверхность — это zip, наш веб-фреймворк, родной для ZAP, обёрнутый вокруг fasthttp. Мы задали простой вопрос: насколько быстро это вообще может идти, и что его останавливает.
Честный ответ удивил нас трижды. Вот всё расследование, с цифрами. Всё публично в hanzoai/benchmarks.
Первое: фреймворк — это налог?
Нет. zip несёт постоянные ~24 наносекунды и одно выделение в 48 байт поверх сырого движка fiber/fasthttp, который он оборачивает, — меньше 1% на любом настоящем обработчике. От края до края, на loopback, zip делает 0.95× сырого fasthttp. Фреймворк — это шум. Хорошо. Дальше.
| фреймворк | запр./с |
|---|---|
| сырой fasthttp | 365–400k |
| zip (HTTP) | 338–348k |
Второе: распределённый результат стал медленнее
Вот здесь стало интересно. У нас две машины на прямом проводном канале 2.5 GbE: spark (NVIDIA GB10, 20 ядер) как сервер, evo (32-ядерный x86) как генератор нагрузки. Увести нагрузку с машины должно было освободить ядра spark для обслуживания — мы ждали, что станет выше.
Стало ниже. 194k req/sec. А когда мы добавили соединений, чтобы давить сильнее, стало хуже: 167k при 4,000 соединениях, 154k при 8,000, и задержка p99 выросла с 5 ms до 52 ms. Это учебный коллапс перегрузки.
Но канал был лишь на ~70 MB/s — 22% от 2.5 GbE. Не полоса. И 20 ядер spark почти простаивали. Не CPU. Тогда что?
Виновник: одна очередь приёма
ethtool -l выдал это: NIC работала с одной аппаратной очередью RX. Каждый входящий пакет обрабатывала softirq одного ядра — и это ядро было потолком. Хуже: драйвер наотрез отказался добавлять очереди (ethtool -L → "Operation not supported").
Так что самая быстрая машина разработчика, которая у нас есть, упиралась в один поток ядра, перекидывающий пакеты, пока девятнадцать ядер смотрели.
Исправление: разводить пакеты программно
Когда оборудование не хочет нескольких очередей, ядро хочет: Receive Packet Steering раскидывает потоки по ядрам программно. Мы включили RPS (и RFS/XPS) на всех 20 ядрах, подняли backlog приёма с 1,000 до 300,000, поставили сокетные буферы по 256 MiB и закрепили governor performance. Один идемпотентный tune.sh, запущенный на обеих машинах.
Тот же провод. Тот же обработчик. 518,000 req/sec — скачок в 2.67×, с пиком в 696k.
| конкурентность | без настройки | с настройкой |
|---|---|---|
| 1,000 | 194k | 518k |
| 4,000 | 154k | 474k (пик 696k) |
Главное: настроенное число сети (518k) теперь бьёт loopback (365–400k), потому что по проводу ядра spark обслуживают полное время, а не делят его с генератором нагрузки. На 518k машина простаивает лишь ~12%. Это близко к настоящему потолку одного генератора.
Третье: ZAP должен был быть быстрее — и мы его таким сделали
zip — родной для ZAP: голый адрес прослушивания говорит нашим двоичным транспортом; http:// говорит HTTP. Мы ждали, что двоичный транспорт выиграет, собрали клиент нагрузки ZAP (средства HTTP его не говорят) и измерили.
ZAP сделал 91k. HTTP сделал 481k. Родной транспорт проиграл — в 5×.
Мы погнались за этим, и дело не в проводе — дело в кодеке. Хвост задержки рассказал историю (p50 здоровые 303 µs, но p99 на 13 ms — подпись сборки мусора), и ослабление GOGC со 100 до 800 более чем удвоило ZAP до 203k. За шестисекундный прогон сервер ZAP запустил 2,203 сборки мусора; сервер HTTP — 57. Это несли три места выделения: маршалер чеканит свежий буфер кадра на вызов, разбор заголовков копирует каждую строку в карту, и — главное — сервер zaphttp выделял свежий контекст запроса на запрос, тогда как собственный сервер fasthttp складывает его в пул на соединение.
Поэтому мы это исправили. Изменение (zap-proto/http, ветка perf/zero-alloc-codec) механическое, не архитектурное: контекст запроса берётся из пула один раз на соединение, рукописный кодировщик кадра дописывает в буфер из пула, а 4-байтовый префикс длины сливается через bufio.Peek/Discard, чтобы массив заголовков не убегал в кучу. Цикл обслуживания сериализует через AppendResponse в этот переиспользуемый буфер и разбирает заголовки на месте — так что на прогретом соединении он не выделяет ничего на запрос — и золотые тесты подтверждают, что провод байт в байт тот же до и после. Чистая перепись кодека, без смены протокола.
Итог: ZAP идёт от 91k → 570k req/sec, подъём в 6.3×, который обгоняет 481k HTTP — теперь ZAP выигрывает в 1.18× на loopback, p99 падает с 13 ms до 1.19 ms, а GC — с 2,203 до 62.
Две вещи, в которых мне пришлось поправить себя, и обе переживают исправление, так что их стоит сказать прямо. Первое: выигрыш — это CPU, не провод. Кадр ZAP на самом деле больше HTTP (124 против 75 байт на запрос /health); ZAP выигрывает тем, что тратит меньше тактов на байт, а не меньше байт. Второе: zaphttp не пропускает JSON. Он несёт тело HTTP непрозрачными байтами на обоих транспортах, поэтому обработчик платит одну и ту же цену маршалинга в любом случае — на JSON-нагрузке формы чата ZAP на деле 0.95× HTTP, небольшая потеря из-за копирования тела в хвост кадра. Пропуск JSON — свойство родного типизированного RPC ZAP, отдельная ось, которую zaphttp не упражняет. Выигрыш на loopback здесь честный и чисто выигрыш CPU и выделений. А на настоящем проводе 2.5 GbE? Оба транспорта сходятся на ~530k — канал насыщен, и кодек больше не узкое место.
Четвёртое: ZAP и правда пропускает JSON?
Та последняя фраза — «пропуск JSON есть свойство родного ZAP, которое zaphttp не упражняет» — долговая расписка, и читатель должен заставить меня её оплатить. zaphttp несёт тело непрозрачным, поэтому никогда не проверяет то, в чём двоичные протоколы должны выигрывать: читать структурированные данные без разбора текста. Поэтому я построил тест, который это делает.
Одна общая запись — вызов формы eth_getBalance, четыре поля в каждую сторону (account, block, id → balance, nonce, blockHash) — прошла туда и обратно четырьмя путями: стандартная библиотека encoding/json, goccy/go-json и sonic (две самые быстрые JSON-библиотеки в Go) и типизированный двоичный формат zap-proto/go. Те же поля на входе, тот же расчёт, те же поля на выходе; тест эквивалентности круга требует перекодирования, точного до байта, чтобы ни одна сторона не пропускала работу, которую делает другая. И я держал JSON на его самой быстрой реализации, не на стандартной библиотеке, чтобы никто не мог отмахнуться от результата.
Декодирование — это бойня. Чтение запроса из четырёх полей:
| кодек | декодирование | выделения |
|---|---|---|
| JSON стандартной библиотеки | 2334 ns | 13 |
| JSON sonic | 845 ns | 8 |
| JSON goccy | 473 ns | 4 |
| типизированный ZAP | 13 ns | 0 |
Тринадцать наносекунд, ноль выделений — в 35× быстрее самого быстрого JSON, в 174× быстрее стандартной библиотеки. Разбора нет: поле лежит по известному смещению, вы его читаете. В этом весь смысл типизированного двоичного провода, и он держится.
Но я обещал сообщить и то, что ZAP не льстит, поэтому:
- Кодирование — ничья, и оно выделяет больше. ZAP сериализует за 629 ns с 12 выделениями против 426 ns / 4 у goccy. Опубликованный сборщик
zap-proto/gov1.3.0 откладывает хвосты переменной длины через копию, и его сборщик объектов убегает в кучу; пуловый API фиксированной ширины, который это чинит, не входит в выпущенный тег, так что это честное число. В сумме полный обработчик сервера (декодирование + расчёт + кодирование) всё же за ZAP — 274 ns против 431 у goccy — потому что выигрыш декодирования топит ничью кодирования. Но несёт это декодирование. - От края до края 174× сжимается до ~1.2×. На настоящем конце
/rpc: ZAP 535k req/s, sonic 483k (1.11×), goccy 452k (1.18×), стандартная библиотека 407k (1.31×). Реально, но далеко от разрыва микробенчмарка — потому что на полумиллионе запросов в секунду сокет и планировщик и есть цена, а сериализация — тонкий её срез. Кодек перестаёт быть узким местом задолго до того, как вы заметите, что он стал быстрее.
Сюрприз в пользу ZAP: на настоящей структурированной записи его провод меньше, не больше — 162 против 233 байт — потому что JSON тратит байты на шестнадцатеричные строки 0x, десятичные целые, кавычки и имена полей. Оговорка «ZAP больше», вышедшая из теста кадрирования, держится только для пустяковых нагрузок размера /health; дайте ему настоящие типизированные поля, и двоичный формат выигрывает и по размеру.
Итак, честно: пропускает ли ZAP JSON? Да — и на пути чтения это даже не близко. Почувствуете ли вы это, зависит от того, ваше ли узкое место — сериализация. На 500k rps крошечных записей — нет. Подставьте жирные вложенные нагрузки или веер, тяжёлый на декодировании, и эти 174× начнут проявляться там, где вы живёте.
Чему мы научились
- Мерьте весь путь и сомневайтесь в инструменте.
heyупёр нас в ~100k — это был клиент, не сервер.bombardierпоказал в 4× больше. - Машина редко бывает первым узким местом. Одна очередь RX у NIC стоила нам 2.67× до того, как мы тронули строку прикладного кода.
- Мерьте выделения, а не только пропускную способность. То, что ZAP «был медленным», отстояло на один флаг
GOGCот того, чтобы явно быть проблемой кодека — и как только мы сложили контекст в пул и обнулили выделения, родной транспорт обогнал HTTP. - Микробенчмарк и нагрузочный тест отвечают на разные вопросы. Типизированное декодирование в 174× быстрее JSON в изоляции и в 1.2× быстрее от края до края — оба числа правдивы, и процитировать только одно было бы ложью умолчанием.
- Сообщайте честно и по-настоящему. Тот же обработчик, прогретые соединения, никакого подобранного GOGC, JSON удержан на самой быстрой библиотеке, а не на самой медленной, скрипт опубликован. Когда честное число было проигрышем — ничья кодирования, сжатый запас от края до края — мы опубликовали и проигрыш.
Кодек ZAP без выделений готов, и он выигрывает. Дальше физика: загрузчик с двумя NIC (параллельные пути приёма к семизначным числам), более быстрое соединение (10 GbE / RDMA), разрез writev, чтобы хвост кадра ZAP пропускал одну копию тела на больших нагрузках, и постквантовые транспорты, которые Go 1.26 даёт нам нативно — PQ-TLS 1.3, ZAP-over-PQ-TLS и PQ-QUIC — где мы отделим цену рукопожатия ML-KEM от пропускной способности в установившемся режиме.
Мы можем довести это так далеко, как понадобится.
Читать далее

Hanzo DNS and Networking: Programmable Infrastructure
Launching Hanzo DNS and Networking — programmable DNS with service discovery and zero-trust virtual networks.

BitDelta: обслуживание 14 моделей Zen с одного GPU-кластера
Как 1-битное дельта-сжатие BitDelta позволяет обслуживать 14 вариантов моделей Zen на общей GPU-инфраструктуре: математика, архитектура и компромиссы.

От ИИ для коммерции к передовым моделям: десятилетие инфраструктуры Hanzo
Двенадцать лет строительства инфраструктуры ИИ — от рекомендательного движка реального времени в 2014 году до 94+ передовых моделей и постквантового блокчейна в 2026 году. Техническая история того, как мы сюда пришли.