Le plancher de latence : une microseconde de travail dans dix de plomberie
Voici un nombre qui recadre la façon dont vous pensez un store clé/valeur. Quand nous isolons le store en mémoire de Zaris et lui envoyons des opérations sans rien d'autre sur le chemin — pas de socket, pas de sérialisation, pas de routage — un seul thread sert les lectures à plus d'un million par seconde, et monte proprement à plus de huit millions sur huit threads. Le store, autrement dit, fait le travail réel d'un GET en moins de deux microsecondes, et il n'est emphatiquement pas le goulet d'étranglement.
Et pourtant un GET complet sur une vraie connexion TCP, sur la même machine, plafonne à quelques centaines de milliers par seconde. Le store peut faire le travail cinq fois plus vite que le serveur ne peut le délivrer. La question de performance intéressante au sujet de Zaris n'est donc pas « à quelle vitesse va le store ? ». C'est l'inverse : où part tout le temps entre la socket et le store ? Cet article en est la réponse, tracée opération par opération, car l'écart entre 1,7 microseconde de logique et 10 à 13 microsecondes de CPU serveur par opération n'est pas du bruit — c'est le plancher de latence, et il est presque entièrement de la plomberie.
La mesure qui lance toute l'enquête
On ne peut pas optimiser un chemin qu'on n'a pas séparé en couches, donc la première chose que nous avons construite fut un ensemble de benchmarks d'ablation — des harnais qui dépouillent le système jusqu'à une couche à la fois.
- Store seul. Appeler directement le store en processus de la partition, en processus, sans réseau. Résultat : ~1,15M GET/s et ~0,5M PUT/s par thread, montant linéairement à ~8,2M / ~2,0M sur huit threads.
- Écho de socket nu. Une simple socket .NET faisant des trames de même forme, sans logique Zaris : ~7,2M trames/s sur la même machine.
- Chemin TCP natif complet. Le vrai client parlant au vrai serveur : ~300-324K ops/s, avec seulement environ trois cœurs logiques sur quatorze occupés de chaque côté.
Lisez ces trois résultats ensemble et deux suspects habituels s'écartent immédiatement. Le store n'est pas la limite — il est 3 à 4 fois plus rapide que le chemin complet. Le système d'exploitation et le loopback ne sont pas non plus la limite — une socket nue déplace des trames vingt fois plus vite. Ce qui reste au milieu, entre une socket qui peut faire des millions et un store qui peut faire des millions, c'est la plomberie de requête propre à Zaris, et elle dépense environ 10 à 13 microsecondes de CPU serveur par opération pour envelopper environ 1,7 microseconde de travail réel.
Ce ratio est le sujet. Suivons un GET à travers lui.
Le chemin d'un seul GET
Rien dans ce diagramme n'est faux ou gaspilleur au premier regard — c'est la forme honnête d'un store en réseau. Le coût est dans les détails de chaque flèche : combien de fois un thread doit se réveiller, combien de fois les octets sont copiés, et combien de travail fixe par requête voyage avec, sans aucun rapport avec la clé lue. Ces détails sont là où vivent les onze microsecondes, et ils se regroupent en trois ensembles.
Goulet d'étranglement un : la chaîne de sauts en sortie
Le plus gros coût n'est pas la lecture de la valeur — c'est sa livraison. Renvoyer une réponse au client, telle qu'actuellement structurée, traverse trois canaux internes, réveille trois threads et copie la charge utile quatre fois sur son chemin de sortie :
- La réponse est passée du shard worker au chemin d'envoi du pipeline serveur.
- Le chemin d'envoi la passe à une boucle d'écriture par connexion, qui l'encode dans un buffer poolé.
- La boucle d'écriture la met en file dans la file d'envoi de la connexion de transport.
- Le transport fusionne les buffers en file en un flux et écrit sur la socket.
Chaque transmission est un réveil de thread, et chaque réveil est un événement d'ordonnancement que l'OS doit traiter — bon marché individuellement, ruineux à des centaines de milliers par seconde. Et à chaque étape les octets sont copiés à nouveau : buffer d'encodage poolé, file d'envoi, buffer de fusion, socket. Côté entrée il y a un coût symétrique : chaque requête qui arrive sur un thread de complétion IOCP est transmise à un shard worker avant qu'aucun travail n'ait lieu, ce qui est un autre réveil et une autre file.
La raison pour laquelle cette structure existe est raisonnable — découpler réception, dispatch, encodage et transmission rend chaque couche plus simple à raisonner et laisse les consommateurs lents exercer une contre-pression sans bloquer la socket. Mais pour le cas courant — une lecture RF1 dont la réponse est déjà en mémoire sur le nœud qui a reçu la requête — c'est énormément plus de machinerie que le travail ne le mérite. La réponse pourrait, en principe, être calculée et réécrite sur le chemin de réception lui-même, avec une copie dans le buffer de socket et aucune transmission du tout.
C'est la première et la plus grosse pièce de travail structurel que la mesure désigne : réduire la sortie à un saut et une copie pour le chemin chaud, et exécuter la lecture/écriture RF1 en ligne sur le thread de réception plutôt que de la faire rebondir à travers le pool de workers. C'est la différence entre une conception facile à dessiner et une conception peu coûteuse à exécuter, et les deux ne sont pas la même chose.
Goulet d'étranglement deux : hachage redondant et un verrou par requête
Le deuxième ensemble est la surcharge fixe par opération — du travail qui a lieu à chaque requête quelle que soit la clé touchée, et qui pour l'essentiel ne devrait pas.
CRC16, trois fois, bit par bit. Zaris route les clés vers les partitions avec un hachage CRC16 — la même famille de hachage qu'utilise Redis Cluster, ce qui est comment le front-end RESP reste compatible slots. Le problème n'est pas le choix du hachage ; c'est que l'implémentation est bit-sérielle (en bouclant bit par bit plutôt qu'avec une table de correspondance) et qu'un seul GET le calcule trois fois lorsque la requête traverse le routage. À environ 260 nanosecondes par calcul, cela fait ~784 ns par GET passées à hacher — plus que tout l'aller-retour de décodage-plus-encodage MessagePack, qui tourne autour de 533 ns. Un CRC16 piloté par table calculé une seule fois et transporté ensuite transformerait le plus gros coût fixe en une erreur d'arrondi.
Une vérification de licence qui prend un verrou, à chaque requête. Plus surprenant : le point d'entrée de la requête consulte le service de licence à chaque opération, et cette consultation prend un verrou à l'échelle du processus et lit l'horloge murale (DateTime.UtcNow) pour décider si le bail courant est toujours valide. Un verrou sur le chemin chaud est exactement le genre de chose qui ne se montre pas dans un micro-benchmark mono-thread puis sérialise silencieusement tout en concurrence. Le verdict de licence change au plus quelques fois sur la vie d'un processus — quand un bail est accordé, renouvelé ou expire. Il n'a rien à faire à être recalculé sous un verrou à chaque GET. La correction consiste à publier le verdict dans un unique champ volatile qu'un renouvellement en arrière-plan met à jour, et à laisser le chemin chaud le lire sans verrou ni appel d'horloge. (La machinerie de bail elle-même fait l'objet de l'article sur le cycle de vie des licences ; le point ici est purement que lire le verdict doit être gratuit.)
Aucun de ces deux problèmes n'est un problème algorithmique profond. Ce sont le genre de coût qui s'accumule quand chaque pièce est écrite correctement en isolation et que personne n'a encore mesuré ce que la somme coûte sur le chemin qui s'exécute un million de fois par seconde.
Goulet d'étranglement trois : le client dépense autant que le serveur
Il serait commode de tout mettre sur le dos du serveur, mais l'ablation dit le contraire : les benchmarks locaux sont limités par le client, et épingler le client sur les cœurs performants seuls l'a fait passer de 169K à 299K ops/s. Le client dépense environ 9 microsecondes et 1,8 Ko par GET, et les coûts riment avec ceux du serveur :
- Les complétions rebondissent à travers le thread pool. Chaque requête en attente est suivie par un
TaskCompletionSourceconfiguré pour exécuter sa continuation de façon asynchrone, de sorte que lorsque la réponse arrive la complétion est postée sur le thread pool plutôt que reprise en ligne — un autre saut d'ordonnancement, à chaque opération. - Une variable d'environnement est lue à chaque opération. Le client appelle
Environment.GetEnvironmentVariable("ZARIS_REQUEST_TIMEOUT")par requête pour décider de son échéance. Lire une variable d'environnement parcourt le bloc d'environnement du processus ; le faire un million de fois par seconde est la plus grosse trame rédigée par Zaris côté client. Elle devrait être lue une fois au démarrage et mise en cache. - Un sémaphore et un verrou de canal par opération, plus des allocations par requête — un id client estampillé sur chaque requête, des métadonnées d'entrée demandées et renvoyées même quand l'appelant ne les a pas demandées.
Le fil conducteur avec le serveur est identique : le travail d'un GET est minuscule, et il est entouré de sauts d'ordonnancement, de verrous et d'allocations qui semblaient chacun gratuits en isolation. Le chemin chaud du client a déjà emprunté la bonne voie — le passage à ValueTask pour les opérations de base a supprimé une allocation par appel lors d'une complétion synchrone — et le reste de la liste est la même idée appliquée plus loin : mettre en cache la variable d'environnement, compléter sans le rebond sur le thread pool, recevoir sur la socket avec un ValueTask, et cesser d'allouer des choses que personne n'a demandées.
Alors, quel est le plancher, vraiment ?
Faites l'addition et l'image honnête est celle-ci. Sur un ordinateur portable, le chemin TCP natif plafonne autour de 300K ops/s non pas parce que le store est lent — il peut faire des millions — mais parce que chaque opération traîne 10 à 13 microsecondes de CPU serveur et ~9 microsecondes de CPU client à travers une chaîne de sauts, de copies, de hachages redondants et de verrous. Le plancher est fixé par la plomberie, pas par le travail.
Le corollaire encourageant est que le plancher est fait de choses réparables, et l'estimation de ce que leur réparation rapporte est concrète. La liste structurelle est :
- Réduire la sortie à un saut et une copie.
- Exécuter le chemin chaud RF1 en ligne sur le thread de réception, ou par lots à chaque réception.
- CRC16 piloté par table, haché une fois par opération.
- Complétions client sans allocation ni rebond sur le thread pool.
- Réception de socket fondée sur
ValueTaskcôté client. - Mettre en cache la variable d'environnement de timeout de requête plutôt que de la lire par opération.
- Publier le verdict de licence dans un champ
volatile— pas de verrou, pas d'horloge, sur le chemin chaud. - Un régime d'allocation général : réponses poolées, pas d'id client par requête, pas de métadonnées non demandées.
Ensemble, on estime que cela fait passer le CPU serveur par opération de ~10 à ~3-4 microsecondes — ce qui est la différence entre avoir besoin d'une douzaine de cœurs occupés pour un million d'ops/s et en avoir besoin d'environ quatre.
Les mises en garde honnêtes
Deux choses gardent cet article véridique plutôt que triomphant.
Premièrement, les nombres absolus ici sont des nombres d'ordinateur portable, et ils sont probablement 2 à 4 fois trop bas face à du vrai matériel serveur avec des machines client dédiées. Ce qui se transfère entre environnements n'est pas le débit absolu — c'est l'attribution relative : le store n'est pas le goulet d'étranglement, la sortie est le plus gros coût serveur, et le client dépense autant que le serveur. Ces conclusions sont indépendantes de la machine ; le chiffre final d'ops/s ne l'est pas, et la vraie validation se fait à l'échelle sur Kubernetes, pas sur un poste de travail.
Deuxièmement, la réplication change le plafond. Tout ce qui précède est l'histoire RF1 — une seule copie, le cas rapide. Un PUT RF2 doit répliquer durablement vers une sauvegarde avant de pouvoir accuser réception, et c'est un chemin véritablement différent et plus coûteux ; une cible réaliste pour lui est de 400-500K par nœud, pas un million. Et quand vous n'avez pas besoin d'allers-retours par opération du tout, le chemin par lots amortit presque tout ce coût fixe sur de nombreuses opérations et peut atteindre un million par nœud dès aujourd'hui. Le chiffre d'un million d'ops par nœud unique pour des opérations individuelles est une cible que le travail structurel ci-dessus est censé atteindre — pas un nombre que nous citons comme livré.
Cette distinction est toute la discipline d'un travail de performance digne de confiance : séparer ce que la mesure prouve (le store est rapide, la plomberie est le plancher, et voici où part chaque microseconde) de ce qu'elle promet (un nombre précis après une liste précise de changements). La première est faite et solide. Le second est de l'ingénierie que nous pouvons désormais faire avec une carte au lieu d'une supposition — ce qui est toute la raison pour laquelle on instrumente le chemin avant d'y toucher.