En général, vaut-il la peine d'utiliser des fonctions virtuelles pour éviter les branchements?


21

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 ifs 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)


12
Eh bien, quelles sont vos exigences de performance? Avez-vous des chiffres durs que vous devez atteindre ou vous engagez-vous dans une optimisation prématurée? Les méthodes de branchement et les méthodes virtuelles sont extrêmement bon marché dans le grand schéma des choses (par exemple, comparées aux mauvais algorithmes, aux E / S ou à l'allocation de tas).
amon

4
Faites tout ce qui est plus facile à lire / flexible / peu de chances d'obtenir de la manière des changements futurs, et une fois que vous avez à travailler alors faire du profilage et voir si cela importe réellement. Ce n'est généralement pas le cas.
Ixrec

1
Question: "Mais, en général, combien coûtent les fonctions virtuelles ..." Réponse: Branche indirecte (wikipedia)
rwong

1
N'oubliez pas que la plupart des réponses sont basées sur le comptage du nombre d'instructions. En tant qu'optimiseur de code de bas niveau, je ne fais pas confiance au nombre d'instructions; vous devez les prouver sur une architecture CPU particulière - physiquement - dans des conditions expérimentales. Les réponses valables pour cette question doivent être empiriques et expérimentales, pas théoriques.
rwong

3
Le problème avec cette question est qu'elle présuppose que c'est assez grand pour s'inquiéter. Dans les vrais logiciels, les problèmes de performances se présentent en gros morceaux, comme des tranches de pizza de plusieurs tailles. Par exemple, regardez ici . Ne présumez pas que vous savez quel est le plus gros problème - laissez le programme vous le dire. Corrigez cela, puis laissez-le vous dire quelle est la suivante. Faites-le une demi-douzaine de fois, et vous pourriez être là où les appels de fonction virtuelle méritent d'être inquiétés. Ils ne l'ont jamais fait, d'après mon expérience.
Mike Dunlavey

Réponses:


21

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 switchesou if/elseavec 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 ( switchou un tas d' if/elseinstructions). 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 switchdé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_ptrici.

... où Creatureest 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 newpour 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 creaturesconteneur 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 humansdans une boucle et other_creaturesdans 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 switchinstruction 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 classavec 20 fonctions virtuelles à un structqui stocke 20 pointeurs de fonction, et les deux sont instanciés plusieurs fois, la surcharge de mémoire de chaque classinstance dans ce cas, 8 octets pour le pointeur virtuel sur les machines 64 bits, tandis que la mémoire la surcharge du structest 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 structsremplir 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é).


Les fonctions virtuelles sont des pointeurs de fonctions, juste implémentées dans le viable de cette classe. Lorsqu'une fonction virtuelle est appelée, elle est d'abord recherchée dans l'enfant et dans la chaîne d'héritage. C'est pourquoi l'héritage profond est très coûteux et est généralement évité en c ++.
Robert Baron

@RobertBaron: Je n'ai jamais vu de fonctions virtuelles implémentées comme vous l'avez dit (= avec une recherche de chaîne dans la hiérarchie des classes). En règle générale, les compilateurs génèrent simplement une table "aplatie" pour chaque type de béton avec tous les pointeurs de fonction corrects, et au moment de l'exécution, l'appel est résolu avec une seule recherche de table directe; aucune pénalité n'est payée pour les hiérarchies d'héritage profondes.
Matteo Italia

Matteo, c'est l'explication qu'un responsable technique m'a donnée il y a de nombreuses années. Certes, c'était pour c ++, donc il peut avoir pris en considération les implications de l'héritage multiple. Merci d'avoir clarifié ma compréhension de l'optimisation des vtables.
Robert Baron

Merci pour la bonne réponse (+1). Je me demande dans quelle mesure cela s'applique à l'identique pour std :: visit au lieu des fonctions virtuelles.
DaveFar

13

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 une O(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() ... elseavec 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 une else 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 que switch()c'était déjà la possibilité la plus rapide.


2
Certains compilateurs permettent aux annotations d'indiquer au compilateur quel cas a plus de chances d'être vrai, et ces compilateurs peuvent produire du code plus rapidement tant que l'annotation est correcte.
gnasher729

5
une opération O (1) n'est pas nécessairement plus rapide en temps d'exécution dans le monde réel qu'une O (n) ou même O (n ^ 20).
whatsisname

2
@whatsisname C'est pourquoi j'ai dit "pour de nombreux cas". Par la définition de O(1)et O(n)il existe un kpour que la O(n)fonction soit supérieure à la O(1)fonction pour tous n >= k. La seule question est de savoir si vous êtes susceptible d'avoir autant de cas. Et, oui, j'ai vu des switch()déclarations avec tellement de cas qu'une else if()échelle est définitivement plus lente qu'un appel de fonction virtuelle ou une répartition chargée.
cmaster - réintègre monica

Le problème que j'ai avec cette réponse est que le seul avertissement contre une décision basée sur un gain de performance totalement non pertinent est caché quelque part dans l'avant-dernier paragraphe. Tout le reste prétend ici , il peut être une bonne idée de prendre une décision sur ifvs switchvs 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.
Doc Brown

7

En général, vaut-il la peine d'utiliser des fonctions virtuelles pour éviter les branchements?

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).

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 (adorable si c'était aussi simple que 4 ifs est le point d'arrêt)

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?

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)

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.


Ayant fait beaucoup de programmation de maintenance, je vais miser avec un peu de prudence: les fonctions virtuelles sont IMNSHO assez mauvaises pour la maintenance, précisément en raison des avantages que vous citez. Le problème central est leur flexibilité; vous pouvez y coller à peu près n'importe quoi ... et les gens le font. Il est très difficile de raisonner statiquement sur la répartition dynamique. Pourtant, dans la plupart des cas spécifiques, le code n'a pas besoin de toute cette flexibilité, et la suppression de la flexibilité d'exécution peut faciliter la réflexion sur le code. Pourtant, je ne veux pas aller jusqu'à dire que vous ne devriez jamais utiliser la répartition dynamique; c'est absurde.
Eamon Nerbonne

Les abstractions les plus agréables à utiliser sont celles qui sont rares (c'est-à-dire qu'une base de code n'a que quelques abstractions opaques), mais très robustes. Fondamentalement: ne collez pas quelque chose derrière une abstraction de répartition dynamique simplement parce qu'elle a une forme similaire pour un cas particulier; ne le faites que si vous ne pouvez raisonnablement concevoir aucune raison de vous soucier d'une distinction entre les objets partageant cette interface. Si vous ne pouvez pas: mieux vaut avoir une aide non encapsulante qu'une abstraction qui fuit. Et même alors; il y a un compromis entre la flexibilité d'exécution et la flexibilité de la base de code.
Eamon Nerbonne

5

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 switchsur 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'un virtualappel 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.

inline void
op0004(machine_state& state) noexcept
{
  const auto c = word_t {0xcf2802e8d0baca1dUL};
  const auto r1 = state.registers[58];
  const auto r2 = state.registers[69];
  const auto r3 = ((r1 + c) | r2);
  state.registers[6] = r3;
}

Le script génère également des routines de répartition à l'aide d'une switchinstruction…

inline int
dispatch(machine_state& state, const opcode_t opcode) noexcept
{
  switch (opcode)
  {
  case 0x0000: op0000(state); return 0;
  case 0x0001: op0001(state); return 0;
  // ...
  case 0x247a: op247a(state); return 0;
  case 0x247b: op247b(state); return 0;
  default:
    return -1;  // invalid opcode
  }
}

… Et un tableau de pointeurs de fonction.

inline int
dispatch(machine_state& state, const opcode_t opcode) noexcept
{
  typedef void (* func_type)(machine_state&);
  static const func_type table[VM_NUM_INSTRUCTIONS] = {
    op0000,
    op0001,
    // ...
    op247a,
    op247b,
  };
  if (opcode >= VM_NUM_INSTRUCTIONS)
    return -1;  // invalid opcode
  table[opcode](state);
  return 0;
}

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_devicemoteur 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, -O3et les -std=c++14commutateurs. Tout d'abord, il a été compilé à l'aide des -fprofile-generatedonnées d'option et de profil collectées pour simuler 1000 instructions aléatoires. Le code a ensuite été recompilé avec l' -fprofile-useoption 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.

entrez la description de l'image ici

À 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 switchversion 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 .


3

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.


Je ne sais pas si une fonction virtuelle implique des "étapes supplémentaires". Étant donné que la disposition de la classe est connue au moment de la compilation, elle est essentiellement identique à un accès au tableau. C'est-à-dire qu'il y a un pointeur vers le haut de la classe, et l'offset de la fonction est connu, alors ajoutez simplement cela dans, lisez le résultat, et c'est l'adresse. Pas beaucoup de frais généraux.

1
Cela implique des étapes supplémentaires. La vtable elle-même contient des pointeurs de fonction, donc lorsque vous arrivez à la vtable, vous avez atteint le même état dans lequel vous avez commencé avec un pointeur de fonction. Tout avant d'arriver à la table est un travail supplémentaire. Les classes ne contiennent pas leurs vtables, elles contiennent des pointeurs vers vtables, et suivre ce pointeur est une déréférence supplémentaire. En fait, il existe parfois un troisième déréférencement car les classes polymorphes sont généralement détenues par le pointeur de classe de base, vous devez donc déréférencer un pointeur pour obtenir l'adresse vtable (pour le déréférencer ;-)).
Nir Friedman

D'un autre côté, le fait que la vtable soit stockée en dehors de l'instance peut en fait être utile pour la localité temporelle par rapport, disons, à un tas de structures disparates de pointeurs de fonction où chaque pointeur de fonction est stocké dans une adresse mémoire différente. Dans de tels cas, une seule table virtuelle avec un million de vptrs peut facilement battre un million de tables de pointeurs de fonction (en commençant par la simple consommation de mémoire). Cela peut être quelque peu compliqué ici - pas si facile à décomposer. En général, je suis d'accord que le pointeur de fonction est souvent un peu moins cher, mais ce n'est pas si facile de mettre l'un au-dessus de l'autre.

Je pense, en d'autres termes, que les fonctions virtuelles commencent à surpasser rapidement et grossièrement les pointeurs de fonction lorsque vous avez une multitude d'instances d'objet impliquées (où chaque objet devrait stocker soit plusieurs pointeurs de fonction soit un seul vptr). Les pointeurs de fonction ont tendance à être moins chers si vous avez, par exemple, un seul pointeur de fonction stocké en mémoire qui va être appelé une cargaison de fois. Sinon, les pointeurs de fonction peuvent commencer à ralentir avec la quantité de redondance des données et les erreurs de cache qui résultent de la mémoire de nombreuses surcharges redondantes et pointant vers la même adresse.

Bien sûr, avec les pointeurs de fonction, vous pouvez également les stocker dans un emplacement central même s'ils sont partagés par un million d'objets distincts pour éviter de surcharger la mémoire et d'obtenir une cargaison de cache manquant. Mais ensuite, ils commencent à devenir équivalents aux vpointers, impliquant un accès par pointeur à un emplacement partagé en mémoire pour accéder aux adresses de fonction réelles que nous voulons appeler. La question fondamentale ici est: stockez-vous l'adresse de la fonction plus près des données auxquelles vous accédez actuellement ou dans un emplacement central? vtables n'autorise que ce dernier. Les pointeurs de fonction permettent les deux sens.

2

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.

entrez la description de l'image ici

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.)

entrez la description de l'image ici

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é.


il a déjà dit qu'il avait plusieurs "sections hautement critiques" qui nécessitent chaque dernière nanoseconde de performance. Ce n'est donc pas une réponse à la question qu'il a posée (même si ce serait une excellente réponse à la question de quelqu'un d'autre)
gbjbaanb

2
@gbjbaanb: Si chaque dernière nanoseconde compte, pourquoi la question commence-t-elle par "en général"? C'est absurde. Lorsque les nanosecondes comptent, vous ne pouvez pas chercher de réponses générales, vous regardez ce que fait le compilateur, vous regardez ce que fait le matériel, vous essayez des variations et vous mesurez chaque variation.
gnasher729

@ gnasher729 Je ne sais pas, mais pourquoi se termine-t-il par des "sections très critiques"? Je suppose que, comme slashdot, il faut toujours lire le contenu, et pas seulement le titre!
gbjbaanb

2
@gbjbaanb: Tout le monde dit qu'ils ont des "sections très critiques". Comment savent-ils? Je ne sais pas que quelque chose est critique jusqu'à ce que je prenne, disons, 10 échantillons et que je le voie sur 2 d'entre eux ou plus. Dans un cas comme celui-ci, si les méthodes appelées prennent plus de 10 instructions, la surcharge de la fonction virtuelle est probablement insignifiante.
Mike Dunlavey

@ gnasher729: Eh bien, la première chose que je fais est d'obtenir des échantillons de pile, et sur chacun, examinez ce que fait le programme et pourquoi. Ensuite, s'il passe tout son temps dans les feuilles de l'arborescence des appels, et que tous les appels sont vraiment inévitables , quel est le rôle du compilateur et du matériel. Vous savez que la répartition des méthodes est importante si les échantillons arrivent dans le processus de répartition des méthodes.
Mike Dunlavey
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.