MultiCore-Programmierung in Java
Transcription
MultiCore-Programmierung in Java
MultiCore-Programmierung in Java Bachelor-Arbeit (SS 2010) Betreuer: Dipl.-Ing. Dr. Hans Moritsch Verfasser: Gerold Egger Matr.-Nr. 8917007 Universität Innsbruck [email protected] 2 Inhaltsverzeichnis 1 Einleitung........................................................................................................................................1 1.1 Möglichkeiten der Performance-Steigerung............................................................................1 1.1.1 Erhöhung der Taktfrequenz..............................................................................................1 1.1.2 Pipelines...........................................................................................................................2 1.1.3 RISC (Reduced Instruction Set Computer)......................................................................3 1.1.4 MMX-Technologie...........................................................................................................3 1.1.5 Cache-Speicher................................................................................................................3 1.1.6 Koprozessoren..................................................................................................................4 1.1.7 Harvard-Architektur.........................................................................................................4 1.2 Leistungs-Steigerung ..............................................................................................................4 1.2.1 Definition.........................................................................................................................4 1.2.2 Speedup und Effizienz.....................................................................................................5 1.2.3 Grenzen der Leistungs-Steigerung ..................................................................................6 2 Parallele Programmierung ..............................................................................................................8 2.1 Zielsetzungen paralleler Programmierung ..............................................................................8 2.2 Entwicklung eines parallelen Programmes .............................................................................9 2.3 Skalierbarkeit.........................................................................................................................10 2.4 Multicore-Prozessoren ..........................................................................................................11 2.5 Parallelrechner.......................................................................................................................12 2.5.1 Klassifikation nach der Art der Befehlsausführung.......................................................12 2.5.2 Klassifikation nach der Speicherorganisation................................................................14 2.6 Kommunikationsnetzwerke...................................................................................................14 2.7 Kopplungsgrad von Mehrprozessor-Systemen......................................................................14 2.7.1 Eng gekoppelte Mehrprozessorsysteme.........................................................................14 2.7.2 Lose gekoppelte Systeme ..............................................................................................15 2.7.3 Massiv parallele Rechner ..............................................................................................15 2.8 Parallelisierungsstrategien.....................................................................................................15 2.8.1 Gebietszerlegung (domain decomposition) ...................................................................16 2.8.2 Funktionale Aufteilung (functional decomposition) .....................................................16 2.8.3 Verteilung von Einzelaufträgen (task farming) .............................................................16 2.8.4 Daten-Pipelining............................................................................................................17 3 2.8.5 Spekulative Zerlegung...................................................................................................18 2.8.6 Hybrides Zerlegungsmodell...........................................................................................18 2.9 Kommunikationsmodelle.......................................................................................................18 2.9.1 Shared-Memory-Programmierung.................................................................................18 2.9.2 Message-Passing-Programmierung................................................................................18 3 Unterstützung des parallelen Programmierens in Java .................................................................20 3.1 Klassische Thread-Programmierung......................................................................................20 3.2 Thread-Programmierung mittels Thread-Pools.....................................................................23 3.3 Fork/Join-Framework............................................................................................................25 4 Performance-Messungen...............................................................................................................31 4.1 Durchführung der Experimente.............................................................................................31 4.2 Problemstellung: Numerische Integration.............................................................................31 4.3 Problemstellung: Sortieren....................................................................................................37 4.4 Problemstellung: Fibonacci-Zahlen.......................................................................................42 4.5 Problemstellung: Multiplikation von Matrizen......................................................................47 4.6 Problemstellung: Jacobi-Relaxation......................................................................................50 5 Zusammenfassung.........................................................................................................................53 Anhang A (Programme zur Problemstellung „Numerische Integration“).......................................54 Anhang B (Programme zur Problemstellung „Sortieren“)..............................................................67 Anhang C (Programme zur Problemstellung „Fibonacci-Zahlen“).................................................78 Anhang D (Programme zur Problemstellung „Matrizen-Multiplikation“)......................................87 Anhang E (Programme zur Problemstellung „Jacobi-Relaxation“)..............................................105 6 Literaturverzeichnis.....................................................................................................................108 4 Kurzfassung Sowohl Multicore-Prozessoren als auch die Programmiersprache Java erfreuen sich großer Beliebtheit. Von Multicore-Prozessoren erwartet man sich revolutionäre Performance-Steigerungen. Während man mit herkömmlichen Lösungen zur Leistungs-Steigerung schon an den physikalischen Grenzen angelangt ist, steht die Verwendung von Multicore-Prozessoren – zumindest im PC-Bereich – erst am Beginn. Die Erwartungen in diesen neuen Lösungsansatz sind groß. Allerdings darf bei aller Euphorie nicht darauf vergessen werden, daß auch auch entsprechende softwareseitige Anpassungen erforderlich sind. Inwiefern die beliebte Programmiersprache Java dazu geeignet ist adäquate Anwender-Software zu entwickeln, soll diese Arbeit untersuchen. 1 Einleitung In den letzten Jahren haben sich auch im PC-Bereich Multi-Core-Prozessoren durchgesetzt. Neben der Voraussetzung, daß das Betriebssystem im Stande ist, mehrere CPUs für das ProzessScheduling zu benutzen, müssen auch die Anwendungs-Programme so programmiert sein, daß parallele Abarbeitungen von Programmteilen möglich sind. Dazu kommen oft speziell entwickelte, optimierte, parallele Algorithmen zu Einsatz. Multi-Core-Prozessoren erlauben es, das Paradigma des parallelen Programmierens umzusetzen. In der parallelen Programmierung werden Programme in Abschnitte zerlegt, die parallel (nebenläufig) ausgeführt werden können. Diese Programmabschnitte müssen im Allgemeinen synchronisiert werden. Primäres Ziel des parallelen Programmierens ist die Leistungssteigerung. Wenn in dieser Arbeit von Leistung (engl.: performance) gesprochen wird, ist die Schnelligkeit gemeint, mit der die Programme abgearbeitet werden, und nicht etwa der Funktionsumfang oder die Zuverlässigkeit eines Systems. 1.1 Möglichkeiten der Performance-Steigerung Zur Programmierung von Hochleistungsanwendungen für Multicore-Prozessoren können höhere Programmiersprachen eingesetzt werden. Im Gegensatz dazu konzentrieren sich frühere Bestrebungen die Leistung zu erhöhen auf hardwarenahe Komponenten bzw. auf Eigenschaften der RechnerArchitektur. 1.1.1 Erhöhung der Taktfrequenz Seit jeher wurde versucht, eine bessere Performance durch höhere Taktfrequenzen zu erreichen. Dabei ist man mittlerweile an das Limit eines vernünftigen Betriebes der Prozessoren gestoßen, da der Aufwand, die Prozessoren zu kühlen, kaum mehr zu handhaben ist. Das zeigt sich an den überdimensionalen Kühlkörpern, die auf den Prozessoren angebracht sind (siehe Abbildung 1). Allerdings wird bezüglich der Wärmeentwicklung versucht gegenzusteuern, indem man die CoreSpannung (= die Spannung die der eigentliche Prozessor benutzt) wesentlich niedriger setzt, als die I/O-Spannung, die vom Motherboard bereitgestellt wird. Die geringere Spannung bewirkt eine geringere Wärmeentwicklung. Einleitung 2 Abbildung 1: Prozessorkühler für Sockel 775 (Intel Pentium D) mit Heatpipe im Vergleich zu einem Kühler für den Sockel 7 (Intel Pentium 1 MMX) – (Quelle: Wikipedia) 1.1.2 Pipelines Die Abarbeitung von Maschinen-Befehlen erfolgt in mehreren Phasen (Pipeline-Stufen), die nicht zwingenderweise unmittelbar nacheinander erfolgen müssen. In jeder Phase wird eine Teilaufgabe eines Befehls erledigt. So können die ersten Phasen eines weiteren Befehls bereits abgearbeitet werden, während der vohergehende Befehl noch seine letzten Phasen durchläuft. Es kommt so zu einer Überlappung der Teilaufgaben unterschiedlicher Befehle (siehe Abbildung 2). Dadurch steigt der Befehlsdurchsatz. Zu Konflikten (hazards) kommt es, wenn unter den Befehlen Abhängigkeiten existieren. Dadurch wird die Parallelität eingeschränkt. Superskalare Prozessoren Werden in einen Prozessor mehrere Pipelines eingebaut, die parallel arbeiten können, so erhält man superskalare Prozessoren. Die Anzahl aller, im besten Fall parallel laufender, Pipelines gibt den Grad der Superskalarität an. Einleitung 3 Abbildung 2: Pipelining – (Quelle: Wikipedia) 1.1.3 RISC (Reduced Instruction Set Computer) Die RISC-Technologie verwendet Prozessoren mit einem reduzierten Befehlssatz. Dadurch kann die CPU schneller arbeiten, weil sie nur auf wenige Befehle spezialisiert ist. Das konträre Verfahren stellt das CICS-Verfahren (Complex Instruction Set Computer) dar. Hierbei bietet der Prozessor auch komplexe Instruktionen an, die mittels eines Microcode-Progamms erst in die eigentlichen Verarbeitungsschritte umgesetzt werden. 1.1.4 MMX-Technologie Der Prozessor verfügt über Befehle, die speziell für Multimedia-Aufgaben geeignet sind. Programme, die auf diesen Befehlssatz angepasst sind, können effizienter arbeiten, da durch diese Multimedia-Befehle Aufgaben in weniger Einzelschritte unterteilt werden müssen. 1.1.5 Cache-Speicher Mit Cache-Speichern wird versucht, bereits vorhandene Daten für eventuelle weitere Zugriffe vorrätig zu halten. Cache-Speicher liefern die Daten wesentlich schneller, als der Hauptspeicher. Allerdings ist Cache-Speicher sehr teuer und daher nur in beschränktem Ausmaß vorhanden. Aufgrund Einleitung 4 dessen ergibt sich ein weiteres Problem, nämlich das der geeigneten Cache-Strategie. Die Cache-Strategie soll so gewählt werden, daß die Treffer-Rate (gefundene Daten im Cache-Speicher im Verhältnis zu allen Anfragen an den Cache-Speicher) möglichst hoch ist. 1.1.6 Koprozessoren Koprozessoren entlasten die CPU bei Fließkomma-Berechnungen. Falls die CPU über keine Fließkomma-Einheit verfügt, müssen solche Berechnungen von der CPU in viele Ganzzahl-Berechnungen zerlegt werden, was wesentlich länger dauert. Die CPU übernimmt die Steuerfunktionen. Der Koprozessor kann nur zusammen mit einem normalen Prozessor arbeiten. 1.1.7 Harvard-Architektur Im Gegensatz zur klassischen Von-Neumann-Architektur werden bei der Harvard-Architektur Daten und Befehle in physikalisch getrennten Speichern gehalten. Beide Speicher werden von eigenen Bus-Systemen angesteuert. Somit können Befehle und Daten gleichzeitig geladen und geschrieben werden. 1.2 Leistungs-Steigerung Als Maß für die Leistung soll in dieser Arbeit die Ausführungszeit von Programmen verstanden werden. Dabei soll natürlich nur die reine Rechenzeit berücksichtigt werden und nicht etwa der Zeitaufwand für Ein- und Ausgabe dazu gezählt werden. Ein algorithmusspezifischer Aufwand wie beispielsweise das Erstellen von Daten-Strukturen, auf denen der Algorithmus arbeitet - soll allerdings in die Ausführungszeit mit aufgenommen werden. 1.2.1 Definition Die Performance p x eines Programms x verhält sich umgekehrt proportional zur Laufzeit t x des Programms: p x ~1/t x Ein Programm y ist um den Faktor c schneller als das Programm x wenn c=t x /t y gilt. Einleitung 5 Die Performance hängt hauptsächlich von folgenden Faktoren ab: • dem verwendeten Algorithmus, • der verwendeten Programmier-Sprache, • dem Compiler und seinen Optimierungs-Einstellungen, • dem Instruktionssatz des Prozessors, • der Taktfrequenz, und • der benötigten Takt-Zyklen pro Anweisung. 1.2.2 Speedup und Effizienz Speedup ist die Beschleunigung eines Programms gegenüber der seriellen (sequentiellen) Ausführung. Speedup ist somit ein geeignetes Maß, um die Wirksamkeit der parallelen Abarbeitung zu messen. Für die Messung des Speedups wird das gleiche Problem auf unterschiedlich vielen Prozessoren gelöst, und die Ausführungszeiten (Laufzeiten der Programme) verglichen. Die Größe des Gesamt-Problems bleibt konstant, aber die Problemgröße eines einzelnen Prozessors sinkt mit wachsender Anzahl an Prozessoren. Der Speedup eines parallel ausgeführten Programms auf p Prozessoren errechnet sich mit S p = T 1 / T p T(1) … sequentielle Aufführungszeit T(p) … Ausführungszeit bei Verwendung von p Prozessoren Ein idealer Speedup würde eine lineare Kennlinie über der Anzahl der Prozessoren zeigen. Der reale Speedup liegt etwas darunter, weil sich mit steigender Anzahl verwendeter Prozessoren der Overhead vergrößert, (siehe Abbildung 3). Unter Effizienz versteht man den Speedup in Relation zur Anzahl der eingesetzten Prozessoren p: E p = S P / p Abbildung 3: Idealer und realer Speedup (Quelle: hikwww2.fzk.de) Einleitung 6 Das Höchstmaß an Effizienz ist 1. In der Realität sinkt die Effizienz mit steigender Anzahl an verwendeten Prozessoren. 1.2.3 Grenzen der Leistungs-Steigerung In diesem Abschnitt soll die Frage betrachtet werden, inwieweit es sinnvoll ist, die Anzahl der parallel arbeitenden Prozessoren beliebig zu erhöhen. Durch mehrere parallel laufende Prozesse bzw. Threads ergibt sich ein Verwaltungs-Overhead. Das betrifft die Aufteilung des Gesamtproblems in Teilprobleme und die grundsätzliche Untersuchung nach Parallelisierbarkeit, die durch Abhängigkeiten begrenzt ist. Ebenso stellt die Synchronisierung sowie die Kommunikation der Prozesse bzw. Threads einen limitierenden Faktor dar – besonders bei unterschiedlich langen Laufzeiten der zu synchronisierenden Prozesse bzw. Threads. Gesetz von Amdahl Programme können in der Praxis nie vollständig parallel abgearbeitet werden, weil sie oft sowohl Abschnitte beinhalten, die zeitlich voneinander abhängig sind, als auch solche, die sequentiell abgearbeitet werden müssen. Das heißt, es gibt einen Teil α eines Programms, der sich parallelisieren läßt, und einen restlichen Teil 1−α der sich nicht parallelisieren läßt. Wäre ein Programm, wie im Idealfall, vollständig parallelisierbar, dann wäre die Beschleunigung bei p Prozessoren 1/ p gegenüber der rein sequentiellen Abarbeitung. Da dieser Idealfall praktisch nicht zu erreichen ist, nehmen wir an, der parallelisierbare Anteil lasse sich um einen Faktor c beschleunigen. Der Speedup S(p) errechnet sich dann folgendermaßen: S p = 1 / 1−α α /c Das Gesetz von Amdahl zeigt, daß der nicht parallelisierbare Anteil eines Programms eine Leistungsbegrenzung darstellt, sodaß trotz beliebig vieler eingesetzter Prozessoren keine Geschwindigkeits-Steigerung mehr erreichbar ist. Ist der nicht parallelisierbare Anteil eines Programms 20 % der seriellen Gesamt-Laufzeit, dann konvergiert der maximal erreichbare Speedup gegen eine obere Schranke von 5. Durch den Einsatz weiterer Prozessoren würde sich die Programm-Laufzeit nicht mehr verringern, und die Effizienz ginge gegen Null. Das Gesetz von Amdahl stellt eine pessimistische Abschätzung des maximalen Speedups dar, da es nicht berücksichtigt, daß durch den Einsatz weiterer Prozessoren auch weitere Ressourcen – wie Einleitung 7 größere Cache-Speicher – zur Verfügung stehen. Dies könnte sogar zu einem super-linearen Speedup führen (allerdings stellt sich die Frage, ob Amdahl derartige Neben-Effekte in seinem Modell überhaupt berücksichtigen wollte). Gesetz von Gustafson Während Amdahl die Größe des zu lösenden Problems als konstant betrachtet, sieht Gustafson die Laufzeit des Programms als konstant an, und erklärt, daß durch die steigende Anzahl eingesetzter Prozessoren größere Probleme gelöst werden könnten. Bei zunehmender Problemgröße wird der serielle Anteil eines Programms gegenüber dem parallelen immer kleiner und unbedeutender. Unter dieser Betrachtungsweise kann man keine Grenze mehr festlegen, ab der eine größere Anzahl an Prozessoren nicht mehr sinnvoll wäre. Gustafson definiert den Speedup als S p = 1−α p∗α wobei α wieder den parallelen An- teil des Programms darstellt, und p die Anzahl der Prozessoren ist. Das Gesetz von Gustafson ist allerdings nicht bei Algorithmen geeignet, die einen hohen seriellen Anteil haben, oder für Programme, die in festgelegten Zeitgrenzen antworten müssen (Echtzeit-Anwendungen). Parallele Programmierung 8 2 Parallele Programmierung Aus dem Programmier-Paradigma der parallelen Programmierung ergeben sich zwei grundsätzliche Problemstellungen [5]: • die Zerlegung des Programms in parallelisierbare Abschnitte • die Synchronisation parallel ablaufender Programmteile Von parallelisierbaren Transaktionen (Prozesse oder Threads) spricht man, wenn die parallele (eventuell verzahnte) Ausführung zum selben Ergebnis führt, wie die sequentielle Ausführung. Nebenläufigkeit bedeutet, daß Transaktionen nebeneinander ausgeführt werden können, aber nicht zwingend tatsächlich parallel ablaufen müssen. Es kann auch eine scheinbare gleichzeitige Abarbeitung vorliegen wie bei Time-Sharing-Systemen. Als Multi-Tasking bezeichnet man die Nebenläufigkeit von mehreren Prozessen. Multi-Threading ist die Nebenläufigkeit von mehreren Threads (innerhalb eines Prozesses). Die Parallelisierung kann … • explizit durch den Programmierer erfolgen, oder • implizit, automatisch durch den Compiler. Die Parallelisierung durch den Programmierer erfordert eine gut überlegte Auswahl des geeigneten (parallelen) Algorithmus bzw. eine genaue Analyse des Problems. Diese Problem-Analyse kann auch maschinell durch einen Compiler erfolgen. Compiler, die derartige Analysen durchführen sind schwer zu bauen. Außerdem kann angenommen werden, daß der Programmierer die Parallelität seines Programms besser überblickt, als eine Software. Automatische Parallelisierung kommt vor allem auf Ebene der Kontrollstrukturen zum Einsatz. Für größere Programmkomponenten – wie Funktionen/Unterprogramme – sollte der Programmierer eingreifen. Für die kleinste Einheit – den einzelnen Befehlen – kann der Prozessor für Parallelität in Form von Pipelining sorgen [31]. 2.1 Zielsetzungen paralleler Programmierung Die parallele Programmierung verfolgt folgende Ziele [18]: 1. Ausgleichen der Rechenlast 2. Die notwendige Kommunikation soll im Verhältnis zum Rechenaufwand gering sein. Parallele Programmierung 9 3. Sequentielle Engpässe verringern 4. Gewährleistung der Skalierbarkeit Ausgleichen der Rechenlast Durch die Möglichkeit, Teilprobleme gleichzeitig zu bearbeiten, verringert sich die Last an jedem Prozessor-Kern. Kommunikation im Verhältnis zum Rechen-Aufwand Es erscheint nicht sinnvoll, die Zerlegung des Gesamtproblems in Teilprobleme beliebig feiner Granularität fortzusetzen. Ab einem gewissen Stadium (das vom Verflechtungsgrad der TeilProbleme abhängt), übersteigt der Kommunikations-Aufwand die Zeitersparnis der parallelen Verarbeitung. Wie hoch der passende Grad an Granularität ist, hängt hauptsächlich vom Algorithmus ab aber auch von der Plattform. Sequentielle Engpässe Sequentielle Engpässe ergeben sich aus zeitlichen Abhängigkeiten zwischen einzelnen Tasks. 2.2 Entwicklung eines parallelen Programmes Der Entwurfs-Prozess zur Entwicklung paralleler Programme kann in vier Phasen aufgeteilt werden (siehe Abbildung 4): 1. Partitionierung 2. Kommunikation 3. Agglomeration (Anhäufung) 4. Mapping Partitionierung und Kommunikation machen aus dem Gesamt-Problem nebenläufige, skalierbare Algorithmen. Agglomeration und Mapping sorgen für eine möglichst gleichmäßige und performante Aufteilung der Last auf die CPUs. Durch Feinabstimmung können Algorithmen optimiert werden, um die Leistung zu steigern [33]. Abbildung 4: 4 Phasen des Entwurfs-Prozesses (Quelle: http://kbs.cs.tuberlin.de/ivs/Lehre/SS04/VS/) Parallele Programmierung 10 Partitionierung Die Aufteilung des Gesamt-Problems in kleinere Teil-Probleme nennt man Partitionierung. Diese kann grundsätzlich entsprechend den Daten erfolgen (domain decomposition, data decomposition) oder nach den Funktionalitäten (functional decomposition) [40]. Kommunikation In der Kommunikations-Phase wird untersucht, ob und wie die einzelnen Teilprobleme untereinander Informationen austauschen müssen. Dadurch entsteht wiederum eine Verflechtung der zuvor isolierten Tasks. Die Tasks müssen untereinander koordiniert werden. Agglomeration Hierbei werden kleine Tasks wiederum zu größeren zusammengefaßt, wenn dadurch der Kommunikations-Aufwand vermindert werden kann, oder die Performance verbessert werden kann. Diese Phase dient der Vorbereitung für ein sinnvolles Mapping, d.h. eine möglichst gleichmäßige Aufteilung der Problem-Größen auf die Prozessor-Kerne. Mapping Die Zuteilung der Teilprobleme (Tasks) auf die zur Verfügung stehenden Prozessoren/Prozessor-Kerne wird als Mapping bezeichnet. Die Zuteilung soll so erfolgen, daß die Ressourcen möglichst gut (d.h. gleichmäßig) ausgelastet sind. Durch Kommunikation stark verflochtene Tasks sollen am gleichen Prozessor-Kern ausgeführt werden, um den Overhead an Kommunikation klein zu halten. 2.3 Skalierbarkeit Ein wichtiges Kriterium für den Einsatz eines Mehrprozessor-Systems ist die Skalierbarkeit eines Problems. Skalierbarkeit untersucht den Ressourcen-Bedarf eines Programms. Ein Programm ist gut skalierbar, wenn es mit n-mal so vielen Prozessoren ein n-fach größeres Problem in gleicher Zeit lösen kann, bzw. wenn das Programm mit n-mal so vielen Prozessoren das gleiche Problem in einem n-tel der ursprünglichen Zeit lösen kann. Neben dieser Skalierbarkeit der Rechenzeit kann man analog eine Skalierbarkeit des Speicherbedarfs definieren. Aufgrund des KommunikationsOverhead zwischen den Prozessen bzw. Threads werden in der Praxis diese idealen Werte kaum erreicht [29]. Parallele Programmierung 11 Um die Skalierbarkeit eines Anwendungsprogramms zu erhöhen können Maßnahmen ergriffen werden, wie das Cachen von Inhalten, langsame Zugriffe auf Datenträger auf später verschieben, synchrone Aufrufe vermeiden, die das System blockieren usw. Bei Multiprozessor-Systemen spricht man von vertikaler Skalierung. Hier wird die Leistungsfähigkeit eines einzelnen Rechners erhöht. Im Gegensatz dazu wird bei der horizontalen Skalierung die Last auf zusätzliche Rechner verteilt. Das Blade-Konzept unterstützt einen solchen Ansatz. Blade-Server sind Baugruppen von Prozessoren mit eigener Hauptplatine samt Arbeitsspeicher. Die Platinen werden in Slots eingeführt, und nutzen gemeinsam die Netzteile des Baugruppenträgers [30]. 2.4 Multicore-Prozessoren Parallele Algorithmen benötigen zu ihrer parallelen Abarbeitung auch die entsprechende Hardware in Form von mehreren CPUs. Diese können als gekoppelte Rechner vorliegen, oder als MulticoreProzessoren. Die Multicore-Variante ist kostengünstiger, als der Einbau mehrerer Prozessor-Chips. Als Multicore-Prozessoren werden Prozessoren bezeichnet, die über mehrere CPUs (Central Processing Units) - auf einem einzigen Chip – verfügen. Es handelt sich um mehrere vollständige Prozessoren inklusive eigener arithmetisch-logischer Einheit (ALU), Registersätze und - sofern vorhanden - Floating Point Unit (FPU). Nach der Anzahl der vorhandenen Cores spricht man von Dual-Core-, Triple-Core-, Quad-Core-Prozessoren usw. Unix, der SMP-Linux-Kernel und Microsoft Windows ab XP unterstützen Multicore-Prozessoren [31]. Nach dem Aufbau bzw. der Funktionsweise der Prozessoren lassen sich symmetrische und asymmetrische Multicore-Prozessor-Systeme unterscheiden [18]: • Symmetrische Multicore-Prozessoren: In symmetrischen Multicore-Prozessor-Systemen sind alle Kerne gleichartig. Das bedeutet, daß Programme auf beliebigen Kernen ausgeführt werden können. • Asymmetrische Multicore-Prozessoren: In asymmetrischen Multicore-Prozessor-Systemen existieren verschiedenartige Kerne. Die Kerne haben unterschiedliche Maschinensprachen, und übernehmen unterschiedliche Aufgaben. Einige Kerne arbeiten wie Hauptprozessoren, andere wie Koprozessoren. Ein Pro- Parallele Programmierung 12 gramm kann nur auf einem solchen Kerntyp ausgeführt werden, für den es geschrieben wurde. 2.5 Parallelrechner Parallelrechner kommen vor allem für Simulationen zum Einsatz. Rechnerisch aufwändige Simulationen werden hauptsächlich in den Natur- und Ingenieurs-Wissenschaften benötigt. Mit derartigen Simulationen können reale Experimente ersetzt werden. Das ist oft wesentlich kostengünstiger als das reale Experiment (z.B. Crashtests bei Autos) [21]. Parallelrechner zeichnen sich dadurch aus, daß mehrere Rechner mit der Lösung der gleichen Aufgabe beschäftigt sind. Man kann verschiedene Architiektur-Modelle von Parallelrechnern unterscheiden. Analog dazu ergeben sich – je nach Rechnerarchitektur - entsprechende Programmiermodelle [28]. Parallelrechner lassen sich folgendermaßen klassifizieren [32]: • nach der Art der Befehlsausführung (Klassifikation nach Flynn) • nach der Speicherorganisation (verteilter Speicher oder gemeinsamer Speicher) 2.5.1 Klassifikation nach der Art der Befehlsausführung Hierbei werden die Rechner-Architekturen nach der Kombination von ein oder mehreren Befehlsströmen und ein oder mehreren Datenströmen unterteilt. Kriterien gleiche Instruktion single instruction unterschiedliche Operationen multiple instruction gleicher Datensatz single data (SD) SISD MISD unterschiedliche Datensätze multiple data (MD) SIMD MIMD Klassifikation nach Flynn (Quelle: http://www.rz.uni-karlsruhe.de/rz/hw/sp) Parallele Programmierung 13 Beschreibung der vier Architekturen: • SISD (Single Instruction, Single Data): Ein Befehl verarbeitet einen Datensatz (herkömmliche Rechnerarchitektur eines seriellen Rechners, wie PCs oder Workstations in Von-Neumann- oder Harvard-Architktur). • SIMD (Single Instruction, Multiple Data): Ein Befehl verarbeitet mehrere Datensätze, n Prozessoren führen zu einem Zeitpunkt den gleichen Befehl, aber mit unterschiedlichen Daten aus (Vektorrechner und Prozessor-Arrays für Spezial-Anwendungen). Auf allen Prozessoren läuft das gleiche Programm mit unterschiedlichen Daten. Diese Architektur zeichnet sich durch eine große Portabilität und einfache Verwaltung aus, da es nur ein ausführbares Programm gibt (siehe Abbildung 5). Abbildung 5: SIMD (Quelle: http://kbs.cs.tu-berlin.de/ivs/Lehre/SS04/VS/) • MISD (Multiple Instruction, Single Data): Mehrere Befehle verarbeiten den gleichen Datensatz, und führen somit redundante Berechnungen durch. Diese Rechnerarchitektur ist nie realisiert worden. • MIMD (Multiple Instruction, Multiple Data): Unterschiedliche Befehle verarbeiten unterschiedliche Datensätze. Auf jedem Prozessor kann ein anderes Programm laufen. Diese Architektur ist flexibel, aber schwieriger zu programmieren, als die zuvor genannten. MIMD ist das Konzept fast aller modernen Parallelrechner. Parallele Programmierung 14 2.5.2 Klassifikation nach der Speicherorganisation • Parallel-Rechner mit gemeinsamem Hauptspeicher: Alle Prozessoren greifen auf einen gemeinsamen Hauptspeicher zu. Bei vielen Prozessoren ist die Zeit für den Hauptspeicherzugriff der limitierende Faktor. • Parallel-Rechner mit verteiltem Speicher: Jeder Prozessor kann nur auf seinen eigenen lokalen Speicher zugreifen. Der Zugriff auf Daten anderer Prozessoren erfolgt über ein Kommunikationsnetzwerk. Alternativ kann das Betriebssystem den gesamten Hauptspeicher als Virtual Shared Memory betrachten. 2.6 Kommunikationsnetzwerke Die Leistungsfähigkeit eines Parallelrechners wird wesentlich durch das Kommunikationsnetz bestimmt. Man unterscheidet unidirektional und bidirektional nutzbare Netzwerke. Weiters wird die Leistung durch die Topologie des Netzwerkes bestimmt. So kann es bei bestimmten Topologien auch zu Kommunikations-Engpässen kommen (z.B. bei Baum-Strukturen an der Wurzel) [32]. 2.7 Kopplungsgrad von Mehrprozessor-Systemen Bei Systemen mit enger Kopplung nutzen die Prozessoren einen gemeinsamen Arbeitsspeicher. In Systemen mit loser Kopplung verfügen die Prozessoren jeweils über einen eigenen Arbeitsspeicher. Von massiv parallelen Rechnern spricht man, wenn eine große Zahl von Prozessoren (bis zu mehreren tausend) mit jeweils etwas Arbeitsspeicher über ein dichtes Netz mit individuellen, sehr schnellen Verbindungen gekoppelt sind [32]. 2.7.1 Eng gekoppelte Mehrprozessorsysteme Bei eng gekoppelten Mehrprozessorsystemen greifen wenige (derzeit 2 bis 16) Prozessoren auf einen geteilten großen Arbeitsspeicher zu. Sie befinden sich an einem Ort und benutzen einen gemeinsamen Speicherbus. Parallele Programmierung 15 2.7.2 Lose gekoppelte Systeme Um einen guten Grad an Parallelisierung zu erreichen, sind Systeme anzustreben, die einen geringen Grad an Abhängigkeiten ihrer Komponenten aufweisen (lose gekoppelte Systeme). So kann jede CPU ihre Arbeit für sich erledigen, ohne daß zu viel Aufwand an Kommunikation betrieben werden muß. Bei lose gekoppelten Mehrprozessorsystemen (loosely-coupled multi-processor system) verfügt jeder Prozessor über einen eigenen (lokalen) Arbeits-Speicher. Die Prozessoren kommunizieren über geteilte Verbindungen in der Form lokaler Netze oder Clusternetze. 2.7.3 Massiv parallele Rechner Bei massiv parallelen Rechnern (massively parallel systems) sind eine große Zahl von Prozessoren (bis zu mehreren tausend) mit etwas Arbeitsspeicher in einem dichten Netzwerk mit individuellen, sehr schnellen Verbindungen gekoppelt. Die Anzahl und die Übertragungskapazität der Verbindungen steigen mit der Zahl der verbundenen Prozessoren [32]. 2.8 Parallelisierungsstrategien Bevor man sich über Parallelisierung eines Codes Gedanken macht, sollte man nach der optimalen sequentiellen Variante suchen. Erst wenn der sequentielle Code optimiert ist, sollte man mit der Parallelisierung beginnen. Parallelisierungsbestrebungen werden durch Abhängigkeiten behindert. Dabei können Abhängigkeiten zwischen den Daten bestehen oder auch innerhalb des angewandten Algorithmus. Parallelisierungsstrategien basieren auf dem Prinzip des Zerlegens („divide and conquer“). Man unterscheidet eine Datenzerlegung und eine funktionale Zerlegung. Schwierigkeiten bereiten vor allem Problemstellungen, die Kommunikation zwischen den Prozessen bzw. Threads voraussetzen. Hier ist es schwierig, den passenden Grad an Parallelität zu finden. Zu starke Parallelisierung könnte dazu führen, daß der Aufwand für Kommunikation höher wird, als die Einsparung, die durch Parallelisierung erreicht wird, sodaß das Programm im Endeffekt langsamer laufen würde als bei sequentieller Abarbeitung. Deshalb ist es auch üblich, einen Schwellwert (Threshold) für die Problemgröße festzulegen, ab dem eine Problemstellung erst parallel verarbeitet wird. Ist die Problemgröße noch unterhalb des Schwellwertes, wird eine sequentielle Verarbeitung bevorzugt [18]. Parallele Programmierung 16 2.8.1 Gebietszerlegung (domain decomposition) Diese Zerlegung basiert auf der Datenparallelität. Das Problem läßt sich in Teilprobleme aufteilen, wie beispielsweise die Abszissen-Abschnitte bei der numerischen Integration. Derartige Problemstellungen sind die angenehmsten, weil sich ihre Zerlegung durch die Natur der Sache ergibt. Die Teilprobleme werden dann möglichst gleichmäßig auf alle verfügbaren Prozessorkerne aufgeteilt. Dabei ist – neben der Anzahl der Teilprobleme - auch die Problemgröße der Teilprobleme zu berücksichtigen. Auf jeden Kern sollte nicht primär die gleiche Anzahl an Teil-Problemen fallen, sondern es sollte vor allem jeder Kern die möglichst gleiche Gesamt-Problemgröße zu bewältigen haben. Zu diesem Zweck kann eine zyklische Aufteilung der Teilprobleme gegenüber einer blockweisen Aufteilung zielführender sein. 2.8.2 Funktionale Aufteilung (functional decomposition) Diese Zerlegung basiert auf der Kontrollparallelität. Bei der funktionalen Aufteilung wird das Gesamtproblem in kleinere, weniger komplexe Teilprobleme zerlegt. Die Zerlegung erfolgt in der Weise, daß jedes Teilproblem eine geschlossene Funktionalität darstellt. Das heißt, die Teilprobleme sind weitgehend voneinander unabhängig. Die Teilprobleme können also als Funktionen betrachtet werden. Die Funktionen des Programms (z.B. bei Strömungsberechnungen) werden auf verschiedene Prozessoren aufgeteilt. 2.8.3 Verteilung von Einzelaufträgen (task farming) Hier übernimmt ein Master-Prozess die Verwaltungsarbeiten. Der Master zerlegt das Gesamtproblem in mehrere kleinere Tasks, und verteilt diese an die Slave-Prozesse. Ebenso ist der Master für das Einsammeln der Ergebnisse der Slaves zuständig und für die Berechnung des Gesamt-Ergebnisses. Zwischen den einzelnen Slaves findet normalerweise keine Kommunikation statt. Die Lastverteilung kann statisch oder dynamisch erfolgen. Bei statischer Lastverteilung werden die einzelnen Tasks am Beginn der Berechnung auf die Slaves verteilt, sodaß der Master vorübergehend frei von Verwaltungsarbeiten ist und eventuell auch einen Task übernehmen kann. Bei der dynamischen Lastverteilung hingegen werden die Tasks flexibel an die Slaves zugeteilt, je nach der Auslastung der Slaves. Dieses Vorgehen ist vorteilhaft, wenn die Anzahl der Tasks nicht im vorhinein bekannt ist, oder die Anzahl der Tasks die Anzahl der zur Verfügung stehenden Slaves übersteigt, oder wenn die Ausführungszeiten der Tasks nicht voraussagbar sind bzw. sehr unausgewogen sind, und somit vorerst keine balancierte Lastverteilung innerhalb der Slaves besteht [33]. Parallele Programmierung 17 Task-Farming kann einen hohen Skalierbarkeitsgrad erreichen, jedoch kann der Master-Prozess einen Engpass bedeuten. Daher kann es sinnvoll sein, mehrere Master einzusetzen, wobei dann jeder von ihnen für eine Gruppe von Slaves zuständig ist. Abbildung 6: Task-Farming (Master-Slave) Quelle: http://kbs.cs.tu-berlin.de/ivs/Lehre/SS04/VS/ 2.8.4 Daten-Pipelining Daten-Pipelining basiert auf dem Prinzip der funktionalen Zerlegung. Dabei werden Berechnungen in einzelne Phasen zerlegt (siehe Kapitel 1.1.2 Pipelines). Die Phasen können zeitlich überlappend abgearbeitet werden. Die Effizienz ist abhängig von einer gleichmäßigen Auslastung der Pipelines. Dieses Verfahren wird häufig in Datenreduktions- und Bildverarbeitungs-Anwendungen benutzt [33]. Parallele Programmierung 18 Abbildung 7: Data-Pipelining (Quelle: http://kbs.cs.tu-berlin.de/ivs/Lehre/SS04/VS/) 2.8.5 Spekulative Zerlegung Bei Verzweigungen des Programm-Flusses können vorsorglich Programmzweige ausgewertet werden, obwohl noch nicht feststeht, daß sie tatsächlich gebraucht werden. Werden die Berechnungen später nicht gebraucht, dann werden sie einfach verworfen. Dieses Verfahren kommt nur zum Einsatz, wenn keines der zuvor genannten Verfahren angewandt werden kann [33]. 2.8.6 Hybrides Zerlegungsmodell Das hybride Zerlegungsmodell wird auch als geschichtete Zerlegung bezeichnet. Es kommt bei großen, umfangreichen Anwendungen zum Einsatz. Dabei wird das Gesamtproblem in Schichten unterteilt, und für jede Schicht ein geeignetes Zerlegungsverfahren gewählt [33]. 2.9 Kommunikationsmodelle Prozesse einer parallelen Anwendung müssen im allgmeinen Daten austauschen und miteinander kommunizieren. Diese Kommunikation erfolgt über ein schnelles Kommunikations-Netzwerk. 2.9.1 Shared-Memory-Programmierung Die Parallelisierung erfolgt automatisch durch Compiler-Optionen, oder mittels Parallelisierungsdirektiven durch Verwendung von parallelen mathematischen Bibliotheken. Die Prozessor-Kommunikation geht direkt über einen schnellen breitbandigen Datenbus [33]. 2.9.2 Message-Passing-Programmierung Das Rechengebiet (domain) wird auf alle Prozessoren aufgeteilt (domain decomposition). Jeder Prozessor rechnet lokal und kommuniziert über optimierte MPI-Funktionen mit den anderen Pro- Parallele Programmierung 19 zessoren. Die Effizienz sinkt oft mit der Anzahl der Prozessoren, da die Interprozessor-Kommunikation stark zunimmt. Kommunikationsmethoden [33]: • Synchron, d.h. alle Tasks nehmen gleichzeitig an der Kommunikation teil. • Asynchron, d.h. die Tasks führen die Kommunikationsoperationen zu unterschiedlichen Zeitpunkten aus. • Blockierend, d.h. der Task wartet, bis die Kommunikationsoperation beendet ist. • Nicht blockierend, d.h. der Task stößt die Kommunikationsoperation an und führt dann simultan zur Kommunikation andere Operationen aus. Unterstützung des parallelen Programmierens in Java 20 3 Unterstützung des parallelen Programmierens in Java Betriebssysteme erlauben die Zuweisung von bestimmten CPUs für die Abarbeitung eines Anwendungs-Programms (CPU-Affinity). Inwiefern es klug ist, den Prozess-Scheduler auf diese Weise zu bevormunden, ist fraglich. Allerdings darf sich dieser Trend nicht in die AnwendungsEntwicklung von Java-Programmen fortsetzen, da sonst die angestrebte Plattform-Unabhängigkeit von Java nicht mehr gegeben wäre. Es kann ja im vorhinein nicht gesagt werden, wie viele Cores auf der Zielplattform zur Verfügung stehen werden. Das Programm muß auf einem SingleCoreProzessor ebenso lauffähig sein, wie auf beispielsweise einem Prozessor mit 64 Cores. Java zählt zu den wenigen Programmiersprachen, bei denen von Anfang an für Parallelismus vorgesorgt wurde. Und Java ist keineswegs eine Programmiersprache, in der Stillstand in der technologischen Entwicklung herrscht. So sind in den rund 15 Jahren, in denen Java mittlerweile schon zur Verfügung steht, viele Neuerungen dazugekommen – insbesondere auch Neuerungen, die das parallele Programmieren betreffen. In den folgenden Abschnitten sollen diese erläutert werden. 3.1 Klassische Thread-Programmierung Schon von Anfang an war Java für die parallele Programmierung durch das Konzept der Threads gerüstet. Threads Threads sind Ausführungsstränge, sie werden auch als leichtgewichtige Prozesse bezeichnet. Threads unterscheiden sich von (schwergewichtigen) Prozessen dadurch, daß sie sich untereinander bestimmte Ressourcen teilen, was den Kontext-Switch beim Timesharing-Verfahren – das besonders bei Einprozessor-Systemen von Bedeutung ist – beschleunigt. Die gemeinsam verwendeten Ressourcen sind das Code-Segment, das Daten-Segment, sowie die verwendeten Datei-Deskriptoren und Netzwerkverbindungen. Threads sind Teil eines (schwergewichtigen) Prozesses. Ein Prozess kann nur einen einzigen Thread enthalten (z.B. der main-Thread in einem Java-Programm), oder aus mehreren Threads (bei der Multithread-Programmierung). Threads „leben“ - wie Prozesse – nur einmal. Sie haben die gleichen Zustände von „erzeugt“, „lauffähig“, „laufend“, „blockiert“ Unterstützung des parallelen Programmierens in Java 21 bzw. „unterbrochen“ bis „beendet“. Soll ein gleichartiger Task ein weiteres Mal laufen, muß er neu als Thread instanziert werden, und mit der start()-Methode zum Laufen gebracht werden [34]. Wie Threads implementiert sind, hängt von der Java Virtual Machine (JVM) ab, und in weiterer Folge dann, wie Threads vom darunter liegenden Betriebssystem unterstützt werden. Bei Betriebssystemen, die Threads direkt unterstützen, kann die JVM Java-Threads direkt auf das Betriebssystem abbilden (native Threads). Bei anderen Betriebssystemen erfolgt die Implementierung der Java-Threads in Form sogenannter „green threads“. Green Threads werden von der JVM emuliert, ohne dabei vom Betriebssystem unterstützt zu werden. Sie werden - im Gegensatz zu nativen Threads - im User-Modus ausgeführt. Native Threads können auf preemptive Art gewechselt werden, während green Threads blockieren müssen, oder explizit die Kontrolle abgeben müssen, damit es zu einem Thread-Wechsel auf der CPU kommt. Der Trend geht weg von Green-Threads hin zu nativen Threads [20]. Multi-Thread-Programmierung Jedes lauffähige Java-Programm braucht eine main-Funktion, um gestartet werden zu können. Der Inhalt dieser main-Funktion bildet den Inhalt des main-Threads, den vorläufig einzigen Ausführungsstrang des Programms. Durch den Start neuer Threads kommen weitere Ausführungstränge hinzu, die parallel zum main-Thread abgearbeitet werden. Bei MehrkernProzessoren kann ein Kern mit der Abarbeitung eines bestimmten Threads beauftragt werden. So wird der Thread ohne Unterbrechung abgearbeitet. Stehen weniger Prozessor-Kerne als laufende Threads zur Verfügung, muß der Schedulder des Betriebssystems die Abarbeitungs-Reihenfolge der Threads regeln. Üblicherweise wird in dieser Situation das Time-Sharing-Verfahren (ZeitscheibenVerfahren) angewandt, bei dem in regelmäßigen Zeit-Intervallen (Zeit-Scheiben) ein Wechsel des Threads stattfindet, der von der CPU bearbeitet wird. So ein Wechsel (Context-Switch) ist mit einem Zeit-Aufwand verbunden, der nicht für die Abarbeitung von Threads genutzt werden kann. Allerdings ist dieser Aufwand bei Threads wesentlich geringer, als bei (schwergewichtigen) Prozessen. Im Fall von mehreren Prozessor-Kernen, die zur Abarbeitung der Threads zur Verfügung stehen, ist es einleuchtend, daß sich dadurch eine Geschwindigkeits-Steigerung ergibt. Allerdings kann MultiThread-Programmierung auch die Ausführung auf Single-Prozessor-System beschleunigen, wenn die Threads unterschiedliche Ressourcen beanspruchen. Dabei kommt es auf eine passende Unterstützung des parallelen Programmierens in Java 22 Kombination der quasi-parallel ausgeführten Threads an. So können bei lange dauernden Operationen Wartezeiten sinnvoll für die Abarbeitung anderer Threads genutzt werden. Thread und Runnable Jede Klasse, die die Möglichkeit bieten soll, als Thread ausgeführt zu werden, muß die Schnittstelle Runnable implementieren. Java bietet eine Klasse Thread, die bereits diese Schnittstelle implementiert. Die Schnittstelle Runnable schreibt (nur) die parameterlose Methode run() vor. Klassen, die zur parallelen Abarbeitung vorgesehen sind, können auch von der Klasse Thread abgeleitet werden. Allerdings hat man sich dadurch die Vererbung aus anderen Klassen verbaut, da Java keine Mehrfach-Vererbung unterstützt. Daher ist die Implementierung des Interfaces Runnable der Weg, der eine weitere Ableitung offen läßt und daher zu bevorzugen ist. Wenn eine parallele Abarbeitung in Gang gebracht werden soll, muß ein Thread-Objekt erzeugt werden. Dazu erhält der Konstruktor des Thread das Objekt, das das Interface Runnable implementiert. Damit der Thread auch in die Warteschlange des Prozess-Schedulers kommt, muß die start()Methode des Thread-Objekts aufgerufen werden. Die start()-Methode bewirkt den (internen) Aufruf der run()-Methode laut Schnittstelle Runnable. Im Code schaut das so aus: class BeispielKlasse implements Runnable { ... } ... BeispielKlasse beispielObjekt = new BeispielKlasse(); Thread thread = new Thread(beispielObjekt).start(); Wichtig dabei ist, daß der Benutzer nicht direkt die run()-Methode aufrufen darf. Ein solcher Aufruf würde nicht zu einer parallelen Abarbeitung führen, sondern zu einer seriellen Ausführung. Threads haben eine bestimmte Priorität. Ohne explizite Festlegung dieser Priorität ist sie gleich hoch wie die Priorität des erzeugenden Threads. Java unterstützt 10 Prioritätsstufen. Für das Scheduling entscheidend ist allerdings auch, wie viele Prioritätsstufen das Betriebssystem unterstützt, und inwiefern der Scheduler des Betriebssystem Prioritäten überhaupt berücksichtigt. Threads können auch als Daemon-Threads (Hintergrund-/Dienst-Threads) gekennzeichnet werden. Daemon-Threads laufen auf unbestimmte Zeit weiter (d.h. über die Laufzeit des erzeugenden Threads hinaus). Die Kennzeichnung eines Threads als Daemon hat mit der Methode setDaemon() noch vor dem Aufruf der start()-Methode zu erfolgen. Unterstützung des parallelen Programmierens in Java 23 Weiters können für Threads Namen vergeben werden. Bei der Vergabe kontrolliert weder der JavaCompiler noch die JVM ob die Namen eindeutig sind. Vergibt der Programmierer keinen Namen, dann wird automatisch ein Name der Form Thread<n> vergeben, wobei n eine laufende Nummer ist. Außerdem können Threads zu Gruppen zusammengefaßt werden. Thread-Gruppen erleichtern die Verwaltung bei einer großen Anzahl von Threads. Wichtig für die Synchronisation von Threads ist die Methode join(). Damit können Threads auf die Beendigung anderer Threads warten. Kritische Regionen können mit dem Schlüsselwort synchronized vor mehrfacher Abarbeitung gesperrt werden. Die Sperre kann sich auf Methoden, Blöcke oder Objekte beziehen. Eine feingranulare Sperre ist zu bevorzugen, um Deadlocks möglichst zu vermeiden. Derartige Sperren schränken die Parallelität einer Anwendung ein [20]. 3.2 Thread-Programmierung mittels Thread-Pools Die klassische Thread-Programmierung wie sie Java von Anfang an angeboten hat, ist gekennzeichnet durch einen niedrigen Abstraktionsgrad. Mit Java 1.5 wurden mit dem Package java.util. concurrent die „Concurrency Utilities“ eingeführt. Dieses Package beinhaltet high-level APIs für nebenläufige Programmierung. Es gibt zwei Unterpakete zur Realisierung von kritischen Abschnitten: java.util.concurrent.atomic und java.util.concurrent.locks. Die Concurrency Utilities bestehen aus folgenden Teilen [16]: • Concurrent Collections Diese bieten fein-granularere Sperren, als das Collections Framework von Java 2. • Executor-Framework Das Executor-Framework beinhaltet das neue Konzept der ThreadPools. • Synchronizers • Locks und Conditions • Atomic Variables … zur atomaren Manipulation von Variablen Unterstützung des parallelen Programmierens in Java 24 Executor-Klassen Die Schnittstelle Executor schreibt eine execute()-Methode vor, der ein Objekt übergeben wird, das die Schnittstelle Runnable implementiert. Implementierungen zum Executor-Interface sind ThreadPoolExecutor und ScheduledThreadPoolExecutor. In diesen Implementierungen werden Thread-Pools benutzt, um die häufige Erzeugung neuer Threads einzuschränken. Threads werden in den Pools nach getaner Arbeit nicht beendet, sondern halten sich bereit, um weitere Aufgaben zu übernehmen. ScheduledThreadPoolExecutor ist für wiederholte Ausführung gleicher Threads gerüstet. Die Klasse Executors bietet Methoden zur Erzeugung und Handhabung derartiger Objekte. Die Schnittstelle ExecutorService erweitert Executor, und managt Queuing und Scheduling von Tasks, und erlaubt kontrolliertes Shutdown. Threads mit Rückgabewert Da Objekte, die Runnable implementieren, nur zur reinen Abarbeitung gedacht sind, wurde mit Java 1.5 eine weitere Schnittstelle Callable eingeführt. Diese ermöglicht eine Rückgabe berechneter Ergebnisse. So wie Runnable die Methode run() vorschreibt, schreibt Callable call() als einzige parameterlose Methode vor, um das Ergebnis zu bestimmen. Über die get()Methode eines sogenannten Future-Objektes erhält man das Ergebnis. Die Methode get() blockiert solange, bis das Ergebnis zur Verfügung steht. Das Future-Objekt entsteht z.B. bei der Übergabe des Callable-Objektes an ein ExecutorService-Objekt durch dessen Methode submit(). Ein typisches Code-Segment sieht folgendermaßen aus: /* CallableKlasse schreiben */ class MyCallable implements Callable<ReturnType> { public ReturnType call() { /* Belegung des ReturnTypeObjekts */ } } ... MyCallable task = new MyCallable( ReturnObj ); ExecutorService exe = Executors.newCachedThreadPool(); Future<ReturnType> result = exe.submit( task ); ReturnType resOb = result.get(); ... ExecuterService bietet neben der Methode submit() noch die Methode invokeAll(), mit der ganze Sammlungen (Collections) von Tasks zur Abarbeitung übergeben werden können. Unterstützung des parallelen Programmierens in Java 25 Entsprechend wird dabei eine Liste von Future-Objekten erzeugt, um die einzelnen Rückgaben später abfragen zu können. 3.3 Fork/Join-Framework Für Java 1.7 ist bezüglich nebenläufiger Programmierung ein Fork/Join-Framework geplant. Das Grundprinzip dieses Frameworks ist, Probleme, u.a. rekursiv, in Teil-Probleme aufzuteilen, die parallel gelöst werden, und deren Ergebnisse nach Beendigung der Tasks wieder zusammengestellt werden (siehe Abbildung 9). Es soll ein leichtgewichtiges Java-Framework werden, das sich gut portieren läßt, sodaß sich gute plattform-unabhängige Speedups ergeben. Neu an dieser Technologie ist vor allem das Prinzip des Work-Stealings, die aufgeteilten Tasks dynamisch an die beteiligten CPUs zu vergeben. Damit soll vermieden werden, daß auf die Beendigung der Arbeit einiger CPUs noch gewartet werden muß, während andere Prozessoren bereits ohne Arbeit sind. Voraussetzung für dieses Work-Stealing ist eine genügend fein-granulare Aufsplittung des Gesamtproblems [11]. Abbildung 8: Prinzip der Arbeitsweise des Fork/Join-Frameworks (Quelle: http://5l3vgw.bay.livefilestore.com/) Design des Fork/Join-Frameworks Zuerst wird ein Pool von Worker-Threads eingerichtet. Damit können ausständige Tasks (die noch abgearbeitet werden müssen) an Threads vergeben werden, ohne daß der Thread erst (aufwändig) Unterstützung des parallelen Programmierens in Java 26 kreiert werden muß. Üblicherweise werden so viele Worker-Threads erzeugt, wie CPUs vorhanden sind. Optimierungs-Überlegungen gehen dahin, daß man eventuell weniger Worker-Threads erzeugt, und somit CPUs für weitere (unvorhergesehene) Aufgaben frei läßt. Oder man erzeugt mehr Worker-Threads, als CPUs zur Verfügung stehen, damit Threads, die ihre Aufgaben vorzeitig erledigt haben, gleich weitere Sub-Tasks zur Bearbeitung vorfinden. Die Zuteilung (mapping) der Threads an die CPUs übernimmt die Java-Virtual-Machine (JVM) und das Betriebssystem. Fork/Join-Tasks sind keine Instanzen klassischer Threads, sondern leichtgewichtiger AusführungsKlassen namens FJTask. Leichtgewichtig deshalb, weil für Worker-Threads einige Features überflüssig sind – die klassische Tasks haben – wie beispielsweise die Unterbrechungsbehandlung durch I/O-Operationen. FJTask implementiert – gleich wie Thread – das Interface Runnable. Ebenso wie bei klassischen Threads kann auch ein FJTask durch Übergabe eines Runnable-Objekts erzeugt (und gestartet) werden. Die zur Verarbeitung anstehenden Tasks werden in Warteschlangen (Queues) verwaltet. Jeder Worker-Thread hat eine eigene Warteschlange. Das Fork/Join-Framework verwendet spezielle Warteschlangen namens Deque. Deque steht für double-ended-Queue. Das bedeutet, daß – anders als bei normalen Queues - Tasks an beiden Enden der Queue entnommen werden können [37]. Die Klasse FJTaskRunnerGroup richtet die Pools von Worker-Threads ein, und startet die Ausführung der zugewiesenen Tasks. Diese Zuweisung erfolgt mit den Methoden invoke() bzw. coInvoke(). Beide Methoden sorgen nicht nur für eine parallele Ausführung, sondern auch für eine Synchronisation der übergebenen Threads. Im Code sieht das beispielsweise so aus: class Fib extends FJTask { ..... FJTaskRunnerGroup group = new FJTaskRunnerGroup(2); Fib f = new Fib(35); group.invoke(f); ..... @Override public void run() { ... // SubTasks erzeugen: Fib f1 = new Fib(n 1); Fib f2 = new Fib(n 2); // Tasks parallel ausführen und synchronisieren: coInvoke(f1, f2); ... Als Alternative zur Ableitung von der Klasse FJTaks, kann die Ableitung von der Klasse Fork JoinTask erfolgen. ForkJoinTask ist eine abstrakte Basisklasse für Tasks, die mithilfe der Klasse ForkJoinPool ausgeführt werden. Subklassen von ForkJoinTask sind Recursi Unterstützung des parallelen Programmierens in Java 27 veAction und RecursiveTask, von denen der Benutzer seine Klassen ableiten kann. Recur siveAction stellt die ergebnislose Variante dar, während mit RecursiveTask ein Ergebnis kreiert wird. Der Code ändert sich entsprechend: class Fib extends RecursiveTask<Integer> { ..... ForkJoinPool pool = new ForkJoinPool(numTasks); Fib f = new Fib(num); pool.invoke(f); ..... @Override protected Integer compute() { // SubTasks erzeugen: Fib f1 = new Fib(n – 1); Fib f2 = new Fib(n – 2); // Tasks parallel ausführen und synchronisieren: invokeAll(f1, f2); Die Klasse ForkJoinTask stellt die leichtgewichtige Form eines Future-Objekts dar. Die invoke()-Methoden sind semantisch einem fork- und join-Aufruf äquivalent. Das heißt die Synchronisation der Tasks erfolgt automatisch. ForkJoinTasks sollten relativ kleine Teil-Probleme lösen. Die Anzahl der Basis-Operationen pro Task sollte idealerweise zwischen 100 und 10000 betragen (lt. Doug Lea auf http://gee.cs.oswego.edu/). Wenn die Tasks zu groß sind, kann der Parallelismus den Durchsatz nicht mehr verbessern; sind sie zu klein, wirkt sich der Verwaltungs-Overhead der Tasks negativ auf die Performance aus. Work-Stealing Eine wesentliche Neuerung des Fork/Join-Frameworks ist Work-Stealing. Das bedeutet, daß unbeschäftigte Threads (die ihre übertragenen Tasks schon beendet haben) sofort weitere Tasks aus den Warteschlangen anderer Threads übernehmen. Damit Work-Stealing optimal funktioniert, wird im Fork/Join-Framework die schon erwähnte spezielle Warteschlange Deque verwendet. Jeder fork-Aufruf stellt den neuen (kleineren) Task – anders als bei normalen Queues – an den Head der Deque (LIFO-Prinzip). Das hat den Effekt, daß die aufwändigen Tasks am Tail der Deque landen, während sich die kleineren Tasks am Head befinden. Worker-Threads holen sich die Tasks von ihrer eigenen Deque immer vom Head (nach dem LIFOPrinzip), während sich „stehlende“ Worker-Threads die Tasks immer vom Tail einer fremden Deque Unterstützung des parallelen Programmierens in Java 28 – das ist die Warteschlange eines anderen Worker-Threads – holen. Das läuft dann nach dem FIFOPrinzip. Der Sinn dieser Vorgangsweise liegt darin, daß sich „stehlende“ Threads die relativ aufwändigen Tasks holen, und dadurch auch relativ lange beschäftigt sind. Dadurch reduzieren sich aber auch die Zugriffe auf fremde Warteschlangen. Und da die Wahrscheinlichkeit gering ist, daß zwei Threads gleichzeitig leere Warteschlangen haben, kommt es kaum zu konkurrenzierenden Zugriffen auf gleiche Tasks in fremden Warteschlangen [37]. Einordnen eines neuen Tasks in die Deque beim einem fork-Aufruf (push-Operation): Worker-Thread Abarbeitung eines Tasks aus der eigenen Deque (pop-Operation): Worker-Thread Work-Stealing aus fremder Deque (take-Operation): Worker-Thread A Worker-Thread B leere Deque Als Folge des Work-Stealings laufen fein-granulare Anwendungen schneller, als grob-granulare. Implementierung Das Fork/Join-Framework wurde mit rund 800 Zeilen reinen Java-Codes implementiert. Dieser befindet sich hauptsächlich in der Klasse FJTaskRunner, einer Unterklasse von java.lang. Thread.FJTasks. Die Klasse FJTaskRunnerGroup dient zum Konstruieren der WorkerThreads, verwaltet einige Zustands-Variablen (z.B. die Identitäten aller Worker-Threads, die für Work-Stealing benötigt werden), und unterstützt koordiniertes Startup und Shutdown. (Quelle: Doug Lea, http://gee.cs.oswego.edu/dl/papers/fj.pdf) Unterstützung des parallelen Programmierens in Java 29 Im Zusammenhang mit Deques gibt es drei Operationen: (1) push-Operation: Mit fork kreierte neue Tasks legt der kreierende Worker-Thread mit einer push-Operation an den Head seiner Deque. (2) pop-Operation: Jeder Worker-Thread entnimmt mit einer pop-Operation Tasks aus dem Head seiner Deque. (3) take-Operation: Hat ein Worker-Thread seine eigenen Tasks abgearbeitet (leere Deque), dann schaut er in den Deques (zufälliger) anderer Worker-Threads nach, ob noch Tasks zur Verarbeitung anstehen. Wenn er solche findet entnimmt er sie mit einer take-Operation vom Tail der Deque. Deques verwalten eine top-Variable, die auf den Head der Deque verweist, und eine base-Variable, die auf den Tail der Deque zeigt. top wird von push und pop inkrementiert bzw. dekrementiert, während base nur von der take-Operation verändert wird. Am Head wird die Deque also wie ein Stack behandelt. Bei der Implementierung von Deques konnten Locks aus folgenden Gründen weitgehend vermieden werden: • push- und pop-Operationen werden nur vom Worker-Thread durchgeführt, der die betreffende Deque besitzt. Daher können diese Operationen an einer Deque nie gleichzeitig auftreten. • take-Operationen finden gegenüber push- und pop-Operationen relativ selten statt. Wenn auf eine Deque eine take-Operation angewendet wird, muß nur verhindert werden, daß ein weiterer Thread eine take-Operation auf die gleiche Deque ausführt. • Ein Konflikt zwischen pop- und take-Operation kann nur auftreten, wenn nur mehr ein einziger Task in der Deque ist. Dieses Problem wurde elegant umgangen, indem bei den Abfragen, ob die Deque nur mehr ein einziges Element enthält der Pre-Inkrement- bzw. PreDekrement-Operator verwendet wurde. • push-Operation: if (−−top >= base) ... take-Operation: if (++base < top) ... push- und take-Operationen können zu keinen Konflikten führen. Unterstützung des parallelen Programmierens in Java 30 Für die Zuordnung der Tasks zu CPUs ist die JVM verantwortlich. Es gibt keine Möglichkeit zu erfahren, ob die JVM einen Task immer gleich einer neuen CPU zuweist, sobald eine frei ist. Diese Zuweisungen stellen daher eine unbekannte Größe bei Performance-Messungen dar. Ebenso ist die Arbeit des Garbage-Collectors (GC) grundsätzlich ein nicht vorhersehbares Ereignis, das Performance-Messungen beeinflussen könnte. Allerdings ist der GC ein Prozess mit niedriger Priorität, sodaß sich seine Aktivität auf die Messungen nicht gravierend auswirken dürfte. Auch die Verwendung größerer Daten-Typen dämpft den Speedup ein. Das Fork/Join-Framework ist dafür optimiert, daß die Threads eine Lokalität aufweisen. Das heißt der Zugriff eines Worker-Threads auf die eigene Deque erfolgt schneller, als der Zugriff auf fremde Deques beim Work-Stealing. Deshalb ist das Work-Stealing auch dadurch eingeschränkt, daß hierbei die aufwändigeren Taks geholt werden, wodurch die Worker-Threads länger beschäftigt werden. Die Worker-Threads verbringen also die meiste Zeit damit die Tasks aus ihrer eigenen Deque zu bearbeiten. Performance-Messungen 31 4 Performance-Messungen In diesem Abschnitt sollen Performance-Vergleiche der verschiedenen - unter Java zur Verfügung stehenden – Technologien gemacht werden. Weiters werden unterschiedliche Problemstellungen anhand von Beispiel-Programmen betrachtet. 4.1 Durchführung der Experimente Die Java-Programme wurden an einem MultiCore-Rechner mit 32 Cores an der Universität Innsbruck getestet. Das verwendete 32-Core-System ist eine SunFire X4600 M2 mit 8 Quad-Core AMD Opteron-8356-Prozessoren. Zur Bestimmung der Laufzeiten wurde jedes Programm mit den gleichen Argumenten (mindestens) dreimal gestartet und daraus der mittlere der drei Werte notiert. Bei einer starken Streuung der gemessenen Werte (d.h. Abweichungen um mehr als ca. 10 %) wurden Bereichs-Angaben notiert. Die Bereichs-Angaben geben den minimalen und maximalen Wert aus 12 gemessenen Werten an. Durch diese Vorgehensweise soll einerseits vermieden werden statistische Ausreißer zu notieren, die keinen repräsentativen Wert darstellen, und andererseits eine repräsentative Größe für Streuungen abgebildet werden. 4.2 Problemstellung: Numerische Integration Die numerische Integration ist ein Verfahren zur näherungsweisen Berechnung von Integralen, bei der die zu berechnende Fläche entlang der Abszisse in gleich breite Abschnitte (Streifen) aufgeteilt wird. Die Flächen-Berechnungen dieser Streifen sind unabhängig voneinander, und eigenen sich daher von Natur aus zur parallelen Berechnung. Bei der Zuteilung der Teil-Probleme (Berechnungen der Streifen-Flächen) sollte allerdings darauf geachtet werden, daß die Größen der Teil-Probleme wahrscheinlich unterschiedlich sein werden, da auch die Flächen von stark unterschiedlicher Größe sein können. Dies ist auch im betrachteten Beispiel der Fall! Es sind verschiedene Methoden bekannt, um die entsprechenden Abschnitts-Flächen dem tatsächlichen Verlauf der Kurve des Gesamt-Problems anzunähern. Theoretisch können hier Funktionen be- Performance-Messungen 32 liebig hohen Grades zur Approximation verwendet werden. In der Praxis haben sich allerdings einfachere Methoden durchgesetzt: Rechtecks-Methode Die Höhe des Teil-Abschnittes der Fläche wird durch den Funktionswert des Mittelwertes der begrenzenden Abszissen-Werte angenommen. Dadurch erfolgt die Näherung der Fläche durch ein Rechteck. Diese (einfachste) Methode der numerischen Integration wird im betrachteten Beispiel angewandt. Trapez-Methode Im Unterschied zur Rechtecks-Methode werden die Funktionswerte an beiden Abszissen-Begrenzungen des Streifens berechnet, und durch eine Gerade verbunden. Dadurch ergibt sich ein Trapez als Fläche des Teil-Abschnittes. Simpson-Methode Wie bei der Trapez-Methode werden beide Funktionswerte der vertikalen Begrenzungen berechnet, aber hier durch eine Parabel-Kurve miteinander verbunden. Näherungsweise Berechnung der Zahl π Im betrachteten Beispiel-Programm wird die Zahl π (pi) wird mit Hilfe einer numerischen Integration durch die Formel 4 /1 x² auf dem Intervall [0,1] berechnet. Die Anzahl (nx) der Teil-Intervalle wird über die Konsole mit Hilfe der Methode getStripes() eingelesen. Zu jedem Teil-Intervall wird der Mittelpunkt auf der x-Achse berechnet, damit der Fehler - gegenüber dem tatsächlichen Integral - möglichst gut durch das berechnete Rechteck ausgeglichen wird. In einer Schleife werden zuerst nur die Ordinaten-Werte aufsummiert, und erst nach der Schleife mit der Breite (dx) eines Teil-Intervalls multipliziert. Die Dauer der Berechnung wird mit System.currentTimeMillis() gemessen und am Ende des Programmes ausgegeben. Sequentielle Variante Das Programm PiCalcSeq.java berechnet das Integral sequentiell. Die anderen Versionen führen eine parallele Berechnung mit verschiedenen Technologien durch. Bei der sequentiellen Be- Performance-Messungen 33 rechnung steigt der Zeit-Aufwand erwartungsgemäß mit der Anzahl der Teil-Abschnitte ziemlich linear an. Intervalle Dauer in ms 100 210 200 405 300 600 400 792 3500 500 988 3000 600 1182 2500 700 1379 800 1568 900 1760 1000 1957 500 1100 2162 0 1200 2347 1300 2547 1400 2737 1500 2928 Laufzeit in ms Sequentielle numerische Integration 2000 1500 1000 300 500 700 900 1100 1300 1500 200 400 600 800 1000 1200 1400 Anzahl der Teil-Intervalle Parallele Varianten Folgende Varianten der parallelen Berechnung wurden implementiert und getestet: Das Programm PiCalcThreads1.java benutzt klassische Java-Threads, wie sie von Anfang an in Java zur Verfügung stehen. Die Berechnung soll parallel über mehrere Rechen-Einheiten (CPUs) erfolgen. Die Anzahl der verfügbaren Rechen-Einheiten wird dem Programm als Parameter übergeben. Die Teilintervalle werden gleichmäßig auf die verfügbaren Threads verteilt. Die weitere Berechnung erfolgt im Prinzip gleich, wie beim sequentiellen Algorithmus. Damit das Programm übersichtlich bleibt, erfolgt die Berechnung in einer eigenen Methode calc(). Diese wird über eine Instanz dieses Programms aufgerufen, um sich aus dem statischen Kontext der main()-Methode zu befreien. Performance-Messungen 34 In der Berechnungs-Methode calc() wird für jede Rechen-Einheit eine Instanz einer ServiceKlasse eingerichtet. Diese Instanzen werden jeweils über einen separaten Thread (Thread-Array serverThread) zur Ausführung gebracht. Die Service-Klasse ist als innere Klasse implementiert. Da ihre Instanzen als Threads ausgeführt werden, muß die Klasse das Interface Runnable implementieren. Die Service-Klasse erhält über die Methode addValue(double) die Stellen, zu denen die Ordinaten-Werte berechnet werden sollen. Die Berechnung erfolgt bei Ausführung (als Thread) in einer Schleife. Die einem Service übergebenen Stellen sind nicht benachbart, sondern - zur besseren Last-Verteilung gleichmäßig über das gesamte Intervall [0, 1] verteilt. Es ist nämlich durchaus zu erwarten, daß der Rechen-Aufwand allgemein im gesamten Intervall ungleichmäßig verteilt ist (wie auch in diesem Beispiel-Programm). Die Methode calc() wartet auf die Beendigung aller Service-Threads, und holt sich dann von diesen über die Methode getResult() die Teil-Ergebnisse. Die Multiplikation der Funktions- Werte mit der Breite der Teil-Intervalle erfolgt hier zentral, da es für jeden Service-Thread eine zusätzliche (unnötige) Rechen-Operation bedeuten würde. Das End-Ergebnis der Zahl π wird schließlich an die main-Funktion übergeben, die die Ausgabe des Ergebnisses durchführt. Die Variante PiCalcThreads2.java ist eine weitere Version, die klassische Threads benutzt. Im Gegensatz zum vorigen Programm wird hier in der Service-Klasse kein Array mit den zu bearbeitenden Stellen angelegt, sondern die Service-Instanz berechnet sich selber die Stellen. Das Programm PiCalcThreadPool.java benutzt die Concurrency-Utilities von Java 5. In der Methode calc() werden mit Executors.newFixedThreadPool entprechend viele Threads eingerichtet (soviel wie CPUs zur Verfügung stehen). Diese Threads werden von einem Objekt der Klasse ExecutorService verwaltet. Dieses ExecutorService-Objekt erhält über die Methode submit() Objekte für Teilberechnungen übergeben, die die Schnittstelle Callable implementieren. Um den Aufruf der entsprechenden call()-Methode muß sich der Programmierer nicht mehr kümmern, er erfolgt automatisch. Die Ergebnisse werden über Future-Objekte eingesammelt. Die Future-Objekte enthalten nach erfolgreicher Berechnung die Teil-Ergebnisse, und stellen diese über die blockierende Methode get() zur Verfügung. Performance-Messungen 35 Im Programm PiCalcFJ1.java wird das Problem mit Hilfe des Fork/Join-Frameworks gelöst, das mit Java 7 in die Concurrency-Uitils integriert werden soll. Das benutzte Klassen-Modul wird von der Klasse FJTask abgeleitet. Das Thread-Pool ist eine Instanz der Klasse FJTaskRunnerGroup. Die Teil-Probleme (Tasks) werden in einem Objekt vom Typ Array List verwaltet. PiCalcFJ2.java ist eine Variante zu PiCalcFJ1.java, in der die verwendete Klasse nicht von FJTask abgeleitet wird, sondern von der Klasse RecursiveAction, die wiederum von ForkJoinTask abgeleitet ist. Statt der run()-Methode ist eine compute()-Methode zu implementieren, und das Thread-Pool ist nicht mehr vom Typ FJTaskRunnerGroup, sondern vom Typ ForkJoinPool. Ansonsten ist die Verarbeitung dem vorigen Beispiel sehr ähnlich. Da die Laufzeiten für eine einzige Berechnung der Zahl π oft nur im Bereich von wenigen Milli-Sekunden waren, wurden die Berechnungen eine Million Mal wiederholt. Dadurch ergibt sich ein Wertebereich der gemessenen Laufzeiten, in dem die gemessenen Zeiten besser miteinander verglichen werden können. In der Wertetabelle werden folgende Abkürzungen der Programmnamen benutzt: th1 .... PiCalcThreads1.java th2 .... PiCalcThreads2.java tp .... PiCalcThreadPool.java fj1 .... PiCalcFJ1.java fj2 .... PiCalcFJ2.java Für 1000 Teil-Intervalle wurden folgende Laufzeiten (in ms) gemessen: Threads th1 th2 tp fj1 fj2 1 7471 7792 1814 10434 9777 2 3789 4022 935 5112 4917 4 1917 2074 493 4790 4118 8 994 1049 272 3597 2971 16 536 552 177 2348 1908 32 371 324 121 994 1434 1317 64 276 1079 295 163 1318 1237 1596 128 191 910 187 196 1837 1246 1215 256 204 1644 186 174 1087 1449 1289 Performance-Messungen 36 Numerische Integration 1000 Teil-Intervalle 12000 Laufzeiten in ms 10000 th1 th2 tp fj1 fj2 8000 6000 4000 2000 0 1 2 4 8 16 32 64 128 256 Anzahl der Threads Die serielle Berechnung mit 1000 Teil-Intervallen und einer Million Wiederholungen dauerte 1975 ms. Im Vergleich zur seriellen Berechnung ergeben sich für die ThreadPool-Variante folgende Speedups: Speedups (ThreadPool-Varinate) 12 (tpVariante) 10 1 1,09 8 2 2,11 4 4,01 8 7,26 2 16 11,16 0 Speedup Speedups Threads 6 4 1 2 4 8 16 Anzahl der Threads Erkenntnisse aus den Messdaten Laut obigen Messwerten scheinen beide Varianten des Fork/Join-Frameworks für diese Problemstellung weniger gut geeignet zu sein. Nur die ThreadPool-Varinate zeigt in allen gemessenen Werten ein besseres Laufzeit-Verhalten, als die serielle Berechnung. Die Thread-Varianten bringen erst ab einer höheren Thread-Anzahl (von mehr als 4 Threads) eine Beschleunigung, wodurch der Einsatz von Threads nicht mehr effizient ist. Nachteilig erscheinen auch die starken Streuungen mancher Varianten bei Anwendung vieler Threads. Performance-Messungen 37 4.3 Problemstellung: Sortieren Sortier-Algorithmen eignen sich gut für parallele Verarbeitung, weil sie meistens nach dem „divide & conquer“-Prinzip in Teil-Probleme zerlegt werden können. Das ist auch bei dem hier betrachteten QuickSort-Algorithmus der Fall. Durch das jeweilige Zerteilen der zu sortierenden Listen in zwei Teil-Listen entsteht eine Baum-förmige Aufteilungs-Struktur in immer kleinere Teil-Probleme, bis die Listen nur mehr aus einem einzigen Element bestehen. Sequentielle Variante Das Programm QuickSortSeq.java führt den QuickSort-Algorithmus durch rekursiven Aufruf dieser Verteilungs-Strategie auf, jedoch ohne parallele Verarbeitung. In der sort()-Methode wird für beide Teil-Listen jeweils wiederum die sort()-Methode (rukursiv) aufgerufen. Die Laufzeiten vergrößern sich - lt. Messungen - deutlich mit der Anzahl der zu sortierenden Elemente, liegen aber im akzeptablen Rahmen. Sequentielle Sortierung Laufzeit in ms 10³ 1 10⁴ 25 10⁵ 66 10⁶ 196 10⁷ 2109 (Quicksort-Algorithmus) 2500 2000 Laufzeit in ms Anzahl der Elemente 1500 1000 500 0 10³ 10⁴ 10⁵ 10⁶ 10⁷ Anzahl der Elemente Parallele Varianten Bei den parallelen Technologien zeigen sich markante Unterschiede im Laufzeit-Verhalten. Die Messungen wurden mit 10⁶ zu sortierenden Elementen durchgeführt. Die Elemente sind vom FließKomma-Typ double, also jeweils 8 Byte groß. Die Sortierung mittels klassischer Threads in QuickSortThreads.java zeigt, daß sich die Laufzeiten verbessern je mehr Threads benutzt werden. Die Laufzeiten verbessern sich, bis sie – in diesem Beispiel – das 500- bis 1000-fache der Anzahl der verfügbaren Cores erreichen. Wenn mehr Performance-Messungen 38 Threads benutzt werden, erhöhen sich die Laufzeiten wieder leicht. Die optimale Laufzeit liegt offensichtlich bei einer relativ feinen Granularität. Folgende Werte wurden auf der 32-Core-Maschine gemessen: Laufzeit in ms 2⁴ 65358 2⁵ 18198 2⁶ 5462 2⁷ 2100 2⁸ 1056 2⁹ 625 2¹⁰ 351 2¹¹ 246 2¹² 178 2¹³ 155 2¹⁴ 87 2¹⁵ 88 2¹⁶ 113 2¹⁷ 148 2¹⁸ 167 2¹⁹ 215 2²⁰ 210 2²¹ 213 2²² 209 Quicksort-Algorithmus (10⁶ Elemente) (Berechnung mit klassischen Java-Threads) 1200 1000 800 Laufzeit in ms Anzahl der Threads 600 400 200 0 2⁸ 2⁹ 2¹⁰ 2¹¹ 2¹² 2¹³ 2¹⁴ 2¹⁵ 2¹⁶ 2¹⁷ 2¹⁸ 2¹⁹ 2²⁰ 2²¹ 2²² Anzahl der Threads Im gemessenen Bereich von ca. 4096 bis 262144 Threads ergibt sich für die Thread-Varinate hier ein Speedup > 1. Dieser ist im folgenden Diagramm dargestellt. Die Effizienz in diesem Bereich ist aber aufgrund der hohen Thread-Anzahl äußerst gering! Nur ein Speedup > 1 stellt eine Beschleunigung dar. Daher muß in diesem Beispiel festgestellt werden, daß nur mit einer eingegrenzten Anzahl an verwendeten Threads eine Beschleunigung zu erreichen ist. Mit steigender Thread-Anzahl sinkt - ab ca. 32000 Threads - wieder der Speedup, was dadurch erklärt werden kann, daß die Erzeugung, die Verwaltung und das Beenden der Threads einen Performance-Messungen 39 zusätzlichen Aufwand verursacht, der den gewünschten Effekt der parallelen Verarbeitung zunichte macht. Speedup Sortierung von 10⁶ Elementen mittels Threads 2,5 Speedup 2 1,5 1 0,5 0 2048 4096 8192 16384 32768 65536 262144 524288 Anzahl der Threads Effizienz Sortierung von 10⁶ Elementen mittels Threads 0,00045 0,00040 0,00035 Effizienz 0,00030 0,00025 0,00020 0,00015 0,00010 0,00005 0,00000 2¹¹ 2¹² 2¹³ 2¹⁴ 2¹⁵ Anzahl der Threads 2¹⁶ 2¹⁷ 2¹⁸ 2¹⁹ Performance-Messungen 40 Im Gegensatz zur Variante, die klassische Threads benutzt, ändern sich die Laufzeiten bei den anderen Technologieen (Concurrency Utilities lt. Java 5 und Fork/Join-Framework) in Abhängigkeit von der Anzahl der verwendeten Threads nicht wesentlich. | L a u f z e i t e n / [ m s ] Threads |QuickSortThreadPool QuickSortFJ1 QuickSortFJ2 + 1 | 210 222 234 2 | 200 225 225 4 | 207 217 230 8 | 208 228 219 16 | 207 222 224 32 | 210 240 222 64 | 211 232 254 128 | 210 251 244 256 | 213 270 254 1024 | 210 405 287 2048 | ? 764 658 4096 | ? 2958 1455 8192 | ? 7313 8486 16384 | ? 8285 50651 32768 | 201 20692 65536 | ? 81096 1048576 | 206 ? 16777216 | 205 ? + Die Klasse ForkJoinPool, die im Programm QuickSortFJ2 verwendet wird, ist offenbar nur für 32767 Threads ausgelegt. Einträge, die in obiger Tabelle daher nicht ermittelt werden konnten sind mit '' gekennzeichnet. Die mit '?' gekennzeichneten Einträge wurden nicht ermittelt, weil entweder keine aufschlussreichen Werte zu erwarten sind, oder die Laufzeiten sowieso unattraktiv lang wären. Interessant ist auch der Vergleich der Fork/Join-Variante, die Objekte vom Typ FJTask und FJTaskRunnerGroup benutzt (Programm QuickSortFJ1) und der Variante, die Objekte vom Performance-Messungen 41 Typ ForkJoinTask und ForkJoinPool benutzt (Programm QuickSortFJ2). Die zweite Variante verhält sich zuerst leicht vorteilhafter gegenüber der erstgenannten. Ab einer höheren Anzahl an verwendeten Threads klaffen die Laufzeiten aber stark auseinander, und die erstgenannte Variante zeigt ein wesentlich besseres Laufzeitverhalten. Vergleich der Fork/Join-Varianten 60000 50000 Laufzeit in ms 40000 30000 QuickSortFJ1 QuickSortFJ2 20000 10000 0 32 64 128 256 1024 2048 4096 8192 16384 Anzahl der Threads Auch die Messung mit 10⁷ zu sortierenden Elementen zeigt, daß besonders die Technologie der Concurrency Utilities (Programm QuickSortThreadPool) ziemlich gleichbleibende Laufzeiten aufweist, die von der Anzahl der Threads nahezu unabhängig ist: Threads Laufzeit/[ms] 1 2174 2 2164 4 2343 8 2173 16 2160 32 2125 64 2311 128 2271 256 2283 1024 2158 32768 2122 1048576 2125 16777216 2177 Performance-Messungen 42 4.4 Problemstellung: Fibonacci-Zahlen Die Fibonacci-Folge ist eine unendliche Folge von Zahlen (den Fibonacci-Zahlen), bei der sich die jeweils folgende Zahl durch Addition der beiden vorherigen Zahlen ergibt: 0, 1, 1, 2, 3, 5, 8, 13, … Die Bestimmung einer Zahl aus dieser Folge macht es daher notwendig alle vorhergehenden Folgen-Glieder zu berechnen. Dadurch ergeben sich recht schnell aufwändige rekursive Berechnungen. Daher ist dieses Beispiel ebenfalls für parallele Abarbeitung interessant. Sequentielle Variante Im Programm FibSeq.java wird die Fibonacci-Zahl zu einem bestimmten Argument durch einen sequentiellen Algorithmus berechnet. Zur Berechnung einer Fibonacci-Zahl werden jeweils die beiden vorangehenden Folgen-Elemente benötigt, war durch einen rekursiven Aufruf der Berechnungs-Funktion seqFib() geschieht. Für den sequentiellen Algorithmus wurden folgende Laufzeiten gemessen: n Laufzeit in ms Sequentielle Berechnung der Fibonacci-Zahlen 30 20 32 32 34 64 36 147 38 364 40 927 42 2409 1000 44 6262 0 46 16358 7000 Laufzeit(fib(n)) in ms 6000 5000 4000 3000 2000 30 32 34 36 38 40 42 44 n Günstigerweise zeigen die gemessenen Ergebnisse nur eine sehr geringe Streuung, wodurch die Laufzeiten aufgrund von Erfahrungswerten gut abschätzbar werden. Performance-Messungen 43 Parallele Varianten Durch die Verwendung von klassischen Threads im Programm FibThreads.java erhöhen sich die Laufzeiten wesentlich. Solange das Argument n eine bestimmte Größe hat, werden Threads erzeugt. Die Verwendung von klassischen Java-Threads bringt hier keine Vorteile gegenüber der sequentiellen Berechnung. Laufzeit in ms 20 17 22 47 24 115 26 257 28 726 30 3088 Berechnung von Fibonacci-Zahlen (mit klassischen Java-Threads) 20000 Laufzeit(fib(n)) in ms n 15000 10000 5000 0 20 22 24 26 28 30 32 n 32 18816 Auch die Verwendung der Concurrency Utilities in FibThreadPool.java bringt keine Verbesserung der Laufzeiten, und das Programm stößt – besonders bei wenigen Threads – bald an seine Grenzen. Zu große - und daher nicht mehr gemessene - Werte sind in der Tabelle mit '>>' gekennzeichnet. + | Threads n | 32 64 128 256 512 1024 + 18 | 18 ms 19 ms 18 ms 19 ms 19 ms 19 ms 20 | 39 ms 43 ms 46 ms 45 ms 45 ms 45 ms 21 | >> 58 ms 63 ms 63 ms 63 ms 62 ms 22 | >> 79 ms 94 ms 98 ms 94 ms 93 ms 23 | >> >> 118 ms 136 ms 138 ms 138 ms 24 | >> >> >> 191 ms 215 ms 216 ms 25 | >> >> >> 252 ms 317 ms 360 ms 26 | >> >> >> >> 429 ms 623 ms 27 | >> >> >> >> 598 ms 935 ms 28 | >> >> >> >> >> 1290 ms + Performance-Messungen 44 Berechnung der Fibonacci-Zahlen Concurrency Utilities (1024 Threads) 1400 Laufzeiten(fib(n)) in ms 1200 1000 800 600 400 200 0 20 21 22 23 24 25 26 27 28 Argument n der Fibonacci-Funktion Die Fork/Join-Variante lt. Programm FibFJ1.java - in der die Klassen FJTask und FJTask RunnerGroup benutzt werden - zeigt schon wesentlich bessere Laufzeiten, als die vorher genannten parallelen Technologien. Das Diagramm zeigt, daß die Problemstellung – bis zu 16 verwendetenThreads - gut skaliert. Diese Anzahl entspricht der Hälfte der verfügbaren Cores. Laufzeiten in ms in Abhängigkeit von n u. der ThreadAnzahl: 1 2 4 8 16 32 64 128 n Thread Threads Threads Threads Threads Threads Threads Threads 36 262 189 184 142 137 200 220 259 38 553 422 338 294 289 367 400 495 40 1219 775 605 450 394 485 655 804 42 2940 1566 1043 681 611 654 914 900 44 7380 3829 2250 1221 787 803 996 1067 46 18502 9323 5048 2732 1469 1072 1725 1531 Performance-Messungen 45 Berechnung der Fibonacci-Zahlen Abhängigkeiten von der Anzahl an Threads 20000 18000 16000 Laufzeiten in ms 14000 1 Thread 2 Threads 4 Threads 8 Threads 16 Threads 12000 10000 8000 6000 4000 2000 0 36 38 40 42 44 46 n Die zweite Fork/Join-Variante lt. Programm FibFJ2.java – die die Klassen ForkJoinTask und ForkJoinPool benutzt – skaliert ähnlich gut, wie die vorige Variante. Bei den in der Tabelle mit '?' ausgefüllten Einträgen war eine Angabe der Messwerte nicht mehr sinnvoll, weil die gemessenen Werte stark gestreut waren. Laufzeiten in ms in Abhängigkeit von n u. der ThreadAnzahl: 1 2 4 8 16 32 64 128 n Thread Threads Threads Threads Threads Threads Threads Threads 36 264 181 172 142 149 220 305 324 38 556 335 212 186 184 217 339 450 40 1251 711 401 284 231 239 ? ? 42 3132 1570 863 523 349 258 ? ? 44 7799 3832 1993 1117 644 508 ? ? 46 19469 9480 4845 2594 1457 1101 ? ? Performance-Messungen 46 Beide Fork/Join-Varianten skalieren für Messwerte unter einer Zehntel Sekunde nicht. Vermutlich fällt dort der Verwaltungs-Overhead der Threads mehr ins Gewicht, als die Zeitersparnis durch parallele Berechnung. Allerdings muß gesagt werden, daß im Bereich von unter einer Zehntel Sekunde eine Verbesserung der Laufzeit für den Benutzer nicht merkbar ist, und daher auch nichts bingt. Gegenüber der sequentiellen Laufzeit von 927 ms für die Berechnung von fib(40) ergeben sich für die Fork/Join-Varianten folgende Speedup- und Effizienz-Werte: FibFJ1 1 2 4 8 16 32 Th. Threads Threads Threads Threads Threads 64 Th. 128 Th. Laufzeit 1219 775 605 450 394 485 655 804 Speedup 0,76 1,2 1,53 2,06 2,35 1,91 1,42 1,15 Effizienz 0,76 0,6 0,38 0,26 0,15 0,06 0,22 0,01 64 Th. 128 Th. FibFJ2 1 2 4 8 16 32 Th. Threads Threads Threads Threads Threads Laufzeit 1251 711 401 284 231 239 ? ? Speedup 0,74 1,3 2,31 3,26 4,01 3,88 ? ? Effizienz 0,74 0,65 0,58 0,41 0,25 0,12 ? ? Obwohl sich für beider Varianten Speedups ergeben, zeigen die Effizienz-Werte unter Berücksichtigung der dafür eingesetzten Threads (CPUs) keine besonders guten Ergebnisse. Immerhin würde die ideale Effizienz bei 1 liegen. Performance-Messungen 47 4.5 Problemstellung: Multiplikation von Matrizen Die Multiplikation zweier Matrizen stellt ein Problem mit hohem Rechen-Aufwand dar. Der Aufwand steigt kubisch mit der Größe der Matrizen, das heißt mit der Anzahl ihrer Elemente. Die Komplexität von Matrizen-Multiplikationen – mit dem in folgenden Programmen angewandten Standard-Algorithmus - wird mit O(n³) angegeben. Für die Problemstellung der Matrizen-Multiplikation wurden zwei sequentielle Algorithmen implementiert und getestet. Zum einen der normale (naive) Standard-Algorithmus und zum anderen ein verbesserter Algorithmus namens Jama. Zunächst sollen die Laufzeiten dieser beiden Versionen verglichen werden. Die Tests wurden jeweils mit zwei gleich großen quadratischen N×N-Matrizen durchgeführt. Laufzeiten der Multiplikation zweier NxN-Matrizen N MatrixMult_naive.java MatrixMult_jama.java 50 12 ms 9 ms 100 31 ms 38 ms 200 44 ms 44 ms 300 82 ms 53 ms 400 265 ms 115 ms 500 647 ms 208 ms 600 1486 ms 365 ms 700 3036 ms 550 ms 800 5310 ms 1551 ms 900 9780 ms 2693 ms 1000 16495 ms 3700 ms Matrizen-Multiplikation von 2 NxN-Matrizen Vergleich zweier sequentieller Algorithmen Laufzeit in ms 20000 15000 MatrixMult_naive.java MatrixMult_jama.java 10000 5000 0 100 200 300 400 500 N 600 700 800 900 1000 Performance-Messungen 48 Erkenntnisse aus den Messdaten Das Diagramm zeigt, daß die Laufzeiten des naiven Algorithmus – besonders bei großen Matrizen – oft um ein Vielfaches höher sind, als die Laufzeiten des Jama-Algorithmus. Besonders bemerkenswert ist auch, daß der Anstieg der Kurve beim Jama-Algorithmus wesentlich flacher verläuft, als beim naiven Algorithmus. Somit zeigt auch dieses Beispiel, daß die Wahl des Algorithmus wesentlichen Einfluß auf das Laufzeit-Verhalten hat. Parallele Versionen Aufbauend auf dem Jama-Algorithmus wurden die Versionen mit den klassischen Threads, den Councurrency Utilities lt. Java 5, und den zwei Varianten des Fork/Join-Frameworks programmiert. Die Laufzeiten für parallele Abarbeitung sollen am Beispiel der Multiplikation zweier 1000×1000Matrizen in Abhängigkeit von der Anzahl der verwendeten Threads betrachtet werden. Folgende Abkürzungen sollen gelten: Th Variante mit klassischen Threads TP ThreadPool-Variante mit Concurrency Utities lt. Java 5 FJ1 Fork/Join-Variante die von FJTask ableitet FJ2 Fork/Join-Variante die von ForkJoinTask ableitet Laufzeiten in ms Anzahl an Threads Th TP FJ1 FJ2 1 3784 3799 3851 3805 2 1950 1956 1965 1975 4 1153 1156 1148 1161 8 915 918 808 1056 16 808 879 1055 895 32 857 895 883 911 64 733 731 769 771 128 633 768 799 784 256 592 977 792 784 512 597 1879 860 826 1024 659 6193 943 959 2048 757 28089 1232 1334 Performance-Messungen 49 Java-Technologien zur parallelen Abarbeitung im Vergleich 2500 Laufzeit in ms 2000 1500 Th TP FJ1 FJ2 1000 500 0 1 2 4 8 16 32 64 128 256 512 1024 2048 Anzahl an Threads Erkenntnisse aus den Messdaten Aus obigem Diagramm bzw. obigen Messwerten läßt sich ablesen, daß – bis zu einer vernünftigen Anzahl an Threads von ca. 128 – alle vier Technologien ähnliche Laufzeiten haben. Bei einer großen Anzahl an Threads versagt allein die ThreadPool-Varinate ihren Dienst durch extrem lange Laufzeiten. In diesem Bereich erweisen sich die einfachen Java-Threads als erstaunlich konstant. Im Gegensatz zur sequentiellen Berechnung mithilfe des Jama-Algorithmus, die 3700 ms gedauert hat, ergeben sich für die 2. Fork/Join-Variante folgende Speedups. Die Werte zu den anderen Technologien sind aufgrund ähnlicher Laufzeiten ähnlich. Speedup 1 0,97 2 1,87 4 3,19 8 3,5 16 4,13 32 4,06 64 4,8 128 4,72 256 4,72 512 4,48 1024 3,86 2048 2,77 Speedups 6 5 4 Speedups Threads 3 2 1 0 2 1 8 4 32 16 128 64 512 256 Anzahl an Threads 2048 1024 Performance-Messungen 50 4.6 Problemstellung: Jacobi-Relaxation Im Gegensatz zu den bisher betrachteten Problemstellung unterscheidet sich diese dadurch, daß zwischen den Threads ein Informations-Austausch erfolgen muß. Der Austausch erfolgt für jede Iteration, das heißt, die Iterationen müssen synchronisiert ablaufen. Für diese Synchronisation steht die Klasse java.util.concurrent.CyclicBarrier zur Verfügung. Eine derartige Barriere bewirkt, daß hier eine bestimmte Anzahl an Threads aufeinander warten. Im Gegensatz zu join() werden die Threads aber nicht beendet, sondern laufen weiter, sobald genügend Threads am BarrierPunkt angelangt sind. Sobald dies der Fall ist wird üblicherweise eine Aktion durchgeführt, in diesem Programm eben der Werte-Austausch der Ergebnisse der bisherigen Iterationen. Im Konstruktor einer CyclicBarrier wird angegeben, wie viele Threads an der Barriere erwartet werden. Diese werden als Parties bezeichnet. In der run()-Methode erfolgt die Synchronisation über die await()-Methode. Diese kann eine Exception auslösen, falls Threads vorzeitig beendet werden. Der Begriff CyclicBarrier soll darauf hinweisen, daß die Barriere wiederholt verwendet werden kann. Sequentielle Variante Die sequentielle Abarbeitung (JacobiSeq.java) ergab folgende Meßdaten: Sequentielle Berechnung (Jacobi-Matrix) 20000 18000 16000 14000 Laufzeit in ms Iterationen Laufzeit/[ms] 2 376 4 724 6 1068 8 1417 10 1768 15 2625 20 3493 25 4350 30 5216 35 6084 40 6959 45 7815 50 8682 60 10427 70 12168 80 13868 90 15563 100 17290 12000 10000 8000 6000 4000 2000 0 10 20 30 40 50 60 Iterationen 70 80 90 100 Performance-Messungen 51 Parallele Variante Die parallele Berechnung (JacobiThreadPool.java) ergab folgende Meßdaten: Parallele Berechnung (Jacobi-Relaxation) 2500 2000 Laufzeit in ms Iterationen Laufzeit/[ms] 10 294 352 20 483 621 30 781 936 40 764 1328 50 1117 1304 60 1269 1586 70 1458 1836 80 1705 1873 90 1826 2152 100 1853 2322 1500 1000 500 0 10 20 30 40 50 60 70 80 90 100 Iterationen Aus den Mittelwerten der parallelen Laufzeiten in Relation zu den sequentiellen Laufzeiten errechnen sich folgende Speedups: Speedups (Jacobi-Relaxation) 9 8 7 6 Speedup Iterationen Speedups 10 5,47 20 6,33 30 6,08 40 6,65 50 7,18 60 7,31 70 7,38 80 7,75 90 7,82 100 8,28 5 4 3 2 1 0 10 20 30 40 50 60 70 80 90 100 Iterationen Die Anzahl der Iterationen kann über ein Kommandozeilen-Argument angegeben werden. Für jede Iteration wird ein eigener Thread angelegt. Performance-Messungen 52 Erkenntnisse aus den Messdaten Die Messdaten der sequentiellen Berechnung weisen nur eine sehr geringe Streuung auf. Die Laufzeiten sind praktisch (linear) proportional zur Anzahl der durchlaufenen Iterationen. Bei der parallelen Berechnung sind starke Streuungen zu beobachten. Die Streuungen wurden – wie in den vorhergehenden Problemstellungen – aus 12 Messwerten ermittelt. Die Daten zeigen, daß die parallele Abarbeitung offensichtlich wesentlich effizienter erfolgt. Die Laufzeiten steigen auch hier fast linear zur Zahl der Iterationen an. Allerdings sind die Laufzeiten der parallelen Berechnung wesentlich besser, als die Laufzeiten der sequentiellen Berechnung. Dadurch ergeben sich erfreuliche Speedups, wie man aus obiger Tabelle bzw. obigem Diagramm sehen kann. Zusammenfassung 53 5 Zusammenfassung Neuere Entwicklungen bezüglich paralleler Programmierung – wie das Fork/Join-Framework – zeigen, daß es durchaus möglich ist, in reinem Java-Code skalierbare, effiziente und portierbare parallele Algorithmen zu schreiben. Java ist eine innovative Programmiersprache, in der versucht wird, dem Programmierer immer effizientere und komfortablere Frameworks zur Verfügung zu stellen. Das wird mit zunehmender Notwendigkeit des parallelen Programmierens auch nötig sein, damit die Sprache weiterhin akzeptiert wird. Die Experimente der Laufzeit-Messung haben gezeigt, daß durch die Verwendung von Technologien zur parallelen Programm-Abarbeitung nicht automatisch eine Verbesserung der Laufzeit eintritt. In einigen Fällen kommt es sogar zu einer deutlichen Verschlechterung durch den Mehraufwand der Erzeugung, Verwaltung und Entfernung von Threads bzw. Thread-Pools. Eine Verschlechterung der Laufzeit hängt sicher oft damit zusammen, daß der benutzte Algorithmus nicht gut zur parallelen Verarbeitung geeignet ist. Daher sind geeignete (spezielle) parallele Algorithmen mindestens ebenso wichtig, wie Frameworks für die parallele Berechnung, um durch die Verwendung von MultiCore-Prozessoren bessere Performance zu erreichen. Die Entwicklung dieser parallelen Algorithmen bedarf allerdings guter Kenntnisse der jeweiligen Problemstellung, und zählt damit sicher nicht zu den einfachen Aufgaben in der Informatik. Aus diesem Grund wird die parallele Programmierung – mit Hilfe von MultiCore-Prozessoren – in der Informatik ein interessantes Kapitel bleiben, das die Informatiker noch längere Zeit beschäftigen wird, und sicherlich noch viel Interessantes hervorbringt. Anhang A (Programme zur Problemstellung „Numerische Integration“) Anhang A (Programme zur Problemstellung „Numerische Integration“) PiCalcSeq.java import java.util.Scanner; /** * Klasse zur Berechnung der Zahl pi durch numerische Integration. * Die Berechnung erfolgt durch einen sequentiellen Algorithmus. */ public class PiCalcSeq { public static void main(String[] args) { int nx = getStripes(args); // nx ... Anzahl der Abschnitte auf der x-Achse long startTime = System.currentTimeMillis(); // Beginn der Laufzeit-Messung double dx = 1 / (double) nx; double pi = 0; for (int i = 0; i < nx; i++) { for (int ii = 0; ii < 1000000; double x = (i + 0.5) * dx; pi += 4 / (1 + x * x); } } pi *= dx; } // dx ... IntervallBreite ii++) { // x ... Intervall-MittelPunkt long endTime = System.currentTimeMillis(); // Ende der Laufzeit-Messung System.out.println(" Berechnete Zahl pi: " + (pi / 1E06)); System.out.println(" RechenZeit: " + (endTime - startTime) + " ms"); /** Anzahl der Teil-Intervalle festlegen. * @param args KommandozeilenArgumente * @return Anzahl der Teil-Intervalle */ private static int getStripes(String[] args) { int ret = -1; try { if (args.length > 0) { ret = new Integer(args[0]).intValue(); } else { System.out.println(" Die Zahl pi wird mit Hilfe einer numerischen Integration"); System.out.println(" auf dem Intervall [0, 1] berechnet."); System.out.print(" In wie viele Teile soll das Intervall geteilt werden: "); Scanner sc = new Scanner(System.in); ret = sc.nextInt(); sc.close(); } } catch (Exception e) { System.err.println(" Ungültige Eingabe!"); System.exit(2); } return ret; } } 54 Anhang A (Programme zur Problemstellung „Numerische Integration“) PiCalcThreads1.java import java.util.Scanner; /** * Klasse zur Berechnung der Zahl pi durch numerische Integration. * Die Berechnung erfolgt parallel durch Java-Threads. */ public class PiCalcThreads1 { /** Anzahl der private static /** Anzahl der private static verfuegbaren Rechen-Einheiten (CPUs) */ int nodes; Abschnitte auf der x-Achse */ int nx; public static void main(String[] args) { nodes = getNodes(args); nx = getStripes(args); System.out.println(" " + nodes + " Nodes " + nx + " TeilIntervalle"); long startTime = System.currentTimeMillis(); // Beginn der Laufzeit-Messung PiCalcThreads1 piCalc = new PiCalcThreads1(); double pi = piCalc.calc(); } long endTime = System.currentTimeMillis(); // Ende der Laufzeit-Messung System.out.println(" Berechnete Zahl pi: " + (pi / 1E06)); System.out.println(" RechenZeit: " + (endTime - startTime) + " ms"); /** Pi berechnen. * pi als Näherung einer numerischer Integration */ private double calc() { Service[] service = new Service[nodes]; Thread[] serverThread = new Thread[nodes]; int n1 = nx / nodes; // max. Anzahl Abschnitte, die 1 Node zu berechnen hat if (nx % nodes > 0) n1++; // Ausführungs-Einheiten festlegen: for (int i = 0; i < nodes; i++) { service[i] = new Service(n1); serverThread[i] = new Thread(service[i]); } // Problem in TeilProbleme (Tasks) zerlegen: double dx = 1 / (double) nx; // dx ... IntervallBreite for (int i = 0; i < nx; i++) { double x = (i + 0.5) * dx; // x ... Intervall-MittelPunkt service[i % nodes].addValue(x); } // Teil-Probleme von den Ausführungs-Einheiten parallel abarbeiten lassen: for (int i = 0; i < nodes; i++) { serverThread[i].start(); } // Ergebnisse der Teil-Probleme synchronisieren: try { for (int i = 0; i < nodes; i++) { serverThread[i].join(); } } catch (InterruptedException e) {} // Teil-Ergebnisse zusammenfügen: double pi = 0; for (int i = 0; i < nodes; i++) { pi += service[i].getResult(); } } pi *= dx; return pi; 55 Anhang A (Programme zur Problemstellung „Numerische Integration“) /** Anzahl der Rechen-Einheiten festlegen. * @param s ArgumentListe * @return Anzahl der Rechen-Einheiten */ public static int getNodes(String[] s) { int ret = -1; try { switch (s.length) { case 0: System.out.println(" Usage: java PiCalcThreads1 [<#TeilIntervalle>] <#CPUs>"); System.exit(1); break; case 1: ret = new Integer(s[0]).intValue(); break; default: ret = new Integer(s[1]).intValue(); } } catch (Exception e) { System.err.println(" Ungültiges Argument!"); System.exit(1); } return ret; } /** Anzahl der Teil-Intervalle festlegen. * @param args KommandozeilenArgumente * @return Anzahl der Teil-Intervalle */ private static int getStripes(String[] args) { int ret = -1; try { if (args.length > 1) { ret = new Integer(args[0]).intValue(); } else { System.out.println(" Die Zahl pi wird mit Hilfe einer numerischen Integration"); System.out.println(" auf dem Intervall [0, 1] berechnet."); System.out.print(" In wie viele Teile soll das Intervall geteilt werden: "); Scanner sc = new Scanner(System.in); ret = sc.nextInt(); sc.close(); } } catch (Exception e) { System.err.println(" Ungültige Eingabe!"); System.exit(2); } return ret; } /** * Klasse zur Berechnung und Verwaltung von Teil-Ergebnissen */ class Service implements Runnable { /** Berechnetes Teil-Ergebnis dieser Instanz */ private double result; /** Zu bearbeitende x-Werte */ private double xvals[]; /** Zaehler der bereits uebergebenen x-Werte */ private int cnt; /** Anzahl der zu bearbeitenden x-Werte */ private int n1; 56 Anhang A (Programme zur Problemstellung „Numerische Integration“) /** Konstruktor * @param n1 Anzahl der Stellen, zu denen die FunktionsWerte zu berechnen sind. */ public Service(int n1) { result = 0; xvals = new double[n1]; cnt = 0; this.n1 = n1; } /** Stelle an der Abszisse speichern, zu der der FunktionsWert zu berechnen ist. * @param x Stelle an der Abszisse */ public void addValue(double x) { if (cnt < n1) { xvals[cnt] = x; cnt++; } } /** TeilErgebnis dieser Instanz abfragen. */ public double getResult() { return result; } } } @Override public void run() { for (int i = 0; i < xvals.length; i++) { for (int ii = 0; ii < 1000000; ii++) { double x = xvals[i]; if (x > 0) { // 0 bedeutet unbelegt (letztes Element kann unbenutzt sein!) result += 4 / (1 + x * x); } } } } 57 Anhang A (Programme zur Problemstellung „Numerische Integration“) PiCalcThreads2.java import java.util.Scanner; /** Klasse zur Berechnung der Zahl pi durch numerische Integration. */ public class PiCalcThreads2 { /** Anzahl der verfuegbaren Rechen-Einheiten (CPUs) */ private static int nodes; /** Anzahl der Abschnitte auf der x-Achse private static int nx; */ public static void main(String[] args) { nodes = getNodes(args); nx = getStripes(args); System.out.println(" " + nodes + " Nodes " + nx + " TeilIntervalle"); long startTime = System.currentTimeMillis(); PiCalcThreads2 piCalc = new PiCalcThreads2(); double pi = piCalc.calc(); } long endTime = System.currentTimeMillis(); System.out.println("Berechnete Zahl pi: " + (pi / 1E06)); System.out.println("RechenZeit: " + (endTime - startTime) + " ms"); /** Pi berechnen. * pi als Näherung einer numerischer Integration */ private double calc() { Service[] service = new Service[nodes]; Thread[] serverThread = new Thread[nodes]; double dx = 1 / (double) nx; int n1 = nx / nodes; if (nx % nodes > 0) n1++; // dx ... IntervallBreite // max. Anzahl Abschnitte, die 1 Node zu berechnen hat for (int i = 0; i < nodes; i++) { service[i] = new Service(i, nodes, n1, dx); serverThread[i] = new Thread(service[i]); } for (int i = 0; i < nodes; i++) { serverThread[i].start(); } try { for (int i = 0; i < nodes; i++) { serverThread[i].join(); } } catch (InterruptedException e) {} double pi = 0; for (int i = 0; i < nodes; i++) { pi += service[i].getResult(); } } pi *= dx; return pi; 58 Anhang A (Programme zur Problemstellung „Numerische Integration“) /** Anzahl der Rechen-Einheiten festlegen. * @param s ArgumentListe * @return Anzahl der Rechen-Einheiten */ public static int getNodes(String[] s) { int ret = -1; try { switch (s.length) { case 0: System.out.println(" Usage: java PiCalcThreads2 [<#TeilIntervalle>] <#CPUs>"); System.exit(1); break; case 1: ret = new Integer(s[0]).intValue(); break; default: ret = new Integer(s[1]).intValue(); } } catch (Exception e) { System.err.println(" Ungültiges Argument!"); System.exit(1); } return ret; } /** Anzahl der Teil-Intervalle festlegen. * @param args KommandozeilenArgumente * @return Anzahl der Teil-Intervalle */ private static int getStripes(String[] args) { int ret = -1; try { if (args.length > 1) { ret = new Integer(args[0]).intValue(); } else { System.out.println(" Die Zahl pi wird mit Hilfe einer numerischen Integration"); System.out.println(" auf dem Intervall [0, 1] berechnet."); System.out.print(" In wie viele Teile soll das Intervall geteilt werden: "); Scanner sc = new Scanner(System.in); ret = sc.nextInt(); sc.close(); } } catch (Exception e) { System.err.println(" Ungültige Eingabe!"); System.exit(2); } return ret; } /** Klasse zur Berechnung und Verwaltung von Teil-Ergebnissen */ class Service implements Runnable { /** eindeutige Nummer der Instanz */ private int n; /** Anzahl der verfuegbaren Rechen-Einheiten (CPUs) */ private int nodes; /** Anzahl der zu bearbeitenden x-Werte */ private int n1; /** Intervall-Breite */ private double dx; /** Berechnetes Teil-Ergebnis dieser Instanz */ private double result; 59 Anhang A (Programme zur Problemstellung „Numerische Integration“) /** Konstruktor * @param n1 Anzahl der Stellen, zu denen die FunktionsWerte zu berechnen sind. */ public Service(int n, int nodes, int n1, double dx) { this.n = n; this.nodes = nodes; this.n1 = n1; this.dx = dx; this.result = 0; } /** TeilErgebnis dieser Instanz abfragen. */ public double getResult() { return result; } @Override public void run() { int n= this.n; for (int i = 0; i < n1; i++) { for (int ii = 0; ii < 1000000; ii++) { double x = (n + i * nodes + 0.5) * dx; if (x < 1) { result += 4 / (1 + x * x); } } } } } } // x ... Intervall-MittelPunkt // Intervall [0, 1] 60 Anhang A (Programme zur Problemstellung „Numerische Integration“) PiCalcThreadPool.java import import import import import import import import java.util.Scanner; java.util.concurrent.Executors; java.util.concurrent.ExecutorService; java.util.concurrent.Callable; java.util.concurrent.Future; java.util.ArrayList; java.util.List; java.util.Iterator; /** * Klasse zur Berechnung der Zahl pi durch numerische Integration. * Die Berechnung erfolgt parallel durch die Java-Concurrency-Utilities von Java 1.5. */ public class PiCalcThreadPool { /** Anzahl der verfuegbaren Rechen-Einheiten (CPUs) */ private static int nodes; /** Anzahl der Abschnitte auf der x-Achse */ private static int nx; public static void main(String[] args) { nodes = getNodes(args); nx = getStripes(args); long startTime = System.currentTimeMillis(); // Beginn der Laufzeit-Messung PiCalcThreadPool piCalc = new PiCalcThreadPool(); double pi = piCalc.calc(); } long endTime = System.currentTimeMillis(); // Ende der Laufzeit-Messung if (pi > 0) { System.out.println(" Berechnete Zahl pi: " + (pi / 1E06)); System.out.println(" RechenZeit: " + (endTime - startTime) + " ms"); } /** Pi berechnen. * @return pi als Näherung einer numerischer Integration */ private double calc() { ExecutorService executor = Executors.newFixedThreadPool(nodes); List<Future> futures = new ArrayList<Future>(nx); try { double dx = 1 / (double) nx; // dx ... IntervallBreite for (int i = 0; i < nx; i++) { double x = (i + 0.5) * dx; // x ... Intervall-MittelPunkt futures.add(executor.submit(new Service(x))); // System.out.println("hier 1"); } double pi = 0; for (Iterator<Future> it = futures.iterator(); it.hasNext(); ) { pi += ((Double) it.next().get()).doubleValue(); } pi *= dx; return pi; } } catch (Exception e) { System.err.println(" Fehler in der Berechnung!"); return -1; } finally { executor.shutdown(); } 61 Anhang A (Programme zur Problemstellung „Numerische Integration“) /** Anzahl der Rechen-Einheiten festlegen. * @param s ArgumentListe * @return Anzahl der Rechen-Einheiten */ public static int getNodes(String[] s) { int ret = -1; try { switch (s.length) { case 0: System.out.println(" Usage: java PiCalcThreadPool System.exit(1); break; case 1: ret = new Integer(s[0]).intValue(); break; default: ret = new Integer(s[1]).intValue(); } } catch (Exception e) { System.err.println(" Ungültiges Argument!"); System.exit(1); } return ret; } [<#TeilIntervalle>] <#CPUs>"); /** Anzahl der Teil-Intervalle festlegen. * @param args KommandozeilenArgumente * @return Anzahl der Teil-Intervalle */ private static int getStripes(String[] args) { int ret = -1; try { if (args.length > 1) { ret = new Integer(args[0]).intValue(); } else { System.out.println(" Die Zahl pi wird mit Hilfe einer numerischen Integration"); System.out.println(" auf dem Intervall [0, 1] berechnet."); System.out.print(" In wie viele Teile soll das Intervall geteilt werden: "); Scanner sc = new Scanner(System.in); ret = sc.nextInt(); sc.close(); } } catch (Exception e) { System.err.println(" Ungültige Eingabe!"); System.exit(2); } return ret; } /** Innere Klasse zur Berechnung und Verwaltung von Teil-Ergebnissen */ class Service implements Callable<Double> { private double x; // zu bearbeitender x-Wert /** Konstruktor * @param x Stelle, zu der der FunktionsWert zu berechnen ist. */ public Service(double x) { // System.out.println("hier 2"); this.x = x; } } } @Override public Double call() throws Exception { // System.out.println("hier 3"); double result = 0; for (int ii = 0; ii < 1000000; ii++) { result += 4 / (1 + x * x); } return new Double( result ); } 62 Anhang A (Programme zur Problemstellung „Numerische Integration“) PiCalcFJ1.java import import import import import java.util.Scanner; java.util.ArrayList; java.util.List; java.util.Iterator; EDU.oswego.cs.dl.util.concurrent.*; /** * Klasse zur Berechnung der Zahl pi durch numerische Integration. * Die Berechnung erfolgt parallel durch das Fork-Join-Framework * durch Ableitung von der Klasse FJTask. */ public class PiCalcFJ1 extends FJTask { private static int nx; // Anzahl d. Teilintervalle private double x; // Stelle des Teil-Intervalls an der Abszisse private volatile double result; // Ergebnis für das Teil-Intervall private boolean isMaster = false; // Master-Thread? /** Konstruktor */ public PiCalcFJ1(boolean isMaster) { this.isMaster = isMaster; } /** Konstruktor */ public PiCalcFJ1(double x) { this.x = x; } @Override public void run() { if (this.isMaster) { PiCalcFJ1[] tasks = new PiCalcFJ1[nx]; double dx = 1 / (double) nx; // dx ... IntervallBreite // Problem in TeilProbleme (Tasks) zerlegen: for (int i = 0; i < nx; i++) { double x = (i + 0.5) * dx; // x ... Intervall-MittelPunkt tasks[i] = new PiCalcFJ1(x); } // Tasks zur parallelen Abarbeitung übergeben: coInvoke(tasks); // Teil-Ergebnisse synchronisieren: this.result = 0; for (int i = 0; i < nx; i++) { this.result += tasks[i].getAnswer(); } this.result *= dx; this.result /= 1E06; } else { // Berechnung der Slaves (Worker-Threads): for (int ii = 0; ii < 1000000; ii++) { this.result += 4 / (1 + this.x * this.x); } } } public double getAnswer() { if (!isDone()) throw new IllegalStateException(" return this.result; } Noch nicht berechnet!"); 63 Anhang A (Programme zur Problemstellung „Numerische Integration“) public static void main(String[] args) { try { int nodes = getNodes(args); nx = getStripes(args); long startTime = System.currentTimeMillis(); // Anzahl d. verfuegbaren CPUs // Beginn der Laufzeit-Messung FJTaskRunnerGroup group = new FJTaskRunnerGroup(nodes); PiCalcFJ1 master = new PiCalcFJ1(true); group.invoke(master); double pi = master.getAnswer(); } long endTime = System.currentTimeMillis(); // Ende der Laufzeit-Messung System.out.println(" Berechnete Zahl pi: " + pi); System.out.println(" RechenZeit: " + (endTime - startTime) + " ms"); } catch (InterruptedException ex) {} /** Anzahl der Rechen-Einheiten festlegen. * @param s ArgumentListe * @return Anzahl der Rechen-Einheiten */ public static int getNodes(String[] s) { int ret = -1; try { switch (s.length) { case 0: System.out.println(" Usage: java PiCalcFJ1 [<#TeilIntervalle>] <#CPUs>"); System.exit(1); break; case 1: ret = new Integer(s[0]).intValue(); break; default: ret = new Integer(s[1]).intValue(); } } catch (Exception e) { System.err.println(" Ungültiges Argument!"); System.exit(1); } return ret; } /** Anzahl der Teil-Intervalle festlegen. * @param args KommandozeilenArgumente * @return Anzahl der Teil-Intervalle */ private static int getStripes(String[] args) { int ret = -1; try { if (args.length > 1) { ret = new Integer(args[0]).intValue(); } else { System.out.println(" Die Zahl pi wird mit Hilfe einer numerischen Integration"); System.out.println(" auf dem Intervall [0, 1] berechnet."); System.out.print(" In wie viele Teile soll das Intervall geteilt werden: "); Scanner sc = new Scanner(System.in); ret = sc.nextInt(); sc.close(); } } catch (Exception e) { System.err.println(" Ungültige Eingabe!"); System.exit(2); } return ret; } } 64 Anhang A (Programme zur Problemstellung „Numerische Integration“) PiCalcFJ2.java import import import import import import java.util.Scanner; java.util.ArrayList; java.util.List; java.util.Iterator; EDU.oswego.cs.dl.util.concurrent.*; jsr166y.*; /** * Klasse zur Berechnung der Zahl pi durch numerische Integration. * Die Berechnung erfolgt parallel durch das Fork-Join-Framework * durch Ableitung von der Klasse RecursiveAction, die wiederum von * ForkJoinTask abgeleitet ist. */ public class PiCalcFJ2 extends RecursiveAction { private static int nx; // Anzahl d. Teilintervalle private double x; // Stelle des Teil-Intervalls an der Abszisse private volatile double result; // Ergebnis für das Teil-Intervall private boolean isMaster = false; // Master-Thread? /** Konstruktor */ public PiCalcFJ2(boolean isMaster) { this.isMaster = isMaster; } /** Konstruktor * @param x Stelle des Teil-Intervalls */ public PiCalcFJ2(double x) { this.x = x; } /** Berechnung laut Formel */ @Override public void compute() { if (this.isMaster) { PiCalcFJ2[] tasks = new PiCalcFJ2[nx]; double dx = 1 / (double) nx; // dx ... IntervallBreite // Problem in TeilProbleme (Tasks) zerlegen: for (int i = 0; i < nx; i++) { double x = (i + 0.5) * dx; // x ... Intervall-MittelPunkt tasks[i] = new PiCalcFJ2(x); } // Tasks zur parallelen Abarbeitung übergeben: invokeAll(tasks); // Teil-Ergebnisse synchronisieren: this.result = 0; for (int i = 0; i < nx; i++) { this.result += tasks[i].getAnswer(); } this.result *= dx; this.result /= 1E06; } else { // Berechnung der Slaves (Worker-Threads): for (int ii = 0; ii < 1000000; ii++) { this.result += 4 / (1 + this.x * this.x); } } } /** Prueft, ob die Berechnung fertig ist. */ public double getAnswer() { if (!isDone()) throw new IllegalStateException("Not yet computed"); return this.result; } 65 Anhang A (Programme zur Problemstellung „Numerische Integration“) public static void main(String[] args) { int nodes = getNodes(args); nx = getStripes(args); long startTime = System.currentTimeMillis(); // Anzahl d. verfuegbaren CPUs // Anzahl d. Teilintervalle // Beginn der Laufzeit-Messung ForkJoinPool pool = new ForkJoinPool(nodes); PiCalcFJ2 master = new PiCalcFJ2(true); pool.invoke(master); double pi = master.getAnswer(); } long endTime = System.currentTimeMillis(); // Ende der Laufzeit-Messung System.out.println(" Berechnete Zahl pi: " + pi); System.out.println(" RechenZeit: " + (endTime - startTime) + " ms"); /** Anzahl der Rechen-Einheiten festlegen. * @param s ArgumentListe * @return Anzahl der Rechen-Einheiten */ public static int getNodes(String[] s) { int ret = -1; try { switch (s.length) { case 0: System.out.println(" Usage: java PiCalcFJ2 [<#TeilIntervalle>] <#CPUs>"); System.exit(1); break; case 1: ret = new Integer(s[0]).intValue(); break; default: ret = new Integer(s[1]).intValue(); } } catch (Exception e) { System.err.println(" Ungültiges Argument!"); System.exit(1); } return ret; } /** Anzahl der Teil-Intervalle festlegen. * @param args KommandozeilenArgumente * @return Anzahl der Teil-Intervalle */ private static int getStripes(String[] args) { int ret = -1; try { if (args.length > 1) { ret = new Integer(args[0]).intValue(); } else { System.out.println(" Die Zahl pi wird mit Hilfe einer numerischen Integration"); System.out.println(" auf dem Intervall [0, 1] berechnet."); System.out.print(" In wie viele Teile soll das Intervall geteilt werden: "); Scanner sc = new Scanner(System.in); ret = sc.nextInt(); sc.close(); } } catch (Exception e) { System.err.println(" Ungültige Eingabe!"); System.exit(2); } return ret; } } 66 Anhang B (Programme zur Problemstellung „Sortieren“) Anhang B (Programme zur Problemstellung „Sortieren“) QuickSortSeq.java /** * Klasse zum Sortieren eines double-Arrays mittels Quicksort-Algorithmus */ public class QuickSortSeq { public static void main(String[] args) { try { int size = 0; // Groesse des Arrays, das sortiert wird if (args.length > 0) { size = Integer.parseInt(args[0]); } else { System.out.println(" Usage: java QuickSortSeq <n> ... (n = size of array)"); System.exit(-1); } double[] v = new double[size]; initArray(v); long startTime = System.currentTimeMillis(); // Beginn der Laufzeit-Messung sort(v, 0, v.length-1); long endTime = System.currentTimeMillis(); // Ende der Laufzeit-Messung // printArray(v); System.out.println(" Elapsed time: " + (endTime - startTime) + " ms"); } catch (NumberFormatException e) { System.err.println(" Ungültige, nicht numerische Argumente!"); System.exit(-1); } } /** Initialisierung des Arrays mit zufälligen double-Werten * @param v initialsiertes Array */ public static void initArray(double[] v) { for (int i=0; i < v.length; i++) { v[i] = Math.random(); } } /** Array ausgeben * @param v auszugebendes Array */ public static void printArray(double[] v) { for (int i=0; i < v.length; i++) { System.out.println(" " + v[i]); } System.out.println(); } /** Array sortieren * @param a zu sortierendes Array * @param l linker Rand des bearbeiteten Bereichs * @param r rechter Rand des bearbeiteten Bereichs */ public static void sort(double[] a, int l, int r){ int i = 0; int j = 0; double x = 0; double h = 0; i = l; j = r; x = a[(l+r)/2]; 67 Anhang B (Programme zur Problemstellung „Sortieren“) } } do { while (a[i] < x) while (x < a[j]) if (i <= j) { h = a[i]; a[i] = a[j]; a[j] = h; i++; j--; } } while (i <= j); if (l < j) sort(a, if (i < r) sort(a, { i++; } { j--; } l, j); i, r); 68 Anhang B (Programme zur Problemstellung „Sortieren“) QuickSortThreads.java /* */ Quelle: http://webcache.googleusercontent.com/search?q=cache:A6qfrK1YSNwJ:www.wiasberlin.de/people/telschow/2001ss-edv2/Vorlesungen/01-Parallelprogrammierung/01Parallelprogrammierung.ppt+parallel+quicksort&cd=9&hl=de&ct=clnk&gl=at Beschreibung: QuickSort mittels Threads Ein Array aus zufälligen Werten wird erzeugt. Die Größe des Arrays kann über einen Parameter angegeben werden. Damit nicht zuviele kleine Threads erzeugt werden - die zuviel Overhead erzeugen wird über den Parameter c festgelegt, wann sequentiell weitergearbeitet wird. /** * Klasse zum Sortieren eines double-Arrays mittels Quicksort-Algorithmus */ public class QuickSortThreads extends Thread { private private private private double[] a; int l; int r; int c; // // // // zu sortierendes Array linker Rand des bearbeiteten Bereichs rechter Rand des bearbeiteten Bereichs MinimalGröße eines TeilArrays ab wann ein Thread erzeugt wird /** Konstruktor * @param a zu sortierendes Array * @param l linker Rand des bearbeiteten Bereichs * @param r rechter Rand des bearbeiteten Bereichs * @param c Threshold ab dem parallel gearbeitet wird */ public QuickSortThreads(double[] a, int l, int r, int c) { this.a = a; this.l = l; this.r = r; this.c = c; } /** Konstruktor * @param a zu sortierendes Array */ public QuickSortThreads(double[] a) { this(a, 0, a.length - 1, a.length); } @Override public void run() { sort(a, l, r, c); } public static void main(String[] args) { try { int nthreads = 16; // Anzahl an Worker-Threads int size = 0; // Groesse des Arrays, das sortiert wird if (args.length > 0) { size = Integer.parseInt(args[0]); } else { System.out.println(" Usage: java QuickSortThreads <n> [threads] ... (n = size of array)"); System.exit(-1); } if (args.length > 1) { nthreads = Integer.parseInt(args[1]); } 69 Anhang B (Programme zur Problemstellung „Sortieren“) // } double[] v = new double[size]; initArray(v); long startTime = System.currentTimeMillis(); sort(v, 0, v.length-1, nthreads); long endTime = System.currentTimeMillis(); printArray(v); System.out.println(" Elapsed time: " + (endTime } catch (NumberFormatException e) { System.err.println(" Ungültige, nicht numerische System.exit(-1); } // Beginn der Laufzeit-Messung // Ende der Laufzeit-Messung - startTime) + " ms"); Argumente!"); /** Initialisierung des Arrays mit zufälligen double-Werten * @param v initialsiertes Array */ public static void initArray(double[] v) { for (int i=0; i < v.length; i++) { v[i] = Math.random(); } } /** Array ausgeben * @param v auszugebendes Array */ public static void printArray(double[] v) { for (int i=0; i < v.length; i++) { System.out.println(" " + v[i]); } System.out.println(); } } /** Array sortieren * @param a zu sortierendes Array * @param l linker Rand des bearbeiteten Bereichs * @param r rechter Rand des bearbeiteten Bereichs * @param c Threshold ab dem parallel gearbeitet wird */ public static void sort(double[] a, int l, int r, int c) { int i = 0; int j = 0; double x = 0; double h = 0; i = l; j = r; x = a[(l+r)/2]; do { while (a[i] < x) { i++; } while (x < a[j]) { j--; } if (i <= j) { h = a[i]; a[i] = a[j]; a[j] = h; i++; j--; } } while (i <= j); QuickSortThreads tl = null; QuickSortThreads tr = null; if ((l+c < j) && (i+c < r)) { // parallele Abarbeitung: tl = new QuickSortThreads(a, l, j, c); tr = new QuickSortThreads(a, i, r, c); tl.start(); tr.start(); } else { // sequentielle Abarbeitung: if (l < j) sort(a, l, j, c); if (i < r) sort(a, i, r, c); } try { if (tl != null) tl.join(); if (tr != null) tr.join(); } catch (InterruptedException ie) {} } 70 Anhang B (Programme zur Problemstellung „Sortieren“) QuickSortThreadPool.java /* */ Quelle: http://webcache.googleusercontent.com/search?q=cache:A6qfrK1YSNwJ:www.wiasberlin.de/people/telschow/2001ss-edv2/Vorlesungen/01-Parallelprogrammierung/01Parallelprogrammierung.ppt+parallel+quicksort&cd=9&hl=de&ct=clnk&gl=at Beschreibung: QuickSort mittels Threads Ein Array aus zufälligen Werten wird erzeugt. Die Größe des Arrays kann über einen Parameter angegeben werden. Damit nicht zuviele kleine Threads erzeugt werden - die zuviel Overhead erzeugen wird über den Parameter c festgelegt, wann sequentiell weitergearbeitet wird. import java.util.concurrent.*; /** * Klasse zum Sortieren eines double-Arrays mittels Quicksort-Algorithmus */ public class QuickSortThreadPool implements Runnable { private private private private private static ExecutorService exec; double[] a; // zu sortierendes Array int l; // linker Rand des bearbeiteten Bereichs int r; // rechter Rand des bearbeiteten Bereichs int c; // MinimalGröße eines TeilArrays ab wann ein Thread erzeugt wird /** Konstruktor * @param a zu sortierendes Array * @param l linker Rand des bearbeiteten Bereichs * @param r rechter Rand des bearbeiteten Bereichs * @param c Threshold ab dem parallel gearbeitet wird */ public QuickSortThreadPool(double[] a, int l, int r, int c) { this.a = a; this.l = l; this.r = r; this.c = c; } /** Konstruktor * @param a zu sortierendes Array */ public QuickSortThreadPool(double[] a) { this(a, 0, a.length - 1, a.length); } @Override public void run() { sort(a, l, r, c); } public static void main(String[] args) { try { int nthreads = 1; // Anzahl an Worker-Threads int size = 0; // Groesse des Arrays, das sortiert wird if (args.length > 0) { size = Integer.parseInt(args[0]); } else { System.out.println(" Usage: java QuickSortThreadPool <n> [threads] ... (n = size of array)"); System.exit(-1); } 71 Anhang B (Programme zur Problemstellung „Sortieren“) if (args.length > 1) { nthreads = Integer.parseInt(args[1]); } // } double[] v = new double[size]; initArray(v); long startTime = System.currentTimeMillis(); // Beginn der Laufzeit-Messung exec = Executors.newFixedThreadPool(nthreads); sort(v, 0, v.length-1, v.length); long endTime = System.currentTimeMillis(); // Ende der Laufzeit-Messung printArray(v); System.out.println(" Elapsed time: " + (endTime - startTime) + " ms"); } catch (NumberFormatException e) { System.err.println(" Ungültige, nicht numerische Argumente!"); System.exit(-1); } /** Initialisierung des Arrays mit zufälligen double-Werten * @param v initialsiertes Array */ public static void initArray(double[] v) { for (int i=0; i < v.length; i++) { v[i] = Math.random(); } } /** Array ausgeben * @param v auszugebendes Array */ public static void printArray(double[] v) { for (int i=0; i < v.length; i++) { System.out.println(" " + v[i]); } System.out.println(); } /** Array sortieren * @param a zu sortierendes Array * @param l linker Rand des bearbeiteten Bereichs * @param r rechter Rand des bearbeiteten Bereichs * @param c Threshold ab dem parallel gearbeitet wird */ public static void sort(double[] a, int l, int r, int c) { int i = 0; int j = 0; double x = 0; double h = 0; i = l; j = r; x = a[(l+r)/2]; do { while (a[i] < x) { i++; } while (x < a[j]) { j--; } if (i <= j) { h = a[i]; a[i] = a[j]; a[j] = h; i++; j--; } } while (i <= j); if ((l+c < j) && (i+c < r)) { // parallele Abarbeitung: Runnable taskL = new QuickSortThreadPool(a, l, l+c, c); Runnable taskR = new QuickSortThreadPool(a, i, i+c, c); Future futureL = exec.submit(taskL); Future futureR = exec.submit(taskR); try { futureL.get(); futureR.get(); } catch (InterruptedException e) { System.err.println("Task unterbrochen!"); 72 Anhang B (Programme zur Problemstellung „Sortieren“) } } } catch (ExecutionException e) { System.err.println("Fehler bei der Ausführung des Tasks!"); } } else { // sequentielle Abarbeitung: if (l < j) sort(a, l, j, c); if (i < r) sort(a, i, r, c); } 73 Anhang B (Programme zur Problemstellung „Sortieren“) 74 QuickSortFJ1.java import EDU.oswego.cs.dl.util.concurrent.*; /** * Klasse zum Sortieren eines double-Arrays mittels Quicksort-Algorithmus */ class QuickSortFJ1 extends FJTask { private private private private double[] a; int l; int r; int c; // zu sortierendes Array // linker Rand des bearbeiteten Bereichs // rechter Rand des bearbeiteten Bereichs // MinimalGröße eines TeilArrays ab wann ein Thread erzeugt wird /** Konstruktor * @param a zu sortierendes Array * @param l linker Rand des bearbeiteten Bereichs * @param r rechter Rand des bearbeiteten Bereichs * @param c Threshold ab dem parallel gearbeitet wird */ public QuickSortFJ1(double[] a, int l, int r, int c) { this.a = a; this.l = l; this.r = r; this.c = c; } /** Konstruktor * @param a zu sortierendes Array */ public QuickSortFJ1(double[] a) { this(a, 0, a.length - 1, a.length); } @Override public void run() { sort(a, l, r, c); } /** Kontrolle, ob die Berechnung fertig ist. * @return true, wenn das Ergebnis vorliegt, sonst FehlerMeldung */ public boolean getAnswer() { if (!isDone()) throw new IllegalStateException("Not yet computed"); return true; } public static void main(String[] args) { try { int groupSize = 1; // Anzahl an Worker-Threads int size = 0; // Groesse des Arrays, das sortiert wird if (args.length > 0) { size = Integer.parseInt(args[0]); } else { System.out.println(" Usage: java QuickSortFJ1 <n> [threads] ... (n = size of array)"); System.exit(-1); } if (args.length > 1) { groupSize = Integer.parseInt(args[1]); } double[] v = new double[size]; initArray(v); Anhang B (Programme zur Problemstellung „Sortieren“) // } long startTime = System.currentTimeMillis(); // Beginn der Laufzeit-Messung FJTaskRunnerGroup group = new FJTaskRunnerGroup(groupSize); QuickSortFJ1 f = new QuickSortFJ1(v); group.invoke(f); f.getAnswer(); long endTime = System.currentTimeMillis(); // Ende der Laufzeit-Messung printArray(v); System.out.println(" Elapsed Time: " + (endTime - startTime) + " ms"); } catch (NumberFormatException e) { System.err.println(" Ungültige, nicht numerische Argumente!"); System.exit(-1); } catch (InterruptedException ex) {} // die /** Initialisierung des Arrays mit zufälligen double-Werten * @param v initialsiertes Array */ public static void initArray(double[] v) { for (int i=0; i < v.length; i++) { v[i] = Math.random(); } } /** Array ausgeben * @param v auszugebendes Array */ public static void printArray(double[] v) { for (int i=0; i < v.length; i++) { System.out.println(" " + v[i]); } System.out.println(); } /** Array sortieren * @param a zu sortierendes Array * @param l linker Rand des bearbeiteten Bereichs * @param r rechter Rand des bearbeiteten Bereichs * @param c Threshold ab dem parallel gearbeitet wird */ public static void sort(double[] a, int l, int r, int c) { int i = l; int j = r; double h = 0; double x = a[ (l+r) / 2 ]; do { while (a[i] < x) { i++; } while (x < a[j]) { j--; } if (i <= j) { h = a[i]; a[i] = a[j]; a[j] = h; i++; j--; } } while (i <= j); } } if ((l+c < j) && (i+c < r)) { // parallele Abarbeitung: QuickSortFJ1 taskL = new QuickSortFJ1(a, l, l+c, c); QuickSortFJ1 taskR = new QuickSortFJ1(a, i, i+c, c); coInvoke(taskL, taskR); taskL.getAnswer(); taskR.getAnswer(); } else { // sequentielle Abarbeitung: if (l < j) sort(a, l, j, c); if (i < r) sort(a, i, r, c); } 75 Anhang B (Programme zur Problemstellung „Sortieren“) 76 QuickSortFJ2.java /* */ Variante mit der Klasse "ForkJoinTask" (statt FJTask) import EDU.oswego.cs.dl.util.concurrent.*; import jsr166y.*; /** * Klasse zum Sortieren eines double-Arrays mittels Quicksort-Algorithmus */ class QuickSortFJ2 extends RecursiveAction { private private private private double[] a; int l; int r; int c; // zu sortierendes Array // linker Rand des bearbeiteten Bereichs // rechter Rand des bearbeiteten Bereichs // MinimalGröße eines TeilArrays ab wann ein Thread erzeugt wird /** Konstruktor * @param a zu sortierendes Array * @param l linker Rand des bearbeiteten Bereichs * @param r rechter Rand des bearbeiteten Bereichs * @param c Threshold ab dem parallel gearbeitet wird */ public QuickSortFJ2(double[] a, int l, int r, int c) { this.a = a; this.l = l; this.r = r; this.c = c; } /** Konstruktor * @param a zu sortierendes Array */ public QuickSortFJ2(double[] a) { this(a, 0, a.length - 1, a.length); } @Override public void compute() { sort(a, l, r, c); } /** Kontrolle, ob die Berechnung fertig ist. * @return true, wenn das Ergebnis vorliegt, sonst FehlerMeldung */ public boolean getAnswer() { if (!isDone()) throw new IllegalStateException("Not yet computed"); return true; } public static void main(String[] args) { try { int groupSize = 1; // Anzahl an Worker-Threads int size = 0; // Groesse des Arrays, das sortiert wird if (args.length > 0) { size = Integer.parseInt(args[0]); } else { System.out.println(" Usage: java QuickSortFJ2 <n> [threads] ... (n = size of array)"); System.exit(-1); } if (args.length > 1) { groupSize = Integer.parseInt(args[1]); } Anhang B (Programme zur Problemstellung „Sortieren“) // } double[] v = new double[size]; initArray(v); long startTime = System.currentTimeMillis(); ForkJoinPool pool = new ForkJoinPool(groupSize); QuickSortFJ2 f = new QuickSortFJ2(v); pool.invoke(f); f.getAnswer(); long endTime = System.currentTimeMillis(); printArray(v); System.out.println(" Elapsed Time: " + (endTime } catch (NumberFormatException e) { System.err.println(" Ungültige, nicht numerische System.exit(-1); } // Beginn der Laufzeit-Messung // Ende der Laufzeit-Messung - startTime) + " ms"); Argumente!"); /** Initialisierung des Arrays mit zufälligen double-Werten * @param v initialsiertes Array */ public static void initArray(double[] v) { for (int i=0; i < v.length; i++) { v[i] = Math.random(); } } /** Array ausgeben * @param v auszugebendes Array */ public static void printArray(double[] v) { for (int i=0; i < v.length; i++) { System.out.println(" " + v[i]); } System.out.println(); } /** Array sortieren * @param a zu sortierendes Array * @param l linker Rand des bearbeiteten Bereichs * @param r rechter Rand des bearbeiteten Bereichs * @param c Threshold ab dem parallel gearbeitet wird */ public static void sort(double[] a, int l, int r, int c) { int i = l; int j = r; double h = 0; double x = a[ (l+r) / 2 ]; do { while (a[i] < x) { i++; } while (x < a[j]) { j--; } if (i <= j) { h = a[i]; a[i] = a[j]; a[j] = h; i++; j--; } } while (i <= j); } } if ((l+c < j) && (i+c < r)) { // parallele Abarbeitung: QuickSortFJ2 taskL = new QuickSortFJ2(a, l, l+c, c); QuickSortFJ2 taskR = new QuickSortFJ2(a, i, i+c, c); invokeAll(taskL, taskR); taskL.getAnswer(); taskR.getAnswer(); } else { // sequentielle Abarbeitung: if (l < j) sort(a, l, j, c); if (i < r) sort(a, i, r, c); } 77 Anhang C (Programme zur Problemstellung „Fibonacci-Zahlen“) Anhang C (Programme zur Problemstellung „FibonacciZahlen“) FibSeq.java /* Beispiele von Fibonnaci-Zahlen auf: http://www.ijon.de/mathe/fibonacci/index.html */ /** * Klasse zur Berechnung einer Fibonacci-Zahl. * Die Berechnung erfolgt durch einen sequentiellen Algorithmus. */ public class FibSeq { /** Berechnung einer Fibonacci-Zahl durch Rekursion. * @param n OrdnungsZahl der Fibonacci-Zahl * @return berechnete Fibonacci-Zahl */ private static int seqFib(int n) { if (n <= 1) return n; else return seqFib(n-1) + seqFib(n-2); } public static void main(String[] args) { int num = 0; if (args.length == 1) { num = Integer.parseInt(args[0]); } else { System.out.println("Usage: java FibSeq <n> System.exit(-1); } } } // compute fib(num) ... (computes fib(n))"); long startTime = System.currentTimeMillis(); // Beginn der Laufzeit-Messung int result = seqFib(num); long endTime = System.currentTimeMillis(); // Ende der Laufzeit-Messung System.out.println(" fib(" + num + ") = " + result); System.out.println(" Elapsed Time: " + (endTime - startTime) + " ms"); 78 Anhang C (Programme zur Problemstellung „Fibonacci-Zahlen“) 79 FibThreads.java /* */ Beispiele von Fibonnaci-Zahlen auf: http://www.ijon.de/mathe/fibonacci/index.html /** * Klasse zur Berechnung einer Fibonacci-Zahl. * Die Abarbeitung erfolgt über Java-Threads. */ class FibThreads extends Thread { static final int sequentialThreshold = 13; volatile int number; // for tuning // argument/result /** Konstruktor * @param n OrdnungsZahl der Fibonacci-Zahl in der Reihe der Fibonacci-Zahlen FibThreads(int n) { number = n; } */ /** Berechnung einer Fibonacci-Zahl durch Rekursion. * @param n OrdnungsZahl der Fibonacci-Zahl * @return berechnete Fibonacci-Zahl */ int seqFib(int n) { if (n <= 1) return n; else return seqFib(n-1) + seqFib(n-2); } /** Abfrage des Ergebnisses. * @return Fibonacci-Zahl des (Teil-)Problems */ int getAnswer() { return number; } @Override public void run() { int n = number; if (n <= sequentialThreshold) number = seqFib(n); else { FibThreads f1 = new FibThreads(n - 1); FibThreads f2 = new FibThreads(n - 2); coInvoke(f1, f2); number = f1.number + f2.number; } } // BasisFall // SubTasks erzeugen // zur parallelen Ausführung übergeben // Ergebnisse zusammenführen /** Zwei Teil-Probleme zur parallelen Ausführung bringen, * und anschließend synchronisieren. * @param f1 1. Teil-Problem als Thread * @param f2 2. Teil-Problem als Thread */ private void coInvoke(Thread f1, Thread f2) { try { f1.start(); f2.start(); f1.join(); f2.join(); } catch (InterruptedException ie) { ie.printStackTrace(); } } Anhang C (Programme zur Problemstellung „Fibonacci-Zahlen“) public static void main(String[] args) { try { int num = 0; // compute fib(num) if (args.length == 1) { num = Integer.parseInt(args[0]); } else { System.out.println(" Usage: java FibThreads <n> ... (computes fib(n))"); System.exit(-1); } long startTime = System.currentTimeMillis(); // Beginn der Laufzeit-Messung FibThreads f = new FibThreads(num); f.start(); f.join(); int result = f.getAnswer(); long endTime = System.currentTimeMillis(); // Ende der Laufzeit-Messung System.out.println(" fib(" + num + ") = " + result); System.out.println(" Elapsed Time: " + (endTime - startTime) + " ms"); } } } catch (InterruptedException ex) {} // die 80 Anhang C (Programme zur Problemstellung „Fibonacci-Zahlen“) 81 FibThreadPool.java /* Beispiele von Fibonnaci-Zahlen auf: http://www.ijon.de/mathe/fibonacci/index.html ============================== Variante mit Concurrency Utils ============================== */ In der main-Methode wird ein Thread nach der klassischen Thread-Programmierung gestartet. Dieser Thread ruft allerdings in seiner run-Methode zwei weitere Threads auf. Jeder dieser beiden Threads ruft wieder zwei weitere Tasks auf usw. import java.util.concurrent.*; /** * Klasse zur Berechnung einer Fibonacci-Zahl. * Die Abarbeitung erfolgt über die Concurrent-Utilities lt. Java 1.5. */ class FibThreadPool extends Thread{ static final int sequentialThreshold = 13; // for tuning volatile int number; // argument/result private static ExecutorService executor; /** Konstruktor * @param n OrdnungsZahl der Fibonacci-Zahl in der Reihe der Fibonacci-Zahlen FibThreadPool(int n) { number = n; } */ /** Berechnung einer Fibonacci-Zahl durch Rekursion. * @param n OrdnungsZahl der Fibonacci-Zahl * @return berechnete Fibonacci-Zahl */ int seqFib(int n) { if (n <= 1) return n; else return seqFib(n-1) + seqFib(n-2); } /** Abfrage des Ergebnisses. * @return Fibonacci-Zahl des (Teil-)Problems */ int getResult() { return number; } @Override public void run() { int n = number; if (n <= sequentialThreshold) // base case number = seqFib(n); else { // create subtasks : Callable<Integer> task1 = new Service(n-1); Callable<Integer> task2 = new Service(n-2); Future<Integer> future1 = executor.submit(task1); Future<Integer> future2 = executor.submit(task2); try { // join both: Integer result1 = future1.get(); Integer result2 = future2.get(); this.number = result1.intValue() + result2.intValue(); // combine results Anhang C (Programme zur Problemstellung „Fibonacci-Zahlen“) } } 82 } catch (Exception e) { System.err.println(" Fehler in der Berechnung!"); } public static void main(String[] args) { int num = 0; int nodes = 16; // number of threads try { if (args.length > 0) { num = Integer.parseInt(args[0]); } else { System.out.println(" Usage: java FibThreadPool <n> [threads] System.exit(-1); } if (args.length > 1) { nodes = Integer.parseInt(args[1]); } } catch (NumberFormatException e) { System.err.println(" Ungültige, nicht numerische Argumente!"); System.exit(-1); } try { long startTime = System.currentTimeMillis(); executor = Executors.newFixedThreadPool(nodes); ... (computes fib(n))"); // Beginn der Laufzeit-Messung FibThreadPool f = new FibThreadPool(num); f.start(); f.join(); int result = f.getResult(); } long endTime = System.currentTimeMillis(); // Ende der Laufzeit-Messung System.out.println(" fib(" + num + ") = " + result); System.out.println(" Elapsed Time: " + (endTime - startTime) + " ms"); } catch (Exception e) { System.err.println(" Fehler in der Berechnung!"); System.exit(-1); } finally { executor.shutdown(); } /** Klasse zur Berechnung und Verwaltung von Teil-Ergebnissen */ class Service implements Callable<Integer> { private int n; /** Konstruktor */ public Service(int n) { this.n = n; } } } @Override public Integer call() throws Exception { FibThreadPool f = new FibThreadPool(this.n); f.start(); f.join(); return new Integer( f.getResult() ); } Anhang C (Programme zur Problemstellung „Fibonacci-Zahlen“) 83 FibFJ1.java import EDU.oswego.cs.dl.util.concurrent.*; /** * * * */ class Klasse zur Berechnung einer Fibonacci-Zahl. Die Abarbeitung erfolgt mit dem Fork/Join-Framework. Die Klasse ist von FJTask abgeleitet. FibFJ1 extends FJTask { static final int sequentialThreshold = 13; // for tuning volatile int number; // argument/result /** Konstruktor * @param n OrdnungsZahl der Fibonacci-Zahl in der Reihe der Fibonacci-Zahlen FibFJ1(int n) { number = n; } */ /** Berechnung einer Fibonacci-Zahl durch Rekursion. * @param n OrdnungsZahl der Fibonacci-Zahl * @return berechnete Fibonacci-Zahl */ int seqFib(int n) { if (n <= 1) return n; else return seqFib(n-1) + seqFib(n-2); } /** Abfrage des Ergebnisses. * @return Fibonacci-Zahl des (Teil-)Problems */ int getAnswer() { if (!isDone()) throw new IllegalStateException("Not yet computed"); return number; } @Override public void run() { int n = number; if (n <= sequentialThreshold) // BasisFall number = seqFib(n); else { // SubTasks erzeugen: FibFJ1 f1 = new FibFJ1(n - 1); FibFJ1 f2 = new FibFJ1(n - 2); // Tasks parallel ausführen und synchronisieren: coInvoke(f1, f2); number = f1.number + f2.number; } } public static void main(String[] args) { try { int groupSize = 1; // Anzahl an Worker-Threads int num = 0; // Fibonacci-Zahl zum Argument <num> wird berechnet if (args.length > 0) { num = Integer.parseInt(args[0]); } else { System.out.println(" Usage: java FibFJ1 <n> [threads] ... (computes fib(n))"); System.exit(-1); } if (args.length > 1) { groupSize = Integer.parseInt(args[1]); } Anhang C (Programme zur Problemstellung „Fibonacci-Zahlen“) } } long startTime = System.currentTimeMillis(); // Beginn der Laufzeit-Messung FJTaskRunnerGroup group = new FJTaskRunnerGroup(groupSize); FibFJ1 f = new FibFJ1(num); group.invoke(f); int result = f.getAnswer(); long endTime = System.currentTimeMillis(); // Ende der Laufzeit-Messung System.out.println(" fib(" + num + ") = " + result); System.out.println(" Elapsed Time: " + (endTime - startTime) + " ms"); } catch (NumberFormatException e) { System.err.println(" Ungültige, nicht numerische Argumente!"); System.exit(-1); } catch (InterruptedException ex) {} 84 Anhang C (Programme zur Problemstellung „Fibonacci-Zahlen“) 85 FibFJ2.java /* */ Variante mit der Klasse "ForkJoinTask" (statt FJTask) statt run-Methode => compute-Methode statt FJTaskRunnerGroup -> ForkJoinPool import EDU.oswego.cs.dl.util.concurrent.*; import jsr166y.*; /** * * * * */ class Klasse zur Berechnung einer Fibonacci-Zahl. Die Abarbeitung erfolgt mit dem Fork/Join-Framework. Die Klasse ist von RecursiveTask abgeleitet, die wiederum von ForkJoinTask abgeleitet ist. FibFJ2 extends RecursiveTask { static final int sequentialThreshold = 13; // for tuning volatile int number; // argument/result /** Konstruktor * @param n OrdnungsZahl der Fibonacci-Zahl in der Reihe der Fibonacci-Zahlen FibFJ2(int n) { number = n; } /** Berechnung einer Fibonacci-Zahl durch Rekursion. * @param n OrdnungsZahl der Fibonacci-Zahl * @return berechnete Fibonacci-Zahl */ int seqFib(int n) { if (n <= 1) return n; else return seqFib(n-1) + seqFib(n-2); } /** Abfrage des Ergebnisses. * @return Fibonacci-Zahl des (Teil-)Problems */ int getAnswer() { if (!isDone()) throw new IllegalStateException("Not yet computed"); return number; } @Override protected Integer compute() { int n = number; if (n <= sequentialThreshold) // BasisFall number = seqFib(n); else { // SubTasks erzeugen: FibFJ2 f1 = new FibFJ2(n - 1); FibFJ2 f2 = new FibFJ2(n - 2); // Tasks parallel ausführen und synchronisieren: invokeAll(f1, f2); number = f1.number + f2.number; } return new Integer(number); } */ Anhang C (Programme zur Problemstellung „Fibonacci-Zahlen“) public static void main(String[] args) { try { int groupSize = 1; // Anzahl an Worker-Threads int num = 0; // Fibonacci-Zahl zum Argument <num> wird berechnet if (args.length > 0) { num = Integer.parseInt(args[0]); } else { System.out.println(" Usage: java FibFJ2 <n> [threads] ... (computes fib(n))"); System.exit(-1); } if (args.length > 1) { groupSize = Integer.parseInt(args[1]); } } } long startTime = System.currentTimeMillis(); // Beginn der Laufzeit-Messung ForkJoinPool pool = new ForkJoinPool(groupSize); FibFJ2 f = new FibFJ2(num); pool.invoke(f); int result = f.getAnswer(); long endTime = System.currentTimeMillis(); // Ende der Laufzeit-Messung System.out.println(" fib(" + num + ") = " + result); System.out.println(" Elapsed Time: " + (endTime - startTime) + " ms"); } catch (NumberFormatException e) { System.err.println(" Ungültige, nicht numerische Argumente!"); System.exit(-1); } 86 Anhang D (Programme zur Problemstellung „Matrizen-Multiplikation“) Anhang D (Programme zur Problemstellung „MatrizenMultiplikation“) MatrixMult_naive.java /* Quelle: http://artisans-serverintellect-com.si-eioswww6.com/default.asp?W40 http://forums.sun.com/thread.jspa?threadID=681520 */ /** * Klasse zur Multiplikation zweier Matrizen. * Die Abarbeitung erfolgt sequentiell. */ public class MatrixMult_naive { /* TestMatrizen: static int[][] a = {{5,6,7},{4,8,9},{3,2,1}}; static int[][] b = {{6,4,8},{5,7,8},{4,3,2}}; static static */ static static static int[][] x = {{4,8,9},{3,2,1}}; int[][] y = {{6,4,2,0},{5,7,9,11},{4,3,2,1}}; int[][] a; int[][] b; int[][] c; public static void main(String[] args) { int rowsA = 1000, colsA = 1000; int rowsB = 1000, colsB = 1000; // prüfen, ob Dimensionen der Matrizen über Kommando-Zeilen-Par. angegeben: if (args.length == 4) { rowsA = new Integer(args[0]).intValue(); colsA = new Integer(args[1]).intValue(); rowsB = new Integer(args[2]).intValue(); colsB = new Integer(args[3]).intValue(); } a = initMatrix(rowsA, colsA); b = initMatrix(rowsB, colsB); int rows = a.length; int cols = b[0].length; c = new int[rows][cols]; long startTime = System.currentTimeMillis(); naiveMatrixMultiply(a, b, c); long endTime = System.currentTimeMillis(); /* // Beginn der Laufzeit-Messung // Ende der Laufzeit-Messung printMatrix(a); printMatrix(b); printMatrix(c); */ System.out.println(" } Matrix[" + a.length + "*" + a[0].length + "] * " + "Matrix[" + b.length + "*" + b[0].length + "] = " + "Matrix[" + rows + "*" + cols + "]"); System.out.println(" RechenZeit: " + (endTime - startTime) + " ms"); 87 Anhang D (Programme zur Problemstellung „Matrizen-Multiplikation“) /** Belegung einer Matrix mit zufälligen ganzzahligen Werten. * @param r Anzahl der Zeilen der Matrix * @param c Anzahl der Spalten der Matrix * @return Matrix mit ganzzahligen (zufälligen) Werten */ private static int[][] initMatrix(int r, int c) { int[][] m = new int[r][c]; for (int i = 0; i < r; i++) { for (int j = 0; j < c; j++) { m[i][j] = (int) (Math.random() * 10); } } return m; } /** Multiplikation der Matrizen: * c[m][p] = a[m][n] * b[n][p] * @param a erste Matrize * @param b zweite Matrize * @param c Ergebnis-Matrix */ private static void naiveMatrixMultiply( final int[][] a, final int[][] b, final int[][] c ) { check(a,b,c); final int m = a.length; final int n = b.length; final int p = b[0].length; for (int j = 0; j < p; j++) { for (int i = 0; i < m; i++) { int s = 0; for (int k = 0; k < n; k++) { s += a[i][k] * b[k][j]; } c[i][j] = s; } } } /** Kompatibilität der Matrizen überprüfen * (LaufzeitFehler im FehlerFall auslösen) * @param a erste Matrize * @param b zweite Matrize * @param c Ergebnis-Matrix */ private static void check(final int[][] a, final int[][] b, final int[][] c) { check(a); check(b); check(c); if (c == a | c == b) throw new IllegalArgumentException("a or b // check dimensionality final int am = a.length, an = a[0].length; final int bm = b.length, bn = b[0].length; final int cm = c.length, cn = c[0].length; if (bm != an) throw new IllegalArgumentException("a.n != "(Zweite Matrix muss soviele Zeilen haben, if (cm != am) throw new IllegalArgumentException("c.m != if (cn != bn) throw new IllegalArgumentException("c.n != } cannot be used for output c"); b.m " + wie die erste Spalten hat!)"); a.m"); b.n"); 88 Anhang D (Programme zur Problemstellung „Matrizen-Multiplikation“) /** Prüfen, ob Matrizen leer sind * (LaufzeitFehler im FehlerFall auslösen) * @param array überprüfte Matrix */ private static void check(final int[][] array) { if (array == null || array.length == 0 || array[0] == null) throw new IllegalArgumentException("Array must be non-null and non empty."); } /** Matrix ausgeben * @param a auszugebende Matrix */ private static void printMatrix(final int[][] a) { final int rows = a.length; final int cols = a[0].length; } } System.out.println(rows + "*" + cols + "-Matrix:"); for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { System.out.print(" " + a[i][j]); } System.out.println(); } System.out.println(); 89 Anhang D (Programme zur Problemstellung „Matrizen-Multiplikation“) MatrixMult_jama.java /* Quelle: http://artisans-serverintellect-com.si-eioswww6.com/default.asp?W40 http://forums.sun.com/thread.jspa?threadID=681520 */ /** * Klasse zur Multiplikation zweier Matrizen. * Die Abarbeitung erfolgt sequentiell, jedoch * mit einem speziellen Algorithmus (Jama). */ public class MatrixMult_jama { /* TestMatrizen: static int[][] a = {{5,6,7},{4,8,9},{3,2,1}}; static int[][] b = {{6,4,8},{5,7,8},{4,3,2}}; static static */ static static static int[][] x = {{4,8,9},{3,2,1}}; int[][] y = {{6,4,2,0},{5,7,9,11},{4,3,2,1}}; int[][] a; int[][] b; int[][] c; public static void main(String[] args) { int rowsA = 1000, colsA = 1000; int rowsB = 1000, colsB = 1000; // prüfen, ob Dimensionen der Matrizen über Kommando-Zeilen-Par. angegeben: if (args.length == 4) { rowsA = new Integer(args[0]).intValue(); colsA = new Integer(args[1]).intValue(); rowsB = new Integer(args[2]).intValue(); colsB = new Integer(args[3]).intValue(); } a = initMatrix(rowsA, colsA); b = initMatrix(rowsB, colsB); int rows = a.length; int cols = b[0].length; c = new int[rows][cols]; long startTime = System.currentTimeMillis(); jamaMatrixMultiply(a, b, c); long endTime = System.currentTimeMillis(); /* // Beginn der Laufzeit-Messung // Ende der Laufzeit-Messung printMatrix(a); printMatrix(b); printMatrix(c); */ System.out.println(" } Matrix[" + a.length + "*" + a[0].length + "] * " + "Matrix[" + b.length + "*" + b[0].length + "] = " + "Matrix[" + rows + "*" + cols + "]"); System.out.println(" RechenZeit: " + (endTime - startTime) + " ms"); /** Belegung einer Matrix mit zufälligen ganzzahligen Werten. * @param r Anzahl der Zeilen der Matrix * @param c Anzahl der Spalten der Matrix * @return Matrix mit ganzzahligen (zufälligen) Werten */ private static int[][] initMatrix(int r, int c) { int[][] m = new int[r][c]; for (int i = 0; i < r; i++) { for (int j = 0; j < c; j++) { m[i][j] = (int) (Math.random() * 10); } } return m; } 90 Anhang D (Programme zur Problemstellung „Matrizen-Multiplikation“) /** Multiplikation der Matrizen: * c[m][p] = a[m][n] * b[n][p] * @param a erste Matrize * @param b zweite Matrize * @param c Ergebnis-Matrix */ private static void jamaMatrixMultiply( final int[][] a, final int[][] b, final int[][] c) { check(a,b,c); final int m = a.length; final int n = b.length; final int p = b[0].length; } final int[] Bcolj = new int[n]; for (int j = 0; j < p; j++) { for (int k = 0; k < n; k++) { Bcolj[k] = b[k][j]; } for (int i = 0; i < m; i++) { final int[] Arowi = a[i]; int s = 0; for (int k = 0; k < n; k++) { s += Arowi[k] * Bcolj[k]; } c[i][j] = s; } } /** Kompatibilität der Matrizen überprüfen * (LaufzeitFehler im FehlerFall auslösen) * @param a erste Matrize * @param b zweite Matrize * @param c Ergebnis-Matrix */ private static void check(final int[][] a, final int[][] b, final int[][] c) { check(a); check(b); check(c); if (c == a | c == b) throw new IllegalArgumentException("a or b // check dimensionality final int am = a.length, an = a[0].length; final int bm = b.length, bn = b[0].length; final int cm = c.length, cn = c[0].length; if (bm != an) throw new IllegalArgumentException("a.n != "(Zweite Matrix muss soviele Zeilen haben, if (cm != am) throw new IllegalArgumentException("c.m != if (cn != bn) throw new IllegalArgumentException("c.n != } cannot be used for output c"); b.m " + wie die erste Spalten hat!)"); a.m"); b.n"); /** Prüfen, ob Matrizen leer sind * (LaufzeitFehler im FehlerFall auslösen) * @param array überprüfte Matrix */ private static void check(final int[][] array) { if (array == null || array.length == 0 || array[0] == null) throw new IllegalArgumentException("Array must be non-null and non empty."); } 91 Anhang D (Programme zur Problemstellung „Matrizen-Multiplikation“) /** Matrix ausgeben * @param a auszugebende Matrix */ private static void printMatrix(final int[][] a) { final int rows = a.length; final int cols = a[0].length; } } System.out.println(rows + "*" + cols + "-Matrix:"); for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { System.out.print(" " + a[i][j]); } System.out.println(); } System.out.println(); 92 Anhang D (Programme zur Problemstellung „Matrizen-Multiplikation“) MatrixMultThreads.java /* Quelle: http://artisans-serverintellect-com.si-eioswww6.com/default.asp?W40 http://forums.sun.com/thread.jspa?threadID=681520 Beschreibung: ---------------------------------------------Variante mit klassischen Threads ---------------------------------------------Das Programm erzeugt 2 Matrizen mit zufälligen Werten. Die Dimension der beiden Matrizen kann über die Argumente angegeben werden. Dann werden diese Matrizen miteinander multipliziert. */ import java.util.*; /** * Klasse zur Multiplikation zweier Matrizen. * Die Abarbeitung erfolgt mit Hilfe klassischer Threads. */ public class MatrixMultThreads { /* TestMatrizen: static int[][] a = {{5,6,7},{4,8,9},{3,2,1}}; static int[][] b = {{6,4,8},{5,7,8},{4,3,2}}; static static */ static static static int[][] x = {{4,8,9},{3,2,1}}; int[][] y = {{6,4,2,0},{5,7,9,11},{4,3,2,1}}; int[][] a; int[][] b; int[][] c; public static void main(String[] args) { int nThreads = 1; // Anzahl der Threads (CPUs) if (args.length > 0) { nThreads = new Integer(args[0]).intValue(); } else { System.out.println(" Aufruf: java MatrixMultThreads <nThreads>" + " [ <rowsA> <colsA> <rowsB> <colsB> ]"); System.exit(-1); } int rowsA = 1000, colsA = 1000; int rowsB = 1000, colsB = 1000; // prüfen, ob Dimensionen der Matrizen über Kommando-Zeilen-Par. angegeben: if (args.length > 4) { rowsA = new Integer(args[1]).intValue(); colsA = new Integer(args[2]).intValue(); rowsB = new Integer(args[3]).intValue(); colsB = new Integer(args[4]).intValue(); } a = initMatrix(rowsA, colsA); b = initMatrix(rowsB, colsB); int rows = a.length; int cols = b[0].length; c = new int[rows][cols]; /* */ long startTime = System.currentTimeMillis(); threadedMatrixMultiply(a, b, c, nThreads); long endTime = System.currentTimeMillis(); printMatrix(a); printMatrix(b); printMatrix(c); // Beginn der Laufzeit-Messung // Ende der Laufzeit-Messung 93 Anhang D (Programme zur Problemstellung „Matrizen-Multiplikation“) System.out.println(" } Matrix[" + a.length + "*" + a[0].length + "] * " + "Matrix[" + b.length + "*" + b[0].length + "] = " + "Matrix[" + rows + "*" + cols + "]"); System.out.println(" RechenZeit: " + (endTime - startTime) + " ms"); /** Belegung einer Matrix mit zufälligen ganzzahligen Werten. * @param r Anzahl der Zeilen der Matrix * @param c Anzahl der Spalten der Matrix * @return Matrix mit ganzzahligen (zufälligen) Werten */ private static int[][] initMatrix(int r, int c) { int[][] m = new int[r][c]; for (int i = 0; i < r; i++) { for (int j = 0; j < c; j++) { m[i][j] = (int) (Math.random() * 10); } } return m; } /** Multiplikation der Matrizen: * c[m][p] = a[m][n] * b[n][p] * @param a erste Matrize * @param b zweite Matrize * @param c Ergebnis-Matrix * @param numTasks Anzahl der benutzten Threads */ private static void threadedMatrixMultiply( final int[][] a, final int[][] b, final int[][] c, final int numTasks) { check(a,b,c); final ArrayList<Thread> threads = new ArrayList<Thread>(numTasks); final int m = a.length; final int n = b.length; final int p = b[0].length; for (int interval = numTasks, end = p, size = (int) Math.ceil(p * 1.0 / numTasks); interval > 0; interval--, end -= size) { final int to = end; final int from = Math.max(0, end - size); final Runnable runnable = new Runnable() { @Override public void run() { final int[] Bcolj = new int[n]; for (int j = from; j < to; j++) { for (int k = 0; k < n; k++) { Bcolj[k] = b[k][j]; } for (int i = 0; i < m; i++) { final int[] Arowi = a[i]; int s = 0; for (int k = 0; k < n; k++) { s += Arowi[k] * Bcolj[k]; } c[i][j] = s; } } } }; Thread t = new Thread(runnable); t.start(); threads.add(t); } try { for (Iterator<Thread> it = threads.iterator(); it.hasNext(); ) { it.next().join(); } } catch (InterruptedException e) {} } 94 Anhang D (Programme zur Problemstellung „Matrizen-Multiplikation“) /** Kompatibilität der Matrizen überprüfen * (LaufzeitFehler im FehlerFall auslösen) * @param a erste Matrize * @param b zweite Matrize * @param c Ergebnis-Matrix */ private static void check(final int[][] a, final int[][] b, final int[][] c) { check(a); check(b); check(c); if (c == a | c == b) throw new IllegalArgumentException("a or b // check dimensionality final int am = a.length, an = a[0].length; final int bm = b.length, bn = b[0].length; final int cm = c.length, cn = c[0].length; if (bm != an) throw new IllegalArgumentException("a.n != "(Zweite Matrix muss soviele Zeilen haben, if (cm != am) throw new IllegalArgumentException("c.m != if (cn != bn) throw new IllegalArgumentException("c.n != } cannot be used for output c"); b.m " + wie die erste Spalten hat!)"); a.m"); b.n"); /** Prüfen, ob Matrizen leer sind * (LaufzeitFehler im FehlerFall auslösen) * @param array überprüfte Matrix */ private static void check(final int[][] array) { if (array == null || array.length == 0 || array[0] == null) throw new IllegalArgumentException("Array must be non-null and non empty."); } /** Matrix ausgeben * @param a auszugebende Matrix */ private static void printMatrix(final int[][] a) { final int rows = a.length; final int cols = a[0].length; } } System.out.println(rows + "*" + cols + "-Matrix:"); for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { System.out.print(" " + a[i][j]); } System.out.println(); } System.out.println(); 95 Anhang D (Programme zur Problemstellung „Matrizen-Multiplikation“) MatrixMultThreadPool.java /* Quelle: http://artisans-serverintellect-com.si-eioswww6.com/default.asp?W40 http://forums.sun.com/thread.jspa?threadID=681520 Beschreibung: ---------------------------------------------Variante mit ExecutorService & FixedThreadPool ---------------------------------------------Das Programm erzeugt 2 Matrizen mit zufälligen Werten. Die Dimension der beiden Matrizen kann über die Argumente angegeben werden. Dann werden diese Matrizen miteinander multipliziert. */ import java.util.*; import java.util.concurrent.*; /** * Klasse zur Multiplikation zweier Matrizen. * Die Abarbeitung erfolgt mit Hilfe der Concurrency-Utils (lt. Java 1.5). */ public class MatrixMultThreadPool { /* TestMatrizen: static int[][] a = {{5,6,7},{4,8,9},{3,2,1}}; static int[][] b = {{6,4,8},{5,7,8},{4,3,2}}; static static */ static static static int[][] x = {{4,8,9},{3,2,1}}; int[][] y = {{6,4,2,0},{5,7,9,11},{4,3,2,1}}; int[][] a; int[][] b; int[][] c; public static void main(String[] args) { int nThreads = 1; // Anzahl der Threads (CPUs) if (args.length > 0) { nThreads = new Integer(args[0]).intValue(); } else { System.out.println(" Aufruf: java MatrixMultThreadPool <nThreads>" + " [ <rowsA> <colsA> <rowsB> <colsB> ]"); System.exit(-1); } int rowsA = 1000, colsA = 1000; int rowsB = 1000, colsB = 1000; // prüfen, ob Dimensionen der Matrizen über Kommando-Zeilen-Par. angegeben: if (args.length > 4) { rowsA = new Integer(args[1]).intValue(); colsA = new Integer(args[2]).intValue(); rowsB = new Integer(args[3]).intValue(); colsB = new Integer(args[4]).intValue(); } a = initMatrix(rowsA, colsA); b = initMatrix(rowsB, colsB); int rows = a.length; int cols = b[0].length; c = new int[rows][cols]; /* */ long startTime = System.currentTimeMillis(); multiplyMatrix(a, b, c, nThreads); long endTime = System.currentTimeMillis(); printMatrix(a); printMatrix(b); printMatrix(c); // Beginn der Laufzeit-Messung // Ende der Laufzeit-Messung 96 Anhang D (Programme zur Problemstellung „Matrizen-Multiplikation“) System.out.println(" } Matrix[" + a.length + "*" + a[0].length + "] * " + "Matrix[" + b.length + "*" + b[0].length + "] = " + "Matrix[" + rows + "*" + cols + "]"); System.out.println(" RechenZeit: " + (endTime - startTime) + " ms"); /** Belegung einer Matrix mit zufälligen ganzzahligen Werten. * @param r Anzahl der Zeilen der Matrix * @param c Anzahl der Spalten der Matrix * @return Matrix mit ganzzahligen (zufälligen) Werten */ private static int[][] initMatrix(int r, int c) { int[][] m = new int[r][c]; for (int i = 0; i < r; i++) { for (int j = 0; j < c; j++) { m[i][j] = (int) (Math.random() * 10); } } return m; } /** Multiplikation der Matrizen: * c[m][p] = a[m][n] * b[n][p] * @param a erste Matrize * @param b zweite Matrize * @param c Ergebnis-Matrix * @param numTasks Anzahl der benutzten Threads */ private static void multiplyMatrix( final int[][] a, final int[][] b, final int[][] c, final int numTasks) { check(a,b,c); final int m = a.length; final int n = b.length; final int p = b[0].length; final ExecutorService executor = Executors.newFixedThreadPool(numTasks); List<Future> futures = new ArrayList<Future>(numTasks); for (int interval = numTasks, end = p, size = (int) Math.ceil(p * 1.0 / numTasks); interval > 0; interval--, end -= size) { final int to = end; final int from = Math.max(0, end - size); final Runnable runnable = new Runnable() { public void run() { final int[] Bcolj = new int[n]; for (int j = from; j < to; j++) { for (int k = 0; k < n; k++) { Bcolj[k] = b[k][j]; } for (int i = 0; i < m; i++) { final int[] Arowi = a[i]; int s = 0; for (int k = 0; k < n; k++) { s += Arowi[k] * Bcolj[k]; } c[i][j] = s; } } } }; futures.add(executor.submit(new Thread(runnable))); } try { for (Iterator<Future> it = futures.iterator(); it.hasNext(); ) { it.next().get(); } executor.shutdown(); executor.awaitTermination(2, TimeUnit.DAYS); // O(n^3) can take a while! } catch (Exception e) {} } 97 Anhang D (Programme zur Problemstellung „Matrizen-Multiplikation“) /** Kompatibilität der Matrizen überprüfen * (LaufzeitFehler im FehlerFall auslösen) * @param a erste Matrize * @param b zweite Matrize * @param c Ergebnis-Matrix */ private static void check(final int[][] a, final int[][] b, final int[][] c) { check(a); check(b); check(c); if (c == a | c == b) throw new IllegalArgumentException("a or b // check dimensionality final int am = a.length, an = a[0].length; final int bm = b.length, bn = b[0].length; final int cm = c.length, cn = c[0].length; if (bm != an) throw new IllegalArgumentException("a.n != "(Zweite Matrix muss soviele Zeilen haben, if (cm != am) throw new IllegalArgumentException("c.m != if (cn != bn) throw new IllegalArgumentException("c.n != } cannot be used for output c"); b.m " + wie die erste Spalten hat!)"); a.m"); b.n"); /** Prüfen, ob Matrizen leer sind * (LaufzeitFehler im FehlerFall auslösen) * @param array überprüfte Matrix */ private static void check(final int[][] array) { if (array == null || array.length == 0 || array[0] == null) throw new IllegalArgumentException("Array must be non-null and non empty."); } /** Matrix ausgeben * @param a auszugebende Matrix */ private static void printMatrix(final int[][] a) { final int rows = a.length; final int cols = a[0].length; } } System.out.println(rows + "*" + cols + "-Matrix:"); for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { System.out.print(" " + a[i][j]); } System.out.println(); } System.out.println(); 98 Anhang D (Programme zur Problemstellung „Matrizen-Multiplikation“) MatrixMultFJ1.java import import import import java.util.ArrayList; java.util.List; java.util.Iterator; EDU.oswego.cs.dl.util.concurrent.*; /** * Klasse zur Multiplikation zweier Matrizen. * Die Abarbeitung erfolgt mit Hilfe des Fork/Join-Frameworks. * Die Klasse ist daher von FJTask abgeleitet. */ public class MatrixMultFJ1 extends FJTask { /* TestMatrizen: static int[][] a = {{5,6,7},{4,8,9},{3,2,1}}; static int[][] b = {{6,4,8},{5,7,8},{4,3,2}}; */ static int[][] a = {{4,8,9},{3,2,1}}; static int[][] b = {{6,4,2,0},{5,7,9,11},{4,3,2,1}}; static int[][] a; static int[][] b; static int[][] c; private private private private private int[][] a_run; int[][] b_run; int[][] c_run; int m, n; int from, to; static private int nThreads = 1; private boolean isMaster = false; // Anzahl der Threads (CPUs) // Master-Thread? /** Konstruktor */ public MatrixMultFJ1(boolean isMaster) { this.isMaster = isMaster; } public MatrixMultFJ1(int[][] a_run, int[][] b_run, int[][] c_run, int m, int n, int from, int to) { this.a_run = a_run; this.b_run = b_run; this.c_run = c_run; this.m = m; this.n = n; this.from = from; this.to = to; } @Override public void run() { if (this.isMaster) { check(a,b,c); final int m = a.length; final int n = b.length; final int p = b[0].length; MatrixMultFJ1[] tasks = new MatrixMultFJ1[nThreads]; for (int interval = nThreads, end = p, size = (int) Math.ceil(p * 1.0 / nThreads); interval > 0; interval--, end -= size) { final int to = end; final int from = Math.max(0, end - size); MatrixMultFJ1 t = new MatrixMultFJ1(a, b, c, m, n, from, to); tasks[interval-1] = t; } coInvoke(tasks); 99 Anhang D (Programme zur Problemstellung „Matrizen-Multiplikation“) } for (int i = nThreads; i > 0; i--) { tasks[i-1].getAnswer(); } } else { final int[] Bcolj = new int[n]; for (int j = from; j < to; j++) { for (int k = 0; k < n; k++) { Bcolj[k] = b[k][j]; } for (int i = 0; i < m; i++) { final int[] Arowi = a[i]; int s = 0; for (int k = 0; k < n; k++) { s += Arowi[k] * Bcolj[k]; } c[i][j] = s; } } } boolean getAnswer() { if (!isDone()) throw new IllegalStateException("Not yet computed"); return true; } public static void main(String[] args) { try { if (args.length > 0) { nThreads = new Integer(args[0]).intValue(); } else { System.out.println(" Aufruf: java MatrixMultFJ1 <nThreads>" + " [ <rowsA> <colsA> <rowsB> <colsB> ]"); System.exit(-1); } int rowsA = 1000, colsA = 1000; int rowsB = 1000, colsB = 1000; // prüfen, ob Dimensionen der Matrizen über Kommando-Zeilen-Par. angegeben: if (args.length > 4) { rowsA = new Integer(args[1]).intValue(); colsA = new Integer(args[2]).intValue(); rowsB = new Integer(args[3]).intValue(); colsB = new Integer(args[4]).intValue(); } a = initMatrix(rowsA, colsA); b = initMatrix(rowsB, colsB); int rows = a.length; int cols = b[0].length; c = new int[rows][cols]; /* */ } long startTime = System.currentTimeMillis(); // Beginn der Laufzeit-Messung final FJTaskRunnerGroup group = new FJTaskRunnerGroup(nThreads); MatrixMultFJ1 master = new MatrixMultFJ1(true); group.invoke(master); master.getAnswer(); long endTime = System.currentTimeMillis(); // Ende der Laufzeit-Messung printMatrix(a); printMatrix(b); printMatrix(c); System.out.println(" Matrix[" + a.length + "*" + a[0].length + "] * " + "Matrix[" + b.length + "*" + b[0].length + "] = " + "Matrix[" + rows + "*" + cols + "]"); System.out.println(" RechenZeit: " + (endTime - startTime) + " ms"); } catch (InterruptedException ex) {} 100 Anhang D (Programme zur Problemstellung „Matrizen-Multiplikation“) /** Belegung einer Matrix mit zufälligen ganzzahligen Werten. * @param r Anzahl der Zeilen der Matrix * @param c Anzahl der Spalten der Matrix * @return Matrix mit ganzzahligen (zufälligen) Werten */ private static int[][] initMatrix(int r, int c) { int[][] m = new int[r][c]; for (int i = 0; i < r; i++) { for (int j = 0; j < c; j++) { m[i][j] = (int) (Math.random() * 10); } } return m; } /** Kompatibilität der Matrizen überprüfen * (LaufzeitFehler im FehlerFall auslösen) * @param a erste Matrize * @param b zweite Matrize * @param c Ergebnis-Matrix */ private static void check(final int[][] a, final int[][] b, final int[][] c) { check(a); check(b); check(c); if (c == a | c == b) throw new IllegalArgumentException("a or b // check dimensionality final int am = a.length, an = a[0].length; final int bm = b.length, bn = b[0].length; final int cm = c.length, cn = c[0].length; if (bm != an) throw new IllegalArgumentException("a.n != "(Zweite Matrix muss soviele Zeilen haben, if (cm != am) throw new IllegalArgumentException("c.m != if (cn != bn) throw new IllegalArgumentException("c.n != } cannot be used for output c"); b.m " + wie die erste Spalten hat!)"); a.m"); b.n"); /** Prüfen, ob Matrizen leer sind * (LaufzeitFehler im FehlerFall auslösen) * @param array überprüfte Matrix */ private static void check(final int[][] array) { if (array == null || array.length == 0 || array[0] == null) throw new IllegalArgumentException("Array must be non-null and non empty."); } /** Matrix ausgeben * @param a auszugebende Matrix */ private static void printMatrix(final int[][] a) { final int rows = a.length; final int cols = a[0].length; } } System.out.println(rows + "*" + cols + "-Matrix:"); for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { System.out.print(" " + a[i][j]); } System.out.println(); } System.out.println(); 101 Anhang D (Programme zur Problemstellung „Matrizen-Multiplikation“) MatrixMultFJ2.java import import import import import java.util.ArrayList; java.util.List; java.util.Iterator; EDU.oswego.cs.dl.util.concurrent.*; jsr166y.*; /** * Klasse zur Multiplikation zweier Matrizen. * Die Abarbeitung erfolgt mit Hilfe des Fork/Join-Frameworks. * Die Klasse ist von ForkJoinTask abgeleitet. */ public class MatrixMultFJ2 extends RecursiveAction { /* TestMatrizen: static int[][] a = {{5,6,7},{4,8,9},{3,2,1}}; static int[][] b = {{6,4,8},{5,7,8},{4,3,2}}; */ static int[][] a = {{4,8,9},{3,2,1}}; static int[][] b = {{6,4,2,0},{5,7,9,11},{4,3,2,1}}; static int[][] a; static int[][] b; static int[][] c; private private private private private int[][] a_run; int[][] b_run; int[][] c_run; int m, n; int from, to; static private int nThreads = 1; private boolean isMaster = false; // Anzahl der Threads (CPUs) // Master-Thread? /** Konstruktor */ public MatrixMultFJ2(boolean isMaster) { this.isMaster = isMaster; } public MatrixMultFJ2(int[][] a_run, int[][] b_run, int[][] c_run, int m, int n, int from, int to) { this.a_run = a_run; this.b_run = b_run; this.c_run = c_run; this.m = m; this.n = n; this.from = from; this.to = to; } @Override public void compute() { if (this.isMaster) { check(a,b,c); final int m = a.length; final int n = b.length; final int p = b[0].length; MatrixMultFJ2[] tasks = new MatrixMultFJ2[nThreads]; for (int interval = nThreads, end = p, size = (int) Math.ceil(p * 1.0 / nThreads); interval > 0; interval--, end -= size) { final int to = end; final int from = Math.max(0, end - size); MatrixMultFJ2 t = new MatrixMultFJ2(a, b, c, m, n, from, to); tasks[interval-1] = t; } 102 Anhang D (Programme zur Problemstellung „Matrizen-Multiplikation“) } invokeAll(tasks); for (int i = nThreads; i > 0; i--) { tasks[i-1].getAnswer(); } } else { final int[] Bcolj = new int[n]; for (int j = from; j < to; j++) { for (int k = 0; k < n; k++) { Bcolj[k] = b[k][j]; } for (int i = 0; i < m; i++) { final int[] Arowi = a[i]; int s = 0; for (int k = 0; k < n; k++) { s += Arowi[k] * Bcolj[k]; } c[i][j] = s; } } } boolean getAnswer() { if (!isDone()) throw new IllegalStateException("Not yet computed"); return true; } public static void main(String[] args) { if (args.length > 0) { nThreads = new Integer(args[0]).intValue(); } else { System.out.println(" Aufruf: java MatrixMultFJ2 <nThreads>" + " [ <rowsA> <colsA> <rowsB> <colsB> ]"); System.exit(-1); } int rowsA = 1000, colsA = 1000; int rowsB = 1000, colsB = 1000; // prüfen, ob Dimensionen der Matrizen über Kommando-Zeilen-Par. angegeben: if (args.length > 4) { rowsA = new Integer(args[1]).intValue(); colsA = new Integer(args[2]).intValue(); rowsB = new Integer(args[3]).intValue(); colsB = new Integer(args[4]).intValue(); } a = initMatrix(rowsA, colsA); b = initMatrix(rowsB, colsB); int rows = a.length; int cols = b[0].length; c = new int[rows][cols]; long startTime = System.currentTimeMillis(); // Beginn der Laufzeit-Messung final ForkJoinPool pool = new ForkJoinPool(nThreads); MatrixMultFJ2 master = new MatrixMultFJ2(true); pool.invoke(master); master.getAnswer(); long endTime = System.currentTimeMillis(); // Ende der Laufzeit-Messung /* printMatrix(a); printMatrix(b); printMatrix(c); */ System.out.println(" } Matrix[" + a.length + "*" + a[0].length + "] * " + "Matrix[" + b.length + "*" + b[0].length + "] = " + "Matrix[" + rows + "*" + cols + "]"); System.out.println(" RechenZeit: " + (endTime - startTime) + " ms"); 103 Anhang D (Programme zur Problemstellung „Matrizen-Multiplikation“) /** Belegung einer Matrix mit zufälligen ganzzahligen Werten. * @param r Anzahl der Zeilen der Matrix * @param c Anzahl der Spalten der Matrix * @return Matrix mit ganzzahligen (zufälligen) Werten */ private static int[][] initMatrix(int r, int c) { int[][] m = new int[r][c]; for (int i = 0; i < r; i++) { for (int j = 0; j < c; j++) { m[i][j] = (int) (Math.random() * 10); } } return m; } /** Kompatibilität der Matrizen überprüfen * (LaufzeitFehler im FehlerFall auslösen) * @param a erste Matrize * @param b zweite Matrize * @param c Ergebnis-Matrix */ private static void check(final int[][] a, final int[][] b, final int[][] c) { check(a); check(b); check(c); if (c == a | c == b) throw new IllegalArgumentException("a or b // check dimensionality final int am = a.length, an = a[0].length; final int bm = b.length, bn = b[0].length; final int cm = c.length, cn = c[0].length; if (bm != an) throw new IllegalArgumentException("a.n != "(Zweite Matrix muss soviele Zeilen haben, if (cm != am) throw new IllegalArgumentException("c.m != if (cn != bn) throw new IllegalArgumentException("c.n != } cannot be used for output c"); b.m " + wie die erste Spalten hat!)"); a.m"); b.n"); /** Prüfen, ob Matrizen leer sind * (LaufzeitFehler im FehlerFall auslösen) * @param array überprüfte Matrix */ private static void check(final int[][] array) { if (array == null || array.length == 0 || array[0] == null) throw new IllegalArgumentException("Array must be non-null and non empty."); } /** Matrix ausgeben * @param a auszugebende Matrix */ private static void printMatrix(final int[][] a) { final int rows = a.length; final int cols = a[0].length; } } System.out.println(rows + "*" + cols + "-Matrix:"); for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { System.out.print(" " + a[i][j]); } System.out.println(); } System.out.println(); 104 Anhang E (Programme zur Problemstellung „Jacobi-Relaxation“) Anhang E (Programme zur Problemstellung „JacobiRelaxation“) JacobiSeq.java public class JacobiSeq { static static static static static final int N = 1000; final double OMEGA = 0.6; double[][] f = new double[N][N]; double[][] u = new double[N][N]; double[][] uhelp = new double[N][N]; public static void main(String[] args) { final int iterations; if (args.length < 1) { System.out.println(" Aufruf: java JacobiSeq <iterations>"); System.exit(-1); } iterations = new Integer(args[0]).intValue(); // } init(); long startTime = System.currentTimeMillis(); // Beginn der Laufzeit-Messung for (int i=0; i < iterations; i++) { jacobi(); System.out.println((i+1) + ". Iteration"); } long endTime = System.currentTimeMillis(); // Ende der Laufzeit-Messung System.out.println(" RechenZeit: " + (endTime - startTime) + " ms"); private static void jacobi() { for (int j=1; j < N-1; j++) { for (int i=1; i < N-1; i++) { uhelp[i][j] = (1-OMEGA) * u[i][j] + OMEGA * 0.25 * ( f[i][j] + u[i-1][j] + u[i+1][j] + u[i][j+1] + u[i][j-1]); } } } for (int j=1; j < N-1; j++) { for (int i=1; i < N-1; i++) { u[i][j] = uhelp[i][j]; } } /** Initialisierung der rechten Seite der Rand- und Anfangs-Werte */ private static void init() { for (int j=0; j < N; j++) { for (int i=0; i < N; i++ ) { f[i][j] = i * (i-1) + j * (j-1); if (i==0 || i==N-1 || j==0 || j==N-1) { // erste und letzte Elemente u[i][j] = f[i][j]; } else { u[i][j] = 1.0; } } } } } 105 Anhang E (Programme zur Problemstellung „Jacobi-Relaxation“) JacobiThreadPool.java /* Variante mit Cyclic-Barrier und Thread-Pool auf Java 1.5 * jacobi-Methode wird in run-Methode implementiert * Die meiste Zeit verbraucht das Schließen des Thread-Pools! */ import java.util.concurrent.*; import java.util.*; public class JacobiThreadPool implements Runnable { final static int N = 1000; final static double OMEGA = 0.6; static double[][] f = new double[N][N]; static double[][] u = new double[N][N]; volatile static double[][] uhelp = new double[N][N]; static CyclicBarrier barrier; public static void main(String[] args) { try { final int iterations; if (args.length < 1) { System.out.println(" Aufruf: java JacobiThreadPool <iterations>"); System.exit(-1); } iterations = new Integer(args[0]).intValue(); init(f, u, N); long startTime = System.currentTimeMillis(); // Beginn der Laufzeit-Messung final ExecutorService executor = Executors.newFixedThreadPool(iterations); List<Future> futures = new ArrayList<Future>(iterations); // barrier = new CyclicBarrier(iterations); for (int i=0; i < iterations; i++) { futures.add(executor.submit(new JacobiThreadPool())); System.out.println((i+1) + ". Iteration"); } for (Iterator<Future> it = futures.iterator(); it.hasNext(); ) { it.next().get(); } executor.shutdown(); executor.awaitTermination(2, TimeUnit.DAYS); // O(n^3) can take a while! long endTime = System.currentTimeMillis(); } // Ende der Laufzeit-Messung System.out.println(" RechenZeit: " + (endTime - startTime) + " ms"); } catch (Exception e) { System.err.println(" Fehler in der Berechnung!"); } @Override public void run() { for (int j=1; j < N-1; j++) { for (int i=1; i < N-1; i++) { uhelp[i][j] = (1-OMEGA) * u[i][j] + OMEGA * 0.25 * ( f[i][j] + u[i-1][j] + u[i+1][j] + u[i][j+1] + u[i][j-1]); } } for (int j=1; j < N-1; j++) { for (int i=1; i < N-1; i++) { u[i][j] = uhelp[i][j]; } } 106 Anhang E (Programme zur Problemstellung „Jacobi-Relaxation“) } try { barrier.await(); } catch (InterruptedException ex) { return; } catch (BrokenBarrierException ex) { return; } /** Initialisierung der rechten Seite der Rand- und Anfangs-Werte */ private static void init(double[][] f, double[][] u, final int N) { for (int j=0; j < N; j++) { for (int i=0; i < N; i++ ) { f[i][j] = i * (i-1) + j * (j-1); if (i==0 || i==N-1 || j==0 || j==N-1) { // erste und letzte Elemente u[i][j] = f[i][j]; } else { u[i][j] = 1.0; } } } } } 107 Literaturverzeichnis 108 6 Literaturverzeichnis [1] Akhter, Shameem: Multicore-Programmierung, Verlag: entwickler.press, 2008 [2] Andrews, Gregory R.: Foundations of multithreaded, parallel, and distributed programming, Addison-Wesley, 2000 [3] Bell, Doug: Parallel programming, Wiley Heyden, 1983 [4] Bräunl, Thomas: Parallele Programmierung, Vieweg, 1993 [5] Brawer, Steven: Introduction to parallel programming, Academic Press, 1989 [6] Culler, David E.: Parallel computer architecture, Verlag: Kaufmann, 1999 [7] Feilmeier, M.: Parallele Datenverarbeitung und parallele Algorithmen, Kursmaterialien, 1979 [8] Goetz, Brian: Java concurrency in practice, Addison-Wesley, 2006 [9] Grama, Ananth: Introduction to parallel computing, Pearson Addison Wesley, 2007 [10] Kredel, Heinz: Thread- und Netzwerk-Programmierung mit Java, dpunkt-Verlag, 2002 [11] Lea, Douglas: Concurrent programming in Java, Addison-Wesley, 2005 [12] Lin, Calvin: Principles of parallel programming, Pearson, 2009 [13] Magee, Jeff: Concurrency, Wiley, 2006 [14] Malyshkin, Victor [Hrsg.]: Parallel computing technologies, Springer, 2003 [15] Naik, Vijay K.: Multiprocessing, Kluwer-Verlag, 1993 [16] Oechsle, Rainer: Parallele Programmierung mit Java Threads, Fachbuchverl. Leipzig im Carl-HanserVerl. 2001 [17] Petersen, Wesley P.: Introduction to parallel computing, Oxford Univ. Press, 2004 [18] Ragsdale, Susan [Hrsg.]: Parallele Programmierung, McGraw-Hill, 1992 [19] Rajasekaran, Sanguthevar [Hrsg.]: Handbook of parallel computing, Chapman & Hall/CRC, 2008 [20] Ullenboom, Christian: Java ist auch eine Insel, Galileo Press, 2005 [21] Ungerer, Theo: Parallelrechner und parallele Programmierung, Spektrum, Akad. Verl. 1997 [22] Wilkinson, Barry: Parallel programming, Pearson/Prentice Hall, 2005 [23] Zhou, Xingming [Hrsg.]: Advanced parallel processing technologies, Springer, 2003 [24] Zöbel, Dieter: Konzepte der parallelen Programmierung, Teubner-Verlag, 1988 Literaturverzeichnis 109 [25] Angelika Langer: Multithread Grundlagen (http://www.angelikalanger.com/Articles/EffectiveJava/12.MT-Basics/12.MT-Basics.html), 31.10.2010 [26] Angelika Langer: ThreadPools (http://www.angelikalanger.com/Articles/EffectiveJava/20.ThreadPools/20.ThreadPools.html), 31.10.2010 [27] Forschungszentrum Karlsruhe: Leistungskriterien für parallele Programme (http://hikwww2.fzk.de/hik/orga/hlr/AIX/testen/), 31.10.2010 [28] Max-Planck-Institut für Metallforschung: Physik auf Parallelrechnern (http://www.mf.mpg.de/mpg/websiteMetallforschung/pdf/05_Serviceeinrichtungen/ Datenverarbeitung/vorlesungen/PaPR1.pdf), 31.10.2010 [29] Stephan Schmidt: Skalierbarkeit (http://www.deutsche-startups.de/?p=14278), 31.10.2010 [30] TU Wien: Mehrprozessorsysteme (http://gd.tuwien.ac.at/study/hrh-glossar/12-1_1.htm), 31.10.2010 [31] TU Dresden: Parallelisierung (http://tudresden.de/die_tu_dresden/zentrale_einrichtungen/zih/dienste/rechner_und_ arbeitsplatzsysteme/hochleistungsrechner/parallel#newNavigationList), 31.10.2010 [32] Uni Karlsruhe: Parallelrechner (http://www.rz.uni-karlsruhe.de/rz/hw/sp/onlinekurs/PARALLELRECHNER/), 31.10.2010 [33] TU Berlin: Parallele Programmiermodelle (http://kbs.cs.tu-berlin.de/ivs/Lehre/SS04/VS/), 31.10.2010 [34] Christian Ullenboom: Java (http://openbook.galileodesign.de/javainsel5/), 31.10.2010 [35] Doug Lea: Doug Lea's Home Page (http://gee.cs.oswego.edu/dl/), 31.10.2010 [36] Doug Lea: Fork/Join-Framework (http://gee.cs.oswego.edu/dl/papers/fj.pdf), 31.10.2010 [37] Doug Lea: Concurrency (http://gee.cs.oswego.edu/dl/classes/EDU/oswego/cs/dl/util/concurrent), 31.10.2010 [38] Brian Goetz: Learn how to exploit fine-grained parallelism using the fork-join framework (http://www.ibm.com/developerworks/java/library/j-jtp11137.html#listing3), 31.10.2010 [39] Stephan Schmidt: Concurrency (http://codemonkeyism.com/concurrency-rant-different-types-ofconcurrency-and-why-lots-of-people-already-use-erlang-concurrency/), 31.10.2010 [40] Doug Lea: Parallel Decomposition (http://zone.ni.com/devzone/cda/tut/p/id/6616) , 31.10.2010