Je voulais sauter ici parmi ces réponses déjà excellentes et admettre que j'ai adopté la laideur de travailler en arrière vers l'anti-modèle de changement de code polymorphe en branches switches
ou if/else
avec des gains mesurés. Mais je ne l'ai pas fait en gros, uniquement pour les chemins les plus critiques. Il n'a pas besoin d'être aussi noir et blanc.
Comme avertissement, je travaille dans des domaines comme le lancer de rayons où l'exactitude n'est pas si difficile à atteindre (et est souvent floue et approximative de toute façon) tandis que la vitesse est souvent l'une des qualités les plus compétitives recherchées. Une réduction des temps de rendu est souvent l'une des demandes les plus courantes des utilisateurs, nous nous grattant constamment la tête et trouvant comment y parvenir pour les chemins mesurés les plus critiques.
Refactorisation polymorphe des conditionnelles
Tout d'abord, il vaut la peine de comprendre pourquoi le polymorphisme peut être préférable d'un aspect de maintenabilité que la ramification conditionnelle ( switch
ou un tas d' if/else
instructions). Le principal avantage ici est l' extensibilité .
Avec le code polymorphe, nous pouvons introduire un nouveau sous-type dans notre base de code, ajouter des instances de celui-ci à une structure de données polymorphe et faire en sorte que tout le code polymorphe existant fonctionne toujours de manière automatique sans autre modification. Si vous avez un tas de code dispersé dans une grande base de code qui ressemble à la forme de "Si ce type est" foo ", faites-le" , vous pourriez vous retrouver avec un fardeau horrible de mettre à jour 50 sections disparates de code afin d'introduire un nouveau type de chose, et finissent toujours par en manquer quelques-uns.
Les avantages de maintenabilité du polymorphisme diminuent naturellement ici si vous n'avez que quelques ou même une section de votre base de code qui doit effectuer de telles vérifications de type.
Barrière d'optimisation
Je suggérerais de ne pas considérer cela du point de vue de la ramification et du pipelining autant, et de le regarder davantage du point de vue de la conception du compilateur des barrières d'optimisation. Il existe des moyens d'améliorer la prédiction de branche qui s'appliquent aux deux cas, comme le tri des données en fonction du sous-type (s'il s'inscrit dans une séquence).
Ce qui diffère davantage entre ces deux stratégies, c'est la quantité d'informations que l'optimiseur possède à l'avance. Un appel de fonction connu fournit beaucoup plus d'informations, un appel de fonction indirecte qui appelle une fonction inconnue au moment de la compilation conduit à une barrière d'optimisation.
Lorsque la fonction appelée est connue, les compilateurs peuvent effacer la structure et la réduire en fragments, en alignant les appels, en éliminant le surcoût potentiel, en effectuant un meilleur travail lors de l'allocation des instructions / registres, peut-être même en réorganisant les boucles et d'autres formes de branches, en générant des -LUT miniatures codées, le cas échéant (quelque chose que GCC 5.3 m'a récemment surpris avec une switch
déclaration en utilisant une LUT codée en dur pour les résultats plutôt qu'une table de saut).
Certains de ces avantages disparaissent lorsque nous commençons à introduire des inconnues à la compilation dans le mélange, comme dans le cas d'un appel de fonction indirect, et c'est là que la ramification conditionnelle peut très probablement offrir un avantage.
Optimisation de la mémoire
Prenons un exemple de jeu vidéo qui consiste à traiter une séquence de créatures à plusieurs reprises dans une boucle étroite. Dans un tel cas, nous pourrions avoir un conteneur polymorphe comme celui-ci:
vector<Creature*> creatures;
Remarque: pour plus de simplicité, j'ai évité unique_ptr
ici.
... où Creature
est un type de base polymorphe. Dans ce cas, l'une des difficultés des conteneurs polymorphes est qu'ils veulent souvent allouer de la mémoire pour chaque sous-type séparément / individuellement (ex: utiliser le lancement par défaut operator new
pour chaque créature individuelle).
Cela fera souvent la première priorisation de l'optimisation (si nous en avons besoin) basée sur la mémoire plutôt que sur la ramification. Une stratégie consiste ici à utiliser un allocateur fixe pour chaque sous-type, en encourageant une représentation contiguë en allouant en gros morceaux et en regroupant la mémoire pour chaque sous-type alloué. Avec une telle stratégie, cela peut certainement aider à trier ce creatures
conteneur par sous-type (ainsi que par adresse), car cela améliore non seulement la prédiction des branches, mais améliore également la localité de référence (permettant d'accéder à plusieurs créatures du même sous-type à partir d'une seule ligne de cache avant l'expulsion).
Dévirtualisation partielle des structures de données et des boucles
Disons que vous avez parcouru tous ces mouvements et que vous souhaitez toujours plus de vitesse. Il convient de noter que chaque étape que nous entreprenons ici dégrade la maintenabilité, et nous serons déjà à un stade de broyage des métaux avec des rendements de performance décroissants. Il doit donc y avoir une demande de performances assez importante si nous pénétrons sur ce territoire, où nous sommes prêts à sacrifier encore plus la maintenabilité pour des gains de performances de plus en plus petits.
Pourtant, la prochaine étape à essayer (et toujours avec une volonté d' annuler nos changements si cela n'aide pas du tout) pourrait être la dévirtualisation manuelle.
Conseil de contrôle de version: à moins que vous ne soyez beaucoup plus averti en optimisation que moi, il peut être utile de créer une nouvelle branche à ce stade avec la volonté de la jeter si nos efforts d'optimisation échouent, ce qui pourrait très bien arriver. Pour moi, c'est tout essai et erreur après ce genre de points, même avec un profileur à la main.
Néanmoins, nous n'avons pas à appliquer cet état d'esprit en gros. Poursuivant notre exemple, disons que ce jeu vidéo est composé de loin de créatures humaines. Dans un tel cas, nous ne pouvons dévirtualiser que des créatures humaines en les hissant et en créant une structure de données distincte juste pour elles.
vector<Human> humans; // common case
vector<Creature*> other_creatures; // additional rare-case creatures
Cela implique que toutes les zones de notre base de code qui ont besoin de traiter des créatures ont besoin d'une boucle spéciale pour les créatures humaines. Pourtant, cela élimine les frais généraux de répartition dynamique (ou peut-être, de manière plus appropriée, la barrière d'optimisation) pour les humains qui sont, de loin, le type de créature le plus courant. Si ces zones sont nombreuses et que nous pouvons nous le permettre, nous pouvons le faire:
vector<Human> humans; // common case
vector<Creature*> other_creatures; // additional rare-case creatures
vector<Creature*> creatures; // contains humans and other creatures
... si nous pouvons nous le permettre, les chemins les moins critiques peuvent rester tels quels et traiter simplement tous les types de créatures de manière abstraite. Les chemins critiques peuvent être traités humans
dans une boucle et other_creatures
dans une seconde boucle.
Nous pouvons étendre cette stratégie si nécessaire et potentiellement comprimer certains gains de cette façon, mais il convient de noter à quel point nous dégradons la maintenabilité dans le processus. L'utilisation de modèles de fonction ici peut aider à générer le code pour les humains et les créatures sans dupliquer la logique manuellement.
Dévirtualisation partielle des classes
Quelque chose que j'ai fait il y a des années et qui était vraiment dégoûtant, et je ne suis même plus sûr que ce soit bénéfique (c'était à l'ère C ++ 03), c'était la dévirtualisation partielle d'une classe. Dans ce cas, nous stockions déjà un ID de classe avec chaque instance à d'autres fins (accessible via un accesseur dans la classe de base qui n'était pas virtuelle). Là, nous avons fait quelque chose d'analogue à cela (ma mémoire est un peu floue):
switch (obj->type())
{
case id_common_type:
static_cast<CommonType*>(obj)->non_virtual_do_something();
break;
...
default:
obj->virtual_do_something();
break;
}
... où a virtual_do_something
été implémenté pour appeler des versions non virtuelles dans une sous-classe. C'est grossier, je sais, de faire un downcast statique explicite pour dévirtualiser un appel de fonction. Je ne sais pas à quel point c'est bénéfique maintenant car je n'ai pas essayé ce genre de chose depuis des années. Avec une exposition à la conception orientée données, j'ai trouvé que la stratégie ci-dessus de diviser les structures de données et les boucles de manière chaude / froide était beaucoup plus utile, ouvrant plus de portes pour des stratégies d'optimisation (et beaucoup moins laides).
Dévirtualisation en gros
Je dois admettre que je ne suis jamais allé aussi loin en appliquant un état d'esprit d'optimisation, donc je n'ai aucune idée des avantages. J'ai évité les fonctions indirectes dans la prospective dans les cas où je savais qu'il n'y aurait qu'un seul ensemble central de conditions (ex: traitement d'événements avec un seul événement central de traitement des événements), mais je n'ai jamais commencé avec un état d'esprit polymorphe et optimisé à fond jusqu'ici.
Théoriquement, les avantages immédiats pourraient être une manière potentiellement plus petite d'identifier un type qu'un pointeur virtuel (ex: un seul octet si vous pouvez vous engager à l'idée qu'il existe 256 types uniques ou moins) en plus d'effacer complètement ces barrières d'optimisation .
Il peut également être utile dans certains cas d'écrire du code plus facile à maintenir (par rapport aux exemples de dévirtualisation manuelle optimisés ci-dessus) si vous utilisez une seule switch
instruction centrale sans avoir à diviser vos structures de données et vos boucles en fonction du sous-type, ou s'il y a un ordre -dépendance dans ces cas où les choses doivent être traitées dans un ordre précis (même si cela nous oblige à nous ramifier partout). Ce serait pour les cas où vous n'avez pas trop d'endroits qui doivent le faire switch
.
Je ne recommanderais généralement pas cela, même avec un état d'esprit très critique, sauf si cela est raisonnablement facile à maintenir. «Facile à entretenir» aurait tendance à dépendre de deux facteurs dominants:
- Ne pas avoir un réel besoin d'extensibilité (ex: savoir avec certitude que vous avez exactement 8 types de choses à traiter, et jamais plus).
- Ne pas avoir beaucoup d'endroits dans votre code qui doivent vérifier ces types (ex: un endroit central).
... pourtant je recommande le scénario ci-dessus dans la plupart des cas et itère vers des solutions plus efficaces par dévirtualisation partielle selon les besoins. Cela vous donne beaucoup plus de marge de manœuvre pour équilibrer les besoins d'extensibilité et de maintenabilité avec les performances.
Fonctions virtuelles et pointeurs de fonction
Pour couronner le tout, j'ai remarqué ici qu'il y avait une discussion sur les fonctions virtuelles par rapport aux pointeurs de fonction. Il est vrai que les fonctions virtuelles nécessitent un peu de travail supplémentaire pour être appelées, mais cela ne signifie pas qu'elles sont plus lentes. Contre-intuitivement, cela peut même les rendre plus rapides.
C'est contre-intuitif ici parce que nous sommes habitués à mesurer le coût en termes d'instructions sans prêter attention à la dynamique de la hiérarchie de la mémoire qui a tendance à avoir un impact beaucoup plus important.
Si nous comparons un class
avec 20 fonctions virtuelles à un struct
qui stocke 20 pointeurs de fonction, et les deux sont instanciés plusieurs fois, la surcharge de mémoire de chaque class
instance dans ce cas, 8 octets pour le pointeur virtuel sur les machines 64 bits, tandis que la mémoire la surcharge du struct
est de 160 octets.
Le coût pratique, il peut y avoir beaucoup plus de ratés de cache obligatoires et non obligatoires avec le tableau des pointeurs de fonction par rapport à la classe utilisant des fonctions virtuelles (et éventuellement des défauts de page à une échelle d'entrée suffisamment grande). Ce coût a tendance à éclipser le travail légèrement supplémentaire d'indexation d'une table virtuelle.
J'ai également traité des bases de code C héritées (plus anciennes que moi) où le fait de les structs
remplir de pointeurs de fonctions, et instancié de nombreuses fois, a en fait amélioré considérablement les performances (plus de 100% d'améliorations) en les transformant en classes avec des fonctions virtuelles, et simplement en raison de la réduction massive de l'utilisation de la mémoire, de la convivialité accrue du cache, etc.
D'un autre côté, lorsque les comparaisons deviennent plus sur des pommes avec des pommes, j'ai également trouvé la mentalité opposée de traduire d'un état d'esprit de fonction virtuelle C ++ en état d'esprit de pointeur de fonction de style C pour être utile dans ces types de scénarios:
class Functionoid
{
public:
virtual ~Functionoid() {}
virtual void operator()() = 0;
};
... où la classe stockait une seule fonction redoutable (ou deux si nous comptons le destructeur virtuel). Dans ces cas, cela peut certainement aider dans les chemins critiques à transformer cela en ceci:
void (*func_ptr)(void* instance_data);
... idéalement derrière une interface sécurisée pour cacher les lancers dangereux de / vers void*
.
Dans les cas où nous sommes tentés d'utiliser une classe avec une seule fonction virtuelle, cela peut rapidement aider à utiliser des pointeurs de fonction à la place. Une grande raison n'est même pas nécessairement le coût réduit de l'appel d'un pointeur de fonction. C'est parce que nous ne sommes plus confrontés à la tentation d'allouer chaque fonctionoïde séparée sur les régions dispersées du tas si nous les agrégons en une structure persistante. Ce type d'approche peut permettre d'éviter plus facilement les surcharges associées à la segmentation et à la mémoire si les données d'instance sont homogènes, par exemple, et seul le comportement varie.
Il y a donc certainement des cas où l'utilisation de pointeurs de fonction peut aider, mais souvent je l'ai trouvé dans l'autre sens si nous comparons un tas de tables de pointeurs de fonction à une seule table virtuelle qui ne nécessite qu'un seul pointeur pour être stocké par instance de classe . Cette table sera souvent placée dans une ou plusieurs lignes de cache L1 ainsi que dans des boucles serrées.
Conclusion
Donc de toute façon, c'est mon petit tour sur ce sujet. Je recommande de s'aventurer dans ces domaines avec prudence. Faites confiance aux mesures, pas à l'instinct, et étant donné la façon dont ces optimisations dégradent souvent la maintenabilité, n'allez que dans la mesure de vos moyens (et une voie sage serait d'errer du côté de la maintenabilité).