domaine : règles de faible latence
Une mesure indique si elle a été réalisée à chaud ou à froid, et correspond à la production [R:une-mesure-correspond-au-régime-qu'elle-rapporte]
Écrit pour les publics Rust et Java.
Écrit à partir de JMH et de ses exemples (Aleksey Shipilev), dont l'existence est l'affirmation qu'un microbenchmark fait à la main sur la JVM est erroné par défaut.
Indiquez dans quel régime la mesure a été effectuée et vérifiez qu'il s'agit du régime dans lequel la production s'exécute. Deux erreurs se cachent ici, et une seule d'entre elles a une réputation.
Mesurer à froid alors que la production est à chaud. Une première exécution se déroule en interprété ou depuis un cache d'instructions froid, avec un prédicteur de branche non entraîné, des tables de pages non peuplées et, sur un runtime managé, aucun code compilé. Le chiffre peut être un à deux ordres de grandeur pire que l'état stable, et il décrit l'échauffement plutôt que le travail. Sur la JVM, l'escalade est progressive -- interpréteur, puis C1, puis C2, avec remplacement sur la pile pour les boucles longues -- et prend des milliers d'itérations, c'est pourquoi une boucle de chronométrage faite à la main est erronée par défaut et pourquoi un harnais existe.
Mesurer à chaud alors que la production est à froid, ce qui est la moitié qui ne reçoit aucune attention car elle ressemble à de la rigueur. Un chemin qui s'exécute une fois par minute n'est jamais chaud. Une rafale d'ouverture de marché arrive dans du code froid. Un gestionnaire d'erreur s'exécute pour la première fois de la journée au pire moment possible. Pour chacun de ceux-ci, le chiffre à chaud est un nombre qu'aucun utilisateur ne verra jamais, et l'échauffement qui a été soigneusement écarté est le comportement de production. L'écarter n'est pas une hygiène là-bas ; c'est supprimer la mesure.
La règle est donc une correspondance de portée plutôt qu'une procédure : dites quel régime a été mesuré, dites dans quel régime se trouve la production, et montrez qu'ils sont identiques. Là où la production est véritablement les deux -- un flux régulier avec des rafales à froid -- cela donne deux mesures et deux chiffres rapportés, pas un seul moyenné jusqu'à l'insignifiance.
C'est [R:mesurer-la-prétention-pas-un-sous-ensemble] appliqué au temps plutôt qu'à un ensemble : les échantillons d'échauffement sont un sous-ensemble, les écarter est une décision de portée, et une décision de portée prise par réflexe est celle qui fabrique un mauvais nombre avec l'autorité d'avoir été mesuré.
Vérification du mode de défaillance : le chemin de production est-il à chaud ou à froid au moment où cela compte, et qu'ai-je mesuré ? Si le benchmark a écarté les N premières itérations, nommez ce que la production a qui correspond à ces itérations. Si rien ne le fait, la partie écartée était la réponse.
Deux voisins, délibérément séparés. [R:pas-d-omission-coordonnée] est l'instrument qui ne prend pas d'échantillons pendant les événements qui comptent ; celui-ci est l'instrument qui les prend dans le mauvais régime. Et [R:vérifier-que-l-abstraction-a-été-compilée] couvre le cas où l'optimiseur a supprimé le travail que le benchmark chronométrait -- une mesure à chaud de rien du tout.
Une file sans limite convertit la surcharge en latence au lieu d'un refus [R:une-file-sans-limite-n-a-pas-de-comportement-en-surcharge]
Écrit pour les publics Rust et Java.
Écrit à partir de la loi de Little (J. D. C. Little, 1961) : L = lambda W, donc pour une file qui ne refuse jamais, l'attente croît sans limite une fois que le taux d'arrivée dépasse le taux de service.
Décidez ce que fait la file lorsque la consommation est en retard sur la production. Croître n'est pas une décision.
La profondeur de la file est la latence -- c'est la loi de Little et non une heuristique : avec des arrivées au taux lambda et une longueur moyenne de file L, l'attente moyenne est L/lambda, donc un L sans limite est une attente sans limite. Une file sans limite de capacité n'a donc pas de limite de latence, et une exigence de latence exprimée en amont de celle-ci n'est maintenue par rien.
La défaillance a une forme caractéristique qui la rend tardive à diagnostiquer. Les producteurs ne bloquent jamais, donc rien en amont ne signale de problème ; le débit semble correct, car chaque élément est finalement traité ; et le seul symptôme est que la latence augmente de façon monotone. Au moment où elle est visible, le retard accumulé fait un travail dont personne ne veut -- chaque élément consommé est déjà obsolète, donc la capacité est dépensée à fournir des réponses à des questions dont le moment est passé, c'est pourquoi le système ne récupère pas lorsque la charge revient à la normale. Il doit d'abord se vider, au même rythme qui a pris du retard.
Donc, limitez la file et choisissez le comportement à la limite. Quatre sont légitimes et le choix est une décision de domaine, pas une décision technique :
- Bloquer le producteur. Contre-pression réelle -- le consommateur le plus lent fixe le rythme pour tout le monde, et la pression se propage jusqu'à celui qui peut réellement la dissiper. Erroné là où le producteur ne doit pas être arrêté, ce qui est le régime auquel
[R:pas-de-blocage-dans-une-fenêtre-de-publication]se rapporte. - Supprimer le plus ancien. Correct pour les données de marché, les lectures de capteurs, la télémétrie : un tick obsolète a une valeur négative et le plus récent est le seul qui vaille la peine d'être conservé.
- Supprimer le plus récent. Correct là où le retard est une file d'attente équitable et où les nouveaux arrivants n'ont reçu aucune promesse.
- Rejeter et le dire. Correct pour la saisie de commandes et tout ce qu'un appelant doit connaître du sort de son appel. Une commande supprimée silencieusement est pire qu'une refusée de toute la largeur de la confiance que l'appelant a placée dans l'appel.
Ne pas choisir sélectionne un cinquième comportement que personne ne choisirait délibérément : tout absorber, dégrader la latence de chaque élément de manière égale, et échouer finalement en épuisant la mémoire -- ou, sur un runtime managé, par une pause de collecte causée par le retard lui-même, ce qui ralentit davantage le consommateur et est la chose la plus proche dans la production d'une boucle de rétroaction avec le mauvais signe.
La structure sans limite reste le bon choix là où la limite réside véritablement ailleurs -- une file dont les producteurs sont limités en débit en amont, ou dont le consommateur est prouvé plus rapide que tout taux d'arrivée possible. Dites lequel, dans le code, à côté de la file. Une file sans limite avec une raison énoncée est une décision ; une sans est l'absence d'une décision portant la même forme.
Vérification du mode de défaillance : si la consommation est en retard sur la production pendant dix secondes, que fait cette file et qui le découvre ? Si la réponse est « elle croît » et « personne », il n'y a pas de comportement de surcharge ici, seulement un symptôme de surcharge en attente.
Une structure n'est correcte que sous le régime de concurrence que sa preuve a supposé [R:une-structure-conserve-le-régime-sous-lequel-elle-a-été-prouvée]
Écrit pour les publics Rust et Java.
Écrit à partir de Dmitry Vyukov, file MPSC intrusive basée sur des nœuds, 1024cores -- où le régime est énoncé dans le nom de l'algorithme et nulle part dans son code.
Lisez le régime à partir de l'argument de correction de la structure, puis nommez ce qui, dans le code, empêche de le violer. « La documentation dit monoconducteur » n'est pas un mécanisme.
MPSC signifie multi-producteur, mono-conducteur, et le mono est une précondition plutôt qu'une description. mpscq_pop de Vyukov lit self->tail, marche à partir de lui, et le réécrit -- un chargement simple et un stockage simple, non synchronisés, car on a supposé qu'exactement un thread les exécutait. Deux consommateurs s'y entrelacent et tous deux peuvent retourner le même nœud, ou l'un peut perdre un nœud entièrement. Aucun des deux résultats ne déclenche quoi que ce soit. Il n'y a pas d'assertion, pas de verrou à disputer, pas de chemin d'erreur : la file retourne des valeurs plausibles et le programme continue avec un élément dupliqué ou disparu.
Ce silence est tout le problème, et il est aggravé par la charge. Deux consommateurs exécutant cent opérations peuvent ne jamais s'entrelacer dans la fenêtre qui compte ; la suite de tests passe, le test de trempage en préproduction passe, et l'entrelacement arrive avec le trafic de production. Un défaut qui n'apparaît que dans des conditions que vous ne pouvez pas reproduire est impossible à distinguer, de l'extérieur, d'un défaut ailleurs.
Donc, mettez le régime dans le type, là où le langage le permet. Distribuez exactement un handle de consommateur à la construction ; ne le rendez ni Clone ni Sync ; prenez &mut self sur pop pour que le vérificateur d'emprunt enforce l'exclusivité. Alors « deux consommateurs » n'est pas un bogue à trouver mais un programme qui ne compile pas -- [R:rendre-les-états-invalides-non-représentables], et [R:typestate-pour-les-protocoles] là où le régime change au cours de la durée de vie d'une structure. C'est l'instance au plus fort effet de levier de cette discipline dans tout le domaine, car le contrôle alternatif est une phrase dans un commentaire d'en-tête et la défaillance qu'il empêche est non attribuable.
Là où le langage ne le portera pas -- Java n'a pas un tel handle -- le contrôle doit être quelque chose qui s'exécute réellement : enregistrez le thread propriétaire lors de la première utilisation et affirmez-le sur chaque appel suivant dans une build de débogage, ou acheminez toute la consommation via un seul objet dont la construction est le seul endroit où l'invariant est énoncé. Un commentaire n'est pas un contrôle ([R:garantie-a-besoin-d-un-lecteur]).
La classe est plus large que les files, et les autres membres échouent de la même manière. Un anneau SPSC avec deux producteurs. Une structure documentée comme non réentrante, entrée depuis un gestionnaire de signal ou un callback. Un HashMap atteint depuis deux threads parce que le champ qui le contenait est devenu partagé trois refactorings plus tard. Un itérateur maintenu sur une mutation. Dans chaque cas, le régime était une hypothèse de l'argument de correction et rien dans l'artefact ne l'enregistre.
Vérification du mode de défaillance : quel régime l'argument de correction de cette structure suppose-t-il, et qu'est-ce qui échouerait si je le violais maintenant ? Si la réponse honnête est « le nom le dit » ou « nous faisons attention », la structure est non protégée et la prochaine personne à l'utiliser ne le saura pas.
La mémoire qui n'a jamais été touchée n'est pas encore de la mémoire [R:alloué-n-est-pas-résident]
Écrit pour les publics Rust et Java.
Écrit à partir d'Ulrich Drepper, « Ce que tout programmeur devrait savoir sur la mémoire » (2007), sur la pagination à la demande et le coût d'un premier toucher.
Pré-allouer n'est pas préparer. Une allocation réussie achète de l'espace d'adressage ; la page arrive au premier toucher, et le toucher est ce qui coûte.
malloc et mmap rendent des adresses virtuelles. Les pages physiques sont fournies paresseusement : la première écriture sur chaque page piège dans le noyau, qui trouve un cadre, le met à zéro -- car les données de l'ancien occupant ne doivent pas fuir -- et le mappe. Donc, un tampon d'un gigaoctet alloué au démarrage et écrit pour la première fois sur le chemin critique contient environ un quart de million de défauts de page, arrivant un par page, répartis sur le premier passage sur celui-ci. Toute assertion sans allocation sur ce chemin est satisfaite et chacune d'elles mesure la mauvaise chose : rien n'a appelé l'allocateur.
La résidence n'est pas non plus permanente une fois atteinte. Les pages peuvent être réclamées sous pression mémoire ou échangées, donc un processus de longue durée qui a touché ses tampons au démarrage puis les a laissés inactifs peut à nouveau faillir sur un chemin qui a été sans défaut pendant une semaine. C'est pourquoi le remède a deux moitiés et pourquoi ne faire que la première est une erreur courante et coûteuse.
Donc, pour tout ce qui se trouve sur un chemin avec une échéance : écrivez sur chaque page que le chemin touchera, au démarrage, avant que tout travail réel n'arrive -- le toucher doit être une écriture, car une lecture d'une page non touchée peut être servie par la page zéro partagée et ne prouve rien. Ensuite, verrouillez-la : mlockall(MCL_CURRENT | MCL_FUTURE), et pas de swap sur la machine. Sur la JVM, -XX:+AlwaysPreTouch fait la première moitié pour le tas et vaut le démarrage plus lent précisément parce que le coût est déplacé hors du chemin qui a une limite.
Les grandes pages changent l'arithmétique dans les deux sens et méritent leur propre décision plutôt qu'une valeur par défaut. Moins de pages, plus grandes signifie moins de défauts et moins de pression TLB ; chaque défaut est plus grand, et les grandes pages transparentes apportent un chemin de défragmentation qui peut bloquer un thread pendant des millisecondes exactement au mauvais moment. Les grandes pages explicites réservées au démarrage évitent cela ; laisser THP sur always et espérer est la forme que ce domaine continue de devoir nommer.
Touchez depuis le thread qui possédera la mémoire, pas depuis le thread qui fait la configuration. Sur une machine multi-socket, la première écriture décide également du placement : une page est allouée sur le nœud de celui qui la provoque, donc la façon évidente de satisfaire cette règle -- une boucle dans le thread d'initialisation parcourant chaque tampon -- met chaque page sur le nœud de ce thread, et un travailleur épinglé sur l'autre socket lit ensuite tout cela sur l'interconnexion à environ deux fois la latence locale. Le chemin est maintenant sans défaut et entièrement distant : un état stable plus lent acheté avec un premier message plus rapide. Épinglez d'abord les threads propriétaires, puis laissez chacun d'eux toucher ce qu'il utilisera.
Vérification du mode de défaillance : chaque page que ce chemin touchera-t-elle été écrite, par le thread qui la lira, et qu'est-ce qui l'empêche d'être réclamée ? Trois questions, car ne répondre qu'à la première laisse un système qui est rapide jusqu'à ce qu'il devienne inactif, et ne répondre qu'aux deux premières en laisse un qui est rapide sur un seul socket.
[R:pas-d-allocation-sur-le-chemin-critique] est le frère et aucun ne subsume l'autre -- celui-ci concerne l'entrée dans l'allocateur, celui-ci concerne la mémoire qu'il a déjà retournée.
[R:répondre-à-l-exigence-à-sa-couche] est pourquoi les remèdes sont tous une politique mémoire, un verrou ou un paramètre de démarrage : aucun choix de conteneur ou de crate n'atteint cela.
[R:pas-de-blocage-dans-une-fenêtre-de-publication] est là où il mord le plus fort, car un défaut de page pris entre deux stockages d'un écrivain maintient la fenêtre ouverte pendant toute sa durée.
Répondre à une exigence à la couche où elle se trouve [R:répondre-à-l-exigence-à-sa-couche]
Écrit pour les publics Rust et Java.
Dites à quelle couche vit une exigence avant de choisir quoi que ce soit, et satisfaites-la là.
Une exigence concernant la machine -- ne pas perturber un cœur isolé voisin, ne pas défaillir une page sur le chemin d'envoi, ne pas prendre un verrou qu'un thread temps réel conteste -- n'est pas satisfaite en choisissant un crate plus rapide, un transport mieux réputé ou une API asynchrone. Ce sont des décisions au niveau de la bibliothèque. Elles peuvent être correctes et peuvent même aider, mais elles ne maintiennent pas une contrainte au niveau système, et les traiter comme si elles le faisaient convertit une question ouverte en une question fermée avec rien derrière.
L'échec n'est pas le mauvais choix ; c'est la mauvaise écriture comptable. Une contrainte marquée comme satisfaite cesse d'être examinée. Une contrainte ouverte est encore...