std::vector Performance Analyse
Einleitung
Der std::vector
wird in C++-Programmen sehr häufig verwendet.
Viele Programmierer wissen jedoch nicht, daß dieses Container-Template
ein schlechtes Laufzeitverhalten hat und für größere
Datenmengen nicht geeignet ist.
Dokumentation: cppreference.com
Protokollklasse
Das Laufzeitverhalten vom std::vector
untersuchen wir mit einer geschwätzigen
Protokollklasse, die ausführlich über alle Ereignisse aus ihrem C++-Leben berichtet.
class Chatterbox
{
int i;
public:
Chatterbox ()
{ cout << "+ Chatterbox" << endl; i = 0; }
Chatterbox (int init)
{ cout << "+ Chatterbox " << init << endl; i = init; }
Chatterbox (const Chatterbox & cp)
{ cout << "+ Chatterbox cp " << cp. i << endl; i = cp. i; }
~Chatterbox ()
{ cout << "- Chatterbox " << i << endl; }
Chatterbox & operator = (const Chatterbox & asgn)
{ cout << ". Chatterbox = " << asgn. i << endl; i = asgn. i; return * this; }
};
|
Beispieldaten
Am Anfang von unserem kleinen Testprogramm werden ein paar Beispielobjekte angelegt.
cout << "Construct samples" << endl;
Chatterbox cb1 (1);
Chatterbox cb2 (2);
Chatterbox cb3 (3);
Chatterbox cb4 (4);
Chatterbox cb5 (5);
|
Output:
Construct samples
+ Chatterbox 1
+ Chatterbox 2
+ Chatterbox 3
+ Chatterbox 4
+ Chatterbox 5
|
Testvector
Der Testvector wird am Anfang eines geschachtelten Blocks angelegt,
damit wir am Ende des Scopes den Destruktor beobachten können.
{
vector <Chatterbox> cbvec;
|
Ein- und Anfügen
Wenn sich die Größe des internen Speicherblocks ändert,
dann wird der vorhandene Block nicht reallokiert.
Stattdessen wird ein neuer Block allokiert, und alle vorhandenen Elemente
werden mit dem Copy-Konstruktor dort hinein kopiert.
Anschließend werden die Destruktoren der bisherigen Objekte aufgerufen
und der bisherige Speicherblock wird freigegeben.
Dieses Verhalten ist unabhängig davon, ob das neue Element am Anfang,
in der Mitte oder am Ende eingefügt wird.
cout << "--------------" << endl;
cout << "Append cb 3" << endl;
cbvec. push_back (cb3);
cout << "--------------" << endl;
cout << "Insert cb 2" << endl;
cbvec. insert (cbvec. begin (), cb2);
cout << "--------------" << endl;
cout << "Append cb 4" << endl;
cbvec. push_back (cb4);
cout << "--------------" << endl;
cout << "Insert cb 1" << endl;
cbvec. insert (cbvec. begin (), cb1);
cout << "--------------" << endl;
cout << "Append cb 5" << endl;
cbvec. push_back (cb5);
|
Output:
--------------
Append cb 3
+ Chatterbox cp 3
--------------
Insert cb 2
+ Chatterbox cp 2
+ Chatterbox cp 3
- Chatterbox 3
--------------
Append cb 4
+ Chatterbox cp 4
+ Chatterbox cp 2
+ Chatterbox cp 3
- Chatterbox 2
- Chatterbox 3
--------------
Insert cb 1
+ Chatterbox cp 1
+ Chatterbox cp 2
+ Chatterbox cp 3
+ Chatterbox cp 4
- Chatterbox 2
- Chatterbox 3
- Chatterbox 4
--------------
Append cb 5
+ Chatterbox cp 5
+ Chatterbox cp 1
+ Chatterbox cp 2
+ Chatterbox cp 3
+ Chatterbox cp 4
- Chatterbox 1
- Chatterbox 2
- Chatterbox 3
- Chatterbox 4
|
Elemente löschen
Beim Löschen von Elementen werden die vorhandenen Elemente mit dem
Assignment-Operator an eine neue Stelle kopiert.
Die Anzahl der internen Kopieroperationen ist abhängig von der Position,
an der der Vector-Container verändert wird.
Der überschüssige Speicher wird i.a. nicht automatisch freigegeben.
Zum Freigeben dieses Speichers muß explizit shrink_to_fit
aufgerufen werden.
cout << "--------------" << endl;
cout << "Remove first" << endl;
cbvec. erase (cbvec. begin ());
cout << "--------------" << endl;
cout << "Remove last" << endl;
cbvec. pop_back ();
|
Output:
--------------
Remove first
. Chatterbox = 2
. Chatterbox = 3
. Chatterbox = 4
. Chatterbox = 5
- Chatterbox 5
--------------
Remove last
- Chatterbox 5
|
Ein- und Anfügen im freien Speicher
Wenn im internen Speicherblock noch freier Platz vorhanden ist
(z.B. wegen vorangegangener Löschoperationen), dann ist
das Verhalten des Vector-Containers beim Ein- und Anfügen
sehr ähnlich wie beim Löschen.
cout << "--------------" << endl;
cout << "Insert cb 1" << endl;
cbvec. insert (cbvec. begin (), cb1);
cout << "--------------" << endl;
cout << "Append cb 5" << endl;
cbvec. push_back (cb5);
|
Output:
--------------
Insert cb 1
+ Chatterbox cp 1
+ Chatterbox cp 4
. Chatterbox = 3
. Chatterbox = 2
. Chatterbox = 1
- Chatterbox 1
--------------
Append cb 5
+ Chatterbox cp 5
|
Interne Größe explizit ändern
Wenn man die Größe des internen Speicherblocks explizit ändert,
dann wird der vorhandene Block (wie oben beschrieben) nicht reallokiert.
Stattdessen wird ein neuer Block allokiert, und alle vorhandenen Elemente
werden mit dem Copy-Konstruktor dort hinein kopiert.
cout << "--------------" << endl;
cout << "Reserve" << endl;
cbvec. reserve (10);
cout << "--------------" << endl;
cout << "Shrink" << endl;
cbvec. shrink_to_fit ();
|
Output:
--------------
Reserve
+ Chatterbox cp 1
+ Chatterbox cp 2
+ Chatterbox cp 3
+ Chatterbox cp 4
+ Chatterbox cp 5
- Chatterbox 1
- Chatterbox 2
- Chatterbox 3
- Chatterbox 4
- Chatterbox 5
--------------
Shrink
+ Chatterbox cp 1
+ Chatterbox cp 2
+ Chatterbox cp 3
+ Chatterbox cp 4
+ Chatterbox cp 5
- Chatterbox 1
- Chatterbox 2
- Chatterbox 3
- Chatterbox 4
- Chatterbox 5
|
Destruktor
Am Ende des geschachtelten Block-Scopes wird der Destruktor
des Vector-Containers ausgeführt.
Dieser ruft die Destruktoren aller enthaltenen Elemente auf.
cout << "--------------" << endl;
cout << "Destruct vector" << endl;
}
|
Output:
--------------
Destruct vector
- Chatterbox 1
- Chatterbox 2
- Chatterbox 3
- Chatterbox 4
- Chatterbox 5
|
Beispieldaten löschen
Am Ende unseres kleinen Testprogramms werden die Beispielobjekte gelöscht,
die wir ganz am Anfang angelegt hatten.
cout << "--------------" << endl;
cout << "Destruct samples" << endl;
return 0;
|
Output:
--------------
Destruct samples
- Chatterbox 5
- Chatterbox 4
- Chatterbox 3
- Chatterbox 2
- Chatterbox 1
|
Compiler-Unterschiede
Der oben gezeigte Output des Testprogramms wurde mit dem MSVC-Compiler Version 2022 erzeugt.
Bei anderen Compilern ist das Rechenergebnis (der Inhalt vom Vector-Container) stets dasselbe.
Die Wege dorthin, also die internen Zwischenschritte, können aber sehr unterschiedlich sein.
Beim g++-Compiler 11.2.0 gibt es z.B. beim erstmaligen Einfügen vom Objekt 1 kein
Neu-Allokieren und komplettes Umkopieren.
Output:
--------------
Insert cb 1
+ Chatterbox cp 1
+ Chatterbox cp 4
. Chatterbox = 3
. Chatterbox = 2
. Chatterbox = 1
- Chatterbox 1
|
C++11 Move Semantics
Wenn wir in unserer geschwätzigen Protokollklasse die C++11 Move Semantics ergänzen,
dann ruft der Compiler in einigen (nicht in allen) Fällen statt des Copy-Konstruktors
den Move-Konstruktor auf und statt des Assignment-Operators den Move-Assignment-Operator.
Anzahl und Reihenfolge der Aufrufe bleiben jedoch gleich.
class Chatterbox
{
int i;
public:
Chatterbox ()
{ cout << "+ Chatterbox" << endl; i = 0; }
Chatterbox (int init)
{ cout << "+ Chatterbox " << init << endl; i = init; }
Chatterbox (const Chatterbox & cp)
{ cout << "+ Chatterbox cp " << cp. i << endl; i = cp. i; }
Chatterbox (Chatterbox && mv)
{ cout << "+ Chatterbox mv " << mv. i << endl; i = mv. i; }
~Chatterbox ()
{ cout << "- Chatterbox " << i << endl; }
Chatterbox & operator = (const Chatterbox & asgn)
{ cout << ". Chatterbox = " << asgn. i << endl; i = asgn. i; return * this; }
Chatterbox & operator = (Chatterbox && mv)
{ cout << ". Chatterbox mv = " << mv. i << endl; i = mv. i; return * this; }
};
|
Beispiel Output, erstmaliges Einfügen von Objekt 1 und 5:
--------------
Insert cb 1
+ Chatterbox cp 1
+ Chatterbox mv 2
+ Chatterbox mv 3
+ Chatterbox mv 4
- Chatterbox 2
- Chatterbox 3
- Chatterbox 4
--------------
Append cb 5
+ Chatterbox cp 5
+ Chatterbox cp 1
+ Chatterbox cp 2
+ Chatterbox cp 3
+ Chatterbox cp 4
- Chatterbox 1
- Chatterbox 2
- Chatterbox 3
- Chatterbox 4
|
C++11 Move Semantics mit noexcept
Wenn wir den Move-Konstruktor und den Move-Assignment-Operator zusätzlich mit noexcept
kennzeichnen, dann werden diese Funktionen vom std::vector
häufiger aufgerufen.
Beachte aber folgendes:
- Es handelt sich um ein undokumentiertes Feature, das funktionieren kann oder auch nicht.
- Die Anzahl der internen Zwischenschritte bleibt gleich, es sind eben nur andere Schritte.
- In einigen Fällen können Move-Konstruktor und Move-Assignment-Operator Exceptions
werfen und dürfen nicht mit
noexcept
gekennzeichnet werden.
class Chatterbox
{
int i;
public:
Chatterbox ()
{ cout << "+ Chatterbox" << endl; i = 0; }
Chatterbox (int init)
{ cout << "+ Chatterbox " << init << endl; i = init; }
Chatterbox (const Chatterbox & cp)
{ cout << "+ Chatterbox cp " << cp. i << endl; i = cp. i; }
Chatterbox (Chatterbox && mv) noexcept
{ cout << "+ Chatterbox mv " << mv. i << endl; i = mv. i; }
~Chatterbox ()
{ cout << "- Chatterbox " << i << endl; }
Chatterbox & operator = (const Chatterbox & asgn)
{ cout << ". Chatterbox = " << asgn. i << endl; i = asgn. i; return * this; }
Chatterbox & operator = (Chatterbox && mv) noexcept
{ cout << ". Chatterbox mv = " << mv. i << endl; i = mv. i; return * this; }
};
|
Beispiel Output, erstmaliges Einfügen von Objekt 1 und 5:
--------------
Insert cb 1
+ Chatterbox cp 1
+ Chatterbox mv 2
+ Chatterbox mv 3
+ Chatterbox mv 4
- Chatterbox 2
- Chatterbox 3
- Chatterbox 4
--------------
Append cb 5
+ Chatterbox cp 5
+ Chatterbox mv 1
+ Chatterbox mv 2
+ Chatterbox mv 3
+ Chatterbox mv 4
- Chatterbox 1
- Chatterbox 2
- Chatterbox 3
- Chatterbox 4
|
Hintergrund-Infos
Dokumentation: cppreference.com
Der C++-Standard ISO/IEC 14882 beschreibt in der Version C++20 die Klasseneigenschaften
"trivially copyable" (6.8, 11.2.1) und "standard-layout" (11.2.5).
In früheren Versionen gab es noch die Eigenschaft POD (Plain Old Data, 14882:2017 6.9.9),
seit C++20 wird dieser Begriff nicht mehr verwendet.
Gemäß C++20 6.8 können "trivially copyable" Objekte mit memcpy
und memmove
im Speicher kopiert und verschoben werden.
Über andere Objekte gibt es keine Aussage.
Für Container-Templates wie std::vector
beschreibt der C++-Standard
das Interface und das Verhalten.
Die konkrete Implementierung bleibt dem Compilerbauer überlassen.
Ein std::vector
muß beliebige C++-Objekte verarbeiten können,
nicht nur "trivially copyable".
Deshalb verwenden die Implementierungen weder realloc
noch memcpy
und memmove
.
Siehe auch: C++ Object relocation, Published Proposal
"It is intended that most standard library types be trivially relocatable types."
Mögliche Optimierungen
In der Praxis können fast alle C++-Objekte mit memcpy
und memmove
an eine andere Stelle im Speicher verschoben werden.
Beachte: Es geht hier um das Verschieben von kompletten Objekten
("most derived object", C++20 6.7.2.2, 6.7.2.6) und nicht um
"potentially-overlapping subobjects" (C++20 6.7.2.7).
Ein Container wie std::vector
enthält stets komplette Objekte.
Beachte weiterhin: Es geht hier um das Verschieben von Elementen innerhalb
eines dynamischen Array-Containers (Verschieben an eine andere Stelle im Speicher)
und nicht um das Duplizieren von Objekten mit memcpy
.
Zum Verbessern der Performance von dynamischen Arrays gibt es u.a.
die folgenden Möglichkeiten:
- Beim Einfügen/Löschen werden die nachfolgenden Objekte mit
memmove
verschoben.
- Beim Ändern der Größe wird der interne Speicherblock reallokiert.
- Mit einer optimierten Speicherverwaltung werden Reallokationen und Fragmentierung reduziert.
Quelltext
Hier ist noch der vollständige Quelltext der drei Testprogramme ohne und mit Move Semantics:
01_vector1.cpp
01_vector2.cpp
01_vector3.cpp