Performance der parallelen STL-Algorithmen
Nach dem Blick auf die parallelen Algorithmen der STL steht diesmal ein Performanztest mit dem Microsoft Compiler und dem GCC Compiler an.
- Rainer Grimm
Nach dem Blick auf die parallelen Algorithmen der STL steht diesmal ein Performanztest mit dem Microsoft Compiler und dem GCC Compiler an.
In meinem letzten Artikel "Parallele Algorithmen der STL mit dem GCC-Compiler" habe ich die notwendige Theorie ĂĽber den C++17 Algorithmus vorgestellt. Heute soll ein Performanztest mit dem Microsoft Compiler und dem GCC Compiler die einfache Frage beantworten: Zahlt sich die Execution Policy aus?
Der Grund für den kurzen Umweg von meinen Template-Artikeln ist, dass ich festgestellt habe, dass GCC mein Lieblingsfeature des C++17 Standards unterstützt: die parallelen Algorithmen der Standard Template Library. Ich verwende in diesem Artikel den brandneuen GCC 11.1, aber ein GCC 9 sollte auch in Ordnung sein. Um die parallelen STL-Algorithmen mit dem GCC zu nutzen, muss eine zusätzliche Bibliothek installiert werden.
Threading Building Blocks
Der GCC benutzt unter der Haube die Intels Thread Building Blocks (TBB), eine C++ Template-Bibliothek, die von Intel fĂĽr die parallele Programmierung auf Multicore-Prozessoren entwickelt wurde.
Um genau zu sein, ist TBB in der Version 2018 oder höher notwendig. Als ich das Entwicklerpaket der TBB auf meinem Linux-Desktop (Suse) installiert habe, wählt der Paketmanager auch den TBB Memory Allocator aus. Die Verwendung der TBB ist einfach. Dem Linker muss lediglich TBB mit dem Flag -ltbb
bekannt gemacht werden.
Nun bin ich bereit, meine nächsten Schritte mit den parallelen Algorithmen durchzuführen. Hier sind die ersten Zahlen unter Verwendung des Microsoft Compilers 19.16 und des GCC 11.1.
Performance-Zahlen mit dem Microsoft Compiler und dem GCC Compiler
Das folgende Programm parallelSTLPerformance.cpp
berechnet die Tangens mit der sequentiellen (1), parallelen (2) und parallelen und vektorisierten (3) Execution Policy.
// parallelSTLPerformance.cpp
#include <algorithm>
#include <cmath>
#include <chrono>
#include <execution>
#include <iostream>
#include <random>
#include <string>
#include <vector>
constexpr long long size = 500'000'000;
const double pi = std::acos(-1);
template <typename Func>
void getExecutionTime(const std::string& title, Func func){ // (4)
const auto sta = std::chrono::steady_clock::now();
func(); // (5)
const std::chrono::duration<double> dur = std::chrono::steady_clock::now()
- sta;
std::cout << title << ": " << dur.count() << " sec. " << std::endl;
}
int main(){
std::cout << '\n';
std::vector<double> randValues;
randValues.reserve(size);
std::mt19937 engine;
std::uniform_real_distribution<> uniformDist(0, pi / 2);
for (long long i = 0 ; i < size ; ++i) {
randValues.push_back(uniformDist(engine));
}
std::vector<double> workVec(randValues);
getExecutionTime("std::execution::seq", [workVec]() mutable { // (6)
std::transform(std::execution::seq, workVec.begin(), workVec.end(),// (1)
workVec.begin(),
[](double arg){ return std::tan(arg); }
);
});
getExecutionTime("std::execution::par", [workVec]() mutable { // (7)
std::transform(std::execution::par, workVec.begin(), workVec.end(),// (2)
workVec.begin(),
[](double arg){ return std::tan(arg); }
);
});
getExecutionTime("std::execution::par_unseq", [workVec]() mutable { // (8)
std::transform(std::execution::par_unseq, // (3)
workVec.begin(), workVec.end(),
workVec.begin(),
[](double arg){ return std::tan(arg); }
);
});
std::cout << '\n';
}
Zunächst wird der Vektor randValues
mit 500 Millionen Zahlen aus dem halboffenen Intervall [0, pi / 2 [
gefĂĽllt. Das Funktions-Template getExecutionTime
(4) erhält den Namen der Execution Policy und die Lambda-Funktion und führt die Lambda-Funktion (5) aus. Zum Abschluss wird die Ausführungszeit dargestellt. Es gibt eine Besonderheit bei den drei Lambda-Ausdrücken ((6), (7) und (8)), die in diesem Programm verwendet werden. Sie sind als mutable
deklariert. Das ist notwendig, weil die Lambda-AudrĂĽcke ihr Argument workVec
verändern. Lambda-Ausdrücke sind per Default konstant. Wenn er seinen Werte ändern will, muss er als mutable
deklariert werden.
Disclaimer
Ich betone ausdrĂĽcklich, dass ich nicht Windows und Linux miteinander vergleichen, da beide Computer, auf denen Windows oder Linux verwendet werden, unterschiedliche Leistungscharakteristiken besitzen. Diese Performanzzahlen sollen nur ein BauchgefĂĽhl geben. Wer die Zahlen fĂĽr das eigene System kennen will, mus den Test wiederholen.
Ich benutze die maximale Optimierung auf Windows und Linux. Das bedeutet unter Windows kommt das Flag /O2
und unter Linux das Flag -O3
zum Einsatz.
Um es kurz zu machen: Mir geht es darum, ob und in welchem Umfang sich die parallele AusfĂĽhrung der STL-Algorithmen auszahlt. Mein Hauptaugenmerk liegt dabei auf den relativen Performanzunterschieden der sequentiellen und parallelen AusfĂĽhrung.
Windows
Mein Windows-Rechner hat acht logische Kerne, aber die parallele AusfĂĽhrung ist mehr als zehnmal schneller.
Die Zahlen für die parallele und die parallele und vektorisierte Ausführung liegen in der gleichen Größenordnung. Hier ist die Erklärung dazu von dem Visual C++ Team Blog: Using C++17 Parallel Algorithms for Better Performance: Note that the Visual C++ implementation implements the parallel and parallel unsequenced policies the same way, so you should not expect better performance for using par_unseq on our implementation, but implementations may exist that can use that additional freedom someday.
Linux
Mein Linux-Rechner besitzt nur vier Kerne. Hier sind die Zahlen.
Die Zahlen verhalten sich erwartungsgemäß. Ich habe vier Kerne, und die parallele Ausführung ist etwa viermal so schnell wie die sequentielle Ausführung. Die Leistungszahlen der parallelen und vektorisierten Version und der parallelen Version liegen in der gleichen Größenordnung. Meine Vermutung ist natürlich, dass der GCC-Compiler die gleiche Strategie wie der Windows-Compiler verwendet. Wenn ich nach der parallelen und vektorisierten Ausführung frage, indem ich die Ausführungsrichtlinie std::execute::par_unseq
verwende, bekomme ich die parallele Execution Policy (std::execute::par
). Dieses Verhalten entspricht dem C++17 Standard, da die Execution Policy nur eine Empfehlung fĂĽr den Compiler ist.
Meines Wissens unterstützt weder der Windows-Compiler noch der GCC-Compiler die parallele und vektorisierte Ausführung der parallelen STL-Algorithmen. Um die parallelen und vektorisierten Algorithmen in Aktion zu sehen, könnte Nvidias STL-Implementierung Thrust ein idealer Kandidat sein. Für weitere Informationen möchte ich auf den Nvidi- Beitrag "C++ Standard Parallelism" verweisen.
Wie geht's weiter?
Nach diesem C++17 Abstecher gehe ich zurück auf meinen ursprünglichen Pfad: Templates. In meinem nächsten Post tauche ich tiefer in Templates ein und schreibe über die Template-Instanziierung. ()