tp mpi

Transcript

tp mpi
Corso di Architettura dei calcolatori e parallelismo
anno 2007/2008
Formulazione parallela dell'algoritmo di Dijkstra
Marco Trentini
matricola 062275
[email protected]
Introduzione
In questa esercitazione viene presentata una versione parallela dell'algoritmo di Dijkstra, utilizzato
per determinare i percorsi minimi da un dato nodo sorgente a tutti gli altri nodi di un grafo (con pesi
non negativi). E' anche presente un'analisi teorica sulle tempistiche di esecuzione, speedup ed
efficienza dell'algoritmo parallelo, supportata da un'analisi pratica effettuata grazie all'utilizzo di un
cluster di calcolatori.
Implementazione
Per rappresentare il grafo viene utilizzata una classe generica con un contenitore templato:
template <class A, class B, class C, class D, class Z> class graph
A e B sono informazioni per i nodi, C e D per gli archi e Z e' il tipo di dato utilizzato per
rappresentare l'id dei nodi, a seconda della dimensione del grafo.
Il grafo puo' essere rappresentato sia tramite una lista concatenata sia tramite una matrice di
adiacenza. Le informazioni A, B, C e D sono valide solo per la rappresentazione del grafo tramite
lista di adiacenza. Nel caso della rappresentazione con matrice di adiacenza si assume che C sia il
tipo di dato usato per esprimere il peso dell'arco (valori della matrice).
Seguono le strutture e le variabili utilizzate per la rappresentazione del grafo:
/**
object arc
*/
struct arcItem{
C field_1;
D field_2;
arcItem *next;
arcItem *prev;
Z nodeId;
};
/**
object node
*/
struct nodeItem{
A field_1;
B field_2;
nodeItem *next;
nodeItem *prev;
Z nodeId;
Z arcNbr; //total arc number for the node
arcItem *start_arcList;
arcItem *last_arcList;
};
/**
matrix
*/
struct nodeMatrix{
C **m;
Z *nodeRemap;
Z nodeNbr;
Z arcNbr;
};
/**
node list pointers
*/
nodeItem *start_nodeList;
nodeItem *last_nodeList;
Z tot_node;
Z tot_arc; //total arc number for the graph
bool isUndirected; //undirected graph (default) or not
nodeMatrix gMatrix;
La rappresentazione con matrice di adiacenza e' utile con grafi densi (|E| vicino a O(|V|2)), mentre la
rappresentazione con lista concatenata e' efficiente con grafi sparsi (|E| molto minore di O(|V|2)).
Si puo' notare che il tempo di esecuzione sequenziale di un algoritmo che attraversa tutto il grafo e
che usa una matrice di adizacenza e' nell'ordine di W(|V|2). Utilizzando una lista concatenata il
tempo di esecuzione e' nell'ordine di W(|V|+|E|).
Vengono presentati gli operatori e le funzioni membro piu' importanti a supporto della classe graph
(per un elenco completo si veda direttamente il sorgente).
Operatori:
graph<A,B,C,D,Z>& operator=(const graph<A,B,C,D,Z> &other)
operatore di assegnamento: permette l'assegnamento tra oggetti della classe grafo (rilasciando la
memoria del “vecchio” grafo a sinistra dell'operatore).
Funzioni membro:
graph(void) :
start_nodeList(NULL),last_nodeList(NULL),tot_node(0),tot_arc(0),isUndirected(true)
costruttore: inizializza i dati privati (grafo non orientato di default)
graph(bool graphType) : start_nodeList(NULL),last_nodeList(NULL),tot_node(0),tot_arc(0)
costruttore con parametro per il tipo di grafo (true per grafo non orientato)
graph(const graph<A,B,C,D,Z> &b) :
start_nodeList(NULL),last_nodeList(NULL),tot_node(0),tot_arc(0)
costruttore per copia: permette di creare un grafo identico a quello passato come parametro
void swap(graph<A,B,C,D,Z> &b)
scambia i “contenuti” di istanze di grafi
~graph()
distruttore: dealloca (con operazioni di delete) tutta la memoria che e' stat allocata (con operazioni
di new) per il grafo
bool delArcMono(Z id_a, Z id_b)
bool delArc(Z id_a, Z id_b)
rimuove l'arco del nodo id_a verso il nodo id_b
bool findArcMono(Z id_a, Z id_b)
bool findArc(Z id_a, Z id_b)
verifica l'esistenza di un arco dal nodo id_a verso il nodo id_b
bool addArc(C field_1, D field_2,Z id_a, Z id_b)
bool addArcMono(C field_1, D field_2,Z id_a, Z id_b)
aggiunge un arco (con le relative informazioni) al nodo id_a verso nodo id_b
bool getArcInfo(C *field_1, D *field_2,Z id_a, Z id_b)
recupera le informazioni dell'arco che collega il nodo id_a al nodo id_b
bool findNode(Z id)
cerca il nodo id
bool delNode(Z id)
cancella il nodo id dalla lista dei nodi (compresa la lista dei suoi archi e gli archi degli altri nodi
collegati ad esso)
bool addNode(A field_1, B field_2, Z id)
aggiunge il nodo id (con le relative informazioni) al grafo
bool getNodeInfo(A *field_1,B *field_2,Z id)
recupera le informazioni nel nodo id
void printGraphInfo()
stampa informazioni (su stdo) sul grafo come numero di nodi, di archi e dimensioni del grafo
void printGraphMatrixOnFile()
stampa nel file graphMatrix.txt la matrice di adiacenza
bool buildGraphMatrixFromFile(Z nodeNbr)
costruisce la matrice di adiacenza prendendo i dati del grafo dal file graphMatrix.txt
void printGraphOnFile()
stampa nel file graph.txt la lista concatenata rappresentante il grafo
bool buildRandomGraph(Z nodeNbr)
costruisce un grafo con numero di nodi passato come parametro (costruisce solo la lista di
adiacenza). L'esistenza e il peso (con un valore tra 1 e 100) degli archi tra i nodi sono determinati in
modo randomico.
bool buildRandomGraphMatrix(Z nodeNbr,C nullData)
costruisce un grafo con numero di nodi passato come parametro (costruisce solo la matrice di
adiacenza). L'esistenza e il peso (con un valore tra 1 e 100) degli archi tra i nodi sono determinati in
modo randomico.
void buildGraphMatrix(C nullData)
costruisce la matrice di adiacenza del grafo a partire dalla lista concatenata rappresentante il grafo.
void Dijkstra(Z sId,C *d,C infinite,double *p,C nullData)
esegue l'algoritmo di Dijkstra sul nodo sorgente sId restituendo l'array delle distanze d e quello dei
predecessori p
void DijkstraMPI(Z sId,C *d,C infinite,double *p,C nullData, int numtasks,int rank)
versione parallela dell'algoritmo di Dijkstra con:
sId: nodo sorgente
d: array delle distanze
infinite: valore infinito per inizializzare le distanze
p: array dei predecessori
nullData: valore di assenza arco tra nodi
numtasks: numero totale di task
rank: numero della task
Versione seriale dell'algoritmo di Dijkstra:
void Dijkstra(Z sId,C *d,C infinite,double *p,C nullData) const{
bool q[gMatrix.nodeNbr];
Z u;
bool first_cycle=false;
//Initialize-single-source
for(Z i=0;i<gMatrix.nodeNbr;i++){
q[i]=true; //node i present in q
if(gMatrix.m[sId][i]==nullData)
d[i]=infinite; //infinite value
else
d[i]=gMatrix.m[sId][i];
p[i]=-1; //nil value
}
d[sId]=0;
for(Z i=0;i<gMatrix.nodeNbr;i++){
//extract-min(q)
first_cycle=true;
for(Z y=0;y<gMatrix.nodeNbr;y++){
if(q[y]){
if(first_cycle){
first_cycle=false;
u=y;
}
else{
if(d[y]<d[u]){
u=y;
}
}
}
}
q[u]=false;
for(Z y=0;y<gMatrix.nodeNbr;y++){
if(q[y]){
//relax
if(gMatrix.m[u][y]!=nullData){
if(d[y]>(d[u]+gMatrix.m[u][y])){
d[y]=d[u]+gMatrix.m[u][y];
p[y]=u;
}
}
}
}
}
}
La versione seriale dell'algoritmo di Dijkstra e' cosi' strutturata:
inizializzazione dei dati: (di costo n) dove si inizializza il vettore Q (inizialmente tutti i nodi
appartengono a Q), il vettore delle distanze d (con il peso dell'arco se esiste altrimenti un valore
infinito) e il vettore dei predecessori con un valore NIL.
poi c'e' il ciclo principale (di costo n) che contiene i seguenti sotto-blocchi:
estrazione del nodo u minimo in Q: (di costo n) verifica quale nodo contenuto in Q ha la distanza
minima verso il nodo sorgente
il nodo in questione viene marcato come non piu' appartenente a Q
rilassamento: (di costo n) per tutti i nodi y appartenenti a Q (se esiste un arco tra u e y) si verifica
che la distanza da y non sia maggiore della distanza da u + il peso dell'arco tra u e y.
In caso negativo si aggiorna la distanza da y ed il predecessore di y, u (scelta greddy).
Come si puo' dedurre dal codice, la versione seriale dell'algoritmo di Dijkstra ha un tempo di
esecuzione di Q(n2 ).
Versione MPI dell'algoritmo di Dijkstra:
void DijkstraMPI(Z sId,C *d,C infinite,double *p,C nullData, int numtasks,int rank) const{
bool q[gMatrix.nodeNbr];
Z u, u_dest[2], u_src[2];
bool first_cycle=false;
Z startNode, lastNode, partialNode;
Z recvbuf_gat[numtasks][2];
MPI_Status statD[numtasks-1],statP[numtasks-1];
MPI_Request reqsD[numtasks-1],reqsP[numtasks-1];
partialNode=(gMatrix.nodeNbr)/numtasks;
startNode=(rank*partialNode);
if((rank+1) % numtasks)
lastNode=startNode+partialNode-1;
else
lastNode=gMatrix.nodeNbr-1;
std::cout<<"rank= "<<rank<<" startNode= "<<startNode<<" lastNode= "<<lastNode<<"\n"<<std::flush;
//Initialize-single-source
for(Z i=startNode;i<=lastNode;i++){
q[i]=true; //node i present in q
if(gMatrix.m[sId][i]==nullData)
d[i]=infinite; //infinite value
else
d[i]=gMatrix.m[sId][i];
p[i]=-1; //nil value
}
d[sId]=0;
for(Z i=0;i<gMatrix.nodeNbr;i++){
//extract-min(q)
first_cycle=true;
for(Z y=startNode;y<=lastNode;y++){
if(q[y]){
if(first_cycle){
first_cycle=false;
u=y;
u_src[0]=u;
u_src[1]=d[u];
}
else{
if(d[y]<d[u]){
u=y;
u_src[0]=u;
u_src[1]=d[u];
}
}
}
}
MPI_Gather(u_src,2,MPI_UNSIGNED,&recvbuf_gat,2,MPI_UNSIGNED,0,MPI_COMM_WORLD);
if(rank==0){
//find global minimum
for(int t=0;t<numtasks;t++){
d[recvbuf_gat[t][0]]=recvbuf_gat[t][1];
if(d[recvbuf_gat[t][0]]<d[u])
u=recvbuf_gat[t][0];
}
}
u_dest[0]=u;
u_dest[1]=d[u];
MPI_Bcast(u_dest,2,MPI_UNSIGNED,0,MPI_COMM_WORLD);
u=u_dest[0];
d[u]=u_dest[1];
q[u]=false;
for(Z y=startNode;y<=lastNode;y++){
if(q[y]){
//relax
if(gMatrix.m[u][y]!=nullData){
if(d[y]>(d[u]+gMatrix.m[u][y])){
d[y]=d[u]+gMatrix.m[u][y];
p[y]=u;
}
}
}
}
}
std::cout<<"rank= "<<rank<<" \n"<<std::flush;
//MPI_Gather(d+startNode,lastNode-startNode+1,MPI_UNSIGNED,d,lastNodestartNode+1,MPI_UNSIGNED,0,MPI_COMM_WORLD);
//MPI_Gather(p+startNode,lastNode-startNode+1,MPI_DOUBLE,p,lastNodestartNode+1,MPI_DOUBLE,0,MPI_COMM_WORLD);
#if defined COLLECT
if(rank!=0){
MPI_Send(d+startNode,lastNode-startNode+1,MPI_UNSIGNED,0,1,MPI_COMM_WORLD);
MPI_Send(p+startNode,lastNode-startNode+1,MPI_DOUBLE,0,1,MPI_COMM_WORLD);
}
else{
for(int t=1;t<numtasks;t++){
if(t!=numtasks-1){
MPI_Irecv(d+(t*partialNode),partialNode,MPI_UNSIGNED,t,1,MPI_COMM_WORLD,&reqsD[t-1]);
MPI_Irecv(p+(t*partialNode),partialNode,MPI_DOUBLE,t,1,MPI_COMM_WORLD,&reqsP[t-1]);
}
else {
MPI_Irecv(d+(t*partialNode),gMatrix.nodeNbr(t*partialNode),MPI_UNSIGNED,t,1,MPI_COMM_WORLD,&reqsD[t-1]);
MPI_Irecv(p+(t*partialNode),gMatrix.nodeNbr(t*partialNode),MPI_DOUBLE,t,1,MPI_COMM_WORLD,&reqsP[t-1]);
}
}
MPI_Waitall(numtasks-1,reqsD,statD);
MPI_Waitall(numtasks-1,reqsP,statP);
}
#endif
}
Sia p il numero di processi e sia n il numero di vertici del grafo. La matrice di adiacenza viene
partizionata in p sotto-insiemi usando il metodo di partizionamento dei dati 1-D. Ogni processo
lavora su n/p vertici consecutivi della matrice di adiacenza, del vettore delle distanze e di quello dei
predecessori.
La versione MPI dell'algoritmo di Dijkstra e' cosi' strutturata:
inizializzazione dei dati: (di costo n/p) dove si inizializza il vettore Q (inizialmente tutti i nodi
appartengono a Q), il vettore delle distanze d (con il peso dell'arco se esiste altrimenti un valore
infinito) e il vettore dei predecessori con un valore NIL. Ogni processo lavora solo sulla propria
partizione dei dati.
poi c'e' il ciclo principale (di costo n) che contiene i seguenti blocchi:
estrazione del nodo u minimo in Q: (di costo n/p) verifica quale nodo contenuto in Q ha la distanza
minima verso il nodo sorgente.
Ogni processo trova il nodo minimo nella propria partizione di dati e lo comunica (operazione di
gather), con la relativa distanza, al processo 0 che funge da collettore. Il processo 0 ricava (con
costo p) il minimo globale u e lo comunica, con la relativa distanza, ai restanti processi (operazione
di bcast).
ogni processo marca il nodo u come non piu' appartenente a Q
rilassamento: (di costo n/p) ogni processo per i nodi y appartenenti alla propria partizione e
contenuti in Q (se esiste un arco tra y e u) verifica che la distanza da y non sia maggiore della
distanza da u + il peso dell'arco tra u e y. In caso negativo aggiorna la distanza da y ed il
predecessore di y, u.
Alla fine del ciclo principale abbiamo che ogni processo ha la soluzione delle distanze e dei
predecessori per i nodi che appartengono alla propria partizione. E' quindi necessario e utile
collezionare tutte le soluzioni locali nel processo 0 (tramite operazioni di send e irecv) in modo tale
poter effettuare delle elaborazioni finali sulla soluzione globale.
La computazione necessaria per ogni processo per aggiornare il vettore delle distanze e quello dei
predecessori e' Q(n/p) e questo viene fatto n volte, quindi Q( n2 / p).
Ad ogni iterazione (n) vengono effettuate un'operazione di gather e una di bcast ciascuna con due
valori. Finito il ciclo principale ogni processo, tranne lo 0, effettua 2 send sincrone con n/p valori
ciascuna (per inviare le soluzioni delle distanze e dei predecessori al processo 0), mentre il processo
0 comanda 2p-2 receive asincrone per ricevere i dati inviati dagli altri processi. Il processo 0 attende
di ricevere tutti i dati con delle waitall. Tutte queste comunicazioni vanno a costituire il tempo
utilizzato per le comunicazioni (Tc).
Ts = Θ ( n2 )
Tp = Θ ( n2 / p ) + Tc
S = Ts / Tp
S = Θ ( n2 ) / ( Θ ( n2 / p ) + Tc )
To = pTp - Ts
E=S/p
E = 1 / ( 1 + ( To / Ts) )
Alcune considerazioni sui dati: con questa implementazione ogni processo alloca tutta la matrice di
adiacenza. In questo modo non e' necessaria una distribuzione iniziale dei dati e l'accesso alla
matrice su ogni nodo-cluster risulta diretto (a livello di indici della matrice). Ogni nodo-cluster
alloca e costruisce la matrice di adiacenza rappresentante il grafo a partire dal file testuale (che
contiene la matrice di adiacenza del grafo). Questo metodo da un lato velocizza la distribuzione dei
dati e semplifica l'accesso alla matrice, dall'altro limita la dimensione del grafo trattabile in RAM su
ogni nodo-cluster. Sia t il numero massimo di nodi del grafo trattabili in RAM, allora utilizzando
una distribuzione dei dati su p nodi-cluster si riuscirebbe a trattare un grafo con una dimensione di
tp (quindi se p=100 la dimensione del grafo aumenterebbe di due ordini di grandezza e cosi' via). Le
prove sul cluster (nodi-cluster con 1 Gb di RAM) hanno riscontrato dei limiti dimensionali per il
grafo tra 25.000 e 35.000 nodi (con 35.000 fallisce la richiesta di allocazione di memoria). Con una
distribuzione dei dati si riuscirebbe a trattare grafi con 500.000 nodi (avendo a disposizione 20
nodi-cluster). Se si ha la necessita' di trattare grafi di maggiore dimensioni (ad esempio per
verificare la soluzione seriale dell'algoritmo con un grafo di 500.000 nodi, in cui fallirebbe la
richiesta di allocazione dei dati) si potrebbe implementare lo stesso algoritmo con I/O direttamente
su file (evitando cosi' il problema dell'allocazione di memoria). Ovviamente l'I/O su file
comporterebbe dei tempi di accesso molto piu' lunghi rispetto a quelli richiesti per la RAM.
La versione seriale del main:
#include
#include
#include
#include
#include
#include
#include
"mpi.h"
<iostream>
<fstream>
<stdlib.h>
<limits.h>
"graph.h"
<sys/time.h>
#define INFINITE_VALUE UINT_MAX
#define totNode 25000
#define sourceNode 998
#define TYPE_ID unsigned int
using namespace std;
int main(int argc, char *argv[]){
graph<unsigned int,string,unsigned int,string,unsigned int> graphTest(false);
TYPE_ID dest,source,y;
clock_t c0, c1;
struct timeval start,end;
ofstream myfile;
//graphTest.buildRandomGraphMatrix(totNode,0);
graphTest.buildGraphMatrixFromFile(totNode);
//graphTest.printGraphMatrixOnFile();
graphTest.printGraphInfo();
unsigned int d[graphTest.getTotMatrixNode()];
double p[graphTest.getTotMatrixNode()];
y=sourceNode;
gettimeofday(&start,NULL);
c0 = clock();
graphTest.Dijkstra(y,d,INFINITE_VALUE,p,0);
c1 = clock();
gettimeofday(&end,NULL);
end.tv_sec=end.tv_sec-start.tv_sec;
end.tv_usec=end.tv_usec-start.tv_usec;
if(end.tv_usec<0){
end.tv_usec=end.tv_usec+1000000;
end.tv_sec--;
}
cout<<"elapsed wall clock time : "<<end.tv_sec<<" sec, "<<end.tv_usec<<" us\n";
cout<<"elapsed CPU time : "<<((float)(c1 - c0)/CLOCKS_PER_SEC)<<" sec\n";
myfile.open("DijkstraFromOneS.txt");
source=y;
for(TYPE_ID g=0;g<graphTest.getTotMatrixNode();g++){
myfile<<"d["<<g<<"]="<<d[g]<<" ";
myfile<<"p["<<g<<"]="<<p[g]<<" ";
}
myfile<<"\n";
for(TYPE_ID g=0;g<graphTest.getTotMatrixNode();g++){
if((d[g]==INFINITE_VALUE)||(y==g)){
myfile<<"there isn't a path between node "<<y<<" and node "<<g<<"\n";
}
else{
myfile<<"minimal path between node "<<y<<" and node "<<g<<" is "<<d[g]<<" with
nodes: \n";
dest=g;
while(p[dest]!=-1){
myfile<<" "<<p[dest]<<",";
dest=(TYPE_ID)p[dest];
}
myfile<<"\n";
}
}
myfile.close();
return 0;
}
La versione di MPI del main:
#include
#include
#include
#include
#include
#include
#include
"mpi.h"
<iostream>
<fstream>
<stdlib.h>
<limits.h>
"graph.h"
<sys/time.h>
#define INFINITE_VALUE UINT_MAX
#define totNode 15000
#define sourceNode 998
#define TYPE_ID unsigned int
using namespace std;
int main(int argc, char *argv[]){
int numtasks, rank, rc, resultlen;
char name[100];
graph<unsigned int,string,unsigned int,string,unsigned int> graphTest(false);
TYPE_ID dest,source,y;
clock_t c0, c1;
struct timeval start,end;
double mpi_start, mpi_end;
ofstream myfile;
rc = MPI_Init(&argc,&argv);
if (rc != MPI_SUCCESS){
std::cout<<"Error starting MPI program. Terminating."<<std::endl;
MPI_Abort(MPI_COMM_WORLD, rc);
}
MPI_Comm_size(MPI_COMM_WORLD,&numtasks);
MPI_Comm_rank(MPI_COMM_WORLD,&rank);
MPI_Get_processor_name(name,&resultlen);
std::cout<<"Number of tasks="<<numtasks<<" My rank="<<rank<<" on "<<name<<std::endl;
graphTest.buildGraphMatrixFromFile(totNode);
if(rank==0)
graphTest.printGraphInfo();
unsigned int d[graphTest.getTotMatrixNode()];
double p[graphTest.getTotMatrixNode()];
y=sourceNode;
std::cout<<"build graph done for rank="<<rank<<" on "<<name<<std::endl;
MPI_Barrier(MPI_COMM_WORLD);
gettimeofday(&start,NULL);
c0 = clock();
mpi_start=MPI_Wtime();
graphTest.DijkstraMPI(y,d,INFINITE_VALUE,p,0,numtasks,rank);
mpi_end=MPI_Wtime();
c1 = clock();
gettimeofday(&end,NULL);
end.tv_sec=end.tv_sec-start.tv_sec;
end.tv_usec=end.tv_usec-start.tv_usec;
if(end.tv_usec<0){
end.tv_usec=end.tv_usec+1000000;
end.tv_sec--;
}
MPI_Barrier(MPI_COMM_WORLD);
usleep((rank+1)*100000);
std::cout<<"rank= "<<rank<<" elapsed wall clock time : "<<end.tv_sec<<" sec,
"<<end.tv_usec<<" us\n";
std::cout<<"rank= "<<rank<<" elapsed CPU time : "<<((float)(c1 - c0)/CLOCKS_PER_SEC)<<"
sec\n";
std::cout<<"rank= "<<rank<<" elapsed MPI wall time : "<<mpi_end-mpi_start<<"
sec\n"<<std::flush;
#if defined COLLECT
if(rank==0){
myfile.open("DijkstraFromOneMpi.txt");
source=y;
for(TYPE_ID g=0;g<graphTest.getTotMatrixNode();g++){
myfile<<"d["<<g<<"]="<<d[g]<<" ";
myfile<<"p["<<g<<"]="<<p[g]<<" ";
}
myfile<<"\n";
for(TYPE_ID g=0;g<graphTest.getTotMatrixNode();g++){
if((d[g]==INFINITE_VALUE)||(y==g)){
myfile<<"there isn't a path between node "<<y<<" and node "<<g<<"\n";
}
else{
myfile<<"minimal path between node "<<y<<" and node "<<g<<" is "<<d[g]<<" with
nodes: \n";
dest=g;
while(p[dest]!=-1){
myfile<<" "<<p[dest]<<",";
dest=(TYPE_ID)p[dest];
}
myfile<<"\n";
}
}
myfile.close();
}
#endif
MPI_Finalize();
return 0;
}
Entrambe le versioni del main generano un file testuale contenente la soluzione (esistenza di un
percorso minimo tra archi con relativo peso e lista dei nodi contenuti nel percorso). Confrontando il
file generato dalla versione seriale con quello generato dalla versione paralella possiamo verificare
la correttezza dell'algoritmo parallelo.
Le prove sono state effettuate su un cluster con le seguenti caratteristiche:
SCILX: beowulf cluster of Grandi Attrezzature Consortium
- 20 dual AMD Athlon MP 2000+ SMP nodes (totally 40 CPUs)
- 1 GByte RAM per node (totally 20 GBytes)
- NAS Dell Powervault 725N with about 300 GByte of storage
- 10/100 MBit Ethernet service network
- Dolphin Interconnect Solutions D334/D335 for High Performance Computing (v3.3.0.2)
- Operative System: Linux Debian Lenny
- Compilers: GCC-4.x, GCC-3.x, GFORTRAN, G77-3.x, PGI 7.0 (require license)
- Shells/Interpreters: BASH, KSH, TCSH, PERL, JAVA-1.6, PYTHON-2.4, PYTHON-2.5
- Softwares: MP-MPICH-1.5.0 , OPENMPI-1.2.6 , NMPI-1.3.1
- Libraries: ACML-3.6.1 (GNU, GFORTRAN, PGI), FFT3, FFTW, BLACS, BLAS,
SCALAPACK, GSL
- Support to OpenMP
La sottomissione dei job e' avvenuta tramite l'uso di TORQUE utilizzando i seguenti script:
OPENMPI-GNU con ETHERNET
#!/bin/bash
#PBS -V
#PBS -S /bin/bash
#PBS -k eo
#PBS -N TEST
#PBS -l nodes=1:ppn=2:sci
#PBS -l walltime=08:00:00
echo "The nodefile is ${PBS_NODEFILE} and it contains:"
cat ${PBS_NODEFILE}
echo "The unique nodefile contains:"
cat ${PBS_NODEFILE} | sed -e "s/ /\n/g" | uniq > /tmp/hostfile.${PBS_O_JOBID}
cat /tmp/hostfile.${PBS_O_JOBID}
echo ""
cd $PBS_O_WORKDIR
source /usr/local/Modules/3.2.5/init/bash
module load OPENMPI-1.2.6-GNU
#mpiexec -v -np 10 -npernode 1 free -m
mpiexec -v -np 1 -npernode 1 ./serial_25000
#mpiexec -v -np 3 -npernode 1 ./serial_15000
exit 0
NMPI-GNU con DOLPHI
#!/bin/bash
#PBS -V
#PBS -k eo
#PBS -N HW_nmpi
#PBS -l nodes=15:ppn=2:sci
#PBS -l walltime=00:50:00
echo "Start @" `date`
echo "The nodefile is ${PBS_NODEFILE} and it contains:"
cat ${PBS_NODEFILE}
echo "The unique nodefile contains:"
cat ${PBS_NODEFILE} | sed -e "s/ /\n/g" | uniq > /tmp/hostfile.${PBS_O_JOBID}
cat /tmp/hostfile.${PBS_O_JOBID}
echo ""
cd $PBS_O_WORKDIR
source /usr/local/Modules/3.2.5/init/bash
module load NMPI-1.3.1-GNU
export NUM_NODES=`cat /tmp/hostfile.${PBS_O_JOBID} | wc -l`
export NUM_NODES_T=`cat $PBS_NODEFILE | wc -l`
echo $PBS_NODEFILE
echo $NUM_NODES
echo $NUM_NODES_T
mpdboot -n ${NUM_NODES_T} -f ${PBS_NODEFILE} -r rsh -s -v -1
#mpdboot -n ${NUM_NODES} -f /tmp/hostfile.${PBS_O_JOBID} -r rsh -s -v -1
#mpiexec -v -np 4 -npernode 1 ./mpi_5000_nmpi
#mpiexec -machinefile /tmp/hostfile.${PBS_O_JOBID} -n $NUM_NODES ./mpi_15000_dolp
mpiexec -machinefile /tmp/hostfile.${PBS_O_JOBID} -n $NUM_NODES
./mpi_15000_dolp_nocollect
#mpiexec -machinefile ${PBS_NODEFILE} -n $NUM_NODES_T ./mpi_15000_dolp
mpdallexit
echo "End @" `date`
exit 0
Le prove sono state effettuate con:
- grafo di 5.000 nodi, 12.498.287 archi, ram richiesta 95 Mbyte, file testuale (matrice di adiacenza)
di 61 Mbyte;
- grafo di 15.000 nodi, 112.496.720 archi, ram richiesta 858 Mbyte, file testuale (matrice di
adiacenza) di 550 Mbyte;
5000 nodi
p
seriale
2T/1N
2T/2N
1T/2N
1T/5N
1T/10N
1T/15N
1T/15N nocoll
wall clock (s) mpi time ETH (s) mpi time DOLP (s)
1
2
4
2
5
10
15
15
S=Ts/Tp
E=S/p
To=pTp-Ts (s)
0,74
0,55
1,53
1,4
1,69
2,25
11,04
11,79
0,53
0,33
0,54
0,32
0,42
0,45
0,31
1,4
2,24
1,37
2,31
1,76
1,64
2,39
0,7
0,56
0,69
0,46
0,18
0,11
0,16
0,32
0,58
0,34
0,86
3,46
6,01
3,91
15000 nodi
p
seriale
2T/1N
2T/2N
1T/2N
1T/5N
1T/10N
1T/15N
1T/15N nocoll
wall clock (s) mpi time ETH (s) mpi time DOLP (s)
1
2
4
2
5
10
15
15
S=Ts/Tp
E=S/p
To=pTp-Ts (s)
6,74
290,62
260,74
6,87
6,57
6,83
14,89
13,88
404,92
435,23
4,2
2,01
1,72
1,64
1,44
0,02
0,02
1,6
3,35
3,92
4,11
4,68
0,01
0
0,8
0,67
0,39
0,27
0,31
803,1
1734,18
1,66
3,31
10,46
17,86
14,86
Cosiderazioni sui risultati ottenuti
Utilizzando l'interfaccia Ethernet la versione parallela dell'algoritmo si e' dimostrata meno efficente
della versione seriale, praticamente in tutte le configurazioni provate (tranne in quelle evidenziate di
verde). Si puo' notare come all'aumentare dei nodi-cluster aumenti anche il tempo richiesto per il
calcolo: questo e' dovuto all'overhead introdotto dallo scambio dei dati tra i nodi-cluster utilizzando
le interfacce ethernet. Interessanti sono i risultati ottenuti con doppio thread sia su singolo che
doppio nodo-cluster. Si puo' vedere che mentre nel caso del grafo di 5000 nodi questa
configurazione risulta efficiente, nel caso del grafo con 15000 nodi il tempo di calcolo parallelo
aumenta considerevolmente. Un grafo di 15000 nodi occupa quasi 860 Mbyte in RAM; essendoci
due thread, la memoria richiesta e' di 1720 Mbyte; ogni nodo-cluster ha 1 Gbyte di RAM e quindi e'
necessario far uso dello swap su disco con tempi di accesso ai dati molto peggiori. Un grafo di 5000
nodi occupa invece quasi 100 Mbyte e quindi la memoria richiesta su ogni nodo-cluster (200
Mbyte) puo' essere allocata tutta in ram.
I risultati migliorano considerevolmente utilizzando l'interfaccia Dolphin: aumentando il numero di
nodi-cluster coinvolti nella computazione aumenta lo speedup (e quindi il tempo di computazione
parallelo diminuisce) e l'efficienza diminuisce: nel caso del grafo con 15000 nodi si passa da un
tempo di esecuzione di 6,74 secondi per la versione seriale a 1,64 secondi per la versione parallela
con 15 nodi-cluster; in questo caso abbiamo uno speedup pari a 4,11 e una efficienza di 0,27.
Aumentando la dimensione del problema, notiamo che , a parità del numero di nodi-cluster,
speedup ed efficienza aumentano. Aumentando il numero di nodi-cluster, mantenendo costante la
dimensione del problema, notiamo che l'efficienza diminuisce.
Queste due caratteristiche ci portano a dire che la formulazione parallela del problema è scalabile
(fino ad un certo numero di nodi-cluster, dipendente da Tc): riusciamo ad ottenere una efficienza
costante aumentando la dimensione del problema e il numero di nodi-cluster coinvolti nella
comunicazione.
Ovviamente l'utilizzo del cluster parallelo per eseguire l'algoritmo di Dijstra diventa utile ed
efficente con un grafo di dimensioni significative: aspettare 6,74 secondi (grafo con 15.000 nodi)
per trovare tutti i percorsi minimi da un dato nodo sorgente (utilizzando la versione seriale) e' piu'
che accettabile e puo' essere eseguito su singola macchina.
Il discorso cambia quando c'e' la necessita' di ricercare tutti i percorsi minimi tra tutte le coppie di
nodi del grafo. Utilizzando sempre l'algoritmo di Dijstra e' possibile parallelizzare questo problema
in due modi diversi:
a) partizionamento dei vertici in seriale: l'insieme dei nodi del grafo viene suddiviso in partizioni e
assegnate ad ogni processo/nodo-cluster che utilizzera' la versione seriale dell'algoritmo di Dijstra
per determinare i percorsi minimi dei verteci;
Ts = Θ ( n3 )
Tp = Θ ( n3 / p ) con p=n diventa Θ ( n2 )
S = Ts / Tp
S = Θ ( n3 ) / Θ ( n3 / p ) con p=n diventa Θ ( n )
To = pTp - Ts
E=S/p
E = (Θ ( n3 ) / Θ ( n3 / p )) / p con p=n diventa Θ ( 1 )
In questo metodo notiamo che non ci sono comunicazioni; ad ogni modo questo metodo ha buone
prestazioni quando il numero di processi/nodi-cluster e' al piu' pari al numero di nodi del grafo;
quando il numero di processi/nodi-cluster e' superiore al numero di nodi del grafo l'algoritmo
diventa poco scalabile poiche' aumentando il numero di nodi-cluster, mantenendo costante la
dimensione del problema, l'efficienza non diminuisce.
Nel nostro contesto possiamo stimare che per un grafo di 15.000 nodi la versione seriale
dell'algoritmo richieda Ts= 6,74 * 15.0000 = 28 ore. Utilizzando questo metodo parallelo Tp
dovrebbe essere pari a Tp= (15.000/15) * 6,74 = quasi 2 ore. Con questo metodo ogni
processo/nodo-cluster deve allocare tutta la matrice di adiacenza e quindi abbiamo le problematiche
di memoria viste in precedenza.
b) partizionamento dei vertici in parallelo: l'insieme dei nodi del grafo viene suddiviso in partizioni
e assegnate ad un set di processi/nodi-cluster che utilizzeranno la versione parallela dell'algoritmo
di Dijstra per determinare i percorsi minimi dei verteci.
Ts = Θ ( n3 )
Tp = Θ ( n3 / p ) + Tc
S = Ts / Tp
S = Θ ( n3) / ( Θ ( n3 / p ) + Tc )
To = pTp - Ts
E=S/p
E = 1 / ( 1 + ( To / Ts) )
Questo metodo permette di sfruttare, in modo efficente, piu' nodi-cluster rispetto a quello
precedente (effettuando piu' parallelismo).