Pensez-vous qu'il y a un compromis entre l'écriture de code orienté objet "sympa" et l'écriture de code à faible latence très rapide? Par exemple, éviter les fonctions virtuelles en C ++ / la surcharge du polymorphisme, etc. réécrire du code qui a l'air méchant, mais qui est très rapide, etc.?
Je travaille dans un domaine qui est un peu plus axé sur le débit que sur la latence, mais c'est très critique pour les performances, et je dirais "sorta" .
Pourtant, un problème est que tant de gens se trompent complètement sur leurs notions de performance. Les novices se trompent souvent à peu près tout et leur modèle conceptuel entier de «coût de calcul» a besoin d'être retravaillé, avec seulement la complexité algorithmique étant la seule chose qu'ils peuvent obtenir correctement. Les intermédiaires se trompent sur beaucoup de choses. Les experts se trompent.
Mesurer avec des outils précis qui peuvent fournir des mesures telles que les erreurs de cache et les erreurs de prédiction de branche est ce qui garde toutes les personnes de tout niveau d'expertise dans le domaine sous contrôle.
La mesure est également ce qui indique ce qu'il ne faut pas optimiser . Les experts passent souvent moins de temps à optimiser que les novices, car ils optimisent les véritables points chauds mesurés et n'essaient pas d'optimiser les coups sauvages dans l'obscurité en fonction de intuitions sur ce qui pourrait être lent (ce qui, sous une forme extrême, pourrait inciter à une micro-optimisation juste sur toutes les autres lignes de la base de code).
Concevoir pour la performance
Avec cela mis à part, la clé de la conception pour la performance vient de la partie conception , comme dans la conception d'interface. L'un des problèmes liés à l'inexpérience est qu'il y a généralement un changement précoce des mesures de mise en œuvre absolues, comme le coût d'un appel de fonction indirect dans un contexte généralisé, comme si le coût (qui est mieux compris dans un sens immédiat du point de vue d'un optimiseur). plutôt qu'un point de vue de branchement) est une raison pour l'éviter dans toute la base de code.
Les coûts sont relatifs . Bien qu'il y ait un coût pour un appel de fonction indirect, par exemple, tous les coûts sont relatifs. Si vous payez ce coût une fois pour appeler une fonction qui passe par des millions d'éléments, s'inquiéter de ce coût, c'est comme passer des heures à marchander des sous pour acheter un produit d'un milliard de dollars, pour finalement conclure à ne pas acheter ce produit car il était un sou trop cher.
Conception d'interface plus grossière
L' aspect de conception d' interface de la performance cherche souvent plus tôt à pousser ces coûts à un niveau plus grossier. Au lieu de payer les coûts d'abstraction à l'exécution pour une seule particule, par exemple, nous pourrions pousser ce coût au niveau du système de particules / émetteur, transformant efficacement une particule en détail d'implémentation et / ou simplement des données brutes de cette collection de particules.
La conception orientée objet ne doit donc pas être incompatible avec la conception axée sur les performances (que ce soit la latence ou le débit), mais il peut y avoir des tentations dans un langage qui se concentre sur celui-ci pour modéliser des objets granulaires de plus en plus minuscules, et là, le dernier optimiseur ne peut pas Aidez-moi. Il ne peut pas faire des choses comme fusionner une classe représentant un seul point d'une manière qui donne une représentation SoA efficace pour les modèles d'accès à la mémoire du logiciel. Une collection de points avec une conception d'interface modélisée au niveau de grossièreté offre cette opportunité et permet d'itérer vers des solutions de plus en plus optimales au besoin. Une telle conception est conçue pour la mémoire en masse *.
* Notez l'accent mis sur la mémoire ici et non sur les données , car travailler dans des zones critiques pour les performances pendant une longue période aura tendance à changer votre vision des types de données et des structures de données et à voir comment ils se connectent à la mémoire. Un arbre de recherche binaire ne devient plus uniquement une question de complexité logarithmique dans des cas tels que des morceaux de mémoire éventuellement disparates et sans cache pour les nœuds d'arborescence, sauf s'ils sont aidés par un allocateur fixe. La vue ne rejette pas la complexité algorithmique, mais elle ne la voit plus indépendamment des dispositions de mémoire. On commence également à voir les itérations de travail comme étant davantage des itérations d'accès à la mémoire. *
De nombreuses conceptions critiques en termes de performances peuvent en fait être très compatibles avec la notion de conceptions d'interface de haut niveau qui sont faciles à comprendre et à utiliser pour les humains. La différence est que le «haut niveau» dans ce contexte concernerait l'agrégation de masse de la mémoire, une interface modélisée pour des collections de données potentiellement volumineuses, et avec une implémentation sous le capot qui peut être assez bas. Une analogie visuelle pourrait être une voiture vraiment confortable et facile à conduire et à manipuler et très sûre tout en allant à la vitesse du son, mais si vous ouvrez le capot, il y a peu de démons cracheurs de feu à l'intérieur.
Avec une conception plus grossière, il est également plus facile de fournir des modèles de verrouillage plus efficaces et d'exploiter le parallélisme dans le code (le multithreading est un sujet exhaustif que je vais en quelque sorte ignorer ici).
Pool de mémoire
Un aspect critique de la programmation à faible latence va probablement être un contrôle très explicite de la mémoire pour améliorer la localité de référence ainsi que la vitesse générale d'allocation et de désallocation de la mémoire. Un allocateur personnalisé regroupant la mémoire fait en fait écho au même type de mentalité de conception que nous avons décrit. Il est conçu pour le vrac ; il est conçu à un niveau grossier. Il préalloue la mémoire en gros blocs et regroupe la mémoire déjà allouée en petits morceaux.
L'idée est exactement la même de pousser des choses coûteuses (allouer un morceau de mémoire contre un allocateur à usage général, par exemple) à un niveau plus grossier et plus grossier. Un pool de mémoire est conçu pour gérer la mémoire en masse .
Type Systèmes Mémoire séparée
L'une des difficultés de la conception granulaire orientée objet dans n'importe quel langage est qu'elle veut souvent introduire de nombreux types et structures de données définis par l'utilisateur. Ces types peuvent alors vouloir être alloués en petits morceaux minuscules s'ils sont alloués dynamiquement.
Un exemple courant en C ++ serait pour les cas où le polymorphisme est requis, où la tentation naturelle est d'allouer chaque instance d'une sous-classe contre un allocateur de mémoire à usage général.
Cela finit par décomposer les dispositions de mémoire éventuellement contiguës en petits morceaux et morceaux dispersés à travers la plage d'adressage, ce qui se traduit par davantage de défauts de page et d'échecs de cache.
Les domaines qui exigent la réponse déterministe la plus faible latence, sans bégaiement sont probablement le seul endroit où les points chauds ne se résument pas toujours à un seul goulot d'étranglement, où de minuscules inefficacités peuvent réellement réellement "s'accumuler" (quelque chose que beaucoup de gens imaginent ne se passe pas correctement avec un profileur pour les garder sous contrôle, mais dans les domaines liés à la latence, il peut en fait y avoir de rares cas où de minuscules inefficacités s'accumulent). Et beaucoup des raisons les plus courantes d'une telle accumulation peuvent être les suivantes: l'allocation excessive de minuscules morceaux de mémoire partout.
Dans des langages comme Java, il peut être utile d'utiliser plus de tableaux d'anciens types de données simples lorsque cela est possible pour les zones goulot d'étranglement (zones traitées en boucles serrées) telles qu'un tableau de int
(mais toujours derrière une interface de haut niveau volumineuse) au lieu de, par exemple , un ArrayList
des Integer
objets définis par l'utilisateur . Cela évite la ségrégation mémoire qui accompagnerait typiquement cette dernière. En C ++, nous n'avons pas autant à dégrader la structure si nos modèles d'allocation de mémoire sont efficaces, car les types définis par l'utilisateur peuvent y être alloués de manière contiguë et même dans le contexte d'un conteneur générique.
Fusionner la mémoire ensemble
Une solution ici consiste à rechercher un allocateur personnalisé pour les types de données homogènes, et peut-être même entre les types de données homogènes. Lorsque de minuscules types de données et structures de données sont aplatis en bits et octets en mémoire, ils prennent une nature homogène (mais avec des exigences d'alignement variables). Lorsque nous ne les considérons pas dans un état d'esprit centré sur la mémoire, le système de types de langages de programmation "veut" diviser / séparer les régions de mémoire potentiellement contiguës en petits morceaux dispersés.
La pile utilise cette concentration centrée sur la mémoire pour éviter cela et potentiellement stocker à l'intérieur de celle-ci toute combinaison mixte possible d'instances de type définies par l'utilisateur. Utiliser davantage la pile est une excellente idée lorsque cela est possible car le haut de celle-ci est presque toujours assis dans une ligne de cache, mais nous pouvons également concevoir des allocateurs de mémoire qui imitent certaines de ces caractéristiques sans modèle LIFO, fusionnant la mémoire entre des types de données disparates en contigus morceaux même pour des modèles d'allocation et de désallocation de mémoire plus complexes.
Le matériel moderne est conçu pour être à son apogée lors du traitement de blocs de mémoire contigus (accéder à plusieurs reprises à la même ligne de cache, à la même page, par exemple). Le mot-clé y est contigu, car cela n'est bénéfique que s'il y a des données environnantes d'intérêt. Ainsi, une grande partie (mais aussi la difficulté) des performances consiste à fusionner à nouveau des morceaux de mémoire séparés en blocs contigus auxquels on accède dans leur intégralité (toutes les données environnantes étant pertinentes) avant l'expulsion. Le système de type riche de types spécialement définis par l'utilisateur dans les langages de programmation peut être le plus grand obstacle ici, mais nous pouvons toujours atteindre et résoudre le problème via un allocateur personnalisé et / ou des conceptions plus volumineuses, le cas échéant.
Laid
"Ugly" est difficile à dire. C'est une métrique subjective, et quelqu'un qui travaille dans un domaine très critique en termes de performances commencera à changer son idée de la «beauté» en une idée beaucoup plus orientée données et se concentrant sur les interfaces qui traitent les choses en vrac.
Dangereux
"Dangereux" pourrait être plus facile. En général, les performances ont tendance à vouloir atteindre un code de niveau inférieur. La mise en œuvre d'un allocateur de mémoire, par exemple, est impossible sans atteindre sous les types de données et travailler au niveau dangereux des bits et octets bruts. En conséquence, cela peut aider à mettre davantage l'accent sur une procédure de test minutieuse dans ces sous-systèmes critiques pour les performances, en adaptant la rigueur des tests avec le niveau d'optimisation appliqué.
Beauté
Pourtant, tout cela se ferait au niveau des détails de mise en œuvre. Dans un état d'esprit à grande échelle et critique pour les performances, la «beauté» a tendance à se tourner vers les conceptions d'interface plutôt que vers les détails de mise en œuvre. Il devient de plus en plus prioritaire de rechercher des interfaces «belles», utilisables, sûres et efficaces plutôt que des implémentations en raison des ruptures de couplage et de cascade qui peuvent survenir face à un changement de conception d'interface. Les implémentations peuvent être échangées à tout moment. Nous itérons généralement vers les performances selon les besoins et comme indiqué par les mesures. La clé de la conception de l'interface est de modéliser à un niveau suffisamment grossier pour laisser de la place à de telles itérations sans casser tout le système.
En fait, je dirais que l'accent mis par un vétéran sur le développement essentiel à la performance aura souvent tendance à mettre l'accent sur la sécurité, les tests, la maintenabilité, juste le disciple de SE en général, car une base de code à grande échelle qui a un certain nombre de performances -les sous-systèmes critiques (systèmes de particules, algorithmes de traitement d'image, traitement vidéo, rétroaction audio, raytracers, moteurs maillés, etc.) devront prêter une attention particulière à l'ingénierie logicielle pour éviter de se noyer dans un cauchemar de maintenance. Ce n'est pas par pure coïncidence que souvent les produits les plus étonnamment efficaces peuvent également contenir le moins de bogues.
TL; DR
Quoi qu'il en soit, c'est mon point de vue sur le sujet, qui va des priorités dans des domaines véritablement critiques aux performances, ce qui peut réduire la latence et provoquer de minuscules inefficacités, et ce qui constitue réellement la «beauté» (lorsque l'on regarde les choses de la manière la plus productive).