ausarbeitung gc-analyse, fazit
This commit is contained in:
parent
0b97576231
commit
4ffaa00148
Binary file not shown.
@ -177,9 +177,11 @@ Die Wahl der Programmiersprache zur Verwirklichung des Projekts beeinflusst star
|
||||
Unser Projekt geht in eine etwas andere Richtung. Bei imperativer Programmierung muss ein großes Augenmerk auf die Vermeidung unerwünschter wechselseitiger Beeinflussungen verschiedener Threads und Prozesse gelegt werden, die fehlerhafte Rechenergebnisse zur Folge haben. Außerdem muss bei der Thread-/Prozesskommunikation immer die Gefahr von Verklemmungen beachtet werden, die schlimmstenfalls zu einem kompletten Stillstand der Programmausführung führen. Beide Probleme sind schwierig zu detektieren und zu lokalisieren.\par
|
||||
Die genannten klassischen Probleme des Parallelrechnens können mit pur funktionaler Programmierung gut vermieden werden. Nebenbedingungen treten in pur funktionalem Programmcode (einen korrekten Compiler/Interpreter vorausgesetzt) garantiert nicht auf. Da das DCB-Problem bis auf das Einlesen der Eingabedaten und die Ausgabe pur funktional realisierbar ist, ist es optimal für eine derartige Implementierung geeignet Die konkrete Wahl der funktionalen Programmiersprache fiel auf \emph{Haskell}. \par
|
||||
\medskip
|
||||
Für Haskell wurden Bibliotheken entwickelt, die eine einfache und effiziente Programmierung paralleler Programme erlauben. Wir verwenden das Paket \emph{parallel} in Verbindung mit \emph{repa}-Arrays. Durch \emph{parallel} können geeignete Algorithmen mit wenig Aufwand, aufgeteilt werden. Dabei werden Funktionsaufrufe unevaluiert in einem Array gespeichert und dort von freien Threads abgearbeitet. Diese Technik nennt man Work-Stealing und die noch nicht ausgewerteten Funktionen werden in Haskell \emph{Sparks} genannt. Man kann sich dies als einen auf den Funktionsaufruf beschränkten light-weight-Thread vorstellen -- mit weniger Overhead. Die \emph{repa}-Arrays bieten Funktionen, um die einzelnen Elemente eines Arrays parallel zu berechnen. Mit diesen Techniken lässt sich sequentieller Programmcode einfach parallelisieren, da hierfür nur wenige Änderungen im Programmcode erforderlich sind. Es müssen lediglich die Berechnungsfunktionen an die parallelisierende Funktion übergeben und die Funktion zur Auswertung der Arrayelemente ausgetauscht werden. \par
|
||||
Für Haskell wurden Bibliotheken entwickelt, die eine einfache und effiziente Programmierung paralleler Programme erlauben. Wir verwenden das Paket \emph{parallel} in Verbindung mit \emph{repa}-Arrays. Durch \emph{parallel} können geeignete Algorithmen mit wenig Aufwand, aufgeteilt werden. Dabei werden Funktionsaufrufe unevaluiert in einem Array gespeichert und dort von freien Threads abgearbeitet. Diese Technik nennt man Work-Stealing und die noch nicht ausgewerteten Funktionen werden in Haskell \emph{Sparks} genannt.
|
||||
Man kann sich dies als einen auf den Funktionsaufruf beschränkten light-weight-Thread vorstellen -- mit weniger Overhead. Die \emph{repa}-Arrays bieten Funktionen, um die einzelnen Elemente eines Arrays parallel zu berechnen. Mit diesen Techniken lässt sich sequentieller Programmcode einfach parallelisieren, da hierfür nur wenige Änderungen im Programmcode erforderlich sind. Es müssen lediglich die Berechnungsfunktionen an die parallelisierende Funktion übergeben und die Funktion zur Auswertung der Arrayelemente ausgetauscht werden. \par
|
||||
\medskip
|
||||
Zwei wichtige Punkte müssen dennoch beachtet werden. Zum einen verwendet Haskell das Konzept \emph{\en{Lazy Evaluation}}. Befehle werden immer nur soweit berechnet, wie sie an anderer Stelle benötigt werden. Dadurch entstehen manchmal zur Laufzeit große Bäume nur teilweise ausgewerteter Befehle, welche die Ausführungszeit durch eine hohe Garbage-Collector-Auslastung stark negativ beeinflussen. Es muss demnach darauf geachtet werden, die Berechnung später ohnehin erforderlicher Werte frühzeitig zu erzwingen. Zum anderen ist die Anzahl der Sparks standardmäßig nicht begrenzt, sodass auch hier zu große Arrays entstehen können, deren Abarbeitung allerdings im Verlaufe des Programms durch o.\,g. Lazy Evaluation evtl.\ gar nicht erforderlich ist. Daher beschränken wir die Anzahl der möglichen Sparks, und somit der maximal möglichen Worker-Threads, auf 1000. Erwähnenswert ist noch, dass diese Technik \emph{nicht} von Hyper-Threading profitiert,da kein Kontextwechsel der Threads nötig ist, und wir somit 1000 \glqq echte\grqq \ Kerne für eine maximale Auslastung benötigen. Die obere Grenze wird eher durch Amdahls Gesetz, denn durch die verfügbaren Kerne beschränkt.\par
|
||||
Zwei wichtige Punkte müssen dennoch beachtet werden. Zum einen verwendet Haskell das Konzept \emph{\en{Lazy Evaluation}}. Befehle werden immer nur soweit berechnet, wie sie an anderer Stelle benötigt werden. Dadurch entstehen manchmal zur Laufzeit große Bäume nur teilweise ausgewerteter Befehle, welche die Ausführungszeit durch eine hohe Garbage-Collector-Auslastung stark negativ beeinflussen. Es muss demnach darauf geachtet werden, die Berechnung später ohnehin erforderlicher Werte frühzeitig zu erzwingen. Zum anderen ist die Anzahl der Sparks standardmäßig nicht begrenzt, sodass auch hier zu große Arrays entstehen können, deren Abarbeitung allerdings im Verlaufe des Programms durch o.\,g. Lazy Evaluation evtl.\ gar nicht erforderlich ist.
|
||||
Daher beschränken wir die Anzahl der möglichen Sparks, und somit der maximal möglichen Worker-Threads, auf 1000. Erwähnenswert ist noch, dass diese Technik \emph{nicht} von Hyper-Threading profitiert,da kein Kontextwechsel der Threads nötig ist, und wir somit 1000 \glqq echte\grqq \ Kerne für eine maximale Auslastung benötigen. Die obere Grenze wird eher durch Amdahls Gesetz, denn durch die verfügbaren Kerne beschränkt.\par
|
||||
|
||||
|
||||
\section{Der Algorithmus}
|
||||
@ -254,7 +256,21 @@ Hierbei stehen die einzelnen Flags für:
|
||||
Insbesondere das Unfolding der Funktionen und das Weiterreichen des Codes an LLVM bringt einen starken Performance-Zugewinn. LLVM kann hier auf die jeweils benutzte Architektur weiter optimieren.
|
||||
|
||||
\subsection{Garbage-Collector-Optimierung}
|
||||
TODO\todo{machen!}
|
||||
\begin{figure}[h!]
|
||||
\centering
|
||||
\includegraphics[width=15cm]{./img/gc_total.png}
|
||||
% gc_total.png: 1280x960 pixel, 96dpi, 33.86x25.40 cm, bb=0 0 960 720
|
||||
\caption{ghc-gc-tune Ausgabe}
|
||||
\label{fig:ghctune}
|
||||
\end{figure}
|
||||
Da Programmiersprachen mit Garbage-Collector immer den Ruf haben langsam zu sein, haben wir auch versucht den Garbage-Collector zu analysieren und zu optimieren. Der GHC benutzt einen Mehrstufigen GC. Dieser setzt sich zusammen ausr einem kleinen Speicher, der häufig collected wird (für z.b. Zwischenergebnisse, Funktionsvariablen und andere kurzlebige Daten) und einem selten collectedent Speicher, in dem langlebige Objekte liegen (z.b. Konfigurationseinstellungen, etc.). Noch mehr Level sind theoretisch möglich, liefern aber meist nicht mehr Performance. Auch versucht der GC diese Bereiche so klein wie möglich zu halten - wobei er zwischen neuallokation (=mögliche "Speicherverschwendung") und aufräumen (=Speicher freigeben; Zeitaufwändig) abwägen muss.\par \medskip
|
||||
Generell kann man beim GHC-GC 2 grundlegende Optionen einstellen: Die Allokationsgröße (-A) und die Heap-Größe (-H). Hiermit wird mindestens -H Speicher allokiert und falls dieser nicht reicht in Blöcken von -A weiterer Speicher geholt. Weitere Optionen\footnote{\url{http://www.haskell.org/ghc/docs/7.6.3/html/users_guide/runtime-control.html}} (z.b. Initiale Stackgröße bei neuen Threads, etc.) sind möglich, aber in unserem Kontext nicht relevant.
|
||||
|
||||
Das Programm \texttt{ghc-gc-tune} probiert einfach brute-force die möglichen (meist sinnvollen) Kombinationen von -A und -H aus. Wir haben die Analyse auf dem Datensatz einer sparse 2000x2000-Adjazenzmatrix mit 20000 Einträgen durchgeführt um realistische Daten zu erhalten.\par
|
||||
Da der Heap nur bei Schwankungen massiv zur Laufzeitverschlimmerung beiträgt lässt sich sehen, dass dieses sich erst negativ auswirkt, wenn wir einen zu großen Heap anfragen. Unser Programm liest zu Beginn alle Daten komplett ein. Von diesem Zeitpunkt aus werden die Datenstrukturen nur unwesentlich größer, da sie sich prinzipiell auf das Ergebnis des Algorithmus reduzieren lassen. So legen wir z.B. keine großen Zwischenergebnisse an, die sich hier negativ auswirken würden.
|
||||
Die Allocation-Area (-A) ist standardmäßig auf $512k$ auseglegt, was sich hier als ideal herausstellt. Insbesondere in der Graph $Residency \cdot Time$ (in Abbildung \ref{fig:ghctune} oben links) zeigt, dass der GC am effektivsten mit nahe Standardeinstellungen (-A 512k, -H 0) ist. Wenn wir den Heap noch minimal erhöhen, ersetzen wir die inital ersten Allokationen durch eine einzelne, welches hier als Minimum bei (-A 512k -H 0) zu sehen ist.\par
|
||||
\medskip
|
||||
Alles in allem ist in unserem Falle kein GHC-GC-Tuning notwendig, da der GHC von sich aus schon sinnvolle Werte hat und so ein Test immer nur eine Einzelprüfung sein kann. Außerdem hat so ein \texttt{ghc-gc-tune}-Durchlauf eine Laufzeit, die weit über der Ausführung des Einzelfalles liegt.\footnote{Insgesamt wird das Programm $15\cdot10\times$ aufgerufen - häufig mit einer viel schlechteren Laufzeit als Ideal. Im Beispiel dauerte der längste Aufruf ca. 14 Sekunden, der schnellste ca. 2.}
|
||||
|
||||
\subsection{Laufzeit und Amdahls Gesetz} \label{test}
|
||||
\begin{figure}[h!]
|
||||
@ -292,9 +308,11 @@ Insgesamt lässt sich hierbei sehen, dass wir fast immer gleichauf mit Amdahls G
|
||||
|
||||
Die Versuche aus Abschnitt~\ref{test} belegen, dass der Algorithmus gut parallelisierbar ist und unser Programm nahezu optimal die Teilaufgaben auf die verfügbaren Ressourcen aufteilt. Für eine Berechnung auf noch mehr Rechenkernen ist zu erwarten, dass der Speedup zusätzlich durch Kommunikation leicht reduziert wird, da nach jedem Erweiterungsschritt die Teilergebnisse gesammelt werden müssen und nach doppelten Graphen gefiltert wird. Allerdings kommt uns dabei die Parallelisierung über \emph{Sparks} zugute, da durch die kleinen Teilaufgaben die Berechnungen gut aufgeteilt werden können und kein Prozess im Leerlauf auf die Synchronisation warten muss. \par
|
||||
\medskip
|
||||
Ein Detail, das die Rechenzeit negativ beeinflusst, ist Haskells Garbage-Collector. Dieser läuft zwar parallel, während auf anderen Kernen zum Teil weitergerechnet wird, blockiert jedoch selbstverständlich einen Kern für diese Zeit. Durch Angaben initialer Heap- und Stackgröße beim Programmstart lässt sich die Zeit des Garbage-Collectors um einige Sekunden reduzieren. Die optimalen Initialwerte sind stark von der Größe und den Werten der Eingabeparameter abhängig, sodass genaue Werte in der Praxis gemessen werden müssten, was den Aufwand nicht lohnt. Große Initialwerte beeinflussen die Laufzeit natürlich nicht negativ.\par
|
||||
Auch konnten wir zeigen, dass der Garbage-Collector nicht massiv die laufzeit verlängert. Die Speicherallokation machte bei unserem Programm im Falle von 4 Kernen lediglich 2,06s der 13,41s Gesamtlaufzeit aus. Auch wird die Garbage-Collection parallel auf allen Kernen ausgeführt, sodass sich auch hier die Arbeit geteilt wird und das System nicht nur auf einem Kern rechnet, da während der Garbage-Collection keine schreibenden Speicherzugriffe stattfinden können.
|
||||
Auch eine Optimierung der Heap und Allocation-Parameter bringt in unserem Falle keinen weiteren Zugewinn, da diese schon von sich aus nahezu optimal gewählt sind. Präventiv allerdings zu große Werte zu nehmen würde die Laufzeit etwas verschlechtern und eine Analyse hat einen zu großen Zeitaufwand, sodass sie nur im einzelfalle sinnvoll sein kann.\par
|
||||
\medskip
|
||||
Eine parallelen Berechnung auf sehr vielen Rechenkernen bringt wie beschrieben einen sehr guten erwarteten Speedup, verglichen mit dem Amdahlschen Gesetz. Daher ist grundsätzlich auch eine GPU-beschleunigte Berechnung denkbar. Diese würde mutmaßlich den Speedup stark erhöhen, man müsste wegen des begrenzten Grafikspeichers allerdings den exponentiellen Speicherbedarf beachten.
|
||||
Eine parallelen Berechnung auf sehr vielen Rechenkernen bringt wie beschrieben einen sehr guten erwarteten Speedup, verglichen mit dem Amdahlschen Gesetz. Allerdings ist nach Amdahls Gesetz auch der Speedup bei einem Faktor von etwa 47,27 beschränkt, welcher nicht durch hinzufügen weiterer Kerne erreicht werden kann.\par
|
||||
Grundsätzlich wäre auch eine GPU-beschleunigte Berechnung denkbar. Diese würde allerdings Probleme aufgrund der Natur des Algorithmus haben. Wir haben es hier nicht mit einem "flachen" Daten-Parallelen Algorithmus zu tun, sondern die Graphen können in einer beliebigen Generation nicht mehr expandierbar sein. Dieser Ablauf entspricht eher einem extrem unbalancierten Baum, welcher durch massiven Kommunikations-Overhead und die nötigen Kopiervorgänge auf Speicher(bus)ebene eine effiziente Grafikkartenberechnung behindert. Dennoch könnte man hier gegenüber einer herkömmlichen 4 oder 6-Kern-Maschiene noch etwas Performance herausholen.
|
||||
|
||||
%\newpage
|
||||
%\printbibliography[heading=bibintoc]
|
||||
|
BIN
doc/ausarbeitung/img/gc_total.png
Normal file
BIN
doc/ausarbeitung/img/gc_total.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 47 KiB |
BIN
gc-analysis/gc1.png
Normal file
BIN
gc-analysis/gc1.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
BIN
gc-analysis/gc2.png
Normal file
BIN
gc-analysis/gc2.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 26 KiB |
BIN
gc-analysis/gc_total.png
Normal file
BIN
gc-analysis/gc_total.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 47 KiB |
Loading…
Reference in New Issue
Block a user