Performance der parallelen STL-Algorithmen

Modernes C++ Rainer Grimm  –  5 Kommentare

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?

Performance of the Parallel STL Algorithms

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.

Performance of the Parallel STL Algorithms

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.

Performance of the Parallel STL Algorithms

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.

Performance of the Parallel STL Algorithms

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.