Rejoignez la bêta Hanzo et obtenez 50 % de réduction sur toute offre payante

Un demi-million de RPS sur un GB10 : le réseau était le goulot, pas la machine

Nous avons voulu voir jusqu'où le plan de données de notre cloud pouvait aller sur une NVIDIA GB10. La réponse nous en a plus appris sur les files de réception de la NIC que sur notre propre code, et elle a plus que doublé notre débit sur le même câble.

Sur cette page

Nous construisons notre cloud sur un seul binaire Go : un artefact qui monte la passerelle d'IA, le commerce, IAM et KMS, et qui sert api.hanzo.ai. Sa surface HTTP est zip, notre framework web natif de ZAP, enveloppé sur fasthttp. Nous nous sommes posé une question simple : à quelle vitesse cela peut-il vraiment aller, et qu'est-ce qui l'arrête.

La réponse honnête nous a surpris trois fois. Voici toute l'enquête, chiffres compris. Tout est public dans hanzoai/benchmarks.

D'abord : le framework est-il la taxe ?

Non. zip ajoute environ ~24 nanosecondes constantes et une seule allocation de 48 octets au-dessus du moteur fiber/fasthttp brut qu'il enveloppe : moins de 1 % sur n'importe quel handler réel. De bout en bout, en loopback, zip fait 0.95× le fasthttp brut. Le framework est du bruit. Bien. On avance.

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

Ensuite : le résultat distribué est devenu plus lent

C'est là que cela devient intéressant. Nous avons deux machines sur un lien filaire direct à 2.5 GbE : spark (une NVIDIA GB10, 20 cœurs) comme serveur, evo (x86 à 32 cœurs) comme générateur de charge. Sortir la charge de la machine devait libérer les cœurs de spark pour servir ; nous nous attendions à ce que cela monte.

C'est allé vers le bas. 194k req/sec. Et quand nous avons ajouté des connexions pour pousser plus fort, c'est devenu pire : 167k à 4,000 connexions, 154k à 8,000, avec une latence p99 qui passe de 5 ms à 52 ms. C'est un effondrement de congestion de manuel.

Mais le lien n'était qu'à ~70 MB/s, soit 22 % de 2.5 GbE. Pas la bande passante. Et les 20 cœurs de spark étaient presque au repos. Pas le CPU. Alors, quoi ?

Le coupable : une seule file de réception

ethtool -l a tout donné : la NIC tournait avec une seule file RX matérielle. Chaque paquet entrant était traité par la softirq d'un cœur, et ce cœur était le plafond. Pire : le pilote a refusé net d'ajouter des files (ethtool -L → "Operation not supported").

Ainsi, la machine de développement la plus rapide que nous ayons était bridée par un seul thread du noyau qui pellettait des paquets, pendant que dix-neuf cœurs regardaient.

Le correctif : orienter les paquets en logiciel

Quand le matériel ne veut pas de files multiples, le noyau, lui, le veut : Receive Packet Steering répartit les flux entre les cœurs en logiciel. Nous avons activé RPS (et RFS/XPS) sur les 20 cœurs, relevé le backlog de réception de 1,000 à 300,000, posé des tampons de socket de 256 MiB et épinglé le governor performance. Un tune.sh idempotent, lancé sur les deux machines.

Le même câble. Le même handler. 518,000 req/sec, un bond de 2.67×, avec un pic à 696k.

concurrencesans réglageréglé
1,000194k518k
4,000154k474k (pic 696k)

Le détail qui compte : le chiffre réseau réglé (518k) bat maintenant le loopback (365–400k), parce que sur le câble les cœurs de spark servent à plein temps au lieu de se partager avec le générateur de charge. À 518k la machine n'est oisive que d'environ ~12 %. C'est proche du vrai plafond d'un seul générateur.

Troisièmement : ZAP devait être plus rapide, alors nous l'avons rendu plus rapide

zip est natif de ZAP : une adresse d'écoute nue parle notre transport binaire ; http:// parle HTTP. Nous nous attendions à ce que le transport binaire gagne, nous avons construit un client de charge ZAP (les outils HTTP ne le parlent pas) et nous avons mesuré.

ZAP a fait 91k. HTTP a fait 481k. Le transport natif a perdu, par 5×.

Nous l'avons poursuivi, et ce n'est pas le câble : c'est le codec. La queue de latence racontait l'histoire (p50 un sain 303 µs, mais p99 à 13 ms, signature d'un ramasse-miettes), et relâcher GOGC de 100 à 800 a plus que doublé ZAP jusqu'à 203k. Sur une exécution de six secondes, le serveur ZAP a déclenché 2,203 collectes ; le serveur HTTP, 57. Trois sites d'allocation le portaient : le marshaler fabrique un tampon de trame neuf à chaque appel, le décodage des en-têtes copie chaque chaîne dans une map, et, le gros, le serveur de zaphttp allouait un contexte de requête neuf par requête là où le serveur de fasthttp le met en pool par connexion.

Alors nous l'avons corrigé. Le changement (zap-proto/http, branche perf/zero-alloc-codec) est mécanique, pas architectural : le contexte de requête est mis en pool une fois par connexion, un encodeur de trames écrit à la main ajoute dans un tampon de pool, et le préfixe de longueur de 4 octets est drainé par bufio.Peek/Discard pour qu'aucun tableau d'en-têtes n'échappe vers le tas. La boucle de service sérialise avec AppendResponse dans ce tampon réutilisé et décode les en-têtes sur place, si bien que sur une connexion chaude elle n'alloue rien par requête, et les tests d'or confirment que le câble est identique octet pour octet avant et après. Réécriture pure du codec, sans changement de protocole.

Le résultat : ZAP passe de 91k → 570k req/sec, un gain de 6.3× qui dépasse les 481k de HTTP : ZAP gagne maintenant de 1.18× en loopback, avec p99 qui s'effondre de 13 ms à 1.19 ms et le GC qui tombe de 2,203 à 62.

Deux choses sur lesquelles j'ai dû me corriger, et les deux survivent au correctif, autant les dire clairement. D'abord, le gain est le CPU, pas le câble. La trame ZAP est en fait plus grande que celle de HTTP (124 contre 75 octets pour une requête /health) ; ZAP gagne en dépensant moins de cycles par octet, pas moins d'octets. Ensuite, zaphttp ne saute pas JSON. Il porte le corps HTTP comme des octets opaques sur les deux transports, donc un handler paie le même coût de marshal dans les deux cas : sur une charge JSON en forme de chat, ZAP fait en fait 0.95× HTTP, une légère perte due à la copie du corps dans la queue de la trame. L'élision de JSON est une propriété du RPC typé ZAP natif, un axe distinct que zaphttp n'exerce pas. Le gain en loopback ici est honnête et purement un gain de CPU et d'allocations. Et sur le vrai câble 2.5 GbE ? Les deux transports font match nul à ~530k : le lien est saturé et le codec n'est plus le goulot.

Quatrièmement : est-ce que ZAP saute vraiment JSON ?

Cette dernière phrase — « l'élision de JSON est une propriété native de ZAP que zaphttp n'exerce pas » — est une reconnaissance de dette, et un lecteur devrait me la faire honorer. zaphttp porte le corps opaque, donc il ne teste jamais ce que les protocoles binaires sont censés gagner : lire des données structurées sans analyser du texte. J'ai donc construit le test qui le fait.

Un enregistrement partagé — un appel en forme de eth_getBalance, quatre champs dans chaque sens (account, block, id → balance, nonce, blockHash) — a fait l'aller-retour de quatre façons : la bibliothèque standard encoding/json, goccy/go-json et sonic (les deux bibliothèques JSON les plus rapides de Go), et le binaire typé de zap-proto/go. Les mêmes champs en entrée, le même calcul, les mêmes champs en sortie ; un test d'équivalence d'aller-retour impose un réencodage exact à l'octet pour qu'aucun côté ne saute le travail que l'autre fait. Et j'ai tenu JSON à son implémentation la plus rapide, pas à la bibliothèque standard, pour que personne ne puisse écarter le résultat.

Le décodage est un massacre. À la lecture de la requête à quatre champs :

codecdécodageallocations
JSON bibliothèque standard2334 ns13
JSON sonic845 ns8
JSON goccy473 ns4
ZAP typé13 ns0

Treize nanosecondes, zéro allocation : 35× plus rapide que le JSON le plus rapide, 174× plus rapide que la bibliothèque standard. Il n'y a pas d'analyse : le champ est à un décalage connu, on le lit. C'est tout l'argument d'un câble binaire typé, et il tient.

Mais j'avais promis de rapporter les parties qui ne flattent pas ZAP, alors :

  • L'encodage est un match nul, et il alloue plus. ZAP sérialise en 629 ns avec 12 allocations contre 426 ns / 4 pour goccy. Le constructeur publié de zap-proto/go v1.3.0 diffère les queues de longueur variable par une copie et son constructeur d'objets s'échappe vers le tas ; l'API à largeur fixe et pool qui le corrige n'est pas dans le tag publié, donc c'est le chiffre honnête. Au net, le handler complet du serveur (décoder + calculer + encoder) favorise bien ZAP — 274 ns contre 431 pour goccy — parce que le gain du décodage noie le match nul de l'encodage. Mais c'est le décodage qui porte.
  • De bout en bout, le 174× se réduit à ~1.2×. Sur un vrai point d'accès /rpc : ZAP 535k req/s, sonic 483k (1.11×), goccy 452k (1.18×), bibliothèque standard 407k (1.31×). Réel, mais loin de l'écart du microbenchmark : à un demi-million de requêtes par seconde, le socket et l'ordonnanceur sont le coût, et la sérialisation en est une fine tranche. Le codec cesse d'être le goulot bien avant qu'on remarque qu'il est devenu plus rapide.

Une surprise qui joue pour ZAP : sur un vrai enregistrement structuré, son câble est plus petit, pas plus grand — 162 contre 233 octets — parce que JSON dépense des octets en chaînes hexadécimales 0x, en entiers décimaux, en guillemets et en noms de champs. La réserve « ZAP est plus gros », sortie du test de trame, ne vaut que pour des charges triviales de la taille de /health ; donnez-lui de vrais champs typés et le binaire gagne aussi en taille.

Alors, honnêtement : est-ce que ZAP saute JSON ? Oui, et sur le chemin de lecture ce n'est pas serré. Que vous le sentiez dépend de si la sérialisation est votre goulot. À 500k rps de tout petits enregistrements, elle ne l'est pas. Glissez des charges imbriquées épaisses ou un éventail lourd en décodage, et ce 174× commence à se voir là où vous vivez.

Ce que nous avons appris

  • Mesurez le chemin entier, et interrogez l'outil. hey nous a plafonnés à ~100k : c'était le client, pas le serveur. bombardier a révélé 4× de plus.
  • La machine est rarement le premier goulot. Une seule file RX de NIC nous a coûté 2.67× avant que nous touchions une ligne de code applicatif.
  • Mesurez les allocations, pas seulement le débit. Que ZAP « soit lent » tenait à un drapeau GOGC d'être, à l'évidence, un problème de codec ; et une fois le contexte mis en pool et les allocations ramenées à zéro, le transport natif a dépassé HTTP.
  • Un microbenchmark et un test de charge répondent à des questions différentes. Le décodage typé est 174× plus rapide que JSON isolément et 1.2× plus rapide de bout en bout : les deux chiffres sont vrais, et n'en citer qu'un serait un mensonge par omission.
  • Rapportez-le juste et réel. Le même handler, des connexions chaudes, pas de GOGC trié sur le volet, JSON tenu à sa bibliothèque la plus rapide et non à la plus lente, et le script publié. Quand le chiffre honnête était une perte — le match nul de l'encodage, la marge comprimée de bout en bout — nous avons publié la perte aussi.

Le codec ZAP sans allocation est fait, et il gagne. Ce qui reste est physique : un générateur à double NIC (des chemins de réception parallèles vers les sept chiffres), une interconnexion plus rapide (10 GbE / RDMA), une découpe writev pour que la queue de trame de ZAP saute une copie du corps sur les grosses charges, et les transports post-quantiques que Go 1.26 nous donne nativement — PQ-TLS 1.3, ZAP-over-PQ-TLS et PQ-QUIC — où nous séparerons le coût de la poignée de main ML-KEM du débit en régime établi.

Nous pouvons aller aussi loin qu'il le faut.

Commentaires

Connectez-vous avec votre identifiant Hanzo pour commenter.

Chargement des commentaires…

Lire la suite