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

En esta página
Construimos nuestra nube sobre un único binario de Go: un artefacto que monta la pasarela 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 sencilla: a qué velocidad puede ir esto de verdad, y qué lo frena.
La respuesta sincera nos ha sorprendido tres veces. Aquí está toda la investigación, con sus cifras. Todo es público en hanzoai/benchmarks.
Primero: ¿el framework es el peaje?
No. zip añade unos ~24 nanosegundos constantes y una única asignación de 48 bytes sobre el motor fiber/fasthttp en bruto al que envuelve: menos del 1% en cualquier handler real. De extremo a extremo, en loopback, zip hace el 0.95× del fasthttp en bruto. El framework es ruido. Bien. Sigamos.
| framework | pet./s |
|---|---|
| fasthttp en bruto | 365–400k |
| zip (HTTP) | 338–348k |
Segundo: el resultado distribuido se volvió más lento
Aquí se puso interesante. Tenemos dos equipos en un enlace cableado directo de 2.5 GbE: spark (una NVIDIA GB10, 20 núcleos) como servidor, evo (x86 de 32 núcleos) como generador de carga. Sacar la carga del equipo debía liberar los núcleos de spark para servir; esperábamos que subiera.
Fue hacia abajo. 194k req/sec. Y cuando añadimos conexiones para apretar más, se puso peor: 167k con 4,000 conexiones, 154k con 8,000, con la latencia p99 subiendo de 5 ms a 52 ms. Eso es un colapso por congestión de libro.
Pero el enlace solo iba a ~70 MB/s: el 22% de 2.5 GbE. No era el ancho de banda. Y los 20 núcleos de spark estaban casi en reposo. 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 entrante lo procesaba la softirq de un núcleo, y ese núcleo era el techo. Peor: el controlador se negó en redondo a añadir colas (ethtool -L → "Operation not supported").
Así, el equipo de desarrollo más rápido que tenemos estaba limitado por un único hilo del kernel paleando paquetes, mientras diecinueve núcleos miraban.
El arreglo: dirigir los paquetes por software
Cuando el hardware no quiere varias colas, el kernel sí: Receive Packet Steering reparte los flujos entre núcleos por software. Hemos encendido RPS (y RFS/XPS) en los 20 núcleos, hemos subido el backlog de recepción de 1,000 a 300,000, hemos puesto búferes de socket de 256 MiB y hemos fijado el governor performance. Un tune.sh idempotente, ejecutado en los dos equipos.
El mismo cable. El mismo handler. 518,000 req/sec: un salto de 2.67×, con un pico de 696k.
| concurrencia | sin ajuste | ajustado |
|---|---|---|
| 1,000 | 194k | 518k |
| 4,000 | 154k | 474k (pico 696k) |
La puntilla: la cifra de red ajustada (518k) ahora supera al loopback (365–400k), porque por el cable los núcleos de spark sirven a tiempo completo en lugar de repartirse con el generador de carga. A 518k el equipo solo está ocioso un ~12%. Eso está 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 a pelo habla nuestro transporte binario; http:// habla HTTP. Esperábamos que ganara el transporte binario, construimos un cliente de carga ZAP (las herramientas HTTP no lo hablan) y medimos.
ZAP hizo 91k. HTTP hizo 481k. El transporte nativo perdió, por 5×.
Lo perseguimos, y no es el cable: es el códec. La cola de latencia contaba la historia (p50 un sano 303 µs, pero p99 a 13 ms: firma de recolección de basura), y relajar GOGC de 100 a 800 más que duplicó ZAP hasta 203k. En una ejecución de seis segundos el servidor ZAP disparó 2,203 recolecciones de basura; el servidor HTTP, 57. Tres sitios de asignación lo sostenían: el serializador acuña un búfer de trama nuevo por llamada, la descodificación de cabeceras copia cada cadena a un mapa y, el grande, el servidor de zaphttp asignaba un contexto de petición nuevo por petición donde el servidor de fasthttp lo guarda en un pool por conexión.
Así que lo arreglamos. El cambio (zap-proto/http, rama perf/zero-alloc-codec) es mecánico, no de arquitectura: el contexto de petición se guarda en un pool una vez por conexión, un codificador de tramas escrito a mano añade sobre un búfer de pool, y el prefijo de longitud de 4 bytes se drena por bufio.Peek/Discard para que ninguna matriz de cabeceras escape al heap. El bucle de servicio serializa con AppendResponse en ese búfer reutilizado y descodifica las cabeceras in situ, de modo que sobre una conexión en caliente no asigna nada por petición, y las pruebas de oro confirman que el cable es idéntico byte a byte antes y después. Reescritura pura del códec, 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 gana ahora por 1.18× en loopback, con p99 desplomándose de 13 ms a 1.19 ms y la GC bajando de 2,203 a 62.
Dos cosas en las que tuve que corregirme, y las dos sobreviven al arreglo, así que conviene decirlas claro. Primero, la victoria es de CPU, no del cable. La trama ZAP es en realidad más grande que la de HTTP (124 frente a 75 bytes en una petición /health); ZAP gana gastando menos ciclos por byte, no menos bytes. Segundo, zaphttp no se salta JSON. Lleva el cuerpo HTTP como bytes opacos en los dos transportes, así que un handler paga el mismo coste de serialización en ambos: con una carga JSON con forma de chat, ZAP es de hecho 0.95× HTTP, una pérdida leve por copiar el cuerpo al final de la trama. La elisión de JSON es una propiedad del RPC tipado de ZAP nativo, un eje distinto que zaphttp no ejercita. La victoria en loopback aquí es honesta y puramente de CPU y de asignaciones. ¿Y sobre el cable real de 2.5 GbE? Los dos transportes empatan a ~530k: el enlace está saturado y el códec ya no es el cuello de botella.
Cuarto: ¿ZAP de verdad se salta JSON?
Esa última frase — «la elisión de JSON es una propiedad nativa de ZAP que zaphttp no ejercita» — es un pagaré, y quien lee debería hacerme cobrarlo. zaphttp lleva el cuerpo opaco, así que nunca pone a prueba lo que se supone que los protocolos binarios ganan: leer datos estructurados sin analizar texto. Así que construí la prueba que sí lo hace.
Un registro compartido — una llamada con forma de eth_getBalance, cuatro campos en cada sentido (account, block, id → balance, nonce, blockHash) — hizo el viaje de ida y vuelta de cuatro maneras: la biblioteca estándar encoding/json, goccy/go-json y sonic (las dos bibliotecas 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 sujeté JSON a su implementación más rápida, no a la biblioteca estándar, para que nadie pueda desentenderse del resultado.
La descodificación es una masacre. Al leer la petición de cuatro campos:
| códec | descodificación | asignaciones |
|---|---|---|
| JSON estándar | 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 análisis: el campo está en un desplazamiento conocido y se lee. Esa es toda la propuesta de un cable binario tipado, y se sostiene.
Pero prometí contar 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 frente a 426 ns / 4 de goccy. El constructor publicado de
zap-proto/gov1.3.0 aplaza las colas de longitud variable mediante una copia y su constructor de objetos escapa al heap; la API de ancho fijo con pool que lo arregla no está en la etiqueta publicada, así que ese es el número honesto. En neto, el handler completo del servidor (descodificar + computar + codificar) sí favorece a ZAP — 274 ns frente a 431 de goccy — porque la victoria de la descodificación ahoga el empate de la codificación. Pero la lleva la descodificación. - De extremo a extremo, el 174× se encoge a ~1.2×. Sobre un extremo
/rpcreal: ZAP 535k req/s, sonic 483k (1.11×), goccy 452k (1.18×), la biblioteca estándar 407k (1.31×). Real, pero ni de lejos la brecha del microbenchmark: a medio millón de peticiones por segundo el socket y el planificador son el coste, y la serialización es una loncha fina. El códec deja de ser el cuello de botella mucho antes de que se note que se ha vuelto más rápido.
Una sorpresa que juega a favor de ZAP: en un registro estructurado de verdad su cable es más pequeño, no más grande — 162 frente a 233 bytes — porque JSON gasta bytes en cadenas hexadecimales 0x, enteros decimales, comillas y nombres de campo. La salvedad de que «ZAP es más grande», salida de la prueba de entramado, solo vale para cargas triviales del tamaño de /health; dadle campos tipados de verdad y el binario gana también en tamaño.
Así que, con honestidad: ¿ZAP se salta JSON? Sí, y en el camino de lectura no hay color. Que lo notéis depende de si la serialización es vuestro cuello de botella. A 500k rps de registros minúsculos, no lo es. Meted cargas anidadas gordas o un abanico pesado en descodificación y ese 174× empieza a aparecer donde vivís.
Qué hemos aprendido
- Medid el camino entero y cuestionad la herramienta.
heynos topó en ~100k: era el cliente, no el servidor.bombardierreveló 4× más. - El equipo 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.
- Medid las asignaciones, no solo el caudal. Que ZAP «fuera lento» estaba a una bandera
GOGCde ser, a todas luces, un problema de códec; y en cuanto metimos el contexto en un pool y anulamos las asignaciones, el transporte nativo adelantó a HTTP. - Un microbenchmark y una prueba de carga responden a preguntas distintas. La descodificación tipada es 174× más rápida que JSON aislada y 1.2× más rápida de extremo a extremo: las dos cifras son verdad, y citar solo una sería una mentira por omisión.
- Contadlo justo y real. El mismo handler, conexiones en caliente, sin un GOGC elegido a dedo, JSON sujeto a su biblioteca más rápida y no a 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 extremo a extremo — también publicamos la pérdida.
El códec ZAP sin asignaciones está hecho, y gana. Lo que queda es físico: un generador de doble NIC (caminos de recepción en paralelo hacia las siete cifras), una interconexión más rápida (10 GbE / RDMA), un reparto writev para que el final de la trama ZAP se salte una copia del cuerpo en cargas grandes, y los transportes poscuánticos que Go 1.26 nos da de forma nativa — PQ-TLS 1.3, ZAP-over-PQ-TLS y PQ-QUIC — donde separaremos el coste del apretón de manos ML-KEM del caudal en régimen 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: servir 14 modelos Zen desde un solo clúster de GPU
Cómo la compresión delta de 1 bit de BitDelta nos permite servir 14 variantes de modelos Zen desde una infraestructura de GPU compartida: las matemáticas, la arquitectura y las contrapartidas.

De la IA de comercio a los modelos de frontera: la década de infraestructura de Hanzo
Doce años construyendo infraestructura de IA — de un motor de recomendaciones en tiempo real en 2014 a 94+ modelos de frontera y una cadena de bloques poscuántica en 2026. La historia técnica de cómo hemos llegado hasta aquí.