GPGPU-Programmierung – Rechnen auf Grafikkarten
Eine Einführung in die eingebettete Programmierung der GPU
Grafikkarten werden längst nicht mehr nur zur Grafikdarstellung verwendet. Immer häufiger wird ihre Rechenleistung für allgemeinere Aufgaben genutzt, etwa für Simulationen, Bilderkennung oder künstliche Intelligenz. Diese Nutzung außerhalb des Grafikbereichs ist auch bekannt als GPGPU (General-Purpose Computing on Graphics Processing Units). Der Unterschied zwischen einem Grafikprozessor (GPU) und einem Hauptprozessor (CPU) besteht in erster Linie im Grad der Parallelisierung. Während die CPU für die serielle Ausführung optimiert ist, arbeitet die GPU massiv parallel. Dies ermöglicht eine um ein Vielfaches höhere Rechenleistung, sofern sich die Anwendung gut parallelisieren lässt.
Entwicklungssysteme für GPGPU-Anwendungen
GPGPU-Anwendungen bestehen aus zwei Teilen: Host-Code und Device-Code. Der Host-Code wird auf der CPU ausgeführt und ist für die Organisation des Programmablaufs zuständig, während der Device-Code die rechenintensiven Programmteile enthält und auf die Grafikkarte ausgelagert wird.
Bei traditionellen Konzepten der GPGPU-Programmierung sind Host- und Device-Code strikt getrennt. Die GPU-Kernel werden in einer eigenen Programmiersprache geschrieben und mit einem speziellen Compiler übersetzt. Zu dieser Kategorie gehören unter anderem CUDA und OpenCL, die am häufigsten verwendeten Programmiersprachen für GPGPU-Anwendungen.
Andere Entwicklungsumgebungen, wie z. B. OpenACC oder C++AMP, setzen auf Erweiterungen des Compilers, um eine bessere Integration zwischen Host- und Device-Code zu erreichen. Dies gelingt jedoch nur zum Teil und stellt hohe Anforderungen an die Compiler-Entwicklung. Bei C++-AMP etwa müssen GPU-Kernel mit dem Keyword "restrict" gekennzeichnet werden, so dass Host- und Device-Code zwar in der selben Quelldatei nebeneinander stehen können, aber logisch voneinander getrennt bleiben. Ein weiterer Nachteil dieser Systeme ist, dass meist nur ein Teil der Hardwarefunktionen unterstützt wird, was die erzielbare Performance einschränkt.
Die eingebettete Programmierung – wie von GOOPAX verwendet – ist ein neues Konzept in der GPGPU-Programmierung. Die GPU-Kernel werden dabei nicht als getrennte Programme entwickelt, sondern sind direkt in das Hauptprogramm integriert. Die Programmerzeugung basiert allein auf Features, die die Programmiersprache C++ zur Verfügung stellt.
Eingebettete Programmierung
Die Verwendung einer eingebetteten Programmiersprache für GPGPU-Anwendungen – auch embedded DSL genannt – wurde bereits 2007 von Thomas Jansen vorgeschlagen [1]. Das dort beschriebene Programmiersystem GPU++ ließ sich jedoch nur für einfache Anwendungen verwenden, da die Verwendung von Schleifen sehr eingeschränkt war. Mit GOOPAX steht nun ein Programmiersystem zur Verfügung, das eine eingebettete Programmierung auch komplexer GPGPU-Anwendungen erlaubt.
Bei eingebetteten Programmiersprachen werden die GPU-Kernel direkt in das Hauptprogramm integriert. Die Programmierumgebung stellt spezielle Datentypen und Operatorüberladungen zur Verfügung, deren Verwendung letztlich zur Erzeugung von GPU-Kernelprogrammen führt, die auf der Grafikkarte ausgeführt werden. Zur Erzeugung der Kernel-Programme werden ausschließlich Funktionalitäten verwendet, die die Programmiersprache des Hauptprogramms zur Verfügung stellt. Das gesamte Parsing, also die Interpretation des Quellcodes, erledigt ein Standard-Compiler.
Die eingebettete Programmierung bietet eine Reihe von Vorteilen:
- Das enge Zusammenspiel von CPU- und GPU-Programmteilen vereinfacht die Programmierung und ermöglicht neue Programmierverfahren. Hier ist insbesondere die Meta-Programmierung interessant (s. u.), die einen erheblichen Zuwachs an Geschwindigkeit bringen kann.
- Hohe Zuverlässigkeit, da nur eine Programmiersprache und nur ein Compiler verwendet werden. Dadurch werden Fehlerquellen reduziert.
- Funktionen sind zum Teil sowohl im Host-Code als auch im Device-Code lauffähig, können also wiederverwendet werden. Dies ermöglicht kürzere Programme und erhöht zudem die Sicherheit.
- Bessere Portabilität, da ein Standard-Compiler verwendet werden kann.
Automatisierte Programmerzeugung durch Meta-Programmierung
Die eingebettete Programmierung ermöglicht ein mächtiges Feature: Meta-Programmierung. Durch das Zusammenspiel von CPU- und GPU-Befehlen wird die Programmerzeugung automatisiert. GOOPAX erlaubt die gleichzeitige Verwendung von CPU- und GPU-Instruktionen innerhalb einer Funktion. Dadurch werden an die jeweilige Problemstellung angepasste Kernel erzeugt. Maßgeschneiderte Lösungen werden so direkt im Programmcode dargestellt, was einen deutlichen Geschwindigkeitsvorteil mit sich bringt. Komplexe Datenstrukturen können komplett in Registern abgelegt und effizient verarbeitet werden. Ganze Rechenwege können direkt als GPU-Programmcode dargestellt werden.
Bei der Programmierung unterscheidet man normalerweise zwischen den beiden Lebensphasen "Compile Time" (Übersetzungszeit) und "Run Time" (Laufzeit). Für die schnelle Programmausführung ist es dabei vorteilhaft, wenn Ausdrücke bereits zur Compile Time berechnet werden können, da sie dann bereits vom Compiler berechnet werden können und keine Laufzeitkosten mehr verursachen.
Bei GOOPAX gibt es nicht zwei, sondern vier solcher Phasen:
- CPU Compile Time
- CPU Run Time
- GPU Compile Time
- GPU Run Time
Zu den ersten beiden Phasen gehören Ausdrücke, die nur aus herkömmlichen CPU-Variablen bestehen. Diese rufen – genau wie GPU Compile Time – keine Laufzeitkosten hervor. Durch das Einbinden von Ausdrücken oder Steuerkonstrukten, die auf CPU-Variablen basieren, ist es möglich, ganze Verzweigungsstrukturen zu erstellen, die keinerlei Kosten auf die Laufzeit des GPU-Kernels haben.
Interessant ist dies insbesondere für die Ausnutzung von Registern. Grafikkarten stellen üblicherweise eine große Zahl an Registern zur Verfügung (typischerweise 256 bei aktuellen Grafikkarten). Durch eine geschickte Ausnutzung von Registern lassen sich Speicherzugriffe reduzieren, was zu einem erheblichen Geschwindigkeitsvorteil führen kann. Die Meta-Programmierung ermöglicht eine einfache Ausnutzung der zur Verfügung stehenden Register. Beispielsweise lässt sich so auf einfache Art eine Matrix-Multiplikation programmieren, die garantiert nur in Registern ausgeführt wird und somit sehr schnell läuft.
Da die endgültige Erzeugung der GPU-Kernel erst zur Laufzeit erfolgt, können auch Eigenschaften der Grafikkarte in der Code-Erzeugung berücksichtigt werden. Dies ermöglicht es, Programme zu schreiben, die auf allen Grafikkarten mit hoher Geschwindigkeit laufen. Diese Eigenschaft ist unter dem Stichwort Performance-Portability bekannt.
Vergleich verschiedener GPGPU-Programmiersysteme
OpenCL | CUDA | OpenACC | C++ AMP | SYCL | GOOPAX | |
---|---|---|---|---|---|---|
General-Purpose-Programmierung | ✓ | ✓ | ✓ | ✓ | ⎯ | ✓ |
Eingebettete Programmierung | ⎯ | ⎯ | ⎯ | ⎯ | ✓ | ✓ |
hardwareunabhängig | ✓ | ⎯ | ✓ | ✓ | ✓ | ✓ |
Läuft unter mehreren Betriebssystemen | ✓ | ✓ | ✓ | ⎯ | ✓ | ✓ |
Kernel Meta-Programmierung | ⎯ | ⎯ | ⎯ | ⎯ | ✓ | ✓ |
Zur Erstellung von GPGPU-Anwendungen stehen verschiedene Entwicklungssysteme zur Verfügung. Eine eingebettete Programmierung komplexer GPGPU-Anwendungen bietet derzeit nur die Entwicklungsumgebung GOOPAX. SYCL verfolgt zwar einen ähnlichen Ansatz, ist jedoch wegen der fehlenden Möglichkeit, echte Schleifen zu verwenden, keine vollständige Programmiersprache und nicht für die Entwicklung komplexer Anwendungen geeignet [2].
Hardware- und Betriebssystemunabhängigkeit ist bei OpenCL, OpenACC, SYCL und GOOPAX gegeben. CUDA läuft dagegen nur auf Grafikkarten von NVIDIA, C++ AMP wird bisher nur von Microsoft unterstützt [2].
Bei der Performance bietet GOOPAX durch Meta-Programmierung, Profiling und Just in time-Kompilierung Vorteile. Gerade die Meta-Programmierung erlaubt Optimierungen, die bei anderen Verfahren nicht möglich sind oder einen hohen Programmieraufwand erfordern würden.
Einfaches Beispielprogramm mit GOOPAX
#include
using namespace goopax;
using namespace std;
struct foo :
public kernel
{
void program(resource& a)
{
gpu_for(gpu_uint k=global_id(), a.size(), global_size())
{
a[k] = k;
}
}
} Foo;
int main(int argc, char** argv)
{
goopax_env Env(argc, argv);
buffer a(Env.default_device(), 100);
Foo(a);
cout << "a=" << a << endl;
}
Im dargestellten Beispielprogramm wird zunächst GOOPAX als Bibliothek eingebunden. Anschließend wird ein GPU-Kernel durch das Erstellen der Klasse foo implementiert, dessen Funktion program die entsprechenden Anweisungen enthält. Variablen, die auf der Grafikkarte existieren, werden durch spezielle Datentypen wie gpu_int, gpu_float oder gpu_double deklariert. Im GPU-Kernel werden Zahlen in einen Speicherbereich geschrieben (Zeile 13). Von CPU-Seite wird dieser Speicherbereich auf der Grafikkarte reserviert (Zeile 21) und die Ausführung des GPU-Kernels auf der Grafikkarte angestoßen (Zeile 23). Der GPU-Kernel wird dabei parallelisiert ausgeführt. Die Zahl der Threads wird dabei normalerweise von der Grafikkarte vorgegeben. Jeder Thread kann seine eigene Threadnummer durch den Aufruf der Funktion global_id() erfragen, sowie die Gesamtzahl der Threads durch den Aufruf von global_size(). Übersetzt wird das Programm mit einem ganz normalen C++-Compiler.
Beispiel: N-Body-Simulation
N-Body-Simulationen dienen im GPGPU-Bereich häufig als Beispielprogramm. Es werden Teilchen simuliert, die miteinander wechselwirken, etwa durch Gravitation. Verwendung finden N-Body-Simulationen beispielsweise in der Astrophysik zur Simulation von Sternhaufen oder Galaxien.
Zur Berechnung der Kraft, die auf die einzelnen Teilchen wirkt, gibt es verschiedene Verfahren. Das einfachste Verfahren ist die direkte Kraftberechnung, die insbesondere für kleinere Teilchenzahlen unter hunderttausend Teilchen ein geeignetes Verfahren ist. Hier werden die Kräfte zwischen allen Teilchenpaaren direkt berechnet und aufsummiert.
template using Vector3 = Eigen::Matrix;
struct calculateForce :
public kernel
{
void program(const resource>& x, resource>& v,
gpu_float dt, gpu_float mass,
resource>& xnew)
{
gpu_for(gpu_uint i=global_id(), x.size(), global_size())
{
Vector3 F = {0,0,0};
gpu_for(gpu_uint k=0, x.size())
{
Vector3 r = x[k] - x[i];
F += r * pow<-3,2>(r.squaredNorm() + 1E-20);
}
v[i] += F * (dt * mass);
xnew[i] = x[i] + v[i] * dt;
}
}
} CalculateForce;
Dargestellt ist der GPU-Kernel einer N-Body-Simulation nach dem Verfahren der direkten Kraftberechnung, programmiert mit GOOPAX. Dem Kernel werden verschiedene Parameter übergeben (Zeilen 6-8): Speicherbereiche mit den Positionen (x) und Geschwindigkeiten (v) der Teilchen, der Zeitschritt (dt), Teilchenmasse (mass) und ein Speicherbereich für die neuen Teilchenpositionen (xnew). Die neuen Teilchenpositionen werden in einen gesonderten Speicherbereich geschrieben, damit sich die einzelnen Threads nicht in die Quere kommen.
Zwei Schleifen werden ausgeführt, wobei die äußere Schleife (ab Zeile 10) parallelisiert ausgeführt wird, damit sich die einzelnen Threads die Berechnung teilen. In der inneren Schleife (ab Zeile 14) werden dann aus den Abstandsvektoren die einzelnen Kräfte zwischen den Teilchenpaaren berechnet und aufsummiert. Die kleine Zahl 1E-20 wird hinzugefügt, um bei k=i eine Division durch Null zu verhindern.
Erwähnenswert ist die Einbindung der Bibliothek Eigen zur Verwendung von Vektorarithmetik. Dies vereinfacht nicht nur die Programmierung, es verdeutlicht auch das Prinzip der Code-Wiederverwendung. Durch die eingebettete Programmierung wird es möglich, Funktionen zu schreiben, die sowohl auf der CPU als auch auf der GPU lauffähig sind. Es lassen sich sogar – wie hier im Fall der Bibliothek Eigen – externe Bibliotheken einbinden, die gar nicht für die Verwendung auf GPUs konzipiert wurden. Bei herkömmlichen Entwicklungssystemen wie OpenCL oder CUDA ist dies nicht möglich, dort müsste man entweder eine Klasse für Vektorarithmetik eigens für die Verwendung auf der GPU neu implementieren oder auf die Verwendung von Vektorarithmetik ganz verzichten.
Zusammenfassung
Im Artikel wurde die eingebettete Programmierung als Verfahren zur GPGPU-Programmierung vorgestellt. Verdeutlicht wurde dieses Prinzip anhand zweier Programmierbeispiele mit GOOPAX. Die Vorteile der eingebetteten Programmierung in Bezug auf Geschwindigkeit, Zuverlässigkeit und vor allem bei der Reduzierung des Programmieraufwandes dürften für viele Programmierer interessant sein.