Untersuchungen zur Abstraktion der GPU-Programmierung in Java am Beispiel Fluid-Simulation

Größe: px
Ab Seite anzeigen:

Download "Untersuchungen zur Abstraktion der GPU-Programmierung in Java am Beispiel Fluid-Simulation"

Transkript

1 Masterarbeit Fakultät Informatik Untersuchungen zur Abstraktion der GPU-Programmierung in Java am Beispiel Fluid-Simulation Matthias Klaÿ Kleiberweg Augsburg Tel: Mat.-Nr.: Hochschule Augsburg An der Hochschule Augsburg Prüfer Prof. Dr. Meixner Start Abgabe Tel: info@hs-augsburg.de Fax:

2 i Abstract For the past years the programming of graphics units has gained importance. While the performance of normal processors increases only with linear gain, it duplicates for graphic processing units (GPU) with each new generation. As applications like real-time simulations or data evaluations require more and more computing power, the requirements posed to the underyling hardware also grows signicantly. To use the available potential of GPUs, developers have been dependent on native APIs, provided by dierent manufacturers like NVIDIA or AMD. However, not every program is written in native languages, which is why the demand for the ability to use the GPU computing power in higher-level programming languages increases. Both the evaluation of available libraries to use GPUs in Java and their usage to implement a uid simulation are part of this work. Kurzzusammenfassung Die Programmierung von Grakkarten hat in den vergangenen Jahren zunehmend an Bedeutung gewonnen. Während die Rechenleistung von Prozessoren nur langsam linear steigt, so verdoppelt sich diese für Grakkarten mit jeder Generation. Gleichzeitig steigen in verschiedensten Bereichen, wie z.b. Echtzeitsimulationen oder Auswertungen auf groÿen Datenmengen, die Anforderungen an die Rechenleistung der unterliegenden Hardware. Um das zur Verfügung stehende Potential der Grakkarten zu nutzen waren Entwickler bisher stark auf herstellerspezische Schnittstellen wie CUDA oder OpenCL angewiesen. Jedoch besteht der Bedarf an Rechenleistung nicht nur auf der Ebene nativer Sprachen, sondern ebenfalls unter Nutzung von höheren Programmiersprachen. Insbesondere die Evaluierung verfügbarer Abstraktionsbibliotheken sowie deren Verwendung zur Implementierung einer Fluid-Simulation sind Thema dieser Arbeit.

3 ii Masterarbeit Hochschule für angewandte Wissenschaften, Augsburg Fakultät für Informatik Hiermit erkläre ich, dass ich die folgende Masterarbeit Untersuchungen zur Abstraktion der GPU-Programmierung in Java am Beispiel Fluid-Simulation selbstständig verfasst, noch nicht anderweitig für Prüfungszwecke vorgelegt, keine anderen Hilfsmittel als die angegebenen Quellen oder Hilfsmittel verwendet sowie wörtliche und sinngemäÿe Zitate als solche gekennzeichnet habe. Matthias Klaÿ

4 iii Struktur der Arbeit Kapitel 1 motiviert die Verwendung von Grakkarten zur Berechnung rechenintensiver Aufgaben. Dabei wird u.a. darauf eingegangen, warum die Grakkarte für bestimmte Berechnungen besser als der normale Prozessor geeignet ist. Im Anschluss wird in Kapitel 2 beschrieben, wie die Programmierung der Grakkarten sich im Zeitverlauf entwickelt hat und warum eine Java-Abstraktion sinnvoll ist. Dabei werden u.a. auch Vor- und Nachteile gegenüber einer nativen Vorgehensweise aufgezeigt. Schlieÿlich geht das Kapitel kurz auf einige am Markt verfügbare Abstraktionsbibliotheken ein und beschreibt abschlieÿend das Ziel der Arbeit. Als Nächstes führt Kapitel 3 in die eigentliche Grakkartenprogrammierung ein. Dies beinhaltet auf der einen Seite die Architektur einer NVIDIA Grakkarte, also deren Ausführungsmodell und Speicherstruktur, und auf der anderen Seite eine kurze Einführung in CUDA-C zur Programmierung. Kapitel 4 beschäftigt sich anschlieÿend mit der Beschreibung der Navier-Stokes-Flüssigkeitsgleichungen und deren numerische Lösung zur Implementierung eines Simulators. Dies bezieht hauptsächlich die am Fraunhofer IWU implementierten Verfahren ein. Das sich anschlieÿende Kapitel 5 trägt das Thema Abstraktion. Darin werden verschiedene Anforderungen an Abstraktionsbibliotheken diskutiert und die Bibliotheken im Detail vorgestellt. Abschlieÿend wird ein Geschwindigkeitsbenchmark vorgestellt, anhand dem die Bibliotheken verglichen werden. Darauf aufbauend wird eine Bibliothek ausgewählt, unter deren Nutzung in Kapitel 6 der native Simulator des Fraunhofer IWU portiert wird. Zusätzlich wird die erstellte Implementierung, inkl. der unterliegenden Bibliothek, anhand den Aspekten Ausführungszeit und Objektorientierung weiter optimiert. Kapitel 7 fasst schlieÿlich die erreichten Ergebnisse zusammen und bietet einen Überblick über zukünftige Anforderungen an die Bibliothek und die Portierung. Fraunhofer IWU Die Arbeit ist in der Projektgruppe RMV des Fraunhofer IWU in Augsburg entstanden. Das Fraunhofer IWU beschäftigt sich mit der anwendungsorientierten Forschung auf dem Gebiet der Werkzeugmaschinen und Produktionstechnik. Die Projektgruppe RMV spezialisiert diese Gebiet im Rahmen der Ressourcenezienz. Dies deckt auf der einen Seite die Entwicklung von Werkzeugen und Maschinen ab, mit der die Produktion ezienter gestaltet werden kann, und auf der anderen Seite auch Möglichkeiten, um verwendete Rohstoe besser zu nutzen bzw. anschlieÿend wieder zu verwerten. Die Rahmenbedingungen der Masterarbeit stellt dabei die Forschungstätigkeit des Betreuers Herrn M.Sc. Stefan Krotil, die sich mit der Echtzeitsimulation von Flüssigkeiten im industriellen Umfeld beschäftigt. Aktuelle Simulationssoftware ist verhältnismäÿig langsam und nur schwer zu kongurieren. Diese wird deswegen nur selten in der Industrie eingesetzt. Durch die Verbindung

5 iv der Navier-Stokes-Gleichungen mit der Nutzung des Leistungspotentials der Grakkarten ergeben sich deswegen Vorteile in Bezug auf die Entwicklungsgeschwindigkeit und -kosten neuer Produkte. Danksagung Der Autor möchte sich zuerst bei Prof. Dr. Meixner (Hochschule Augsburg) für die Betreuung während der Masterarbeit bedanken. Insbesondere das oene Feedback und Kommentare über resultierende Evaluationsgraphen waren während der Arbeit sehr hilfreich. Das Thema der GPU- Abstraktion ist zwar ein unheimlich spannendes und für die Zukunft relevantes Themengebiet, der Autor wäre aber selbst nie auf die entsprechende Themenstellung gekommen. Auch hier vielen Dank für den Themenvorschlag! Auÿerdem geht ein Dankeschön an Herrn M.Sc. Stefan Krotil, der die Rahmenbedingungen für die Arbeit schuf und, obwohl die Arbeit selbst nur als Randthema seiner wissenschaftlichen Tätigkeit zu betrachten ist, stets mit Rat und Tat zur Seite stand. Insbesondere auch vielen Dank für die geduldigen Erklärungen zum Thema Navier-Stokes-Gleichungen!

6 Inhaltsverzeichnis 1 Motivation 1 2 Stand der Technik und Ziel der Arbeit Graphics Processing Unit, Grakprozessor (GPU)-Computing Native GPU-Programmierung vs. Java-Abstraktion CUDA-Abstraktionsbibliotheken für Java Programmierung einer GPU Architektur einer GPU mit CUDA Unterstützung Thread-Organisation Ausführung von Blöcken und Warps Speichermodell und -zugri CUDA Programmierung Beispiel Array-Inkrementierung Synchronisation von Threads Kompilierung von CUDA-Code OpenCL Navier-Stokes-Gleichungen zur Flüssigkeitssimulation Impuls- und Inkompressibilitätsgleichungen Diskretisierung des Simulationsraums Numerische Berechnung der Einzelterme der Navier-Stokes-Gleichungen Gröÿe der Simulationsschritte Datenstrukturen im vorhandenen Simulator Ablauf eines Simulationsschrittes CUDA-Abstraktion in Java Anforderungen an eine Java API Algorithmen zur Evaluation Evaluation verfügbarer Abstraktionsbibliotheken JCuda Java-GPU

7 Inhaltsverzeichnis vi Aparapi Rootbeer Delite Projekt Sumatra Evaluation der Ausführungsgeschwindigkeiten Randbedingungen der Messung Aufbau des Evaluations-Projektes Ergebnisse der Zeitmessung Fazit der Evaluation Portierung des Simulators Struktur des Simulators Erweiterung und Verbesserung von Aparapi Implementierung objektorientierter Einsprungpunkte Erhöhung der Geschwindigkeit durch Caching des besten OpenCL-Gerätes Aufruf von OpenCL-Methoden von auÿerhalb der Kernel-Klasse Implementierung der Verwendung von Objekten in Kernels Abbildung der neuen API auf den Fluid-Simulator Geschwindigkeitsvergleich zum nativen Simulator Fazit Erreichte Ziele Ansatzpunkte für zukünftige Verbesserungen Ausblick zur GPU-Entwicklung A Java-Native-Interface (JNI) 101 B Feature-Vergleich der Bibliotheken 103 C Proling im JNI- und GPU-Umfeld 106 C.1 Proling in Java C.2 Proling von JNI-C++-Quelltext C.3 Proling in CUDA Abkürzungsverzeichnis 111 Abbildungsverzeichnis 113 Literaturverzeichnis 114

8 Kapitel 1 Motivation Während der letzten Jahre sind die Anforderungen an Simulationssoftware massiv gestiegen. Um schneller berechnete Ergebnisse auswerten zu können, werden bisher als Batch-Bearbeitung durchgeführte Berechnungen heute als Echtzeit-Simulation umgesetzt. Auf diese Art und Weise können oensichtlich unsinnige Parameter bereits vor Beendigung der Simulation verworfen und damit schneller und ezienter die bestmögliche Einstellung gefunden werden. Ein Beispiel für eine rechenintensive Simulation ist die Berechnung des Verhaltens von Flüssigkeiten. Das Problem liegt dabei darin, dass z.b. für ein Gitter mit den Dimensionen eine Milliarde Zellen für einen einzigen Simulationsschritt bearbeitet werden müssen. Es handelt sich dabei nicht um einfache Additionen beliebiger Werte sondern um die Lösung komplexer Dierential-Gleichungen. Diese Berechnungen in Echtzeit durchzuführen ist mit Hilfe von sequentiellen Programmen auf Grund der Rechenmenge nicht möglich. Heute werden hierfür massiv parallele Ansätze verfolgt. Traditionell wurden alle Berechnungen auf der Central Processing Unit (CPU) ausgeführt. Diese ist auf die Minimierung der sequentiellen Ausführungszeit einer Aufgabe optimiert. Dazu wurden spezielle Architektur-Optimierungen wie Out-of-order-Ausführung, Schattenregister o.ä. eingeführt [KWH10][S. 3]. Eine Steigerung der Rechenleistung wurde durch immer höhere Taktraten ermöglicht. Aufgrund physikalischer Limitierungen, wie z.b. Leckströme, und der exponentiellen Zunahme des Stromverbrauchs für die Takterhöhung innerhalb einer CPU, ist dieser Weg in den letzten Jahren immer schwieriger und teurer geworden [KWH10][S. 1] [SK11][S. 3]. Stattdessen setzen die Hersteller vermehrt auf die Integration von mehreren CPU-Kernen sowie auf die optimierte Nutzung der vorhandenen Ressourcen durch Hyperthreading / Simultaneous Multithreading (SMT). Aktuelle Prozessoren besitzen dabei zwischen 6 und 16 Kerne und sind mit Frequenzen zwischen drei und vier GHz getaktet [int13] [SK11][S. 3]. Eine Alternative bietet die Nutzung von bereits auf die parallele Berechnung von Millionen von Partikeln optimierten Grakkarten. Das Anfang 2013 vorgestelle NVIDIA Flagschi, NVIDIA TITAN, stellt dabei 2688 Rechenkerne zur Verfügung [TIT13]. Im Gegensatz zur langsamen Steigerung der Kern-Zahlen bei CPUs verdoppeln sich diese bei GPUs mit nahezu jeder Generation [KWH10][S. 2]. Eine Übersicht der Entwicklung der Rechenleistungen von GPUs und CPUs ndet sich in Abbildung 1.1. Die in GPUs verbauten Recheneinheiten sind dabei deutlich schwächer als ihre in CPUs verbauten Verwandten. Während bei Letzteren Threads auf sequentielle Ausführungszeit optimiert sind, wird bei GPUs der Durchsatz maximiert. Dies wird durch Ausnutzung der Parallelität und nicht durch ausgefeilte Kontrollusslogik erreicht [KWH10][S. 4]. In besonders für die GPU-Ausführung geeigneten Anwendungsfällen sind damit Geschwindigkeitssteigerungen um mehr als den Faktor 100 realistisch.

9 Abbildung 1.1: Vergleich der Entwicklung der Rechenleistung von CPUs und GPUs [cud13][s. 2] 2

10 Kapitel 2 Stand der Technik und Ziel der Arbeit Der klassische Einsatzzweck von Grakkarten ist die Berechnung von Visualisierungen, insbesondere auch für Computerspiele. Die Verwendung von Grakkarten zur Berechnung komplexer Algorithmen und Formeln, wie es z.b. für eine Fluid-Simulation notwendig ist, ist dagegen verhältnismäÿig neu. Um das Jahr 2000 wurde das Potential dieser Art der Berechnung erkannt. Zu diesem Zeitpunkt gab es allerdings noch keine für derartige Berechnungen ausgelegte Application Programming Interface (API). Stattdessen wurde auf existierende Schnittstellen wie Open Graphics Library (OpenGL) und DirectX zurückgegrien und die numerische Berechnung durch die bereits verfügbaren Domänenobjekte der Grakberechnung abgebildet. Beispielsweise wurden für die Datenblöcke der Berechnung für die Speicherung von Farbblöcken gedachte Speicherstrukturen verwendet. Diese Art der numerischen Berechnung unter Verwendung von bestehenden Grak-Schnittstellen wird gemeinhin als General Purpose GPU (GPGPU)-Berechnung bezeichnet [SK11][S. 5] [fer09][s. 3]. 2.1 GPU-Computing Da diese Art der Nutzung der Grakkarte unübersichtlichen und schlecht wartbaren Quelltext produziert, führte NVIDIA im November 2006 Compute Unied Device Architecture (CUDA) ein. CUDA wird näher in Kapitel 3 beschrieben. Durch das neue Programmiermodell wurden Limitierungen durch die Nutzung von GPGPU-APIs aufgehoben und eine speziell für numerische Berechnungen erstellte API eingeführt [SK11][S. 6f] [KWH10][S. 5]. Dadurch kann explizit speziziert werden, ob ein Codeteil auf der für sequentiellen Code optimierten CPU oder auf der für parallele Algorithmen optimierten GPU ausgeführt werden soll. Die eigentliche Programmierung erfolgt dabei in C mit einer entsprechenden Erweiterung für CUDA. Diese erlaubt die Spezikation unterschiedlicher Parameter, wie z.b. die Anzahl an Threads, die an der Ausführung beteiligt sind, deren Aufteilung, sowie die Nutzung verschiedener Speicher-Architekturen. Sämtliche Parameter zur Ausführung auf der Grakkarte werden dabei händisch vom Entwickler speziziert. Dieser wird dabei nur sehr wenig von eventuell vorhandenen Bibliotheken oder der CUDA-API selbst unterstützt und muss durch grundlegendes Verständnis der zugrunde liegenden Hardware abschätzen, welche Optimierungsmöglichkeiten am Besten zum jeweiligen Anwendungsfall passen. Im Gegensatz zu GPGPU-Berechnungen steht damit eine explizite Schnittstelle für numerische Berechnungen zur Verfügung. Diese Art der Berechnung bezeichnet man auch als GPU-Computing [fer09][s. 3].

11 2.2. Native GPU-Programmierung vs. Java-Abstraktion Native GPU-Programmierung vs. Java-Abstraktion Die Entwicklung von CUDA als Schnittstelle, die explizit die Ressourcen der GPU für numerische Berechnungen bereitstellt, ist als Evolutionsschritt in der Nutzung der Grakkarte zu verstehen. Die native Art der Programmierung bringt dabei einige Nachteile mit sich. Im Wesentlichen lassen sich dabei die Nachteile von C++ und weiterer nativer Sprachen bzw. die Gründe zur Einführung höherer Programmiersprachen, wie z.b. Java, übertragen. C bzw. C++ erfordern, dass der Entwickler die Speicherverwaltung selbst übernimmt. Im Fall von CUDA-C ist die Sachlage noch komplexer, da hier zusätzlich speziziert werden muss, wo der Speicher (CPU oder GPU) alloziert werden soll. Gegebenenfalls müssen entsprechende Daten manuell in den Speicher der GPU transferiert werden. Ein erklärtes Ziel von Java ist es dagegen, diese explizite Speicherverwaltung an die Sprache zu delegieren [GM96][S. 14]. Im Wesentlichen sind auch die Verwaltung des GPU-Speichers sowie der zugehörige Transfer-Vorgang kapselbar. Diese können entsprechend von einer Bibliothek übernommen werden. Ein groÿer Vorteil von Java ist deren Objektorientierung, die es erlaubt, groÿe Programme in kleinere Strukturen aufzuspalten und damit den resultierenden Quelltext lesbar zu gestalten. C++ bietet entsprechende Möglichkeiten zur Objektorientierung ebenfalls, CUDA-C dagegen nicht. Entsprechend entstehen Programme mit vielen globalen und speziell für CUDA annotierten Funktionen und Variablen, die schnell in einer unübersichtlichen Architektur resultieren. Komplexität, die durch die Nutzung der Hardware-Architektur, also beispielsweise spezieller Speicher-Architekturen oder Ausführungsmodelle, entsteht, kann so nicht gekapselt werden und fällt entsprechend mehr ins Gewicht. Die Beschreibung der Summation aller Elemente eines Arrays ist z.b. eine Anfängeraufgabe. Dagegen benötigt eine eziente parallele Implementierung mehrere Schleifen, Synchronisation und komplexe Speicherarchitekturen (siehe dazu [Par07], Algorithmus von Blelloch). Die Erstellung des Quelltextes ist mitunter ein wesentlicher Unterschied der Programmierumgebungen. Durch die eingeschränkten Möglichkeiten in Java (z.b. keine Pointer) steht eine sehr gute Integrierte Entwicklungsumgebung (IDE)-Unterstützung zur Verfügung. Refactoring-Optionen, statische Code-Analyse und unterschiedlichste Bibliotheken helfen dabei verhältnismäÿig schnell gut zu wartenden Quelltext zu erstellen. Dagegen ist die IDE-Unterstützung für C++ und CUDA-C bestenfalls rudimentär. Fehlende Refactoring-Optionen, wenig statische Code-Analyse, also z.b. keine Anzeige von unbenutzten Variablen usw., führen zu schlecht wartbarem und unübersichtlichem Quelltext. Die Ausführung von in CUDA-C spezizierten Methoden auf der CPU ist in der Bibliothek nicht vorgesehen und erschwert damit gleichzeitig die Testbarkeit. Der Quelltext selbst ist in beiden Umgebungen schnell erstellt. Diesen allerdings anschlieÿend lauähig zu gestalten benötigt dagegen eine sehr unterschiedliche Zeitspanne. Nachfolgend sind beispielhaft zwei auftretende Fehlermeldungen aufgelistet: Der Thread 'Win64-Thread' (0x1564) hat mit Code 1 (0x1) geendet. Eine Ausnahme (erste Chance) bei 0x000007fefe199e5d in Fluid_Solver.exe: Microsoft C++-Ausnahme: cudaerror_enum an Speicherposition 0x0014f590..

12 2.3. CUDA-Abstraktionsbibliotheken für Java 5 Die Aufgabe von Fehlermeldungen ist es auf einen aufgetretenen Fehler hinzuweisen. Diese Anforderung erfüllen die beiden Beispielmeldungen. Allerdings wird kein Hinweis auf die Ursache gegeben. Irgendwann im Programm ist ein Thread abgestürzt. Gleichzeitig ist irgendwo im Programm auf eine fehlerhafte Speicherposition der GPU zugegrien worden. Fraglich bleibt dabei, wo exakt der Fehler aufgetreten ist. Ein Java-Entwickler ist daran gewöhnt die letzten Anweisungen in Form eines Stack-Traces auf der Konsole angezeigt zu bekommen, natürlich inklusive Zeilennummern und Quelltext-Datei. Der Fehler kann damit oftmals auf den ersten Blick erkannt und behoben werden. Im Fall von CUDA-C und C++ ist die Verwendung des Debuggers notwendig, wobei so lange Instruktionen durchlaufen werden müssen, bis durch systematisches Eingrenzen die fehlerhafte Anweisung gefunden wurde. Auch dies trit allerdings nicht auf alle Fehler zu. Wird beispielsweise vergessen Speicher freizugeben oder die Referenz auf allozierte Speicherbereiche verloren, so entstehen Lecks, die innerhalb der Applikation zu Fehlern führen. Die Ursache ist dabei nicht trivial zu nden, da jede einzelne Zeile im Quelltext dazu beitragen kann. In Java ist dies durch die automatische Speicherverwaltung durch den Java Garbage Collector nicht möglich, womit eine signikante Steigerung der Entwicklungsgeschwindigkeit erzielt werden kann. Entsprechendes ist ebenfalls Ziel der Java-Abstraktion. Auf der einen Seite sollte die explizite Speicherverwaltung dem Entwickler abgenommen werden und nur optional zur Verfügung stehen. Auf der anderen Seite sollte eine Fehlerbehandlung so implementiert werden, sodass die Fehler auf konkrete Quelltext Zeilen verweisen und nicht auf Speicherbereiche. 2.3 CUDA-Abstraktionsbibliotheken für Java Java bietet durchaus viele Vorteile gegenüber C++ und CUDA-C, wie aus dem vorherigen Abschnitt hervorgeht. Ab dem Jahr 2010 startete deshalb auch die Entwicklung verschiedener Abstraktionsmodelle zur Nutzung von CUDA in Java. Einzelne Bibliotheken werden näher in Kapitel 5.3 beschrieben. Grundsätzlich sind fünf Bibliotheken verfügbar: JCuda, Java-GPU, Aparapi, Rootbeer und Delite. Rootbeer, Delite und Java-gpu sind dabei Bibliotheken, die an Universitäten entstanden sind. Delite stammt aus dem Pervasive Parallelism Laboratory (PPL) der Stanford-Universität [BSL + 11], Rootbeer von der Syracuse-Universität in New York [PSFW12] und Java-gpu vom Trinity College in Dublin [Cal10]. Delite ist dabei die einzige Bibliothek, die aktiv von einer ganzen Gruppe weiter entwickelt wird. Die anderen Bibliotheken wurden in Master- oder Doktorarbeiten entworfen und implementiert. Hinter Aparapi und JCuda stehen dagegen keine Universitäten. Zu JCuda ist hier nichts näher bekannt. Aparapi wurde hingegen von AMD zur SuperComputing 2009 entworfen und vorgestellt. Ende 2012, Anfang 2013 nahm auch ein Projekt der virtuellen Maschine von Java die Arbeit zur nativen Integration von GPUs auf (Projekt Sumatra, [Sum13a]). Die Bibliotheken nehmen für sich in Anspruch, dem Entwickler die vielen Einzelschritte zur Ausführung von Programm-Code auf der GPU abzunehmen und den generierten Code trotzdem performant auszuführen. Dabei soll es insbesondere möglich sein, sehr viel einfacher und schneller einfach verständliche und gut lesbare Algorithmen zu entwickeln, die gleichzeitig auch

13 2.3. CUDA-Abstraktionsbibliotheken für Java 6 testbar bleiben. Durch die entsprechend geringere Implementierungszeit soll dabei die schnelle Evaluierung und Implementierung unterschiedlicher Algorithmen ermöglicht werden [PSFW12]. Die Entwickler der Bibliotheken schlieÿen dabei aber nicht aus, dass anschlieÿend getestete und evaluierte Algorithmen wiederum in CUDA-C oder Open Computing Language (OpenCL), dem von AMD geprägten Standard zur Programmierung heterogener Architekturen, nachprogrammiert werden, um das dort zur Verfügung stehende Optimierungspotential zu nutzen und die entwickelte Implementierung weiter zu beschleunigen [apa13b]. Neben der vereinfachten Entwicklung von Algorithmen zur Ausführung auf der Grak-Hardware steht durch die Verwendung derartiger Bibliotheken allen in Java programmierten Anwendungen die Nutzung der zusätzlichen, parallelen, Ressourcen oen. Somit können auch Anwendungen, die groÿe Datenmengen verarbeiten, durch die stark nebenläuge Ausführung beschleunigt werden. Ein Beispiel hierfür ist die angedachte Beschleunigung der Apache Hadoop Bibliothek zur Auswertung groÿer Datenmengen durch die Verwendung von GPU-Berechnungen. Ziel der Arbeit Diese Arbeit verfolgt im Wesentlichen zwei Einzelziele: Die oben genannten fünf Bibliotheken sind auf dem Markt verfügbar und können frei verwendet werden. Einige Bibliotheken sind im Entwicklungsstand weiter fortgeschritten, einige sind mitten in der Entwicklung und manche werden bereits nicht mehr weiter entwickelt. Eine Übersicht über den momentanen Entwicklungsstand bzw. ein unvoreingenommener Vergleich existiert im Augenblick nicht. Dieser stellt, einschlieÿlich der Beschreibung der Funktionsweise der Bibliotheken sowie einem Funktions- und Geschwindigkeitsvergleich, eine Säule der Arbeit dar. Als zweite Säule dient der Nachweis, dass eine ausgewählte Bibliothek in der Lage ist, einen komplexen Anwendungsfall auf der GPU zu implementieren. Dabei soll gezeigt werden, dass die Funktionalität der resultierenden Anwendung identisch ist, gleichzeitig der entstehende Quelltext aber besser zu warten ist. Insbesondere soll dabei Wert auf die Testbarkeit der nebenläugen Algorithmen sowie auf eine objektorientierte Programmstruktur gelegt werden. Unter Umständen ist es dabei auch nötig eine für die Portierung ausgewählte Bibliothek weiter anzupassen, sodass der Quelltext besser lesbar, schneller ausführbar oder strukturierter ist. Die beiden Ziele sind jeweils auf die bereits erwähnte Simulation von Flüssigkeiten zu beziehen. Das Fraunhofer IWU entwickelt derzeit einen auf CUDA basierenden Simulator, der unter Nutzung verschiedener, für die GPU optimierter, Algorithmen Fluide simulieren und anzeigen kann. Die Berechnung umfasst dabei die Lösung komplexer Poisson- und Dierentialgleichungen. Der Anwendungsfall ist optimal, da auf der einen Seite komplexe und zeitaufwändige Berechnungen durchgeführt werden müssen, gleichzeitig aber auch jede Zelle unabhängig von jeder anderen Zelle berechnet werden kann.

14 Kapitel 3 Programmierung einer GPU Im bisherigen Verlauf der Arbeit wurden CUDA und die GPU-Programmierung nur namentlich erwähnt. Um verschiedene Abstraktionsbibliotheken evaluieren zu können muss dagegen ein grundsätzliches Verständnis der Hardware-Architektur sowie des Programmiermodells vorliegen. Deswegen führt der nachfolgende Abschnitt näher in CUDA ein, beschreibt das Ausführungsmodell und die Speicherarchitektur der Grakkarte sowie, schlieÿlich, den CUDA-Konkurrenten OpenCL. 3.1 Architektur einer GPU mit CUDA Unterstützung GPUs zeichnen sich durch die massiv parallele Ausführung von Aufgaben aus. Genau wie bei CPUs wird die nebenläuge Ausführung eines Programms auch hier durch mehrere Ausführungsfäden (Threads) erreicht. Um sich allerdings nicht, wie auf CPUs, durch die Verwendung mehrerer 1000 Threads, Probleme wie Race-Conditions oder Deadlocks einzuhandeln, stellen NVIDIA, bzw. die Khronos Group für OpenCL, eine spezielle Architektur zur Verfügung. Die nachfolgende Beschreibung konzentriert sich dabei auf CUDA, da diese im Fraunhofer IWU verwendet wird. OpenCL funktioniert dabei, unter Verwendung anderer Begrie, analog Thread-Organisation Die Verwaltung der Menge an verfügbaren Threads folgt in CUDA einer speziellen Organisation. Threads werden immer in sogenannte Blöcke partitioniert, wobei die maximale Blockgröÿe eine hardwarespezische Konstante darstellt. Bei modernen GPUs liegt diese bei 1024 Threads pro Block, bei Älteren nur bei 512 oder weniger. Alle Threads in einem Block werden auf der GPU in der gleichen Hardwareeinheit abgearbeitet. Ausgeführt werden die Threads nicht einzeln, sondern immer als Gruppe von Threads, sogenannte Warps. Ein Warp bezeichnet eine Menge von 32 aufeinander folgender Threads [KWH10][S. 71]. Der Begri Warp ist NVIDIA spezisch. AMD bzeichnet in OpenCL 64 Threads, die zusammen ausgeführt werden, als Wavefronts [gcn12][s. 3]. Threads auf CPUs unterscheiden sich stark von Threads auf GPUs. Während Threads auf CPUs jeweils unterschiedliche Instruktionen ausführen können, führen alle Threads innerhalb eines gerade auszuführenden Warps immer dieselbe Instruktion aus. NVIDIA bezeichnet das Ausführungsmodell als Single Instruction, Multiple Thread (SIMT) [cud13][s. 66]. Bevor jeweils die nächste Instruktion geladen und dekodiert wird, wird demnach die momentane Instruktion auf alle Threads angewendet [KWH10][S. 98]. Dieses Vorgehen resultiert in einer massiven Entlas-

15 3.1. Architektur einer GPU mit CUDA Unterstützung 8 tung der Hardware zum Holen und zur Dekodierung neuer Instruktionen, da für alle 32 Threads im aktiven Warp dieselbe Instruktion wiederverwendet werden kann. Obwohl alle Threads in einem Warp demselben Ausführungspfad folgen, kann auch eine Verzweigung für einzelne Threads des Warps erfolgen (Branch Divergence, [cud13][s. 67], [KWH10][S. 98]). Dies hat allerdings zur Folge, dass alle nicht vom Hauptpfad abweichenden Threads warten müssen, da auf Grund des SIMT-Ausführungsmodells eine Instruktion auf alle Threads des Warps angewendet werden muss. SIMT ist dabei nicht mit Single Instruction, Multiple Data (SIMD) zu verwechseln. Bei SIMD wird eine Instruktion auf viele Datensätze angewandt. Im Gegensatz dazu wird bei SIMT eine Instruktion an alle Threads des Warps verteilt. Die Instruktion beeinusst damit nicht nur das Ergebnis einer Rechnung, sondern den Kontrolluss eines Threads [LNOM08][S. 44]. Die Organisation vorhandener Threads in Form eines Grids an Blöcken, die wiederum Threads enthalten, ist in Abbildung 3.1 illustriert Ausführung von Blöcken und Warps Hauptbestandteil der GPU sind die Streaming-Multiprozessoren (SMs). Im Wesentlichen handelt es sich dabei um die Kapselung eines Instruktions-Caches, einer Einheit zum Abholen neuer Befehle, einem speziellen Speicher, der näher in Abschnitt erläutert wird, sowie um eine bestimmte Anzahl an Streaming-Prozessoren (SPs) (alias CUDA-Core, die Fermi Architektur enthält beispielsweise 32 Stück [fer09][s. 8]) und Gleitkomma-Berechnungseinheiten. Die auszuführenden Blöcke werden auf SMs mit freien Kapazitäten verteilt (siehe Abbildung 3.2) und von den SP des SM verarbeitet. Wie viele Blöcke parallel ausgeführt werden können hängt von den benötigten Ressourcen eines Blocks ab. Sind nicht genügend Ressourcen vorhanden, um z.b. acht Blöcke parallel auszuführen, so werden automatisch weniger Blöcke an den SM verteilt [KWH10][S. 70f]. Da damit weniger Blöcke gleichzeitig ausgeführt werden können, sinkt damit auch die Nebenläugkeit der Gesamtausführung und letztendlich auch die Ausführungsgeschwindigkeit. Beispiele für begrenzte Ressourcen beziehen sich auf die maximale Anzahl an Threads, die von einem SM überwacht und ausgeführt werden können. Bei modernen GPUs liegt diese Konstante bei Threads [kep12][s. 7], die beispielsweise in zwei Blöcke mit jeweils Threads, vier Blöcke mit jeweils 512 Threads oder acht Blöcke mit 256 Threads aufgeteilt werden können. Wenn die Obergrenze an parallel abarbeitbaren Blöcken schon bei 8 Blöcken liegt, ist eine Aufteilung in mehr als 8 Blöcke u.u. ebenfalls nicht möglich [KWH10][S. 71]. Ein weiteres Beispiel für begrenzte Ressourcen ist die maximale Anzahl an Registern, die pro SM zur Verfügung stehen. Moderne GPUs stellen hier Register zur Verfügung. Geht man von einer maximalen Anzahl von Threads im Block aus, so stehen jedem Thread 32 Register zur Verfügung. Werden mehr Register pro Thread verwendet, so wird die Anzahl an Blöcken pro SM reduziert und damit die Ausführungszeit u.u. deutlich erhöht [KWH10][S. 112f]. Schlieÿlich ist noch zu klären, warum Blöcke überhaupt in Warps aufgeteilt werden. Es handelt sich dabei um das Mittel der GPU zur Steigerung des Datendurchsatzes. Grund dafür ist, dass ein Warp während einer längeren Warte-Operation, wie z.b. einem Speicherzugri, blockiert ist.

16 3.1. Architektur einer GPU mit CUDA Unterstützung 9 Host Kernel 1 Device Grid 1 Block (0,0) Block (1,0) Block Block (0,1) (1,1) Block (1,1) Kernel 1 Grid 2 Thread (0,0,0) Thread (0,1,0) (0,0,1) (1,0,1) (2,0,1) Thread (1,0,0) Thread (1,1,0) Thread (2,0,0) Thread (2,1,0) Abbildung 3.1: Thread-Organisation [KWH10][S. 63] In diesem Fall kann der Warp-Scheduler, der dafür zuständig ist, welcher Warp als nächstes ausgeführt wird, einen anderen Warp auswählen und ausführen. Beim Wechsel entsteht kein zusätzlicher Overhead (Zero-Overhead-Scheduling). Im Endeekt wird also, durch den schnellen Wechsel, die Latenz, die durch die langen Wartezeiten entsteht, versteckt und die Ressourcen optimal ausgenutzt (Latency-Hiding, [KWH10][S. 73]) Speichermodell und -zugri Zur optimierten Ausführung muss der Entwickler die Hardware-Architektur der GPU kennen. Insbesondere die Verwendung spezieller Speicherarchitekturen ermöglicht dabei eine signikante Steigerung der Ausführungsgeschwindigkeit. Allein die Verwendung des nachfolgend beschriebenen Shared-Memory ermöglicht im Anwendungsfall Matrix-Multiplikation eine weitere Steigerung der Geschwindigkeit um den Faktor 16 [KWH10][S. 90]. Host, Device und Kernel Vor der eigentlichen Beschreibung, welche Speichertypen Grakkarten zur Verfügung stehen, werden noch drei Begrie eingeführt. NVIDIA unterscheidet die Begrie Host und Device. Device verweist dabei auf die GPU und den zugehörigen Speicher, Host auf die CPU und deren Speicher [SK11][S. 23]. Die Speicherbereiche sind normalerweise physikalisch voneinander getrennt, sodass ein Aufruf auf dem falschen Gerät eine Fehlermeldung auslöst. Abschlieÿend wird als Kernel eine Funktion bezeichnet, die auf einem Device ausgeführt wird [SK11][S. 23]. Die Speichertypen unterscheiden sich v.a. in Hinblick auf Zugrisgeschwindigkeit und Sichtbarkeit der Speicherinhalte.

17 3.1. Architektur einer GPU mit CUDA Unterstützung 10 CUDA Programm Block 0 Block 1 Block 2 Block 3 Block 4 Block 5 Block 6 Block 7 GPU mit 2 SMs SM 0 SM 1 Block 0 Block 2 Block 4 Block 6 Block 1 Block 3 Block 5 Block 7 GPU mit 4 SMs SM 0 SM 1 SM 2 SM 3 Block 0 Block 1 Block 2 Block 3 Block 4 Block 5 Block 6 Block 7 Abbildung 3.2: Aufteilung von Blöcken auf mehrere SM [cud13][s. 7] Als Ziel für den initialen Kopiervorgang können Daten vom Host entweder in das Global-, Constant- oder Texture-Memory des Devices geladen werden. Global Memory Der globale Speicher umfasst typischerweise mehrere GB an Speicherplatz und bietet damit Raum zum Speichern aller notwendigen Rechendaten. Die Menge an Speicherplatz wird dabei mit sehr hohen Zugriszeiten erkauft, die leicht mehrere hundert Taktzyklen betragen können. Damit wird ein entsprechender Zugri schnell zum Flaschenhals einer Applikation. Im Endeekt entsteht schlieÿlich aus einem Zugri auf das Global-Memory durch die nebenläuge Ausführung von Threads und Warps eine ganze Menge an Zugrien [cud13][s. 74]. Constant Memory Elemente im bereits erwähnten Speicher für Konstanten werden im globalen Speicher abgelegt. Statt allerdings bei jedem Zugri wiederum den langsamen Speicherzugri auszuführen, wird der Inhalt der letzten Zugrie in einem speziellen Cache auf dem SM zwischengespeichert (Constant-Cache). Ezient ist dieser Speicher damit nur nutzbar, wenn ein Speicherbereich mehrfach bzw. von vielen unterschiedlichen Threads abgerufen wird, da nur in diesem Fall die geringe Zugriszeit greift. Ist ein Datum nicht im Cache vorhanden, so muss trotzdem auf den langsamen globalen Speicher zurückgegrien werden. Um Inkonsistenzen zu vermeiden, ergibt sich durch die Zwischenspeicherung im Cache eines SM die Limitierung, dass nur der Host den Konstanten-Speicher verändern darf. Für das Device ist dieser Speicherbereich nur lesbar. Schlieÿlich ist auch die Menge an Elementen, die im Speicherbereich für Konstanten zwischengespeichert werden können, sehr beschränkt. Eine aktuelle NVIDIA GTX 680 Grakkarte bietet dafür rund 65 kb an Speicher [KWH10][S. 82]. Die Begrenzung auf eine relativ geringe Menge an verfügbarem Speicher auf SMs ist charakteristisch für CUDA und ndet sich teilweise auch in den weiteren speziellen Speichertypen wieder. Texture Memory Ein weiterer, dem Constant Memory sehr ähnlicher, Speichertyp ist das Texture Memory. Entsprechend dem Constant Memory werden auch hier Daten in einen Texture-

18 3.2. CUDA Programmierung 11 Cache auf dem Device zwischengespeichert. Jedoch ist der Cache darauf optimiert gute Zugriszeiten auch auf direkt angrenzende Zellen zu bieten [cud13][s. 73]. Drei weitere Speichertypen sind ausschlieÿlich für das Device sicht- und schreibbar. Register Zuerst gibt es die bereits erwähnten Register, die jedem Thread zur Verfügung stehen. Diese Art der Speicherung besitzt die schnellste Zugriszeit, ist allerdings durch die zur Verfügung stehende Anzahl an Speicherplätzen sehr begrenzt. Moderne Devices stellen hier zwischen 63 und 255 Register pro Thread zur Verfügung [kep12][s. 7]. Werden in einem Kernel lokale Arrays verwendet bzw. mehr Register initialisiert, als für einen Thread zur Verfügung stehen, so wird dieser Speicher im globalen Speicher alloziert und steht dementsprechend auch mit der damit einhergehenden langsamen Zugriszeit zur Verfügung [cud13][s. 77]. Diese Art von Speicher nennt man lokalen Speicher (Local-Memory). Shared-Memory Der letzte Speicher, der in diesem Rahmen erwähnt werden soll, ist das Shared-Memory. Der Speicherbereich steht, im Gegensatz zu Registern und dem lokalen Speicher, nicht pro Thread, sondern pro Block zur Verfügung. Genau wie bei Registern ist dieser Speicherplatz auf dem Chip angesiedelt und damit sehr schnell im Zugri. Allerdings ist der Speicherplatz stark begrenzt. Ein modernes Device stellt hier rund 48 kb zur Verfügung. Der Speicherplatz selbst ist in mehrere Bänke, typischerweise 16, aufgeteilt. Gleichzeitige Zugrie auf die selbe Bank werden serialisiert ausgeführt und als Bank-Konikt (bank conict) bezeichnet [cud13][s. 77]. Shared-Memory eignet sich gut um in einem Block mehrere Threads kooperieren zu lassen [KWH10][S. 80]. Allerdings handelt man sich im Gegenzug eine stark erhöhte Komplexität ein, da mit falschen Zugrien Bank-Konikte auftreten können, die die Geschwindigkeitssteigerung wieder zunichte machen. Auÿerdem erfordert die Programmierung von Shared-Memory spezielle Algorithmen, sodass nur Threads in einem Block kooperieren müssen und nicht auf Ergebnisse von anderen Blöcken angewiesen sind. Diese stehen auf Grund der lokalen Natur des Speichertyps nicht zur Verfügung. Gerade im Hinblick auf die unterschiedlichen Speichertypen lassen sich bei einer Anwendung hohe Geschwindigkeitssteigerungen erzielen. Gleichzeitig entsteht, v.a. durch Shared-Memory, eine stark erhöhte Komplexität, da der Programm-Code unter dessen Nutzung nicht nur von den auszuführenden Threads, sondern zusätzlich von der zugrunde liegenden Blockstruktur abhängig ist. 3.2 CUDA Programmierung Um anschlieÿend verschiedene Frameworks zur CUDA-Abstraktion evaluieren zu können, stellt dieser Abschnitt die Kern-Anweisungen von CUDA-C vor. In keiner Weise sollen alle Konzepte und Anweisungen der Spracherweiterung abgedeckt werden. Dafür wird an dieser Stelle auf die Bücher [SK11] und [KWH10] verwiesen.

19 3.2. CUDA Programmierung 12 (Device) Grid Block (0,0) Shared Memory Block (1,0) Shared Memory Register Register Register Register Thread (0,0) Thread (1,0) Thread (0,0) Thread (1,0) Host Global Memory Constant Memory Abbildung 3.3: Device Speichermodell [KWH10][S. 47] Beispiel Array-Inkrementierung Als Einstiegsprogramm sollen alle Elemente eines Arrays inkrementiert werden. Das Programm ist nicht besonders komplex und weist eine sehr hohe Speichersättigung durch das geringe Verhältnis zwischen Berechnung und Speicherzugri auf, dient aber gerade auf Grund der Unabhängigkeit der Einzeloperationen als gutes Beispiel. Die Lösung ist in Abbildung 3.1 abgedruckt. Wie bereits erwähnt ist CUDA-C eine Erweiterung der Programmiersprache C. Einstiegspunkt des Programms ist folglich eine implementierte main-methode. Als Erstes wird der zu bearbeitende Array initialisiert und in eine Variable h_content geschrieben. Das h_ steht dabei für Host. Dies ist eine gern verwendete Konvention, um unterscheiden zu können, ob der zugehörige Speicher auf dem Device (d_) oder auf dem Host zu nden ist. Anschlieÿend wird globaler Speicher auf der Grakkarte alloziert. Das Kommando cudamalloc übernimmt dies nach Übergabe einer Variablenadresse und der erwarteten Speichergröÿe. Dies entspricht im Wesentlichen dem Verhalten des malloc-kommandos in C. Mit cudamemcpy(ziel, Quelle, Gröÿe, Richtung) wird der Inhalt einer Variablen auf das Device verschoben. Einzig erklärungsbedürftiger Parameter ist hier die Richtung. Diese gibt an, ob Inhalt vom Host auf das Device kopiert wird (cudamemcpyhosttodevice), vom Device auf das Device (cudamemcpydevicetodevice) oder vom Device auf den Host (cudamemcpydevicetohost). Nachdem der Speicher mit den zu bearbeitenden Werten gefüllt ist, kann ein Kernel-Aufruf erfolgen. Dieser sieht wie ein normaler Methodenaufruf aus. Einziger Unterschied sind die beiden Variablen gridsize und blocksize, die in drei spitze Klammern gehüllt sind (<<<... >>>). In Abschnitt wurde beschrieben, dass Threads immer in Blöcke aufgeteilt werden. An der Stelle der Variable gridsize wird hier die Anzahl an Blöcken eingetragen, die zur Ausführung benötigt werden, während blocksize angibt, wie viele Threads in einem Block vorhanden sind [KWH10][S. 42]. Im Beispiel werden also zwei Blöcke mit jeweils 512 Threads initialisiert. Wie in Abbildung 3.1 angedeutet, ist auch die Übergabe mehrdimensionaler (bis zu drei Dimensionen)

20 3.2. CUDA Programmierung 13 global void incrementkernel ( unsigned int * d_ content, unsigned int count ) { int tid = blockidx. x * blockdim. x + threadidx. x; if ( tid > count ) return ; d_content [ tid ] += 1; } int main ( void ) { int count = 1000, size = sizeof ( unsigned int ) * count ; //... ( Initialisierung des Arrays ) unsigned int * d_ content ; cudamalloc (( void **) & d_ content, size ); cudamemcpy ( d_content, h_content, size, cudamemcpyhosttodevice ); int blocksize = 512; int gridsize = ( count / blocksize ) + 1; incrementkernel < < < gridsize, blocksize > > >( d_ content, count ); cudamemcpy ( h_content, d_content, size, cudamemcpydevicetohost ); } //... ( Ausgabe des Inhalts und Freigabe der Speicherbereiche ) Quelltext 3.1: Map-Kernel zur Inkrementierung aller Array-Elemente

21 3.2. CUDA Programmierung 14 Blöcke möglich. Für verschiedene Anwendungsfälle, z.b. bei der Implementierung eines Kernels zur Matrixmultiplikation, sind zwei Dimensionen sinnvoll, da dadurch nicht händisch die x- und y-koordinaten aus einer globalen Thread-ID berechnet werden müssen. Damit wird der Quelltext übersichtlicher und durch eine fehlende, und sehr zeitaufwändige, Modulo-Operation zur Berechnung der x- und y- Koordinaten aus einer globalen ID, schneller. Gleichzeitig muss bei der Spezikation der Blockgröÿen beachtet werden, dass auch hier nicht die maximale Blockgröÿe von 512 bzw Threads pro Block überschritten wird. Nach Ausführung des Kernels wird der Inhalt von der Grakkarte wieder zurück kopiert und ausgegeben. Schlieÿlich sollte nicht vergessen werden den Speicher auf der Grakkarte wieder mit cudafree freizugeben. Speicherlecks wirken sich hier bis zum Neustart des Rechners aus. Das eigentliche Kernstück des Programms ist der nebenläug ausgeführte Kernel. Dieser sieht im Groÿen und Ganzen wie eine C-Funktion aus. Besonders wichtig ist das Kürzel global in der Signatur der Funktion. Dieses gibt an, dass die Funktion auf dem Device auszuführen ist. Neben global existieren noch die Kürzel device und host. Ersteres bezeichnet dabei Funktionen, die nur vom Device aufgerufen werden und auch auf dem Device wiederum ausgeführt werden. Letzteres bezeichnet Funktionen, die auf dem Host ausgeführt werden. host ist auch der Standardfall für alle nicht annotierten Funktionen. Die letzten beiden Kürzel können auch in Kombination verwendet werden. Damit wird angegeben, dass eine Funktion sowohl von Host als auch von Device aufrufbar ist [KWH10][S. 52]. Die Grundidee an diesem Kernel ist, dass jeder Thread ein Element des Arrays inkrementiert. Um festzustellen, welcher Thread welches Element inkrementieren soll, ist eine Abbildung der Threads auf den Zahlenraum der natürlichen Zahlen notwendig. CUDA stellt hierfür die Variablen blockid, blockdim und threadid zur Verfügung. Theoretisch könnten hier, wie vorher erwähnt, alle Dimensionen x, y und z verwendet werden. Da in diesem Beispiel nur die x- Dimension verwendet wird, wird auch nur.x auf den Variablen aufgerufen. Die eindeutige ID eines Threads ergibt sich durch Multiplikation der Block Dimension (blockdim.x) mit der Block- ID (blockidx.x) addiert mit der momentanen Thread-ID im aktuellen Block (threadidx.x). Da immer ganze Blöcke initialisiert werden, kann es vorkommen, dass Threads mit den höchsten zugewiesenen Zahlen keine Entsprechung im Array nden, da dieser nicht genügend Elemente aufweist. Um Ausführungsfehler zu vermeiden wird dies vor der Ausführung der eigentlichen Kernel-Anweisungen geprüft. Erst dann nden Array-Zugrie und Berechnungen statt. In diesem Fall werden beispielsweise = 1024 Threads für eine Array-Gröÿe von 1000 gestartet. Ohne die Prüfung fänden 24 Threads keine Entsprechung im Array. Bei der Ausführung würde folglich eine unspecified launch failure Fehlermeldung auftreten Synchronisation von Threads Zur Synchronisation von Threads steht eine Methode zur Verfügung, die es erlaubt durch eine Barriere darauf zu warten, dass alle Threads im aktuellen Block bis zur Barriere aufgeschlossen haben. Erst dann wird die Ausführung fortgesetzt. Zur Erstellung einer derartigen Barriere wird die Funktion syncthreads() aufgerufen [cud13][s. 94]. Allerdings sind bei der Verwendung dieser Methode zwei Fallstricke zu beachten:

22 3.2. CUDA Programmierung 15 Die Barriere gilt nur für alle Threads in einem Block. Eine Synchronisierung über mehrere Blöcke hinweg ist generell nicht möglich. Durch diese Einschränkung können alle Blöcke unabhängig voneinander ausgeführt werden. Allerdings ist darauf zu achten, dass alle zu berechnenden Programmabschnitte entsprechend unabhängig voneinander sind, sodass trotz der fehlenden Synchronisation das korrekte Ergebnis ermittelt wird [KWH10][S. 69]. Die Aufteilung in unabhängige Blöcke ndet sich in der Literatur auch unter dem Stichwort tiling. Ein zweiter Fallstrick bezieht sich auf Synchronisationspunkte, die nur unter vorheriger Auswertung einer if-bedingung erreichbar ist. Wird die Bedingung von einigen Threads positiv und von einigen Threads negativ ausgewertet, so blockiert die Ausführung, da nicht alle Ausführungspfade die Barriere erreichen. Die weitere Ausführung wird erst freigegeben, wenn alle Threads genau diese erreicht haben. Auch das Setzen eines zusätzlichen Synchronisationspunktes in den else Zweig der Bedingung schat hier keine Abhilfe, da dieser Punkt als neue Barriere angesehen wird [KWH10][S. 69]. Da die Ausführungsumgebung garantiert, dass ein Kernel-Aufruf vor einem nachfolgenden Kernel Aufruf abgeschlossen ist, ist auch eine Art Synchronisation über mehrere Kernel-Aufrufe hinweg möglich. Ein Block übergreifender Synchronisationspunkt ist in diesem Fall das Ende eines Kernel. Diese Art der Synchronisation verursacht allerdings zusätzliche Kosten, da die verschiedenen Kernel-Aufrufe verwaltet werden müssen [cud13][s. 70] Kompilierung von CUDA-Code Um CUDA-Code zu kompilieren stellt NVIDIA einen eigenen Werkzeugkasten zur Verfügung. Dieser beinhaltet verschiedene Werkzeuge und Formate zur Kompilierung und Ausführung. Zu Beginn der Kompilierung werden der Host- und der Device-Code in der Quelltext-Datei getrennt. Der Host-Code wird als normaler C oder C++ Code weiter kompiliert. Unter Unix kommt hier die GNU Compiler Collection (GCC) zum Einsatz, unter Windows der Visual Studio C Compiler [nvc13][s. 2]. Der Device-Code wird getrennt behandelt. CUDA unterscheidet zwischen einer virtuellen Architektur (compute architecture) und einer realen GPU-Architektur (sm architecture). Grund hierfür ist, dass NVIDIA die Binärkompatibilität der Grakkartengenerationen zugunsten zukünftiger Verbesserungen nicht zusichern möchte. Stattdessen wurde das Parallel Thread execution architecture (PTX)-Zwischenformat eingeführt, das in keiner Weise von den eigentlich auf der GPU zur Verfügung stehenden Instruktionen abhängt, sondern nur über die zur Verfügung stehenden GPU-Features, z.b. atomare Operationen, deniert wird. Erst in einem zweiten Schritt wird von der GPU abhängiger binärer Code erzeugt und ggf. in einer binären cubin-datei gespeichert. Wie in Abbildung 3.4 illustriert werden beide Architekturen bei der Kompilierung angegeben. Die virtuelle Architektur gibt also an, von welchen allgemeinen Features der Kernel-Code abhängt. Diese müssen von der eigentlichen Grakkarte später zur Verfügung gestellt werden. Die reale Architektur muss zur virtuellen Architektur passen, muss aber nicht zwingend angegeben werden [nvc13][s. 29]. Stattdessen kann ein Just-In-Time (JIT)-Compiler verwendet werden. Zur Laufzeit wird vom Ausführungssystem entschieden welche reale Architektur verwendet werden soll. Der Zwischen-

23 3.2. CUDA Programmierung 16 *.cu (Device Code) Virtuelle compute Arch. Phase 1 (nvopencc) PTX Reale sm Arch. Phase 2 (ptxas) Ausführung (CUDA Gerätetreiber) cubin Abbildung 3.4: Kompilierung einer CUDA Quelltext-Datei über die PTX -Zwischenrepräsentation zu einer binären cubin-datei [nvc13][s. 30] code wird anschlieÿend für die reale Architektur kompiliert und ausgeführt [nvc13][s. 32]. Als Resultat der Kompilierung wird die cubin- bzw. PTX-Datei anschlieÿend in ein Fatbinary geschrieben. Dieses kann auch mehrere bereits kompilierte cubin-dateien für unterschiedliche reale Architekturen aufnehmen [nvc13][s. 32]. Schlieÿlich wird es mit der Host-Binärdatei vom Host-Linker zusammengefügt und als ausführbare Binärdatei abgelegt. Vor der Ausführung wird die für die Ausführungsumgebung passende kompilierte bzw. die PTX-Datei gesucht und ausgeführt. Nähere Informationen zur Kompilierung des CUDA-Quelltextes nden sich in der Dokumentation zum NVIDIA Compiler [nvc13] OpenCL OpenCL ist eine standardisierte Sprache für heterogene, parallele Architekturen, wie z.b. GPUs von NVIDIA und AMD. Die Programmierung erfolgt passend zu CUDA in C. Während CUDA sich allerdings auf die Programmierung von NVIDIA GPUs konzentriert, werden von OpenCL verschiedenste parallele Architekturen unterstützt. Beispielsweise kann auch eine Implementierung von OpenCL für Field Programmable Arrays (FPGAs) erstellt werden [KWH10][S. 206]. Da der Standard damit sehr viele unterschiedliche Architekturen unterstützen muss, werden spezielle Eigenschaften von Geräten nicht verwendet. Daraus folgt, dass OpenCL-Code zwangsläu- g nicht so schnell ausführbar sein kann, wie es im Gegensatz dazu erstellte CUDA-Programme sein können, da diese nur auf NVIDIA Hardware hin optimiert werden [KWH10][S. 206]. Das Programmiermodell entspricht weitgehend dem Modell von CUDA. Quasi alle Begriichkeiten nden in OpenCL ihre Entsprechung. Eine Übersicht, wie die Begrie in OpenCL wiederzunden sind, ist in Abbildung 3.5 dargestellt. Wesentlich komplexer als in CUDA ist das Starten von Kernels sowie die Verwaltung von Geräten.

24 3.2. CUDA Programmierung 17 OpenCL CUDA Ausführung Kernel Kernel Host Host NDrange Grid Work Item Thread Work Group Block API-Aufrufe get_global_id(0) blockidx.x * blockdim.x + threadidx.x get_local_id(0) threadidx.x get_global_size(0) griddimx.x * blockdim.x get_local_size(0) blockdim.x Speicher Global-Memory Global-Memory Constant-Memory Constant-Memory Local-Memory Shared-Memory Private-Memory Local-Memory Abbildung 3.5: Übersicht der Begriichkeiten in CUDA und OpenCL [KWH10][S. 207, 209, 210, 231] Während in CUDA standardmäÿig ein Gerät zur Ausführung ausgewählt wird, geschieht in OpenCL die Auswahl explizit. Der Start von Kernels erfolgt durch explizite Funktionsaufrufe, die vorher wiederum mit verschiedenen Funktionsaufrufen konguriert werden müssen [KWH10][S. 212]. Hintergrund ist hier, dass sehr unterschiedliche Hardware-Typen unterstützt werden, die eine komplexere Geräteverwaltung erfordern. Die Implementierung von NVIDIA für OpenCL kompiliert dabei zu PTX-Zwischencode. Ab hier kann die PTX-Zwischendatei genau wie jede beliebige, aus CUDA-Quelltext erzeugte, PTX-Datei behandelt werden [ope09][s. 16]. Insgesamt ist OpenCL ein wichtiger Ansatz, um verschiedene Architekturen über eine abstrakte Schnittstelle ansteuern zu können. Für native, geschwindigkeitsabhängige, Anwendungen, ist CUDA die bessere Wahl, da hier die Architekturspezika besser ausgenutzt werden können. Für eine Java-Abstraktion ist u.u. OpenCL die bessere Wahl, da die aufwändige Implementierung einer Abstraktion anschlieÿend alle zu OpenCL kompatiblen Geräte unterstützt.

25 Kapitel 4 Navier-Stokes-Gleichungen zur Flüssigkeitssimulation Als Anwendungsfall zur Evaluation der Abstraktionsbibliotheken dient, wie schon erwähnt, der vom Fraunhofer IWU in CUDA-C implementierte Simulator. Der nachfolgende Abschnitt führt in die grundlegende mathematischen Formeln der Fluid-Simulation ein und zeigt auf, wie die Formeln numerisch gelöst werden können. Da der Fokus der Arbeit auf der Abstraktion von CUDA-C in Java liegt, konzentriert sich die Beschreibung auf die bereits im Simulator vorliegenden Lösungsverfahren. 4.1 Impuls- und Inkompressibilitätsgleichungen Die Gleichungen setzen sich aus der Impuls- und der Inkompressibilitätsgleichung zusammen, wobei die zweite Gleichung nur als Randbedingung der ersten Gleichung zu verstehen ist. Die Impuls-Gleichung errechnet dabei ein Geschwindigkeitsfeld u innerhalb des Simulationsraums. Durch Verschiebung von Einzelpartikeln im Vektorfeld ergibt sich die Flüssigkeitssimulation [CCE01][S. 1]. Im kompakter Form sehen die beiden Gleichungen wie folgt aus: u t = ( u ) u } {{ } Advektion 1 ρ p + ν u } {{ } } {{ } Druck Diffusion + f }{{} Externe Kräfte (4.1) u = 0 (4.2) Die Impulsgleichung (Gleichung 4.1) berechnet den Impuls im Vektorfeld durch Anwendung verschiedener Kräfte: Advektion, Druck, Diusion und externe Kräfte. Der Gradienten-Operator ( ) innerhalb der Gleichungen bezeichnet dabei die Richtungsableitung [CCE01][S. 2]: ( = x, y, ) (4.3) z Wird der Operator auf ein Skalarfeld angewendet, so ergibt sich als Ergebnis ein Feld, das angibt, wie sich das ursprüngliche Feld räumlich verändert. Wendet man den Operator stattdessen auf ein Vektorfeld an, so ergibt sich als Resultat die sog. Divergenz, die den Nettouss von oder zu einer Zelle beschreibt. Die Advektion (auch Transport) beschreibt die Verschiebung der Geschwindigkeitsvektoren über den Simulationsraum. Stellt man sich z.b. ein Partikel vor, so muss dieses mit einer bestimmten

26 4.2. Diskretisierung des Simulationsraums 19 Geschwindigkeit durch den Raum bewegt werden. Im Endeekt werden die Partikel von den Geschwindigkeitsvektoren durch den Raum gezogen [CCE01][S. 2]. Zum Beispiel ist die Advektion auch die Kraft, die eine Feder, die in einem Fluss schwimmt, diesen hinab trägt. Die zweite Kraft, der Druck, wird durch kollidierende Moleküle der Flüssigkeit in einem Bereich verursacht. Entsprechende Kollisionen führen zu Geschwindigkeitsänderungen und damit zu einer Veränderung des Impulses an der Position im Vektorfeld [FC04][S. 642]. Die Variable ρ beschreibt die Dichte der zu simulierenden Flüssigkeit. Oft wird diese Variable mit dem konstanten Wert 1 belegt [CCE01][S. 2]. Eine weitere Kraft, die den Impuls im Vektorfeld beeinusst, ist die Diusion. Passiert ein Partikel der Flüssigkeit ein Hindernis oder weitere Partikel mit einer anderen Geschwindigkeit, so verändert sich die Geschwindigkeit der passierenden Partikel und Wirbel entstehen [Rør04][S. 21]. Die Änderung der Geschwindigkeiten erfolgt dabei durch Annäherung an das gemeinsame arithmetische Mittel. Die in der Gleichung enthaltene Variable ν beschreibt dabei die Viskosität, also die Zähigkeit, der Flüssigkeit. Mathematisch wird dieses Verhalten über den Laplace-Operator ausgedrückt [NC08][S. 643]. Dieser beschreibt, inwieweit die Werte innerhalb einer Zelle des Geschwindigkeitsfelds vom Durchschnittswert der Nachbarzellen abweichen [CCE01][S. 2]: 2 = = 2 x y z 2 (4.4) Der letzte Teil der Impulsgleichung, f, beschreibt den Einuss externer Kräfte. Hier können sowohl lokale Kräfte, also nur einen Teil des Simulationsraums beeinussende, oder Körperkräfte, also Kräfte, die den Gesamtraum beeinussen, angelegt werden [FC04][S. 642]. Die zweite Gleichung beschreibt die Randbedingung der Nicht-Kompressibilität (Gleichung 4.2). Diese bringt zum Ausdruck, dass das Volumen der simulierten Flüssigkeit in Raum und Zeit konstant bleibt, d.h. auch unter Einwirkung von Druck nicht verändert werden kann [FC04][S. 641]. Es ist dabei sicher von dieser Randbedingung auszugehen, wenn die sog. Mach-Zahl kleiner als 0, 3 ist. Die Mach-Zahl beschreibt dabei das Verhältnis der Geschwindigkeit zur Schall- Geschwindigkeit [KKS99][S. 4]. Generell ist die Möglichkeit der Anwendung der Randbedingung wünschenswert, da in diesem Fall die Gleichungen wesentlich vereinfacht werden können [BMF07][S. 7]. Um die Randbedingung zu erfüllen, muss die Gesamtströmung innerhalb der Flüssigkeit 0 sein, weswegen der resultierende Vektorraum auch als divergenzfrei bezeichnet wird [CCE01][S. 2]. Die Divergenzfreiheit wird dadurch erreicht, dass der Druck im Feld immer so berechnet wird, dass der Nettouss 0 ergibt [BMF07][S. 8]. 4.2 Diskretisierung des Simulationsraums Vor einer eigentlichen Berechnung der Flüssigkeiten muss festgelegt werden, wie die Domäne der Simulation in Berechnungschritte diskretisiert werden kann. Grundsätzlich gibt es hierzu zwei Möglichkeiten [NC08][S. 636]:

27 4.3. Numerische Berechnung der Einzelterme der Navier-Stokes-Gleichungen 20 Euler und Lagrange Wird die Diskretisierung nach Euler gewählt, so werden feste Netzpunkte in jedem Schritt berechnet. Im Wesentlichen wird also das kubische Volumen des Berechnungsraums in Zellen aufgeteilt, an deren Positionen regelmäÿig das Geschwindigkeits- und Druckfeld aktualisiert werden. In jeder Zelle wird der aktuelle Wert der Felder gespeichert. Wird hingegen die Diskretisierung nach Lagrange gewählt, so werden im Gegensatz zur Berechnung fester Punkte Partikel durch den Raum bewegt. Die Implementierung fällt auf Grund der irregulären Struktur allerdings wesentlich schwerer. Der aktuell bestehende Simulator berechnet die Einzelwerte der Impuls-Gleichung über einen sog. Semi-Langrange-Ansatz [BMF07][S. 20]. Im Wesentlichen werden hier alle Elemente der Impulsgleichung mit Ausnahme der Advektion, nach Euler, also an festen Netzpunkten, berechnet. Die Advektion dagegen wird nach Lagrange berechnet, da hier einfachere Algorithmen verfügbar sind. Die Abbildung zwischen den beiden Ansätzen geschieht durch Interpolation der Werte auf die Gitterzellen. Netz-Struktur Ein weiterer wichtiger Punkt, der vor der Implementierung zu klären ist, ist welche Netzstruktur gewählt werden soll. Hier nden sich in der Literatur zwei Ansätze: Collocated-Grids und Staggered-Grids. Bei Collocated-Grids werden alle Simulationswerte, also Druck und Geschwindigkeit, im Zentrum einer jeden Gitterzelle gespeichert. Das Verfahren zeichnet sich im Gegensatz zu Staggered- Grids v.a. über seine Einfachheit aus, leidet gleichzeitig aber unter numerischer Ungenauigkeit [Rør04][S. 37f]. Staggered-Grids speichern im Gegenzug dazu die einzelnen Werte an unterschiedlichen Stellen in der Zelle. So wird der Druck weiterhin im Zentrum gespeichert, die einzelnen Komponenten des Geschwindigkeitsvektors dagegen aber an den Grenzen zur jeweiligen Nachbarzelle. Auf diese Weise kann der Satz der zentralen Dierenzen an den Grenzen der Zellen ansetzen und muss nicht, wie bei Collocated-Grids, Werte an diskreten Gitterzellen verwenden. Nähere Details zu Staggered-Grids und dessen Vorteile nden sich in [BMF07][S. 17]. Im vorliegenden Simulator wird ein Marker-and-Cell-Grid (MAC-Grid) eingesetzt, das einem Staggered-Grid entspricht [BMF07][S. 17]. Die Datenspeicherung erfolgt dabei nicht an den Zellgrenzen sondern in den Arrays, die auch die Zellen selbst repräsentieren. Erst bei der Berechnung der Einzelformeln werden die Zellgrenzen mit einbezogen. 4.3 Numerische Berechnung der Einzelterme der Navier-Stokes- Gleichungen Für den Ablauf eines Simulationsschrittes müssen die einzelnen Teile der Navier-Stokes-Gleichungen berechnet werden. Die Beschreibung der Lösungsverfahren beschränkt sich hier hauptsächlich auf die mathematischen Verfahren. Die Implementierungen können direkt aus den Lösungswegen abgeleitet werden und entsprechen auch den im existierenden Simulator verwendeten.

28 4.3. Numerische Berechnung der Einzelterme der Navier-Stokes-Gleichungen 21 Berechnung der Diusion Unter Einbeziehung der Zeitscheibe t berechnet sich die Diusion unter Auswertung der folgenden Formel [CCE01][S. 2]: diffusion = t ν 2 u (4.5) Die Schwierigkeit bei der Lösung dieser Gleichung liegt in der Lösung von 2. Dies kann unter Verwendung der beiden folgenden Gleichungen berechnet werden: 2 u = ( 2 u x (x, y, z), 2 u y (x, y, z), 2 u z (x, y, z) ) (4.6) 2 g(x, y, z) = g(x + 1, y, z) + g(x 1, y, z) + g(x, y + 1, z) + g(x, y 1, z)+ g(x, y, z + 1) + g(x, y, z 1) 6g(x, y, z) (4.7) Anschlieÿend ergibt sich die Diusion durch schlichtes Einsetzen in Gleichung 4.5. Berechnung der Advektion Die Advektion wird mit der Methode der Charakteristiken nach Stam durchgeführt. Die Grundidee der Methode ist wie folgt: In jedem Zeitschritt bewegen sich Partikel durch das Feld. Um die Geschwindigkeit zur Zeit t + t zu berechnen wird das Partikel auf die Zeit t unter Anwendung des letzten Geschwindigkeitsvektors zurückverfolgt und der aktuelle Wert auf diesen letzten Wert gesetzt. Dies führt zu einer numerisch sehr stabilen Berechnung der Advektion [Sta99][S. 3]. u 2 (x) = u 1 (p(x, t)) (4.8) Im vorliegenden Simulator wird die Rückverfolgung für jeden Punkt x durchgeführt und das Ergebnis anschlieÿend auf einen Gitterpunkt interpoliert. Weitere mögliche Ansätze zur Lösung der partiellen Dierentialgleichung des Advektions-Terms bestehen durch die Algorithmen nach MacCormack und Runga-Kutta [GH99]. Berechnung der Projektion Diusion und Advektion können unter Anwendung der obigen Verfahren recht einfach gelöst werden. Die letzte Gleichung muss dagegen zwei Formeln erfüllen - den Druck-Anteil aus der Impulsgleichung und die Bedingung der Nicht-Kompressibilität. Beide Gleichungen werden zuerst miteinander verbunden [FC04][S. 644f]: Mittels der Helmholtz-Hodge-Dekomposition lässt sich ein Vektorfeld als w = u + p (4.9) schreiben. Dabei wird angenommen, dass u ein divergenzfreies Feld ist, das parallel zur Flüssigkeitsebene steht. Ein divergenzfreies Feld ergibt sich demnach zu

29 4.4. Gröÿe der Simulationsschritte 22 u = w p (4.10) Wird der Nabla-Operator auf beide Seiten angewendet, so ergibt sich unter Verwendung von u = 0 die Poisson-Druckgleichung zu 2 p = w (4.11) Hiermit lässt sich ein Projektionsoperator P denieren, der ein Vektorfeld w auf ein divergenzfreies Feld u abbildet: P w = P u + P( p) (4.12) Wendet man den Projektionsoperator auf die Impulsgleichung an, so entfällt die Druckgleichung wegen P( p) = 0 und es ergibt sich die nale Form der für den Simulator relevanten vereinigten Impulsgleichung: u t = P( ( u ) u + ν u + f) (4.13) Letztlich ist somit der Ablauf der Simulation klar deniert: Zuerst werden Diusion, Advektion und externe Kräfte berechnet und addiert. Der benötigte Druckvektor p wird durch Lösung von Gleichung 4.11 unter Verwendung des gerade berechneten nicht-divergenzfreien Feldes w ermittelt. Schlieÿlich erhält man das divergenzfreie Druckfeld u durch Auswertung von Gleichung Die sich im Zwischenschritt ergebende Poisson-Gleichung kann durch Jakobi-Iterationen gelöst werden. Die zugehörige Gleichung für die (l + 1)-te Iteration ergibt sich zu [FC04][S. 649]: p (l+1) i,j,k = (l) (l) (l) (l) (l) (l) (l) p i 1,j,k + p i+1,j,k + p i,j+1,k + p i,j 1,k + p i,j,k+1 + p i,j,k 1 ( p i,j,k )2 w 6 (4.14) Die Anzahl an Jakobi-Iterationen dürfen hier nicht zu klein sein, damit das Verfahren annähernd konvergieren kann. Im vorliegenden Simulator werden 5 Iterationen eingesetzt. Statt der Jakobi-Iterationen kann auch das iterative Verfahren der konjugierten Gradienten zur Lösung der Matrix verwendet werden [She94][S. 30]. Das Verfahren löst die Matrix ezienter und ist im vorhandenen Simulator ebenfalls implementiert. 4.4 Gröÿe der Simulationsschritte Für jeden Zeitschritt ist im Simulator eine konstante Zeitscheibe fest gesetzt. Dieser Wert ist über die folgende Formel approximiert: t x u max (4.15)

30 4.5. Datenstrukturen im vorhandenen Simulator 23 Dabei wird zugrunde gelegt, dass die Zeitscheibe maximal so groÿ sein kann, dass der längste Geschwindigkeitsvektor nicht gröÿer als die Breite einer Zelle ist [CCE01][S. 3]. Ist dieser bekannt und gleichzeitig die Gitterbreite statisch, so kann der Wert als konstant angenommen und statt der Formel eingesetzt werden. 4.5 Datenstrukturen im vorhandenen Simulator Verschiedene Datenstrukturen sind nötig um die Simulationsschritte zu berechnen. Im Wesentlichen beschränkt sich der existierende Simulator auf die Verwendung von eindimensionalen Arrays. Beispielsweise werden für das Halten des Geschwindigkeitsfeldes drei Arrays der entsprechenden Feldgröÿe alloziert. Jedes der Arrays enthält eine Komponente des Geschwindigkeitsvektors (x, y, z) an einem Punkt des Feldes. Der gleiche Eekt könnte auch durch Verwendung eines Punkt-structs erzielt werden. Die Verwendung von structs ist allerdings in Bezug auf die Ausführungsgeschwindigkeit und auf die Cache-Hierarchien sehr umstritten [KWH10][S. 161]. Weitere Arrays werden für die Geschwindigkeiten an Zuuss-Stellen im Netz, die Berechnung des Gradienten und die Druck-Felder sowie für eventuell bestehende Hindernisse alloziert. Da viele der Berechnungen nicht in-place durchgeführt werden können, werden für das Geschwindigkeitsund das Druckfeld jeweils weitere Arrays alloziert, die bei jedem Simulationsschritt getauscht werden. Somit können alle Punkte im Geschwindigkeitsfeld getrennt voneinander bzw. nebenläu- g zueinander berechnet werden. 4.6 Ablauf eines Simulationsschrittes Die eigentliche Implementierung folgt dem obigen Lösungsverfahren. Der Ablauf im Einzelnen ist in Abbildung 4.1 dargestellt. Zuerst werden eventuell vorhandene Einüsse behandelt. Von diesen zeigt jeweils ein Geschwindigkeitsvektor in den Simulationsraum. Damit diese Vektoren konstant bleiben und nicht durch die Berechnung des Drucks o.ä. verändert werden, müssen sie in jedem Schritt auf den konstanten Wert des Einusses gesetzt werden. Anschlieÿend werden Diusion, Advektion und die Projektion entsprechend der bereits beschriebenen Algorithmen berechnet. Zusätzlich werden dabei jeweils vorhandene Randbedingungen beachtet. Diese beziehen sich z.b. darauf, dass kein Partikel in einen Festkörper verschoben werden darf. Entsprechend darf auch kein Partikel den Simulationsraum verlassen. Die Berechnungen der Navier-Stokes Gleichungen werden für jeden Simulationsschritt wiederholt. Zusätzlich werden nach jedem Schritt u.u. verschiedene Darstellungen, z.b. die Partikel- Positionen oder deren Geschwindigkeitsvektoren, berechnet und angezeigt.

31 4.6. Ablauf eines Simulationsschrittes 24 simulierend Aktualisierung der Geschwindigkeitsvektoren durch Einflüsse Diffusion Advektion des Geschwindigkeitsfelds Projektion Advektion der Partikel Abbildung 4.1: Ablauf eines Simulationsschrittes

32 Kapitel 5 CUDA-Abstraktion in Java Nach der theoretischen und praktischen Einführung in die Navier-Stokes-Flüssigkeitsgleichungen stellt sich die Frage, inwiefern sich die ersten beiden Kapitel, also die CUDA-Programmierung und die Navier-Stokes-Gleichungen, sich mit Java, bzw. einer dazu Bytecode-kompatiblen Sprache, verbinden lassen. Dazu beschäftigt sich dieses Kapitel insbesondere mit den verfügbaren CUDA- Abstraktionsbibliotheken und deren Vor- und Nachteilen in Bezug auf die nachfolgend gestellten Anforderungen. 5.1 Anforderungen an eine Java API Aus den Navier-Stokes-Gleichungen lassen sich verschiedene Anforderungen ableiten, wovon einige schon ein Vorgri auf die im weiteren Verlauf beschriebenen Fähigkeiten der einzelnen Bibliotheken sind. Eine Vergleichsmatrix über alle evaluierten Merkmale der Bibliotheken ndet sich in Anhang B. Plattform-Unterstützung Da die Entwicklung am Fraunhofer IWU auf Windows-Computern erfolgt, ist eine Unterstützung des entsprechenden Betriebssystems erforderlich. Eine Unterstützung von Unix-basierenden Betriebssystemen ist dagegen unter Umständen in Zukunft sinnvoll, im Moment jedoch unnötig. Allerdings ist bereits hier darauf hinzuweisen, dass die Entwicklung der Bibliotheken stark auf die Unix-Umgebung fokussiert ist und Windows meist nur rudimentär unterstützt wird. Code-Umwandlung In CUDA werden Kernels in CUDA-C geschrieben. Eine Java-Abstraktion sollte es hingegen ermöglichen diese in Java zu denieren. Durch eine für den Entwickler transparente Umwandlung in nativen Quelltext sollte dieser auch auf der Grakkarte ausführbar sein. Hierbei stellt sich die Frage, welche Java-Programmierkonstrukte, wie Klassen, Collections-API, Arrays o.ä., unterstützt werden. Gleichzeitig ist fraglich, ob eine objektorientierte Programmierung von Kernel-Funktionen bzw. Konstrukte wie Verweise auf statische Funktionen o.ä., möglich sind. Dabei ist die Einbindung von nativem Code wünschenswert. Hiermit ist eine prototypische Entwicklung auf Java möglich, wobei der resultierende Code später auf die native Sprache abgebildet werden kann. Der Vorteil dabei ist, dass relativ schnell in Java ein GPU-Kernel geschrieben werden kann. Dabei muss keine Rücksicht auf C- oder GPU-Spezika genommen werden. Allerdings ist der generierte CUDA-Kernel mit ziemlicher Sicherheit auf Grund der automatischen Generierung nicht so performant, wie es ein nativ geschriebener Kernel sein könnte, da dieser sich direkt

33 5.1. Anforderungen an eine Java API 26 an den Spezika der Grakkarte orientieren kann und damit auch passende Optimierungen, wie Loop-Unrolling o.ä., anwenden kann. Deswegen kann in einem zweiten Schritt der prototypische Java-Quelltext in nativen und optimierten performanten CUDA-C- oder OpenCL-Code übersetzt und in die Ausführungsumgebung eingebunden werden. Speicherverwaltung In CUDA muss jeder Speicherbereich händisch verwaltet, d.h. alloziert und wieder freigegeben, werden. Eine Java-Abstraktion sollte dies für den Entwickler übernehmen, um damit eigentlich überüssigen, bzw. generierbaren, Code zu entfernen. Trotz der automatischen Speicherverwaltung sollten spezielle Speicherbereiche wie Constant- oder Shared-Memory weiterhin nutzbar bleiben, sodass die GPU-Anwendung weiterhin durch Verwendung dieser schnellen Speicherbausteine performant ausgeführt werden kann. Einige der nachfolgend beschriebenen Bibliotheken verfolgen den Ansatz, vor jeder Kernel-Ausführung den nötigen Speicherbereich auf der GPU zu allozieren, den nötigen Speicher aus dem CPU-Speicher in den GPU-Speicher zu kopieren, die Ausführung zu starten, nötigen Speicher zurück zu kopieren und den GPU-Speicher schlieÿlich wieder freizugeben. Dieser Vorgang ist in Abbildung 5.1 illustriert. Problematisch wird dies sobald Kernels mehrfach ausgeführt werden, da vor und nach jedem Kernel-Start die Daten kopiert werden müssen. CUDA erlaubt es durch die explizite Spezikation von Kopiervorgängen diesen Flaschenhals zu vermeiden. Eine entsprechende Unterstützung ist für die Implementierung der Navier-Stokes-Gleichungen wichtig, da hier sehr oft Kernels mehrfach ausgeführt werden müssen. Ein Beispiel hierfür ist das Jakobi-Verfahren, das zur Lösung der Projektion eingesetzt wird. Eine weitergehende Anforderung ist die Ausführung von mehreren verschiedenen Kernels, wobei nur beim ersten Kernel nötige Daten kopiert werden und anschlieÿend, bei weiteren Kernels, die gleichen Daten wiederverwendet werden können. Dieses Muster lässt sich beim bestehenden Flüssigkeitssimulator recht häug nden, da z.b. Advektion und Diusion jeweils in eigenen Kernels ausgeführt werden, trotzdem aber in der Funktion jeweils dieselben Daten manipulieren. Alloziere CPU-Speicher weitere Aufrufe Alloziere GPU-Speicher Kopiervorgang CPU => GPU Kernel-Ausführung Kopiervorgang GPU => CPU Freigabe GPU-Speicher Freigabe CPU-Speicher Abbildung 5.1: Typische Abarbeitung mehrerer Kernel Aufrufe auf den gleichen Daten in Java Entwicklungsgeschwindigkeit und Lernkurve Zu dieser Kategorie gehören sehr unterschiedliche Merkmale, insbesondere jedoch die Möglichkeit der Einbettung in ein bestehendes Projekt, die Entwicklungsgeschwindigkeit, die Testbarkeit des erstellten Codes, die zukünftige

34 5.1. Anforderungen an eine Java API 27 Weiterentwicklung und die Möglichkeit Fehler, sowohl im selbst erstellten Quelltext als auch in der Bibliothek, zu nden. Zwar wird eine GPU-Abstraktionsumgebung nur ein einziges Mal in ein Projekt eingebunden, so ist doch die Komplexität der Einbindung ein wichtiges Kriterium. Müssen bei jeder Aktualisierung alle Bibliotheken händisch kompiliert, kopiert und eingebunden werden? Was muss dafür geändert werden? Wie kompliziert ist das entsprechende Build-Skript? Kann überhaupt auf Quelltext auÿerhalb der Bibliothek zugegrien werden, oder muss sämtlicher Quelltext basierend auf einer Domain Specic Language (DSL) der Bibliothek speziziert werden? Gleichzeitig stellt sich auch die Frage, wie schnell, und wann, die Umwandlung in GPU-Code erfolgt und ob dafür extra Kommandos im Build-Skript ausgeführt werden müssen. Ist die Bibliothek eingebunden, so sollte der Entwickler schnell in der Lage sein, diese richtig einzusetzen und Algorithmen zu implementieren. Dazu ist es wichtig, dass ein eventueller Staging- Zwischenschritt zur Code-Umwandlung die Entwicklungsgeschwindigkeit nur wenig beeinusst und gleichzeitig generierter Code ohne weitere Fehler lauähig ist. Eventuell auftretende Fehler sollten bei der Ausführung des evtl. generierten Quelltextes nicht verschluckt bzw. ignoriert werden, sondern in geeigneter Form dem Entwickler präsentiert werden. Dies bezieht sich v.a. auf häug in der Entwicklung auftretende ArrayIndexOutOfBounds- Exceptions. Zur weiteren Fehlersuche ist die Möglichkeit des Debuggings ein wichtiges Instrument, das auch bei der GPU-Programmierung nicht fehlen darf. Dies bezieht sich v.a. darauf, Kernels, u.u. auch in Java, zur Fehlersuche in Einzelschritt-Auswertung betrachten zu können. Dabei sollten eventuell auftretende Race-Bedingungen zwischen nebenläug ausgeführten Threads erkennbar sein. Ist auch durch Debugging der Fehler nicht ersichtlich, so muss ein Blick in den Quelltext der unterliegenden Bibliothek möglich sein. Diese sollte dazu eine nicht zu hohe Komplexität aufweisen, damit die Struktur und der Ablauf der Code-Umwandlung vom Entwickler in möglichst kurzer Zeit verstanden werden kann. Komplexität entsteht hier auf der einen Seite durch verworrene Architekturen, falsche Methodennamen, fehlende Dokumentation o.ä. und auf der anderen Seite durch komplexe Ausführungsmodelle, eventuell also z.b. durch einen zusätzlich nötigen Staging- Schritt zur Code-Umwandlung. Abgesehen davon sollte die Möglichkeit zur Ausgabe des generierten CUDA-C- oder OpenCL-Code in lesbarer Form bestehen. Damit lassen sich Fehler, die im eigenen Algorithmus liegen, von Fehlern innerhalb der Code-Generierung unterscheiden. Weiterhin sollte auch in Zukunft eine ausgewählte Bibliothek weiterentwickelt und unterstützt werden, sodass Fehler schnell behoben werden und auch auf Fragen zügig geantwortet wird. Hier ist insbesondere eine aktive Gemeinde zur Beantwortung auftretender Fragen und Probleme rund um die Bibliothek hervorzuheben. Auÿerdem ist wichtig, dass eine entsprechende Bibliothek nicht auf Einzelentwickler angewiesen ist, die das Projekt jederzeit aufgeben und damit die Einstellung der Entwicklung bewirken können. Ausführungsmodi In CUDA-C ist die Ausführung hauptsächlich auf die Grakkarte begrenzt. Zusätzliche Ausführungsmodi, wie z.b. die Ausführung mit OpenMP, sind nur mit Workarounds möglich. Dies ist v.a. darauf zurückzuführen, dass für die Host-Ausführung zusätzliche Schleifen notwendig sind, die im CUDA-Ausführungsmodell implizit vorhanden sind. Gleichzeitig müssen zur Kompatibilität zu CUDA auch globale Variablen (z.b. threadidx.x und blockidx.x)

35 5.1. Anforderungen an eine Java API 28 für die aktuelle Thread-Nummer bzw. den aktuellen Block emuliert werden. Ein entsprechendes Abstraktionsframework sollte zumindest die Java-Thread-Pool-Ausführung, z.b. durch Implementierung des Master-Worker-Patterns, gleichwertig zur CUDA-C bzw. OpenCL Ausführung unterstützen. Dies ist insbesondere ein Vorteil für Geschwindigkeitsvergleiche zwischen GPU und CPU und dient gleichzeitig der Entwicklungsgeschwindigkeit und Testbarkeit des Codes, da nicht zur Ausführung jeder Code-Änderung der Entwickler-Quelltext in CUDA-C umgewandelt werden muss, sondern direkt in Java ausgeführt werden kann. Abgesehen davon besitzt nicht jeder Entwicklungsserver eine leistungsfähige Grakkarte. Soll, z.b. durch Continuous- Integration, auf einem Server die Applikation oder entsprechende Tests, regelmäÿig ausgeführt werden, so kann dies nebenläug auf den verfügbaren CPU-Kernen erfolgen. Unterstützte Sprach-Features Bestimmte Features sind für die Implementierung von Algorithmen wichtig und müssen von den Bibliotheken zur Verfügung gestellt werden. Insbesondere sind hier eindimensionale Arrays sowie entsprechende Operationen darauf, OpenGL-Unterstützung und eine API zur Zeitmessung erforderlich. Da die Navier-Stokes-Gleichungen stark auf der Verwendung von primitiven Arrays basieren, die in verschiedensten Kernels abgearbeitet werden, ist deren Unterstützung, v.a. für den Datentyp double, notwendig. Entsprechende Operationen darauf, d.h. Operationen zum Belegen und Auslesen von Array-Feldern, sind entsprechend zur Verfügung zu stellen. Eine Möglichkeit zum Auslesen der Länge des Arrays, die es in C nicht gibt, ist der Code-Leserlichkeit förderlich, aber nicht zwingend notwendig. Die Lesbarkeit eines Programms lässt sich zusätzlich stark über die Unterstützung und Verwendung von Objekten erhöhen, da damit die Implementierung eines Domänenmodells möglich wird. Insbesondere für Arrays sind deswegen Objekte ein wichtiger Evaluierungsparameter, auch in Bezug auf die Serialisierungsgeschwindigkeit und die Umwandlung in äquivalente C-Structs. Als Alternative zu Objekten wären u.u. auch mehrdimensionale Arrays ausreichend. Hier stellt sich ebenfalls die Frage nach der Unterstützung durch die Bibliotheken. Damit verschiedene Lösungsansätze schnell miteinander verglichen werden können ist eine Schnittstelle zur Zeitmessung erforderlich. Diese sollte v.a. die GPU-Zeit mit einbeziehen. CUDA bietet dafür z.b. eine eigene Schnittstelle durch Events an, die den Vorteil haben, dass kein durch das Betriebssystem entstehender Overhead, z.b. durch den Thread-Scheduler, mit einbezogen wird [SK11][S. 108]. Damit die berechnete Simulation ausgegeben werden kann verwendet der bestehende Simulator OpenGL zur Visualisierung. Ein ähnliches Interface sollte auch die Java-Abstraktion bieten. Möglichst sollte dabei kein Overhead durch zusätzliche Kopiervorgänge (GPU CPU GPU) entstehen. Ausführungsgeschwindigkeit Mit dem Flüssigkeitssimulator ist eine Echtzeit-Simulation das Ziel des Gesamtprojektes. Eine entsprechende Java-Abstraktionsumgebung sollte daher möglichst wenig zusätzlichen Overhead mit einbringen, damit die eigentliche Ausführungszeit durch die zusätzliche Bibliothek nicht wesentlich erhöht wird. Eventuell existierende Code-Optimierungen zur Steigerung der Geschwindigkeit sind ebenfalls wünschenswert.

36 5.2. Algorithmen zur Evaluation Algorithmen zur Evaluation Zur Evaluation wurden unter Nutzung der Abstraktionsbibliotheken sechs verschiedene Algorithmen beschrieben und implementiert. Dabei wurden, soweit möglich, für alle Bibliotheken die gleichen Implementierungen der Algorithmen zugrunde gelegt. Array-Inkrementierung Als sehr einfaches Beispiel für eine GPU-Parallelisierung dient die Inkrementierung eines Arrays um jeweils +1. Damit die Funktionalität korrekt implementiert werden kann muss die Bibliothek die Verwendung primitiver Arrays erlauben. Die Idee des Algorithmus ist, dass jeder Thread ein Element aus dem Array in ein Register lädt, dieses inkrementiert und das Ergebnis zurück in den Array schreibt. Durch sehr groÿe zu inkrementierende Arrays werden damit sehr viele Threads gestartet. Die Bibliotheken müssen daher in der Lage sein die Threads in Blöcke aufzuteilen und nacheinander abzuarbeiten. Matrix-Multiplikation Zwei quadratische Matrizen identischer Gröÿe werden für diese Evaluationsaufgabe miteinander multipliziert. Dabei werden zuerst keinerlei Optimierungen wie Shared- bzw. Local-Memory angewendet. Der implementierte Algorithmus ist wesentlich komplexer als der der Array-Inkrementierung, da im Kernel eine zusätzliche Schleife ausgeführt werden muss, die die Anzahl an Berechnungen deutlich erhöht. Die zugrunde liegende Idee ist, dass jeder Thread eine Zelle der resultierenden Matrix berechnet. Jeder Thread muss also insgesamt genau so viele Multiplikationen und Additionen, wie die Matrix breit und hoch ist, ausführen. Da die Matrix-Multiplikation als Paradefall für Shared-Memory gilt, wird unter Nutzung von Block-Tiling der Algorithmus weiter optimiert. Dabei macht man sich zunutze, dass viele Threads in einem Block die gleichen Daten aus den Ausgangsmatrizen benötigen. Alle Threads eines Blocks kollaborieren nachfolgend jeweils darin, einen Teil der Ausgangsmatrizen in den schnellen Speicher zu laden und nutzen diesen daraufhin zur eigentlichen Berechnung. Anschlieÿend wird, so lange weitere Blöcke vorhanden sind, der nächste Block verarbeitet [KWH10][S. 84]. Da die Gröÿe von Shared-Memory auf Grakkarten stark beschränkt ist, muss die Gröÿe des Sub- Blockes so gewählt werden, dass die Speichergröÿe nicht überschritten wird. Auf einer GeForce GTX 660TI Grakkarte stehen beispielsweise 48 kb pro Block zur Verfügung, womit 6144 Double- Zahlen speicherbar sind. Daraus ergibt sich ein Maximum von 78 für die Breite und Höhe des Sub-Blocks. Mandelbrot Als weiteres Beispiel zur GPU-Evaluation dient die Berechnung eines Mandelbrot- Fraktals. Alle Pixel des entstehenden Bildes sind dabei unabhängig voneinander berechenbar, sodass die Aufgabe ohne Verluste auf der GPU zu parallelisieren ist. Jeder Thread ist im entstehenden Algorithmus für die Berechnung eines Pixels zuständig. Mandelbrot ist ein klassisches Beispiel für den Test der GPU-Parallelisierung, das sich bei nahezu jeder Bibliothek als Testfall wiedernden lässt. Eine wesentliche Neuerung gegenüber der Matrix- Multiplikation ist hingegen nicht vorhanden.

37 5.2. Algorithmen zur Evaluation 30 Parallele Summation von Array-Elementen Noch komplexer ist der Algorithmus zur Berechnung der Reduktion eines Arrays unter Nutzung der Addition. Ziel ist die Summation aller Elemente eines Arrays (z.b. reduce([0, 1, 2, 3, 4, 5, 6]) = 21). Ein zugehöriger serieller Algorithmus ist sehr einfach zu implementieren. Dagegen ist die Nutzung der parallelen Ressourcen der GPU anspruchsvoll, da im naiven Ansatz jedes Zwischenergebnis von allen vorherigen Berechnungen abhängt. Eine Möglichkeit diesen Flaschenhals zu umgehen ist der Algorithmus nach Blelloch, der von NVIDIA in einer Ausarbeitung zusammengefasst wurde [Par07]. Der Ablauf des Algorithmus ist in Abbildung 5.2 für einen Beispielblock mit 8 Elementen dargestellt. Zuerst wird der Block in zwei Hälften aufgeteilt. Für jedes Element in der ersten Hälfte addiert ein Thread sein Element auf ein entsprechendes Element in der zweiten Hälfte. Anschlieÿend wird der Block halbiert und der Vorgang entsprechend dem ersten Schritt fortgesetzt. Der Algorithmus terminiert, wenn die Blockgröÿe 1 ist, da dann das erste Element des Blocks die Summe enthält. Damit keine Inkonsistenzen auftreten müssen die Threads zwischen den Halbierungen der Block-Gröÿen aufeinander warten. Die Summen der Einzelblöcke werden auf die CPU zurück kopiert und dort nal aufaddiert. Wird die Block-Gröÿe entsprechend klein gewählt, so kann die Abarbeitung durch Shared- Memory stark beschleunigt werden. Wie bei der Matrix-Multiplikation werden dazu in einem ersten Schritt die Daten in das Shared-Memory geladen und anschlieÿend die dort abgelegten Werte zur Berechnung genutzt. Insbesondere die notwendige Synchronisation zwischen zwei Abarbeitungsschritten zeichnet diesen Testfall aus. Diese ist notwendig, da ein Read-After-Write (RAW)-Konikt zwischen der Beendigung der Addition eines Threads und dem Lesen des Ergebnisses zur nächsten Addition eines anderen Threads besteht. Ist die Synchronisation inkorrekt implementiert, so ergeben sich während der Abarbeitung durch auftauchende Race-Conditions falsche Ergebnisse. Jakobi-Verfahren zur Lösung von Poisson-Gleichungen Das Jakobi-Verfahren zur Lösung von linearen Gleichungssystemen kann analog zur allgemeinen Formel des Verfahren implementiert werden [MV11][S. 76]: Abbildung 5.2: Parallele Reduktion (Beispiel für einen Block), vgl. [Par07][S. 8]

38 5.2. Algorithmen zur Evaluation 31 x (m+1) i = 1 a ii ( b i a ij x (m) j j i ) Jeder Thread berechnet eine Zelle des Ergebnisvektors. Da die Ausführungsreihenfolge der Threads unspeziziert ist, muss zur Sicherstellung der korrekten Abarbeitung der Gleichung eine Synchronisation zwischen dem Lesen von x (m) j und dem Schreiben von x (m+1) i eingefügt werden. Problem dabei ist, dass CUDA nur die Synchronisation von Blöcken unterstützt. Die beiden Werte x i und x j können dagegen auch in zwei verschiedenen Blöcken zu nden sein. In diesem Anwendungsfall spielt jedoch das Weglassen der Synchronisation keine Rolle. Tritt der Fall ein, dass ein neuer Wert für x (m+1) i geschrieben wird, bevor der in der gleichen Speicherzelle zu ndende Wert x (m) i innerhalb der Schleife aus der Zelle gelesen wurde, so wird der bereits aktualisierte Wert verwendet. Dies resultiert in einer schnelleren Konvergenz des Verfahrens, da x (m+1) i bereits näher am Ergebniswert liegt. Insgesamt entsteht dadurch eine Mischung aus dem Jakobi- und dem Gauÿ-Seidel-Verfahren. Schlieÿlich wird die Abhängigkeit zwischen den Iterationen des Verfahrens durch vielfache Kernel- Aufrufe gelöst. Im Test wurden jeweils 200 Aufrufe ohne Prüfung auf ein Konvergenz-Kriterium durchgeführt. Um diesen Testfall korrekt implementieren zu können, muss die mehrfache Ausführung von Kernels möglich sein. Im besten Fall kann für die Bibliothek speziziert werden, dass zwischen den Einzeliterationen keine Daten zwischen GPU und CPU kopiert werden, was sich wiederum in einer geringeren Ausführungszeit niederschlagen sollte. Verfahren der konjugierten Gradienten zur Lösung von Poisson-Gleichungen Der letzte und gleichzeitig anspruchsvollstes Testfall ist das Verfahren der konjugierten Gradienten. Das zugehörige Verfahren zur Lösung des Gleichungssystems A x = b wurde [Mic11][S. 3] entnommen. Voraussetzung für die Konvergenz des Lösungsverfahren ist eine positiv denite und symmetrische Matrix A. Das Verfahren wurde pro Iteration mit sieben Kernel-Aufrufen implementiert. Enthalten sind darin Skalar- und Matrixprodukte sowie Vektoradditionen und -skalierungen. Im Gegensatz zum Jakobi-Verfahren muss zur ezienten Implementierung die Bibliothek die mehrfache Ausführung verschiedener Kernels unterstützen. Auch hier ist wiederum die Geschwindigkeit stark davon davon abhängig, ob die Bibliothek die Speicherbereiche der Kernels trennt, womit ein zusätzlicher Kopiervorgang nötig ist, oder ob der gleiche Speicherbereich auf der GPU ohne weitere Datentransfers nutzbar ist.

39 5.3. Evaluation verfügbarer Abstraktionsbibliotheken Evaluation verfügbarer Abstraktionsbibliotheken Dieser Abschnitt beschreibt die aktuell verfügbaren Bibliotheken zur Java-Abstraktion. Zuerst wird jeweils die Architektur bzw. der Ablauf der Code-Umwandlung erklärt. Dies ist insbesondere wichtig, um ein Gefühl für die Komplexität der Bibliothek und den Ablauf der Code- Transformation, bzw. der Abbildung auf GPU-Code, zu bekommen. Anschlieÿend werden die vorhandenen Features mit den Anforderungen aus Abschnitt 5.1 verglichen. Der Laufzeitvergleich wird, im Gegensatz zu allen anderen Anforderungen, nicht in jedem Bibliotheksabschnitt einzeln behandelt, sondern aus Gründen der Übersichtlichkeit am Ende des Kapitels in gesammelter Form. Die nachfolgenden Abschnitte beschreiben dabei nicht die Erfüllung jeder Evaluationsanforderung im Einzelnen, da nicht jede Bibliothek auf Grund ihrer Architektur und ihrer Zielsetzung alle Anforderungen erfüllen kann. Ist dies klar ersichtlich, so werden derartige Anforderungen nicht näher behandelt JCuda JCuda [jcu13] ist ein unter der MIT-Lizenz stehendes Java-Binding für CUDA, das wichtige API-Funktionen aus dem CUDA-Toolkit von NVIDIA abstrahiert und als Java-Funktionen zur Verfügung stellt. Die Bibliothek wird von einem Einzel-Entwickler, Marco Hutter, entwickelt. Weitere Informationen über die Bibliothek nden sich leider nicht, da sich auf der zugehörigen Web-Site kein Impressum oder ähnliche Daten nden, die weiteren Aufschluss über den Hersteller bzw. die näheren Entwicklungsumstände geben. Veröentlichungen oder ähnliche Quellen sind ebenfalls nicht vorhanden. Die nachfolgende Beschreibung bezieht sich deswegen einzig auf den öentlichen Quelltext der Bibliothek. Der selbe Hersteller bietet zusätzlich ein Binding für OpenCL mit dem Namen JOCL an, das hier allerdings auf Grund der Nähe zu JCuda nicht näher betrachtet wird. Auÿerdem stehen verschiedene Bibliotheken, die auf JCuda bzw. JOCL basieren, zur Verfügung. Diese enthalten Funktionen für typische Basic Linear Algreba Subprograms (BLAS)-Operationen, wie z.b. Matrix- Operationen, die schnelle Fourier-Transformation o.ä.. Die Bibliothek stellt wohl einen Klon der von NVIDIA in CUDA-C implementierten Bibliothek für BLAS-Operationen (cublas) dar. Ablauf der Ausführung Im Wesentlichen besteht JCuda aus einer einfachen Abstraktionsschicht, deren Funktionsnamen und Klassen der CUDA-Toolkit-API nachempfunden sind. Da die Terminologie stark an die Terminologie von CUDA angelehnt ist, ndet man sich als Entwickler schnell zurecht. Diese Abstraktionsschicht reicht die abzuarbeitenden Daten via Java Native Interface (JNI) an C-Funktionen weiter, die anschlieÿend wiederum Toolkit-Funktionen von CUDA aufrufen. Die verwendeten Kernel müssen direkt in CUDA-C geschrieben werden, da eine automatische Transformation von Java nach CUDA-Code nicht erfolgt. Ausgeführt wird allerdings auch kein CUDA-Quelltext, sondern Quelltext in kompilierter Form, d.h. entweder PTX-Zwischencode oder von der Architektur abhängiger Code in Form einer cubin-datei. Die nötige Infrastruktur zur

40 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 33 Kompilierung ist vom Entwickler zu entwerfen. Die Kompilierung selbst kann damit zu einem beliebigen Zeitpunkt erfolgen, d.h. entweder zur Laufzeit oder vor dem Programmstart. Ein Beispiel für die stark an CUDA-C angelehnte API ist in Quelltext 5.1 dargestellt. Die Infrastruktur zur Code-Kompilierung ist hier nicht eingezeichnet und kann dem beiliegenden Evaluationsprojekt entnommen werden. Insgesamt erinnert der entstehende Java-Quelltext stark an nativen CUDA-Quelltext. Einzig der Kernel-Aufruf sowie die Instantiierung der Device-Pointer unterscheiden sich. Der Kernel selbst ist nicht eingezeichnet, da dieser exakt dem nativen Kernel in Quelltext 3.1 entspricht. Evaluation Sämtliche Testfälle können mit der Bibliothek durch die Unterstützung von primitiven Arrays, mehrfachen Kernel-Aufrufen sowie Shared-Memory usw. ohne gröÿeren Aufwand implementiert werden. Einige Fallstricke sind hierbei zu beachten: Wie bereits erwähnt erfordert JCuda die explizite Speicherverwaltung durch den Entwickler. Dieser muss in Bytes vorgeben, wie groÿ der allozierte Speicherbereich sein soll, den Speicher händisch auf die GPU kopieren und den Kernel anschlieÿend, wiederum manuell, starten. Eine nahtlose Koppelung an die Datentypen von Java erfolgt nicht, womit die zu allozierende Speichergröÿe durch eine an die C sizeof-funktion angelehnte Methode berechnet werden muss. Auftretende Fehler durch Zugri auf Speicherbereiche, die auÿerhalb der allozierten Gröÿe liegen, werden verschluckt und nicht an Java zurück propagiert. Eine erwartete ArrayIndexOutOf- BoundsException tritt z.b. nicht auf. Auch weitere mögliche auftretende Fehler werden angezeigt. Wird beispielsweise versucht zu viel Speicher auf die Grakkarte zu kopieren, so stürzt der Grakkartentreiber ab und ein CUDA_- ERROR_UNKNOWN wird zurückgegeben. Dies entspricht dem Verhalten des CUDA-Toolkits, ist aber trotzdem der Fehlersuche nicht förderlich. Die explizite Speicherverwaltung bringt hingegen durchaus Vorteile mit sich. Dadurch, dass sämtliche den Speicher betreende Vorgänge vom Entwickler angestoÿen werden, ist ein mehrfacher Kernel-Aufruf ohne automatische Kopiervorgänge implizit gegeben. Entsprechender Overhead, wie er in anderen Bibliotheken auftritt, entfällt. Gleichzeitig ist die Mächtigkeit der Bibliothek durch die starke Anlehnung an den CUDA-Toolkit zwar gegeben, dehnt sich aber nicht auf alle Bereiche aus. Insbesondere Referenz-Datentypen, wie sie in CUDA über Structs realisierbar sind, entfallen. Diese werden zwar im CUDA-C Quelltext unterstützt, können aber nicht aus Java heraus auf das Device kopiert werden. Hierfür existieren zwar in den einschlägigen Foren der Bibliothek bereits Vorschläge, eine Implementierung liegt jedoch nicht vor. Dafür werden, durch die explizite Spezikation der Kernel-Funktionen in nativem CUDA-C, spezielle Speicher wie Constant- oder Shared-Memory unterstützt. Als Ausführungsmodi wird nur der GPU-Modus angeboten. Dies liegt darin begründet, dass vom Entwickler spezizierter nativer CUDA-Code ausgeführt wird. Allerdings liegt auch kein Wrapper mit pthreads o.ä. vor. Ein Geschwindigkeitsvergleich Host-Device ist damit nicht möglich. Der native Quelltext in CUDA-C führt auch dazu, dass die Kernel-Funktion nur unter Einbeziehung der Grakkarte getestet werden kann. Ein Aufruf von Java-Methoden aus der Kernel-Funktion ist entsprechend nicht möglich. Die für einen Vergleich erforderliche API zur Zeitmessung ist dagegen in Form von CUDA-Events

41 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 34 CUdeviceptr devicevalues = new CUdeviceptr (); int valuessize = values. length * Sizeof. INT ; cumemalloc ( devicevalues, valuessize ); cumemcpyhtod ( devicevalues, Pointer. to ( values ), valuessize ); Pointer kernelparameters = Pointer. to ( Pointer. to ( devicevalues ), Pointer. to ( new int []{ values. length }) ); int gridsizex = ( values. length / getm axth read sper Bloc k ()) + 1; culaunchkernel ( getenvironment (). function, gridsizex, 1, 1, // Groesse des Grids 1024, 1, 1, // Groesse der Bloecke 0, null, // Groesse des Shared - Memory kernelparameters, null // Kernel - Parameter ); cuctxsynchronize (); cumemcpydtoh ( Pointer. to ( values ), devicevalues, valuessize ); cuctxsynchronize (); cumemfree ( devicevalues ); Quelltext 5.1: Kernel-Aufruf in JCuda implementiert. Dies entspricht aus den in den Anforderungen genannten Gründen der optimalen Lösung. Auch die unterstützten Plattformen entsprechen den Anforderungen. Mit Windows und Linux werden die beiden gröÿten Systeme unterstützt. Damit allerdings unter Linux eine Ausführung möglich ist, muss die libcuda.so bereits vor Ausführung der Applikation geladen sein. Dies wird durch Hinzufügen des nötigen Pfades in die LD_PRELOAD Umgebungsvariable erreicht. Die Einbettung der Bibliothek erfordert hauptsächlich das Schreiben von Code zur Kompilierung der CUDA-Dateien in PTX oder Cubin. Soweit nicht alle Kernels bereits vor Ausführung kompiliert werden sollen, ist ein explizites Build-Skript damit nicht zwingend notwendig. Bei der eigentlichen Verwendung der Bibliothek ist stark darauf zu achten, dass der CuContext, der den JNI-Kontext kapselt, entweder wiederverwendet wird, oder aber nach der Kernel-Ausführung wieder freigegeben wird. Andernfalls sind bereits bei 100 Kernel-Aufrufen die Ressourcen des ausführenden Rechners erschöpft. Insgesamt ist die Bibliothek auf Grund der dünnen Abstraktionsschicht leicht zu durchschauen, ist aber auf Grund der fehlenden Ausführungsmodi schlecht testbar. JCUDA Die Bibliothek JCuda ist nicht mit der an der Rice-Universität in Houston entwickelten CUDA-Abstraktionsbibliothek JCUDA zu verwechseln. Diese Bibliothek konzentriert sich ebenfalls darauf, native Funktionen der CUDA-Bibliothek durch JNI-Aufrufe für den Entwickler verfügbar zu machen, erfordert dabei aber noch einen zusätzlichen Schritt zwischen Java-Code- Kompilierung und paralleler Ausführung. Dieser ist nötig, da Kernel-Aufrufe mit vier Kleinerbzw. Gröÿer-Zeichen (<<<<... >>>) im Java-Code konguriert werden. Die entsprechende

42 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 35 Compiler-Erweiterung ist proprietär. Die entstandene Bibliothek ist nur innerhalb der Rice- Universität verfügbar. Da letztendlich nur 2009 eine kurze Arbeit dazu veröentlicht wurde, wird die Bibliothek nicht weiter in die Evaluierung einbezogen [YGS09] Java-GPU Eine weitere Bibliothek, mit dem Namen java-gpu, ist als Doktorarbeit am Trinity College in Dublin entstanden. In der Ausarbeitung entwarf Peter Calvert den nötigen Code zur impliziten Schleifenparallelisierung auf der Grakkarte. Im Wesentlichen geht es dabei um die implizite und explizite Erkennung und Parallelisierung von Schleifen in einem gegebenen Quellcode. Die Parallelisierung erfolgt durch einen zusätzlichen Staging-Schritt zwischen der Kompilierung der Quelldateien und der eigentlichen Ausführung der generierten *.class-dateien. In diesem Zwischenschritt werden die generierten Dateien nochmals gelesen, parallelisiert und die parallele Variante erneut geschrieben. Wird schlieÿlich die parallele Variante im Java-Classpath bei der Ausführung angegeben, so wird die Applikation nebenläug auf der Grakkarte ausgeführt. Code-Umwandlung Zur Parallelisierung sind verschiedene Schritte notwendig. Die Einzelschritte sind in Abbildung 5.4 verdeutlicht. Zuerst wird der von javac generierte Bytecode durch Verwendung des ASM-Frameworks [BLC02] importiert. Die Bibliothek erlaubt dabei die dynamische Transformation von Bytecode Objekten, also das Lesen und Einfügen von Instruktionen im existierenden Bytecode-Instruktionsgraphen. Um das Einlesen von Instruktionen über den Graph einfach zu gestalten, wird das Visitor-Pattern angewendet [BLC02][S. 7]. Das Visitor-Pattern [GHJV94][S. 331] besteht aus zwei Arten von Elementen - Visitor s und Nodes. Visitors denieren visit-methoden, die nach Aufruf der accept-methode auf einem Node des Objektgraphen in der richtigen Reihenfolge aufgerufen werden. Im Beispiel von java-gpu werden visit-methoden, z.b. für Methoden, Felder, Klassen oder auch Annotationen, deniert. Diese kapseln den nötigen Code zum Aufbau des internen Objekt-Graphen zur Abbildung der Instruktionen in der kompilierten Class-Datei. Andere Visitors werden wiederum zur späteren Code-Generierung verwendet. Abbildung 5.3 illustriert den grundsätzlichen Aufbau zur Erstellung einer internen Bytecode-Repräsentation durch Nutzung des Entwurfsmusters. Während der Abarbeitung der einzelnen Bytecode-Instruktionen durch Aufruf dedizierter visit- Methoden werden verschiedene Optimierungen angewendet. Einzelne Methoden werden in Abschnitte ohne ein- oder ausgehende Sprünge (Basic-Blocks) zerlegt [BLC02][S. 10]. Zur Erstellung eines Ablauf-Graphen werden Instruktionen den Blöcken zugeordnet, die anschlieÿend wiederum untereinander verbunden werden. Um sicherzustellen, dass der Quellcode richtig typisiert ist, wird für Lese- und Schreiboperationen auf Felder, Variablen und Methodenparameter geprüft, ob deren Typen respektive zueinander passen. Beispielsweise darf kein Feld vom Typ Integer mit einem String beschrieben werden. Als Ergebnis ergibt sich ein typisierter Graph an Instruktionen, die aus dem Bytecode gelesen wurden, der anschlieÿend zur Schleifenerkennung weiterverwendet werden kann. Der zugehörige Quelltext ndet sich in bytecode.methodimporter.

43 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 36 Node accept(visitor: NodeVisitor) NodeVisitor MethodNode ClassNode FieldNode visitclass(node: ClassNode): void visitmethod(node: MethodNode): void visitfield(node: FieldNode): void (...) visitor -> visitmethod(this) visitor -> visitclass(this) visitor -> visitfield(this) Abbildung 5.3: Lesen einer Klasse unter Nutzung des Visitor-Patterns, siehe [GHJV94][S. 331f] Bytecode-Import (1) Schleifenerkennung (2) Kernel-Extraktion (3) Datei-Export (4) Abbildung 5.4: Schritte zur impliziten Schleifenparallelisierung in java-gpu

44 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 37 Als nächstes müssen existierende Schleifen erkannt werden. Diese sind im Bytecode nur noch durch Sprünge bekannt und müssen aus den Basic-Blocks wiederhergestellt werden. Der entsprechende Vorgang erfolgt in drei Schritten: Zuerst werden natürliche Schleifen erkannt. Eine natürliche Schleife ist eine solche, die nur einen Eintritts- und nur einen Austrittspunkt besitzt. Beispielsweise kann damit eine for-schleife nicht mit einem return abgebrochen werden, da damit zwei Abbruchbedingungen gegeben wären. Entsprechend gefundene Schleifen werden als Menge an Loop-Objekten zurückgegeben (siehe analysis.loops.loopdetector, [Cal10][S. 15f]). Eine Schleife ist auf der GPU nur parallelisierbar, soweit ihre Indexe bekannt sind. Dazu wird im zweiten Schritt versucht, die Schleifen in sog. triviale Schleifen umzuwandeln. Diese denieren sich dadurch, dass nur eine Bedingung die Schleife beendet, keine Schreiboperationen auf Variablen vor der Prüfung der Bedingung auftauchen und der Schleifen-Zähler um eine einfache Zahl in- oder dekrementiert wird, also keine Operationen auÿer Addition und Subtraktion dabei auftreten. Schlieÿlich müssen die Indexe normalisiert werden. Dies bezieht z.b. die Anwendung einer >= in eine <= Abbruchbedingung oder die Anpassung der Indexe für einen <-Operator mit ein. Die entstehende Menge an trivialen Schleifen wird an den letzten Schritt, die Erkennung von geschachtelten Schleifen, weitergegeben (vgl. analysis.loops.looptrivialiser, [Cal10][S. 28,32f]). Im Wesentlichen werden hier die erkannten trivialen Schleifen in eine Baumstruktur übersetzt (loop nesting). Dies ist unabdingbar, da aus einem gerade ausgeführten Kernel keine weiteren Kernels aufgerufen werden können. Dieser Fall könnte eintreten, wenn verschachtelte Schleifen nicht erkannt und daraufhin alle Schleifen parallelisiert würden. Die eigentliche Implementierung der Erkennung ist straight-forward, da im Objektgraphen nur verglichen werden muss, ob als Kind-Element eine weitere Schleife auftritt (siehe analysis.loops.loopnester#nest). Für die erkannten Schleifen kann nun die eigentliche Extraktion durchgeführt werden. Die Parallelisierung erfolgt immer von auÿen nach innen. Nach und nach wird versucht mehrdimensionale Kernel-Aufrufe zu erkennen. Parallelisierbare Schleifen müssen dabei immer direkt geschachtelt sein. Es darf also bis auf die Inkrementierung von Schleifenzählern keine Instruktion zwischen den Schleifenköpfen zu nden sein. Dies ist nötig, da die mehrfach geschachtelten Schleifen in CUDA abstrahiert werden und die Einzelschleifen damit nicht mehr explizit ausgeschrieben werden, sondern über das Ausführungsmodell abgebildet werden. Zuerst wird jeweils überprüft, ob eine Schleife überhaupt als CUDA-Kernel extrahiert werden kann. Die Bibliothek stellt dabei zwei Möglichkeiten zur Verfügung: Spezikation einer Annotation mit Angabe des Namens der Inkrement-Variable, sowie die Durchführung einer automatischen Analyse. Letztere stellt sicher, dass keine direkten Schreibvorgänge auf lokale Variablen auÿerhalb des Schleifenrumpfes geschehen und dass sich keine indirekten Schreibzugrie, z.b. auf Arrays, überschneiden [Cal10][S. 36]. Dies ist zur parallelen Ausführung der einzelnen Schleifendurchläufe in CUDA und der damit einhergehenden unspezizierten Ausführungsreihenfolge nötig. Von der Reihenfolge abhängige Schleifendurchläufe würden Race-Conditions und damit unspeziziertes Verhalten hervorrufen. Anschlieÿend wird die Anzahl der parallelisierbaren Dimensionen ermittelt. Dies wird durch Überprüfung der direkten Kind-Elemente auf ihren Schleifentyp durch Anwendung des Resultats des vorhergehenden LoopNesters erreicht. Verwendete Variablen werden schlieÿlich insofern erkannt, als deren Inhalte beim Kernel-Start und dessen Beendigung kopiert werden müssen. Dazu

45 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 38 wird eine sogenannte Live-Variable-Analyse durchgeführt. Eine Variable ist dabei live, wenn sie gelesen wird, bevor ihr Inhalt geschrieben wurde [Cal10][S. 16]. Derartige Variablen müssen von auÿerhalb befüllt worden sein und müssen deswegen auf die GPU kopiert werden. Zusätzlich werden automatisch alle statische Variablen in den Konstanten-Speicher kopiert. Auf all diesen copy-in-variablen wird abgefragt, ob auf sie während der Kernel-Abarbeitung ein Schreibzugri stattndet. In diesem Fall wird die Variable nach der Kernel Ausführung zurück kopiert (siehe analysis.kernelextractor). Basierend auf den gefunden Kernel-Instruktionen wird nun der eigentliche CUDA-C-Kernel durch Anwendung des Visitor-Patterns auf dem internen Objektgraphen erstellt. Der entsprechend generierte Kernel-Code, sowie nötiger Quelltext für den Aufruf des Kernels, wird direkt in eine Datei geschrieben. Der Name der Aufruf-Methode entspricht dabei der JNI-Konvention. Wird während des Exportes eine Instruktion erkannt, die nicht in C umgesetzt werden kann, so erfolgt ein Fallback auf die Java-Implementierung. Dies ist u.a. dann der Fall, wenn eine Java-API- Methode aufgerufen wird, für die kein C Äquivalent zur Verfügung steht (z.b. einige Java-Math- Methoden). Nach erfolgreichem Export des Kernels wird schlieÿlich der Basic-Block des Kernels durch einen JNI-Methodenaufruf ersetzt. Dazu muss letztendlich ein interner Methodenaufruf im Instruktionsgraph erzeugt und statt dem eigentlichen Basic-Block eingehängt werden. Abschlieÿend werden geänderte Teile des Instruktions-Graph in Bytecode exportiert und die generierten Quelltext-Dateien kompiliert. Evaluation Die Bibliothek ist insbesondere interessant, weil sie eine der ersten Bibliotheken zur GPU-Parallelisierung in Java darstellt und dabei eine sehr klare und schöne Struktur aufweist. Allerdings haften ihr zwei Nachteile an, weswegen im Nachfolgenden nicht weiter auf unterschiedliche Features eingegangen wird: ˆ Die Plattform-Unterstützung ist rein auf Linux begrenzt. Das in den Anforderungen geforderte Windows-Betriebssystem wird nicht unterstützt. Grund dafür sind die sich von Linux unterscheidenden Header-Imports der Windows C-Quelltext-Dateien. ˆ Das Projekt ist für den ursprünglichen Entwickler mit der Dissertation abgeschlossen. Seit der Fertigstellung der Arbeit fanden keine Code-Änderungen mehr statt. Mittlerweile kompiliert der generierte Code zwar noch, ausführbar ist er aber nicht mehr. Eine entsprechende Anfrage an den Entwickler erhielt zwar eine Antwort, eine Lösung erfolgte jedoch nicht. Gleichzeitig fehlen noch viele weitere geforderten Anforderungen: Verschiedene Ausführungsmodi sind nicht möglich, da nur entweder eine sequentielle Abarbeitung oder aber eine GPU-Abarbeitung möglich ist. Eine parallele Ausführung in Java ist ausgeschlossen bzw. wird nicht unterstützt. Abgesehen davon ziehen mehrere Aufrufe desselben Kernels zusätzliche Kopiervorgänge nach sich, da die Bibliothek nicht zwischenspeichert, welche Objekte bereits auf der GPU gespeichert sind. Sehr positiv ist die Übersichtlichkeit des Quelltextes und insbesondere die Dokumentation. Der Quelltext kann durch Inline-Kommentare und aussagekräftige Methoden- und Feldnamen im Stil

46 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 39 von Clean-Code wie ein Buch gelesen werden. Mit der Dissertation als Dokumentation sind auch die theoretischen Hintergründe verständlich dargestellt und geben einen guten Überblick über die Architektur der Arbeit Aparapi Aparapi (A Parallel API) ermöglicht die explizite Spezikation von OpenCL-Kernels in Java. Die Bibliothek stellt ein aktives Community-Projekt dar, das v.a. von drei AMD-Entwicklern weiterentwickelt wird. Ursprünglich wurde die Bibliothek unter dem Codenamen Barista für die Konferenz SuperComputing 2009 entwickelt. Besonders zeichnet sich die Bibliothek dadurch aus, dass die Parallelisierung nicht in einem zusätzlichen Schritt zwischen Kompilierung und Ausführung erfolgt, sondern dass ausführbarer OpenCL-Quellcode zur Laufzeit generiert, kompiliert und ausgeführt wird. Kernel-Spezikation Ausführbare Kernels werden durch Ableitung von com.amd.aparapi.- Kernel erstellt. Ein Beispiel für einen Kernel inkl. dessen Ausführung ndet sich in Quelltext 5.2. Die zu implementierende #run Methode kapselt dabei die parallel auszuführenden Instruktionen. Felder werden automatisch in Kernel-Parameter umgewandelt. Dabei werden Annotationen für Speicherbereiche wie Constant- oder Shared-Memory unterstützt. Instantiierte Kernels sind schwergewichtig, da diese im Hintergrund den nötigen OpenCL-Kontext halten. Dementsprechend sollten auch nicht zu viele Kernel-Instanzen gleichzeitig vorgehalten werden, sondern besser bestehende Instanzen wiederverwendet oder aber als Zwischenschritt #dispose aufgerufen werden. Dadurch wird verwendeter Speicher wieder freigegeben. Durch Ausführung der #execute Methode wird die Abarbeitung gestartet. Hierzu ist eine Range anzugeben, die die ein- oder zweidimensionale Dimensionsgröÿe bei der Ausführung kapselt. Auf dem Kernel-Objekt kann zusätzlich durch Aufruf von #setexecutionmode der Ausführungsmodus gesetzt werden. Aparapi stellt hier die parallele Ausführung auf GPU, CPU, in Form eines Java-Thread-Pools sowie in Form einer nativen CPU-Ausführung mit OpenCL, sowie einen sequentiellen Ausführungsmodus zur Verfügung. Kompilierung und Ausführung Die Kompilierung wird immer pro Kernel-Instanz beim ersten Kernel-Start ausgeführt und bei mehrfacher Ausführung wiederverwendet. Tritt während der Kompilierung ein Fehler auf, so fällt die Ausführung automatisch auf einen Java-Thread-Pool zurück. Gleichzeitig wird die Transformation und Generierung des OpenCL-Quelltextes nur für die native Ausführung durchgeführt. Da OpenCL, im Gegensatz zu CUDA, nicht auf die GPU- Ausführung festgelegt ist, kann der generierte Code sowohl für den nativen CPU- als auch den GPU-Ausführungsmodus verwendet werden. Die Ausführung erfolgt in diversen Einzelschritten, die in Abbildung 5.5 verdeutlicht sind. In einem ersten Schritt wird der Bytecode der Kernel-Klasse eingelesen und in eine interne Repräsentation überführt. Verantwortlich hierfür ist die ClassModel#parse-Methode, die zuerst den Pfad der entsprechenden Class-Datei sucht und den Inhalt anschlieÿend konvertiert. Dazu

47 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 40 public class ArrayIncrementKe rnel extends Kernel private int [] in ; private int [] out ; private int length ; public ArrayIncrementKe rnel ( int [] in, int [] out, int length ) { this. in = in ; this. out = out ; this. length = length ; public void run () { int id = getglobalid (); if ( id >= length ) return ; } out [ id ] = in [ id ] + 1; } public static void main ( String [] args ) { int [] in = {1, 2, 3, 4, 5}; int [] out = new int [ in. length ]; ArrayIncrementKe rnel kernel = new ArrayIncr ementkernel ( in, out, in. length ); kernel. setexecutionmode ( EXECUTION_ MODE. GPU ); kernel. execute ( in. length ); System. out. println ( Arrays. tostring ( out )); } Quelltext 5.2: Array-Inkrementierung mit Aparapi

48 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 41 wird in Aparapi keine zusätzliche Bibliothek verwendet. Stattdessen entsteht die interne Repräsentation durch Anwendung der Java-Bytecode Spezikation [LYBB13], d.h. durch Byte-weises einlesen der Quelldatei. Im Wesentlichen wurde im Rahmen der Bibliothek also eine eigenständige Bytecode-Bibliothek zum Einlesen erstellt. Jede einzelne Instruktion wird mit einem internen InstructionSet verglichen, der sämtlichen möglichen Befehle innerhalb des Bytecodes enthält. Die sich ergebende Menge an Instruction-Objekten geben den Kontrolluss der Methoden wieder. In einem zweiten Schritt wird der Instruktionsgraph durch Setzen der Verzweigungsziele auf den einzelnen Instruktionen vervollständigt und das Ergebnis so angepasst, dass anschlieÿend OpenCL-Quelltext generiert werden kann. Dabei werden beispielsweise Bytecode DUP-Operationen [LYBB13][S. 412], die eine bestimmte Menge an vorhergehenden Operationen duplizieren, explizit ausgeschrieben. Eine weitere Anpassung bezieht sich auf die Entfernung bzw. Erkennung von Instruktionen mit Seiteneekten. Dazu gehören u.a. Instruktionen wie return(a++) oder a = b++. Daraufhin werden auf dieselbe Art und Weise die aus dem Kernel-Eintrittspunkt aufgerufenen Methoden eingelesen. Möglich sind dabei u.a. Aufrufe statischer Methoden. Wichtig ist dabei die Prüfung auf eventuell auftretende Rekursion. Diese wird von OpenCL nicht unterstützt [Mun11][S. 232]. Da für jede Methode eine Liste an Methoden vorgehalten wird, die von der Methode selbst aufgerufen werden, kann rekursiv geprüft werden, ob unter Aufruf dieser Methoden die eigene Methode nochmals aufgerufen wird (siehe MethodModel#checkForRecursion). Tritt der entsprechende Fall ein, so ist eine OpenCL-Generierung nicht möglich, und der Ausführungsmodus wird auf die Java-Thread-Pool-Ausführung zurückgesetzt. Als Abschluss der Erkennungs- bzw. Einlesephase wird nochmals über alle Methoden und deren Instruktionen gelaufen und erkannt, welche Felder, Arrays und Objekte verwendet werden. Diese werden sowohl für den Kopiervorgang als auch für das Schreiben des Kernels markiert. Für das eigentliche Schreiben der Kernel-Methode ist die KernelWriter-Klasse zuständig. Hier werden benötigte Referenzen in einem struct vorgehalten sowie Methoden-Implementierungen geschrieben. Das Schreiben der einzelnen Instruktionen erfolgt Instruktion für Instruktion in der BlockWriter#writeBlock-Methode. In if-konstrukten über alle möglichen Bytecode- Instruktionstypen werden die passenden OpenCL-Instruktionen in den resultierenden String geschrieben. Als Gesamtergebnis ergibt sich eine Zeichenkette mit dem Kernel, seinen Instruktionen, allen aufgerufenen Methoden und deren Inhalt, sowie, für die Abarbeitung nötige, structs und Felder. Schlieÿlich wird in einem JNI-Aufruf durch Nutzung der OpenCL-API der generierte Code kompiliert (#clcreateprogramwithsource und #clbuildprogram). Aus dem Kernel referenzierte Felder werden zur eigentlichen Ausführung in einen Array an KernelArg-Objekten übersetzt. Diese kapseln sowohl den Typ des Feldes, z.b. Float, Int, Array, usw., als auch den Speichertyp, also Constant-, Local- oder Global-Memory, und den Feld-Inhalt. Entsprechend wird für die Argumente Speicherplatz alloziert, die Inhalte auf die GPU kopiert und der Kernel ausgeführt. Nach der Ausführung wird nötiger Speicher wieder zurück auf die CPU kopiert und unter den passenden Adressen Java zur Verfügung gestellt.

49 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 42 ja GPU- oder CPU-Ausführung? nein ja noch nicht kompiliert Lade und konvertiere *.class-dateien Setzen der Verzweigungen Einlesen von aufgerufenen Methoden Erkennung verwendeter Felder Java Ausführung Schreiben des Kernels Kompilierung via JNI OpenCL Ausführung Abbildung 5.5: Ausführung eines Aparapi-Kernels

50 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 43 Für Aparapi konnten alle gestellten Evaluationsalgorithmen vollständig implemen- Evaluation tiert werden. Insbesondere hervorzuheben ist die Unterstützung der expliziten Speicherverwaltung, die es ermöglicht, mehrere Kernel-Aufrufe, ohne zwischengeschaltete Kopieroperationen, zu erstellen [apa13b][explicitbuerhandling]. Dies funktioniert allerdings nur basierend auf einem Kernel, da Speicherbereiche mehrerer Kernels vollständig getrennt sind. Deswegen kann ein Kopiervorgang bei Ausführung des zweiten Kernels nicht verhindert werden. Die Entwickler empfehlen momentan aus dem Standard-Eintrittspunkt mit einem If-Konstrukt auf eine Klassen-Variable zu prüfen und darauf basierend weitere Methoden aufzurufen, wobei jede Bedingung einen eigenständigen Kernel repräsentiert [apa13b][emulatingmultipleentrypointsusingcurrentapi]. Ein Vorschlag für die Verwendung verschiedener Eintrittspunkte in einem Kernel durch die Implementierung dedizierter Methoden liegt dabei bereits vor, ist aber im Augenblick nur halbfertig im Quellcode zu nden. Eine objektorientierte Programmierung ist dementsprechend nur sehr begrenzt möglich. Speziziert man unterschiedliche Kernel-Objekte, so handelt man sich auf der einen Seite einen stark erhöhten Speicherbereich durch im Hintergrund vorgehaltenen OpenCL-Kontexte sowie eine verlangsamte Ausführung durch auf Grund der getrennten Speicherbereiche nötig werdende Kopiervorgänge ein. Zum Thema Speicherverwaltung wurden zusätzlich weitere Speicherarten implementiert. So werden explizit Constant- und Shared-Memory über Annotationen unterstützt [apa13b][usingconstantmemory, UsingLocalMemory]. Diese müssen vom Entwickler händisch an Attribute angebracht werden, damit der für OpenCL notwendige Quelltext für die Speichertypen richtig eingefügt wird. Die Verwendung expliziter Annotationen ist durchaus sinnvoll, da nur an sehr ausgewählten Stellen die Speichermodelle sinnvoll sind und meist angepasste Algorithmen benötigen. Basierend auf diesen Implementierungen können leicht weitere Speichertypen, wie z.b. Texture- Memory o.ä., implementiert werden. All diese Speichermodelle müssen, soweit der Entwickler sie nicht verwenden möchte, nicht speziziert werden. In diesem Fall übernimmt die Bibliothek die gesamte Speicherverwaltung, inkl. der Kopiervorgänge von und zur GPU, transparent für den Entwickler. Eventuell auftretende Exceptions werden nicht gefangen. Tritt z.b. eine NullPointerException während der GPU-Ausführung auf, so beendet die Ausführung mit einem OpenCL-Fehler und fällt auf die Ausführung in Java zurück. Tritt hier die Exception nochmals auf, so wird diese entsprechend normalen Java-Exceptions weitergereicht. Leider entspricht es nur dem Idealfall, dass ein Fehler, der auf der GPU auftritt, auch in der Java-Ausführung in Erscheinung tritt. Während der Evaluation wurden mehrere Fehler angetroen, die nur in der GPU-Ausführung auftraten. In Hinblick auf die Verwendung von Arrays bietet Aparapi die Verwendung von eindimensionalen Arrays an. Mehrdimensionale Arrays werden erst in der neuesten Version unterstützt. Das length-attribut auf Arrays ist ebenfalls implementiert, ist allerdings in der vorliegenden Version nicht funktionsfähig. Verwendete Arrays dürfen nicht nur primitive Datentypen, sondern auch komplexere Objekte enthalten. Diese werden in C-Structs zur Ausführung umgewandelt. Die Entwickler warnen allerdings vor Geschwindigkeitseinbuÿen aufgrund der struct-befüllung [apa13b][newfeatures].

51 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 44 Soweit der Ausführungsmodus entsprechend gesetzt ist wird der vorliegende Java-Kernel-Code automatisch in OpenCL umgesetzt. Dabei werden Methodenaufrufe für Methoden auÿerhalb der eigenen Klasse unterstützt. Allerdings sollte vom Aufruf allzu komplexer Methoden abgesehen werden, da entsprechender Code auch auf der GPU ausgeführt werden muss. Durch Implementierung des OpenCL-Interfaces lässt sich die Code-Generierung so beeinussen, dass selbst geschriebener nativer OpenCL-Code zur Ausführung verwendet wird [apa13b][new- OpenCLBinding]. Dadurch lässt sich die in den Anforderungen beschriebene prototypische Entwicklung umsetzen. Als Ausführungsmodi stehen die bereits oben beschriebenen Varianten CPU, GPU, Java-Thread- Pool (JTP) sowie SEQ zur sequentiellen Ausführung zur Verfügung. Bis auf SEQ werden alle Ausführungsmodi gleichwertig unterstützt. Dies bezieht sich v.a. auch darauf, dass API-Aufrufe zum Herausnden der Thread-ID auch im JTP-Modus funktionieren. Da spezizierte Blöcke zur JTP-Ausführung sequentiell ausgeführt werden, ist auch die Verwendung von Shared-Memory im entsprechenden Modus möglich und liefert die richtigen Ergebnisse. SEQ wird nur mit Blöcken der Gröÿe 1 unterstützt, da gröÿere Werte im Fall von Block-Barrieren durch die sequentielle Ausführung einen Dead-Lock auslösen würden (siehe KernelRunner). Dies ist allerdings auf Grund der guten Unterstützung von JTP keine Einschränkung. Zur Zeitmessung wird vor und nach der Kernel-Ausführung die momentane Systemzeit in Nanosekunden gemessen und die Dierenz ermittelt. Diese spiegelt die Ausführungszeit inklusive aller Kopiervorgänge wieder. Über eine zusätzliche System-Property lässt sich auch explizites Proling einschalten. Damit werden bei jeder Kernel-Ausführung ProfileInfo-Objekte generiert, die einen detaillierten Überblick über die Dauer von ausgeführten Lese-, Schreib- und Ausführungsoperationen geben. Eine Schnittstelle zu einem OpenGL-Binding zur Anzeige von Ergebnissen gibt es momentan nicht. Folglich muss der Speicherinhalt jeweils vor der Visualisierung in den CPU-Speicher zurück kopiert werden und kann nicht direkt auf der GPU erfolgen. Schön wäre hier eine direkte Integration von z.b. Java Bindings für OpenGL (JOGL), so dass kein weiterer Kopiervorgang nötig ist. In den FAQ geben die Entwickler von Aparapi an, dass sie sich eher auf parallele Datenberechnungen als auf die Anzeige von Graken spezialisieren. Eine direkte Integration von JOGL ist deswegen nicht geplant [apa13b]. Als Plattformen zur Ausführung werden Linux und Windows gleichermaÿen unterstützt. Durch den JIT-Ansatz zur Kompilierung von OpenCL-Code ist die Bibliothek sehr leicht einzubinden, da keine etwaigen Staging-Schritte in einem Build-Skript nach der Kompilierung ausgeführt werden müssen. Damit muss nur das entsprechende jar-archiv eingebunden werden und zusätzlich sichergestellt werden, dass die native OpenCL-Bibliothek von Aparapi aundbar ist. Ein groÿes Manko ist die mangelhafte Dokumentation. Das Wiki bietet zwar einige Informationen, eine Beschreibung der Architektur ndet sich allerdings nirgends. Einige JavaDoc-Kommentare bieten Hinweise, was komplexe Methoden bewirken. Im Wesentlichen bleibt zur Dokumentation aber nur der Blick in den Quelltext. Dieser ist allerdings nicht immer klar geschrieben ist und weist mittlerweile viele Fragmente auf (siehe z.b. Code für mehrere Eintrittspunkte in einem Kernel). Da die Bibliothek von einer breiten Community und von drei AMD Entwicklern vorangetrieben wird, ist eine Einstellung sehr unwahrscheinlich. Momentan wird aktive Entwicklung zur Ein-

52 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 45 bindung der in Java 8 kommenden Lambda-Funktionen betrieben (siehe dazu auch Abschnitt 5.3.6). Insgesamt ist die Komplexität der Bibliothek überschaubar, da das Einlesen des Bytecodes nur die Java-Spezikation abarbeitet und anschlieÿend auf einer internen Repräsentation Optimierungen durchgeführt werden. Tritt ein Fehler bei der Kompilierung bzw. Code-Generierung auf, so lässt sich dieser v.a. auf Grund der JIT-Kompilierung, die ein direktes Debugging in die Bibliothek erlaubt, mit verhältnismäÿig geringem Aufwand nden. Schwierig wird die Fehlersuche v.a. wenn ein Fehler im nativen Teil der Bibliothek gesucht werden muss Rootbeer Die Bibliothek Rootbeer wird von Philip Pratt-Szeliga an der Syracuse-Universität in New York entwickelt. Die grundsätzliche Bibliothek (java-autocuda) entwarf er in seiner Masterarbeit zur impliziten Parallelisierung von Java-Code in CUDA [PS10], die fast parallel zu java-gpu entstand. Zum Kopieren nötiger Daten und zum Starten von Kernels nützt diese das bereits beschriebene Framework JCuda [PS10][S. 22]. Die entstandene Bibliothek wurde weiter entwickelt und letztlich in Rootbeer umbenannt. Allerdings liegt mittlerweile der Fokus nicht mehr auf der impliziten Parallelisierung von Java, sondern, genau wie bei Aparapi, auf der expliziten Spezikation paralleler Kernels. Auch JCuda wurde im Lauf der Zeit entfernt und durch eine selbst entwickelte Ausführungsumgebung ersetzt. Im Gegensatz zu Aparapi ist bei Rootbeer ein expliziter Staging-Schritt zur Code-Generierung nach der Kompilierung notwendig. Hier wird der Kernel-Code extrahiert und in die entsprechende CUDA-Darstellung umgewandelt. Dies geschieht auf Basis eines Eingangs-Jar-Archivs und ergibt nach der Umwandlung ein neues Jar-Archiv, das alle parallelen Code-Bestandteile enthält. Kernel-Spezikation Ein Kernel, wie z.b. zur Array-Inkrementierung in Quelltext 5.3, wird durch Implementierung des Kernel-Interfaces und Denition der entsprechenden #gpumethod deniert. Um die eigentliche Ausführung kümmert sich die Rootbeer-Klasse. Diese kapselt die Methoden zur Ausführung, zum Setzen der Grid-Konguration und der Grakkarte. Vorsicht ist bei der parallelen Ausführung mehrerer Kernels geboten. Die Rootbeer-Klasse ist aufgrund von Klassenattributen nicht thread-safe. Der Ausführungsmodus kann zur Laufzeit geändert werden. Rootbeer bietet hier drei Modi an: MODE_GPU, MODE_JEMU (Java-Emulation mit Thread-Pools) und MODE_NEMU (Native Emulation mit pthreads). Der Modus kann allerdings zur Ausführungszeit nicht beliebig verändert werden. Bei der Umwandlung wird entweder nativer CPU- oder GPU-Code generiert. Entsprechend muss bei einer Änderung des Ausführungsmodus die Umwandlung erneut ausgeführt werden. Dagegen kann ein Wechsel auf JEMU immer stattnden, da hier kein nativer Quelltext benötigt wird. Die eigentliche Ausführung beginnt mit Ausführung der #runall-methode. Normalerweise wird eine Liste an Kernel-Objekten erwartet, die nacheinander abgearbeitet werden. Seit Neuestem ist auch die Spezikation eines einzelnen Kernel-Objektes möglich (sog. Template), das entsprechend der Thread-Konguration ausgeführt wird. Zur Laufzeit können innerhalb der Kernel-Methode verschiedene Rootbeer-Methoden zur Iden-

53 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 46 tizierung des aktuellen Threads auf dem RootbeerGpu-Objekt aufgerufen werden. Diese geben, analog zur nativen CUDA-Ausführung, z.b. den momentanen Block oder die Thread-ID im aktuellen Block zurück. Staging Zur Generierung des CUDA-Codes ist ein expliziter Staging-Schritt nötig. Dieser ist in Abbildung 5.6 illustriert und wird nachfolgend genauer erklärt. Auf Grund fehlender Architekturdokumentation stammen die nachfolgenden Informationen direkt aus dem Quelltext. Die zentrale Einstiegsklasse zur Kompilierung ist rootbeer.main. Hier werden entsprechende Kommandozeilenargumente, die zur Kompilierung verwendet werden, erkannt und verarbeitet. Argumente sind z.b. für die verschiedenen Ausführungsmodi, die Ausführung von Tests, die Verwendung von Doubles und Rekursion vorhanden. Direkt im Anschluss wird basierend auf dem verwendeten Betriebssystem (oziell unterstützt sind Windows, Linux und Mac OS X) in den entsprechenden Verzeichnissen nach der CUDA-Bibliothek gesucht und diese geladen. Anschlieÿend wird die Bytecode-Bibliothek Soot initialisiert. Diese stellt die Funktionalitäten zum Einlesen, zur Manipulation und zur Ausgabe von Bytecode zur Verfügung [VKZ11][S. 1]. Als interne Intermediate Representation / Zwischenrepräsentation (IR), die auch für Manipulationen genutzt werden kann, stellt sie zwei Varianten zur Verfügung: Jimple und Shimple. Dabei ist Jimple die eigentliche Zwischensprache und Shimple eine Static Single Assignment (SSA)-optimierte Variante davon. SSA bedeutet, dass eine Zuweisung an eine Variable nur exakt einmal im Programm vorkommen kann. Dies wird über die Einführung zusätzlicher Variablen erreicht. Rootbeer verzichtet auf diese Art der Darstellung und verwendet Jimple als Zwischensprache [PSFW12][S. 3]. Zur Initialisierung wird der Inhalt des Eingangsarchivs in einem temporären Verzeichnis abgelegt und anschlieÿend das Laden der Klassen angestoÿen. Dies erfolgt über eine für Rootbeer modi- zierte Variante von Soot. Im Wesentlichen wurde eine RootbeerCompiler-Klasse aufgebaut, die die Einzelklassen des Jars einliest und Meta-Informationen daraus extrahiert. Dabei werden v.a. Eintrittspunkte für Kernels erkannt und der Aufrufgraph zwischen allen Methoden und Klassen des Jars aufgebaut. Jedem Eintrittspunkt wird dabei zusätzlich ein Tiefensuchen-Objekt (DfsInfo) zugeordnet, das in der Eintrittsmethode direkt oder indirekt referenzierte Typen sowie Methodenaufrufe enthält. Die einzelnen Instruktionen im Methodenkörper werden wiederum von Soot eingelesen und in die interne Darstellung übersetzt. Nachdem alle Klassen in das Zwischenformat konvertiert sind, werden die einzelnen Kernel- Methoden bearbeitet. Zuerst prüft dabei ein FieldReadWriteInspector auf Felder, die in der Eintrittsmethode, bzw. in daraus aufgerufenen Methoden, verwendet werden. Entsprechend geschriebene oder gelesene Felder werden in passenden Datenstrukturen abgelegt. Diese sind später wichtig, um herausnden zu können, welche Daten auf die GPU und respektive zurück kopiert werden müssen. Anschlieÿend wird der eigentliche Kernel-Code generiert. Der entsprechende Schritt hängt von der zur Staging-Zeit angegebenen Ausführungskonguration ab: Wird der GPU-Modus eingestellt, so wird CUDA-Code erzeugt. Alle anderen Kongurationen resultieren in nativem C-Code, der mit pthreads parallelisiert ist. Intern wird diese Art der Erzeugung in einer OpenCLScene#getOpenCLCode-Methode durchgeführt. Die Methode zur CUDA-Code Erzeugung bendet sich dabei ebenfalls in der OpenCLScene-Klasse. Der oen-

54 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 47 public class ArrayIncrementKe rnel implements Kernel { private int [] in ; private int [] out ; public ArrayIncrementKe rnel ( int [] in, int [] out ) { this. in = in ; this. out = out ; public void gpumethod () { int index = RootbeerGpu. getthreadid (); if ( index >= in. length ) return ; int value = in [ index ]; out [ index ] = value + 1; } public static void main ( String [] args ) { int [] in = new int [] {1, 2, 3, 4, 5}; int [] out = new int [ in. length ]; Rootbeer rootbeer = new Rootbeer (); List < Kernel > kernels = new ArrayList < Kernel >(); for ( int i = 0; i < in. length ; i ++) kernels. add ( new ArrayIncrementKernel (in, out ); rootbeer. runall ( kernels ); Configuration. runtimeinstance (). setmode ( Configuration. MODE_JEMU ); } } System. out. println ( Arrays. tostring ( out )); Quelltext 5.3: Array-Inkrementierung mit Rootbeer

55 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 48 Initialisierung Laden der Klassen im jar-archiv für alle Kernels Prüfung von Feldern ja MODE_GPU Generierung des nativen Codes Generierung des nativen Codes Kompilierung Generiere Serialisierungs-Bytecode Schreiben der Klassen Packen der Klassen als neues jar-archiv Abbildung 5.6: Ablauf der Kompilierung in Rootbeer

56 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 49 sichtliche Widerspruch stammt wohl noch aus der Zeit der Masterarbeit, da damals versucht wurde, OpenCL-Code zu erzeugen. Es blieb allerdings beim Versuch, da kein valider Quelltext erzeugt werden konnte [PS10][S. 52]. Die Code-Generierung für die GPU- und die native Ausführung unterscheidet sich nur dadurch, dass der GPU-Code am Ende noch zusätzlich kompiliert wird. Die Spezika, wie z.b. die Parallelisierung mit pthreads oder aber Kernel-Eintrittspunkte (z.b. das global-attribut für CUDA) werden über Tweaks Klassen abgebildet, die global als Singleton zu Beginn des Staging-Schrittes gesetzt wurden und an passenden Stellen im Programm, wie z.b. zum Einfügen der Quelltext- Zeilen zum Kernel-Start, aufgerufen werden. Die eigentliche Code-Generierung ist etwas schwieriger als in Aparapi. Grund dafür ist, dass sowohl Synchronisationsmonitore als auch Exceptions auf der GPU unterstützt werden. Beide Features nden sich weit verstreut im Generierungscode wieder. Für Monitore wird bei jeder Methode, soweit diese synchronisiert ist, ein Intialisierungsblock für die Synchronisierung geschrieben. Diese ist über aktives Warten und ein atomares Compare-and- Swap (CAS)-Kommando realisiert. Exceptions müssen über try-catch-blöcke abgebildet werden, die auch intern im Code-Segment vorkommen dürfen. Dazu wird bei der Abbildung der einzelnen Instruktionen durch entsprechende Code-Generierung explizit auf Exceptions geachtet. Unterstützt werden dabei nur NullPointerExceptions und OutOfMemoryExceptions. ArrayIndexOutOfBounds-Exeptions werden nur indirekt unterstützt, da Rootbeer einen Fehler beim Lesen der Daten vom Heap meldet. Dabei gerät allerdings die Ausführung in einen Deadlock, da die Threads, die die Ergebnisse einsammeln können, ewig auf die Ergebnisse in ihrer BlockingQueue warten. Nach Schreiben des Initialisierungs-Codes, v.a. für die Monitore, fehlt noch ein letzter Schritt vor dem Schreiben der eigentlichen Instruktionen. Zur Unterstützung von Monitoren müssen eventuell vorhandene Monitor-Gruppen über EnterMonitorStmt- und ExitMonitorStmt-Instruktionen erkannt werden. Anschlieÿend werden die einzelnen Instruktionen den Gruppen zugeordnet und diese einzeln durch das bereits in java-gpu eingeführte Visitor-Pattern verarbeitet. Für jeden Instruktionstyp ist dabei in der Klasse MethodStmtSwitch eine case.*-methode implementiert, die in einen StringBuilder den generierten C-Code schreibt. Hier ist auch für die Monitor-Gruppe ein nochmaliges aktives Warten implementiert. Das Ergebnis der Methodeninhalte wird zusammen mit nötigen Header-Denitionen, Methoden- Prototypen und einem Kernel-Template in einen String geschrieben. Das Kernel-Template enthält den Eintritts-Quellcode, der schlieÿlich die eigentlichen, generierten, Kernels aufruft. Dieser Inhalt wird jeweils für Linux und für Windows vorgehalten, da die entsprechenden Header- Denitionen im Fall von NEMU verschieden sind. Der Aufruf der generierten Kernel-Methode ergibt sich schlieÿlich durch String-Ersetzung eines in der Kernel-Template-Datei denierten Parameters durch den generierten Methodennamen. Der generierte Code wird im Fall der GPU-Ausführung anschlieÿend kompiliert. Da die Header- Dateien im Fall der CUDA-Ausführung für Windows und Linux gleich sind, wird nur der Linux- Code zur Kompilierung verwendet. Als Ergebnis ergeben sich zwei Byte-Arrays mit dem für 32- und 64-Bit als Fat-Binary kompilierten Quellcode. Dieses enthält den für unterschiedliche GPU-Architekturen kompilierten cubin-code. Nach der Kompilierung wird abschlieÿend Java Bytecode generiert, der nötige Methoden zum

57 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 50 Lesen- und Schreiben der auf der GPU benötigten und veränderten Daten zur Verfügung stellt. Die Idee dabei ist, dass der Inhalt sämtlicher verwendeter Felder und Variablen zuerst in einen Speicherbereich auf den Heap geschrieben werden und dieser anschlieÿend mit nur einem, via JNI aufgerufenen, cudamemcpy-aufruf auf die GPU transferiert werden kann. Rückwärts funktioniert der Ablauf analog. Die dazu nötigen Methoden werden mit Soot generiert und in eine Serializer-Klasse geschrieben, die in die Kernel-Klasse eingehängt wird (siehe VisitorGen). Schlieÿlich werden alle Kernel-Klassen mit dem zusätzlichen Interface CompiledKernel versehen, das die notwendigen Methoden, zum Beispiel zum Abruf der erwähnten Serializer-Instanz, kapselt. Gleichzeitig wird das Interface bei der Ausführung zur Unterscheidung bereits kompilierter und noch nicht kompilierter Kernels verwendet. Wurde der Staging-Schritt beispielsweise übersprungen, so fehlt das Interface an den Kernel-Klassen. Da damit die Code-Generierung und -Kompilierung abgeschlossen ist, werden nun alle Klassen aus der Zwischendarstellung zurück in Bytecode geschrieben und die resultierende Dateien als neues jar-archiv gepackt. Ausführung Nach der Kompilierung kann der generierte Code ausgeführt werden. Der grundlegende Ablauf ist in Abbildung 5.7 illustriert. Wird der Staging-Schritt übersprungen, so wird dies durch Prüfung auf das CompiledKernel- Interface erkannt und der Kernel n-mal durch Aufruf der #gpumethod-methode ausgeführt. Da die JEMU-Ausführung näher an die GPU-Ausführung angelehnt ist, wäre diese hier die bessere Wahl. Dies lässt sich in der Rootbeer#runAll Methode durch Änderung einer if-bedingung umstellen. Für die NEMU-Ausführung wird zuerst der generierte C-Code kompiliert und anschlieÿend ausgeführt. Die Kompilierung erfolgt dabei bei jedem Kernel-Aufruf. Anschlieÿend wird durch Aufruf der generierten writetoheap-methoden der Heap-Inhalt für den Kernel-Aufruf generiert und der Inhalt an eine native #runoncpu-methode übergeben. Diese führt in C den eigentlichen Kernel mit konstanten vier Threads aus. Die Ergebnisse werden unter Nutzung der readfromheap-methoden gelesen und in den Objekten wieder zur Verfügung gestellt. Die für die GPU-Auführung nötige Anzahl an Threads bzw. Blöcken wird vor der Ausführung von einem BlockShaper berechnet, der entweder eine zur Kompilierzeit gesetzte Thread-Konguration übernimmt oder eine Konguration automatisch so zu bestimmen versucht, dass die Dierenz der in der GPU gestarteten Threads zur Anzahl der geforderten Kernel-Aufrufe minimal ist. Für den NEMU-Modus spielt das keine groÿe Rolle, da dieser in vier Threads das Verhalten nur emuliert. Die JEMU-Ausführung greift auf ein selbst geschriebenes Master-Worker-Pattern zurück. Dazu wird eine Menge an Arbeitern, alias CpuCores, instantiiert und die auszuführenden Kernels auf diese verteilt. Die Thread- sowie die Block-ID werden während der Abarbeitung einer Aufgabe zur Kernel-Ausführung aktualisiert. Damit die nebenläug laufenden Threads dabei keine Race- Conditions verursachen, werden die Werte in Attribute des aktuellen Threads gesetzt. Über Abruf, welcher Thread die #gettheadidxx bzw. #getblockidxx Methoden aufruft, einen Cast auf den benutzerdenierten Thread und Abruf des Attributes, wird der entsprechend richtige Wert für die momentane Abarbeitung zurückgegeben. Die GPU-Ausführung entspricht bis auf wenige Erweiterungen dem NEMU-Modus. Entspre-

58 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 51 ja erster Kernel kompiliert? nein ja NEMU_MODUS nein ja JEMU_MODUS nein Kopiere Kernel- Blöcke in CPU-Heap Kopiere Kernel- Blöcke in CPU-Heap Bestimme Block und Grid Größe Kompiliere Kernel-Code Ausführung der Kernel-Funktion Parallele Abarbeitung auf CPU-Kernen Lade Funktion auf GPU Ausführung der Kernel-Funktion Sequentieller Aufruf von #gpumethod auf allen Kernels Lesen der Blöcke Lesen der Blöcke Entfernen der Kernel Funktion Abbildung 5.7: Ablauf der Ausführung in Rootbeer

59 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 52 chend werden zuerst nötige Daten im Heap gesammelt. Hier besteht bereits die Möglichkeit die Serialisierung zu parallelisieren. Dies ist bisher noch deaktiviert, soll aber in einer der nächsten Versionen freigeschaltet werden. Anschlieÿend werden, analog zum NEMU-Modus, die Blockund Grid-Gröÿen berechnet und als Abschluss der Kongurationsphase der kompilierte cubin- Code in einer #compilecode-methode über einen nativen Funktionsaufruf geladen. Durch einen Aufruf von #runblocks wird ein nativer Aufruf gestartet, der zuerst die Heap-Daten auf die GPU kopiert, den Kernel Aufruf unter Verwendung von NVIDIA API-Methoden startet und das Resultat zurück kopiert. Analog zum NEMU-Modus werden die im Heap gesammelten Daten schlieÿlich gelesen und dem aufrufenden Code zur Verfügung gestellt. Evaluation Obwohl die Bibliothek im Vergleich zu den drei vorhergehenden Bibliotheken wesentlich komplexer ist, konnten mit Rootbeer nicht alle Algorithmen zur Evaluation implementiert werden. Dies betrit die Algorithmen zur Berechnung der Reduktion von Arrays sowie das Verfahren der konjugierten Gradienten. Beide Algorithmen benötigen eine Synchronisation von auf der GPU ausgeführten Thread- Blöcken. Im Test produzierte diese Synchronisation jedoch unter Verwendung des exakt gleichen Algorithmus, der auch bei Aparapi und JCuda zum Einsatz kam, die falschen Resultate. Die von Rootbeer berechneten Ergebnisse deuten darauf hin, dass die Synchronisation nicht funktionsfähig ist. Eine Anfrage an den Entwickler wurde zwar beantwortet, der Fehler jedoch nicht behoben. Weitere Schwierigkeiten in Bezug auf die Bibliothek betreen beispielsweise die Code-Generierung: Diese ist vom Entwickler vollständig gekapselt und funktioniert im Normalfall auch problemlos. Trotzdem lassen sich Fälle konstruieren, in dem der generierte Quelltext nicht ausführbar ist. Grund dafür ist, dass die Entfernung nicht verwendeter Methoden und Klassen während der Umwandlung nicht immer zuverlässig funktioniert. Unter expliziter Referenzierung der Methoden und Klassen konnte dies während der Evaluation verhindert werden. Ebenfalls kann es vorkommen, dass nach gröÿeren Umbenennungen im Code der Staging-Vorgang abbricht bzw. keine Veränderung aufweist. In diesem Fall müssen händisch die als Cache dienenden Verzeichnisse.rootbeer- und.soot im Benutzerverzeichnis gelöscht werden. Für Linux bietet die Bibliothek dafür ein eigenes Skript mit dem Namen clear_cache an. Auch die Verwendung von Block- und Thread-Ids in Kernels funktioniert nicht zuverlässig. Im Test konnten Fälle reproduziert werden, in denen die Berechnung der globalen ID aus Threadund Block-ID das falsche Resultat lieferte. Wird dagegen die ID als Kernel-Parameter bei der expliziten Erstellung der Kernel-Objekte übergeben, so wird der generierte Code fehlerfrei ausgeführt. Die Code-Umwandlung erfolgt bis auf oben genannte Einschränkungen zuverlässig. Verwendete Methoden, die aus der eigentlichen Kernel-Funktion aufgerufen werden, werden ebenfalls konvertiert und für die Ausführung mit einbezogen. Nativer Code, wie in den Anforderungen beschrieben, kann dagegen nicht eingebunden werden. Sehr unschön ist die Dauer des Staging- Vorgangs. Diese kann gut und gerne fünf bis 10 Minuten betragen und muss nach jeder Code- Änderung neu ausgeführt werden. Mussten dabei die Cache-Verzeichnisse.soot und.rootbeer zusätzlich gelöscht werden, so dauert die Umwandlung um so länger. Die lange Dauer ist al-

60 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 53 lerdings im Augenblick Schwerpunkt der Entwicklung und wurde in den letzten Beta-Versionen wesentlich verbessert ( alpha4). Eine schnelle, prototypische Entwicklung, ist damit trotzdem nicht möglich. Der Autor hat hier starke Zweifel, dass, wie in der Rootbeer-Veröentlichung beschrieben, eine deutliche Steigerung der Produktivität durch geringere Entwicklungszeiten gegenüber der nativen CUDA-Entwicklung möglich ist [PSFW12][S. 1]. Rootbeer übernimmt die Verwaltung des GPU-Speichers inklusive der Kopiervorgänge auf die Grakkarte. Leider ist dabei der mehrfache Aufruf einzelner Kernels, ohne dass bei jedem Aufruf die Daten erneut kopiert werden, nicht möglich. Nach einem Aufruf ist der Zustand des Rootbeer- Ausführungsobjektes wieder zurückgesetzt. Das Selbe gilt entsprechend auch für die Ausführung verschiedener Kernels. Bei jeder Ausführung wird der Code erneut geladen, nötige Daten kopiert, die Ausführung gestartet, Daten zurück kopiert und der Kontext aufgeräumt. Shared-Memory kann dabei durch eine API für alle Ausführungsmodi verwendet werden. Andere Speichertypen werden dagegen nicht unterstützt. In Sachen Fehlerbehandlung stellt Rootbeer eine eigene Infrastruktur für die Unterstützung von Exceptions zur Verfügung. Dies funktioniert einwandfrei für NullpointerExceptions und OutOf- MemoryExceptions. ArrayIndexOutOfBoundsExceptions werden, wie bereits beschrieben, nur indirekt unterstützt. Eine Rückmeldung erfolgt in jedem Fall. Bricht allerdings die Kompilierung während des Stagings ab, so bleibt quasi keine Möglichkeit den Fehler als normaler Entwickler zu nden. Derartige Fehler treten beispielsweise bei der Verwendung des momentan nicht unterstützten JDK 7 auf. Auch das Windows-Betriebssystem wird im Augenblick nur oziell unterstützt, während im Test der Staging-Vorgang mit einer Fehlermeldung abbricht. Rootbeer-Code zu testen ist verhältnismäÿig schwierig. Im Wesentlichen bleibt die Auswahl, entweder mit der seriellen bzw. JEMU-Ausführung vorlieb zu nehmen, wobei dann nicht alle Features unterstützt werden, oder aber vorher den langwierigen Staging-Schritt in Kauf zu nehmen, ohne dann aber die Möglichkeit des Debuggings zu haben, da der ausgeführte Code rein generiert ist und auch nicht auf der CPU ausgeführt wird. Die Komplexität der Bibliothek trägt dabei nicht zum einfachen Aunden von Ursachen für Fehlermeldungen bei. Ein normaler Staging-Vorgang, der mit dem bereitgestellten jar-archiv vorgenommen wird, bietet nur die Möglichkeit des Debuggens, wenn der Bibliotheks-Quelltext in einer IDE aufgesetzt wird und von dort aus der Staging-Vorgang angestoÿen wird. Durch die mangelnde Architektur-Dokumentation und den unverständlichen, schlecht strukturierten und wenig dokumentierten Quelltext wird die Einarbeitung bzw. das Debuggen massiv erschwert. Zur Zeitmessung steht eine sog. Stopwatch-Klasse zur Verfügung, die die CPU-Dierenz in Millisekunden misst. Dediziert kann damit abgefragt werden, wie lange für die Serialisierung, Ausführung und Deserialisierung gebraucht wurde und wie insgesamt die Grid-Konguration bei der Ausführung aussah. Allerdings funktioniert die Zeitmessung nur für die GPU-Ausführung. Für die NEMU- und die JEMU-Ausführungsmodi ist dies nicht implementiert. Der Einbau in ein bestehendes Projekt ist verhältnismäÿig einfach. In einem Build-Skript kann der zusätzliche Schritt des Aufrufs des Rootbeer-Jars eingebaut werden, sodass automatisch das parallelisierte jar erzeugt wird. Genau wie bei Aparapi muss natürlich das entsprechende jar- Archiv zur Programmierung im Classpath vorhanden sein. Schlieÿlich muss bei der Einrichtung eines Betriebssystems, auf der Rootbeer ausgeführt werden soll, überprüft werden, wo die Bibliothek für CUDA genau abgelegt ist. Die von Rootbeer über-

61 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 54 prüften Pfade benden sich in der Klasse CudaLoader. Diese enthält allerdings einige Standard- Suchpfade, die nicht zwangsläug für alle Betriebssysteme zutreen müssen. Entsprechend muss die Klasse ggf. angepasst werden, da sonst die Ausführung des generierten Quelltextes auf Grund der nicht aundbaren CUDA-Bibliothek fehlschlägt. Für das Evaluationsbetriebssystem Ubuntu wurde dies entsprechend durchgeführt. Insgesamt besitzt Rootbeer durchaus Potential zur Nutzung als GPU-Bibliothek. Insbesondere die Unterstützung von Exceptions auf der GPU sind attraktiv. Allerdings ist der momentane Entwicklungsstand nicht annähernd für eine produktive Anwendung empfehlenswert. Hinzu kommt, dass der momentane Hauptentwickler seine Doktorarbeit aufbauend auf Rootbeer schreibt und diese relativ bald beendet haben wird. Die entsprechende Information stammt aus Antworten des Entwicklers auf Fehlermeldungen auf der Github-Seite des Projekts. Dementsprechend wird sich der Entwickler nach Fertigstellung der Arbeit anderweitig bewerben und damit nicht zwingend Zeit für Rootbeer erübrigen können.

62 5.3. Evaluation verfügbarer Abstraktionsbibliotheken Delite Einen ganz anderen Weg als Rootbeer und Aparapi nimmt die von der Stanford-Universität entwickelte Bibliothek Delite. Genau wie java-gpu setzt die Bibliothek auf implizite Parallelisierung und erfordert damit keine explizite Spezikation von Kernels, die später auf der GPU ausgeführt werden. Das Grundprinzip der Bibliothek ist der, von den Entwicklern geprägte, Begri der Sprach-Virtualisierung (language virtualization, [RO10][S. 2]). Eine Programmiersprache ist dann virtualisierbar, wenn sie eine DSL-Umgebung beherbergen kann, die gegenüber einer Eigenimplementierung der DSL bzgl. folgender Kriterien identisch ist: ˆ Ausdrucksfähigkeit Domänen-Spezialisten sollen in der Lage sein ihr Wissen in natürlicher Sprache abzulegen. ˆ Geschwindigkeit Das Domänen-Wissen kann zur Optimierung des generierten Codes genutzt werden, um dadurch eine höhere Geschwindigkeit zu erzielen. ˆ Sicherheit Die Implementierung des DSL-Programms darf Garantien bzgl. Programmausführung nicht aufgeben. ˆ Vertretbarer Aufwand Statt einer DSL kann eine neue Sprache implementiert werden. Dazu ist allerdings die aufwändige Erstellung eines entsprechenden Compilers nötig. Dieser Aufwand soll bei der Sprach-Virtualisierung entfallen. Framework-Stack Vor der Beschreibung des Ablaufs der Kompilierung bzgl. der eigentlichen Programm-Spezikation ist es nötig den grundsätzlichen Framework-Stack zu verstehen bzw. deren Einzelaufgaben näher zu erläutern. Im Wesentlichen werden verschiedene DSLs, Delite selbst, Leightweight Modular Staging (LMS) sowie Scala-Virtualized verwendet. Grundsätzlich bietet die Programmiersprache Scala bereits eine gute Ausgangsposition für DSLs. Grund dafür sind v.a. die hohe Flexibilität der Syntax der Sprache, sehr wenige Sprach-Features, die nicht als Methode realisiert und damit auch vom Entwickler überschreibbar sind, implizite Denitionen und Parameter sowie Manifeste [MRHO12][S. 1]. Die hohe Flexibilität erlaubt damit die Spezikation von Konstrukten, die auf der einen Seite wie natürliche Sprache und auf der anderen Seite wie native Scala-Konstrukte aussehen. Dies wird auch in Bibliotheken, z.b. zum Unit-Testing von Scala-Applikationen, aktiv verwendet. Der Ausdruck (5+3) mustbe 8 ist z.b. in der Sprache valide. Scala selbst setzt ebenfalls auf sehr wenige integrierte Sprach-Features und deniert die meisten Features über derartige Funktionen. Für domänenspezische Sprachen, wie es die Sprach-Virtualisierung fordert, ist dies optimal, da die Syntax an die Problem-Domäne angepasst werden kann. Weitere Konstrukte erlauben es, Parameter und Funktionsaufrufe zu verstecken. Somit werden implizite Typ-Konvertierungen, Methodenaufrufe oder Parameter möglich, die bei Bedarf aufgerufen werden. Die entsprechenden Aufrufe bzw. Parameter-Denitionen werden vom Compiler eingefügt. Zum Beispiel ist es damit möglich, eine Funktion, die als def test(implicit a:

63 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 56 String) {...} deniert ist, mit test() aufzurufen. Dazu muss aber im momentanen Scope eine implizite Variable von Typ String deniert sein: implicit val somevariable = "HalloWelt". Auf dem selben Weg lassen sich auch Methodenaufrufe einfügen [OSV10][S. 453]. Im Hinblick auf domänenspezische Sprachen schat dies die Möglichkeit eine klare Syntax durch Verstecken von nötigen, aber für die Domäne semantisch verwirrenden, Methodenaufrufen zu schaen. Eine spezielle Ausprägung von impliziten Parametern sind Manifest-Objekte. Diese geben zur Laufzeit Aufschluss über generische Typen. In einer generischen Methode kann damit z.b. geprüft werden, ob der Typ-Parameter T vom Typ String ableitet. Sobald der Compiler einen impliziten Parameter einer Methode vom entsprechenden Typ ndet, wird automatisch ein entsprechendes Objekt generiert [MRHO12][S. 2]. Zur Laufzeit ist dies in Java nicht möglich, da durch die Erstellung des Bytecodes die generischen Typ-Informationen verloren gehen [OSV10][S. 546]. Bei Scala-Virtualized handelt es sich um eine Erweiterung des Scala-Compilers sowie der Scala-Bibliothek. Die davon für Delite wichtigste Erweiterung ist, dass noch mehr fest vordenierte Sprachkonstrukte vom Entwickler überschreibbar sind. Ein Beispiel dafür ist, dass, unter Verwendung der Erweiterung, auch das If-Konstrukt durch Überschreiben der ifthenelse(c,a,b) Methode neu implementiert werden kann [MRHO12][S. 2f]. Anwendung OptiML DSLs Delite LMS Leightweight Modular Staging Scala-Virtualized Scala Library OptiLA... Scala Compiler Abbildung 5.8: Framework- Stack in Delite Das Delite zugrunde liegende Framework LMS nutzt diese Art der Überschreibung syntaktischer Konstrukte um eine IR der Kontrollstrukturen des Programms aufzubauen. Im Wesentlichen stellt es den Unterbau für einen als Bibliothek denierten Compiler bzw. Transformer zur Verfügung. Die Umwandlung in eine IR wird dabei durch Ausführung des Domänen-Quellcodes erstellt. Da Ergebnisse von Ausdrücken bei der Ausführung direkt bestimmt werden, unterscheidet LMS zwischen Ausdrücken, die zur Staging-Zeit bestimmt werden, und Ausdrücken, deren Resultat erst bei der eigentlichen Ausführung bestimmt werden kann. Dazu werden sog. abstrakte Typ-Konstruktoren eingeführt. Deren Aussagekraft beschränkt sich darauf, dass ein gegebener Ausdruck zur Ausführungszeit ein Ergebnis liefern wird, zur Stage-Zeit aber nur der Code-Transformation und -Generierung dient. Zum Beispiel wird ein Ausdruck der Form (3+4) im generierten Code zu einer konstanten Zahl 7, da kein abstrakter Typ-Konstruktor verwendet wird. Schreibt man dagegen einen Ausdruck der Form val a: Rep[Int] = 3; val b = 3 + 4, so wird die Zahl 3 durch einen impliziten Methodenaufruf in ein abstraktes Typ-Objekt umgewandelt. Der daran hängende Code kann zur Staging-Zeit nicht ausgewertet werden und ieÿt in die Code-Generierung ein [RO10][S. 1]. Die nötige Infrastruktur zur Code-Generierung sowie die IR für Basis-Typen in Scala stellt LMS ebenfalls zur Verfügung. Dabei werden Scala, C++, CUDA und OpenCL als Zielsprachen für die Code-Generierung unterstützt. Gleichzeitig werden durch LMS zur Staging-Zeit bereits diverse Code-Optimierungen durchgeführt. Diese beziehen sich beispielsweise auf sog. Loop-Fusion, also das Zusammenlegen mehrerer Schleifen, oder Inlining, d.h. die Entfernung von Funktionsaufrufen durch direktes Einfügen in den aufrufenden Quellcode. Zusätzlich existiert eine einfach zu nutzende Schnittstelle um eigene Optimierungen einzubauen bzw. neue IR-Nodes einzufügen. Dies bezieht sich wiederum auf das Grundkonzept der Sprach-Virtualisierung, das u.a. voraus-

64 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 57 setzt, dass das Domänen-Wissen zur Generierung von optimalem Code genutzt werden kann [RSL + 11][S. 11]. Auf LMS aufbauend stellt Delite die Infrastruktur für oft verwendete parallele Muster zur Verfügung. So können auf einem denierten DeliteArray z.b. Map-Reduce-Operationen ausgeführt, in die IR übersetzt, optimiert und für die Code-Generierung verwendet werden [RSL + 11][S. 7]. Entwickler müssen dabei beim Schreiben ihrer DSL-Applikationen darauf achten, nur die denierten DSL-API Methoden zu verwenden. Die normalen Scala-Collection-Klassen sind hierfür nicht geeignet. Auÿerdem ist Delite die eigentliche Schnittstelle, die den generierten Code in Dateien schreibt und diese im Anschluss einer Delite-Runtime zur Ausführung übergibt. LMS stellt nur die grundlegenden Funktionalitäten zur Erstellung der IR, für Transformationen und zur Code- Generierung zur Verfügung. Delite setzt diese Funktionalitäten zu einer Gesamt-Bibliothek zur parallelen Ausführung zusammen. Auf Delite aufbauend nden sich verschiedene vordenierte DSLs, wovon die Meisten undokumentiert sind. Die wohl wichtigste prototypisch implementierte DSL ist OptiML, die Strukturen für maschinelles Lernen zur Verfügung stellt. Zusätzlich können mathematische Konstrukte wie Matrizen, Vektoren oder Graphen verwendet werden [Suj][S. 1]. Im Wesentlichen ist OptiML als Alternative zu Matlab positioniert. OptiML wurde ebenfalls zur Evaluation von Delite verwendet. Die letzte Ebene ndet sich in der eigentlichen DSL-Anwendung, die vom Entwickler speziziert wird. Eine Beschreibung inklusive Beispiel ndet sich im folgenden Abschnitt. Kernel-Spezikation Delite erfordert keine Spezikation von parallelen Kernels, sondern erstellt diese basierend auf von der Bibliothek bereitgestellten Domänenobjekten. Eine Beispielanwendung zur parallelen Array-Inkrementierung, die auf OptiML aufbaut, ndet sich in in Quelltext 5.4. Eine DSL ist immer in ein Interface und eine Implementierung aufgeteilt. Der eigentliche Code wird nur gegen das Interface geschrieben. Bei Ausführung der Transformation wird eine Implementierung deniert, die beschreibt, wie die Interface-Methoden auf die IR und die IR auf den zu generierenden Quelltext abgebildet werden. Für OptiML besteht das Interface in der OptiMLApplication-Klasse. Dort werden alle, für die DSL verfügbaren, Methoden angegeben. Durch ein benutzerdeniertes Mix-In können auch selbst implementierte DSL-Methoden eingehängt werden. Mix-Ins erlauben es den Quellcode anderer Klassen in die aktuelle Klasse einzumischen. Es ist eine Art Vererbung, die aber durch Linearisierung auch Mehrfachvererbung zulässt [OSV10][S. 645]. Benutzerdenierte Methoden müssen anschlieÿend implementiert werden. Dabei muss angegeben werden, wie die DSL-Methoden auf z.b. generierten Scala- oder CUDA-Code abgebildet werden sollen. Die Implementierung wird durch Ableitung von einem Runner-Objekt und durch Einmischung des Interface-Traits deniert. Ein Trait ist eine abstrakte Klasse, die in eine andere Klasse, durch Nutzung des with-schlüsselwortes, eingemischt werden kann [OSV10][S. 217]. In diesem Fall wird der vorher bereits denierte Applikations-Trait deniert, der die #main-methode implementiert. Diese Methode kapselt den eigentlichen, auszuführenden, Quelltext. Die Beispielimplementierung verwendet eine API-Methode um einen Nullvektor der Länge 5

65 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 58 object SingleDimensionArraySupportRunner extends OptiMLApplicationRunner with SingleDimensionArraySupport trait SingleDimensionArraySupport extends OptiMLApplication { def main () { val length = 5 val vectorelements = Vector. zeros ( length ) } } vectorelements. pprint () val newvectorelements = vectorelements. map (_ + 1) newvectorelements. pprint () Quelltext 5.4: Array-Inkrementierung mit Delite zu erstellen. Die verwendete Vektor-Klasse stammt aus der OptiLA-DSL, auf der OptiML aufbaut. Die Methode #zeros wird auf eine #densevector_obj_zeros Methode abgebildet, die mit Parametern und Rückgabewerten auf den bereits beschriebenen abstrakten Typ-Konstruktoren basiert. Nachfolgend wendet die Map-Operation auf alle Elemente des Vektors die in runden Klammern angegebene Funktion an und gibt einen neuen Vektor, der alle Ergebnisse enthält, zurück. Die anzuwendende Funktion ist hier in Kurzform angegeben. Der Unterstrich referenziert den Wert des ersten Parameters der Funktion, der um +1 inkrementiert wird. Schlieÿlich wird der neue Vektor auf die Konsole ausgegeben. Staging Der Ablauf der Ausführung eines Delite- bzw. OptiML-Programms gliedert sich in zwei Schritte: das Staging, in dem der denierte Scala-Code transformiert wird, und die Ausführung des Ergebnisses. Der Ablauf ist in Abbildung 5.9 dargestellt. Vor dem Staging wird der Scala-Code mit dem erweiterten Scala-Compiler kompiliert. Damit dabei keine Typ-Informationen verloren gehen, verwendet der Code von Delite und LMS exzessiv Manifest-Objekte. Diese sind nötig, da, wie bereits erwähnt, generische Typen im Java-Bytecode auf Object gecastet werden und damit verloren gehen [LYBB13][S. 66]. Manifest-Objekte werden vom Compiler als entsprechender Ersatz eingefügt. Die eigentliche Umwandlung beginnt mit Ausführung der denierten Runner-Klasse. Dabei wird nicht die vom Entwickler denierte #main-methode ausgeführt, sondern eine Methode in der Klasse DeliteApplication, die für das eigentliche Staging zuständig ist. Basierend auf den eingestellten Kongurationsoptionen wird ermittelt, welche Ziel-Sprachen generiert werden sollen. Entsprechend werden Instanzen der Generatoren erstellt. Wird eine benutzerdenierte DSL verwendet, so muss auch die entsprechende #getcodegenpkg-methode überschrieben werden. Diese muss basierend auf der übergebenen Zielsprache einen, um die neuen DSL-Methoden erweiterten, Generator zurückgeben. Zur Initialisierung gehört ebenfalls das Setzen nötiger Transformatoren für die spätere Optimierung der generierten IR sowie das Kopieren der von den Ziel-Sprachen verwendeten Daten-

66 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 59.scala Kompilierung Scala Compiler.class Stage Delite / OptiML Hello World Ausführung Delite Runtime.scala.deg.cpp.cu Abbildung 5.9: Ablauf ausgehend von Scala-Quellcode-Dateien bis hin zum ausgeführten Programm strukturen in den Build-Ordner. Delite generiert immer alle kongurierten Ziel-Sprachen und erlaubt es zur Laufzeit diese Sprachen zu wechseln bzw. teilweise auch zu kombinieren [RSL + 11][S. 8]. Die kopierten Datenstrukturen beziehen sich z.b. auf in den verschiedenen Ziel-Sprachen implementierte Utility-Klassen wie eine Array-Liste o.ä.. Im nächsten Schritt wird die eigentliche IR generiert. Dies wird durch Ausführung der denierten #main-methode erreicht, die um die zusätzlichen Programm-Argumente geliftet wurde. Da die Argumente erst zur Laufzeit und nicht zur Stage-Zeit bekannt sind, werden diese durch ein abstraktes Symbol (fresh[t], [RO10][S. 5]) ersetzt. Damit ist die externe Abhängigkeit gekapselt und der Funktionsinhalt oen gelegt. Die Funktion wird nun ausgeführt (siehe Expressions- #reifysubgraph, val r = b) und durch Aufruf der implementierten Interface-Methoden, sowie der überschriebenen Methoden der Scala-Kontrollstrukturen, in die IR übersetzt. Eventuell auftretende Sub-Blöcke werden durch rekursive Methoden-Aufrufe ebenfalls eingelesen. Als Beispiel seien hier der then- und der else-block von ifthenelse genannt. Diese müssen extra eingelesen werden, weswegen eine zusätzliche reify-aktion ausgeführt wird. Externe Abhängigkeiten bzw. Seiteneekte werden gleichzeitig mit reect-aktionen eingesammelt. Beide Aktionen beziehen sich auf die monadische Charakterisierung von Seiteneekten und Berechnungen in funktionalen Programmiersprachen bzw. dem λ-kalkül [Fil10][S. 1]. Dazu werden Reify- und Reflect-Objekte in die IR eingehängt. Ersteres gibt dabei die Grenzen zwischen verschiedenen Monaden (dt. einwertiges Element, Einzeller) an. Letzteres bestimmt Abhängigkeiten bzw. Seiteneekte des Monaden in einem Reflect-Element. LMS speichert diese Seiteneekte als Menge an Ausdrücken im eingehängten Reect-Element [RO10][S. 7] (siehe Effects#reflectEffectInternal). Während des Aufbaus der IR können weitere Code-Optimierungen einieÿen. Die empfohlene Vorgehensweise ist das Überschreiben der Implementierung der Interface-Methode, Anwenden der Optimierung und Aufruf der Basis-Methode. Durch Pattern-Matching lassen sich die Optimierungen elegant implementieren [RO10][S. 3f]. Im Anschluss werden kongurierte Transformationen ausgeführt. Delite ersetzt beispielsweise

67 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 60 einzelne Map- und Reduce-Objekte durch eine optimierte Kombination. Diese Transformation wird allerdings nur in der Optigraph-DSL verwendet. LMS stellt zusätzlich die Infrastruktur zur Verschmelzung mehrerer Schleifen (loop fusion) bereit. Erstere werden explizit in einer eigenen Delite-Phase ausgeführt und die IR entsprechend umgehängt. Die Verschmelzung der Schleifen wird dabei kurz vor der eigentlichen Code-Generierung, also nach Ausführung der Delite-Transformationen, beim Durchlaufen der Einzelblöcke zur Generierung, ausgeführt. Zuletzt fehlt noch die Aufspaltung des Quelltextes in einzelne Kernel-Blöcke sowie die Ausgabe des generierten Codes. Für beide Operationen ist die Klasse DeliteGenTaskGraph zuständig, die auch die Ausgabe der einzelnen Generator-Quelltext-Teile steuert. Da zur Laufzeit anschlieÿend verschiedene Code-Sprachen (Scala, OpenCL, CUDA) zur Verfügung stehen, wird gleichzeitig ein Delite Execution Graph (DEG) generiert. Dieser enthält, in Form eines JavaScript Object Notation (JSON)-Objektes, für jeden generierten Kernel-Block Abhängigkeiten von Variablen und anderen Blöcken sowie die für den Block zur Verfügung stehenden Ziel-Sprachen. Dazu passend wird jeweils der Pfad zur generierten Kernel-Datei der Sprache ausgegeben. Der DEG wird während der Ausführung benötigt, um Kernels auf die Hardwareressourcen verteilen zu können. Dabei wird einmal über alle gespeicherten Blöcke der IR gelaufen. Für jeden Block werden anhand der gespeicherten Abhängigkeiten (Reect-Eekte) gebundene bzw. geänderte Variablen ermittelt (siehe DeliteGenTaskGraph#emitAnyNode). Zusätzlich ermöglichen es die bereits ermittelten Seiteneekte eventuell auftretende Abhängigkeiten von anderen Kernels (im Code antideps genannt) zu ermitteln. Basierend auf dem Block-Typ (z.b. While-Schleife oder If-Else) wird ein Eintrag im DEG geschrieben, der die antideps sowie Eingangs- und Ausgangsvariablen enthält. Durch Nutzung der Export-Generatoren wird für alle spezizierten Sprachen der zugehörige Block generiert und dessen Inhalt anschlieÿend zusammen mit dem von den Ein- und Ausgangsvariablen abhängigen Header und Footer des Kernels emittiert. Der eigentlich Block-Inhalt wird Ausdruck für Ausdruck über Pattern-Matching und in Abhängigkeit von der Ziel-Sprache in den Methodenkörper geschrieben. Wenn nötig werden zuletzt nötige Methoden zum Kopieren des Inhaltes auf die Ziel-Architektur bzw. zurück sowie zur Allokation des Speichers ausgegeben. Ausführung Nach dem Staging kann der DEG ausgeführt werden. Delite stellt dazu eine eigene Runtime zur Verfügung. Die Ausführung teilt sich wiederum in drei Phasen auf: Die Erstellung eines Ausführungsplans, dessen Kompilierung und Ausführung. Für die Erstellung des Ausführungsplans ist ein Scheduler zuständig, für die Ausführung ein Executor. Beide Klassen stehen in unterschiedlichen Ausprägungen zur Verfügung. Damit lässt sich kongurieren, ob der generierte Code nur auf der CPU (SMPStaticScheduler, SMPExecutor) oder auf CPU und GPU (SMP_GPU_Scheduler, SMP_GPU_Executor) ausgeführt werden soll. Die nachfolgende Beschreibung bezieht sich auf die kombinierte GPU- / CPU-Ausführung. Die reine CPU-Ausführung entspricht einem Subset der beschriebenen Variante. Nach dem Einlesen des generierten DEG und dessen Übersetzung in eine interne Repräsentation wird ein Ausführungsplan erstellt. Dieser gibt an, auf welcher Hardwareressource, also z.b. auf welcher GPU oder auf welcher CPU, die Aufgabe ausgeführt wird und in welcher Reihenfolge die

68 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 61 Ausführung stattnden muss, um alle Abhängigkeiten zu befriedigen. Ob eine Operation auf der GPU ausgeführt wird, hängt davon ab, ob die Operation eine Ausführung auf der GPU unterstützt. Grundsätzlich wird die Code-Generierung für alle kongurierten Ziele und alle gefundenen Operationen während des Stagings durchgeführt. Wird allerdings eine Operation gefunden, die auf Grund des Variablentyps oder auf Grund der Operation nicht generierbar ist, so wird eine Exception geworfen und die Generierung für dieses Ziel abgebrochen. Wenig überraschend sind Schleifen die einzigen Operationen, die auf der GPU unterstützt werden (siehe GPUCodegen). Für die CPU-Ausführung werden parallele Operationen, wie z.b. Schleifen, aufgespalten und auf verfügbare CPU-Ressourcen verteilt. Gleichzeitig wird zur Reduktion von Kopiervorgängen versucht die Operationen auf den Hardwareressourcen auszuführen, auf denen bereits ihre Abhängigkeiten ausgeführt werden (clustering, SMP_GPU_StaticScheduler#cluster, [BSL + 11][S. 94]). Nach der Erstellung des Ausführungsplans werden die Kernels zur späteren Ausführung kompiliert. Auÿerdem werden Übergänge zwischen den Einzeloperationen eingefügt. Dies schlieÿt zum Beispiel das Kopieren der Daten zwischen Hardware-Ressourcen und Synchronisationspunkte nach Kopiervorgängen ein. Damit ist gewährleistet, dass z.b. eine von einer GPU-Operation abhängige CPU-Operation so lange wartet, bis die Durchführung der GPU-Operation beendet und der Kopiervorgang auf die CPU abgeschlossen ist. Auch die eigentlichen Kernel-Aufrufe werden entsprechend eingefügt. Abschlieÿend wird der generierte Ausführungsplan ausgeführt. Evaluation Im Vergleich zu den anderen Bibliotheken mutet Delite in Verbindung mit der vorhandenen Toolchain als professionellstes Projekt an. Grund dafür ist die sehr durchdachte und gut dokumentierte Code-Architektur sowie die Features zur impliziten Parallelisierung. Trotzdem konnten nicht alle Testfälle implementiert werden, da die Code-Generierung nicht vollständig zuverlässig funktioniert. Kompiliert ein spezizierter Quelltext gegen die vorhandene DSL, so bedeutet dies nicht, dass der generierte Code anschlieÿend auch lauähig ist. Entsprechend ausgegebene Fehlermeldungen sind kryptisch zu lesen und dienen nicht der Fehlersuche. Zwei Beispiele für derartige Fehlermeldungen sind in Abbildung 5.5 dargestellt. Ob nun der Wert x4440 in der Zeile 77 gefunden wurde oder nicht - der Entwickler kann derartige Angaben nicht auf seinen selbst entwickelten Quelltext abbilden und einen eventuell vorhandenen Fehler beheben. Nur durch Ausprobieren ist es hier möglich ausführbaren Quelltext zu produzieren. Derartige Fehlermeldungen stellen insgesamt den gröÿten Nachteil der Bibliothek dar. Auf Grund der zwischengeschalteten Code-Generierung ist auch eine Einzelschritt-Auswertung zur Fehlersuche nicht möglich. Selbst wenn dies im generierten Quelltext möglich wäre, so könnte der Entwickler mit den kryptischen Variablen nichts anfangen. Die Code-Generierung ist für eine einzelne umzuwandelnde Klasse im Vergleich zu Rootbeer schneller. Müssen mehrere Klassen transformiert werden, so muss der Staging-Vorgang jeweils einzeln ausgeführt werden und dauert insgesamt genauso lang. Gleichzeitig wird unter Nutzung der Standard-Konguration der bereits vorher transformierte Code überschrieben. Im Normalfall wird in einer Applikation nur eine Main-Methode, d.h. nur ein Runner, transformiert, wodurch dies nur für die Evaluierung eine Einschränkung darstellt.

69 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 62 error : illegal sharing of mutable objects Sym ( 509) at Sym (510)= Reflect ( NewVar ( Sym (509)), Summary ( false, false, false, false, true, List ( Sym (509)), List ( Sym (509)), List (), List ()), List ( Sym (509))) / x4422x4446x4460x4505. scala : 77: error : not found : value x4440 val x4504 = x4440 * x4440 Quelltext 5.5: Zwei Beispiele für Fehlermeldungen in Delite Der Entwickler muss sich bei der Programmierung weder um die Spezikation von Kernels noch um Speicherverwaltung in irgendeiner Art kümmern. Basic-Blocks werden automatisch erkannt und im Stil von java-gpu automatisch parallelisiert. Im Gegensatz zu dem erwähnten Framework bietet Delite allerdings die Möglichkeit selbst Optimierungen einzubauen und damit im Stil der Sprach-Virtualisierung optimalen Domänen-Code zu produzieren. Die Speicherverwaltung geht in Delite sogar soweit, dass mehrfach ausgeführte Kernels direkt auf der GPU ausgeführt werden und kein Kopiervorgang dazwischen mehr erfolgt. Die Bibliothek prüft für jeden Aufruf von Kernels explizit ob ein Kopiervorgang notwendig ist und kopiert nur in diesem Fall benötigte Daten. Speicherstrukturen wie Shared- oder Constant-Memory können nur über bereits vorimplementierte BLAS-Operationen, die jeweils eine optimierte GPU-Variante aufweisen, oder durch selbst denierte DSL-Methoden inkl. der Spezikation des Kernel-Inhaltes, verwendet werden. Im Wesentlichen sind die Speicherbereiche also vor dem Entwickler versteckt, was die Komplexität des Domänen-Quelltextes deutlich reduziert. Als Exception wurde für Delite eine auf der GPU geworfene ArrayIndexOutOfBounds-Exception provoziert. Der Fehler wird transparent an den Nutzer auf der CPU weitergereicht und als Ursache die erwartete Kernel-Datei angegeben. Fehlersuche stellt allerdings im Allgemeinen ein Problem bei Delite dar. Die Implementierung ist sehr komplex und springt v.a. durch implizite Methodenaufrufe durch die Quellcode-Dateien. Der Staging-Schritt erleichtert dabei die Fehlersuche auch nicht unbedingt, wobei zumindest im Gegensatz zu Rootbeer keine externe Rootbeer.jar zum Staging notwendig ist, sondern der Runner direkt ausgeführt werden kann. Damit kann das Staging selbst in der bereits eingerichteten IDE verfolgt und gedebugged werden. Allerdings ist dazu Voraussetzung, dass die IDE sämtlichen Delite-Quellcode zur Verfügung gestellt bekommt. Während der Evaluierung wurde die für gute Scala-Unterstützung bekannte IDE IntelliJ IDEA verwendet. Trotz dieser guten Unterstützung stürzt diese regelmäÿig ab. Dies liegt wohl u.a. an der Gröÿe der Bibliothek sowie an der angepassten Version des Scala-Compilers und der Scala- Bibliothek. Bereits beim Schreiben des Quellcodes werden Kompilierfehler angezeigt, die in der verwendeten Compiler-Version allerdings gar keinen Fehlern entsprechen. Diese Konstrukte werden nur eben nicht vom Standard Scala-Compiler sowie, insbesondere, von der IDE unterstützt. Als Ausführungsmodi stehen sehr unterschiedliche Möglichkeiten zur Verfügung. Der generierte Code kann als CUDA, OpenCL, pures Scala und C++ ausgeführt werden, wobei OpenCL in der aktuellen Version nicht unterstützt wird und CUDA nur auf dem Entwickler-Branch funktioniert.

70 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 63 Für die CUDA- und Scala-Ausführung kann jeweils speziziert werden wie viele Grakkerne oder Threads für die Ausführung verwendet werden. Eine Kombination von CUDA- und Scala- Ausführung ist nur beschränkt möglich. Sobald mehr als ein Scala Thread in Verbindung mit der CUDA-Ausführung verwendet wird, schlägt die Kompilierung des generierten Quelltextes fehl. Ein erneutes Staging ist für eine geänderte Ausführungsumgebung nicht notwendig. Beim Staging wird der Quellcode für alle angegebenen Ziel-Sprachen generiert. Dieser kann entsprechend auch ohne weitere Transformation bei der Ausführung verwendet werden. Als komplette Ausführungsumgebung kümmert sich Delite auch um die Zeitmessung der generierten Programme. Diese erfolgt automatisch beim Programmstart und endet bei Beendigung des Programms. Einzelmessungen können ebenfalls über eine integrierte API durchgeführt werden. Allerdings wird die gemessene Zeit jeweils auf die Konsole geschrieben. Für normale Entwickler ist dies ausreichend. Für eine Evaluation stellt dies allerdings auf Grund der nötigen Anschlussauswertung eine Einschränkung dar. Deswegen wurde dafür eine eigene auf der internen API basierende DSL entwickelt, die die Ergebnisse als Double zurück gibt. Dieser Wert kann anschlieÿend in eine Datei geschrieben werden, die auch zur Auswertung verwendet werden kann. Entsprechend der eingebetteten Umgebung fallen Tests sehr schwer. Im Gegensatz zu Aparapi können in Tests keine Kernels einzeln gestartet und deren Ergebnis betrachtet und validiert werden. Stattdessen ist die integrierte Ausführungsumgebung zu nutzen. Dabei ist es ebenfalls nicht möglich, normalen, nicht für Delite geschriebenen, Code, in einem Delite-Programm einzubinden. Die Nutzung einer Unit-Testing-Bibliothek ist damit ausgeschlossen. Als Plattformen werden sowohl die geforderte Windows-Umgebung als auch die optionale Linux- Umgebung oziell unterstützt. Funktionsfähig ist allerdings im Augenblick nur die Ausführung auf UNIX-basierten Systemen, da im Fall von Windows eine Option von GCC vorausgesetzt wird, die für den Windows Visual Studio Compiler nicht existiert. Während der Evaluierung wurde versucht diese Option zu entfernen. Als Ergebnis traten diverse weitere Fehler auf. Im Allgemeinen begrenzt die integrierte Ausführungsumgebung auch die Integration in eigene Projekte. Es muss schlicht die gesamte Anwendung parallelisiert und mit Delite ausgeführt werden. Dazu muss eigentlich auch die mitgelieferte Umgebung verwendet werden. Ein Verzeichnis im Delite Ordner enthält den Quellcode von LMS. Ein zweites Verzeichnis enthält die Dateien für Delite, d.h. dessen Ausführungsumgebung (runtime), das Framework selbst, die DSLs sowie vorhandene Apps. Eine eigene Anwendung sollte sich als Applikation eingliedern und dementsprechend auch im dazu passenden Ordner geschrieben werden. Entsprechend wird dann auch der ClassPath zum Aunden der Einzelklassen richtig generiert. Das project-verzeichnis enthält dabei das eigentliche Scala Build Tool (SBT)-Build-Skript, das für den Aufbau des ClassPath und das Laden der Abhängigkeiten, z.b. auf Scala-Virtualized, zuständig ist. Diese Struktur entspricht nicht dem, was der Autor sich als Bibliotheksintegration erwartet. Zum Aufbau einer Delite-Anwendung im typischen Maven- oder Gradle- Stil, sind einige weitere Zwischenschritte nötig. Ein src-verzeichnis soll den Quellcode der Applikation enthalten. Notwendige Bibliotheken werden entweder vom Build-Skript nachgezogen oder in einem lib- Verzeichnis abgelegt. Dazu müssen die Bibliotheken allerdings als jar vorliegen. Diese lassen sich mit dem SBT-Build-Skript von Delite generieren. Entsprechend kann man für LMS vorgehen. Durch Analyse des Delite-Build-Skriptes kann der ClassPath analog zum SBT-Skript in Gradle mittels der Jar-Abhängigkeiten rekonstruiert werden. Die Transformation erfolgt dann nicht

71 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 64 mehr mit dem mitgelieferten delitec-skript, sondern in der IDE durch Ausführung des Runners bzw. durch Ausführung des Build-Skripts. Vorsicht ist während der Kompilierung der Delite-Artefakte immer in Bezug auf zur Verfügung stehenden Speicher geboten. Unter Umständen muss dieser für SBT zur erfolgreichen Kompilierung wesentlich erhöht werden. Die Schritte der Artefakt-Generierung müssen für jede Aktualisierung der Delite-Bibliothek jeweils wiederholt werden. Eine richtige Bibliotheksintegration ist nach Aussagen der Entwickler ebenfalls für eine der nächsten Versionen geplant. Insgesamt stellt Delite eine sehr professionelle und ständig weiter entwickelte Bibliothek mit aktiver Community dar, deren Mitglieder auch gerne bei Problemen helfen. Die Community selbst scheint momentan noch verhältnismäÿig klein zu sein, was auf die Verbreitung der Bibliothek selbst schlieÿen lässt. Der Einsatz der Bibliothek ist auf Grund mangelnder Tool-Unterstützung und teilweise auf Grund der verschiedenen impliziten Methodenaufrufe für Scala typischen verwirrenden Compiler- und Staging-Fehlermeldungen nicht einfach. Bis zu einem möglichen produktiven Einsatz dürfte entsprechend noch eine längere Zeit vergehen Projekt Sumatra Als Abschluss der Beschreibung der GPU-Abstraktionsbibliotheken für Java soll noch eine letzte Bibliothek angesprochen werden. Diese bezieht sich auf die Bemühungen der Sprache selbst, eine GPU-Abstraktion aufzunehmen und, z.b. durch die Verwendung von Java 8 Lambda-Funktionen, den Entwicklern zur Verfügung zu stellen. Das zugehörige Projekt hört auf den Namen Sumatra [Sum13a]. Die nachfolgende Beschreibung bezieht sich hauptsächlich auf vereinzelte Präsentationen und v.a. Mailing-Listen der Projekte. Bisher existieren hauptsächlich Ideen und vereinzelte prototypische Teilimplementierungen. Wissenschaftliche Veröentlichungen sind (noch) nicht zu nden. Die ozielle Integration wurde Ende September für Java 9 angekündigt. Das Projekt teilt sich momentan in zwei Bestrebungen auf, wovon Erstere bereits gröÿtenteils abgeschlossen ist: Es wird ein Prototyp basierend auf dem bereits beschriebenen Framework Aparapi implementiert, der unter Nutzung von Lambda-Funktionen eine parallele Variante des Methodeninhalts generiert und diesen auf einer OpenCL kompatiblen GPU ausführt. Ein von den Entwicklern vorgestelltes Beispiel ist in Abbildung 5.10 zu nden. Die nötige Parallelisierung ndet dabei in einer angepassten Variante der ForEachOps statt. Hier wird auch der Aufruf auf eine angepasste Aparapi-Variante gestartet und der OpenCL-Quellcode damit ausgeführt [Sum13b][2013-April]. Der zweite Ansatz, der teilweise parallel bearbeitet und auch als Fortsetzung zum Aparapi- Streams. intrange (0, in. length ). parallel (). foreach ( id -> { c[ id ] = a[ id ] + b[ id ]; }); Abbildung 5.10: Addition zweier Arrays mit unter Nutzung des Sumatra-Aparapi Prototyps

72 5.3. Evaluation verfügbarer Abstraktionsbibliotheken 65 Prototyp angesehen wird, ist die Integration von Graal zur Kompilierung und Erstellung des GPU-Codes durch Erstellung eines PTX- bzw. Heterogeneous System Architecture Intermediate Language (HSAIL)-Backends. Sowohl PTX (siehe 3.2.3) als auch HSAIL sind Zwischensprachen für parallele Architekturen. Der Ansatz von HSAIL ist allerdings noch wesentlich tiefgreifender als der von PTX, da diese die Parallelisierung auf unterschiedlichen Gerätetypen in der Zwischensprache bereits abbildet [Kyr12][S. 3]. Zum Beispiel unterstützt HSAIL Instruktionen zum Laden von Kernel-Argumenten sowie zum Setzen von Ids für abzuarbeitende Kernel-Instanzen [hsa13]. PTX-Zwischencode bezieht sich nur auf den CUDA-Code, der auch auf Grakkarten ausgeführt wird. Zur Ausführung von Java-Code mit dem entsprechenden Zwischencode wird zuerst der Anfangscode durch einen High Level Compiler (HLC) in das HSAIL-Zwischenformat kompiliert. Dieses wird auf der Ziel-Maschine von einem Finalizer in die richtige Instruction Set Architecture (ISA) konvertiert und ausgeführt [Kyr12][S. 5]. Graal ist eine Erweiterung des momentan in der Java Virtual Machine (JVM) eingesetzten HotSpot-Compilers. Das Ziel des Projektes ist es den Prozess der Kompilierung und deren Kon- gurationsoptionen transparent für den Anwendungsentwickler zu machen. Beispielsweise ist es möglich durch Callbacks während der Kompilierung Nodes in der internen Graph-Repräsentation des Quellcodes (Sea-of-Nodes-Darstellung) Nodes auszutauschen und damit zu optimieren [Gra13a] [Gra13b]. Dies erinnert stark an das Konzept von Delite zur Optimierung der internen Darstellung durch Callbacks auf den Domänen-Code. Gleichzeitig bindet Graal den eigentlichen Compiler über ein Compiler Runtime Interface (CRI) an. Dadurch werden jetzt schon sowohl der HotSpot-Compiler als auch der neue, komplett in Java geschriebene, Maxine-Compiler unterstützt. Durch Implementierung eines eigenen Backends für die erwähnten IR-Darstellungen kann während dem Durchlaufen der ForEachOps entsprechender GPU-Code erzeugt werden. Durch die reine Verwendung des HotSpot-Compilers ist dies nicht möglich, da dieser auf die Erzeugung von Bytecode festgelegt ist und gleichzeitig nicht aus dem Quellcode heraus beeinussbar ist. Das gesamte Projekt bendet sich aktuell in einem sehr frühen Stadium. Der Aparapi Prototyp liegt dabei inkl. der nötigen Generierung von HSAIL in lauähiger Form im Aparapi Repository vor. Genau dieser bildet auch die Grundlage für die momentane Weiterentwicklung von Aparapi. Das Compiler-Backend für Graal ist im Graal-Repository zu nden. Allerdings fehlt noch die Anbindung an die JVM selbst. Insgesamt müssen alle Prototypen nacheinander integriert werden, bis ein Gesamt-Prototyp zur Verfügung steht. Das Projekt besitzt in jedem Fall groÿes Potential und ist damit in Zusammenhang mit Graal auch in Zukunft relevant. Die Entwickler setzen mit ihren Ansätzen auf die bestehenden, teilweise auch bereits hier beschriebenen, Bibliotheken und versuchen deren Limitierungen, z.b. eventuell vorhandene überüssige Kopieroperationen, zu eliminieren und zu verbessern [Sum13c]. Im momentanen Zustand repräsentiert der Ansatz jedoch noch keine verwendbare Alternative zu den bestehenden Bibliotheken.

73 5.4. Evaluation der Ausführungsgeschwindigkeiten Evaluation der Ausführungsgeschwindigkeiten Abschlieÿend zur Evaluation der Abstraktionsbibliotheken folgt ein Vergleich der Ausführungsgeschwindigkeiten anhand den in Abschnitt 5.2 vorgestellten Algorithmen. Dazu wird zuerst näher auf Randbedingungen der Geschwindigkeitsevaluation eingegangen. Anschlieÿend folgt eine kurze Beschreibung des Aufbaus des Projektes sowie abschlieÿend die Vorstellung der erzielten Ergebnisse der Messungen Randbedingungen der Messung Evaluationsrechner Die Evaluation ndet auf einem Ubuntu-Linux-Rechner statt. Dieser enthält neben einem AMD Opteron Prozessor mit 16 Kernen 16 GB Arbeitsspeicher und eine GeForce GTX660Ti Grakkarte mit 2 GB Speicher und 1344 Streaming-Prozessoren, die auf 7 Multiprozessoren verteilt sind. Damit keine Beeinussung der Laufzeiten auftritt wurde der Rechner explizit für die Berechnung reserviert. Zum Starten bzw. Überprüfen des Fortschritts wurde zur geringen Beeinussung ein SSH-Terminal verwendet. Der Grund für die Verwendung eines Linux Betriebssystems liegt in der teilweise fehlenden Windows-Unterstützung der Bibliotheken. Da die Geschwindigkeiten der Bibliotheken, die Windows nicht unterstützen, trotzdem einieÿen sollen, bleibt nur die Verwendung eines Unix- Betriebssystems. Der im weiteren Verlauf der Arbeit erstellte Vergleich der Ausführungszeiten zwischen dem nativen Simulator und einer ausgewählten Bibliothek erfolgt abschlieÿend unter Windows. Zeitmessung Die Zeitmessung erfolgt jeweils ohne Staging-Zeiten, d.h. ohne Messung der Umwandlung von Java in GPU-Code. Dies ist v.a. der Fairness geschuldet, da jede Bibliothek ihren auszuführende Quelltext anders generiert und kompiliert. Aparapi generiert sämtlichen Code zur Laufzeit, Rootbeer und Delite bereits zur Staging-Zeit. Im Fall von Delite wird auch das Runtime-Scheduling beim Hochfahren des Delite-Kontextes nicht mit einbezogen. Für Rootbeer wird die Vorbereitung der Ausführungsumgebung jeweils mit eingerechnet, da diese bei jedem Kernel-Start erfolgt und nicht gekapselt werden kann. Im GPU-Fall ist die volle Ausführungszeit mit einbezogen, d.h. inklusive aller Kopiervorgänge von und zur GPU. Diese sind für einen fairen Vergleich zwischen Java- und GPU-Ausführung zwingend mit einzubeziehen, da diese als Overhead bei jeder Auslagerung rechenintensiver Operationen an die GPU anfallen. Zur Berechnung der Geschwindigkeitssteigerung im Vergleich zu Referenzbibliotheken, wie die serielle oder die native Ausführung, wurde möglichst ein über die Ausführungsparameter berechnetes arithmetisches Mittel verwendet. Eine Überschreitung der maximalen Ausführungszeit ieÿt entsprechend mit dem Timeout von 180 Sekunden in die Berechnung ein. Fällt eine Bibliothek auf die serielle Ausführung zurück, so wird, soweit die Berechnung noch innerhalb des Zeit-Limits abgeschlossen werden kann, die resultierende Gesamtzeit zur Berechnung des arithmetischen Mittels verwendet.

74 5.4. Evaluation der Ausführungsgeschwindigkeiten 67 Häugkeit der Ausführung sowie Timeout Die auszuführende Aufgabe wird jeweils einmal ohne Zeitmessung ausgeführt. Dies führt dazu, dass in jedem Fall, z.b. auch für Aparapi, der Code kompiliert ist. Auch der Hotspot-Compiler der JVM hat damit die Chance zur Kompilierung bzw. Optimierung häug durchlaufener Code-Abschnitte. Nach dem Probedurchlauf wird der Test jeweils 50 Mal je Bibliothek durchgeführt und die Ausführungszeiten aufgenommen. In den resultierenden Graphen ist jeweils das arithmetische Mittel dieser Ausführungszeiten eingezeichnet. Liefert die Bibliothek auf die auszuführende Operation nach 120 Sekunden pro Durchlauf keine Antwort, so wird die Ausführung abgebrochen und der Timeout, also die 180 Sekunden, als Ausführungszeit angenommen. Dies ist nicht vollständig korrekt, da in den 180 Sekunden jeweils auch der Aufbau der Evaluationsumgebung inkl. der Initialisierung der Testdaten stattnden muss und eben nicht nur die Ausführung des Testfalls inkl. Zeitmessung. Die Evaluationsfälle sind jedoch so gewählt, dass der Timeout für eine normale Berechnung ausreicht. Tritt eine Überschreitung der Ausführungszeit auf, so dauert die Berechnung im Normalfall wesentlich länger Aufbau des Evaluations-Projektes Architektur und Start der Evaluierungsschritte Das Projekt ist in mehrere, in Abbildung 5.11 dargestellte, Gradle-Module aufgespalten. Die Evaluation jeder Bibliothek ist dabei in einem Modul enthalten, in dem alle Abhängigkeiten gekapselt sind. Zusätzlich stellt ein Modul (Eval) die Infrastruktur der Auswertung sowie den Quelltext zur Auswertung der Resultate zur Verfügung. Dies beinhaltet die Logik zum Starten der Testfälle, deren Parametergröÿen sowie das Schreiben der Ergebnisse in CSV-Dateien. Schlussendlich enthält das Modul auch Funktionen um die CSV-Datei wieder einzulesen und die resultierenden Graken bzw. weitere Statistiken zu generieren. Schlieÿlich kapselt ein Modul alle Code-Bestandteile, die von allen anderen Modulen benötigt werden (Util). Darin enthalten ist auf der einen Seite ein Interface, das für jeden Testfall eine Methode enthält. Als Rückgabe wird ein Evaluationsobjekt erwartet, das für verschiedene Parametergröÿen ausgeführt werden kann und entsprechende Test-Resultate zurück gibt. Jede evaluierte Bibliothek muss dieses Interface als zentrale Komponente implementieren. Damit kann die Evaluation vollständig automatisch durchlaufen werden, da eine Evaluationsklasse die entsprechenden Objekte aufsammeln und für die verschiedenen Testfälle ausführen kann. Abgesehen davon enthält das Modul nötige Methoden zur Initialisierung der Testdaten bzw. zur Verikation resultierender Ergebnisse. Bis auf Delite, das auf Grund der Struktur keine externen Java-Methoden aufrufen kann, nutzen alle Bibliotheken diese Initialisierungsmethoden. Die native Evaluation ist dagegen vollkommen unabhängig vom globalen Evaluationsprojekt. Ein zusätzlicher JNI-Layer zur automatischen Ausführung wäre hier nur zusätzlicher Overhead ohne weiteren Nutzen gewesen. Jeder Evaluationsschritt wird auf eine externe JVM abgebildet. Dies hat mehrere Vorteile: ˆ Jeder Schritt ndet die gleiche Umgebung mit den gleichen Speicherbedingungen vor. ˆ Tritt ein Speicher-Leak in einer Bibliothek auf, so wird meist der GPU-Speicher nach Beendigung der JVM wieder freigegeben. Damit tritt der Fall nicht ein, dass wegen nicht

75 5.4. Evaluation der Ausführungsgeschwindigkeiten 68 Eval Cuda-Nativ-Eval Rootbeer-Eval Rootbeer-GPU-Kernels Aparapi-Eval JCuda-Eval Delite-Eval Rootbeer-GPU.jar Inhalt von Rootbeer-GPU-Kernels als jar gepackt und von Rootbeer transformiert und parallelisiert Util Abbildung 5.11: Module der Evaluation freigegebenem Speicher alle anderen Evaluationen keinen Speicher mehr vornden. ˆ Nach einer bestimmten Zeitspanne kann der Prozess abgebrochen werden. Die Kommunikation der virtuellen Maschinen ndet dabei über das Dateisystem statt. Die gestarteten JVMs speichern ihre Ergebnisse in Dateien, die generisch von der Haupt-JVM wieder gelesen und verarbeitet werden können. Einbindung der Bibliotheken JCuda und Aparapi können durch Einbindung der jar-archive sowie durch Platzierung nötiger dynamischer Bibliotheken (.dll,.so) verwendet werden. Für Rootbeer und Delite ist dagegen noch ein zusätzlicher Staging-Schritt notwendig, der sich auch im Build-Skript wiedernden muss. Für beide Bibliotheken wurde ein eigener Gradle-Task erstellt, der für die Umwandlung bzw. Generierung des notwendigen Quelltextes zuständig ist. Im Fall von Rootbeer werden die Kernels in einem zusätzlichen Modul speziziert (siehe Rootbeer- GPU-Kernels in Abbildung 5.11). Der Inhalt dieses Moduls wird unter Verwendung der Rootbeer- Bibliothek transformiert und als Abhängigkeit zu Rootbeer-Eval eingehängt. Theoretisch könnten die Kernels auch direkt in Rootbeer-Eval speziziert werden. Auf Grund der Entfernung von nicht verwendeten Methoden seitens Rootbeer sollte jedoch die Menge an transformiertem Code so gering wie möglich gehalten werden. Gleichzeitig erhöht dies die Entwicklungsgeschwindigkeit, da damit weniger Quelltext eingelesen und transformiert werden muss. Bei der Einbindung ist strikt darauf zu achten, dass nur der Classpath der transformierten jar- Datei zur Ausführung verwendet wird. In Abbildung 5.11 referenziert deswegen Rootbeer-Eval auch direkt das transformierte jar-archiv. Wird trotzdem das ursprüngliche Rootbeer-GPU- Kernels Modul im Classpath referenziert, so ist das Ergebnis abhängig von der Reihenfolge der Einbindung der Class-Dateien. Wird z.b. zuerst die nicht-transformierte Variante geladen, so erfolgt die Ausführung seriell, da nur der erste Vorgang des Ladens einer Klasse berücksichtigt wird und alle weiteren Anfragen zum Laden einer Klasse mit dem selben voll-qualizierten Namen verworfen werden [GJGB13][S. 316]. Zu erkennen ist dieses Verhalten daran, dass die resultierenden Ausführungszeiten der GPU und der JEMU-Ausführung identisch sind.

76 5.4. Evaluation der Ausführungsgeschwindigkeiten 69 Wie bereits in der Evaluation von Delite beschrieben, wurde die Bibliothek nicht nach dem Standardverfahren von Delite durch Verwendung der delite- und delitec-skripte sowie durch Ablage der Quelltexte innerhalb der Bibliothek eingebunden, sondern über den Standard Mavenbzw. Gradle-Weg. Da entsprechend die mitgelieferten Skripte zur Transformation nicht mehr funktionsfähig sind, wird durch Starten der Delite-Umwandlungsklasse die Transformation direkt gestartet. Dazu existiert für jeden Evaluationsalgorithmus ein eigener Gradle-Task, der die Klasse aufruft und die Ergebnisse in ein eigenes Ziel-Verzeichnis umleitet. Dessen Name basiert auf dem Namen der umzuwandelnden Klasse. Ohne das Setzen eines individuellen Zielpfades würde jeder Start der Transformation das vorhergehende Ergebnis überschreiben. Zum eigentlichen Start eines Delite-Tasks wird die interne #main-methode der Delite-Runtime verwendet. Verschiedene Parameter, wie z.b. der Name und Ort des DEG, die Verwendung von CUDA oder die Anzahl an zu verwendenden Threads, werden vorher in der Delite-Cong bzw. u.u. in Umgebungsvariablen gesetzt und von Delite anschlieÿend zur Ausführung einbezogen. Die Initialisierung der Umgebungsvariablen muss dabei vor dem ersten Zugri auf die Delite-Cong erfolgen, da die entsprechende Kongurationsklasse bei Initialisierung die Werte aus den Umgebungsvariablen liest und die Optionen setzt. Die Initialisierung erfolgt dabei vom Classloader bei der ersten Referenzierung der Klasse. Da eine #main-methode keine Ergebnisse zurück liefern kann, erfolgt die nötige Rückgabe über das Dateisystem. Das Schreiben von Dateien mit benutzerdeniertem Inhalt wird von Delite nicht unterstützt. Hierzu wurde eine eigene DSL-Methode implementiert. Messung der Ausführungszeiten Um die Zeitmessung zu vereinfachen und gleichartig zu gestalten wird jeweils vor Start einer Kernel-Ausführung mit System#nanoTime die Zeit bestimmt und die Dierenz zum Ende der Ausführung gespeichert. Auf die Verwendung von internen APIs zur Zeitmessung wird dabei verzichtet, da nicht jede Bibliothek die gleichen Funktionalitäten zur Zeitmessung aufweist. Vorsicht ist bei der Zeitmessung mit Delite geboten. Die Bibliothek wendet eine sogenannte common subexpression elimination an [Suj][S. 23]. Diese bildet mehrfache ausgeführte Anweisungen, die keine weiteren Seiteneekte haben, auf die erste Ausführung ab. Wird also z.b. ein Evaluations-Task 50 Mal ausgeführt, so liefert nur die erste Ausführung die richtige Laufzeit. Alle anderen Durchläufe liefern ein Resultat nahe 0, da keine weitere Ausführung erfolgt. Um dies auszuschlieÿen ruft jeder Evaluationsdurchlauf die Delite-Runtime mit dem DEG erneut auf. Delite besitzt damit keine interne Schleife und muss die Funktion richtig ausführen. Abgesehen davon legt die Bibliothek starken Wert auf den Abhängigkeitsgraphen. Nicht voneinander abhängige Blöcke können umgeordnet oder z.b. parallel auf unterschiedlichen Prozessoren ausgeführt werden. Dieses Verhalten stellt ein Problem dar, da zwischen Start, Durchführung und Stop der Evaluation keine Abhängigkeit besteht. Dies führt dazu, dass Delite die Startzeit berechnet, direkt anschlieÿend die Evaluation stoppt und erst dann die Berechnung startet. Entsprechend ergeben sich bei der Messung Zeiten nahe 0. Um dies zu verhindern ndet sich die eigentliche Evaluation in einer eigenen Funktion, die die Start-Zeit übergeben bekommt. Als Rückgabewert der Funktion wird die Startzeit, die, in Abhängigkeit des in der Evaluation berechneten Ergebnisses, verändert wurde, verwendet. Die Veränderung kann dabei z.b. bei einem Array die Addition und, direkt nachfolgend, die Subtraktion des ersten Array-Elements sein. Die entstehende Abhängigkeit verhindert damit die Vertauschung

77 5.4. Evaluation der Ausführungsgeschwindigkeiten 70 der Berechnung der Startzeit und der eigentlichen Evaluation. Um noch eine Abhängigkeit zwischen Berechnung der Stoppzeit und der Evaluation zu schaen, wurde die DSL-Methode zur Bestimmung der aktuellen Zeit so deniert, dass diese einen zusätzlichen Dummy-Parameter entgegen nimmt. Delite sieht diesen Parameter als Abhängigkeit an. Hier kann das Ergebnis der Evaluationsfunktion verwendet werden, um die benötigte Abhängigkeit zwischen Berechnung und Zeitmessung zu schaen. Die native Evaluation verwendet zur Messung der Geschwindigkeit die clock_gettime-funktion [clo13]. Damit ist es u.a. möglich die aktuelle Zeit in Nanosekunden zu bestimmen. Ein Hindernis stellt sich bei der Initialisierung der Testdaten. Der verwendete Compiler verwendet zur Allokation des Speichers eine optimistische Strategie [mal13]. Der zurückgegebene Speicher wird beim ersten Aufruf initialisiert und nicht sofort beim Aufruf der #malloc-funktion. Die Allokation ist jedoch verhältnismäÿig zeitaufwändig und verfälscht, soweit sie während der Zeitmessung erfolgt, die Ergebnisse deutlich. Als Abhilfe kann entweder der Speicher alloziert und direkt anschlieÿend, z.b. per Schleife, initialisiert oder, alternativ, via #cudamallochost alloziert werden. Die zweite Lösung verwendet einen besonderen Speichertyp, sog. page locked memory. Dieser garantiert, dass die Seiten aus dem virtuellen Speicher nicht auf die Festplatte ausgelagert werden. Gleichzeitig wird der Speicher sofort alloziert. Für CUDA hat dies den zusätzlichen Vorteil, dass der Speicher über die direkte Referenzierung des physikalischen Speichers ohne Umwege transferiert werden kann, was einen zusätzlichen Durchsatz beim Kopiervorgang von rund 20% bedeutet. Die zweite Speicherart wird in der nativen Simulation folgerichtig eingesetzt. Dies trägt u.a. dem möglichen Optimierungspotential innerhalb von CUDA Rechnung und setzt dabei die gleiche Optimierung ein, die auch von Aparapi angewendet wird. Andernfalls wäre Aparapi in Bezug auf Kopieroperationen deutlich im Vorteil Ergebnisse der Zeitmessung Die bereits beschriebenen Algorithmen werden 50 Mal ausgeführt und deren Mittelwert als Ergebnis in den Graphen aufgenommen. Die Zahl 50 erweist sich als verhältnismäÿig zuverlässig. Es ergibt sich dabei eine Abweichung von im Schnitt 3% um den Mittelwert. Die Scala-Ausführung von Delite schwankt dabei am stärksten. In Ausnahmefällen steigt die Abweichung auf bis zu 30%. Array-Inkrementierung Die sehr einfache Operation weist ein sehr kleines Verhältnis zwischen Berechnung und Speicherzugrien auf. Im Wesentlichen erfolgt nur eine Addition in Verbindung mit zwei sehr teuren Speicherzugrien auf den globalen Speicher. Zusätzlich ist die Zeit der Kernel-Ausführung im Verhältnis der zum Kopieren notwendigen Zeit sehr niedrig. Der entstehende Overhead lässt daher die Erwartungshaltung entstehen, dass die serielle Ausführung schneller als die GPU-Ausführung ist. Selbst im Vergleich der seriellen Abarbeitung im Gegensatz zu Java Thread-Pools sollte die serielle Ausführung schneller sein, da trotz der sehr parallel ausführbaren Aufgabe der Overhead zur Erstellung und Verwaltung der Threads das mögliche Einsparpotential durch eine Verminderung der ohnehin sehr geringen Rechenzeit übersteigt.

78 5.4. Evaluation der Ausführungsgeschwindigkeiten 71 Das Ergebnis der Zeitmessung ist in Abbildung 5.12 dargestellt. Gemessen wurden die Inkrementierung von Arrays mit einer Anzahl von 1 bis Elementen. Wie zu erwarten ergibt sich für alle Bibliotheken, dass deren Ausführung wesentlich langsamer als die serielle Ausführung ist. Die serielle Kurve ndet sich erst im rechten Diagramm wieder, in der neben der seriellen Zeit nur GPU-Ausführungszeiten eingezeichnet sind (ohne Rootbeer und Delite). Würde man den Graph vergröÿern und als Parametergröÿen bis knapp betrachten, so ergäbe sich ein interpolierter Schnittpunkt zwischen der seriellen Ausführung und den GPU-Bibliotheken, insbesondere der nativen CUDA-Ausführung sowie JCuda, bei zwischen und Bis dahin überwiegt der Geschwindigkeitsvorteil der seriellen Ausführung. Auällig ist v.a. der hohe Unterschied zwischen den Einzelbibliotheken. In der Übersicht im unteren Graphen sind alle Bibliotheken eingezeichnet. Darin scheinen JCuda und die native Evaluation eine Ausführungszeit von nahe 0 zu besitzen. Der krasse Gegensatz dazu ist die CUDA-Ausführung von Delite, die bereits bei einer Startzeit von mehr als ms beginnt. Dabei ist die Zeit der reinen CUDA-Ausführung in Delite minimal. Diese rangiert im Bereich zweistelliger µs. Auf die Steuerung der Ausführung, bzw. die Ausführung auf der CPU, entfällt dagegen ein dreistelliger Millisekunden-Betrag. Dies ist ein deutlicher Hinweis, warum die Ausführung so langsam ist. Vergleicht man die CUDA-Zeiten mit denen anderer Bibliotheken, so ergibt sich, dass diese eigentlich im zweistelligen Mikrosekundenbereich liegen sollten. Delite nutzt also nicht die Kapazität von CUDA aus, sondern berechnen den Hauptteil der Testfälle auf der CPU. Da gleichzeitig die Verwendung mehrerer Java-Threads in Verbindung mit CUDA nicht funktionsfähig ist, wird der Groÿteil der Berechnung nur seriell ausgeführt. Dieses Verhalten lässt sich auch in den weiteren Testfällen wiedernden. Der Graph der GPU-Ausführung endet für Aparapi bei einer Parametergröÿe von rund Ab diesem Wert fällt die Ausführung von der GPU- auf die JTP-Ausführung zurück. Grund dafür ist, dass die Anzahl an Threads im Verhältnis zu der Anzahl an Gruppen bzw. der Gruppengröÿe in Aparapi falsch gesetzt wird. Die Gesamtanzahl an Threads muss ein Vielfaches der Gruppengröÿen sein. Dies wurde nicht vollständig berücksichtigt, weswegen die Ausführung hier mit einem OpenCL-Fehler abbricht. Im weiteren Verlauf der Arbeit wurde der Fehler zwar behoben. Da in den anderen Bibliotheken jedoch ebenfalls keine Fehler beseitigt wurden, wurde die reparierte Version nicht zur Evaluation verwendet. JCuda und die native Ausführung scheinen quasi gleichauf. Vergleicht man deren Ausführungszeiten in einem Proler, der angibt, an welchen Stellen wie viel Zeit verbraucht wird, so ergibt sich, dass die Kernel-Ausführungen der beiden Bibliotheken exakt die gleiche Zeitspanne benötigen. Der Unterschied zwischen den beiden Kurven ergibt sich durch den JNI-Overhead sowie durch den, bei der nativen Evaluation verwendeten, schnelleren Speicher. Insgesamt benötigt die native Ausführung trotz der gleichen Kernel-Ausführungszeiten nur rund 60% der JCuda- Ausführungszeit. Rootbeer als Bibliothek weist dagegen eine sehr lange Ausführungszeit auf. Selbst die GPU-Ausführung schat es nicht in die rechte Kurve der GPU-Ausführungszeiten und ist damit nicht mit den anderen Bibliotheken zu vergleichen. Als Ursache für diese hohen Ausführungszeiten lassen sich zwei Gründe nden: Auf der einen Seite deniert Rootbeer eine maximale Anzahl an Threads, die gleichzeitig auf der Grakkarte ausgeführt werden können. Dieser Wert ist fest auf einprogrammiert und passt sich nicht an die Grakkarte an. Werden für Berechnungen Thread-

79 5.4. Evaluation der Ausführungsgeschwindigkeiten 72 Gröÿen benötigt, die diese Grenze übersteigen, teilt Rootbeer die Berechnung auf mehrere Kernel- Ausführungen auf. Je mehr Kernel-Invokes benötigt werden, desto langsamer wird entsprechend die Ausführungszeit. Rootbeer setzt den Wert der maximal parallel ausgeführten Blöcke auf 64 fest. Moderne Grakkarten führen Block-Zahlen in Millionenhöhe parallel aus. Als zweiten Grund ndet sich die Codequalität. Im Proler fällt eine als BlockingQueue bezeichnete Klasse auf, die allerdings auf neu abzuarbeitende Aufgaben in einer while-schleife aktiv wartet. Allein diese Schleife zeichnet für 90% der Ausführungszeit verantwortlich. Die Java-Ausführung von Rootbeer ist sogar noch langsamer. Dies äuÿert sich darin, dass ab einer Array-Gröÿe von Sekunden zur Berechnung nicht mehr ausreichen und diese deswegen vorzeitig abgebrochen wird. Die serielle Berechnung benötigt zur Inkrementierung eines entsprechend groÿen Arrays rund 0,3 ms. Matrix-Multiplikation Die Matrix-Multiplikation weist eine wesentlich höhere Menge an nötigen Berechnungen auf. Jeder Thread muss den Inhalt jeder Zelle in der zu berechnenden Zeile bzw. Spalte laden. Jeweils zwei Zellen werden durch eine Multiplikation verknüpft und deren Inhalt addiert. Zusätzlich müssen für die linearisierten Eingabematrizen die Indexe der Zellen berechnet werden, wodurch jeweils eine Multiplikation und eine Addition zusätzlich anfallen. Geht man von einer Matrix-Gröÿe und -Breite von M aus und bezieht dabei nur Multiplikation ein, so ergibt sich ein Verhältnis zwischen Speicherzugri und Berechnung zu M 3 2 M = 1, 5. Je höher dieses Verhältnis, desto besser ist ein Algorithmus für die GPU-Ausführung geeignet. Die Array-Inkrementierung kommt hier nur auf einen Wert von 0, 5. Das Ergebnis ist in Abbildung 5.13 dargestellt. Wie erwartet liegen bis auf Rootbeer und Delite alle Zeiten unter der sequentiellen Ausführungszeit. Aparapi ist dabei 80 Mal schneller, JCuda sogar 119 Mal. Die Kurve der nativen Evaluation hebt sich kaum von JCuda ab. Auch hier sind die Zeiten der Kernels identisch, wodurch nur ein zusätzlicher Overhead v.a. durch den JNI-Layer auftritt. Bei der hohen Anzahl an Berechnungen fällt dieser allerdings nicht mehr ins Gewicht. Für Delite wurde zur Ausführung nicht die in der Bibliothek verfügbare, optimierte Matrix- Multiplikation verwendet, sondern die unoptimierte Variante, die auch bei den anderen Bibliotheken zum Einsatz kommt. Die Scala-Ausführung schat es dabei noch sich an die Java- Ausführungszeit von Aparapi anzunähern und liegt sogar noch unter der seriellen Zeit. Die GPU- Ausführung ist hingegen deutlich langsamer als die serielle Variante. Verwendet man statt dem unoptimierten Algorithmus die in der Bibliothek zur Verfügung stehende, optimierte, Matrix- Multiplikation, so sinkt die Ausführungszeit wesentlich auf rund 1/8 der seriellen Zeit. Auch die anderen Bibliotheken stellen über Shared-Memory Mittel zur Optimierung zur Verfügung. Dadurch benötigt Aparapi nur noch 1/604 und JCuda nur noch 1/648 der seriellen Zeit. Die native, optimierte, Ausführung weist mit 1/668 die höchste Beschleunigung auf. Unter diesem Aspekt erscheint die BLAS optimierte Variante von Delite sehr langsam. Parallele Summation von Array-Elementen Das Ergebnis der Evaluation ndet sich in Abbildung Als Erstes sticht ins Auge, dass keine der Abstraktionsbibliotheken die serielle Ausführungszeit bei der Summation der Elemente unterschreiten kann. Dies gilt auch bei ver-

80 5.4. Evaluation der Ausführungsgeschwindigkeiten 73 Abbildung 5.12: Ausführungszeiten zur Array-Inkrementierung bis (links: GPU- und CPU-Ausführungszeiten, rechts: GPU-Ausführungszeiten bis , unten: Ausführungszeiten bis

81 5.4. Evaluation der Ausführungsgeschwindigkeiten 74 Abbildung 5.13: Ausführungszeiten zur Matrix-Multiplikation (links: GPU und CPU- Ausführungszeiten, rechts: GPU-Ausführungszeiten) hältnismäÿig groÿen Arrays, die summiert werden sollen. Zurückzuführen ist dies wohl auf die sehr einfache Implementierung auf der CPU, die mit nur einer Schleife auskommt. Der Overhead des Algorithmus von Blelloch führt hier wesentlich zusätzlichen Overhead ein. Rund 120% der seriellen Zeit benötigt im Schnitt die nächsthöhere Kurve der nativen Ausführung. Direkt gefolgt wird diese von der Bibliothek JCuda, die allerdings bei einer Array-Gröÿe von rund 33 Millionen Einträgen abbricht. Ab dieser Grenze meldet die Bibliothek zu wenig Speicher. Dass dies nicht richtig ist folgt aus der durchgängigen Kurve der nativen Evaluation. Auch Aparapi führt den Anwendungsfall ohne Fehler aus. Letztendlich benötigt JCuda bis zu dieser Grenze bereits rund die doppelte Ausführungszeit der seriellen Ausführung. Aparapi hat ebenfalls Probleme mit der Ausführung dieses Anwendungsfalls und benötigt auf der GPU 286% der seriellen Ausführungszeit. Die CPU-Ausführung der Bibliothek ist noch langsamer. Hier konnte ein massiver Overhead festgestellt werden, der dazu führt, dass die Kurve mit massiver Steigung exponentiell wächst. Die Zeiten betragen das fache der seriellen Ausführungszeit. Ein Erklärungsversuch bezieht sich hier auf lange Wartezeiten bei Locks zwischen den Einzelrunden der Reduktion. Bei Beobachtung der CPU-Auslastung während der Berechnung wurde auch selten mehr als eine CPU in Anspruch genommen. Delite liegt im Gegensatz dazu mit der CPU-Ausführung, die 146% der seriellen Ausführungszeit benötigt, wesentlich besser. Der Abfall zu Beginn der Kurve ist wohl auf eine Kompilierung des auszuführenden Quelltextes durch den Hotspot-Compiler zurückzuführen. Im Gegensatz dazu mutet die Kurve der Delite CUDA-Ausführung sehr seltsam an. Hier ist der Einbruch bei rund Elementen wesentlich deutlicher ausgeprägt. Dieser konnte auch bei mehrfacher Ausführung des Evaluationsfalls reproduziert werden. Der Overhead liegt in jedem Fall so hoch, dass eine entsprechende Ausführung nicht lohnenswert ist. Rootbeer ndet sich nicht in der Grak wieder. Grund dafür ist, dass die sich ergebenden Ergeb-

82 5.4. Evaluation der Ausführungsgeschwindigkeiten 75 nisse rechnerisch nur unter Annahme einer Fehlfunktion der Synchronisation erklärt werden können. Da der generierte Code nicht ausgegeben werden kann, wurde auf eine weitere Fehlersuche verzichtet. Zusätzlich zum rudimentären Algorithmus wurde die Reduktion unter Nutzung von Shared- Memory für JCuda, Aparapi und nativem CUDA-C implementiert. Ohne das Ergebnis näher zu erläutern benötigt Aparapi hierdurch rund 68%, JCuda 91% und die native Ausführung 80% der nicht optimierten Ausführungszeit. Die Speicherarchitekturen haben also deutlichen Einuss auf die Ausführungsgeschwindigkeit der Algorithmen. Insgesamt ist das Verhältnis zwischen Berechnungen und Speichertransfer in diesem Anwendungsfall nicht hoch genug, um eine GPU-Ausführung zu rechtfertigen. Die Reduktion ist eher ein Anwendungsfall, der innerhalb eines Algorithmus, der auf der GPU ausgeführt wird, zum Einsatz kommt. Ein Beispiel ist das Verfahren der konjugierten Gradienten, in dem die Reduktion ebenfalls eine Rolle spielt. Mandelbrot Die resultierende Grak zur Berechnung der Mandelbrot-Fraktale ndet sich in Abbildung Rootbeer ist, wie in den bereits vorhergehenden Fällen, die langsamste Bibliothek und ist dabei im Gesamten rund 22 Mal (GPU) bzw. 28 Mal (JEMU) langsamer als die serielle Ausführung. Die CUDA-Ausführung von Delite beginnt mit einem hohen Overhead, gleicht sich aber insgesamt an die serielle Ausführung an und endet mit einer im Schnitt 1,25 Mal höheren Ausführungszeit. Aparapi und JCuda schenken sich in diesem Anwendungsfall nichts. Diesmal ist Aparapi rund 6% schneller. Gleichzeitig ist auch die Aparapi Thread-Pool-Ausführung wesentlich schneller als die serielle Ausführung (rund 9 Mal). Schnellste Kurve ist insgesamt die native Ausführung, die im Verhältnis zur seriellen Ausführung eine Beschleunigung um den Faktor 143 aufweist. Jakobi-Verfahren zur Lösung von Poisson-Gleichungen Durch die mehrfache Ausführung von Kernels innerhalb des Jakobi-Algorithmus ist zu erwarten, dass die Ausführungsgeschwindigkeiten von JCuda, Aparapi und Delite wesentlich niedriger als die der Rootbeer-Ausführung sind, da hier auf weitere Kopiervorgänge zwischen GPU und CPU bei jedem Kernel-Start und -Ende verzichtet werden kann. An Abbildung 5.16 ist dieses Verhalten nicht vollständig zu erkennen, da Rootbeer, wie leider auch bereits in den vorherigen Tests, eine wesentliche höhere Ausführungszeit aufzeigt. Aparapi und JCuda nden sich unter der seriellen Ausführungszeit wieder, wobei Aparapi rund 54% und JCuda 70% der Zeit benötigen. Allerdings rechnet sich der GPU-Overhead erst ab einer Matrix-Gröÿe von rund Dies gilt nicht für Rootbeer, Delite und Aparapi JTP. Im Fall von Aparapi JTP ist die typische Anfangssteigung als Overhead zu erkennen. Damit beträgt die Ausführungszeit im Verhältnis zur seriellen Zeit das rund 13-fache. Delite kann für diesen Anwendungsfall keinerlei Optimierungen anwenden und erreicht die 85- (CUDA) bzw. 174-fache (Scala) Zeit. Diese Werte beziehen bereits die in 4 Evaluationsfällen aufgetauchten Zeitüberschreitungen mit ein. Aus diesem Grund ist die steiler steigende Kurve von Rootbeer mit einer 10 (GPU) bzw. 19 (JEMU) Mal langsameren Ausführung schneller als Delite.

83 5.4. Evaluation der Ausführungsgeschwindigkeiten 76 Abbildung 5.14: Ausführungszeiten zur Reduktion (links: GPU und CPU-Ausführungszeiten, rechts: GPU-Ausführungszeiten) Abbildung 5.15: Ausführungszeiten zur Berechnung eines Mandelbrot-Fraktals (links: GPU und CPU-Ausführungszeiten, rechts: GPU-Ausführungszeiten)

84 5.4. Evaluation der Ausführungsgeschwindigkeiten 77 Abbildung 5.16: Ausführungszeiten zur Lösung von Poisson-Gleichungen unter Nutzung des Jakobi-Verfahrens (links: GPU und CPU-Ausführungszeiten, rechts: GPU-Ausführungszeiten) Verfahren der konjugierten Gradienten zur Lösung von Poisson-Gleichungen Das selbe Verhalten lässt sich auch beim Verfahren der konjugierten Gradienten mit seinen vielen Kernel-Aufrufen feststellen. Der Algorithmus wurde ausschlieÿlich für Aparapi und JCuda implementiert. Für Delite wurde zwar die Implementierung bis zum kompilierenden Code fertiggestellt, konnte aber auf Grund nicht zu debuggender Fehler bei der Code-Generierung nicht zur Ausführung gebracht werden. Der selbe Grund für die Nicht-Implementierung gilt auch für Rootbeer. Auch hier wurde ein kompilierender Algorithmus fertiggestellt. Jedoch liefert dieser zur Ausführungszeit unter Verwendung desselben Algorithmus, der auch bei JCuda und Aparapi zum Einsatz kommt, die falschen Ergebnisse. Da für kleinere Matrizen die richtigen Resultate berechnet werden, liegt der Schluss wiederum nahe, dass es sich wiederum um den selben Fehler in Bezug auf die Synchronisation von Threads in Blöcken handelt. Für die anderen Bibliotheken sind die Ergebnisse in Abbildung 5.17 dargestellt. Bis zu einer Matrix-Gröÿe von rund Elementen schat es keine GPU-Ausführung die serielle Ausführung zu unterschreiten. Erst ab dieser Gröÿe übersteigt die mögliche Beschleunigung durch Nutzung der Grakkarte den Overhead der Nutzung der GPU. Im Gesamten ermöglicht dies für die GPU-Ausführung von Aparapi im Verhältnis zur seriellen Ausführungszeit eine Beschleunigung von 1,7 und für JCuda sowie die native Ausführung eine Beschleunigung von 1,8.

85 5.5. Fazit der Evaluation 78 Abbildung 5.17: Ausführungszeiten zur Lösung von Poisson-Gleichungen unter Nutzung des Verfahrens der konjugierten Gradienten (links: GPU und CPU-Ausführungszeiten, rechts: Ausführungszeiten bis zu einer Parametergröÿe von < 1.200) 5.5 Fazit der Evaluation Alle Bibliotheken besitzen Stärken und Schwächen. Eine der Bibliotheken wird nachfolgend für die Implementierung des Simulators ausgewählt: Rootbeer scheidet als erste Bibliothek aus. Groÿes Plus der Bibliothek ist die einfach zu verwendende API und die Unterstützung auch spezieller Konstrukte wie Shared-Memory. Allerdings ist der generierte Code sehr fehleranfällig. Dies kann v.a. daran festgemacht werden, dass nicht in allen Evaluationsfällen lauähiger bzw. richtiger Quelltext produziert werden konnte. Insbesondere betrit dies die Reduktion und das komplexe Verfahren der konjugierten Gradienten. Auÿerdem ist die langsame Ausführungszeit über alle Evaluationsfälle hinweg ein Ausschlusskriterium. Mit JCuda können alle Anwendungsfälle korrekt implementiert werden und auch die Ausführungszeit ist durch die direkte Abbildung auf native CUDA-Funktionen und einen sehr dünnen JNI-Layer sehr gut. Als Ausschlusskriterium fungiert hier die Art der Programmierung. Eine Java-Abstraktion sollte es ermöglichen, GPU-Quelltext in Java zu spezizieren. Gleichzeitig sollte als Ausführungsmodus auch eine Java-Ausführung unterstützt werden, damit aus Java heraus ein Testen des Algorithmus möglich ist. Keine der beiden Anforderungen kann JCuda erfüllen, da Kernels nativ in CUDA-C speziziert werden müssen. Als vorletzte Bibliothek ist auch Delite nicht verwendbar. Diese Bibliothek entspricht eigentlich dem, was ein Entwickler sich als GPU-Abstraktion wünscht. Die domänenspezische Programmierung und Erstellung des Quelltextes resultiert in nahezu optimalem Quelltext, wobei zusätzlich die Ausführung über Pattern-Matching noch vom Entwickler wesentlich verbesserbar ist. Leider bendet sich die Bibliothek noch nicht in einem Stadium, in dem diese produktiv einge-

86 5.5. Fazit der Evaluation 79 setzt werden kann. Kennzeichen hierfür betreen auf der einen Seite teilweise nicht lauähigen generierten Quelltext, Fehlermeldungen, die nicht auf den Quelltext zurückzuführen sind, keine Möglichkeit zum Debuggen und zusätzlich die nicht besonders schnelle Ausführungszeit. Trotzdem ist hervorzuheben, dass die Nutzung externer Bibliotheken für BLAS-Operationen und die automatische Abbildung von Domänenfunktionen in Delite auf diese Operationen eine Besonderheit darstellt, die auch für andere Bibliotheken wünschenswert ist. Schlieÿlich bleibt als letzte Bibliothek Aparapi. Diese überzeugt durch eine schnelle Ausführungszeit, eine zuverlässige Code-Generierung sowie mit einer für den Programmierer freundlichen API, die insbesondere durch den impliziten, zur Laufzeit erfolgenden, Staging-Vorgang besonders testbar ist. Allerdings bleiben auch für diese Bibliothek verschiedene Nachteile, wie insbesondere die sehr hohe Fehleranfälligkeit des unterliegenden Quelltextes sowie die bisher nicht vorhandene Implementierung von verschiedenen Kernel-Aufrufen, sodass mehrere verschiedene Kernels auf unterschiedliche Speicherbereiche abgebildet werden. Trotzdem entspricht Aparapi der von allen evaluierten Bibliotheken besten Option und wird folgerichtig über den weiteren Verlauf der Arbeit zur Portierung des nativen Simulators eingesetzt.

87 Kapitel 6 Portierung des Simulators Im vorherigen Kapitel wurden verschiedene Bibliotheken evaluiert und nal Aparapi als am besten einsetzbare Bibliothek ausgewählt. Anhand dieser wurde der bestehende native Simulator des Fraunhofer Instituts nach Java portiert. Dazu wurde zuerst eine Implementierung auf Basis der verfügbaren Bibliothek erstellt und diese anschlieÿend weiter verbessert. Einige Spezika werden im nachfolgenden Kapitel genauer erläutert. 6.1 Struktur des Simulators Die grundsätzliche Struktur des Simulators ist in Abbildung 6.1 dargestellt. Der ursprüngliche Kernel-Code aus dem nativen Simulator konnte fast unverändert übernommen werden, da C und Java sich in der Syntax nicht wesentlich unterscheiden. Hauptsächlich wurden Pointer durch Arrays und Unions jeweils durch mehrere Arrays ersetzt. Einige Hilfsmethoden, z.b. zur Berechnung des Array-Indizes aus drei Dimensionen, wurden zur Übersichtlichkeit hinzugefügt. Auÿerdem mussten, durch den Austausch des nativen CUDA-C durch Aparapi, die Kernel- Aufrufe durch Aparapi-Aufrufe ersetzt werden. Die Struktur der Kernels wird im nächsten Abschnitt beschrieben. Kernel-Hierarchie unter Nutzung der Aparapi-API Statt der Verwendung von globalen Funktionen, wie sie im ursprünglichen Simulator zu nden sind, wurde eine Kernel-Hierarchie entworfen, die die einzelnen Kernel-Methoden kapselt. Um die explizite Speicherverwaltung von Aparapi nutzen zu können, darf dabei allerdings nur eine einzige Kernel-Klasse verwendet werden. Entsprechend der empfohlenen Vorgehensweise für mehrere Einsprungpunkte, übernimmt eine globale Variable im Kernel dabei die Unterscheidung, welche Kernel-Aufrufe welche Kernel-Methoden starten sollen. Im Simulator erfolgt die Aufteilung basierend auf einem If-Konstrukt, das über einen übergebenen Task-Parameter auf Gleichheit zu globalen Integer-Konstanten prüft. Jedem Kernel, bzw. der zugehörigen If- Bedingung, ist eine entsprechende Konstante zugewiesen. Zur Strukturierung des entstehenden Quelltextes bietet Aparapi drei Möglichkeiten: Kernel-Klasse Die Einzelmethoden werden alle in eine Kernel-Klasse geschrieben. Daraus resultiert sehr unübersichtlicher Quelltext, da in einer einzigen, mehrere 1000 Zeilen langen, Datei das Aunden einer Einzelzeile sehr aufwändig ist.

88 6.1. Struktur des Simulators 81 Kernel InterpolationKernel BoundaryKernel PressureKernel AdvectionKernel Main SimulationGUI VisualizationKernel FluidSolver public void init(); public void step(); public void cleanup(); RuntimeVariables SimulationKernel private static final int ADD_SOURCE = 0; private static final int SWAP_VELOCITIES = 1; private int task; // (...) public void gpumethod(); SimulationData Model AddSourceVelocity Diffusion Swap DefaultModel Abbildung 6.1: Struktur des mit Aparapi implementierten Simulators

89 6.1. Struktur des Simulators 82 Statische Methoden Alternativ können die Methoden in andere Klassen ausgelagert werden und statisch aus dem Einstiegspunkt referenziert und aufgerufen werden. Vorsicht ist geboten, soweit Methoden aus der Kernel-Klasse selbst benötigt werden. Beispielsweise muss zur Ermittlung der aktuellen Thread-ID die Methode #getglobalid in der Kernel- Klasse verwendet werden. Diese Methode ist jedoch nur innerhalb der Kernel-Klasse aufrufbar und steht damit in der statischen, externen, Methode nicht zur Verfügung. Weitere Beispiele dafür betreen u.a. die Berechnung von Wurzeln oder das Runden von Werten. Aparapi stellt für derartige Funktionen eigene Methoden zur Verfügung, die diese Operationen auf OpenCL-Konstrukte abbilden. Für die Thread-ID ist eine Lösung durch Übergabe der ID als Parameter an die aufzurufende Funktion möglich. Andere Anwendungsfälle, wie z.b. die Wurzelfunktion, besitzen keine derartige Lösung. Entsprechend kann Quelltext, der derartige Methoden verwendet, nicht ausgelagert werden. Vererbungshierarchie Als dritte und letzte Möglichkeit kann eine Pseudo-Vererbungshierarchie eingesetzt werden. Mehrere verschiedene Kernel-Klassen werden implementiert und leiten voneinander linear ab, wobei die oberste Kernel-Klasse von der Aparapi-Kernel-Klasse ableiten muss. Jede dieser Unterklassen kann dabei Methoden für eine Untergruppe an Methoden halten. Im implementierten Simulator wird diese Aufteilung z.b. zur Berechnung der Advektion bzw. zur Kapselung der Visualisierungsmethoden verwendet. Für die Implementierung wurde eine Kombination der drei oben genannten Ansätze verwendet. Die Auslagerung in statische Methoden wurde dabei auf Grund der etwas besseren Testbarkeit bevorzugt. Hier muss auf keine Aparapi-Konstrukte zurückgegrien werden, da statische Funktionen direkt getestet werden können. Die meisten Kernel-Methoden nutzen jedoch native Kernel-Funktionen, v.a. zur Wurzelberechnung, womit die Auslagerung nur teilweise möglich ist. Grundsätzlich sind alle drei Ansätze über die JTP-Ausführung testbar. Dabei hält sich auch der Speicherverbrauch stark in Grenzen, da der teure OpenCL-Kontext erst bei der ersten nativen Ausführung initialisiert wird. Ein Test kann z.b. über die normale JUnit-Infrastruktur speziziert und anschlieÿend ohne weitere Zusätze in einem Continuous-Integration-Server ausgeführt werden. Datenstrukturen Als Datenstruktur zum Halten des Simulationszustands bzw. für die Berechnungen wurden die in der nativen Simulation verwendeten Arrays übernommen. Die Java-seitigen Arrays werden allerdings im Gegensatz zur nativen Variante nicht nach dem Kopiervorgang auf die GPU freigegeben, sondern weiter vorgehalten. Ein nahtloser Wechsel zwischen JTP- und GPU-Abarbeitung ist damit durch einen Kopiervorgang ohne vorherige neue Allokation der Ressourcen möglich. Gleichzeitig resultiert daraus ein erhöhter Speicherverbrauch, der aber auf Grund der physikalischen Trennung der Speicher von Device und Host keine Auswirkungen hat. Die Daten selbst werden in einer SimulationData-Klasse vorgehalten. Im Gegensatz dazu hält ein Model nur generelle Simulationsparameter, also z.b. die Breite, Höhe und Tiefe des Simulationsraums. In der nativen Simulation ndet sich das Modell ebenfalls wieder, wobei das Halten der

90 6.1. Struktur des Simulators 83 Simulationsdaten nicht nötig ist, da kein direkter Wechsel zwischen GPU und CPU-Ausführung möglich ist. Eine zusätzliche RuntimeVariables-Klasse hält auÿerdem alle Werte, die aus der Benutzerober- äche veränderbar sind, namentlich z.b. der Ausführungsmodus oder die Kameraposition. Abarbeitungssteuerung im Fluid-Solver Die zentrale Abarbeitungssteuerung übernimmt die FluidSolver-Klasse. Diese ist für die Initialisierung eines einzelnen Kernel-Objektes zur Simulation verantwortlich. Dazu werden Set- und Get-Methoden auf dem Kernel-Objekt aufgerufen, die jeweils entsprechende Objekte von oder zur GPU kopieren. Für einzelne Kernel Aufrufe wird jeweils der entsprechende Task auf dem Kernel gesetzt und die Abarbeitung gestartet. Die #step-methode kapselt die Abarbeitung eines vollständigen Simulationsschrittes. Insgesamt werden hier 34 Aufrufe gestartet, wobei der Groÿteil auf die Projektion entfällt, die unter Nutzung von 5 Jakobi Iterationen den Druck im Simulationsraum angleicht. Die Steuerungslogik, also die Simulationsschritte und deren Reihenfolge, entspricht wiederum der nativen Implementierung. Grasche Benutzerschnittstelle mit Swing und JOGL Die native Simulation verwendet zur Visualisierung OpenGL. JOGL [jog13] stellt die Bibliothek unter Java zur Verfügung. Ein entsprechender Simulations-Canvas wird in Swing eingebunden und über APIs, die stark denen der nativen Implementierung gleichen, konguriert und ausgeführt. Die Simulationsschleife wird ebenfalls von OpenGL vorgegeben. In JOGL entspricht dies einem Animator-Objekt, der als Taktgeber fungiert und alle n Millisekunden eine #display-methode aufruft. Diese ruft entsprechend jeweils den Simulationsschritt im FluidSolver auf und stellt anschlieÿend unter Nutzung zurück kopierter Werte die Ergebnisse in verschiedenen Visualisierungen dar. Kopiert wird nur benötigter Inhalt, hauptsächlich also die Positionen darzustellender Partikel sowie deren Farben. Im Schnitt wird die Simulation im GPU-Modus dabei mit rund 50 Frames pro Sekunde ausgeführt. Für einen üssigen Ablauf der Simulation ist dies ausreichend. Im Gegensatz dazu erreicht die JTP-Darstellung gerade rund 5 Frames pro Sekunde. Final ergibt sich eine Oberäche wie in Abbildung 6.2 dargestellt. Der graue Kasten im Bild repräsentiert den Simulationsraum. Links ist ein länglicher Balken zu sehen, der einen Einuss repräsentiert. Dessen Geschwindigkeitsvektoren zeigen jeweils nach rechts in den Simulationsraum. Rechts ist ein Aususs abgebildet. Links unten ist ein weiteres Rechteck zu sehen, das ein Hindernis repräsentiert.

91 6.2. Erweiterung und Verbesserung von Aparapi 84 Abbildung 6.2: Bildschirm-Foto des Simulators während der Simulation 6.2 Erweiterung und Verbesserung von Aparapi Nachfolgend werden an der Bibliothek verschiedene Verbesserung zur Erhöhung der Geschwindigkeit bzw. zur Verbesserung der Nutzbarkeit beschrieben Implementierung objektorientierter Einsprungpunkte Die bedingte Unterstützung mehrfache Einsprungpunkte stellt eine deutliche Einschränkung im Programmiermodell von Aparapi dar. Dabei ist v.a. die Verwendung unterschiedlicher Speicherbereiche für verschiedene Kernels ein Problem bei der Erstellung objektorientierter Anwendungen. Änderung der Java-API An der grundsätzlichen API sind einige Änderungen vorzunehmen. Im Wesentlichen betrit dies die Tatsache, dass in der vorliegenden Version jeder Kernel einen eigenen KernelRunner besitzt, der wiederum auf einen eigenständigen OpenCL-Kontext abgebildet wird. Ziel ist es auf einen einzigen OpenCL-Kontext mehrere Kernel-Aufrufe, in diesem Fall also KernelRunners, abzubilden. In Java führt dies zu der in Abbildung 6.3 angegeben Struktur. Wichtigste Änderung ist, dass einzelne Kernels leichtgewichtig werden. Dies wird dadurch erreicht, dass Kernels selbst nicht mehr ausgeführt werden können, sondern einem KernelRunner zur Ausführung übergeben werden. Eine direkte Referenz auf einen KernelRunner innerhalb eines Kernels existiert nicht mehr. Damit ein KernelRunner zwischen verschiedenen Kernel-Klassen zur Ausführung unterscheiden kann, wird zusätzlich eine Map von der Kernel-Klasse auf ein KernelMapping eingeführt.

92 6.2. Erweiterung und Verbesserung von Aparapi 85 Dieses kapselt bisher als direkte Attribute der KernelRunner-Klasse zu ndende Attribute. Diese müssen pro Kernel einmal vorgehalten werden. Beispiele umfassen dabei die schon als Schlüssel der Map dienende Kernel-Klasse, die Argumente, die zur Ausführung an den Kernel übergeben werden müssen, die umgewandelte Darstellung transformierte des Einsprung-Punktes sowie ein jnicontexthandle. Die Hauptaufgabe der KernelArg-Klasse ist die Kapselung des Datentyps eines Kernel-Arguments und dessen Länge. Ausprägungen der Klasse werden auf der nativen Seite zum Start der Kernels benötigt. Die EntryPoint-Klasse dient hauptsächlich der Code-Generierung und wird anschlieÿend nur noch zur Generierung von Structs für die Konvertierung von Objekten verwendet. Wichtigstes Element ist hingegen das Context-Handle. Auf der nativen Seite müssen verschiedene Daten zwischengespeichert werden, die nicht bei jedem Kernel-Aufruf erneut gesetzt werden sollen. Dazu gehört u.a. der Quelltext des Kernels sowie dessen Parameter. Um dies zu gewährleisten, existiert in der nativen Umgebung eine Klasse JNIContext. Der korrekte Namen der Klasse ist eigentlich KernelContext, da die Klasse alle für einen Kernel relevanten Attribute kapselt, mit JNI dagegen nichts zu tun hat. Eine Instanz dieser Klasse wird jeweils beim ersten Start eines Kernels instanziiert und deren Speicheradresse als jnicontexthandle zurückgegeben. Für jeden weiteren JNI-Methodenaufruf wird diese Adresse wiederum übergeben und zurück in die JNIContext-Klasse umgewandelt. Auch die explizite Speicherverwaltung muss in der neuen API angepasst werden. Get- und Put- Operationen machen, im Gegensatz zur bisherigen API, innerhalb der Kernel-Klasse keinen Sinn mehr, da hier kein Zugri auf den nativen Teil der Bibliothek mehr möglich ist. Die entsprechenden Methoden wurden deswegen in den KernelRunner verschoben und werden nun unabhängig von allen Kernel-Klassen auf den OpenCL-Kontext abgebildet. Die dabei verfolgte Idee ist, dass die native Implementierung die Abbildung gleicher Java- Referenzen auf den gleichen OpenCL-Speicherbereich übernimmt. Die Java-Seite bemerkt davon nichts, sondern übergibt die Referenzen schlicht weiterhin an die native Seite. Sobald Get- oder Put-Methoden aufgerufen werden, wird, basierend auf der Referenz, der OpenCL-Speicherinhalt aktualisiert oder ausgelesen und, falls nötig, der Inhalt des Java-Objektes durch Überschreiben des Adressinhalts aktualisiert. Änderung der nativen Implementierung Die native Implementierung ist in sieben wesentliche JNI-Methoden aufgeteilt, die den Lebenszyklus eines KernelRunners bzw. eines Kernels wieder spiegeln: initjni Die Methode wird einmal pro Kernel aufgerufen. Darin wird die Umgebung für die Ausführung eines bestimmten Kernels geschaen. Wesentliches Element ist dabei die Erstellung des bereits erwähnten JNIContext-Objektes, das alle Attribute eines Kernels kapselt. Jeder Kontext besitzt z.b. eine ID für ein OpenCL-Gerät, eine eigene Abarbeitungswarteschlange und z.b. Referenzen auf Kernel-Objekte oder -Klassen. buildprogramjni Nachdem der CUDA-Quelltext Java seitig erstellt wurde, wird dieser, wiederum gemeinsam mit einem Kontext, an die Methode übergeben. Folgend wird der Quelltext kompiliert und die CommandQueue für den Kontext erstellt und gesetzt.

93 6.2. Erweiterung und Verbesserung von Aparapi 86 Launcher public static void main(string[] args); KernelRunner private Map<Class, KernelMapping> kernelmapping; public void execute(kernel kernel, Range range); public KernelRunner put(int[] array); public KernelRunner get(int[] array); public void dispose(); KernelMapping public final Class<? extends Kernel> kernelclass; public final List<KernelArg> kernelargs; public final Entrypoint entrypoint; public long jnicontexthandle; Kernel public abstract void run(); public int getlocalid(int dim); public int getgroupid(int dim); public int getglobalid(int dim); public static double sqrt(double value); public static double floor(double value); MandelbrotKernel private int[] pixels; public void run(); MatrixMultiplicationKernel private int[] in1; private int[] in2; private int[] result; public void run(); Abbildung 6.3: Geänderte Aparapi-API um verschiedene Kernels auf den selben KernelRunner abbilden zu können.

94 6.2. Erweiterung und Verbesserung von Aparapi 87 setargsjni Bisher wurden keine Argumente für den Kernel übergeben. Dies wird nun durch den Aufruf der Methode nachgeholt. Übergeben wird eine Menge an KernelArg-Objekten, die jeweils in ein natives KernelArg-Objekt umgewandelt werden. Dieses beinhaltet auch den Puer, der für das Halten der Referenz auf direkten GPU-Speicher zuständig ist. runkerneljni Vor Aufruf dieser Methode wird auf der Java-Seite jedes Kernel-Argument geprüft und, falls nötig, Referenzen von Arrays aktualisiert bzw. explizite Put-Operationen abgearbeitet. Wird dabei erkannt, dass eine Referenz verändert wurde, so wird ein Boolean- Flag übergeben, das als Indikator für eine auszuführende Aktualisierung dient. In diesem Fall wird evtl. bereits allozierter Speicher freigegeben, neuer Speicher alloziert und dieser als Argument für den Kernel an der passenden Stelle gesetzt. Abschlieÿend werden die Daten kopiert und der Kernel ausgeführt. disposejni Pro Kontext kann hier allozierter Speicher wieder freigegeben werden. getjni Wird der explizite Speichermodus verwendet, so werden Get-Operationen auf diese Methode abgebildet. Entsprechend wird der Speicher der GPU ausgelesen und der Inhalt im KernelArg gesetzt. getextensionsjni Die Methode dient dem Aunden der verfügbaren OpenCL-Erweiterungen. Folgend auf die bisherige Implementierung wurden verschiedene Abläufe leicht verändert und die Struktur optimiert. Dazu wurden nicht nur die neuen Funktionen eingebaut, sondern die interne Struktur durch Verschiebung globaler Funktionen in entsprechende Klassen, sowie durch die Verwendung von Polymorphismus, objektorientiert gestaltet. Die sich ergebende Struktur ist in Abbildung 6.4 dargestellt und wird nachfolgend näher erläutert. Grundsätzlich existieren jetzt zwei Kontexte: Der erste Kontext ist für das Halten eines Kernels und dessen Attribute zuständig. Diese Klasse entspricht der früheren JNIContext-Klasse und wurde entsprechend ihrer Verantwortlichkeit in KernelContext umbenannt. Als zweiter Kontext existiert eine neu geschaene KernelRunnerContext-Klasse. Diese hält pro KernelRunner alle, für die OpenCL-Ausführung notwendigen, Konstrukte, z.b. also die früher im JNIContext vorgehaltene Warteschlange für OpenCL-Kommandos. Die Instanziierung der entsprechenden Objekte folgt dem Schema der bisherigen JNIContext- Implementierung. Beim ersten Aufruf einer entsprechenden JNI-Methode wird das Objekt instanziiert und dessen Adresse als Kontext zurückgegeben. Für den KernelRunnerContext wurde eine neue JNI-Methode geschaen, die beim ersten Kernel-Aufruf innerhalb eines KernelRunner aufgerufen wird und das native Objekt instanziiert. Der zurückgegebene Kontext wird global im KernelRunner vorgehalten und bei den folgenden JNI-Aufrufen jeweils übergeben. Statt wie bisher bei der Ausführung eines Kernels für einen Array pauschal Speicher zu allozieren und die notwendigen Daten zu kopieren, muss nun nach bereits erstellten OpenCL- Referenzen Ausschau gehalten werden. Ist Speicher für ein Java-Objekt bereits alloziert, so muss der OpenCL-Speicher wiederverwendet werden. Die Verwaltung des Speichers sowie dessen Abbildung auf Java-Objekte übernimmt eine neue Klasse mit dem Namen BufferManager. Die Klasse wird pro KernelRunnerContext einmal instanziiert. Durch entsprechende Methoden kann ein GPU-Puer für eine Java-Referenz abgeholt werden. Intern wird die Menge an momentan

95 6.2. Erweiterung und Verbesserung von Aparapi 88 vorhandenen Puern durchlaufen. Wird die Java-Referenz durch Vergleich auf Adressgleichheit gefunden, so wird der entsprechende Puer zurückgegeben. Ansonsten wird ein neuer Puer alloziert. Die Prüfung erfolgt explizit auf Adressgleichheit und nicht auf Gleichheit der Objektinhalte. Arrays mit gleichem Inhalt, aber unterschiedlichen Adressen, werden voraussichtlich auch in Kernels unterschiedlich verwendet und besitzen u.u. nach der Kernel-Ausführung andere Inhalte. Für die Puer sind als Datenstruktur zwei Klassen relevant: AparapiBuffer und ArrayBuffer. Beide Klassen waren in der bisherigen Implementierung ebenfalls vorhanden. ArrayBuffer kapselt dabei die Referenz auf einen Java-Array und dessen Elemente sowie den zugehörigen GPU- Speicher. Die Klasse AparapiBuffer ist dagegen für das Halten von mehrdimensionalen Arrays zuständig, die linearisiert auf die GPU abgebildet werden. Um besser mit den Klassen arbeiten zu können, wurde eine dritte Klasse, GPUElement, eingefügt, von der beide Puer-Klassen abgeleitet sind. Diese ist für das Halten des GPU-Speichers zuständig. Um herausnden zu können, wann ein Puer nicht mehr benötigt wird und deswegen freigegeben werden kann, hält die GPUElement-Klasse einen Referenzzähler in Form einer simplen Integer- Variable. Verweist ein Kernel-Argument auf einen Puer, so wird dessen interner Zähler um +1 inkrementiert. Wird dagegen das Kernel-Argument nicht mehr benötigt oder ändert sich der referenzierte Puer, so wird der Zähler des entsprechend nun veralteten Puers um 1 dekrementiert. Vor jedem Aufruf eines Kernels wird auf dem BufferManager eine Methode zum Aufräumen überüssiger Puer aufgerufen. Zu diesem Zeitpunkt wurden bereits alle notwendigen Referenzen aktualisiert, womit veraltete, nicht mehr referenzierte Puer gelöscht und deren OpenCL- Speicherinhalte freigegeben werden können. Der BufferManager durchläuft alle gespeicherten Puer und prüft, ob die Elemente jeweils einen Referenzzähler mit Inhalt > 0 haben. Ist dies nicht der Fall, so wird der Speicher freigegeben. In einer früheren Version wurde auf den Referenzzähler verzichtet und stattdessen über alle gespeicherten KernelContext-Objekte und deren Argumente gelaufen. Sobald kein Puer mehr von einem Kontext referenziert wurde wurde dieser freigegeben. Der Ansatz ist funktionsfähig und wesentlich zuverlässiger als der Referenzzähler, der darauf angewiesen ist, dass alle, die Klasse verwendenden, Code-Abschnitte den Zähler richtig verwenden. Im Gegensatz dazu bringt das Durchlaufen der Kernel-Argumente und -Kontexte eine signikant längere Ausführungszeit mit sich, weswegen der Referenzzähler als Mittel der Wahl eingesetzt wird. Schlieÿlich wurde auch die Logik hinter der bisherigen dispose-methode verändert. Bisher wurden dadurch der eine, bisher im KernelRunner referenzierte, OpenCL-Kontext aufgeräumt. In der neuen Implementierung werden alle gespeicherten Kernel-Kontexte eines Runner-Kontexts aufgeräumt. Damit ist sichergestellt, dass nach erfolgreicher Ausführung auch z.b. der OpenCL- Kontext sowie die CommandQueue freigegeben werden Erhöhung der Geschwindigkeit durch Caching des besten OpenCL- Gerätes Im Verlauf der Implementierung mehrfacher Einsprungpunkte wurde der Java-Teil von Aparapi einem Proling unterzogen. Im Ergebnis fällt eine Methode auf, die die Ausführung um rund 30% verlängert. Der entsprechende Auszug aus dem Proler ist in Abbildung 6.5 dargestellt.

96 6.2. Erweiterung und Verbesserung von Aparapi 89 KernelRunnerContext cl_device_id deviceid; cl_device_type devicetype; cl_context context; cl_command_queue commandqueue; BufferManager* buffermanager; std::vector kernelcontextlist; KernelContext jobject kernelobject; jclass kernelclass; cl_program program; cl_kernel kernel; KernelArg** args; KernelArg KernelContext* kernelcontext; jobject argobj; jobject javaarg; GPUElement* buffer; GPUElement jobject javaobject; cl_mem mem; int referencecount; BufferManager std::vector aparapibufferlist; std::vector arraybufferlist; void cleanupnonreferencebuffers(); AparapiBuffer int numdims; void* data; ArrayBuffer int length; void* addr; Abbildung 6.4: Struktur der nativen Klassen in Aparapi

97 6.2. Erweiterung und Verbesserung von Aparapi 90 Abbildung 6.5: Auszug aus VisualVM zum Flaschenhals der Device#best-Methode Die Methode ist dafür zuständig, basierend auf den zur Verfügung stehenden Recheneinheiten, das beste OpenCL-Gerät auszuwählen und zurückzugeben. Um an die verfügbaren Geräte zu gelangen wird eine native Methode aufgerufen, die die Plattformen aus OpenCL extrahiert und zurück gibt. Dies ist verhältnismäÿig zeitaufwändig und stellt einen Flaschenhals dar. Um diesen zu umgehen wurde vorausgesetzt, dass sich die Ausführungsumgebung, also die Menge an zur Verfügung stehenden OpenCL-Geräten, während der Ausführung nicht ändert. Hot-Swapping wurde explizit auÿen vor gelassen. Unter dieser Annahme kann das beste Gerät des letzten Methodenaufrufs als Attribut in der Device-Klasse zwischengespeichert werden. Ist das Attribut null, so wird die Methode tatsächlich ausgeführt und das Ergebnis im Attribut zwischengespeichert. Ansonsten wird jeweils nur der Inhalt des Attributs zurückgegeben und die Ausführungszeit damit entsprechend verkürzt Aufruf von OpenCL-Methoden von auÿerhalb der Kernel-Klasse Um auch aus statischen Methoden heraus OpenCL-Methoden, wie z.b. die Methode zur Berechnung der Quadratwurzel, aufrufen zu können, wurden diese zwar in der Kernel-Klasse belassen, dafür aber statisch und öentlich gemacht. Da die Methoden anschlieÿend aus einem Einsprungpunkt indirekt referenziert werden, erkennt Aparapi die Methoden als auf direkten OpenCL-Quelltext abzubildende Funktionen und generiert den Quelltext entsprechend. In eine eigene Klasse können die Methoden nicht ausgelagert werden, da die zuständige OpenCLDelegate-Annotation im Augenblick nur in der Kernel-Klasse gesucht wird Implementierung der Verwendung von Objekten in Kernels In der aktuellen Version von Aparapi ist die Verwendung von Objekten relativ eingeschränkt. Zwar können Arrays, die Objekte enthalten, verwendet werden. Diese müssen jedoch einzeln in native Structs umgewandelt werden, womit der Array zeitaufwändig neu aufgebaut werden muss. Eine Alternative besteht in der Verwendung von Objekten, die Arrays und zugehörige Methoden beinhalten. Statt also Arrays zu nutzen, die komplexe Objekte beinhalten, wird stattdessen ein

98 6.2. Erweiterung und Verbesserung von Aparapi 91 Objekt verwendet, dass für jedes Attribut des ursprünglichen Objektes einen primitiven Array verwendet, der anschlieÿend auch direkt auf der GPU verwendet werden kann. Dies ermöglicht zusätzlich die Verwendung von Methoden, die auf die Attribute zugreifen, manipulieren und diese kapseln. Ein Anwendungsfall ist beispielsweise die im Simulator verwendete Linearisierung einer Matrix auf einen eindimensionalen Array. Im Augenblick müssen für jeden Zugri zuerst die zugehörigen Indexe berechnet werden, um anschlieÿend das richtige Element extrahieren bzw. aktualisieren zu können. Durch die Nutzung einer kapselnden Klasse kann dieser Quelltext zum Zugri auf den Array, bzw. der Berechnung des linearisierten Indexes, gekapselt und das entstehende Konstrukt nach auÿen hin durch ein Matrix-Interface zur Verfügung gestellt werden. Abgesehen davon ermöglicht dies die mehrfache Verwendung der Klasse. Im Simulator wird z.b. nicht nur eine linearisierte Matrix verwendet, sondern an sehr unterschiedlichen Stellen, z.b. für die Speicherung der Geschwindigkeitsvektoren und der Partikel-Positionen. Durch Verwendung einer Klasse könnte der Quelltext maÿgeblich verkleinert und besser strukturiert werden. Da auf der GPU keine Objekte unterstützt werden, muss diese Art der Objektorientierung vor der Kernel-Ausführung rückgängig gemacht werden. Beide nachfolgend kurz vorgestellten Varianten versuchen deswegen dies, basierend auf dem Aufrufgraphen, durch Inlining durchzuführen. Wird beispielsweise ein Feld array in einem Objektattribut object innerhalb eines Kernels verwendet, so müsste das entsprechende Feld in array_object umbenannt, object selbst entfernt und alle Zugrie aktualisiert werden. Methoden müssen, für jedes verwendete Objektattribut, dupliziert werden, damit diese auf die richtigen, umbenannten, Felder zugreifen. Gleichzeitig werden die Methodennamen, genau wie bei den Attributen, unter Verwendung der Attribut-Namen, die zu der Methode führen, umbenannt. Zur eigentlichen Implementierung der Funktionalität stehen zwei Varianten zur Auswahl: Entfernung der Objektorientierung im Bytecode Die erste Variante bezieht sich darauf, den Bytecode unmittelbar nach dem Einlesen in Aparapi und noch vor der Code-Generierung so zu verändern, dass die Objekte in der resultierenden Struktur nicht mehr vorkommen. Entsprechend müssen Objektfelder und Methoden verschoben, ursprüngliche Felder gelöscht und Referenzen innerhalb von Instruktionen aktualisiert werden. Code-Generierung Statt den Bytecode zu verändern wird während der Code-Umwandlung der Aufruf-Graph von Methoden und Klassen erstellt und dieser bei der Code-Generierung, zur Entfernung von Objekten berücksichtigt. Die erste der beiden Varianten ist, aufgrund der generischen Struktur, der schönere der beiden Ansätze. Allerdings benötigt dieser zur Umsetzung eine vollständige Bytecode-Bibliothek, die auch die Verschmelzung von Klassen ermöglicht. Dies ist händisch sehr aufwändig, da im Bytecode sog. Constant-Pools für Klassen, Methoden und Felder verwendet werden [LYBB13][S. 82]. Derartige Unterstützung ist in Aparapi im Augenblick nicht zu nden, da das Einlesen des Bytecodes händisch implementiert wurde. Entsprechend scheidet diese Variante auf Grund des nötigen Zeitaufwands vorerst aus. Der zweite Ansatz ist wesentlich weniger generisch und erfordert die Umstrukturierung des

99 6.2. Erweiterung und Verbesserung von Aparapi 92 Aparapi Quelltextes. Standardmäÿig analysiert Aparapi nur den Einsprungpunkt, um referenzierte Methoden und Attribute zu nden. Bei diesem Ansatz muss jeder gefundene Aufruf von Objektmethoden ebenfalls analysiert und in einer internen Struktur abgebildet werden. Dabei muss gleichzeitig der Aufrufgraph, an dieser Stelle also die Hierarchie der verwendeten Feldnamen, gespeichert werden, um die Felder und Methoden anschlieÿend entsprechend umbenennen bzw. dupliziert ausgeben zu können. Der Graph wird anschlieÿend auch noch dazu gebraucht, um native Referenzen auf Kernel-Objekte abbilden zu können, da nach der Code-Generierung die Struktur des generierten Codes vom Java-Code dieriert. Auf Grund der nicht benötigten Bytecode-Transformation wurde dieser Ansatz weiter verfolgt. Dies führt dazu, dass sehr einfache Objekte innerhalb von Kernels verwendet werden können. Hauptsächlich beschränkt sich dies auf einfache Plain Old Java Object (POJO)s mit Getterund Setter-Methoden. Sobald komplexere Objekte verwendet werden, z.b. zur Kapselung einer linearisierten Matrix, ist der Quelltext in der erstellten Implementierung nicht mehr valide. Die Ursache besteht darin, dass bei der Code-Generierung über die einzelnen Instruktionen gelaufen wird und diese zum Schreiben des Quelltextes verwendet werden. Wird nun ein Methodenaufruf geschrieben, der innerhalb eines Objektes gestartet wird, so fehlt die Referenz auf den Aufruf-Graphen. Es kann nicht ermittelt werden, wo die Objektmethode anzusiedeln ist, bzw. wie die umbenannte Methode, die aufgerufen werden soll, heiÿt. Entsprechend können keine Feld-Umbenennungen vorgenommen werden, womit der resultierende Quelltext nicht ausgeführt werden kann. Um hier Abhilfe zu schaen müssten im Vorfeld der Generierung die Bytecode-Instruktionen geändert werden. Allerdings könnte in diesem Fall auch direkt der Bytecode verändert werden, um die Objekte komplett zu entfernen und die bisherige Code-Generierung zu belassen. Entsprechend wurde die Implementierung an dieser Stelle gestoppt und als Aufgabe für zukünftige Arbeiten belassen.

100 6.3. Abbildung der neuen API auf den Fluid-Simulator Abbildung der neuen API auf den Fluid-Simulator Mit der neuen API kann der Quelltext wesentlich übersichtlicher gestaltet werden. Die Struktur der Kernels ist in Abbildung 6.6 dargestellt. Weitere Simulationsklassen, wie z.b. der FluidSolver, wurden intern verändert, zugunsten der Übersichtlichkeit aber nicht in die Abbildung aufgenommen. Kernels können nun vollständig voneinander getrennt in verschiedene Pakete und Vererbungshierarchien aufgeteilt werden. Eine bisher notwendige globale Variable zur Unterscheidung der Kernel-Aufrufe entfällt, da die Entscheidung, welcher Einsprungpunkt aufgerufen wird, vom KernelRunner übernommen wird. Dieser wird im Rahmen der Simulation einmalig instanziiert und dient anschlieÿend als Proxy für alle Kernel-Aufrufe. Die Instanziierung geschieht in der Klasse FluidSolver. Zusätzlich werden hier Instanzen der, für die Berechnung der Navier-Stokes-Gleichungen nötigen, Kernels gehalten. Ein Simulationsschritt wird, entsprechend der bisherigen Implementierung, über eine öentliche #step-methode von der Oberäche bzw. von einem Benchmark, aufgerufen. Ebenfalls im FluidSolver enthalten ist eine Methode, die einen beliebigen Kernel mit einer geg. Anzahl an Threads ausführt. Diese ist darauf zurückzuführen, dass die Kernels zur Visualisierung von einem Enum und nicht vom FluidSolver gehalten werden. Auch die Logik zur Ausführung der entsprechenden Kernels ndet sich in Attributen des Enums. Um Kernels auf dem KernelRunner auszuführen muss der FluidSolver deswegen die Ausführung von externen Kernels unterstützen. Zusätzlich müssen für die Visualisierung Methoden zum Schreiben bzw. Lesen der Arrays in den FluidSolver eingefügt werden, da nur dieser den KernelRunner referenziert und damit indirekten Zugri auf die Speicherbereiche der GPU besitzt. Die Kernel-Klassen enthalten jeweils Referenzen auf alle benötigten Arrays. Diese sind als Klassen- Attribute dargestellt und werden entsprechend auf jeweils identische Speicherbereiche der GPU abgebildet. In einigen Fällen ist eine zusätzliche Vererbungshierarchie einzelner Kernels sinnvoll, da alle Unterklassen die gleichen Methoden oder Attribute verwenden. Teilweise enthält auch die Basisklasse alle Attribute und den Kernel, wobei sich nur der Konstruktor und dessen Argumente der einzelnen Implementierungen unterscheidet. Im Augenblick funktioniert dies nur, soweit alle Attribut der Basisklasse mindestens protected sind. Private Attribute werden von Aparapi nicht in den generierten OpenCL-Quelltext einbezogen, womit der resultierende Quelltext nicht kompilierbar ist.

101 6.3. Abbildung der neuen API auf den Fluid-Simulator 94 kernel.advection AdvectParticlesKernel AdvectVelocitiesSTAMKernel kernel.pressure PressureDivergenceKernel PressureGradientKernel PressureSTAMKernel aparapi KernelRunner Kernel PressureVelocityUpdateKernel SwapPressureKernel kernel.visualization ParticleVerticesKernel VelocityVerticesKernel BaseVisualizationKernel LengthBasedVerticesKernel GradientVerticesKernel VerticesParticleFieldKernel PressureParticleFieldKernel DivergencesParticleFieldKernel kernel AddSourceVelocityKernel DiffusionKernel SwapVelocitiesKernel Abbildung 6.6: Struktur des mit der neuen API implementierten Simulators

102 6.4. Geschwindigkeitsvergleich zum nativen Simulator Geschwindigkeitsvergleich zum nativen Simulator Um die Portierung abschlieÿend bewerten zu können wird ein Benchmark durchgeführt, der die native Implementierung mit der Aparapi Implementierung anhand der Ausführungszeiten vergleicht. Dazu wird jeweils ein Modell mit zwischen 1 und Zellen initialisiert. Anschlieÿend werden Simulationsschritte berechnet. Für jeden Schritt wird dies 50 Mal durchgeführt, wodurch die Standardabweichung im Vergleich zum arithmetischen Mittel bei rund 1% liegt. Evaluationsumgebung Als Evaluationsumgebung wird, wie in den Anforderungen gefordert, ein Windows-Rechner verwendet. Für alle nicht GPU-Berechnungen stehen zwei Intel Xeon Prozessoren mit jeweils 6 Kernen und je 12 Hardware-Threads zur Verfügung. Zusätzlich stehen zur Berechnung 32 GB RAM bereit. Für GPU-Berechnung enthält der Evaluationsrechner 2 NVIDIA Tesla K20c mit je 13 SMs sowie eine Quattro K5000 mit 9 SMs zur Verfügung. Zur Geschwindigkeitsvergleich wird jeweils eine Tesla K20c verwendet. Zeitmessung Um in der nativen Zeitmessung die Ausführungszeit in höchstmöglicher Präzision messen zu können, kommt der sog. QueryPerformanceCounter zum Einsatz [Que13]. Die clock_gettime-funktion kann hier nicht verwendet werden, da diese in Windows nicht zur Verfügung steht. Der QueryPerformanceCounter wird basierend auf einer bestimmten Taktfrequenz pro Zeitschritt inkrementiert. Durch den geg. Umstand, dass die Taktfrequenz sich während der Systemausführung nicht ändert, kann die Ausführungszeit mit höchstmöglicher Genauigkeit bestimmt werden. Für die Java-Zeitmessung kommt wiederum die bereits in der Bibliotheksevaluation verwendete System#nanoTime-Methode zum Einsatz. Ergebnisgraph Es ergibt sich der in Abbildung 6.7 dargestellte Graph. Dieser enthält drei Kurven, wobei die rote Kurve für die native Implementierung des Fraunhofer Instituts, die grüne Kurve für die öentliche vorhandene Aparapi-Bibliothek und die blaue Kurve für die in der Arbeit angepasste Version steht. Alle drei Kurven verlaufen dabei fast parallel. Insgesamt ist der Wert der ursprünglichen Aparapi Version im Gegensatz zur nativen Ausführung rund 4 Mal so hoch, die angepasste Version dagegen nur rund 3 Mal. Der Grund für die schnellere Ausführung der angepassten Version liegt v.a. in der Speicherung des erstmalig berechneten Device#best Wertes, was, wie bereits beschrieben, eine Senkung der Ausführungszeit um rund 30% ausmacht. Im Vergleich zu den bisherigen Evaluationsfällen stellt die Flüssigkeitssimulation ein wesentlich komplexeres Umfeld mit einer Kombination aus sehr unterschiedlichen Anforderungen dar. Zwar spiegelt das Ergebnis die Graphen der Evaluation ungefähr wieder, so ist doch ein wesentlich höherer Overhead vorhanden. Vermutlich lässt sich dieser auf den Zusatzaufwand der JNI- Methodenaufrufe zurückführen, wobei dieser durch die hohen Anzahl an Kernel-Aufrufen ( Aufrufe pro Evaluationsfall) deutlich ins Gewicht fällt.

103 6.4. Geschwindigkeitsvergleich zum nativen Simulator 96 Abbildung 6.7: Vergleich der Ausführungszeiten zwischen nativer und Aparapi Ausführung am Beispiel des Fluid-Simulators

104 Kapitel 7 Fazit Abschlieÿend werden die erreichten Ziele, noch zu implementierende, zukünftige Verbesserungen für die Portierung als auch für Aparapi sowie zukünftige Entwicklungen in Bezug auf die GPU- Programmierung vorgestellt. 7.1 Erreichte Ziele Im Rahmen der Arbeit wurden verschiedene Bibliotheken zur Java-GPU-Abstraktion vorgestellt. Dabei wurde aufgezeigt, dass diese funktionsfähig eingesetzt werden können. Allerdings sind die meisten Bibliotheken von einem produktiven Einsatz noch weit entfernt. Hier machen sich Fehler in Bezug auf die Code-Generierung, und deren Fehleranfälligkeit, sowie der Möglichkeit zur Fehlersuche, deutlich bemerkbar. Gleichzeitig wurde nachgewiesen, dass die Ausführungsgeschwindigkeit, unter Auslassung der Code-Generierung und -Kompilierung, nicht zwangsläug langsamer als eine native Ausführung sein muss. Für Einzelaufgaben waren im Vergleich zur nativen Ausführung verschiedene Bibliotheken fast gleich schnell. Natürlich fällt hier weiterhin ein Overhead an, der u.a. auf die erhöhte Abstraktion zurückzuführen. Der Unterschied ist jedoch meist vergleichsweise gering. Allerdings trit diese Aussage nicht auf alle Bibliotheken zu. Vor dem Einsatz einer der verfügbaren Bibliotheken sollte genau evaluiert werden, wie schnell diese einen konkreten Anwendungsfall abarbeiten kann. Ansonsten kann es, wie für Delite und Rootbeer nachgewiesen, sogar zu, im Vergleich zur seriellen Ausführung, langsameren Ausführungszeiten kommen. Insgesamt steckt die Java-Abstraktion noch in den Kinderschuhen. Delite ist aufgrund der zur Domäne sehr nahen Programmierung der Wunschansatz der abstrakten GPU-Programmierung. Alle anderen Bibliotheken lehnen sich sehr stark an das GPU-Programmiermodell an, womit die Komplexität der Programmierung nicht gekapselt wird und eine bessere Quelltext-Struktur nicht möglich ist. Aparapi wurde in dieser Hinsicht in der Arbeit um mehrfache Einsprungpunkte erweitert, womit der resultierende Java-Quelltext wesentlich besser strukturiert werden kann. Hier besteht auch in Zukunft noch weiteres Potential, insbesondere auch in Bezug auf die in dieser Arbeit angesprochene Implementierung der Verwendung von Java-Objekten auf der GPU.

105 7.2. Ansatzpunkte für zukünftige Verbesserungen Ansatzpunkte für zukünftige Verbesserungen Aparapi ist, wie bereits beschrieben, die Bibliothek, die am ehesten als produktiv einsetzbar zu bezeichnen ist. Unter Verwendung der Bibliothek wurde nachgewiesen, dass auch ein komplexer Anwendungsfall, wie die Flüssigkeitssimulation, abstrakt beschrieben werden kann und gleichzeitig anschlieÿend besser les-, wart- und testbar ist. Trotz des Leistungsabfalls auf rund 1/3 der Ausführungsgeschwindigkeit ist der Anwendungsfall Fluid-Simulation noch in Echtzeit berechenbar. Erst bei groÿen Grid-Gröÿen macht sich der Overhead bemerkbar. Innerhalb der Bibliothek muss zukünftig stark an der Testabdeckung gearbeitet werden. Im Build- Skript der Bibliothek ist keine automatische Testausführung vorhanden, womit die wenigen, im Augenblick vorhandenen Tests, unbemerkt fehlschlagen können. Für die native Implementierung sind überhaupt keine Tests vorhanden, womit eine Erweiterung dieses Teils sehr schwierig ist, da jederzeit ein Feature beschädigt werden kann. Vorbild sollte hier die Bibliothek Rootbeer mit starkem Fokus auf Unit-Tests sein. Auf Grund der OpenCL-Abhängigkeit dürften Tests, die nur für die native Umgebung konzipiert sind, schwierig werden. Zur Sicherstellung des Funktionierens der nativen Features wären allerdings bereits automatisch ausgeführte Beispiele, z.b. die Fourier-Transformation oder die Matrix-Multiplikation, ausreichend. Als Integrationstest konzipiert würden diese zumindest teilweise einzelne Unit-Tests ersetzen. Gleichzeitig wäre eine direkte Einbindung von OpenGL wünschenswert. Aufgrund der Trennung der Speicherbereiche von Aparapi und OpenGL müssen die Daten jeweils als Umweg zurück auf den Host kopiert werden, um anschlieÿend wieder zur Darstellung zurück auf das Device verschoben zu werden. Eine direkte Integration würde die Laufzeit wesentlich verkürzen und die darstellbare Frame-Rate deutlich erhöhen. Gleichzeitig steckt hinter diesem Feature durchaus Aufwand, da hierzu die OpenGL-Java-Bibliothek, also z.b. JOGL, ebenfalls angepasst werden muss. Bereits angesprochen wurde die wünschenswerte Erweiterung von Aparapi für Objektorientierung. Der Aufwand steckt hier vor allem in der Evaluation verschiedener Bytecode Bibliotheken sowie in der Portierung der bisherigen Funktionalität zum Einlesen des Bytecodes auf die neue Bibliothek. Die Erstellung bzw. Ausführung eines Benchmarks ist hier zwangsläug nötig, da die neue Bytecode-Bibliothek die Ausführungszeit nicht steigern sollte. Basierend darauf kann das Inlining verhältnismäÿig einfach eingebaut werden. Als Gratis-Eekt dabei ergibt sich ein gröÿeres Refactoring des Aparapi Quelltextes, woraus sich eine erheblich klarere Struktur ergeben dürfte. Darüber hinaus besteht auch Potenzial im Einbau von optimierten Bibliotheken, wie z.b. cublas für CUDA bzw. clmath für OpenCL. Der native Simulator verwendet diese Bibliothek z.b. zur Berechnung der Matrix-Multiplikationen beim Verfahrens der konjugierten Gradienten. Hier ist eine deutliche Geschwindigkeitssteigerung möglich. Zwar bietet Aparapi die Möglichkeit der Einbindung von nativen Kernels, so bezieht sich dies nur auf reine OpenCL-Dateien. Die Möglichkeit zur Einbindung externer Bibliotheken ist nicht gegeben. Genauso fehlt eine mögliche Abstraktion, um die Bibliotheken elegant in eigenen Kernels nutzen zu können. Schlieÿlich wäre eine Optimierung der JTP-Ausführung sinnvoll. Interessant wäre dabei, ob der Overhead eingeschränkt werden kann, sodass auch diese Art der Ausführung konkurrenzfähig wird. Zum Testen ist der Ausführungsmodus zwar ausreichend, zum produktiven Einsatz je-

106 7.3. Ausblick zur GPU-Entwicklung 99 doch viel zu langsam. Ein erster Schritt wäre hier ein ausführliches Proling, um für bestimmte Anwendungsfälle, wie z.b. dem beschriebenen Jakobi-Verfahren, die Ursache für den Overhead zu ermitteln. In diesem Testfall ist Aparapi deutlich langsamer als die Host-Ausführungen von Delite und Rootbeer. Ein weiterer Ansatzpunkt ist die Prüfung, warum der AMD-Proler für Aparapi nicht funktioniert. Proling ist ein sehr sinnvolles Feature, um die Bibliothek weiter zu optimieren bzw. den generierten Quelltext anhand der resultierenden Ausführungsgeschwindigkeit zu testen. Ohne Proling ist nur eine Sicht- bzw. Funktionalitätsprüfung möglich. Mit Proling kann explizit angegeben werden, wo eventuell ein Flaschenhals vorhanden ist. Auch für den portierten Simulator wäre eine erhöhte Testabdeckung wünschenswert. Zwar wurde eine automatische Testausführung durch Verwendung von Gradle eingebaut und auch rudimentär erste Tests für die Portierung erstellt, so reichen diese bei weitem nicht für einen produktiven Einsatz aus. Dies liegt v.a. daran, dass der Fokus dieser Arbeit auf dem Aufzeigen der Möglichkeit der Erstellung von Tests und eben nicht auf einer vollständigen Testabdeckung liegt. 7.3 Ausblick zur GPU-Entwicklung Die bereits in der Einführung genannten Gründe für eine Steigerung der Anforderungen an verfügbare Rechenleistung wird auch in Zukunft die Verwendung von GPUs weiter befeuern. Dies führt auch dazu, dass die Grakkarten immer schneller an die wachsenden Erwartungen angepasst werden. Ein Beispiel hierfür ist die von AMD vorgestellte Heterogeneous Uniform Memory Access (huma) Speicherarchitektur, die es der GPU ermöglicht, direkt, ohne weitere Kopiervorgänge, auf den Speicher der GPU zuzugreifen. Auf diese Weise wird die gröÿte Einschränkung der Programmierung von GPUs umgangen und önet weitere Einsatzgebiete der Verwendung von Grakkarten [hum13][s. 9]. Interessanterweise hat NVIDIA bisher keine Antwort auf die von AMD dadurch gestellte Herausforderung veröentlicht. Durch die mit huma in Kombination mit HSAIL einhergehende Vereinfachung der GPU-Programmierung ist auch ein Schub in Richtung GPU-Abstraktion für weitere Sprachen auÿerhalb von OpenCL und CUDA-C zu erwarten. Die Unterstützung für HSAIL in Aparapi ist bereits ein deutlicher Hinweis hierauf. Die Entwickler bei AMD verwenden dabei bereits Geräte mit huma-unterstützung [apa13a][issue 128]. Gleichzeitig wird auch mit dem Projekt Sumatra die Java-GPU-Abstraktion in eine breite Öffentlichkeit getragen. Soweit die Implementierung fertiggestellt ist wird die Verwendung von GPUs durch parallele Listen o.ä. auch Einzug in normale Applikation nden, in denen bisher der Overhead der Verwendung von Grakkarten zu groÿ war. Sumatra wird allerdings wohl nie Bibliotheken wie Aparapi oder Rootbeer komplett ablösen können. Sumatra konzentriert sich augenscheinlich mehr auf die Parallelisierung von Operationen auf Listen als um die Abbildung komplexer GPU-Spezika wie spezielle Speicherbereiche oder Thread- und Blöckgröÿen. Diese sind jedoch für eine performante Ausführung auf der Grakkarte erforderlich. Dementsprechend wird Aparapi höchstens als unterliegende Bibliothek von Sumatra zum Einsatz kommen, wobei Sumatra nie als vollwertiger Ersatz bestehen kann. Gerade die

107 7.3. Ausblick zur GPU-Entwicklung 100 Möglichkeit durch Callbacks den erzeugten Quelltext zu optimieren deutet eventuell auch auf die Möglichkeit einer domänenspezischen GPU-Abstraktion an, wie sie ebenfalls bei Delite zum Einsatz kommt.

108 Anhang A Java-Native-Interface (JNI) Viele der beschriebenen Abstraktions-Bibliotheken verwenden das JNI zum Zugri auf die Gra- kkarte durch die nativen CUDA- bzw. OpenCL-Bibliotheken. Wie in Grak A.1 abgebildet ist das JNI Teil der virtuellen Maschine und ermöglicht den direkten Zugri auf native Systembibliotheken bzw. selbst geschriebenen C oder C++ Quelltext. Gleichzeitig ist aber auch der umgekehrte Weg, also der Aufruf von Java-Funktionen aus nativen Funktionen heraus über ein Invocation-Interface möglich [Lia99][S. 5]. Zugri auf native Funktionen Um auf native Funktionen zugreifen zu können müssen mehrere Schritte befolgt werden: Die Funktion wird zuerst mit dem Schlüsselwort native markiert. Mit javah -jni wird zu der zugehörigen Klasse eine C-Header-Datei generiert. Diese kann in einer zugehörigen C-Datei implementiert und zu einer Bibliothek kompiliert werden (*.so in Linux, *.dll in Windows). Anschlieÿend kann die Bibliothek mit System#loadLibrary() geladen werden [Lia99][S. 11,13]. Java sucht nach der entsprechenden Bibliothek in der java.library.path System-Property. Diese enthält standardmäÿig auch den Inhalt der LD_LIBRARY_PATH (Linux) bzw. PATH Variable (Windows) [Lia99][S. 17]. JNI-Referenztypen JNI unterscheidet verschiedene Referenztypen zum Zugri auf Java-Objekte: Lokale Referenzen Diese Art der Referenz ist nur innerhalb eines JNI-Aufrufs gültig. Anschlieÿend wird sie automatisch freigegeben. Entsprechend sollte die Referenz nicht innerhalb der nativen Umgebung vorgehalten werden. Wird als Parameter einer nativen Funktion Java Applikation Java virtuelle Maschine JNI Native Applikation/ Bibliothek Systemumgebung Abbildung A.1: Grundsätzliche JNI-Struktur [Lia99][S. 5]

109 102 ein Java-Objekttyp speziziert, so wird immer eine lokale Referenz übergeben [Lia99][S. 156]. Globale Referenzen Globale Referenzen müssen dagegen explizit vom Programmierer erstellt und anschlieÿend auch wieder freigegeben werden. Die JVM kümmert sich also nicht um die entsprechende Verwaltung [Lia99][S. 156]. Schwache globale Referenzen Diese letzte Referenz-Typ entspricht der globalen Referenz, bis auf dem Umstand, dass das unterliegende Objekt durch den Garbage Collector aufgeräumt werden darf. Der Wert der Referenz entspricht in diesem Fall NULL. In Aparapi werden globale Referenzen auf die nötigen Kernel-Argumente vorgehalten, sodass diese auch über Kernel-Aufrufe hinweg gültig sind.

110 Anhang B Feature-Vergleich der Bibliotheken JCuda Java-GPU Rootbeer Aparapi Delite Sprache Java Java Java Java Scala Plattform-Unterstützung Linux ja ja ja ja ja Windows ja nein ja (mit Fehlern) ja nein *3) Parallelisierung explizit (manuelle Kernels) ja nein ja ja nein implizit (aut. Erkennung) nein ja nein nein ja Automatische Code-Umwandlung nein ja ja ja ja Staging erforderlich nein ja ja nein ja Speicherverwaltung Aut. Allokation und Freigabe nein ja ja ja ja Mehrere Kernel Aufrufe ohne ern. Kopieren ja nein nein ja ja Entwicklung Dokumentation Beispiele Lernkurve + *4) + ++ Entwicklungs-Geschwindigkeit + *4) Testbarkeit Entwicklungsumgebung Zukünftige Weiterentwicklung ja nein?? ja ja Aufruf von Java-Projektmethoden ja ja ja ja nein

111 Native Implementierung der Kernels möglich ja nein nein ja nein In bestehendes Projekt einbaubar ja ja ja ja nein Fehlersuche Proler CUDA ja *4) ja nein ja Komplexität der Bibliothek niedrig mittel hoch mittel sehr hoch Dokumentation der Bibliothek - JavaDoc Architektur-Dokumentation Code-Leserlichkeit Aktive Community Leserlichkeit des generierten Codes *1) *2) *1) ++ Verständlichkeit der Fehlermeldungen ++ *2) - ++ Ausführungs-Modi GPU - OpenCL nein nein nein ja ja - CUDA ja ja ja nein nein *3) Java-Thread-Pools nein nein ja ja ja Unterstützung von... OpenGL ja nein nein nein nein Primitive Datentypen ja ja ja ja ja Eindimensionale Arrays ja ja ja ja ja Mehrdimensionale Arrays nein ja ja nein *5) ja Array-Length Attribut nein ja ja ja *3) ja Objekte in äuÿeren Klassen nein *4) ja nein ja Objekte auf der GPU nein ja ja ja ja Strings auf der GPU nein nein ja nein ja Exceptions auf der GPU nein nein ja nein / indirekt nein Mehrdimensionale Grid-Gröÿen ja ja (implizit) nein ja *1) API zur Geschwindigkeits-Messung ja nein ja (nur GPU) ja ja BLAS-Operationen ja (externe Bib.) nein nein nein ja 104

112 Shared-Memory auf der GPU ja nein ja ja *1) Shared-Memory Unterstützung in Java *1) *1) ja ja ja Explizite Spezikation der Grid-Gröÿen ja nein ja ja nein Mehrere Grakkarten / -kerne ja nein nein ja ja Geschwindigkeit ++ *1 ++ Gesamteindruck Legende *1) trit nicht zu *2) Bibliothek funktioniert zur Zeit nicht *3) Zur Zeit nicht lauähig, oziell unterstützt *4) unbekannt *5) mittlerweile implementiert 105

113 Anhang C Proling im JNI- und GPU-Umfeld Proling stellt das Mittel der Wahl dar, um in Anwendungen Flaschenhälse in Bezug auf sehr unterschiedliche Themen wie Ausführungszeiten von Einzelmethoden, Speicherverbrauch, Festplattenzugri oder Speicher-Transferzeiten und -durchsatz zu nden [Shi03][S. 3]. Entsprechende Werkzeuge variieren auf den unterschiedlichen Plattformen, genau wie die zu betrachtenden limitierenden Faktoren der Ausführungsgeschwindigkeit. Deswegen ist das nachfolgende Kapitel in Abschnitte für die Java, die CUDA und die C++-Ausführung aufgeteilt. Flaschenhälse entstehen dabei durch noch bestehende Applikationsfehler, falsche Datenstrukturen oder durch fehlendes Verständnis von Operationen, wie z.b. Netzwerkzugrie oder Hardware- Operationen. Durch einen Proler lassen sich diese Flaschenhälse in übersichtlicher Form darstellen und verhältnismäÿig schnell beheben. C.1 Proling in Java Die JVM stellt zur Geschwindigkeitsmessung bzw. zum Aunden von Flaschenhälsen mit HPROF ein Werkzeug zur Verfügung [hpr13]. Dieses ermöglicht es den Speicher und die Geschwindigkeit von Methoden zu überwachen. Im Anwendungsfall der Optimierung von Aparapi bzw. der Fluid-Simulation ist insbesondere die Ausführungsgeschwindigkeit der entscheidende Faktor, da Aparapi in Bezug auf verfügbaren Speicher im Normalfall nicht limitiert ist. Dagegen bestimmt die CPU-Geschwindigkeit wesentlich das Ergebnis. Bei der Ausführung muss zur Erstellung des Geschwindigkeitsprols die Kommandozeilenoption -J-agentlib:hprof=cpu=samples übergeben werden. Die nachfolgend generierte Datei enthält anschlieÿend u.a. die Ausführungszeit jeder ausgeführten Methode in Prozent, die Anzahl der Methodenaufrufe sowie den Methodennamen selbst. Intern wird das Prol durch regelmäÿigen Abruf des Stack-Zustandes der ausführenden Threads erreicht. Die Traces werden analysiert, aufgespalten und die eingetragenen Methoden zur Erstellung des Prols verwendet. Da hierfür ein kohärenter Stack-Zustand Voraussetzung ist, muss der Zugri synchronisiert erfolgen und u.u. sogar der Thread in seiner Ausführung angehalten werden. Dies führt dazu, dass die Ausführungszeiten innerhalb eines Prolers wesentlich von der direkten Ausführung ohne Proler abweichen [Shi03][S. 28]. Diese Methode des Proling wird auch Sampling genannt. Ein anderer Ansatz ist die sog. Instrumentierung. Statt in regelmäÿigen Intervallen den Stack zu analysieren wird vor der Ausführung Bytecode eingefügt, der zur Messung der genauen

114 C.2. Proling von JNI-C++-Quelltext 107 Ausführungszeiten bzw. Methodenaufrufe dient. Im Fall des Sampling ist die Anzahl der Methodenaufrufe sowie deren Ausführungszeiten nur eine Schätzung, da nur in Intervallen die Thread-Zustände ausgelesen werden. Bei der Instrumentierung wird der tatsächliche, genaue Wert gemessen, wobei die Ausführungszeit im Gegensatz zum Sampling deutlich sinkt [hpr13]. Zur Aundung der Device#best-Flaschenhalses war auf Grund des hohen Anteils der #best- Methode an der Ausführungszeit Sampling ausreichend. Um die Kommandozeilenoptionen nicht händisch verwenden zu müssen und zusätzlich eine graphische Auswertung verwenden zu können, können zusätzlich graphische Werkzeuge, wie das in dieser Arbeit verwendetet Tool VisualVM, genutzt werden. C.2 Proling von JNI-C++-Quelltext Da die Erweiterung des nativen Anteils von Aparapi auf Grund der besseren Unterstützung von C++ in Visual Studio stattfand, wurde auch für das Proling die gleiche IDE verwendet. Im Gegensatz zu Java besitzt Visual Studio hierfür keine eingebaute Funktionalität. Stattdessen kann eine Erweiterung, die sog. Visual Studio Proling Tools, nachinstalliert werden, die den gewünschten Funktionsumfang enthält. Proling unterliegt in Windows einer strikten Gruppenrichtlinie. Bevor die Aktion entsprechend durchgeführt werden kann muss der Proling-Treiber, vsperfcmd, von einem Administrator gestartet werden. Zu beachten sind dabei u.u. nötige Parameter, die Entwicklern die Verwendung des Treibers ermöglichen. Ist der Treiber geladen, so kann in Visual Studio ein Proling-Lauf gestartet werden. Die Werte, die gemessen werden können, sind äquivalent zu den HPROF-Optionen. Entsprechend können der Speicherverbrauch sowie die Ausführungszeiten von Funktionen mit Hilfe von Sampling und Instrumentierung gemessen werden. Eine zusätzliche Option, Concurrency, bezieht sich auf die Messung von Wartezeiten von Threads [vis13]. Zur Auswertung steht wiederum eine graphische Oberäche zur Verfügung, deren Werkzeuge den selben Umfang wie die Oberäche von VisualVM haben. Eine zusätzliche, sehr nützliche, Option bezieht sich auf die Ausblendung von Quelltext auÿerhalb des eigenen Projektes. Zum Proling muss die Applikation gestartet werden. Allerdings ndet im Fall von Aparapi kein Start einer C++-Methode statt, da der native Anteil nur von Java aus aufgerufen wird. Abhilfe schat hier das Feature, den Proler an einen bestehenden Prozess anhängen zu können. Dementsprechend kann die Applikation zuerst im Debugging-Modus in Java gestartet werden und vor dem Aufruf der zu messenden JNI-Methode ein Breakpoint gesetzt werden. Wird dieser erreicht, so ist auch der Prozess aundbar und kann genutzt werden, um innerhalb von Visual Studio den Proler an den Java-Prozess anzuhängen. Anschlieÿend wird die Ausführung in Java fortgesetzt, der C++-Code ausgeführt und die Statistik entsprechend erstellt. Entsprechend funktioniert auch das Debuggen von JNI-Anwendungen in Visual Studio. Auch hier wird zuerst der Debugger in Java gestartet, auf einen Breakpoint gewartet, der Debugger in Visual Studio an den Java-Prozess gehängt und die Ausführung in Java fortgesetzt. Visual Studio erkennt das Erreichen einer Breakpoints im C++-Code und stoppt die Ausführung an

115 C.3. Proling in CUDA 108 der entsprechend richtigen Stelle. Allerdings ist darauf hinzuweisen, dass dieser Ansatz nur unter Verwendung einer in Visual Studio generierten Bibliothek mit Debug-Symbolen funktioniert. Ansonsten wird der native Code als nicht äquivalent zum in Visual Studio geladenen Quelltext erkannt und der Debug-Modus ignoriert. C.3 Proling in CUDA Im Gegensatz zum Proling in Java und C++ interessieren bei der Bewertung der GPU-Ausführung mehrere Kriterien: Natürlich ist weiterhin die Ausführungszeit ein wichtiges Kriterium. Allerdings ist hier nicht unbedingt die Bewertung auf Basis einzelner Funktionen interessant, sondern die Ausführungszeit einzelner Kernels sowie deren Konguration, d.h. die Anzahl an Blöcken und deren Gröÿe. Abgesehen davon ist der Speicher ein wichtiges Kriterium. Relevant für die Geschwindigkeit ist die Menge an zu kopierenden Daten sowie der entsprechende Durchsatz. Dieser hängt u.a. auch von der Art des Quellspeichers ab. Im Abschnitt wird genauer erklärt, warum die native Simulation z.b. Page-Locked-Memory nutzen sollte und nicht normalen Speicher, der u.a. auf die Festplatte ausgelagert werden kann. NVIDIA stellt für CUDA mit dem auf dem Kommandozeilenprogramm nvprof basierenden Visual Proler das nötige Werkzeug zur Messung der oben genannten Werte zur Verfügung. Eine mögliche Anzeige ist mit dem Verfahren der konjugierten Gradienten in Abbildung C.1 dargestellt. In der Abbildung ist der Kopiervorgang von der CPU auf die GPU markiert. Rechts wird zusätzlich der Kopierdurchsatz angezeigt, in diesem Fall 5,7 GB/s. Die zugehörige Zeit ist in der Detail-Anzeige im unteren Teil der Abbildung dargestellt. Da für das Verfahren mehrere Kernels ausgeführt werden, existiert für jeden Kernel eine eigene Spur, in der die einzelnen Kernel- Aufrufe abgebildet sind. Zusätzlich wird angezeigt, wie viel Prozent ein Kernel zur gesamten Ausführungszeit beiträgt. Allein mit diesem Mittel können bereits verschiedene Speicherarten verglichen bzw. unnötige Kernel-Aufrufe, wie sie z.b. in Rootbeer bestehen, erkannt werden. Um auch weitere Flaschenhälse zu nden analysiert der Proler das Verhalten des Programms und zeigt evtl. vorhandene Probleme an. Dies betrit z.b. divergierende Ausführungspfade unterschiedlicher Threads, langsamen Kopierdurchsatz, fehlende Überlappung von Kopiervorgängen und Ausführung oder Speicherbereiche, deren Kopiervorgänge den Durchsatz aufgrund ihrer kleinen Gröÿe signikant verlangsamen. Der Visual Proler kann ebenfalls zur Analyse der Java-Abstraktionsbibliotheken verwendet werden. Dazu wird das Programm java mit den entsprechenden Kommandozeilenargumenten zum Start der Java-Anwendung konguriert und ausgeführt. Der Zugri auf die GPU wird dabei erkannt und gemessen. AMD stellt mit dem App Proler für OpenCL ein ähnliches Programm zur Verfügung [amd13]. Dies ist insbesondere wichtig, da der Visual Proler von NVIDIA keine OpenCL-Anwendungen analysieren kann. Eine visuelle Darstellung ist hier allerdings nur mit dem mitgelieferten Visual Studio Plug-In für Windows möglich. Unter Linux bleibt nur die direkte Verwendung der Kommandozeilenoberäche. Für Aparapi konnten beide Varianten weder unter Linux noch unter

116 C.3. Proling in CUDA 109 Windows produktiv zum Einsatz gebracht werden. Wird, genau wie bei NVIDIA, java als Ausführungsprogramm speziziert und entsprechende Argumente angegeben, so passiert schlicht nichts. Die einzige Ausgabe beschreibt, dass der Proler initialisiert wurde. Zusätzlich steigt die CPU-Auslastung auf 100%, wobei sich das Programm anschlieÿend nicht mehr beendet. Abbildung C.1: Anzeige des NVIDIA Visual Proler für die native Ausführung des Verfahrens der konjugierten Gradienten

Binäre Bäume. 1. Allgemeines. 2. Funktionsweise. 2.1 Eintragen

Binäre Bäume. 1. Allgemeines. 2. Funktionsweise. 2.1 Eintragen Binäre Bäume 1. Allgemeines Binäre Bäume werden grundsätzlich verwendet, um Zahlen der Größe nach, oder Wörter dem Alphabet nach zu sortieren. Dem einfacheren Verständnis zu Liebe werde ich mich hier besonders

Mehr

Fachbericht zum Thema: Anforderungen an ein Datenbanksystem

Fachbericht zum Thema: Anforderungen an ein Datenbanksystem Fachbericht zum Thema: Anforderungen an ein Datenbanksystem von André Franken 1 Inhaltsverzeichnis 1 Inhaltsverzeichnis 1 2 Einführung 2 2.1 Gründe für den Einsatz von DB-Systemen 2 2.2 Definition: Datenbank

Mehr

CUDA. Moritz Wild, Jan-Hugo Lupp. Seminar Multi-Core Architectures and Programming. Friedrich-Alexander-Universität Erlangen-Nürnberg

CUDA. Moritz Wild, Jan-Hugo Lupp. Seminar Multi-Core Architectures and Programming. Friedrich-Alexander-Universität Erlangen-Nürnberg CUDA Seminar Multi-Core Architectures and Programming 1 Übersicht Einleitung Architektur Programmierung 2 Einleitung Computations on GPU 2003 Probleme Hohe Kenntnisse der Grafikprogrammierung nötig Unterschiedliche

Mehr

Softwaretests in Visual Studio 2010 Ultimate Vergleich mit Java-Testwerkzeugen. Alexander Schunk Marcel Teuber Henry Trobisch

Softwaretests in Visual Studio 2010 Ultimate Vergleich mit Java-Testwerkzeugen. Alexander Schunk Marcel Teuber Henry Trobisch Softwaretests in Visual Studio 2010 Ultimate Vergleich mit Java-Testwerkzeugen Alexander Schunk Henry Trobisch Inhalt 1. Vergleich der Unit-Tests... 2 2. Vergleich der Codeabdeckungs-Tests... 2 3. Vergleich

Mehr

Lineargleichungssysteme: Additions-/ Subtraktionsverfahren

Lineargleichungssysteme: Additions-/ Subtraktionsverfahren Lineargleichungssysteme: Additions-/ Subtraktionsverfahren W. Kippels 22. Februar 2014 Inhaltsverzeichnis 1 Einleitung 2 2 Lineargleichungssysteme zweiten Grades 2 3 Lineargleichungssysteme höheren als

Mehr

Datensicherung. Beschreibung der Datensicherung

Datensicherung. Beschreibung der Datensicherung Datensicherung Mit dem Datensicherungsprogramm können Sie Ihre persönlichen Daten problemlos Sichern. Es ist möglich eine komplette Datensicherung durchzuführen, aber auch nur die neuen und geänderten

Mehr

4D Server v12 64-bit Version BETA VERSION

4D Server v12 64-bit Version BETA VERSION 4D Server v12 64-bit Version BETA VERSION 4D Server v12 unterstützt jetzt das Windows 64-bit Betriebssystem. Hauptvorteil der 64-bit Technologie ist die rundum verbesserte Performance der Anwendungen und

Mehr

etutor Benutzerhandbuch XQuery Benutzerhandbuch Georg Nitsche

etutor Benutzerhandbuch XQuery Benutzerhandbuch Georg Nitsche etutor Benutzerhandbuch Benutzerhandbuch XQuery Georg Nitsche Version 1.0 Stand März 2006 Versionsverlauf: Version Autor Datum Änderungen 1.0 gn 06.03.2006 Fertigstellung der ersten Version Inhaltsverzeichnis:

Mehr

EasyWk DAS Schwimmwettkampfprogramm

EasyWk DAS Schwimmwettkampfprogramm EasyWk DAS Schwimmwettkampfprogramm Arbeiten mit OMEGA ARES 21 EasyWk - DAS Schwimmwettkampfprogramm 1 Einleitung Diese Präsentation dient zur Darstellung der Zusammenarbeit zwischen EasyWk und der Zeitmessanlage

Mehr

Erweiterung der Aufgabe. Die Notenberechnung soll nicht nur für einen Schüler, sondern für bis zu 35 Schüler gehen:

Erweiterung der Aufgabe. Die Notenberechnung soll nicht nur für einen Schüler, sondern für bis zu 35 Schüler gehen: VBA Programmierung mit Excel Schleifen 1/6 Erweiterung der Aufgabe Die Notenberechnung soll nicht nur für einen Schüler, sondern für bis zu 35 Schüler gehen: Es müssen also 11 (B L) x 35 = 385 Zellen berücksichtigt

Mehr

Das große ElterngeldPlus 1x1. Alles über das ElterngeldPlus. Wer kann ElterngeldPlus beantragen? ElterngeldPlus verstehen ein paar einleitende Fakten

Das große ElterngeldPlus 1x1. Alles über das ElterngeldPlus. Wer kann ElterngeldPlus beantragen? ElterngeldPlus verstehen ein paar einleitende Fakten Das große x -4 Alles über das Wer kann beantragen? Generell kann jeder beantragen! Eltern (Mütter UND Väter), die schon während ihrer Elternzeit wieder in Teilzeit arbeiten möchten. Eltern, die während

Mehr

Systeme 1. Kapitel 6. Nebenläufigkeit und wechselseitiger Ausschluss

Systeme 1. Kapitel 6. Nebenläufigkeit und wechselseitiger Ausschluss Systeme 1 Kapitel 6 Nebenläufigkeit und wechselseitiger Ausschluss Threads Die Adressräume verschiedener Prozesse sind getrennt und geschützt gegen den Zugriff anderer Prozesse. Threads sind leichtgewichtige

Mehr

Kurzeinführung LABTALK

Kurzeinführung LABTALK Kurzeinführung LABTALK Mit der Interpreter-Sprache LabTalk, die von ORIGIN zur Verfügung gestellt wird, können bequem Datenmanipulationen sowie Zugriffe direkt auf das Programm (Veränderungen der Oberfläche,

Mehr

mobilepoi 0.91 Demo Version Anleitung Das Software Studio Christian Efinger Erstellt am 21. Oktober 2005

mobilepoi 0.91 Demo Version Anleitung Das Software Studio Christian Efinger Erstellt am 21. Oktober 2005 Das Software Studio Christian Efinger mobilepoi 0.91 Demo Version Anleitung Erstellt am 21. Oktober 2005 Kontakt: Das Software Studio Christian Efinger ce@efinger-online.de Inhalt 1. Einführung... 3 2.

Mehr

Übung: Verwendung von Java-Threads

Übung: Verwendung von Java-Threads Übung: Verwendung von Java-Threads Ziel der Übung: Diese Übung dient dazu, den Umgang mit Threads in der Programmiersprache Java kennenzulernen. Ein einfaches Java-Programm, das Threads nutzt, soll zum

Mehr

Handbuch. NAFI Online-Spezial. Kunden- / Datenverwaltung. 1. Auflage. (Stand: 24.09.2014)

Handbuch. NAFI Online-Spezial. Kunden- / Datenverwaltung. 1. Auflage. (Stand: 24.09.2014) Handbuch NAFI Online-Spezial 1. Auflage (Stand: 24.09.2014) Copyright 2016 by NAFI GmbH Unerlaubte Vervielfältigungen sind untersagt! Inhaltsangabe Einleitung... 3 Kundenauswahl... 3 Kunde hinzufügen...

Mehr

Virtueller Seminarordner Anleitung für die Dozentinnen und Dozenten

Virtueller Seminarordner Anleitung für die Dozentinnen und Dozenten Virtueller Seminarordner Anleitung für die Dozentinnen und Dozenten In dem Virtuellen Seminarordner werden für die Teilnehmerinnen und Teilnehmer des Seminars alle für das Seminar wichtigen Informationen,

Mehr

OECD Programme for International Student Assessment PISA 2000. Lösungen der Beispielaufgaben aus dem Mathematiktest. Deutschland

OECD Programme for International Student Assessment PISA 2000. Lösungen der Beispielaufgaben aus dem Mathematiktest. Deutschland OECD Programme for International Student Assessment Deutschland PISA 2000 Lösungen der Beispielaufgaben aus dem Mathematiktest Beispielaufgaben PISA-Hauptstudie 2000 Seite 3 UNIT ÄPFEL Beispielaufgaben

Mehr

Übersicht. Nebenläufige Programmierung. Praxis und Semantik. Einleitung. Sequentielle und nebenläufige Programmierung. Warum ist. interessant?

Übersicht. Nebenläufige Programmierung. Praxis und Semantik. Einleitung. Sequentielle und nebenläufige Programmierung. Warum ist. interessant? Übersicht Aktuelle Themen zu Informatik der Systeme: Nebenläufige Programmierung: Praxis und Semantik Einleitung 1 2 der nebenläufigen Programmierung WS 2011/12 Stand der Folien: 18. Oktober 2011 1 TIDS

Mehr

Objektorientierte Programmierung für Anfänger am Beispiel PHP

Objektorientierte Programmierung für Anfänger am Beispiel PHP Objektorientierte Programmierung für Anfänger am Beispiel PHP Johannes Mittendorfer http://jmittendorfer.hostingsociety.com 19. August 2012 Abstract Dieses Dokument soll die Vorteile der objektorientierten

Mehr

Suche schlecht beschriftete Bilder mit Eigenen Abfragen

Suche schlecht beschriftete Bilder mit Eigenen Abfragen Suche schlecht beschriftete Bilder mit Eigenen Abfragen Ist die Bilderdatenbank über einen längeren Zeitraum in Benutzung, so steigt die Wahrscheinlichkeit für schlecht beschriftete Bilder 1. Insbesondere

Mehr

Prinzipien Objektorientierter Programmierung

Prinzipien Objektorientierter Programmierung Prinzipien Objektorientierter Programmierung Valerian Wintner Inhaltsverzeichnis 1 Vorwort 1 2 Kapselung 1 3 Polymorphie 2 3.1 Dynamische Polymorphie...................... 2 3.2 Statische Polymorphie........................

Mehr

1 Einleitung. 1.1 Caching von Webanwendungen. 1.1.1 Clientseites Caching

1 Einleitung. 1.1 Caching von Webanwendungen. 1.1.1 Clientseites Caching 1.1 Caching von Webanwendungen In den vergangenen Jahren hat sich das Webumfeld sehr verändert. Nicht nur eine zunehmend größere Zahl an Benutzern sondern auch die Anforderungen in Bezug auf dynamischere

Mehr

The ToolChain.com. Grafisches Debugging mit der QtCreator Entwicklungsumgebung

The ToolChain.com. Grafisches Debugging mit der QtCreator Entwicklungsumgebung The ToolChain Grafisches Debugging mit der QtCreator Entwicklungsumgebung geschrieben von Gregor Rebel 2014-2015 Hintergrund Neben dem textuellen Debuggen in der Textkonsole bieten moderene Entwicklungsumgebungen

Mehr

Handbuch Fischertechnik-Einzelteiltabelle V3.7.3

Handbuch Fischertechnik-Einzelteiltabelle V3.7.3 Handbuch Fischertechnik-Einzelteiltabelle V3.7.3 von Markus Mack Stand: Samstag, 17. April 2004 Inhaltsverzeichnis 1. Systemvorraussetzungen...3 2. Installation und Start...3 3. Anpassen der Tabelle...3

Mehr

C++11 C++14 Kapitel Doppelseite Übungen Musterlösungen Anhang

C++11 C++14 Kapitel Doppelseite Übungen Musterlösungen Anhang Einleitung Dieses Buch wendet sich an jeden Leser, der die Programmiersprache C++ neu lernen oder vertiefen möchte, egal ob Anfänger oder fortgeschrittener C++-Programmierer. C++ ist eine weitgehend plattformunabhängige

Mehr

Zeichen bei Zahlen entschlüsseln

Zeichen bei Zahlen entschlüsseln Zeichen bei Zahlen entschlüsseln In diesem Kapitel... Verwendung des Zahlenstrahls Absolut richtige Bestimmung von absoluten Werten Operationen bei Zahlen mit Vorzeichen: Addieren, Subtrahieren, Multiplizieren

Mehr

Übung 9 - Lösungsvorschlag

Übung 9 - Lösungsvorschlag Universität Innsbruck - Institut für Informatik Datenbanken und Informationssysteme Prof. Günther Specht, Eva Zangerle Besprechung: 15.12.2008 Einführung in die Informatik Übung 9 - Lösungsvorschlag Aufgabe

Mehr

Programmierbeispiele und Implementierung. Name: Michel Steuwer E-Mail: michel.steuwer@wwu.de

Programmierbeispiele und Implementierung. Name: Michel Steuwer E-Mail: michel.steuwer@wwu.de > Programmierbeispiele und Implementierung Name: Michel Steuwer E-Mail: michel.steuwer@wwu.de 2 > Übersicht > Matrix Vektor Multiplikation > Mandelbrotmenge / Apfelmännchen berechnen > Kantendetektion

Mehr

Agile Vorgehensmodelle in der Softwareentwicklung: Scrum

Agile Vorgehensmodelle in der Softwareentwicklung: Scrum C A R L V O N O S S I E T Z K Y Agile Vorgehensmodelle in der Softwareentwicklung: Scrum Johannes Diemke Vortrag im Rahmen der Projektgruppe Oldenburger Robot Soccer Team im Wintersemester 2009/2010 Was

Mehr

Stundenerfassung Version 1.8 Anleitung Arbeiten mit Replikaten

Stundenerfassung Version 1.8 Anleitung Arbeiten mit Replikaten Stundenerfassung Version 1.8 Anleitung Arbeiten mit Replikaten 2008 netcadservice GmbH netcadservice GmbH Augustinerstraße 3 D-83395 Freilassing Dieses Programm ist urheberrechtlich geschützt. Eine Weitergabe

Mehr

Robot Karol für Delphi

Robot Karol für Delphi Robot Karol für Delphi Reinhard Nitzsche, OSZ Handel I Version 0.1 vom 24. Januar 2003 Zusammenfassung Nach der Einführung in die (variablenfreie) Programmierung mit Robot Karol von Freiberger und Krško

Mehr

Einführung in die Java- Programmierung

Einführung in die Java- Programmierung Einführung in die Java- Programmierung Dr. Volker Riediger Tassilo Horn riediger horn@uni-koblenz.de WiSe 2012/13 1 Wichtig... Mittags keine Pommes... Praktikum A 230 C 207 (Madeleine + Esma) F 112 F 113

Mehr

Computerarithmetik ( )

Computerarithmetik ( ) Anhang A Computerarithmetik ( ) A.1 Zahlendarstellung im Rechner und Computerarithmetik Prinzipiell ist die Menge der im Computer darstellbaren Zahlen endlich. Wie groß diese Menge ist, hängt von der Rechnerarchitektur

Mehr

Grundlagen von Python

Grundlagen von Python Einführung in Python Grundlagen von Python Felix Döring, Felix Wittwer November 17, 2015 Scriptcharakter Programmierparadigmen Imperatives Programmieren Das Scoping Problem Objektorientiertes Programmieren

Mehr

Diplomarbeit. Konzeption und Implementierung einer automatisierten Testumgebung. Thomas Wehrspann. 10. Dezember 2008

Diplomarbeit. Konzeption und Implementierung einer automatisierten Testumgebung. Thomas Wehrspann. 10. Dezember 2008 Konzeption und Implementierung einer automatisierten Testumgebung, 10. Dezember 2008 1 Gliederung Einleitung Softwaretests Beispiel Konzeption Zusammenfassung 2 Einleitung Komplexität von Softwaresystemen

Mehr

BüroWARE Exchange Synchronisation Grundlagen und Voraussetzungen

BüroWARE Exchange Synchronisation Grundlagen und Voraussetzungen BüroWARE Exchange Synchronisation Grundlagen und Voraussetzungen Stand: 13.12.2010 Die BüroWARE SoftENGINE ist ab Version 5.42.000-060 in der Lage mit einem Microsoft Exchange Server ab Version 2007 SP1

Mehr

Abamsoft Finos im Zusammenspiel mit shop to date von DATA BECKER

Abamsoft Finos im Zusammenspiel mit shop to date von DATA BECKER Abamsoft Finos im Zusammenspiel mit shop to date von DATA BECKER Abamsoft Finos in Verbindung mit der Webshopanbindung wurde speziell auf die Shop-Software shop to date von DATA BECKER abgestimmt. Mit

Mehr

OpenGL. (Open Graphic Library)

OpenGL. (Open Graphic Library) OpenGL (Open Graphic Library) Agenda Was ist OpenGL eigentlich? Geschichte Vor- und Nachteile Arbeitsweise glscene OpenGL per Hand Debugging Trend Was ist OpenGL eigentlich? OpenGL ist eine Spezifikation

Mehr

ARAkoll 2013 Dokumentation. Datum: 21.11.2012

ARAkoll 2013 Dokumentation. Datum: 21.11.2012 ARAkoll 2013 Dokumentation Datum: 21.11.2012 INHALT Allgemeines... 3 Funktionsübersicht... 3 Allgemeine Funktionen... 3 ARAmatic Symbolleiste... 3 Monatsprotokoll erzeugen... 4 Jahresprotokoll erzeugen

Mehr

Stellen Sie bitte den Cursor in die Spalte B2 und rufen die Funktion Sverweis auf. Es öffnet sich folgendes Dialogfenster

Stellen Sie bitte den Cursor in die Spalte B2 und rufen die Funktion Sverweis auf. Es öffnet sich folgendes Dialogfenster Es gibt in Excel unter anderem die so genannten Suchfunktionen / Matrixfunktionen Damit können Sie Werte innerhalb eines bestimmten Bereichs suchen. Als Beispiel möchte ich die Funktion Sverweis zeigen.

Mehr

Zahlensysteme: Oktal- und Hexadezimalsystem

Zahlensysteme: Oktal- und Hexadezimalsystem 20 Brückenkurs Die gebräuchlichste Bitfolge umfasst 8 Bits, sie deckt also 2 8 =256 Möglichkeiten ab, und wird ein Byte genannt. Zwei Bytes, also 16 Bits, bilden ein Wort, und 4 Bytes, also 32 Bits, formen

Mehr

Einführung in die Programmierung

Einführung in die Programmierung : Inhalt Einführung in die Programmierung Wintersemester 2008/09 Prof. Dr. Günter Rudolph Lehrstuhl für Algorithm Engineering Fakultät für Informatik TU Dortmund - mit / ohne Parameter - mit / ohne Rückgabewerte

Mehr

Anleitung E Mail Thurcom E Mail Anleitung Version 4.0 8.2014

Anleitung E Mail Thurcom E Mail Anleitung Version 4.0 8.2014 Anleitung E Mail Inhalt 1. Beschreibung 1.1. POP3 oder IMAP? 1.1.1. POP3 1.1.2. IMAP 1.2. Allgemeine Einstellungen 2. E Mail Programme 3 3 3 3 3 4 2.1. Thunderbird 4 2.2. Windows Live Mail 6 2.3. Outlook

Mehr

Data Quality Management: Abgleich großer, redundanter Datenmengen

Data Quality Management: Abgleich großer, redundanter Datenmengen Data Quality Management: Abgleich großer, redundanter Datenmengen Westendstr. 14 809 München Tel 089-5100 907 Fax 089-5100 9087 E-Mail Datras@Datras.de Redundanz und relationales Datenbankmodell Redundanz:

Mehr

Kommunikations-Management

Kommunikations-Management Tutorial: Wie importiere und exportiere ich Daten zwischen myfactory und Outlook? Im vorliegenden Tutorial lernen Sie, wie Sie in myfactory Daten aus Outlook importieren Daten aus myfactory nach Outlook

Mehr

Diese Ansicht erhalten Sie nach der erfolgreichen Anmeldung bei Wordpress.

Diese Ansicht erhalten Sie nach der erfolgreichen Anmeldung bei Wordpress. Anmeldung http://www.ihredomain.de/wp-admin Dashboard Diese Ansicht erhalten Sie nach der erfolgreichen Anmeldung bei Wordpress. Das Dashboard gibt Ihnen eine kurze Übersicht, z.b. Anzahl der Beiträge,

Mehr

Microsoft Update Windows Update

Microsoft Update Windows Update Microsoft bietet mehrere Möglichkeit, Updates durchzuführen, dies reicht von vollkommen automatisch bis zu gar nicht. Auf Rechnern unserer Kunden stellen wir seit September 2006 grundsätzlich die Option

Mehr

Objektorientierte Programmierung

Objektorientierte Programmierung Objektorientierte Programmierung 1 Geschichte Dahl, Nygaard: Simula 67 (Algol 60 + Objektorientierung) Kay et al.: Smalltalk (erste rein-objektorientierte Sprache) Object Pascal, Objective C, C++ (wiederum

Mehr

OPERATIONEN AUF EINER DATENBANK

OPERATIONEN AUF EINER DATENBANK Einführung 1 OPERATIONEN AUF EINER DATENBANK Ein Benutzer stellt eine Anfrage: Die Benutzer einer Datenbank können meist sowohl interaktiv als auch über Anwendungen Anfragen an eine Datenbank stellen:

Mehr

Visual Basic Express Debugging

Visual Basic Express Debugging Inhalt Dokument Beschreibung... 1 Projekt vorbereiten... 1 Verknüpfung zu Autocad/ProStructures einstellen... 2 Debugging... 4 Autocad/ProSteel Beispiel... 5 Dokument Beschreibung Debuggen nennt man das

Mehr

Die Gleichung A x = a hat für A 0 die eindeutig bestimmte Lösung. Für A=0 und a 0 existiert keine Lösung.

Die Gleichung A x = a hat für A 0 die eindeutig bestimmte Lösung. Für A=0 und a 0 existiert keine Lösung. Lineare Gleichungen mit einer Unbekannten Die Grundform der linearen Gleichung mit einer Unbekannten x lautet A x = a Dabei sind A, a reelle Zahlen. Die Gleichung lösen heißt, alle reellen Zahlen anzugeben,

Mehr

DOKUMENTATION VOGELZUCHT 2015 PLUS

DOKUMENTATION VOGELZUCHT 2015 PLUS DOKUMENTATION VOGELZUCHT 2015 PLUS Vogelzucht2015 App für Geräte mit Android Betriebssystemen Läuft nur in Zusammenhang mit einer Vollversion vogelzucht2015 auf einem PC. Zusammenfassung: a. Mit der APP

Mehr

C++ Grundlagen. ++ bedeutet Erweiterung zum Ansi C Standard. Hier wird eine Funktion eingeleitet

C++ Grundlagen. ++ bedeutet Erweiterung zum Ansi C Standard. Hier wird eine Funktion eingeleitet C++ Grundlagen ++ bedeutet Erweiterung zum Ansi C Standard Hier wird eine Funktion eingeleitet Aufbau: In dieser Datei stehen die Befehle, die gestartet werden, wenn das Programm gestartet wird Int main()

Mehr

Bereich METIS (Texte im Internet) Zählmarkenrecherche

Bereich METIS (Texte im Internet) Zählmarkenrecherche Bereich METIS (Texte im Internet) Zählmarkenrecherche Über die Zählmarkenrecherche kann man nach der Eingabe des Privaten Identifikationscodes einer bestimmten Zählmarke, 1. Informationen zu dieser Zählmarke

Mehr

Speicher in der Cloud

Speicher in der Cloud Speicher in der Cloud Kostenbremse, Sicherheitsrisiko oder Basis für die unternehmensweite Kollaboration? von Cornelius Höchel-Winter 2013 ComConsult Research GmbH, Aachen 3 SYNCHRONISATION TEUFELSZEUG

Mehr

Lizenzierung von System Center 2012

Lizenzierung von System Center 2012 Lizenzierung von System Center 2012 Mit den Microsoft System Center-Produkten lassen sich Endgeräte wie Server, Clients und mobile Geräte mit unterschiedlichen Betriebssystemen verwalten. Verwalten im

Mehr

Einführung in die Informatik Tools

Einführung in die Informatik Tools Einführung in die Informatik Tools Werkzeuge zur Erstellung von Softwareprojekten Wolfram Burgard 8.1 Motivation Große Softwareprojekte werden schnell unübersichtlich. Änderungen im Code können leicht

Mehr

! " # $ " % & Nicki Wruck worldwidewruck 08.02.2006

!  # $  % & Nicki Wruck worldwidewruck 08.02.2006 !"# $ " %& Nicki Wruck worldwidewruck 08.02.2006 Wer kennt die Problematik nicht? Die.pst Datei von Outlook wird unübersichtlich groß, das Starten und Beenden dauert immer länger. Hat man dann noch die.pst

Mehr

robotron*e count robotron*e sales robotron*e collect Anmeldung Webkomponente Anwenderdokumentation Version: 2.0 Stand: 28.05.2014

robotron*e count robotron*e sales robotron*e collect Anmeldung Webkomponente Anwenderdokumentation Version: 2.0 Stand: 28.05.2014 robotron*e count robotron*e sales robotron*e collect Anwenderdokumentation Version: 2.0 Stand: 28.05.2014 Seite 2 von 5 Alle Rechte dieser Dokumentation unterliegen dem deutschen Urheberrecht. Die Vervielfältigung,

Mehr

Dokumentation von Ük Modul 302

Dokumentation von Ük Modul 302 Dokumentation von Ük Modul 302 Von Nicolas Kull Seite 1/ Inhaltsverzeichnis Dokumentation von Ük Modul 302... 1 Inhaltsverzeichnis... 2 Abbildungsverzeichnis... 3 Typographie (Layout)... 4 Schrift... 4

Mehr

SDD System Design Document

SDD System Design Document SDD Software Konstruktion WS01/02 Gruppe 4 1. Einleitung Das vorliegende Dokument richtet sich vor allem an die Entwickler, aber auch an den Kunden, der das enstehende System verwenden wird. Es soll einen

Mehr

Objektorientierte Programmierung. Kapitel 12: Interfaces

Objektorientierte Programmierung. Kapitel 12: Interfaces 12. Interfaces 1/14 Objektorientierte Programmierung Kapitel 12: Interfaces Stefan Brass Martin-Luther-Universität Halle-Wittenberg Wintersemester 2012/13 http://www.informatik.uni-halle.de/ brass/oop12/

Mehr

Funktion Erläuterung Beispiel

Funktion Erläuterung Beispiel WESTFÄLISCHE WILHELMS-UNIVERSITÄT WIRTSCHAFTSWISSENSCHAFTLICHE FAKULTÄT BETRIEBLICHE DATENVERARBEITUNG Folgende Befehle werden typischerweise im Excel-Testat benötigt. Die Beispiele in diesem Dokument

Mehr

AUF LETZTER SEITE DIESER ANLEITUNG!!!

AUF LETZTER SEITE DIESER ANLEITUNG!!! BELEG DATENABGLEICH: Der Beleg-Datenabgleich wird innerhalb des geöffneten Steuerfalls über ELSTER-Belegdaten abgleichen gestartet. Es werden Ihnen alle verfügbaren Belege zum Steuerfall im ersten Bildschirm

Mehr

Kapitel 3 Frames Seite 1

Kapitel 3 Frames Seite 1 Kapitel 3 Frames Seite 1 3 Frames 3.1 Allgemeines Mit Frames teilt man eine HTML-Seite in mehrere Bereiche ein. Eine Seite, die mit Frames aufgeteilt ist, besteht aus mehreren Einzelseiten, die sich den

Mehr

Testplan. Hochschule Luzern Technik & Architektur. Software Komponenten FS13. Gruppe 03 Horw, 16.04.2013

Testplan. Hochschule Luzern Technik & Architektur. Software Komponenten FS13. Gruppe 03 Horw, 16.04.2013 Software Komponenten FS13 Gruppe 03 Horw, 16.04.2013 Bontekoe Christian Estermann Michael Moor Simon Rohrer Felix Autoren Bontekoe Christian Studiengang Informatiker (Berufsbegleitend) Estermann Michael

Mehr

1 Mathematische Grundlagen

1 Mathematische Grundlagen Mathematische Grundlagen - 1-1 Mathematische Grundlagen Der Begriff der Menge ist einer der grundlegenden Begriffe in der Mathematik. Mengen dienen dazu, Dinge oder Objekte zu einer Einheit zusammenzufassen.

Mehr

Dokumentation zum Spielserver der Software Challenge

Dokumentation zum Spielserver der Software Challenge Dokumentation zum Spielserver der Software Challenge 10.08.2011 Inhaltsverzeichnis: Programmoberfläche... 2 Ein neues Spiel erstellen... 2 Spielfeldoberfläche... 4 Spielwiederholung laden... 5 Testdurchläufe...

Mehr

Sie werden sehen, dass Sie für uns nur noch den direkten PDF-Export benötigen. Warum?

Sie werden sehen, dass Sie für uns nur noch den direkten PDF-Export benötigen. Warum? Leitfaden zur Druckdatenerstellung Inhalt: 1. Download und Installation der ECI-Profile 2. Farbeinstellungen der Adobe Creative Suite Bitte beachten! In diesem kleinen Leitfaden möchten wir auf die Druckdatenerstellung

Mehr

Verhindert, dass eine Methode überschrieben wird. public final int holekontostand() {...} public final class Girokonto extends Konto {...

Verhindert, dass eine Methode überschrieben wird. public final int holekontostand() {...} public final class Girokonto extends Konto {... PIWIN I Kap. 8 Objektorientierte Programmierung - Vererbung 31 Schlüsselwort: final Verhindert, dass eine Methode überschrieben wird public final int holekontostand() {... Erben von einer Klasse verbieten:

Mehr

Reporting Services und SharePoint 2010 Teil 1

Reporting Services und SharePoint 2010 Teil 1 Reporting Services und SharePoint 2010 Teil 1 Abstract Bei der Verwendung der Reporting Services in Zusammenhang mit SharePoint 2010 stellt sich immer wieder die Frage bei der Installation: Wo und Wie?

Mehr

Einführung in PHP. (mit Aufgaben)

Einführung in PHP. (mit Aufgaben) Einführung in PHP (mit Aufgaben) Dynamische Inhalte mit PHP? 2 Aus der Wikipedia (verkürzt): PHP wird auf etwa 244 Millionen Websites eingesetzt (Stand: Januar 2013) und wird auf etwa 80 % aller Websites

Mehr

IT-Governance und Social, Mobile und Cloud Computing: Ein Management Framework... Bachelorarbeit

IT-Governance und Social, Mobile und Cloud Computing: Ein Management Framework... Bachelorarbeit IT-Governance und Social, Mobile und Cloud Computing: Ein Management Framework... Bachelorarbeit zur Erlangung des akademischen Grades Bachelor of Science (B.Sc.) im Studiengang Wirtschaftswissenschaft

Mehr

Jede Zahl muss dabei einzeln umgerechnet werden. Beginnen wir also ganz am Anfang mit der Zahl,192.

Jede Zahl muss dabei einzeln umgerechnet werden. Beginnen wir also ganz am Anfang mit der Zahl,192. Binäres und dezimales Zahlensystem Ziel In diesem ersten Schritt geht es darum, die grundlegende Umrechnung aus dem Dezimalsystem in das Binärsystem zu verstehen. Zusätzlich wird auch die andere Richtung,

Mehr

Beschreibung und Bedienungsanleitung. Inhaltsverzeichnis: Abbildungsverzeichnis: Werkzeug für verschlüsselte bpks. Dipl.-Ing.

Beschreibung und Bedienungsanleitung. Inhaltsverzeichnis: Abbildungsverzeichnis: Werkzeug für verschlüsselte bpks. Dipl.-Ing. www.egiz.gv.at E-Mail: post@egiz.gv.at Telefon: ++43 (316) 873 5514 Fax: ++43 (316) 873 5520 Inffeldgasse 16a / 8010 Graz / Austria Beschreibung und Bedienungsanleitung Werkzeug für verschlüsselte bpks

Mehr

Es sollte die MS-DOS Eingabeaufforderung starten. Geben Sie nun den Befehl javac ein.

Es sollte die MS-DOS Eingabeaufforderung starten. Geben Sie nun den Befehl javac ein. Schritt 1: Installation des Javacompilers JDK. Der erste Start mit Eclipse Bevor Sie den Java-Compiler installieren sollten Sie sich vergewissern, ob er eventuell schon installiert ist. Gehen sie wie folgt

Mehr

WordPress. Dokumentation

WordPress. Dokumentation WordPress Dokumentation Backend-Login In das Backend gelangt man, indem man hinter seiner Website-URL einfach ein /wp-admin dranhängt www.domain.tld/wp-admin Dabei gelangt man auf die Administrationsoberfläche,

Mehr

Adobe Photoshop. Lightroom 5 für Einsteiger Bilder verwalten und entwickeln. Sam Jost

Adobe Photoshop. Lightroom 5 für Einsteiger Bilder verwalten und entwickeln. Sam Jost Adobe Photoshop Lightroom 5 für Einsteiger Bilder verwalten und entwickeln Sam Jost Kapitel 2 Der erste Start 2.1 Mitmachen beim Lesen....................... 22 2.2 Für Apple-Anwender.........................

Mehr

Nach der Anmeldung im Backend Bereich landen Sie im Kontrollzentrum, welches so aussieht:

Nach der Anmeldung im Backend Bereich landen Sie im Kontrollzentrum, welches so aussieht: Beiträge erstellen in Joomla Nach der Anmeldung im Backend Bereich landen Sie im Kontrollzentrum, welches so aussieht: Abbildung 1 - Kontrollzentrum Von hier aus kann man zu verschiedene Einstellungen

Mehr

Netzwerk einrichten unter Windows

Netzwerk einrichten unter Windows Netzwerk einrichten unter Windows Schnell und einfach ein Netzwerk einrichten unter Windows. Kaum ein Rechner kommt heute mehr ohne Netzwerkverbindungen aus. In jedem Rechner den man heute kauft ist eine

Mehr

Grundlagen verteilter Systeme

Grundlagen verteilter Systeme Universität Augsburg Insitut für Informatik Prof. Dr. Bernhard Bauer Wolf Fischer Christian Saad Wintersemester 08/09 Übungsblatt 3 12.11.08 Grundlagen verteilter Systeme Lösungsvorschlag Aufgabe 1: a)

Mehr

Installation der SAS Foundation Software auf Windows

Installation der SAS Foundation Software auf Windows Installation der SAS Foundation Software auf Windows Der installierende Benutzer unter Windows muss Mitglied der lokalen Gruppe Administratoren / Administrators sein und damit das Recht besitzen, Software

Mehr

Der Aufruf von DM_in_Euro 1.40 sollte die Ausgabe 1.40 DM = 0.51129 Euro ergeben.

Der Aufruf von DM_in_Euro 1.40 sollte die Ausgabe 1.40 DM = 0.51129 Euro ergeben. Aufgabe 1.30 : Schreibe ein Programm DM_in_Euro.java zur Umrechnung eines DM-Betrags in Euro unter Verwendung einer Konstanten für den Umrechnungsfaktor. Das Programm soll den DM-Betrag als Parameter verarbeiten.

Mehr

S/W mit PhotoLine. Inhaltsverzeichnis. PhotoLine

S/W mit PhotoLine. Inhaltsverzeichnis. PhotoLine PhotoLine S/W mit PhotoLine Erstellt mit Version 16.11 Ich liebe Schwarzweiß-Bilder und schaue mir neidisch die Meisterwerke an, die andere Fotografen zustande bringen. Schon lange versuche ich, auch so

Mehr

1. Einführung. 2. Archivierung alter Datensätze

1. Einführung. 2. Archivierung alter Datensätze 1. Einführung Mit wachsender Datenmenge und je nach Konfiguration, kann orgamax mit der Zeit langsamer werden. Es gibt aber diverse Möglichkeiten, die Software wieder so zu beschleunigen, als würden Sie

Mehr

Lösungsvorschlag zur 4. Übung

Lösungsvorschlag zur 4. Übung Prof. Frederik Armknecht Sascha Müller Daniel Mäurer Grundlagen der Informatik 3 Wintersemester 09/10 Lösungsvorschlag zur 4. Übung 1 Präsenzübungen 1.1 Schnelltest a) Welche Aussagen zu Bewertungskriterien

Mehr

FlowFact Alle Versionen

FlowFact Alle Versionen Training FlowFact Alle Versionen Stand: 29.09.2005 Rechnung schreiben Einführung Wie Sie inzwischen wissen, können die unterschiedlichsten Daten über verknüpfte Fenster miteinander verbunden werden. Für

Mehr

DB2 Kurzeinführung (Windows)

DB2 Kurzeinführung (Windows) DB2 Kurzeinführung (Windows) Michaelsen c 25. Mai 2010 1 1 Komponenten von DB2 DB2 bietet zahlreiche graphische Oberflächen für die Verwaltung der verschiedenen Komponenten und Anwendungen. Die wichtigsten

Mehr

Ist Excel das richtige Tool für FMEA? Steve Murphy, Marc Schaeffers

Ist Excel das richtige Tool für FMEA? Steve Murphy, Marc Schaeffers Ist Excel das richtige Tool für FMEA? Steve Murphy, Marc Schaeffers Ist Excel das richtige Tool für FMEA? Einleitung Wenn in einem Unternehmen FMEA eingeführt wird, fangen die meisten sofort damit an,

Mehr

Erklärung zum Internet-Bestellschein

Erklärung zum Internet-Bestellschein Erklärung zum Internet-Bestellschein Herzlich Willkommen bei Modellbahnbau Reinhardt. Auf den nächsten Seiten wird Ihnen mit hilfreichen Bildern erklärt, wie Sie den Internet-Bestellschein ausfüllen und

Mehr

Anleitung zum Login. über die Mediteam- Homepage und zur Pflege von Praxisnachrichten

Anleitung zum Login. über die Mediteam- Homepage und zur Pflege von Praxisnachrichten Anleitung zum Login über die Mediteam- Homepage und zur Pflege von Praxisnachrichten Stand: 18.Dezember 2013 1. Was ist der Mediteam-Login? Alle Mediteam-Mitglieder können kostenfrei einen Login beantragen.

Mehr

Fassade. Objektbasiertes Strukturmuster. C. Restorff & M. Rohlfing

Fassade. Objektbasiertes Strukturmuster. C. Restorff & M. Rohlfing Fassade Objektbasiertes Strukturmuster C. Restorff & M. Rohlfing Übersicht Motivation Anwendbarkeit Struktur Teilnehmer Interaktion Konsequenz Implementierung Beispiel Bekannte Verwendung Verwandte Muster

Mehr

1 Installation QTrans V2.0 unter Windows NT4

1 Installation QTrans V2.0 unter Windows NT4 1 Installation QTrans V2.0 unter Windows NT4 1.1 Unterstützte Funktionen Unter NT4 wird nur der Betrieb von QTrans im Report-Client-Modus unterstützt, d. h. für die Anzeige von Schraubergebnissen und für

Mehr

Einführung in. Logische Schaltungen

Einführung in. Logische Schaltungen Einführung in Logische Schaltungen 1/7 Inhaltsverzeichnis 1. Einführung 1. Was sind logische Schaltungen 2. Grundlegende Elemente 3. Weitere Elemente 4. Beispiel einer logischen Schaltung 2. Notation von

Mehr

10 Erweiterung und Portierung

10 Erweiterung und Portierung 10.1 Überblick In vielen Fällen werden Compiler nicht vollständig neu geschrieben, sondern von einem Rechnersystem auf ein anderes portiert. Das spart viel Arbeit, ist aber immer noch eine sehr anspruchsvolle

Mehr

In 15 einfachen Schritten zum mobilen PC mit Paragon Drive Copy 10 und Microsoft Windows Virtual PC

In 15 einfachen Schritten zum mobilen PC mit Paragon Drive Copy 10 und Microsoft Windows Virtual PC PARAGON Technologie GmbH, Systemprogrammierung Heinrich-von-Stephan-Str. 5c 79100 Freiburg, Germany Tel. +49 (0) 761 59018201 Fax +49 (0) 761 59018130 Internet www.paragon-software.com Email sales@paragon-software.com

Mehr

Fachdidaktik der Informatik 18.12.08 Jörg Depner, Kathrin Gaißer

Fachdidaktik der Informatik 18.12.08 Jörg Depner, Kathrin Gaißer Fachdidaktik der Informatik 18.12.08 Jörg Depner, Kathrin Gaißer Klassendiagramme Ein Klassendiagramm dient in der objektorientierten Softwareentwicklung zur Darstellung von Klassen und den Beziehungen,

Mehr