Le letture casuali parallele sembrano funzionare bene - perché?


18

Considera il seguente programma per computer molto semplice:

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

Qui ed y sono n matrici -element di byte, e p è un n matrice -element di parole. Qui n è grande, ad esempio n = 2 31 (in modo che solo una minima parte dei dati si adatti a qualsiasi tipo di memoria cache).Xynpnnn=231

Supponiamo che costituito da numeri casuali , distribuiti uniformemente tra 1 e n .p1n

Dal punto di vista dell'hardware moderno, ciò dovrebbe significare quanto segue:

  • leggere è economico (lettura sequenziale)p[io]
  • leggere è molto costoso (letture casuali; quasi tutte le letture sono mancate cache; dovremo recuperare ogni singolo byte dalla memoria principale)X[p[io]]
  • scrivere è economico (scrittura sequenziale).y[io]

E questo è davvero ciò che sto osservando. Il programma è molto lento rispetto a un programma che esegue solo letture e scritture sequenziali. Grande.

Ora arriva la domanda: in che misura questo programma si parallelizza sulle moderne piattaforme multi-core?


La mia ipotesi era che questo programma non si parallelizza bene. Dopotutto, il collo di bottiglia è la memoria principale. Un singolo core sta già perdendo la maggior parte del suo tempo in attesa di alcuni dati dalla memoria principale.

Tuttavia, questo non era quello che ho osservato quando ho iniziato a sperimentare alcuni algoritmi in cui il collo di bottiglia era questo tipo di operazione!

Ho semplicemente sostituito il for-loop ingenuo con un for-loop parallelo OpenMP (in sostanza, dividerà semplicemente l'intervallo in parti più piccole ed eseguirà queste parti su diversi core della CPU in parallelo).[1,n]

Sui computer di fascia bassa, le accelerazioni erano davvero minori. Ma su piattaforme di fascia alta sono rimasto sorpreso dal fatto che stavo ottenendo eccellenti accelerazioni quasi lineari. Alcuni esempi concreti (i tempi esatti potrebbero essere un po 'fuori, ci sono molte variazioni casuali; questi erano solo esperimenti rapidi):

  • 2 xeon a 4 core (in totale 8 core): fattore 5-8 accelerazioni rispetto alla versione a thread singolo.

  • Xeon 2 x 6 core (in totale 12 core): fattore di velocità 8-14 rispetto alla versione a thread singolo.

Questo era del tutto inaspettato. Domande:

  1. Perché proprio questo tipo di programma si parallelizza così bene ? Cosa succede nell'hardware? (La mia ipotesi attuale è qualcosa del genere: le letture casuali da thread diversi sono "pipeline" e il tasso medio di ottenere risposte a questi è molto più alto che nel caso di un singolo thread.)

  2. È necessario utilizzare più thread e più core per ottenere eventuali accelerazioni? Se un certo tipo di pipelining si verifica effettivamente nell'interfaccia tra la memoria principale e la CPU, un'applicazione a thread singolo non potrebbe far sapere alla memoria principale che presto avrà bisogno di , x [ p [ i + 1 ] ] , ... e il computer potrebbe iniziare a recuperare le relative linee di cache dalla memoria principale? Se ciò è possibile in linea di principio, come posso realizzarlo in pratica?X[p[io]]X[p[io+1]]

  3. Qual è il modello teorico giusto che potremmo usare per analizzare questo tipo di programmi (e fare previsioni corrette delle prestazioni)?


Modifica: ora sono disponibili alcuni codici sorgente e risultati di benchmark disponibili qui: https://github.com/suomela/parallel-random-read

Alcuni esempi di figure del campo da baseball ( ):n=232

  • circa. 42 ns per iterazione (lettura casuale) con un singolo thread
  • circa. 5 ns per iterazione (lettura casuale) con 12 core.

Risposte:


9

pnpnpp

Ora prendiamo in considerazione i problemi di memoria. Lo speedup super-lineare che hai effettivamente osservato sul tuo nodo basato su Xeon di fascia alta è giustificato come segue.

nn/pp

n=231

n

Infine, oltre a QSM (Queuing Shared Memory) , non sono a conoscenza di nessun altro modello parallelo teorico che tenga conto allo stesso livello della contesa per l'accesso alla memoria condivisa (nel tuo caso, quando usi OpenMP la memoria principale è condivisa tra i core e la cache è sempre condivisa anche tra i core). Comunque, anche se il modello è interessante, non ha ottenuto un grande successo.


1
Può anche essere utile considerare questo dato in quanto ciascun core fornisce una quantità più o meno fissa di parallelismo a livello di memoria, ad esempio 10 x [] carichi in corso in un determinato momento. Con una probabilità dello 0,5% di un hit in L3 condiviso, un singolo thread avrebbe una probabilità di 0,995 ** 10 (95 +%) di richiedere a tutti quei carichi di attendere una risposta della memoria principale. Con 6 core che forniscono un totale di 60 x [] letture in sospeso, c'è quasi una probabilità del 26% che almeno una lettura colpirà in L3. Inoltre, più MLP è tanto più il controller di memoria può programmare gli accessi per aumentare la larghezza di banda effettiva.
Paul A. Clayton,

5

Ho deciso di provare __builtin_prefetch () da solo. Lo sto postando qui come risposta nel caso in cui altri vogliano testarlo sui loro computer. I risultati sono simili a quelli descritti da Jukka: circa una riduzione del 20% del tempo di esecuzione durante il prefetch di 20 elementi in anticipo rispetto al prefetch di 0 elementi in anticipo.

risultati:

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

Codice:

#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. L'accesso DDR3 è infatti pipeline. http://www.eng.utah.edu/~cs7810/pres/dram-cs7810-protocolx2.pdf le diapositive 20 e 24 mostrano cosa succede nel bus di memoria durante le operazioni di lettura in pipeline.

  2. (parzialmente errato, vedi sotto) Più thread non sono necessari se l'architettura della CPU supporta il prefetch della cache. Le moderne architetture x86 e ARM e molte altre architetture hanno un'istruzione di prefetch esplicita. Molti tentano inoltre di rilevare schemi negli accessi alla memoria e di eseguire automaticamente il prefetch. Il supporto software è specifico del compilatore, ad esempio GCC e Clang hanno __builtin_prefech () intrinseco per il prefetching esplicito.

L'hyperthreading in stile Intel sembra funzionare molto bene per i programmi che trascorrono la maggior parte del loro tempo in attesa di errori nella cache. Nella mia esperienza, nel carico di lavoro intensivo di calcolo l'accelerazione supera di poco il numero di core fisici.

EDIT: ho sbagliato nel punto 2. Sembra che mentre il prefetching possa ottimizzare l'accesso alla memoria per single core, la larghezza di banda della memoria combinata di più core è maggiore della larghezza di banda del single core. Quanto maggiore, dipende dalla CPU.

Il prefetcher hardware e altre ottimizzazioni insieme rendono molto difficile il benchmarking. È possibile costruire casi in cui il prefetching esplicito ha un effetto molto visibile o inesistente sulle prestazioni, essendo questo benchmark uno di questi.


__builtin_prefech sembra molto promettente. Sfortunatamente, nei miei rapidi esperimenti non mi è sembrato molto utile con le prestazioni a thread singolo (<10%). Quanto grandi miglioramenti di velocità dovrei aspettarmi in questo tipo di applicazione?
Jukka Suomela,

Mi aspettavo di più. Poiché so che il prefetch ha un effetto significativo su DSP e giochi, ho dovuto sperimentare me stesso. Si è scoperto che la tana del coniglio diventa più profonda ...
Juhani Simola,

Il mio primo tentativo è stato quello di creare un ordine casuale fisso memorizzato in un array, quindi iterare in quell'ordine con e senza prefetch ( gist.github.com/osimola/7917602 ). Ciò ha comportato una differenza di circa il 2% su un Core i5. Sembra che il prefetch non funzioni affatto o che il predittore hardware capisca il riferimento indiretto.
Juhani Simola,

1
Quindi, testando ciò, il secondo tentativo ( gist.github.com/osimola/7917568 ) accede alla memoria in sequenza generata da un seme casuale fisso. Questa volta, la versione di prefetching era circa 2 volte più veloce di quella non prefetch e 3 volte più veloce rispetto al prefetch 1 passo avanti. Si noti che la versione di prefetch esegue più calcoli per accesso alla memoria rispetto alla versione senza prefetch.
Juhani Simola,

Questo sembra dipendere dalla macchina. Ho provato il codice di Pat Morin qui sotto (non posso commentare quel post poiché non ho la reputazione) e il mio risultato è all'interno dell'1,3% per diversi valori di prefetch.
Juhani Simola,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.