Laboratorul 8 - Introducere în programarea distribuită cu MPI

Despre MPI și programarea distribuită

Până acum, ați lucrat cu programarea paralelă, care presupune existența a mai multor fire de execuție (threads), care execută instrucțiuni în mod paralel și concurent, ele având acces la aceeași zonă de memorie, în cadrul unei singure mașini de calcul, care are mai multe procesoare.

Putem extinde conceptul de programare paralelă prin folosirea a mai multor mașini de calcul, care sunt conectate în cadrul unei rețele într-un concept numit programarea distribuită.

Spre deosebire de programarea paralelă, în programarea distribuită nu există conceptul de memorie partajată (shared memory). Întrebarea este următoarea: cum poate o mașină să știe ce date are o altă mașină din cadrul rețelei? Soluția este comunicarea prin mesaje, realizată de către mașinile (de tip nod) dintr-o rețea. Comunicarea prin mesaje are două funcționalități:

  • comunicarea: un nod (transmițător / sender) trimite date prin intermediul unui canal de comunicație către un alt nod (receptor / receiver)
  • sincronizarea: un mesaj nu poate fi recepționat înainte ca acesta să fie transmis

MPI (Message Passing Interface) reprezintă un standard pentru comunicarea prin mesaje, elaborat de MPI Forum, și are la bază modelul proceselor comunicante prin mesaje.

Un proces reprezintă un program aflat în execuție și se poate defini ca o unitate de bază care poate executa una sau mai multe sarcini în cadrul unui sistem de operare. Spre deosebire de thread-uri, un proces are propriul său spațiu de adrese (propria zonă de memorie) și acesta poate avea, în cadrul său, mai multe thread-uri în execuție, care partajează resursele procesului.

În cadrul lucrului în C/C++, MPI reprezintă o bibliotecă, care are funcționalitățile implementate într-un header numit mpi.h. Pentru compilare, la MPI există un compilator specific:

  • mpicc, pentru lucrul în C
  • mpic++, pentru lucrul în C++

În ambele limbaje, pentru rularea unui program MPI folosim comanda mpirun, împreună cu parametrul -np, unde precizăm numărul de procese care rulează în cadrul programului distribuit.

Exemplu:

  • compilare:
    • C: mpicc hello.c -o hello
    • C++: mpic++ hello.cpp -o hello
  • rulare: mpirun -np 4 hello - rulare cu 4 procese

Dacă încercați să rulați comanda mpirun cu un număr de procese mai mare decât numărul de core-uri fizice disponibile pe procesorul vostru, este posibil să primiți o eroare cum ca nu aveți destule sloturi libere. Puteți elimina acea eroare adăugând parametrul –oversubscribe atunci când rulați mpirun.

Instalare MPI: Pentru a lucra cu MPI, trebuie să instalați biblioteca pentru MPI pe Linux, folosind următoarea comandă: sudo apt install openmpi-bin openmpi-common openmpi-doc libopenmpi-dev

În caz că lucrați cu MPI pe WSL, o să vă apară un warning, care poate fi fixat cu ajutorul hint-urilor de aici

Implementarea unui program distribuit în MPI

Exemplu de program MPI - Hello World:

#include "mpi.h"
#include <stdio.h>
#include <stdlib.h>
 
#define  MASTER 0
 
int main (int argc, char *argv[]) {
    int numtasks, rank, len;
    char hostname[MPI_MAX_PROCESSOR_NAME];
 
    MPI_Init(&argc, &argv);
    MPI_Comm_size(MPI_COMM_WORLD, &numtasks);
    MPI_Comm_rank(MPI_COMM_WORLD,&rank);
    MPI_Get_processor_name(hostname, &len);
    if (rank == MASTER)
        printf("MASTER: Number of MPI tasks is: %d\n",numtasks);
    else
        printf("WORKER: Rank: %d\n",rank);
 
    MPI_Finalize();
}

Un comunicator (MPI_Comm) reprezintă un grup de procese care comunică între ele. MPI_COMM_WORLD reprezintă comunicatorul default, din care fac parte toate procesele.

Funcții:

  • MPI_Init - se inițializează programul MPI, mai precis se creează contextul în cadrul căruia rulează procesele. Argumentele din linie de comandă sunt pasate către contextul de rulare a proceselor.
  • MPI_Comm_size - funcție care determină numărul de procese (numtasks) care rulează în cadrul comunicatorului (de regulă MPI_COMM_WORLD)
  • MPI_Comm_rank - funcție care determină identificatorul (rangul) procesului curent în cadrul comunicatorului.
  • MPI_Get_processor_name - determină numele procesorului
  • MPI_Finalize - declanșează terminarea programului MPI

În cadrul schimbului de date între procese, este necesar mereu să precizăm tipul acestora. În MPI, se folosește enum-ul MPI_Datatype, care se mapează cu tipurile de date din C/C++, după cum puteți vedea în tabelul de mai jos:

MPI_Datatype Echivalentul din C/C++
MPI_INT int
MPI_LONG long
MPI_CHAR char
MPI_FLOAT float
MPI_DOUBLE double

Putem crea comunicatoare dintr-un alt comunicator folosind funcția MPI_Comm_split, care împarte un comunicator în mai multe comunicatoare mai mici. Semnătura funcției este următoarea: int MPI_Comm_split(MPI_Comm comm, int color, int key, MPI_Comm * newcomm), unde:

  • comm - comunicatorul este împărțit
  • color - identificator al noului comunicator, din care face parte un proces (de regulă rang_vechi_proces / dimensiune_comunicator_nou)
  • key - noul rang al procesului în cadrul noului comunicator (de regulă rang_vechi_proces % dimensiune_comunicator_nou)
  • newcomm - noul comunicator format

Aveți mai jos o imagine care ilustrează cum funcționează MPI_Comm_split:

Funcții de transmisie a datelor în MPI

Convenție: vom marca cu ↓ parametrii de tip input ai unei funcții și cu ↑ parametrii de tip output în cadrul prezentării funcțiilor de mai jos.

MPI_Send

MPI_Send reprezintă funcția prin care un proces trimite date către un alt proces. Semnătura funcției este următoarea:

int MPI_Send(void* data, int count, MPI_Datatype datatype, int destination, int tag, MPI_Comm communicator), unde:

  • data (↓) - reprezintă datele trimise de la procesul sursă către procesul destinație
  • count (↓) - dimensiunea datelor transmise
  • datatype (↓) - tipul datelor transmise
  • destination (↓) - rangul / identificatorului procesului destinație, către care se trimit datele
  • tag (↓) - identificator al mesajului
  • communicator (↓) - comunicatorul în cadrul căruia se face trimiterea datelor între cele două procese

MPI_Send este o funcție blocantă. Mai precis, programul se blochează până când bufferul dat ca prim parametru poate fi refolosit, chiar dacă nu se execută acțiunea de primire a mesajului transmis de procesul curent (MPI_Recv). Dacă apare cazul în care procesul P1 trimite date (MPI_Send) la procesul P2, iar P2 nu are suficient loc în buffer-ul de recepție (buffer-ul nu are suficient loc liber sau este plin) atunci P1 se va bloca.

MPI_Recv

MPI_Recv reprezintă funcția prin care un proces primește date de la un alt proces. Semnătura funcției este următoarea:

int MPI_Recv(void* data, int count, MPI_Datatype datatype, int source, int tag, MPI_Comm communicator, MPI_Status* status), unde:

  • data (↑) - reprezintă datele primite de la procesul sursă de către procesul destinație
  • count (↓) - dimensiunea datelor primite
  • datatype (↓) - tipul datelor primite
  • source (↓) - rangul / identificatorului procesului sursă, care trimite datele
  • tag (↓) - identificator al mesajului
  • communicator (↓) - comunicatorul în cadrul căruia se face trimiterea datelor între cele două procese
  • status - conține date despre mesajul primit, MPI_Status fiind o structură ce conține informații despre mesajul primit (sursa, tag-ul mesajului, dimensiunea mesajului). Dacă nu dorim să ne folosim de datele despre mesajul primit, punem MPI_STATUS_IGNORE, prin care se ignoră status-ul mesajului.

În situația în care procesul P apelează funcția de MPI_Recv(), el se va bloca până va primi toate datele asteptate, astfel că dacă nu va primi nimic sau ceea ce primește este insuficient, P va rămâne blocat. Adică MPI_Recv() se termină doar în momentul în care buffer-ul a fost umplut cu datele așteptate.

Structura MPI_Status include următoarele câmpuri:

  • int count - dimensiunea datelor primite
  • int MPI_SOURCE - identificatorul procesului sursă, care a trimis datele
  • int MPI_TAG - tag-ul mesajului primit

MPI_Recv este o funcție blocantă, mai precis programul se poate bloca până când se execută acțiunea de trimitere a mesajului către procesul sursă.

Un exemplu de program în care un proces trimite un mesaj către un alt proces:

#include "mpi.h"
#include <stdio.h>
#include <stdlib.h>
 
int main (int argc, char *argv[])
{
    int  numtasks, rank, len;
    char hostname[MPI_MAX_PROCESSOR_NAME];
 
    MPI_Init(&argc, &argv);
    MPI_Comm_size(MPI_COMM_WORLD, &numtasks); // Total number of processes.
    MPI_Comm_rank(MPI_COMM_WORLD,&rank); // The current process ID / Rank.
    MPI_Get_processor_name(hostname, &len);
 
    srand(42);
    int random_num = rand();
    printf("Before send: process with rank %d has the number %d.\n", rank,
            random_num);
 
    if (rank == 0) {
        MPI_Send(&random_num, 1, MPI_INT, 1, 0, MPI_COMM_WORLD);
    } else {
        MPI_Status status;
        MPI_Recv(&random_num, 1, MPI_INT, 0, 0, MPI_COMM_WORLD, &status);
        printf("Process with rank %d, received %d with tag %d.\n",
                rank, random_num, status.MPI_TAG);
    }
 
    printf("After send: process with rank %d has the number %d.\n", rank,
            random_num);
 
    MPI_Finalize();
 
}

Când un proces X trimite un mesaj către un proces Y, tag-ul T al mesajului din MPI_Send, executat de procesul X, trebuie să fie același cu tag-ul mesajului din MPI_Recv, executat de procesul Y, deoarece procesul Y așteaptă un mesaj care are tag-ul T, altfel, dacă sunt tag-uri diferite, programul se va bloca.

O ilustrație a modului cum funcționează împreună funcțiile MPI_Send și MPI_Recv:

Mai jos aveți un exemplu în care un proces trimite un întreg array de 100 de elemente către un alt proces:

Click pentru exemplu

Click pentru exemplu

#include "mpi.h"
#include <stdio.h>
#include <stdlib.h>
 
int main (int argc, char *argv[])
{
    int numtasks, rank, len;
    int size = 100;
    char hostname[MPI_MAX_PROCESSOR_NAME];
    int arr[size];
 
    MPI_Init(&argc, &argv);
    MPI_Comm_size(MPI_COMM_WORLD, &numtasks);
    MPI_Comm_rank(MPI_COMM_WORLD,&rank);
    MPI_Get_processor_name(hostname, &len);
 
    srand(42);
    if (rank == 0) {
        for (int i = 0; i < size; i++) {
            arr[i] = i;
        }
 
        printf("Process with rank [%d] has the following array:\n", rank);
        for (int i = 0; i < size; i++) {
            printf("%d ", arr[i]);
        }
        printf("\n");
 
        MPI_Send(arr, size, MPI_INT, 1, 1, MPI_COMM_WORLD);
        printf("Process with rank [%d] sent the array.\n", rank);
    } else {
        MPI_Status status;
        MPI_Recv(arr, size, MPI_INT, 0, 1, MPI_COMM_WORLD, &status);
        printf("Process with rank [%d], received array with tag %d.\n",
                rank, status.MPI_TAG);
 
        printf("Process with rank [%d] has the following array:\n", rank);
        for (int i = 0; i < size; i++) {
            printf("%d ", arr[i]);
        }
        printf("\n");
    }
 
    MPI_Finalize();
 
}
MPI_Bcast

MPI_Bcast reprezintă o funcție prin care un proces trimite un mesaj către toate procesele din comunicator (message broadcast), inclusiv lui însuși.

În cadrul implementării MPI_Bcast sunt executate acțiunile de trimitere și de recepționare de mesaje, așadar nu trebuie să apelați MPI_Recv.

Semnătura funcției este următoarea:

int MPI_Bcast(void* data, int count, MPI_Datatype datatype, int root, MPI_Comm communicator), unde:

  • data (↓ + ↑) - reprezintă datele care sunt transmise către toate procesele. Acest parametru este de tip input pentru procesul cu identificatorul root și este de tip output pentru restul proceselor.
  • count (↓) - dimensiunea datelor trimise
  • datatype (↓)- tipul datelor trimise
  • root (↓) - rangul / identificatorului procesului sursă, care trimite datele către toate procesele din comunicator, inclusiv lui însuși
  • tag (↓) - identificator al mesajului
  • communicator (↓) - comunicatorul în cadrul căruia se face trimiterea datelor către toate procesele din cadrul acestuia

O ilustrație care arată cum funcționează MPI_Bcast aveți mai jos:

MPI_Scatter

MPI_Scatter este o funcție prin care un proces împarte un array pe bucăți egale ca dimensiuni, unde fiecare bucată revine, în ordine, fiecărui proces, și le trimite tuturor proceselor din comunicator, inclusiv lui însuși.

Semnătura funcției este următoarea: int MPI_Scatter(void* send_data, int send_count, MPI_Datatype send_datatype, void* recv_data, int recv_count, MPI_Datatype recv_datatype, int root, MPI_Comm communicator), unde:

  • send_data (↓) - reprezintă datele care sunt împărțite și trimise către procesele din comunicator
  • send_count (↓) - reprezintă dimensiunea bucății care revine fiecărui proces (de regulă se pune ca fiind dimensiunea_totală / număr_de_procese).
  • send_datatype (↓) - tipul datelor trimise către procese
  • recv_data (↑) - reprezintă datele care sunt primite și stocate de către procese
  • recv_count (↓) - dimensiunea datelor primite (de regulă dimensiunea_totală / număr_de_procese)
  • recv_datatype (↓) - tipul datelor primite de către procese (de regulă este același cu send_datatype)
  • root (↓) - identificatorul procesului care împarte datele și care le trimite către procesele din comunicator, inclusiv lui însuși
  • communicator (↓) - comunicatorul din care fac parte procesele (de regulă MPI_COMM_WORLD)

O ilustrație a modului cum funcționează MPI_Scatter:

MPI_Gather

MPI_Gather este o funcție care reprezintă inversul lui MPI_Scatter, în sensul că un proces primește elemente de la fiecare proces din comunicator, inclusiv de la el însuși, și le unifică într-o singură colecție.

Semnătura funcției este următoarea: int MPI_Gather(void* send_data, int send_count, MPI_Datatype send_datatype, void* recv_data, int recv_count, MPI_Datatype recv_datatype, int root, MPI_Comm communicator), unde:

  • send_data (↓) - reprezintă datele care trimise de fiecare proces către procesul cu id-ul root
  • send_count (↓) - reprezintă dimensiunea bucății trimisă de fiecare proces (de regulă se pune ca fiind dimensiunea_totală / număr_de_procese).
  • send_datatype (↓) - tipul datelor trimise de către procese
  • recv_data (↑) - reprezintă datele care sunt primite și stocate de către procesul root
  • recv_count (↓) - dimensiunea datelor primite (de regulă dimensiunea_totală / număr_de_procese)
  • recv_datatype (↓) - tipul datelor primite de către procesul root (de regulă este același cu send_datatype)
  • root (↓) - identificatorul procesului care primește datele (inclusiv de la el însuși)
  • communicator (↓) - comunicatorul din care fac parte procesele (de regulă MPI_COMM_WORLD)

O ilustrare a modului cum funcționează MPI_Gather:

Mai jos aveți un exemplu de MPI_Scatter folosit împreună cu MPI_Gather:

Click pentru exemplu

Click pentru exemplu

#include <stdio.h>
#include <stdlib.h>
#include <mpi.h>
 
#define ROOT 0
#define CHUNK_SIZE 5 // numarul de elemente per proces
 
int main (int argc, char **argv) {
    int rank, proc, a;
 
    int* arr;
    int* process_arr;
    int* result_arr;
 
    MPI_Init(&argc, &argv);
 
    MPI_Comm_rank(MPI_COMM_WORLD, &rank);
    MPI_Comm_size(MPI_COMM_WORLD, &proc);
 
    if (rank == ROOT) {
        arr = malloc (CHUNK_SIZE * proc * sizeof(int));
        for (int i = 0; i < proc * CHUNK_SIZE; ++i) {
            arr[i] = 0;
        }
    }
 
    process_arr = malloc (CHUNK_SIZE * sizeof(int));
    MPI_Scatter(arr, CHUNK_SIZE, MPI_INT, process_arr, CHUNK_SIZE, MPI_INT, ROOT, MPI_COMM_WORLD);
 
    for (int i = 0; i < CHUNK_SIZE; i++) {
        printf("Before: rank [%d] - value = %d\n", rank, process_arr[i]);
        process_arr[i] = i;
        printf("After: rank [%d] - value = %d\n", rank, process_arr[i]);
    }
 
    if (rank == ROOT) {
        result_arr = malloc (CHUNK_SIZE * proc * sizeof(int));
    }
 
    MPI_Gather(process_arr, CHUNK_SIZE, MPI_INT, result_arr, CHUNK_SIZE, MPI_INT, ROOT, MPI_COMM_WORLD);
 
    if (rank == ROOT) {
        for (int i = 0; i < CHUNK_SIZE * proc; i++) {
            printf("%d ", result_arr[i]);
        }
        printf("\n");
    }
 
    if (rank == ROOT) {
        free(arr);
        free(result_arr);
    }
 
    free(process_arr);
 
    MPI_Finalize();
    return 0;
}

Algoritmul inel

Algoritmul inel este un algoritm de tip undă, care funcționează pe topologii de sub formă de inel, unde fiecare nod proces are un vecin dedicat (Urm / next). În cadrul topologiilor inel, transmisia datelor se face folosind adresarea prin direcție, unde mai precis un nod proces trimite datele către vecinul său (Urm), astfel în cadrul topologiei formându-se un ciclu Hamiltonian.

În cadrul algoritmului, avem un nod proces inițiator, care trimite un jeton către vecinul său, care este pasat de fiecare proces către vecinul său de-a lungul ciclului, până când jetonul ajunge înapoi până la nodul proces inițiator.

Pseudocod:

chan token[1..n] (tok_type tok) //  canalul de comunicatie

// initiator
process P[i] {
    tok_type tok;
    send token[next](tok); // trimite token-ul la urmatorul nod
    receive token[i](tok); // primeste token-ul de la nodul precedent
    decizie;
}

// neinitiator
process P[k, k = 1..n, k != n] {
    tok_type tok;
    receive token[k](tok); // primeste token-ul de la nodul precedent
    send token[next](tok); // trimite token-ul la urmatorul nod
}

Pentru a vedea execuția pe pași al acestui algoritm, aveți această prezentare la dispoziție.

Exerciții

Scheletul de laborator este disponibil aici.

  • Exercițiul 1: Implementați algoritmul inel folosind MPI, unde procesul cu id-ul 0 trimite un număr aleatoriu către procesul 1 (vecinul său), iar apoi celelalte noduri vor primi numărul de la procesul precedent, îl incrementează cu 2 și îl trimit la următorul proces (de exemplu procesul 2 primește valoarea de la procesul 1, o incrementează și o trimite mai departe procesului 3), totul terminându-se când valoarea ajunge la procesul 0. Pentru fiecare proces trebuie să afișați rangul acestuia și valoarea primită.
  • Exercițiul 2: Implementați un program în MPI în care procesul 0 trimite o valoare generată aleatoriu către celelalte procese, folosind MPI_Bcast. Atenție: Nu folosiți MPI_Recv.
  • Exercițiul 3: Implementați un program în MPI cu N procese, în care procesul 0 inițializează un array de dimensiune 5 * N cu 0, îl împarte (dimensiunea chunk-ului fiind 5, definită în schelet) și îl trimite tuturor proceselor cu MPI_Scatter. Procesele vor aduna fiecare element primit cu rangul fiecăruia, apoi ele vor trimite array-urile pe care au efectuat operația de adunare cu rangul către procesul 0 folosind MPI_Gather. Procesul 0 va afișa ulterior array-ul rezultat din MPI_Gather.
  • Exercițiul 4: Implementați un program MPI cu 4 procese, unde 3 procese trimit o valoare către al patrulea proces, care va primi valoarea folosind MPI_ANY_SOURCE și va afișa valoarea primită și rangul procesului sursă folosind MPI_SOURCE din MPI_Status (status din MPI_Recv).

MPI_ANY_SOURCE este folosit în loc de rangul procesului sursă la MPI_Recv, indicând astfel că procesul receptor poate primi un mesaj de la oricare proces.

  • Exercițiul 5: Implementați un program MPI cu 2 procese, unde procesul 0 trimite 10 valori cu tag-uri diferite către procesul 1, iar procesul 1 primește valorile folosind parametrul MPI_ANY_TAG în MPI_Recv. Afișați valoarea primită împreună cu tag-ul acesteia folosind MPI_Status (status din MPI_Recv).

MPI_ANY_TAG este folosit în loc de tag-ul mesajului la MPI_Recv, indicând faptul că poate primi orice mesaj de la procesul sursă, neținând cont de tag-ul pe care procesul sursă îl pune mesajului la MPI_Send.

  • Exercițiul 6: Se pornește un număr de procese N divizibil cu 4. Se împart procesele în grupe de câte 4 și își trimit în cerc (inel) rangul. Se afișează vechiul rang împreună cu dimensiunea vechiului grup. Se afișează noul rang împreună cu dimensiunea noului grup. Fiecare proces are voie sa comunice doar în grupul lui. Se va folosi MPI_Comm_split. Fiecare proces va afișa rangul său din noul grup, numărul grupului și ce număr a primit.

Resurse

apd/laboratoare/08.txt · Last modified: 2022/01/19 20:30 by elena.taran
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0