Присоединяйтесь к бете Hanzo и получите скидку 50% на любой платный тариф

Полмиллиона 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. Фреймворк — это шум. Хорошо. Дальше.

фреймворкзапр./с
сырой fasthttp365–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,000194k518k
4,000154k474k (пик 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 ns13
JSON sonic845 ns8
JSON goccy473 ns4
типизированный ZAP13 ns0

Тринадцать наносекунд, ноль выделений — в 35× быстрее самого быстрого JSON, в 174× быстрее стандартной библиотеки. Разбора нет: поле лежит по известному смещению, вы его читаете. В этом весь смысл типизированного двоичного провода, и он держится.

Но я обещал сообщить и то, что ZAP не льстит, поэтому:

  • Кодирование — ничья, и оно выделяет больше. ZAP сериализует за 629 ns с 12 выделениями против 426 ns / 4 у goccy. Опубликованный сборщик zap-proto/go v1.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 ID, чтобы комментировать.

Загрузка комментариев…

Читать далее