Masterarbeit - lab4inf.fh-muenster.de · etc. Der Kalman-Filter verbessert unter Minimierung der...
Transcript of Masterarbeit - lab4inf.fh-muenster.de · etc. Der Kalman-Filter verbessert unter Minimierung der...
-
Fachhochschule Münster
Masterarbeit
Implementation eines CUDA basierten Kalman-Filterszur Spurrekonstruktion des ATLAS-Detektors am LHC
Rene Böing, B.Sc.
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 Verwer-
tung 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 Ereig-
nisdaten des ATLAS-Experiments am CERN zugeschnitten. Es werden ausgehend
von einer selbst entwickelten Grundimplementation verschiedene Verfahren zur Op-
timierung der Berechnungsgeschwindigkeit beschrieben. Neben der erfolgreichen Im-
plementation des Kalman-Filters wird ein Vergleich der Laufzeit mit einer CPU ba-
sierten 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 implemen-
tation is faster than a CPU based approach. The Kalman filter implementation is
customized to fit the needs of track reconstruction for the ATLAS expirement loca-
ted 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. Niko-
laus Wulff bedanken, der durch die Kontaktaufnahme mit der Wuppertaler ATLAS-
Gruppe 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 Implementationsde-
tails, zu großem Dank verpflichtet. Auch möchte ich der restlichen ATLAS For-
schungsgruppe und insbesondere Herrn Prof. Dr. Peter Mättig für die gute Zusam-
menarbeit danken.
Ich möchte mich zudem bei meinem Projektpartner Maik Dankel für die überaus
gute Zusammenarbeit bedanken. Auch die Masterprojektgruppe bestehend aus Phil-
ipp 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 36
4.1 Detektordaten . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36
4.1.1 Kalman-Filter Initialisierung . . . . . . . . . . . . . . . . . . . 38
4.2 Projekteigenschaften . . . . . . . . . . . . . . . . . . . . . . . . . . . 38
4.3 Funktionsimplementierung . . . . . . . . . . . . . . . . . . . . . . . . 39
4.3.1 Devicefunktionen . . . . . . . . . . . . . . . . . . . . . . . . . 39
4.3.2 Hostfunktion . . . . . . . . . . . . . . . . . . . . . . . . . . . 53
4.4 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 . . . . . . . . . . . . . . . . . . . . . . . 30
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
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 . . . . . . . . . . . . . . . . . . . . . . 28
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
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 Teilchenphy-
sik. Zu diesem Zweck werden im Large Hadron Collider, kurz LHC, Protonen und
Atomkerne bei sehr hohen Schwerpunktsenergien von bis zu acht Terraelektronen-
volt (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 AT-
LAS 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 Teilchen-
kollision 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 Teil-
chenstrahlen 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 regis-
trieren können. In Abbildung 1 ist der Detektor des ATLAS-Experiments dargestellt.
Es sind die verschiedenen Detektorlagen abgebildet, welche jeweils mit unterschiedli-
chen 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, wel-
che 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 Implementa-
tion, 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 se-
hen. 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 Ge-
nauigkeit der Messung. Ein großer Vorteil im Vergleich zu anderen Verfahren ist
die endrekursive Arbeitsweise des Filters, welche zur Korrektur der nächsten Mes-
sung 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 verar-
beiten 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 Er-
gebnisse 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 = Fkpk−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 Fehlerkovarianzma-
trix zur Vorhersage führt.
Ck|k−1 = FkCk−1|k−1FTk + PkQkP
Tk (2)
Im Falle der ersten Messung liegen keine vorherigen Werte vor und es müssen Start-
werte 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 verwende-
te Startwert zu stark von der eigentlichen Spur entfernt liegt. Außerdem kann die
Vorhersage komplett fehlschlagen, wenn der vorhergesagte Pfad die nächste Detek-
torlage 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 korrespondie-
renden Messpunkte und Referenzpunkte ausgeführt. Dadurch wird anstatt mk jetzt
∆mk = mk −Hkpk|ref für den Fit verwendet, sodass sich die Startwertproblematikentspannt.[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 Kalman-
Filters. 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 Kalman-
Gain 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−1HTk (Vk + HkCk|k−1H
Tk )−1 (3)
4
-
1 Einleitung
Die Aktualisierung des vorausgesagten Wertes pk|k−1 wird, wie in Gleichung 4 an-
gegeben, berechnet. Der neue Messwert aus der Messreihe ist im Vektor mk gespei-
chert.
pk|k = pk|k−1 + Kk(mk −Hkpk|k−1) (4)
Zudem kann die Fehlerkovarianzmatrix Ck|k mit Hilfe von Gleichung 5 berechnet
werden.
Ck|k = (I−KkHk)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 ge-
steigert werden, da der Kalman-Filter für die Spurrekonstruktion benutzt wird und
damit alle Messpunkte bereits vorliegen. Um die Informationen der letzten Messun-
gen bei der Berechnung der ersten Messungen zu beachten, werden weitere Schritte
durchgeführt, um ein möglichst optimales Ergebnis für jeden Messpunkt zu bekom-
men, 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 zwei-
mal 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(C
fk|k + C
bk|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(p
bk|k−1 + p
fk|k) (7)
Um das, durch das Smoothing korrigierte, p’k zu bestimmen, muss, wie in Glei-
chung 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 Host-
System 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 Sche-
duler für zu bearbeitende Daten. Der Scheduler arbeitet auf Block-Ebene (sie-
he 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 an-
gebunden 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 Ab-
satz genauer erläutert.
L2 Cache Ähnlich zu einer CPU hat eine GPU mehrere Cache-Stufen. Der L2-
Cache 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 errei-
chen.
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 En-
gine), welcher auf Warp-Ebene agiert. Dieser Scheduler verfügt über zwei In-
struction 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 vor-
handen, 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 Co-
res 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. Die-
ser 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 auto-
matische Ä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 gan-
ze Warps geschickt werden können. Derzeitig haben alle Architekturen von NVI-
DIA 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 (CUDA-
spezifische 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 Bei-
spiele gegeben, die in der Implementierung des Kalman-Filters eine große Rolle spie-
len, allerdings nicht auf Grund der verwendeten Sprache, sondern auf Grund der ver-
wendeten Hardware und deren Eigenschaften durchgeführt werden. So werden an-
ders 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. Die-
ser 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 Trans-
fers 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 Host-
Speicher 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 ver-
schiedensten 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 Block-
schaltbild 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 fol-
gendes, 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 hinterein-
ander. Im Speicher liegen an der Startadresse 32 Floats für Iteration 1, an-
schließ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 fal-
len. 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 ab-
gelegt. 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 Informa-
tionen 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 Spei-
cher 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 Me-
mory verarbeiten und es muss je nach Algorithmus spezieller Gebrauch von diesem
Speicher gemacht werden. Oftmals kann es durch den Gebrauch von Shared Me-
mory 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 Grafikkartenprogrammie-
rung. 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++ Umge-
bungen 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 Prag-
mas unterstützt. Neben diesen Verwendungsmöglichkeiten von CUDA innerhalb be-
reits existierenden Codes besteht die Option, geschriebenen CUDA-Code in nativen
x86-Code zu übersetzen. Dies ermöglicht automatischen Gebrauch von Autovek-
torisierung, SSE-/AVX-Befehlen und Multicore-CPUs zu machen. Damit kann im
HPC-Berech zwischen CPUs und GPUs gewechselt werden, ohne einen Algorith-
mus 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 entspre-
chende 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 sol-
len. 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 ver-
wendet 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 den-
noch auf mehr als nur die Compute Capability geachtet werden. NVIDIA hat mit
der CUDA 5 Spezifikation zwar GPUDirect eingeführt, welches direkten RDMA Zu-
griff 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. So-
wohl 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 kom-
pilieren, solange das Computelevel des Zieldevices gleich oder höher ist, als das vom
Kernel verlangte Computelevel. Hierzu muss der zu kompilierende Code mit speziel-
len 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 auf-
geteilt, 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 Ergebnismatrixaij setzt sich aus der Summe der beiden entsprechenden Felder aus den beiden
l×k Matrizen zusammen. Dies lässt sich in CUDA leicht durch entsprechendeDimensionierung 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 Syn-
chronisierungspunkten 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 Aufgaben-
stellung, nicht einfach die Einheiten mit genügend Daten zu versorgen. Um dieses
Problem zu entschärfen werden sogenannte Streams eingeführt, welche parallel ab-
gearbeitet 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 be-
nutzen. 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 An-
schluss können die Daten für den nächsten Kernel kopiert werden, etc. Dieses Vorge-
hen 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 Kopier-
vorgang 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 Abar-
beitung 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) un-
terstü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 Deviceco-
deabschnitt selbst zu kompilieren, während der Hostcode an den normalen C/C++-
Compiler weitergeleitet wird. Diese Unterscheidung kann auf ganze Dateien zutref-
fen, sodass *.c oder *.cpp Dateien immer direkt an den Hostcodecompiler weiterge-
reicht 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 CU-
DA 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 Na-
me(Parameter 0,..., Parameter n) {...} allem voran noch der CUDA-Befehl devicestehen.
__device__ void foo(float *a, float*b) {
...
}
Listing 2: Funktionskopf für GPU-Funktion
Damit wird diese Funktion in Grafikkartencode übersetzt. Der Aufruf dieser Funk-
tion innerhalb des Hostcodes orientiert sich sehr stark an einem normalen Funk-
tionsaufruf, benötigt allerdings mehr als nur die Funktionsparameter um korrekt
ausgeführt zu werden. Zu beachten ist ebenfalls, dass die aus C bekannten Funk-
tionsparameter 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. In-
formationen 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 (a, b);
...
Listing 3: Aufruf GPU-Funktion
Es ist ersichtlich, dass es neben den üblichen Parametern noch eine weitere Para-
meterart gibt, welche in den Spitzklammern angegeben wird. Welche
Parameter das sind und welchen Einfluss diese haben, wird in den folgenden Ab-
schnitten 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 bearbei-
tenden Daten zu ermitteln, existieren im Device-Code eingebaute Variablen, welche
von der CUDA-API automatisch gesetzt werden. Um beispielsweise die Position ei-
nes 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 xPosGrid = blockIdx.x;
int yPosGrid = blockIdx.y;
int zPosGrid = blockIdx.z;
int xGridSize = gridDim.x;
int yGridSize = gridDim.y;
int 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 Blockdimensio-
nen. 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 automa-
tisch 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 (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 un-
terschiedlich 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 (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 ein-
fach. Die Nachteile sind allerdings identisch zu denen statischer Arrays in normalen
C-Code, sodass oft auf dynamische Größen zurückgegriffen werden muss. Die dyna-
mische Allokation erfordert die Kenntnis über die Größe des benötigten Speichers
auf der Hostseite. Außerdem ist es notwendig den Speicher kernelseitig in Teilberei-
che 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 Zeigerarithme-
tik 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 auf-
treten, aber genau wie beim Host, müssen diese Fehler nicht zum Absturz oder zu
Fehlermeldungen führen.
int items = n;
foo (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 Struk-
tur 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 nachge-
dacht werden muss. Der Einfachheit halber werden in diesem Kapitel nur die grund-
legenden 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 ab-
gebildet. 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 = cudaStreamCreate (& 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 Kopier-
vorgängen oder in Kernelaufrufen genutzt werden. An dieser Stelle ist anzumer-
ken, dass fast alle API-Funktionen einen Fehlercode zurückgeben, welcher entspre-
chend ü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 Stream-
parameter kein Zeiger.
//Host Code
...
error = cudaStreamDestroy(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 entsprechen-
de Funktionen zur Steuerung und Überwachung eines Streams zur Verfügung.
//Host Code
...
error = cudaStreamQuery(stream1);
...
error = cudaStreamSynchronize(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 Aufga-
ben oder bis zum Auftreten eines Fehlers blockiert. Die Rückgabewerte, sowie deren
Interpretation, ist, bis auf den in diesem Fall unnötigen Rückgabewert cudaError-
NotReady, zur ersten Funktion identisch.
3.5 API-Fehler abfangen
Da die meisten Funktionen der CUDA-Bibliothek verschiedenste Fehlercodes zurück-
geben können, ist es sinnvoll für die Fehlercodeabfragen eine Funktion oder eine Ma-
krofunktion zu erstellen. Um die Handhabung im Fehlerfall zu vereinfachen, emp-
fiehlt sich eine Makrofunktion, da diese sehr einfach die Zeile und Quellcodedatei
des Fehlers ausgeben kann und es keine Kontextswitches auf der CPU zum Auf-
ruf 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 Fehlermel-
dung zu übersetzen.
//Host Code
...
char * errorMessage = cudaGetErrorString(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 CUDA_ERROR_HANDLER(value) { \
cudaError_t _m_cudaStat = value; \
if (_m_cudaStat != cudaSuccess) { \
fprintf(stderr , "Error %s at line %d in file %s\n", \
cudaGetErrorString(_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 Ab-
fangen von Fehlern dargestellt. Wie dort zu sehen ist, gibt dieses Makro eine Feh-
lermeldung 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 ei-
ne 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 da-
zu 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;
CUDA_ERROR_HANDLER(cudaGetDeviceCount (&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
cudaDeviceProp prop[count];
for(int deviceId = 0; deviceId < count; deviceId ++) {
CUDA_ERROR_HANDLER(cudaGetDeviceProperties (&prop[deviceId], deviceId));
}
Listing 20: Deviceeigenschaften ermitteln
28
-
3 CUDA Programmierung
Im Anschluss an die for-Schleife befinden sich die Deviceeigenschaften in dem an-
gegebenen 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 Devi-
ces im Code (siehe Listing 21) nachfolgende Befehle entscheidend beeinflussen kann.
//Host Code
int deviceId = 0;
CUDA_ERROR_HANDLER(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;
CUDA_ERROR_HANDLER(cudaSetDevice (0));
CUDA_ERROR_HANDLER(cudaStreamCreate (& stream1));
...
kernel (paramA);
...
CUDA_ERROR_HANDLER(cudaSetDevice (1));
CUDA_ERROR_HANDLER(cudaStreamCreate (& stream2));
...
kernel (paramB);
...
CUDA_ERROR_HANDLER(cudaSetDevice (0));
//NOT WORKING
kernel (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
-
3 CUDA Programmierung
Variable
Besc
hreibung
int
EC
CE
nab
led
Wir
dE
CC
unte
rstü
tzt
und
ist
akti
v?
int
asyncE
ngi
neC
ount
Anza
hl
der
asynch
ronen
Ausf
ühru
ngs
einhei
ten
int
clock
Rat
eT
aktr
ate
inkH
z
int
com
pute
Mode
Hos
tthre
adzu
griff
smust
erau
fdas
Dev
ice
int
concu
rren
tKer
nel
sG
leic
hze
itig
eA
usf
ühru
ng
meh
rere
rK
ernel
s?
int
kern
elE
xec
Tim
eoutE
nab
led
Ist
die
Lau
fzei
tei
nes
Ker
nel
sb
egre
nzt
?
int
majo
rC
ompute
Cap
abilit
ydes
Dev
ices
(Vor
dem
Kom
ma)
int
max
Gri
dSiz
e[3]
Max
imal
eD
imen
sion
sgrö
ßeei
nes
Gri
ds
int
max
Thre
adsD
im[3
]M
axim
ale
Dim
ensi
onsg
röße
eines
Blo
cks
int
max
Thre
adsP
erB
lock
Max
imal
eA
nza
hl
anT
hre
ads
inei
nem
Blo
ck
int
max
Thre
adsP
erM
ult
iPro
cess
orM
axim
ale
glei
chze
itig
ausf
ührb
are
Anza
hl
anT
hre
ads
pro
SM
X
int
mem
oryB
usW
idth
Bre
ite
der
Sp
eich
eran
bin
dung
inB
it
int
mem
oryC
lock
Rat
eM
axim
ale
Tak
trat
edes
Sp
eich
ers
inkH
z
int
min
orC
ompute
Cap
abilit
ydes
Dev
ices
(Nac
hdem
Kom
ma)
int
mult
iPro
cess
orC
ount
Anza
hl
der
SM
X-E
inhei
ten
char
nam
e[25
6]A
SC
IIStr
ing
zur
Iden
tifizi
erung
des
Dev
ices
size
tsh
ared
Mem
Per
Blo
ckV
erfü
gbar
eG
röße
des
Shar
edM
emor
ypro
Blo
ck
size
tto
talC
onst
Mem
Grö
ßedes
gesa
mte
nko
nst
ante
nSp
eich
ers
size
tto
talG
lobal
Mem
Grö
ßedes
gesa
mte
nR
AM
sdes
Dev
ices
int
war
pSiz
eG
röße
eines
War
ps
Tab
elle
2:C
UD
AD
evic
eeig
ensc
haf
ten
30
-
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 ein-
zigen 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 Hostsys-
tem 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);
CUDA_ERROR_HANDLER(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 Gra-
fikkarte 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 Ver-
wendung 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
CUDA_ERROR_HANDLER(cudaFree(vga_P));
Listing 24: Speicher auf einem Device freigeben
Nach der Allokation und Verwendung eines Speicherbereichs ist es analog zu Host-
speicher notwendig, diesen wieder freizugeben, um Speicherlecks im Programm zu
verindern. Hierfür stellt die CUDA-API die in Listing 24 dergestellte Funktion be-
reit, 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 in-
terpretiert 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);
CUDA_ERROR_HANDLER(cudaMalloc (&vga_P , size));
32
-
3 CUDA Programmierung
// host_P f l l e n
...
CUDA_ERROR_HANDLER(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 Datentrans-
fer nicht über den gewohnten Zugriff auf Indizes des Devicezeigers geschieht, son-
dern ähnlich zur aus C/C++ bekannten memcpy-Funktion ein auf dem Host liegen-
der 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, wel-
che ü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 Speicher-
bereich 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
CUDA_ERROR_HANDLER(cudaMemcpyAsync ((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 aller-
dings 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);
CUDA_ERROR_HANDLER(cudaMallocHost ((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 Host-
systems, da unnötige Kopien und implizite Allokationen von Pinned Memory durch
die CUDA-API wegfallen. Die Verwendung von Pinned Memory hat allerdings un-
ter 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 System-
performance 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
CUDA_ERROR_HANDLER(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 be-
kannten Multithreadingprobleme zu verhindern. Dabei stellt die CUDA Bibliothek
verschiedene Synchronisationsbefehle zur Verfügung, welche es erlauben, auf speziel-
le 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 Ver-
arbeitungsstrukturen 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 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 TrackData_t;
struct TrackStruct {
TrackData_t track;
TrackInfo_t info;
TrackInfo_t truthTrackInfo;
};
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 Simu-
lation, falls die Daten aus einem Simulator stammen (truthTrackInfo) beinhaltet.
struct TrackHitStruct {
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 TrackHitStruct TrackHit_t;
Listing 31: Struktur eines Hits
Die TrackHitStruct-Struktur beinhaltet alle benötigten Parameter für einen Mess-
punkt 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 Genau-
igkeit 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 gespei-
chert. 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 is2Dim-
Variable gespeichert. Diese Information ist in der Grundversion noch nicht mit in
dieser Struktur zusammengefasst und wird vom Host an gegebener Stelle selbst be-
rechnet. 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 auf-
einander 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:
pk−1|k−1 =
0
0
0
0
0
,Ck−1|k−1 =
250 0 0 0 0
0 250 0 0 0
0 0 0.25 0 0
0 0 0 0.25 0
0 0 0 0 1E − 6
(9)
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 CMake-
Projektes. CMake wird genutzt, um den Bauprozess der Anwendung zu automa-
tisieren 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 Kompi-
lationsphase 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. Par-
allel zur Arbeit mit CUDA wird außerdem im Rahmen einer weiteren Masterarbeit
von Herrn Maik Dankel die hier vorliegende Aufgabenstellung mit der Programmier-
sprache 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- / Aus-
gabeverzeichnisse angegeben werden. Außerdem werden in der Konfiguration die
benötigten externen Bibliotheken über entsprechende Befehle lokalisiert. Die ver-
schiedenen 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 wei-
teren 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 In-
formationen analysiert um einerseits die benötigten Daten zu bestimmen und an-
dererseits die konkrete Implementierung zu beeinflussen. Im Abschnitt 4.1 sind die
eingehenden Daten aus der EventReader-Schnittstelle definiert. Daraus lässt sich un-
ter Anderem die Dimension der einzelnen Arrays aus dem Kalman-Filter ableiten,
welche konkreten Einfluss auf die Implementierung haben. Zunächst wird eine geeig-
nete Schnittstelle zum Device definiert, sodass eine sinnvolle Verarbeitung der Daten
möglich ist. Im einfachsten Fall wird eine