Medio millón de RPS en un GB10: el cuello de botella fue la red, no la máquina
Quisimos ver qué tan rápido podía ir el plano de datos de nuestra nube en una NVIDIA GB10. La respuesta nos enseñó más de las colas de recepción de la NIC que de nuestro propio código, y más que duplicó el throughput sobre el mismo cable.

En esta página
Armamos nuestra nube sobre un solo binario de Go: un artefacto que monta el gateway de IA, el comercio, IAM y KMS, y sirve api.hanzo.ai. Su superficie HTTP es zip, nuestro framework web nativo de ZAP, envuelto sobre fasthttp. Nos hicimos una pregunta simple: qué tan rápido puede ir esto de verdad, y qué lo detiene.
La respuesta honesta nos sorprendió tres veces. Acá va toda la historia, con los números. Todo está público en hanzoai/benchmarks.
Primero: ¿el framework es el costo?
No. zip carga unos ~24 nanosegundos constantes y una sola asignación de 48 bytes encima del motor fiber/fasthttp crudo que envuelve: menos del 1% en cualquier handler real. De punta a punta, en loopback, zip hace 0.95× del fasthttp crudo. El framework es ruido. Bien. Sigamos.
| framework | req/s |
|---|---|
| fasthttp crudo | 365–400k |
| zip (HTTP) | 338–348k |
Segundo: el resultado distribuido se puso más lento
Ahí se puso interesante. Tenemos dos máquinas en un link cableado directo de 2.5 GbE: spark (una NVIDIA GB10, 20 núcleos) como servidor y evo (x86 de 32 núcleos) como generador de carga. Sacar la carga de la máquina tenía que liberar los núcleos de spark para atender; esperábamos que subiera.
Se fue para abajo. 194k req/sec. Y cuando sumamos conexiones para empujar más fuerte, se puso peor: 167k a 4,000 conexiones, 154k a 8,000, con la latencia p99 subiendo de 5 ms a 52 ms. Eso es un colapso de congestión de manual.
Pero el link apenas iba a ~70 MB/s, el 22% de 2.5 GbE. No era ancho de banda. Y los 20 núcleos de spark estaban casi ociosos. No era la CPU. ¿Entonces qué?
El culpable: una sola cola de recepción
ethtool -l lo delató: la NIC corría con una sola cola RX de hardware. Cada paquete de entrada lo procesaba la softirq de un núcleo, y ese núcleo era el techo. Peor: el driver se negó de plano a agregar colas (ethtool -L → "Operation not supported").
Así que la máquina de desarrollo más rápida que tenemos estaba ahogada por un solo hilo del kernel paleando paquetes, mientras diecinueve núcleos miraban.
El arreglo: encaminar los paquetes en software
Cuando el hardware no hace varias colas, el kernel sí: Receive Packet Steering reparte los flujos entre núcleos en software. Prendimos RPS (y RFS/XPS) en los 20 núcleos, subimos el backlog de recepción de 1,000 a 300,000, pusimos búferes de socket de 256 MiB y fijamos el governor performance. Un tune.sh idempotente, corrido en las dos máquinas.
El mismo cable. El mismo handler. 518,000 req/sec, un salto de 2.67×, con pico en 696k.
| concurrencia | sin afinar | afinado |
|---|---|---|
| 1,000 | 194k | 518k |
| 4,000 | 154k | 474k (máximo 696k) |
Lo que cierra: el número de red afinado (518k) ahora le gana al loopback (365–400k), porque sobre el cable los núcleos de spark atienden a tiempo completo en vez de compartir con el generador de carga. A 518k la máquina solo está ociosa un ~12%. Eso queda cerca del techo real de un solo generador.
Tercero: ZAP tenía que ser más rápido, así que lo hicimos
zip es nativo de ZAP: una dirección de escucha pelada habla nuestro transporte binario; http:// habla HTTP. Esperábamos que el transporte binario ganara, armamos un cliente de carga ZAP (las herramientas de HTTP no lo hablan) y medimos.
ZAP hizo 91k. HTTP hizo 481k. El transporte nativo perdió, por 5×.
Lo seguimos, y no es el cable: es el codec. La cola de latencia contaba la historia (p50 un sano 303 µs, pero p99 en 13 ms, firma de recolección de basura), y aflojar GOGC de 100 a 800 más que duplicó ZAP a 203k. En una corrida de seis segundos el servidor ZAP disparó 2,203 recolecciones de basura; el servidor HTTP, 57. Tres puntos de asignación lo cargaban: el marshaler crea un búfer de frame nuevo por llamada, la decodificación de encabezados copia cada string a un mapa y, el grande, el servidor de zaphttp asignaba un contexto de solicitud nuevo por solicitud donde el servidor de fasthttp lo pone en un pool por conexión.
Así que lo corregimos. El cambio (zap-proto/http, rama perf/zero-alloc-codec) es mecánico, no de arquitectura: el contexto de la solicitud entra a un pool una vez por conexión, un encoder de frames hecho a mano agrega sobre un búfer del pool, y el prefijo de longitud de 4 bytes se drena con bufio.Peek/Discard para que ningún arreglo de encabezados se escape al heap. El loop de servicio serializa con AppendResponse en ese búfer reutilizado y decodifica los encabezados en el lugar, así que sobre una conexión caliente no asigna nada por solicitud, y las pruebas golden confirman que el cable es idéntico byte por byte antes y después. Reescritura pura del codec, sin cambio de protocolo.
El resultado: ZAP pasa de 91k → 570k req/sec, un salto de 6.3× que supera los 481k de HTTP: ZAP ahora gana por 1.18× en loopback, con p99 cayendo de 13 ms a 1.19 ms y el GC bajando de 2,203 a 62.
Hay dos cosas en las que me tuve que corregir, y las dos sobreviven al arreglo, así que vale decirlas claro. Primero, la ganancia es de CPU, no del cable. El frame de ZAP es en realidad más grande que el de HTTP (124 contra 75 bytes en una solicitud /health); ZAP gana gastando menos ciclos por byte, no menos bytes. Segundo, zaphttp no se salta JSON. Carga el cuerpo HTTP como bytes opacos en los dos transportes, así que un handler paga el mismo costo de marshal de cualquier lado: con un payload JSON con forma de chat, ZAP es de hecho 0.95× HTTP, una pérdida pequeña por copiar el cuerpo al final del frame. Saltarse el JSON es una propiedad del RPC tipado de ZAP nativo, un eje aparte que zaphttp no ejercita. La ganancia en loopback acá es honesta y puramente de CPU y de asignaciones. ¿Y sobre el cable real de 2.5 GbE? Los dos transportes empatan en ~530k: el link está saturado y el codec ya no es el cuello de botella.
Cuarto: ¿ZAP de verdad se salta JSON?
Esa última línea — «saltarse el JSON es una propiedad nativa de ZAP que zaphttp no ejercita» — es un pagaré, y quien lee debería hacerme cobrarlo. zaphttp carga el cuerpo opaco, así que nunca prueba lo que se supone que los protocolos binarios ganan: leer datos estructurados sin parsear texto. Así que armé la prueba que sí lo hace.
Un registro compartido — una llamada con forma de eth_getBalance, cuatro campos para cada lado (account, block, id → balance, nonce, blockHash) — hizo el viaje de ida y vuelta de cuatro formas: la biblioteca estándar encoding/json, goccy/go-json y sonic (las dos bibliotecas de JSON más rápidas de Go) y el binario tipado de zap-proto/go. Los mismos campos de entrada, el mismo cómputo, los mismos campos de salida; una prueba de equivalencia de ida y vuelta exige una recodificación exacta al byte para que ningún lado se salte trabajo que el otro hace. Y dejé JSON en su implementación más rápida, no en la biblioteca estándar, para que nadie pueda hacer a un lado el resultado.
La decodificación es una masacre. Al leer la solicitud de cuatro campos:
| codec | decodificación | asignaciones |
|---|---|---|
| JSON stdlib | 2334 ns | 13 |
| JSON sonic | 845 ns | 8 |
| JSON goccy | 473 ns | 4 |
| ZAP tipado | 13 ns | 0 |
Trece nanosegundos, cero asignaciones: 35× más rápido que el JSON más rápido, 174× más rápido que la biblioteca estándar. No hay parseo: el campo está en un offset conocido y se lee. Esa es toda la propuesta de un cable binario tipado, y se sostiene.
Pero prometí reportar las partes que no favorecen a ZAP, así que:
- La codificación es un empate, y asigna más. ZAP serializa en 629 ns con 12 asignaciones contra 426 ns / 4 de goccy. El builder publicado de
zap-proto/gov1.3.0 deja las colas de largo variable para una copia y su builder de objetos se escapa al heap; la API de ancho fijo con pool que lo arregla no está en el tag publicado, así que ese es el número honesto. En neto, el handler completo del servidor (decodificar + computar + codificar) sí favorece a ZAP — 274 ns contra 431 de goccy — porque la ganancia de la decodificación tapa el empate de la codificación. Pero lo carga la decodificación. - De punta a punta, el 174× se achica a ~1.2×. Sobre un endpoint
/rpcreal: ZAP 535k req/s, sonic 483k (1.11×), goccy 452k (1.18×), la biblioteca estándar 407k (1.31×). Real, pero lejos de la brecha del microbenchmark: a medio millón de solicitudes por segundo el socket y el scheduler son el costo, y la serialización es una tajada fina. El codec deja de ser el cuello de botella mucho antes de que se note que se puso más rápido.
Una sorpresa que juega a favor de ZAP: en un registro estructurado de verdad su cable es más chico, no más grande — 162 contra 233 bytes — porque JSON gasta bytes en strings hex 0x, enteros decimales, comillas y nombres de campo. La advertencia de que «ZAP es más grande», salida de la prueba de framing, solo vale para payloads triviales del tamaño de /health; denle campos tipados de verdad y el binario gana también en tamaño.
Entonces, con honestidad: ¿ZAP se salta JSON? Sí, y en el camino de lectura no está cerca. Que lo sientan depende de si la serialización es su cuello de botella. A 500k rps de registros muy chicos, no lo es. Metan payloads anidados pesados o un fan-out cargado de decodificación y ese 174× empieza a aparecer donde viven.
Qué aprendimos
- Midan el camino completo y cuestionen la herramienta.
heynos topó en ~100k: era el cliente, no el servidor.bombardiermostró 4× más. - La máquina casi nunca es el primer cuello de botella. Una sola cola RX de la NIC nos costó 2.67× antes de tocar una línea de código de aplicación.
- Midan las asignaciones, no solo el throughput. Que ZAP «fuera lento» estaba a una bandera
GOGCde ser, a las claras, un problema de codec; y cuando metimos el contexto en un pool y llevamos las asignaciones a cero, el transporte nativo le pasó a HTTP. - Un microbenchmark y una prueba de carga responden preguntas distintas. La decodificación tipada es 174× más rápida que JSON en aislamiento y 1.2× más rápida de punta a punta: los dos números son ciertos, y citar solo uno sería una mentira por omisión.
- Repórtenlo justo y real. El mismo handler, conexiones calientes, sin un GOGC elegido a mano, JSON en su biblioteca más rápida y no en la más lenta, y el script publicado. Cuando el número honesto era una pérdida — el empate de la codificación, el margen comprimido de punta a punta — también publicamos la pérdida.
El codec ZAP de cero asignaciones está listo, y gana. Lo que queda es físico: un generador de doble NIC (caminos de recepción en paralelo hacia las siete cifras), interconexión más rápida (10 GbE / RDMA), un reparto writev para que la cola del frame de ZAP se salte una copia del cuerpo en payloads grandes, y los transportes poscuánticos que Go 1.26 nos da nativos — PQ-TLS 1.3, ZAP-over-PQ-TLS y PQ-QUIC — donde vamos a separar el costo del handshake ML-KEM del throughput en estado estable.
Podemos llevar esto tan lejos como haga falta.
Leer más

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

BitDelta: cómo servir 14 modelos Zen desde un solo cluster de GPU
Cómo la compresión de deltas a 1 bit de BitDelta nos deja servir 14 variantes de modelos Zen sobre infraestructura de GPU compartida: la matemática, la arquitectura y los tradeoffs.

Del comercio con IA a los modelos de frontera: una década de infraestructura de Hanzo
Doce años armando infraestructura de IA — de un motor de recomendaciones en tiempo real en 2014 a 94+ modelos de frontera y una blockchain post-cuántica en 2026. La historia técnica de cómo llegamos hasta acá.