masterarbeit - lab4inf.fh-muenster.de · etc. der kalman-filter verbessert unter minimierung der...

93
Fachhochschule M¨ unster Masterarbeit Implementation eines CUDA basierten Kalman-Filters zur Spurrekonstruktion des ATLAS-Detektors am LHC Rene B¨ oing, B.Sc. [email protected] Matrikelnummer: 618384 16. Oktober 2013 Betreuer: Prof. Dr. rer. nat. Nikolaus Wulff Zweitpr¨ ufer: Dr. Sebastian Fleischmann

Upload: others

Post on 19-Oct-2020

5 views

Category:

Documents


0 download

TRANSCRIPT

  • Fachhochschule Münster

    Masterarbeit

    Implementation eines CUDA basierten Kalman-Filterszur 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 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