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.
[email protected]
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