Das Parallelisieren von zufälligen Lesevorgängen scheint gut zu funktionieren - warum?


18

Betrachten Sie das folgende sehr einfache Computerprogramm:

for i = 1 to n:
    y[i] = x[p[i]]

Hier und sind -Glied Arrays von Bytes, und ist eine -Glied Array von Wörtern. Hier ist groß, z. B. (so dass nur ein vernachlässigbarer Teil der Daten in einen beliebigen Cache-Speicher passt).xynpnnn=231

Angenommen, besteht aus Zufallszahlen , die gleichmäßig zwischen und .p1n

Aus Sicht der modernen Hardware sollte dies Folgendes bedeuten:

  • Lesen von ist billig (sequentielles Lesen)p[i]
  • Lesen ist sehr teuer (random liest, fast alle liest Cache - Misses sind, wir werden jedes einzelne Byte aus dem Hauptspeicher zu holen haben)x[p[i]]
  • Schreiben ist billig (sequentielles Schreiben).y[i]

Und das ist in der Tat, was ich beobachte. Das Programm ist im Vergleich zu einem Programm, das nur sequentielles Lesen und Schreiben ausführt, sehr langsam. Groß.

Nun stellt sich die Frage: Wie gut passt dieses Programm auf moderne Multi-Core-Plattformen?


Meine Hypothese war, dass dieses Programm nicht gut parallelisiert. Schließlich ist der Engpass der Hauptspeicher. Ein einzelner Kern verschwendet bereits die meiste Zeit damit, auf einige Daten aus dem Hauptspeicher zu warten.

Dies war jedoch nicht das, was ich beobachtete, als ich anfing, mit einigen Algorithmen zu experimentieren, bei denen der Engpass eine solche Operation war!

Ich habe einfach die naive for-Schleife durch eine OpenMP-Parallel-for-Schleife ersetzt (im Wesentlichen wird nur der Bereich [1,n] auf kleinere Teile aufgeteilt und diese Teile auf verschiedenen CPU-Kernen parallel ausgeführt).

Auf Low-End-Computern waren die Beschleunigungen in der Tat gering. Aber auf High-End-Plattformen war ich überrascht, dass ich ausgezeichnete nahezu lineare Beschleunigungen erhielt. Einige konkrete Beispiele (das genaue Timing kann ein bisschen abweichen, es gibt viele zufällige Variationen; dies waren nur schnelle Experimente):

  • 2 x 4-Core-Xeon (insgesamt 8 Kerne): Faktor 5-8-Beschleunigung im Vergleich zur Single-Threaded-Version.

  • 2 x 6-Core Xeon (insgesamt 12 Kerne): Faktor 8-14-Beschleunigung im Vergleich zur Single-Threaded-Version.

Das war völlig unerwartet. Fragen:

  1. Genau warum tut diese Art von Programm parallelisieren so gut ? Was passiert in der Hardware? (Meine derzeitige Vermutung geht in diese Richtung: Die zufälligen Lesevorgänge von verschiedenen Threads sind "pipelined" und die durchschnittliche Rate, mit der Antworten auf diese Fragen erhalten werden, ist viel höher als im Fall eines einzelnen Threads.)

  2. x[p[i]]x[p[i+1]]

  3. Was ist das richtige theoretische Modell , mit dem wir diese Art von Programmen analysieren (und korrekte Vorhersagen über die Leistung treffen können)?


Bearbeiten: Hier sind nun einige Quellcode- und Benchmark-Ergebnisse verfügbar: https://github.com/suomela/parallel-random-read

n=232

  • ca. 42 ns pro Iteration (zufälliges Lesen) mit einem einzelnen Thread
  • ca. 5 ns pro Iteration (zufälliges Lesen) mit 12 Kernen.

Antworten:


9

pnpnpp

Lassen Sie uns nun die Speicherprobleme berücksichtigen. Die superlineare Beschleunigung, die Sie tatsächlich auf Ihrem High-End-Xeon-basierten Knoten beobachtet haben, ist wie folgt begründet.

nn/pp

n=231

n

Schließlich ist mir neben QSM (Queuing Shared Memory) kein anderes theoretisches Parallelmodell bekannt, das auf derselben Ebene den Konflikt um den Zugriff auf Shared Memory berücksichtigt (in Ihrem Fall wird bei Verwendung von OpenMP der Hauptspeicher von den Kernen gemeinsam genutzt) und der Cache wird immer auch von den Kernen gemeinsam genutzt). Obwohl das Modell interessant ist, hat es keinen großen Erfolg erzielt.


1
Es kann auch hilfreich sein, dies so zu betrachten, dass jeder Kern eine mehr oder weniger feste Menge an Parallelität auf Speicherebene bereitstellt, z. B. 10 x [] Ladevorgänge zu einem bestimmten Zeitpunkt. Bei einer Wahrscheinlichkeit von 0,5% eines Treffers in gemeinsam genutztem L3 hätte ein einzelner Thread eine Wahrscheinlichkeit von 0,995 * 10 (95 +%), dass alle diese Ladevorgänge auf eine Hauptspeicherantwort warten müssen. Bei 6 Kernen mit insgesamt 60 x [] ausstehenden Lesevorgängen besteht eine Wahrscheinlichkeit von fast 26%, dass mindestens ein Lesevorgang in L3 ausgeführt wird. Je mehr MLP vorhanden ist, desto mehr kann der Speichercontroller Zugriffe planen, um die tatsächliche Bandbreite zu erhöhen.
Paul A. Clayton

5

Ich habe beschlossen, __builtin_prefetch () selbst auszuprobieren. Ich poste es hier als Antwort, falls andere es auf ihren Rechnern testen möchten. Die Ergebnisse liegen in der Nähe dessen, was Jukka beschreibt: Ungefähr 20% weniger Laufzeit beim Vorauslesen von 20 Elementen gegenüber dem Vorauslesen von 0 Elementen.

Ergebnisse:

prefetch =   0, time = 1.58000
prefetch =   1, time = 1.47000
prefetch =   2, time = 1.39000
prefetch =   3, time = 1.34000
prefetch =   4, time = 1.31000
prefetch =   5, time = 1.30000
prefetch =   6, time = 1.27000
prefetch =   7, time = 1.28000
prefetch =   8, time = 1.26000
prefetch =   9, time = 1.27000
prefetch =  10, time = 1.27000
prefetch =  11, time = 1.27000
prefetch =  12, time = 1.30000
prefetch =  13, time = 1.29000
prefetch =  14, time = 1.30000
prefetch =  15, time = 1.28000
prefetch =  16, time = 1.24000
prefetch =  17, time = 1.28000
prefetch =  18, time = 1.29000
prefetch =  19, time = 1.25000
prefetch =  20, time = 1.24000
prefetch =  19, time = 1.26000
prefetch =  18, time = 1.27000
prefetch =  17, time = 1.26000
prefetch =  16, time = 1.27000
prefetch =  15, time = 1.28000
prefetch =  14, time = 1.29000
prefetch =  13, time = 1.26000
prefetch =  12, time = 1.28000
prefetch =  11, time = 1.30000
prefetch =  10, time = 1.31000
prefetch =   9, time = 1.27000
prefetch =   8, time = 1.32000
prefetch =   7, time = 1.31000
prefetch =   6, time = 1.30000
prefetch =   5, time = 1.27000
prefetch =   4, time = 1.33000
prefetch =   3, time = 1.38000
prefetch =   2, time = 1.41000
prefetch =   1, time = 1.41000
prefetch =   0, time = 1.59000

Code:

#include <stdlib.h>
#include <time.h>
#include <stdio.h>

void cracker(int *y, int *x, int *p, int n, int pf) {
    int i;
    int saved = pf;  /* let compiler optimize address computations */

    for (i = 0; i < n; i++) {
        __builtin_prefetch(&x[p[i+saved]]);
        y[i] += x[p[i]];
    }
}

int main(void) {
    int n = 50000000;
    int *x, *y, *p, i, pf, k;
    clock_t start, stop;
    double elapsed;

    /* set up arrays */
    x = malloc(sizeof(int)*n);
    y = malloc(sizeof(int)*n);
    p = malloc(sizeof(int)*n);
    for (i = 0; i < n; i++)
        p[i] = rand()%n;

    /* warm-up exercise */
    cracker(y, x, p, n, pf);

    k = 20;
    for (pf = 0; pf < k; pf++) {
        start = clock();
        cracker(y, x, p, n, pf);
        stop = clock();
        elapsed = ((double)(stop-start))/CLOCKS_PER_SEC;
        printf("prefetch = %3d, time = %.5lf\n", pf, elapsed);
    }
    for (pf = k; pf >= 0; pf--) {
        start = clock();
        cracker(y, x, p, n, pf);
        stop = clock();
        elapsed = ((double)(stop-start))/CLOCKS_PER_SEC;
        printf("prefetch = %3d, time = %.5lf\n", pf, elapsed);
    }

    return 0;
}

4
  1. DDR3-Zugang ist in der Tat Pipeline. http://www.eng.utah.edu/~cs7810/pres/dram-cs7810-protocolx2.pdf Die Folien 20 und 24 zeigen, was im Speicherbus während der Pipeline-Leseoperationen passiert.

  2. (teilweise falsch, siehe unten) Mehrere Threads sind nicht erforderlich, wenn die CPU-Architektur den Cache-Prefetch unterstützt. Moderne x86- und ARM-Architekturen sowie viele andere Architekturen verfügen über eine explizite Prefetch-Anweisung. Viele versuchen außerdem, Muster bei Speicherzugriffen zu erkennen und das Prefetching automatisch durchzuführen. Die Softwareunterstützung ist compilerspezifisch, zum Beispiel haben GCC und Clang __builtin_prefech () für das explizite Prefetching.

Hyperthreading im Intel-Stil scheint sehr gut für Programme zu funktionieren, die die meiste Zeit auf Cache-Fehler warten. Nach meiner Erfahrung liegt die Beschleunigung bei einer rechenintensiven Arbeitsbelastung kaum über der Anzahl der physischen Kerne.

EDIT: Ich habe mich in Punkt 2 geirrt. Während Prefetching den Speicherzugriff für einen einzelnen Kern optimieren kann, ist die kombinierte Speicherbandbreite mehrerer Kerne größer als die Bandbreite eines einzelnen Kerns. Wie viel größer, hängt von der CPU ab.

Der Hardware Prefetcher und andere Optimierungen zusammen machen das Benchmarking sehr schwierig. Es ist möglich, Fälle zu konstruieren, in denen explizites Prefetching eine sehr sichtbare oder nicht vorhandene Auswirkung auf die Leistung hat, wobei dieser Maßstab einer der letzteren ist.


__builtin_prefech klingt sehr vielversprechend. Leider schien es in meinen schnellen Experimenten bei der Single-Thread-Leistung nicht viel zu helfen (<10%). Mit wie großen Geschwindigkeitsverbesserungen sollte ich bei dieser Art von Anwendung rechnen?
Jukka Suomela

Ich habe mehr erwartet. Da ich weiß, dass Prefetch in DSP und Spielen erhebliche Auswirkungen hat, musste ich selbst experimentieren. Es stellte sich heraus, dass das Kaninchenloch tiefer geht ...
Juhani Simola

Mein erster Versuch bestand darin, eine feste zufällige Reihenfolge zu erstellen, die in einem Array gespeichert war, und dann mit und ohne Prefetch ( gist.github.com/osimola/7917602 ) in dieser Reihenfolge zu iterieren . Das brachte einen Unterschied von rund 2% auf einem Core i5. Klingt so, als ob der Prefetch überhaupt nicht funktioniert oder der Hardware Predictor die Indirektion versteht.
Juhani Simola

1
Um dies zu überprüfen, greift der zweite Versuch ( gist.github.com/osimola/7917568 ) nacheinander auf den Speicher zu, der von einem festen zufälligen Startwert generiert wird. Diesmal war die Vorabrufversion ungefähr zweimal so schnell wie das Nicht-Vorabrufen und dreimal schneller als das Vorabrufen um einen Schritt voraus. Beachten Sie, dass die Vorabrufversion mehr Berechnungen pro Speicherzugriff ausführt als die Nicht-Vorabrufversion.
Juhani Simola

Dies scheint maschinenabhängig zu sein. Ich habe den folgenden Code von Pat Morin ausprobiert (kann diesen Beitrag nicht kommentieren, da ich nicht den Ruf habe) und mein Ergebnis liegt bei verschiedenen Prefetch-Werten innerhalb von 1,3%.
Juhani Simola
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.