Die wichtigsten Neuerungen von OpenMP 4.0

Standards  –  2 Kommentare

Mit dem unmittelbar bevorstehenden Erscheinen von OpenMP 4.0 will der Standard für die Shared-Memory-Programmierung einen entscheidenden Schritt aus dem wissenschaftlichen Umfeld heraus schaffen. Dafür sorgen vor allem neue Konstrukte für die Nutzung von Coprozessoren, aber auch viele Erweiterungen bestehender Features.

"Multi-Core is here to stay." Dieser Satz beschreibt die derzeitige Situation in der Entwicklung der Anzahl Prozessorkerne mehr als treffend. Seit der Einführung des ersten Dual-Core-Prozessors ist die Anzahl an Kernen stetig gestiegen. Mit Intels Xeon Phi erreicht die Entwicklung die Many-Core-Ära; 60 Prozessorkerne mit je vier Hardware-Threads fordern "massive Parallelität" von der Applikation, um das System auszulasten.

Aber nicht nur die steigende Zahl an Kernen sorgt für mehr Parallelität. Über die Jahre hinweg wurde stetig an der Breite der SIMD-Register pro Kern gedreht. Begnügte sich Intel MMX in den frühen Tagen mit 64-Bit-Registern, nutzen Intels Advanced Vector Extensions bereits 256 Bit. Intels Many Integrated Core (MIC) Architecture erweitert die Vektorregister gar auf 512 Bit. Das sind sage und schreibe 16 Fließkommawerte in einfacher Genauigkeit beziehungsweise acht Werte in doppelter Genauigkeit. Ohne SIMD-Parallelität verschenkt man damit einen Faktor 16 beziehungsweise 8 im Vergleich zur maximalen Performance des Prozessorkerns.

Es gilt daher, mit der steigenden Zahl der Kerne und den Erweiterungen des SIMD-Befehlssatzes Schritt zu halten. Heutige Applikationen müssen mehrere Ebenen an Parallelität ausnutzen, um die Leistung der Prozessoren voll auszuschöpfen. Traditionelles Multithreading ist nicht mehr das allein selig machende Instrument. Weiterhin verwenden Applikationen immer häufiger Bibliotheken und/oder Plug-ins, die ihrerseits ebenfalls auf Parallelität getrimmt worden sind. Somit stellt sich dem Programmierer immer wieder das Problem, das Zusammenspiel zwischen Applikation, Bibliotheken und Hardware so zu gestalten, dass ein effizientes Ergebnis entsteht.

Eine mögliche Lösung des Dilemmas sind Task-basierte Programmiermodelle. Im Gegensatz zum traditionellen Multithreading wird in diesen Programmiermodellen die Applikation in unabhängige Teilaufgaben ("Tasks") zerlegt. Diese Tasks werden dann vom Laufzeitsystem auf die verfügbaren Threads verteilt und ausgeführt. Beispiele für diese Art von Programmierung sind Cilk Plus und Threading Building Blocks von Intel, aber auch C++11 mit async. Tasks erleichtern das Zusammenspiel zwischen unterschiedlichen Komponenten dadurch, dass jede Komponente unabhängig von anderen Tasks erzeugt werden kann und diese sich dann wie von selbst während der Ausführung mischen. Ebenso lässt sich jederzeit die Anzahl der ausführenden Threads anpassen; Tasks werden dann einfach auf mehr oder weniger Threads verteilt.

OpenMP hat sich seit seiner Einführung 1997 zum De-facto-Standard für die Shared-Memory-Parallelisierung im technisch-wissenschaftlichen Umfeld entwickelt. Es unterstützt die Programmiersprachen C, C++ und Fortran gleichermaßen und ist heute auf allen gängigen, aber auch auf nicht so gängigen Plattformen zu finden. Das "Open" im Namen bedeutet, dass jede Partei den Standard ohne Lizenzkosten frei implementieren darf (auch in Teilen) und sich in die Weiterentwicklung einbringen kann.

Der OpenMP-Standard besteht aus einer Menge von Direktiven zum Ausdruck von Parallelität, API-Routinen für zusätzliche Funktionen und Umgebungsvariablen zur Steuerung des parallelen Programms zur Laufzeit. Die Direktiven sind in C/C++ als sogenannte Pragmas und in Fortran als spezielle Direktiven in Form von Kommentaren realisiert. Somit lässt sich ein OpenMP-Programm in vielen Fällen auch weiterhin für eine sequenzielle Ausführung übersetzen. OpenMP erlaubt ferner die konzentrierte Anwendung auf die rechenintensiven Teile eines Programms, während die umgebenden Bereiche weitgehend unverändert bestehen bleiben können. Dies lässt sich für eine inkrementelle Parallelisierung je nach Bedarf ausnutzen.

Das grundlegende Konstrukt ist die sogenannte "parallele Region", in der zusätzlich zum initialen Thread ("Master") eine Menge weiterer Threads ("Worker") zur Verfügung steht, die zusammen ein Team formen. Außerhalb paralleler Regionen wird das Programm unverändert sequenziell ausgeführt. Sogenannte Worksharing-Konstrukte erlauben die Verteilung von Arbeit auf die Threads im Team, wie im folgenden Codebeispiel die Iterationen der for-Schleife.

const int N = 1000000;
double A[N], B[N], C[N];
/* Initialisierung von A, B und C */

#pragma omp parallel for
for (int i = 0; i < N; i++)
{
A[i] = B[i] + C[i];
}

Hierbei werden die Iterationen in so viele Blöcke aufgeteilt, wie Threads im Team partizipieren, und die Blöcke entsprechend verteilt. Das ermöglicht eine Ausführung mit minimalem Overhead und resultiert in einem optimalen Datenzugriffsmuster für Cache-Architekturen. Falls zur Eliminierung von Lastungleichgewichten benötigt, ist eine Kontrolle der Aufteilung der Iterationen über die schedule-Klausel möglich, zum Beispiel resultiert (dynamic, c) in einer Aufteilung in Blöcke des Umfangs von c Iterationen, die in der Reihenfolge auf Threads verteilt werden, wie diese ihre vorherigen Blöcke erledigen.

Eine Herausforderung in der Shared-Memory-Parallelisierung ist die Verwaltung der Datenumgebung, in OpenMP "Scoping" genannt. Hierbei wird festgelegt, welche Variablen im gemeinsamen Speicher verbleiben und wo Threads eine eigene Kopie benötigen. Das geschieht in OpenMP über Klauseln. Zugriffe auf Variablennamen, die an einer parallelen Region in der shared-Klausel spezifiziert werden, resultieren im Zugriff auf die Originalvariablen. Das ist generell der Standard für alle Variablen, die außerhalb einer parallelen Region deklariert wurden. Im obigen Beispiel sind das die Arrays A, B und C sowie die Konstante N, die explizit als shared(A, B, C, N) hätten deklariert werden können. Variablen, die private gekennzeichnet wurden, werden für jeden Thread im Team neu als nicht initialisierte Kopie angelegt.

Das ist immer dann notwendig, wenn Variablen unterschiedliche Werte für unterschiedliche Threads haben müssen, etwa die Schleifenvariable i im Beispiel. Hier war die Angabe nicht notwendig, weil die zu einem do/for-Worksharing zugehörige Schleifenvariable automatisch privatisiert wird. Soll eine private Variable mit einem Wert vorbelegt werden, steht firstprivate zur Verfügung, das die Variable mit dem Wert direkt vor dem Konstrukt initialisiert.