Laboratorul 2 - Paralelizarea buclelor în OpenMP

Loop scheduling

În OpenMP, când o structură de tip for este paralelizată fiecărui thread îi revine un număr egal de iterații din cadrul acelui for (aceasta este configuratia default). Uneori, se întâmplă ca iterațiile să fie echilibrate între ele în ceea ce privește workload-ul, alteori nu. Când workload-ul nu este echilibrat între thread-uri, pot apărea probleme în ceea ce privește performanțele programului - prin debalansarea incarcarii (load imbalance).

Pentru a preveni situații când thread-urile au volume diferite de workload, există conceptul de scheduling în OpenMP. Pentru scheduling se folosește directiva schedule(tip_de_schedule, chunk_size), unde tipul de schedule poate fi: static, dynamic, guided, auto. Specificarea dimensiunii unui chunk este opționala. In cazul în care aceasta nu este specificată, acesta are o valoare default (in functie de tipul de schedule ales).

Exemplu de scheduling:

#pragma omp parallel for schedule(tip_de_schedule, chunk_size)
for (int i = 0; i < 100; i++) {
    // do stuff
}

Un exemplu grafic pentru cele trei tipuri de scheduling “clasic” poate fi vazut aici:

OpenMP Scheduling - RvdP@Sun

In cele ce urmeaza, vom lua pe rand cele trei tipuri de scheduling si le vom analiza individual.

Static scheduling

În cadrul static scheduling, iterațiile unui for sunt împărțite în chunks, de dimensiune chunk_size, și distribuite thread-urilor în ordine circulară. Dacă chunk_size nu este precizat, acesta va fi egal cu numărul_de_iterații_for / numărul_de_thread-uri

Iata un exemplu folosind 4 thread-uri:

Alegem chunk_size = 2

#pragma omp parallel for private(i) schedule(static, 2)
for (i = 0; i < 16; i++) {
    printf("iteration no. %d | thread no. %d\n", i, omp_get_thread_num());
}

În acest caz, distribuția iterațiilor pe thread-uri va fi în felul următor:

  • thread-ul 0: 0, 1 (chunk 1), 8, 9 (chunk 5)
  • thread-ul 1: 2, 3 (chunk 2), 10, 11 (chunk 6)
  • thread-ul 2: 4, 5 (chunk 3), 12, 13 (chunk 7)
  • thread-ul 3: 6, 7 (chunk 4), 14, 15 (chunk 8)

Alegem chunk_size = 4. Dacă nu am preciza chunk_size, default ar fi 4 în acest caz - 16 iterații și 4 threads, deci 16 / 4.

#pragma omp parallel for private(i) schedule(static, 4)
for (i = 0; i < 16; i++) {
    printf("iteration no. %d | thread no. %d\n", i, omp_get_thread_num());
}

În acest caz, distribuția iterațiilor pe thread-uri va fi în felul următor:

  • thread-ul 0: 0, 1, 2, 3 (chunk 1)
  • thread-ul 1: 4, 5, 6, 7 (chunk 2)
  • thread-ul 2: 8, 9, 10, 11 (chunk 3)
  • thread-ul 3: 12, 13, 14, 15 (chunk 4)

Static schedule este util de folosit atunci când se știe că există echilibru între iterațiile din chunks pentru un anumit chunk_size.

Cum poate influența valoarea chunk_size performanțele

Să luam următorul exemplu de cod (rulând cu 4 threads), unde w(int i) este o funcție a cărei complexitate este influențată de parametrul de intrare i:

a();
#pragma omp parallel for
for (int i = 0; i < 16; ++i) {
    w(i);
}
z();

Grafic, fără niciun tip de scheduling, performanța este ilustrată în felul următor: No scheduling

Aici se poate observa un dezechilibru în ceea ce privește workload-ul între thread-uri, thread-ul 3 având un workload dublu față de thread-ul 0.

Dacă setăm chunk_size cu 1, performanța este ilustrată în felul următor: Schedule cu chunk_size 1

În acest caz, thread-urile au un workload echilibrat, datorită distribuirii uniforme a iterațiilor din for, așadar avem o performanță bună.

Dacă setăm chunk_size cu 2, performanța este ilustrată în felul următor: Schedule cu chunk_size 2

În acest caz, se observă un dezechilibru între thread-uri din punctul de vedere al workload-ului, implicând o performanță mai proastă decât în cazul când chunk_size este setat cu 1.

Dynamic scheduling

În cadrul dynamic scheduling, iterațiile sunt împărțite în chunks de dimensiune chunk_size, ca la static scheduling, însă diferența față de static scheduling este că iterațiile nu sunt distribuite într-o anumită ordine către thread-uri, așa cum se întâmplă la static schedule. Dacă nu se precizează valoarea pentru chunk_size, atunci va avea valoarea default de 1.

Dynamic schedule este folosit atunci când avem iterații total debalansate în ceea ce privește workload-ul (în timp ce la static schedule putem intui un pattern de workload între iterații - acest lucru puteți observa în demo, în fișierul static_schedule.c).

Exemplu:

#pragma omp parallel for private(i) schedule(dynamic, 2)
for (i = 0; i < 16; i++) {
    w(i);
    printf("iteration no. %d | thread no. %d\n", i, omp_get_thread_num());
}

unde w(i) este o funcție ce are performanță aleatoare, care poate să nu depindă de parametrul de intrare.

Spre deosebire de static scheduling, la dynamic scheduling există comunicare între threads după fiecare iterație, cu scopul de a construi o împărțire a iterațiilor între thread-uri, pentru a avea workload-urile între thread-uri cât mai balansate, fapt ce duce la overhead.

După fiecare rulare, împărțirea iterațiilor către thread-uri se schimbă.

Un exemplu fara niciun tip de schedule ar fi următorul: Fara schedule

Intr-un exemplu cu dynamic schedule se poate observa că nu există distribuție uniformă a iterațiilor către thread-uri: Schedule dynamic

De reținut faptul că dynamic schedule nu rezolva mereu problemele și este posibil ca static schedule să fie o soluție mai bună, cum ar fi în acest exemplu cu schedule static si chunk_size = 1: Schedule random static

Guided scheduling

Guided scheduling se aseamănă cu dynamic schedule, în sensul că avem o împărțire pe chunks și o distribuire neuniformă a iterațiilor peste threadurile de executie.

Diferența față de dynamic schedule constă în dimensiunea chunk-urilor. Dimensiunea unui chunk este proporțională cu numărul de iterații neasignate în acel moment thread-ului împărțit la numărul de threads, la început un chunk putând avea dimensiunea nr_iterații / nr_threads, ca pe parcurs să scadă dimensiunea acestuia. De exemplu la Valoarea la chunk_size (dacă nu avem, default este 1) reprezintă dimensiunea minimă pe care o poate avea un chunk (se poate ca ultimele chunk-uri să aibă o dimensiune mai mică decât dimensiunea dată unui chunk).

Exemplu:

#pragma omp parallel for private(i) schedule(guided, 2)
for (i = 0; i < 16; i++) {
    w(i);
    printf("iteration no. %d | thread no. %d\n", i, omp_get_thread_num());
}

Guided schedule este folosit când între iterații există un dezechilibru major în ceea ce privește workload-ul.

Auto scheduling

La auto scheduling, tipul de scheduling (static, dynamic, guided) este determinat la compilare și/sau la runtime.

Exemplu:

#pragma omp parallel for private(i) schedule(auto)
for (i = 0; i < 16; i++) {
    w(i);
    printf("iteration no. %d | thread no. %d\n", i, omp_get_thread_num());
}

Clauza nowait

Atunci când paralelizăm un for, există o barieră după fiecare for paralelizat, unde se așteaptă ca toate thread-urile din for-ul paralelizat să ajungă în același punct în același timp, ca apoi să-și continue execuția.

Exemplu:

#pragma omp parallel
{
    #pragma omp for private(i)
    for (i = 0; i < 16; i++) {
        c(i);
    }
    d();
}

În exemplul de mai sus, thread-urile din for-ul paralelizat trebuie să ajungă în același punct (să se sincronizeze), ca apoi să execute funcția d(). Blocare pana la terminarea buclei for

Pentru a elimina această barieră / sincronizare, putem folosi directiva nowait, prin care thread-urile nu mai așteaptă ca fiecare să ajungă în același punct (să fie sincronizate încât să fie în același punct în același timp), astfel un thread, după ce trece de for-ul paralelizat, trece imediat la execuția următoarelor instrucțiunilor, fără să mai aștepte după celelalte thread-uri.

Exemplu:

#pragma omp parallel
{
    #pragma omp for nowait private(i)
    for (i = 0; i < 16; i++) {
        c(i);
    }
    d();
}

Rularea rezultata arata astfel: Rulare utilizand nowait

Directiva Reduction

reduction este o directivă folosită pentru operații de tip reduce / fold pe arrays / colecții sau simple însumări / înmulțiri în cadrul unui loop. Mai precis, elementele dintr-un array sau indecșii unui loop sunt “acumulați” într-o singură variabilă, cu ajutorul unei operații, al cărui semn este precizat, in mod automat sincronizat peste mai multe threaduri. Nu orice operatie poate fi utilizata intr-o operatie de tip reduction.

Tipar: reduction(operator_operatie:variabila_in_care_se_acumuleaza)

Exemplu de reduction: reduction(+:sum), unde se însumează elementele unui array în variabila sum

Exemplu de folosire de reduction:

int sum = 0;
#pragma omp parallel for reduction(+:sum) private(i)
for (i = 1; i <= num_steps; i++) {
    sum += i;
}

Exerciții

1) Rulați Demo laborator 2 legate de scheduling și modificați valorile la chunk_size, unde să faceți observații legate de performanțe. De asemenea, puteți schimba și tipul de schedule, pentru a observa eventuale schimbări în privința performanței.

2) Paralelizați fișierul atan.c din Schelet laborator 2 folosind reduction.

3) Rulați programul din fișierul schedule.c din schelet de mai multe ori și schimbați tipurile de schedule (cu tot cu chunk_size) și observați performanțele.

4) Generați fișiere folosind scriptul gen_files.sh și paralelizați programul din fișierul count_letters.cpp din schelet.

Resurse

Referinte

app/laboratoare/02.txt · Last modified: 2022/10/19 14:58 by emil.slusanschi
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