Ho scritto questo codice SOR solver. Non preoccupatevi troppo di ciò che fa questo algoritmo, non è la preoccupazione qui. Ma solo per completezza: può risolvere un sistema lineare di equazioni, a seconda di quanto sia ben condizionato il sistema.Perché questo codice non scala in modo lineare?
Lo eseguo con una matrice sparso di righe 2097152 mal condizionate (che non converge mai), con al massimo 7 colonne diverse da zero per riga.
Traducendo: l'esterno do-while
ciclo eseguirà 10000 iterazioni (il valore passo come max_iters
), mezzo for
eseguirà 2097152 iterazioni, diviso in blocchi di work_line
, suddivisi tra i fili OpenMP. Il ciclo più interno di for
avrà 7 iterazioni, tranne in pochissimi casi (meno dell'1%) in cui può essere inferiore.
C'è una dipendenza tra i thread nei valori della matrice sol
. Ogni iterazione del mezzo for
aggiorna un elemento ma legge fino a 6 altri elementi dell'array. Poiché SOR non è un algoritmo esatto, durante la lettura, può avere qualsiasi valore precedente o attuale su quella posizione (se si ha familiarità con i risolutori, questo è un Gauss-Siedel che tollera il comportamento di Jacobi in alcuni punti per il bene di parallelismo).
typedef struct{
size_t size;
unsigned int *col_buffer;
unsigned int *row_jumper;
real *elements;
} Mat;
int work_line;
// Assumes there are no null elements on main diagonal
unsigned int solve(const Mat* matrix, const real *rhs, real *sol, real sor_omega, unsigned int max_iters, real tolerance)
{
real *coefs = matrix->elements;
unsigned int *cols = matrix->col_buffer;
unsigned int *rows = matrix->row_jumper;
int size = matrix->size;
real compl_omega = 1.0 - sor_omega;
unsigned int count = 0;
bool done;
do {
done = true;
#pragma omp parallel shared(done)
{
bool tdone = true;
#pragma omp for nowait schedule(dynamic, work_line)
for(int i = 0; i < size; ++i) {
real new_val = rhs[i];
real diagonal;
real residual;
unsigned int end = rows[i+1];
for(int j = rows[i]; j < end; ++j) {
unsigned int col = cols[j];
if(col != i) {
real tmp;
#pragma omp atomic read
tmp = sol[col];
new_val -= coefs[j] * tmp;
} else {
diagonal = coefs[j];
}
}
residual = fabs(new_val - diagonal * sol[i]);
if(residual > tolerance) {
tdone = false;
}
new_val = sor_omega * new_val/diagonal + compl_omega * sol[i];
#pragma omp atomic write
sol[i] = new_val;
}
#pragma omp atomic update
done &= tdone;
}
} while(++count < max_iters && !done);
return count;
}
Come si può vedere, non v'è alcun blocco all'interno della regione in parallelo, così, per quello che ci insegnano sempre, è il tipo di problema al 100% in parallelo. Questo non è ciò che vedo in pratica.
Tutti i miei test sono stati eseguiti su CPU Intel (R) Xeon (R) E5-2670 v2 @ 2,50 GHz, 2 processori, 10 core ciascuno, abilitato per hyper-thread e somma di fino a 40 core logici.
Durante il mio primo set, work_line
è stato corretto su 2048 e il numero di thread variava da 1 a 40 (40 corse in totale). Questo è il grafico del tempo di esecuzione di ogni ciclo (secondi x numero di thread):
La sorpresa era la curva logaritmica, così ho pensato che poiché la linea di lavoro era così grande, le cache condivise non erano molto ben utilizzati, quindi ho scavato questo file virtuale /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size
che mi ha detto che la cache L1 di questo processore sincronizza gli aggiornamenti in gruppi di 64 byte (8 raddoppia nell'array sol
). Così mi sono messo il work_line
-8:
Poi ho pensato 8 era troppo basso per evitare stalli NUMA e impostare work_line
-16:
Durante l'esecuzione di quanto sopra, ho pensato "Chi sono io per prevedere che cosa è work_line
è buono? Basta vedere ...", e programmato per eseguire ogni work_line
da 8 a 2048, passaggi di 8 (vale a dire ogni multiplo della linea della cache, da 1 a 256). I risultati per 20 e 40 fili (secondi x dimensioni della scissione della metà for
ciclo, diviso tra i fili):
ritengo casi con bassa work_line
soffre molto sincronizzazione cache, mentre grande work_line
non offre alcun vantaggio oltre un certo numero di thread (presumo perché il percorso della memoria è il collo di bottiglia).È molto triste che un problema che sembra parallelo al 100% presenti un comportamento così negativo su una macchina reale. Quindi, prima di essere convinto che i sistemi multi-core sono una bugia molto ben venduta, vi chiedo innanzitutto:
Come posso rendere questo codice in scala lineare al numero di core? Cosa mi manca? C'è qualcosa nel problema che lo rende non buono come sembra in un primo momento?
Aggiornamento
Seguendo i suggerimenti, ho provato sia con static
e dynamic
programmazione, ma la rimozione delle atomiche di lettura/scrittura sulla matrice sol
. Come riferimento, le linee blu e arancione sono le stesse del grafico precedente (fino a work_line = 248;
). Le linee gialle e verdi sono le nuove. Per quello che ho potuto vedere: static
fa una differenza significativa per il basso work_line
, ma dopo 96 i benefici di dynamic
superano il suo sovraccarico, rendendolo più veloce. Le operazioni atomiche non fanno alcuna differenza.
Non ho molta familiarità con il metodo SOR/Gauss-Seidel ma con la moltiplicazione della matrice o con la decomposizione di Cholesky, l'unico modo per ottenere un ridimensionamento corretto consiste nell'utilizzare la piastrellatura del ciclo per riutilizzare i dati mentre è ancora in il cache. Vedi http://stackoverflow.com/questions/22479258/cholesky-decomposition-with-openmp. Altrimenti è legato alla memoria. –
Anche se non ho familiarità con l'algoritmo, una rapida occhiata a quel loop interno suggerisce che probabilmente hai una posizione di memoria spaziale molto scarsa. (come nel caso tipico dell'algebra lineare sparsa) In questo caso, probabilmente sei limitato dall'accesso alla memoria. – Mysticial
Qual è la complessità temporale di SOR? http://www.cs.berkeley.edu/~demmel/cs267/lecture24/lecture24.html#link_4 O (N^3/2)? Con Matrix Mult i calcoli vanno come N^3 mentre le letture vanno come N^2 quindi è per questo che può scalare bene. Quindi, a meno che il numero di calcoli sia molto più grande delle letture, allora sarà legato alla memoria. Molti algoritmi di base sembrano scalare bene se si ignora il fatto che i core sono veloci e la memoria principale è lenta. Il livello BLAS 2 (ad esempio matrix * vec) si ridimensiona bene ignorando la memoria lenta. È solo il livello 3 BLAS (O (N^3) per esempio GEMM, Choleksy, ...) che si adatta bene alla memoria lenta. –