Was sind die Leistungskosten einer virtuellen Methode in einer C ++ - Klasse?


106

Wenn mindestens eine virtuelle Methode in einer C ++ - Klasse (oder einer ihrer übergeordneten Klassen) vorhanden ist, verfügt die Klasse über eine virtuelle Tabelle und jede Instanz über einen virtuellen Zeiger.

Die Speicherkosten sind also ziemlich klar. Am wichtigsten sind die Speicherkosten für die Instanzen (insbesondere wenn die Instanzen klein sind, z. B. wenn sie nur eine Ganzzahl enthalten sollen: In diesem Fall kann ein virtueller Zeiger in jeder Instanz die Größe der Instanzen verdoppeln Der von den virtuellen Tabellen belegte Speicherplatz ist im Vergleich zum vom eigentlichen Methodencode belegten Speicherplatz normalerweise vernachlässigbar.

Dies bringt mich zu meiner Frage: Gibt es messbare Leistungskosten (dh Geschwindigkeitsauswirkungen), um eine Methode virtuell zu machen? Bei jedem Methodenaufruf wird zur Laufzeit eine Suche in der virtuellen Tabelle durchgeführt. Wenn diese Methode also sehr häufig aufgerufen wird und diese Methode sehr kurz ist, kann es zu einem messbaren Leistungseinbruch kommen. Ich denke, es hängt von der Plattform ab, aber hat jemand einige Benchmarks durchgeführt?

Der Grund, den ich frage, ist, dass ich auf einen Fehler gestoßen bin, der zufällig darauf zurückzuführen ist, dass ein Programmierer vergessen hat, eine virtuelle Methode zu definieren. Dies ist nicht das erste Mal, dass ich einen solchen Fehler sehe. Und ich dachte: warum wir fügen die virtuelle Schlüsselwort bei Bedarf statt Entfernen des virtuellen Schlüsselwort , wenn wir absolut sicher sind , dass es nicht notwendig? Wenn die Leistungskosten niedrig sind, empfehle ich in meinem Team einfach Folgendes: Machen Sie einfach jede Methode standardmäßig virtuell, einschließlich des Destruktors, in jeder Klasse und entfernen Sie sie nur, wenn Sie dies benötigen. Klingt das für dich verrückt?



7
Der Vergleich von virtuellen mit nicht virtuellen Anrufen ist nicht sehr umfangreich. Sie bieten unterschiedliche Funktionen. Wenn Sie virtuelle Funktionsaufrufe mit dem C-Äquivalent vergleichen möchten, müssen Sie die Kosten für den Code hinzufügen, der die entsprechende Funktion der virtuellen Funktion implementiert.
Martin York

Welches ist entweder eine switch-Anweisung oder eine große if-Anweisung. Wenn Sie klug wären, könnten Sie mithilfe einer Funktionszeigertabelle erneut implementieren, aber die Wahrscheinlichkeit, dass etwas falsch gemacht wird, ist viel höher.
Martin York


7
Die Frage bezieht sich auf Funktionsaufrufe, die nicht virtuell sein müssen, daher ist der Vergleich sinnvoll.
Mark Ransom

Antworten:


103

Ich habe einige Timings auf einem 3-GHz-PowerPC-Prozessor ausgeführt. In dieser Architektur kostet ein virtueller Funktionsaufruf 7 Nanosekunden länger als ein direkter (nicht virtueller) Funktionsaufruf.

Es lohnt sich also nicht, sich über die Kosten Gedanken zu machen, es sei denn, die Funktion ist so etwas wie ein trivialer Get () / Set () - Accessor, bei dem alles andere als Inline verschwenderisch ist. Ein Overhead von 7 ns für eine Funktion, die auf 0,5 ns inline ist, ist schwerwiegend. Ein 7-ns-Overhead für eine Funktion, deren Ausführung 500 ms dauert, ist bedeutungslos.

Die hohen Kosten für virtuelle Funktionen sind nicht wirklich die Suche nach einem Funktionszeiger in der vtable (das ist normalerweise nur ein einzelner Zyklus), sondern dass der indirekte Sprung normalerweise nicht verzweigt werden kann. Dies kann eine große Pipeline-Blase verursachen, da der Prozessor keine Befehle abrufen kann, bis der indirekte Sprung (der Aufruf über den Funktionszeiger) beendet und ein neuer Befehlszeiger berechnet wurde. Die Kosten für einen virtuellen Funktionsaufruf sind also viel höher, als es bei Betrachtung der Baugruppe erscheinen mag ... aber immer noch nur 7 Nanosekunden.

Bearbeiten: Andrew, Not Sure und andere sprechen auch den sehr guten Punkt an, dass ein virtueller Funktionsaufruf einen Befehls-Cache-Fehler verursachen kann: Wenn Sie zu einer Code-Adresse springen, die sich nicht im Cache befindet, kommt das gesamte Programm zum Stillstand, während der Anweisungen werden aus dem Hauptspeicher abgerufen. Dies ist immer ein bedeutender Stillstand: auf Xenon ungefähr 650 Zyklen (nach meinen Tests).

Dies ist jedoch kein spezifisches Problem für virtuelle Funktionen, da selbst ein direkter Funktionsaufruf einen Fehler verursacht, wenn Sie zu Anweisungen springen, die sich nicht im Cache befinden. Entscheidend ist, ob die Funktion vor kurzem ausgeführt wurde (was die Wahrscheinlichkeit erhöht, dass sie sich im Cache befindet) und ob Ihre Architektur statische (nicht virtuelle) Zweige vorhersagen und diese Anweisungen vorab in den Cache abrufen kann. Mein PPC nicht, aber vielleicht die neueste Hardware von Intel.

Meine Zeitsteuerung für den Einfluss von Icache-Fehlern auf die Ausführung (absichtlich, da ich versucht habe, die CPU-Pipeline isoliert zu untersuchen), sodass diese Kosten nicht berücksichtigt werden.


3
Die Kosten in Zyklen entsprechen in etwa der Anzahl der Pipeline-Stufen zwischen dem Abruf und dem Ende des Branch-Retirement. Es sind keine unbedeutenden Kosten, und es kann sich summieren, aber wenn Sie nicht versuchen, eine enge Hochleistungsschleife zu schreiben, gibt es wahrscheinlich größere Perf-Fische, die Sie braten können.
Crashworks

7 Nanosekunden länger als was. Wenn ein normaler Anruf 1 Nanosekunde dauert, was würdevoll ist, wenn ein normaler Anruf 70 Nanosekunden dauert, ist dies nicht der Fall.
Martin York

Wenn Sie sich die Timings ansehen, habe ich festgestellt, dass für eine Funktion, die 0,66 ns inline kostet, der differenzielle Overhead eines direkten Funktionsaufrufs 4,8 ns und einer virtuellen Funktion 12,3 ns (im Vergleich zur Inline) beträgt. Sie machen den guten Punkt, dass, wenn die Funktion selbst eine Millisekunde kostet, 7 ns nichts bedeutet.
Crashworks

2
Mehr wie 600 Zyklen, aber es ist ein guter Punkt. Ich habe es aus dem Timing herausgelassen, weil ich mich nur für den Overhead aufgrund der Pipeline-Blase und des Prologs / Epilogs interessierte. Der Icache-Fehler tritt bei einem direkten Funktionsaufruf genauso leicht auf (Xenon hat keinen Icache-Verzweigungsprädiktor).
Crashworks

2
Kleinere Details, aber in Bezug auf "Dies ist jedoch kein spezifisches Problem für ..." ist es für den virtuellen Versand etwas schlimmer, da sich eine zusätzliche Seite (oder zwei, wenn sie zufällig über eine Seitengrenze fällt) im Cache befinden muss - für die virtuelle Versandtabelle der Klasse.
Tony Delroy

18

Beim Aufrufen einer virtuellen Funktion ist der Aufwand definitiv messbar. Der Aufruf muss die vtable verwenden, um die Adresse der Funktion für diesen Objekttyp aufzulösen. Die zusätzlichen Anweisungen sind die geringste Sorge. Vtables verhindern nicht nur viele potenzielle Compiler-Optimierungen (da der Typ des Compilers polymorph ist), sondern können auch Ihren I-Cache beschädigen.

Ob diese Strafen erheblich sind oder nicht, hängt natürlich von Ihrer Anwendung ab, wie oft diese Codepfade ausgeführt werden und von Ihren Vererbungsmustern.

Meiner Meinung nach ist es jedoch eine umfassende Lösung für ein Problem, das Sie auf andere Weise lösen können, wenn Sie standardmäßig alles als virtuell haben.

Vielleicht könnten Sie sich ansehen, wie Klassen entworfen / dokumentiert / geschrieben werden. Im Allgemeinen sollte der Header für eine Klasse deutlich machen, welche Funktionen von abgeleiteten Klassen überschrieben werden können und wie sie aufgerufen werden. Wenn Programmierer diese Dokumentation schreiben, ist dies hilfreich, um sicherzustellen, dass sie korrekt als virtuell markiert sind.

Ich würde auch sagen, dass das Deklarieren jeder Funktion als virtuell zu mehr Fehlern führen kann, als nur zu vergessen, etwas als virtuell zu markieren. Wenn alle Funktionen virtuell sind, kann alles durch Basisklassen ersetzt werden - öffentlich, geschützt, privat - alles wird zum fairen Spiel. Durch Zufall oder Absicht können Unterklassen dann das Verhalten von Funktionen ändern, die dann Probleme verursachen, wenn sie in der Basisimplementierung verwendet werden.


Die größte verlorene Optimierung ist das Inlining, insbesondere wenn die virtuelle Funktion häufig klein oder leer ist.
Zan Lynx

@ Andrew: interessante Sicht. Ich stimme Ihrem letzten Absatz jedoch nicht zu: Wenn eine Basisklasse eine Funktion hat save, die auf einer bestimmten Implementierung einer Funktion writein der Basisklasse beruht , dann scheint es mir, dass sie entweder saveschlecht codiert ist oder writeprivat sein sollte.
MiniQuark

2
Nur weil das Schreiben privat ist, kann es nicht verhindert werden, dass es überschrieben wird. Dies ist ein weiteres Argument dafür, dass Dinge standardmäßig nicht virtuell gemacht werden. Auf jeden Fall habe ich an das Gegenteil gedacht - eine generische und gut geschriebene Implementierung wird durch etwas ersetzt, das ein spezifisches und nicht kompatibles Verhalten aufweist.
Andrew Grant

Beim Caching abgestimmt - Wenn Sie bei einer großen objektorientierten Codebasis nicht den Leistungspraktiken der Codelokalität folgen, können Ihre virtuellen Aufrufe sehr leicht Cache-Fehler verursachen und einen Stillstand verursachen.
Nicht sicher

Und ein Icache-Stall kann wirklich ernst sein: 600 Zyklen in meinen Tests.
Crashworks

9

Es hängt davon ab, ob. :) (Hattest du noch etwas erwartet?)

Sobald eine Klasse eine virtuelle Funktion erhält, kann sie kein POD-Datentyp mehr sein (möglicherweise war es vorher auch keiner, in diesem Fall macht dies keinen Unterschied), und dies macht eine ganze Reihe von Optimierungen unmöglich.

std :: copy () für einfache POD-Typen kann auf eine einfache memcpy-Routine zurückgreifen, Nicht-POD-Typen müssen jedoch sorgfältiger behandelt werden.

Die Konstruktion wird viel langsamer, da die vtable initialisiert werden muss. Im schlimmsten Fall kann der Leistungsunterschied zwischen POD- und Nicht-POD-Datentypen erheblich sein.

Im schlimmsten Fall kann es zu einer 5-mal langsameren Ausführung kommen (diese Nummer stammt aus einem Universitätsprojekt, das ich kürzlich durchgeführt habe, um einige Standardbibliotheksklassen erneut zu implementieren. Die Erstellung unseres Containers dauerte ungefähr 5-mal so lange, sobald der gespeicherte Datentyp eine erhielt vtable)

In den meisten Fällen ist es natürlich unwahrscheinlich, dass messbare Leistungsunterschiede auftreten. Dies soll lediglich darauf hinweisen, dass dies in einigen Grenzfällen kostspielig sein kann.

Leistung sollte hier jedoch nicht Ihre Hauptüberlegung sein. Alles virtuell zu machen ist aus anderen Gründen keine perfekte Lösung.

Wenn Sie zulassen, dass in abgeleiteten Klassen alles überschrieben wird, ist es viel schwieriger, Klasseninvarianten beizubehalten. Wie garantiert eine Klasse, dass sie in einem konsistenten Zustand bleibt, wenn eine ihrer Methoden jederzeit neu definiert werden kann?

Wenn Sie alles virtuell machen, werden möglicherweise einige potenzielle Fehler beseitigt, aber es werden auch neue eingeführt.


7

Wenn Sie die Funktionalität des virtuellen Versands benötigen, müssen Sie den Preis bezahlen. Der Vorteil von C ++ besteht darin, dass Sie eine sehr effiziente Implementierung des vom Compiler bereitgestellten virtuellen Versands verwenden können, anstatt eine möglicherweise ineffiziente Version, die Sie selbst implementieren.

Wenn Sie sich jedoch nicht mit dem Overhead herumschlagen müssen, wenn Sie kein X benötigen, geht es möglicherweise etwas zu weit. Und die meisten Klassen sind nicht dafür ausgelegt, geerbt zu werden - um eine gute Basisklasse zu erstellen, ist mehr erforderlich, als ihre Funktionen virtuell zu machen.


Gute Antwort, aber IMO, in der zweiten Hälfte nicht nachdrücklich genug: Sich mit dem Overhead herumzuschlagen, wenn Sie ihn nicht brauchen, ist ehrlich gesagt verrückt - besonders wenn Sie diese Sprache verwenden, deren Mantra lautet: "Zahlen Sie nicht für das, was Sie anziehen." nicht benutzen. " Standardmäßig alles virtuell zu machen, bis jemand rechtfertigt, warum es nicht virtuell sein kann / sollte, ist eine abscheuliche Richtlinie.
underscore_d

5

Der virtuelle Versand ist um eine Größenordnung langsamer als einige Alternativen - weniger aufgrund von Indirektion als vielmehr aufgrund der Verhinderung von Inlining. Im Folgenden werde ich dies veranschaulichen, indem ich den virtuellen Versand einer Implementierung gegenüberstelle, die eine "Typ (identifizierende) Nummer" in die Objekte einbettet und eine switch-Anweisung verwendet, um den typspezifischen Code auszuwählen. Dies vermeidet den Overhead von Funktionsaufrufen vollständig - nur ein lokaler Sprung. Durch die erzwungene Lokalisierung (im Switch) der typspezifischen Funktionalität entstehen potenzielle Kosten für Wartbarkeit, Neukompilierungsabhängigkeiten usw.


IMPLEMENTIERUNG

#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';
  }
}

LEISTUNGSERGEBNISSE

Auf meinem Linux-System:

~/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

Dies deutet darauf hin, dass ein Inline-Typ-Nummer-Switched-Ansatz etwa (1,28 - 0,23) / (0,344 - 0,23) = 9,2- mal so schnell ist. Dies ist natürlich spezifisch für die genauen vom System getesteten / Compiler-Flags und -Versionen usw., aber im Allgemeinen indikativ.


KOMMENTARE ZUM VIRTUELLEN VERSAND

Es muss jedoch gesagt werden, dass Overheads für virtuelle Funktionsaufrufe selten von Bedeutung sind, und zwar nur für häufig genannte triviale Funktionen (wie Getter und Setter). Selbst dann können Sie möglicherweise eine einzige Funktion bereitstellen, um viele Dinge gleichzeitig abzurufen und einzustellen, wodurch die Kosten minimiert werden. Die Leute sorgen sich viel zu sehr um den virtuellen Versand - machen Sie also die Profilerstellung, bevor Sie unangenehme Alternativen finden. Das Hauptproblem bei ihnen ist, dass sie einen Offline-Funktionsaufruf ausführen, aber auch den ausgeführten Code delokalisieren, wodurch sich die Cache-Nutzungsmuster ändern (zum Guten oder (häufiger) Schlechten).


Ich habe eine Frage zu Ihrem Code gestellt, weil ich mit g++/ clangund einige "seltsame" Ergebnisse habe -lrt. Ich fand es für zukünftige Leser erwähnenswert.
Holt

@Holt: gute Frage angesichts der mysteriösen Ergebnisse! Ich werde es mir in den nächsten Tagen genauer ansehen, wenn ich eine halbe Chance bekomme. Prost.
Tony Delroy

3

Die zusätzlichen Kosten sind in den meisten Szenarien praktisch nichts. (entschuldige das Wortspiel). ejac hat bereits sinnvolle relative Maßnahmen veröffentlicht.

Das Größte, was Sie aufgeben, sind mögliche Optimierungen aufgrund von Inlining. Sie können besonders gut sein, wenn die Funktion mit konstanten Parametern aufgerufen wird. Dies macht selten einen wirklichen Unterschied, aber in einigen Fällen kann dies sehr groß sein.


In Bezug auf Optimierungen:
Es ist wichtig, die relativen Kosten von Konstrukten Ihrer Sprache zu kennen und zu berücksichtigen. Big O - Notation ist onl Hälfte der Geschichte - wie funktioniert Ihre Anwendung Skala . Die andere Hälfte ist der konstante Faktor davor.

Als Faustregel würde ich mich nicht bemühen, virtuelle Funktionen zu vermeiden, es sei denn, es gibt klare und spezifische Hinweise darauf, dass es sich um einen Flaschenhals handelt. Ein klares Design steht immer an erster Stelle - aber es ist nur ein Stakeholder, der andere nicht übermäßig verletzen sollte.


Erfundenes Beispiel: Ein leerer virtueller Destruktor auf einem Array von einer Million kleiner Elemente kann mindestens 4 MB Daten durchpflügen und Ihren Cache überladen. Wenn dieser Destruktor entfernt werden kann, werden die Daten nicht berührt.

Beim Schreiben von Bibliothekscode sind solche Überlegungen alles andere als verfrüht. Sie wissen nie, wie viele Schleifen um Ihre Funktion gelegt werden.


2

Während alle anderen hinsichtlich der Leistung virtueller Methoden und dergleichen korrekt sind, besteht meines Erachtens das eigentliche Problem darin, ob das Team über die Definition des virtuellen Schlüsselworts in C ++ Bescheid weiß.

Betrachten Sie diesen Code, was ist die Ausgabe?

#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;
}

Kein Wunder hier:

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

Da ist nichts virtuell. Wenn das virtuelle Schlüsselwort in den Klassen A und B an der Vorderseite von Foo hinzugefügt wird, erhalten wir dies für die Ausgabe:

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

So ziemlich das, was jeder erwartet.

Sie haben erwähnt, dass es Fehler gibt, weil jemand vergessen hat, ein virtuelles Schlüsselwort hinzuzufügen. Betrachten Sie also diesen Code (wobei das virtuelle Schlüsselwort der Klasse A, aber nicht der Klasse B hinzugefügt wird). Was ist dann die Ausgabe?

#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;
}

Antwort: Dasselbe, als ob das virtuelle Schlüsselwort zu B hinzugefügt würde? Der Grund ist, dass die Signatur für B :: Foo genau mit A :: Foo () übereinstimmt und weil A's Foo virtuell ist, ist es auch B's.

Betrachten Sie nun den Fall, in dem B's Foo virtuell ist und A's nicht. Was ist dann die Ausgabe? In diesem Fall ist die Ausgabe

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

Das virtuelle Schlüsselwort funktioniert in der Hierarchie nach unten und nicht nach oben. Die Methoden der Basisklasse werden niemals virtuell. Das erste Mal, dass eine virtuelle Methode in der Hierarchie angetroffen wird, beginnt der Polymorphismus. Für spätere Klassen gibt es keine Möglichkeit, frühere Klassen mit virtuellen Methoden zu versehen.

Vergessen Sie nicht, dass virtuelle Methoden bedeuten, dass diese Klasse zukünftigen Klassen die Möglichkeit gibt, einige ihrer Verhaltensweisen zu überschreiben / zu ändern.

Wenn Sie also eine Regel zum Entfernen des virtuellen Schlüsselworts haben, hat dies möglicherweise nicht die beabsichtigte Wirkung.

Das virtuelle Schlüsselwort in C ++ ist ein leistungsstarkes Konzept. Sie sollten sicherstellen, dass jedes Mitglied des Teams dieses Konzept wirklich kennt, damit es wie geplant verwendet werden kann.


Hallo Tommy, danke für das Tutorial. Der Fehler, den wir hatten, war auf ein fehlendes "virtuelles" Schlüsselwort in einer Methode der Basisklasse zurückzuführen. Übrigens, ich sage, machen Sie alle Funktionen virtuell (nicht das Gegenteil), und entfernen Sie dann das Schlüsselwort "virtuell", wenn dies eindeutig nicht benötigt wird.
MiniQuark

@MiniQuark: Tommy Hui sagt, wenn Sie alle Funktionen virtuell machen, kann ein Programmierer das Schlüsselwort in einer abgeleiteten Klasse entfernen, ohne zu bemerken, dass es keine Auswirkungen hat. Sie müssten auf irgendeine Weise sicherstellen, dass das virtuelle Schlüsselwort immer in der Basisklasse entfernt wird.
M. Dudley

1

Abhängig von Ihrer Plattform kann der Overhead eines virtuellen Anrufs sehr unerwünscht sein. Indem Sie jede Funktion als virtuell deklarieren, rufen Sie sie im Wesentlichen alle über einen Funktionszeiger auf. Zumindest ist dies eine zusätzliche Dereferenzierung, aber auf einigen PPC-Plattformen werden mikrocodierte oder auf andere Weise langsame Anweisungen verwendet, um dies zu erreichen.

Ich würde aus diesem Grund gegen Ihren Vorschlag empfehlen, aber wenn es Ihnen hilft, Fehler zu verhindern, ist es möglicherweise den Kompromiss wert. Ich kann nicht anders, als zu denken, dass es einen Mittelweg geben muss, der es wert ist, gefunden zu werden.


-1

Zum Aufrufen der virtuellen Methode sind nur ein paar zusätzliche asm-Anweisungen erforderlich.

Aber ich glaube nicht, dass Sie sich Sorgen machen, dass Spaß (int a, int b) im Vergleich zu Spaß () ein paar zusätzliche Push-Anweisungen enthält. Machen Sie sich also auch keine Sorgen um Virtuals, bis Sie sich in einer besonderen Situation befinden und feststellen, dass dies wirklich zu Problemen führt.

PS Wenn Sie eine virtuelle Methode haben, stellen Sie sicher, dass Sie einen virtuellen Destruktor haben. Auf diese Weise vermeiden Sie mögliche Probleme


Als Antwort auf die Kommentare 'xtofl' und 'Tom'. Ich habe kleine Tests mit 3 Funktionen durchgeführt:

  1. Virtuell
  2. Normal
  3. Normal mit 3 int Parametern

Mein Test war eine einfache Iteration:

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

Und hier die Ergebnisse:

  1. 3,913 Sek
  2. 3,873 Sek
  3. 3.970 Sek

Es wurde von VC ++ im Debug-Modus kompiliert. Ich habe nur 5 Tests pro Methode durchgeführt und den Mittelwert berechnet (daher können die Ergebnisse ziemlich ungenau sein) ... Auf jeden Fall sind die Werte bei 100 Millionen Aufrufen fast gleich. Und die Methode mit 3 zusätzlichen Push / Pop war langsamer.

Der Hauptpunkt ist, wenn Sie die Analogie mit Push / Pop nicht mögen, denken Sie an zusätzliche if / else in Ihrem Code? Denken Sie an die CPU-Pipeline, wenn Sie zusätzliche if / else hinzufügen ;-) Außerdem wissen Sie nie, auf welcher CPU der Code ausgeführt wird ... Der übliche Compiler kann Code generieren, der für eine CPU optimaler und für eine andere weniger optimal ist ( Intel) C ++ Compiler )


2
Der zusätzliche ASM löst möglicherweise nur einen Seitenfehler aus (der für nicht virtuelle Funktionen nicht vorhanden wäre). Ich denke, Sie vereinfachen das Problem erheblich.
xtofl

2
+1 zu xtofls Kommentar. Virtuelle Funktionen führen eine Indirektion ein, die Pipeline- "Blasen" einführt und das Caching-Verhalten beeinflusst.
Tom

1
Das Timing im Debug-Modus ist bedeutungslos. MSVC erstellt im Debug-Modus sehr langsamen Code, und der Loop-Overhead verbirgt wahrscheinlich den größten Unterschied. Wenn Sie eine hohe Leistung anstreben, sollten Sie überlegen, ob / else-Verzweigungen auf dem schnellen Weg minimiert werden sollen. Weitere Informationen zur x86-Leistungsoptimierung auf niedriger Ebene finden Sie unter agner.org/optimize . (Auch einige andere Links im x86-Tag-Wiki
Peter Cordes

1
@Tom: Der entscheidende Punkt hierbei ist, dass nicht-virtuelle Funktionen inline können, virtuelle jedoch nicht (es sei denn, der Compiler kann devirtualisieren, z. B. wenn Sie finalin Ihrer Überschreibung verwendet haben und einen Zeiger auf den abgeleiteten Typ anstelle des Basistyps haben ). Bei diesem Test wurde jedes Mal dieselbe virtuelle Funktion aufgerufen, sodass eine perfekte Vorhersage getroffen wurde. Keine anderen Pipeline-Blasen als bei begrenztem callDurchsatz. Und das indirekte callkann ein paar weitere Uops sein. Die Zweigvorhersage funktioniert auch für indirekte Zweige gut, insbesondere wenn sie immer am selben Ziel sind.
Peter Cordes

Dies fällt in die übliche Falle von Mikrobenchmarks: Es sieht schnell aus, wenn Zweigprädiktoren heiß sind und nichts anderes passiert. Der Fehlvorhersageaufwand ist bei indirekten callals bei direkten höher call. (Und ja, normale callBefehle müssen ebenfalls vorhergesagt werden. Die Abrufphase muss die nächste abzurufende Adresse kennen, bevor dieser Block decodiert wird. Daher muss sie den nächsten Abrufblock basierend auf der aktuellen Blockadresse und nicht auf der Befehlsadresse vorhersagen wie vorhergesagt, wo in diesem Block es eine Verzweigungsanweisung gibt ...)
Peter Cordes
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.