Implementation eines CUDA basierten Kalman-Filters zur
Transcription
Implementation eines CUDA basierten Kalman-Filters zur
Fachhochschule Münster Masterarbeit Implementation eines CUDA basierten Kalman-Filters zur Spurrekonstruktion des ATLAS-Detektors am LHC Rene Böing, B.Sc. rene.boeing@fh-muenster.de Matrikelnummer: 618384 16. Oktober 2013 Betreuer: Prof. Dr. rer. nat. Nikolaus Wulff Zweitprüfer: Dr. Sebastian Fleischmann Urheberrechtlicher Hinweis Dieses Werk einschließlich seiner Teile ist urheberrechtlich geschützt. Jede Verwertung ausserhalb der engen Grenzen des Urheberrechtgesetzes ist ohne Zustimmung des Autors unzulässig und strafbar. Das gilt insbesondere für Vervielfältigungen, Übersetzungen, Mikroverfilmungen sowie die Einspeicherung und Verarbeitung in elektronischen Systemen. I Zusammenfassung Die vorliegende Masterarbeit thematisiert die Implementation eines Kalman-Filters für kleine Matrizen auf Basis der von NVIDIA entwickelten Programmiersprache CUDA. Die Implementation ist dabei speziell auf die Spurrekonstruktion von Ereignisdaten des ATLAS-Experiments am CERN zugeschnitten. Es werden ausgehend von einer selbst entwickelten Grundimplementation verschiedene Verfahren zur Optimierung der Berechnungsgeschwindigkeit beschrieben. Neben der erfolgreichen Implementation des Kalman-Filters wird ein Vergleich der Laufzeit mit einer CPU basierten Lösung durchgeführt, um abschließend zu ermitteln, ob die Verwendung von Grafikkarten die Berechnungsdauer des Kalman-Filters reduzieren kann. Die Arbeit zeigt, dass die Verwendung von CUDA die Verarbeitungsdauer im Vergleich zu einer CPU basierten Lösung auf ein Viertel reduzieren kann. Abstract This master’s thesis describes the implementation of a Kalman filter using GPU technology based on NVIDIA CUDA. Besides the objective of implementing the Kalman filter this thesis answers the question of whether or not such an implementation is faster than a CPU based approach. The Kalman filter implementation is customized to fit the needs of track reconstruction for the ATLAS expirement located at CERN. Based on a self-developed basic implementation, various strategies to optimize and enhance the speed, at which the most recent released graphic solutions of NVIDIA produce results, are applied. As a result the final implementation finishes the calculations in a quarter of the time needed by the CPU implementation. II Danksagung Ich möchte mich an dieser Stelle bei allen beteiligten Personen bedanken, die das Anfertigen und Fertigstellen dieser Masterarbeit ermöglicht haben. Ich möchte mich an dieser stelle ganz besonders bei Herrn Prof. rer. net. Nikolaus Wulff bedanken, der durch die Kontaktaufnahme mit der Wuppertaler ATLASGruppe diese Arbeit möglich gemacht hat. Herrn Dr. Sebastian Fleischmann bin ich für die vielen Hilfestellungen im Bereich Hochenergiephysik, sowie seiner Betreuung und Beratung bei Implementationsdetails, zu großem Dank verpflichtet. Auch möchte ich der restlichen ATLAS Forschungsgruppe und insbesondere Herrn Prof. Dr. Peter Mättig für die gute Zusammenarbeit danken. Ich möchte mich zudem bei meinem Projektpartner Maik Dankel für die überaus gute Zusammenarbeit bedanken. Auch die Masterprojektgruppe bestehend aus Philipp Schoppe und Matthias Töppe verdient meinen Dank. Weiterhin bedanke ich mich bei Nancy Linek, Marina Böing und nochmals Maik Dankel für das Korrekturlesen dieser Arbeit. III Inhaltsverzeichnis Inhaltsverzeichnis Zusammenfassung II Abstract II Abbildungsverzeichnis VI Tabellenverzeichnis VII Listings VIII 1 Einleitung 1 1.1 Motivation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1 1.2 Ziele der Arbeit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2 1.3 Kalman-Filter Grundlagen . . . . . . . . . . . . . . . . . . . . . . . . 2 1.4 GPU-Architektur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7 1.4.1 Hardwaremodell . . . . . . . . . . . . . . . . . . . . . . . . . . 7 1.4.2 Warps . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11 1.4.3 Hardwareeigenschaften und Programmierung . . . . . . . . . . 12 2 NVIDIA CUDA 15 2.1 Definition Host und Device . . . . . . . . . . . . . . . . . . . . . . . . 15 2.2 Compute Capability . . . . . . . . . . . . . . . . . . . . . . . . . . . 16 2.3 Kernel . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17 2.4 Grundlegendes Threadingmodell . . . . . . . . . . . . . . . . . . . . . 17 2.5 Streams . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19 3 CUDA Programmierung 21 3.1 CUDA Host und Device . . . . . . . . . . . . . . . . . . . . . . . . . 21 3.2 Threadingmodell . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22 3.3 Shared Memory in CUDA . . . . . . . . . . . . . . . . . . . . . . . . 24 3.4 CUDA Streams . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25 3.5 API-Fehler abfangen . . . . . . . . . . . . . . . . . . . . . . . . . . . 27 3.6 Deviceeigenschaften . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28 3.7 Verwaltung mehrerer Devices . . . . . . . . . . . . . . . . . . . . . . 29 IV Inhaltsverzeichnis 3.8 Grafikkartenspeicher allozieren und verwalten . . . . . . . . . . . . . 31 3.9 Synchronisation von Threads . . . . . . . . . . . . . . . . . . . . . . . 35 4 Implementierung 4.1 36 Detektordaten . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36 4.1.1 Kalman-Filter Initialisierung . . . . . . . . . . . . . . . . . . . 38 4.2 Projekteigenschaften . . . . . . . . . . . . . . . . . . . . . . . . . . . 38 4.3 Funktionsimplementierung . . . . . . . . . . . . . . . . . . . . . . . . 39 4.4 4.3.1 Devicefunktionen . . . . . . . . . . . . . . . . . . . . . . . . . 39 4.3.2 Hostfunktion . . . . . . . . . . . . . . . . . . . . . . . . . . . 53 Optimierungsschritte . . . . . . . . . . . . . . . . . . . . . . . . . . . 56 4.4.1 Datenstrukturen . . . . . . . . . . . . . . . . . . . . . . . . . 56 4.4.2 Blobdaten und Pinned Memory . . . . . . . . . . . . . . . . . 59 4.4.3 CUDA Streams . . . . . . . . . . . . . . . . . . . . . . . . . . 60 4.4.4 OpenMP . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 62 4.4.5 Deviceauslastung steigern . . . . . . . . . . . . . . . . . . . . 63 4.4.6 Numerische Genauigkeit und Symmetrie . . . . . . . . . . . . 66 5 Performance 69 5.1 Performancevergleich der Optimierungsstufen . . . . . . . . . . . . . 69 5.2 Performancevergleich OpenCL vs. CUDA vs. CPU . . . . . . . . . . . 71 6 Fazit 73 7 Ausblick 74 8 Anhang 76 Literatur 78 Eidesstattliche Erklärung 82 V Abbildungsverzeichnis Abbildungsverzeichnis 1 ATLAS-Detektor . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2 2 Vergleich der Messpunkte und echter Spur . . . . . . . . . . . . . . . 3 3 Kalman-Filter korrigierte Spur . . . . . . . . . . . . . . . . . . . . . . 5 4 Durch Smoothing korrigierte Spur . . . . . . . . . . . . . . . . . . . . 6 5 GK110 Blockdiagramm . . . . . . . . . . . . . . . . . . . . . . . . . . 7 6 SMX Blockdiagramm . . . . . . . . . . . . . . . . . . . . . . . . . . . 9 7 Warp Scheduler . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10 8 Transferraten in Abhängigkeit der Datenmenge . . . . . . . . . . . . 12 9 Verschiedene Speicherzugriffsmuster . . . . . . . . . . . . . . . . . . . 13 10 Skalierbarkeit über mehrere Devices . . . . . . . . . . . . . . . . . . . 16 11 Zusammenfassung des Threadingmodells . . . . . . . . . . . . . . . . 18 12 Abarbeitung von Streams . . . . . . . . . . . . . . . . . . . . . . . . 19 13 Vergleich der Kopiervorgänge . . . . . . . . . . . . . . . . . . . . . . 35 14 Projekt Erstellungsablauf . . . . . . . . . . . . . . . . . . . . . . . . . 39 15 Aufbau des Verarbeitungsgrids . . . . . . . . . . . . . . . . . . . . . . 41 16 Ausgabe des Visual Profilers . . . . . . . . . . . . . . . . . . . . . . . 57 17 Grafischer Laufzeitvergleich . . . . . . . . . . . . . . . . . . . . . . . 72 18 Technische Spezifikation der Compute Capabilities . . . . . . . . . . . 77 VI Tabellenverzeichnis Tabellenverzeichnis 1 Parameterübersicht CUDA-Kernelaufruf . . . . . . . . . . . . . . . . 22 2 CUDA Deviceeigenschaften 3 Inlinefunktionen Matrix/Vektor-Multiplikation . . . . . . . . . . . . . 45 4 Zu übertragende Datenmengen pro Spur . . . . . . . . . . . . . . . . 58 5 Zu übertragende Datenmengen pro Event . . . . . . . . . . . . . . . . 59 6 Verwendetes Computersystem . . . . . . . . . . . . . . . . . . . . . . 69 7 Verwendeter Testdatensatz . . . . . . . . . . . . . . . . . . . . . . . . 69 8 Performancevergleich . . . . . . . . . . . . . . . . . . . . . . . . . . . 70 9 Angepasster Performancevergleich . . . . . . . . . . . . . . . . . . . . 70 10 Performancevergleich CPU/CUDA/OpenCL . . . . . . . . . . . . . . 71 . . . . . . . . . . . . . . . . . . . . . . . 30 VII Listings Listings 1 Beispielcode für Warpdivergenz . . . . . . . . . . . . . . . . . . . . . 11 2 Funktionskopf für GPU-Funktion . . . . . . . . . . . . . . . . . . . . 21 3 Aufruf GPU-Funktion . . . . . . . . . . . . . . . . . . . . . . . . . . 22 4 CUDA Threadindizes . . . . . . . . . . . . . . . . . . . . . . . . . . . 22 5 CUDA Blockgrößen . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22 6 CUDA Gridposition, sowie Gridgröße . . . . . . . . . . . . . . . . . . 23 7 Beispielanwendung der Threadposition . . . . . . . . . . . . . . . . . 23 8 Beispielaufruf im Host-Code . . . . . . . . . . . . . . . . . . . . . . . 23 9 Mehrdimensionaler Beispielaufruf im Host-Code . . . . . . . . . . . . 24 10 Shared Memory mit statischer Größe . . . . . . . . . . . . . . . . . . 24 11 Shared Memory mit dynamischer Größe . . . . . . . . . . . . . . . . 24 12 Beispielaufruf im Host-Code mit dynamischem Shared Memory . . . . 25 13 Deklaration eines Streams . . . . . . . . . . . . . . . . . . . . . . . . 25 14 Initialisierung eines Streams . . . . . . . . . . . . . . . . . . . . . . . 26 15 Löschen eines Streams . . . . . . . . . . . . . . . . . . . . . . . . . . 26 16 Status eines Streams . . . . . . . . . . . . . . . . . . . . . . . . . . . 26 17 Lesbarer Fehlercode . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27 18 Error Handler . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27 19 Anzahl der Devices ermitteln 20 Deviceeigenschaften ermitteln . . . . . . . . . . . . . . . . . . . . . . 28 21 Ein Device auswählen . . . . . . . . . . . . . . . . . . . . . . . . . . . 29 22 Streambindung an ein Device . . . . . . . . . . . . . . . . . . . . . . 29 23 Speicher auf einem Device reservieren . . . . . . . . . . . . . . . . . . 31 24 Speicher auf einem Device freigeben . . . . . . . . . . . . . . . . . . . 32 25 Daten zum Device kopieren . . . . . . . . . . . . . . . . . . . . . . . 32 26 Asynchrones Kopieren . . . . . . . . . . . . . . . . . . . . . . . . . . 34 27 Allokation von Pinned Memory . . . . . . . . . . . . . . . . . . . . . 34 28 Freigabe von Pinned Memory . . . . . . . . . . . . . . . . . . . . . . 35 29 Struktur eines Events . . . . . . . . . . . . . . . . . . . . . . . . . . . 36 30 Struktur eines Tracks . . . . . . . . . . . . . . . . . . . . . . . . . . . 36 31 Struktur eines Hits . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36 32 Spurrekonstruktionsdatenstruktur . . . . . . . . . . . . . . . . . . . . 39 . . . . . . . . . . . . . . . . . . . . . . 28 VIII Listings 33 Matrixindizes und Funktionskopf . . . . . . . . . . . . . . . . . . . . 40 34 Start der Kalman-Filterung . . . . . . . . . . . . . . . . . . . . . . . 42 35 Prädiktionsphase des Kalman-Filters . . . . . . . . . . . . . . . . . . 43 36 Matrix-Vektor-Multiplikation . . . . . . . . . . . . . . . . . . . . . . 44 37 Kalman-Gain Implementation 1D . . . . . . . . . . . . . . . . . . . . 46 38 Kalman-Gain Implementation 2D-Invertierung . . . . . . . . . . . . . 47 39 Kalman-Gain Implementation 2D . . . . . . . . . . . . . . . . . . . . 47 40 pk|k Implementation 1D . . . . . . . . . . . . . . . . . . . . . . . . . 48 41 pk|k Implementation 2D . . . . . . . . . . . . . . . . . . . . . . . . . 49 42 Ck|k Implementation 1D . . . . . . . . . . . . . . . . . . . . . . . . . 50 43 Ck|k Implementation 2D Anpassung . . . . . . . . . . . . . . . . . . . 51 44 Speichern der Updateergebnisse aus dem Hinweg . . . . . . . . . . . . 51 45 Implementation Smoothing . . . . . . . . . . . . . . . . . . . . . . . . 52 46 Pseudocode Hostfunktion . . . . . . . . . . . . . . . . . . . . . . . . . 53 47 Spurdaten auslesen und verarbeiten . . . . . . . . . . . . . . . . . . . 54 48 Allokation und Kopieren von Daten . . . . . . . . . . . . . . . . . . . 55 49 Starten des Kalman-Filter Kernels . . . . . . . . . . . . . . . . . . . . 55 50 Pseudocode Anpassung der Datenstrukturen . . . . . . . . . . . . . . 58 51 Datenblob als Pinned Memory . . . . . . . . . . . . . . . . . . . . . . 60 52 Pseudocode streambasiertes Filtern . . . . . . . . . . . . . . . . . . . 60 53 Indexberechnung für höhere Auslastung . . . . . . . . . . . . . . . . . 65 54 Shared Memory Benutzung bei gesteigerter Auslastung . . . . . . . . 65 55 Matrixmultiplikation transponiert . . . . . . . . . . . . . . . . . . . . 67 IX Listings Abkürzungsverzeichnis AVX Advanced Vector Extensions CPU Central Processing Unit CUDA Compute Unified Device Architecture FCFS First Come First Serve FLOPS Floating Point Operations Per Second GPU Graphics Processing Unit GPGPU General Purpose Graphics Processing Unit SIMD Single Instruction Multiple Data SM Streaming Multiprocessors SMX Next Generation Streaming Multiprocessors SSE Streaming SIMD Extensions SVD Singular Value Decomposition X 1 Einleitung 1 Einleitung Die 1952 gegründete Organisation für Nuklearforschung CERN, welche ihren Namen aus dem französischen Akronym “Conseil Européen pour la Recherche Nucléaire” ableitet, betreibt unter anderem Grundlagenforschung im Bereich der Teilchenphysik. Zu diesem Zweck werden im Large Hadron Collider, kurz LHC, Protonen und Atomkerne bei sehr hohen Schwerpunktsenergien von bis zu acht Terraelektronenvolt (TeV) zur Kollision gebracht. Die Ergebnisse dieser Kollision geben Aufschluss über die Wechselwirkungen der Teilchen und ermöglichen eine Überprüfung der vom derzeitigen Standardmodell der Physik hervorgesagten Eigenschaften.[1] Am LHC werden sieben Experimente durchgeführt, wobei die Experimente ATLAS und CMS die beiden größten sind. Die Ergebnisse der beiden Detektoren werden für eine gegenseitige Ergebnisverifizierung genutzt, da beide Detektoren unabhängig voneinander entwickelt und umgesetzt sind. Am ATLAS Experiment beteiligen sich über 3000 Wissenschaftler aus insgesamt 38 Ländern.[2] 1.1 Motivation Der Detektor des ATLAS-Experiments am CERN nimmt pro Sekunde ca. 65 TB Rohdaten auf, welche dann auf ca. 300 MB/s ausgedünnt werden.[4] Es werden dabei sogenannte Events bzw. Ereignisse aufgezeichnet. Als Ereignis wird eine Teilchenkollision im Detektor bezeichnet. Zu diesem Zweck werden Protonen oder ganze Atomkerne auf nahezu Lichtgeschwindigkeit beschleunigt und dann im Detektor des ATLAS Experiments zur Kollision gebracht. Die beiden aufeinander treffenden Teilchenstrahlen besitzen dabei eine Energie von bis zu vier TeV. Durch die Kollision entstehen Bruchstücke in Form von neuen Teilchen. Diese Teilchen wechselwirken mit den im Detektor befindlichen Messinstrumenten, sodass diese die Teilchen registrieren können. In Abbildung 1 ist der Detektor des ATLAS-Experiments dargestellt. Es sind die verschiedenen Detektorlagen abgebildet, welche jeweils mit unterschiedlichen Messinstrumenten ausgestattet sind. Die aufgezeichneten Daten müssen weiter verarbeitet werden. Teil dieses Verarbeitungsprozesses ist die Rekonstruktion der Flugbahn der Teilchen, sowie die Suche nach dem Entstehungsort. Zu diesem Zweck wird ein Kalman-Filter eingesetzt, welcher in der Lage ist, die systembedingten Ungenauigkeiten der aufgezeichneten Messungen zu verbessern. Der Aufwand diese Datenmengen zu analysieren ist enorm, sodass nach neuen Mitteln und Wegen ge- 1 1 Einleitung Abbildung 1: ATLAS-Detektor[3] sucht wird, die Verarbeitungsgeschwindigkeit zu erhöhen. Dabei steht nicht nur die verarbeitende Hardware im Fokus der Entwicklung, ebenso werden die verwendeten Algorithmen verbessert oder ersetzt. 1.2 Ziele der Arbeit Das Ziel dieser Arbeit ist es, eine Kalman-Filter Implementation zu entwickeln, welche auf Basis der Programmiersprache CUDA die Berechnung des Kalman-Filters auf einer Grafikkarte ausführt. Die grafikkartenspezifische Implementation wird im Anschluss einem Laufzeitvergleich mit einer auf der CPU rechnenden Implementation, sowie einer OpenCL basierten Lösung unterzogen. Diese Zeitmessungen zeigen auf, in wie weit der Kalman-Filter unter Verwendung einer CUDA basierten Lösung beschleunigt werden kann und es wird detailliert beschrieben, welche Techniken zur Beschleunigung und Optimierung des Kalman-Filters beitragen. 1.3 Kalman-Filter Grundlagen Um die Genauigkeit der Messung zu verbessern und damit den wahren Punkt der Messung näher zu kommen, wird der sogenannte Kalman-Filter eingesetzt. Dieser Filter ist 1960 von Herrn Rudolph E. Kalman in seinem Paper A New Approach 2 1 Einleitung to Linear Filtering and Prediction Problems veröffentlicht worden und wird heute in vielen Bereichen, wie beispielsweise in der Luft- und Raumfahrt, eingesetzt. In diesem Kapitel wird die Arbeitsweise des Kalman-Filters erläutert.[5] Abbildung 2: Vergleich der Messpunkte und echter Spur In Abbildung 2 ist ein Vergleich von Messpunkten und der echten Spur zu sehen. Es wird deutlich, dass die Spur des Teilchens nicht exakt mit den Messpunkten übereinstimmt. Dies liegt an mehreren Faktoren, wie beispielsweise die Auflösung der Detektorlage, Anregung von mehreren benachbarten Messpunkten, Störungen, etc. Der Kalman-Filter verbessert unter Minimierung der Fehlerkovarianz die Genauigkeit der Messung. Ein großer Vorteil im Vergleich zu anderen Verfahren ist die endrekursive Arbeitsweise des Filters, welche zur Korrektur der nächsten Messung nur die Ergebnisse der vorherigen benötigt, nicht aber den kompletten Verlauf der Korrekturberechnung. Damit wird sowohl die zu speichernde Datenmenge pro Messpunkt minimiert, als auch die Berechnung des Ergebnisses für einen neuen Messpunkt im Vergleich zu Verfahren, welche die komplette Messreihe neu verarbeiten müssen, vereinfacht. Informationen zur Herleitung einzelner Formeln, sowie Beispiele sind im Paper [6] zu finden. Der Kalman-Filter arbeitet in zwei grundlegenden Schritten. Im ersten Schritt wird eine Prädiktion für die k-te Messung durchgeführt. Hierbei werden die Ergebnisse aus der vorherigen Messung pk−1|k−1 mit der sogenannten Jakobimatrix Fk multipliziert. Dies ist die a priori Prognose der Messung und wird mit pk|k−1 3 1 Einleitung bezeichnet. Daraus resultiert Gleichung 1. pk|k−1 = Fk pk−1|k−1 (1) Außerdem wird noch die a priori Fehlerkovarianzmatrix Ck|k−1 über Gleichung 2 geschätzt. Die angegebene Matrix Qk beschreibt das prozessbedingte Rauschen und wird im Falle der Spurrekonstruktion für das ATLAS-Experiment nicht berücksichtigt, sodass die Multiplikation der Jakobimatrix mit der vorherigen Fehlerkovarianzmatrix zur Vorhersage führt. Ck|k−1 = Fk Ck−1|k−1 FTk + Pk Qk PTk (2) Im Falle der ersten Messung liegen keine vorherigen Werte vor und es müssen Startwerte angenommen werden. Die Bestimmung dieser Startwerte ist mit den hier verwendeten Formeln aus verschiedenen Gründen problematisch. Die vom Teilchen durchquerten Materialien genau zu bestimmen ist eine schwierige Aufgabe, da die bisher geschätzte Flugbahn nicht nahe der echten Flugbahn verlaufen muss. Zudem ist die lineare Approximation des Spurmodels eventuell falsch, falls der verwendete Startwert zu stark von der eigentlichen Spur entfernt liegt. Außerdem kann die Vorhersage komplett fehlschlagen, wenn der vorhergesagte Pfad die nächste Detektorlage nicht schneidet. Eine Lösung für dieses Problem ist die Verwendung einer Referenzspur, welche durch vorangegangene Mustererkennungsverfahren generiert wird, um die Messpunkte zu einer Spur zusammen zu fassen. Der Fit der Spur wird nicht mehr auf den Messpunkten alleine, sondern auf der Differenz der korrespondierenden Messpunkte und Referenzpunkte ausgeführt. Dadurch wird anstatt mk jetzt ∆mk = mk − Hk pk|ref für den Fit verwendet, sodass sich die Startwertproblematik entspannt.[7] Die Wahl der Startwerte ist in Kapitel 4.1.1 auf Seite 38 beschrieben. Damit ist die Prädiktionsphase abgeschlossen und es folgt die Aktualisierungsphase des KalmanFilters. Diese Phase korrigiert die vorhergesagten Ergebnisse der ersten Phase unter Berücksichtigung des eingehenden Messpunktes. Zunächst wird, wie in Gleichung 3 dargestellt, Kk berechnet, welches als Kalman-Gain bezeichnet wird. Der KalmanGain minimiert die a posteriori Fehlerkovarianz.[8] Die angegebene Matrix Hk dient als Transformationsmatrix von einer Dimension in eine Andere. Dies ist im Falle der Spurrekonstruktion wichtig und wird im Kapitel 4.3.1 auf Seite 39 näher beleuchtet. Kk = Ck|k−1 HTk (Vk + Hk Ck|k−1 HTk )−1 4 (3) 1 Einleitung Die Aktualisierung des vorausgesagten Wertes pk|k−1 wird, wie in Gleichung 4 angegeben, berechnet. Der neue Messwert aus der Messreihe ist im Vektor mk gespeichert. pk|k = pk|k−1 + Kk (mk − Hk pk|k−1 ) (4) Zudem kann die Fehlerkovarianzmatrix Ck|k mit Hilfe von Gleichung 5 berechnet werden. Ck|k = (I − Kk Hk )Ck|k−1 (5) In Abbildung 3 ist beispielhaft die Korrektur der Spur angegeben, welche die Mess- Abbildung 3: Kalman-Filter korrigierte Spur punkte und die echte Teilchenspur zumindest für die Messungen ml , l > k die Spur näher an das wahre Ergebnis bringt. Um die vorherigen Messungen zu verbessern, fehlen dem Kalman-Filter einige Informationen. Dieser Informationsgehalt kann gesteigert werden, da der Kalman-Filter für die Spurrekonstruktion benutzt wird und damit alle Messpunkte bereits vorliegen. Um die Informationen der letzten Messungen bei der Berechnung der ersten Messungen zu beachten, werden weitere Schritte durchgeführt, um ein möglichst optimales Ergebnis für jeden Messpunkt zu bekommen, in dem alle vorhandenen Informationen eingeflossen sind. Hierfür wird ein Smoothing-Verfahren eingesetzt. Für das in diesem Projekt verwendete Smoothing wird der Kalman-Filter zweimal auf alle Messpunkte, mit jeweils der entgegen gesetzten Richtung, angewendet. 5 1 Einleitung Anschließend werden die jeweiligen Ergebnisse über die Fehlermatrizen gewichtet zusammengefasst. K’k = Cfk|k (Cfk|k + Cbk|k )−1 (6) Die Gewichtung wird mittels Gleichung 6 berechnet, wobei zu beachten ist, dass das f in Cfk|k die Matrix der Vorwärtsrichtung bezeichnet und dementsprechend b in Cbk|k die Matrix der Rückwärtsrichtung. p’k = pfk|k + K’k (pbk|k−1 + pfk|k ) (7) Um das, durch das Smoothing korrigierte, p’k zu bestimmen, muss, wie in Gleichung 7 angegeben, aus der Vorwärtsrichtung das aktualisierte pk|k und für den Rückweg das prognostizierte pk|k−1 genutzt werden. Dies verhindert eine doppelte Gewichtung des aktualisierten Wertes. C’k = (I − K’k )Cfk|k (8) Gleichung 8 beschreibt die Berechnung der neuen Fehlerkovarianzmatrix C’k . Abbildung 4: Durch Smoothing korrigierte Spur Abbildung 4 veranschaulicht die durch das Smoothing verbesserte Spur. Durch die Berücksichtigung der letzten Messungen sind die beiden ersten Messpunkte korrigiert worden und es zeichnet sich eine weitere Annäherung an die reale Spur ab. 6 1 Einleitung 1.4 GPU-Architektur Um zu verstehen, warum GPUs so viel mehr Leistung bieten als moderne CPUs und dennoch nur in speziellen Bereichen schneller sind als eben jene, muss die Architektur moderner GPUs bekannt sein. Die aktuell am weitesten fortgeschrittene GPU wird von NVIDIA unter dem Chipnamen GK110 gebaut. Die hier vorgestellte Architektur lässt sich grundlegend auf frühere Chips und deren Architekturen anwenden, wobei sich Einheitenanzahl und Ausführungsfähigkeiten unterscheiden können. 1.4.1 Hardwaremodell Abbildung 5: GK110 Blockdiagramm[14] Das Blockdiagramm in Abbildung 5 stellt den grundlegenden Aufbau dar. PCI Express 3.0 Host Interface Über dieses Interface ist die GPU mit dem HostSystem verbunden. Die Kommunikation hat eine Bandbreite von knapp 16 GB/s und ist vollduplexfähig. 7 1 Einleitung GigaThread Engine Die GigaThread Engine ist ein in Hardware realisierter Scheduler für zu bearbeitende Daten. Der Scheduler arbeitet auf Block-Ebene (siehe Kapitel 1.4.2 auf Seite 11) und weist den SMX-Einheiten entsprechende Arbeitsblöcke zu. Memory Controller GK110 verfügt über insgesamt 6 Memory Controller, welche mit jeweils 64 Bit (insgesamt 384 Bit) an den dahinterliegenden Speicher angebunden sind. Der Speicher kann, je nach Modell, ECC-fähig sein und die maximale Speicherbestückung erlaubt 6 GB Speicher mit einer theoretischen Gesamtbandbreite von ca. 250 GB/s. SMX Die sogenannten Next Generation Streaming Multiprocessors sind am ehesten mit einem CPU-Kern vergleichbar, welcher in der Lage ist, mehrere Threads gleichzeitig auszuführen. Da diese Einheit besonders wichtig im Hinblick auf die Programmierung von GPUs ist, wird die Funktionsweise im folgenden Absatz genauer erläutert. L2 Cache Ähnlich zu einer CPU hat eine GPU mehrere Cache-Stufen. Der L2Cache dient dabei einerseits regulär als Cache für Speicherzugriffe, andererseits tauschen SMX-Einheiten bei Bedarf Informationen über diese Cachestufe aus. 8 1 Einleitung Abbildung 6: SMX Blockdiagramm[15] Abbildung 6 zeigt eine detaillierte Ansicht über eine SMX. Ein Verständnis über die Abarbeitung von Instruktionen, die Anzahl der Register und deren Größe, sowie die Konfigurationsmöglichkeiten der Caches ist essentiell um eine hohe Auslastung der GPU und damit in der Regel einhergehende hohe Anzahl von FLOPS zu erreichen. Instruction Cache Dies ist der Instruktionsspeicher, in dem die Instruktionen für die auszuführenden Warps zwischengespeichert werden. Warp Scheduler Stellt einen weiteren Hardwarescheduler dar (vgl. GigaThread Engine), welcher auf Warp-Ebene agiert. Dieser Scheduler verfügt über zwei Instruction Dispatch Units, welche parallel die Befehle n und n+1 an ein Warp schicken (siehe Abbildung 7). Es sind pro SMX je vier Warp Scheduler vorhanden, welche innerhalb von zwei Takten jeweils 8 Warps mit neuen Instruk- 9 1 Einleitung Abbildung 7: Warp Scheduler[16] tionen für die nächsten zwei Takte versorgen können (siehe Kapitel 1.4.2 auf Seite 11). Register File Pro SMX stehen 65536 32-Bit Register zur Verfügung, welche in Blöcken von 32 Einheiten zu den jeweiligen Single und Double Precision Cores zugewiesen werden können. Das Limit pro Kern liegt allerdings bei 256 Registern. Kerne Eine SMX des GK110 besteht aus insgesamt 192 Single Precision Kernen, 64 Double Precision Kernen, 32 Special Function Einheiten und 32 Load/Store Einheiten.[9] Shared Memory / L1 Cache Pro SMX sind 64 KB lokaler Speicher verbaut. Dieser Speicher fungiert sowohl als L1 Cache, als auch als sogenannter Shared Memory. Shared Memory ist ein extrem schneller, lokaler Speicher, welcher es erlaubt, innerhalb eines CUDA-Blocks Daten auszutauschen. Die Aufteilung des Speichers in L1-Cache und Sahred Memory kann konfiguriert werden. Mit dem Compute Level 3.5 des GK110 lässt sich der Speicher in 16 KB L1 Cache / 48 KB Shared Memory, 32 KB L1 Cache / 32 KB Shared Memory oder 48 KB L1 Cache / 16 KB Shared Memory aufteilen. Wird eine Konfiguration gewählt, die mit dem aktuell ausgeführten Kernel inkompatibel ist, erfolgt eine automatische Änderung der Eisntellungen. Bei einer Konfiguration von 48 KB L1 Cache und 16 KB Shared Memory und einem Kernel, welcher 40 KB Shared 10 1 Einleitung Memory anfordert, wird die Konfiguration an den Kernel angepasst. 48 KB Read-Only Data Cache Repräsentiert einen lokalen Cache für Read-Only Werte. Tex Lokaler Speicher für Texturen. Texturspeicher kann allerdings beliebige Daten enthalten, welche sich in einem Texturformat darstellen lassen. 1.4.2 Warps Ein Warp ist die kleinste Menge an Threads, welche die GPU einzeln ansprechen kann. Diese Gruppe von Threads muss damit immer die gleichen Instruktionen ausführen. Dies wird durch Abbildung 7 deutlich, da Instruktionen nur an ganze Warps geschickt werden können. Derzeitig haben alle Architekturen von NVIDIA eine Warpsize von 32 Threads bzw. Cores, welche im Verbund Instruktionen ausführen. Dies kann als eine Art SIMD-Architektur (Single Instruction Multiple Data) interpretiert werden, welche allerdings bei der Implementierung nur indirekt beachtet werden kann, da CUDA keine Unterscheidung von Threads innerhalb oder außerhalb von Warps vorsieht. Sei als Beispiel folgender Code gegeben (CUDAspezifische Befehle werden im Kapitel 2 auf Seite 15 erläutert): // Laenge der Arrays sei 32 == Warpsize // threadIndex sei entsprechend im Intervall [0 , 31] __global__ void fooCopy32 ( double * src , double * res ) { int threadIndex = threadIdx . x ; if ( threadIndex < 16) res [ threadIndex ] = src [ threadIndex ]; else res [ threadIndex ] = src [ threadIndex ] + 1; } Listing 1: Beispielcode für Warpdivergenz Es scheint, als wenn 16 Threads den if-Zweig ausführen und die anderen 16 Threads den else-Zweig, dem ist allerdings auf Grund der Warpsize nicht so. Bei der Ausführung durchlaufen alle 32 Threads den ersten Zweig, die Ergebnisse der ersten 16 werden gespeichert. Anschließend führen alle 32 Threads den else-Zweig aus. Dort werden die Ergebnisse der ersten 16 Threads verworfen. Es sollte bei der Programmierung darauf geachtet werden, Code-Divergenzen innerhalb eines Warps zu vermeiden. Weitere Beispiele und Vermeidungsstrategien sind im Kapitel 2 zu finden. 11 1 Einleitung 1.4.3 Hardwareeigenschaften und Programmierung Im Anschluss an die Erläuterung der grundlegenden Architektur werden einige Beispiele gegeben, die in der Implementierung des Kalman-Filters eine große Rolle spielen, allerdings nicht auf Grund der verwendeten Sprache, sondern auf Grund der verwendeten Hardware und deren Eigenschaften durchgeführt werden. So werden anders als bei klassischen CPUs einige lokale Speicher nicht automatisch angesprochen und benutzt, sondern müssen im Programmcode selbst explizit angesteuert werden. Da dieser lokale On-Chip-Speicher um Größenordnungen schneller sein kann, müssen diese Hardwareeigenschaften genutzt werden. Kommunikation zwischen System und GPU Die im Kapitel 1.4.1 beschriebenen Übertragungsgeschwindigkeiten zeigen auf, dass die Anbindung der Grafikkarte an das Host-System vergleichsweise langsam ist. Dieser Umstand kann sich je nach Problemstellung als relevant erweisen und muss bei der Implementierung des Kalman-Filters beachtet werden. Die erreichte Bandbreite wird dabei maßgeblich von der Größe der zu übertragenen Daten beeinflusst und es spielt neben der Bandbreite auch die Verzögerung für den Start eines Transfers eine Rolle. Dieser Umstand ist für ein PCIe 2.0 und PCIe 3.0 Interface unter Verwendung von Pinned Memory in Abbildung 8 zu sehen. Deshalb sollten ne- Abbildung 8: Transferraten in Abhängigkeit der Datenmenge ben der zu übertragenden Datenmenge die Anordnung und die Größe der einzelnen 12 1 Einleitung Datenpakete beachtet werden. Viele kleine Datenpakete sollten, wenn möglich, in eine zusammenhängende Struktur oder in einen Datenblob hintereinander im HostSpeicher liegend mit einem einzigen Transfer zum GPU-Speicher transferiert und auf der GPU entsprechend verarbeitet werden. Coalesced Memory Access Abbildung 9: Verschiedene Speicherzugriffsmuster Wird ein Datum aus dem Hauptspeicher in den lokalen Speicher eines Threads gelesen, so werden automatisch 128 Byte in den L1 Cache der SMX übertragen. Zu beachten ist, dass ab der Kepler-Architetkur (GK110) Ladevorgänge aus dem Hauptspeicher immer im L2 Cache zwischengepeichert werden. [13] Dies führt zu verschiedensten Szenarien von suboptimalen Speicherzugriffen, welche die Bandbreite verschwenden und die Latenz erhöhen können. In Abbildung 9 sind vier verschiedene Szenarien dargestellt, die verdeutlichen, dass unterschiedliche Datenstrukturen die Bandbreite und Anzahl der Schreib-/Lesevorgänge erheblich beeinflussen können. Hierbei wird nicht nur die Speicherbandbreite unnötig verschwendet. Das Blockschaltbild einer SMX (Abbildung 6) zeigt, dass die Anzahl der Load/Store-Einheiten um Faktor sechs geringer ist, als die der single precision Einheiten. Gegeben sei folgendes, konstruiertes Beispiel, in dem 32 Threads eine Aufgabe erledigen, deren Datenstruktur pro Thread genau 32 Floats enthält und damit eigentlich optimale 128 Byte lang ist. Außerdem wird angenommen, dass jeder Thread die Summe der 13 1 Einleitung 32 Floats iterativ bilden muss, wobei pro Schritt genau ein Wert hinzu addiert wird. Es gibt verschiedene Möglichkeiten, eine Datenstruktur aufzubauen, welche für diese Applikation funktionieren würde. Beispielstrukturen: Struktur A Die Summendaten für die 32 Threads liegen pro Iteration hintereinander. Im Speicher liegen an der Startadresse 32 Floats für Iteration 1, anschließend 32 Floats für Iteration 2, usw. Die 32 Threads würden alle einen Ladebefehl absetzen, wobei alle Adressen in einen 128 Byte großen Block fallen. Mit dieser Struktur wird pro Iteration genau ein Block gelesen und nur ein Ladebefehl an die entsprechden Load-/Store-Einheiten übergeben. Das heißt dieser Zugriff verwendet so wenig Bandbreite mit so wenigen Ladebefehlen wie möglich. Struktur B Die Summendaten werden pro Element beziehungsweise pro Thread abgelegt. Das heißt, es stehen an der Startadresse 32 Floats, welche für Thread 1 von Iteration 1 bis 32 alle Daten enthält. Anschließend kommen die Informationen für Thread 2 usw. In diesem Fall würden in Iteration 1 32 Ladebefehle ausgeführt werden, von denen jeweils 124 Byte übertragen werden, welche erst in der nächsten Iteration benötigt werden. Der Overhead liegt bei knapp 97 %. Dies kann eventuell durch die Caches abgefangen werden, sodass es zu keinen nachfolgenden Ladebefehlen kommen muss, allerdings setzt dies ausreichend große Caches voraus, welche je nach Algorithmus nicht mehr ausreichend Platz bieten könnten. Es wird deutlich, dass die richtigen Datenstrukturen einen großen Einfluss auf die Durchsatzraten und Latenzen des Arbeitsspeichers auf der GPU haben können. Shared Memory Wie auf Seite 10 beschrieben, ist der sogennante Shared Memory ein lokaler Speicher mit geringer Latenz und hoher Bandbreite. Durch die relativ kleine Größe des Speichers lassen sich jedoch nicht beliebig große Daten innerhalb des Shared Memory verarbeiten und es muss je nach Algorithmus spezieller Gebrauch von diesem Speicher gemacht werden. Oftmals kann es durch den Gebrauch von Shared Memory vermieden werden, aus dem Hauptspeicher gelesene Daten für die aktuelle Berechnung zu verwerfen und später nochmal nachladen zu müssen. [17] 14 2 NVIDIA CUDA 2 NVIDIA CUDA CUDA ist eine von NVIDIA entwickelte Sprache für die Grafikkartenprogrammierung. Sie erlaubt es, die Ressourcen der GPU für Berechnungen zu nutzen, ist dabei stark an ANSI-C angelehnt und kann dementsprechend in C/C++ Umgebungen durch einfaches Einbinden der CUDA-Bibliothek verwendet werden. Zudem kann CUDA nativ in der Programmiersprache Fortran verwendet werden und wird von Standards wie beispielsweise OpenACC durch einfaches Einführen von Pragmas unterstützt. Neben diesen Verwendungsmöglichkeiten von CUDA innerhalb bereits existierenden Codes besteht die Option, geschriebenen CUDA-Code in nativen x86-Code zu übersetzen. Dies ermöglicht automatischen Gebrauch von Autovektorisierung, SSE-/AVX-Befehlen und Multicore-CPUs zu machen. Damit kann im HPC-Berech zwischen CPUs und GPUs gewechselt werden, ohne einen Algorithmus in mehreren Sprachen oder Implementierungen zu entwickeln. Weiterhin gibt es CUDA-Wrapper für weitere Programmiersprachen, die es erlauben die GPU in Java oder Python zu benutzen. 2.1 Definition Host und Device Den Programmiersprachen CUDA und OpenCL ist es gemein, dass die Grafikkarte nicht automatisch verwendet wird, um Berechnungen auszuführen. Vielmehr muss im Code unterschieden werden, welcher Teil auf der GPU und welcher Teil auf der CPU berechnet werden muss. Die Unterteilung in diese Ebenen erfolgt über entsprechende Befehle im Programmcode, wobei der Teil, der wie von anderen Sprachen gewohnt auf der CPU-Seite ausgeführt wird, dem sogenannten Host entspricht und die NVIDIA Grafikkarten des Systems als Device bezeichnet werden. Das Device bekommt vom Host sogenannte Kernel übergeben, welche ausgeführt werden sollen. Die Abbildung 10 verdeutlicht die Möglichkeit pro Host mehrere Devices zu verwenden. Außerdem besteht die Option, Arbeit beliebig auf die verschiedenen Devices zu verteilen und es ist nicht erforderlich homogene Devices einzusetzen. Es kann jederzeit ein neues Device in ein vorhandenes Gesamtsystem eingebaut und (sofern multiple Devices im Programmcode berücksichtigt werden) automatisch verwendet werden. Voraussetzung für die automatische Verwendung ist allerdings, dass der Code mit dem Featureset (siehe Kapitel 2.2 Compute Capability) des Devices übereinstimmt oder unter bestimmten Voraussetzungen aktueller ist. 15 2 NVIDIA CUDA Abbildung 10: Skalierbarkeit über mehrere Devices[20] 2.2 Compute Capability Die Compute Capability beschreibt das verfügbare Featureset eines Devices. Die im Kapitel 1.4.1 vorgestellte Kepler GPU auf Basis des GK110 unterstützt die aktuell fortschrittlichste Compute Capability 3.5. Es muss bei der Grafikkartenwahl dennoch auf mehr als nur die Compute Capability geachtet werden. NVIDIA hat mit der CUDA 5 Spezifikation zwar GPUDirect eingeführt, welches direkten RDMA Zugriff auf Peripheriegeräte erlaubt [10], allerdings wird dies beispielsweise nur von den Tesla K20 Karten, nicht aber von der Consumerkarte Geforce GTX TITAN trotz identischen Chips und Computelevel unterstützt[12]. Dies macht das Überprüfen des unterstützten Featuresets per Hand erforderlich. Im Anhang auf Seite 77 sind die verschiedenen Hardwarespezifikationen nach Computelevel aufgeschlüsselt. Sowohl das Computelevel, als auch die technischen Daten eines Devices können zur Laufzeit abgefragt werden, sodass das Programm entsprechend reagieren kann. Ein Beispielcode befindet sich im Kapitel Deviceeigenschaften auf Seite 28. Damit vorhandener Kernelcode auf einem Device höheren Computelevels ausführbar ist, darf der Kernel nicht nur als kompilierter Code im Binaryformat vorliegen, son- 16 2 NVIDIA CUDA dern muss in einem virtuellen Codeformat abgespeichert werden. Dieses virtuelle Codeformat erlaubt, Kernel zur Laufzeit für die entsprechende Architektur zu kompilieren, solange das Computelevel des Zieldevices gleich oder höher ist, als das vom Kernel verlangte Computelevel. Hierzu muss der zu kompilierende Code mit speziellen Compilerflags kompiliert werden, bei denen die Zielarchitektur und der Zielcode einer virtuellen Architektur entsprechen. Es wird anschließend PTX-Code generiert, welcher nicht von der GPU ausgeführt werden kann, aber vor der Ausführung in ausführbaren Binärcode übersetzt wird.[23] 2.3 Kernel Ein Kernel ist eine in CUDA geschriebene Funktion (siehe Listing 2 auf Seite 21). Diese Funktionen müssen neben dem Rückgabewert noch mit device versehen werden. Dies teilt dem CUDA-Compiler mit, dass diese Funktion auf einem Device ausgeführt werden muss. Mehr Informationen dazu befinden sich im Abschnitt 3.1 auf Seite 21. 2.4 Grundlegendes Threadingmodell Die Verwaltung von Threads wird in vier Bereiche unterschiedlicher Dimension aufgeteilt, um die Handhabung von tausenden Threads zu vereinfachen. Thread Ein Thread ist vergleichbar mit einem normalen CPU-Thread. Warp Eine Gruppe von Threads, welche die gleichen Instruktionen ausführen müssen wird zu einem Warp zusammengefasst (siehe Kapitel 1.4.2 auf Seite 11). Block Kernel werden in sogenannten Blocks bzw. Blöcken ausgeführt. Ein Block besteht dabei aus n Threads und kann bis zu drei Dimensionen beinhalten. Dies kann hilfreich sein, zwei- oder dreidimensionale Probleme im Programm selbst durch zwei- oder dreidimensionale Darstellung der Threads abzuarbeiten. Dies wird durch ein Beispiel verdeutlicht: Ein Algorithmus addiert zwei l × k Matrizen. Jedes Feld in der Ergebnismatrix aij setzt sich aus der Summe der beiden entsprechenden Felder aus den beiden l × k Matrizen zusammen. Dies lässt sich in CUDA leicht durch entsprechende Dimensionierung eines Blocks darstellen, sodass jeder Thread die Indizes i, j 17 2 NVIDIA CUDA besitzt, während die Blockgröße genau der Matrixgröße von l × k entspricht. Blöcke sind für CUDA die größte zusammenhängende Anzahl von Threads, welche sich an Synchronisierungspunkten synchronisieren müssen. Grid Das sogenannte Grid ist vom Aufbau her vergleichbar mit den Blöcken. Ein Gridelement besteht dabei aus einem Block und dementsprechend einer Menge von Threads und kann ebenso wie die Blöcke bis zu drei Dimensionen haben. Anders als bei den Blöcken synchronisieren sich Gridelemente nicht an Synchronisierungspunkten im Code, sondern die unterschiedlichen Blöcke laufen unabhängig voneinander. Abbildung 11: Zusammenfassung des Threadingmodells[19] Ein zweidimensionaler Aufbau des Threadingmodells ist in Abbildung 11 dargestellt und verdeutlicht die Abhängigkeiten. 18 2 NVIDIA CUDA 2.5 Streams Durch den enormen Flopdurchsatz moderner Grafikkarten ist es, je nach Aufgabenstellung, nicht einfach die Einheiten mit genügend Daten zu versorgen. Um dieses Problem zu entschärfen werden sogenannte Streams eingeführt, welche parallel abgearbeitet werden können. Ein Stream ist dabei eine Art Verarbeitungskette, welche die an den Stream gesendeten Befehle abarbeitet. Die Verarbeitung erfolgt dabei nach dem First Come First Serve (FCFS) Prinzip. Hierbei werden die eingehenden Befehle in genau der Reihenfolge abgearbeitet, in der sie an den Stream gesendet werden. Hierzu stellt CUDA eine Reihe von asynchronen Funktionen zur Verfügung, welche es erlauben, mehrere Befehle an einen Stream zu senden, ohne den Host zu blockieren, während die klassischen blockierende Aufrufe automatisch Stream 0 benutzen. Dass die Verwendung von Streams einen Performancevorteil bringen kann, zeigen die verschiedenen Abarbeitungsketten in Abbildung 12. Das obere Beispiel Abbildung 12: Abarbeitung von Streams ohne Streambenutzung zeigt die klassische Arbeitsweise ohne die Verwendung von Streams. Das Hostprogramm kopiert zunächst alle Daten auf das Device, startet dann die Kernelausführung und kopiert die Ergebnisse anschließend zurück. Im Anschluss können die Daten für den nächsten Kernel kopiert werden, etc. Dieses Vorgehen sorgt in diesem einfachen Beispiel dafür, dass die Grafikkarte nur ein Drittel der Laufzeit Berechnungen durchführt. Durch das Benutzen von Streams ist es möglich den Vorgang zu parallelisieren. Hierbei können während der Ausführung des ersten Kernels die benötigten Eingabedaten für den zweiten Kernel kopiert werden. Wenn das Device zudem noch mehrere Kopiereinheiten (Copyengines) bietet, kann mit 19 2 NVIDIA CUDA einem dritten auszuführenden Kernel der Kopiervorgang zum Device, der Kopiervorgang vom Device und die Ausführung des mittleren Kernels parallel ablaufen. Die Verwendung von Streams führt hierbei nicht automatisch zu einer besseren Laufzeit, wie das dritte Beispiel zeigt. Hier werden die Kommandos zum Device in falscher Reihenfolge an die Streams geschickt. Durch die nötige Serialisierung der Abarbeitung der Streams wird hierbei die Laufzeit nicht verbessert. Sollte ein Kernel beispielsweise nicht die zur Verfügung stehenden Ressourcen des Devices nutzen, so kann das Device, sofern es concurrent Kernels (siehe Kapitel 3.6 auf Seite 28) unterstützt, multiple Kernel gleichzeitig ausführen, sodass die sogenannte Utilization der Devicekerne entsprechend ansteigt. 20 3 CUDA Programmierung 3 CUDA Programmierung Um den im Kapitel 4 ab Seite 36 vorgestellten Code mit der Implementierung des Kalman-Filters besser verstehen zu können, ist eine Einführung in die Syntax der CUDA-Programmiersprache erforderlich. 3.1 CUDA Host und Device Der NVIDIA CUDA-Compiler hat mehr Aufgaben, als nur den Grafikkarten-Code zu kompilieren. Er erlaubt es außerdem, Code für die Grafikkarte (das Device) und für die CPU (der Host) in einer Datei automatisch zu trennen und den Devicecodeabschnitt selbst zu kompilieren, während der Hostcode an den normalen C/C++Compiler weitergeleitet wird. Diese Unterscheidung kann auf ganze Dateien zutreffen, sodass *.c oder *.cpp Dateien immer direkt an den Hostcodecompiler weitergereicht werden. Sollte eine Datei die CUDA-C Dateiendung *.cu aufweisen, so wird dieser Code auf Device- und Hostcode hin untersucht und von dem entsprechendem Compiler kompiliert. Da CUDA starke Ähnlichkeiten mit C hat, werden durch CUDA einige neue Kommandos eingefügt, welche diese Unterscheidung ermöglichen. Um eine Funktion foo(float *a, float*b) auf der Grafikkarte berechnen zu lassen muss diese Funktion neben dem typischen Funktionsaufbau aus Rückgabewert Name(Parameter 0,..., Parameter n) {...} allem voran noch der CUDA-Befehl device stehen. __device__ void foo ( float *a , float * b ) { ... } Listing 2: Funktionskopf für GPU-Funktion Damit wird diese Funktion in Grafikkartencode übersetzt. Der Aufruf dieser Funktion innerhalb des Hostcodes orientiert sich sehr stark an einem normalen Funktionsaufruf, benötigt allerdings mehr als nur die Funktionsparameter um korrekt ausgeführt zu werden. Zu beachten ist ebenfalls, dass die aus C bekannten Funktionsparameter nicht immer vollautomatisch auf die Grafikkarte kopiert werden. In einigen Fällen ist es nötig, die Daten zunächst auf die Grafikkarte zu kopieren. Informationen zum Kopieren von Daten zur Grafikkarte, sowie das reservieren von Grafikkartenspeicher sind im Kapitel 3.8 auf Seite 31 zu finden. Der Aufruf dieser Funktion ist im Code durch das Beispiel 2 gegeben. 21 3 CUDA Programmierung ... foo < < < Parameter 0 , ... , Parameter n > > >(a , b ) ; ... Listing 3: Aufruf GPU-Funktion Es ist ersichtlich, dass es neben den üblichen Parametern noch eine weitere Parameterart gibt, welche in den Spitzklammern <<<...>>> angegeben wird. Welche Parameter das sind und welchen Einfluss diese haben, wird in den folgenden Abschnitten erläutert. Eine kurze Übersicht der Parameter ist in Tabelle 1 gegeben. Tabelle 1: Parameterübersicht CUDA-Kernelaufruf Parameter Beschreibung Kapitel Auf Seite 1 Dimensionen des Grids 2.4 22 2 Dimensionen eines Threadblocks 2.4 22 3 Dynamische Größe des Shared Memory 3.3 24 4 Verwendeter Stream 3.4 25 3.2 Threadingmodell Da der Zugriff auf diese Informationen innerhalb des Device-Codes oftmals benötigt wird, um beispielsweise die korrekte Position der vom aktuellen Thread zu bearbeitenden Daten zu ermitteln, existieren im Device-Code eingebaute Variablen, welche von der CUDA-API automatisch gesetzt werden. Um beispielsweise die Position eines Threads innerhalb eines Blocks zu bestimmen, kann folgender Codeabschnitt genutzt werden. int xPosBlock = threadIdx . x ; int yPosBlock = threadIdx . y ; int zPosBlock = threadIdx . z ; Listing 4: CUDA Threadindizes Je nach Aufgabenstellung kann es zudem sinnvoll sein, die absolute Größe eines Blocks im Code zu kennen. Dies geschieht über die folgenden Kommandos: int xBlockSize = blockDim . x ; int yBlockSize = blockDim . y ; int zBlockSize = blockDim . z ; 22 3 CUDA Programmierung Listing 5: CUDA Blockgrößen Äquivalent hierzu die Befehle für die Position und Dimension des gesamten Grids. int int int int int int xPosGrid = blockIdx . x ; yPosGrid = blockIdx . y ; zPosGrid = blockIdx . z ; xGridSize = gridDim . x ; yGridSize = gridDim . y ; zGridSize = gridDim . z ; Listing 6: CUDA Gridposition, sowie Gridgröße Die Werte dieser Variablen sind immer benutzerdefiniert. Beim Aufruf eines Kernels muss im ersten CUDA-Parameter die Größe der einzelnen Dimensionen angegeben werden. Der zweite Parameter bezieht sich immer auf die Größe der Blockdimensionen. Gegeben sei der Kernel aus dem Codeabschnitt 7. __device__ void foo ( float *a , float * b ) { int myPos = threadIdx . x + blockDim . x * blockIdx . x ; b [ myPos ] = a [ myPos ]; } Listing 7: Beispielanwendung der Threadposition Der Aufruf dieser Funktion muss offensichtlich eine spezielle Größe der x-Dimension der Blöcke sowie des Grids angeben. Hierbei ist zu beachten, das bei Verwendung einer eindimensionalen Struktur für die Blockgröße oder die Gridgröße automatisch die verbleibenden Dimensionsgrößen auf Eins gesetzt werden und somit keine überflüssigen Threads erzeugt werden. Seien die Zeiger a und b zwei Arrays mit der Größe n, so könnte die Funktion foo folgendermaßen aufgerufen werden. // a und b seien bereits auf der Grafikkarte alloziert und a wurde kopiert foo < < <n ,1 > > >( a , b ) ; Listing 8: Beispielaufruf im Host-Code Dies führt zu einem eindimensionalen Grid der Größe n, wobei jedes Gridelement aus einem eindimensionalen Threadblock der Größe Eins besteht. Da die Größen der einzelnen Dimensionen je nach Compute Capability der verwendeten Hardware unterschiedlich sein können und damit die Größe des zu kopierenden Arrays begrenzen, muss sowohl die Implementierung, als auch der Aufruf der Funktion gegebenenfalls mehrere der verfügbaren Dimensionen nutzen. Dies kann je nach Aufgabenstellung irrelevant sein. Die genauen Größen befinden sich im Anhang und lassen sich von 23 3 CUDA Programmierung der Abbildung 18 auf Seite 77 ablesen. Um mehrdimensionale Grids und Blocks zu erzeugen gibt es den dim3 Datentyp von NVIDIA. Dessen Benutzung ist in Listing 9 angegeben. dim3 gridDim (n ,m , l ) ; dim3 blockDim (32 ,32 ,16) ; foo < < < gridDim , blockDim > > >(a , b ) ; Listing 9: Mehrdimensionaler Beispielaufruf im Host-Code 3.3 Shared Memory in CUDA In CUDA hat der Programmierer direkten Zugriff auf den schnellen lokalen Shared Memory. Hierfür stellt CUDA im Devicecode den Befehl shared zur Verfügung, welches eine Variable als im Shared Memory liegend markiert. Der Speicherbereich kann sowohl dynamisch zur Laufzeit reserviert werden, als auch statisch im Kernel. Die statische Allokation ist im Listing 10 zu sehen und ähnelt stark der aus C bekannten Allokation von Arrays fester Größe. __device__ void foo100 ( float *a , float * b ) { int myPos = threadIdx . x + blockDim . x * blockIdx . x ; __shared__ float c [100]; c [ myPos ] = a [ myPos ] + b [ myPos ]; ... } Listing 10: Shared Memory mit statischer Größe Die statische Größe macht die Benutzung des Shared Memory Speichers sehr einfach. Die Nachteile sind allerdings identisch zu denen statischer Arrays in normalen C-Code, sodass oft auf dynamische Größen zurückgegriffen werden muss. Die dynamische Allokation erfordert die Kenntnis über die Größe des benötigten Speichers auf der Hostseite. Außerdem ist es notwendig den Speicher kernelseitig in Teilbereiche zu splitten, da nur ein einziger Zeiger auf den Anfang des Speicherbereichs zeigt. Dies ist solange kein Problem, wie es nur ein einziges Array gibt, welches beachtet werden muss. Sollten mehrere Arrays benötigt werden, muss mittels Zeigerarithmetik jeweils der Anfang der Teilbereiche bestimmt werden. Der dynamische Bereich muss außerdem mit dem Keyword extern gekennzeichnet werden. __device__ void fooDyn ( float *a , float *b , int items ) { int itemPos = threadIdx . x + blockDim . x * blockIdx . x ; extern __shared__ float * c ; __shared__ float d [32]; 24 3 CUDA Programmierung float * p1 , p2 ; p1 = c ; p2 = c + items ; p1 [ itemPos ] = a [ itemPos ] + b [ itemPos ]; p2 [ itemPos ] = a [ itemPos ] * b [ itemPos ]; ... } Listing 11: Shared Memory mit dynamischer Größe Listing 11 zeigt ein einfaches Beispiel für zwei Arrays auf den Shared Memory mit dynamischer Größe. Es ist ersichtlich, dass trotz des Einsatzes eines dynamischen Bereiches weiterhin die Möglichkeit besteht, statische Größen zu verwenden. Wichtig ist, dass im Kernel selbst keine Möglichkeit besteht, zu prüfen, ob der dynamische Bereich groß genug ist. Hierbei muss sich auf die Berechnung der Hostseite verlassen werden. Im Fehlerfall können die von der Hostseite bekannten Speicherfehler auftreten, aber genau wie beim Host, müssen diese Fehler nicht zum Absturz oder zu Fehlermeldungen führen. int items = n ; foo < < <1 , n , sizeof ( float ) * items *2 > > >(a , b , items ) ; Listing 12: Beispielaufruf im Host-Code mit dynamischem Shared Memory Im Listing 12 ist der Kernelaufruf auf Hostseite angegeben. 3.4 CUDA Streams Um die in Kapitel 2.5 beschriebenen Streams zu verwenden, müssen diese zunächst in beliebiger Anzahl erstellt werden. Ein Stream selbst wird dabei durch eine Struktur beschrieben, dessen Inhalt während der Initialisierung von der API gefüllt wird. Streams sind rein hostseitig existent und relevant und spielen somit keine Rolle in einem Kernel. Die Verwaltung der Streams kann, je nach Struktur, komplexe Züge annehmen, sodass im Vorfeld über eine geeignete Anwendung der Streams nachgedacht werden muss. Der Einfachheit halber werden in diesem Kapitel nur die grundlegenden Funktionen anhand eines Beispiels mit einem einzelnen Stream erläutert, die weit komplexere Anwendung von Streams in der Umsetzung des Kalman-Filters wird im Kapitel 4.4.3 ausführlich beschrieben. // Host Code ... cudaStream_t stream1 ; 25 3 CUDA Programmierung ... Listing 13: Deklaration eines Streams In Listing 13 ist die Deklaration des Datentyps cudaStream t eines Streams abgebildet. Um die Variable stream1 benutzen zu können, ist allerdings noch eine Initialisierung nötig, sodass der Code aus Listing 14 eingefügt werden muss. // Host Code ... error = c u d a S t r e am C r e a t e (& stream1 ) ; ... Listing 14: Initialisierung eines Streams Damit ist stream1, sofern kein Fehler zurückgegeben wird, korrekt initialisiert und kann in den verschiedenen API-Aufrufen, wie beispielsweise asynchronen Kopiervorgängen oder in Kernelaufrufen genutzt werden. An dieser Stelle ist anzumerken, dass fast alle API-Funktionen einen Fehlercode zurückgeben, welcher entsprechend überprüft werden sollte. Die Fehlerüberprüfung ist in Kapitel 3.5 auf Seite 27 erläutert. Um einen Stream nach Benutzung zu schließen muss die Destroyfunktion aus Listing 15 aufgerufen werden. Im Gegensatz zur Initialisierung ist der Streamparameter kein Zeiger. // Host Code ... error = c u d a S t r e a m D e s t r o y ( stream1 ) ; ... Listing 15: Löschen eines Streams Da die Synchronisation zwischen verschiedenen Streams und das gezielte Warten auf Ergebnisse innerhalb eines Streams essentiell sind, stellt die CUDA API entsprechende Funktionen zur Steuerung und Überwachung eines Streams zur Verfügung. // Host Code ... error = c u da St re a mQ ue ry ( stream1 ) ; ... error = c u d a S t r e a m S y n c h r o n i z e ( stream1 ) ; ... Listing 16: Status eines Streams Die cudaStreamQuery-Funktion aus Listing 16 ist eine asynchrone Funktion, welche den akutellen Ausführungsstatus des übergebenen Streams zurückgibt. 26 3 CUDA Programmierung cudaSuccess Der Stream hat alle Aufgaben erfolgreich abgeschlossen. cudaErrorNotReady Der Stream hat noch weitere Aufgaben auszuführen. cudaErrorInvalidResourceHandle Der angegebene Stream exisitert nicht bzw. nicht mehr. Außerdem kann der Stream alle Fehlercodes von vorherigen asynchronen Aufrufen zurückgeben. Die zweite Funktion aus Listing 16 ist eine synchrone Funktion, welche den Hostprozess bis zur vollständigen Abarbeitung aller noch anstehender Aufgaben oder bis zum Auftreten eines Fehlers blockiert. Die Rückgabewerte, sowie deren Interpretation, ist, bis auf den in diesem Fall unnötigen Rückgabewert cudaErrorNotReady, zur ersten Funktion identisch. 3.5 API-Fehler abfangen Da die meisten Funktionen der CUDA-Bibliothek verschiedenste Fehlercodes zurückgeben können, ist es sinnvoll für die Fehlercodeabfragen eine Funktion oder eine Makrofunktion zu erstellen. Um die Handhabung im Fehlerfall zu vereinfachen, empfiehlt sich eine Makrofunktion, da diese sehr einfach die Zeile und Quellcodedatei des Fehlers ausgeben kann und es keine Kontextswitches auf der CPU zum Aufruf einer Funktion geben muss. Da die Fehlercodes durch ein Enum repräsentiert werden[24], ist es nötig dieses Enum in eine vom Programmierer lesbare Fehlermeldung zu übersetzen. // Host Code ... char * errorMessage = c u d a G e t E r r o r S t r i n g ( error ) ; ... Listing 17: Lesbarer Fehlercode Die in Listing 17 dargestellte Funktion gibt einen null-terminiertes char-Array zurück, in dem sich eine lesbare Repräsentation des Fehlers befindet. // Host Code # define C U D A _ E R R O R _ H A N D L E R ( value ) { cudaError_t _m_cudaStat = value ; if ( _m_cudaStat != cudaSuccess ) { fprintf ( stderr , " Error % s at line % d in file % s \ n " , c u d a G e t E r r o r S t r i n g ( _m_cudaStat ) , __LINE__ , __FILE__ ) ; exit (1) ; 27 \ \ \ \ \ \ 3 CUDA Programmierung } } Listing 18: Error Handler Im Listing 18 ist die in der Implementierung verwendete Makro-Funktion zum Abfangen von Fehlern dargestellt. Wie dort zu sehen ist, gibt dieses Makro eine Fehlermeldung auf die Konsole aus und beendet anschließend das Programm. 3.6 Deviceeigenschaften Da nicht alle Grafikkarten die gleichen technischen Daten haben, sei es durch eine neue Grafikkartengeneration oder durch Verbreiterung der bestehenden Karten, kann es sinnvoll sein, den Hostcode durch Überprüfen der Funktionalitäten und technischen Daten einer Grafikkarte zur Laufzeit anzupassen. Diese Informationen können dazu dienen, die Auslastung auf zukünftigen Grafikkarten zu erhöhen, indem der Workload dynamisch angepasst wird. Außerdem können diese Informationen dazu genutzt werden, ein Programm kontrolliert zu beenden und den Benutzer darauf hinzuweisen, dass der aktuelle Code für die darunterliegende Hardware angepasst werden muss. Dies kann beispielsweise leicht der Fall sein, wenn sich die Warpsize von bisher 32 auf zum Beispiel 64 erhöhen würde, da oftmals viele Codeabschnitte auf dieser festen Größe aufbauen. Da die Devices nicht identisch sein müssen, müssen diese Informationen für jedes Device abgefragt werden und es muss entsprechend reagiert werden. // Host Code int count ; C U D A _ E R R O R _ H A N D L E R ( c u d a G e t D e v i c e C o u n t (& count ) ) ; Listing 19: Anzahl der Devices ermitteln In Listing 19 ist dargestellt, wie zunächst die Anzahl der im System vorhandenen CUDA-Devices ermittelt werden kann. Jedes Device muss einzeln geprüft werden. Dies ist in Listing 20 dargestellt. // Host Code cudaD evicePr op prop [ count ]; for ( int deviceId = 0; deviceId < count ; deviceId ++) { C U D A _ E R R O R _ H A N D L E R ( c u d a G e t D e v i c e P r o p e r t i e s (& prop [ deviceId ] , deviceId ) ) ; } Listing 20: Deviceeigenschaften ermitteln 28 3 CUDA Programmierung Im Anschluss an die for-Schleife befinden sich die Deviceeigenschaften in dem angegebenen prop-Array. Der Datentyp cudaDeviceProp ist dabei eine Struktur mit allen Daten des Devices. Die für die Umsetzung des Kalman-Filters wichtigsten in CUDA 5.0 enthaltenen Eigenschaften sind in der Tabelle 2 ersichtlich. 3.7 Verwaltung mehrerer Devices Beim Umgang mit mehreren Devices ist zu beachten, dass die Zuweisung eines Devices im Code (siehe Listing 21) nachfolgende Befehle entscheidend beeinflussen kann. // Host Code int deviceId = 0; C U D A _ E R R O R _ H A N D L E R ( cudaSetDevice ( deviceId ) ) ; Listing 21: Ein Device auswählen So ist es leicht durschaubar, dass eine Speicherallokation nach dem Setzen von Device 0 nur auf Device 0 durchgeführt wird und dementsprechend der zurückgegebene Zeiger nur auf dem Device gültig ist. Es gibt allerdings weitere Befehle, bei denen dieser Zusammenhang nicht so einfach ersichtlich ist. // Host Code cudaStream_t stream1 , stream2 ; C U D A _ E R R O R _ H A N D L E R ( cudaSetDevice (0) ) ; C U D A _ E R R O R _ H A N D L E R ( c u d a S t r e a m C r e a t e (& stream1 ) ) ; ... kernel < < <1 ,1 ,0 , stream1 > > >( paramA ) ; ... C U D A _ E R R O R _ H A N D L E R ( cudaSetDevice (1) ) ; C U D A _ E R R O R _ H A N D L E R ( c u d a S t r e a m C r e a t e (& stream2 ) ) ; ... kernel < < <1 ,1 ,0 , stream2 > > >( paramB ) ; ... C U D A _ E R R O R _ H A N D L E R ( cudaSetDevice (0) ) ; // NOT WORKING kernel < < <1 ,1 ,0 , stream2 > > >( paramA ) ; Listing 22: Streambindung an ein Device Im Beispiel aus Listing 22 sind offensichtlich 2 Devices im System verbaut und ansprechbar. Es werden zwei Streams angelegt und je Device der gleiche Kernel mit anderem Parameter ausgeführt. In der letzten Zeile ist ein fehlerhafter Aufruf dargestellt, welcher fehlschlagen wird. Wir sehen, dass paramA zwar auf dem Device 0 liegt und der Kernel offensichtlich auf Device 0 ausführbar ist, allerdings verwendet der letzte Aufruf den stream2 zur Ausführung, welcher erst nach der Auswahl des 29 Wird ECC unterstützt und ist aktiv? Anzahl der asynchronen Ausführungseinheiten Taktrate in kHz Hostthreadzugriffsmuster auf das Device Gleichzeitige Ausführung mehrerer Kernels? Ist die Laufzeit eines Kernels begrenzt? Compute Capability des Devices (Vor dem Komma) Maximale Dimensionsgröße eines Grids Maximale Dimensionsgröße eines Blocks Maximale Anzahl an Threads in einem Block int ECCEnabled int asyncEngineCount int clockRate int computeMode int concurrentKernels int kernelExecTimeoutEnabled int major int maxGridSize[3] int maxThreadsDim[3] int maxThreadsPerBlock 30 Maximale Taktrate des Speichers in kHz Compute Capability des Devices (Nach dem Komma) Anzahl der SMX-Einheiten ASCII String zur Identifizierung des Devices Verfügbare Größe des Shared Memory pro Block Größe des gesamten konstanten Speichers Größe des gesamten RAMs des Devices Größe eines Warps int memoryClockRate int minor int multiProcessorCount char name[256] size t sharedMemPerBlock size t totalConstMem size t totalGlobalMem int warpSize Tabelle 2: CUDA Deviceeigenschaften Breite der Speicheranbindung in Bit int memoryBusWidth int maxThreadsPerMultiProcessor Maximale gleichzeitig ausführbare Anzahl an Threads pro SMX Beschreibung Variable 3 CUDA Programmierung 3 CUDA Programmierung zweiten Devices angelegt wird. Dieser Stream hat damit seine Gültigkeit nur auf dem zweiten Device und kann dementsprechend nur dort verwendet werden. Dieses Verhalten kann unerwartet sein und muss dementsprechend besondere Beachtung bekommen. Zudem können Grafikkarten mit verschiedenen Zugriffsberechtigungen konfiguriert werden, welche den Zugriff anderer Prozesse oder mehrerer Threads einschränken können. Welchen Wert diese Eigenschaft für ein spezifisches Device hat, ist in den Deviceeigenschaften gespeichert und kann über Abfrage des Wertes des Computemodes ermittelt werden (siehe Kapitel 3.6). Die möglichen Werte dieser Eigenschaft sind folgende[25]: cudaComputeModeDefault Ein beliebiger Thread in einem beliebigen Prozess kann das Device benutzen. cudaComputeModeExclusive In diesem Modus kann nur ein einziger Thread in einem einzigen Prozess das Device benutzen. cudaComputeModeProhibited Hierbei wird dieses Device für alle Threads aller Prozesse geblockt und kann somit nicht genutzt werden. cudaComputeModeExclusiveProcess Hier können beliebig viele Threads eines einzigen Prozesses das Device benutzen. Sollte kein Device als aktives Device im Code ausgewählt werden, wird immer das Device mit der ID 0 angesprochen. 3.8 Grafikkartenspeicher allozieren und verwalten Damit in CUDA Speicher im Arbeitsspeicher der Grafikkarte reserviert wird, müssen ähnlich wie bei C/C++ mallocs durchgeführt werden. Anders als auf dem Hostsystem ist noch ein weiterer Parameter als nur die Größe des Speicherbereiches nötig, um Speicher zu reservieren. // Host Code int * vga_P ; size_T size = 1000* sizeof ( int ) ; C U D A _ E R R O R _ H A N D L E R ( cudaMalloc (& vga_P , size ) ) ; Listing 23: Speicher auf einem Device reservieren 31 3 CUDA Programmierung Im Codeabschnitt 23 ist die von der CUDA-Library zur Verfügung gestellte Funktion zur Speicherreservierung dargestellt. Diese Funktion gibt einen Fehlercode zurück und erwartet als Parameter die Adresse eines Zeigers, in dem im Anschluss an den Aufruf die Adresse des Speicherbereiches mit der angegebenen Größe auf der Grafikkarte gespeichert ist. Diese Art des Speichermanagements, mit echten Zeigern auf Speicherbereiche des Devices, hat im Vergleich zu einem einfacheren System bei OpenCL, welches mit einer Art Identifikationsnummer arbeitet, sowohl Vorteile als auch Nachteile. Der wohl größte Nachteil ist die Durchmischung von Zeigern auf der Hostseite. Während die Verwendung von Zeigern in C/C++ komplex werden kann, so wird dieses Problem durch hinzufügen von Devicezeigern weiter verschärft, da dem Programmierer zu jeder Zeit bewusst sein muss, ob ein Zeiger zu dem Host oder zu dem Device gehört. Weiter verschlimmert wird dieser Zustand bei der Verwendung mehrerer Devices, sodass zu der Unterscheidung Host oder Device noch jedes Device unterschieden werden muss. Auf der anderen Seite sind die bekannten Vorteile von Zeigern für die Devicezeiger gültig. Und dies sowohl auf Hostseite, als auch auf der Deviceseite. Einige dieser Vorteile werden im Kapitel 4 deutlich. // Host Code C U D A _ E R R O R _ H A N D L E R ( cudaFree ( vga_P ) ) ; Listing 24: Speicher auf einem Device freigeben Nach der Allokation und Verwendung eines Speicherbereichs ist es analog zu Hostspeicher notwendig, diesen wieder freizugeben, um Speicherlecks im Programm zu verindern. Hierfür stellt die CUDA-API die in Listing 24 dergestellte Funktion bereit, welche analog zum free() auf Hostseite funktioniert. Erwähnenswert ist, dass der Aufruf cudaFree(NULL); valide ist und somit keinen Fehler zurückgibt, während ein bereits freigegebener Zeiger bei erneuter Freigabe einen Fehler zurückgibt.[26] Es ist auf Hostseite nicht möglich einen Devicezeiger über einfache Zuweisungen mit Inhalt zu füllen. Ein Aufruf der Art vga P[0] = 1; würde auf Hostseite so interpretiert werden, als wenn der Zeiger auf einen Speicherbereich im Host zeigt, sodass hierbei diverse Speicherfehler auftreten können und das weitere Verhalten des Programms nicht voraussagbar ist. // Host Code int * vga_P , host_P [1000]; size_T size = 1000* sizeof ( int ) ; C U D A _ E R R O R _ H A N D L E R ( cudaMalloc (& vga_P , size ) ) ; 32 3 CUDA Programmierung // host_P f l l e n ... C U D A _ E R R O R _ H A N D L E R ( cudaMemcpy (( void *) vga_P , ( const void *) host_P , size , cudaMemcpyHostToDevice )); Listing 25: Daten zum Device kopieren Der in Listing 25 dargestellte Kopiervorgang macht deutlich, dass der Datentransfer nicht über den gewohnten Zugriff auf Indizes des Devicezeigers geschieht, sondern ähnlich zur aus C/C++ bekannten memcpy-Funktion ein auf dem Host liegender Speicher in einen auf einem Device liegenden Speicher kopiert wird. Dies wird über die cudaMemcpy-Funktion realisiert. Wie zu sehen ist, erwartet diese Funktion zunächst einen void-Pointer auf den Zielbereich. Anschließend muss ein const void Zeiger für den Quellbereich angegeben werden, gefolgt von der Größe in Bytes, welche übertragen werden soll. Der letzte Parameter bestimmt die Kopierrichtung. In diesem Fall gibt es folgende fünf Möglichkeiten. cudaMemcpyHostToHost Die Kopie wird von einem im Host liegenden Speicherbereich zu einem anderen, auf dem Host liegenden, Speicherbereich kopiert. Dies ist vor allem bei asynchroner Verarbeitung von Daten von Bedeutung, um den korrekten Ausführungszeitpunkt der Hostkopie zu gewährleisten. cudaMemcpyHostToDevice In diesem Fall werden die Daten vom Host auf das Device kopiert. cudaMemcpyDeviceToHost Hier werden die Daten vom Device zurück auf den Host transferiert. cudaMemcpyDeviceToDevice Hiermit kann auf einem Device eine Kopie eines Speicherbereiches erzeugt werden oder alternativ eine Kopie von Device A zu Device B gesendet werden. cudaMemcpyDefault Diese Funktion spielt nur bei der Verwendung eines unified adress space eine Rolle. unified adress space beschreibt einen gemeinsamen Adressraum für die CPU und GPU. Neben der synchronen Kopierfunktion existiert noch eine asynchrone Variante. Diese ist in Listing 26 dargestellt. 33 3 CUDA Programmierung // Host Code C U D A _ E R R O R _ H A N D L E R ( c ud aM em c py As yn c (( void *) vga_P , ( const void *) host_P , size , cudaMemcpyHostToDevice , stream1 ) ) ; Listing 26: Asynchrones Kopieren Erkennbar ist der nahezu identische Aufruf. Der Streamparameter ist dabei optional und kann, sofern dieser Aufruf keinem Stream zugeordnet werden soll, durch eine 0 ersetzt werden, sodass der Defaultstream des Devices genutzt wird. Neben diesen beiden Kopierfunktionen gibt es eine Reihe weiterer, welche allerdings in diesem Projekt keine Verwendung finden. Weitere Informationen sind in der CUDA Library in [11] zu finden. Zu denen aus C/C++ vergleichsweise bekannten Funktionen gibt es noch eine spezielle Funktion zur Allokation von Hostspeicher. Welche Vorteile diese Funktion gegenüber der normalen Allokation mittels malloc hat, wird erst deutlich, wenn ein Kopiervorgang von oder zum Device durchgeführt werden soll. Da die Devices in der Regel über den PCIe-Bus mit dem Hostsystem verbunden sind, müssen alle zu kopierenden Daten über diesen Bus laufen. Hierfür muss sichergestellt werden, dass die zu kopierenden Daten nicht auf die Festplatte ausgelagert werden können, um dem System direkten Zugriff auf den Speicher zu gewähren. Damit ist es notwendig die Daten zunächst in einen sogenannten non pageable Memory-Bereich zu kopieren. Dieser Bereich wird auch als Pinned Memory bezeichnet. Erst anschließend können die Daten über den PCIe-Bus zum Device kopiert werden. Dieser Kopiervorgang kostet auf Hostseite CPU-Zeit sowie Bandbreite des Arbeitsspeichers, sodass die CUDA API eine Alternative bietet. Das Problem besteht in Rückrichtung genauso, mit dem Unterschied, dass das Device keine Kopie anlegen muss, sondern das Host System zunächst in einen Pinned Memory Bereich schreiben muss und erst anschließend die Daten in den angegebenen Puffer kopiert werden. In Abbildung 13 sind zwei Kopiervorgänge dargestellt, welche den unterschiedlichen Ablauf der Kopievorgänge abbilden. // Host Code int * host_P ; size_t size = 1000* sizeof ( int ) ; C U D A _ E R R O R _ H A N D L E R ( cudaMall ocHost (( void **) & host_P , size ) ) ; Listing 27: Allokation von Pinned Memory Die in dem Listing 27 dargestellte Allokation von Hostspeicher über die CUDA-API ermöglicht es ohne diesen Umweg zu arbeiten, indem der allozierte Speicherbereich 34 3 CUDA Programmierung Abbildung 13: Vergleich der Kopiervorgänge selbst nicht mehr pagable ist. Dies erhöht die maximale Transferleistung des Hostsystems, da unnötige Kopien und implizite Allokationen von Pinned Memory durch die CUDA-API wegfallen. Die Verwendung von Pinned Memory hat allerdings unter Umständen gravierende Nachteile. Dadurch, dass dieser Speicherbereich nicht ausgelagert werden kann, wird der verfügbare Speicher für reguläre Allokationen verkleinert, sodass diese früher ausgelagert werden müssen und somit die Systemperformance verlangsamen können. Aus diesem Grund sollte Pinned Memory nur als Puffer zum Einsatz kommen und nicht zu exzessiv genutzt werden. Um diese Art Speicher wieder freizugeben bedarf es der Funktion aus Listing 28. // Host Code C U D A _ E R R O R _ H A N D L E R ( cudaFreeHost ( host_P ) ) ; Listing 28: Freigabe von Pinned Memory 3.9 Synchronisation von Threads Die Synchronisation von Threads ist erforderlich, um die von der CPU Seite bekannten Multithreadingprobleme zu verhindern. Dabei stellt die CUDA Bibliothek verschiedene Synchronisationsbefehle zur Verfügung, welche es erlauben, auf spezielle Befehle zu warten. Dies kann förderlich sein, falls die Art der Synchronisation sich nur auf einen lesenden oder schreibenden Zugriff bezieht. Innerhalb des Projektes wird die Funktion syncthreads() zur Synchronisation von Blöcken genutzt. Dieser Synchronisationstyp blockiert einen GPU-Kern, bis alle weiteren GPU-Kerne des Blocks an diesem Punkt angelangt sind und alle lesenden und schreibenden Zugriffe auf den Arbeitsspeicher abgeschlossen sind. 35 4 Implementierung 4 Implementierung 4.1 Detektordaten Die zur Verfügung gestellten Testdaten liegen im Format des Rootframeworks vor. Dieses, unter der LGPL-Lizens stehende, Framework wird zur Datenanalyse und -verarbeitung genutzt, da es auf die Verarbeitung großer Datenmengen spezialisiert ist. So ist es vergleichsweise einfach möglich, vorliegende Daten zu visualisieren oder miteinander zu kombinieren, ohne die Originaldaten zu verlieren. Außerdem steht ein C++-Interpreter zur Verfügung, welcher die Erstellung eigener Klassen und Verarbeitungsstrukturen zur Laufzeit ermöglicht. Die Datenstrukturen werden in einem sogenannten EventReader verarbeitet und in Ereignisse (Events) und dazugehörige Spuren (Tracks) zusammengeführt. Der EventReader wird innerhalb eines externen Projektes erstellt, sodass die vorgegebene Schnittstelle zum Auslesen von Testdaten genutzt wird. typedef std :: vector < Track_t > KF_Event_t ; Listing 29: Struktur eines Events Im Codeabschnitt 29 ist ein vom EventReader zurückgegebenes Event beschrieben. Da ein Event aus Tracks zusammengesetzt wird, wird das Event durch einen Vektor von Tracks beschrieben. typedef std :: vector < TrackHit_t > TrackData_t ; struct TrackStruct { TrackData_t track ; TrackInfo_t info ; TrackInfo_t truthTr ackInfo ; }; typedef TrackStruct Track_t ; Listing 30: Struktur eines Tracks Der Codeabschnitt 30 zeigt den Aufbau eines Tracks. Ein Track wird durch eine Struktur beschrieben, welche eine Liste der zugehörigen Hits (track ), Informationen über den Startpunkt der Flugbahn (info) und den echten Startpunkt aus der Simulation, falls die Daten aus einem Simulator stammen (truthTrackInfo) beinhaltet. struct Track HitStruc t { scalar_t normal [ ORDER ]; scalar_t ref [ ORDER ]; 36 4 Implementierung scalar_t err_locX ; scalar_t err_locY ; scalar_t cov_locXY ; scalar_t jacobi [ ORDER * ORDER ]; scalar_t jacobiInverse [ ORDER * ORDER ]; char is2Dim ; int detType ; int bec ; }; typedef struct Trac kHitStr uct TrackHit_t ; Listing 31: Struktur eines Hits Die TrackHitStruct-Struktur beinhaltet alle benötigten Parameter für einen Messpunkt einer Detektorlage. ORDER ist ein globales Define und wird durch die Zahl Fünf ersetzt. Dieses Define leitet sich aus den vorliegenden Daten ab und beschreibt die Ordnung der meisten quadratischen Matrizen. Der Typ scalar t kann über ein weiteres Define gesteuert werden und wird über eine Typdefinition zu einem Float oder Double. Dies erlaubt eine einfache Umschaltung der Genauigkeit, wobei auf Deviceseite der Typ gpu scalar t separat umgestellt werden kann, sodass die Genauigkeit der Berechnung und die Genauigkeit der weiteren Verarbeitung auf Hostseite getrennt voneinander konfigurierbar sind. In den Variablen normal und ref ist die Position des Treffers bzw. die schon korrigierte Position der Referenzspur gespeichert. Der erwartete Fehler wird in den drei darauf folgenden Variablen beschrieben, wobei nicht alle Berechnungen die Kovarianzmatrix oder den Fehler des Y-Wertes benötigen, da nicht immer ein zweidimensionaler Treffer vorliegt. Ob ein Treffer zweidimensional oder eindimensional behandelt werden muss, wird in der is2DimVariable gespeichert. Diese Information ist in der Grundversion noch nicht mit in dieser Struktur zusammengefasst und wird vom Host an gegebener Stelle selbst berechnet. Im weiteren Verlauf des Projektes wird diese Information vom EventReader selbst bestimmt und in dieser Struktur entsprechend gespeichert. Die Jakobimatrix übersetzt einen Treffer von einer Lage zur nächsten, sodass die Koordinaten aufeinander abgebildet werden. Die Inverse wird für den Rückweg des Kalman-Filters benötigt. Weiterhin wird die Art der Detektorlage in detType beschrieben und bec beschreibt, ob der Treffer zu den sogenannten barrel end caps gehört. 37 4 Implementierung 4.1.1 Kalman-Filter Initialisierung Die Startparameter des Kalman-Filters für die erste Messung lauten wie folgt: 0 250 0 0 0 0 0 0 0 0 250 0 pk−1|k−1 = (9) 0 0.25 0 0 0 , Ck−1|k−1 = 0 0 0 0.25 0 0 0 0 0 0 0 0 1E − 6 Die Wahl des Ck−1|k−1 -Parameters beschreibt einen großen anzunehmenden Fehler, da der vorherige Startwert pk−1|k−1 annimmt, es Bestünde keine Differenz zwischen Messpunkt und Referenzspur. 4.2 Projekteigenschaften Das im Rahmen der Masterarbeit umgesetzte Programm ist Teil eines CMakeProjektes. CMake wird genutzt, um den Bauprozess der Anwendung zu automatisieren und Abhängigkeiten des Projektes von anderen Projekten zu prüfen. Da das CUDA-Programm in Rahmen einer Kollaboration aus einem Masterprojektteam und einer weiteren Masterarbeit besteht, sind in dem CMake-Projekt Abhängigkeiten zwischen den einzelnen Subprojekten abgebildet und werden während der Kompilationsphase entsprechend behandelt. Das Masterprojektteam bestehend aus den Personen Philipp Schoppe und Matthias Töppe hat im Rahmen des Projektes eine Schnittstelle zu den vorliegenden Detektordaten definiert und implementiert. Parallel zur Arbeit mit CUDA wird außerdem im Rahmen einer weiteren Masterarbeit von Herrn Maik Dankel die hier vorliegende Aufgabenstellung mit der Programmiersprache OpenCL umgesetzt. In Abbildung 14 ist der grundlegende Vorgang zur Erstellung eines Kompilats abgebildet. Zunächst muss CMake mit dem Pfad zur ersten Konfigurationsdatei aufgerufen werden, in der der Projektname, Compileroptionen, sowie Ein- / Ausgabeverzeichnisse angegeben werden. Außerdem werden in der Konfiguration die benötigten externen Bibliotheken über entsprechende Befehle lokalisiert. Die verschiedenen Subprojekte können die lokalisierten Bibliotheken für einen erfolgreiches Kompilat voraussetzen, sodass eine fehlende Abhängigkeit durch eine Fehlermeldung angezeigt und der Bauprozess abgebrochen wird. Außerdem gibt es die Möglichkeit, 38 4 Implementierung Abbildung 14: Projekt Erstellungsablauf Subprojekte zu einer eigenen Bibliothek zu bauen und diese Bibliothek in den weiteren Subprogrammen zu verwenden. Die Baureihenfolge wird somit, wie in der Abbildung 14 dargestellt, automatisch angepasst, sodass die interne Bibliothek vor den einbindenden Programmen kompiliert wird. Neben der Verwendung von CMake und Make zur Kompilierung der Programme wird SVN als Versionierungssystem genutzt. 4.3 Funktionsimplementierung 4.3.1 Devicefunktionen Bei der Implementierung des Kalman-Filters werden zunächst die benötigten Informationen analysiert um einerseits die benötigten Daten zu bestimmen und andererseits die konkrete Implementierung zu beeinflussen. Im Abschnitt 4.1 sind die eingehenden Daten aus der EventReader-Schnittstelle definiert. Daraus lässt sich unter Anderem die Dimension der einzelnen Arrays aus dem Kalman-Filter ableiten, welche konkreten Einfluss auf die Implementierung haben. Zunächst wird eine geeignete Schnittstelle zum Device definiert, sodass eine sinnvolle Verarbeitung der Daten möglich ist. Im einfachsten Fall wird eine einzelne Spurrekonstruktion durchgeführt, sodass nur die Daten dieser Spur inklusive der Startparameter, etc. benötigt werden. Aus diesem Grund wird eine Struktur angelegt, welche diese Daten bereitstellt. struct k a l m a n F i l t e r P a r a m s { TrackHit_t * hits ; 39 4 Implementierung scalar_t * C_ k_ 1k _ 1_ gl ob a l ; bool * is2Dim ; int * hitCount ; trackHitData * fitsForward ; trackHitData * fitsBackward ; trackHitData * fitsResult ; scalar_t * C_Values ; scalar_t * C_Inverse ; }; typedef struct k a l m a n F i l t e r P a r a m s filterParam ; Listing 32: Spurrekonstruktionsdatenstruktur In der Struktur filterParam aus dem Codeabschnitt 32 werden alle nötigen Parameter übergeben. An erster Stelle wird ein Zeiger auf die zur Spur gehörenden Treffer übergeben. Die zweite Variable zeigt auf ein globales Initialisierungsarray für die Prädiktion der Fehlerkovarianzmatrix Ck|k−1 im ersten Schritt des Kalman-Filters. Da dieser Wert für alle Spuren identisch ist, wird dieses Array nur einmalig im Arbeitsspeicher des Devices abgelegt und mit den vorgegebenen Werten gefüllt. Alle C k 1k 1 global -Zeiger zeigen auf diesen einen Speicherbereich. Der nächste Zeiger verweist auf ein Array, in dem für jeden Treffer vermerkt ist, ob dieser Treffer als zweidimensionaler oder eindimensionaler Fall behandelt werden muss. Der hitCount verweist auf die Anzahl der Treffer dieser Spur und gibt damit die Länge aller anderen Arrays vor. Die Zeiger fitsForward, fitsBackward und fitsResult sind Zeiger auf anfangs nicht gefüllte Speicherbereiche in denen während der Berechnung das jeweilige Ergebnis des Kalman-Filters gespeichert wird. Analog dazu stehen in C Values und C Inverse die berechneten Fehlerkovarianzmatrizen. Da der Kalman-Filter oftmals mit Matrixmultiplikationen arbeitet, werden auf Grund der Datenlage 25 Threads pro Spurrekonstruktion verwendet. Dabei sind diese Threads in zwei Dimensionen aufgeteilt, sodass der Matrixindex eines Threads über die Breite einer Zeile multipliziert mit der Zeilenposition und der Addition der Spaltenposition bestimmt werden kann. Die hier beschriebene Version ist als Grundlage zu sehen, welche in den weiteren Kapiteln weiter optimiert und korrigiert wird. Außerdem ist die numerische Genauigkeit für einige Teilspuren nicht hoch genug und führt zu falschen Ergebnissen. Die zunächst stichprobenartig durchgeführte Plausibilitätsprüfung hat dieses Problem nicht aufgezeigt, da zufällig Spuren extern durch Wolfram Mathematica nachgerechnet und bestätigt wurden, welche nicht von diesem Problem betroffen waren. __global__ void doFiltering ( filterParam * param ) { 40 4 Implementierung int column = threadIdx . y ; int row = threadIdx . x ; int block = blockIdx . x ; ... } Listing 33: Matrixindizes und Funktionskopf Die Verwendung der Variablen row und column im Listing 33 hat zwei Gründe. Zum einen ist die Lesbarkeit im Code deutlich höher, wenn bei der Indizierung die entsprechenden Variablennamen genutzt werden können, was automatisch den Code wartbarer macht und Fehler von vornherein vermeidet. Der entscheidendere Grund ist allerdings die Datenlokalität dieser Variablen, denn diese liegen nun, sofern freie Register zur Verfügung stehen, in den Registern der einzelnen Threads. Da nach derzeitigem Stand ausreichend Register zur Verfügung stehen, benötigt die häufige Indexberechnung entsprechend wenig Zeit im Vergleich zu im Arbeitsspeicher liegenden Indizes. Das Argument der doFiltering-Funktion ist ein Zeiger auf ein ganzes Array von zu bearbeitenden Spurrekonstruktionsdaten. In Abbildung 15 ist Abbildung 15: Aufbau des Verarbeitungsgrids der Aufbau des Grids und der Blöcke abgebildet. Die durch die Datenlage gewählten 41 4 Implementierung Datenstrukturen führen zu dem abgebildeten Verarbeitungskonzept. Da die verschiedenen Spuren unabhängig voneinander berechnet werden können und eine Spur genau einem Ereignis zugehörig ist, werden alle Spuren eines Ereignisses zu einem Grid zusammengefasst. Damit kann ein komplettes Ereignis auf ein Device kopiert werden, wobei die Spuren unabhängig voneinander berechnet werden müssen, da keinerlei Synchronisation zwischen der einzelnen Blöcken möglich ist, was genau dem vorliegenden Problem entspricht. Jeder Block besteht aus 5x5 Threads, wobei jeder Thread genau ein Teilergebnis der verschiedenen mathematischen Operationen bestimmt. Es werden alle benötigten Variablen deklariert. Dabei wird die Anzahl der Treffer für die zu berechnende Spur in ein Register geladen. Anschließend wird sichergestellt, dass nur Ergebnisse von Threads berücksichtigt werden, wenn diese innerhalb der Ordnung der Daten von maximal 5x5 liegen. Dies stellt einen Branch dar, welcher innerhalb eines Warps zwar divergiert, da nur 25 Threads arbeiten dürfen, aber 32 Threads innerhalb eines Warps immer den gleichen Code ausführen. Allerdings lässt sich dies auf Grund der Problemstellung nicht verhindern, sodass pro Spur die Ergebnisse von sieben Threads verworfen werden müssen. Wegen dem fehlendem else-Zweig gibt es keinen weiteren Overhead in diesem Branch. Im Anschluss daran werden die vom Kalman-Filter zur Berechnung genutzten Variablen deklariert. Die Variablennamen entsprechen abgesehen von der Jakobimatrix der Terminologie aus den Grundlagen des Kalman-Filters in Kapitel 1.3. Die Jakobimatrix Fk wird in der Variablen jacobi zwischengespeichert. Dabei werden alle Variablen im Shared Memory angelegt, da diese ausreichend klein sind um gleichzeitig vorgehalten zu werden, ohne das gleichzeitig ausgeführte Blöcke blockieren müssen. Dies liegt an den Eigenschaften des verwendeten Devices, da in diesem Fall die maximale Anzahl von gleichzeitig ausführbaren Blöcken seitens der SMX blockiert. Wie dieses Limit verändert werden kann, ist im Kapitel 4.4.5 auf Seite 63 beschrieben. Im Anschluss werden die Startwerte aus Kapitel 4.1.1 in den lokalen Shared Memory Bereich geschrieben und die Berechnung in Vorwärtsrichtung wird gestartet. Da die Größe des Grids mit der Anzahl der Spuren in dem aktuellen Ereignis übereinstimmt, wird der Blockindex als Index auf den, dem Kernel übergebenen, Parameter verwendet. int c ur re n tH it In d ex ; for ( c ur r en tH i tI nd ex = 0; cu rr e nt Hi tI n de x < privHitCount ; cu rr en t Hi tI nd e x ++) { // currentHit = hits [ cu rr e nt Hi tI n de x ]; 42 4 Implementierung normalData [ row ] = param [ block ]. hits [ c ur r en tH it I nd ex ]. normal [ row ]; refData [ row ] = param [ block ]. hits [ c ur r en tH it I nd ex ]. ref [ row ]; err_locX = param [ block ]. hits [ c ur re nt H it In de x ]. err_locX ; err_locY = param [ block ]. hits [ c ur re nt H it In de x ]. err_locY ; cov_locXY = param [ block ]. hits [ c ur re nt H it In de x ]. cov_locXY ; jacobi [ row * ORDER + column ] = param [ block ]. hits [ cu r re nt Hi t In de x ]. jacobi [ row * ORDER + column ]; __syncthreads () ; ... Listing 34: Start der Kalman-Filterung Zu Beginn der iterativen Berechnung werden die Informationen des aktuellen Treffers in die im Codeabschnitt 34 angegebenen Variablen geschrieben. Die hier verwendete Grafikarchitektur liest blockweise Daten aus dem Speicher und speichert diese zunächst im Level 2 Cache, sodass die multiplen Lesevorgänge auf den Arbeitsspeicher gegebenenfalls reduziert werden könnnen und direkt aus dem Level 2 Cache gelesen werden kann. Um sicher zu stellen, dass jeder Thread alle Daten in den gemeinsamen Shared Memory geschrieben hat, wird zum Schluss eine Synchronisation der Threads durchgeführt. Da in der Regel mehr Blöcke abgearbeitet werden müssen, als gleichzeitig ausgeführt werden können, kann das wartende Warp theoretisch ausgelagert werden. Dies ist allerdings vom Compiler und der verwendeten Hardware abhängig. Anschließend wird die Prädiktionsphase des Kalman-Filters durchgeführt. __device__ __ fo r ce in li n e_ _ void doPrediction ( scalar_t * jacobi , scalar_t * p_k_1k_1 , scalar_t * p_kk_1 , scalar_t * C_k_1k_1 , scalar_t * C_kk_1 , int column , int row ) { // Predict state m ul ti pl y 5x 1c _A v ( jacobi , p_k_1k_1 , p_kk_1 , column , row ) ; // Predict Error Covariance pred ict_C_kk _1 ( jacobi , C_k_1k_1 , C_kk_1 , column , row ) ; } Listing 35: Prädiktionsphase des Kalman-Filters Die Prädiktionsphase wird in einer Devicefunktion implementiert. Um unnötige Kontextwechsel zu vermeiden, wird die Funktion über forceinline linefunktion in den Kernel eingebaut. Durch das Kommando immer als In- device wird dem CUDA-Compiler außerdem mitgeteilt, dass die Funktion doPrediction nur auf einem Device laufen darf. In der Prädiktionsphase muss mittels der Gleichung 1 der Wert von pk|k−1 bestimmt werden. Diese Matrix-Vektor-Multiplikation wird in ei- 43 4 Implementierung ner weiteren Inlinefunktion durchgeführt, welche die 5x5 Jakobimatrix mit dem 5x1 Vektor pk−1|k−1 multipliziert und im Zielvektor pk|k−1 speichert. __device__ __ fo r ce in li n e_ _ void mu l ti pl y5 x 1c _A v ( scalar_t *A , scalar_t *v , scalar_t *c , int column , int row ) { scalar_t result = 0; for ( int i = 0; i < ORDER ; i ++) { result += A [ row * ORDER + i ] * v [ i ]; } c [ row ] = result ; __syncthreads () ; } Listing 36: Matrix-Vektor-Multiplikation Die Implementation der multiply5x1c Av -Funktion aus dem Codeabschnitt 36 zeigt eine Besonderheit der GPU-Programmierung. Durch die hohe Anzahl an gleichzeitigen Threads können alle Teilergebnisse gleichzeitig bestimmt werden. Bei genauerem Hinsehen wird eine hohe Berechnungsredundanz deutlich. Durch den Verwendeten Blockaufbau von zwei Dimensionen mit jeweils der Größe Fünf, stehen immer 25 Threads zur Verfügung. Da das hier abgebildete Problem allerdings nur 5 Teilergebnisse hat und die benötigten Berechnungen dieser Teilergebnisse so gering sind, dass sich eine divide and conquer -Strategie zur Unterteilung der Berechnung in Teilergebnisse nicht lohnt, arbeiten jeweils 5 Prozessoren am gleichen Ergebnis. Dies liegt an der Tatsache, dass alle Threads bei dem gegebenen Code zunächst ein Ergebnisregister result auf Null setzen und anschließend die Summe aus den Produkten einer Spalte in einer bestimmten Zeile der Matrix mit der zugehörigen Zeile des Vektors bildet. Anschließend wird das Ergebnis in den Ergebnisvektor in die entsprechende Zeile geschrieben und alle Threads synchronisieren sich. Dieser Overhead an identischen Berechnungen lässt sich zu diesem Zeitpunkt nicht verhindern, da das Starten eines neuen Kernels mit den gegebenen Eingangsdaten und nur eindimensionalen Block der Größe Fünf diesen Overhead nicht verringern würde, da immer mindestens ein kompletter Warp den Code ausführt und damit auf allen bekannten NVIDIA-Grafikkarten mindestens 32 Threads diesen Code ausführen müssen. In Tabelle 3 sind weitere Funktionen angegeben, welche für die Implementation des Kalman-Filters benötigt werden. Dabei benutzen alle diese Funktionen ein Register als Ergebnisspeicher und lesen die Daten aus dem Shared Memory. Die Funktionen, die eine der Eingabevariablen überschreiben, arbeiten mit zwei Synchronisierungspunkten zur Eliminierung von Race-Conditions. 44 4 Implementierung Tabelle 3: Inlinefunktionen Matrix/Vektor-Multiplikation Funktionsname Funktion Ergebnis multiply5x1c Av c = Av 5x1 multiply5x1v Av v = Av 5x1 multiply5x5 C = AB 5x5 multiply5x5A AB A = AB 5x5 multiply5x5B AB B = AB 5x5 Im Anschluss an die Prädiktionsphase wird die Aktualisierungsphase durchgeführt. Für die Aktualisierung des prognostizierten pk|k−1 und der Fehlerkovarianzmatrix Ck|k−1 wird jeweils der Kalman-Gain benötigt. Die Berechnung des Kalman-Gains ist vom aktuellen Treffer abhängig, da je nach Dimension unterschiedliche Berechnungen durchgeführt werden müssen. Gleiches gilt für die Berechnung von pk|k und Ck|k . Da dieser Branch für alle Threads des Kernels identisch ist, wird in diesem Fall nur eine der beiden Berechnungen von dem ausführendem Warp durchgeführt. Kk = Ck|k−1 HTk (Vk + Hk Ck|k−1 HTk )−1 (10) In Gleichung 10 ist die allgemeine Berechnugnsformel für den Kalman-Gain angegeben. Die Berechnung hängt maßgeblich von der Dimension ab, da im Falle eines eindimensionalen Treffers Vk ein Skalar ist. Dieses Skalar ist das Quadrat der Variablen err locX und muss auf die transformierte Fehlerkovarianzmatrix addiert werden. Hk = 1 0 0 0 0 (11) Die Transformationsmatrix Hk aus Gleichung 11 gilt nur für den eindimensionalen Fall und transformiert die Fehlerkovarianzmatrix im Gleichungsabschnitt Hk Ck|k−1 HTk zu einem Skalar mit dem Wert c0,0 . Aus der Multiplikation der Fehlerkovarianzmatrix und der transponierten Transformationsmatrix folgt ein Vektor mit den Werten der ersten Spalte der Fehlerkovarianzmatrix. Diese Vereinfachungen führen zu Glei- 45 4 Implementierung chung 12. c0,0 c1,0 1 Kk = c2,0 err locX ∗ err locX + c0,0 c3,0 c4,0 (12) Diese optimierte Gleichung benötigt drei bzw. vier Operationen, abhängig davon, ob das Compilerflag zur Unterstützung der MADD-Funktion aktiv ist oder nicht und ob die Zielarchitetkur die MADD-Operation unterstützt. Die Implementation der Gleichung ist als Inlinefunktion realisiert und führt die in Listing 12 angegebenen Instruktionen aus. scalar_t inv = C_kk_1 [0] + err_locX [0] * err_locX [0]; inv = (( scalar_t ) 1.0) / inv ; gain_k [ row ] = inv * C_kk_1 [ row ]; // MUL + ADD or MADD // DIV // MUL Listing 37: Kalman-Gain Implementation 1D Im zweidimensionalem Fall ist die Berechnung des Kalman-Gains deutlich komplexer. Hk = 1 0 0 0 0 ! (13) 0 1 0 0 0 Gleichung 13 gibt die Transformationsmatrix für den zeidimensionalen Fall an. Es wird deutlich, dass die Transformation in eine 2x5 oder 2x2 Matrix überführt. ! err locX 2 cov locXY (14) Vk = cov locXY err locY 2 In Gleichung 14 ist das benötigte Vk dargestellt. Damit steht im zu invertierendem Teil des Kalman-Gains eine 2x2 Matrix. Die Invertierung dieser Matrix wird über die, sich aus der cramer’schen Regel ableitende, Invertierungsvorschrift durchgeführt, sodass die Inverse der Determinanten mit der Adjunkten der Matrix multipliziert werden muss. (Vk + Hk Ck|k−1 HTk )−1 = c0,0 + err locX 2 c0,1 + cov locXY c1,0 + cov locXY c1,1 + err locY 2 46 !−1 (15) 4 Implementierung Die Anwendung der Invertierungsvorschrift auf Gleichung 15 führt zu Gleichung 16. ! −c0,1 − cov locXY c1,1 + err locY 2 c0,0 + err locX 2 −c1,0 − cov locXY (c0,0 + err locX 2 ) ∗ (c1,1 + err locY 2 ) − (c0,1 + cov locXY ) ∗ (c1,0 + cov locXY ) (16) scalar_t scalar_t scalar_t scalar_t scalar_t inv11 *= inv01 *= inv10 *= inv00 *= inv00 inv01 inv10 inv11 det = det ; - det ; - det ; det ; = C_kk_1 [0] + ( err_locX [0] * err_locX [0]) ; = C_kk_1 [1] + cov_locXY [0]; = C_kk_1 [ ORDER ] + cov_locXY [0]; = C_kk_1 [ ORDER + 1] + ( err_locY [0] * err_locY [0]) ; 1.0 / ( inv00 * inv11 - inv01 * inv10 ) ; Listing 38: Kalman-Gain Implementation 2D-Invertierung Bei der Implementation der Invertierung werden zunächst die Werte der vier Matrixelemente berechnet. Die vier Einzelwerte werden in je einer Variablen gespeichert. Anschließend wird die Determinante berechnet und mit den Einzelwerten multipliziert. Damit ist die Invertierung abgeschlossen. Diese Berechnung hat den größten Overhead, da jeder Thread die gleichen Ergebnisse berechnet und speichert. In diesem Fall macht eine Unterscheidung in kleinere Abschnitte wieder keinen Sinn, da alle Threads des Warps jeden Branch berechnen würden und damit der Sinn der Verzweigungen abhanden kommt. c0,0 c1,0 Kk = c2,0 c3,0 c4,0 c0,1 ! c1,1 inv11 inv01 c2,1 inv10 inv00 c3,1 c4,1 In Gleichung 17 ist die vereinfachte Berechnung des Kalman-Gains angegeben. gain_k [ row * 2] = C_kk_1 [ row * ORDER ] * inv11 + C_kk_1 [ row * ORDER + 1] * inv10 ; gain_k [ row * 2 + 1] = C_kk_1 [ row * ORDER ] * inv01 + C_kk_1 [ row * ORDER + 1] * inv00 ; Listing 39: Kalman-Gain Implementation 2D 47 (17) 4 Implementierung Der im Listing 39 dargestellte Code berechnet und speichert die Ergebnisse von Gleichung 17. Hierbei werden die Indizes der verschiedenen Threads genutzt, um möglichst wenig Speicherzugriffe pro Thread durchzuführen. Die erste Spalte der Kalman-Gain-Ergebnismatrix setzt sich aus der Summe der ersten Spalte der Fehlerkovariantmatrix multipliziert mit dem Wert von inv11 und dem Wert der zweiten Spalte der Fehlerkovarianzmatrix multipliziert mit inv10 zusammen. Analog dazu setzt sich die zweite Spalte aus der Summe der Werte der ersten Spalte multipliziert mit inv01 und dem Wert der zweiten Spalte multipliziert mit inv00 zusammen. Da jeder Thread einer Zeile zugeordnet ist, muss jeder Thread die entsprechenden Werte der Zeile in die Ergebnismatrix speichern. Eine Unterscheidung zwischen den Threads nach Spalte kann den Overhead auf Grund der gemeinsamen Codeausführung eines Warps nicht verringern. Im Anschluss an die Kalkulation des Kalman-Gains müssen pk|k und Ck|k berechnet werden. In beiden Fällen sind dies die Eingabewerte für den nächsten Treffer, sodass diese Werte im Code in pk−1|k−1 beziehungsweise Ck−1|k−1 gespeichert werden. pk|k = pk|k−1 + Kk (mk − Hk pk|k−1 ) (18) Die Berechnung von pk|k wird durch Einsetzen der bekannten Dimension des Problems weiter aufgelöst. Hierbei wird der Kalman-Gain mit der Differenz aus den Skalaren mk0 und pk|k−1 0 multipliziert und auf pk|k−1 addiert. p_kk [ row ] = gain_k [ row ] * ( m_k [0] - p_kk_1 [0]) + p_kk_1 [ row ]; Listing 40: pk|k Implementation 1D Bei der Implementation aus Listing 40 kann die verwendete Blockarchitektur genutzt werden, um alle Ergebniszeilen gleichzeitig zu berechnen. Um bei der Implementation von Gleichung 18 im zweidimensionalen Fall auf die verschiedenen Matrix-Vektor-Multiplikationen zu verzichten, wird wieder eine Berechnungsvorschrift durch Einsetzen der bekannten Dimensionsgrößen hergeleitet. p0 ! ! p1 ! m0 1 0 0 0 0 m − p 0 0 p2 = MHP = mk − Hk pk|k−1 = − (19) m1 0 1 0 0 0 m1 − p1 p3 p4 48 4 Implementierung Die Differenz von mk und dem Produkt aus Hk und pk|k−1 ergibt den Vektor MHP aus Gleichung 19. pk|k p0 k0,0 p1 k1,0 = pk|k−1 + Kk MHP = p2 + k2,0 p3 k3,0 p4 k4,0 k0,1 ! k1,1 m 0 − p0 k2,1 m −p 1 1 k3,1 k4,1 (20) Durch Einsetzen in die Ursprungsgleichung ergibt sich Gleichung 20, aus der die Berechnungsvorschrift pk|k i = pk|k−1 i + (m0 − pk|k−1 0 ) ∗ ki,0 + (m1 − pk|k−1 1 ) ∗ ki,1 für jede Zeile i abgelesen werden kann. Diese Berechnungsvorschrift führt zum Code aus Listing 41. p_kk [ row ] = gain_k [ row * 2] * ( m_k [0] - p_kk_1 [0]) Die In i ti al i si er un g + gain_k [ row * 2 + 1] * ( m_k [1] - p_kk_1 [1]) + p_kk_1 [ row ]; Listing 41: pk|k Implementation 2D Anschließend wird die Fehlerkovarianzmatrix aktualisiert. Zunächst wird Gleichung 21 durch Einsetzen der bekannten Matrix- und Vektorgrößen vereinfacht. Ck|k = (I − Kk Hk )Ck|k−1 (21) Es wird zunächst eine Formel für den eindimensionalen Fall abgeleitet. Durch einsetzen der speziellen Matrizen und 1 0 0 1 (I − Kk Hk ) = 0 0 0 0 Vektoren wird Gleichung 22 gebildet. k0 0 0 0 0 0 0 k1 k2 1 0 0 0 0 − 1 0 0 0 1 0 k3 0 0 0 0 1 k4 (22) Durch Auflösen folgt Gleichung 23. 1 − k0 0 0 0 0 −k1 1 0 0 0 (I − Kk Hk ) = −k2 0 1 0 0 −k3 0 0 1 0 −k4 0 0 0 1 Die sich ergebene Formel wird in CUDA als weitere Funktion implementiert. 49 (23) 4 Implementierung C_kk [ row * ORDER + column ] = 0; __syncthreads () ; C_kk [ row * ORDER ] = gain_k [ row ]; __syncthreads () ; if ( row == column ) C_kk [ row * ORDER + column ] = 1 - C_kk [ row * ORDER + column ]; else C_kk [ row * ORDER + column ] = - C_kk [ row * ORDER + column ]; __syncthreads () ; m ul ti pl y 5x 5A _A B ( C_kk , C_kk_1 , column , row ) ; Listing 42: Ck|k Implementation 1D Die Implementation aus dem Codelisting 42 setzt zunächst den Klammerterm um, indem die Zielmatrix genullt und anschließend die erste Zeile mit den Werten des Kalman-Gains initialisiert wird. Die Subtraktion von der Einheitsmatrix erfolgt im Anschluss daran durch Invertieren des Vorzeichens der Matrix, falls der aktuelle Thread nicht auf der Hauptdiagonalen arbeitet. Falls der Thread das Ergebnis der Hauptdiagonalen bestimmt, wird anstatt der Invertierung des Vorzeichens der bisherige Inhalt von Eins subtrahiert. Damit ist Gleichung 23 implementiert und muss mit der prognostizierten Fehlerkovarianzmatrix multipliziert werden. Die Umsetzung der Berechnung eines zweidimensionalen Treffers erfolgt analog, sodass zunächst die speziellen 1 0 0 1 (I − Kk Hk ) = 0 0 0 0 Matrizen in die Gleichung eingesetzt werden. k0,0 k0,1 0 0 0 ! 0 0 0 k1,0 k1,1 1 0 0 0 0 1 0 0 − k2,0 k2,1 0 1 0 0 0 0 1 0 k3,0 k3,1 0 0 0 0 1 (24) k4,0 k4,1 Durch Auflösen resultiert daraus Gleichung 25. 1 − k0,0 −k0,1 −k1,0 1 − k1,1 (I − Kk Hk ) = −k2,1 −k2,0 −k3,1 −k3,0 −k4,0 −k4,1 0 0 0 0 0 0 1 0 0 0 1 0 0 0 1 (25) Die Implementation dieser Gleichung in der Kernelfunktion funktioniert analog zur vorherigen Implementation des eindimensionalen Updates der Fehlerkovarianzmatrix. Der Unterschied zum eindimensionalen Fall ist lediglich eine weitere, mit Werten des Kalman-Gains gefüllte, Spalte. Aus diesem Grund muss nur die Codezeile 50 4 Implementierung C kk[row * ORDER] = gain k[row]; ersetzt werden. Anstatt nur die erste Zeile zu kopieren, muss zudem die zweite Zeile kopiert werden. Es kann in diesem Fall auf eine zweite Codezeile zum Kopieren verzichtet werden, da die Spaltenanzahl einer Zweierpotenz entspricht. Dies ermöglicht eine erweiterte Indexberechnung über den logischen UND-Operator aus der boolschen Algebra. Es muss weiterhin aus jeder Zeile kopiert werden, sodass jeder Thread um seinen Zeilenindex verschoben auf die Matrizen zugreifen muss. Weiterhin müssen die Threads Spalte Null und Spalte Eins kopieren. Diese Verschiebung des Indizes um Eins wird durch den UND-Operator auf den Spaltenindex jedes Threads angewendet, sodass das Ergebnis dieser Operation entweder Null oder Eins ergibt. Daraus folgt der Codeabschnitt 43. Die restlichen Operationen werden aus dem eindimensionalem Fall übernommen. C_kk [ row * ORDER + ( column & 1) ] = gain_k [ row * 2 + ( column & 1) ]; Listing 43: Ck|k Implementation 2D Anpassung Damit sind die benötigten Berechnungen für den aktuellen Treffer abgeschlossen. Die Ergebnisse dieses Treffers werden im Hauptspeicher des Devices gespeichert, da diese für den Smoothing-Vorgang benötigt werden. param [ block ]. fitsForward [ cu rr en t Hi tI nd e x ]. data [ row ] = p_k_1k_1 [ row ]; c u r r e n t C V a l u e P o i n t e r [ ORDER * row + column ] = C_k_1k_1 [ ORDER * row + column ]; c u r r e n t C V a l u e P o i n t e r = c u r r e n t C V a l u e P o i n t e r + ORDER * ORDER ; Listing 44: Speichern der Updateergebnisse aus dem Hinweg In Listing 44 ist der Speichervorgang dargestellt. Gespeichert werden pk|k und Ck|k und es wird, falls ein weiterer Treffer in der aktuell bearbeiteten Spur vorhanden ist, mit der Berechnung des Hinwegs fortgefahren. Andernfalls wird mit der Berechnung des Rückwegs begonnen. Bevor die Kalkulation des Rückwegs gestartet wird, wird die globale Fehlerkovarianzmatrix aus dem übergebenen Spurparameter in die lokale Fehlerkovarianzmatrixvariable geschrieben. Anders als beim Hinweg wird pk−1|k−1 nicht mit einem Nullvektor vorinitialisiert, sondern es wird das Ergebnis des Hinwegs als Startwert verwendet. Die Berechnung des Rückwegs ist, bis auf wenige Ausnahmen, identisch zur Berechnung des Hinwegs. Unterschiedlich ist der Startindex, welcher auf dem letzten vorhandenen Treffer gesetzt wird, sowie die Laufrichtung, welche den Startwert dekrementiert und entstrechend der Vorgabe rückwärts durch die Spur läuft. Außerdem wird beim Laden der Trefferwerte nicht die Jakobimatrix des Treffers in die lokale 51 4 Implementierung Jakobivariable geschrieben, sondern die Inverse dieser Jakobimatrix wird verwendet. Eine Ausnahme bildet hier die Bearbeitung des ersten Treffers, da in diesem Fall die reguläre Jakobimatrix genutzt werden muss. Die zu speichernden Ergebnisse für den Smoothing-Teil des Filters werden beim Rückweg analog zum Hinweg in den Hauptspeicher geschrieben. Hierbei müssen nicht die Updatewerte gespeichert werden, sondern die prognostizierten Werte, da sonst die Messung selbst doppelt gewichtet wird. Die für das Smoothing benötigte Summe der beiden Fehlerkovarianzmatrizen wird in der Filterstruktur in der Variable C Inverse gespeichert und vom Host invertiert. Weitere Informationen zur Invertierung sind im Kapitel 4.3.2 zu finden. Das erfolgreiche Berechnen des Rückwegs ermöglicht das Fortfahren mit der Ausführung des Smoothings. Das Smoothing selbst ist als weitere Kernelfunktion implementiert, welche vom Host aufgerufen wird und erwartet, identisch zum Kernel der Hin-/Rückweg-Berechnung des Kalman-Filters, ein Array aus den einzelnen Spurinformationen eines Events als Parameter. Das Smoothing verwnedet außerdem einen identischen Aufbau des Grids und der Blöcke, sodass 5x5 Threads pro Block aktiv sind und jede Spur genau von einem Block abgearbeitet wird. for ( int hit = 0; hit < privHitCount ; hit ++) { int pTranslation = hit * ORDER * ORDER ; // K = C_f ( C_f + C_b ) ^ -1 multiply5x5 ( param [ block ]. C_Values + pTranslation , param [ block ]. C_Inverse + pTranslation , K , column , row ) ; __syncthreads () ; // p ’ = Kp ^ b m ul ti pl y 5x 1c _A v (K , param [ block ]. fitsBackward [ hit ]. data , PRes , column , row ) ; __syncthreads () ; // p ’ = kp ^ b + p ^ f PRes [ row ] += param [ block ]. fitsForward [ hit ]. data [ row ]; // C ’ = I - K C_S [ row * ORDER + column ] = I [ row * ORDER + column ] - K [ row * ORDER + column ]; __syncthreads () ; // C ’ = ( I - K ) C ^ f m ul ti pl y 5x 5A _A B ( C_S , param [ block ]. C_Values + pTranslation , column , row ) ; // Write back everything param [ block ]. C_Inverse [ pTranslation + row * ORDER + column ] = C_S [ row * ORDER + column ]; param [ block ]. fitsResult [ hit ]. data [ row ] = PRes [ row ]; } 52 4 Implementierung Listing 45: Implementation Smoothing Der Codeabschnitt 45 zeigt den relevanten Teil des Smoothings. Es werden alle Berechnungen für jeden Treffer durchgeführt, sodass die Messung jedes Treffers aktualisiert werden kann. Zunächst wird hierzu K’k durch die Multiplikation der Fehlerkovarianzmatrix aus dem Hinweg, sowie der Inversen der Summe der Fehlerkovarianzmatrizen aus Hin- und Rückweg bestimmt. Im Anschluss daran kann p’k berechnet werden. Dazu wird zuerst K’k pb berechnet, wobei b analog zur Schreibweise aus den Kapitel 1.3 keinen Exponenten darstellt, sondern die Richtung aus welcher das p stammt. Es gilt ebenso, dass b der Rückrichtung und f der Vorwärtsrichtung entsprechen. Anschließend wird das Ergebnis PRes gebildet, indem pf addiert wird. Danach wird die Fehlerkovarianzmatrix C’k bestimmt und gemeinsam mit p’k im Hauptspeicher des Devices gespeichert. Damit ist das Smoothing abgeschlossen und die Ergebnisse können vom Host gelesen und verarbeitet werden. 4.3.2 Hostfunktion Der Host einer CUDA-Applikation versorgt die einzelnen Devices mit Arbeit und übernimmt dabei größtenteils die Verwaltung. Im Verlauf der Masterarbeit wird deutlich, dass einige der benötigten Funktionen auf dem Host deutlich performanter umsetzbar sind, sodass einige der Berechnungen auf die Hostseite ausgelagert werden. Sowohl die Auslagerung der Berechnungen, als auch die Verwaltung wird erläutert. Der Algorithmus ist in einer C++-Klasse gekapselt und muss zur Verwendung entsprechend instaziiert werden. In der hier erläuterten Grundversion benötigt die Klasse keine weiteren Parameter zum Ausführen, da alle Informationen fest im Code vermerkt sind. Im Verlauf der Optimierungen wird die starre Implementation durch eine parametrierbare Implementation ersetzt. Dieser Abschnitt erläutert die Grundversion, die anschließend durch einzelne Optimierungsschritte angepasst wird. Initialize EventReader ; Read events EventReader ; FOR event IN EventReader DO preprocessing event ; Copy to Device ; Run Kalman - Filter on Device ; Read Results ; Invert matrices ; Copy inverted matrices onto Device ; Run Kalman - Filter - Smoothing on Device ; Read final results ; 53 4 Implementierung Process final results ; END FOR ; Listing 46: Pseudocode Hostfunktion Der Pseudocode aus Listing 46 gibt den Ablauf des Hostprogrammes wieder. Zuerst wird der vom Masterprojekt-Team entwickelte EventReader instanziiert und initialisiert. Die Initialisierung liest dabei Daten aus einem angegebenen Root-File ein und liest darin gespeicherte Ereignisse und Spuren aus. Diese werden in den im Kapitel 4.1 beschriebenen Strukturen gespeichert und können über eine dafür vorgesehene Funktion abgerufen werden. Neben dem Erzeugen des EventReaders und der anschließenden Initialisierung wird eine Fehlerkovarianzmatrix angelegt, welche die Standardparameter für den Kalman-Filter Hin-/Rückweg enthält. Diese Fehlerkovarianzmatrix wird anschließend auf das Standarddevice kopiert und der Devicezeiger wird für den späteren Gebrauch gespeichert. Es wird mit der Abarbeitung der vorhandenen Datensätze begonnen. Dies wird mittels einer FOR-Schleife über die vorhandenen Ereignisse realisiert. Innerhalb der FOR-Schleife wird die eigentliche Verarbeitung der Daten vorgenommen. Dabei werden als Erstes die Daten des Ereignisses verarbeitet, um diese in ein für die Grafikkarte passendes Format zu bringen, da die Daten als C++ Standardvektoren vorliegen. Da das Kopieren von Daten auf das Device für kleine Transfers vergleichsweise langsam ist, werden die Daten zu Blöcken zusammengefasst, sodass die Datenmenge pro Transfer vergrößert wird. hitCount = reader . getTrack (i , j ) . track . size () ; TrackHit_t hits [ hitCount ]; bool is2Dim [ hitCount ]; for ( int k = 0; k < hitCount ; k ++) { hits [ k ] = reader . getTrack (i , j ) . track . at ( k ) ; // Set if 2 Dim or not if ( abs ( hits [ k ]. normal [1] + 99999.) > 1. E -4) { is2Dim [ k ] = true ; } else { is2Dim [ k ] = false ; } } Listing 47: Spurdaten auslesen und verarbeiten Im Codelisting 47 ist das Kopieren der Vektordaten in lokale Arrays dargestellt. Das Kopieren wird pro Spur bzw. Track vorgenommen und bestimmt die Anzahl der Treffer in dieser Spur, reserviert Speicher für entsprechend viele Treffer und 54 4 Implementierung Dimensionsdaten auf dem Hoststack und füllt diese Arrays anschließend mit den Daten der Spur. Ob der vorliegende Treffer zweidimensional ist, wird über einen Zahlenwert gekennzeichnet. Alle Treffer, deren locY-Wert -99999 entspricht, sind eindimensionale Treffer. Damit sind die benötigten Spurdaten aus dem EventReader extrahiert und können auf das Device kopiert werden. TrackHit_t * vga_hits ; C U D A _ E R R O R _ H A N D L E R ( cudaMalloc (( void **) & vga_hits , sizeof ( hits ) ) ) ; CUDA_ERROR_HANDLER ( cudaMemcpy ( vga_hits , hits , sizeof ( hits ) , cudaMemcpyHostToDevice )); Listing 48: Allokation und Kopieren von Daten In Listing 48 ist beispielhaft eine Allokation und das Starten des Kopiervorgangs angegeben. Alle anderen von dem Device benötigten Werte werden auf ähnliche Art und Weise alloziert und gegebenenfalls kopiert. Zu sehen ist außerdem, dass auf dem Host selbst der Zeiger auf den Hauptspeicher des Devices gespeichert wird. Um Zeiger des Hostsystems und der Devices nicht zu vertauschen, beginnen alle auf ein Device zeigende Zeiger mit vga . Es besteht zudem die Möglichkeit Pointerarithmetik hostseitig auf die Zeiger der Devices anzuwenden. Dies wird in der Hostfunktion beispielsweise verwendet, um die Allokationsanzahl der Ergebnisspeicher zu reduzieren, indem ein einzelner, genügend großer Speicherbereich auf dem Device reserviert wird und über Zeigerarithmetik die Zeiger der Kalman-Filter Struktur kalmanFilterParams berechnet werden. Die verschiedenen Zeiger werden in der Parameterstruktur entsprechend gesetzt. Die Filterstruktur selbst ist dabei ein Array mit der Länge der im Eriegnis vorhandenen Spuren. Unter der Voraussetzung, dass alle Teilstrukturen mit den Zeigern auf die Spurinformationen gefüllt sind, werden diese auf das Device kopiert, sodass der Kernel für die Berechnung des Hin-/Rückwegs des Kalman-Filters gestartet werden kann. dim3 threads ( ORDER , ORDER ) ; doFiltering < < < trackCount , threads > > >( vga_filterP ) ; Listing 49: Starten des Kalman-Filter Kernels Codeabschnitt 49 zeigt den Aufruf des Filterkernels. Durch diesen Aufruf wird das Ausführungsgrid mit trackCount Blöcken gestartet, wobei jeder Block die benötigten 5x5 Threads zur Verarbeitung hat. Damit wird das Device mit der Berechnung starten und der Host muss auf die komplette Ausführung des Kernels warten. Wenn der Kernel die Berechnungen erfolgreich beendet hat, muss die für das Smoothing 55 4 Implementierung benötigte invertierte Matrix von Cf + Cb berechnet werden. Aus diesem Grund werden die von dem Device berechneten Matrixsummen aus den C Inverse Zeigern auf den Host zurückkopiert und anschließend invertiert. Das Invertieren der Matrizen ist durch Verwendung der GSL-Bibliothek in dem EventReader als Funktion implementiert und arbeitet auf Basis der Singular Value Decomposition (SVD) um die Pseudoinverse der Matrix zu bilden. Diese Pseudoinversen werden anschließend auf das Device zurückkopiert. Die Kopie überschreibt dabei die nicht weiter benötigten Summen der Fehlerkovarianzmatrizen. Der Smoothingkernel wird mit den gleichen Eigenschaften und Paramtern des Kalman-Filter Hin-Rückwegkernels gestartet und blockiert die weitere Ausführung des Hostcodes bis der Kernel die Berechnungen abgeschlossen hat. Im Anschluss an alle benötigten Berechnungen werden die Resultate des Smoothing und die Zwischenergebnisse des Hin-/Rückwegs ausgelesen und können zur weiteren Verarbeitung benutzt werden. Da zum aktuellen Zeitpunkt keine weitere Verwendung der Daten ansteht, werden diese auf dem Device und dem Host wieder freigegeben und die Berechnung des Filters wird für das nächste Ereignis durchgeführt. Sobald alle Ereignisse abgearbeitet sind, wird die global genutzte Initialisierungsfehlerkovarianzmatrix freigegeben und die Hostfunktion wird verlassen. 4.4 Optimierungsschritte Die hier vorgestellten Schritte zur Optimierung der Performance des Kalman-Filters basieren auf Analysen, die mit Hilfe des Visual Profilers von NVIDIA durchgeführt werden. Da der Profiler eine nur geringe Auslastung der GPU und hohe Auslastung des Hostsystems offenlegte, werden zunächst Optimierungen auf der Hostseite durchgeführt, um maximale Performancesteigerungen zu erzielen. Die Performancesteigerungen der unterschiedlichen Optimierungsstufen sind im Kapitel 5.1 näher erläutert. 4.4.1 Datenstrukturen In Abbildung 16 ist das Ausführungsprofil der bisher erläuterten Basisapplikation abgebildet. Es ist ersichtlich, dass der EventReader die ersten 5 Sekunden der Ausführung in Anspruch nimmt und erst ab diesem Zeitpunkt die Verarbeitung der Daten beginnt. Die gelben Balken repräsentieren einen Kopiervorgang, während die 56 4 Implementierung Abbildung 16: Ausgabe des Visual Profilers blauen Balken eine Kernelausführung anzeigen. Welcher Kernel ausgeführt wird ist darunter zu sehen. Wichtig an dieser Grafik ist die im vorherigen Absatz angesprochene Auslastung des Hostsystems bzw. Dauer der Kopiervorgänge. Da der aktuelle Zustand ab Starten der Berechnungen zu ca. 95 % aus Kopiervorgängen und Warten auf das Hostsystem besteht, wird versucht die bisher erzielte Kopierleistung zu erhöhen. Der Profiler gibt an, dass bei ca. 67 % der Kopiervorgänge nur eine durchschnittliche Transferrate von 1,1 GB/s erreicht wird, welche weit von den erreichbaren Datenraten von PCIe2 (8 GB/s) oder PCIe3 (16 GB/s) entfernt liegt. Die in Abbildung 8 dargestellten Transferraten zeigen deutlich, dass eine Vergrößerung der simultan zu übertragenen Daten automatisch zu einer deutlich gesteigerten Kopiereffizienz führen. Zu diesem Zweck wird der Hostcode zunächst auf größeren Datendurchsatz hin optimiert. Momentan werden die benötigten Informationen pro Spur auf das Device übertragen. Dies führt dazu, dass die einzelnen Kopiervorgänge aus relativ kleinen Datensätzen bestehen. Falls das Programm mit doppelter Genauigkeit für den Datentypen scalar t arbeitet, so hat die TrackHit t Datenstruktur mit den Trefferinformationen, ohne die bisher noch nicht in dieser Struktur gespeicherten is2Dim-Variable, auf einem 64-Bit System eine Größe von 520 Byte. Neben den Treffern wird noch die Tracklänge übertragen, welche auf einem 64-Bit System 8 Byte groß ist. Außerdem wird ein Array is2Dim mit der Trefferdimension übertragen, welches pro Treffer eine Größe von einem Byte hat. In dem verwendeten Testdatensatz gibt es 19656 Spuren in 96 Ereignissen, welche durchschnittlich 32,69 Treffer beinhalten. Die minimale Spurlänge liegt bei fünf Treffern, die maximale bei 101 Treffern. Tabelle 4 macht deutlich, warum die bisher übertragenen Datenmengen nicht ausreichen um die möglichen Übertragungskapazitäten auszunutzen. Dadurch, dass die Verarbeitung pro Ereignis gestartet wird und die schon verwendeten Strukturen mit 57 4 Implementierung Tabelle 4: Zu übertragende Datenmengen pro Spur Variable Minimum Durchschnitt Maximum trackHit t 2600 Bytes 16999 Bytes 52520 Bytes is2Dim 5 Bytes 33 Bytes 101 Bytes Tracklänge 8 Bytes 8 Bytes 8 Bytes Zeigern arbeiten, werden alle Treffer, Dimensionsdaten und die jeweilige Länge pro Ereignis zusammengefasst. Es werden nicht die Datenstrukturen des Kalman-Filters oder des EventReaders angepasst, sondern die Struktur der Daten im Arbeitsspeicher. ... FOR event IN EventReader DO t o t a l T r a c k L e ng t h = 0; t r a c k L e n g t h A rr a y [ totalTracks ]; FOR track j IN event DO t o t a l T r a c k L e n gt h += j . trackLength ; t r a c k L e n g t h A r ra y [ j ] = j . trackLength ; END FOR ; allHits [ t o t a l T r a c kL e n g t h ]; allIs2Dim [ t o t a l T r a c k L e n g t h ]; c ur re nt H it In d ex = 0; FOR track j IN event DO FOR Hit k IN track DO allHits [ c ur r en tH it I nd ex + k ] = j . hits [ k ]; allIs2Dim [ c ur re n tH it In d ex + k ] = is2DIM ( j . hits [ k ]) ; c ur re nt H it In de x += j . trackLength ; END FOR ; END FOR ; ... END FOR ; Listing 50: Pseudocode Anpassung der Datenstrukturen Im Codelisting 50 sind die benötigten Änderungen an der Datenvorverarbeitung dargestellt. Zunächst muss die Gesamtanzahl der Treffer im aktuellen Ereignis bestimmt werden. Dies wird über eine einfache FOR-Schleife realisiert, welche die Gesamtlänge bestimmt und in einem Array die Länge jeder Spur speichert. Im Anschluss daran werden zwei weitere Arrays für die Treffer und die Dimension des jeweiligen Treffers auf dem Stack angelegt und über die darauf folgende FOR-Schleife mit den Daten gefüllt. Danach werden die drei Arrays auf das Device kopiert. Ausgehend von der durchschnittlichen Spurlänge ergibt sich Tabelle 5 aus den 96 Ereignissen mit mini- 58 4 Implementierung Tabelle 5: Zu übertragende Datenmengen pro Event Variable Minimum Durchschnitt Maximum trackHit t 1411 KBytes 3399 KBytes 6607 KBytes is2Dim 2,7 KBytes 6,6 KBytes 12,8 KBytes Tracklänge 0,7 KBytes 1,6 KBytes 3,1 KBytes mal 85, durchschnittlich 204,75 und maximal 398 Spuren pro Event. Damit kann die effektive Bandbreite des PCIe-Busses deutlich gesteigert werden und es fallen je nach Ereignis bis zu 1991 weniger Allokationen und Transfers der einzelnen Spurinformationen auf das Device an. Neben diesen drei Variablen werden die Ergebnisspeicher in den Kalman-Filter-Parametern durch ein einziges großes Array ersetzt, sodass entsprechend viele Allokationen wegfallen. Als Nebeneffekt wird für jede eingesparte Allokation ein dazugehöriges Free auf den Devicespeicher eingespart, sodass die Anzahl der CUDA-Library-Aufrufe pro Event um mehrere tausend reduziert wird. Die Kalman-Filter Parameter werden nicht mehr direkt auf die Zeiger der Arrays im Devicespeicher gesetzt, sondern müssen über Zeigerarithmetik ausgerechnet und anschließend gesetzt werden. Dieser so entstehende Overhead spielt im Vergleich zu den eingesparten Aufrufen und Transfers aber keine Rolle. Dies wird an dem Geschwindigkeitszuwachs (siehe Kapitel 5.1) deutlich. 4.4.2 Blobdaten und Pinned Memory Die im vorherigen Kapitel beschriebenen Änderungen an der Datenstruktur im Speicher lassen den Schluss zu, die jetzt auf wenige Arrays aufgeteilten Daten noch weiter zusammenzufassen, um weitere Funktionsaufrufe zu vermeiden und die effektive Datenrate für die vergleichsweise kleinen Arrays der Dimension is2Dim und der Spurlänge allHitCount noch zu steigern. Um dies zu erreichen wird anstatt der Bestimmung der Größe einzelner Arrays die Gesamtgröße berechnet und ein void-Feld passender Größe im Hostspeicher alloziert. Anschließend werden Zeiger berechnet, die auf die Stellen des Blobfeldes zeigen, an dem die einzelnen Arrays beginnen. Sobald diese Zeiger berechnet sind, können diese wie bei den vorherigen Arrays mit Daten gefüllt werden. Auf dem Device wird ein Speicherbereich identischer Größe angelegt und es findet nur ein einziger Kopiervorgang statt. Selbiges wird für die Ergebnisspeicherbereiche durchgeführt, sodass die gefitteten Ergebnisse sowie die 59 4 Implementierung Fehlerkovarianzmatrizen in einem großen Datenfeld im Devicespeicher liegen. size_t c u r r e n t P a g e L o c k e d M e m o r y S i z e = 1024 * 1024 * 10; void * global DataBlob ; // = malloc ( blobSize ) ; CUDA_ERROR_HANDLER ( cuda MallocHo st (& globalDataBlob , currentPageLockedMemorySize , 0) ) ; Listing 51: Datenblob als Pinned Memory Die Blobdaten werden in einem weiteren Schritt nicht mehr über den malloc-Befehl alloziert, sondern über die von der CUDA-Bibliothek zur Verfügung gestellte cudaMallocHost-Funktion. Damit werden die Daten in einen nicht auslagerbaren Speicherbereich angelegt, sodass Kopieroperationen direkt ausgeführt werden können und nicht zunächst eine nicht auslagerbare Kopie angelegt werden muss. Die Speicherallokation ist in Listing 51 abgebildet. Um möglichst wenige Allokationen durchführen zu müssen wird der angeforderte Speicherbereich nur dann freigegeben, wenn die Informationen des nachfolgenden Ereignisses größer sind und damit nicht mehr in dieses Datenfeld passen. Aus diesem Grund wird die aktuelle Größe des Datenfeldes in der Variable currentPageLockedMemorySize gespeichert. Während der Vorverarbeitung wird die benötigte Größe des aktuellen Ereignisses bestimmt und gegebenenfalls ein größeres Datenfeld angefordert, wobei der vorherige Speicherbereich freigegeben wird. 4.4.3 CUDA Streams Nach der Eliminierung unnötiger Kopiervorgänge und Allokationen ergab eine erneute Analyse des Programms durch den Visual Profiler ein weiteres Problem. Bisher gibt es keine Überlappung zwischen den Kopiervorgängen und der Ausführung der Kernel. Um eine Überlappung zu erreichen werden Streams eingesetzt. Da die Verwendung von Streams nur unter Einhaltung einer korrekten Abarbeitungskette zum parallelen Ausführen und Kopieren führt (siehe Kapitel 2.5), werden deutliche Änderung am Programmablauf nötig, da die Ereignisse nicht mehr rein sequenziell abgearbeitet werden können, um den gewünschten Performancegewinn durch Streams zu erreichen. FOR stream IN TotalStreams DO Initialize Streams ; END FOR ; eventsLeft = true ; WHILE a n y S t r e a m W o r k i n g DO 60 4 Implementierung FOR stream IN TotalStreams DO IF workLevel == 0 DO Preprocessing event ; Issue asynchronous Copy to Device ; Issue asynchronous Kalman - Filter on Device ; Issue asynchronous Read Results ; workLevel = 1; ELSE IF workLevel == 1 DO Wait for previous task of stream ; Invert matrices ; Issue asynchronous Copy inverted matrices to Device ; Issue asynchronous Kalman - Filter - Smoothing on Device ; Issue asynchronous read of final results ; ELSE IF workLevel == 2 DO Wait for previous task of stream ; Process final results ; END IF ; END FOR END WHILE ; Listing 52: Pseudocode streambasiertes Filtern Im Codeabschnitt 52 ist die angepasste Programmstuktur angegeben. Die Verarbeitung erfolgt nur noch indirekt Ereignisweise, da die While-Schleife solange durchlaufen wird, bis kein Stream mehr ein Ereignis durchläuft. Diese Herangehensweise erlaubt es, weniger Ereignisse zu verarbeiten, als Streams vorhanden sind, sodass eine maximale Flexibilität erhalten bleibt. Die Implementation dieser Programmstruktur wird mit Hilfe einer Stream-Datenstruktur umgesetzt, welche in der Initialisierungsphase in einem Stream-Array Verwendung findet. Neben dem Stream werden in dieser Struktur außerdem noch das aktuell bearbeitete Ereignis sowie ein Zeiger auf ein Blobdatenfeld und dessen aktuelle Größe gespeichert. Die generierten Daten der einzelnen Streams sind unter Umständen noch nicht komplett transferiert, wenn die Daten des nachfolgenden Streams generiert und verarbeitet werden, sodass jeder Stream sein eigenes Datenfeld benötigt. Außerdem wird in der Datenstruktur der aktuelle Zustand des Streams (im Codelisting durch die Variable workLevel repräsentiert) gespeichert. Die Anzahl der Streams ist konfiguerierbar und kann auf unterschiedlichen Devices angepasst werden. Um die Streamfunktionalität zu nutzen, werden alle CUDA-Aufrufe für das kopieren von Daten durch einen asynchronen Funktionsaufruf ersetzt. Die Kernelausführung bekommt als weiteren Parameter den jeweiligen Stream übergeben und wird dementsprechend asynchron ausgeführt. Die Unterteilung in mehrere Arbeitsschritte pro Stream ist notwendig, da nicht alle Aufgaben auf dem Device ausgeführt werden. Der Zwischenschritt der Matrixin- 61 4 Implementierung vertierung macht es daher nötig, Daten vor Aufruf des zweiten Kernels zurück auf den Host zu kopieren und die Matrixinvertierung durchzuführen. Im Anschluss an die Verteilung der aktuell anstehenden Aufgaben pro Stream, werden alle Streams den nächsten Arbeitsschritt durchführen. Hierfür müssen alle Streams auf die erfolgreiche Ausführung der vorherigen Schritte im eigenen Stream warten. Dies ist ein blockierender Aufruf im Hostsystem. Probehalber ist eine alternative Version implementiert, die anstatt eines blockierenden Synchronisierungspunktes einen nicht blockierenden Aufruf durchführt. Dieser fragt den Ausführungsstatus des aktuellen Streams ab. Nur bei komplett abgearbeiteten Aufgaben wird der nächste Schritt ausgeführt, ansonsten wird der nächster Stream die gleiche Abfrage durchführen. Diese Variante stellt sich jedoch als langsamer heraus, da der blockierende Synchronisationspunkt im Regelfall nicht blockieren muss und die Synchronisation durch die API günstiger als das manuelle Abfragen des Streamstatus und anschließend unterschiedlich verlaufenden Ausführungen ist. Des Weiteren kann die CPU im blockierenden Fall weitere Threads abarbeiten. 4.4.4 OpenMP Um weitere Steigerungen der Geschwindigkeiten zu erreichen wird die Anwendung um Multithreading erweitert. Hierbei wird OpenMP für die Threadgenerierung und Verwaltung verwendet, sodass die Anpassungen am auszuführenden Code relativ gering sind. Um ein mehrfaches Auslesen und Verarbeiten der Eingabedaten zu vermeiden wird die Ausführung und Initialisierung des EventReader ausgelagert. Außerdem wird die Filterfunktion mit einem Parameter aufgerufen, welcher den EventReader, den Startindex der zu verarbeitenden Ereignisse aus dem EventReader, den letzten zu verarbeitenden Index der Ereignisse, sowie einen Zeiger auf die Initialisierungsmatrix der Fehlerkovarianzmatrizen beinhaltet. Die Verarbeitung erfolgt anstatt vom ersten Ereignis bis zum letzten Eregnis des EventReaders vom Startindex bis zum Zielindex, sodass jeder erstellte Thread mit einem anderen Start- und Endindex nur einen Teil der Ereignisse bearbeiten muss. Die Anzahl der Threads richtet sich ohne weitere Konfiguration nach der Anzahl der Prozessoren des Hostsystems und kann gegebenenfalls extern konfiguriert werden. Die Verwendung mehrerer CPU-Kerne kann die Verarbeitung enorm beschleunigen, da das vergleichsweise teure Invertieren der Fehlerkovarianzmatrizen von der CPU berechnet wird. 62 4 Implementierung 4.4.5 Deviceauslastung steigern Nach den bisherigen Optimierungen ist die Auslastung der GPU-Kerne weiterhin relativ niedrig. Dies liegt an der Problemstellung und deren Umsetzung, da der Kalman-Filter für die Spurrekonstruktion nur mit 5x5 Matrizen arbeitet und die bisherige Verarbeitung auf Deviceseite pro Block genau eine dieser Spurrekonstruktionen durchführt. Die theoretische Auslastung eines Devices hängt bei vorgegebener Hardware von vier Faktoren ab. Threads per Block Die Anzahl der Threads pro ausgeführtem Block begrenzt unter Umständen die Anzahl der verwendeten Einheiten, da die Größe dieser Blöcke begrenzt ist. Durch die bisher genutzten 25 Threads mit einer Gesamtgröße von einem Warp ist dieser Faktor nicht das begrenzende Element. Registers per Thread Die Anzahl der Register pro SMX ist begrenzt und kann unter Umständen die gleichzeitig aktiven Threads limitieren. Dieser Faktor spielt auf Grund der relativ geringen Registerzahl im derzeitigen Optimierungsstand keine weitere Rolle. Shared Memory Die Größe des Shared Memory-Bereiches liegt bei maximal 48 KB. Sollte ein Kernel diesen Speicher komplett belegen, so kann dementsprechend kein anderer Kernel mehr ausgeführt werden, welcher den Shared Memory Speicherbereich nutzt. Dieses Element begrenzt die theoretische Auslastung für die Spurrekonstruktion nicht. Blocks per Multiprocessor Die Anzahl der gleichzeitig ausgeführten Blöcke per SMX ist begrenzt. Sollte keines der vorherigen Elemente die Grafikhardware auslasten, so wird dieser Faktor zum begrenzenden Element. Bei dem verwendeten Compute Level 3.5 der Devices liegt die Anzahl der gleichzeitig ausführbaren Blöcke bei 16 pro SMX. Dies ist bei beiden Kalman-Filter Kerneln der begrenzende Faktor. Ermittelt wird dies durch Verwendung des von NVIDIA frei zur Verfügung gestellten Occupancy Calculator. In diesen werden die ersten drei Werte für einen Kernel eingetragen und es wird berechnet, welcher der vier Faktoren die theoretische Auslastung der GPU-Kerne limitiert. Für den momentanen Stand der Optimierungen beträgt die theoretische Auslastung 25 % für beide Kernel. Es werden außerdem 63 4 Implementierung mögliche Auslastungen bei Veränderung der einzelnen Werte grafisch dargestellt, sodass schnell ersichtlich ist, ob und wo weiteres Potenzial vorhanden ist. Obwohl beide Kernel noch Steigerungen der theoretischen Auslastung erlauben, wird aus zeitlichen Gründen nur der Smoothing-Kernel entsprechend angepasst. Der vorherige Filterkernel wird zudem entsprechend verändert und erweitert, ist allerdings zum aktuellen Zeitpunkt nicht fehlerfrei ausführbar. Die grundlegende Idee zur Steigerung der Auslastung basiert auf dem Berechnen mehrerer Spuren in einem einzigen Block. Dies ist problematisch, da jeder Synchronisationspunkt in einem Kernel den gesamten Block betrifft. Dies kann beispielsweise zu Deadlocks führen, wenn zwei Blöcke unterschiedlich lang sind und der erste Warp die Ausführung der Spurberechnung beendet hat und die zweite Spur noch weitere Elemente berechnen muss. Genau dieses Problem macht die Umsetzung für den Kalman-Filter Kernel für die Berechnung des Hin- und Rückwegs deutlich komplexer, da hier in den einzelnen Schleifendurchläufen abhängig von der Anzahl der ein- und zweidimensionalen Treffer der jeweiligen Spur unterschiedliche Pfade durchlaufen werden. NVIDIA sichert eine korrekte Ausführung nur dann zu, wenn jeder Thread in einem Block nicht nur die gleiche Anzahl an Synchronisationsblöcken trifft, sondern nur falls alle Threads die gleichen Synchronisationsblöcke treffen. Außerdem ist es nötig, die Parameter des Kernels an das neue Threadmodell von einem Grid mit weniger Blöcken als Spuren und mehreren Spuren pro Block anzupassen. Des Weiteren muss beachtet werden, dass die Berechnung von zwei Spuren durch beispielsweise einen 5x10 Block nicht schneller werden können, da sieben Threads für die Berechnung der zweiten Spur im gleichen Warp wie die Threads der ersten Spur wären und damit die angestrebte doppelte Einheitenauslastung nur dafür sorgt, dass der erste Warp alle Berechnungen doppelt ausführen muss. Einmal für Spur Eins, wobei die Ergebnisse der letzten 7 Threads verworfen werden und dann für Spur Zwei, wobei die Ergebnisse der ersten 25 Threads verworfen werden. Deshalb wird die Gridstruktur so gewählt, dass jede Spur 32 Threads für die Berechnung anfordert, sodass der bisherige Overhead pro Warp weiterhin bestehen bleibt und jede Spur innerhalb eines Warps berechnet wird. Um dem Aufrufer möglichst wenig Arbeit zu machen, wird die Bestimmung der xund y-Koordinate eines Threads durch eine Berechnung aus einem eindimensionalen Threadblock ersetzt, sodass der Host pro gleichzeitig berechneter Spur genau 32 Threads anfordert. Der Kernel bestimmt dann für jeden Thread die zu berarbeitende 64 4 Implementierung Spur und die jeweiligen Koordinaten des Threads. Gelöst wird dies, indem die erste Dimension des Blockes immer aus 32 Threads besteht und die zweite Dimension des Blockes eine Größe hat, die der Anzahl der Spuren entspricht. unsigned int threadIndex = threadIdx . x ; unsigned int block = blockIdx . x * blockDim . y + threadIdx . y ; K a l m a n F i l t e r P a r a m e t e r _ t * currentParam = param + block ; ... if ( threadIndex < ORDER * ORDER ) { unsigned int row = threadIndex / ORDER ; unsigned int column = threadIndex % ORDER ; ... } Listing 53: Indexberechnung für höhere Auslastung Im Codeabschnitt 53 ist die aktualisierte Bestimmung der Threadkoordinaten abgebildet. Bevor diese Koordinaten bestimmt werden, wird der Index auf das dem Kernel übergebene Parameterarray der Spuren berechnet, indem die Größe der yDimension des aktuellen Blocks mit der Größe des aktuellen Grids multipliziert wird und anschließend noch die y-Koordinate des Blockes addiert wird. Diese Lösung basiert darauf, dass das Grid gleich der Anzahl der Spuren (n) geteilt durch die Anzahl der Spuren pro Block (blockDim.y) ist. Damit gibt es n/blockDim.y Blöcke, wobei jeder Block (blockIdx.x) genau blockDim.y Spuren bearbeiten muss und pro Spur (threadIdx.y) eines Blocks eine y-Koordinate im Block existiert. Daraus resultiert der zu benutzende Parameter zu blockIdx.x ∗ blockDim.y + threadIdx.y. Im Anschluss daran werden für die jeweils 25 ersten Threads eines Warps die Zeile und Spalte bestimmt, indem der Threadindex für die Zeile durch Fünf geteilt und für die Spalte Modulo Fünf gerechnet wird. __shared__ gpu_scalar_t PResALL [ ORDER * P A R A L L E L _ T R A C K S _ P E R _ B L O C K _ S M O O T H I N G ]; ... gpu_scalar_t * PRes = PResALL + threadIdx . y * ORDER ; Listing 54: Shared Memory Benutzung bei gesteigerter Auslastung Im nächsten Schritt muss die Größe der Shared Memory Bereiche angepasst werden. Zu diesem Zweck wird die eigentliche Größe einer Variable mit der maximalen Anzahl an Spuren pro Block multipliziert. Jeder Warp berechnet einen Zeiger auf einen Subbereich dieser Variable, welcher analog zur Berechnung der von dem Warp zu bearbeitende Spur die benötigte Größe der Variable mit der y-Koordinate des Blocks multipliziert und somit den benötigten Versatz bestimmt. Die Gesamtgröße einer einzelnen Variable wird aktuell statisch über die Größe der Variable multipliziert mit 65 4 Implementierung einem Define, welches die maximale Anzahl von gleichzeitig bearbeitbaren Spuren pro Block definiert, berechnet. Dieses Define lässt sich durch die Benutzung von dynamischen Shared Memory zur Laufzeit ersetzen. Weitere Informationen für eine mögliche dynamische Umsetzung befinden sich im Kapitel 7. Es muss sichergestellt werden, dass alle Warps die gleichen Synchronistationsblöcke erreichen und jeweils die Anzahl der Blöcke übereinstimmt. Zu diesem Zweck wird die längste Spur des aktuellen Blocks ermittelt, da alle Warps solange innerhalb der Berechnungsschleife bleiben müssen, bis das letzte Ergebnis der längsten Spur berechnet ist. Dies führt zu einem Overhead, welcher je nach Konstellation der Spurlängen zu- oder abnimmt. Weiterhin muss sichergestellt werden, dass Spuren, welche eigentlich keine Berechnungen mehr durchführen müssen, da sich keine weiteren Treffer in der Spur befinden, dennoch alle vorhandenen Synchronisationspunkte erreichen. Zu diesem Zweck werden alle aus dem Arbeitsspeicher lesenden und schreibenden Zugriffe mit einem IF versehen. Die Bedingung für dieses IF ist nur dann wahr, wenn der aktuelle Trefferzähler kleiner als die Länge der eigenen Spur ist. Damit wird sichergestellt, das keine Daten gelesen oder geschrieben werden, wenn diese nicht innerhalb der Spur liegen. Nötige Synchronisationen für diese Leseund Schreibzugriffe werden außerhalb der bedingten Verzweigung durchgeführt. Unabhängig davon werden immer alle regulären Berechnungen durchgeführt, damit alle Warps die Synchronisationspunkte erreichen und ausführen. Durch diese Veränderung kann die theoretische Auslastung der Smoothing-Funktion für eine GPU mit der Compute Capability 3.5 auf 100 % gesteigert werden. 4.4.6 Numerische Genauigkeit und Symmetrie Nach der Implementation der unterschiedlichen Performanceverbesserungen werden mit Hilfe eines vom Masterprojektteam entwickelten EventPlotters die Plausibilitäten der Ergebnisse geprüft, indem verschiedene Histogramme von den vorliegenden Daten angezeigt und entsprechend ausgewertet werden. Dabei fällt auf, dass einige der berechneten Ergebnisse weit außerhalb des erwarteten Bereichs liegen und es stellt sich bei genauerer Betrachtung dieser Ausreißer heraus, dass die Ergebnisse nicht korrekt sind. Aus diesem Grund ist vor der Speicherung der Daten in den Hauptspeicher des Devices eine Überprüfung der Symmetrie der Fehlerkovarianzmatrizen eingebaut. Es wird deutlich, dass einige der Fehlerkovarianzmatrizen trotz der erwarteten Symmetrie teils gravierende Abweichungen von dieser enthalten. Um 66 4 Implementierung dieses Problem zu lösen, müssen einige der Berechnungen für den Kalman-Filter angepasst werden. Die eingeführte Joseph-Form weist eine höhere numerische Stabilität auf. Ck|k = (I − Kk Hk )Ck|k−1 (I − Kk Hk )T + Kk Vk KTk (26) Während der Filterung des Hin- und Rückwegs muss die Berechnung der Fehlerkovarianzmatrix der Aktualisierungsphase durch die in Gleichung 26 angegebene Formel ersetzt werden. C’k = (I − K’k )Cfk|k (I − K’k )T + K’k Cbk|k K’Tk (27) Um die numerische Genauigkeit für den Smoothing-Kernel zu erhöhen, muss die Berechnung von C’k durch die in Gleichung 27 dargestellte Formel ersetzt werden. Für die Implementation beider Formeln werden die schon vorhandenen MatrixVektor-Inlinefunktionen verwendet und um Funktionen erweitert, welche eine Matrix über Indexoperationen als transponierte Matrix für die Berechnung verwenden. __device__ __ fo r ce in li n e_ _ void m u l t i p l y 5 x 5 A _ A B _ B T r a n s p o s e d ( gpu_scalar_t *A , gpu_scalar_t *B , int column , int row ) { gpu_scalar_t result = 0; for ( int i = 0; i < ORDER ; i ++) { result += A [ ORDER * row + i ] * B [ ORDER * column + i ]; } __syncthreads () ; A [ ORDER * row + column ] = result ; __syncthreads () ; } Listing 55: Matrixmultiplikation transponiert In Listing 55 ist eine der transponierenden Mutliplikationsfunktionen abgebildet. Die eigentlich zu transponierende Matrix B wird über Manipulation der Indizes innerhalb der FOR-Schleife dargestellt. Die Verwendung der Joseph-Form erhöht den Berechnungsaufwand leicht und verwendet im Falle des Smoothingkernels eine weitere Matrix für die Berechnung. Dies führt dazu, das der Smoothingkernel mehr Register benötigt und die auslastungssteigernden Maßnahmen die theoretische Auslastung auf 62,5 % herabsetzen, da nicht mehr ausreichend Register pro SMX zur Verfügung stehen. Eine weitere Steigerung der Programmperformance wird erreicht, indem die Berechnung der Pseudoinversen mit Hilfe der SVD durch eine Cholesky-Dekomposition ersetzt wird, 67 4 Implementierung welche nur positiv definite symmetrische Matrizen invertieren kann. Dies ist im Falle der Spurrekonstruktion für die Fehlerkovarianzmatrizen gegeben und ist durch die hohe numerische Genauigkeit durch Verwendung der Joseph-Form für die Ergebnisse der Kernel verwendbar. 68 5 Performance 5 Performance 5.1 Performancevergleich der Optimierungsstufen Tabelle 6: Verwendetes Computersystem Typ Daten Plattform Fujitsu Workstation CELSIUS M720 POWER Prozessor Intel Xeon E5-1620 3.60GHz 10 MB Turbo Boost Arbeitsspeicher 8 GByte DDR3 1600Mhz Mainboard Fujitsu Systemboard D3128 GPU ZOTAC GeForce GTX TITAN 6 GB Festplatte 128 GB SSD Betriebsystem OpenSUSE 12.3 Grafikkartentreiber NVIDIA 319.32 CUDA Version CUDA 5.0 Die folgenden Laufzeiten und Performanceangaben beziehen sich auf das in Tabelle 6 angegebene System. Die Laufzeiten beinhalten nicht die vom EventReader benötigte Zeit zum Auslesen der Daten von der Festplatte in die C++ Vektorstrukturen und beinhalten die Initialisierung von OpenMP zur Threaderstellung und für CUDA die Initialisierung der Streams, sowie alle weiteren Berechnungen und Datenverarbeitungen der Kalman-Filter Implementation. Die Messungen sind über das Auslesen der Systemzeit mit der Systemfunktion clock gettime und der Uhridentifikation CLOCK REALTIME realisiert. Laufzeiten beziehen sich immer auf die Dauer eines Durchlaufs eines speziellen Testdatensatzes, welcher aus den in Tabelle 7 aufgeführten Testdaten besteht. Tabelle 7: Verwendeter Testdatensatz Typ Im Datensatz Korrigierter Datensatz 96 96 Spuren 19656 19582 Treffer ∼650000 ∼220000 Ereignisse 69 5 Performance Tabelle 8: Performancevergleich Optimierung Laufzeit Fakor zur Grundversion Grundversion 12,1 s 1 Datenstrukturen 4,4 s 2,8 Blobdaten und Pinned Memory 4,1 s 2,95 Streams 3,9 s 3,1 OpenMP 0,77 s 15,7 Finale Version 0,32 s 37,8 Die rot markierten Ergebnisse sind nur bedingt vergleichbar, da die Trefferzahl der einzelnen Spuren nicht mit der ursprünglichen Trefferzahl übereinstimmt. Dies wird durch das Entfernen von ungültigen Spuren und Treffern im EventReader verursacht. Der Marker einer defekten Spur ist der numerische Wert −999 und sorgt dafür, die Spur zu verwerfen. Dies ist in den gegebenen Testdaten bei 74 Spuren der Fall, sodass diese wegfallen. Neben den entfernten Spuren werden alle Trefferdaten mit dem Detektortyp Drei entfernt, sodass die Testdaten weiter verdünnt werden. Als Folge dieser Modifikationen des EventReaders sind nunmehr die Resultate für die in Tabelle 7 in Spalte Korrigierter Datensatz angegebenen Daten zu berechnen. Die Anzahl der Treffer ist dadurch auf ein Drittel geschrumpft, was einen direkten Vergleich nicht ermöglicht. Um die Vergleichbarkeit der Ergebnisse für den finalen Tabelle 9: Angepasster Performancevergleich Optimierung Laufzeit Faktor zur Grundversion OpenMP 0,77 s 15,7 OpenMP EventReader 0,48 s 15,7 Finale Version 0,32 s 23,5 Programmstand zu ermöglichen, wird der Zeitpunkt der OpenMP-Optimierung wiederhergestellt und nur der EventReader soweit angepasst, dass dieser die gleichen Modifikationen an den Testdaten vornimmt, wie der EventReader der aktuellen Version. Das Ergebnis des Vergleichs ist in Tabelle 9 abgebildet. Es wird deutlich, dass die Optimierung der Matrixinvertierung und Verbesserungen an den Kerneln trotz gesteigerter Komplexität der Berechnung durch die Verwendung der Joseph-Form 70 5 Performance die Performance weiter steigern. Der Faktor zur Grundversion wird über einen ermittelten Schlüssel berechnet, welcher sich aus der Division der Laufzeiten zwischen der regulären OpenMP-Version und der angepassten Version zusammensetzt. Damit ist der Erfolg der einzelnen Optimierungsstufen deutlich sichtbar. 5.2 Performancevergleich OpenCL vs. CUDA vs. CPU Tabelle 10: Performancevergleich CPU/CUDA/OpenCL Durchläufe CPU CUDA OpenCL 500 0,478 s 0,118 s 0,180 s 100 0,477 s 0,118 s 0,181 s 10 0,469 s 0,142 s 0,196 s 5 0,470 s 0,160 s 0,211 s 2 0,485 s 0,227 s 0,244 s 1 0,486 s 0,324 s 0,261 s Die in Tabelle 10 dargestellten Ergebnisse spiegeln die benötigte Zeit pro Durchlauf wieder. Die Zeitmessung wird identisch zu den vorherigen Messungen durchgeführt, wobei die Anzahl der Durchläufe vorgibt, wie oft der komplette Datensatz berechnet wird. Dabei wird bei allen Implementationen jede Allokation, jede Berechnung und jeder Kopiervorgang entsprechend der Anzahl der Durchläufe ausgeführt. Die Abarbeitung erfolgt dabei in der Art, dass Ereignis Eins bis 96 berechnet werden und im Anschluss an die Verarbeitung der Ereignisse, die Verarbeitung bei Ereignis Eins neu gestartet wird. In Abbildung 17 ist das Laufzeitverhalten der einzelnen finalen Implementationen abgebildet. Es ist ersichtlich, dass sowohl die CUDA-Implementation, als auch die OpenCL-Implementation schneller als die durch Verwendung der GSL-Bibliothek implementierten CPU Version des Kalman-Filters sind. Alle drei Implementationen verwenden OpenMP, um alle Prozessorkerne der CPU zu benutzen. Es ist außerdem ersichtlich, dass es Unterschiede im Laufzeitverhaltens gibt. Die CPU-Version skaliert nahezu linear mit Anzahl der Durchläufe, sodass ein einzelner Durchlauf relativ gesehen kaum langsamer ist, als die Berechnung von hunderten Durchläufen. Anders ist dies bei OpenCL und CUDA, wobei OpenCL bei geringen Datenmengen 71 5 Performance Abbildung 17: Grafischer Laufzeitvergleich besser abschneidet, da die Laufzeit für einen einzelnen Durchlauf geringer als die Laufzeit des CUDA-Programms ist. Dieser Geschwindigkeitsvorteil wird allerdings schon bei der Verwendung von doppelt so vielen Ereignissen umgedreht, sodass die Ausführungsgeschwindigkeit pro Durchlauf der CUDA-Implementation letztlich am höchsten ist. Dies lässt sich an der Auslastung der Grafikkarte sehen. Während für einen einzelnen Durchlauf die durchschnittliche Auslastung der Kerne bei nur rund 3 % liegt, beträgt die Auslastung bei 100 Durchläufen bereits über 26 %. Die Laufzeit der beiden Kalman-Filter Kernel für den Hin-/Rückweg und das Smoothing steht im Verhältnis 73 % zu 27 %, sodass die theoretisch maximal mögliche Auslastung der Kerne bei ca. 31,7 % liegt. Da der Profiler mit Programmstart die durchschnittliche Auslastung bestimmt und zunächst der EventReader die Testdaten von der Festplatte einliest und damit keine Kernel auf der Grafikkarte ausgeführt werden, wird die angezeigte Auslastung verfälscht. Die theoretisch möglichen 31,7 % gelten nur für die Laufzeit ohne die vom EventReader benötigte Zeit. Damit ergibt sich die theoretische Auslastung zu 0,317 11,8 ∗ 13, 3 = 0, 295, welche vom CUDA-Programm annähernd erreicht werden kann. Damit ist das CUDA-Programm in der Lage, sich der von den Kerneln theoretisch mögliche Verarbeitungsgeswchwindigkeit auf der zur Verfügung stehenden Grafikhardware anzunähern. 72 6 Fazit 6 Fazit Die Umsetzung des Kalman-Filters auf ein GPGPU System ist erfolgreich. Die Implementation der Berechnungen ist abgeschlossen und alle bekannten Fehler sind beseitigt. Messungen der Ausführungsgeschwindigkeit zeigen außerdem eine bis zu vierfach höhere Performance im Vergleich zur GSL-Bibliothek basierten Implementation für die reine CPU-Berechnung. Dieser Erfolg ist mehreren Faktoren zu verdanken. Zum einen eignen sich die Formeln des Kalman-Filters auf Grund der vielen Matrixund Vektoroperationen gut für eine Berechnung auf der Grafikkarte. Allerdings begrenzt das bearbeitete Problem der Spurrekonstruktion die Dimensionsgrößen der Matrizen und Vektoren, sodass die Implementation des Kalman-Filters für dieses Problem eine für die GPGPU-Programmierung untypisch kleine Dimensionierung der Matrizen und Vektoren umsetzen muss. Indem die zur Berechnung benötigten Matrizen und Vektoren für die Spurrekonstruktion im lokalen Shared Memory gehalten werden, werden die Berechnungen mit den kleinen Matrizen und Vektoren beschleunigt. Zudem sind die Rechenoperationen so implementiert, dass die Anzahl an unterschiedlichen Ausführungspfaden innerhalb eines Warps pro Iteration nur einmal nötig ist. Im Kernel selbst werden außerdem alle Daten nur einmalig aus dem Hauptspeicher der Grafikkarte gelesen, sodass eine zusätlziche Latenz durch mehrfaches Auslesen der gleichen Daten ausgeschlossen ist. Daraus resultiert eine hohe Geschwindigkeit für die Berechnungen auf der Grafikkarte, sodass erste Performancemessungen mit dem Visual Profiler deutliches Optimierungspotenzial für den hostseitig ausgeführten Code, sowie für die genutzte Bandbreite über die PCIe Schnittstelle aufzeigen. Um die Performance weiter zu steigern sind unterschiedliche Optimierungsschritte implementiert, welche die Ausführungsgeschwindigkeit erheblich steigern. Durch den geringen Datendurchsatz über die PCIe Schnittstelle fasst die erste Optimierung die Daten zunächst zusammen, sodass alle Trefferdaten eines Ereignisses in einem einzigen Feld gespeichert und mit einem Kopiervorgang zum Device übertragen werden können. Dies spart mehrere tausend Allokationen und Kopiervorgänge, sodass die Geschwindigkeit des Programms deutlich gesteigert ist. In weiteren Schritten werden unterschiedliche Datenfelder zu einem Datenblob kombiniert, sodass letztlich alle benötigten Inputdaten durch einen einzelnen Kopiervorgang übertragen werden. Dies führt für die vorliegende Datenmenge zu maximaler Ausnutzung der möglichen Bandbreite. 73 7 Ausblick Damit die Devices neben den Kopiervorgängen weitere Kernel abarbeiten können, werden CUDA Streams implementiert und hostseitig OpenMP für eine parallele Abarbeitung der Aufgaben umgesetzt. Außerdem wird die theoretische Auslastung der Grafikkarte durch gleichzeitiges Berechnen mehrerer Spuren im Smoothingteil des Kalman-Filters deutlich gesteigert, sodass die vorhandenen Devices besser ausgelastet werden. Die vorliegende Masterarbeit zeigt erfolgreich, dass durch paralleles Ausführen von Arbeitsblöcken des Kalman-Filters und durch Lastverteilung auf Host und Devices die Ausführungsgeschwindigkeit erheblich gesteigert werden kann und im direkten Vergleich zu einer auf der CPU rechnenden Lösung schneller ist. Im Vergleich zu einer parallel entwickelten OpenCL Lösung[27] benötigt die CUDA-Implementation für die Berechnung von großen Datenmengen nur rund zwei Drittel der Zeit und kann sich ebenfalls durchsetzen. 7 Ausblick Neben den Implementationen der Optimierungsstufen gibt es noch weitere Optionen den Kalman-Filter zu beschleunigen. Für eine weitere Steigerung der Auslastung sollte der Kernel für den Hin-/Rückweg des Kalman-Filters soweit angepasst werden, dass dieser ähnlich zu dem Smoothingkernel mehrere Spuren gleichzeitig verarbeiten kann. Erste Änderungen am Code sind bereits umgesetzt, sodass die Synchronisationspunkte alle innerhalb der Berechnungsschleifen einzeln angesprungen werden können. Es ist eine Erweiterung des Codes nötig, welche die lesenden und schreibenden Operationen nur weiter durchführen, wenn der aktuelle Trefferindex noch innerhalb der Grenzen der betrachteten Spur liegen. Ansonsten muss, identisch zum Smoothingkernel, nur die Berechnungen bzw. die Synchronisation erfolgen. Der daraus resultierende Overhead ist der gleiche, wie beim Smoothing, sodass als weitere Anpassung der Hostcode dahingehend verändert werden sollte, die Spuren zunächst nach der Länge zu sortieren. Dies würde den Overhead der unterschiedlichen Längen unter Umständen deutlich reduzieren. Die Steigerung der Deviceauslastung ist in der aktuellen Version nur starr umgesetzt, sodass keine automatische Anpassung an zukünftige Hardware möglich ist und gegebenenfalls neu kompiliert werden müsste. Eine Alternative zu dieser Lösung wäre es, die benötigten Ressourcen eines Kernels im Programmcode zu hinterlegen 74 7 Ausblick und einen Kalibrierungsmodus zu implementieren, welcher die Ressourcen pro zu rekonstruierender Spur als Teiler der Kapazität der vorhandenen Grafikhardware verwendet. Dies würde für die vier limitiernden Ressourcen, welche in Kapitel 4.4.5 beschrieben sind, die maximale Anzahl an gleichzeitig berechenbaren Spuren geben, sodass der kleinste Wert eine optimale Auslastung der Hardware für einen gegebenen Kernel garantiert. Nötige Anpassungen betreffen sowohl die Kernelfunktionen, als auch die Hostfunktionen, da die Verwendung von dynamischen Größen des Shared Memory benötigt wird, um einer variablen Anzahl von Spuren ausreichend Speicher zur Verfügung zu stellen. Damit muss die Hostfunktion die benötigte Speichergröße eines Kernels berechnen und diese Größe im Kernelparameter setzen. Im Kernel muss der externe Speicherblock zunächst aufgeteilt werden, da nur ein einziger Zeiger auf diesen Bereich übergeben wird und somit die Zeiger auf die Matrizen und Vektoren vom Kernel berechnet werden müssen. Diese Anpassung ist allerdings vergleichsweise gering, da in der statischen Variante die Zeiger ebenso mittels der gleichen Zeigerarithmetik berechnet werden. Weiterhin wäre eine Implementation der Cholesky-Invertierung auf der Grafikkarte zu erstellen, um zu überprüfen, ob diese Art der Matrixinvertierung ausreichend schnell auf der Grafikkarte durchgeführt werden kann, sodass es nicht mehr nötig ist, die Invertierung hostseitig durchzuführen. Damit müssen weniger Kopiervorgänge stattfinden und es würde die Anzahl der Kernelaufrufe sinken, da die bisherige Trennung in zwei Kernel obsolet werden würde, falls die Invertierung auf der Grafikkarte durchgeführt werden kann. 75 8 Anhang 8 Anhang 76 8 Anhang Abbildung 18: Technische Spezifikation der Compute Capabilities[21] 77 Literatur Literatur [1] CERN, About CERN, URL: http://home.web.cern.ch/about, (09.07.13) [2] CERN, ATLAS, URL: http://home.web.cern.ch/about/experiments/ atlas, (09.07.13) [3] Dr. Fleischmann, Track Reconstruction in the ATLAS Experiment - The Deterministic Annealing Filter, S. 15, URL: http://cds.cern.ch/record/ 1014533, (09.07.13) [4] Vgl. ATLAS Experiment;How much data will be recorded, URL: http:// atlas.ch/what\_is\_atlas.html#5 (09.07.2013) [5] Vgl. Greg Welsh, Gary Bishop;An Introduction to the Kalman Filter; Kapitel 4: The Kalman Filter, S. 21, URL: http://www.cs.unc.edu/~tracker/ media/pdf/SIGGRAPH2001_CoursePack_08.pdf (09.07.2013) [6] Greg Welsh, Gary Bishop;An Introduction to the Kalman Filter, URL: http://www.cs.unc.edu/~tracker/media/pdf/SIGGRAPH2001\ _CoursePack\_08.pdf (09.07.2013) [7] Vgl. Frühwirth, R. et al.; Data Analysis Techniques for High-Energy Physics, 2. Auflage, S. 252, Cambridge University Press [8] Vgl. Greg Welsh, Gary Bishop;An Introduction to the Kalman Filter; Kapitel 4.1.2: The Computational Origins of the Filter, S. 23, URL: http:// www.cs.unc.edu/~tracker/media/pdf/SIGGRAPH2001_CoursePack_08.pdf (09.07.2013) [9] Vgl. NVIDIA Corporation;NVIDIA’s Next Generation CUDATM Compute Architecture: Kepler GK110, S. 8, URL: http://www.nvidia.com/content/ PDF/kepler/NVIDIA-Kepler-GK110-Architecture-Whitepaper.pdf (09.07.2013) [10] Vgl. NVIDIA Corporation;NVIDIA’s Next Generation CUDATM Compute Architecture: Kepler GK110, S. 21, URL: http://www.nvidia.com/content/ PDF/kepler/NVIDIA-Kepler-GK110-Architecture-Whitepaper.pdf (09.07.2013) 78 Literatur [11] NVIDIA Corporation;Kopierfunktionen, URL: http://developer. download.nvidia.com/compute/cuda/4_2/rel/toolkit/docs/online/ group\_\_CUDART\_\_MEMORY.html (09.07.2013) [12] Vgl. Heise bringt Verlag; riesige satz, GTC 2013: Speichermengen URL: für Stacked GPUs, DRAM letzter Ab- http://www.heise.de/newsticker/meldung/ GTC-2013-Stacked-DRAM-bringt-riesige-Speichermengen-fuer-GPUs-1826882. html (09.07.2013) [13] Vgl. NVIDIA Corporation;Kepler Tuning Guide, Abschnitt 1.4.4.2, S. 7, URL: http://docs.nvidia.com/cuda/pdf/Kepler\_Tuning\_Guide.pdf (09.07.2013) [14] Vgl. NVIDIA Corporation;NVIDIA’s Next Generation CUDATM Compute Architecture: Kepler GK110, S. 6, URL: http://www.nvidia.com/content/ PDF/kepler/NVIDIA-Kepler-GK110-Architecture-Whitepaper.pdf, (09.07.13) [15] Vgl. NVIDIA Corporation;NVIDIA’s Next Generation CUDATM Compute Architecture: Kepler GK110, S. 8, URL: http://www.nvidia.com/content/ PDF/kepler/NVIDIA-Kepler-GK110-Architecture-Whitepaper.pdf, (09.07.13) [16] Vgl. NVIDIA Corporation;NVIDIA’s Next Generation CUDATM Compute Architecture: Kepler GK110, S. 10, URL: http://www.nvidia.com/content/ PDF/kepler/NVIDIA-Kepler-GK110-Architecture-Whitepaper.pdf, (09.07.13) [17] Vgl. schnitt NVIDIA Shared Corporation;CUDA Memory, URL: C Best Practices Guide, Ab- http://docs.nvidia.com/cuda/ cuda-c-best-practices-guide/\#shared-memory (09.07.2013) [18] Vgl. NVIDIA Corporation;CUDA C Best Practices Guide, Abschnitt Synchronization-Functions URL: http://docs.nvidia.com/cuda/ cuda-c-programming-guide/index.html\#synchronization-functions (09.07.2013) 79 Literatur [19] Vgl. re NVIDIA 6, Corporation;CUDA Thread Hierachy C URL: Programming Guide, Figu- http://docs.nvidia.com/cuda/ cuda-c-programming-guide/index.html (09.07.2013) [20] NVIDIA Corporation;CUDA C Programming Guide, Figure 5, A Scalable Programming Model URL: http://docs.nvidia.com/cuda/ cuda-c-programming-guide/index.html (09.07.2013) [21] Vgl. NVIDIA Corporation;CUDA C Programming Guide, Table 12: Technical Specifications per Compute Capability, URL: http://docs.nvidia.com/ cuda/cuda-c-programming-guide/index.html (09.07.2013) [22] Vgl. belle NVIDIA 10, Corporation;CUDA Technical C Specifications Programming per Compute Guide, Ta- Capability URL: http://docs.nvidia.com/cuda/cuda-c-programming-guide/ index.html\#features-technical-specifications.xml\_\ _technical-specifications-per-compute-capability (09.07.2013) [23] Vgl. NVIDIA NVCC, Options Corporation;NVIDIA for Steering CUDA GPU Code Compiler Driver Generation URL: http://docs.nvidia.com/cuda/cuda-compiler-driver-nvcc\ #options-for-steering-gpu-code-generation (09.07.2013) [24] Vgl. NVIDIA Corporation;NVIDIA CUDA Error Codes, enum cudaError URL: http://developer.download.nvidia.com/compute/ cuda/4_1/rel/toolkit/docs/online/group__CUDART__TYPES_ g3f51e3575c2178246db0a94a430e0038.html (09.07.2013) [25] Vgl. NVIDIA des, URL: Corporation;NVIDIA CUDA Device Computemo- http://developer.download.nvidia.com/compute/ cuda/4_2/rel/toolkit/docs/online/group__CUDART__TYPES_ g7eb25f5413a962faad0956d92bae10d0.html (09.07.2013) [26] Vgl. NVIDIA Corporation;NVIDIA cudaFree, URL: http://developer. download.nvidia.com/compute/cuda/4_2/rel/toolkit/docs/online/ group__CUDART__MEMORY_gb17fef862d4d1fefb9dba35bd62a187e.html (09.07.2013) 80 Literatur [27] Dankel, M.; Implementierung eines GPU-beschleunigten Kalman-Filters mittels OpenCL, 2013, -Forschungsbericht-, Fachhochschule Münster 81 Literatur Eidesstattliche Erklärung Ich, Rene Böing, Matrikel-Nr. 61 83 84, versichere hiermit an Eides statt durch meine eigene Unterschrift, dass ich die vorstehende Arbeit mit dem Thema “Implementation eines CUDA basierten Kalman-Filters zur Spurrekonstruktion des ATLASDetektors am LHC ” selbstständig und ohne unzulässige fremde Hilfe angefertigt habe. Ich habe keine anderen als die angegebenen Quellen und Hilfsmittel benutzt, wobei ich alle wörtlichen und sinngemäßen Zitate als solche gekennzeichnet habe. Die Erklärung bezieht sich auch auf in der Arbeit gelieferte Zeichnungen, Skizzen, bildliche Darstellungen und dergleichen. Diese Arbeit wurde bisher in gleicher oder ähnlicher Form noch keiner anderen Prüfungsbehörde vorgelegt und auch nicht veröffentlicht. Stadtlohn, den 16.10.2013 82