Le plafond du worker pool : quand un nœud plafonne à 25 % de CPU
Voici un résultat de benchmark qui ressemble à une contradiction. Un nœud Zaris servant une charge majoritairement en lecture plafonne à un peu moins d'un demi-million d'ops/s et refuse de grimper — ajoutez plus de connexions client, plus de générateurs de charge, et le nombre ne bouge pas. Le diagnostic évident est « il est saturé ». Sauf que le graphe de CPU dit le contraire : le nœud est posé à environ un quart de son CPU disponible. Trois cœurs sur quatre sont inactifs, et le débit est plat.
Ce n'est pas un nœud limité par les cœurs, et — si vous l'avez écarté avec soin — ce n'est pas non plus un benchmark limité par le client. C'est une troisième chose, et c'est un classique : le worker pool de dispatch des requêtes était plus étroit que le travail ne le voulait, donc les requêtes faisaient la queue pendant que les cœurs se tournaient les pouces.
D'abord, écartez les suspects habituels
Deux explications doivent partir avant que celle-ci ne soit même sur la table, car elles sont bien plus courantes.
Limité par les cœurs. Si le nœud est à ou près de 100 % de CPU et que le débit est plat, vous êtes véritablement à court de CPU — la réponse est plus de cœurs ou plus de nœuds, et Zaris met à l'échelle les lectures en ajoutant des cœurs. Ce n'est pas ce cas : ici le CPU est inactif.
Limité par le client. Un seul générateur de charge sature bien avant le serveur, et alors vous mesurez votre client, pas Zaris. Si le débit est plat quand vous ajoutez des cœurs serveur, suspectez d'abord votre client — ajoutez des générateurs et des connexions jusqu'à ce que le nombre côté serveur réponde réellement. Nous avons couvert ce piège dans l'article sur les lectures, et c'est celui à éliminer avant d'aller plus loin.
Ce qui reste après ces deux-là est le cas intéressant : assez de charge offerte pour garder le nœud occupé, beaucoup de marge de CPU, et un débit toujours plat. Quand le goulet d'étranglement est à l'intérieur du serveur mais que ce n'est pas le CPU, la chose à regarder est la concurrence — combien de requêtes le nœud traitera en même temps.
Le pool de dispatch est la limite de concurrence
Chaque requête qui arrive à un nœud Zaris est remise à un worker pool de dispatch de requêtes. Un worker la ramasse, la route vers la partition qui possède la clé, exécute l'opération contre le store en processus de cette partition, et réécrit la réponse. Pour une lecture, l'opération elle-même est une recherche en mémoire à l'échelle de la microseconde — véritablement minuscule.
La largeur de ce pool — combien de workers peuvent être en pleine requête simultanément — fixe un plafond dur sur les requêtes en vol, et c'est un nombre différent de « à quelle vitesse va une opération ». La valeur par défaut d'origine dérivait la largeur d'un petit multiple de Environment.ProcessorCount. Sur un nœud à 4 vCPU, un pool à quelques fois les cœurs n'est qu'une poignée de workers.
Voici la subtilité qui fait paraître le graphe de CPU faux. Si chaque worker passait toute sa tranche à brûler du CPU sur la recherche, un pool dimensionné aux cœurs serait exactement juste et le nœud atteindrait 100 % de CPU. Mais les workers ne passent pas toute leur tranche sur le CPU. Un worker se met occasionnellement en pause — une continuation await, une écriture de socket qui ne se termine pas de façon synchrone, un bref verrou sur une structure partagée. Un worker en pause ne brûle aucun CPU, mais il garde quand même son emplacement de pool. Avec seulement une poignée d'emplacements, quelques workers en pause suffisent à bloquer tout le nœud : les nouvelles requêtes font la queue derrière eux, et parce que les workers occupés attendent surtout plutôt que de calculer, les cœurs ne s'allument jamais. Débit plat, CPU inactif. La largeur du pool était le plafond, pas le silicium.
L'indice qui distingue cela d'un benchmark limité par le client : les requêtes font la queue au serveur (la latence grimpe, le débit est plat) pendant que le CPU serveur reste bas. Le cas limité par le client a l'air différent — le serveur est inactif parce que rien n'arrive assez vite. Ici, le travail arrive ; le nœud refuse juste d'en prendre assez à la fois.
La correction : dimensionner le pool pour le travail, pas pour le nombre de cœurs
La réparation n'est pas exotique. Les lectures sont des recherches en mémoire à la microseconde, donc le pool devrait être fixé large par défaut — assez large pour qu'une poignée de workers en pause ne puisse pas affamer le nœud — et il devrait être réglable, car la bonne largeur dépend du nœud et de la charge, pas d'un multiple fixe du nombre de cœurs.
La largeur de pool par défaut est désormais nombre de CPU × 16, bornée dans une plage saine de 64 à 1024, avec un plancher de max(4, nombre de CPU × 4). Deux variables d'environnement la remplacent :
| Variable | Valeur par défaut | Ce qu'elle fait |
|---|---|---|
ZARIS_SERVER_MAX_WORKERS | nombre de CPU × 16 (borné 64–1024) | Remplacement dur (1–4096) pour la largeur du pool de dispatch. Relevez-la quand un nœud a du CPU en réserve et que les requêtes font la queue. |
ZARIS_SERVER_MIN_WORKERS | max(4, nombre de CPU × 4) | Borne inférieure (1–4096) sur la largeur du pool. |
# A node with spare CPU and requests queuing — widen the pool.
ZARIS_SERVER_MAX_WORKERS=512 ZARIS_SERVER_MIN_WORKERS=64 \
dotnet Clustron.Zaris.Server.dll
Une propriété qui mérite d'être soulignée : c'est un changement côté serveur et rien d'autre. Le protocole de fil ne change pas, le SDK client ne change pas, et il n'y a pas de bump NuGet à poursuivre — vous réglez un nœud en le redémarrant avec un environnement différent, et chaque client existant continue de lui parler sans changement.
Réglez-le honnêtement — élargir n'est pas gratuit
Le réflexe après avoir lu ceci est de pousser ZARIS_SERVER_MAX_WORKERS au plafond sur chaque nœud. Ne le faites pas. Un pool plus large aide dans exactement une situation et ne fait rien — ou nuit légèrement — dans les autres.
- Il aide quand les requêtes font la queue pendant que le CPU est inactif. C'est le cas de tout cet article : la concurrence en vol est la limite, donc en admettre davantage remplit les cœurs. C'est là que vous verrez le débit bondir.
- Il ne fait rien quand vous êtes déjà limité par le CPU. Si le nœud est à 100 % de CPU, plus de workers ne feront pas surgir du débit de nulle part — ils ajoutent juste de la surcharge de changement de contexte à une machine qui n'a aucun cycle en réserve. Plus de workers n'est pas plus de calcul.
- Il peut nuire si vous le poussez à une largeur absurde. Des milliers de workers malmenant un nœud à 2 vCPU passent du vrai temps à changer de contexte au lieu de servir. C'est pourquoi il y a une borne, et pourquoi le bouton plafonne à 4096 plutôt qu'à l'infini.
La boucle de réglage honnête est : guetter la signature — latence qui grimpe et débit plat pendant que le CPU est bien en dessous de la saturation — relever MAX_WORKERS, et mesurer. Si le CPU grimpe et que le débit suit, le pool était votre plafond. Si rien ne bouge, ce n'était pas lui, et vous devriez remettre le bouton et chercher ailleurs (le chemin réseau, le client, ou un besoin véritable de plus de nœuds).
Mesurez le CPU là où c'est réellement vrai
Toute cette enquête repose sur la confiance dans le nombre de CPU, et ce nombre est facile à obtenir faux.
- Lisez le CPU depuis le cgroup, pas depuis une estimation de metrics-server de cluster. Sur Kubernetes surtout, le chiffre du metrics-server est en retard et lissé — il vous dira qu'un nœud est à 40 % alors que la comptabilité du cgroup dit qu'il est saturé, ou l'inverse. Diagnostiquez un cas « débit plat, CPU inactif » depuis le cgroup, ou vous poursuivrez un nombre qui n'est pas réel.
- Les benchmarks locaux sont limités par l'hôte et cacheront le plafond. Sur un ordinateur portable, le générateur de charge et le serveur se disputent les mêmes cœurs, donc vous ne présentez jamais au serveur une charge propre et suffisante — le plateau que vous mesurez est votre banc d'essai, pas Zaris. Le vrai plafond du worker pool est apparu à l'échelle sur un cluster managé avec la charge pilotée depuis des machines séparées, pas sur une machine de développeur. Validez le débit là où le client ne peut pas être le goulet d'étranglement.
À retenir
Une courbe de débit plate a trois causes très différentes, et le graphe de CPU vous dit laquelle vous regardez. À 100 % de CPU, vous êtes limité par les cœurs — ajoutez des nœuds. Avec le serveur inactif parce que rien n'arrive assez vite, vous êtes limité par le client — corrigez le test. Mais un nœud plat au quart de son CPU, avec des requêtes qui font visiblement la queue, n'est ni l'un ni l'autre : c'est une limite de concurrence, et sur Zaris c'est le worker pool de dispatch. La valeur par défaut est désormais assez large pour que la plupart des charges ne le rencontrent jamais, et ZARIS_SERVER_MAX_WORKERS est là pour celles qui le rencontrent — à relever délibérément, quand le graphe de CPU l'a mérité.
Pour la surface complète des variables d'environnement et les valeurs par défaut exactes, voir la référence de configuration. Si vous dimensionnez un cluster pour le débit, le guide de benchmarking parcourt les réglages qui font réellement bouger les nombres, et Des lectures qui montent avec vos cœurs couvre l'autre moitié de l'histoire — pourquoi, une fois le pool hors du chemin, ajouter des cœurs ajoute de la capacité de lecture au lieu de buter contre un mur à boucle d'événements unique.