Mais cette POO pourrait-elle être un inconvénient pour les logiciels basés sur les performances, c'est-à-dire à quelle vitesse le programme s'exécute-t-il?
Souvent oui !!! MAIS...
En d'autres termes, de nombreuses références entre de nombreux objets différents, ou en utilisant de nombreuses méthodes de nombreuses classes, pourraient-elles entraîner une implémentation "lourde"?
Pas nécessairement. Cela dépend de la langue / du compilateur. Par exemple, un compilateur C ++ optimisant, à condition que vous n'utilisiez pas de fonctions virtuelles, réduira souvent la surcharge de votre objet à zéro. Vous pouvez faire des choses comme écrire un wrapper sur unint
là ou un pointeur intelligent de portée sur un ancien pointeur ordinaire qui fonctionne aussi rapidement que l'utilisation directe de ces anciens types de données simples.
Dans d'autres langages comme Java, il y a un peu de surcharge pour un objet (souvent assez petit dans de nombreux cas, mais astronomique dans de rares cas avec des objets vraiment minuscules). Par exemple, Integer
il est considérablement moins efficace que int
(prend 16 octets contre 4 sur 64 bits). Pourtant, ce n'est pas seulement un gaspillage flagrant ou quoi que ce soit de ce genre. En échange, Java offre des éléments tels que la réflexion sur chaque type défini par l'utilisateur de manière uniforme, ainsi que la possibilité de remplacer toute fonction non marquée comme final
.
Prenons cependant le meilleur des cas: le compilateur C ++ optimisant qui peut optimiser les interfaces d'objet jusqu'à zéro surcharge. Même alors, la POO dégrade souvent les performances et l'empêche d'atteindre le pic. Cela pourrait ressembler à un paradoxe complet: comment pourrait-il en être ainsi? Le problème réside dans:
Conception d'interface et encapsulation
Le problème est que même lorsqu'un compilateur peut écraser la structure d'un objet jusqu'à zéro surcharge (ce qui est au moins très souvent vrai pour l'optimisation des compilateurs C ++), l'encapsulation et la conception d'interface (et les dépendances accumulées) des objets à grain fin empêcheront souvent la représentations de données les plus optimales pour les objets qui sont destinés à être agrégés par les masses (ce qui est souvent le cas pour les logiciels à performances critiques).
Prenez cet exemple:
class Particle
{
public:
...
private:
double birth; // 8 bytes
float x; // 4 bytes
float y; // 4 bytes
float z; // 4 bytes
/*padding*/ // 4 bytes of padding
};
Particle particles[1000000]; // 1mil particles (~24 megs)
Supposons que notre modèle d'accès à la mémoire consiste simplement à parcourir ces particules séquentiellement et à les déplacer autour de chaque image à plusieurs reprises, en les faisant rebondir dans les coins de l'écran, puis en rendant le résultat.
Nous pouvons déjà voir un surdébit de remplissage de 4 octets flagrant requis pour aligner birth
correctement le membre lorsque les particules sont agrégées de manière contiguë. Déjà ~ 16,7% de la mémoire est gaspillée avec l'espace mort utilisé pour l'alignement.
Cela peut sembler théorique, car nous avons actuellement des gigaoctets de DRAM. Pourtant, même les machines les plus bestiales que nous avons aujourd'hui n'ont souvent que 8 mégaoctets quand il s'agit de la région la plus lente et la plus grande du cache CPU (L3). Moins nous pouvons nous y adapter, plus nous le payons en termes d'accès répété aux DRAM, et plus les choses ralentissent. Du coup, le gaspillage de 16,7% de mémoire ne semble plus être une affaire banale.
Nous pouvons facilement éliminer cette surcharge sans aucun impact sur l'alignement du champ:
class Particle
{
public:
...
private:
float x; // 4 bytes
float y; // 4 bytes
float z; // 4 bytes
};
Particle particles[1000000]; // 1mil particles (~12 megs)
double particle_birth[1000000]; // 1mil particle births (~8 bytes)
Maintenant, nous avons réduit la mémoire de 24 Mo à 20 Mo. Avec un modèle d'accès séquentiel, la machine consommera désormais ces données un peu plus rapidement.
Mais regardons ce birth
domaine de plus près. Disons qu'il enregistre l'heure de début de la naissance (création) d'une particule. Imaginez que le champ n'est accessible que lorsqu'une particule est créée pour la première fois, et toutes les 10 secondes pour voir si une particule doit mourir et renaître à un endroit aléatoire sur l'écran. Dans ce cas, birth
est un champ froid. Il n'est pas accessible dans nos boucles critiques pour les performances.
Par conséquent, les données critiques pour les performances ne sont pas de 20 mégaoctets mais en fait un bloc contigu de 12 mégaoctets. La mémoire chaude réelle à laquelle nous accédons fréquemment a diminué de moitié . Attendez - vous à des accélérations importantes par rapport à notre solution originale de 24 mégaoctets (n'a pas besoin d'être mesurée - déjà fait ce genre de choses mille fois, mais n'hésitez pas en cas de doute).
Pourtant, remarquez ce que nous avons fait ici. Nous avons complètement rompu l'encapsulation de cet objet de particules. Son état est désormais divisé entre Particle
les champs privés d' un type et un tableau parallèle séparé. Et c'est là qu'intervient la conception granulaire orientée objet.
Nous ne pouvons pas exprimer la représentation optimale des données lorsqu'il est confiné à la conception d'interface d'un seul objet très granulaire comme une seule particule, un seul pixel, même un seul vecteur à 4 composants, peut-être même un seul objet "créature" dans un jeu , etc. La vitesse d'un guépard sera gaspillée s'il se trouve sur une île minuscule de 2 mètres carrés, et c'est ce que la conception orientée objet très granulaire fait souvent en termes de performances. Il limite la représentation des données à une nature sous-optimale.
Pour aller plus loin, disons que puisque nous ne faisons que déplacer des particules, nous pouvons en fait accéder à leurs champs x / y / z dans trois boucles distinctes. Dans ce cas, nous pouvons bénéficier des intrinsèques SIMD de style SoA avec des registres AVX qui peuvent vectoriser 8 opérations SPFP en parallèle. Mais pour ce faire, nous devons maintenant utiliser cette représentation:
float particle_x[1000000]; // 1mil particle X positions (~4 megs)
float particle_y[1000000]; // 1mil particle Y positions (~4 megs)
float particle_z[1000000]; // 1mil particle Z positions (~4 megs)
double particle_birth[1000000]; // 1mil particle births (~8 bytes)
Nous volons maintenant avec la simulation de particules, mais regardez ce qui est arrivé à notre conception de particules. Il a été complètement démoli et nous examinons maintenant 4 tableaux parallèles et aucun objet pour les agréger. Notre Particle
conception orientée objet est devenue sayonara.
Cela m'est arrivé plusieurs fois en travaillant dans des domaines critiques pour les performances, où les utilisateurs exigent de la vitesse, seule l'exactitude étant la seule chose qu'ils demandent le plus. Ces petites conceptions orientées objet minuscules ont dû être démolies, et les ruptures en cascade ont souvent nécessité que nous utilisions une stratégie de dépréciation lente vers la conception plus rapide.
Solution
Le scénario ci-dessus ne présente qu'un problème avec les conceptions granulaires orientées objet. Dans ces cas, nous finissons souvent par devoir démolir la structure afin d'exprimer des représentations plus efficaces en raison des répétitions SoA, de la division de champ chaud / froid, de la réduction de remplissage pour les modèles d'accès séquentiel (le remplissage est parfois utile pour les performances avec accès aléatoire dans les cas d'AoS, mais presque toujours un obstacle pour les modèles d'accès séquentiel), etc.
Pourtant, nous pouvons prendre cette représentation finale sur laquelle nous nous sommes installés et modéliser une interface orientée objet:
// Represents a collection of particles.
class ParticleSystem
{
public:
...
private:
double particle_birth[1000000]; // 1mil particle births (~8 bytes)
float particle_x[1000000]; // 1mil particle X positions (~4 megs)
float particle_y[1000000]; // 1mil particle Y positions (~4 megs)
float particle_z[1000000]; // 1mil particle Z positions (~4 megs)
};
Maintenant, nous allons bien. Nous pouvons obtenir tous les goodies orientés objet que nous aimons. Le guépard a tout un pays à parcourir aussi vite qu'il le peut. Nos conceptions d'interface ne nous piègent plus dans un goulot d'étranglement.
ParticleSystem
peut même être abstrait et utiliser des fonctions virtuelles. C'est sans objet maintenant, nous payons les frais généraux au niveau de la collection de particules plutôt qu'au niveau par particule . Les frais généraux représentent 1/1 000 000 de ce qu'il en serait autrement si nous modélisions des objets au niveau des particules individuelles.
C'est donc la solution dans de véritables domaines critiques pour les performances qui gèrent une charge élevée, et pour toutes sortes de langages de programmation (cette technique profite au C, C ++, Python, Java, JavaScript, Lua, Swift, etc.). Et il ne peut pas facilement être qualifié d '"optimisation prématurée", car cela concerne la conception et l' architecture de l' interface . Nous ne pouvons pas écrire une base de code modélisant une seule particule comme un objet avec une cargaison de dépendances client dans unParticle's
interface publique, puis changer d'avis plus tard. J'ai fait beaucoup de choses lors de mon appel pour optimiser les bases de code héritées, et cela peut prendre des mois à réécrire soigneusement des dizaines de milliers de lignes de code pour utiliser la conception plus volumineuse. Cela affecte idéalement la façon dont nous concevons les choses à l'avance à condition de pouvoir anticiper une charge élevée.
Je continue de faire écho à cette réponse sous une forme ou une autre dans de nombreuses questions de performance, et en particulier celles qui concernent la conception orientée objet. La conception orientée objet peut toujours être compatible avec les besoins de performances les plus exigeants, mais nous devons changer un peu la façon dont nous y pensons. Nous devons donner à ce guépard un espace pour courir aussi vite que possible, et c'est souvent impossible si nous concevons de petits objets minuscules qui stockent à peine n'importe quel état.