Die Portierung rechenintensiver HPC-Anwendungen von CPUs auf GPUs kann die Performance um das Zehn- bis Hundertfache steigern. Besonders für datenintensive Simulationen lohnt sich dieser Schritt. Dieser Leitfaden fasst die wichtigsten Konzepte und bewährten Methoden zusammen.
Warum GPUs für HPC?
GPUs sind für massiv parallele Berechnungen optimiert und bieten im Vergleich zu CPUs eine deutlich höhere Rechenleistung bei datenintensiven Aufgaben. Während CPUs wie der AMD EPYC oder Intel Xeon auf einige hundert Kerne mit hohen Taktraten setzen, bestehen GPUs und APUs wie die AMD MI300A oder NVIDIA A100 aus tausenden von Kernen, die in Gruppen von 64 Threads (Wavefronts/Warp) parallel arbeiten. Dies macht GPUs besonders effizient für Algorithmen, die sich in viele unabhängige Teilaufgaben zerlegen lassen – wie z.B. die Lattice-Boltzmann-Methode, bei der jede Gitterzelle unabhängig berechnet werden kann.
Ein entscheidender Vorteil von GPUs ist ihre Speicherbandbreite. Während eine moderne CPU etwa 0,2 TB/s erreicht, bieten APUs wie die AMD MI300A bis zu 5,3 TB/s und die NVIDIA A100 bis zu 2 TB/s. Allerdings sind Speicherzugriffe auf GPUs mit deutlich höheren Latenzen verbunden, was besondere Optimierungsstrategien erfordert.
Herausforderungen bei der Portierung
Latency Hiding: Warum mehr Threads als Kerne?
GPUs haben zwar eine hohe Rechenleistung, aber der Zugriff auf den globalen Speicher ist langsam. Um die volle Performance zu erreichen, müssen GPUs die Latenzzeiten verbergen, indem sie zwischen verschiedenen Threads wechseln. Praktisch bedeutet das, dass man mindestens das Vierfache der Anzahl der GPU-Kerne an Threads bereitstellen sollte – idealerweise sogar mehr, um die GPU optimal zu auszulasten.
Thread Divergence: Verzweigungen bremsen die GPU
GPUs führen Threads in Gruppen von 64 Threads (Wavefronts/Warp) aus. Wenn Threads innerhalb eines Wavefronts/Warp unterschiedliche Zweige durchlaufen, müssen einige Threads leer laufen, während andere Berechnungen ausführen. Dies führt zu einer ineffizienten Nutzung der GPU-Ressourcen. Die Lösung besteht darin, Verzweigungen zu minimieren oder in separaten Wavefronts/Warp zu gruppieren.
Datentransfer
Der Datentransfer zwischen CPU und GPU ein kritischer Engpass, insbesondere bei dedizierten GPUs, APUs können diesen Engpass bei richtiger Speicherverwaltung ggf. reduzieren. Die Bandbreite über PCIe 4.0 x16 beträgt nur etwa 32 GB/s, was deutlich unter der Bandbreite des GPU-Speichers liegt. Daher sollte man Daten einmal auf die GPU kopieren und dort für die gesamte Berechnung halten.
Programmiermodelle im Vergleich
OpenMP Target Offloading: Der einfachste Einstieg
OpenMP Target Offloading ist eine pragmatische Lösung für Entwickler, die schnell Ergebnisse erzielen möchten. Mit nur wenigen Zeilen Code kann man Schleifen auf die GPU auslagern, indem man spezielle Pragmas hinzufügt. OpenMP generiert dann automatisch den GPU-Code, der auf der Zielhardware ausgeführt wird. Dieser Ansatz ist u.a. für C, C++ und Fortran verfügbar und bietet eine hohe Portabilität. Ein weiterer Vorteil ist, dass OpenMP die Pragmas ignoriert, wenn keine Compiler-Flags gesetzt sind (z.B. -mp=gpu für Nvidia Compiler oder -fopenmp –offload-arch=gfx942 für AMD clang), sodass der Code weiterhin auf CPUs laufen kann.
HIP: Maximale Kontrolle und Performance (für AMD GPUs und APUs)
HIP (Heterogeneous-Compute Interface for Portability) bietet maximale Kontrolle über die GPU-Berechnungen und ermöglicht es, den Code direkt für die GPU-Architektur zu optimieren. Im Gegensatz zu OpenMP erfordert HIP jedoch mehr Änderungen am ursprünglichen Code, da man explizit GPU-Kernels schreiben und die Speicherverwaltung selbst übernehmen muss. HIP Code kann auch für Nvidia GPUs kompiliert werden, ist jedoch dann nicht so performant wie CUDA Code auf der selben GPU.
Standard-Parallelismus: Portabel, aber eingeschränkt
C++17/20 und Fortran 2018 haben u.a. mit std::execution::par_unseq (C++) bzw. do concurrent (Fortran) Mechanismen eingeführt, um Parallelität auszudrücken. Diese Ansätze sind besonders attraktiv, weil sie keine GPU-spezifischen Pragmas oder Bibliotheken erfordern. Da diese funktionen jedoch nicht für diesen Verwendungszweck angedacht sind, gibt es auch größere Einschränkungen:
- Wenig Flexibilität, z. B. keine Möglichkeit, nur einen Teil solcher Konstruktionen in einer Kompilierungseinheit zu laden
- Bei diskreten GPUs gibt es keine Option zur Verarbeitung von Datenübertragungen.
- Es gibt nach wie vor keine breite Unterstützung durch Compiler. Auf Hunter: Cray Fortran für Fortran, (AMD-)Clang für C++
Schritte zur Portierung von CPU-Code auf GPUs
Die Portierung von CPU-Code auf GPUs erfolgt in mehreren strukturierten Schritten, die eine effiziente und performante Umsetzung gewährleisten. Zunächst ist eine Hotspot-Analyse erforderlich, bei der mit Profiling-Tools wie Score-P, Vampir, Nsight oder OmniPerf die zeitintensivsten Funktionen im Code identifiziert werden. Dabei wird auch die Arithmetic Intensity (FLOP/Byte-Ratio) des kritischen Code-Bereichs berechnet und mit den Grenzwerten der Ziel-GPU verglichen, um zu bestimmen, ob der Hotspot rechen- oder speicherbandbreitenlimitiert ist.
Im nächsten Schritt folgt die Extraktion einer MiniApp, die nur die kritische Funktion enthält. Dies ermöglicht schnellere Kompilierungs- und Testzyklen, vereinfacht das Debugging und erlaubt die Nutzung von Tools wie dem Compiler Explorer für detaillierte Analysen. Die MiniApp wird dabei so angepasst, dass sie die Skalierung auf mehrere Kerne unterstützt, die First-Touch-Policy für NUMA-Domänen beachtet und zunächst mit vereinfachten, später mit realistischen Datenmustern arbeitet.
Bevor die eigentliche GPU-Portierung beginnt, sollte der CPU-Code optimiert werden, insbesondere durch Vectorisierung. Hier wird geprüft, ob der Code automatisch vectorisiert wird oder ob Skalaroperationen vorliegen, die manuell mit Compiler-Hints oder spezifischen Erweiterungen optimiert werden können, um die SIMD-Ausnutzung zu maximieren.
Für die GPU-Portierung selbst wird ein passendes Programmiermodell gewählt, wie HIP/CUDA für maximale Kontrolle, OpenMP Target Offloading für einfache Integration oder Standard-Parallelismus für Portabilität. Die Schleifenstruktur wird für GPU-Threads angepasst, Speicher auf der GPU allokiert und Daten einmalig übertragen, um den PCIe-Flaschenhals zu vermeiden. Der Kernel wird mit passenden Block- und Thread-Konfigurationen aufgerufen.
Anschließend erfolgen GPU-spezifische Optimierungen. Hier wird mit dem Speicherlayout experimentiert, etwa durch den Wechsel von Array-of-Structures (AoS) zu Strcture-of-Arrays (SoA) oder das Hinzufügen von Padding für Cacheline-Alignment. Zudem werden Constant Memory für unveränderliche Daten und Shared Memory für temporäre, wiederverwendbare Daten genutzt. Performance-Analysen mit GPU-spezifischen Tools wie Nsight Compute oder OmniPerf helfen, Bottlenecks zu identifizieren und die Thread-Konfiguration zu optimieren.
Im Performance-Vergleich zwischen CPU und GPU werden die Laufzeiten der kritischen Funktion gegenübergestellt, der Speedup-Faktor bestimmt und die Ausnutzung der GPU-Ressourcen analysiert. Dies gibt Aufschluss darüber, ob der Code memory-bound oder compute-bound ist.
Abschließend lassen sich einige wichtige Erkenntnisse zusammenfassen: Der MiniApp-Ansatz beschleunigt die Entwicklung und das Testen deutlich. Das Speicherlayout hat direkten Einfluss auf die Performance, und native GPU-Programmiermodelle bieten bessere Kontrolle und Performance als OpenMP oder Standard-Parallelismus. Die vollständige Portierung komplexer Simulationen ist zeitaufwendig, wobei auf APUs schrittweises Portieren möglich ist, während auf dedizierten GPUs der Datenfluss optimiert werden muss, indem Daten möglichst auf der GPU gehalten werden.
Weitere Ressourcen
- AMD ROCm Dokumentation: rocm.docs.amd.com
- NVIDIA CUDA Toolkit: developer.nvidia.com/cuda-zone
- OpenMP Offloading: openmp.org
- Kokkos Tutorial: kokkos.org
- HLRS GPU-Tutorials: hlrs.de/training


