Wie hoch ist der Overhead von intelligenten Zeigern im Vergleich zu normalen Zeigern in C ++?

100

Wie hoch ist der Overhead von intelligenten Zeigern im Vergleich zu normalen Zeigern in C ++ 11? Mit anderen Worten, wird mein Code langsamer, wenn ich intelligente Zeiger verwende, und wenn ja, wie viel langsamer?

Insbesondere frage ich nach C ++ 11 std::shared_ptrund std::unique_ptr.

Offensichtlich wird das Material, das auf den Stapel geschoben wird, größer sein (zumindest denke ich das), da ein intelligenter Zeiger auch seinen internen Status (Referenzanzahl usw.) speichern muss. Die Frage ist wirklich, wie viel dies kosten wird meine Leistung beeinflussen, wenn überhaupt?

Zum Beispiel gebe ich einen intelligenten Zeiger von einer Funktion anstelle eines normalen Zeigers zurück:

std::shared_ptr<const Value> getValue();
// versus
const Value *getValue();

Oder wenn beispielsweise eine meiner Funktionen einen intelligenten Zeiger als Parameter anstelle eines normalen Zeigers akzeptiert:

void setValue(std::shared_ptr<const Value> val);
// versus
void setValue(const Value *val);
Venemo
quelle
8
Die einzige Möglichkeit, dies zu wissen, besteht darin, Ihren Code zu vergleichen.
Basile Starynkevitch
Welches meinst du? std::unique_ptroder std::shared_ptr?
Stefan
10
Die Antwort ist 42. (ein anderes Wort, wer weiß, Sie müssen Ihren Code profilieren und auf Ihrer Hardware für Ihre typische Arbeitslast verstehen.)
Nim
Ihre Anwendung muss intelligente Zeiger extrem nutzen, damit sie von Bedeutung sind.
user2672165
Die Kosten für die Verwendung eines shared_ptr in einer einfachen Setter-Funktion sind schrecklich und erhöhen den Overhead um mehrere 100%.
Lothar

Antworten:

175

std::unique_ptr hat nur dann Speicheraufwand, wenn Sie ihn mit einem nicht trivialen Deleter versehen.

std::shared_ptr hat immer Speicher-Overhead für Referenzzähler, obwohl es sehr klein ist.

std::unique_ptr Zeitaufwand nur während des Konstruktors (wenn der bereitgestellte Deleter kopiert und / oder der Zeiger auf Null initialisiert werden muss) und während des Destruktors (um das eigene Objekt zu zerstören).

std::shared_ptrhat Zeitaufwand im Konstruktor (um den Referenzzähler zu erstellen), im Destruktor (um den Referenzzähler zu dekrementieren und möglicherweise das Objekt zu zerstören) und im Zuweisungsoperator (um den Referenzzähler zu erhöhen). Aufgrund der Gewindesicherheitsgarantien von std::shared_ptrsind diese Inkremente / Dekremente atomar und erhöhen somit den Overhead.

Beachten Sie, dass keiner von ihnen Zeitaufwand beim Dereferenzieren hat (beim Abrufen des Verweises auf das eigene Objekt), während dieser Vorgang für Zeiger am häufigsten zu sein scheint.

Zusammenfassend lässt sich sagen, dass es einen gewissen Overhead gibt, der den Code jedoch nicht verlangsamen sollte, es sei denn, Sie erstellen und zerstören kontinuierlich intelligente Zeiger.

Lisyarus
quelle
11
unique_ptrhat keinen Overhead im Destruktor. Es funktioniert genauso wie bei einem rohen Zeiger.
R. Martinho Fernandes
6
@ R.MartinhoFernandes im Vergleich zum Rohzeiger selbst hat der Destruktor Zeitaufwand, da der Rohzeiger-Destruktor nichts tut. Im Vergleich dazu, wie ein Rohzeiger wahrscheinlich verwendet werden würde, hat er sicherlich keinen Overhead.
Lisyarus
3
Erwähnenswert ist, dass ein Teil der Kosten für die Erstellung / Zerstörung / Zuweisung von shared_ptr auf die Thread-Sicherheit zurückzuführen ist
Joe
1
Und was ist mit dem Standardkonstruktor von std::unique_ptr? Wenn Sie ein erstellen std::unique_ptr<int>, wird das interne int*initialisiert, nullptrob es Ihnen gefällt oder nicht.
Martin Drozdik
1
@MartinDrozdik In den meisten Situationen würden Sie auch den Rohzeiger auf Null setzen, um später die Nichtigkeit zu überprüfen, oder so ähnlich. Trotzdem fügte dies der Antwort hinzu, danke.
Lisyarus
26

Wie bei jeder Codeleistung besteht das einzige wirklich zuverlässige Mittel, um harte Informationen zu erhalten , darin, den Maschinencode zu messen und / oder zu überprüfen .

Das heißt, einfache Argumentation sagt das

  • Bei Debug-Builds ist mit einem gewissen Overhead zu rechnen, da z. B. operator->als Funktionsaufruf ausgeführt werden muss, damit Sie darauf zugreifen können (dies ist wiederum auf die generelle mangelnde Unterstützung für das Markieren von Klassen und Funktionen als Nicht-Debug zurückzuführen).

  • Denn shared_ptrbei der ersten Erstellung können Sie mit einem gewissen Overhead rechnen, da dies die dynamische Zuweisung eines Steuerblocks umfasst und die dynamische Zuweisung sehr viel langsamer ist als jede andere grundlegende Operation in C ++ (verwenden Sie diese, make_sharedwenn dies praktisch möglich ist, um diesen Overhead zu minimieren).

  • Auch für die shared_ptrAufrechterhaltung eines Referenzzählers gibt es einen minimalen Overhead, z. B. beim Übergeben eines shared_ptrBy-Werts, aber es gibt keinen solchen Overhead für unique_ptr.

Wenn Sie den ersten Punkt oben berücksichtigen, tun Sie dies beim Messen sowohl für Debug- als auch für Release-Builds.

Das internationale C ++ - Standardisierungskomitee hat einen technischen Leistungsbericht veröffentlicht , der jedoch bereits 2006 veröffentlicht wurde unique_ptrund shared_ptrder Standardbibliothek hinzugefügt wurde. Trotzdem waren intelligente Zeiger zu diesem Zeitpunkt ein alter Hut, daher berücksichtigte der Bericht auch dies. Zitieren des relevanten Teils:

„Wenn der Zugriff auf einen Wert über einen einfachen intelligenten Zeiger erheblich langsamer ist als der Zugriff über einen normalen Zeiger, verarbeitet der Compiler die Abstraktion ineffizient. In der Vergangenheit hatten die meisten Compiler erhebliche Abstraktionsstrafen, und einige aktuelle Compiler tun dies immer noch. Es wurde jedoch berichtet, dass mindestens zwei Compiler Abstraktionsstrafen unter 1% und ein weiterer eine Strafe von 3% haben. Die Beseitigung dieser Art von Overhead liegt also auf dem neuesten Stand der Technik. “

Als fundierte Vermutung wurde mit den heute beliebtesten Compilern ab Anfang 2014 das „Gut auf dem neuesten Stand der Technik“ erreicht.

Prost und hth. - Alf
quelle
Könnten Sie bitte einige Details in Ihre Antwort zu den Fällen aufnehmen, die ich meiner Frage hinzugefügt habe?
Venemo
Dies mag vor 10 oder mehr Jahren der Fall gewesen sein, aber heute ist die Überprüfung des Maschinencodes nicht mehr so ​​nützlich, wie die oben genannte Person vorschlägt. Je nachdem, wie Anweisungen weitergeleitet, vektorisiert usw. werden und wie der Compiler / Prozessor mit Spekulationen umgeht, ist dies letztendlich die Geschwindigkeit. Weniger Code Maschinencode bedeutet nicht unbedingt schnelleren Code. Die einzige Möglichkeit, die Leistung zu bestimmen, besteht darin, sie zu profilieren. Dies kann sich pro Prozessor und auch pro Compiler ändern.
Byron
Ein Problem, das ich gesehen habe, ist, dass, sobald shared_ptrs auf einem Server verwendet werden, die Verwendung von shared_ptrs zunimmt und bald shared_ptrs zur Standard-Speicherverwaltungstechnik wird. Jetzt haben Sie 1-3% Abstraktionsstrafen wiederholt, die immer wieder übernommen werden.
Nathan Doromal
Ich denke, das Benchmarking eines Debug-Builds ist eine völlige Zeitverschwendung
Paul Childs,
26

Meine Antwort unterscheidet sich von den anderen und ich frage mich wirklich, ob sie jemals Code profiliert haben.

shared_ptr hat aufgrund seiner Speicherzuweisung für den Steuerblock (der den Referenzzähler und eine Zeigerliste für alle schwachen Referenzen enthält) einen erheblichen Aufwand für die Erstellung. Aufgrund dessen und der Tatsache, dass std :: shared_ptr immer ein 2-Zeiger-Tupel ist (eines für das Objekt, eines für den Steuerblock), hat es auch einen enormen Speicheraufwand.

Wenn Sie einen shared_pointer als Wertparameter an eine Funktion übergeben, ist dieser mindestens zehnmal langsamer als ein normaler Aufruf und erstellt im Codesegment viele Codes für das Abwickeln des Stapels. Wenn Sie es als Referenz übergeben, erhalten Sie eine zusätzliche Indirektion, die auch in Bezug auf die Leistung ziemlich schlechter sein kann.

Deshalb sollten Sie dies nur tun, wenn die Funktion wirklich in die Eigentümerverwaltung involviert ist. Andernfalls verwenden Sie "shared_ptr.get ()". Es soll nicht sicherstellen, dass Ihr Objekt während eines normalen Funktionsaufrufs nicht getötet wird.

Wenn Sie verrückt werden und shared_ptr für kleine Objekte wie einen abstrakten Syntaxbaum in einem Compiler oder für kleine Knoten in einer anderen Diagrammstruktur verwenden, werden Sie einen enormen Leistungsabfall und einen enormen Speicherzuwachs feststellen. Ich habe ein Parser-System gesehen, das kurz nach dem Markteintritt von C ++ 14 und bevor der Programmierer den korrekten Umgang mit intelligenten Zeigern lernte, neu geschrieben wurde. Das Umschreiben war eine Größenordnung langsamer als der alte Code.

Es ist keine Silberkugel und rohe Zeiger sind per Definition auch nicht schlecht. Schlechte Programmierer sind schlecht und schlechtes Design ist schlecht. Entwerfen Sie mit Sorgfalt, entwerfen Sie unter Berücksichtigung klarer Eigentumsverhältnisse und versuchen Sie, shared_ptr hauptsächlich an der Subsystem-API-Grenze zu verwenden.

Wenn Sie mehr erfahren möchten, können Sie Nicolai M. Josuttis guten Vortrag über "Der reale Preis gemeinsamer Zeiger in C ++" https://vimeo.com/131189627 ansehen.
Er geht tief in die Implementierungsdetails und die CPU-Architektur für atomare Schreibbarrieren ein Schlösser usw. Wenn Sie einmal zugehört haben, werden Sie nie davon sprechen, dass diese Funktion billig ist. Wenn Sie nur einen Beweis für die langsamere Größe wünschen, überspringen Sie die ersten 48 Minuten und beobachten Sie, wie er Beispielcode ausführt, der bis zu 180-mal langsamer (kompiliert mit -O3) ausgeführt wird, wenn Sie überall einen gemeinsam genutzten Zeiger verwenden.

Lothar
quelle
Danke für deine Antwort! Auf welcher Plattform haben Sie sich profiliert? Können Sie Ihre Ansprüche mit einigen Daten belegen?
Venemo
Ich habe keine Nummer zu zeigen, aber Sie können einige in Nico Josuttis Talk vimeo.com/131189627
Lothar
6
Schon mal was gehört std::make_shared()? Außerdem finde ich Demonstrationen von offensichtlichem Missbrauch etwas langweilig ...
Deduplicator
2
Alles, was "make_shared" tun kann, ist, Sie vor einer zusätzlichen Zuordnung zu schützen und Ihnen etwas mehr Cache-Lokalität zu geben, wenn der Steuerblock vor dem Objekt zugewiesen wird. Es kann überhaupt nicht helfen, wenn Sie den Zeiger herumreichen. Dies ist nicht die Wurzel der Probleme.
Lothar
14

Mit anderen Worten, wird mein Code langsamer, wenn ich intelligente Zeiger verwende, und wenn ja, wie viel langsamer?

Langsamer? Höchstwahrscheinlich nicht, es sei denn, Sie erstellen mit shared_ptrs einen riesigen Index und haben nicht genügend Speicher, bis Ihr Computer faltig wird, wie wenn eine alte Dame von einer unerträglichen Kraft aus der Ferne zu Boden stürzt.

Was Ihren Code langsamer machen würde, sind langsame Suchvorgänge, unnötige Schleifenverarbeitung, riesige Kopien von Daten und viele Schreibvorgänge auf die Festplatte (wie Hunderte).

Die Vorteile eines intelligenten Zeigers hängen alle mit der Verwaltung zusammen. Aber ist der Aufwand notwendig? Dies hängt von Ihrer Implementierung ab. Angenommen, Sie iterieren über ein Array von 3 Phasen. Jede Phase verfügt über ein Array von 1024 Elementen. Das Erstellen eines smart_ptrfür diesen Prozess ist möglicherweise übertrieben, da Sie nach Abschluss der Iteration wissen, dass Sie es löschen müssen. Sie können also zusätzlichen Speicher gewinnen, wenn Sie keine smart_ptr...

Aber willst du das wirklich tun?

Ein einzelner Speicherverlust kann dazu führen, dass Ihr Produkt zeitlich ausfällt (sagen wir, Ihr Programm verliert 4 Megabyte pro Stunde, es würde Monate dauern, bis ein Computer kaputt geht, aber es wird kaputt gehen, Sie wissen es, weil das Leck da ist). .

Ist wie zu sagen "Ihre Software ist für 3 Monate garantiert, dann rufen Sie mich für den Service."

Am Ende geht es also wirklich darum ... können Sie mit diesem Risiko umgehen? Wenn Sie einen Rohzeiger verwenden, um Ihre Indizierung über Hunderte verschiedener Objekte durchzuführen, sollten Sie die Kontrolle über den Speicher verlieren.

Wenn die Antwort Ja lautet, verwenden Sie einen Rohzeiger.

Wenn Sie nicht einmal darüber nachdenken möchten, smart_ptrist a eine gute, praktikable und großartige Lösung.

Claudiordgz
quelle
4
ok, aber valgrind ist gut darin, nach möglichen Speicherlecks
greywolf
@ Paladin Ja, wenn Sie mit Ihrem Gedächtnis umgehen können, smart_ptrsind wirklich nützlich für große Teams
Claudiordgz
3
Ich benutze unique_ptr, es vereinfacht viele Dinge, aber ich mag shared_ptr nicht, Referenzzählung ist nicht sehr effizient GC und es ist auch nicht perfekt
Graywolf
1
@Paladin Ich versuche, rohe Zeiger zu verwenden, wenn ich alles einkapseln kann. Wenn es etwas ist, das ich wie ein Streit überall herumreichen werde, dann werde ich vielleicht ein smart_ptr in Betracht ziehen. Die meisten meiner unique_ptrs werden in der großen Implementierung verwendet, wie eine Haupt- oder Ausführungsmethode
Claudiordgz
@ Lothar Ich sehe, dass Sie eines der Dinge umschrieben haben, die ich in Ihrer Antwort gesagt habe: Thats why you should not do this unless the function is really involved in ownership management... großartige Antwort, danke, positiv bewertet
Claudiordgz
0

Nur für einen kurzen Blick und nur für den []Bediener ist es ~ 5X langsamer als der Rohzeiger, wie im folgenden Code gezeigt, der mit gcc -lstdc++ -std=c++14 -O0diesem Ergebnis kompiliert und ausgegeben wurde:

malloc []:     414252610                                                 
unique []  is: 2062494135                                                
uq get []  is: 238801500                                                 
uq.get()[] is: 1505169542
new is:        241049490 

Ich fange an, C ++ zu lernen. Ich habe Folgendes im Kopf: Sie müssen immer wissen, was Sie tun, und sich mehr Zeit nehmen, um zu wissen, was andere in Ihrem C ++ getan haben.

BEARBEITEN

Wie von @Mohan Kumar angegeben, habe ich weitere Details angegeben. Die gcc-Version lautet 7.4.0 (Ubuntu 7.4.0-1ubuntu1~14.04~ppa1): Das obige Ergebnis wurde erhalten, wenn das -O0verwendet wird. Wenn ich jedoch das '-O2'-Flag verwende, habe ich Folgendes erhalten:

malloc []:     223
unique []  is: 105586217
uq get []  is: 71129461
uq.get()[] is: 69246502
new is:        9683

Dann verschoben zu clang version 3.9.0, -O0war:

malloc []:     409765889
unique []  is: 1351714189
uq get []  is: 256090843
uq.get()[] is: 1026846852
new is:        255421307

-O2 war:

malloc []:     150
unique []  is: 124
uq get []  is: 83
uq.get()[] is: 83
new is:        54

Das Ergebnis von Clang -O2ist erstaunlich.

#include <memory>
#include <iostream>
#include <chrono>
#include <thread>

uint32_t n = 100000000;
void t_m(void){
    auto a  = (char*) malloc(n*sizeof(char));
    for(uint32_t i=0; i<n; i++) a[i] = 'A';
}
void t_u(void){
    auto a = std::unique_ptr<char[]>(new char[n]);
    for(uint32_t i=0; i<n; i++) a[i] = 'A';
}

void t_u2(void){
    auto a = std::unique_ptr<char[]>(new char[n]);
    auto tmp = a.get();
    for(uint32_t i=0; i<n; i++) tmp[i] = 'A';
}
void t_u3(void){
    auto a = std::unique_ptr<char[]>(new char[n]);
    for(uint32_t i=0; i<n; i++) a.get()[i] = 'A';
}
void t_new(void){
    auto a = new char[n];
    for(uint32_t i=0; i<n; i++) a[i] = 'A';
}

int main(){
    auto start = std::chrono::high_resolution_clock::now();
    t_m();
    auto end1 = std::chrono::high_resolution_clock::now();
    t_u();
    auto end2 = std::chrono::high_resolution_clock::now();
    t_u2();
    auto end3 = std::chrono::high_resolution_clock::now();
    t_u3();
    auto end4 = std::chrono::high_resolution_clock::now();
    t_new();
    auto end5 = std::chrono::high_resolution_clock::now();
    std::cout << "malloc []:     " <<  (end1 - start).count() << std::endl;
    std::cout << "unique []  is: " << (end2 - end1).count() << std::endl;
    std::cout << "uq get []  is: " << (end3 - end2).count() << std::endl;
    std::cout << "uq.get()[] is: " << (end4 - end3).count() << std::endl;
    std::cout << "new is:        " << (end5 - end4).count() << std::endl;
}
liqg3
quelle
Ich habe den Code jetzt getestet, er ist nur 10% langsam, wenn der eindeutige Zeiger verwendet wird.
Mohan Kumar
8
Niemals mit -O0Code vergleichen oder debuggen. Die Ausgabe wird äußerst ineffizient sein . Verwenden Sie immer mindestens -O2(oder -O3heutzutage, weil einige Vektorisierungen nicht durchgeführt werden -O2)
phuclv
1
Wenn Sie Zeit haben und eine Kaffeepause einlegen möchten, nehmen Sie -O4, um die Verbindungszeit zu optimieren, und alle kleinen winzigen Abstraktionsfunktionen werden inline und verschwinden.
Lothar
Sie sollten einen freeAufruf in den Malloc-Test aufnehmen und delete[]für new (oder variable astatisch machen), da die unique_ptrs delete[]unter der Haube in ihren Destruktoren aufrufen .
RnMss