Despre ``țevi''
Arhitectura Modernă a Procesoarelor (2)

Mihai Budiu -- mihaib+@cs.cmu.edu
http://www.cs.cmu.edu/~mihaib/

11 noiembrie 1998

Subiect:
pipelining
Cunoștințe necesare:
cunoștințe elementare despre arhitectura calculatoarelor, programare în limbaj de asamblare
Cuvinte cheie:
pipeline, stall, forward, hazard


Contents

Procesoarele RISC au apărut, în prima jumătate a anilor '80; era începutul unei revoluții în arhitectura calculatoarelor. Ideea era că în loc de a oferi o sumedenie de operațiuni exotice, este mai eficace pentru un procesor să ofere un set restrîns de operațiuni, pe care le poate executa cu foarte mare viteză. Primele procesoare RISC conțineau între 25 de mii și 40 de mii de tranzistoare.

În ziua de astăzi Pentium II conține 15 milioane de tranzistoare! Văzut dinafară Pentium II nu este un RISC, dar arhitectura internă este de acest tip.

Întrebarea este: ce s-a întîmplat cu toate tranzistoarele astea, unde au fost ``înghițite''? Setul de instrucțiuni al unui procesor modern este practic identic cu al unui procesor de acum 15 ani (exceptînd ornamente de genul MMX), deci funcționalitatea oferită este neschimbată. Atunci la ce treabă au fost înhămați toți tranzistorii? Răspunsul este: pentru a crește performanța.

Acest articol prezintă una dintre tehnicile cele mai simple la îndemîna unui proiectant pentru a crește performanța unui microprocesor; practic toate procesoarele fabricate în ziua de azi o folosesc. Este vorba de tehnica ``benzii de asamblare'', numită și pipeline. Vom vedea că e vorba de o idee extrem de simplă și eficace. Vom vedea apoi că proprietățile unei benzi de asamblare ridică o grămadă de noi probleme, și că implementarea tehnicii este extrem de complicată, cerînd un suport arhitectural substanțial. Vom mai vedea și unele dintre metodele folosite pentru a rezolva problemele ivite.

Tehnologie, paralelism și performanță

Există două forțe motoare responsabile de creșterea spectaculoasă a puterii de calcul a microprocesoarelor. Una dintre aceste forțe este tehnologia de fabricație și miniaturizarea. Miniaturizarea unui circuit integrat digital se măsoară în microni; distanța care se indică este, grosolan vorbind, distanța între două sîrme adiacente pe o suprafață a circuitului. Tehnologia curentă dominantă în acest an este undeva între 0.25 și 0.35 microni. Pentru comparație, un fir de păr omenesc are cam 25 de microni, adică de 100 de ori mai mult!

Scăderea dimensiunilor înseamnă că distanța dintre circuite scade, deci semnalul electric poate parcurge mai multe dintre ele în aceeași unitate de timp. De asemenea înseamnă că frecvența ceasului poate fi crescută. Ceasul unui microprocesor este echivalentul insului de la tobă de pe o galeră romană din antichitate: bate ritmul cu care se sincronizează toate părțile componente (vîslașii). Frecvențele atinse la ora actuală de procesoarele comerciale sunt de 600Mhz: 600 de milioane de operații pe secundă, pentru procesoarele Alpha 21264.

Atît despre tehnologie. A doua metodă de creștere a performanței, care este oarecum o consecință indirectă a miniaturizării, este paralelismul. Dacă ai mai mulți vîslași barca merge mai repede. Cu cît vîslașii sunt mai mici, cu atît poți pune mai mulți în cală. (Desigur, analogia nu e perfectă, pentru că, spre deosebire de galere, pentru un microprocesor un vîslaș mai mic face la fel de multă treabă ca unul mare.)

Practic toate articolele din seria aceasta vor discuta numai despre această a doua metodă. Vom vedea că procesoarele moderne consacră o cantitate impresionantă de resurse pentru a stoarce o cît de mică îmbunătățire a performanței (acolo se duc cele 15 milioane de tranzistoare ale lui Pentium). În general o dublare a mărimii circuitului produce mult mai puțin decît o dublare a performanței, dar în ziua de azi și obținerea cîtorva procente este o realizare meritorie.

Introducem acum cititorului prima tehnică de prelucrare paralelă, numită ``tehnica benzii de asamblare''.

Banda de asamblare

Despre economia capitalistă

Averea considerabilă a lui Henry Ford se datora, cel puțin în parte, metodei sale inovatoare de a organiza munca la fabricile sale de automobile: lucrătorii stau așezați de-a lungul unei benzi, iar mașinile neterminate merg de la unul la altul. Fiecare execută asupra mașinii o singură operațiune, după care o pasează mai departe. Asta e banda de asamblare.

Pentru a face mai evidente beneficiile tehnicii, îmi permit să o ilustrez cu încă un exemplu. Să presupunem că vreți să investiți într-o spălătorie/uscătorie (Nufărul?). Ce i-ați recomanda proprietarului: să cumpere 10 mașini de spălat care centrifughează și usucă, sau, cu aceiași bani, 9 mașini care spală și 9 care usucă? Presupunem că un spălatul și uscatul durează la fel de mult (oricare mașină am folosi), o jumătate de oră.

Răspunsul este: cu cele 9 perechi de mașini eficiența este cu 80% mai mare! Iată de ce: să presupunem că avem un șuvoi constant de consumatori. Atunci cu mașinile de spălat putem face 10 încărcături de rufe în fiecare oră. Cu mașinile perechi însă putem face 9 încărcături la fiecare jumătate de oră, așa cum arată tabelul 1:


Table 1: Funcționarea celor două modele de spălătorie presupunînd că există suficient de multe rufe. Varianta cu mașini de uscat și spălat separate produce de două ori mai multe serii de rufe spălate în același timp.
Ora 0 1/2 1 1 1/2 2 2 1/2 3
1 mașină intră 1 intră 2 intră 3 intră 4
1 terminat 2 terminat 3 terminat
2 mașini 1 la spălat 2 la spălat 3 la spălat 4 la spălat 5 la spălat 6 la spălat 7 la spălat
1 la uscat 2 la uscat 3 la uscat 4 la uscat 5 la uscat 6 la uscat
1 terminat 2 terminat 3 terminat 4 terminat 5 terminat


Cum rămîne cu calculatoarele?

Ei bine, exact aceeași idee poate fi aplicată în cazul construcției microprocesoarelor! În acest caz banda de asamblare se numește pipeline, sau conductă. De aici și titlul articolului de față. Am oarecare dificultăți în a alege o traducere rezonabilă a termenului, așa că pe parcursul articolului voi folosi variați termeni, incluzînd pe cel de ``țeavă''.

Cum se aplică deci conceptul în cazul microprocesoarelor? Care e treaba unui microprocesor? Să execute, una cîte una, instrucțiunile programelor scrise de utilizatori. Dar execuția unei instrucțiuni se poate descrie ca o serie de pași succesivi; ceva de genul: adu instrucțiunea din memorie, uită-te ce fel de instrucțiune este (o operație aritmetică/logică, un apel de procedură, un salt, etc.), decide care date trebuie procesate (care sunt regiștrii1 care conțin acele date), extrage datele din regiștri, efectuează operația asupra datelor, pune rezultatul la loc unde trebuie, și o ia de la capăt cu instrucțiunea următoare. În figura 1 avem structura unui procesor ipotetic pe care indicăm cinci stagii prin care o instrucțiune trece în prelucrare.

Figure 1: Arhitectura internă a unui microprocesor ipotetic. PC este Program Counter, adresa instrucțiunii în curs de execuție din program. IR este Instruction Register, registrul în care instrucțiunea curentă este decodificată pentru a extrage comenzile asupra celorlalte unități ale microprocesorului. Cache-urile, în număr de două, sunt memoriile în care procesorul ține datele, respectiv instrucțiunile. MUX sunt multiplexoare: circuite care au mai multe intrări și o singură ieșire; ele aleg una dintre intrări și o trimit la ieșire. Informația despre care dintre intrări este selectată depinde de instrucțiunea curentă, și este indicată prin linia punctată. De exemplu, ultimul multiplexor din dreapta, va alege linia de sus pentru instrucțiuni care citesc din memorie și pun rezultatul într-un registru, și va alege linia de jos pentru instrucțiuni care scriu o valoare calculată. ALU este unitatea aritmetică-logică (Arithmetic-Logical Unit). Am etichetat unele dintre conexiuni cu informații despre valoarea pe care o poartă. Pentru fiecare instrucțiune, unele dintre aceste sîrme vor purta informații utile iar altele nu. Încercați să identificați pentru fiecare tip de instrucțiune de fapt ce se întîmplă.
\begin{figure}\centerline{\epsfxsize=14cm\epsffile{procesor.eps}}\end{figure}

Ei bine, instrucțiunile joacă exact rolul rufelor: dacă avem în procesor cîte un circuit independent pentru fiecare din aceste numeroase sarcini, atunci putem pune aceste circuite să lucreze simultan pe instrucțiuni succesive. Astfel, în timp ce instrucțiunea 1 pune rezultatul la loc (stagiul de acces la memorie), instrucțiunea 2 operează asupra propriilor date (stagiul de execuție), instrucțiunea 3 tocmai extrage datele (stagiul de decodificare), iar instrucțiunea 4 tocmai este adusă din memorie (stagiul de citire).

Observați că -- aparent -- cîștigul pe care l-am obține transformînd procesorul într-un pipeline, este pe gratis: și un procesor obișnuit are nevoie de toate aceste circuite, însă atunci cînd folosea unul dintre ele, celelalte erau inutile. Că lucrurile nu stau chiar așa vom vedea în secțiunile următoare.

Cartea ``canonică'' pentru studiul arhitecturii calculatoarelor moderne este Hennessy and Patterson ``Computer Architecture -- a Quantitative Approach'', publicată de editura Morgan Kaufmann în ediția a doua în 1995. Această carte este de fapt versiunea pentru cursuri doctorale a unei alte cărți de aceiași autori. Cartea este excelent scrisă, iar capitolele 3 și 4 sunt dedicate în întregime tehnicii de pipelining, în total 250 de pagini. Dacă subiectul vă interesează cu adevărat, vă recomand să obțineți această carte; dacă nu, poate că articolul acesta este suficient de ilustrativ. Acest articol va discuta doar despre tehnicile elementare folosite în pipelining; despre tehnicile avansate (multiple-issue, out-of-order execution, etc.) voi scrie probabil un altul. Desenele din acest text, și diagrama procesorului ipotetic, sunt bazate pe prezentarea din această carte.

Regiștrii de separație

Dacă un procesor este implementat ca un pipeline, atunci între diferitele stagii ale țevii se află niște ``tampoane'', care izolează stagiile unul de altul. Arhitectural vorbind, tampoanele sunt de fapt tot niște regiștri, numiți pipeline registers. Fiecare din acești regiștri este comandat de ceasul microprocesorului, și încarcă în interior toate rezultatele procesării obținute din stagiul anterior: instrucțiunea, rezultatele parțiale, informații de stare, etc. Figura 2 arată segmentarea procesorului de mai sus.

Figure 2: Procesorul de mai sus împărțit în stagii. Barele hașurate sunt regiștrii de separație. Observați că am mutat un multiplexor din stagiul de acces la memorie în stagiul de citire, pentru că altfel în cazul unui salt am fi avut două stagii care încearcă simultan să scrie în registrul PC. Cu toate acestea, linia care controlează multiplexorul vine în continuare din stagiul de acces la memorie, unde instrucțiunile de salt își termină de calculat destinația.
\begin{figure}\centerline{\epsfxsize=14cm\epsffile{registri.eps}}\end{figure}

Vom vedea că regiștrii de separație au un rol important pentru blocajul țevii în anumite circumstanțe.

Influența asupra ceasului

Ce cîștigăm cu ajutorul țevilor?

Păi, în primul rînd, fiecare din stagii este mai scurt decît întregul, deci se poate executa mai repede decît dacă am executa toate stagiile unul după altul. Putem deci mări frecvența ceasului; este exact același fenomen ca la mașinile de spălat de mai sus (încărcături la jumătate de oră în loc de o oră).

În al doilea rînd, așa cum am văzut, acum toate stagiile sunt folosite simultan, fiecare pentru altă instrucțiune. Aceasta este o sursă de paralelism, care implică o altă creștere a performanței.

Observați că succesul acestei metode se bazează pe faptul că avem de procesat un șir de instrucțiuni extrem de lung, continuu. Dacă la spălătorie vin rufe din două în două ore, atunci nu cîștigăm nimic din faptul că putem scoate o nouă serie la fiecare jumătate de oră.

Observați că durează o vreme de cînd prima instrucțiune intră în țeavă pînă cînd termină execuția: atîția cicli de ceas cîte stagii avem. Latența (latency), sau durata propagării unei instrucțiuni prin pipeline, este mai mare decît în cazul unui procesor fără pipeline, pentru că am adăugat durata stocării datelor în regiștrii de separație. Pe de altă parte, observați că după ce prima instrucțiune iese din țeavă, a doua se termină în ciclul imediat următor. Deci rata de execuție (throughput) este de o instrucțiune pe ciclu de ceas! Distincția între latency și throughput este extrem de importantă.

Acest fenomen apare într-o formă exacerbată în cazul rețelelor de calculatoare, în care există doi parametri independenți: durata propagării datelor între două calculatoare, și viteza de transmisiune a datelor. Putem avea linii cu durată de propagare mică (de exemplu cu latența de 2ms), dar cu viteză mică, cum ar fi un modem de 14.4Kbps. Putem avea însă o linie cu durată de propagare extrem de mare (500ms), dar cu o viteza foarte mare, cum ar fi un canal de transmisiune prin satelit, de 2Mbps. Interesant este că în cazul rețelelor de calculatoare produsul acestor cantități (latența * rata de transmisie) este cel mai important; cu cît produsul este mai mare, cu atît susținerea performanței rețelei este mai greu de obținut. Dar am divagat; sper să revin asupra acestei teme într-un alt articol. Înapoi la țevile noastre.

Să observăm că viteza la care putem pune ceasul este limitată de cel mai lent stagiu din țeavă. Asta pentru că toți lucrătorii trebuie să lucreze cu ritmul celui mai încet dintre ei. Din această cauză, o împărțire a sarcinilor la 5 circuite nu garantează o creștere a vitezei de 5 ori. Să presupunem că cele 5 stagii durează 3, 3, 3, 5, respectiv 3 nanosecunde, și că întîrzierea unui registru de separație este de 2 nanosecunde. Atunci circuitul fără țeavă execută o instrucțiune la fiecare 3+3+3+5+3=17 nanosecunde, și asta dă și viteza ceasului. Pe de altă parte, circuitul din ``felii'' execută o instrucțiune la 5+2 nanosecunde (5 pentru stagiul cel mai lent, plus două ns pentru propagarea prin registru). Iată deci cum, deși am împărțit sarcina la 5, din cauza imbalansului creșterea de viteză obținută este de numai 17/7 = 2.42 ori.

Dependențe

Trebuie să vă temperez și mai tare entuziasmul în ceea ce privește pipeline-urile: mai există o grămadă de probleme pe care le-am trecut cu vederea, dar care devin evidente de îndată ce ne aplecăm puțin asupra construcției.

Problema este că, foarte adesea, nu putem executa mai multe instrucțiuni consecutive chiar una după alta, pentru că anumite constrîngeri fac acest lucru imposibil. Acest gen de interferență între instrucțiuni consecutive se numește în engleză hazard. Voi folosi în româna termenul ``dependențe'', deși acesta nu este tocmai exact2. Despre ce fel de dependențe este vorba?

Dependențe structurale

Prima problemă care poate apărea provine din faptul că una dintre aserțiunile mele de mai sus poate fi falsă; anume aceasta: ``cînd folosea unul dintre [stagii], celelalte erau inutile''. Iată un exemplu în care acest lucru nu este adevărat: un procesor trebuie după fiecare instrucțiune să incrementeze adresa de unde se ia următoarea instrucțiune (adresa este aflată în registrul numit ``Program Counter''). Pentru că incrementarea este o operațiune aritmetică, procesorul ar putea folosi pentru acest scop unitatea aritmetică-logică (în figura noastră am fi avut în loc de ALU și circuitul de incrementare un singur circuit). Aici avem deci un conflict: o altă instrucțiune, aflată în stagiul de calcul ar putea dori să folosească acea unitate în același timp pentru că trebuie să adune două numere.

Astfel de ``hazards'' sunt numite ``structurale'', pentru că structura procesorului nu permite executarea anumitor tipuri de instrucțiuni simultan în stagii diferite. În exemplul nostru, instrucțiunile care nu folosesc unitatea aritmetică (de pildă o instrucțiune de salt absolut) nu cauzează nici un fel de conflicte.

Putem da și alte exemple de dependențe structurale: mai multe instrucțiuni vor să acceseze simultan același registru, mai multe instrucțiuni vor să acceseze memoria (de pildă o instrucțiune care vrea să-și adune operanzii și tocmai îi citește și una care a terminat și vrea să scrie rezultatul), sau instrucțiuni a căror execuție durează mai mulți cicli de ceas.

Un exemplu de ultima speță sunt de pildă operațiile în virgulă flotantă (adică cu numere ``reale'', nu întregi) care durează uneori zeci de cicli de ceas, iar procesorul de obicei are o singură unitate de calcul în virgulă flotantă.

Teoretic un hazard structural se poate oricînd evita duplicînd unitățile funcționale care sunt în conflict; această soluție nu este însă întotdeauna fezabilă. De pildă, dacă o împărțire3 durează 10 cicli, atunci ar trebui să avem 10 împărțitoare pentru a permite execuția a 10 împărțiri succesive.

Tot pentru a evita dependențele structurale procesoarele moderne au, așa cum arătam în figură, două cache-uri L14 separate: unul pentru instrucțiuni (I-cache) și unul pentru date (D-cache): în acest fel se poate citi o instrucțiune simultan cu scrierea rezultatelor alteia.

Vom vedea un pic mai jos cum rezolvă un procesor astfel de dependențe.

Dependențe ale datelor

O dependență mult mai subtilă este cea a datelor. Să presupunem că avem un program cu două instrucțiuni consecutive: una care scrie numărul 2 în registrul 3, iar următoarea care adaugă valoarea 5 acelui registru.

În tabelul 2 vedem cum progresează aceste instrucțiuni într-o țeavă ipotetică care seamănă cu cea descrisă mai sus. Am mai pus niște instrucțiuni noop, care nu fac nimic, în jur, pentru a ilustra mai bine


Table 2: Avansul instrucțiunilor într-un pipeline ideal. Am prescurtat stagiile cu C: citire, D: decodificare, E: execuție, M: memorie, S: scriere. Această execuție nu este posibilă (fără ajutor suplimentar), pentru că la momentul de timp 3 instrucțiunea de adunare vrea valoarea din R3, care va fi scrisă în R3 de instrucțiunea de scriere abia la momentul 5. Dependența este marcată cu două semne + în tabel.
  Ceasul
Instrucțiunea 0 1 2 3 4 5 6 7 8
noop C D E M S        
scrie 2 în R3   C D E M S+      
aduna 5 la R3     C D+ E M S    
noop       C D E M S  
noop         C D E M S


Diagrama din tabelul 2 este tipică pentru a descrie evoluția datelor într-un pipeline. Programul este scris pe verticală, ceasul este marcat pe orizontală. Starea țevii poate fi citită pe verticală, de sus în jos. Căsuța de la instrucțiunea 1 și ceasul 3 arată în care dintre fazele țevii se află acea instrucțiune la momentul 3, în cazul nostru în stagiul de acces la memorie (M).

Care e problema? Problema este că instrucțiunea de scriere pune datele în registrul 3 abia cînd atinge ultimul stagiu din țeavă. Pe de altă parte, instrucțiunea de acumulare citește valoarea registrului 3 atunci cînd este în faza de decodificare. Dar datorită suprapunerilor, decodificarea instrucțiunii de acumulare se face la momentul 3, iar scrierea la momentul 5! Din cauza suprapunerii, în execuție am inversat ordinea în timp în care se petrec două operațiuni. Dacă nu facem nimic, rezultatul final va fi desigur greșit, pentru că acumularea nu vede efectul scrierii.

Înainte de a vedea ce e de făcut să inspectăm o altă dificultate care poate apărea.

Dependențe ale controlului

Un tip special de dependențe este cauzat de instrucțiunile de salt. O instrucțiune de salt indică o întrerupere în fluxul normal al programului. Neplăcerea apare din faptul că execuția instrucțiunii de salt se termină destul de tîrziu, abia cînd instrucțiunea a calculat adresa finală de destinație. Dar între timp în țeavă au intrat o grămadă de instrucțiuni, toate cele care urmau imediat. Evident, acestea nu trebuie executate (sau vor fi executate dacă saltul este condiționat de o condiție care este falsă).

Astfel de dependențe se numesc ``dependențe de control'', din cauză că sunt produse de modificări în ``controlul'' (ordinea de execuție) a programului.

Voi consacra un articol întreg acestui tip de dependențe, pentru că efectul lor este catastrofal asupra performanței, și pentru că sunt extrem de greu de reparat fără a pierde toate avantajele unui pipeline. Impactul salturilor este extrem de important din două motive:

Să estimăm costul unei astfel de situații pentru procesorul nostru de mai sus, cu întîrzieri de 3, 3, 3, 5, 3 nanosecunde. Atunci procesorul va executa 7 instrucțiuni fiecare la cîte un ciclu, după care timp de 4 cicli va umple din nou țeava golită de un salt. Asta înseamnă 7*6 + 4*6 = 66 cicli pentru 7 instrucțiuni, adică 66/7 = 9.42ns/instrucțiune în medie. Creșterea de performanță a procesorului față de modelul fără țeavă este acum de 17/9.42 = 1.8 ori. Și încă am presupus că celelalte feluri de dependențe nu cauzează nici o întîrziere!

Excepții

Mai avem de-a face cu o ultimă neplăcere, care este mult mai greu de rezolvat decît cele indicate anterior, și despre care nici nu vom vorbi prea mult în acest articol.

Cînd anumite condiții excepționale se ivesc, procesorul trebuie să întrerupă complet fluxul execuției, să execute o rutină specială, iar apoi uneori să reia programul întrerupt din exact același punct. Evenimentele care cauzează această întrerupere intempestivă se numesc excepții. Există multe feluri de excepții, iar tratamentul lor depinde de tip. Exemple de excepții: împărțirea prin zero, accesul la o pagină de memorie virtuală care nu se află în memoria fizică, indicația terminării unui transfer de către un dispozitiv periferic (ex. discul), întîlnirea unui punct de oprire (breakpoint) pus de un program de depanare, etc.

Problema cea mai mare nu este în asemenea cazuri oprirea programului și saltul (care seamănă teribil cu o instrucțiune obișnuită de salt), ci repornirea. În momentul apariției unei excepții, în țeavă se pot afla o sumedenie de instrucțiuni, cine știe de unde de prin memorie (poate la una dintre ele s-a ajuns printr-un salt sau chiar o altă excepție), etc.

Dacă procesorul vrea să poată relua execuția după o excepție, atunci trebuie să posede o grămadă de circuite care mențin foarte multă informație despre întreaga stare a țevii, pentru a permite repornirea. Un astfel de pipeline se numește restartable, și este extrem de complicat.

Soluții

Concluzia care se desprinde este că e mai ușor de zis decît de făcut un pipeline. Dar lupta pentru supremație în performanța se dă pe viață și pe moarte între marile companii (nu e o metaforă: cei mai mari concurenți ai lui Intel, Cyrix și AMD au trebuit să fie cumpărați de alte mari companii, IBM, respectiv National Semiconductors, pentru a supraviețui).

Ca atare trebuie găsite soluții. În restul articolului vom investiga niște soluții pentru problema dependențelor (dar nu și pentru cea a excepțiilor restartabile).

Compilatoare grijulii

O posibilă soluție (care însă nu este folosită decît parțial) este ca în software să garantăm că astfel de lucruri nu se pot întîmpla. Compilatorul care generează cod pentru un microprocesor ar trebui să ne asigure că două instrucțiuni care se vor afla simultan în țeavă nu vor interfera una cu alta. Compilatorul poate obține acest efect umplînd spațiul dintre două astfel de instrucțiuni cu instrucțiuni care nu fac nimic (no-op: no operation).

Din păcate soluția aceasta nu este viabilă. Un motiv este că ar trebui ca compilatorul să aibă cunoștințe intime despre arhitectura internă a țevii (ca să știe ce depinde de cine). Dar Pentium, Pentium Pro și Pentium II au arhitecturi interne complet diferite, deși implementează același set de instrucțiuni; ne-ar trebui deci un compilator diferit pentru fiecare mașina; mai mult decît atît, programele de pe una n-ar mai merge pe alta, din cauză ca altele ar fi dependențele care trebuie evitate.

Pe de altă parte, compilatoarele moderne încearcă din răsputeri să ajute hardware-ul, fără a garanta neapărat generarea unui cod lipsit complet de dependențe. Operațiunea numită code scheduling (ordonarea codului) este extrem de importantă pentru a mări performanța programelor. Practic compilatoarele încearcă să aranjeze instrucțiunile codului în așa fel încît instrucțiuni care depind una de alta (cum sunt cele două de mai sus) sunt cît mai departe una de alta. De exemplu, dacă după cele două instrucțiuni de mai sus vine o instrucțiune care incrementează registrul 2, atunci ultimele două instrucțiuni pot fi schimbate între ele fără a modifica rezultatul programului, tocmai pentru că sunt independente. Îmi propun să discut într-un articol separat despre tehnicile de creștere a performanței folosite de compilatoare, așa că trec acum la prezentarea primei soluții reale folosite pentru a rezolva problema dependențelor.

Blocajul (stall)

Dacă o instrucțiune nu poate progresa în țeavă din cauză că-i lipsesc anumite resurse (în exemplul de mai sus, registrul 3 încă nu are valoarea necesară), atunci aceste instrucțiuni sunt pur și simplu ținute pe loc în țeavă în aceleași stagii, în timp ce cele care le preced sunt lăsate să continue. Oprirea unei instrucțiuni se numește blocaj, sau stall. În spatele instrucțiunii care continuă se formează un gol, numit ``bulă'' (cu b mic) (bubble). Bula este de fapt o instrucțiune noop: no operation, care nu are nici un efect.

Tabelul 3 prezintă evoluția programului de mai sus atunci cînd apare o bulă.


Table 3: Avansul instrucțiunilor într-un pipeline cu blocaj. Steluța indică un blocaj: la momentul respectiv instrucțiunea indicată rămîne în același stagiu. Pentru că starea țevii se poate citi pe fiecare coloană, observați că la momentul de timp 4 numai stagiile C, E, M și S apar; asta înseamnă că stagiul D conține o bulă. La momentul următor bula se propagă în stagiul E, și o nouă bulă apare în stagiul D. De îndată ce instrucțiunea care scrie în R3 ajunge în stagiul S, instrucțiunea următoare poate continua, pentru că a obținut rezultatul. Observați că acum dependența este rezolvată, pentru că semnele + sunt în aceeași coloană, deci rezultatul este deja disponibil cînd este cerut.
  Ceasul
Instrucțiunea 0 1 2 3 4 5 6 7 8
noop C D E M S        
noop   C D E M S      
scrie 2 în R3     C D E M S+    
aduna 5 la R3       C C* C* D+ E M
noop             C D E


De îndată ce instrucțiunea care avea resursele cerute își termină execuția, instrucțiunile de după ele, care aveau nevoie de resurse, își pot continua execuția în mod obișnuit. Blocarea unei instrucțiuni în țeavă este relativ ușor de produs: registrul de separație de dinaintea acelui stagiu nu mai citește valorile produse de stagiul anterior, ci păstrează vechiul său conținut.

Soluția prin care compilatorul inserează instrucțiuni inutile se numește statică, pentru că programul apoi rămîne neschimbat. Prin contrast, atunci cînd bulele sunt create de microprocesorul însuși atunci cînd programul se execută, tehnica se numește dinamică. Observați că și în acest caz programul din memorie este neschimbat: bula apare doar în țeavă, și apoi dispare.

Soluția asta pare acceptabilă. Care este costul pe care trebuie să-l plătim?

În primul rînd trebuie hardware special pentru a detecta dependențele. Asta înseamnă practic o serie de comparatoare: un comparator compară registrul în care scrie instrucțiunea care se află în stagiul 4 cu regiștrii citiți de instrucțiunile din stagiile 3, 2. Un alt comparator se uită să vadă dacă tipurile de instrucțiuni din aceste stagii într-adevăr folosesc regiștri (de pildă o instrucțiune de salt imediat nu folosește nici un registru. Dacă, de pildă, instrucțiunea din stagiul 4 va scrie în registrul consumat de instrucțiunea din stagiul 2, atunci stagiul 2 este blocat, stagiile 3 și 4 avansează, iar în stagiul 3 se formează o bulă.

Un al doilea preț pe care-l plătim pentru blocaj este scăderea performanței. Din cauză că instrucțiunile nu avansează și țeava procesează nimicuri, rata efectivă cu care instrucțiunile sunt executate scade sub una pe ciclu. Cît de mult scade, depinde de o sumedenie de factori, începînd cu calitatea compilatorului și terminînd cu capacitatea țevii de a evita blocajele prin următorul mijloc pe care-l analizăm, înaintarea.

Înaintarea (forwarding)

În lupta lor acerbă cu nanosecundele, proiectanții de microprocesoare au găsit încă o soluție pentru a rezolva dependențele. În exemplul de mai sus, o instrucțiune aflată în stagiul de decodificare avea nevoie de niște date pe care instrucțiunea aflată în stagiul de execuție tocmai le calculase. Cea din stagiul de decodificare însă trebuia să aștepte ca rezultatul calculelor să fie pus într-un registru, ceea ce se va întîmpla abia mai tîrziu. Ideea este atunci simplă: din moment ce tot am deja rezultatul, de ce să mai aștept să fie scris în registru și apoi să-l iau din nou? Ce-ar fi dacă producătorul ar trimite rezultatul pe o scurtătură direct la consumator?

Această tehnică se numește ``înaintare'' (forwarding), deși strict vorbind este o ``înapoiere'', pentru că datele sunt trimise înapoi în țeavă, de la producător la consumator. Asta permite instrucțiunii din stagiul de decodificare să-și continue execuția netulburată, fără a mai fi nevoie de introducerea unei bule. Înaintarea este ilustrată în figura 3

Figure 3: Stagiul de execuție împreună cu cărarea de înaintare a datelor. Stagiul de execuție poate prelua o valoare fie de la stagiul de decodificare, fie de la propria sa ieșire. Noul multiplexor introdus, în partea de jos a figurii, face această alegere. Multiplexorul este comandat de un comparator care se uită să vadă dacă una dintre instrucțiuni produce valoarea unui registru de care cealaltă are nevoie.
\begin{figure}\centerline{\epsfxsize=10cm\epsffile{inaintare.eps}}\end{figure}

Soluția este clar preferabilă, pentru că viteza de execuție rămîne aceeași, o instrucțiune pe ciclu. Costul plătit este însă în hardware: pe lîngă circuitele prezente în cazul blocării, înaintarea are nevoie de o mulțime de trasee speciale și circuite de selecție (multiplexoare) pentru a trimite datele pe scurtături. Dar budgetul în tranzistoare al proiectanților este atît de mare încît nu se dau în lături de la așa ceva.

Procesoarele reale folosesc deci o mixtură a celor trei tehnici prezentate anterior; nu orice se poate rezolva cu înaintare, așa că blocajul este practic întotdeauna necesar. (Un exemplu ar fi o instrucțiune care termină de calculat un rezultat abia în stagiul 4, dar care este consumat de instrucțiunea următoare în stagiul 2. În momentul în care a doua instrucțiune vrea valoarea, ea nici nu există, deci înaintarea nu este de nici un folos., deci trebuie folosit un blocaj.)

Variațiuni și concluzii

Încheiem aici incursiunea noastră în arhitectura procesoarelor-țeavă. În comparație cu alte tehnici, pe care sperăm că le vom putea prezenta în articole ulterioare, pipelining-ul este pînă la urmă o metodă destul de simplă și cu o eficacitate imediată. În procesoarele moderne nu numai fazele de execuție ale unei instrucțiuni sunt ``pipelined'', ci și operațiile aritmetice; de pildă unele unități aritmetice calculează o înmulțire pe 32 de biți în 5-6 cicli de ceas, dar pentru că înmulțitorul este pipelined, el poate accepta o nouă pereche de operanzi la fiecare ciclu, și poate produce rezultate cu aceeași frecvență.

Procesoarele moderne folosesc un întreg arsenal de alte tehnici, extrem de sofisticate, dintre care enumerăm aici:

Superpipeline:
am definit acest termen deja: indică un pipeline extrem de adînc, cîteodată de peste 10 stagii.

Superscalar:
procesoarele superscalare au la dispoziție mai multe pipelines paralele, și pot lansa în execuție mai multe instrucțiuni simultan. Pentium II are de pildă două țevi, numite U și V. Un astfel de procesor poate termina mai multe instrucțiuni în fiecare ciclu de ceas!

VLIW:
este un acronim de la Very Long Instruction Word, adică ``instrucțiuni foarte lungi''. Astfel de procesoare au instrucțiuni formate din mai multe instrucțiuni elementare (ex. o adunare și o scădere simultan). Compilatorul are sarcina să grupeze instrucțiunile laolaltă în așa fel încît să poată fi executate simultan. Diferența față de un superscalar este că superscalarul decide dinamic care instrucțiuni merg împreună, pe cînd la VLIW acest lucru se face la compilare.

Reordonare:
procesoarele moderne sunt în stare să execute instrucțiunile în altă ordine decît cea în care sunt prezente în program; asta se numește out of order execution. Practic ele realizează echivalentul operației de scheduling pe care o fac compilatoarele, dar o fac în mod dinamic. Avantajul este că dacă trebuie să blochezi o instrucțiune în țeavă dar cele de după ea au toate resursele disponibile, poți să le lași s-o ia înainte, ținînd țeava ocupată.

Execuția speculativă:
este o tehnică folosită pentru a micșora impactul ``hazardurilor'' de control (adică dependențele produse de salturi). Procesorul presupune că saltul se face într-o anumită direcție și încarcă în țeavă instrucțiunile de la ``destinație'' (asta se numește speculație -- speculation). Dacă se dovedește că a greșit în ghicirea adresei de destinație a saltului, atunci pur și simplu șterge tot ce a încărcat greșit în țeavă și începe execuția de la locul unde mergea în realitate saltul.

Prezicerea salturilor:
am văzut că dependențele de control sunt foarte costisitoare, și forwarding nu ajută în cazul lor (pentru că adresa destinație este calculată relativ tîrziu, dar este necesară foarte devreme, în chiar primul stagiu, care aduce instrucțiuni din memorie). Pentru asta procesoarele moderne încearcă să ``ghicească'' în mod inteligent direcția unui salt, bazîndu-se pe execuțiile trecute ale acelui salt. O cantitate enormă de hardware este dedicată acestui scop.

Dacă vrem să rezumăm învățămintele acestui text într-o frază putem spune așa: atunci cînd ai destulă mînă de lucru și multe activități de făcut, e foarte economic să organizezi munca într-o bandă de asamblare; specializarea fiecărui lucrător garantează eficacitate sporită în muncă, iar durata medie de producere a unui rezultat este egală cu lungimea activității celui mai lent dintre lucrători.

Gata cu distracția, acum la treabă. Fiecare să-și reia locul în țeavă!



Footnotes

... strii1
Regiștrii microprocesorului sunt o minusculă memorie aflată în interiorul procesorului, unde el păstrează datele pe care intenționează să le proceseze în viitorul foarte apropiat.
... exact2
Pentru a fi precis, hazardurile sunt de mai multe tipuri, iar unele hazarduri se numesc dependencies. Oricum, aproximarea mea nu este complet nerezonabilă.
... tire3
Presupunem că circuitul care face împărțirea nu poate le însuși fi implementat ca un pipeline.
... L14
Am scris cel puțin trei articole (1) (2) (3) despre cache-uri în PC Report; le puteți accesa din pagina mea de web.