Meio milhão de RPS em um GB10: o gargalo era a rede, não a máquina
Saímos para ver até onde o plano de dados da nossa nuvem conseguia ir em uma NVIDIA GB10. A resposta nos ensinou mais sobre as filas de recepção da NIC do que sobre o nosso próprio código, e mais do que dobrou a vazão no mesmo fio.

Nesta página
Construímos nossa nuvem sobre um único binário Go: um artefato que monta o gateway de IA, o comércio, IAM e KMS e serve api.hanzo.ai. A superfície HTTP dele é zip, nosso framework web nativo de ZAP, embrulhado sobre fasthttp. Fizemos uma pergunta simples: o quão rápido isso consegue ir de verdade, e o que o impede.
A resposta honesta nos surpreendeu três vezes. Aqui está a investigação inteira, com os números. Tudo é público em hanzoai/benchmarks.
Primeiro: o framework é o imposto?
Não. zip carrega cerca de ~24 nanossegundos constantes e uma única alocação de 48 bytes sobre o motor fiber/fasthttp cru que ele embrulha: menos de 1% em qualquer handler real. De ponta a ponta, em loopback, zip faz 0.95× o fasthttp cru. O framework é ruído. Bom. Seguimos.
| framework | req/s |
|---|---|
| fasthttp cru | 365–400k |
| zip (HTTP) | 338–348k |
Segundo: o resultado distribuído ficou mais lento
Aqui ficou interessante. Temos duas máquinas num link cabeado direto de 2.5 GbE: spark (uma NVIDIA GB10, 20 núcleos) como servidor, evo (x86 de 32 núcleos) como gerador de carga. Tirar a carga da máquina devia liberar os núcleos de spark para atender; esperávamos que subisse.
Foi para baixo. 194k req/sec. E quando somamos conexões para empurrar mais, ficou pior: 167k a 4,000 conexões, 154k a 8,000, com a latência p99 subindo de 5 ms para 52 ms. Isso é um colapso de congestionamento de manual.
Mas o link estava só em ~70 MB/s — 22% de 2.5 GbE. Não era banda. E os 20 núcleos de spark estavam quase ociosos. Não era CPU. Então o quê?
O culpado: uma única fila de recepção
ethtool -l entregou: a NIC rodava com uma única fila RX de hardware. Cada pacote de entrada era processado pela softirq de um núcleo, e esse núcleo era o teto. Pior: o driver se recusou na hora a adicionar filas (ethtool -L → "Operation not supported").
Assim, a máquina de desenvolvimento mais rápida que temos estava limitada por uma única thread do kernel empurrando pacotes, enquanto dezenove núcleos assistiam.
O conserto: direcionar os pacotes em software
Quando o hardware não quer várias filas, o kernel quer: Receive Packet Steering reparte os fluxos entre os núcleos em software. Ligamos RPS (e RFS/XPS) nos 20 núcleos, subimos o backlog de recepção de 1,000 para 300,000, pusemos buffers de socket de 256 MiB e fixamos o governor performance. Um tune.sh idempotente, rodado nas duas máquinas.
O mesmo fio. O mesmo handler. 518,000 req/sec — um salto de 2.67×, com pico em 696k.
| concorrência | sem ajuste | ajustado |
|---|---|---|
| 1,000 | 194k | 518k |
| 4,000 | 154k | 474k (pico 696k) |
O detalhe: o número de rede ajustado (518k) agora bate o loopback (365–400k), porque no fio os núcleos de spark atendem em tempo integral em vez de dividir com o gerador de carga. A 518k a máquina fica ociosa só ~12%. Isso está perto do teto real de um único gerador.
Terceiro: o ZAP devia ser mais rápido — então fizemos ele ser
zip é nativo de ZAP: um endereço de escuta nu fala nosso transporte binário; http:// fala HTTP. Esperávamos que o transporte binário ganhasse, construímos um cliente de carga ZAP (as ferramentas HTTP não conseguem falar com ele) e medimos.
O ZAP fez 91k. O HTTP fez 481k. O transporte nativo perdeu — por 5×.
Fomos atrás, e não é o fio: é o codec. A cauda de latência contava a história (p50 uns saudáveis 303 µs, mas p99 em 13 ms — assinatura de coleta de lixo), e relaxar GOGC de 100 para 800 mais do que dobrou o ZAP para 203k. Numa corrida de seis segundos o servidor ZAP disparou 2,203 coletas de lixo; o servidor HTTP, 57. Três pontos de alocação carregavam isso: o marshaler cunha um buffer de frame novo por chamada, a decodificação de cabeçalhos copia cada string para um mapa e — o grande — o servidor de zaphttp alocava um contexto de request novo por request onde o próprio servidor de fasthttp o guarda num pool por conexão.
Então corrigimos. A mudança (zap-proto/http, branch perf/zero-alloc-codec) é mecânica, não de arquitetura: o contexto do request entra num pool uma vez por conexão, um encoder de frame feito à mão acrescenta num buffer de pool, e o prefixo de comprimento de 4 bytes é drenado por bufio.Peek/Discard para que nenhum array de cabeçalho escape para o heap. O loop de serve serializa com AppendResponse nesse buffer reutilizado e decodifica os cabeçalhos no lugar — então, numa conexão quente, não aloca nada por request — e os testes golden confirmam que o fio é idêntico byte a byte antes e depois. Reescrita pura do codec, sem mudança de protocolo.
O resultado: o ZAP vai de 91k → 570k req/sec, um ganho de 6.3× que ultrapassa os 481k do HTTP — o ZAP agora ganha por 1.18× em loopback, com p99 caindo de 13 ms para 1.19 ms e o GC caindo de 2,203 para 62.
Duas coisas em que precisei me corrigir — e as duas sobrevivem ao conserto, então vale dizê-las com clareza. Primeiro, a vitória é CPU, não o fio. O frame ZAP é na verdade maior que o HTTP (124 contra 75 bytes num request /health); o ZAP ganha gastando menos ciclos por byte, não menos bytes. Segundo, zaphttp não pula JSON. Ele carrega o corpo HTTP como bytes opacos nos dois transportes, então um handler paga o mesmo custo de marshal dos dois lados — num payload JSON com forma de chat, o ZAP é de fato 0.95× HTTP, uma perda leve por copiar o corpo para a cauda do frame. A elisão de JSON é uma propriedade do RPC tipado de ZAP nativo, um eixo separado que zaphttp não exercita. A vitória em loopback aqui é honesta e puramente de CPU e de alocação. E no fio real de 2.5 GbE? Os dois transportes empatam em ~530k — o link está saturado e o codec já não é o gargalo.
Quarto: o ZAP realmente pula JSON?
Aquela última frase — «a elisão de JSON é uma propriedade nativa de ZAP que zaphttp não exercita» — é uma nota promissória, e um leitor devia me fazer pagá-la. zaphttp carrega o corpo opaco, então nunca testa aquilo em que os protocolos binários deviam ganhar: ler dados estruturados sem analisar texto. Então construí o teste que faz isso.
Um registro compartilhado — uma chamada no formato de eth_getBalance, quatro campos em cada sentido (account, block, id → balance, nonce, blockHash) — fez a ida e volta de quatro jeitos: a biblioteca padrão encoding/json, goccy/go-json e sonic (as duas bibliotecas JSON mais rápidas do Go) e o binário tipado de zap-proto/go. Os mesmos campos na entrada, o mesmo cálculo, os mesmos campos na saída; um teste de equivalência de ida e volta exige uma recodificação exata ao byte para que nenhum lado pule trabalho que o outro faz. E segurei o JSON na implementação mais rápida, não na biblioteca padrão, para que ninguém possa dispensar o resultado.
A decodificação é um massacre. Lendo o request de quatro campos:
| codec | decodificação | alocações |
|---|---|---|
| JSON biblioteca padrão | 2334 ns | 13 |
| JSON sonic | 845 ns | 8 |
| JSON goccy | 473 ns | 4 |
| ZAP tipado | 13 ns | 0 |
Treze nanossegundos, zero alocações — 35× mais rápido que o JSON mais rápido, 174× mais rápido que a biblioteca padrão. Não há análise: o campo fica num offset conhecido, você lê. Essa é a proposta inteira de um fio binário tipado, e ela se sustenta.
Mas prometi relatar as partes que não favorecem o ZAP, então:
- A codificação é um empate, e aloca mais. O ZAP serializa em 629 ns com 12 alocações contra 426 ns / 4 do goccy. O builder publicado de
zap-proto/gov1.3.0 adia as caudas de comprimento variável por uma cópia e o builder de objetos escapa para o heap; a API de largura fixa com pool que corrige isso não está na tag publicada, então esse é o número honesto. No líquido, o handler completo do servidor (decodificar + computar + codificar) favorece o ZAP — 274 ns contra 431 do goccy — porque a vitória da decodificação abafa o empate da codificação. Mas quem carrega é a decodificação. - De ponta a ponta, o 174× encolhe para ~1.2×. Num endpoint
/rpcreal: ZAP 535k req/s, sonic 483k (1.11×), goccy 452k (1.18×), biblioteca padrão 407k (1.31×). Real, mas longe da lacuna do microbenchmark — porque a meio milhão de requests por segundo o socket e o scheduler são o custo, e a serialização é uma fatia fina disso. O codec deixa de ser o gargalo muito antes de você notar que ele ficou mais rápido.
Uma surpresa que joga a favor do ZAP: num registro estruturado de verdade o fio dele é menor, não maior — 162 contra 233 bytes — porque JSON gasta bytes em strings hex 0x, inteiros decimais, aspas e nomes de campo. A ressalva de que «ZAP é maior», saída do teste de framing, só vale para payloads triviais do tamanho de /health; dê campos tipados de verdade e o binário ganha no tamanho também.
Então, com honestidade: o ZAP pula JSON? Sim — e no caminho de leitura não é nem perto. Se você sente isso depende de a serialização ser o seu gargalo. A 500k rps de registros minúsculos, não é. Troque por payloads aninhados gordos ou um fan-out pesado em decodificação e esse 174× começa a aparecer onde você vive.
O que aprendemos
- Meça o caminho inteiro e questione a ferramenta.
heynos limitou a ~100k — era o cliente, não o servidor.bombardierrevelou 4× mais. - A máquina raramente é o primeiro gargalo. Uma única fila RX da NIC nos custou 2.67× antes de tocarmos uma linha de código de aplicação.
- Meça as alocações, não só a vazão. O ZAP «estar lento» estava a um flag
GOGCde ser, com clareza, um problema de codec — e quando colocamos o contexto num pool e zeramos as alocações, o transporte nativo ultrapassou o HTTP. - Um microbenchmark e um teste de carga respondem perguntas diferentes. A decodificação tipada é 174× mais rápida que JSON isolada e 1.2× mais rápida de ponta a ponta — os dois números são verdade, e citar só um seria uma mentira por omissão.
- Relate de forma justa e real. O mesmo handler, conexões quentes, sem GOGC escolhido a dedo, JSON preso à biblioteca mais rápida e não à mais lenta, e o script publicado. Quando o número honesto era uma perda — o empate da codificação, a margem comprimida de ponta a ponta — publicamos a perda também.
O codec ZAP de alocação zero está pronto, e ele ganha. O que resta é físico: um gerador de NIC dupla (caminhos de recepção paralelos rumo a sete dígitos), interconexão mais rápida (10 GbE / RDMA), um split writev para a cauda do frame ZAP pular uma cópia do corpo em payloads grandes, e os transportes pós-quânticos que o Go 1.26 nos dá nativamente — PQ-TLS 1.3, ZAP-over-PQ-TLS e PQ-QUIC — onde vamos separar o custo do handshake ML-KEM da vazão em regime permanente.
Podemos levar isso até onde precisarmos.
Leia mais

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

BitDelta: servir 14 modelos Zen a partir de um único cluster de GPU
Como a compressão delta de 1 bit do BitDelta permite servir 14 variantes de modelos Zen a partir de infraestrutura de GPU compartilhada: a matemática, a arquitetura e os tradeoffs.

Da IA comercial aos modelos de fronteira: a década de infraestrutura da Hanzo
Doze anos construindo infraestrutura de IA — de um motor de recomendação em tempo real em 2014 a 94+ modelos de fronteira e uma blockchain pós-quântica em 2026. A história técnica de como chegamos até aqui.