In C ++ warum und wie sind virtuelle Funktionen langsamer?

38

Kann mir jemand im Detail erklären, wie genau die virtuelle Tabelle funktioniert und welche Zeiger beim Aufruf von virtuellen Funktionen zugeordnet sind.

Wenn sie tatsächlich langsamer sind, können Sie dann anzeigen, dass die Ausführung der virtuellen Funktion mehr Zeit in Anspruch nimmt als normale Klassenmethoden? Es ist leicht, den Überblick zu verlieren, wie / was passiert, ohne Code zu sehen.

MdT
quelle
5
Das Aufrufen der richtigen Methode in einer vtable dauert offensichtlich länger als das direkte Aufrufen der Methode, da mehr zu tun ist. Wie viel länger oder ob diese zusätzliche Zeit im Kontext Ihres eigenen Programms von Bedeutung ist, ist eine andere Frage. en.wikipedia.org/wiki/Virtual_method_table
Robert Harvey
10
Langsamer als was genau? Ich habe Code gesehen, der eine fehlerhafte, langsame Implementierung des dynamischen Verhaltens mit vielen switch-Anweisungen aufwies, nur weil einige Programmierer gehört hatten, dass virtuelle Funktionen langsam sind.
Christopher Creutzig
7
Oft ist es nicht so, dass virtuelle Aufrufe selbst langsam sind, sondern dass der Compiler nicht in der Lage ist, sie einzubinden.
Kevin Hsu
4
@ Kevin Hsu: ja das ist es absolut. Fast jedes Mal, wenn jemand Ihnen mitteilt, dass er durch das Eliminieren eines "virtuellen Funktionsaufruf-Overheads" eine Beschleunigung erzielt hat, werden Optimierungen vorgenommen, die jetzt möglich sind, da der Compiler keine Optimierung durchführen konnte der unbestimmte Anruf zuvor.
Uhrzeit
7
Selbst eine Person, die den Assembly-Code lesen kann, kann den Overhead bei der tatsächlichen CPU-Ausführung nicht genau vorhersagen. Desktop-basierte CPU-Hersteller haben in jahrzehntelanger Forschung nicht nur in die Branchenvorhersage, sondern auch in die Wertevorhersage und spekulative Ausführung investiert, um die Latenz virtueller Funktionen zu maskieren. Warum? Weil Desktop-Betriebssysteme und -Software sie häufig verwenden. (Ich würde nicht das gleiche über mobile CPUs sagen.)
Rwong

Antworten:

55

Virtuelle Methoden werden üblicherweise über sogenannte virtuelle Methodentabellen (kurz vtable) implementiert, in denen Funktionszeiger gespeichert sind. Dies fügt dem eigentlichen Aufruf eine Indirektion hinzu (muss die Adresse der aufzurufenden Funktion aus der vtable abrufen und dann aufrufen - im Gegensatz zum bloßen Aufrufen im Voraus). Natürlich dauert dies einige Zeit und etwas mehr Code.

Dies ist jedoch nicht unbedingt die Hauptursache für Langsamkeit. Das eigentliche Problem ist, dass der Compiler (in der Regel) nicht wissen kann, welche Funktion aufgerufen wird. Daher kann es keine Inline-Optimierung durchführen. Dies allein könnte ein Dutzend sinnloser Anweisungen hinzufügen (Register vorbereiten, aufrufen und anschließend den Zustand wiederherstellen) und andere, scheinbar nicht zusammenhängende Optimierungen verhindern. Wenn Sie wie verrückt verzweigen, indem Sie viele verschiedene Implementierungen aufrufen, erleiden Sie dieselben Hits, die Sie wie verrückt verzweigen würden: Der Cache und der Verzweigungsprädiktor helfen Ihnen nicht, die Verzweigungen dauern länger als perfekt vorhersehbar Ast.

Groß aber : Diese Leistungstreffer sind normalerweise zu klein, um von Bedeutung zu sein. Sie sollten überlegen, ob Sie einen Hochleistungscode erstellen und eine virtuelle Funktion hinzufügen möchten, die mit alarmierender Häufigkeit aufgerufen wird. Doch auch bedenken , dass Anrufe virtuelle Funktion mit anderen Mitteln der Verzweigung (ersetzen if .. else, switch, Funktionszeiger, etc.) werden nicht das grundlegende Problem lösen - es ist sehr gut langsamer sein kann. Das Problem (falls überhaupt vorhanden) sind keine virtuellen Funktionen, sondern eine (unnötige) Indirektion.

Bearbeiten: Der Unterschied in den Anrufanweisungen wird in anderen Antworten beschrieben. Grundsätzlich lautet der Code für einen statischen ("normalen") Aufruf:

  • Kopieren Sie einige Register auf dem Stapel, damit die aufgerufene Funktion diese Register verwenden kann.
  • Kopieren Sie die Argumente an vordefinierte Positionen, damit die aufgerufene Funktion sie unabhängig von ihrem Aufruf finden kann.
  • Schieben Sie die Absenderadresse.
  • Verzweigen / Springen zum Funktionscode, der eine Adresse zur Kompilierungszeit ist und daher vom Compiler / Linker in der Binärdatei fest codiert wird.
  • Rufen Sie den Rückgabewert von einem vordefinierten Speicherort ab und stellen Sie die zu verwendenden Register wieder her.

Ein virtueller Aufruf macht genau dasselbe, außer dass die Funktionsadresse zur Kompilierungszeit nicht bekannt ist. Stattdessen ein paar Anweisungen ...

  • Rufen Sie den vtable-Zeiger vom Objekt ab, der auf ein Array von Funktionszeigern (Funktionsadressen) zeigt, einen für jede virtuelle Funktion.
  • Holen Sie sich die richtige Funktionsadresse aus der vtable in ein Register (der Index, in dem die richtige Funktionsadresse gespeichert ist, wird zur Kompilierungszeit festgelegt).
  • Springen Sie zu der Adresse in diesem Register, anstatt zu einer fest codierten Adresse zu springen.

Was Zweige betrifft: Ein Zweig ist alles, was zu einem anderen Befehl springt, anstatt nur den nächsten Befehl ausführen zu lassen. Dazu gehören if, switchTeile von verschiedenen Schleifen, Funktionsaufrufe, etc. , und manchmal sind die Compiler implementiert Dinge , die nicht in einer Art und Weise zu tun Zweig scheint, braucht eigentlich einen Zweig unter der Haube. Siehe Warum verarbeitet ein sortiertes Array schneller als ein unsortiertes Array? Warum das so langsam sein kann, was CPUs tun, um dieser Verlangsamung entgegenzuwirken, und warum dies kein Allheilmittel ist.

Gemeinschaft
quelle
6
@ JörgWMittag sie sind alle Interpreter-Sachen und sie sind immer noch langsamer als der von C ++
Sam
13
@ JörgWMittag Diese Optimierungen dienen in erster Linie dazu, Indirektion / Late Binding (fast) kostenlos zu machen, wenn es nicht benötigt wird , da in diesen Sprachen jeder Anruf technisch spät gebunden ist. Wenn Sie wirklich in kurzer Zeit viele verschiedene virtuelle Methoden von einem Ort aus aufrufen, helfen diese Optimierungen nicht oder schaden aktiv (erstellen Sie viel Code für nichts). C ++ - Jungs sind nicht sehr an diesen Optimierungen interessiert, weil sie in einer ganz anderen Situation sind ...
10
@ JörgWMittag ... C ++ - Typen interessieren sich nicht besonders für diese Optimierungen, weil sie sich in einer ganz anderen Situation befinden: Der mit AOT kompilierte vtable-Weg ist bereits ziemlich schnell, nur sehr wenige Aufrufe sind tatsächlich virtuell, viele Fälle von Polymorphismus sind früh. gebunden (über Templates) und somit an die AOT-Optimierung anpassbar. Schließlich erfordert die adaptive Ausführung dieser Optimierungen (anstatt nur zur Kompilierungszeit zu spekulieren) die Generierung von Laufzeitcode, was eine Menge Kopfschmerzen mit sich bringt. JIT-Compiler haben diese Probleme bereits aus anderen Gründen gelöst, sodass es ihnen nichts ausmacht, aber AOT-Compiler möchten dies vermeiden.
3
tolle Antwort, +1. Zu beachten ist jedoch, dass die Ergebnisse der Verzweigung manchmal zum Zeitpunkt der Kompilierung bekannt sind, beispielsweise wenn Sie Framework-Klassen schreiben, die unterschiedliche Verwendungen unterstützen müssen, aber sobald der Anwendungscode mit diesen Klassen interagiert, ist die spezifische Verwendung bereits bekannt. In diesem Fall könnte die Alternative zu virtuellen Funktionen C ++ - Vorlagen sein. Ein gutes Beispiel wäre CRTP, das das Verhalten von virtuellen Funktionen ohne vtables emuliert: en.wikipedia.org/wiki/Curiously_recurring_template_pattern
DXM
3
@ James Du hast einen Punkt. Was ich zu sagen versucht habe, ist: Jede Indirektion hat die gleichen Probleme, es ist nichts Spezifisches virtual.
23

Hier ist ein Teil des tatsächlichen disassemblierten Codes eines virtuellen Funktionsaufrufs bzw. eines nicht virtuellen Aufrufs:

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

callq  0x4007aa

Sie können sehen, dass für den virtuellen Anruf drei zusätzliche Anweisungen erforderlich sind, um die richtige Adresse zu ermitteln, während die Adresse des nicht virtuellen Anrufs in kompiliert werden kann.

Beachten Sie jedoch, dass diese zusätzliche Nachschlagezeit in den meisten Fällen als vernachlässigbar angesehen werden kann. In Situationen, in denen die Nachschlagezeit signifikant ist, wie in einer Schleife, kann der Wert normalerweise zwischengespeichert werden, indem die ersten drei Anweisungen vor der Schleife ausgeführt werden.

Die andere Situation, in der die Nachschlagezeit erheblich wird, besteht darin, dass Sie über eine Sammlung von Objekten verfügen und in einer Schleife eine virtuelle Funktion für jedes dieser Objekte aufrufen. In diesem Fall benötigen Sie jedoch einige Mittel, um die aufzurufende Funktion auszuwählen, und eine virtuelle Tabellensuche ist ein ebenso gutes Mittel wie jedes andere . Da der vtable-Lookup-Code so häufig verwendet wird, dass er stark optimiert ist, kann der Versuch, ihn manuell zu umgehen, mit hoher Wahrscheinlichkeit zu einer schlechteren Leistung führen.

Karl Bielefeldt
quelle
1
Zu verstehen ist, dass die vtable-Suche und der indirekte Aufruf in fast allen Fällen einen vernachlässigbaren Einfluss auf die Gesamtlaufzeit der aufgerufenen Methode haben.
John R. Strohm
11
@ JohnR.Strohm Ein Mann vernachlässigbar ist der Engpass eines anderen Mannes
James
1
-0x8(%rbp). Oh mein Gott ... diese AT & T-Syntax.
Abyx
" drei zusätzliche anweisungen" nein, nur zwei: laden des vptr und laden des funktionszeigers
curiousguy
@curiousguy es sind in der Tat drei zusätzliche Anweisungen. Sie haben vergessen, dass eine virtuelle Methode immer für einen Zeiger aufgerufen wird , daher müssen Sie den Zeiger zuerst in ein Register laden. Zusammenfassend besteht der allererste Schritt darin, die Adresse, die die Zeigervariable enthält, in das Register% rax zu laden und dann gemäß der Adresse im Register den Wert vtpr für diese Adresse in das Register% rax zu laden und anschließend gemäß dieser Adresse im Registrieren Sie sich, laden Sie die Adresse der aufzurufenden Methode in% rax und rufen Sie dann q *% rax! auf.
Gab 是 好人
18

Langsamer als was ?

Virtuelle Funktionen lösen ein Problem, das nicht durch direkte Funktionsaufrufe gelöst werden kann. Im Allgemeinen können Sie nur zwei Programme vergleichen, die dasselbe berechnen. "Dieser Raytracer ist schneller als dieser Compiler" ist nicht sinnvoll, und dieses Prinzip lässt sich sogar auf kleine Dinge wie einzelne Funktionen oder Programmiersprachenkonstrukte verallgemeinern.

Wenn Sie keine virtuelle Funktion verwenden, um dynamisch zu einem Code zu wechseln, der auf einem Datum basiert, z. B. dem Typ eines Objekts, müssen Sie etwas anderes verwenden, z. B. eine switchAnweisung, um dasselbe zu erreichen. Dieses etwas andere hat seine eigenen Gemeinkosten und Auswirkungen auf die Organisation des Programms, die seine Wartbarkeit und globale Leistung beeinflussen.

Beachten Sie, dass Aufrufe an virtuelle Funktionen in C ++ nicht immer dynamisch sind. Wenn ein Objekt aufgerufen wird, dessen genauer Typ bekannt ist (weil das Objekt kein Zeiger oder Verweis ist oder weil sein Typ ansonsten statisch abgeleitet werden kann), handelt es sich bei den Aufrufen nur um reguläre Memberfunktionsaufrufe. Dies bedeutet nicht nur, dass keine zusätzlichen Kosten für den Versand anfallen, sondern dass diese Anrufe auf die gleiche Weise wie normale Anrufe eingebunden werden können.

Mit anderen Worten, Ihr C ++ - Compiler kann feststellen, ob für virtuelle Funktionen kein virtueller Versand erforderlich ist. Daher gibt es normalerweise keinen Grund, sich um die Leistung im Vergleich zu nicht virtuellen Funktionen Sorgen zu machen.

Neu: Auch Shared Libraries dürfen nicht vergessen werden. Wenn Sie eine Klasse verwenden, die sich in einer gemeinsam genutzten Bibliothek befindet, ist der Aufruf einer normalen Member-Funktion nicht einfach eine nette Befehlssequenz callq 0x4007aa. Es muss ein paar Schritte durchlaufen, wie das Indirektisieren durch eine "Programmverknüpfungstabelle" oder eine solche Struktur. Daher könnte die Indirektion einer gemeinsam genutzten Bibliothek den Kostenunterschied zwischen einem (wirklich indirekten) virtuellen Anruf und einem direkten Anruf etwas (wenn nicht vollständig) ausgleichen. Bei Überlegungen zu Kompromissen bei virtuellen Funktionen muss daher berücksichtigt werden, wie das Programm aufgebaut ist: Ist die Klasse des Zielobjekts monolithisch mit dem Programm verknüpft, das den Aufruf ausführt?

Kaz
quelle
4
"Langsamer als was?" - Wenn Sie eine Methode virtuell machen, die nicht virtuell sein muss, haben Sie ziemlich gutes Vergleichsmaterial.
Tdammers
2
Vielen Dank für den Hinweis, dass Aufrufe von virtuellen Funktionen nicht immer dynamisch sind. Bei jeder anderen Antwort sieht es so aus, als würde das Deklarieren einer Funktion unabhängig von den Umständen einen automatischen Leistungseinbruch bedeuten.
Syndog
12

weil ein virtueller Anruf gleichbedeutend ist mit

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

Wenn der Compiler mit einer nicht-virtuellen Funktion die erste Zeile konstant falten kann, ist dies eine Dereferenzierung, eine Hinzufügung und ein dynamischer Aufruf, der nur in einen statischen Aufruf umgewandelt wird

Dadurch kann auch die Funktion eingebunden werden (mit allen Konsequenzen für die Optimierung).

Ratschenfreak
quelle