Neue Möglichkeiten 14.04.2014, 00:00 Uhr

Mit C++ AMP auf der Überholspur

Mit der C++ Accelerated Massive Parallelism Library stellt Visual Studio 2013 die Möglichkeit zur Verfügung, die enorme Leistungsfähigkeit moderner Grafikkarten und Multicore-Prozessoren zu nutzen.
Unter der Haube heutiger Computer-Systeme arbeitet eine Vielzahl von Prozessoren. Das gilt auch für Smartphones: Auch sie haben verfügen heutzutage über mehrere Cores.

Galt es bisher für den Entwickler, nur für einen Prozessor zu entwickeln, steht er heute vor der viel schwierigeren Herausforderung, gleich für eine Vielzahl unterschiedlichster Prozessoren und Architekturen Code zu entwickeln.

In besonderem Maße gilt das auch für die Grafikkarte. Auch sie verfügt heute über viele Rechenkerne. Und statt sie nur bunter Pixel auf dem Bildschirm zeichnen zu lassen, kann man die Kerne dazu benutzen, Rechnungen parallel auszuführen. Solche Ansätze eignen sich für sogenannte datenparallele Probleme, bei denen eine Vielzahl von gleichen Operationen auf große Mengen von Daten angewendet werden soll.

Leider ist die Programmierung der Grafikkarte für sogenannte GPGPU-Anwendungen (General Purpose GPU Computing) aber recht kompliziert und eher etwas für Experten als für den Allround-Entwickler.

Mit C++ AMP eröffnet Microsoft dem Allround-Entwickler die Möglichkeit von den großen Beschleunigungszuwächsen auf Multi-Prozessor-Hardware zu profitieren. C++ AMP ist ein einfach zu erlernendes API, mit dem sich Code für Multi-Prozessor-Architekturen entwickeln lässt. Dabei kann der Code auf verschiedenen Accelerators (Beschleunigern) laufen. So heißen in C++ AMP die Recheneinheiten, auf denen datenparalleler Code ablaufen soll.

Hierbei hat der Entwickler die Kontrolle darüber, wo die Berechnung und damit die Beschleunigung stattfinden soll. Dank C++ AMP kann man von GPU- zu CPU-Beschleunigung wechseln, ohne dass der Code geändert werden müsste.

Eine besonders schöne Eigenschaft von C++ AMP ist die Verwendung des neuen C++11-Standards. Er ermöglicht zum Beispiel die Verwendung von Lambda-Funktionen.

C++ AMP läuft unter Windows 7 und Windows Server 2008 RC. Jede Grafikkarte mit DirectX-11-Unterstützung (oder höher) kann von C++ AMP profitieren. Aber auch Grafikkarten, die DirectX 11 nicht unterstützen, können einen Geschwindigkeitsgewinn durch Rückgriff auf die Multicore-CPU verbuchen.

C++ AMP in Aktion

Es sollen zwei Matrizen miteinander Multipliziert werden. Sie lässt sich in gleichartige Rechenoperationen zerlegen, die sich dann parallel ausführen lassen. Um mit C++ AMP loslegen zu können, muss die Headerdatei eingebunden werden.

Die neuen Funktionen befinden sich dann im Namensraum concurrency, sodass noch ein entsprechendes using namesapce concurrency nötig ist. Sofern man Precomplied Headerdateien benutzt, bietet es sich an mit in die Liste der vorkompilierten Dateien mit aufzunehmen.

Das kleine Beispielprogramm (siehe Listing) multipliziert zwei Matrizen, die mit zufälligen Zahlen initialisiert sind. Bei der einfachen Matrixmultiplikation übernehmen zwei geschachtelte for-Schleifen die zeilen- und spaltenweise Addition und Multiplikation, sodass etwa N x N Rechenschritte nötig sind, wenn die Matrizen von Rang N sind.

// einfache Methode der Matrixmultiplikation
void matrix_multiplication(const vector<double>& a,
 const vector<double>& b, vector<double>& c, int N)
{
 for (int i = 0; i < N; ++i)
 {
  for (int j = 0; j < N; ++j)
  {
   for (int k = 0; k < N; ++k)
   {
    c[i*N + j] += a[i*N + k] * b[k*N + j];
   }
  }
 }
}
Nun zur C++-AMP-Version der Matrixmultiplikation.

// AMP Variante der Matrixmultiplikation
void  matrix_multiplication_amp(const vector<double>& a,
 const vector<double>& b, vector<double>& c, int N)
{
 concurrency::extent<2> e(N, N);

 array_view<const double, 2> matrix_a(e, a);
 array_view<const double, 2> matrix_b(e, b);
 array_view<double, 2> matrix_c(e, c);

 parallel_for_each(matrix_c.extent,
    [=](index<2> idx) restrict(amp) {
  int i = idx[0];
  int j = idx[1];

  for (int k = 0; k < matrix_a.extent[0]; ++k)
   matrix_c(idx) += matrix_a(i, k) * matrix_b(k, j);
 });
}
Hier übernimmt eine parallel_for_each-Anweisung die gleichzeitige Ausführung der Operationen. Dabei hilft es, dass bei der Matrixmultiplikation jedes Element unabhängig von anderen berechnet werden kann — aber der Reihe nach. Im ersten Schritt werden die beiden Matrizen der GPU bekannt gemacht. Dies geschieht mit der C++-AMP-Klasse array_view. Sie stellt einen Iterator dar, mit dessen Hilfe der parallelisierte Zugriff auf die Daten erfolgt.

Instanziiert wird ein array_view-Objekt mit den eigentlichen Nutzdaten, der Matrix-Dimension und den Elementtypen, hier als template-Parameter angegeben. Nun kommt der wichtigste Teil des Programms - die parallel_for_each-Funktion. Sie stellt eine Überladung der parallel_for_each-Funktion aus dem Namensraum concurrency dar. Dieser Funktionsaufruf erwartet zwei Argumente:

1. den Wertebereich, hier durch matrix_c.extent gegeben,

2. einen Lambda-Ausdruck.

Die Anweisungen, die sich innerhalb des Codeblockes des Lambda-Ausdrucks befinden, werden direkt von der GPU also parallel ausgeführt. In unserem Beispiel entspricht dies genau der Berechnung des Wertes an der (i, j)-Position. Lambda-Ausdrücke sind anonyme Funktionen, die auf den Zustand, der sich im selben Sichtbarkeitsbereich befindet, zugreifen können. Seit C++11 gehören diese zum Sprachstandard. Lambda-Funktionen vereinfachen das Programmieren von C++-AMP-Code und erleichtern ihre Lesbarkeit. Sie werden daher noch öfter auftreten.

Bei diesem Code fällt ein neues Schlüsselwort auf: restrict(amp). Es lässt sich auf Funktionen oder Lambdas anwenden und kann aktuell nur in der Form restrict(cpu) oder restrict(amp) benutzt werden.

Mit der Compiler-spezifischen Erweiterung restrict(amp) teilen Sie dem Compiler mit, dass hier Code für die GPU kompiliert werden soll. Code, der derart markiert wurde, unterliegt einigen Einschränkungen, die die Hardwaregrenzen aktueller GPUs widerspiegeln und durch das DirectX-11-Programmiermodell begründet sind.

Alternativ: können Sie restrict(cpu) verwenden. Dieser Code unterliegt dabei keinerlei Einschränkungen und Sie können den vollen C++-Sprachumfang benutzen. Dies entspricht also dem Fall, wenn Funktionen keine Dekorierung mit restrict besitzen.

Die wohl gravierendste Einschränkung von restrict(amp) ist, dass innerhalb solcher Funktionen nur Variablen vom Typ int, unsigned int, float und double verwendet werden dürfen. Hinzukommen Klassen beziehungsweise Strukturen, die nur aus diesen elementaren Typen aufgebaut sind. Weitere Einschränkungen lauten:

  •     Rekursive Aufrufe sind nicht erlaubt.
  •     Die Verwendung von try-catch-Anweisungen ist nicht möglich und
  •     restrict(amp)-Funktionen dürfen nur restrict(amp)-Funktionen aufrufen.

Damit ist die Liste der Einschränkungen zwar noch nicht zu Ende, jedoch sind hiermit die Wichtigsten genannt worden.

Das Objekt matrix_c.extent, welches als erster Parameter an die parallel_for_each-Anweisung übergeben wird, stellt den Wertebereich der parallelen Ausführung dar. In dem Beispiel wird matrix_c über den Konstruktor mit dem Objekt extent<2> e(N, N) instanziert. Dieses Objekt kann man sich als einen zweidimensionalen Zeiger vorstellen, der die Elemente der NxN-Matrix vollständig durchläuft.

Für jeden Wert (i, j), der innerhalb des Wertebereichs liegt, wird der Funktionsblock im Lambda-Ausdruck parallel aufgerufen. Über den Index idx wird mitgeteilt, welches Matrixelement berechnet werden soll.

Auf einem Testrechner (Notebook Intel CORE i7, Nvidia Quadro 1000M, Windows 7 SP1, 64-bit) lief die C++-AMP-Variante bei zwei Matrizen der Größe 500x500 bis zu 270-mal schneller.

Beschreibung von C++11 Lambda-Ausdrücken

Mit Einführung von C++11 wurden erstmals Lambda-Ausdrücke in den Sprachstandard aufgenommen. Das Konzept der Lambdas stammt ursprünglich aus den funktionalen Programmiersprachen, wie Haskell oder F#, aber auch nicht-funktionale Sprachen unterstützen dieses Konzept. Seit .NET 3.5. sind Lambda-Ausdrücke auch in den Sprachen C# und VB bekannt. Jedoch kennt C++ schon seit langem ein ähnliches Konzept, nämlich das der Funktionsobjekte oder Funktoren.

Funktoren sind Objektklassen, die den Operator operator() überladen, und sich somit wie Funktionen verhalten. In den STL-Algorithmen werden Funktoren intensiv genutzt. Weil Funktionsobjekte aber auch gewöhnliche Objekte sind, besitzen sie darüber hinaus auch einen Zustand. Sie haben jedoch den Nachteil, dass diese recht umständlich zu programmieren sind, denn für die Definition eines Funktionsobjekts ist jeweils eine eigene Klasse notwendig. Lambdas hingegen sind einfacher in der Programmierung und besitzen die Vorteile, die man von Funktionsobjekten kennt.

Wie lassen sich nun Lambda-Ausdrücke in C++ verwenden?

Die Spezifikation für Lambda-Ausdrücke lautet:
[capture clause] (parameter list) -> return_type { function body }
Jeder Lambda-Ausdruck beginnt mit der sogenannten capture clause. Diese besteht aus den einschließenden eckigen Klammern und einer optionalen Liste von Variablen. Durch die capture clause wird definiert, auf welche externen Variablen der Funktionsblock zugreifen kann. Dabei bezeichnet man eine Variable als extern, wenn sich diese im selben Sichtbarkeitsbereich wie die Lambda-Funktion befindet. Nach der capture clause folgt eine optionale Parameterliste. Die in der Parameterliste definierten Variablen sind die eigentlichen Variablen der Lambda-Funktion und müssen beim Aufruf mit angegeben werden.

Im Wesentlichen folgt der Aufbau der Parameterliste dem der Methoden und Funktionen, wie man es von C++ kennt. Am Ende der Lambda-Funktion steht der Funktionsblock. Er definiert das Verhalten der Funktion und folgt denselben syntaktischen Regeln wie auch gewöhnliche C++-Funktionen und -Methoden.

Ein Beispiel für einen Lambda-Ausdruck:
 string s = "Hello ";

 auto fn = [&s](string t) -> string { return s + t; };
 string message = fn("World.");
Hier wird die lokale Variable s als Referenz erfasst und man kann innerhalb des Lambda-Ausdruckes auf diese zugreifen. Die in den eckigen Klammern spezifizierten Variablen werden sozusagen eingefangen und innerhalb des Funktionsblocks automatisch nutzbar gemacht. Daher auch die Bezeichnung capture clause.

Möchte man dagegen die Variable s nicht als Referenz, sondern als Kopie übergeben, so lautet die capture clause hier einfach nur [s]. Natürlich können auch mehrere Variablen eingefangen werden.

Nach der capture clause folgt die Parameterliste. In unserem Beispiel besitzt der Lambda-Ausdruck den einzigen Parameter t vom Typ string. Der Rückgabewert ist ebenfalls vom Typ string.

Ein paar Beispiele machen die Syntax klar:

[=] - Vom Compiler werden alle im Funktionsblock verwendeten Variablen automatisch ermittelt. Anders als bei [&] werden hierbei nur die Werte der Variablen bekannt gemacht. Die Variablen können nicht modifiziert werden. Hier ist nur ein lesender Zugriff möglich.

Die wichtigsten Klassen aus der C++ AMP Library

Die Klasse accelerator repräsentiert die Zielhardware, auf der die C++-AMP-Laufzeitumgebung ausgeführt wird. In der Regel wird dies die GPU sein, aber auch andere Beschleuniger sind möglich. Während der Initialisierung der C++-AMP-Laufzeitumgebung werden alle auf dem Rechner verfügbaren Acceleators ermittelt.

Unter diesen Acceleators wird dann nach bestimmten Heuristiken einer als Standard ausgewählt. Der Entwickler braucht sich in der Regel nicht darum zu kümmern, sondern kann den vorgeschlagenen Beschleuniger verwenden. Sollte es dennoch einmal notwendig sein, einen Beschleuniger zu bestimmen, kann man mittels der Methode get_all() alle verfügbaren Beschleuniger ermitteln.
 vector<accelerator> accelerators = accelerator::get_all();
 for (auto acce : accelerators)
 {
  std::wcout << acce.get_description()
   << " is emulated: " <<
                 (acce.get_is_emulated() ? "yes" : "no")
   << std::endl;
 }
Eine zentrale Rolle innerhalb C++ AMP spielen die beiden Klassen array und array_view. Diese Klassen stellen N-dimensionale Datenfelder des Typs T dar. Der Hauptunterschied zwischen array und array_view ist, dass die Klasse array die Daten physikalisch speichert, und array_view nur eine Wrapperklasse darstellt. Der eigentliche physikalische Speicherort der Daten befindet sich anderswo.

In gewisser Weise können diese beiden Klassen mit den Container- und Iteratorenklassen aus der STL verglichen werden. Die array-Klasse stellt sozusagen einen Container dar, und die Klasse array_view einen Iterator mit direktem und gleichzeitigem Zugriff auf die gespeicherten Daten.

Die index-Klasse definiert einen N-dimensionalen Indexpunkt, der als ein Koordinatenvektor in einem N-dimensionalen Raum betrachtet werden kann. Hauptaufgabe der index-Klasse ist die Adressierung der gespeicherten Elemente in einem array beziehungsweise array_view. Hierfür existiert in den array-Klassen eine entsprechende Überladung des Indexoperators operator[], der eine Instanz von Typ index erwartet, und das an diesen Koordinaten gespeicherte Element zurückliefert.

Die Klasse extent ist ein Vektor, der die Grenzkoordinaten eines N-dimensionalen Raums darstellt. Beispielweise repräsentiert die Instanz
concurrency::extent<3> e(3, 5, 7);
im 3-dimensionalen Raum alle Punkte mit einer x-Koordinate zwischen 0 und 2, einer y-Koordinate zwischen 0 und 4 und einer z-Koordinate zwischen 0 und 6. Für die Dimensionen bis drei können die Obergrenzen direkt über den Konstruktor angegeben werden. Für die Dimensionen größer als drei müssen die Grenzen durch einen entsprechenden Array-Parameter mitgeteilt werden.

Zusammenfassung

Mit C++ AMP können C++-Entwickler die Leistungsfähigkeit moderner Grafikkarten auch für allgemeine Berechnungen nutzen. Natürlich bietet C++ AMP noch weitere Features, als hier in diesem Artikel beschrieben werden. Im Namespace concurrency::direct3d werden Funktionen für die DirektX-Interoperabilität bereitgestellt. Mathematische Funktionen, mit einfacher und doppelter Genauigkeit, findet man in concurrency::fast_math beziehungsweise concurrency::precise_math.


Das könnte Sie auch interessieren