Sunteți pe pagina 1din 39

Capitolul 1

Limitele procesării secvenţiale şi necesitatea programării


paralele sau distribuite

Modelul clasic de execuţie a unei aplicaţii constă în procesarea sa secvenţială,


instrucţiune cu instrucţiune. Astfel, se consideră că există un indicator al instrucţiunii aflate în
execuţie, care parcurge aplicaţia (nu neapărat în ordine), începând cu prima instrucţiune.
Instrucţiunea curentă preia din memorie datele necesare, le prelucrează (pe procesorul
sistemului de calcul), apoi realizează în memorie actualizările necesare (dacă este cazul) şi
predă următoarei instrucţiuni care trebuie executate controlul unităţii de procesare. Acest
grup de activităţi se realizează până când aplicaţia se încheie. Acest model descrie execuţia
unei aplicaţii pe un sistem de calcul mono-procesor, realizat conform arhitecturii von
Neumann.

1.1. Arhitectura de tip von Neumann

Savantul american de origine ungară John von Neumann a propus în 1945 cel mai
cunoscut model al unui calculator. Această descriere conceptuală, realizată când tehnologia
era la nivel de pionierat, este şi astăzi considerată una dintre cele mai importante etape în
dezvoltarea ştiinţei calculatoarelor.
Modelul descris în [13] (cunoscut mai târziu sub
numele de modelul von Neumann sau arhitectura
Princeton) stabileşte următoarele caracteristici ale unui
sistem de calcul:
• Calculatorul este compus din: memorie, unitatea
aritmetico-logică, unitatea de control şi
periferice;
• În timpul execuţiei, programul este stocat în
memorie;
• La un anumit moment, memoria poate fi
accesată fie de unitatea de control, fie de
unitatea aritmetico-logică;
• Instrucţiunile programului se execută secvenţial.

John von Neumann


(1903-1957)

La momentul respectiv, sistemul de calcul era considerat independent, procesorul


(alcătuit din unitatea aritmetico-logică şi unitatea de control) realizând accesul la memorie.
Pe lângă faptul că instrucţiunile programului se execută secvenţial, memoria nu poate fi
accesată simultan de unitatea de control şi de unitatea aritmetico-logică (adică nu se pot
produce modificări în starea programului simultan cu transferul de date), deoarece există o

7
singură magistrală de date între memorie şi procesor. O alternativă la modelul von Neumann
este modelul Harvard, caracterizat prin tipuri diferite de memorii: unul pentru date (de tip
citire/scriere) şi altul pentru program (de tip read-only). Astfel, se pot executa simultan
schimbări în starea programului şi citiri/scrieri de date. Modelul Harvard modificat permite
ca memoria dedicată programului să fie tot de tip read/write. Acest model este utilizat de
diverşi producători de procesoare (familia ARM9 este un exemplu) sau este folosit pentru
dispozitive specializate (microcontrolere).
Dezvoltarea tehnologică ulterioară şi necesităţile tot mai mari de putere de calcul au
evidenţiat limitele modelului von Neumann, astfel că au apărut diverse metode de creştere a
vitezei de execuţie a unui program. De exemplu, memoriile intermediare de tip cache oferă
acces mai rapid la datele necesare execuţiei unei instrucţiuni. Posibilitatea accesului rapid la
date şi dezvoltările hardware au dus la realizarea în 2005 a primelor procesoare dual-cores,
care sunt de fapt două procesoare cu aspect fizic unitar, care posedă un sistem de memorii
cache. Au urmat apoi procesoare din ce în ce mai complexe, cu arhitecturi care utilizează
ierarhii din ce în ce mai dezvoltate de memorii cache, cum este cel din figura 1.

Figura 1. Arhitectura procesorului 8-cores AMD FX - Bulldozer [18]

La un alt nivel arhitectural, aceeaşi problemă a necesităţii de resurse de calcul din ce


în ce mai puternice, a fost rezolvată în anii 70 ai secolului trecut prin apariţia calculatoarelor
multi-procesor: sisteme de calcul care includ mai multe procesoare alăturate, conectate prin
magistrale şi care partajează memorie comună, astfel încât manevra datelor să fie rapidă.
Supercalculatoarele actuale sunt sisteme de calcul multi-procesor cu caracteristici uimitoare
fiind utilizate în cercetarea fenomenelor globale [28].
Din momentul în care s-au putut cupla mai multe procesoare s-a deschis posibilitatea
executării pe un sistem de calcul a mai multor aplicaţii simultan. De asemenea, conectarea
calculatoarelor independente în reţele a realizat efectiv modelul cuplării prin magistrale
externe plăcilor de bază a mai multor sisteme independente de calcul. Programarea
secvenţială devine de acum o opţiune, nu o cerinţă (impusă de modelul von Neumann).
Calea către programarea paralelă sau distribuită a fost deschisă de dezvoltarea
hardware, de explozia comunicaţiilor electronice şi de necesităţile în continuă creştere ale
societăţii.

1.2 Calcul concurent, calcul paralel, calcul distribuit

Sistemul de operare al unui calculator mono-procesor este pus în situaţia de a


coordona mai multe procese şi a le executa în mod concurent. De exemplu, transferurile de
date de la/către perifericele de intrare-ieşire, execuţia unor aplicaţii, etc. se realizează

8
simultan din punctul de vedere al utilizatorului, fără ca acesta să observe sincope. Pe de altă
parte, arhitectura mono-procesor impune execuţia unei singure aplicaţii la un anumit moment
de timp. Metoda prin care se realizează aceste procese se numeşte multitasking şi înseamnă
partajarea timpului de lucru al procesorului în intervale disjuncte şi alocarea acestor intervale
diverselor procese aflate în execuţie. Cum aceste intervale sunt foarte scurte, utilizatorul nu
percepe schimbările realizate de sistemul de operare, ci are impresia că toate procesele se
desfăşoară simultan.

Calculul concurent este o caracteristică software, opusă calculului serial, care permite
execuţia simultană a mai multor aplicaţii. De exemplu, pe un procesor mono-nucleu, calculul
concurent se poate realiza prin multitasking (programele în execuţie primesc intervale
disjuncte de timp pentru accesarea procesorului; la fiecare moment de timp un singur
program se execută; la expirarea timpului alocat, starea programului în execuţie se salvează şi
se dă controlul procesorului altui program; aceste schimbări se realizează des, astfel încât
utilizatorul are senzaţia că toate programele se execută în paralel). Pe un calculator multi-
procesor, care deci este alcătuit din mai multe procesoare care partajează memorie comună,
calculul concurent se realizează efectiv sub forma calculului paralel (care este deci o
proprietate hardware). Pe de altă parte, în cazul reţelelor de calculatoare, alcătuite din sisteme
independente, cu memorii independente, calculul concurent capătă forma calculului distribuit
(care este, de asemenea, caracteristică hardware).

După modelul de succes al sistemelor de operare, care gestionează simultan zeci de


procese, programatorii actuali ar trebui să cunoască limbajele de programare care permit
execuţia în paralel. A scrie o aplicaţie paralelă este însă mult mai dificil decât a scrie o
aplicaţie secvenţială, deoarece necesită înţelegerea profundă a problemei, descompunerea
acesteia în sub-probleme care se pot rezolva în paralel, rezolvarea sub-problemelor şi
compunerea rezultatului problemei iniţiale din soluţiile sub-problemelor. Se poate întâmpla
ca o aplicaţie paralelă să fie, în anumite cazuri, mai ineficientă decât aplicaţia secvenţială.
Câştigurile aduse însă de aplicaţiile paralele eficiente, proiectate şi realizate pentru abordarea
unor probleme complexe, ale vieţii reale, sunt însă motive temeinice pentru ca programatorii
să înveţe limbajele calculului paralel şi distribuit.

Un exemplu în acest sens este studiul vitezei de execuţie a aplicaţiei secvenţiale şi a


celei paralele utilizate pentru calcularea unui termen al şirului lui Fibonacci. Vom nota cu
Fib(n) termenul generic al şirului lui Fibonacci; acesta se pate calcula cu formula recursivă:

 Fib(0) = Fib(1) = 1

 Fib(n) = Fib(n − 1) + Fib(n − 2), ∀n ≥ 2

Pentru a calcula în mod iterativ valoarea lui Fib(n) se poate folosi următoarea metodă:

Function f(n)
if (n < 2) return 1
x=1
y=1
z=2
k=2
while (k < n)
x=y

9
y=z
z=x+y
k=k+1
return z

Această metodă (iterativă) are avantajul că urmează şablonul matematic al construirii


valorilor şirului din aproape în aproape. Există însă o altă metodă, mai apropiată de obiectul
acestui curs, care face trecerea spre abordarea concurentă a acestei probleme, şi anume
metoda recursivă:

Function g(n)
if (n < 2) return 1
return g(n - 1)+g(n - 2)

Această metodă necesită cunoştinţe mai avansate de programare şi este mai înceată
(sau produce depăşiri de memorie) la execuţia în limbajul C, chiar pentru valori comune ale
lui n. Avantajul ei este însă că se auto-apelează (pentru n > 2) cu factor de multiplicare 2.
Aceasta înseamnă, de exemplu, că pentru aflarea lui g(8) se lansează o copie a sa care
calculează g(7) şi încă una pentru găsirea lui g(6). Fiecare dintre acestea lansează alte două
execuţii, etc.
În tabelul 1 este prezentat modul în care lucrează funcţia g dacă este apelată pentru
valoarea n = 4. Coloanele tabelului arată modul de încărcare pe stivă a copiilor lui g, până
când se calculează valoarea g(4). Rândurile tabelului arată succesiunea temporală a execuţiei
procedurii g pentru valoarea 4. Apelul 1 este apelul procedurii, care lansează imediat pe stivă
apelul 2, adică o copie a codului pentru valoarea n = 3 şi apoi intră în aşteptare (idle) până
când primeşte valoarea g(3), adică 3. Apelul 2 lansează o nouă copie a aplicaţiei, pentru a
calcula g(2), etc. Starea de execuţie este indicată prin culoarea de fundal gri; starea idle a unui
anumit proces este indicată prin diagonalele trasate. De exemplu, apelul 3 ocupă timp de 4
unităţi de timp o unitate de procesare (începând cu momentul 3), din care jumătate stă în
aşteptare (este idle). Se observă că pe stivă, pentru n = 4, vor exista cel mult 4 copii în
execuţie şi că unele copii stau în aşteptare destul de mult timp, astfel explicându-se faptul că
execuţia lui g este mai înceată decât cea a lui f, pentru aceeaşi valoare a lui n.

Apel 1 Apel 2 Apel 3 Apel 4


(main)
realizat de: - Apel 1 Apel 2 Apel 3
Momente de
timp
1 g(4)
2 g(3)
3 g(2)
4 g(1)
5 return 1
6 g(1) = 1
7 g(0)
8 return 1
9 g(0) = 1
10 return 2
11 g(2) = 2

10
Apel 1 Apel 2 Apel 3 Apel 4
(main)
realizat de: - Apel 1 Apel 2 Apel 3
12 g(1)
13 return 1
14 g(1) = 1
15 return 3
16 g(3) = 3
17 g(2)
18 g(1)
19 return 1
20 g(1) = 1
21 g(0)
22 return 1
23 g(0) = 1
24 return 2
25 g(2) = 2
26 return 5
Tabelul 1. Evoluţia temporală a apelului recursiv g(4)

Trecerea spre execuţia pe mai multe fire urmează modelul din tabelul 1 şi presupune
că sunt disponibile unităţi de procesare astfel încât să poată prelua execuţia la nevoie şi astfel
firele de execuţie să nu aştepte mai mult decât este necesar. Pentru lansarea succesivă a
apelurilor se introduc două funcţii specifice calculului concurent, care permit multiplicarea
execuţiei şi respectiv închiderea tuturor firelor lansate şi continuarea execuţiei în clasicul mod
serial. Aceste două noi comenzi sunt:
• spawn – procedură prin care un proces (părinte) lansează un proces (fiu)
• sync – procedură prin care se aşteptă încheierea tuturor proceselor lansate în apelul
curent prin spawn şi continuarea numai cu procesul părinte.
Folosind aceste două noi comenzi, procedura recursivă concurentă pentru calculul
valorii generice din şirul lui Fibonacci devine:

Function h(n)
if (n < 2) return 1
x = spawn h(n – 1)
//de aici aplicaţia are 1 fir-părinte + 1 fir-fiu
y = spawn h(n – 2)
//de aici aplicaţia are 1 fir-părinte + 2 fire-fiu
sync
//aici se ajunge după ce fii se încheie
return x + y

În tabelul 2 sunt evidenţiate apelurile succesive realizate de spawn şi sync, pentru


fiecare apel este menţionat şi apelul său-părinte (de exemplu, apelurile 4 şi 5 sunt lansate de
apelul 2). Apelul principal h(4) lansează iniţial în execuţie pe altă unitate de procesare
calculul variabilei x ca valoarea întoarsă la apelul h(3) şi continuă execuţia lansând la
momentul imediat următor, pe o altă unitate de procesare, calculul valorii variabilei y ca
valoarea întoarsă de h(2). Fiecare dintre aceste apeluri ocupă apoi alte două unităţi de
procesare. Se observă că la momentul 5 de timp, sunt necesare 9 unităţi de procesare, din care

11
5 lucrează şi 4 sunt în starea idle. Se observă de asemenea că apelul h(4) necesită 12 unităţi
de timp pentru execuţie, pe când apelul g(4) are nevoie de 26 unităţi de timp şi de cel mult 4
execuţii simultane.
La fel ca în tabelul 1, zonele gri reprezintă intervalele de timp în care procesoarele
sunt alocate, iar zonele care au diagonale sunt intervalele de timp în care procesoarele stau în
aşteptare (idle). Observăm însă că apelul 1 avansează şi la momentul 2, când realizează
lansarea apelului 3, spre deosebire de situaţia prezentată în tabelul 1, când este deja în
aşteptare. Şi în acest caz se observă un dezechilibru al încărcării procesoarelor, de exemplu
ultimele 5 procese lucrează 2 intervale de timp, pe când primul procesor este ocupat toate
cele 12 intervale de timp.

Apel 1 Apel 2 Apel 3 Apel 4 Apel 5 Apel 6 Apel 7 Apel 8 Apel 9


(main)
fiul lui: - Apel 1 Apel 1 Apel 2 Apel 2 Apel 3 Apel 3 Apel 4 Apel 4
Mome
nte de
timp
1 h(4)
2 h(3)
3 h(2) h(2)
4 h(1) h(1) h(1)
5 return return h(0) return h(0)
1 1 1
6 h(1)=1 h(1)=1 h(1)=1 return return
1 1
7 h(0)=1 h(0)=1
8 return return
2 2
9 h(2)=2 h(2)=2
10 return
3
11 h(3)=3
12 return
5
Tabelul 2. Evoluţia temporală a apelului recursiv paralel h(4)

1.3 Caracteristicile aplicaţiilor concurente

Arhitectura von Neumann reprezintă modelul unui sistem de calcul cu un singur


element de procesare. Natura însă a evoluat dintotdeauna prin procese concurente. În acest
sens pot fi considerate:
• dezvoltarea unui organism, alcătuit din organe cu rol specific, care lucrează
conform scopului lor dar şi în corelare cu celelalte organe;
• evoluţia unui sistem social (sistem complex, alcătuit din indivizi oarecum
asemănători care interacţionează, de exemplu: un stup de albine, un banc de peşti,
o turmă de animale, populaţia unui continent, posesorii de conturi Facebook, etc.).
Acest model este preluat în zilele noastre cu succes de reţelele de calculatoare sau de
telefonie mobilă, de sistemele interconectate de calcul care permit tranzacţiile online, dar şi

12
de supercalculatoare, care oferă în mod curent acces simultan la mii de procesoare. Calculul
concurent nu este decât modelul actual a ceea ce natura face de milioane de ani: se dezvoltă
prin evoluţii simultane ale ecosistemelor, populaţiilor, evenimentelor geologice, etc.
Tehnologia ne permite ca mişcarea planetelor, evenimentele climatice, migraţiile păsărilor,
servirea clienţilor la un magazin, conducerea corporaţiilor trans-naţionale, tranzacţiile
bancare sau vizionarea filmelor să fie acum studiate sau realizate prin sisteme de calcul
paralel sau distribuit. Globalizarea lumii în care trăim este posibilă şi datorită dezvoltării
calculului concurent.
Odată cu dezvoltarea tehnologică şi apariţia sistemelor de calcul care pot oferi acces
simultan la mai multe unităţi de procesare, cercetătorii au realizat diverse clasificări ale
acestor sisteme. Clasificarea următoare se referă la funcţionalitatea sistemelor de calcul, deci
la modul în care aplicaţia realizează prelucrarea datelor.

Clasificarea Flynn (Michael Flynn, 1966)


Una dintre cele mai vechi clasificări ale prelucrărilor electronice consideră numărul de
prelucrări care se pot realiza simultan şi de fluxuri de date care se pot procesa în acelaşi timp.
Conform acestei clasificări, calculul electronic se poate realiza prin:
• Single Instruction Singe Data (SISD) – procesare serială, aplicaţia avansează
instrucţiune cu instrucţiune, pe un singur fir de execuţie, cu un singur set de date.
Un astfel de exemplu este calculul pe un PC dotat cu un procesor mono-nucleu.
• Single Instruction Multiple Data (SIMD) – aplicaţia avansează instrucţiune cu
instrucţiune, pe un singur fir de execuţie, procesând simultan mai multe seturi de
date. O astfel de situaţie este întâlnită în cazul procesorului vectorial, care
manevrează simultan datele, utilizând un vector de memorii. Astfel de procesoare
sunt utilizate când există colecţii de date care necesită aceeaşi prelucrare; de
exemplu, procesarea grafică pentru jocuri.
• Multiple Instruction Single Data (MISD) – aplicaţia constă din mai multe fire de
execuţie care procesează acelaşi set de date. Aceste arhitecturi nu apar foarte des
în practică, unul dintre cazuri este calculul de toleranţă zero la erori, când se
prelucrează pe mai multe fire de execuţie acelaşi set de date, în vederea mascării
(eliminării) erorilor.
• Multiple Instruction, Multiple Data (MIMD) - aplicaţia constă din mai multe fire
de execuţie care procesează mai multe seturi de date. În general, un fir de execuţie
este alocat unui nucleu. Acest tip de procesare este realizat de marea majoritate a
supercalculatoarelor din Top 500 actual [28]. La rândul lor, procesările de tip
MIMD pot fi:
o Single Program, Multiple Data (SPMD) – când copii ale aplicaţiei se
execută pe mai multe seturi de date, fiecare proces având evoluţie proprie
(deoarece seturile diferite de date conduc la trasee diferite în execuţie,
controlate de instrucţiuni if, case/switch, while, do-while sau for). Acestea
sunt cel mai des întâlnite situaţii, când codul unei aplicaţii descrie
procesările fiecărui fir de execuţie.
o Multiple Program, Multiple Data (MPMD) – când o aplicaţie de tip master
lansează pe alte unităţi de procesare (nuclei - cores) alte aplicaţii de tip
slave.

13
Aplicaţiile concurente se pot evalua folosind clasicele măsuri software, destinate
tuturor categoriilor de aplicaţii. Aceste măsuri se pot clasifica astfel:
• măsuri cantitative: număr de linii de cod, mărimea (în biţi) a fişierului executabil,
viteza de execuţie, numărul de ore de programare necesare proiectării, dezvoltării
şi testării, etc.;
• măsuri calitative: corectitudine (rezolvă problema propusă), completitudine
(rezolvă toate cazurile posibile), scalabilitate (capabilă de a procesa chiar şi
volume de date mai mari decât cele aşteptate, sau se poate uşor dezvolta astfel
încât să le accepte), mentenabilitate (uşurinţa în exploatare şi actualizare), etc.
Măsurile software specifice programelor care se avansează simultan pe mai multe fire
de execuţie se referă atât la caracteristicile de consum total ale resurselor implicate (timp de
procesare, număr de procesoare), cât şi la economiile realizate prin utilizarea acestora (cât
timp se câştigă dacă utilizez în paralel mai multe procesoare, decât dacă folosesc un singur
procesor). În continuare sunt prezentate câteva măsuri specifice calculului concurent.

Acceleraţia (speed-up) este raportul dintre timpul de execuţie pe o singură unitate de


procesare şi timpul de execuţie pe n astfel de unităţi. Este o măsură a-dimensională, care în
mod normal este supraunitară – dacă aplicaţia are o acceleraţie subunitară, atunci varianta
paralelă nu se justifică, deoarece necesită un timp de execuţie mai mare decât varianta serială.
Dacă se notează:
T(n) = timpul necesar execuţiei pe n unităţi de procesare (nuclei),
atunci acceleraţia are expresia:
T (1)
S (n) =
T ( n)
În cazul unei descompuneri ideale pe n fire de execuţie, fără costuri suplimentare
datorate comunicaţiilor şi dacă cele n fire de execuţie reuşesc fiecare să se încheie în timpul
T (1)
, atunci acceleraţia este n. Din formula acceleraţiei se deduce deci că o valoare teoretică
n
maximă a acestei mărimi este n.

Eficienţa (parallel efficiency) este o măsură derivată din acceleraţie, fiind raportul
dintre aceasta şi numărul de procesoare utilizate:
S (n)
E ( n) =
n
În baza observaţiei anterioare, deducem că eficienţa unei aplicaţii concurente are
întotdeauna o valoare subunitară, iar eficienţa unei aplicaţii concurente ideale este 1.

Echilibrul (balance) se referă la o caracteristică generală a execuţiei pe diverse unităţi


de procesare (poate fi şi timpul necesar execuţiei), măsurată prin valorile q i 1 ≤ i ≤ n unde n
este numărul nucleilor:
q
B(n) = min
q max
De exemplu, dacă ne referim la q i 1 ≤ i ≤ n ca fiind timpul de execuţie pe procesorul
2
i, atunci echilibrul aplicaţiei h care lucrează ca în Tabelul 2 este B ( n) = = 16,7% . În
12
general, se urmăreşte ca aplicaţiile concurente să fie echilibrate, adică B(n) să fie cât mai
aproape de 100%.

14
Din punct de vedere constructiv, calculatoarelor paralele li se pot aplica diverse
măsuri: numărul de nuclei, memoria RAM, consumul de energie, viteza de procesare. O
măsură a vitezei de procesare ieşită din uz este numărul de instrucţiuni pe secundă. Cum
aceste instrucţiuni pot necesită intervale de tip de lungimi diferite pentru execuţie, acum se
utilizează numărul de operaţii în virgulă mobilă pe secundă (floating point operations per
second – FLOP/s).

Multiplii unităţii de măsură FLOP/s sunt:


1 kFLOP/s = 103 FLOP/s (kilo)
1 MFLOP/s = 106 FLOP/s (mega)
1 GFLOP/s = 109 FLOP/s (giga)
1 TFLOP/s = 1012 FLOP/s (tera)
1 PFLOP/s = 1015 FLOP/s (peta)
1 EFLOP/s = 1018 FLOP/s (exa)
1 ZFLOP/s = 1021 FLOP/s (zetta)
1 YFLOP/s = 1024 FLOP/s (yotta)
Procesoarele actuale pot realiza 4 operaţii în virgulă mobilă pe unitate de ceas (clock
cycle). Un procesor de 2,5 GHz cu 4 nuclei poate realiza deci
4 ⋅ 4 ⋅ 2,5 GFLOP / s = 40 GFLOP / s .

Dezvoltarea tehnologică a fost surprinsă în anii ’60 de o observaţie empirică, al cărei


adevăr s-a menţinut până acum. Acest enunţ este cunoscut ca Legea lui Moore (Gordon
Moore, 1965) şi trebuie înţeles ca o constatare empirică observată a fi adevărată din 1965 şi
până azi:
Performanţele procesoarelor (măsurate prin numărul de tranzistoare care formează
un circuit integrat) se dublează la fiecare 2 ani.

Legea lui Amdahl (Gene Amdahl, 1967)


Acceleraţia aplicaţiilor paralele are o creştere sub-liniară odată cu creşterea
numărului de procesoare.
Presupunem o aplicaţie concurentă oarecare, pentru care notăm T (1) = a + b , unde a
este intervalul de timp necesar execuţiei părţii ne-paralelizabile şi b este timpul necesar
execuţiei părţii paralelizabile pe o singură unitate de procesare. Înlocuind aceste valori în
formula acceleraţiei, obţinem:
a+b
S (n) =
b
a+
n
În cazul unui echilibru perfect şi dacă nu sunt necesare alte instrucţiuni pentru
repartizarea, controlul firelor de execuţie, colectarea datelor, etc., atunci pe n unităţi de
b
procesare se poate executa această parte a aplicaţiei în timpul a + . Trecând la limită, avem
n
că:
a+b
lim S (n) =
n →∞ a
Cum această valoare este o constantă care depinde de aplicaţia în cauză, înseamnă că
există un prag al numărului de procesoare începând de la care adăugarea de noi procesoare nu
mai determină o îmbunătăţire semnificativă a acceleraţiei. Această concluzie se menţine chiar
în cazul unei aplicaţii perfect echilibrate.

15
Legea lui Gustafson (John Gustafson, 1988)
Pe o configuraţie hardware dată (numărul de procesoare n fixat) se pot scrie aplicaţii
paralele care au acceleraţie liniară.
Cu notaţiile anterioare avem că:
a
+1
lim S (n) = lim b =n
b →∞ b →∞ a 1
+
b n
Deci dacă partea paralelizabilă este suficient de mare, atunci acceleraţia este aproximativ
egală cu numărul de procesoare. Legea lui Gustafson specifică o regulă simplă: dacă vrem să
ne apropiem de eficienţa ideală a unei aplicaţii paralele, partea paralelizată trebuie să fie cât
mai consistentă.

Gordon Earl Moore (1929 - ) Gene Myron Amdahl (1922 - ) John Gustafson (1955 - )

1.4 Metode de scriere a aplicaţiilor concurente

Paralelizarea aplicaţiilor seriale deja scrise necesită o înţelegere profundă a problemei


rezolvate serial, pentru a obţine o aplicaţie eficientă. Cum la momentul scrierii codului nu se
cunoaşte arhitectura pe care se va executa, programatorul ar trebui să aleagă o metodă care să
funcţioneze cât mai eficient în cât mai multe cazuri, cu diverse date de intrare. Una dintre
provocările calculului concurent este proiectarea firelor de execuţie astfel încât acestea să
lucreze echilibrat (conform definiţiei din subcapitolul anterior).
Un exemplu în acest sens este modul în care se adună în paralel, pe un multi-
calculator cu 4 procesoare, 16 valori numerice, stocate într-un vector a (i )1≤i ≤16 . Presupunem
că datele sunt stocate pe fiecare procesor. Dacă s-ar împărţi vectorul în 4 zone:
{a (1), a (2), a (3), a (4)} , {a (5), a (6), a (7), a (8)} , {a (9), a (10), a (11), a (12)} şi
{a (13), a (14), a (15), a (16)} , fiecare alocată unui procesor, atunci toate cele 4 procesoare ar fi
perfect echilibrate deoarece ar lucra acelaşi interval de timp pentru a calcula suma parţială
corespunzătoare zonei de vector alocate, iar la sfârşit unul dintre ele ar colecta valorile găsite
de celelalte 3 şi ar afişa rezultatul obţinut. Această metodă este cea care asigură echilibrul cel
mai bun pentru aplicaţia paralelă.
Dacă însă avem la dispoziţie 16 calculatoare legate într-o reţea locală (LAN) peer-to-
peer de tip inel şi câte o componentă a vectorului este stocată pe fiecare calculator, atunci

16
Capitolul 2

Paralelism implicit

Arhitectura sistemului de calcul poate fi orientată prin diverse dezvoltări tehnologice


spre creşterea vitezei de procesare. Aceste metode fizice de paralelizare alcătuiesc facilităţile
de paralelism implicit, oferite de caracteristicile resurselor de calcul utilizate. În contrast,
programarea concurentă, adică specificarea prin program a proceselor care se desfăşoară
simultan reprezintă paralelismul explicit, care va fi abordat în capitolul următor.
Paralelismul implicit speculează noile facilităţi tehnologice; acum procesoarele nu
mai execută secvenţial instrucţiunile unui program, ci există diverse metode prin care acestea
se pot suprapune temporal. Aceste metode sunt:
• procesare de tip linie de asamblare
• procesare super-scalară
• folosirea procesoarelor care manevrează cuvinte lungi
• ierarhiile de memorii cache
• utilizarea datelor contigue (ţine de programare, dar foloseşte stocarea la adrese
fizice a datelor).

2.1. Procesare de tip linie de asamblare

Procesarea de tip linie de asamblare (pipelining) se referă la fragmentarea


instrucţiunilor executate pe un procesor, astfel încât să fie posibilă execuţia lor simultană,
asemănător lucrului pe o linie de asamblare. La nivel teoretic, se consideră că o instrucţiune
se realizează pe procesor în 5 faze: instruction fetch (IF - încărcarea instrucţiunii), decode (D
- traducere), data fetch (DF - încărcarea datelor) execute (E - procesare efectivă) şi write-back
(WB - scriere a rezultatului în memorie). Practic însă, fiecare procesor are propriile faze de
lucru. De exemplu, AMD FX are 15 faze pentru procesarea valorilor întregi.
Executarea unei aplicaţii, dacă operaţiile de manevră a datelor permit, s-ar putea
desfăşura la nivel optim ca în figura 2 (se execută integral 6 instrucţiuni în 10 unităţi de ceas).

Timpul
0 1 2 3 4 5 6 7 8 9
IF D DF E WB
IF D DF E WB
IF D DF E WB
IF D DF E WB
IF D DF E WB
IF D DF E WB
Figura 2. Dezvoltarea temporală pentru o procesare ideală de tip pipeline

În situaţia ideală prezentată în figura 2 se observă că procesoarele moderne pot


executa în paralel mai multe faze din instrucţiuni diferite (în intervalul 5 sunt deja în execuţie

19
toate fazele, câte una din primele 5 instrucţiuni). Această situaţie nu apare mereu în realitate,
deoarece pot apărea întârzieri. De exemplu:
• dacă instrucţiunea curentă are nevoie de încărcarea unor date (faza DF) care la
momentul respectiv sunt actualizate printr-o fază de WB a unei instrucţiuni
anterioare. În acest caz aşteptarea este obligatorie.
• dacă instrucţiunea din care se doreşte execuţia fazei IF face parte dintr-o
instrucţiune alternativă (if-then-else), dar încă nu s-a produs faza E din aceasta,
deci la momentul respectiv nu se cunoaşte pe care ramură avansează aplicaţia. În
acest caz se pot utiliza resurse suplimentare pentru încărcarea simultană a ambelor
ramuri ale instrucţiunii if, iar când se cunoaşte care ramură este cea aleasă, se
renunţă la cealaltă. Această rezolvare se utilizează în mod curent, deoarece situaţia
apare des în programare – aproximativ 15% din instrucţiunile unei aplicaţii sunt
instrucţiuni alternative. Sub numele de descompunere speculativă, aceeaşi idee
este tratată în subcapitolul 4.2.
Procesarea de tip pipeline este numită şi procesare scalară, deoarece permite
creşterea vitezei de procesare liniar cu creşterea numărului de faze din instrucţiuni diferite
care se pot executa simultan.

2.2. Procesare super-scalară

O altă posibilitate de realizare a paralelismului implicit este construcţia unor


procesoare super-scalare, prin adăugarea de resurse suplimentare. De exemplu, prin
utilizarea a două unităţi aritmetico-logice, se poate considera că un procesor poate lucra
simultan cu două linii de execuţie (ca în figura 3), controlate de o unitate specifică, numită
dispecer.

Timpul
0 1 2 3 4 5 6 7 8 9
IF D DF E WB
IF D DF E WB
IF D DF E WB
IF D DF E WB
IF D DF E WB
IF D DF E WB
IF D DF E WB
IF D DF E WB
IF D DF E WB
IF D DF E WB
IF D DF E WB
IF D DF E WB
Figura 3. Dezvoltarea temporală pentru o procesare ideală cu două fluxuri pipeline

Acest tip de procesare asigură, în cazul ideal, o viteză dublă faţă de procesarea pe un
singur flux pipeline (în exemplul din figura 3 se execută integral 12 instrucţiuni în 10 unităţi
de ceas). Exemplul următor arată că modul de programare este esenţial pentru viteza efectivă
de execuţie [7].
Să presupunem că dorim să adunăm 4 valori întregi, stocate fiecare pe 4 octeţi, în
variabilele a, b, c şi d, aflate în memorie începând cu adresa hexazecimală 1000. Prima

20
versiune a codului în limbaj de asamblare realizează ((a + b) + (c + d)). Figura 4 arată timpul
necesar acestei versiuni.

Versiunea 1
1 load R1, @1000
2 load R2, @1008
3 add R1, @1004
4 add R2, @100C
5 add R1, R2
6 store R1, @2000

Timpul
0 1 2 3 4 5 6 7 8 9 10 11
IF D DF
IF D DF
IF D DF E
IF D DF E
IF D DF E
IF D DF E WB
Figura 4. Dezvoltarea temporală pentru versiunea 1 cu 2 fluxuri pipeline

În acest caz, instrucţiunile 1 şi 2 se pot lansa simultan, deoarece sunt independente.


Fiind instrucţiuni de preluare în regiştri, nu mai conţin şi fazele execute şi write-back. La fel,
perechea de instrucţiuni 3 şi 4 se lansează simultan. Instrucţiunea 6 este dependentă de a
cincea, astfel că faza data fetch este întârziată. Şi instrucţiunea 5 este nevoită să aştepte
finalizarea celor două precedente pentru a intra în faza data fetch. Astfel, întreaga secvenţă de
cod necesită 10 unităţi de timp.
Considerând acum aceeaşi metodă de calcul, dar inter-schimbând instrucţiunile 2 şi 3,
obţinem:

Versiunea 2
1 load R1, @1000
2 add R1, @1004
3 load R2, @1008
4 add R2, @100C
5 add R1, R2
6 store R1, @2000

Această schimbare care pare minoră conduce la prelungirea timpului de procesare, aşa
cum este arătat în figura 5.

Timpul
0 1 2 3 4 5 6 7 8 9 10 11
IF D DF
IF D DF E
IF D DF
IF D DF E
IF D DF E
IF D DF E WB
Figura 5. Dezvoltarea temporală pentru versiunea 2 cu 2 fluxuri pipeline

21
De această dată, perechea de instrucţiuni 1 şi 2 nu se mai poate lansa la acelaşi
moment, deoarece sunt dependente. La fel se întâmplă cu perechea următoare; instrucţiunea 5
aşteaptă încheierea instrucţiunii 4 pentru a intra în faza data fetch. Instrucţiunea 6 se lansează
imediat după lansarea instrucţiunii 5 şi astfel versiunea 2 propusă mai sus necesită 11 unităţi
de timp.
O altă posibilitate pentru adunarea celor patru valori este prezentată în continuare: se
utilizează un singur registru de memorie, folosind adunări succesive: (((a + b) + c) + d). În
acest ultim caz, figura 6 prezintă modul de execuţie.

Versiunea 3
1 load R1, @1000
2 add R1, @1004
3 add R1, @1008
4 add R1, @100C
5 store R1, @2000

Timpul
0 1 2 3 4 5 6 7 8 9 10 11
IF D DF
IF D DF E
IF D DF E
IF D DF E
IF D DF E WB
Figura 6. Dezvoltarea temporală pentru versiunea 3 cu 2 fluxuri pipeline

În acest caz, toate instrucţiunile sunt dependente, astfel că întârzierile se propagă şi se


amplifică. Acest al treilea mod de rezolvare necesită 12 unităţi de timp, fiind astfel cel mai
ineficient din acest punct de vedere, dar este cel mai eficient din punct de vedere al memoriei
utilizate pe procesor (un singur registru).
Discuţia celor trei situaţii de mai sus arată importanţa cunoaşterii arhitecturii pe care
se execută aplicaţia şi utilizarea eficientă a acesteia.

2.3. Procesare cu spaţii mari de memorie

O altă metodă de realizare a paralelismului implicit este folosirea procesoarelor cu


spaţii mari de memorie alocate pentru instrucţiuni (very long instruction word processors –
VLIW processors). Procesoarele au evoluat de la cuvinte de memorie de 2 octeţi (16 biţi) la
cele de 4 octeţi (32 de biţi), iar cele actuale utilizează cuvinte de memorie de 8 octeţi (64 de
biţi). În cazul în care instrucţiunile au cel mult 4 octeţi, arhitecturile moderne pot deci prelua
simultan din codul obiect (rezultat la compilare) câte două instrucţiuni. Aceasta este însă o
metodă statică, deoarece la compilare se produce împachetarea grupurilor de câte două
instrucţiuni care se pot executa în paralel.
Metoda nu conduce deci la o înjumătăţire a timpului de procesare, deoarece:
• nu toate instrucţiunile se pot împacheta în grupuri de câte două;
• timpul de execuţie al grupului este egal cu cel mai mare dintre timpii de execuţie
al instrucţiunilor din grup.

22
2.4. Folosirea ierarhiilor de memorii cache

Cea de-a patra metodă de paralelizare implicită constă în utilizarea ierarhiilor de


memorii cache, care permit scăderea timpului de transfer al datelor din memoria principală
(RAM) în memoria internă (regiştri), pentru a fi procesate de programul în execuţie.
Caracteristicile sistemului de calcul care sunt implicate în viteza de transfer a datelor către
procesor sunt: latenţa (timpul de la lansarea cererii şi până la începerea sosirii datelor) şi
lărgimea de bandă (raportul dintre cantitatea de date şi timpul necesar ajungerii lor în
memoria internă). În situaţia în care nu sunt utilizate memorii intermediare de tip cache,
datele aflate în RAM pot întârzia major procesarea, aşa cum rezultă din următorul exemplu.

Exemplu. Presupunem un procesor cu frecvenţa de 1 GHz, care poate executa 4 instrucţiuni


pe ciclu de ceas, conectat la o memorie RAM cu latenţă de 100 ns. Frecvenţa de 1 GHz = 109
Hz conduce la execuţia unei instrucţiuni în 10-9 s = 1 ns (nanosecundă). Pentru adunarea a doi
vectori se execută în mod repetat codul următor:

1 load R1, @ a[i]


2 load R2, @ b[i]
3 add R1, R2
4 store R1, @ c[i]

Dacă presupunem că grupul format din cele patru instrucţiuni se lansează


concomitent, atunci primele două se încheie după 100 (latenţa) + 1 (execuţie) = 101 ns.
Adunarea se încheie după 102 ns (după ce se cunosc termenii adunării mai este nevoie de 1
ns pentru a se afla suma lor), iar stocarea în RAM a valorii obţinute mai are nevoie de încă
101 ns (1 ns pentru execuţie şi 100 ns transferul sumei în RAM). În final, valoarea variabilei
c[1] ajunge în RAM după 203 ns, iar procesarea pentru aflarea valorii c[2] poate începe după
ce prima instrucţiune s-a încheiat, adică după 101 ns. Registrul R1 este blocat de
instrucţiunile corespunzătoare valorii c[1], deci instrucţiunea

load R1, @ a[2]

se execută în intervalul 203-304 ns. Deşi instrucţiunea

load R2, @ b[2]

se execută mai devreme, deoarece registrul R2 este eliberat la momentul 102 ns, valoarea din
R2 aşteaptă depunerea valorii variabilei a[2] în R1 şi deci instrucţiunea

add R1, R2

se execută în intervalul 304-305 ns. Transferul valorii a[2] + b[2] se face în intervalul 305-
406 ns. În concluzie, execuţia repetată a blocului de 4 instrucţiuni se face în blocuri de 203
ns, deci timpul total necesar este de n ⋅ 203 ns în cazul în care vectorii au câte n componente.
Dintre cele 4 instrucţiuni necesare aflării unui element din vectorul-sumă, trei (prima,
a doua şi a patra) necesită 101 ns pentru execuţie şi cea de-a treia se execută într-o
nanosecundă; de asemenea, primele două se pot executa concomitent, însă ultimele două se
execută secvenţial după ce primele două s-au încheiat.

Dacă însă se folosesc memorii cache, care pot stoca integral cei trei vectori şi dacă
presupunem că lărgimea de bandă permite transferul datelor într-o singură fază, atunci prima

23
dată se aduc toate datele de intrare în cache (100 ns), apoi se calculează toate valorile
vectorului c (aproximativ n ns deoarece se pot executa 4 instrucţiuni în paralel, iar aflarea
unei valori din vectorul c necesită tot 4 instrucţiuni) şi în final toate valorile se trec în RAM
în 100 ns. În acest caz timpul total este de n + 200 ns, de aproximativ 200 de ori mai mic
decât în cazul în care nu se utilizează memorii cache.

2.5. Utilizarea datelor contigue

Creşterea vitezei de procesare a datelor prin metode implicite se mai poate realiza prin
eliminarea accesării datelor stocate la distanţă (stride elimination). Aducerea datelor din
memoria secundară (de exemplu, hard-disc) în memoria principală (RAM) nu se face
individual, ci prin intermediul paginilor de memorie – un grup de adrese adiacente în
memorie, care include şi valoarea cerută de procesor Un grup de date alăturate în memorie se
numesc date contigue (contiguous data). De obicei o pagină de memorie are 4 kB şi această
metodă a fost implementată deoarece s-a considerat că, de obicei, aplicaţiile folosesc date
stocate în zone alăturate. Acest principiu poate fi utilizat în reducerea timpului de procesare a
matricelor mari. Programatorii nu dau mare importanţă ordinii de parcurgere a unei matrice,
dar în cele ce urmează vom vedea că în cazul unor matrice mari, aceasta are impact major
asupra timpului de execuţie.

Exemplu. Considerăm că matricea A are dimensiunea (1000, 1000) şi că este necesară o


aplicaţie care să genereze un vector b cu 1000 de componente, fiecare dintre acestea fiind
suma elementelor din A situate pe câte o linie.

Versiunea 1 de rezolvare a problemei este:


for (i = 0; i < 1000; i++)
b[i] = 0;
for (i = 0; i < 1000; i++)
for (j = 0; j < 1000; j++)
b[j] +=a [j][i];
În acest caz, pentru i fixat, la fiecare execuţie a ultimei instrucţiuni, este adusă în RAM o altă
pagină de memorie, deoarece, după cum se ştie, matricele se stochează pe linii. Dacă tipul de
date este de 4 octeţi, atunci o pagină stochează 1000 de valori. Deşi sunt aduşi de fiecare dată
câte 4 kB de date, este utilizată doar o singură valoare - şi anume a[j][i]. Pentru a se
executa complet structura for interioară sunt necesare deci 1000 de transferuri din hard-disc
în RAM.

O simplă interschimbare a celor doi indici conduce la:

Versiunea 2
for (i = 0; i < 1000; i++)
b[i] = 0;
for (i = 0; i < 1000; i++)
for (j = 0; j < 1000; j++)
b[i] += a[i][j];
În acest caz, pentru i fixat, execuţia completă a structurii for interne necesită doar câteva
transferuri de date de pe hard-disc (în funcţie de tipul de dată utilizat şi de poziţia în pagina
de memorie a valorii a[i][j]).

24
Capitolul 3

Paralelism explicit

Acest capitol este dedicat arhitecturilor cu mai multe unităţi de procesare, care permit
specificarea explicită a proceselor care se pot desfăşura concurent. Spre deosebire de
capitolul anterior (Paralelism implicit), care tratează cazul unei arhitecturi mono-procesor,
acest capitol descrie facilităţile oferite de colecţiile de procesoare.
Trecerea de la programarea secvenţială la programarea paralelă nu este uşoară.
Metoda paralelizării unor aplicaţii secvenţiale deja scrise nu este cea mai eficientă. Pot exista
caracteristici ale problemei care nu sunt speculate de aplicaţiile secvenţiale şi care scapă
atenţiei programatorilor şi în cazurile paralele. Pe de altă parte, scrierea de la început a unor
aplicaţii paralele este o activitate dificilă, care necesită solide şi moderne cunoştinţe de
programare.

3.1. Controlul în aplicaţiile paralele

Aşa cum în cazul aplicaţiilor secvenţiale controlul (adică paşii aplicaţiei) este
specificat prin instrucţiuni, în cazul aplicaţiilor paralele este necesară specificarea controlului
(ce face fiecare proces) dar şi a modelului de comunicare (cum/când/cine/ce comunică).
O aplicaţie paralelă lipsită de comunicaţii poate fi gândită ca o execuţie în izolare a
proceselor, deci ar putea fi înlocuită cu înlănţuirea secvenţială a proceselor pe un singur
procesor. În acest caz, timpul de execuţie ar fi egal cu suma timpilor de execuţie a fiecărui
proces, câştigul adus de procesarea paralelă fiind reducerea vitezei de procesare (folosind
formulele din Capitolul 1, în cazul a n procese perfect echilibrate ca timp de execuţie,
acceleraţia este n şi eficienţa este 1). Deşi acesta este o situaţie extrem de eficientă,
rezolvarea unor probleme complexe a dovedit că o descompunere în procese care comunică
reuşeşte să găsească soluţii mai bune decât dacă nu există comunicare între procese. O
strategie de comunicare prin care procesele să schimbe inteligent date este cheia pentru a
obţine soluţii de calitate la problemele dificile ale lumii actuale.

Controlul procesării reflectă granularitatea paralelizării:


• dacă paralelizarea este de granularitate mare (coarse-grained), atunci se execută
pe fiecare unitate de procesare câte un program (aceste programe pot fi identice
sau diferite între ele) cu propria evoluţie, deci propriul control;
• dacă paralelizarea este de granularitate mică (fine-grained), atunci se execută o
singură aplicaţie, asigurându-se un control unic şi se prelucrează simultan seturi
diferite de date.

Exemplu de control la nivel de program (granularitate mare)


În arhitectura MIMD (Multiple Instructions Multiple Data) se pot implementa
aplicaţii paralele în care fiecare unitate independentă de procesare execută un proces
secvenţial. Cea mai comună situaţie este utilizarea unei reţele de calculatoare mono-procesor,
fiecare dotate cu aplicaţii şi sistem de operare şi care execută diverse procese din aplicaţia

27
paralelă. Această soluţie este generală (se poate utiliza pentru o clasă largă de aplicaţii
paralele), flexibilă (se poate echilibra în funcţie de consumul de resurse), dar necesită spaţii
suplimentare de memorie, deoarece fiecare sistem de calcul este controlat independent (figura
9). Sistemele bazate pe multiple procesoare (CPUs) sunt construite pentru a scădea latenţa:
• memoria cache este destinată măririi şanselor ca datele necesare să fie deja
disponibile, datorită precedentelor transferuri din memoriile externe
procesorului;
• unitatea de control (UC) permite predicţia ramificării codului (branch
prediction - codul se execută în avans, pe ramura cea mai probabilă a
structurilor de decizie, dar există şi instrumente de restaurare şi avans pe
celelalte ramuri, în cazul în care predicţia s-a dovedit a fi incorectă).
Structurile de decizie pot fi: instrucţiunea if-then-else, instrucţiunea
case/switch sau cele care realizează controlul instrucţiunilor repetitive (dacă se
reiau sau se trece la următoarea instrucţiune).
• unitatea de control (UC) permite utilizarea rapidă a valorilor calculate recent
(data forwarding) prin mecanisme de urmărire a locului din aplicaţie în care
este necesară o valoare tocmai calculată şi utilizarea acestei valori deşi aceasta
nu a fost încă depusă în zona de memoria secundară (pe hard-disc).
Orientarea către eliminarea latenţei face ca sistemele de calcul bazate pe CPU să fie
extrem de eficiente în cazul aplicaţiilor secvenţiale. Industria hardware a urmărit, de la
începuturi, eficientizarea acestor aplicaţii. Doar după ce s-a pus problema calculului paralel,
au început să apară preocupări în eficientizarea hardware pe această direcţie.

UC UP UC UP UC UP ... UC UP

Figura 9. CPU - Unităţi de procesare care posedă individual unităţi de control

O altă soluţie (modernă) este oferită de acceleratoarele grafice (arhitectură SIMD –


Single Instruction Multiple Data), care rezolvă o clasă mai restrânsă de probleme cu un cost
scăzut – necesită memorie puţină, existând un singur punct de control (figura 10). Aplicaţiile
care utilizează facilităţile GPU (Graphics Processing Unit) speculează arhitectura lor,
specializată pentru eficientizarea unui mare număr de procese, care manevrează în acelaşi fel
seturi diferite de date.

UC

UP UP UP ... UP

Figura 10. GPU - Unităţi de procesare controlate de o singură unitate de control

28
Controlul este deci exterior unităţilor de procesare, care nu mai oferă facilităţile
branch prediction sau data forwarding. Unităţile aritmetico-logice (UALs) sunt numeroase,
eficiente energetic şi permit execuţie de tip pipeline. Deşi memoria disponibilă pentru GPU
induce o latenţă mult mai mare decât în cazul unui CPU (lipsesc facilităţile descrise în lista
precedentă), numărul mare de operaţii care se pot suprapune (arhitectura este puternic
orientată către suprapunerea de tip pipeline) face ca aproape la fiecare ciclu de ceas sa fie
încheiată execuţia unei instrucţiuni. Aceste caracteristici fac ca dispozitivele hardware de tip
GPU să poată executa mult mai eficient unele aplicaţii paralele decât dispozitivele CPU.

Exemplu de control la nivel de instrucţiune (granularitate mică)


În cazul arhitecturii SIMD (Single Instruction Multiple Data) se pot executa
concomitent operaţii pe fiecare componentă a unei structuri de date. De exemplu, dacă dorim
să adunăm doi vectori a şi b cu câte 1000 de componente şi să stocăm valorile rezultate într-
un alt vector c, atunci aplicaţia secvenţială va conţine instrucţiunile:
for (i = 0; i < 1000; i++)
c[i] = a[i] + b[i];

iar dacă dorim folosirea facilităţilor SIMD, atunci codul paralel (care alocă o poziţie din
vectorul c unui proces şi fiecare proces are acces la componentele corespunzătoare din cei doi
vectori de intrare) va conţine instrucţiunea:
c[current] = a[current] + b[current];

În primul caz procesorul execută 2000 de atribuiri (sunt necesare 1000 de atribuiri
pentru contorul i şi 1000 de atribuiri pentru elementele vectorului c). În al doilea caz se
execută simultan 1000 de atribuiri pentru variabila locală current şi apoi simultan 1000 de
atribuiri pentru calculul valorii corespunzătoare din vectorul c.
Situaţia se complică dacă există instrucţiuni decizionale în codul secvenţial. De
exemplu, dacă există instrucţiunea:
if (x == 0) z = y; else z = y/x;

execuţia sa pe seturi diferite de date necesită doi paşi: întâi se atribuie valoarea din y
variabilei z pentru toate procesele care au valoarea 0 stocată în variabila x şi apoi se atribuie
valoarea y/x variabilei z pentru restul proceselor. În toate procesele paralele, se lucrează pe
valorile curente (locale procesului) ale variabilelor. Instrucţiunea pentru versiunea paralelă
este:
where (x == 0) z = y; elsewhere z = y/x;

Presupunând că această instrucţiune se execută pe patru seturi de date (D1-D4):

D1 D2 D3 D4
înainte de x 0 x 2 x 3 x 0
where y 7 y 8 y 3 y 2
z z z z

atunci etapa 1 în execuţia instrucţiunii where constă în alocarea valorilor variabilei z din
seturile D1 şi D4:

29
D1 D2 D3 D4
etapa 1 din x 0 x 2 x 3 x 0
where y 7 y 8 y 3 y 2
z 7 z z z 2

şi a doua etapă alocă valori pentru variabila z din seturile D2 şi D3:

D1 D2 D3 D4
etapa 2 din x 0 x 2 x 3 x 0
where y 7 y 8 y 3 y 2
z 7 z 4 z 1 z 2

Această metodă de rezolvare foloseşte o mascare a activităţii (activity mask),


permiţând separarea proceselor în grupuri care execută aceeaşi prelucrare pe seturi diferite de
date. Cu cât structurile de decizie sunt mai numeroase sau mai complexe (de exemplu
instrucţiunea case/switch), cu atât masca de activitate împarte procesele în tot mai multe
grupuri, întârziind execuţia aplicaţiei paralele.

3.2. Comunicaţiile în aplicaţiile paralele

Comunicaţia este esenţială pentru eficientizarea aplicaţiilor paralele. Scrierea unor


aplicaţii care se desfăşoară simultan fără ca acestea să comunice nu îmbunătăţeşte decât
timpul total de execuţie, fără însă a economisi resursele de calcul, aşa cum am văzut la
începutul acestui capitol. În loc să executăm în paralel procesele, acestea se pot înseria şi
execută pe un singur procesor, evident în timp mai lung, dar cu aceleaşi costuri totale.
Comunicaţia între procese se poate executa:
• prin spaţiu partajat de adrese (shared address space)
• prin schimb de mesaje (message passing).
Aceste metode de comunicaţie sunt destinate exploatării a două tipuri de arhitecturi.
Din nou, subliniem faptul că aplicaţiile paralele actuale sunt proiectate astfel încât să utilizeze
extrem de eficient caracteristicile platformei pe care urmează a se implementa. O aplicaţie
paralelă nu poate fi eficientizată pentru toate arhitecturile.

3.2.1. Comunicaţiile prin spaţiu partajat de adrese

Principiul comunicaţiilor prin spaţiu partajat de adrese este des întâlnit în natură, fiind
o alternativă la mai des întâlnitul principiu al schimbului de mesaje. În locul sincronizării
expeditorului cu destinatarul de fiecare dată când este necesară o transmisie de date (schimb
de mesaje), comunicaţia prin adrese partajate nu necesită sincronizare: expeditorul depune în
spaţiul comun valoarea pe care doreşte să o transmită şi destinatarul o accesează atunci când
are nevoie de valoarea respectivă.
Aceeaşi metodă este întâlnită în natură la coloniile de furnici, care depun pe sol o
substanţă specifică (feromon) pe care o recunosc şi o interpretează cele care trec ulterior prin
acelaşi loc. Deşi sunt insecte aproape oarbe, furnicile reuşesc să găsească în scurt timp
drumul cel mai scurt de la sursa de hrană la cuib. Un alt model este partajarea vehiculelor,
pentru economisirea carburantului, micşorarea emisiilor poluante şi descongestionarea
şoselelor. În figura 11 este prezentat indicatorul pentru benzile HOV (high-occupancy vehicle
lanes) dedicate, iar în figura 12 se află o imagine a unei astfel de benzi. Controlul utilizării

30
benzilor HOV se face prin sisteme automate de captare şi interpretare a imaginilor,
asigurându-se acces liber doar autovehiculelor în care se află cel puţin două persoane
(posesorii celorlalte autovehicule sunt amendaţi).

Figura 11. Indicatorul rutier High-occupancy vehicle (HOV) în vigoare în SUA

Figura 12. Bandă dedicată HOV în California, SUA [29]

Sistemele de calcul care suportă programarea de tip SPMD se numesc sisteme multi-
procesor. Dacă timpul pentru accesarea memoriei este identic pentru fiecare spaţiu de
memorie, atunci platforma este de tip Uniform Memory Access (UMA) (figura 13). În caz
contrar, platforma este de tip Non-uniform Memory Access (NUMA) (figura 14).
Comenzile de scriere în memoria comună sunt complexe la nivelul implementării,
deoarece necesită mecanisme de blocare, specificate în continuare, în acest subcapitol.

UP UP UP ... UP

M M M ... M

Figura 13. Platformă UMA, spaţiu partajat de memorie

31
UP M UP M ... UP M

Figura 14. Platformă NUMA, spaţiu partajat de memorie

Cea mai eficientă arhitectură cu adrese partajate este oferită de memoria PRAM
(Parallel Random Access Machine): mai multe procesoare accesează un spaţiu comun de
memorie. Un model asemănător este metoda didactică a rezolvării la tablă a unei probleme:
profesorul scrie informaţiile la tablă, iar elevii le preiau, asigurându-se că la momentul când
profesorul şterge tabla şi depune noi informaţii pe tablă, cele precedente au fost deja utilizate
(învăţate sau scrise în caiete) şi deci nu mai este necesară prezenţa lor în spaţiul comun (adică
tabla).
Acest model este cunoscut în Inteligenţa Artificială sub numele de modelul
Blackboard [31], fiind folosit pentru a rezolva probleme complexe care se descompun în sub-
probleme dependente, având următoarele componente:
• baze de cunoştinţe (knowledge sources) stocate distribuit; fiecare astfel de bază poate
rezolva independent o sub-problemă;
• o zonă de memorie la care au acces bazele de cunoştinţe, care conţine problema
iniţială, sub-problemele în care aceasta s-a descompus, soluţiile acestor sub-probleme,
modul în care se utilizează aceste soluţii în rezolvarea altor sub-probleme sau a
problemei iniţiale, etc.;
• un modul de control, care asigură desfăşurarea eficientă a procesului de rezolvare a
problemei iniţiale, care defineşte şi urmăreşte strategia de rezolvare a acesteia. Scopul
modulului este de a împiedica deducţiile nefolositoare rezolvării problemei.
Proiectarea şi programarea acestui modul este esenţială şi arată importanţa
conceptului de oportunism în informatică. Specularea oricăror informaţii şi metode
care ajută la rezolvarea rapidă a problemelor ajunge să fie o artă, care se bazează pe
cunoştinţe avansate, pe experienţa şi pe cooperarea echipei de specialişti care lucrează
la rezolvarea problemei respective.
Câteva sisteme open-source destinate implementării modelului tablei sunt [21, 31].

Cea mai dificilă activitate în cazul spaţiului partajat de memorie o reprezintă


asigurarea transferului corect de date. Pe parcursul execuţiei aplicaţiei paralele, se pot
produce transferuri de date în alt mod decât cel intenţionat la proiectarea algoritmului. De
exemplu, dacă mai multe procese doresc simultan să scrie aceeaşi zonă de memorie, atunci
unele dintre valorile care se intenţionează a se stoca se pierd, deci activitatea realizată de
aceste procese este irosită. Strategia utilizată în cazul PRAM poate fi:
• Exclusive Read, Exclusive Write (EREW) – modelul cel mai restrictiv, când atât
citirile cât şi scrierile se execută secvenţial; dacă la un anumit moment, procesele
încearcă să scrie simultan aceeaşi zonă de memorie, atunci acestea sunt serializate.
La fel se întâmplă şi la tentativa de citire simultană. Asigurând cel mai înalt grad
de securitate a datelor, acest model produce şi cea mai mare întârziere, prin

32
serializarea proceselor care în celelalte cazuri (descrise în continuare) s-ar putea
desfăşura în paralel.
• Concurrent Read, Exclusive Write (CREW) – modelul cel mai întâlnit, care
serializează scrierile, dar permite citirile simultane din aceeaşi zonă de date.
• Exclusive Read, Concurrent Write (ERCW) – modelul care serializează citirile
aceleaşi zone de memorie, dar permite scrierea simultană în acelaşi loc.
• Concurrent Read, Concurrent Write (CRCW) – cel mai puternic model PRAM,
care eliberează programatorul de grija operaţiilor de intrare/ieşire. Toate operaţiile
de citire/scriere care doresc să acceseze simultan o zonă de memorie se execută
(unele având efectul scontat de programatorul procesului, altele nu) şi aplicaţia nu
întârzie.

Accesul concurent este lăsat la decizia protocoalelor de tratare a evenimentelor care ar


putea apărea. Cum am văzut deja, procesele accesează concurent aceeaşi zonă de memorie,
însă nu este garantat succesul tuturor accesărilor. Dacă citirea simultană nu pune probleme,
deoarece nu este importantă o eventuală serializare decât din punctul de vedere al timpului
necesar, scrierea simultană este rezolvată prin unul dintre următoarele protocoale de
rezolvare a conflictelor:
• Common: scrierea se realizează doar dacă toate procesele încearcă să scrie aceeaşi
valoare, altfel toate procesele eşuează în tentativa de scriere şi apoi toate continuă
execuţia.
• Arbitrary: unul (oarecare) dintre procese scrie efectiv şi celelalte eşuează; apoi
toate continuă.
• Priority: scrie procesul care are cea mai înaltă prioritate, celelalte eşuează şi apoi
toate continuă. Acest protocol presupune că fiecărui proces i se alocă (la creare) o
prioritate.
• Sum (sau orice alt operator asociativ): se scrie suma (sau rezultatul aplicării
operatorului) valorilor pe care intenţionează să le scrie fiecare proces şi apoi
fiecare proces îşi continuă execuţia.

3.2.2. Comunicaţiile prin schimb de mesaje

Principiul schimbului de mesaje este asemănător sistemului poştal clasic: pentru a


prelua o informaţie, expeditorul trimite o scrisoare destinatarului, folosind un plic pe care
specifică datele de identificare ale celor doi, în care împachetează mesajul. Atât destinatarul
cât şi expeditorul pot înţelege mesajul, fiind deci în posesia unor facilităţi de procesare şi
stocare a mesajului. La fel, unităţile de procesare care schimbă mesaje posedă spaţii exclusive
de memorie (figura 15), au identificatori unici care îi individualizează în grupul de posibili
participanţi la schimbul de mesaje şi pot folosi funcţiile dedicate: Send sau Receive.

UP M UP M ... UP M

Figura 15. Platformă cu spaţiu exclusiv de memorie, pentru comunicare


prin schimb de mesaje

33
Limbajele de programare care permit schimbul de mesaje implementează şablonul
general MPI (Message Passing Interface), aflat acum la versiunea 3 [32]. Printre cele mai
utilizate implementări MPI menţionăm [23, 25, 26].
Sistemele distribuite (cu spaţii exclusive de memorie) folosesc comunicarea prin
schimb de mesaje, pe când metoda alternativă (comunicarea prin acces la memoria comună)
este potrivită sistemelor de calcul paralel.
Schimbul de mesaje se realizează întotdeauna într-un context (un grup), care defineşte
o submulţime a calculatoarelor legate în reţea care pot comunica. La un moment dat, într-o
aplicaţie distribuită, pot exista mai multe grupuri, dar o comunicaţie are loc într-un anumit
grup. O comunicaţie presupune existenţa a cel puţin unui expeditor (sursă) şi a cel puţin unui
destinatar.
În funcţie de participanţii la comunicaţie, acestea pot fi de următoarele tipuri:
• 1 – 1 (one-to-one), când schimbul de mesaje se realizează prin perechea de
comenzi Send (la sursă)/Receive (la destinaţie);
• 1 – toţi (one-to-all), când sursa trimite câte un mesaj fiecărui proces din grup,
folosind una dintre comenzile Broadcast sau Scatter;
• toţi – 1 (all-to-one), când un singur destinatar colectează mesaje de la toţi
participanţii din grup, cu ajutorul comenzilor Gather sau Reduce.

În comunicarea de tip 1 – 1 există o singură sursă şi un singur destinatar, fiecare dintre


aceste procese cunoscând identificatorul procesului cu care corespondează. Schimbul de date
se realizează printr-o zonă de memorie dedicată (buffer), care se utilizează ca spaţiu de
stocare intermediar şi permite eliberarea memoriei celor doi participanţi la schimb (figura
16).

mesaj
Memorie sursă → Buffer sursă Buffer destinaţie → Memorie destinaţie
(MS) (BS) (BD) (MD)
Sursă Destinaţie

Figura 16. Transmiterea unui mesaj între sursă şi destinaţie

Pentru realizarea transmisiei, sursa trebuie să execute o comandă Send, iar destinaţia
are de executat o comanda Receive. În modelul general, parametrii acestor comenzi sunt:
Send (mesaj, destinatar, eticheta, grup)
Receive (mesaj, sursa, eticheta, grup, stare)
unde: mesaj descrie identificatorul structurii locale de date care conţine mesajul,
destinatar/sursa sunt identificatorii celuilalt proces, eticheta permite împerecherea corectă a
celor două comenzi, deoarece identifică mesajul în mod unic, grup reprezintă contextul în
care se realizează comunicarea, iar stare este o structură de date care reflectă modul în care s-
a realizat efectiv comunicaţia.
Existenţa buffer-ului care intermediază schimbul permite următoarele variante de
comunicare:
• blocking, în care sursa rămâne în execuţia comenzii Send până când buffer-ul său
(BS din figura 16) poate fi rescris. De asemenea, destinaţia stă în execuţia
comenzii Receive până când buffer-ul său (BD din aceeaşi figură) conţine întreg
mesajul transmis. După ce se îndeplineşte condiţia corespunzătoare lui, fiecare
proces poate începe execuţia următoarei instrucţiuni. Această variantă presupune
deci că procesele nu pot avansa decât după ce comunicaţia s-a realizat efectiv
(deci se poate spune „comunicaţia a sincronizat procesele”).

34
• non-blocking, în care fiecare dintre comenzile Send/Receive este împărţită în două
faze, între care fiecare proces poate executa alte comenzi:
o iniţierea comunicării
o testarea realizării comunicării.
Această variantă permite ca aplicaţiile să avanseze ne-sincron, nefiind necesară
execuţia în acelaşi timp a comenzii Send la sursă şi a comenzii Receive la
destinaţie.

În ambele variante, în funcţie de răspunsul la întrebarea „Ce se poate spune despre


procesul Destinaţie, dacă procesul Sursă a încheiat comanda Send?”, există mai multe moduri
de implementare a comunicaţiei:
• standard, când nu se cunoaşte starea în care se află procesul Destinaţie,
programatorul nu se poate baza nici măcar pe faptul că BD conţine mesajul;
• buffered, când comanda Send a procesului Sursă se poate încheia fără ca procesul
Destinaţie să fi început execuţia comenzii Receive;
• sychronous, când la încheierea comenzii Send a procesului Sursă, procesul
Destinaţie a început execuţia comenzii Receive şi mesajul a început să fie
recepţionat;
• ready, când comanda Send a procesului Sursă poate începe numai după ce
comanda Receive din procesul destinaţie şi-a început execuţia.

În cazul comunicării de tip 1 – toţi, se presupune existenţa unui grup, în cadrul căruia
există un proces Sursă, care transmite câte un mesaj fiecărui proces din grup, inclusiv lui
însuşi. Comanda Broadcast transmite acelaşi mesaj tuturor proceselor din grup:

Broadcast (mesaj, sursa, grup)

Pentru a se realiza difuzarea mesajului în grup, fiecare proces execută comenzi


Broadcast identice; dacă presupunem că grupul este format din n calculatoare, atunci cele n
comenzi Broadcast sunt echivalente cu n comenzi Send executate de Sursă şi câte o comandă
Receive executată de fiecare proces. Dacă grupul este mare, atunci Sursa poate provoca
aglomerări pe canalul de comunicaţie. Această problemă va fi tratată în continuare, în acest
subcapitol. În figura 17 este prezentat un grup de 4 calculatoare care au identificatorii 1 – 4,
în care sursa comenzii Broadcast este procesul cu identificatorul 1 şi mesajul conţine
valoarea variabilei a.

date date
a a
1 7 Broadcast (a, 1, grup) 1 7
procese

procese

2 2 7
3 3 7
4 4 7
Figura 17. Efectul unei comenzi Broadcast într-un grup de 4 procese

Comanda Scatter transmite mesaje diferite tuturor proceselor care compun un grup:

Scatter (colectie, element, sursa, grup)

35
Această comandă este generalizarea comenzii Broadcast, în sensul că o colecţie de
date stocată la sursă este fragmentată şi fiecare element este transmis unui proces, în ordinea
identificatorilor proceselor din grup. Dacă presupunem că grupul are n calculatoare, atunci
comenzile Scatter executate de fiecare sunt echivalente cu n comenzi Send executate de Sursă
şi câte o comandă Receive executată de fiecare proces. În figura 18 este prezentat rezultatul
unei comenzi Scatter executată pe fiecare dintre cele 4 procese ale unui grup, în care procesul
1 este sursa şi trimite fiecărui proces din grup (în ordinea identificatorilor) câte o componentă
a vectorului A.

date date
A b A b
1 7 3 5 2 Scatter (A, b, 1, grup) 1 7 3 5 2 7
2
procese

2 3

procese
3 3 5
4 4 2

Figura 18. Efectul unei comenzi Scatter într-un grup de 4 procese

Comunicaţiile de tip toţi – 1 presupun colectarea la destinaţie a mesajelor transmise de


fiecare proces din grup. Comanda Gather este opusă comenzi Scatter, deoarece procesul
destinaţie construieşte în ordinea identificatorilor proceselor participante o structură de date
prin recepţionarea mesajelor transmise de fiecare proces:

Gather (element, colectie, destinatie, grup)

În cazul unui grup de n calculatoare, comenzile Gather executate de fiecare sunt


echivalente cu n comenzi Receive executate de Destinaţie şi câte o comandă Send executată
de fiecare proces. În figura 19 se prezintă efectul comenzii Gather cu destinaţia 1 într-un grup
de 4 procese.

date date
A b A b
1 2 1 2 9 4 6 2
Gather (b, A, 1, grup)
procese

2 9 2 9
procese

3 4 3 4
4 6 4 6

Figura 19. Efectul unei comenzi Gather într-un grup de 4 procese

Comanda Reduce presupune utilizarea unui operator asociativ (fie predefinit, fie
definit în aplicaţie) – exemplul din Figura 20 se referă la operatorul de adunare (SUM). Prin
comanda Reduce, la destinaţie se colectează valorile obţinute prin aplicarea repetată a
operatorului specificat, pentru fiecare dintre structurile de date primite de la fiecare proces
din grup. Dacă presupunem că grupul are n calculatoare, atunci comenzile Reduce executate
de fiecare sunt echivalente cu n comenzi Receive executate de Destinaţie, câte o comandă
Send executată de fiecare proces şi o prelucrare a rezultatelor finale. În figura 20, fiecare
dintre cele 4 componente ale vectorului B se obţine prin adunarea componentelor
corespunzătoare din fiecare vector A, stocat pe fiecare calculator din grup. De exemplu, B[1]
este suma valorilor stocate în A[1] de către fiecare proces.

36
date date
A B
1 2 2 1 0 Reduce (A, B, SUM, 1, grup) 1 7 6 3 6

procese
2 3 1 0 4 2
procese

3 0 3 1 1 3
4 2 0 1 1 4

Figura 20. Efectul unei comenzi Reduce într-un grup de 4 procese

Conectarea unităţilor de procesare

Reţelele alcătuite prin conectarea mai multor unităţi de procesare destinate calculului
paralel sunt de obicei reţele regulate, cu o topologie strict orientată către viteze mari de
transmisie a mesajelor şi distanţe scurte între procesoare. De aceea, reţelele mici sunt
complete de cele mai multe ori, iar cele mari respectă următoarele topologii [7]:
• Stea (star): un procesor este legat la toate celelalte procesoare. De obicei este o
reţea eterogenă, procesorul central asumându-şi rolul de server, iar celelalte – pe
cel de client.
• Vector (cu sau fără circularitate; în primul caz obţinem reţeaua de tip inel): toate
procesoarele sunt conectate la o magistrală (bus).
• Arbore (tree): topologia este de arbore (graf neorientat conex şi fără circuite). În
figura 21 a) este prezentat un arbore binar cu 8 noduri terminale. Cea mai dificilă
problemă a acestei topologii este aglomerarea mesajelor în zona rădăcinii,
deoarece muchiile conexe rădăcinii fac trecerea între sub-arborele stâng şi cel
drept al acesteia. Din acest motiv, arborii sunt eficientizaţi prin creşterea lărgimii
de bandă pentru conexiunile din apropierea rădăcinii, ca în figura 21 b).
• Plasă (mesh): procesoarele sunt legate cu vecinii aflaţi în nodurile unei reţele cu
două sau mai multe dimensiuni. Reţeaua poate fi cu circularitate (toroidală) sau
deschisă. În figura 22 sunt prezentate cele două cazuri pentru o plasă 2D cu 9
noduri. În figura 23 se află o reţea mesh 3D cu 27 de noduri. Pentru cazul general,
se defineşte reţeaua de tip k-d mesh: o plasă din k noduri în d dimensiuni. O reţea
2-d mesh este denumită hipercub. În figura 24 este prezentată seria 2-0 (a), 2-1
(b), 2-2 (c), 2-3 (d) şi 2-4 (e) de hipercuburi.

a) b)
Figura 21. Reţea arbore (tree) şi arbore masiv (fat tree)

37
a) b)
Figura 22. Reţea plasă 2D a) deschisă b) circulară

Figura 23. Reţea plasă 3D, deschisă [7]

a) b) c) d) e)
Figura 24. Hipercuburi în k = 0 - 4 dimensiuni [7]

38
Metode eficiente de comunicare multiplă

În cazul comunicaţiilor multiple se pot produce aglomerări pe canale, cu consecinţe


negative în ce priveşte viteza transferului, implicit a aplicaţiei de calcul paralel. Sincronizarea
tuturor proceselor care participă în comunicaţia colectivă conduce la avansarea lor la
următoarea instrucţiune numai după ce şi cel mai întârziat proces a ajuns şi a executat
comunicaţia respectivă. În figura 25 este prezentată influenţa unei comunicaţii colective într-
un grup de 5 procese, care au ajuns la momente diferite de timp la execuţia acestei
comunicaţii. Procesele îşi pot continua execuţia numai după ce procesul P1 (cel mai întârziat)
a ajuns în punctul de sincronizare. Procesele P2-P5 sunt nevoite să aştepte procesul P1
(timpul de aşteptare este figurat prin linie punctată).

P1

P2

P3

P4
timp

Figura 25. Întârzierea (linie întreruptă) indusă de sincronizarea comunicaţiilor colective

La nivelul implementării comenzilor colective este deci extrem de important modul în


care acestea se realizează. Câteva astfel de situaţii, determinate de topologia conexiunilor,
sunt prezentate în continuare.

Implementarea unei comenzi Broadcast pe un inel de 8 calculatoare


Dacă presupunem că sursa este calculatorul cu identificatorul 0 şi că sensul de
parcurgere a reţelei este cel din figura 26, atunci săgeţile punctate descriu o metodă de
transmisie în 3 paşi a mesajului către toate celelalte calculatoare din inel.
3 3
2

7 6 5 4

0 1 2 3

1
2
3 3

Figura 26 Implementarea optimă a comenzii Broadcast pe inel [7]

39
Primul pas constă în comunicaţia între 0 şi 4. La pasul 2 se efectuează două
comunicaţii: 0-2 şi 4-6. La pasul 3 se realizează 4 schimburi de mesaje: 0-1, 2-3, 4-5 şi 6-7.
Dacă fiecare hop (calculator intermediar) dintr-o comunicaţie este traversat într-o unitate de
timp, atunci pentru încheierea comenzii Broadcast pe cele 8 calculatoare sunt necesare: 4 + 2
+ 1 = 7 unităţi de timp, adică timpul optim, deoarece doar comunicaţia 0 – 7 (cel mai lung
traseu) necesită 7 unităţi de timp. Metoda descrisă (care se numeşte metoda dublării
recursive, deoarece la fiecare pas se dublează numărul de transmisii de date) are o proprietate
foarte importantă: fiecare canal de comunicaţie este parcurs mereu de cel mult un mesaj, deci
niciun canal nu este aglomerat.

Implementarea unei comenzi Broadcast pe o reţea mesh 2D cu 16 calculatoare [7]


Considerând că sursa este calculatorul cu identificatorul 0, atunci metoda dublării
recursive realizează comunicaţia în 4 paşi, astfel (figura 27):
• pasul 1: 0 – 8, un singur transfer, realizat în 2 unităţi de timp
• pasul 2: 0 – 4 şi 8 – 12, două transferuri simultane, care necesită o unitate de timp
• pasul 3: 0 – 2, 4 – 6, 8 – 10 şi 12 – 14, patru trasferuri în paralel, pentru care sunt
necesare 2 unităţi de timp
• pasul 4: 0 – 1, 2 – 3, 4 – 5, 6 – 7, 8 – 9, 10 – 11, 12 – 13 şi 14 – 15, opt schimburi
simultane de mesaje, efectuate într-o unitate de timp.
În total au fost necesare 6 unităţi de timp, ceea ce reprezintă un timp optim, deoarece
cel mai îndepărtat calculator de 0 (şi anume cel cu identificatorul 15) poate recepţiona
mesajul după ce acesta trece prin cel puţin alte 5 calculatoare (se poate observa că distanţa
Hamming între colţurile opuse ale reţelei mesh 2D din figura 27 este 6). Comanda colectivă
este realizată fără aglomerări şi metoda dublării recursive este din nou cea mai eficientă.

3 7 11 15

4 4 4 4

2 6 10 14

1 5 9 13

3 3 3 3
4 4 4 4

0 4 8 12
1

2 2

Figura 27 Implementarea optimă a comenzii Broadcast pe reţea mesh 2D

40
Implementarea unei comenzi Broadcast pe o reţea mesh 3D cu 8 calculatoare [7]
Pentru reţeaua mesh 3D din figura 28, execuţia unei comenzi Broadcast cu sursa 0
prin metoda dublării recursive conduce la 3 faze în transmisie, evidenţiate prin săgeţile
întrerupte care sunt numerotate corespunzător:
• o primă comunicaţie între 0 şi 4, care durează o unitate de timp;
• două transmisii efectuate în paralel, 0 – 2 şi 4 – 6, care durează tot o unitate de
timp;
• patru schimburi simultane de mesaje, 0 – 1, 2 – 3, 4 – 5 şi 6 – 7, care de asemenea
durează o unitate de timp.
Metoda dublării recursive este optimă, deoarece realizează transmisia în cel mai scurt
timp (calculatoarele 0 şi 7 au nevoie de 3 unităţi de timp pentru a transfera un mesaj) şi nu
există aglomerare pe canalele de comunicaţie.

Figura 28. Implementarea optimă a comenzii Broadcast pe reţea mesh 3D

În concluzie, implementarea comenzilor colective este extrem de importantă în cazul


aplicaţiilor distribuite şi depinde atât de topologia reţelei, cât şi de sursă/destinaţie. Modelele
prezentate sunt ideale, în sensul că reţelele reale nu sunt nici echilibrate, nici statice şi foarte
rar au 8 sau 16 componente.
Pentru o eficientizare a comunicaţiilor în calculul distribuit este deci nevoie de
cunoaşterea reţelei, a dinamicii acesteia, a frecvenţei schimburilor de mesaje şi a încărcării
canalelor de comunicaţie. Experienţa şi cunoştinţele programatorului sunt deci extrem de
importante pentru scrierea unor aplicaţii distribuite eficiente.

41
Capitolul 4

Rezolvarea unei probleme prin calcul concurent

Proiectarea unui bun algoritm concurent este dificilă, deoarece trebuie să respecte
principiile generale de eficienţă, care să îi asigure succesul indiferent de condiţiile de
execuţie, dar să şi ţină cont de particularităţile problemei rezolvate. Principiile generale de
eficienţă sunt prezentate în continuare, exemplificate pentru câteva probleme simple;
programatorul care este pus în situaţii efective de rezolvare a unei probleme poate astfel
aplica aceste principii şi le poate adapta situaţiei sale concrete. În capitolul următor sunt
descrise câteva probleme reale, care apar des în proiectele software şi pentru care principiile
generale de eficienţă sunt discutate.

4.1. Graful de precedenţă

Folosirea acestui instrument vizual uşurează proiectarea algoritmilor concurenţi,


deoarece permite observarea modului în care interacţionează procesele. Graful de precedenţă
descrie ordinea în care procesele se execută pentru a rezolva problema în mod concurent.
Graful de precedenţă este graful orientat aciclic în care nodurile sunt procese şi arcul
(i j) arată că procesul j poate începe numai după ce procesul i s-a încheiat. Reamintim că un
graf orientat G este o pereche de mulţimi (V , E ) unde V este mulţimea nodurilor, iar E este
mulţimea arcelor, E ⊂ V × V . Graful este aciclic dacă nu există un drum închis (o succesiune
de arce care să înceapă şi să se încheie în acelaşi nod). În figura 29 este prezentat un graf
orientat ciclic, iar în figura 30 este unul aciclic, obţinut din cel din figura precedentă prin
schimbarea direcţiei unui singur arc, evidenţiat prin săgeată groasă.

Figura 29. Graf orientat ciclic Figura 30. Graf orientat aciclic

Exemple de grafuri de precedenţă


Problema 1: Plecând de la matricea pătrată A(n,n) şi de la vectorul b(n), să se
calculeze cu ajutorul formulei c = A ⋅ b valorile stocate în vectorul c.
Pentru rezolvarea prin programare concurentă a acestei probleme, putem alege o granularitate
fină, creând n procese şi repartizând fiecărui proces Pi , 1 ≤ i ≤ n calculul elementului ci .
Astfel, procesul Pi are nevoie de linia i din matricea A şi de vectorul b. Procesele sunt
independente, pot fi realizate simultan în cazul ideal, când se pot repartiza fiecare unei unităţi

43
de procesare, sau se pot executa în orice ordine atunci când nu sunt suficiente unităţi de
procesare. Graful de precedenţă are deci n noduri şi niciun arc (figura 31).

1 2 3 ... n-1 n

Figura 31. Graful de precedenţă pentru n procese independente

În cazul în care se implementează o granularitate constantă k, indiferent de


dimensiunile matricei A, atunci cele k procese se alocă astfel:
• P1 calculează c1 , c 2 , K , c n ;
k

• P2 calculează c n , c n , K , c n ;
+1 +2 2
k k k

• ...
• Pk calculează c n ,c n ,K, c n .
( k −1) +1 ( k −1) + 2
k k
În figura 32 este prezentat graful de precedenţă pentru k = 4.

1 2 3 4

Figura 32. Graful de precedenţă pentru 4 procese independente

Problema 2: Plecând de la comanda SQL


SELECT * FROM CARS
WHERE MODEL = “LOGAN” AND AN = 2012 AND
(CULOARE = “ALB” OR CULOARE = “GRI”);
să se descrie cum se poate executa eficient (rapid) în paralel.

Rezolvare. Această comandă SELECT foloseşte un singur tabel (CARS), din care
extrage articolele care îndeplinesc o condiţie complexă. Vom nota sub-mulţimile de articole
din CARS care sunt implicate în această execuţie astfel:
• A1 este formată din maşinile Logan;
• A2 conţine maşinile produse în anul 2012;
• A3 are date despre maşinile albe;
• A4 se referă la maşinile gri.
Comanda SELECT are deci scop listarea elementelor mulţimii
A = A1 I A2 I ( A3 U A4 ) .
Elementele mulţimii A se pot afla în paralel în mai multe moduri. În cele două cazuri
pe care le vom analiza, paralelismul execuţiei va fi evaluat, folosind graful de precedenţă.
Rezolvarea 1. O posibilă rezolvare a problemei construieşte mulţimea A astfel:
• A5 = A1 I A2
• A6 = A3 U A4
• A = A5 I A6 .

44
Dacă există procesoare disponibile, atunci graful de precedenţă pentru acest mod de
rezolvare a problemei este prezentat în figura 33 (mulţimile A5 şi A6 se pot găsi în paralel).

A1
A5

A2
A

A3
A6

A4

Figura 33. Graful de precedenţă pentru rezolvarea 1

Gradul de concurenţă este dat de numărul de procese care se pot desfăşura simultan. În acest
4 + 2 +1
caz, gradul mediu de concurenţă este = 2,33 . Dacă presupunem că durata de
3
execuţie a proceselor este aceeaşi (în lipsa unor informaţii privind durata lor reală), atunci
drumul critic în graful de precedenţă din figura 33 are lungimea 2. Reamintim că drumul
critic într-un graf aciclic este lungimea celui mai mare drum care leagă două noduri.
Rezolvarea 2. O rezolvare cu un grad mediu de concurenţă mai scăzut poate construi
mulţimea A astfel:
• A5 = A3 U A4
• A6 = A2 I A5
• A = A1 I A6 .

A1

A
A2
A6

A3
A5

A4

Figura 34. Graful de precedenţă pentru rezolvarea 2

4 +1+1+1
În acest caz, gradul mediu de concurenţă este = 1,75 şi drumul critic are
4
lungimea 3.

45
În capitolul următor vom prezenta o problemă pentru care vom analiza din nou efectul
ordinii efectuării unor operaţii asociative. Aici am considerat că durata proceselor este
identică; în subcapitolul 5.2 vom vedea care este impactul unor durate diverse asupra
timpului total de execuţie al unei aplicaţii paralele.

Evaluarea paralelismului
În cazul unei aplicaţii paralele, programatorii sunt interesaţi de optimizarea
următoarelor caracteristici:
• gradul maxim de concurenţă, care arată numărul maxim de unităţi de procesare
disponibile, fiind deci o valoare limitată de configuraţia hardware pe care se
execută aplicaţia;
• gradul mediu de concurenţă, care arată cât de „paralelizată” este o aplicaţie, fiind
o imagine a eficienţei algoritmului;
• drumul critic al grafului de precedenţă, care este lungimea maximă a drumurilor în
graf, obţinută prin ponderarea nodurilor cu timpii corespunzători de execuţie.
Această valoare arată timpul total de execuţie.

Aceste măsuri sunt caracteristice aplicaţiilor concurente, adăugându-se măsurilor


cantitative generale ale aplicaţiilor (complexitate, număr de linii de cod, timp de execuţie,
etc.) şi celor calitative, specifice Ingineriei software (portabilitate, claritate, mentenabilitate,
etc.). Cele două probleme concrete prezentate anterior arată cum graful de precedenţă este un
instrument general, care poate evalua eficienţa unei metode concurente de rezolvare.

4.2. Metode de descompunere a rezolvării unei probleme

Realizarea unei aplicaţii concurente presupune (printre altele) şi partajarea activităţii


între mai multe procese desfăşurate concurent. Descompunerea se referă atât la efortul de
procesare (descompunere funcţională), cât şi la repartizarea datelor pe mai multe procese
(descompunerea domeniului). De fapt, această clasificare urmează clasificarea Flynn
(descrisă în subcapitolul 1.3), considerând dimensiunea proceselor (pentru descompunerea
funcţională) şi a datelor (pentru descompunerea domeniului). O aplicaţie complexă poate
folosi diverse tipuri de descompunere, caz în care descompunerea sa devine hibridă.

4.2.1. Descompunere funcţională

Descompunerea funcţională descrie modul în care efortul computaţional se


repartizează unor procese care avansează concurent. Problema care se rezolvă poate avea o
descompunere „naturală” în procese – caz în care programatorul o poate folosi. Există însă şi
metode generale, care se pot aplica unor categorii de probleme.
Descompunerea recursivă este o descompunere funcţională care lansează recursiv
procese, care rezolvă fiecare câte o sub-problemă. Dacă metoda de rezolvare este de tip
divide-et-impera, atunci fiecare sub-problemă se poate rezolva folosind un proces, lansat prin
recursivitate; soluţiile sub-problemelor sunt colectate de procesul iniţial, care construieşte din
acestea soluţia problemei. Comunicaţiile în acest caz se rezumă doar la lansarea recursivă a
noilor procese şi la colectarea soluţiilor, deoarece sub-problemele derivate prin divide-et-
impera sunt independente. Dacă problema se rezolvă prin programarea dinamică, atunci tot
prin recursivitate se pot genera procese destinate rezolvării sub-problemelor. Modelul
comunicaţiilor va fi mai complicat, deoarece în acest caz sub-problemele sunt dependente.

46
Exemple de paralelizare prin descompunere recursivă sunt: calculul termenului
generic din şirul lui Fibonacci (prezentat în Capitolul 1), sau sortarea prin metoda rapidă
(quick-sort).
Descompunerea speculativă este descompunerea funcţională în care se lansează (pe
procesoare care altfel nu ar avea încărcare) în avans procese-alternative: procese care
modelează ramurile unei instrucţiuni if-then-else sau case/switch. Când procesul-părinte
ajunge la respectiva instrucţiune şi decide care este ramura pe care se va avansa, atunci se
închid toate procesele-fiu şi se preia starea procesului-fiu corespunzător ramurii alese.

4.2.2. Descompunerea domeniului

Descompunerea domeniului se referă la repartizarea datelor problemei pe diverse


procese, care le vor prelucra în paralel. La nevoie, în final, rezultatele proceselor se
asamblează în soluţia problemei.
Un exemplu de descompunerea a domeniului este descompunerea exploratorie, în
care spaţiul de soluţii este partajat în mulţimi disjuncte, fiecare apoi fiind repartizată unui
proces spre investigare. Fiecare proces găseşte o soluţie optimă în mulţimea sa; aceste soluţii
optime sunt preluate de un proces şi este aleasă cea mai bună dintre ele, care devine soluţia
problemei. O astfel de problemă este alegerea mutărilor într-un joc (de exemplu, în jocul de
şah): algoritmul poate dezvolta un arbore, care are în rădăcină starea curentă şi fii rădăcinii
sunt toate mutările posibile. Fiecare dintre fii rădăcinii este repartizat (ca fiind starea curentă)
unui proces, care alege cea mai bună mutare, ţinând cont şi de mutările adversarului. Apoi se
alege dintre aceste soluţii foarte bune, pe cea mai bună.
Un alt exemplu este descompunerea datelor, când se partajează datele pe procese
(aceeaşi idee ca în cazul SIMD din clasificarea Flynn). Datele de intrare se partajează dacă se
execută o interogare pe o bază de date distribuită: de la fiecare centru de stocare se
recepţionează rezultatele intermediare, care sunt asamblate pe sistemul clientului.
Descompunerea datelor de ieşire se poate realiza dacă se consideră calculele numeric
intensive, de exemplu problema 1 de la subcapitolul 4.1.

Descompunerea problemei este extrem de importantă în calitatea aplicaţiei care o


rezolvă, deoarece determină echilibrul, încărcarea şi comunicaţiile între procese.

47
9 8 6 4

8 9 6 8 4 6 5

7 6 9 4 8 5 6

6 7 4 9 5 8 7

5 4 7 5 9 7 8

4 5 7 9

1 2 3 4 5 6 timp

Fig. 40. Reţea simplă pentru sortarea a 6 elemente

O reţea de sortare simplă care permite la intrare o secvenţă de n valori necesită cel
n( n − 1)
puţin C n2 = comparatoare (deoarece un comparator poate produce cel mult o
2
inversiune – atunci când secvenţa este privită ca o permutare; datele descrescătoare, ca în
exemplul din figura 40, necesită numărul maxim de inversiuni). Din acest punct de vedere,
reţeaua din fig. 40 este optimă, având exact C 62 = 15 comparatoare.
Eficienţa reţelei simple de sortare cu un număr minim de comparatoare este mare,
deoarece:
• transferul de date se face în proximitate, consumul de resurse fiind minimal;
• dispozitivele hardware (comparatoarele) sunt în număr minimal;
• comparările se pot realiza în paralel – acesta fiind singura caracteristică unde
intervin cunoştinţele programatorului şi unde se pot obţine execuţii mai scurte.
Exemplul următor ilustrează cum organizări diferite conduc la timpi diferiţi de
execuţie.

Exemplu de sortare folosind reţea de comparatoare: Metoda Bubble-sort


Paralelizarea sortării prin reţea de comparatoare este o problemă dificilă, în general. O
idee de paralelizare este abordarea individuală, speculând ideea de bază a metodei de sortare
avută în vedere. O altă abordare este tratarea recursivă la nivelul unei clase de metode,
folosind de exemplu formulele:
x = max(a1 , a 2 ,K , a n )
sort ( a1 , a 2 , K , a n ) = ( sort (delete( x, a1 , a 2 , K, a n )), x)
valabile în cazul sortării crescătoare.

Aceste formule arată că procesul de sortare a n valori se face aşezând pe ultima


poziţie cea mai mare valoare dintre cele n valori intrate în procesul de sortare, extrăgând
această valoare şi sortând cele n - 1 valori rămase. Reţeaua de comparatoare care
implementează recursiv abordarea globală a sortării este reprezentată în figura 41.

65
Timpul necesar pentru implementarea sa secvenţială este dat de următoarea relaţie de
recurenţă:
T ( n) = (n − 1) + T (n − 1)
n( n − 1)
şi cum T(2) = 1, avem că T ( n) = ( n − 1) + ( n − 2) + K + 1 =
2

Bloc pentru n – 1
elemente
n elemente

1 2 3 ... n-1 timp

Fig. 41. Model de reţea recursivă pentru sortarea secvenţială a n elemente

Dacă, însă, metoda de sortare permite utilizarea simultană a mai multor comparatoare,
atunci timpul de sortare poate scădea. De exemplu, prima comparare aferentă Blocului pentru
sortarea a n - 1 elemente se poate realiza la momentul 3, nu se aşteaptă până la momentul n.
Sunt necesare 9 unităţi de timp, nu 15 cum ar rezulta din formula de mai sus pentru n = 6
(figura 42).
6 elemente

1 2 3 4 5 6 7 8 9 timp

Fig. 42. Reţea pentru sortarea a 6 elemente prin metoda Bubble-sort

66
Reţeaua din figura 42 este simplă, optimă din punct de vedere al numărului de
comparatoare (15), dar are nevoie de mai mult timp decât sortarea din fig. 40, care este mai
compactă. Gradul de paralelizare al sortării din ultima figură este mai scăzut.
O altă idee de paralelizare este găsirea în paralel a optimului (valorii maxime) din
secvenţa de n elemente, folosind metoda turneului, prezentată deja în acest subcapitol.
Relaţia de recurenţă pentru timpul de sortare în paralel devine astfel:

T ( n) = log(n) + T ( n − 1)

şi cum T(2) = 1, avem că:

T ( n) = log(n) + log(n − 1) + K + 1 = log(n!) ≈ n log(n)

conform aproximării Ramanujan [14].

Iată cum, deşi procesele sincronizează la fiecare trecere la o secvenţă mai scurtă
(producând întârzieri inerente), paralelizarea produce o scădere semnificativă a timpului de
sortare.
La nevoie, dacă problema are restricţii semnificative de timp, atunci aceste două idei
se pot combina. Algoritmul rezultat este mult mai complex, dar asigură un timp de execuţie
extrem de scăzut.

În acest capitol am prezentat câteva probleme pentru care rezolvările concurente au


fost discutate. După cum am văzut, nu există soluţii unice de paralelizare, fiecare având
avantajele sau dezavantajele sale. Dezvoltările tehnologice sau noile limbaje concurente pot
oricând recomanda noi descompuneri sau noi şabloane de comunicaţie între procese.

67

S-ar putea să vă placă și