Vorige Seite  Inhalt  Nächste Seite

3. Anwendung von SMP-Systemen

In diesem Kapitel geht es um die Anwendung von SMP-Systemen. Dabei betrachten wir sowohl die Programmierung eines SMP-Rechners als auch konkrete SMP-taugliche Anwendungen. Die Beispielprogramme dienen auch der Beantwortung der schon einmal aufgeworfenen Frage, ob ein SMP-System mit N Prozessoren auch einen N-fachen Leistungszuwachs bringt. Urspünglich stellte sich diese Frage bei der Anwendung von Parallelrechnern, wo man aber festgestellen mußte, daß es nur sehr wenige Probleme gibt, die linear mit der Prozessoranzahl skalieren, d.h. bei denen sich mit wachsender Prozessoranzahl die Rechenzeit so verkürzt, daß wenn man z.B. die doppelte Anzahl von Prozessoren nimmt, die Rechenzeit nur noch die Hälfte der ursprünglichen beträgt. Die Mehrzahl der Anwendungen, besonders im Betriebsumfeld, skalieren nicht linear. Das Problem ist im Allgemeinen, daß sich viele Anwendungen nur sehr schlecht in voneinander unabhängige Teile zerlegen lassen (also schlecht parallelisierbar sind), sodaß sie auf unterschiedlichen Prozessoren abgearbeitet werden können. Bei SMP-Rechnern kommt noch die Tatsache hinzu, daß diese überwiegend aus Standardkomponenten bestehen, die nicht an die Leistung von speziell konstruierten Parallelrechnerkomponenten herankommen. Ein weiteres Problem ist, daß SMP-Systeme nicht mit beliebig vielen Prozessoren gebaut werden können, da sonst die "Shared-Memory"-Eigenschaft teuer bezahlt werden muss: Der Verwaltungsaufwand und der Preis für einen entsprechend breiten und sicheren Speicherbus wächst dann ins Unermessliche.

3.1. Speedup

Betrachtet man die Laufzeiten eines SMP-tauglichen parallelen Programmes und die eines seriellen Programmes, so interessiert am meißten, um wieviel schneller (oder langsamer) das parallele Programm ist. Bezeichnet t1 die Ausführungszeit eines Algorithmus auf einem Rechner mit einem Prozessor und tP die Zeit, die ein entsprechender paralleler Algorithmus auf einem Rechner mit P Prozessoren benötigt, so definiert man das Verhältnis

SP = t1 / tP


als Speedup. SP ist der Faktor, um den das parallele Programm ggf. schneller läuft, gibt also die Beschleunigung gegenüber dem seriellen Programm an. Fairerweise sollte man nur Algorithmen der selben "Güte" gegenüberstellen, denn es nützt nichts, einen hocheffizienten seriellen Algorithmus mit einem schlechten parallelen zu vergleichen und vice versa. Der Speedup wird insbesondere in den noch folgenden Messungen eine Rolle spielen.

3.2. Linux und SMP

Bevor Linux SMP-fähig wurde, hatte man nur die Wahl, einen Intel SMP-Rechner mit Windows NT, OS/2 bzw. einem kommerziellen PC-UNIX zu betreiben, was sehr teuer war und ist. Bei der neuesten Version von Windows NT, Windows 2000 muss man sogar die Unterstützung für jeden weiteren Prozessor (bzw. Prozessorpaar) teuer erkaufen. Aus diesem und aus anderen naheliegenden Gründen habe ich die Beispielprogramme unter Linux entwickelt, wo der Aufwand zum Herstellen eines SMP-fähigen Kernels mittlerweile nur noch darin besteht, die entsprechende Konstante SMP in den Make-Dateien auf 1 zu setzen (obwohl SMP=1 eigentlich etwas ironisch ist ;-) ) und den Kernel neu zu kompilieren. Da Linux schon seit längerem POSIX-konform ist, unterstützt es auch die üblichen SMP-Programmiermodelle (Stichwort "Multithreading"), auf die wir im folgenden noch kommen. Linux ist auch zur Intel-SMP-Spezifikation in den Versionen 1.1 und 1.4 konform und kann theoretisch mit bis zu 16 Prozessoren arbeiten, obwohl das in der Praxis relativ selten ist; der Standardfall wird eher ein 4-fach SMP-Rechner sein.

3.3. Shared-Memory-Programmierung und Threads

Wie wir in Abschnitt 2.2.3. gelernt haben, besitzen SMP-Rechner einen gemeinsamen Speicherbereich, den man als Shared Memory bezeichnet. Bei der Programmierung eines derartigen Rechners unterscheidet man prinzipiell zwei Konzepte voneinander: Bei beiden Fällen kommunizieren die Prozessoren über Load/Store-Befehle mit dem Speicher. Der Unterschied ist lediglich, daß bei "Shared Everything" alle Datenstrukture im gemeinsamen Speicher liegen, während bei "Shared Something" der Benutzer angeben muß, welche Datenstrukturen er teilen möchte, und welche privat sind.

Wir werden uns der Multithreading-Programmiermethode bedienen um parallele Programme auf einem SMP-Rechner zu schreiben. Aus diesem Grund kommt für uns nur "Shared Everything" in Frage, weil dieses von Threads unterstützt wird. "Shared Something" ist unter Linux zwar auch möglich (durch Interprozesskommunikation) aber weit weniger komfortabel. Was aber sind Threads eigenlich? Nun, prinzipiell haben Threads und Multithreading nichts mit Multiprozessoren zu tun - es gibt sie auch auf Einprozessor-Rechnern. Ihre Entwicklung reicht bis zu den Ursprüngen von UNIX zurück, also bis in das Jahr 1970, wo sie erstmals in das Betriebssystem Multics implementiert wurden, das später dann von Ken Thompson und Dennis Ritchie in UNIX umbenannt wurde.

In der UNIX- und mittlerweile auch in der Windows-Welt unterscheidet man Prozesse und Threads. Prozesse sind die komplexeren Objekte aus der Betriebssystemsicht. Wenn ein Programm gestartet wird, so ist es ein Prozess innerhalb einer Multitasking-Umgebung. Threads sind sogenannte "lightweight processes"; sie ähneln zwar den Prozessen, teilen aber den selben Adressraum, Datei- Deskriptoren usw. So kann es innerhalb eines Prozesses mehrere "parallel" arbeitende Threads geben, die zusammen ein Programm (eine Anwendung) repräsentieren. Parallel ist hier in Anführungszeichen gesetzt, da sie auf Einprozessor-Rechnern nicht wirklich parallel abarbeiten.

Es gibt heute viele populäre Programme, die "multithreaded" sind. Aus der Windows-Welt z.B. die Programme der Office-Reihe oder der "Explorer". Unter Linux gibt es ebensoviele, wenn nicht mehr Multithreading-Anwendungen. Was ist aber nun der eigentliche Vorteil von Threads gegenüber Prozessen? Nun, der Aufwand, der betrieben werden muss, um von einem Prozess auf einen anderen umzuschalten (was innerhalb einer Multitasking-Umgebung oft vorkommt) ist groß. Bei diesem sog. Kontext-Wechsel muss die gesamte Prozessumgebung gespeichert werden, was viel Betriebssystemzeit verbraucht. Die Threads verbrauchen aber weit weniger Ressourcen, weswegen bei einem Thread-Kontext-Wechsel nur die Register, der Programmzähler und der Stack-Pointer gespeichert werden müssen. Ein solcher Kontext-Wechsel erfolgt natürlich viel schneller. Man kann also ein Programm in viele unterschiedliche Teile (Threads) aufspalten, die voneinander unabhängig sind. Durch die Benutzung von Threads erreicht man somit eine gewisse Parallelität, weil zwischen den einzelnen Threads sehr schnell umgeschaltet werden kann. Gewisse Klassen von Anwendungen können davon profitieren.

Diese Parallelität kann auch schon auf einem Einprozessor-System erreicht werden, was aber nur in den wenigsten Fällen eine Leistungssteigerung bringen wird, eher das Gegenteil. Dadurch sind Threads auf Einprozessor-Maschinen oftmals nur ein Komfort-Gewinn, da man "weiterarbeiten" kann (wenn auch langsamer), während andere Dinge im Hintergrund erledigt werden. Sinnvoll werden Threads auf einem SMP-System, weil hier das Betriebssystem, wenn es SMP-tauglich ist, idealerweise einen Thread pro Prozessor unterbringen kann, was letztendlich eine echte parallele Abarbeitung bewirkt.

3.4. Multithreading unter Linux

Die erste Bibliothek, die Multithreading unter Linux anbot, wobei die Threads auch auf die einzelnen Prozessoren eines SMP-Systemes verteilt wurden, war die bb_threads-Library ("Bare Bones"-Threads). Die Bibliothek ist sehr einfach gehalten (der Quellcode ist gepackt nur 7K groß!) und für gößere Anwendungen praktisch nicht nutzbar, da die Aufrufe nicht standardisiert sind.

Besser ist da die Linux Threads-Library, die eine mittlerweile fast komplette Implementation des POSIX-Standard 1003.1c darstellt. Diese POSIX-Konformität bedeutet aber, daß es relativ einfach ist, viele vorhandene auf diesem Standard basierende Anwendungen nach Linux zu portieren (zumindest hinsichtlich des Multithreading). Die kommerziellen UNIX-Varianten wie Solaris, AIX, HP-UX usw. sind ebenfalls POSIX-konform. Beide Varianten von Thread-Libraries offerieren "Kernel-Level Threads". Das bedeutet, daß die Threads als echte UNIX-Prozesse über den UNIX-clone()-Aufruf gestartet werden - mit dem Unterschied jedoch, daß sie sich einen gemeinsamen Speicher teilen. Daraus resultiert, daß der Verwaltungsaufwand bei Kontext-Wechsel nicht ganz so groß ist, wie bei normalen Prozessen.

3.5. Anwendungsbeispiele

In diesem Abschnitt wollen wir schließlich 3 konkrete Programme unter Linux betrachten, die mit Multithreading programmiert wurden. Zum Vergleich betrachten wir die selbe Anwendung als serielle Version bzw. mit 2, 3 und 4 Threads. Bei den Vergleichen ist jedesmal der in 3.1. eingeführten Speedup angegeben, so daß man sofort sehen kann, wie gut eine Anwendung von den 4 Prozessoren profitiert. Die Messungen wurden auf einem 4-fach Xeon-Server (500 Mhz) mit einem Intel "SC450NX MP"-Board durchgeführt, der unter Redhat Linux, Kernel 2.2.15 lief. Der SMP-Server war mit 2 GB Hauptspeicher bestückt, was insbesonders bei der dritten Anwendung von Bedeutung ist. Einschränkend muß allerdings gesagt werden, daß die Anwendungen nur aus dem numerischen Bereich kommen, der vielleicht nicht unbedingt der Haupteinsatzbereich eines SMP-Rechners ist. Jedoch sind umfangreichere Testbenches, die alle Systemkomponeten mit in den Test einbeziehen schwierig zu programmieren und zu verstehen (d.h. sie überschreiten noch die Programmierkünste des Autors ;-) ), weshalb ich mich für numerische Programme entschieden habe. Die Beispielprogramme können als komprimiertes Unix-Archiv (.tar.gz) heruntergeladen werden, weshalb sich ein seitenlanges Einfügen des Quellcodes erübrigt. Ich werde nur bestimmte Ausschnitte aus dem Programm präsentieren, die mir als wichtig und erklärenswert erscheinen.

3.5.1. Pi-Berechnung

piserial.c und pilinuxthread.c

Dieses Programm habe ich nicht selbst erstellt, sondern es stammt aus dem Linux-Parallel-HOWTO. Ich habe es jedoch für die Benutzung mit 4 Threads und zur Zeitmessung ein wenig modifiziert. Es berechnet die allseits bekannte Konstante Pi. Jeder Thread benutzt zur Berechnung von Pi die gleiche Funktion, die lediglich mit anderen Parametern aufgerufen wird. Hier sehen wir die Funktion "process" (eigentlich ist der Name schlecht gewählt!), welche die Berechnung durchführt:
void *process(void *arg)
{
   register double width, localsum;
   register int i;
   register int iproc = (*((char *) arg) - '0');

   width = 1.0 / intervals;
   localsum = 0;

   for(i = iproc; i < intervals; i += 4)
   {
      register double x = (i + 0.5) * width;
      localsum += 4.0 / (1.0 + x * x);
   }

   localsum *= width;

   pthread_mutex_lock(&pi_lock);
   pi += localsum;
   pthread_mutex_unlock(&pi_lock);

  return(NULL);
}
   
Was ist daran aber multithreaded? Wie wir an der for-Schleife sehen, läuft die Berechnung in einem Intervall ab. Das geht so, daß der aktuelle Wert des Intervalls in die Berechnung eingeht, nämlich durch
register double x = (i + 0.5) * width;
   
Nun könnte ein serielles Programm das ganze Intervall durchforsten, also z.B. für i nacheinander die Werte 0, 1, 2, 3, 4,... usw. einsetzen. Das parallele Programm mit 4 Threads geht aber anders vor: Jeder Thread startet mit einem anderen Anfangswert: Thread 0 mit 0, Thread 1 mit 1, Thread 2 mit 2 und schließlich Thread 3 mit 3 (Variable iproc). Nun wird der Wert für i nicht um 1, wie bei einem seriellen Programm, sondern um 4 erhöht. Thread 0 hat für i also als nächsten Wert die 4, Thread 1 die 5, Thread 2 die 6 und Thread 3 die 7. Wie man sieht, wird die Schleife auch nacheinander abgearbeitet, mit dem Unterschied, daß immer 4 Werte von i gleichzeitig in der Berechnung sind.

Interessant ist hierbei folgendes: Die Variable i ist für jeden Thread privat, da innerhalb des Funktionskontextes. Die Variablen intervals und pi können aber von allen benutzt werden - wie man sieht handelt es sich bei dem Programm um eine echte Shared-Memory-Anwendung. Die Anweisungen
pthread_mutex_lock(&pi_lock);
bzw.
pthread_mutex_unlock(&pi_lock);
verhindern, daß zwei Threads gleichzeitig auf die Shared-Memory-Variable pi zugreifen und somit u.U. nur eine Addition effektiv ausgeführt wird. Das liegt daran, daß der Zugriff auf i in zwei Speichertransaktionen ausgeführt wird, wobei während der ersten Transaktion weiterhin von der Variable gelesen werden kann. Mit den Befehlen wird ein Lock gesetzt bzw. gelöscht; ein in der Shared-Memory-Programmierung wichtiges Konzept, auf das ich aber nicht näher eingehen möchte.

Hier nun die Ergebnisse meiner Zeitmessung (Speedup < 1 ist mit "-" gekennzeichnet worden):

Anzahl der Intervalle Zeit(µs) für serielle Version Speedup für 2 Threads Speedup für 3 Threads Speedup für 4 Threads
1 1 - - -
10 2 - - -
100 9 - - -
1000 76 - - -
10000 747 - - -
100000 7596 1.3 1.8 2.28
1000000 74102 1.4 2.13 2.87
10000000 741103 1.4 2.16 2.87
Tabelle 3.1.

Die Werte sind überraschend: in 60% der Fälle läuft das Programm mit 2 oder mehr Threads langsamer, als die serielle Variante. Was sind die Ursachen dafür? Zunächst einmal dauert die Initialisierung der Threads eine ganze Weile, was zumindest bei geringen Intervallgrößen (1, 10, 100) einen großen Unterschied zur seriellen Variante ausmacht. Später dann sind die CPUs wahrscheinlich nicht genügend ausgelastet, und die multithreaded-Variante erweist sich eher als Behinderung für die relativ einfache Berechnung. Erst ab 100000 ist eine Beschleunigung meßbar. Hier ist die Grenze erreicht, wo eine CPU schneller als vier ist, weil die CPUs offenbar gleichmäßig ausgelastet werden können. Doch der stärkste Speedup mit 4 Threads ist lediglich 2.87, was keineswegs linear ist. Wir hätten sicherlich einen Wert um die 4 erwartet. Möglicherweise ist der Speicherbus das Problem, da durch das ständige Schreiben auf den Shared-Memory (Variable pi) der Speicherbus stark benutzt wird. Hinzu kommen noch Locks, durch die bei den einzelnen Threads Wartezyklen entstehen, die im Endeffekt die Gesamtperformance beeinflussen. Leider sieht es im nächsten Beispiel noch schlechter aus.

3.5.2. Primzahl-Berechnung

primeserial.c und primelinuxthread.c

In meinem multithreaded-Primzahl-Programm habe ich eine parallele Variante des Sieb des Eratosthenes implementiert. Der serielle Algorithmus ist sehr einfach: Angenommen wir wollen alle Primzahlen bis 1000 ausrechnen. Wir schreiben dazu alle Zahlen nacheinander bis zu dieser Zahl auf. Dann fangen wir an, alle Vielfachen von 3 zu streichen, danach die von 5, 7, ... usw. (die geraden Zahlen können wir weglassen) bis wir alle Vielfachen von Zahlen herausgestrichen haben. Die übrigbleibenden Zahlen (bis auf die geraden Zahlen) sind nicht Vielfache der anderen Zahlen, also durch diese nicht teilbar, weswegen sie Primzahlen sein müssen. Ebenso kann man das serielle Programm implementieren (mit ein paar Beschleunigungen, z.B. das nur Vielfache von Zahlen gestrichen werden, die selbst noch nicht gestrichen wurden u.a.). Man benötigt nur ein Zahlenfeld, indem man die Elemente am entsprechenden Index "markieren" kann (z.B. durch 0 oder 1). Am Ende durchläuft man das Feld und gibt den Index aus, der nicht als gestrichen markiert wurde.

Die parallele Version arbeitet ähnlich dem Pi-Programm. Jeder Thread startet bei einer anderen Zahl, also Thread 0 bei 3, Thread 1 bei 5, Thread 2 bei 7 und Thread 3 bei 9. Nun wird in der Schleife der Index der aktuell zu bearbeitenden Zahl um die doppelte Threadanzahl hochgezählt, da man sonst irgendwann in einen Zyklus kommt, indem die Threads die selben Zahlen prüfen. Die nächsten zu prüfenden Zahlen wären also im obigen Beispiel 11, 13, 15 und 17. Da wir alle geraden Zahlen herauslassen (wir fangen bei 3 an!), muss am Ende nur noch sichergestellt werden, das die 2 als Primzahl mit ausgegeben wird und der Index in der Ausgabeschleife keine geraden Werte überstreicht, da wir den Inahlt der Elemente an geraden Indexstellen nie geändert haben.

Die Ergebnisse:

Anzahl der Primzahlen Zeit(µs) für serielle Version Speedup für 2 Threads Speedup für 3 Threads Speedup für 4 Threads
1 1 - - -
10 3 - - -
1000 135 - - -
10000 1545 - - -
100000 17018 1.19 1.17 1.00
1000000 321774 1.08 1.17 1.14
10000000 3876768 1.05 1.03 1.06
Tabelle 3.2.

Das Primzahl-Programm skaliert offenbar noch schlechter als die Pi-Berechnung (bzw. skaliert überhaupt nicht :-) ). Aber dabei handelt es sich doch um ein schön parallelisierbares Programm, warum kann es dann nicht von den weiteren Prozessoren profitieren? Die Ursache dafür ist wieder beim Speicher, bzw. wohl auch beim Cache zu finden: Greift ein Prozessor auf eine bestimmte Zeile im Speicher schreibend zu, so kann es passieren, daß andere Prozessoren eben diese Zeile auch in ihrem Cache haben und aus Gründen der Cache-Kohärenz bzw. der Cache-Konsistenz über die Änderungen informiert werden müssen. Das wiederrum resultiert in einer starken Benutzung des Speicherbusses, worunter natürlich auch die Gesamtleistung des Programmes leidet. Im Programm kann man sehen, daß immer wieder lesend und schreibend auf den Array primes zugegriffen wird - wohl der Flaschenhals des Programmes.

3.5.3. Matrixmultiplikation

matmult.c und matmult-thread.c

Das letzte Beispielprogramm führt eine Multiplikation von zwei (n x n)-Matrizen (mit Elementen aus N) durch; eine sehr zeitraubende und langweilige Aufgabe, für die man besser einen Computer benutzt. Der Ansatz hier war der Folgende: Jeder Thread startet mit einer anderen Zeile und führt dann die Multiplikation nach dem bekannten Verfahren durch (also Summe der Produkte aus Zeilenelement der einen Matrix und korrespondierendem Spaltenelement der anderen). Thread 0 startet mit Zeile 1, Thread 1 mit Zeile 2, Thread 2 mit Zeile 3 und Thread 3 mit Zeile 4. Die Threads zählen in der Schleife für die Zeilenauswahl jeweils um die Threadanzahl - im obigen Beispiel 4 - hoch, wodurch im nächsten Schritt Thread 0 mit der Zeile 5 weiterarbeitet, Thread 1 mit Zeile 6 usw. usf. Das Ergebnis wird in eine - in diesem Falle ebenfalls (n x n)-Matrix - zurückgeschrieben. Der Speicheraufwand für das Programm ist enorm. Ausgehend von der Organisation der Daten kann man folgende Formel der Speicherbedarf in MB aufstellen:

M = (12 * N + 12 * N * N) / (1024 * 1024)

Maximal habe ich (3000 x 3000)-Matrizen berechnet, wofür man schon rund 104 MB benötigt. Glücklicherweise stand mir ein Rechner mit 2 GB Hauptspeicher zur Verfügung, mit dem auch z.B. (9000 x 9000)-Matrizen möglich gewesen wären. Die relativ lange Laufzeit bei der Berechnung machte es aber notwendig, einmal abzuschätzen, wie lange das Programm rechnet, d.h. welcher Komplexitätsklasse es angehört (man will ja nicht ewig auf sein Ergebnis warten :-) ). Der Kernpunkt des Programmes ist eine dreifach verschachtelte Schleife, die jeweils n-mal durchlaufen wird. Der Aufwand ergibt sich also zu n * n * n = n³. Das bedeutet eine Verdoppelung der Problemgröße führt zu einer Verachtfachung der Rechenzeit, was durch die Meßergebnisse bestätigt wird. Jetzt wird auch klar, warum ich nur (3000 x 3000)-Matrizen multipliziert habe.

Hier die Ergebnisse der Matrixmultiplikation:

Zeilen- / Spaltengröße Zeit(s) für serielle Version Speedup für 2 Threads Speedup für 3 Threads Speedup für 4 Threads
10 0.000038 - - -
100 0.033 1.13 2.00 2.75
1000 67.03 1.68 2.50 3.20
2000 559.33 1.62 2.40 3.11
3000 1949.03 1.72 2.60 3.30
Tabelle 3.3.

Wie zu erwarten war, ist der Aufwand im unteren Bereich für die Initialisierung der Threads größer als die Rechenzeit für das serielle Programm, weshalb hier Speedup-Werte < 1 herauskommen. Im oberen Bereich skaliert das Programm zwar nicht linear aber immerhin besser als die vorherigen Beispiele. Das mag wohl daran liegen, daß ich viel öfter vom Speicher lese als in ihn schreibe. Deswegen kommt wahrscheinlich der Cache-Effekt mehr zum tragen und wirkt hier nicht so stark als Hindernis. Das Programm ist keineswegs effektiv und könnte sicherlich noch bessere Werte liefern, wenn man die Daten in den Arrays anders organisiert. Man sollte hier aber im Auge behalten, daß die Zeiteinheit in diesem Beispiel nicht µ-Sekunden (wie bei den vorherigen Beispielen) sonder Sekunden ist, und selbst ein Speedup von nur 2 ist hier ein guter Wert, denn es macht schon einen Unterschied ob mein Computer eine halbe oder eine Stunde rechnet!
Vorige Seite  Inhalt  Nächste Seite
Copyright © 2000 by Ralph Schlosser, alle Rechte vorbehalten.
Datum der letzten Änderung: 13.07.2000