C++

Eine codebasierte Einführung in C++ AMP

Daniel Moth

Dieser Artikel behandelt eine Technologie in der Vorabversion, das sogenannte C++ AMP, das im Lieferumfang von Visual Studio 11 enthalten ist. Änderungen an allen Informationen in diesem Artikel sind vorbehalten.

Visual Studio 11 bietet Unterstützung für heterogenes Mainstreamcomputing über eine Technologie namens C++ Accelerated Massive Parallelism (C++ AMP). Dank dieser Technologie können Sie die Vorteile von Beschleunigern (z. B. GPUs) bei der Ausführung von Algorithmen für parallel verarbeitete Daten voll ausschöpfen.

C++ AMP stellt Leistung über tragbare Hardware bereit, ohne die Produktivität, die Sie vom modernen C++ und dem Visual Studio-Paket gewohnt sind, einzuschränken. Gegenüber der ausschließlichen Nutzung der CPU kann es die Geschwindigkeit um ein Vielfaches erhöhen. Bei Konferenzen demonstriere ich in der Regel einen einzelnen Prozess, der gleichzeitig sowohl NVIDIA als AMD GPUs einsetzt, während nach wie vor eine CPU-Ausweichlösung zur Hand ist.

In dieser codebasierten Einführung in C++ AMP wird vorausgesetzt, dass Sie jede Codezeile in diesem Artikel lesen. Der Inlinecode ist ein Hauptbestandteil des Artikels. Der Inhalt des C++-Codes wird nicht unbedingt im Text des Artikels wiederholt.

Setup und Beispielalgorithmus

Beschäftigen wir uns zunächst mit dem einfachen Algorithmus, mit dem wir zusammen mit dem erforderlichen Setupcode zur Vorbereitung auf die spätere Konvertierung in C++ AMP arbeiten werden.

Erstellen Sie ein leeres C++-Projekt, fügen Sie eine neue leere C++-Datei (Source.cpp) hinzu, und geben Sie den folgenden selbsterklärenden Code ein (ich verwende nicht zusammenhängende Zeilennummern, um die Erklärung im Artikeltext zu vereinfachen; dieselben Zeilennummern finden Sie im herunterladbaren Begleitprojekt):

1 #include <amp.h>                // C++ AMP header file
3 #include <iostream>             // For std::cout etc
4 using namespace concurrency;    // Save some typing :)
5 using std::vector;     // Ditto. Comes from <vector> brought in by amp.h
6
79 int main()
80 {
81   do_it();
82
83   std::cout << "Hit any key to exit..." << std::endl;
84   std::cin.get();
85 }

C++ AMP führt eine Reihe von Typen für diverse Headerdateien ein. Wie aus den Zeilen 1 und 4 des vorangegangenen Codeausschnitts ersichtlich, ist die Headerhauptdatei „amp.h“ und die main-Typen werden dem vorhandenen Concurrency-Namespace hinzugefügt. Für C++ AMP ist weder ein zusätzliches Setup noch eine Kompilierungsoption erforderlich. Fügen wir nun über dem main-Typ eine do_it-Funktion hinzu (siehe Abbildung 1).

Abbildung 1: do_it-Funktion bei Aufruf vom main-Typ

52 void do_it()
53 {
54   // Rows and columns for matrix
55   const int M = 1024;
56   const int N = 1024;
57
58   // Create storage for a matrix of above size
59   vector<int> vA(M * N);
60   vector<int> vB(M * N);
61
62   // Populate matrix objects
63   int i = 0;
64   std::generate(vA.begin(), vA.end(), [&i](){return i++;});
65   std::generate(vB.begin(), vB.end(), [&i](){return i--;});
66
67   // Output storage for matrix calculation
68   vector<int> vC(M * N);
69
70   perform_calculation(vA, vB, vC, M, N);
76 }

In den Zeilen 59, 60 und 68 verwendet der Code std::vector-Objekte als flache Container für jede Matrix, obwohl der zweidimensionale Typ hier eigentlich mehr von Interesse wäre. Hierzu jedoch später mehr.

Wichtig ist, die Verwendung der Lambda-Ausdrücke in den Zeilen 64 und 65 zu verstehen, die an die std::generate-Methode zum Auffüllen der beiden Vektorobjekte übergeben werden. Dieser Artikel setzt voraus, dass Sie die Verwendung von Lambda-Ausdrücken in C++ beherrschen. Sie sollten beispielsweise sofort verstehen, dass jedes Vektormitglied auf 0 initialisiert werden würde, wenn die Variable i nach Wert erfasst wurde (durch Ändern der Erfassungsliste entweder in Form von [i] oder [=] und Verwenden des mutable-Schlüsselworts). Wenn Sie mit Lambda-Ausdrücken nicht vertraut sind (eine nützliche Ergänzung zum Standard C++ 11), lesen Sie zunächst den MSDN Library-Artikel „Lambda-Ausdrücke in C++“ (msdn.microsoft.com/library/dd293608), und kehren Sie dann hierher zurück.

Mit der do_it-Funktion wurde ein perform_calculation-Aufruf eingeführt, der wie folgt codiert ist:

7  void perform_calculation(
8    vector<int>& vA, vector<int>& vB, vector<int>& vC, int M, int N)
9  {
15   for (int i = 0; i < M; i++)
16   {
17     for (int j = 0; j < N; j++)
18     {
19       vC[i * N + j] = vA[i * N + j] + vB[i * N + j];
20     }
22   }
24 }

In diesem vereinfachten Beispiel für eine Matrixaddition sticht eine Tatsache hervor, nämlich dass die Mehrdimensionalität der Matrix aufgrund des linearisierten Speicherns der Matrix in einem Vektorobjekt verloren geht. (Aus diesem Grund war es notwendig, die Matrixdimensionen zusammen mit den Vektorobjekten weiterzugeben.) Außerdem müssen Sie seltsame arithmetische Operationen mit den Indizes in Zeile 19 ausführen. Dieser Punkt wäre noch offensichtlicher, wenn Sie Untermatrizen dieser Matrizen addieren wollten.

Bis jetzt gibt es dafür keinen C++ AMP-Code. Als Nächstes sehen Sie, wie Sie durch Ändern der perform_calculation-Funktion einige der C++ AMP-Typen einführen können. In späteren Abschnitten erfahren Sie, wie Sie C++ AMP voll ausschöpfen und Ihre Algorithmen für parallel verarbeitete Daten beschleunigen können.

array_view<T, N>, extent<N> und index<N>

Mit C++ AMP wird ein concurrency::array_view-Typ zum Einschließen von Datencontainern eingeführt. Diesen Typ kann man sich als intelligenten Zeiger vorstellen. Er stellt Daten in rechteckiger Form dar, und zwar in der am wenigsten signifikanten Dimension zusammenhängend. Der Grund für seine Existenz wird später offensichtlich. Als Nächstes werden einige Aspekte seiner Verwendung vorgestellt. Ändern wir den Hauptteil der perform_calculation-Funktion wie folgt:

11     array_view<int> a(M*N, vA), b(M*N, vB);
12     array_view<int> c(M*N, vC);
14
15     for (int i = 0; i < M; i++)
16     {
17       for (int j = 0; j < N; j++)
18       {
19         c(i * N + j) = a(i * N + j) + b(i * N + j);
20       }
22     }

Diese Funktion, die auf der CPU kompiliert und ausgeführt wird, hat dieselbe Ausgabe wie zuvor. Der einzige Unterschied besteht in der unnötigen Verwendung der array_view-Objekte, die in den Zeilen 11 und 12 eingeführt werden. In Zeile 19 erscheint nach wie vor die zunächst seltsam erscheinende Indizierung, allerdings werden statt der Vektorobjekte (vA, vB und vC) jetzt die array_view-Objekte (a, b, c) verwendet, und der Zugriff auf die Elemente erfolgt über den array_view-Funktionsoperator (im Gegensatz zur vorherigen Verwendung des Vektorindizierungsoperators – mehr dazu später).

Dem array_view-Typ muss über ein Vorlagenargument (in diesem Beispiel int) der Elementtyp des Containers, den er einschließt, mitgeteilt werden. Der Container wird als das letzte Konstruktorargument (z. B. die vC-Variable des Vektortyps in Zeile 12) übergeben. Das erste Konstruktorargument ist die Anzahl der Elemente.

Die Anzahl der Argumente kann auch mit einem con­currency::extent-Objekt angegeben werden. Die Zeilen 11 und 12 können daher wie folgt geändert werden:

10     extent<1> e(M*N);
11     array_view<int, 1> a(e, vA), b(e, vB);
12     array_view<int, 1> c(e, vC);

Das extent<N>-Objekt stellt einen mehrdimensionalen Raum dar, in dem die Rangfolge als Vorlagenargument weitergegeben wird. Das Vorlagenargument in diesem Beispiel ist 1, die Rangfolge kann jedoch ein beliebiger Wert größer als null sein. Der extent-Konstruktor akzeptiert die Größe jeder Dimension, die das extent-Objekt darstellt, wie in Zeile 10 gezeigt. Das extent-Objekt kann dann an den array_view-Objektkonstruktor zur Definition seiner Form weitergegeben werden, wie in den Zeilen 11 und 12 dargestellt. In diesen Zeilen wurde dem array_view-Objekt außerdem ein zweites Vorlagenobjekt hinzugefügt, um anzugeben, dass es sich um einen eindimensionalen Bereich handelt. Wie in dem früheren Codebeispiel hätte dieser Schritt unbedenklich ausgelassen werden können, da die Standardrangfolge 1 ist.

Da Sie sich nun mit diesen Typen vertraut gemacht haben, können Sie weitere Änderungen an der Funktion vornehmen, damit diese auf mehr natürliche zweidimensionale Art auf die Daten zugreifen kann. Dies ist eher mit dem Matrixbereich vergleichbar:

10     extent<2> e(M, N);
11     array_view<int, 2> a(e, vA), b(e, vB);
12     array_view<int, 2> c(e, vC);
14
15     for (int i = 0; i < e[0]; i++)
16     {
17       for (int j = 0; j < e[1]; j++)
18       {
19         c(i, j) = a(i, j) + b(i, j);
20       }
22     }

Durch die Änderungen in den Zeilen 10 – 12 werden die array_view-Objekte zweidimensional, sodass zwei Indizes für den Zugriff auf ein Element erforderlich sind. Die Zeilen 15 und 17 greifen auf die Grenzen des extent-Objekts über dessen Indizierungsoperator zu, anstatt direkt die Variablen M und N zu verwenden. Nachdem Sie die Form in das extent-Objekt gekapselt haben, können Sie dieses Objekt in Ihrem gesamten Code verwenden.

Die wichtige Änderung befindet sich in Zeile 19, in der Sie nun keine seltsamen arithmetischen Operationen mehr benötigen. Die Indizierung erfolgt weitaus natürlicher, sodass der gesamte Algorithmus besser zu lesen und zu warten ist.

Wenn das array_view-Objekt mit einem dreidimensionalen extent-Objekt erstellt wurde, erwartet der Funktionsoperator drei ganze Zahlen, um auf ein Element zuzugreifen, und zwar nach wie vor von der signifikantesten bis zur am wenigsten signifikanten Dimension. Wie von einer mehrdimensionalen API zu erwarten, besteht auch die Möglichkeit einer Indizierung in ein array_view-Objekt durch Weitergabe eines einzelnen Objekts an den entsprechenden Indizierungsoperator. Das Objekt muss vom Typ concurrency::index<N> sein, wobei N der Rangfolge des extent-Objekts entspricht, mit dem das array_view-Objekt erstellt wird. Eine nähere Erläuterung dazu, wie Indizierungsobjekte an Ihren Code übergeben werden können, folgt später. Vorerst wollen wir ein solches Objekt manuell erstellen, um ein Gefühl dafür zu entwickeln und es in Aktion zu erleben. Dazu ändern wir den Hauptteil der Funktion wie folgt:

10     extent<2> e(M, N);
11     array_view<int, 2> a(e, vA), b(e, vB);
12     array_view<int, 2> c(e, vC);
13
14     index<2> idx(0, 0);
15     for (idx[0] = 0; idx[0] < e[0]; idx[0]++)
16     {
17       for (idx[1] = 0; idx[1] < e[1]; idx[1]++)
18       {
19         c[idx] = a[idx] + b[idx];
//19         //c(idx[0], idx[1]) = a(idx[0], idx[1]) + b(idx[0], idx[1]);
20       }
22     }

Wie aus den Zeilen 14, 15, 17 und 19 ersichtlich, verfügt der concurrency::index<N>-Typ über eine ähnliche Oberfläche wie der extent-Typ, mit dem Unterschied, dass der index-Typ statt eines n-dimensionalen Raums einen n-dimensionalen Punkt darstellt. Sowohl der extent- als auch der index-Typ unterstützen eine Reihe von arithmetischen Operationen durch Überladen von Operatoren, z. B. die Inkrementoperation aus dem vorherigen Beispiel.

Zuvor wurden die Schleifenvariablen (i und j) zum Indizieren des array_view-Objekts verwendet. Jetzt können sie durch ein einzelnes index-Objekt in Zeile 19 ersetzt werden. Dies zeigt, wie Sie mit dem array_view-Indizierungsoperator das Objekt mit einer einzelnen Variablen (in diesem Beispiel idx des Typs index<2>) indizieren können.

Sie verfügen nun über grundlegende Kenntnisse der drei neuen mit C++ AMP eingeführten Typen: array_view<T, N>, extent<N> und index<N>. Diese Typen haben mehr zu bieten, wie in den Klassendiagrammen in Abbildung 2 gezeigt.

array_view, extent and index Classes
Abbildung 2: Die Klassen array_view, extent und index

Der wahre Antrieb und die wahre Motivation zur Verwendung dieser mehrdimensionalen API besteht darin, Ihre Algorithmen auf einem Beschleuniger für die parallele Datenverarbeitung, wie die GPU, auszuführen. Dazu benötigen Sie einen Einstiegspunkt in die API zum Ausführen Ihres Codes auf dem Beschleuniger sowie eine Möglichkeit, zum Zeitpunkt der Kompilierung zu prüfen, dass Sie eine Teilmenge der C++-Sprache verwenden, die auf einem solchen Beschleuniger effizient ausgeführt werden kann.

parallel_for_each und restrict(amp)

Die API, die die C++ AMP-Laufzeit anweist, Ihre Funktion auf dem Beschleuniger auszuführen, ist eine neue Überladung des concurrency::parallel_for_each-Objekts. Es akzeptiert zwei Argumente: ein extent-Objekt und einen Lambda-Ausdruck

Mit dem extent<N>-Objekt, mit dem Sie bereits vertraut sind, wird bestimmt, wie oft der Lambda-Ausdruck für den Beschleuniger aufgerufen wird. Sie sollten davon ausgehen, dass Ihr Code jedes Mal von einem separaten Thread aufgerufen wird, und zwar möglicherweise gleichzeitig und ohne garantierte Reihenfolge. Beispiel: Ein extent<1>(5)-Objekt führt zu fünf Aufrufen des Lambda-Ausdrucks, der an das parallel_for_each-Objekt weitergegeben wird, während ein extent<2>(3,4)-Objekt 12 Aufrufe des Lambda-Ausdrucks ergibt. In realen Algorithmen planen Sie in der Regel Tausende von Aufrufen Ihres Lambda-Ausdrucks.

Der Lambda-Ausdruck muss ein index<N>-Objekt akzeptieren, mit dem Sie vertraut sind. Das index-Objekt muss dieselbe Rangfolge haben wie das an das parallel_for_each-Objekt weitergegebene extent-Objekt. Der index-Wert ändert sich selbstverständlich mit jedem Aufruf Ihres Lambda-Ausdrucks. So lässt sich zwischen zwei verschiedenen Aufrufen Ihres Lambda-Ausdrucks unterscheiden. Sie können sich den index-Wert als Thread-ID vorstellen.

Nachfolgend eine Codedarstellung der bisherigen Beschreibung im Hinblick auf das parallel_for_each-Objekt:

89     extent<2> e(3, 2);
90     parallel_for_each(e,
91       [=](index<2> idx)
92       {
93         // Code that executes on the accelerator.
94         // It gets invoked in parallel by multiple threads
95         // once for each index "contained" in extent e
96         // and the index is passed in via idx.
97         // The following always hold true
98         //      e.rank == idx.rank
99         //      e.contains(idx) == true
100        //      the function gets called e.size() times
101        // For this two-dimensional case (.rank == 2)
102        //      e.size() == 3*2 = 6 threads calling this lambda
103        // The 6 values of idx passed to the lambda are:
104        //      { 0,0 } { 0,1 } { 1,0 } { 1,1 } { 2,0 } { 2,1 }
105      }
106    );
107    // Code that executes on the host CPU (like line 91 and earlier)

Dieser einfache Code würde ohne eine wichtige Hinzufügung in Zeile 91 nicht kompiliert werden:

error C3577: Concurrency::details::_Parallel_for_each argument #3 is illegal: missing public member: 'void operator()(Concurrency::index<_Rank>) restrict(amp)'

Beim Schreiben des Codes würde Sie nichts davon abhalten, im Hauptteil des Lambda-Ausdrucks (Zeilen 92 – 105) einen Inhalt zu verwenden, der in der vollständigen C++-Sprache (wie vom Visual C++ Compiler unterstützt) zulässig ist. Sie sind jedoch im Hinblick auf bestimmte Aspekte der C++-Sprache in der aktuellen GPU-Architektur beschränkt, sodass Sie angeben müssen, welche Teile Ihres Codes diese Beschränkungen erfüllen (Sie können zum Zeitpunkt des Kompilierens feststellen, ob Sie gegen eine Regel verstoßen). Die Angabe muss für den Lambda-Ausdruck und für alle anderen Funktionssignaturen erfolgen, die Sie über den Lambda-Ausdruck aufrufen. Zeile 91 muss daher wie folgt geändert werden:

91         [=](index<2> idx) restrict(amp)

Dies ist ein neues wichtiges Sprachfeature der C++ AMP-Spezifikation, das dem Visual C++-Compiler hinzugefügt wurde. Funktionen (einschließlich Lambdas) können mit der Anmerkung restrict(cpu), dem impliziten Standardwert, oder restrict(amp) versehen werden, wie im obigen Codebeispiel gezeigt. Möglich ist jedoch auch eine Kombination wie z. B. restrict(cpu, amp). Es sind keine weiteren Optionen vorhanden. Die Anmerkung wird Teil der Funktionssignatur, sodass sie an der Überladung teilnimmt, was ein wichtiger Antrieb zur Entwicklung dieser Funktion war. Wenn eine Funktion mit der Anmerkung restrict(amp) versehen wird, wird sie auf eine Reihe von Beschränkungen hin überprüft. Wird gegen eine Beschränkung verstoßen, wird ein Compilerfehler ausgegeben. Die vollständigen Beschränkungen werden im folgenden Blogpost dokumentiert: bit.ly/vowVlV.

Eine der restrict(amp)-Beschränkungen für Lambda-Ausdrücke besteht darin, dass sie weder Variablen nach Verweis (siehe Nachteil weiter unten in diesem Artikel) noch Zeiger erfassen können. Vor dem Hintergrund dieser Beschränkung stellt sich bei einem Blick auf den letzten Code für parallel_for_each zu Recht die folgende Frage: „Wenn weder Variablen nach Verweis noch Zeiger erfasst werden können, wie kann ich dann Ergebnisse, d. h. erwünschte Nebeneffekte, des Lambda-Ausdrucks erhalten? Alle Änderungen, die an den Variablen, die nach Wert erfasst werden, vorgenommen werden, stehen dem externen Code nicht zur Verfügung, sobald der Lambda-Ausdruck abgeschlossen ist.“

Die Antwort auf diese Frage ist ein Typ, den Sie schon kennen: array_view. Das array_view-Objekt kann im Lambda-Ausdruck nach Wert erfasst werden. Dies ist der Mechanismus zur internen und externen Weitergabe der Daten. Verwenden Sie einfach array_view-Objekte zum Einschließen realer Container. Erfassen Sie dann die array_view-Objekte im Lambda-Ausdruck zum Zugreifen und Auffüllen, und greifen Sie dann nach dem Aufruf der parallel_for_each-Funktion auf die entsprechenden array_view-Objekte zu.

Zusammenfassung

Mit dem neu angeeigneten Wissen können Sie nun zu der eingangs erwähnten seriellen CPU-Matrixaddition zurückkehren und die Zeilen 15 – 22 wie folgt ersetzen:

15     parallel_for_each(e, [=](index<2> idx) restrict(amp)
16     {
19       c[idx] = a[idx] + b[idx];
22     });

Zeile 19 ist unverändert, und die doppelte verschachtelte Schleife mit manueller index-Objekterstellung innerhalb der extent-Grenzen wird durch einen Aufruf der parallel_for_each-Funktion ersetzt.

Wenn Sie mit diskreten Beschleunigern mit eigenem Speicher arbeiten, führt die Erfassung der array_view-Objekte in dem an die parallel_for_each-Funktion übergebenen Lambda-Ausdruck zu einer Kopie der dem globalen Speicher des Beschleunigers zugrunde liegenden Daten. Entsprechend werden die Daten beim Zugreifen über das array_view-Objekt (in diesem Beispiel c) nach dem parallel_for_each-Aufruf über den Beschleuniger wieder zurück in den Hostspeicher kopiert.

Wenn Sie auf die Ergebnisse des array_view-Objekts c über den ursprünglichen Container vC (und nicht über das array_view-Objekt) zugreifen möchten, rufen Sie die synchronize-Methode des array_view-Objekts auf. Der Code würde so wie angegeben funktionieren, da der array_view-Destruktor die synchronize-Methode für Sie aufruft. Ausnahmen würden allerdings auf diesem Wege verloren gehen. Daher wird empfohlen, die synchronize-Methode stattdessen explizit aufzurufen. Fügen Sie eine Anweisung an einer beliebigen Stelle nach dem parallel_for_each-Aufruf hinzu, z. B.:

23          c.synchronize();

Der umgekehrte Vorgang (mit dem sichergestellt wird, dass das array_view-Objekt die aktuellen Daten aus seinem ursprünglichen Container enthält) wird mit der refresh-Methode erzielt.

Vor allem kann das Kopieren von Daten (normalerweise) über einen PCIe-Bus sehr kostspielig sein, sodass die Daten nur in die erforderliche Richtung kopiert werden sollten. In dem weiter oben angegebenen Code können Sie die Zeilen 11 – 13 ändern, um anzugeben, dass die den array_view-Objekten a und b zugrunde liegenden Daten auf den Beschleuniger kopiert (aber nicht zurückkopiert) werden müssen und dass die dem array_view-Objekt c zugrunde liegenden Daten nicht auf den Beschleuniger kopiert werden müssen. Im folgenden Snippet sind die notwendigen Änderungen fett formatiert:

11          array_view<const int, 2> a(e, vA), b(e, vB);
12          array_view<int, 2> c(e, vC);
13          c.discard_data();

Doch auch mit diesen Änderungen ist der Matrixadditionsalgorithmus nicht rechenintensiv genug, um die Overhead-Kosten für das Kopieren der Daten zu rechtfertigen. Daher ist er für die parallele Datenverarbeitung mit C++ AMP nur bedingt geeignet. Ich habe ihn lediglich verwendet, um Ihnen die Grundlagen zu vermitteln.

Durch die Verwendung dieses einfachen Beispiels im gesamten Artikel verfügen Sie nun über die Fähigkeiten, andere Algorithmen, die rechenintensiv genug sind, um Vorteile zu erzielen, zu parallelisieren. Ein solcher Algorithmus stellt die Matrixmultiplikation dar. Vergewissern Sie sich, dass Sie ohne weitere Erklärung meinerseits die folgende einfache serielle Implementierung des Matrixmultiplikationsalgorithmus verstehen:

void MatMul(vector<int>& vC, const vector<int>& vA,
  const vector<int>& vB, int M, int N, int W)
{
  for (int row = 0; row < M; row++)
  {
    for (int col = 0; col < N; col++)
    {
      int sum = 0;
      for(int i = 0; i < W; i++)
        sum += vA[row * W + i] * vB[i * N + col];
      vC[row * N + col] = sum;
    }
  }
}

... sowie die entsprechende C++ AMP-Implementierung:

array_view<const int, 2> a(M, W, vA), b(W, N, vB);
array_view<int, 2> c(M, N, vC);
c.discard_data();
parallel_for_each(c.extent, [=](index<2> idx) restrict(amp)
{
  int row = idx[0]; int col = idx[1];
  int sum = 0;
  for(int i = 0; i < b.extent[0]; i++)
    sum += a(row, i) * b(i, col);
  c[idx] = sum;
});
c.synchronize();

Auf meinem Laptop lässt sich durch die C++ AMP-Matrixmultiplikation eine Leistungssteigerung gegenüber dem seriellen CPU-Code für M=N=W=1024 um mehr als das 40fache erzielen.

Da Ihnen nun die Grundlagen vertraut sind, fragen Sie sich möglicherweise, wie Sie den Beschleuniger auswählen können, auf dem Ihr Algorithmus ausgeführt werden soll, nachdem Sie ihn mit C++ AMP implementiert haben. Dieser Frage wollen wir uns als Nächstes widmen.

accelerator und accelerator_view

Der neue accelerator-Typ ist Teil des Concurrency-Namespace. Dieser Typ stellt ein Gerät im System dar, das von der C++ AMP-Laufzeit verwendet werden kann. Für die erste Version ist dies Hardware mit einem installierten DirectX 11-Treiber (oder DirectX-Emulatoren).

Beim Start der C++ AMP-Laufzeit werden alle Beschleuniger basierend auf interner Heuristik aufgelistet und einer als Standard ausgewählt. Aus diesem Grunde mussten Sie sich in dem vorhergehenden Code nicht direkt mit Beschleunigern auseinandersetzen. Es wurde automatisch ein Standardbeschleuniger ausgewählt. Wenn Sie die Beschleuniger auflisten und den Standardbeschleuniger selbst auswählen möchten, ist dieser Vorgang sehr einfach, wie der selbsterklärende Code in Abbildung 3 zeigt.

Abbildung 3: Auswahl eines Beschleunigers

26 accelerator pick_accelerator()
27 {
28   // Get all accelerators known to the C++ AMP runtime
29   vector<accelerator> accs = accelerator::get_all();
30
31   // Empty ctor returns the one picked by the runtime by default
32   accelerator chosen_one;
33
34   // Choose one; one that isn't emulated, for example
35   auto result =
36     std::find_if(accs.begin(), accs.end(), [] (accelerator acc)
37   {
38     return !acc.is_emulated; //.supports_double_precision
39   });
40   if (result != accs.end())
41     chosen_one = *(result); // else not shown
42
43   // Output its description (tip: explore the other properties)
44   std::wcout << chosen_one.description << std::endl;
45
46   // Set it as default ... can only call this once per process
47   accelerator::set_default(chosen_one.device_path);
48
49   // ... or just return it
50   return chosen_one;
51 }

In Zeile 38 wird eine der zahlreichen Beschleunigereigenschaften abgefragt. Andere sind in Abbildung 4 dargestellt.

accelerator and accelerator_view Classes
Abbildung 4: Die Klassen accelerator und accelerator_view

Wenn Sie verschiedene parallel_for_each-Aufrufe mit unterschiedlichen Beschleunigern verwenden möchten oder aus einem anderen Grund nicht einfach den Standardbeschleuniger global für einen Prozess festlegen möchten, müssen Sie ein accelerator_view-Objekt an die parallel_for_each-Funktion weitergeben. Dies ist möglich, da die parallel_for_each-Funktion eine Überladung besitzt, die das accelerator_view-Objekt als ersten Parameter akzeptiert. Ein accelerator_view-Objekt erhalten Sie genauso einfach, wie Sie ein default_view-Objekt für ein accelerator-Objekt aufrufen. Beispiel:

accelerator_view acc_vw = pick_accelerator().default_view;

Abgesehen von der DirectX 11-Hardware gibt es drei spezielle Beschleuniger, die C++ AMP verfügbar macht:

  • direct3d_ref: Dieser Beschleuniger ist zum Debuggen der korrekten Funktion geeignet, jedoch nicht für die Produktion, da er deutlich langsamer ist als reale Hardware.
  • direct3d_warp: Dies ist eine Ausweichlösung zum Ausführen Ihres C++ AMP-Codes auf der CPU unter Verwendung von Multi-Core- und Streaming-SIMD-Erweiterungen.
  • cpu_accelerator: Dieser Beschleuniger ist zumindest in dieser Version nicht in der Lage, C++ AMP-Code auszuführen. Er ist nur nützlich, um die Bereitstellung von Arrays (eine erweiterte Optimierungstechnik) einzurichten, was über den Rahmen dieses Artikels hinausgeht. Eine Beschreibung finden Sie jedoch in diesem Blogpost: bit.ly/vRksnn.

Tiling und weiterführende Literatur

Das wichtigste Thema, das in diesem Artikel nicht behandelt wurde, ist das Tiling.

Aus dem Blickwinkel des Szenarios betrachtet, kann das Tiling die Geschwindigkeit ebenso um ein Vielfaches erhöhen wie die bisher untersuchten Coding-Techniken und diesen Leistungszuwachs (potenziell) sogar noch toppen. Aus dem Blickwinkel der API betrachtet, besteht das Tiling aus tiled_index- und tiled_extent-Typen sowie aus dem tile_barrier-Typ und einer tile_static-Speicherklasse. Es ist außerdem eine Überladung der parallel_for_each-Funktion vorhanden, die ein tiled_extent-Objekt akzeptiert und deren Lambda-Ausdruck ein tiled_index-Objekt akzeptiert. Innerhalb dieses Lambda-Ausdrucks sind tile_barrier-Objekte und tile_static-Variablen zulässig. Ich beschäftige mich in meinem zweiten Artikel über C++ AMP auf Seite 40 mit dem Tiling.

Weitere Themen können Sie selbstständig mithilfe der Blogposts und der MSDN-Onlinedokumentation erarbeiten:

  • <amp_math.h> ist eine Mathematikbibliothek mit zwei Namespaces, einen für mathematische Funktionen mit hoher Präzision und der andere für schnelle, jedoch weniger genaue mathematische Funktionen. Abonnieren Sie die Bibliothek basierend auf Ihren Hardwarefunktionen und den Anforderungen Ihres Szenarios.
  • <amp_graphics.h> und <amp_short_vectors.h> sowie einige DirectX-Interoperabilitätsfunktionen sind für die Grafikprogrammierung verfügbar.
  • concurrency::array ist ein Containerdatentyp, der an den Beschleuniger gebunden ist und fast dieselbe Oberfläche besitzt wie das array_view-Objekt. Dieser Typ gehört zu den beiden Typen (der andere ist der texture-Typ im graphics-Namespace), die im Lambda-Ausdruck, der an den parallel_for_each-Aufruf weitergegeben wird, nach Verweis erfasst werden müssen. Dies ist der Nachteil, auf den ich weiter oben im Artikel bereits hingewiesen habe.
  • Unterstützung für DirectX-Interna wie Atomics für die threadübergreifende Synchronisierung
  • GPU-Debuggen und Profilerstellung in Visual Studio 11

Schutz künftiger Investitionen

In diesem Artikel wurden Sie in eine moderne C++-API zur parallelen Datenverarbeitung eingeführt, mit der Sie Ihre Algorithmen so ausdrücken können, dass Ihre Anwendung GPUs zur Leistungssteigerung verwenden kann. C++ AMP ist so konzipiert, dass es Ihre künftigen Investitionen für Hardware schützt, die noch entwickelt werden muss.

Sie haben erfahren, wie eine Handvoll Typen (array_view, extent und index) Sie bei der Arbeit mit mehrdimensional Daten unterstützen in Verbindung mit einer einzelnen globalen Funktion (parallel_for_each), mit der Sie Ihren Code auf einem Beschleuniger (den Sie über die accelerator- und accelerator_view-Objekte angeben können) – angefangen bei einem restrict(amp)-Lambda-Ausdruck – ausführen können.

Über die Microsoft Visual C++-Implementierung hinaus wird C++ AMP der Community als offene Spezifikation bereitgestellt, die jeder Benutzer auf jeder beliebigen Plattform implementieren kann.

Daniel Moth ist leitender Program Manager der Microsoft Developer-Abteilung. Er kann über seinen Blog unter danielmoth.com/ erreicht werden.

Unser Dank gilt den folgenden technischen Experten für die Durchsicht dieses Artikels: Steve Deitz, Yossi Levanoni, Robin Reynolds-Haertle, Stephen Toub und Weirong Zhu