House of ROC: AMDs Alternative zu CUDA

Nutzung in C++

Einen etwas innovativeren Anstrich hat die C++-API hc, mit der Laufzeit- und Device-Kernel-Umgebung in C++ benutzbar sind. Der Kern der Sprache basiert auf dem offenen C++AMP-1.2-Standard innerhalb des hc-Namensraumes. Dazu gibt es beispielsweise Erweiterungen für den asynchronen Memory-Transfer im selben Namensraum und die Option, in Host- und Device-seitigem Quelltext C++14 zu benutzen. Die Struktur der API ist ähnlich der von thrust, boost.compute oder sycl.

Typisches Abstraktionsschema der C++-API und Klassenstruktur zur Ausführung von hoch-parallelen Berechnungen auf diskreten Grafikkarten

Das Diagramm zeigt die Abbildung der Host-seitigen Strukturen durch Container, Algorithmen und Funktionen der C++-Sprachen und -Standardbibliothek. Darüber hinaus existiert eine API zur Arbeit mit GPU-Speicherbereichen (hc::array und hc::array_view), zum Transfer von Daten von und zur GPU (hc::copy, hc::async_copy) sowie Funktionen zum Durchführen von Berechnungen und anderen Operationen auf dem Device (hc::parallel_for_each). Im Gegensatz zu aktuellen Low-level GPU-Sprachen wie CUDA, verzichtet sie vollständig auf eine Kernel-Syntax beziehungsweise das Grid-Threadblock-Dispatchment. Optimierungen, die die Ausführung von hc::parallel_for_each auf der Hardware zur Laufzeit betreffen, führt der Compiler beziehungsweise die Laufzeitumgebung durch.

In Anlehnung an obigen BabelStream-Code in CUDA gestaltet sich die Implementierung des Add-Kernel in hc folgendermaßen:

template <class T>
void HCStream<T>::add()
{
hc::array_view<T,1> view_a(this->d_a);
hc::array_view<T,1> view_b(this->d_b);
hc::array_view<T,1> view_c(this->d_c);
    hc::parallel_for_each(hc::extent<1>(array_size)
, [=](hc::index<1> i) [[hc]] {
view_c[i] = view_a[i]+view_b[i];
});
}

Die bereits allozierten Speicherbereiche auf der GPU d_a, d_b und d_c repräsentieren in der HCStream-Klasse Instanzen vom Typ hc::array. Zur vereinfachten Handhabung im folgenden Lambda-Aufruf werden Referenzen auf diese Felder in Objekte vom Typ hc::array_view gekapselt. Das ermöglicht die By-Value-Übergabe an die Lambda-Funktion (hier zu rein illustrativen Zwecken benutzt). Die Funktion hc::parallel_for_each besitzt zsätzlich zur Funktionsweise eine Definition des zu bearbeitenden Indexraums. In diesem Fall ist es ein eindimensionaler Index im rechtsoffenen Intervall [0,array_size),

dessen Dimensionalität zur Compile-Zeit feststehen muss. Dementsprechend muss die Signatur der Lambda-Funktion ebenfalls dieser Dimensionalität folgen und nimmt ein von der Laufzeitbibliothek zur Verfügung gestelltes hc::index<1>-Objekt als Parameter, das letztlich nur dazu dient, die Operationen auf den GPU-Feldern d_a, d_b und d_c zu platzieren.

Die folgende Abbildung zeigt, mit welcher Güte die Implementierungen aller drei Programmierparadigmen auf der ROCm von einer gemeinsamen Compiler-Infrastruktur profitieren.

Vergleich der Speicher-Bandbreiten des BabelStream-Add-Kernels für verschiedene Feldgrößen, GPU-Hardware und Sprachparadigmen

Die Benchmarks mittels hc, HIP und OpenCL liegen über das gesamte Spektrum von Feldgrößen gleichauf – abgesehen von stochastischen Schwankungen. Ein beeindruckender Fakt nebenbei: Die Speicherbrandbreite einer Fiji R9 Nano (veröffentlicht 2015) ist doppelt so hoch wie die einer Nvidia GeForce GTX 1080 (veröffentlicht 2016). Der Grund hierfür liegt in der Speicherarchitektur: Die AMD-Karte benutzt High Bandwidth Memory der ersten Generation, während die Nvidia-Karte GDDR5 DRAM benutzt. Recht deutlich fällt der Vorsprung einer Nvidia Tesla P100 durch ihren High Bandwidth Memory der zweiten Generation gegenüber der Fiji Nano und der GeForce-Karte aus.

Zuletzt bekommt noch ein kleines Juwel in der hc-API seinen Auftritt, das eine besondere Erwähnung verdient: Die Funktionen hc::parallel_for_each und hc::async_copy geben als Rückgabewert ein Objekt vom Typ hc::completion_future zurück. Damit wären auch Konstrukte für Daten- und Aufgabenabhängigkeiten denkbar, die der für C++2020 angedachten Concurrency TS (Technical Specification) ähnlich sind:

std::vector<float> payload (/*pick a number if not 42*/);
hc::array<float,1> d_payload(payload.size());

hc::completion_future when_done = hc::async_copy(payload.begin(),
payload.end(),
d_payload);
when_done.then(call_kernel_functor); //continuation function!

Das eröffnet aus technischer Sicht viele Möglichkeiten, um asynchrone Operationen im Wechselspiel CPU-GPU (Task-Parallelität) zu implementieren und damit die Fähigkeiten einer heterogenen Hardware des 21. Jahrhunderts in vielen Szenarien mit minimalem Code auszureizen. Damit wären auch Konstrukte zum Ausdruck von Daten- sowie Aufgabenabhängigkeiten ähnliche der Concurrency TS denkbar.

std::vector<hc::completion_future> streams(n);
for(hc::completion_future when_done : streams){
    when_done = hc::async_copy(payload_begin_itr,
payload_end_itr,
d_payload_view);
when_done.then(parallel_for_each(/*do magic*/))
.then(hc::async_copy(d_payload_view,result_begin_itr));
}
hc::when_all(streams);

In dem Pseudo-Code sind n Berechnungsschritte inklusive Datentransfer zu und von der GPU an ein completion_future gebunden. Die Laufzeitumgebung kann somit die Operationen derart auszuführen, dass sie maximale Bandbreite und minimale Latenz erreicht.

Der Schritt hc::when_all(streams) dient als Synchronisationsbarriere.