web-dev-qa-db-fra.com

Quel est le coût de performance d'avoir une méthode virtuelle dans une classe C ++?

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 chaque instance aura un pointeur virtuel.

Le coût de la mémoire est donc assez clair. Le plus important est le coût de la mémoire sur les instances (surtout si les instances sont petites, par exemple si elles sont juste destinées à contenir un entier: dans ce cas, avoir un pointeur virtuel dans chaque instance peut doubler la taille des instances. Comme pour 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 la méthode réelle.

Cela m'amène à ma question: existe-t-il un coût de performance mesurable (c'est-à-dire un impact sur la vitesse) pour rendre une méthode virtuelle? Il y aura une recherche dans la table virtuelle lors 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 un impact mesurable sur les performances? Je suppose que cela dépend de la plate-forme, mais quelqu'un a-t-il exécuté des tests de performance?

La raison pour laquelle je demande, c'est que je suis tombé sur un bug qui était 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 avons-nous ajouter le mot-clé virtuel lorsque cela est nécessaire au lieu de supprimer le mot-clé virtuel lorsque nous sommes absolument sûrs qu'il est pas nécessaire ? Si le coût des performances est faible, je pense que je recommanderai simplement ce qui suit dans mon équipe: rendez simplement la méthode every virtuelle par défaut, y compris le destructeur, dans chaque classe, et ne la supprimez que lorsque vous en avez besoin . Cela vous semble-t-il fou?

99
MiniQuark

I a exécuté quelques timings sur un processeur PowerPC de 3 GHz. Sur cette architecture, un appel de fonction virtuelle coûte 7 nanosecondes de plus qu'un appel de fonction direct (non virtuel).

Donc, cela ne vaut pas vraiment la peine de s'inquiéter du coût, à moins que la fonction ne soit quelque chose comme un accesseur trivial Get ()/Set (), dans lequel tout autre chose qu'en ligne est une sorte de gaspillage. Une surcharge de 7ns sur une fonction qui s'aligne à 0,5ns est sévère; une surcharge de 7 ns sur une fonction qui prend 500 ms à exécuter n'a pas de sens.

Le gros coût des fonctions virtuelles n'est pas vraiment la recherche d'un pointeur de fonction dans la table virtuelle (qui n'est généralement qu'un seul cycle), mais le saut indirect ne peut généralement pas être prévu par la branche. Cela peut provoquer une grande bulle de pipeline car le processeur ne peut pas récupérer d'instructions jusqu'à ce que le saut indirect (l'appel via le pointeur de fonction) soit retiré et qu'un nouveau pointeur d'instructions soit calculé. Ainsi, le coût d'un appel de fonction virtuelle est beaucoup plus élevé qu'il n'y paraît en regardant l'Assemblée ... mais toujours seulement 7 nanosecondes.

Edit: Andrew, Not Sure, et d'autres soulèvent également le très bon point qu'un appel de fonction virtuelle peut entraîner un échec du cache d'instructions: si vous passez à un adresse de code qui n'est pas dans le cache, le programme entier s'arrête alors que les instructions sont extraites de la mémoire principale. C'est toujours un décrochage important: sur Xenon, environ 650 cycles (selon mes tests).

Cependant, ce n'est pas un problème spécifique aux fonctions virtuelles car même un appel direct à une fonction entraînera un échec si vous passez à des instructions qui ne sont pas dans le cache. Ce qui importe est de savoir si la fonction a été exécutée auparavant récemment (ce qui la rend plus susceptible d'être dans le cache) et si votre architecture peut prédire les branches statiques (non virtuelles) et récupérer ces instructions dans le cache à l'avance. Mon PPC ne le fait pas, mais peut-être que le matériel le plus récent d'Intel le fait.

Mes synchronisations contrôlent l'influence des échecs d'icache sur l'exécution (délibérément, puisque j'essayais d'examiner le pipeline du processeur de manière isolée), ils actualisent donc ce coût.

92
Crashworks

Il y a certainement un surdébit mesurable lors de l'appel d'une fonction virtuelle - l'appel doit utiliser la table virtuelle 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 les vtables empêchent de nombreuses optimisations potentielles du compilateur (puisque le type est polymorphe le compilateur), mais elles peuvent également détruire votre I-Cache.

Bien entendu, le fait que ces pénalités soient importantes ou non dépend de votre application, de la fréquence à laquelle ces chemins de code sont exécutés et de vos modèles d'héritage.

À mon avis cependant, avoir tout comme virtuel par défaut est une solution globale à un problème que vous pourriez résoudre par d'autres moyens.

Vous pourriez peut-être voir comment les classes sont conçues/documentées/écrites. En général, l'en-tête d'une classe doit indiquer clairement quelles fonctions peuvent être remplacées par des classes dérivées et comment elles sont appelées. Demander aux programmeurs d'écrire cette documentation est utile pour s'assurer qu'ils sont correctement marqués comme virtuels.

Je dirais également que déclarer chaque fonction comme virtuelle pourrait entraîner plus de bugs que de simplement oublier de marquer quelque chose comme virtuel. Si toutes les fonctions sont virtuelles, tout peut être remplacé par des classes de base - publiques, protégées, privées - tout devient un jeu équitable. Par accident ou intention, les sous-classes pourraient alors modifier le comportement des fonctions qui causent alors des problèmes lorsqu'elles sont utilisées dans l'implémentation de base.

18
Andrew Grant

Ça dépend. :) (Vous attendiez-vous à autre chose?)

Une fois qu'une classe obtient une fonction virtuelle, elle ne peut plus être un type de données POD, (il se peut qu'elle ne l'ait pas été auparavant non plus, auquel cas cela ne fera aucune différence) et cela rend impossible toute une gamme d'optimisations.

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

La construction devient beaucoup plus lente car la table doit être initialisée. Dans le pire des cas, la différence de performances entre les types de données POD et non-POD peut être significative.

Dans le pire des cas, l'exécution peut être 5 fois plus lente (ce nombre est tiré d'un projet universitaire que j'ai fait récemment pour réimplémenter quelques classes de bibliothèque standard. Notre conteneur a pris environ 5 fois plus de temps à construire dès que le type de données qu'il a stocké a obtenu vtable)

Bien sûr, dans la plupart des cas, il est peu probable que vous constatiez une différence de performance mesurable, c'est simplement pour souligner que dans certains cas limites, cela peut être coûteux.

Cependant, les performances ne devraient pas être votre principale considération ici. Rendre tout virtuel n'est pas une solution parfaite pour d'autres raisons.

Permettre à tout d'être remplacé dans les classes dérivées rend beaucoup plus difficile la gestion des invariants de classe. Comment une classe garantit-elle qu'elle reste dans un état cohérent lorsque l'une de ses méthodes peut être redéfinie à tout moment?

Rendre tout virtuel peut éliminer quelques bugs potentiels, mais il en introduit également de nouveaux.

9
jalf

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 de répartition virtuelle fournie par le compilateur, plutôt qu'une version éventuellement inefficace que vous implémentez vous-même.

Cependant, vous surcharger avec les frais généraux si vous n'en avez pas besoinx, cela va peut-être un peu trop loin. Et la plupart des classes ne sont pas conçues pour être héritées de - pour créer une bonne classe de base, il faut plus que rendre ses fonctions virtuelles.

7
anon

L'envoi virtuel est un ordre de grandeur plus lent que certaines alternatives - non pas tant en raison de l'indirection que de la prévention de l'incrustation. Ci-dessous, j'illustre cela en contrastant la répartition virtuelle avec une implémentation intégrant un "type (-identifiant) numéro" 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 y a un coût potentiel pour la maintenabilité, les dépendances de recompilation, etc. par la localisation forcée (dans le commutateur) de la fonctionnalité spécifique au type.


IMPLÉMENTATION

#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 DE PERFORMANCE

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 avec changement de numéro de type est environ (1,28 - 0,23)/(0,344 - 0,23) = 9.2 fois plus rapide. Bien sûr, cela est spécifique au système exact testé/drapeaux et version du compilateur, etc., mais généralement indicatif.


COMMENTAIRES SUR L'EXPÉDITION VIRTUELLE

Il faut dire cependant que les surcharges d'appels de fonctions virtuelles sont quelque chose qui est rarement significatif, et seulement pour les fonctions triviales souvent appelées (comme les getters et les setters). Même alors, vous pourriez être en mesure de fournir une seule fonction pour obtenir et définir beaucoup de choses à la fois, minimisant ainsi le coût. Les gens s'inquiètent trop de la répartition virtuelle - faites donc le profilage avant de trouver des alternatives maladroites. 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é qui modifie les modèles d'utilisation du cache (pour le meilleur ou (le plus souvent) le pire).

5
Tony Delroy

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

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


Concernant les optimisations:
Il est important de connaître et de considérer le coût relatif des constructions de votre langue. La notation Big O n'est que la moitié de l'histoire - comment évolue votre application . L'autre moitié est le facteur constant devant lui.

En règle générale, je ne ferais pas tout mon possible pour éviter les fonctions virtuelles, à moins qu'il n'y ait des indications claires et spécifiques qu'il s'agit d'un goulot d'étranglement. Un design épuré vient toujours en premier - mais ce n'est qu'une partie prenante qui ne devrait pas indûment blesser les autres.


Exemple artificiel: un destructeur virtuel vide sur un tableau d'un million de petits éléments peut parcourir au moins 4 Mo de données, détruisant votre cache. Si ce destructeur peut être aligné, les données ne seront pas touchées.

Lors de l'écriture de code de bibliothèque, de telles considérations sont loin d'être prématurées. Vous ne savez jamais combien de boucles seront placées autour de votre fonction.

3
peterchen

Alors que tout le monde a raison 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, quelle 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 de surprenant ici:

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

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

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

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

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

#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 était ajouté à B? La raison en est que la signature de B :: Foo correspond exactement à A :: Foo () et parce que A's Foo est virtuel, de même que B's.

Considérons maintenant le cas où B's Foo est virtuel et A's ne l'est pas. Quelle est alors la sortie? Dans ce cas, la sortie est

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

Le mot-clé virtuel fonctionne vers le bas dans la hiérarchie, pas vers le haut. Il ne rend jamais les méthodes de classe de base virtuelles. La première fois qu'une méthode virtuelle est rencontrée dans la hiérarchie, c'est lorsque le polymorphisme commence. Il n'y a aucun moyen pour les classes ultérieures de faire en sorte que les classes précédentes aient des méthodes virtuelles.

N'oubliez pas que les méthodes virtuelles signifient que cette classe donne aux futures classes la possibilité de remplacer/changer certains de ses comportements.

Donc, si vous avez une règle pour supprimer le mot-clé virtuel, cela 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 connaît vraiment ce concept afin qu'il puisse être utilisé tel que conçu.

2
Tommy Hui

Selon votre plateforme, les frais généraux d'un appel virtuel peuvent être très indésirables. En déclarant chaque fonction virtuelle, vous les appelez essentiellement par le biais d'un pointeur de fonction. À tout le moins, c'est une déréférence supplémentaire, mais sur certaines plates-formes PPC, il utilisera des instructions microcodées ou autrement lentes pour accomplir cela.

Je recommanderais contre votre suggestion pour cette raison, mais si cela vous aide à éviter les bugs, cela peut valoir le coup. Je ne peux pas m'empêcher de penser qu'il doit y avoir un terrain d'entente qui mérite d'être trouvé, cependant.

1
Dan Olson