Quel est le coût en termes de performances d’avoir une méthode virtuelle dans une classe C++ ?

StackOverflow https://stackoverflow.com/questions/667634

Question

Avoir au moins une méthode virtuelle dans une classe C++ (ou l'une de ses classes parentes) signifie que la classe aura une table virtuelle et que chaque instance aura un pointeur virtuel.

Le coût de la mémoire est donc assez clair.Le plus important est le coût mémoire sur les instances (surtout si les instances sont petites, par exemple si elles sont juste censées contenir un entier :dans ce cas, avoir un pointeur virtuel dans chaque instance pourrait doubler la taille des instances.Quant à l'espace mémoire utilisé par les tables virtuelles, je suppose qu'il est généralement négligeable par rapport à l'espace utilisé par le code de méthode réel.

Cela m'amène à ma question :existe-t-il un coût de performance mesurable (c.-à-d.impact vitesse) pour rendre une méthode virtuelle ?Il y aura une recherche dans la table virtuelle au moment de l'exécution, à chaque appel de méthode, donc s'il y a des appels très fréquents à cette méthode, et si cette méthode est très courte, alors il pourrait y avoir une baisse de performances mesurable ?Je suppose que cela dépend de la plate-forme, mais quelqu'un a-t-il effectué des tests ?

La raison pour laquelle je pose la question est que je suis tombé sur un bug dû à un programmeur oubliant de définir une méthode virtuelle.Ce n'est pas la première fois que je vois ce genre d'erreur.Et j'ai pensé :Pourquoi faisons-nous ajouter le mot-clé virtuel en cas de besoin au lieu de suppression le mot-clé virtuel quand on est absolument sûr qu'il l'est pas nécessaire?Si le coût de performance est faible, je pense que je recommanderai simplement ce qui suit à mon équipe :faire simplement chaque méthode virtual par défaut, y compris le destructeur, dans chaque classe, et ne la supprimez que lorsque vous en avez besoin.Cela vous semble fou ?

Était-ce utile?

La solution

Je couru quelques timings sur un 3GHz in- commander le processeur PowerPC. Sur cette architecture, un appel de fonction virtuelle coûte 7 nanosecondes plus qu'un appel de fonction directe (non virtuelle).

Alors, ne vaut pas vraiment se soucier du coût, sauf si la fonction est quelque chose comme un Get trivial () / Set () accesseur, où tout autre que inline est une sorte de gaspillage. Une tête 7ns sur une fonction qui inlines à 0.5ns est sévère; une tête 7ns sur une fonction qui prend 500ms pour exécuter n'a pas de sens.

Le grand coût des fonctions virtuelles ne sont pas vraiment la recherche d'un pointeur de fonction dans le vtable (qui est habituellement juste un seul cycle), mais que le saut indirect ne peut généralement pas être prédit branche. Cela peut entraîner une grande bulle de pipeline que le processeur ne peut pas aller chercher des instructions jusqu'à ce que le saut indirect (l'appel par l'intermédiaire du pointeur de fonction) est retiré et un nouveau pointeur d'instruction calculée. Ainsi, le coût d'un appel de fonction virtuelle est beaucoup plus grand que cela puisse paraître de regarder à l'assemblée ... mais seulement 7 nanosecondes.

Modifier Andrew, ne suis pas sûr, et d'autres soulèvent également le très bon point qu'un appel de fonction virtuelle peut provoquer un défaut de cache d'instructions: si vous sautez vers une adresse de code qui ne sont pas dans le cache alors tout le programme se arrête mort tandis que les instructions sont extraites de la mémoire principale. Ceci est toujours un décrochage important: le xénon, environ 650 cycles (par mes tests).

Toutefois, ce n'est pas un problème spécifique aux fonctions virtuelles, car même un appel de fonction directe provoquera une manquer si vous sautez aux instructions qui ne sont pas dans le cache. Ce qui importe est de savoir si la fonction a été exécutée avant récemment (le rendant plus susceptible d'être dans le cache), et si votre architecture peut prédire les branches statiques (non virtuels) et aller chercher ces instructions dans le cache à l'avance. Mon PPC n'a pas, mais peut-être le matériel le plus récent d'Intel fait.

Mes horaires de contrôle de l'influence des misses ICACHE sur l'exécution (délibérément, depuis que je suis en train d'examiner la conduite de CPU en vase clos), de sorte qu'ils escompte que le coût.

Autres conseils

Il y a certainement en tête mesurable lorsque vous appelez une fonction virtuelle - l'appel doit utiliser le vtable pour résoudre l'adresse de la fonction pour ce type d'objet. Les instructions supplémentaires sont le moindre de vos soucis. Non seulement vtables empêchent de nombreuses optimisations du compilateur potentiels (puisque le type est polymorphique le compilateur), ils peuvent également thrash votre I-Cache.

Bien sûr que ces sanctions sont importantes ou non dépend de votre application, à quelle fréquence les chemins de code sont exécutées, et vos habitudes d'héritage.

A mon avis cependant, ayant tout comme virtuel par défaut est une solution globale à un problème que vous pourriez résoudre d'autres façons.

Peut-être que vous pourriez voir comment les classes sont conçues / documentées / écrit. En général, l'en-tête d'une classe doit bien préciser quelles fonctions peuvent être remplacées par des classes dérivées et la façon dont ils sont appelés. programmeurs ayant écrire cette documentation est utile pour assurer qu'ils sont correctement identifiés comme virtuel.

Je dirais aussi que toutes les fonctions déclarant que virtuelle pourrait conduire à plus de bugs que simplement oublier de marquer quelque chose d'aussi virtuel. Si toutes les fonctions sont tout virtuel peut être remplacé par des classes de base - publique, protégée, privée - tout devient un jeu équitable. Les sous-classes d'accident ou de l'intention pourrait alors changer le comportement des fonctions qui causent alors des problèmes lorsqu'ils sont utilisés dans la mise en œuvre de base.

Cela dépend. :) (Aviez-vous prévu quelque chose d'autre?)

Une fois qu'une classe obtient une fonction virtuelle, il ne peut plus être un type de POD, (il ne peut pas avoir été un avant soit, dans ce cas, cela ne fera pas une différence) et qui fait toute une gamme d'optimisations impossibles .

std :: copy () sur les types de POD simples peut recourir à une simple routine memcpy, mais les types non-POD doivent être plus soigneusement traitées.

Construction devient beaucoup plus lent parce que le vtable doit être initialisé. Dans le pire des cas, la différence de performance entre POD et non-POD types de données peut être important.

Dans le pire des cas, vous pouvez voir 5x exécution plus lente (ce nombre est tiré d'un projet universitaire, je l'ai fait récemment réimplémenter quelques classes de la bibliothèque standard. Notre conteneur a à peu près 5 fois plus de temps pour construire, dès que le type de données qu'il stocké a obtenu un vtable)

Bien sûr, dans la plupart des cas, vous avez peu de chances de voir une différence de performance mesurable, est simplement de souligner que dans certains cas à la frontière, il peut être coûteux.

Cependant, la performance ne devrait pas être votre principale considération ici. Faire tout virtuel est pas une solution parfaite pour d'autres raisons.

Permettre tout à être redéfinie dans les classes dérivées rend beaucoup plus difficile de maintenir invariants de classe. Comment une garantie de classe qu'il reste dans un état cohérent lorsque l'une de ses méthodes pourraient être redéfinie à tout moment?

Faire tout virtuel peut éliminer quelques bugs potentiels, mais il introduit aussi de nouvelles.

Si vous avez besoin de la fonctionnalité d'envoi virtuel, vous devez payer le prix. L'avantage de C ++ est que vous pouvez utiliser une implémentation très efficace d'envoi virtuel fourni par le compilateur, plutôt que d'une version peut-être inefficace vous vous mettre en œuvre.

Cependant, vous avec les frais généraux d'exploitation forestière si vous ne needx il est peut-être aller un peu trop loin. Et la plupart classesare pas conçu pour être hérité de -. Pour créer une bonne classe de base exige plus que de faire ses fonctions virtuelles

La répartition virtuelle est d'un ordre de grandeur plus lente que certaines alternatives - pas tant en raison de l'indirection que de la prévention de l'inline.Ci-dessous, j'illustre cela en contrastant la répartition virtuelle avec une implémentation intégrant un "numéro de type (d'identification)" dans les objets et en utilisant une instruction switch pour sélectionner le code spécifique au type.Cela évite complètement la surcharge des appels de fonction - il suffit de faire un saut local.Il existe un coût potentiel en termes de maintenabilité, de dépendances de recompilation, etc. en raison de la localisation forcée (dans le commutateur) de la fonctionnalité spécifique au type.


MISE EN ŒUVRE

#include <iostream>
#include <vector>

// virtual dispatch model...

struct Base
{
    virtual int f() const { return 1; }
};

struct Derived : Base
{
    virtual int f() const { return 2; }
};

// alternative: member variable encodes runtime type...

struct Type
{
    Type(int type) : type_(type) { }
    int type_;
};

struct A : Type
{
    A() : Type(1) { }
    int f() const { return 1; }
};

struct B : Type
{
    B() : Type(2) { }
    int f() const { return 2; }
};

struct Timer
{
    Timer() { clock_gettime(CLOCK_MONOTONIC, &from); }
    struct timespec from;
    double elapsed() const
    {
        struct timespec to;
        clock_gettime(CLOCK_MONOTONIC, &to);
        return to.tv_sec - from.tv_sec + 1E-9 * (to.tv_nsec - from.tv_nsec);
    }
};

int main(int argc)
{
  for (int j = 0; j < 3; ++j)
  {
    typedef std::vector<Base*> V;
    V v;

    for (int i = 0; i < 1000; ++i)
        v.push_back(i % 2 ? new Base : (Base*)new Derived);

    int total = 0;

    Timer tv;

    for (int i = 0; i < 100000; ++i)
        for (V::const_iterator i = v.begin(); i != v.end(); ++i)
            total += (*i)->f();

    double tve = tv.elapsed();

    std::cout << "virtual dispatch: " << total << ' ' << tve << '\n';

    // ----------------------------

    typedef std::vector<Type*> W;
    W w;

    for (int i = 0; i < 1000; ++i)
        w.push_back(i % 2 ? (Type*)new A : (Type*)new B);

    total = 0;

    Timer tw;

    for (int i = 0; i < 100000; ++i)
        for (W::const_iterator i = w.begin(); i != w.end(); ++i)
        {
            if ((*i)->type_ == 1)
                total += ((A*)(*i))->f();
            else
                total += ((B*)(*i))->f();
        }

    double twe = tw.elapsed();

    std::cout << "switched: " << total << ' ' << twe << '\n';

    // ----------------------------

    total = 0;

    Timer tw2;

    for (int i = 0; i < 100000; ++i)
        for (W::const_iterator i = w.begin(); i != w.end(); ++i)
            total += (*i)->type_;

    double tw2e = tw2.elapsed();

    std::cout << "overheads: " << total << ' ' << tw2e << '\n';
  }
}

RÉSULTATS DES PERFORMANCES

Sur mon système Linux :

~/dev  g++ -O2 -o vdt vdt.cc -lrt
~/dev  ./vdt                     
virtual dispatch: 150000000 1.28025
switched: 150000000 0.344314
overhead: 150000000 0.229018
virtual dispatch: 150000000 1.285
switched: 150000000 0.345367
overhead: 150000000 0.231051
virtual dispatch: 150000000 1.28969
switched: 150000000 0.345876
overhead: 150000000 0.230726

Cela suggère qu'une approche en ligne à commutation de type et de numéro est d'environ (1,28 - 0,23) / (0,344 - 0,23) = 9.2 fois plus vite.Bien sûr, cela est spécifique au système exact testé / aux indicateurs et à la version du compilateur, etc., mais généralement indicatif.


COMMENTAIRES CONCERNANT L'EXPÉDITION VIRTUELLE

Il faut dire cependant que les frais généraux des appels de fonctions virtuelles sont rarement significatifs, et seulement pour les fonctions souvent appelées triviales (comme les getters et les setters).Même dans ce cas, vous pourrez peut-être fournir une fonction unique pour obtenir et définir un grand nombre de choses à la fois, minimisant ainsi les coûts.Les gens s'inquiètent beaucoup trop de la répartition virtuelle - faites donc le profilage avant de trouver des alternatives délicates.Le principal problème avec eux est qu'ils effectuent un appel de fonction hors ligne, bien qu'ils délocalisent également le code exécuté, ce qui modifie les modèles d'utilisation du cache (pour le meilleur ou (le plus souvent) pour le pire).

Le coût supplémentaire est pratiquement rien dans la plupart des scénarios. (pardonnez la blague). ejac a déjà affiché sensibles mesures relatives.

La plus grande chose que vous renoncez est optimisations possibles en raison de inline. Ils peuvent être particulièrement bien si la fonction est appelée avec des paramètres constants. Cela rend rarement une réelle différence, mais dans certains cas, cela peut être énorme.


En ce qui concerne les optimisations:
Il est important de connaître et de considérer le coût relatif des constructions de votre langue. notation Big O est la moitié ONL de l'histoire - comment votre échelle d'application . L'autre moitié est la constante en face d'elle.

En règle générale, je ne sortirais pas de ma façon d'éviter des fonctions virtuelles, à moins d'indications claires et précises qu'il est un goulot de bouteille. Un design épuré vient toujours en premier - mais il est seulement une partie prenante qui ne devrait pas indûment du mal aux autres.


Contrived Exemple: Un vide virtuel sur destructor un tableau d'un million de petits éléments peut labourer à travers au moins 4 Mo de données, débattant votre cache. Si ce destructor peut être inline loin, les données ne seront pas touchés.

Lors de l'écriture du code de la bibliothèque, ces considérations sont loin d'être prématurée. Vous ne savez jamais combien de boucles seront mis autour de votre fonction.

Alors que tout le monde est correct sur les performances des méthodes virtuelles et autres, je pense que le vrai problème est de savoir si l'équipe connaît la définition du mot-clé virtuel en C ++.

Considérez ce code, ce qui est la sortie?

#include <stdio.h>

class A
{
public:
    void Foo()
    {
        printf("A::Foo()\n");
    }
};

class B : public A
{
public:
    void Foo()
    {
        printf("B::Foo()\n");
    }
};

int main(int argc, char** argv)
{    
    A* a = new A();
    a->Foo();

    B* b = new B();
    b->Foo();

    A* a2 = new B();
    a2->Foo();

    return 0;
}

Rien d'étonnant ici:

A::Foo()
B::Foo()
A::Foo()

Comme rien est virtuel. Si le mot-clé virtuel est ajouté à l'avant de Foo dans les deux classes A et B, nous obtenons ceci pour la sortie:

A::Foo()
B::Foo()
B::Foo()

A peu près ce que tout le monde attend.

Maintenant, vous avez mentionné qu'il ya des bugs parce que quelqu'un a oublié d'ajouter un mot-clé virtuel. Considérons donc ce code (où le mot-clé virtuel est ajouté à A, mais pas de classe B). Quelle est la sortie alors?

#include <stdio.h>

class A
{
public:
    virtual void Foo()
    {
        printf("A::Foo()\n");
    }
};

class B : public A
{
public:
    void Foo()
    {
        printf("B::Foo()\n");
    }
};

int main(int argc, char** argv)
{    
    A* a = new A();
    a->Foo();

    B* b = new B();
    b->Foo();

    A* a2 = new B();
    a2->Foo();

    return 0;
}

Réponse: La même chose que si le mot-clé virtuel est ajouté à B? La raison en est que la signature B :: Foo correspond exactement comme A :: Foo () et parce que Foo est virtuel, il en est de B. D'un

Considérons maintenant le cas où B Foo est virtuel et A ce n'est pas. Quel est le résultat alors? Dans ce cas, la sortie est

<*>

Le mot-clé virtuel fonctionne vers le bas dans la hiérarchie, et non pas vers le haut. Il ne fait jamais les méthodes de la classe de base virtuelle. La première fois une méthode virtuelle est rencontrée dans la hiérarchie est quand le polymorphisme commence. Il n'y a pas une façon pour les classes plus tard pour faire des classes précédentes ont des méthodes virtuelles.

Ne pas oublier que les méthodes virtuelles signifient que cette classe donne des cours à venir la possibilité d'outrepasser / modifier certains de ses comportements.

Donc, si vous avez une règle pour supprimer le mot-clé virtuelle, il peut ne pas avoir l'effet escompté.

Le mot-clé virtuel en C ++ est un concept puissant. Vous devez vous assurer que chaque membre de l'équipe sait vraiment ce concept afin qu'il puisse être utilisé comme prévu.

En fonction de votre plate-forme, la surcharge d'un appel virtuel peut être très indésirable. En déclarant que vous êtes virtuel toutes les fonctions essentiellement les appeler tous grâce à un pointeur de fonction. À tout le moins c'est un déréférencement supplémentaire, mais sur certaines plates-formes PPC, il utilisera microcodées ou autrement instructions lentes à cette fin.

Je recommande contre votre suggestion pour cette raison, mais si elle vous aide à prévenir les bugs alors il peut être utile de le commerce. Je ne peux pas empêcher de penser qu'il doit y avoir un moyen terme qui vaut la peine de trouver, bien.

Il faudra juste quelques instructions asm supplémentaire pour appeler la méthode virtuelle.

Mais je ne pense pas que vous craignez que le plaisir (int a, int b) a quelques instructions « push » supplémentaires par rapport à l'amusement (). Donc, ne vous inquiétez pas trop virtuals, jusqu'à ce que vous êtes en situation et voir que cela conduit vraiment aux problèmes.

P.S. Si vous avez une méthode virtuelle, assurez-vous que vous avez un destructor virtuel. De cette façon, vous éviterez des problèmes possibles


En réponse à 'xtofl' et commentaires 'Tom'. Je l'ai fait de petits tests avec 3 fonctions:

  1. Virtual
  2. Normal
  3. Normal avec des paramètres 3 int

Mon test était une itération simple:

for(int it = 0; it < 100000000; it ++) {
    test.Method();
}

Et voici les résultats:

  1. 3913 s
  2. 3873 s
  3. 3970 s

Il a été compilé par VC ++ en mode débogage. Je l'ai seulement 5 tests par méthode et calculé la valeur moyenne (les résultats peuvent donc être assez inexact) ... De toute façon, les valeurs sont presque égales en supposant 100 millions d'appels. Et la méthode avec 3 poussée supplémentaire / pop était plus lent.

Le point principal est que si vous ne voulez pas l'analogie avec le push / pop, penser supplémentaire if / else dans votre code? Pensez-vous de pipeline du processeur lorsque vous ajoutez supplémentaire if / else ;-) En outre, vous ne savez jamais ce que le code CPU sera en cours d'exécution ... compilateur habituelle peut génère un code plus optimal pour une CPU et moins optimale pour un autre (< a href = "http://en.wikipedia.org/wiki/Intel_C%2B%2B_Compiler" rel = "nofollow noreferrer"> Intel C ++ Compiler )

Licencié sous: CC-BY-SA avec attribution
Non affilié à StackOverflow
scroll top