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).