Parallelität auf Schleifenebene

Parallelität auf Schleifenebene ist eine Form von Parallelität in Softwareprogrammierung Das befasst sich mit der Extraktion paralleler Aufgaben aus Schleifen. Die Möglichkeit für die Parallelität auf Schleifenebene ergibt sich häufig in Computerprogrammen, bei denen Daten gespeichert sind Zufallszugriff Datenstrukturen. Wenn ein sequentielles Programm die Datenstruktur iteriert und einzeln auf Indizes arbeitet, wird ein Programm, das Parallelität auf Schleifenebene ausnutzt Themen oder Prozesse die gleichzeitig auf einigen oder allen Indizes arbeiten. Eine solche Parallelität liefert a beschleunigen zur allgemeinen Ausführungszeit des Programms, in der Regel entsprechend mit Amdahls Gesetz.

Beschreibung

Für einfache Schleifen, bei denen jede Iteration unabhängig von den anderen ist, kann die Parallelität auf Schleifenebene sein peinlich parallel, da die Parallelisierung nur einen Prozess zugewiesen werden muss, um jede Iteration zu verarbeiten. Viele Algorithmen sind jedoch so konzipiert, dass sie nacheinander ausgeführt werden und bei parallele Prozessen fehlschlagen Rennen aufgrund der Abhängigkeit innerhalb des Code. Sequentielle Algorithmen gelten manchmal für parallele Kontexte mit geringfügiger Modifikation. Normalerweise benötigen sie jedoch Prozesssynchronisation. Die Synchronisation kann entweder implizit sein, über Nachrichtenübergangoder explizit über Synchronisation Primitive wie Semaphoren.

Beispiel

Betrachten Sie den folgenden Code, der auf einer Liste arbeitet L von Länge n.

zum (int i = 0; i < n; ++i) {   S1: L[i] += 10; } 

Jede Iteration der Schleife nimmt den Wert aus dem aktuellen Index von Lund erhöht es um 10. Wenn Anweisung S1 nimmt T Zeit zum Ausführen, dann braucht die Schleife Zeit n * t Um nacheinander auszuführen, ignoriert das Ignorieren der Zeit durch Schleifenkonstrukte. Betrachten Sie nun ein System mit p Prozessoren wo p> n. Wenn n Threads werden parallel ausgeführt, die Zeit, um alle auszuführen n Schritte werden auf reduziert auf T.

Weniger einfache Fälle erzeugen inkonsistent, d.h. nicht serialisierbar Ergebnisse. Betrachten Sie die folgende Schleife auf derselben Liste L.

zum (int i = 1; i < n; ++i) {   S1: L[i] = L[i-1] + 10; } 

Jede Iteration legt den aktuellen Index auf den Wert der vorherigen plus zehn fest. Wenn nacheinander ausgeführt wird, wird jede Iteration garantiert, dass die vorherige Iteration bereits den richtigen Wert hat. Mit mehreren Threads, Prozessplanung und andere Überlegungen verhindern, dass die Ausführungsreihenfolge eine Iteration garantiert, die nur dann ausgeführt wird, nachdem ihre Abhängigkeit erfüllt ist. Es kann schon sehr gut passieren, was zu unerwarteten Ergebnissen führt. Die Serialisierbarkeit kann wiederhergestellt werden, indem die Synchronisation hinzugefügt wird, um die Abhängigkeit von früheren Iterationen zu erhalten.

Abhängigkeiten im Code

Es gibt verschiedene Arten von Abhängigkeiten, die im Code gefunden werden können.[1][2]

Typ Notation Beschreibung
Wahre (fließende) Abhängigkeit S1 -> T S2 Eine wahre Abhängigkeit zwischen S1 und S2 bedeutet, dass S1 an einen Ort schreibt, der später von S2 gelesen wird
Anti -Abhängigkeit S1 -> A S2 Eine Anti-Abhängigkeit zwischen S1 und S2 bedeutet, dass S1 von einem Ort, an dem später S2 geschrieben wurde, von S1 liest.
Ausgangsabhängigkeit S1 -> o S2 Eine Ausgangsabhängigkeit zwischen S1 und S2 bedeutet, dass S1 und S2 an denselben Ort schreiben.
Eingabeabhängigkeit S1 -> I S2 Eine Eingangsabhängigkeit zwischen S1 und S2 bedeutet, dass S1 und S2 aus demselben Ort lesen.

Um das sequentielle Verhalten einer Schleife bei paralleler Ausführung zu erhalten, muss die wahre Abhängigkeit erhalten bleiben. Anti-Abhängigkeit und Ausgangsabhängigkeit können behandelt werden, indem jeder Prozesse eine eigene Kopie von Variablen (als Privatisierung bezeichnet) verleiht.[1]

Beispiel für wahre Abhängigkeit

S1: int a, b; S2: a = 2; S3: b = a + 40; 

S2 -> T S3, was bedeutet, dass S2 eine echte Abhängigkeit von S3 hat, weil S2 in die Variable schreibt a, aus der S3 liest.

Beispiel für Anti-Abhängigkeit

S1: int a, b = 40; S2: a = b - 38; S3: b = -1; 

S2 -> a s3, was bedeutet, dass S2 gegen S3 eine Anti-Abhängigkeit hat, weil S2 aus der Variablen liest b Bevor S3 darauf schreibt.

Beispiel für Ausgangsabhängigkeit

S1: int a, b = 40; S2: a = b - 38; S3: a = 2; 

S2 -> o S3bedeutet, dass S2 eine Ausgangsabhängigkeit von S3 hat, da beide in die Variable schreiben a.

Beispiel für Eingabeabhängigkeit

S1: int a, b, c = 2; S2: a = c - 1; S3: b = c + 1; 

S2 -> I S3, was bedeutet, dass S2 eine Eingabeabhängigkeit von S3 hat, da S2 und S3 beide aus der Variablen lesen c.

Abhängigkeit in Schleifen

Schleifenverwalter vs Loop-unabhängige Abhängigkeit

Schleifen können zwei Arten von Abhängigkeit haben:

  • Abhängige Schleife
  • Loop-unabhängige Abhängigkeit

In der abhängigen Abhängigkeit von Schleifen haben Schleifen die Abhängigkeit zwischen der Referenz, haben jedoch keine Abhängigkeit zwischen Iterationen. Jede Iteration kann als Block behandelt und parallel ohne andere Synchronisierungsbemühungen durchgeführt werden.

Im folgenden Beispielcode, der zum Austausch der Werte von zwei Längenarray n verwendet wird S1 -> T S3.

zum (int i = 1; i < n; ++i) {   S1: TMP = a[i];   S2: a[i] = b[i];   S3: b[i] = TMP; } 

In der abhängigen Schleife hängen Aussagen in einer Iteration einer Schleife von Aussagen in einer anderen Iteration der Schleife ab. Die abhängige Abhängigkeit von Schleifen verwendet eine modifizierte Version der zuvor gesehenen Abhängigkeitsnotation.

Beispiel für eine abhängige abhängige, wo anschließende abhängige Abhängigkeit, wo S1 [i] -> T S1 [i + 1], wo i zeigt die aktuelle Iteration an und i + 1 zeigt die nächste Iteration an.

zum (int i = 1; i < n; ++i) {   S1: a[i] = a[i-1] + 1; } 

Schleife beförderte Abhängigkeitsgraphen

Eine abhängige Abhängigkeitsgrafik von Loops zeigt grafisch die abhängigen Abhängigkeiten zwischen Iterationen. Jede Iteration ist als Knoten im Diagramm aufgeführt, und die gerichteten Kanten zeigen die wahren, Anti- und Ausgangsabhängigkeiten zwischen jeder Iteration.

Typen

Es gibt eine Vielzahl von Methoden zum parallelisierenden Schleifen.

  • Verteilte Schleife
  • DOALL -Parallelität
  • DOACROSS -Parallelität
  • WENDEL [3]
  • Dopipe -Parallelität

Jede Implementierung variiert geringfügig in der Synchronisierung von Tägern, wenn überhaupt. Darüber hinaus müssen parallele Aufgaben irgendwie auf einen Prozess abgebildet werden. Diese Aufgaben können entweder statisch oder dynamisch zugewiesen werden. Untersuchungen haben gezeigt, dass das Lastausgleich durch einige dynamische Allokationsalgorithmen besser erreicht werden kann als bei statendem.[4]

Der Prozess der Parallelisierung eines sequentiellen Programms kann in die folgenden diskreten Schritte unterteilt werden.[1] Jede konkrete Loop-Parallelisierung unten führt sie implizit durch.

Typ Beschreibung
Zersetzung Das Programm ist in Aufgaben unterteilt, die kleinste Ausbeutungseinheit der Übereinstimmung.
Abtretung Aufgaben werden Prozessen zugeordnet.
Orchestrierung Datenzugriff, Kommunikation und Synchronisation von Prozessen.
Kartierung Prozesse sind an Prozessoren gebunden.

Verteilte Schleife

Wenn eine Schleife eine abhängige Schleife hat, besteht eine Möglichkeit, die Schleife in mehrere verschiedene Schleifen zu verteilen. Aussagen, die nicht voneinander abhängig sind, sind getrennt, damit diese verteilten Schleifen parallel ausgeführt werden können. Betrachten Sie beispielsweise den folgenden Code.

zum (int i = 1; i < n; ++i) {   S1: a[i] = a[i-1] + b[i];   S2: c[i] += d[i]; } 

Die Schleife hat eine abhängige Schleife S1 [i] -> T S1 [i+1] S2 und S1 haben jedoch keine schleifenunabhängige Abhängigkeit, sodass wir den Code wie folgt umschreiben können.

Loop1: zum (int i = 1; i < n; ++i) {   S1: a[i] = a[i-1] + b[i]; } Loop2: zum (int i = 1; i < n; ++i) {   S2: c[i] += d[i]; } 

Beachten Sie, dass nun Loop1 und Loop2 parallel ausgeführt werden können. Anstatt dass eine einzelne Anweisung parallel zu verschiedenen Daten wie in der Datenebene parallelistisch durchgeführt wird, führen hier verschiedene Schleifen unterschiedliche Aufgaben für verschiedene Daten aus. Nehmen wir an, die Ausführung von S1 und S2 seien und Dann ist die Ausführungszeit für die sequentielle Form des obigen Code , Jetzt, weil wir die beiden Aussagen geteilt und in zwei verschiedene Schleifen gestellt haben, gibt uns eine Ausführungszeit von . Wir nennen diese Art von Parallelität entweder Funktion oder Aufgabeparallelität.

DOALL -Parallelität

Die DOALL-Parallelität besteht, wenn Aussagen innerhalb einer Schleife unabhängig ausgeführt werden können (Situationen, in denen keine abhängige Schleife vorliegt).[1] Zum Beispiel wird der folgende Code nicht aus dem Array gelesen aund aktualisiert die Arrays nicht B, c. Keine Iterationen haben eine Abhängigkeit von einer anderen Iteration.

zum (int i = 0; i < n; ++i) {   S1: a[i] = b[i] + c[i]; } 

Nehmen wir an, die Zeit einer Ausführung von S1 sei Dann ist die Ausführungszeit für die sequentielle Form des obigen Code , Jetzt, weil die DOALL-Parallelität existiert, wenn alle Iterationen unabhängig sind, kann die Beschleunigung erreicht werden, indem alle Iterationen parallel ausgeführt werden, was uns eine Ausführungszeit von , die Zeit für eine Iteration in der sequentiellen Ausführung.

Das folgende Beispiel zeigt mit einem vereinfachten Pseudocode, wie eine Schleife parallelisiert werden kann, um jede Iteration unabhängig auszuführen.

begin_parallelismisation(); zum (int i = 0; i < n; ++i) {   S1: a[i] = b[i] + c[i];   End_Parallelism(); } Block(); 

DOACROSS -Parallelität

Die Parallelität von Doacross besteht, wo die Iterationen einer Schleife parallelisiert werden, indem Berechnungen extrahiert werden, die unabhängig durchgeführt werden können und gleichzeitig ausgeführt werden.[5]

Die Synchronisation besteht zur Durchsetzung der abhängigen Abhängigkeit.

Betrachten Sie die folgende Synchronschleife mit Abhängigkeit S1 [i] -> T S1 [i+1].

zum (int i = 1; i < n; ++i) {   a[i] = a[i-1] + b[i] + 1; } 

Jede Schleife -Iteration führt zwei Aktionen aus

  • Berechnung a [i-1] + b [i] + 1
  • Weisen Sie den Wert zu A [i]

Berechnung des Wertes a [i-1] + b [i] + 1und dann kann die Zuordnung in zwei Zeilen zerlegt werden (Aussagen S1 und S2):

S1: int TMP = b[i] + 1; S2: a[i] = a[i-1] + TMP; 

Die erste Zeile, int tmp = b [i] + 1;, hat keine abhängige abhängige Schleife. Die Schleife kann dann parallelisiert werden, indem der Temperaturwert parallel berechnet und dann die Zuordnung zu synchronisiert wird A [i].

Post(0); zum (int i = 1; i < n; ++i) {   S1: int TMP = b[i] + 1;   Warten(i-1);   S2: a[i] = a[i-1] + TMP;   Post(i); } 

Nehmen wir an, die Ausführung von S1 und S2 seien und Dann ist die Ausführungszeit für die sequentielle Form des obigen Code , Jetzt, weil die Parallelität von Doacross existiert, kann die Geschwindigkeit erreicht werden .

Dopipe -Parallelität

Dopipe-Parallelism implementiert eine Pipel-Parallelität für die abhängige Schleife, bei der eine Schleife-Iteration über mehrere synchronisierte Schleifen verteilt wird.[1] Das Ziel von Dopipe ist es, sich wie eine Montagelinie zu verhalten, bei der eine Stufe begonnen wird, sobald ausreichend Daten für sie aus der vorherigen Phase verfügbar sind.[6]

Betrachten Sie die folgenden Synchroncode mit Abhängigkeit S1 [i] -> T S1 [i+1].

zum (int i = 1; i < n; ++i) {   S1: a[i] = a[i-1] + b[i];   S2: c[i] += a[i]; } 

S1 muss nacheinander ausgeführt werden, aber S2 hat keine abhängige abhängige abhängige. S2 könnte parallel unter Verwendung von DOALL -Parallelität ausgeführt werden, nachdem alle von S1 in Serie benötigten Berechnungen durchgeführt wurden. Die Beschleunigung ist jedoch begrenzt, wenn dies erledigt ist. Ein besserer Ansatz besteht darin, parallel zu parallelisieren, dass der S2, der jedem S1 entspricht, ausgeführt wird, wenn S1 fertig ist.

Die Implementierung von Pipelined -Parallelität führt in den folgenden Loops, wobei die zweite Schleife für einen Index ausführt, sobald die erste Schleife seinen entsprechenden Index beendet hat.

zum (int i = 1; i < n; ++i) {   S1: a[i] = a[i-1] + b[i];   Post(i); } zum (int i = 1; i < n; i++) {   Warten(i);   S2: c[i] += a[i]; } 

Nehmen wir an, die Ausführung von S1 und S2 seien und Dann ist die Ausführungszeit für die sequentielle Form des obigen Code , Jetzt, weil die Dopipe-Parallelität existiert, kann die Beschleunigung erreicht werden , wo p ist die Anzahl der Prozessor parallel.

Siehe auch

Verweise

  1. ^ a b c d e Solihin, Yan (2016). Grundlagen der parallelen Architektur. Boca Raton, FL: CRC Press. ISBN 978-1-4822-1118-4.
  2. ^ Goff, Gina (1991). "Praktische Abhängigkeitstests". Proceedings der ACM Sigplan 1991 -Konferenz zum Entwurf und der Implementierung von Programmiersprachen - PLDI '91. S. 15–29. doi:10.1145/113445.113448. ISBN 0897914287. S2CID 2357293.
  3. ^ Murphy, Niall. "Parallelität in Doacross Loops entdecken und ausnutzen" (PDF). Universität von Cambridge. Abgerufen 10. September 2016.
  4. ^ Kavi, Krishna. "Parallelisierung von Doall und Doacross Loops-A Survey". {{}}: Journal zitieren erfordert |journal= (Hilfe); Externer Link in |ref= (Hilfe)
  5. ^ Unnikrishnan, Priya (2012), "Ein praktischer Ansatz zur Parallelisierung von Doacross", Parallelverarbeitung von Euro-Par 2012, Vorlesungsnotizen in Informatik, Vol. 7484, S. 219–231, doi:10.1007/978-3-642-32820-6_23, ISBN 978-3-642-32819-0, S2CID 18571258
  6. ^ "Dopipe: Ein wirksamer Ansatz zur Parallelisierung der Simulation" (PDF). Intel. Abgerufen 13. September 2016.