Mihai Budiu -- mihaib+@cs.cmu.edu
http://www.cs.cmu.edu/~mihaib/
16 iulie 1999
Textul de față se constituie într-un al patrulea articol din seria ``Arhitectura modernă a calculatoarelor''. Deși fac tot ce îmi stă în putință în a face fiecare articol independent de celelalte, ar fi o risipă dacă nu aș refolosi unele dintre informații; din cauza asta trimit cititorul doritor de a lămuri mai multe aspecte la articole precedente; cel mai folositor este cel intitulat ``Despre conducte'', publicat în PC Report din decembrie 1998. Pentru cei care nu au revista, dar au acces la Internet, textul este disponibil din pagina mea de web.
Procesoarele moderne sporesc performanța printr-o multitudine de mijloace; de căpetenie este exploatarea paralelismului din programe. Spunem că un program posedă paralelism dacă părți diferite din program pot fi executate simultan fără a schimba rezultatele finale.
Am ilustrat în texte anterioare cum procesoarele moderne exploatează paralelismul prezent între instrucțiuni consecutive dintr-un program, paralelism numit ``la nivel de instrucțiune'' (Instruction Level Parallelism, ILP). Două sunt mijloacele de căpetenie folosite pentru acest scop: tehnologia de pipeline, și tehnologia superscalară. Voi reaminti pe scurt în ce constau acestea, și care sunt piedicile care stau în calea exploatării lor.
Restul articolului va arăta cum piedicile mai sus-numite includ o piedică foarte importantă cantitativ: chiar instrucțiunile de salt dintr-un program. Restul articolului va arăta care sunt metodele tehnologice folosite pentru a reduce impactul negativ al salturilor.
Paralelismul superscalar este relativ ușor de înțeles: dacă un procesor obișnuit are cîte o bucată din fiecare unitate funcțională, un procesor superscalar are mai multe astfel de copii. Astfel, un procesor superscalar poate efectua simultan două adunări, pentru ca posedă două unități aritmetice. De asemenea, el poate de obicei lansa în execuție mai multe instrucțiuni, pentru că are mai multe unități care decodifică instrucțiunile, etc. Paralelismul superscalar este întîlnit peste tot: atunci cînd o pompă de benzină angajează mai mulți lucrători pentru a manipula pompele, exploatează paralelismul în același fel ca un procesor superscalar.
Pe de altă parte, paralelismul pipeline este inspirat după banda de asamblare de la fabricile de mașini (și nu numai): un lucrător face șasiul, altul pune ușile, un al treilea parbrizul; toți acești lucrători manipulează simultan mașini diferite; o mașină trece succesiv pe la fiecare din ei, și în timp ce prima mașină, cu ușile deja puse, are parbrizul în curs de montare, o a doua tocmai își primește portierele.
Toate procesoarele moderne de performanță folosesc ambele tehnici simultan.
Din păcate, în lumea calculatoarelor, lucrurile nu sunt așa de simple ca într-o fabrică. Asta se întîmplă pentru că paralelismul se poate aplica numai dacă obiectele asupra cărora se operează sunt independente. Dar instrucțiunile unui program depind adesea una de alta; în definitiv un program nu este decît un șir de prelucrări asupra acelorași date. De exemplu, dacă avem două instrucțiuni consecutive: a=b+c; d=d-a, a doua evident nu poate fi executată pînă cînd prima nu s-a terminat, pentru că are nevoie de valoarea lui a calculată de prima. Spunem că între aceste instrucțiuni avem o dependență.
Cînd un procesor întîlnește cod ca cel de mai sus, nu are mare lucru de făcut: va trebui ca unii dintre lucrători să stea nefolosiți o vreme, pentru că nu au nimic de făcut.
Trebuie să realizăm că, decizia dacă astfel de situații sunt sau nu importante, depinde enorm de frecvența lor. Dacă astfel de cazuri apar extrem de rar, atunci optimizările făcute pentru a le preveni nu vor avea un impact prea mare, pentru ca nu e mare lucru de cîștigat. Dimpotrivă, dacă astfel dependențele sunt dese, impactul lor asupra performanței este major.
Bine, dar dese unde? În care program? Nici măcar în interiorul aceluiași program nu avem întotdeauna o densitate constantă de dependențe. Ce trebuie să măsurăm? Atunci cînd un proiectant construiește un nou procesor, trebuie să aibă oarecare metode pentru a-l evalua.
Ei bine, experți din industrie au pus la punct niște suite de măsurători (benchmarks) pentru a evalua performanța sistemelor de calcul. Cele mai faimoase, și probabil și cele mai criticate, sunt cele numite SPEC, de la Standard Performance Evaluation Corporation. Puteți găsi amănunte despre acestea la http://www.specbench.org/. Scopul acestui articol este însă altul, așa că nu voi divaga prea mult despre acest interesant subiect. Important este de reținut că afirmațiile cu caracter cantitativ din acest articol, care nu este clar cui se aplică, sunt făcute în contextul acestor suite de măsurători.
De exemplu, principalul personaj al acestui articol, salturile, apar în medie la fiecare șapte instrucțiuni într-un program din suita SPEC. Dacă numărul era 1 la 100, situația era complet diferită. Proporția este însă mai mult decît semnificativă, și de aceea articolul de față există (are un subiect).
Înainte de a vedea de ce sunt salturile o problemă, vom nota că problema se manifestă în faptul că execuția unei instrucțiuni de salt durează mai mult decît a unei instrucțiuni obișnuite, cel puțin în cazul în care nu aplicăm nici una dintre tehnologiile descrise mai jos. Un exemplu tipic este ca o instrucțiune aritmetică să se execute într-un ciclu de ceas, iar un salt să dureze 3. Cît de importantă este contribuția salturilor în performanța programului? Cu alte cuvinte, cu cît mai încet merge programul decît în cazul în care fiecare salt ar dura și el exact un ciclu?
Un pic de aritmetică răspunde la această întrebare: raportul este 7/(6 + 1*3), din cauză că 7 instrucțiuni ne costa 6+3 cicli în loc de 7; asta înseamnă 7/9, sau o performanță de 77%. Am pierdut deci un sfert din performanță.
Am mai menționat și cu alte ocazii că arhitecții calculatoarelor din ziua de azi sunt gata de orice pentru o creștere de 10% a performanței, iar multe articole publicate se mulțumesc cu chiar mai puțin de atît. În acest context 23 de procente este o cantitate uriașă, care categoric merită diminuată.
Chiar dacă ne-am mulțumi cu aceste pierderi, nu am fi liniștiți pentru multă vreme: pe măsură ce conductele procesoarelor devin din ce în ce mai lungi, și superscalarele pun la bătaie din ce în ce mai multe unități funcționale, penalizarea unui salt crește din ce în ce mai mult. Asta nu pentru că durata unui salt crește neapărat, ci pentru că mai mulți lucrători stau degeaba; un superscalar cu două unități funcționale, în mod ideal ar putea termina două instrucțiuni în fiecare ceas. Atunci prezentă salturilor ar duce la o performanță de 3.5/(3+1*3) = 3.5/6 (3 cicli pentru șase instrucțiuni și 3 pentru salt, în loc de 3.5 în total), sau 58%. Ori procesoarele moderne au uneori chiar mai multe resurse paralele decît atît; nu e păcat ca jumătate din timp să fie irosit?
De ce sunt salturile scumpe? Nu e prea greu de înțeles: instrucțiunile unui program se execută în mod normal în ordine crescătoare a adreselor: 0, 1, 2, 3, etc. Salturile însă perturbă această ordine, indicînd noi adrese de unde programul trebuie executat.
Dar cum funcționează un procesor care exploatează paralelismul? Extrage mai multe instrucțiuni consecutive și încearcă să le execute în paralel, în cazul că nu au dependențe. Ori saltul indică faptul că acele instrucțiuni nici nu trebuie executate!
Salturile sunt categorisite drept tot dependențe de un tip special: dependențe ale controlului (control dependencies). Se numesc astfel pentru că în terminologia limbajelor de programare tot ce nu calculează se numește control; instrucțiunile care indică direcția de execuție sunt instrucțiuni de control.
Voi ilustra importanța dependențelor controlului pentru un procesor pipeline; pentru o explicație mai amplă despre cum se poate citi o schemă ca cea de mai jos vedeți articolul recomandat în introducere.
Voi presupune ca procesorul nostru are o conductă formată din următoarele stagii: un stagiu de citire a instrucțiunii, care o aduce din cache, unul de decodificare, care determină ce face instrucțiunea și aduce operanzii de care are nevoie, stagiul principal, de execuție, în care operația indicată este efectuată asupra datelor, urmat de stagiul de scriere, în care datele calculate sunt puse la locul dorit. Țeava poate conține și alte stagii, dar deocamdată doar acestea ne interesează.
Problema saltului (figura 1) este următoarea: adresa unde se face saltul și condiția de care saltul depinde sunt calculate doar în stagiul de execuție, și pot fi folosite doar în stagiul de scriere. Dar în momentul cînd instrucțiunea de salt a ajuns în acest stagiu, în mod normal instrucțiunile de după ea au intrat deja în țeavă.
Problema este că salturile condiționale pot avea două destinații posibile; oricum am ghici în absența rezultatului final, în orice parte am lua-o, putem greși. Ori nu vrem în nici un caz ca procesorul să execute instrucțiuni pe care programul nu le indică!
Procesoarele oferă de obicei două tipuri de salturi: condiționale și necondiționale. Marea majoritate a salturilor sunt condiționale. Putem apoi categorisi salturile în salturi la adrese fixă (dominante ca număr), salturi la o adresă calculată (relativ puține, sintetizate pentru instrucțiunile de tip switch-case din C/Pascal) și instrucțiunile de întoarcere de la un apel de subrutină. Fiecare dintre aceste tipuri de instrucțiune de salt necesită alte tehnici pentru a fi optimizată; în acest text vom insista asupra instrucțiunilor de salt condiționat.
Cum facem pentru a trata instrucțiunile de după un salt? Cea mai simplă și mai costisitoare soluție constă în a detecta instrucțiunile de salt cît se poate de devreme (în stagiul de citire, dacă se poate) și de a bloca restul țevii din execuție pînă cînd adresa și condiția saltului sunt cunoscute. Terminologia tehnică pentru blocare este stall. Cel mai simplu mod de a face stall este de a injecta în mod artificial în țeavă o instrucțiune care nu face nimic, numită ``noop'' (``no operation'') și de a continua execuția cu aceasta. Această instrucțiune fictivă se mai numește și ``bulă'' (bubble). Figura 2 ilustrează acest procedeu.
Din păcate această soluție asigură doar corectitudinea programului, și nu și eficiența sa. Dacă procedăm astfel plătim toate costurile descrise mai sus. Ar fi grozav dacă am avea o soluție mai eficientă. Aparent nu avem nimic de făcut, dacă nu facem o simplă observație.
Observația de care avem nevoie este că o instrucțiune, pentru a avea efecte permanente și vizibile, trebuie neapărat să-și scrie rezultatele undeva: fie într-un registru, fie în memorie. Asta înseamnă că atîta timp cît instrucțiunea nu a ajuns în stagiul de scriere ea nu este nicidecum vizibilă dinafară. Perfect: atunci putem face următorul lucru: putem încărca în țeavă și alte instrucțiuni de după cea de salt și le lăsăm să se execute. Cînd ajungem cu saltul în stagiul de execuție știm deja dacă celelalte instrucțiuni trebuiau sau nu să fie executate; dacă observăm că am ales bine, suntem oameni făcuți, pentru că saltul nu ne costă decît un ciclu, succesorii lui fiind deja gata de execuție. Altfel recurgem la o soluție asemănătoare cu cea de mai sus: ștergem conținutul din începutul țevii, introducînd mai multe bule.
Acest tip de execuție în care mizăm pe anumite instrucțiuni de a fi utile, și în care dacă ne dăm seama că am greșit renunțăm la ele, se numește execuție speculativă (speculation). Această tehnică este din ce în ce mai folosită în procesoarele moderne.
Observați că în acest caz nu avem nimic de pierdut: performanța poate doar crește, pentru că instrucțiunile pentru care am ghicit bine vor fi mai scurte, iar celelalte vor dura tot atît.
Un procesor superscalar poate face și mai multă speculație: poate porni în execuție ambele destinații ale ramurii (presupunînd că adresa destinație poate fi calculată rapid, înainte de a ajunge în stagiul de execuție), după care poate alege să păstreze numai destinația reală.
Întrebarea la care nu am răspuns este: ``cum ghicim dacă saltul este luat sau nu?'' Ei bine, există o sumedenie de scheme pentru a face acest lucru, din ce în ce mai complicate, dintre care unele le vom explora în continuarea acestui text. Să începem însă cu cele mai evidente, care iau mereu aceeași decizie (și de aceea le voi numi ``statice'').
Cea mai simplă schemă este să presupunem că saltul nu este niciodată luat, și să continuăm să executăm instrucțiunile în continuare. Schema aceasta are meritul de a fi extrem de simplu de implementat. Performanța ei este de cam 40% (adic'a pentru 40% din salturi presupunerea se dovedește corectă). (Exercițiu: calculați care este performanța procesorului în acest caz; presupuneți că toate instrucțiunile înafară de salt durează un ciclu, 40% din salturi durează tot un ciclu, iar restul salturilor durează 3.)
Un scurt raționament ne va și explica de ce schema aceasta nu are nici un fel de șanse să fie mai eficace de atît. Cea mai mare parte a timpului unui program se petrece în cicluri (dacă un program nu ar avea cicluri, execuția lui s-ar termina imediat, pentru că viteza procesorului este de ordinul a sute de milioane de instrucțiuni pe secundă). Ori un ciclu trebuie să conțină cel puțin o instrucțiune de salt care este luat în mod frecvent. Pentru acest salt, metoda de mai sus va ghici mereu eronat.
Cînd designerii au realizat acest lucru, s-au gîndit să schimbe ghicitul în sens exact opus: vor prezice că instrucțiunile de salt sunt toate luate. Calculul adresei destinație în general nu este o problemă, pentru că, așa cum am văzut mai sus, majoritatea salturilor se efectuează la adrese constante, care fac parte din chiar codul instrucțiunii. În mod natural, corectitudinea unei astfel de scheme este de cam 60%.
Un alt rafinament al schemei este a observa că orice buclă trebuie să aibă cel puțin un salt înapoi care este luat. Măsurători pe SPEC au arătat că o schemă care prezice că orice salt înainte nu e luat și orice salt înapoi este are performanțe mai bune decît schema precedentă.
Figura 6 arată acuratețea ghicirii pentru diferite scheme de salt. Deocamdată puteți citi primele trei coloane; despre următoarele vom discuta în continuare.
Trebuie să observăm că, orice schemă de predicție vom implementa, ea trebuie să fie foarte simplă și rapidă. Nu ne putem permite să executăm un algoritm complicat de zeci de instrucțiuni pentru a economisi doi cicli de ceas! Mai mult, soluțiile trebuie să fie toate implementabile în hardware, ceea ce este o constrîngere destul de severă. Vom vedea însă că imaginația cercetătorilor depășește toate aceste obstacole, creind scheme foarte ingenioase.
O metodă foarte interesantă de a face predicția este de a memora în cache-ul de instrucțiuni vechea comportare a unui salt: atît condiția sa cît și adresa de destinație. S-a observat, tot prin măsurători, că foarte adesea salturile tind să se facă în aceeași direcție și în același loc de mai multe ori consecutiv. Această schemă este folosită de microprocesorul PPC604.
Rafinamente ale acestei scheme au dus la crearea unui mijloc modern de anticipare a destinației, numit ``trace cache'' (cache-urmă). Aceasta este o invenție relativ recentă (la ora actuală nu știu de nici un procesor care să o folosească, dar este sigur că în curînd va fi o prezență comună în fiecare calculator), care practic rescrie programul în mod dinamic în cache, punînd instrucțiunile care tind să fie executate în succesiune una după alta. De pildă, dacă un salt este mereu luat, trace-cache-ul va pune instrucțiunile de dinaintea saltului și cele de după una după alta, și va schimba apoi condiția saltului în cea opusă; schema aceasta permite de asemenea procesoarelor un acces mult mai eficace la cache. Sper să revin cu mai multe amănunte asupra acestei interesante tehnologii în alte articole.
Soluția cu cache-ul (nu cache-ul urmă, ci cea care menține informațiile despre salt) este destul de ingenioasă, dar este cam costisitoare și destul de complicată din punct de vedere hardware; cere extragerea mai multor date din cache decît normal și aparatură mai complicată de decodificare.
Putem simplifica schema menținînd o tabelă separată în interiorul procesorului, și nu în cache.
Schema aceasta este extrem de interesantă, pentru că aparent face un compromis destul de mare: amestecă laolaltă informația despre toate salturile din program într-o tabelă unică. Algoritmul este următorul:
Metoda este extrem de ieftină de implementat și foarte rapidă. Figura 3 arată cum este construită în hardware. Ne poate face însă să ne simțim nesiguri: funcția de hash poate amesteca salturi independente. Este adevărat, pentru exemplul de mai sus, dacă avem două salturi în program ale căror adrese se termină în aceiași 10 biți, atunci informația despre condiția ambelor va fi stocată în același loc în tabel.
Și atunci cum putem avea încredere? Ei bine, dacă puneți această întrebare înseamnă că ați uitat de fapt că tot ceea ce facem este doar o ghiceală; nimic nu este sigur aici. Execuția speculativă ne asigură că nu putem strica nimic ghicind greșit. Singura care poate avea de suferit este performanța. Dar performanța depinde de procentul de erori. Ori avem două fenomene care ne vin în ajutor pentru a face schema de mai sus foarte rezonabilă:
Predicția cu 1 bit de ``istorie'' este bunicică, dar suferă de un simptom: este prea sensibilă la mici perturbații. Să presupunem că avem un salt care este mai întotdeauna luat, și numai în mod excepțional nu este. Ei bine, iată cum se va comporta predictorul:
Salt | D | D | D | D | N | D | D | D | N | D |
Predicție | - | D | D | D | D | N | D | D | D | N |
Corect | - | D | D | D | N | N | D | D | N | N |
Ați văzut slăbiciunea? Ei bine, la fiecare ``Nu'' predictorul va ghici prost, după care va schimba pe ``Nu'', deci va ghici din nou prost și data viitoare. La fiecare schimbare facem două erori. În plus, pentru un salt care alternează la fiecare execuție luat/ne-luat, acest predictor va greși tot timpul (mai rău chiar decît schemele de predicție statică). Aparent acest gen de salturi este relativ frecvent, așa că merită să facem un efort să îmbunătățim cumva metoda.
Soluția este din nou la îndemînă: în loc de un bit vom folosi mai mulți! Vom implementa pentru fiecare rînd din tabelă un mic automat finit, care va avea patru stări, ca în figura 4. Stările sunt: ``Sigur Nu'', ``Poate Nu'', ``Poate Da'', ``Sigur Da''. Automatul va face tranziții spre dreapta la fiecare salt luat, și spre stînga la fiecare salt ne-luat.
Este un exercițiu simplu de a vedea că acest automat se va comporta mult mai bine pentru exemplul de mai sus (va face, desigur, erori, dar mult mai puține). Implementarea în hardware este de asemenea banală: folosește ceea ce se numește un contor saturat (saturated counter). Acest contor este ca unul obișnuit, care numără în sus la fiecare salt luat și în jos la fiecare ne-luat, dar care nu trece niciodată mai jos de 0 sau mai sus de 3. ``Ghiceala'' va corespunde celui mai semnificativ bit: dacă e 0, atunci nu sărim, dacă e 1 sărim.
Nu e deloc clar că putem crește calitatea predictorului folosind mai mulți biți pentru contoarele saturate, pentru că atunci predictorii atunci vor fi prea insensibili la schimbări.
Sigur, nici schema asta nu e impecabilă: există pentru fiecare schemă o succesiune de salturi care o poate facă să greșească la fiecare pas. Cu toate acestea, majoritatea procesoarelor din generațiile 1997-1998 foloseau această schemă.
Este un exercițiu interesant de a face ceea ce se numește ``reverse engineering'': putem scrie un program simplu care să testeze comportarea predictorilor la salturi. Iată un exemplu prezentat la cursul doctoral de arhitectura calculatoarelor, ținut de domnul profesor Randal Bryant în toamna trecută:
#define MARIME 1024 #define ABS(x) (((x) < 0) ? (-x) : (x)) int vector[MARIME]; int raspuns; static void bucla() { int i; unsigned suma = 0; int prod = 1; for (i=0; i < MARIME; i++) { x = vector[i]; unsigned ax = (unsigned)(ABS(x)); suma += ax; prod *= x; } raspuns = suma + prod; }
Instrucțiunea ABS se va traduce în ceva de genul:
ax = x; if (x > 0) goto corect; ax = -x; corect:
care conține un salt.
Pentru a studia comportarea fiecărui predictor inițializați vectorul vector cu valori potrivite (pozitive sau negative, după cum doriți să fie executat sau nu saltul), după care executați în mod repetat bucla și măsurați timpul de execuție. Am scris în trecut un articol lung în două episoade despre cum se pot face astfel de măsurători, care include și codul necesar; îl puteți obține din pagina mea de web.
Vă recomand să inițializați vectorul cu trei feluri de valori: pozitive (pentru salt ne-luat), negative (pentru salt luat) și aleatoare, în care valorile sunt generate la întîmplare. Încercați tot felul de formule: toate pozitive, alternant + - + - etc.), alternant dupa o secvență de inițializare + + + + - + - + etc.), aleator, etc.
Schemele de mai sus sunt simpatice, dar suferă de o boală comună: fiecare folosește numai informație locală, despre un singur salt. Dar adesea salturile din program sunt corelate între ele: dacă unul se execută, atunci și un altul se execută, etc.
O schemă deosebit de ingenioasă și eficace a fost propusă în 1993 de Yeh și Yale Patt (Yale Patt, profesor la Universitatea din Michigan, este și unul dintre inventatorii cache-ului cu urmă menționat mai sus, și este una dintre figurile cele mai proeminente din cercetarea contemporană în arhitectura procesoarelor).
Schema aceasta ține minte rezultatele ultimelor X salturi (de exemplu X=6) și în funcție de acestea prezice rezultatul următorului. Schema este imediat implementabilă în hardware: ne trebuie doar un ``shift register'' în care introducem cîte un bit la fiecare nou salt, și o tabelă de 2X înregistrări în care ținem minte rezultatul saltului următor, sau un contor saturat (figura 5). Arhitectural este destul de asemănătoare cu un predictor local; consumă cam aceeași cantitate de resurse hardware.
Schema aceasta pare complet paradoxală, pentru că nici măcar nu se uită care salturi sunt cele X, ci doar la rezultatele lor. Ca orice schemă de acest gen, valabilitatea ei poate fi doar verificată empiric; măsurători pe testele SPEC arată că schema are o comportare excelentă.
În fine, cel mai complicat predictor implementat în procesoarele contemporane este cel din procesorul Alpha 20264 al firmei Compaq (fost al lui Digital, acum achiziționată de Compaq). Acest predictor este de fapt o combinație a trei predictori:
Performanțele tuturor predictorilor prezentați sunt condensate în figura 6. După cum vedeți, predictorul mixt este excelent.
Am văzut că execuția speculativă deschide poarta unei serii întregi de metode empirice de predicție, unele foarte neverosimile. Cercetătorii contemporani însă împing și mai departe aceste tehnici, pentru aplicarea lor și înafara domeniului salturilor. De exemplu, la ultima conferința internațională de arhitectura calculatoarelor ISCA (International Symposium on Computer Architecture) nu mai puțin de 5 articole din 26 discutau forme felurite de predicție a valorii (value prediction).
Predicția valorii este o generalizare a predicției salturilor. Ea poate de pildă fi aplicată și pentru cazul instrucțiunilor de întoarcere de la apelul unei proceduri, a căror adresă de întoarcere este extrasă de pe stiva din memorie.
În general, predicția valorii se face de fiecare dată cînd ceva ia mult timp pentru a fi obținut: de pildă accesul la memorie durează foarte mult (în cazul în care datele nu sunt cache), sau execuția anumitor operații aritmetice este foarte costisitoare, sau calculul destinației unui salt, etc. Pentru astfel de cazuri este mai bine ca procesorul să ghicească rezultatul final decît să stea degeaba să-l aștepte pe cel corect; dacă a greșit nu-i bai: calculele pot fi reluate de la punctul de unde a început speculația.
Măsurătorile experimentale au arătat că și scheme banale de predicție (de pildă: ``ultima valoare a acestui obiect'', menținută într-un cache) oferă îmbunătățiri substanțiale.
Sper să pot dedica un articol separat tehnicii de predicție a valorii, deși strict vorbind, predicția salturilor este doar un caz special. În procesoarele disponibile la momentul de față pe piață însă, putem găsi din plin implementate metode de predicție a salturilor, dar nu prea multe de predicția valorilor. Deci subiectul acestui articol merita o oarecare atenție individuală.
Toate metodele prezentate se bazează pe soluții hardware: procesorul menține informații suplimentare și are circuite în plus pe care le folosește pentru a anticipa rezultatele unor acțiuni din viitor.
Compilatorul, ca întotdeauna în epoca modernă a sistemelor de calcul (cu precădere în ultimii 20 de ani) are însă un cuvînt important de spus pentru a ajuta procesorul să-și sporească performanța. Vom indica pe scurt cîteva lucruri pe care compilatorul, cîteodată în conjuncție cu un suport specializat din partea procesorului însuși, le poate face pentru a scădea impactul costului salturilor:
for (i=0; i <10; i++) a[i] = b[i] * c[i];
poate fi transformată în:
for (i=0; i < 10; i+=2) { a[i] = b[i] * c[i]; a[i+1] = b[i+1] * c[i+1]; }
Avantajul acesteia este că face de două ori mai puține comparații cu 10, deci execută mai puține salturi (metoda se poate generaliza și pentru limite necunoscute sau impare);
c = (a < b) ? a : b
era implementată în procesoarele convenționale în acest fel (dau echivalentul în C al codului din limbajul mașină):
d = a - b; c = a; if (d > 0) goto corect; c = b; corect:
Instrucțiunea de încărcare condițională zice ceva de genul ``încarcă o anumită valoare numai dacă o condiție este adevărată''. Atunci traducerea ar arăta cam așa:
d = a - b; c = a; c = (d > 0) ? b : c; /* incarcare conditionala */
Observați cum saltul a dispărut cu totul.
Aceste instrucțiuni vor avea asociat un registru de un bit. Dacă bitul este 0 instrucțiunile sunt executate. Dacă bitul este 1, atunci instrucțiunile vor fi pur și simplu ignorate. Tehnica seamănă cu execuția speculativă, numai că controlul este dat de compilator, iar nu de procesor.
Aparent e o mare risipă: de ce să execuți instrucțiuni care nu le vrei, nu ar fi mai ieftin să sari peste bucata asta de cod? Așa cum am văzut, cu cît procesorul este mai paralel (are mai multe stagii în țeavă sau mai multe unități funcționale), cu atît costul unui salt (sau a oricărei instrucțiuni care se lasă așteptată de cele care depind de ea) este mai mare. Dacă un salt te costă cît 20 de instrucțiuni, atunci e clar mai bine să execuți 10 instrucțiuni inutile decît să sari peste ele. De exemplu, dacă notăm cu
c# instructiune
pentru a indica faptul că instructiune este predicată cu bitul c (adică se execută numai dacă bitul respectiv este 1), atunci următorul fragment de cod:
if (ceva) { bla; bla; bla; } else { tranca; tranca; tranca; }
se va traduce ca:
c = (ceva == 0); d = !c; c# bla c# bla c# bla d# tranca d# tranca d# tranca
Știm din articole anterioare că designerii sporesc performanța microprocesoarelor exploatînd paralelismul instrucțiunilor. Dar mai știm și că unele instrucțiuni nu se pot executa în paralel, pentru că sunt dependente una de alta. Am văzut în acest articol că instrucțiunile de salt implică astfel de dependențe, pentru că ele indică de fapt care este instrucțiunea următoare de executat.
Faptul că instrucțiunile de salt sunt extrem de frecvente pe arhitecturile contemporane (un rezultat al operațiilor primitive oferite de procesoare și al modului în care compilatoarele generează cod) este extrem de neplăcut, pentru că împiedică exploatarea tuturor resurselor de calcul.
Arhitecții au găsit o multitudine de soluții: unele dintre ele schimbă setul de instrucîuni al procesorului, permițînd scrierea de cod cu mai puține salturi. Altele se bazează pe soluții exclusiv hardware, în care procesorul încearcă să anticipeze direcția și destinația salturilor, și să execute în mod speculativ de acolo programul, în speranța că, dacă a ghicit corect, va cîștiga ceva timp.
Am văzut tot felul de scheme de predicție, unele foarte stranii și neintuitive, dar am mai văzut că ultimul cuvînt în estimarea calității unei scheme îl are performanța ei pe programe reale (de obicei însă acestea sunt substituite cu teste speciale gen SPEC).
Înainte de a încheia trebuie să vă spun că de fapt bogăția de scheme de predicție este mult mai mare, și acest text nu se ocupă decît de cele mai tradiționale. Dacă subiectul vă interesează, faceți un salt spre web și căutați mai multă informație. Eu deja pot să prezic unde veți ``ateriza'': la altavista sau o altă mașină de căutare înrudită. Se vede că am învățat ceva de la hardware...