g 740665 Parallele Systeme - Exam.pdf

g 740665 Parallele Systeme - Exam
g 740665 Parallele Systeme - Exam Aufgabe 1) Betrachten wir ein Szenario, in dem ein Unternehmen plant, von einem herkömmlichen Einzelprozessor-System auf ein paralleles System umzusteigen. Das Ziel ist, große Datenmengen effizienter zu verarbeiten und komplexe Berechnungen schneller durchzuführen. a) Erkläre die drei verschiedenen Typen paralleler Systeme (Multi-Prozessor, Multi-Core und verteilt...

© StudySmarter 2024, all rights reserved.

g 740665 Parallele Systeme - Exam

Aufgabe 1)

Betrachten wir ein Szenario, in dem ein Unternehmen plant, von einem herkömmlichen Einzelprozessor-System auf ein paralleles System umzusteigen. Das Ziel ist, große Datenmengen effizienter zu verarbeiten und komplexe Berechnungen schneller durchzuführen.

a)

Erkläre die drei verschiedenen Typen paralleler Systeme (Multi-Prozessor, Multi-Core und verteilte Systeme) und gib Beispiele für mögliche Anwendungen in einem Unternehmen, das große Datenmengen verarbeitet.

Lösung:

Arten von Parallelsystemen und ihre Anwendungen

In einem parallelen System können Berechnungen gleichzeitig auf mehreren Prozessoren, Kernen oder verteilten Systemen durchgeführt werden. Dies führt zu einer effizienteren Verarbeitung großer Datenmengen. Es gibt drei Haupttypen paralleler Systeme:

  • Multi-Prozessor-Systeme: In diesen Systemen sind mehrere Prozessoren in einem einzigen Computer integriert. Jeder Prozessor kann unabhängig oder gemeinsam an einer Aufgabe arbeiten.
    • Beispiel-Anwendung: Ein Unternehmen könnte ein Multi-Prozessor-System verwenden, um große Datenbanken effizient zu durchsuchen und Datenabfragen parallel zu verarbeiten. Insbesondere könnte dies in einem Data-Warehouse-System sinnvoll sein, in dem verschiedene Abteilungen zur gleichen Zeit umfangreiche Datenanalysen durchführen.
  • Multi-Core-Systeme: In diesen Systemen befinden sich mehrere Prozessorkerne auf einem einzigen Prozessorchip. Jeder Kern kann separate Aufgaben ausführen oder an einer gemeinsamen Aufgabe arbeiten, was die Verarbeitungsgeschwindigkeit erhöht und die Effizienz verbessert.
    • Beispiel-Anwendung: Ein Softwareentwicklungsunternehmen könnte ein Multi-Core-System nutzen, um die Kompilierung großer Softwareprojekte zu beschleunigen. Durch die parallele Ausführung von Kompilierungsaufgaben auf verschiedenen Kernen kann die Gesamtzeit für die Softwareentwicklung erheblich reduziert werden.
  • Verteilte Systeme: In diesen Systemen arbeiten mehrere unabhängige Computer gemeinsam an einer Aufgabe, indem sie über ein Netzwerk verbunden sind. Jeder Computer (Knoten) führt Teilaufgaben aus und trägt somit zur Lösung des gesamten Problems bei.
    • Beispiel-Anwendung: Ein Unternehmen im Bereich Big Data Analytics könnte ein verteiltes System verwenden, um große Mengen von Echtzeit-Datenströmen zu verarbeiten. Ein solches System kann auf Cloud-Plattformen wie Hadoop oder Apache Spark basieren, um Daten zu sammeln, zu analysieren und wertvolle Erkenntnisse gleichzeitig aus verschiedenen Quellen zu gewinnen.

Durch den Einsatz dieser parallelen Systeme können Unternehmen die Effizienz und Geschwindigkeit ihrer Datenverarbeitung erheblich verbessern und somit schneller auf Veränderungen und Trends reagieren.

b)

Diskutiere die Kommunikationsmodelle (Shared Memory und Message Passing) und deren Vor- und Nachteile in Bezug auf Synchronisation und Kommunikationslatenz.

Lösung:

Kommunikationsmodelle in parallelen Systemen

Es gibt zwei Hauptkommunikationsmodelle in parallelen Systemen: Shared Memory und Message Passing. Diese Modelle haben jeweils ihre eigenen Vor- und Nachteile, insbesondere in Bezug auf Synchronisation und Kommunikationslatenz.

  • Shared Memory Modell: In diesem Modell teilen sich mehrere Prozessoren den gleichen Speicherbereich. Alle Prozessoren können auf diesen Speicher zugreifen und Daten lesen oder schreiben. Vorteile:
    • Sehr schnelle Datenübertragung, da keine Daten kopiert werden müssen.
    • Einfacher zu programmieren, insbesondere für Algorithmen, die eine gemeinsame Datenstruktur verwenden.
    Nachteile:
    • Erfordert eine sorgfältige Synchronisation, um Datenkollisionen und Inkonsistenzen zu vermeiden. Dies kann durch Sperrmechanismen wie Mutexes oder Semaphoren erreicht werden, was die Komplexität erhöht.
    • Skalierbarkeit ist beschränkt, da die Anzahl der Prozessoren, die effektiv gemeinsam auf den Speicher zugreifen können, begrenzt ist.
  • Message Passing Modell: In diesem Modell kommunizieren Prozessoren durch das Senden und Empfangen von Nachrichten. Jeder Prozessor hat seinen eigenen lokalen Speicher, und die Daten werden explizit zwischen Prozessoren übertragen. Vorteile:
    • Hohe Skalierbarkeit, da es keine zentrale Sperrstelle gibt und jeder Prozessor unabhängiger arbeitet.
    • Keine Notwendigkeit für komplexe Sperrmechanismen, da die Daten explizit übertragen werden und somit weniger Speicherkoordinationsprobleme auftreten.
    Nachteile:
    • Höhere Kommunikationslatenz, da die Daten explizit gesendet und empfangen werden müssen.
    • Schwierigere Programmierung, insbesondere wenn große Datenmengen und viele Nachrichten zwischen Prozessoren ausgetauscht werden müssen.

Zusammenfassend lässt sich sagen, dass die Wahl des passenden Kommunikationsmodells von den spezifischen Anforderungen der Anwendung abhängt. Shared Memory ist oft schneller und einfacher zu handhaben bei kleinen bis mittleren Systemen, kann jedoch schwieriger zu synchronisieren sein und bietet begrenzte Skalierbarkeit. Message Passing ist besser skalierbar und vermeidet Synchronisationsprobleme auf Kosten höherer Kommunikationslatenzen und einer komplexeren Programmierung.

c)

Berechne die vermutete Effizienzsteigerung, wenn das Unternehmen von einem Einzelprozessor-System, das 100 Stunden zur Verarbeitung eines bestimmten Datenvolumens benötigt, auf ein paralleles Multi-Core-System mit 10 Cores umsteigt. Gehe davon aus, dass die Effizienz (E) durch die Formel E = \frac{T_{single}}{T_{parallel}}, mit T_{single} = 100 Stunden und der idealen Parallelverarbeitung ohne Overhead, gegeben ist.

Lösung:

Berechnung der vermuteten Effizienzsteigerung

Um die vermutete Effizienzsteigerung zu berechnen, wenn das Unternehmen von einem Einzelprozessor-System, das 100 Stunden zur Verarbeitung eines bestimmten Datenvolumens benötigt, auf ein paralleles Multi-Core-System mit 10 Kernen umsteigt, verwenden wir folgende Formel für die Effizienz (E):

\[E = \frac{T_{single}}{T_{parallel}}\]

Gegeben:

  • Einzelprozessor-Zeit: \(T_{single} = 100\) Stunden
  • Anzahl der Kerne: 10

Unter der Annahme einer idealen Parallelverarbeitung ohne Overhead, wird die Gesamtverarbeitungszeit (\(T_{single}\)) gleichmäßig auf die 10 Kerne aufgeteilt:

\[T_{parallel} = \frac{T_{single}}{\text{Anzahl der Kerne}} = \frac{100}{10} = 10\text{ Stunden}\]

Nun setzen wir diese Werte in die Effizienzformel ein:

\[E = \frac{T_{single}}{T_{parallel}} = \frac{100}{10} = 10\]

Dies bedeutet, dass das Unternehmen eine vermutete Effizienzsteigerung um den Faktor 10 erreicht, wenn es von einem Einzelprozessor-System auf ein paralleles Multi-Core-System mit 10 Kernen umsteigt.

Aufgabe 2)

Betrachten Sie die beiden Speicherarchitekturen in parallelen Systemen: Shared Memory und Distributed Memory. Die Art und Weise, wie Prozessoren auf den Speicher zugreifen, unterscheidet sich erheblich zwischen diesen beiden Architekturen. Während in Shared Memory Architekturen alle Prozessoren denselben Speicher nutzen, hat in Distributed Memory Architekturen jeder Prozessor seinen eigenen lokalen Speicher. Evaluieren Sie die Vor- und Nachteile dieser beiden Ansätze in Bezug auf Kommunikation, Skalierbarkeit und Synchronisation.

a)

Erkläre das Konzept des Race Conditions in Shared Memory Architekturen und beschreibe eine Methode, wie man dieses Problem durch Synchronisationstechniken wie Mutex oder Semaphore verhindern kann. Verwende dabei ein Beispiel aus der Praxis.

Lösung:

Race Conditions in Shared Memory Architekturen

In Shared Memory Architekturen teilen sich mehrere Prozessoren denselben Speicherbereich. Eine Race Condition tritt auf, wenn mehrere Threads oder Prozessoren gleichzeitig auf denselben Speicherbereich zugreifen und zumindest ein Zugriff schreibend ist. Dies kann zu inkonsistenten oder unerwarteten Ergebnissen führen, da die Reihenfolge und das Timing der Zugriffe nicht vorhersehbar sind.

Praktisches Beispiel

Betrachten wir ein Beispiel aus der Praxis: Angenommen, zwei Threads möchten den Wert einer gemeinsamen Zählervariable erhöhen:

 'code in html here'  Thread 1: counter = counter + 1  Thread 2: counter = counter + 1 

Ohne Synchronisation könnten beide Threads den ursprünglichen Wert des Zählers lesen, den Wert erhöhen und wieder speichern, was dazu führt, dass der Zähler nur um 1 statt um 2 erhöht wird.

Verhinderung von Race Conditions

Um Race Conditions zu verhindern, können Synchronisationstechniken wie Mutex (Mutual Exclusion) oder Semaphore verwendet werden.

Ein Mutex (kurz für Mutual Exclusion) ist eine Sperre, die sicherstellt, dass nur ein Thread gleichzeitig auf einen kritischen Abschnitt des Codes zugreifen kann. Hier ist ein Beispiel, wie man den oben genannten Code mit einem Mutex schützen kann:

 'code in html here'  mutex.lock()  counter = counter + 1  mutex.unlock() 

Hier sorgt der mutex.lock() Aufruf dafür, dass nur ein Thread den kritischen Abschnitt gleichzeitig betreten kann. Der mutex.unlock() Aufruf gibt die Sperre frei, sodass der nächste Thread eintreten kann.

Semaphore funktionieren ähnlich wie Mutex, bieten jedoch zusätzliche Flexibilität durch die Möglichkeit, eine bestimmte Anzahl von Threads gleichzeitig in den kritischen Abschnitt zu lassen. Sie sind jedoch auch komplexer zu handhaben.

Fazit

  • Race Conditions können in Shared Memory Architekturen zu unerwarteten Verhaltensweisen führen.
  • Mutex und Semaphore sind effektive Methoden, um Race Conditions durch Kontrolle des gleichzeitigen Zugriffs zu verhindern.
  • Durch den Einsatz dieser Synchronisationstechniken kann sichergestellt werden, dass Threads korrekt und sicher miteinander interagieren.

b)

Diskutiere die Kommunikationskosten in Distributed Memory Architekturen. Vergleiche die Effizienz der Kommunikation über Nachrichtenaustausch (Message Passing) im Vergleich zur direkten Speicherzugriffe in Shared Memory Architekturen. Gehe dabei auf konkrete Aufwandsbetrachtungen ein und gib einem Beispiel-Algorithmus an, bei dem die Kommunikationskosten signifikant sind.

Lösung:

Kommunikationskosten in Distributed Memory Architekturen

In Distributed Memory Architekturen besitzt jeder Prozessor seinen eigenen lokalen Speicher. Um Daten auszutauschen, müssen Prozessoren Nachrichten senden und empfangen, was als Message Passing bezeichnet wird. Dieser Ansatz unterscheidet sich erheblich von der direkten Speicherzugriffe in Shared Memory Architekturen, wo alle Prozessoren einen gemeinsamen Speicher nutzen.

Effizienz der Kommunikation

Die Effizienz der Kommunikation in Distributed Memory Architekturen wird durch mehrere Faktoren beeinflusst:

  • Latenz: Die Verzögerung, die entsteht, wenn eine Nachricht gesendet wird, bis sie beim Empfänger ankommt.
  • Bandbreite: Die Menge an Daten, die pro Zeiteinheit übertragen werden kann.
  • Übertragungsprotokolle und Netzwerkarchitekturen: Wie effizient die Nachrichten über das Netzwerk geleitet werden.

Im Vergleich dazu haben Shared Memory Architekturen geringere Kommunikationskosten, da der Speicher direkt von den Prozessoren gelesen und beschrieben werden kann, ohne die zusätzlichen Übertragungsverzögerungen und -kosten.

Konkrete Aufwandsbetrachtungen

Bei der Aufwandsbetrachtung der Kommunikation in Distributed Memory Architekturen spielt die Anzahl der notwendigen Nachrichten und die Größe dieser Nachrichten eine entscheidende Rolle. Hier einige grundlegende Überlegungen:

  • Für jede Kommunikation muss eine Nachricht initialisiert, gesendet, empfangen und verarbeitet werden. Dies resultiert in einem Fixkostenaufwand pro Nachricht.
  • Die Größe der Nachricht beeinflusst die Übermittlungszeit: Große Nachrichten benötigen mehr Zeit, um über das Netzwerk transportiert zu werden, besonders wenn die Bandbreite begrenzt ist.

Beispiel: Matrixmultiplikation

Ein Beispiel-Algorithmus, bei dem die Kommunikationskosten signifikant sind, ist die verteilte Matrixmultiplikation. Hier müssen entweder Teile der Matrizen oder Zwischenergebnisse zwischen den Prozessoren ausgetauscht werden.

 'algorithm example in html here'  // Verteilte Matrixmultiplikation (C = A * B)  // Jeder Prozessor berechnet einen Block der Ergebnis-Matrix C   // Schritt 1: Verteile die Matrizen A und B auf die Prozessoren  // Schritt 2: Berechne lokale Produkte  for (i = start_row; i < end_row; i++) {      for (j = 0; j < N; j++) {         C[i][j] = 0;         for (k = 0; k < N; k++) {            C[i][j] += A[i][k] * B[k][j];        }     }  }   // Schritt 3: Tausche die notwendigen Matrixteile aus  MessagePassing.send(C);  MessagePassing.recv(C); 

Hierbei entstehen Kommunikationskosten sowohl beim Initialisieren und Verteilen der Matrizen als auch beim Austausch der Teilergebnisse.

Fazit

  • Die Kommunikationskosten in Distributed Memory Architekturen sind tendenziell höher als in Shared Memory Architekturen, da Nachrichteninitialisierung und -übertragung nötig sind.
  • Message Passing kann zu signifikanten Verzögerungen führen, insbesondere bei großen Datenmengen und komplexen Algorithmen.
  • Die Effizienz von Algorithmen in Distributed Memory Architekturen hängt stark von der Minimierung der Kommunikationskosten ab.

c)

Analysiere die Skalierbarkeit beider Architekturen. Berechne, wie sich die Anzahl der Prozessoren auf die Gesamtleistung und Effizienz in einem parallelen System auswirkt. Verwende dazu die Formel für die Skalierbarkeit \(\frac{T_1}{T_p}\) und beschreibe den Einfluss von Overheads wie Kommunikations- und Synchronisationskosten.

Lösung:

Skalierbarkeit von Shared Memory und Distributed Memory Architekturen

Die Skalierbarkeit eines Systems gibt an, wie gut es seine Leistung steigern kann, wenn die Anzahl der Prozessoren erhöht wird. Sowohl Shared Memory als auch Distributed Memory Architekturen haben ihre eigenen Vor- und Nachteile hinsichtlich Kommunikation, Skalierbarkeit und Synchronisation. Nun analysieren wir die Skalierbarkeit beider Architekturen und betrachten, wie sich die Anzahl der Prozessoren auf die Gesamtleistung und Effizienz auswirkt, unter Verwendung der Formel für die Skalierbarkeit \(\frac{T_1}{T_p}\), sowie den Einfluss von Overheads wie Kommunikations- und Synchronisationskosten.

Formel für die Skalierbarkeit

Die Skalierbarkeit eines parallelen Systems kann durch das Verhältnis der Ausführungszeit mit einem Prozessor (\(T_1\)) zur Ausführungszeit mit \(p\) Prozessoren (\(T_p\)) gemessen werden:

\[S(p) = \frac{T_1}{T_p}\]

  • \(T_1\): Ausführungszeit mit einem Prozessor.
  • \(T_p\): Ausführungszeit mit \(p\) Prozessoren.

Skalierbarkeit in Shared Memory Architekturen

In Shared Memory Architekturen teilen sich alle Prozessoren denselben Speicher. Der Hauptvorteil dieser Architektur liegt in der direkten Kommunikation und Synchronisation durch den gemeinsamen Speicher. Dies kann jedoch zu Skalierbarkeitsproblemen führen:

  • Memory Contention: Mehrere Prozessoren konkurrieren um den Zugriff auf denselben Speicherbereich, was zu Verzögerungen führen kann.
  • Cache-Kohärenz Overheads: Synchronisation und Konsistenz von Caches führen zu zusätzlichem Aufwand.

Beispiel

Angenommen, ein Algorithmus benötigt auf einem Prozessor \(T_1 = 100\) Sekunden. Mit \(p = 4 \) Prozessoren und einem zusätzlichen Synchronisationsaufwand von insgesamt 20 Sekunden (Overhead), ist die Ausführungszeit:

T_4 = \frac{T_1}{p} + Overhead = \frac{100}{4} + 20 = 25 + 20 = 45 SekundenSkalierbarkeit = \frac{T_1}{T_p} = \frac{100}{45} = 2.22

Skalierbarkeit in Distributed Memory Architekturen

In Distributed Memory Architekturen hat jeder Prozessor seinen eigenen lokalen Speicher. Diese Architektur umgeht die Probleme der Speicherinterferenzen und der Cache-Kohärenz, verursacht jedoch neue Herausforderungen durch die Notwendigkeit des Datenaustauschs:

  • Kommunikations-Overheads: Die Notwendigkeit, Datenpakete zu senden und zu empfangen, beeinflusst die Effizienz bei wachsender Prozessorzahl.
  • Latenz und Bandbreite: Netzwerklatenzen und begrenzte Bandbreiten können die Datenübertragungszeiten verlangsamen.

Beispiel:

Angenommen, derselbe Algorithmus benötigt auf einem Prozessor (\(T_1\)) 100 Sekunden. Mit \(p = 4\) Prozessoren und einem Kommunikationsaufwand von 10 Sekunden pro Nachricht (Annahme: 4 Nachrichten nötig), ist die Ausführungszeit:

T_4 = \frac{T_1}{p} + (Anzahl Nachrichten \times Nachrichtdauer) = \frac{100}{4} + 4 \times 10 = 25 + 40 = 65 SekundenSkalierbarkeit = \frac{T_1}{T_p} = \frac{100}{65} = 1.54

Einfluss von Overheads

Der hauptsächliche Einfluss auf die Skalierbarkeit beider Architekturen stammt von den Overheads:

  • Shared Memory Architekturen: Memory Contention und Cache-Kohärenz führen zu Synchronisationskosten, die Leistungseinbußen verursachen.
  • Distributed Memory Architekturen: Kommunikations-Overheads durch Nachrichtenaustausch beeinträchtigen die Effizienz bei wachsender Anzahl Prozessoren.

Fazit

  • Shared Memory Architekturen bieten schnellere Kommunikation durch direkten Speicherzugriff, sind jedoch durch Synchronisationskosten begrenzt.
  • Distributed Memory Architekturen vermeiden Speicherinterferenz, verursachen aber hohe Kommunikationskosten.
  • Die Skalierbarkeit kann durch die Formel \(\frac{T_1}{T_p}\) quantifiziert und die Effizienz unter Berücksichtigung von Overheads analysiert werden.

d)

Entwickle ein Programm (in einer Sprache deiner Wahl), das die Prinzipien von Shared Memory und Distributed Memory Implementierungen kombiniert. Beschreibe den Einsatz von Threads für Shared Memory und die Verwendung von Message Passing Interface (MPI) für Distributed Memory. Demonstriere den Code durch ein einfaches Beispiel, wie eine Matrixmultiplikation.

Lösung:

Implementierung von Shared Memory und Distributed Memory Prinzipien

In dieser Aufgabe kombinieren wir die Prinzipien von Shared Memory und Distributed Memory Implementierungen, indem wir Threads für Shared Memory und das Message Passing Interface (MPI) für Distributed Memory verwenden. Wir demonstrieren dies anhand eines einfachen Beispiels: der Matrixmultiplikation.

Matrixmultiplikation: Shared Memory und Distributed Memory

Bei der Matrixmultiplikation multiplizieren wir zwei Matrizen \(A\) und \(B\), um eine Ergebnis-Matrix \(C\) zu erhalten. Wir teilen die Arbeit auf mehrere Prozessoren auf, wobei in einem hybriden Ansatz sowohl Threads (für Shared Memory) als auch MPI (für Distributed Memory) zum Einsatz kommen.

Implementierung

Wir verwenden Python mit der Bibliothek `mpi4py` für MPI und die Bibliothek `threading` für die Nutzung von Threads.

'import mpi4py.MPI as MPI import threading import numpy as np  # Funktion für die Matrixmultiplikation (Thread) def matrix_multiply(A, B, C, start_row, end_row):     for i in range(start_row, end_row):         for j in range(B.shape[1]):             for k in range(A.shape[1]):                 C[i, j] += A[i, k] * B[k, j]  # Hauptfunktion def main():     comm = MPI.COMM_WORLD     rank = comm.Get_rank()     size = comm.Get_size()      N = 4  # Größe der Matrizen      if rank == 0:         A = np.random.randint(10, size=(N, N))         B = np.random.randint(10, size=(N, N))         C = np.zeros((N, N))     else:         A = None         B = None         C = None      # Teilen Sie die Matrizen auf alle Prozesse auf     A = comm.bcast(A, root=0)     B = comm.bcast(B, root=0)     C = np.zeros((N, N))      # Verteilung der Reihen auf die Prozesse     rows_per_proc = N // size     start_row = rank * rows_per_proc     end_row = (rank + 1) * rows_per_proc      # Verwendung von Threads für die Berechnung innerhalb jedes Prozesses     threads = []     num_threads = 2  # Anzahl der Threads     rows_per_thread = rows_per_proc // num_threads     for i in range(num_threads):         t_start = start_row + i * rows_per_thread         t_end = start_row + (i + 1) * rows_per_thread         thread = threading.Thread(target=matrix_multiply, args=(A, B, C, t_start, t_end))         threads.append(thread)         thread.start()      for thread in threads:         thread.join()      # Sammeln der Ergebnisse von allen Prozessen     comm.Reduce(MPI.IN_PLACE, C, op=MPI.SUM, root=0)      if rank == 0:         print('Matrix A:', A)         print('Matrix B:', B)         print('Matrix C (Ergebnis):', C)  if __name__ == '__main__':     main()

Erklärung des Codes

  • Wir initialisieren MPI und erhalten den Rang und die Größe des Kommunikations-Clusters.
  • Die Matrizen \(A\) und \(B\) werden nur vom Hauptprozess (Rank 0) erzeugt und dann über alle Prozesse verteilt (Broadcast).
  • Jeder Prozess berechnet einen Teil der Ergebnis-Matrix \(C\).
  • Innerhalb jedes Prozesses verwenden wir Threads, um parallele Berechnungen auf geteilten Speicherbereichen durchzuführen.
  • Nach Abschluss der Berechnungen werden die Teilergebnisse gesammelt und addiert (Reduce).

Dieser hybride Ansatz zeigt, wie man Shared Memory und Distributed Memory Prinzipien kombinieren kann, um die Effizienz und Leistung eines parallelen Algorithmus zu verbessern.

Aufgabe 3)

In dieser Aufgabe sollst Du eine Funktion parallelisieren, die die Summe aller ganzzahligen Elemente eines Arrays berechnet. Nutze dafür OpenMP. Die Funktion hat die folgende Signatur und implementiert in C:

#include int array_sum(int *arr, int size) {    int sum = 0;    for (int i = 0; i < size; i++)         sum += arr[i];    return sum;}

a)

  • Modifiziere die Funktion mithilfe der Compiler-Direktiven von OpenMP, um das Array arr parallel zu iterieren und die Summe zu berechnen. Erläutere, welche OpenMP-Direktiven Du verwendet hast und warum.

Lösung:

Parallelisierung der Funktion zur Summenberechnung

  • Schritt 1: Einfügen der notwendigen OpenMP-Bibliothek

Zuerst muss sichergestellt werden, dass die OpenMP-Bibliothek eingebunden ist:

#include <omp.h>
  • Schritt 2: Nutzung der OpenMP-Direktiven zur Parallelisierung

Um die Schleifen-Iteration zu parallelisieren, nutzen wir die parallel for-Direktive von OpenMP, damit die Schleife über die verschiedenen Threads verteilt wird. Um die Summe korrekt zu berechnen und Datenkonflikte zu vermeiden, verwenden wir zusätzlich die reduction-Klausel:

#include <omp.h>int array_sum(int *arr, int size) {    int sum = 0;    #pragma omp parallel for reduction(+:sum)    for (int i = 0; i < size; i++)         sum += arr[i];    return sum;}
  • Erklärung der OpenMP-Direktiven:
  • #pragma omp parallel for: Diese Direktive teilt die Schleifen-Iterationen gleichmäßig auf die verfügbaren Threads auf und führt sie parallel aus.
  • reduction(+:sum): Diese Klausel stellt sicher, dass jeder Thread seine lokale Summe berechnet und nachdem alle Threads fertig sind, werden die lokalen Summen zu einer globalen Summe zusammengerechnet. Die + symbolisiert die Addition, und sum ist die Variable, die reduziert wird.

b)

  • Ein Team von 4 Threads soll dynamisch, basierend auf der Lastverteilung, die Elemente des Arrays aufteilen. Füge die passende OpenMP-Direktive hinzu und erkläre, wie die dynamische Lastverteilung funktioniert.

Lösung:

Dynamische Lastverteilung mit OpenMP

  • Schritt 1: Einfügen der notwendigen OpenMP-Bibliothek

Zuerst muss sichergestellt werden, dass die OpenMP-Bibliothek eingebunden ist:

#include <omp.h>
  • Schritt 2: Konfiguration des Teams und der dynamischen Lastverteilung

Um die dynamische Lastverteilung mit einem Team von 4 Threads zu realisieren, fügen wir die schedule(dynamic)-Klausel in die parallel for-Direktive ein und spezifizieren die Anzahl der Threads mittels num_threads(4):

#include <omp.h>int array_sum(int *arr, int size) {    int sum = 0;    #pragma omp parallel for num_threads(4) schedule(dynamic) reduction(+:sum)    for (int i = 0; i < size; i++)         sum += arr[i];    return sum;}
  • Erklärung der OpenMP-Direktiven:
  • #pragma omp parallel for: Diese Direktive teilt die Schleifen-Iterationen gleichmäßig auf die verfügbaren Threads auf und führt sie parallel aus.
  • num_threads(4): Diese Klausel spezifiziert, dass genau 4 Threads im Team verwendet werden sollen.
  • schedule(dynamic): Diese Klausel sorgt für eine dynamische Lastverteilung. Das bedeutet, dass die Schleifeniterationen in kleinen Blöcken (standardmäßig eine Iteration pro Block) an die Threads verteilt werden. Sobald ein Thread einen Block abgeschlossen hat, holt er sich den nächsten verfügbaren Block. Dadurch wird die Lastverteilung effizienter, besonders wenn die Iterationen unterschiedlich lange dauern.
  • reduction(+:sum): Diese Klausel stellt sicher, dass jeder Thread seine lokale Summe berechnet und nachdem alle Threads fertig sind, werden die lokalen Summen zu einer globalen Summe zusammengerechnet. Die + symbolisiert die Addition, und sum ist die Variable, die reduziert wird.

c)

  • Verifikationsaufgabe: Gegeben sei ein Array der Größe 10, gefüllt mit den Werten von 1 bis 10 (einschließlich). Bestimme die erwartete Summe und überprüfe Deine parallelisierte Funktion auf Korrektheit, indem Du ein kurzes C-Programm schreibst, das dieses Array erstellt, die Funktion aufruft und die korrekte Summe ausgibt.
  • Erkläre anhand Deines Outputs, ob die Parallelisierung tatsächlich korrekt funktioniert hat.

Lösung:

Verifikation der parallelisierten Funktion

  • Berechnung der erwarteten Summe

Ein Array der Größe 10, gefüllt mit den Werten von 1 bis 10 (einschließlich), hat die erwartete Summe:

Die Summe der ersten n natürlichen Zahlen wird durch die Formel:

Summe = n(n+1)/2

berechnet. Für n = 10 ergibt dies:

Summe = 10 * 11 / 2 = 55
  • Schritt 1: Schreiben des C-Programms zur Verifikation

Das folgende C-Programm erstellt ein Array mit den Werten 1 bis 10, ruft die parallelisierte Funktion array_sum auf und gibt die berechnete Summe aus:

#include <stdio.h>#include <omp.h>int array_sum(int *arr, int size) {    int sum = 0;    #pragma omp parallel for num_threads(4) schedule(dynamic) reduction(+:sum)    for (int i = 0; i < size; i++)         sum += arr[i];    return sum;}int main() {    int arr[10];    for (int i = 0; i < 10; i++)         arr[i] = i + 1;    int result = array_sum(arr, 10);    printf("Die berechnete Summe ist: %d", result);    return 0;}
  • Schritt 2: Kompilieren und Ausführen des Programms

Das Programm kann mit dem folgenden Befehl kompiliert werden:

gcc -fopenmp -o array_sum_test array_sum_test.c

Und mit dem folgenden Befehl ausgeführt:

./array_sum_test
  • Erklärung des Outputs:

Wenn das Programm korrekt kompiliert und ausgeführt wird, sollte es die folgende Ausgabe liefern:

Die berechnete Summe ist: 55

Da die berechnete Summe der erwarteten Summe von 55 entspricht, können wir schlussfolgern, dass die Parallelisierung korrekt funktioniert hat und das Programm das korrekte Ergebnis liefert.

Aufgabe 4)

CUDA-Programmierung für GPUsCUDA von NVIDIA erlaubt es, GPUs für parallele Berechnungen zu programmieren. Dabei wird eine Erweiterung der Programmiersprache C/C++ verwendet. Einige wichtige Konzepte und Funktionen in CUDA sind:

  • Kernel-Funktionen: Diese Funktionen werden auf der GPU ausgeführt.
  • Threads, Blöcke und Grids: Threads sind die kleinsten Berechnungseinheiten, die in Blöcken und Grids organisiert sind.
  • Speicherhierarchie: Es gibt verschiedene Speicherarten wie Register, Shared Memory und Global Memory.
  • Synchronisation: Mit __syncthreads() können Threads innerhalb eines Blocks synchronisiert werden.
  • Performance-Optimierung: Durch koaleszierten Speicherzugriff und die Verwendung von Shared Memory kann die Leistung verbessert werden.

a)

Schreibe eine einfache CUDA-Kernel-Funktion in C/C++, die zwei Arrays von Fließkommazahlen (\texttt{float}) addiert. Die Funktion soll jedes Element des Ausgangsarrays als Summe der entsprechenden Elemente der beiden Eingangsarrays berechnen. Dein Kernel sollte korrekt auf die Speicherhierarchie zugreifen und Threads innerhalb eines Blocks synchronisieren, falls nötig. Gebe danach ein vollständiges Host-Programm an, das diese Kernel-Funktion aufruft.

Lösung:

CUDA-Programmierung für GPUsCUDA von NVIDIA erlaubt es, GPUs für parallele Berechnungen zu programmieren. Dabei wird eine Erweiterung der Programmiersprache C/C++ verwendet. Einige wichtige Konzepte und Funktionen in CUDA sind:

  • Kernel-Funktionen: Diese Funktionen werden auf der GPU ausgeführt.
  • Threads, Blöcke und Grids: Threads sind die kleinsten Berechnungseinheiten, die in Blöcken und Grids organisiert sind.
  • Speicherhierarchie: Es gibt verschiedene Speicherarten wie Register, Shared Memory und Global Memory.
  • Synchronisation: Mit __syncthreads() können Threads innerhalb eines Blocks synchronisiert werden.
  • Performance-Optimierung: Durch koaleszierten Speicherzugriff und die Verwendung von Shared Memory kann die Leistung verbessert werden.

Schreibe eine einfache CUDA-Kernel-Funktion in C/C++, die zwei Arrays von Fließkommazahlen (\texttt{float}) addiert. Die Funktion soll jedes Element des Ausgangsarrays als Summe der entsprechenden Elemente der beiden Eingangsarrays berechnen. Dein Kernel sollte korrekt auf die Speicherhierarchie zugreifen und Threads innerhalb eines Blocks synchronisieren, falls nötig. Gebe danach ein vollständiges Host-Programm an, das diese Kernel-Funktion aufruft.

Lösung:

Zunächst schreiben wir den CUDA-Kernel:

__global__ void addArrays(float *a, float *b, float *c, int size) {    int idx = threadIdx.x + blockIdx.x * blockDim.x;    if (idx < size) {        c[idx] = a[idx] + b[idx];    }}

Dieser Kernel nimmt zwei Eingangsarrays a und b sowie ein Ausgangsarray c und berechnet c[i] = a[i] + b[i] für jedes Element i.

Hier ist das Host-Programm, das diese Kernel-Funktion aufruft:

#include <iostream>const int ARRAY_SIZE = 1000;const int ARRAY_BYTES = ARRAY_SIZE * sizeof(float);int main() {    // Eingangsarrays auf dem Host initialisieren    float h_a[ARRAY_SIZE], h_b[ARRAY_SIZE], h_c[ARRAY_SIZE];    for (int i = 0; i < ARRAY_SIZE; i++) {        h_a[i] = float(i);        h_b[i] = float(i * 2);    }    // Speicher auf der GPU reservieren    float *d_a, *d_b, *d_c;    cudaMalloc((void**)&d_a, ARRAY_BYTES);    cudaMalloc((void**)&d_b, ARRAY_BYTES);    cudaMalloc((void**)&d_c, ARRAY_BYTES);    // Daten vom Host auf die GPU kopieren    cudaMemcpy(d_a, h_a, ARRAY_BYTES, cudaMemcpyHostToDevice);    cudaMemcpy(d_b, h_b, ARRAY_BYTES, cudaMemcpyHostToDevice);    // Anzahl der Threads und Blöcke definieren    int blockSize = 256;    int gridSize = (ARRAY_SIZE + blockSize - 1) / blockSize;    // Kernel aufrufen    addArrays<<>>(d_a, d_b, d_c, ARRAY_SIZE);    // Ergebnis zurück auf den Host kopieren    cudaMemcpy(h_c, d_c, ARRAY_BYTES, cudaMemcpyDeviceToHost);    // Ergebnis überprüfen    for (int i = 0; i < 10; i++) {        std::cout << h_c[i] << std::endl;    }    // Speicher freigeben    cudaFree(d_a);    cudaFree(d_b);    cudaFree(d_c);    return 0;}

Das Host-Programm initialisiert zwei Arrays auf dem Host und kopiert sie dann auf die GPU. Anschließend ruft es den CUDA-Kernel auf, kopiert die Ergebnisse zurück auf den Host und gibt schließlich den Speicher frei.

b)

Erläutere, wie die Organisation von Threads in Blöcken und Grids zur Performance-Optimierung beiträgt. Nutze dabei die Begriffe koaleszierter Speicherzugriff und Shared Memory und erkläre anhand eines Beispiels, wie diese Techniken die Rechenzeit reduzieren können.

Lösung:

CUDA-Programmierung für GPUsCUDA von NVIDIA erlaubt es, GPUs für parallele Berechnungen zu programmieren. Dabei wird eine Erweiterung der Programmiersprache C/C++ verwendet. Einige wichtige Konzepte und Funktionen in CUDA sind:

  • Kernel-Funktionen: Diese Funktionen werden auf der GPU ausgeführt.
  • Threads, Blöcke und Grids: Threads sind die kleinsten Berechnungseinheiten, die in Blöcken und Grids organisiert sind.
  • Speicherhierarchie: Es gibt verschiedene Speicherarten wie Register, Shared Memory und Global Memory.
  • Synchronisation: Mit __syncthreads() können Threads innerhalb eines Blocks synchronisiert werden.
  • Performance-Optimierung: Durch koaleszierten Speicherzugriff und die Verwendung von Shared Memory kann die Leistung verbessert werden.

Aufgabe: Erläutere, wie die Organisation von Threads in Blöcken und Grids zur Performance-Optimierung beiträgt. Nutze dabei die Begriffe koaleszierter Speicherzugriff und Shared Memory und erkläre anhand eines Beispiels, wie diese Techniken die Rechenzeit reduzieren können.

Lösung:

Die Organisation von Threads in Blöcken und Grids ist zentral für die Performance-Optimierung von CUDA-Programmen. Hier sind zwei Haupttechniken, die dabei helfen:

  • Koaleszierter Speicherzugriff:Koaleszierter Speicherzugriff bezieht sich darauf, wie Threads auf benachbarte Speicheradressen zugreifen. Wenn benachbarte Threads auf benachbarte Speicherpositionen zugreifen, werden die Speichertransfers zusammengefasst (koalesziert). Dies reduziert die Anzahl der Speichertransfers und erhöht die Speicherbandbreite. Zum Beispiel:
__global__ void exampleKernel(float *data) {    int idx = threadIdx.x + blockIdx.x * blockDim.x;    // Zugreifen auf aufeinanderfolgende Speicherpositionen    float value = data[idx];    // Verarbeitung}

In diesem Beispiel greifen benachbarte Threads (threadIdx.x) auf aufeinanderfolgende Speicherpositionen (data[idx]) zu, was zu koalesziertem Speicherzugriff führt und die Bandbreite optimiert.

  • Shared Memory:Shared Memory ist ein schneller, teilbarer Speicherbereich, der von allen Threads innerhalb eines Blocks genutzt werden kann. Durch die Verwendung von Shared Memory kann der Zugriff auf Global Memory, der langsamer ist, minimiert werden. Beispiel:
__global__ void exampleKernel(float *data, float *result) {    extern __shared__ float sharedData[];    int idx = threadIdx.x + blockIdx.x * blockDim.x;    sharedData[threadIdx.x] = data[idx];    __syncthreads();    // Verarbeitung unter Verwendung von sharedData    result[idx] = sharedData[threadIdx.x] * 2;}

In diesem Beispiel wird der Wert von data[idx] zuerst in den Shared Memory kopiert. Alle Threads innerhalb desselben Blocks können schnell auf diese Daten zugreifen und somit die Anzahl der langsamen Zugriffe auf den Global Memory reduzieren.

Beispiel zur Performance-Optimierung:Angenommen, wir möchten eine Matrix-Multiplikation durchführen. Hierbei können wir Shared Memory verwenden, um die Zugriffe auf Global Memory zu minimieren:

__global__ void matrixMulKernel(float *A, float *B, float *C, int N) {    __shared__ float sharedA[TILE_SIZE][TILE_SIZE];    __shared__ float sharedB[TILE_SIZE][TILE_SIZE];    int tx = threadIdx.x;    int ty = threadIdx.y;    int row = blockIdx.y * TILE_SIZE + ty;    int col = blockIdx.x * TILE_SIZE + tx;    float value = 0;    for (int k = 0; k < (N + TILE_SIZE - 1) / TILE_SIZE; ++k) {        if (row < N && k * TILE_SIZE + tx < N)            sharedA[ty][tx] = A[row * N + k * TILE_SIZE + tx];        else            sharedA[ty][tx] = 0.0;        if (col < N && k * TILE_SIZE + ty < N)            sharedB[ty][tx] = B[(k * TILE_SIZE + ty) * N + col];        else            sharedB[ty][tx] = 0.0;        __syncthreads();        for (int n = 0; n < TILE_SIZE; ++n)            value += sharedA[ty][n] * sharedB[n][tx];        __syncthreads();    }    if (row < N && col < N)        C[row * N + col] = value;}

In diesem Beispiel teilen die Threads innerhalb eines Blocks jeweils einen Teil der Matrizen A und B und speichern sie im Shared Memory. Dies verhindert wiederholte Zugriffe auf den Global Memory und optimiert somit die Rechenzeit erheblich.

c)

Gegeben seien zwei Arrays mit je einer Million Fließkommazahlen. Entwickle eine CUDA-Strategie, die sowohl die Synchronisation als auch die Speicherhierarchie optimiert, um die folgenden Operationen effizient durchzuführen:

  • Berechnung der Summe der beiden Arrays.
  • Elementweise Multiplikation der Arrays.
Erkläre, wie du die Threads, Blöcke und Speicherarten zuweisen würdest, und zeige die Pseudocodes für die wesentlichen Teile der Implementierung.

Lösung:

CUDA-Programmierung für GPUsCUDA von NVIDIA erlaubt es, GPUs für parallele Berechnungen zu programmieren. Dabei wird eine Erweiterung der Programmiersprache C/C++ verwendet. Einige wichtige Konzepte und Funktionen in CUDA sind:

  • Kernel-Funktionen: Diese Funktionen werden auf der GPU ausgeführt.
  • Threads, Blöcke und Grids: Threads sind die kleinsten Berechnungseinheiten, die in Blöcken und Grids organisiert sind.
  • Speicherhierarchie: Es gibt verschiedene Speicherarten wie Register, Shared Memory und Global Memory.
  • Synchronisation: Mit __syncthreads() können Threads innerhalb eines Blocks synchronisiert werden.
  • Performance-Optimierung: Durch koaleszierten Speicherzugriff und die Verwendung von Shared Memory kann die Leistung verbessert werden.

Aufgabe: Gegeben seien zwei Arrays mit je einer Million Fließkommazahlen. Entwickle eine CUDA-Strategie, die sowohl die Synchronisation als auch die Speicherhierarchie optimiert, um die folgenden Operationen effizient durchzuführen:

  • Berechnung der Summe der beiden Arrays.
  • Elementweise Multiplikation der Arrays.
Erkläre, wie du die Threads, Blöcke und Speicherarten zuweisen würdest, und zeige die Pseudocodes für die wesentlichen Teile der Implementierung.

Lösung:

Für die gegebenen Aufgaben (Summation und Multiplikation der Arrays) können wir die Threads und Blöcke so organisieren, dass wir die Speicherhierarchie und Synchronisation optimieren. Wir werden folgende Schritte durchführen:

  • Benutze koaleszierten Speicherzugriff, indem wir sicherstellen, dass benachbarte Threads auf benachbarte Speicheradressen zugreifen.
  • Verwende Shared Memory, um häufig genutzte Daten zwischen den Threads innerhalb eines Blocks zu teilen und schnelle Zugriffe zu ermöglichen.
  • Nutzt Synchronisation innerhalb eines Blocks, um sicherzustellen, dass alle Threads ihre Berechnungen abgeschlossen haben, bevor gemeinsame Daten verwendet werden.

Im Folgenden zeigen wir die Pseudocodes für die CUDA-Implementierung der Summation und Multiplikation von zwei Arrays:

Pseudocode für die Summation von zwei Arrays

__global__ void sumArrays(float *a, float *b, float *c, int size) {    extern __shared__ float sharedData[];    int tid = blockIdx.x * blockDim.x + threadIdx.x;    int local_tid = threadIdx.x;    if (tid < size) {        sharedData[local_tid] = a[tid] + b[tid];        __syncthreads();        c[tid] = sharedData[local_tid];    }}

Pseudocode für die elementweise Multiplikation von zwei Arrays

__global__ void multiplyArrays(float *a, float *b, float *c, int size) {    extern __shared__ float sharedData[];    int tid = blockIdx.x * blockDim.x + threadIdx.x;    int local_tid = threadIdx.x;    if (tid < size) {        sharedData[local_tid] = a[tid] * b[tid];        __syncthreads();        c[tid] = sharedData[local_tid];    }}

Erklärung der Organisation:

  • Wahl der Threads und Blöcke:Wir verwenden eine Blockgröße von 256 oder 512 Threads, was eine übliche Wahl für gute Leistung ist. Die Anzahl der Blöcke wird dann so berechnet, dass sie die Größe des Arrays abdeckt: int gridSize = (size + blockSize - 1) / blockSize;.
  • Koaleszierter Speicherzugriff:Durch die Zuweisung der Speicheradressen in der Reihenfolge von tid stellen wir sicher, dass benachbarte Threads auf benachbarte Speicherpositionen im Global Memory zugreifen, was den Speicherzugriff coalesziert und somit schneller macht.
  • Verwendung von Shared Memory:Wir verwenden Shared Memory, um die Ergebnisse der Addition/Multiplikation temporär zu speichern. Dies ermöglicht schnellen Zugriff und reduziert die Notwendigkeit für wiederholte Zugriffe auf das langsamere Global Memory.
  • Synchronisation:Die Funktion __syncthreads() stellt sicher, dass alle Threads innerhalb eines Blocks ihre Berechnung abgeschlossen haben, bevor sie auf die Daten im Shared Memory zugreifen. Dies verhindert Datenrennen und stellt korrekte Ergebnisse sicher.

Durch diese Optimierungen wird die Rechenzeit signifikant reduziert, indem wir die Speicherhierarchie und Synchronisation effektiv nutzen.

Sign Up

Melde dich kostenlos an, um Zugriff auf das vollständige Dokument zu erhalten

Mit unserer kostenlosen Lernplattform erhältst du Zugang zu Millionen von Dokumenten, Karteikarten und Unterlagen.

Kostenloses Konto erstellen

Du hast bereits ein Konto? Anmelden