zurück zum Artikel

OpenMP 4.5: Eine kompakte Übersicht zu den Neuerungen

Michael Klemm, Christian Terboven

OpenMP 4.5 ist der nĂ€chste Schritt in der Entwicklung von OpenMP, dem Standard fĂŒr die Shared-Memory-Programmierung. Die neue Version stĂ€rkt vor allem die Programmierung von Beschleunigern mit dem Ziel, zu OpenACC aufzuschließen, bringt aber auch einige allgemeine neue Funktionen.

OpenMP 4.5: Eine kompakte Übersicht zu den Neuerungen

OpenMP 4.5 ist der nĂ€chste Schritt in der Entwicklung von OpenMP, dem Standard fĂŒr die Shared-Memory-Programmierung. Die neue Version stĂ€rkt vor allem die Programmierung von Beschleunigern mit dem Ziel, zu OpenACC aufzuschließen, bringt aber auch einige allgemeine neue Funktionen.

OpenMP 4.0 erschien im Juli 2013 [1] und brachte als grĂ¶ĂŸte Neuerung Konstrukte fĂŒr die Programmierung von Beschleunigern und Coprozessoren. Deren rasante Verbreitung vor allem im Hochleistungsrechnen hat dem OpenMP Language Committee keine Pause gegönnt, verlocken diese GerĂ€te doch mit mehr Rechenleistung bei geringerem Energieverbrauch im Vergleich zu herkömmlichen Prozessoren. DafĂŒr fordern sie aber auch eine spezielle Programmierung ihrer Mikroarchitektur und BerĂŒcksichtigung eines getrennten Speichers.

Sowohl auf diesen GerĂ€ten als auch auf herkömmlichen Multicore-Prozessoren muss somit immer mehr darum gekĂ€mpft werden, ParallelitĂ€t auszudrĂŒcken, um deren Leistung auszunutzen. Um all das zu unterstĂŒtzen, bringt OpenMP 4.5 eine Reihe neuer Funktionen und Verbesserungen fĂŒr parallele Programmierung.

Parallele Schleifen sind ein, wenn nicht das wichtigste Konstrukt in OpenMP. Mit den Worksharing-Konstrukten for fĂŒr C/C++ und do fĂŒr Fortran bietet OpenMP einen denkbar einfachen Weg, eine Schleife in StĂŒcke zu hacken und die einzelnen Teile von den OpenMP-Threads bearbeiten zu lassen. Dennoch gibt es ein paar nervige EinschrĂ€nkungen, die das Programmieren paralleler Schleifen verkomplizieren. Die vielleicht gravierendste ist, dass Worksharing-Konstrukte nicht in anderen derselben Art enthalten sein dĂŒrfen. Man kann also keine parallele Schleife innerhalb einer anderen einbauen, ohne zusĂ€tzliche parallele Regionen samt neuen Teams von Threads zu erzeugen.

Das neue Konstrukt taskloop schafft hier Abhilfe, indem es OpenMP-Tasks zur AusfĂŒhrung nutzt und damit einige dieser EinschrĂ€nkungen aufhebt.

Als kleiner Einschub sei an dieser Stelle erwĂ€hnt, dass OpenMP einen Task als Einheit von Code nebst Datenumgebung versteht. Wird ein task-Konstrukt erreicht, lĂ€sst sich der Task entweder direkt ausfĂŒhren oder aber in eine Warteschlange einreihen und spĂ€ter abarbeiten. Dazu gesellen sich dann noch passende Synchronisationskonstrukte zur Steuerung der Reihenfolge in der Abarbeitung.

Der folgende Code zeigt ein Beispiel, wie sich normale Tasks und eine parallele Schleife mittels des taskloop-Kontruktes verschrÀnkt lassen:

#pragma omp taskgroup
{
#pragma omp task
long_running_task() // kann nebenher ablaufen

#pragma omp taskloop collapse(2) grainsize(500) nogroup
for (int i = 0; i < N; i++)
for (int j = 0; j < M; j++)
loop_body();
}

Der Funktionsaufruf long_running_task() ist ein asynchron gestarteter Task, der sich somit nebenlĂ€ufig zu der darauf folgenden Schleife ausfĂŒhren lĂ€sst. Die Schachtelbarkeit von Tasks erlaubt es weiterhin, dass in der Funktion loop_body() erneut Tasks erzeugt werden, insbesondere auch mit dem taskloop-Konstrukt. Die collapse-Klausel, die ebenfalls beim for/do-Konstrukt verfĂŒgbar ist, instruiert den Compiler, die beiden nachfolgenden Schleifen zu einer zusammenzufassen. Die OpenMP-Implementierung ist fĂŒr das Verteilen von Tasks auf die Threads zustĂ€ndig und kann so fĂŒr Lastverteilung sorgen, falls die Bearbeitung unterschiedlicher Aufgaben unterschiedlich lange dauern. Im Beispiel kann also der Thread, der fĂŒr die Task-AusfĂŒhrung des Funktionsaufrufs long_running_task() zustĂ€ndig war, nach dem Abschluss an der Abarbeitung der Tasks der Schleife teilhaben.

Wie bei OpenMP ĂŒblich unterstĂŒtzt das taskloop-Konstrukt die gewohnten Mittel, die Sichtbarkeit von Daten zu definieren. Das wird in OpenMP Scoping genannt, also das explizite AuffĂŒhren von Variablen in den Klauseln shared, private, firstprivate oder lastprivate. Weiterhin versteht das Konstrukt die Klausel nogroup, welche die implizit vorhandene taskgroup um das Konstrukt abschaltet und somit die automatische Synchronisation mit den erzeugten Tasks vermeidet. Die GrĂ¶ĂŸe dieser Tasks (Anzahl Iterationen) lĂ€sst sich mittels grainsize einstellen. Wer lieber die Anzahl der zu startenden Tasks kontrollieren möchte, kann die num_tasks-Klausel verwenden.

Soll mehr Einfluss auf das Abarbeiten der Tasks genommen werden, wird die mit OpenMP 4.5 neue priority-Klausel interessant: Je höher der angegebene Wert ist, desto höher die PrioritĂ€t eines Tasks. Dieser Hinweis dient als Empfehlung an die OpenMP-Laufzeitumgebung, Tasks auszuwĂ€hlen und deren Bearbeitung vorzuziehen, falls zu einem Zeitpunkt mehrere Tasks zur AusfĂŒhrung bereitstehen. Eine Garantie, aus der Angabe von PrioritĂ€ten eine exakte AusfĂŒhrungsreihenfolge abzuleiten, gibt es aber nicht. Ohne Angabe der Klausel haben alle Tasks die PrioritĂ€t 0, sie sind also gleichwertig.

Gegenseitiger Ausschluss, gemeinhin als Locking bezeichnet, ist in der parallelen Programmierung ein notwendiges Übel. Sperrobjekte ("Locks") sind hĂ€ufig einzusetzen, um Konflikte beim nebenlĂ€ufigen Zugriff auf gemeinsam genutzte Datenstrukturen oder Ressourcen zu vermeiden. Diesen Schutz erkauft man sich durch weniger ParallelitĂ€t aufgrund der Tatsache, dass ein Thread warten muss, bis ein anderer die Sperre aufgehoben und den kritischen Abschnitt verlassen hat. Das ist insbesondere dann Ă€rgerlich, wenn die Sperre nur zur Sicherheit gesetzt werden muss, die Wahrscheinlichkeit eines Zugriffskonflikts aber gering ist.

Das folgende Beispiel zeigt eine einfache (und ineffiziente) Implementierung der Methoden zum EinfĂŒgen und Suchen von Paaren in einer hash_map.

template<class K, class V>
struct hash_map {

hash_map() {
omp_init_lock(&lock);
}

~hash_map() {
omp_destroy_lock(&lock);
}

V& find(const K& key) const {
V* ret = 0;
omp_set_lock(lock);
ret = internal_find(key);
omp_unset_lock(lock);
return *ret;
}

void insert(const K& key, const V& value) {
omp_set_lock(lock);
internal_insert(key, value);
omp_unset_lock(lock);
}
//...

private:
mutable omp_lock_t lock;
hash_buckets *buckets;
// ...
};

Das verkĂŒrzte Beispiel (keine Fehlerbehandlung usw.) zeigt sofort eine SchwĂ€che der Implementierung auf. Die Sperre wird gleich beim Eintritt in den Methoden angefordert, was dazu fĂŒhrt, dass jeweils nur ein Thread auf die Datenstruktur zugreifen kann. Da Hash-Datenstrukturen jedoch explizit dafĂŒr gedacht sind, Zugriffskonflikte aufzulösen, entsteht hier ein unnötiger Engpass: Es könnten sehr wohl mehrere Threads auf die Datenstruktur zugreifen, solange sie nicht auf denselben Hash-Bucket oder sogar auf denselben Eintrag zugreifen oder ihn verĂ€ndern. Typischerweise geht jetzt die Optimierungsarbeit los, und die Sperren werden vom offensichtlichen Ort in die Tiefen der Implementierung verschoben. Manche versuchen sich gar an einer Lock-freien Implementierung.

OpenMP 4.5 kann hier etwas Erleichterung schaffen, indem Programmierer die neue API fĂŒr Sperren nutzen. Diese wurde um zwei Funktionen (omp_init_lock_with_hint und omp_init_nest_lock_with_hint) erweitert, die ein zusĂ€tzliches Argument vom Typ omp_lock_hint_t erwarten (siehe auch folgende Tabelle).

Typ Bedeutung
omp_lock_hint_none Kein Typ gewĂŒnscht, OpenMP-Implementierung kann Typ der Sperre frei wĂ€hlen.
omp_lock_hint_uncontended Die Sperre erzeugt wenige Konflikte.
omp_lock_hint_contended Die Sperre erzeugt viele Konflikte durch konkurrierende Threads.
omp_lock_hint_nonspeculative Sperre soll nicht spekulativ ausgefĂŒhrt werden; zu viel Konfliktpotenzial durch Überlappung des Working-Sets der Threads.
omp_lock_hint_speculative Optimistisches Sperren; Konfliktpotenzial im Working-Set ist gering.

Mit dieser API ist es möglich, dass sich fĂŒr jede einzelne Sperre spezifizieren lĂ€sst, auf welche Art die Sperre implementiert werden soll. Im folgenden Beispiel nutzen die Autoren das, um der OpenMP-Implementierung mitzuteilen, dass die Sperre fĂŒr die hash_map spekulativ ausgefĂŒhrt werden soll (omp_lock_hint_speculative).

template<class K, class V>
struct hash_map {

hash_map() {
omp_init_lock_with_hint(&lock,
omp_lock_hint_speculative);
}

~hash_map() {
omp_destroy_lock(&lock);
}

V& find(const K& key) const {
V* ret = 0;
omp_set_lock(lock);
ret = internal_find(key);
omp_unset_lock(lock);
return *ret;
}

void insert(const K& key, const V& value) {
omp_set_lock(lock);
internal_insert(key, value);
omp_unset_lock(lock);
}
//...

private:
mutable omp_lock_t lock;
hash_buckets *buckets;
// ...
};

Auf einem Prozessor mit Intel Transactional Synchronization Extensions fĂŒhrt das dazu, dass die Hardware die Sperre zunĂ€chst ignoriert und nur bei einem Konflikt zwischen zwei Threads die betroffenen Rechenkerne zurĂŒcksetzt. Darauf wiederholen die Kerne die AusfĂŒhrung mit vollstĂ€ndigem gegenseitigem Ausschluss.

In OpenMP 4.0 wurde das GerĂ€temodell vom Host mit den traditionellen OpenMP-Threads um das Vorhandensein eines oder mehrerer Beschleuniger erweitert, die derzeit alle vom selben Typ sein mĂŒssen. Das target-Konstrukt ĂŒbertrĂ€gt die AusfĂŒhrung vom Host zum entsprechenden GerĂ€t (siehe Abb.), im Folgenden hĂ€ufig Beschleuniger genannt. Der Code, der auf dem Beschleuniger ausgefĂŒhrt wird, wird auch als Kernel bezeichnet und umfasst in der Regel einen rechenintensiven Bereich der Anwendung. Dabei ist es möglich, ĂŒber die map-Klausel Variablen mitsamt ihren Werten mitzunehmen beziehungsweise auf dem GerĂ€t Speicher fĂŒr den Kernel zu reservieren.

OpenMP-4.0-AusfĂŒhrungsmodell mit Host und Beschleuniger (Abb. 1)

OpenMP-4.0-AusfĂŒhrungsmodell mit Host und Beschleuniger (Abb. 1)

OpenMP 4.0 brachte sogenannte Datenregionen, wie sie OpenACC besitzt, in denen Daten auf dem Beschleuniger auch ĂŒber mehrere Kernel-Aufrufe hinweg erhalten bleiben, und zwar fĂŒr die gesamte Lebensdauer der Region. Ein Beispiel ist im folgenden Codebeispiel gezeigt, in dem sich die Datenregion von Zeile 3 bis zum Ende erstreckt.

double var1[N];

#pragma omp target data map(tofrom:var1[:N])
{

class C *c = new C();

#pragma omp target
c.kernel1(); // verwendet var1 und Member von C

#pragma omp target
c.kernel2(); // verwendet var1 und Member von C

delete c;
}

Eine wesentliche BeschrĂ€nkung dieses Ansatzes ist allerdings, dass sich die Dauer einer Datenregion durch den strukturierten Block ergibt, der dem Pragma nachfolgt. Das erlaubt zwar das Umfassen mehrerer Kernelaufrufe wie hier in einfach strukturiertem Code, ermöglicht es aber nicht, in einer Datenregion weitere Variablen auf den Beschleuniger zu mappen oder aber an weiteren Stellen im Code Vorkehrungen zum Mappen von Daten zu treffen. Ein Beispiel hierfĂŒr wĂ€re das Mappen von Daten im Konstruktor der Klasse C.

Dieses oftmals benötigte Feature bringt OpenMP 4.5 in Form der beiden neuen target data-Konstrukte, wie im Folgenden gezeigt. Beide stehen im Code fĂŒr sich selbst. Das target enter data-Konstrukt fĂŒhrt dabei ein Mapping vom Host zum GerĂ€t durch, target exit data entfernt dieses Mapping entsprechend wieder mit der Option, die Daten zum Host zu kopieren.

/* Variablen in var-list werden zum Beschleuniger gemappt */
#pragma omp target enter data map(map-type: var-list) [clauses]

/* Variablen in var-list werden vom Beschleuniger gemappt */
#pragma omp target exit data map(map-type: var-list) [clauses]

Um nun die Motivation fĂŒr die neue Funktion noch einmal aufzugreifen, zeigt das nĂ€chste Beispiel, wie sich im Konstruktor der Klasse C Daten auf ein GerĂ€t im Rahmen einer bestehenden Datenregion mappen lassen.

class C {

public:
C() {
#pragma omp target enter data map(alloc:values[M])
}

~C() {
#pragma omp target exit data map(delete:values[M])
}
private:
double *values;
};

Hierbei wird das Array values auf dem Beschleuniger angelegt. Das target enter data-Konstrukt erzeugt dabei eine sogenannte unstrukturierte Datenregion. Im Destruktor der Klasse C lÀsst sich entsprechend mit einem target exit data-Konstrukt und dem map-Typ delete das Feld abrÀumen.

Eine weitere Neuerung im Kontext der Datenverwaltung zwischen Host und GerĂ€ten ist die UnterstĂŒtzung vom Transfer strukturierter Daten. Bisher war es lediglich möglich, einfache skalare Variablen und Felder oder eine Instanz eines strukturierten Datentyps als Ganzes via bitweisem Kopieren zu mappen. OpenMP 4.5 unterstĂŒtzt nun auch das Mappen einzelner Datentypteile, wobei sich diese Funktion wiederum mit den Mitteln zum Ausdruck von Teilen von Arrays kombinieren lĂ€sst. Der folgende Code zeigt ein paar Beispiele der nun vorhandenen Möglichkeiten, womit zu hoffen ist, dass diese den Transfer komplizierter Datenstrukturen bei der Programmierung von Beschleunigern deutlich vereinfachen.

struct A {
int field;
double array [N];
} a;

#pragma omp target map(a.field)
#pragma omp target map(a.array[23:42])

Um das Thema Mapping von Daten abzuschließen, sei noch erwĂ€hnt, dass nun skalare Variablen in Bezug auf Datenregionen wie "firstprivate" behandelt werden: Sind sie vor einer Datenregion im lokalen Scope deklariert, werden sie genau dann auf das GerĂ€t gemappt, wenn sie in der entsprechende Region referenziert werden. Werden sie nicht referenziert, werden sie auch nicht gemappt – in OpenMP 4.0 bestand der unnötige Zwang fĂŒr eine Implementierung, alle Variablen zu mappen. Wer das neue Verhalten aber nicht mag, der kann die neue Klausel defaultmap(tofrom:scalar) verwenden, sodass die skalaren Variablen wieder alle wie gehabt gemappt werden.

Eine weitere wesentliche Neuerung im Bereich der Beschleunigerprogrammierung ist das asynchrone Offloading. Bisher war es so, dass ein target-Konstrukt den Offload initiierte, also den Wechsel des Kontrollflusses vom Host hin zum GerĂ€t. Damit war aber der ausfĂŒhrende Thread auf dem Host so lange blockiert, bis der Kernel auf dem GerĂ€t abgeschlossen war.

Im Sinne der Integration in den Rest von OpenMP wurde kein neues Konstrukt eingefĂŒhrt, um AsynchronitĂ€t zu ermöglichen, sondern das target-Konstrukt hat ein Upgrade erhalten und ist nun per definitionem ein Task-Konstrukt.

Damit das Verhalten bestehender Programme nicht verĂ€ndert wird, ist ein target-Konstrukt standardmĂ€ĂŸig allerdings ein Task, dessen AusfĂŒhrung nicht aufgeschoben werden darf, sondern direkt erfolgen muss. Um die AsynchronitĂ€t einzuschalten, ist die Klausel nowait hinzuzufĂŒgen. Das hat zur Folge, dass der Kernel – also der Inhalt des target-Konstrukts – auf dem Beschleuniger ausgefĂŒhrt wird, wĂ€hrend auf dem Host die AusfĂŒhrung direkt nach dem Ende des target-Konstrukt fortgesetzt wird. Das gilt ebenso fĂŒr die oben erwĂ€hnten enter/exit target data-Konstrukte, was ein asynchrones Mappen zwischen Host und GerĂ€t ermöglicht.

Um das Ganze sinnvoll anwenden zu können, fehlt noch die Möglichkeit, auf den Abschluss einer asynchronen Operation zu warten. Hierzu wurde auf die mit OpenMP 4.0 eingefĂŒhrten Task-AbhĂ€ngigkeiten zurĂŒckgegriffen. Ein einfaches Beispiel fĂŒr sowohl asynchrone Datentransfers und KernelausfĂŒhrung als auch den Ausdruck von AbhĂ€ngigkeiten ist im Folgenden dargestellt.

double data[N];

#pragma omp target enter data map(to:data[N]) \
depend(inout:data[0]) nowait

do_something_on_the_host();

#pragma omp target depend(inout:data[0]) nowait
perform_kernel_on_device();

do_something_on_the_host();

#pragma omp target exit data map(from:data[N]) \
depend(inout:data[0])

Das target enter data-Konstrukt in Zeile 3 initiiert das Mapping von data auf das GerĂ€t, wegen der Angabe der Klausel nowait kann das aber asynchron geschehen, sodass die AusfĂŒhrung auf dem Host direkt in Zeile 5 mit der Funktion do_something_on_the_host() fortgesetzt wird. Der eigentliche Kernelaufruf erfolgt in den Zeilen 7 und 8: Auch hier wird wieder AsynchronitĂ€t ermöglicht, aber die Angabe der AbhĂ€ngigkeit sorgt dafĂŒr, dass der Kernel erst gestartet wird, wenn der Datentransfer abgeschlossen ist. Das Mapping des Ergebnisses enthĂ€lt ebenfalls wieder eine AbhĂ€ngigkeit, sodass es sich erst ausfĂŒhren lĂ€sst, wenn der Kernel beendet wurde.

Um dieses Feature zu ergĂ€nzen, bringt OpenMP 4.5 eine Reihe von API-Funktionen mit, welche die Speicherverwaltung auf dem GerĂ€t vom Host aus erlauben. Beispielsweise alloziert omp_target_alloc() einen Speicherbereich auf dem GerĂ€t. Es gibt zusĂ€tzlich einen Zeiger dazu zurĂŒck, der sich dann als Argument entsprechende Funktionen mitgeben lĂ€sst, die schließlich einen Kernel aufrufen. Mit der omp_target_memcpy()-Funktion können beliebige Datentransfers ausgedrĂŒckt werden. Diese API-Funktionen sind insbesondere im Zusammenspiel mit Bibliotheken notwendig geworden.

Die folgende abschließende persönliche Anmerkung sei erlaubt: Die Beschleunigerprogrammierung ist bestimmt nicht leicht, nicht unbedingt fĂŒr jedermann gedacht, und auch nicht in jeder Anwendung ist AsynchronitĂ€t oder der Ausdruck von AbhĂ€ngigkeit notwendig. OpenMP 4.5 bringt diese Werkzeuge aber mit und versucht dadurch besonders attraktiv zu sein, dass – anstelle der EinfĂŒhrung neuer Konstrukte – soweit wie möglich auf vorhandene zurĂŒckgegriffen wurde.

Die nun erschienene Version von OpenMP war einst als Nummer 4.1 geplant und angekĂŒndigt, aber mit Blick auf die vielen Neuerungen hat das OpenMP Language Committee, also das Gremium, das den Sprachstandard kontinuierlich weiterentwickelt, beschlossen, die vielen Neuerungen entsprechend mit dem Versionssprung 4.5 zu wĂŒrdigen. In Bezug auf die Beschleunigerprogrammierung hat man mit OpenACC 2.0 aufgeschlossen, im Unterschied dazu aber die vollstĂ€ndige Integration mit der Thread- und Task-parallelen Programmierung geschafft. Diese wurde mit den weiteren vorgestellten neuen Funktionen ausgebaut.

Wichtige Ziele von OpenMP 5.0 – erwartet in zwei bis drei Jahren – sind die Integration von Schnittstellen zum Andocken von Tools, die UnterstĂŒtzung von Daten- und Task-LokalitĂ€t sowie Möglichkeiten zur Arbeitsverteilung zwischen Host und Beschleuniger.

Michael Klemm
ist Teil der Intel Software and Services Group und arbeitet in der Developer Relations Division mit Fokus auf Höchstleistungsrechnen. Er wirkt im OpenMP Language Committee an unterschiedlichsten Fragestellungen mit und leitet die Projektgruppe zur Entwicklung von Fehlerbehandlungsmechanismen fĂŒr OpenMP.

Christian Terboven
ist stellv. Leiter der Gruppe Hochleistungsrechnen am IT Center und Lehrstuhl HPC der RWTH Aachen. Seit 2006 arbeitet er mit im OpenMP Language Committee und ist dort Leiter der Gruppe Affinity, welche sich dem Thema NĂ€he von Threads, Tasks und Daten zu Rechenkernen widmet.
(ane [2])


URL dieses Artikels:
https://www.heise.de/-3020235

Links in diesem Artikel:
[1] https://www.heise.de/hintergrund/Die-wichtigsten-Neuerungen-von-OpenMP-4-0-1915844.html
[2] mailto:ane@heise.de