web-dev-qa-db-fra.com

En C ++ pourquoi et comment les fonctions virtuelles sont-elles plus lentes?

Quelqu'un peut-il expliquer en détail comment fonctionne exactement la table virtuelle et quels pointeurs sont associés lorsque des fonctions virtuelles sont appelées.

S'ils sont réellement plus lents, pouvez-vous montrer que le temps nécessaire à l'exécution de la fonction virtuelle est supérieur aux méthodes de classe normales? Il est facile de perdre la trace de comment/ce qui se passe sans voir de code.

39
MdT

Les méthodes virtuelles sont généralement implémentées via des tables de méthodes dites virtuelles (vtable en abrégé), dans lesquelles les pointeurs de fonction sont stockés. Cela ajoute une indirection à l'appel réel (je dois récupérer l'adresse de la fonction à appeler à partir de la table virtuelle, puis appelez-la plutôt que de l'appeler juste à l'avance). Bien sûr, cela prend du temps et du code.

Cependant, ce n'est pas nécessairement la principale cause de lenteur. Le vrai problème est que le compilateur (généralement/généralement) ne peut pas savoir quelle fonction sera appelée. Il ne peut donc pas l'intégrer ou effectuer d'autres optimisations de ce type. Cela seul pourrait ajouter une douzaine d'instructions inutiles (préparation des registres, appel, puis restauration de l'état par la suite), et pourrait inhiber d'autres optimisations apparemment sans rapport. De plus, si vous vous branchez comme un fou en appelant de nombreuses implémentations différentes, vous subissez les mêmes coups que vous souffririez d'une branche comme un fou par d'autres moyens: le prédicteur de cache et de branche ne vous aidera pas, les branches prendront plus de temps qu'un parfaitement prévisible branche.

Gros mais : Ces résultats de performance sont généralement trop petits pour être importants. Ils valent la peine d'être pris en compte si vous souhaitez créer un code haute performance et envisager d'ajouter une fonction virtuelle qui serait appelée à une fréquence alarmante. Cependant, aussi gardez à l'esprit que le remplacement des appels de fonction virtuelle par d'autres moyens de branchement (if .. else, switch, pointeurs de fonction, etc.) ne résoudra pas le problème fondamental - il pourrait très bien être plus lent. Le problème (s'il existe) n'est pas les fonctions virtuelles mais l'indirection (inutile).

Modifier: la différence dans les instructions d'appel est décrite dans d'autres réponses. Fondamentalement, le code d'un appel statique ("normal") est:

  • Copiez certains registres sur la pile pour permettre à la fonction appelée d'utiliser ces registres.
  • Copiez les arguments dans des emplacements prédéfinis, afin que la fonction appelée puisse les trouver indépendamment de l'endroit où elle est appelée.
  • Appuyez sur l'adresse de retour.
  • Branche/saute au code de la fonction, qui est une adresse au moment de la compilation et donc codée en dur dans le binaire par le compilateur/éditeur de liens.
  • Obtenez la valeur de retour à partir d'un emplacement prédéfini et restaurez les registres que nous voulons utiliser.

Un appel virtuel fait exactement la même chose, sauf que l'adresse de la fonction n'est pas connue au moment de la compilation. Au lieu de cela, quelques instructions ...

  • Obtenez le pointeur vtable, qui pointe vers un tableau de pointeurs de fonction (adresses de fonction), un pour chaque fonction virtuelle, à partir de l'objet.
  • Obtenez la bonne adresse de fonction de la table virtuelle dans un registre (l'index où la bonne adresse de fonction est stockée est décidé au moment de la compilation).
  • Sautez à l'adresse dans ce registre, plutôt que de sauter à une adresse codée en dur.

En ce qui concerne les branches: Une branche est tout ce qui passe à une autre instruction au lieu de simplement laisser la prochaine instruction s'exécuter. Cela inclut if, switch, des parties de diverses boucles, des appels de fonction, etc. et parfois le compilateur implémente des choses qui ne semblent pas se ramifier d'une manière qui a réellement besoin d'une branche sous le capot . Voir Pourquoi le traitement d'un tableau trié est-il plus rapide qu'un tableau non trié? pour savoir pourquoi cela peut être lent, ce que les processeurs font pour contrer ce ralentissement et comment ce n'est pas un remède universel.

56
user7043

Voici du code désassemblé réel provenant d'un appel de fonction virtuelle et d'un appel non virtuel, respectivement:

mov    -0x8(%rbp),%rax
mov    (%rax),%rax
mov    (%rax),%rax
callq  *%rax

callq  0x4007aa

Vous pouvez voir que l'appel virtuel nécessite trois instructions supplémentaires pour rechercher l'adresse correcte, tandis que l'adresse de l'appel non virtuel peut être compilée.

Cependant, notez que la plupart du temps, ce temps de recherche supplémentaire peut être considéré comme négligeable. Dans les situations où le temps de recherche serait important, comme dans une boucle, la valeur peut généralement être mise en cache en exécutant les trois premières instructions avant la boucle.

L'autre situation où le temps de recherche devient significatif est si vous avez une collection d'objets et que vous effectuez une boucle en appelant une fonction virtuelle sur chacun d'eux. Cependant, dans ce cas, vous aurez besoin de certains moyens de sélectionner la fonction à appeler de toute façon, et une recherche de table virtuelle est un moyen aussi bon que n'importe quel autre. En fait, puisque le code de recherche vtable est si largement utilisé qu'il est fortement optimisé, donc essayer de le contourner manuellement a de bonnes chances d'entraîner pire performances.

24
Karl Bielefeldt

Plus lent que quoi?

Les fonctions virtuelles résolvent un problème qui ne peut pas être résolu par des appels de fonction directs. En général, vous ne pouvez comparer que deux programmes qui calculent la même chose. "Ce ray tracer est plus rapide que ce compilateur" n'a pas de sens, et ce principe se généralise même aux petites choses comme les fonctions individuelles ou les constructions de langage de programmation.

Si vous n'utilisez pas de fonction virtuelle pour basculer dynamiquement vers un morceau de code basé sur une donnée, comme le type d'un objet, vous devrez utiliser autre chose, comme une instruction switch pour accomplir la même chose chose. Cette autre chose a ses propres frais généraux, ainsi que des implications sur l'organisation du programme qui influencent sa maintenabilité et sa performance globale.

Notez qu'en C++, les appels aux fonctions virtuelles ne sont pas toujours dynamiques. Lorsque des appels sont effectués sur un objet dont le type exact est connu (car l'objet n'est pas un pointeur ou une référence, ou parce que son type peut autrement être déduit statiquement), les appels ne sont que des appels de fonction membre normaux. Cela signifie non seulement qu'il n'y a pas de frais d'envoi, mais aussi que ces appels peuvent être alignés de la même manière que les appels ordinaires.

En d'autres termes, votre compilateur C++ peut fonctionner lorsque les fonctions virtuelles ne nécessitent pas de répartition virtuelle, il n'y a donc généralement aucune raison de vous inquiéter de leurs performances par rapport aux fonctions non virtuelles.

Nouveau: Aussi, nous ne devons pas oublier les bibliothèques partagées. Si vous utilisez une classe qui se trouve dans une bibliothèque partagée, l'appel à une fonction membre ordinaire ne sera pas simplement une jolie séquence d'instructions comme callq 0x4007aa. Il doit passer par quelques cercles, comme indirectement par le biais d'une "table de liens de programmes" ou d'une telle structure. Par conséquent, l'indirection de bibliothèque partagée pourrait niveler quelque peu (sinon complètement) la différence de coût entre un appel virtuel (vraiment indirect) et un appel direct. Le raisonnement sur les compromis de fonction virtuelle doit donc prendre en compte la façon dont le programme est construit: si la classe de l'objet cible est liée de façon monolithique au programme qui effectue l'appel.

19
Kaz

car un appel virtuel équivaut à

res_t (*foo)(arg_t);
foo = (obj->vtable[foo_offset]);
foo(obj,args)

où, avec une fonction non virtuelle, le compilateur peut replier la première ligne de façon constante, c'est un déréférencement, un ajout et un appel dynamique transformé en un appel statique

cela lui permet également d'intégrer la fonction (avec toutes les conséquences d'optimisation dues)

13
ratchet freak