Il semble y avoir des équivalents approximatifs d'instructions à assimiler au coût d'une branche manquant. Les fonctions virtuelles ont un compromis similaire:
- instruction vs manque de cache de données
- barrière d'optimisation
Si vous regardez quelque chose comme:
if (x==1) {
p->do1();
}
else if (x==2) {
p->do2();
}
else if (x==3) {
p->do3();
}
...
Vous pouvez avoir un tableau de fonctions membre, ou si de nombreuses fonctions dépendent de la même catégorisation, ou si une catégorisation plus complexe existe, utilisez des fonctions virtuelles:
p->do()
Mais, en général, combien coûtent les fonctions virtuelles par rapport aux branchements Il est difficile de tester sur suffisamment de plates-formes pour généraliser, donc je me demandais si quelqu'un avait une règle approximative (charmant si c'était aussi simple que 4 if
s est le point d'arrêt)
En général, les fonctions virtuelles sont plus claires et je pencherais pour elles. Mais, j'ai plusieurs sections très critiques où je peux changer le code des fonctions virtuelles en branches. Je préférerais avoir des réflexions à ce sujet avant d'entreprendre cela. (ce n'est pas un changement trivial ou facile à tester sur plusieurs plates-formes)
la source
Réponses:
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
ouif/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.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:
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éfautoperator 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.
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.
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:
... 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 etother_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):
... 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 faireswitch
.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:
... 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 à unstruct
qui stocke 20 pointeurs de fonction, et les deux sont instanciés plusieurs fois, la surcharge de mémoire de chaqueclass
instance dans ce cas, 8 octets pour le pointeur virtuel sur les machines 64 bits, tandis que la mémoire la surcharge dustruct
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:
... 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:
... 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é).
la source
Observations:
Dans de nombreux cas, les fonctions virtuelles sont plus rapides car la recherche de table est une
O(1)
opération tandis que l'else if()
échelle est uneO(n)
opération. Cependant, cela n'est vrai que si la distribution des cas est plate.Pour un single
if() ... else
, le conditionnel est plus rapide car vous enregistrez la surcharge d'appel de fonction.Ainsi, lorsque vous avez une distribution uniforme des cas, un seuil de rentabilité doit exister. La seule question est de savoir où il se trouve.
Si vous utilisez un
switch()
au lieu d'else if()
appels de fonction Ladder ou virtuels, votre compilateur peut produire un code encore meilleur: il peut créer une branche vers un emplacement qui est recherché à partir d'une table, mais qui n'est pas un appel de fonction. Autrement dit, vous disposez de toutes les propriétés de l'appel de fonction virtuelle sans la surcharge de l'appel de fonction.Si l'un est beaucoup plus fréquent que les autres, commencer un
if() ... else
avec ce cas vous donnera les meilleures performances: vous exécuterez une seule branche conditionnelle qui est correctement prédite dans la plupart des cas.Votre compilateur n'a aucune connaissance de la distribution attendue des cas et assumera une distribution uniforme.
Étant donné que votre compilateur a probablement de bonnes heuristiques en place quant au moment de coder un
switch()
comme uneelse if()
échelle ou une recherche de table. J'aurais tendance à faire confiance à son jugement à moins que vous ne sachiez que la distribution des cas est biaisée.Donc, mon conseil est le suivant:
Si l'un des cas éclipse le reste en termes de fréquence, utilisez une
else if()
échelle triée .Sinon, utilisez une
switch()
instruction, sauf si l'une des autres méthodes rend votre code beaucoup plus lisible. Assurez-vous que vous n'achetez pas un gain de performances négligeable avec une lisibilité considérablement réduite.Si vous avez utilisé un
switch()
et que vous n'êtes toujours pas satisfait des performances, faites la comparaison, mais soyez prêt à découvrir queswitch()
c'était déjà la possibilité la plus rapide.la source
O(1)
etO(n)
il existe unk
pour que laO(n)
fonction soit supérieure à laO(1)
fonction pour tousn >= k
. La seule question est de savoir si vous êtes susceptible d'avoir autant de cas. Et, oui, j'ai vu desswitch()
déclarations avec tellement de cas qu'uneelse if()
échelle est définitivement plus lente qu'un appel de fonction virtuelle ou une répartition chargée.if
vsswitch
vs fonctions virtuelles basées sur perfomance. Dans des cas extrêmement rares, cela peut l'être, mais dans la majorité des cas, ce n'est pas le cas.En général, oui. Les avantages pour la maintenance sont importants (tests en séparation, séparation des préoccupations, modularité et extensibilité améliorées).
Sauf si vous avez profilé votre code et que vous savez que la répartition entre les branches ( l'évaluation des conditions ) prend plus de temps que les calculs effectués ( le code dans les branches ), optimisez les calculs effectués.
C'est-à-dire que la bonne réponse à «combien coûtent les fonctions virtuelles par rapport aux branchements» est de mesurer et de découvrir.
Règle générale : à moins d'avoir la situation ci-dessus (discrimination de branche plus chère que les calculs de branche), optimisez cette partie du code pour l'effort de maintenance (utilisez des fonctions virtuelles).
Vous dites que vous souhaitez que cette section s'exécute le plus rapidement possible; À quelle vitesse est-ce? Quelle est votre exigence concrète?
Utilisez alors des fonctions virtuelles. Cela vous permettra même d'optimiser par plate-forme si nécessaire, tout en gardant le code client propre.
la source
Les autres réponses fournissent déjà de bons arguments théoriques. Je voudrais ajouter les résultats d'une expérience que j'ai effectuée récemment pour estimer si ce serait une bonne idée d'implémenter une machine virtuelle (VM) en utilisant un grand
switch
sur le code op ou plutôt d'interpréter le code op comme un index dans un tableau de pointeurs de fonction. Bien que ce ne soit pas exactement la même chose qu'unvirtual
appel de fonction, je pense qu'il est raisonnablement proche.J'ai écrit un script Python pour générer de façon aléatoire du code C ++ 14 pour une machine virtuelle avec une taille de jeu d'instructions choisie de manière aléatoire (bien que pas uniformément, en échantillonnant la plage basse de manière plus dense) entre 1 et 10000. La machine virtuelle générée avait toujours 128 registres et aucun RAM. Les instructions n'ont pas de sens et ont toutes le format suivant.
Le script génère également des routines de répartition à l'aide d'une
switch
instruction…… Et un tableau de pointeurs de fonction.
La routine de répartition qui a été générée a été choisie au hasard pour chaque machine virtuelle générée.
Pour l'analyse comparative, le flux de codes opérationnels a été généré par un
std::random_device
moteur aléatoire Twister Mersenne (std::mt19937_64
).Le code pour chaque machine virtuelle a été compilé avec GCC 5.2.0 en utilisant le
-DNDEBUG
,-O3
et les-std=c++14
commutateurs. Tout d'abord, il a été compilé à l'aide des-fprofile-generate
données d'option et de profil collectées pour simuler 1000 instructions aléatoires. Le code a ensuite été recompilé avec l'-fprofile-use
option permettant des optimisations basées sur les données de profil collectées.La VM a ensuite été exercée (dans le même processus) quatre fois pendant 50 000 000 cycles et le temps pour chaque analyse a été mesuré. La première exécution a été rejetée pour éliminer les effets de cache froid. Le PRNG n'a pas été réensemencé entre les essais afin de ne pas exécuter la même séquence d'instructions.
En utilisant cette configuration, 1000 points de données pour chaque routine de répartition ont été collectés. Les données ont été collectées sur un APU quadricœur AMD A8-6600K avec un cache de 2048 Ko fonctionnant sous GNU / Linux 64 bits sans bureau graphique ni autres programmes en cours d'exécution. Vous trouverez ci-dessous un graphique du temps CPU moyen (avec écart-type) par instruction pour chaque machine virtuelle.
À partir de ces données, je pouvais avoir confiance que l'utilisation d'une table de fonction est une bonne idée, sauf peut-être pour un très petit nombre de codes opérationnels. Je n'ai pas d'explication pour les valeurs aberrantes de la
switch
version entre 500 et 1000 instructions.Tout le code source de l'indice de référence ainsi que les données expérimentales complètes et un tracé à haute résolution peuvent être trouvés sur mon site Web .
la source
En plus de la bonne réponse de cmaster, que j'ai surévaluée, gardez à l'esprit que les pointeurs de fonction sont généralement strictement plus rapides que les fonctions virtuelles. La répartition des fonctions virtuelles implique généralement d'abord le suivi d'un pointeur de l'objet vers la table virtuelle, l'indexation appropriée, puis le déréférencement d'un pointeur de fonction. La dernière étape est donc la même, mais il y a des étapes supplémentaires au départ. De plus, les fonctions virtuelles prennent toujours "ceci" comme argument, les pointeurs de fonction sont plus flexibles.
Une autre chose à garder à l'esprit: si votre chemin critique implique une boucle, il peut être utile de trier la boucle par destination de répartition. Évidemment, c'est nlogn, alors que parcourir la boucle n'est que n, mais si vous allez parcourir plusieurs fois cela peut valoir la peine. En triant par destination d'expédition, vous vous assurez que le même code est exécuté à plusieurs reprises, le gardant au chaud dans icache, minimisant les erreurs de cache.
Une troisième stratégie à garder à l'esprit: si vous décidez de vous éloigner des fonctions virtuelles / pointeurs de fonction vers des stratégies if / switch, vous pouvez également être bien servi en passant d'objets polymorphes à quelque chose comme boost :: variant (qui fournit également le commutateur sous forme d'abstraction du visiteur). Les objets polymorphes doivent être stockés par le pointeur de base, donc vos données sont partout dans le cache. Cela pourrait facilement avoir une plus grande influence sur votre chemin critique que le coût de la recherche virtuelle. Considérant que la variante est stockée en ligne en tant qu'union discriminée; il a une taille égale au plus grand type de données (plus une petite constante). Si vos objets ne diffèrent pas trop en taille, c'est un excellent moyen de les manipuler.
En fait, je ne serais pas surpris si l'amélioration de la cohérence du cache de vos données aurait un impact plus important que votre question d'origine, alors j'y réfléchirais certainement plus.
la source
Puis-je simplement expliquer pourquoi je pense que c'est un problème XY ? (Vous n'êtes pas seul à leur demander.)
Je suppose que votre véritable objectif est de gagner du temps dans l'ensemble, et pas seulement de comprendre un point sur les erreurs de cache et les fonctions virtuelles.
Voici un exemple de réglage des performances réelles , dans de vrais logiciels.
Dans les vrais logiciels, les choses se font qui, quelle que soit l'expérience du programmeur, pourraient être mieux faites. On ne sait pas ce qu'ils sont jusqu'à ce que le programme soit écrit et que le réglage des performances puisse être fait. Il existe presque toujours plus d'une façon d'accélérer le programme. Après tout, pour dire qu'un programme est optimal, vous dites qu'au panthéon des programmes possibles pour résoudre votre problème, aucun d'entre eux ne prend moins de temps. Vraiment?
Dans l'exemple que j'ai lié, cela prenait à l'origine 2700 microsecondes par "travail". Une série de six problèmes ont été corrigés, allant dans le sens antihoraire autour de la pizza. La première accélération a supprimé 33% du temps. Le second a supprimé 11%. Mais remarquez, le deuxième n'était pas de 11% au moment où il a été trouvé, il était de 16%, car le premier problème avait disparu . De même, le troisième problème a été amplifié de 7,4% à 13% (presque le double) car les deux premiers problèmes avaient disparu.
À la fin, ce processus d'agrandissement a permis d'éliminer toutes les microsecondes sauf 3,7. Cela représente 0,14% du temps d'origine, soit une accélération de 730x.
La suppression des problèmes initialement importants donne une accélération modérée, mais ils ouvrent la voie à la suppression des problèmes ultérieurs. Ces problèmes ultérieurs auraient pu initialement être des parties insignifiantes du total, mais après l'élimination des premiers problèmes, ces petits problèmes deviennent importants et peuvent produire de grandes accélérations. (Il est important de comprendre que, pour obtenir ce résultat, aucun ne peut être manqué, et ce post montre à quel point ils peuvent être facilement.)
Le programme final était-il optimal? Probablement pas. Aucun des accélérations n'avait quoi que ce soit à voir avec les ratés du cache. Est-ce que les erreurs de cache importent maintenant? Peut être.
EDIT: Je reçois des downvotes de la part de personnes se focalisant sur les "sections hautement critiques" de la question du PO. Vous ne savez pas que quelque chose est «hautement critique» tant que vous ne savez pas quelle fraction de temps cela représente. Si le coût moyen de ces méthodes appelées est de 10 cycles ou plus, au fil du temps, la méthode de leur envoyer n'est probablement pas «critique», par rapport à ce qu'elles font réellement. Je vois cela encore et encore, où les gens considèrent "avoir besoin de chaque nanoseconde" comme une raison d'être sage et insensé.
la source