Unisciti alla beta di Hanzo e ottieni il 50% di sconto su qualsiasi piano a pagamento

Mezzo milione di RPS su un GB10: il collo di bottiglia era la rete, non la macchina

Volevamo vedere quanto poteva andare veloce il piano dati del nostro cloud su una NVIDIA GB10. La risposta ci ha insegnato più sulle code di ricezione della NIC che sul nostro codice, e ha più che raddoppiato il throughput sullo stesso cavo.

In questa pagina

Costruiamo il nostro cloud su un solo binario Go: un artefatto che monta il gateway di IA, il commercio, IAM e KMS e serve api.hanzo.ai. La sua superficie HTTP è zip, il nostro framework web nativo di ZAP, avvolto su fasthttp. Ci siamo fatti una domanda semplice: quanto può andare veloce davvero questa cosa, e che cosa la ferma.

La risposta onesta ci ha sorpreso tre volte. Ecco tutta l'indagine, numeri compresi. Tutto è pubblico in hanzoai/benchmarks.

Primo: il framework è la tassa?

No. zip porta circa ~24 nanosecondi costanti e una sola allocazione di 48 byte sopra il motore fiber/fasthttp grezzo che avvolge: sotto l'1% su qualunque handler reale. Da capo a fondo, in loopback, zip fa 0.95× il fasthttp grezzo. Il framework è rumore. Bene. Avanti.

frameworkreq/s
fasthttp grezzo365–400k
zip (HTTP)338–348k

Secondo: il risultato distribuito è diventato più lento

Qui è diventato interessante. Abbiamo due macchine su un collegamento cablato diretto a 2.5 GbE: spark (una NVIDIA GB10, 20 core) come server, evo (x86 a 32 core) come generatore di carico. Spostare il carico fuori dalla macchina doveva liberare i core di spark per servire: ci aspettavamo che salisse.

È andato giù. 194k req/sec. E quando abbiamo aggiunto connessioni per spingere di più, è andato peggio: 167k a 4,000 connessioni, 154k a 8,000, con la latenza p99 che sale da 5 ms a 52 ms. È un collasso da congestione da manuale.

Ma il collegamento era solo a ~70 MB/s: il 22% di 2.5 GbE. Non la banda. E i 20 core di spark erano quasi fermi. Non la CPU. E allora?

Il colpevole: una sola coda di ricezione

ethtool -l lo ha rivelato: la NIC girava con una sola coda RX hardware. Ogni pacchetto in ingresso veniva elaborato dalla softirq di un core, e quel core era il tetto. Peggio: il driver ha rifiutato netto di aggiungere code (ethtool -L → "Operation not supported").

Così la macchina di sviluppo più veloce che abbiamo era strozzata da un solo thread del kernel che spalava pacchetti, mentre diciannove core guardavano.

La correzione: instradare i pacchetti in software

Quando l'hardware non vuole code multiple, il kernel sì: Receive Packet Steering ripartisce i flussi tra i core in software. Abbiamo acceso RPS (e RFS/XPS) su tutti i 20 core, alzato il backlog di ricezione da 1,000 a 300,000, messo buffer di socket da 256 MiB e fissato il governor performance. Un tune.sh idempotente, eseguito su entrambe le macchine.

Lo stesso cavo. Lo stesso handler. 518,000 req/sec: un salto di 2.67×, con un picco a 696k.

concorrenzasenza regolazioneregolato
1,000194k518k
4,000154k474k (picco 696k)

Il punto: il numero di rete regolato (518k) ora batte il loopback (365–400k), perché sul cavo i core di spark servono a tempo pieno invece di dividersi il generatore di carico. A 518k la macchina è inattiva solo per ~12%. È vicino al tetto reale di un solo generatore.

Terzo: ZAP doveva essere più veloce, e così lo abbiamo reso

zip è nativo di ZAP: un indirizzo di ascolto nudo parla il nostro trasporto binario; http:// parla HTTP. Ci aspettavamo che il trasporto binario vincesse, abbiamo costruito un client di carico ZAP (gli strumenti HTTP non lo parlano) e abbiamo misurato.

ZAP ha fatto 91k. HTTP ha fatto 481k. Il trasporto nativo ha perso, di 5×.

Lo abbiamo inseguito, e non è il cavo: è il codec. La coda di latenza raccontava la storia (p50 un sano 303 µs, ma p99 a 13 ms, firma di garbage collection), e allentare GOGC da 100 a 800 ha più che raddoppiato ZAP fino a 203k. In una corsa di sei secondi il server ZAP ha innescato 2,203 garbage collection; il server HTTP, 57. Tre siti di allocazione lo reggevano: il marshaler conia un buffer di frame nuovo per chiamata, la decodifica degli header copia ogni stringa in una mappa e, quello grosso, il server di zaphttp allocava un contesto di richiesta nuovo per richiesta dove il server di fasthttp lo mette in un pool per connessione.

Così lo abbiamo corretto. Il cambiamento (zap-proto/http, ramo perf/zero-alloc-codec) è meccanico, non architetturale: il contesto di richiesta va in pool una volta per connessione, un encoder di frame scritto a mano accoda in un buffer di pool, e il prefisso di lunghezza di 4 byte si drena tramite bufio.Peek/Discard così nessun array di header scappa sullo heap. Il ciclo di servizio serializza con AppendResponse in quel buffer riusato e decodifica gli header sul posto, quindi su una connessione calda non alloca niente per richiesta, e i test golden confermano che il cavo è identico byte per byte prima e dopo. Riscrittura pura del codec, senza cambio di protocollo.

Il risultato: ZAP passa da 91k → 570k req/sec, un salto di 6.3× che supera i 481k di HTTP: ZAP ora vince di 1.18× in loopback, con p99 che crolla da 13 ms a 1.19 ms e il GC che scende da 2,203 a 62.

Due cose su cui ho dovuto correggermi, ed entrambe sopravvivono alla correzione, quindi vale dirle chiaro. Primo, la vittoria è CPU, non il cavo. Il frame ZAP è in realtà più grande di HTTP (124 contro 75 byte per una richiesta /health); ZAP vince spendendo meno cicli per byte, non meno byte. Secondo, zaphttp non salta JSON. Porta il corpo HTTP come byte opachi su entrambi i trasporti, quindi un handler paga lo stesso costo di marshal in entrambi i casi: su un payload JSON a forma di chat, ZAP è di fatto 0.95× HTTP, una lieve perdita per aver copiato il corpo in coda al frame. L'elisione di JSON è una proprietà dell'RPC tipizzato di ZAP nativo, un asse distinto che zaphttp non esercita. La vittoria in loopback qui è onesta e puramente di CPU e di allocazioni. E sul vero cavo 2.5 GbE? Entrambi i trasporti pareggiano a ~530k: il collegamento è saturo e il codec non è più il collo di bottiglia.

Quarto: ZAP salta davvero JSON?

Quell'ultima frase — «l'elisione di JSON è una proprietà nativa di ZAP che zaphttp non esercita» — è un pagherò, e un lettore dovrebbe farmelo onorare. zaphttp porta il corpo opaco, quindi non mette mai alla prova ciò in cui i protocolli binari dovrebbero vincere: leggere dati strutturati senza analizzare testo. Così ho costruito il test che lo fa.

Un record condiviso — una chiamata a forma di eth_getBalance, quattro campi per verso (account, block, id → balance, nonce, blockHash) — ha fatto il giro di quattro modi: la libreria standard encoding/json, goccy/go-json e sonic (le due librerie JSON più veloci di Go) e il binario tipizzato di zap-proto/go. Gli stessi campi in ingresso, lo stesso calcolo, gli stessi campi in uscita; un test di equivalenza del giro impone una ricodifica esatta al byte così nessun lato salta il lavoro che fa l'altro. E ho tenuto JSON alla sua implementazione più veloce, non alla libreria standard, così nessuno può liquidare il risultato.

La decodifica è un massacro. Leggendo la richiesta a quattro campi:

codecdecodificaallocazioni
JSON libreria standard2334 ns13
JSON sonic845 ns8
JSON goccy473 ns4
ZAP tipizzato13 ns0

Tredici nanosecondi, zero allocazioni: 35× più veloce del JSON più veloce, 174× più veloce della libreria standard. Non c'è analisi: il campo sta a un offset noto, lo leggi. È tutta la tesi di un cavo binario tipizzato, e regge.

Ma avevo promesso di riportare le parti che non lusingano ZAP, quindi:

  • La codifica è un pari, e alloca di più. ZAP serializza in 629 ns con 12 allocazioni contro 426 ns / 4 di goccy. Il builder pubblicato di zap-proto/go v1.3.0 rimanda le code a lunghezza variabile tramite una copia e il suo builder di oggetti scappa sullo heap; l'API a larghezza fissa con pool che lo corregge non è nel tag pubblicato, quindi quello è il numero onesto. Al netto, l'handler completo del server (decodifica + calcolo + codifica) favorisce ZAP — 274 ns contro 431 di goccy — perché la vittoria della decodifica sommerge il pari della codifica. Ma è la decodifica a portarlo.
  • Da capo a fondo, il 174× si restringe a ~1.2×. Su un vero endpoint /rpc: ZAP 535k req/s, sonic 483k (1.11×), goccy 452k (1.18×), libreria standard 407k (1.31×). Reale, ma lontano dal divario del microbenchmark: a mezzo milione di richieste al secondo il socket e lo scheduler sono il costo, e la serializzazione ne è una fetta sottile. Il codec smette di essere il collo di bottiglia molto prima che si noti che è diventato più veloce.

Una sorpresa che gioca a favore di ZAP: su un vero record strutturato il suo cavo è più piccolo, non più grande — 162 contro 233 byte — perché JSON spende byte in stringhe esadecimali 0x, interi decimali, virgolette e nomi di campo. La riserva «ZAP è più grande», uscita dal test di framing, vale solo per payload banali della taglia di /health; dagli campi tipizzati veri e il binario vince anche sulla dimensione.

Quindi, onestamente: ZAP salta JSON? Sì, e sul percorso di lettura non è neanche vicino. Che tu lo senta dipende da se la serializzazione è il tuo collo di bottiglia. A 500k rps di record minuscoli, non lo è. Metti payload annidati pesanti o un fan-out carico di decodifica e quel 174× comincia a vedersi dove vivi.

Che cosa abbiamo imparato

  • Misura l'intero percorso, e metti in dubbio lo strumento. hey ci ha fermati a ~100k: era il client, non il server. bombardier ha rivelato 4× di più.
  • La macchina è raramente il primo collo di bottiglia. Una sola coda RX della NIC ci è costata 2.67× prima di toccare una riga di codice applicativo.
  • Misura le allocazioni, non solo il throughput. Che ZAP «fosse lento» era a un flag GOGC dall'essere, in modo evidente, un problema di codec; e una volta messo il contesto in un pool e azzerate le allocazioni, il trasporto nativo ha superato HTTP.
  • Un microbenchmark e un test di carico rispondono a domande diverse. La decodifica tipizzata è 174× più veloce di JSON da sola e 1.2× più veloce da capo a fondo: entrambi i numeri sono veri, e citarne uno solo sarebbe una bugia per omissione.
  • Raccontalo in modo giusto e reale. Lo stesso handler, connessioni calde, niente GOGC scelto a mano, JSON tenuto alla sua libreria più veloce e non alla più lenta, e lo script pubblicato. Quando il numero onesto era una perdita — il pari della codifica, il margine compresso da capo a fondo — abbiamo pubblicato anche la perdita.

Il codec ZAP a zero allocazioni è fatto, e vince. Ciò che resta è fisico: un generatore a doppia NIC (percorsi di ricezione paralleli verso le sette cifre), un'interconnessione più veloce (10 GbE / RDMA), uno split writev così la coda del frame ZAP salta una copia del corpo sui payload grandi, e i trasporti post-quantistici che Go 1.26 ci dà nativamente — PQ-TLS 1.3, ZAP-over-PQ-TLS e PQ-QUIC — dove separeremo il costo dell'handshake ML-KEM dal throughput a regime.

Possiamo spingerlo fin dove ci serve.

Commenti

Accedi con il tuo Hanzo ID per commentare.

Caricamento dei commenti…

Leggi di più