www.agora.ro

Need 4 Speed...

Nevoia de viteză

Diodor Bițan

Chiar dacă puterea de calcul a crescut foarte mult în ultima perioadă, nu toți sunt mulțumiți de performanțele calculatoarelor de astăzi. Este normal să dorim ca programele pe care le scriem să fie cât mai rapide. Acest articol prezintă câteva metode care pot fi folosite pentru accelerarea aplicațiilor pe care le proiectați.

Există tendința de a crede că datorită unor calculatoare mai performante, unor compilatoare bine realizate etc. programarea se va reduce în viitor la programare vizuală, folosind în principal mouse-ul. Este adevărat că tendințe reale în acest sens există, iar zilele bune ale programelor mici, optimizate până la cel mai mic detaliu, scrise 100% în assembler, fără nici un compromis privind viteza sau spațiul, se pare că au trecut. Dacă acum cinci ani orice program trebuia să încapă pe o dischetă, acum sunt destul de comune programele care au nevoie de mai multe CD-uri, gen Microsoft Visual Studio sau Baldur's Gate. În plus, conceptele ca portabilitatea, fiabilitatea, securitatea, lizibilitatea contează din ce în ce mai mult și împing vechile probleme ale vitezei și spațiului undeva în spatele scenei. Să ne resemnăm muncind cu ferestre în Visual Basic, cu baze de date unde toți algoritmii folosiți au fost deja implementați mult mai bine decât am putea noi să o facem vreodată? Să nu mai fie nevoie deloc de performanță? Să fie acesta sfârșitul?

Totuși lucrurile nu stau chiar așa; orice program care realizează ceva are nevoie de eficacitate. Nu fac excepție jocurile, compilatoarele, aplicațiile de grafică și sunet, arhivatoarele, sistemele de operare etc. Se spune că nu mai este nevoie de optimizări datorită implementărilor hardware (vezi acceleratoare grafice 3D). Dar implementarea hardware (care tot un fel de programare este) nu trebuie să o facă cineva? Fără să mai vorbim de driverele la care se lucrează continuu pentru câteva procente de viteză în plus. Poate că raportată la explozia calculatoarelor, a programelor prietenoase, a bazelor de date, a paginilor Web, pare în scădere, însă de fapt nevoia de viteză este din ce în ce mai mare și mult timp această nevoie nu va dispărea.

Ce merită optimizat?

Regula 10-90 (10% din program consumă 90% din timp), ne arată că este suficient să se mărească eficiența unor secțiuni critice ale codului sursă. Aceasta nu înseamnă că în restul programului se poate neglija total performanța. De exemplu, se poate ca o funcție să consume atât de mult timp încât celelalte pierderi de viteză să pară neglijabile. Dacă viteza de execuție a acestei funcții crește de zece ori la un moment dat, toate celelalte pierderi urcă de la unul, două procente la zeci de procente și trebuie și ele luate în considerare.

Pentru funcții de complexitate redusă, cu număr mic de variabile, este puțin probabil ca orice optimizare să crească viteza în mod esențial (Microsoft Visual C++ memorează toate variabilele în regiștri și orice tentativă de îmbunătățire nu duce la rezultate spectaculoase). Pentru funcții complexe, cu multe variabile, cu multe condiții, orice optimizare care eliberează un registru se face simțită.

Pentru a obține performanță este foarte important să știm ce funcție consumă cel mai mult timp, să observăm efectele diferitelor schimbări, să putem verifica toate ideile pe care le găsim pe Internet sau în literatura de specialitate. Trecând peste metoda număratului (1000, 1001, ...) și a cronometrului, se poate folosi excelentul profiler al produsului Microsoft Visual C++ 6.0. Nu vom intra în detalii, dar rulând un program, se pot obține foarte ușor informații cum ar fi: numărul de apeluri pentru fiecare funcție, timpul consumat de execuția fiecărei funcții incluzând sau nu funcțiile apelate de aceasta, numărul de treceri prin fiecare linie de cod etc.

Folosirea funcțiilor QueryPerformanceCounter() și QueryPerformanceFrequency(), care returnează timpul cu o precizie mult mai bună decât funcțiile clasice de tipul gettime(), este o altă metodă care poate duce la creșterea performanțelor. Avantajul este că se pot face verificări în anumite puncte cheie din program, fără să scadă performanța.

Optimizarea funcțiilor

Parametrii nu trebuie să aibă dimensiuni mari; cu excepția câtorva cazuri, este mult mai bine ca parametrii să fie transmiși prin referință și nu prin valoare. Această soluție este avantajoasă mai ales dacă se transmit clase ca parametri; în acest caz sunt apelați constructorii de copiere ai clasei respective (care pot să meargă foarte încet dacă, de exemplu, clasa respectivă alocă date dinamic în constructor).

Dacă suntem siguri că o funcție nu va modifica date la care nu ar trebui să aibă acces, se poate renunța la principiul încapsulării, protejării și accesului limitat la membrii claselor. Pentru funcții care se apelează des, sau cu parametri de dimensiuni mari, această optimizare este vitală.

typedef struct S{

int a[50];

char text[50];

};

class C{

public:

int *data;

int n;

C(C& a){ // constructor de copiere

data=new int[a.n];

memcpy(data,a.data,n*sizeof(int));

}

...

};

int Wrong(C a,C b,S s){...}

int Right(C& a,C* b,S* s){...}

Expandarea inline este o metodă de a anula costul apelurilor pentru funcții foarte des folosite în schimbul măririi dimensiunii fișierului executabil obținut în urma compilării și editării de legături. Oriunde apelăm o funcție inline, compilatorul înlocuiește apelul obișnuit al funcției (cu parametri copiați pe stivă și un "goto" spre funcție) prin intermediul codului funcției. Funcțiile se pot declara inline prin două metode:

inline int first(int a) {...} //prima medoda

class C {

int second(int a) {...}

// prin scrierea codului functiei chiar în

// interiorul clasei, se subintelege

// expandarea inline

};

Se pot seta opțiuni de compilare care să permită compilatorului să decidă ce funcții trebuie expandate inline. Simularea funcțiilor inline se poate realiza foarte bine și cu macrouri (care nici nu cer ca parametrii să fie de un tip anume):

#define MAX(a,b) (((a)>(b))?(a):(b))

Convențiile de apelare influențează ordinea și felul în care sunt transmiși parametrii. În cazul convenției cdecl, eliberarea stivei de parametri va fi realizată de către apelator ceea ce duce la creșterea dimensiunii fișierului executabil. Convențiile stdcall și fastcall plasează acest cod în interiorul funcției. În plus, fastcall transmite primii doi parametri în regiștri, mărind viteza. Funcțiile cu lungime variabilă de parametri vor fi apelate doar în convenție cdecl. (Doar apelantul cunoaște numărul parametrilor și poate "curăța" stiva corect. În momentul compilării nu se poate determina numărul parametrilor suplimentari care vor fi copiați pe stivă.)

__fastcall int printf(const char *format,

...); // apel cdecl

__fastcall int scrie(char* txt);

// apel fastcall

În sfârșit, apelul funcțiilor virtuale consumă mai mult timp decât apelul celorlalte funcții membru ale unei clase, deoarece necesită o accesare suplimentară a vtable-ului clasei. Dacă nu știți cum se folosesc aceste funcții virtuale și nici nu vreți să învățați, acum aveți și un pretext pentru a vă ocupa de altceva.

Pointeri sau referințe

Cei care au încercat să scrie un joc sau un program mai mare, probabil au ajuns la un moment dat la surse care sunt pline de cod de genul:

void Xplode(int x,int y,int radius){

for (int i=0;i<Game.nrMonsters;i++)

if (abs(Game.monsters[i].x-x)+

abs(Game.monsters[i].y-y)<radius)

if (rand()%20>Game.monsterTypes[ \

Game.monsters[i].type].defence[ \

Game.monsters[i].experienceLevel]&&

!Game.monsterTypes[ \

Game.monsters[i].type].XImmune)

Game.monsters[i].DieByXplosion();

}

Pointerii sau referințele rezolvă problema indirectărilor excesive crescând atât lizibilitatea cât și viteza programului:

void Xplode(int x,int y,int radius){

Monster *m;

for (int i=0,m=Game.monsters[0];

i<Game.nrMonsters;i++,m++)

if (abs(m->x-x)+abs(m->y-y)<radius){

MonsterType &mt=Game.monsterTypes[ \

m->type];

if (rand()%20>mt.defence[ \

m->experienceLevel] && !mt.XImmune)

m->DieByXplosion();

}

}

Pointerii pot fi folosiți și pentru accesarea mai eficientă a unei matrice:

int i,j,k;

int *pi;

for (j=0;j<100;j++)

for (i=0;i<100;i++)

m[j][i]++;

for (j=0;j<100;j++){

pi=m[j];

for (i=0;i<100;i++)

(*pi++)++;

}

Compilatorul realizează aceste optimizări, în cazul ciclurilor necalculându-se adresa la fiecare accesare prin înmulțiri ci prin adunări repetate. Eficiența acestei optimizări relizate de compilator este mult mai mare în cazul prezentat anterior decât dacă scriem:

for (j=0;j<100;j++)

for (i=0;i<100;i++)

m[i][j]++; // schimbam m[j][i] cu m[i][j]

Se poate reduce numărul de regiștri folosiți ca să se acceseze variabile din memorie grupându-le în structuri:

void Wrong(int *a,int *b) {...}

// se va folosi cate un registru pentru a

// accesa a si b

typedef struct{

int a[100];

int b[100];

}comb;

void Right(comb *c) {...}

// accesarea elementelor din a si b este

// realizata folosind doar un registru

// continand pointerul c

Același efect se obține în cazul claselor; prin intermediul pointerului this (care ocupă în general un registru al său) sunt accesate toate variabilele clasei.

O altă metodă de a mări viteza constă în folosirea variabilelor globale în punctele critice. Fiind alocate la compilare, aceste variabile nu sunt accesate prin parametri, ci direct, fără să se folosească regiștri suplimentari.

(Pre)calcule

Pe vremea când calculatoarele nu apăruseră și dinozaurii alergau liberi pe câmpii în deplină armonie cu universul, pentru a se realiza calculele complicate, se foloseau tăblițe de piatră pe care erau înscrise tabele cu valori ale diverselor funcții (sinus, logaritm etc.). Ideea în sine nu este rea și este uimitor de câte ori se poate folosi. Câștigul în viteză este foarte mare și prețul memoriei suplimentare necesare tabelelor și precizia ce scade destul de mult nu cantăresc prea mult în balanță. Probabil că ați admirat screen saver-ul cu lupa ce se plimbă pe ecran. Acesta făcea toate calculele geometrice la început (mesajul "Rendering Lens") și rezultatul era o matrice pătrată în care fiecare căsuță conținea coordonatele x și y ale pixelului ce trebuie luat de pe display și pus în punctul respectiv. Astfel se pot realiza deformări oricât de exotice și de costisitoare.

Problema cu tabelele de valori este că, având doar valori discrete, precizia scade nepermis de mult. Parte din precizie se poate recupera făcând o simplă interpolare liniară:

float f_buf[1000]; // valori ale unei functii

// dificil de calculat

...

float f(float x){ // presupun x intre 0,998

int i=x; // partea intreaga a lui x

float fr=x-i; // partea fractionara a lui x

return f_buf[i]*(1-fr)+f_buf[i+1]*fr;

// nu se foloseste nici o impartire pentru

// ca intervalul dintre doua valori

// consecutive ale functiei este 1

}

Precalculările se pot folosi și fără eroare de precizie și fără memorie suplimentară, uneori producând accelerări radicale, ca în următorul exemplu, o funcție care realizează rotirea unor vectori 2D:

void slowRotate(float alpha){

for (int i=0;i<n;i++){

vf[i].x=vi[i].x*cos(alpha)+

vi[i].y*sin(alpha);

vf[i].y=-vi[i].x*sin(alpha)+

vi[i].y*cos(alpha);

}

}

void fastRotate(float alpha){

float s=sin(alpha),c=cos(alpha);

for (int i=0;i<n;i++){

vf[i].x=vi[i].x*c+vi[i].x*s;

vf[i].y=-vi[i].x*s+vi[i].x*c;

}

}

Creșterea mare de viteză apare datorită faptului că sin() și cos() sunt funcții.

Includem tot la precalculări și următoarea optimizare:

for (q=0;q<100;q++)

v[q]=q*q; // 0 1 4 9 16 ...

for (q=0,w=0;q<100;++q,w+=q*2-1)

v[q]=w; // 0 1 4 9 16 ...

Optimizări cu numere întregi

De ce este de 10 ori mai greu să calculăm 12934534 împărțit la 9 decât 12934534 împărțit la 10? Pentru că oamenii preistorici, având și ei 10 degete, ne-au transmis sistemul zecimal. (Mare ghinion: dacă ar fi avut 16 degete am fi colonizat planeta Marte acum 200 de ani; ce s-ar fi întâmplat dacă am fi avut 256 de degete?) Tot așa, calculatorului (care folosește sistemul binar) îi este foarte ușor să împartă și să înmulțească cu puteri ale lui 2, operații cunoscute sub numele de shift-ări ce constau în deplasări de biți la dreapta (înmulțiri cu 2, operator <<) sau la stânga (împărțiri la 2, operator >>):

i=5; // 000101 5

i<<=2; // 010100 20 i=i*4;

i>>=3; // 000010 2 i=i/8;

Chiar dacă compilatoarele realizează aceste optimizări automat, cei care le cunosc vor căuta să dea compilatorului cât mai multe ocazii de a le pune în aplicare. De exemplu, renunțând la sistemul cu 360 grade și adoptând unul cu 256 grade. Sau având grijă ca matricele să aibă ca dimensiuni puteri ale lui doi:

int a[256][256];

...

for (int x=0;x<255;x++)

for (y=0;y<255;y++)

a[y][x]++;

// viteza nu creste. Compilatorul ac-

// tualizeaza pointerul a[y][x] printr-o

// simpla adunare la fiecare trecere

int getxy(int x,int y){

return a[y][x];

}

// adresa de memorie se calculeaza foarte

// usor, printr-o shift-are viteza este mai

// mare decat in cazul cand dimensiunile

// matricei nu erau puteri ale lui 2

Se pot realiza înmulțiri și cu numere diferite de puterile lui 2, mai rapide decât înmulțirile normale. Exemplul clasic este cel de putpixel() pentru modul 13h

char* video_memory=(char*)0xa0000;

void slowPutPixel(int x,int y,char col){

video_memory[y*320+x]=col;

}

#define fasterPutPixel(x,y,col){

video_memory[(y)<<6+(y)<<8+(x)]=col;

}

Este evident că y<<6+y<<8=y*64+y*256=y*320. Orice înmulțire poate fi calculată folosind un astfel de artificiu. De exemplu, o înmulțire cu 255 poate fi efectuată astfel:

#define times255(x) ((x)<<8-(x))

// x<<8-x=256*x-x=255*x

Se poate de asemenea accelera operația de modul cu numere puteri ale lui doi. Această operație este echivalentă cu păstrarea ultimilor biți dintr-un număr, care se poate realiza cu un ȘI pe biți: i%=16 este echivalentă cu i&=(15). Avem 15=00001111(2), deci operația este corectă.

Operații cu virgulă fixă

O altă opinie este aceea că viteza de efectuare a operațiilor în virgulă mobilă s-a îmbunătățit atât de mult încât folosirea ei nu duce la o încetinire importantă a execuției programului. Într-adevăr, viteza scade doar de câteva ori, dacă folosim float pentru a păstra numere la care interesează doar o zecimală exactă. Alternativa o reprezintă virgula fixă, care de multe ori este preferabilă. Vom da un exemplu de virgulă fixă pentru 16 biți din care 8 sunt folosiți pentru cifrele zecimale. Principalele operații pot fi implementate astfel:

typedef struct{

unsigned char fract;

// partea fractionara a numarului

signed char deci;

// partea zecimala a numarului

} smart;

typedef union{

smart d;

__int16 i;

} fixed_16;

#define ORI_F16(x,y) ((__int32)(x)*(y)>>8)

#define IMP_F16(x,y) (((__int32)(x)<<8)/(y))

#define TOFLOAT_F16(x) ((x)/256.0)

void test(){

fixed_16 a={128,1},b={128,2},c={64,20},d;

// a=1.5, b=2.5, c=20.25

printf("(a+b)/(b*c) = %f",TOFLOAT_F16(

IMP_F16((a.i+b.i),ORI_F16(b.i,c.i))));

if (a.i>b.i)

puts("a greater than b\n");

// comparatia.

}

Se observă că singura pierdere de viteză față de înmulțirile și împărțirile numerelor întregi este o shift-are. Se pot scrie în assembler operațiile, și pentru tipuri de date pe mai mulți biți și cu poziția virgulei oriunde, dar se găsesc pe Internet biblioteci care să facă toate acestea.

 

Loop-ul cel rău

 

Pentru a nu păți ca în povestea de tristă amintire, loop-ii noștri trebuie să se miște cât mai repede. Să considerăm următorul ciclu folosit pentru a aduna în vectorul c conținutul vectorilor a și b.

int a[100],b[100],c[100];

int k=100;

for (int i=0;i<k;i++)

c[i]=a[i]+b[i];

O construcție echivalentă este:

i=0;

goto loop_start;

loop_condition:

i++;

if (i>=k)

goto loop_end;

// la compilare devine jmp conditionat

loop_start:

c[i]=a[i]+b[i];

goto loop_condition; // jmp neconditionat

loop_end:

Se observă că în loc de un jmp condiționat și un jmp necondiționat, se poate face doar jmp-ul condiționat, înlocuind ciclul for cu unul do while:

i=0;

do{

c[i]=a[i]+b[i];

i++;

}while (i<k);

Am presupus că avem k > 0. Se poate scăpa de comparația din condiție și deci de accesarea de 100 de ori a variabilei k (sau ocuparea unui registru) inversând ordinea de parcurgere a ciclului:

i=k;

do{

i--;

c[i]=a[i]+b[i];

}while (i);

Dacă k este divizibil cu doi, se poate reduce la jumătate numărul de comparații și salturi desfășurând ciclul ("loop unrolling"):

i=k;

do{

c[--i]=a[i]+b[i];

c[--i]=a[i]+b[i];

}while (i);

S-ar fi putut repeta de n ori expresia din interiorul ciclului, dar k ar fi trebuit să fie divizibil cu n. În cazul în care k nu este neapărat divizibil, și considerăm n=8, se poate folosi un artificiu numit "Duff's Device" care este ceva între magie și artă. Vă lasăm plăcerea de a afla singuri cum și de ce acest algoritm funcționează:

switch ((i=k)&7){

// (i=k)&7 calculeaza modulul imparțirii lui

// k la 8 și i=k

case 0: do{ c[--i]=a[i]+b[i];

case 7:c[--i]=a[i]+b[i];

case 6:c[--i]=a[i]+b[i];

case 5:c[--i]=a[i]+b[i];

case 4:c[--i]=a[i]+b[i];

case 3:c[--i]=a[i]+b[i];

case 2:c[--i]=a[i]+b[i];

case 1: c[--i]=a[i]+b[i];

}while (i);

}

Desfășurarea ciclului se poate folosi cu și mai mult succes pentru a elimina total unele cicluri. De exemplu, funcția următoare stabilește numărul de vecini aflați pe verticală, orizontală sau diagonală care au valoarea 1 într-o matrice ale cărei elemente au valori 0 și 1.

int nVec(int x,int y){

int n=0;

for (int i=-1;i<=1;i++)

for (int j=-1;j<=1;j++)

if (i || j)

n+=matrix[x+i][y+j];

return n;

}

Funcția se poate scrie și astfel:

int nVec(int x,int y) {

return matrix[x+1][y+1]+

matrix[x+1][y]+

matrix[x+1][y-1]+

matrix[x][y+1]+

matrix[x][y-1]+

matrix[x-1][y+1]+

matrix[x-1][y]+

matrix[x-1][y-1];

}

Și ultima accelerare sau accelerarea ultimă, care funcționează pentru orice program sau componentă hardware, chiar și pentru Windows '95, este g = 9.8 m/s2.

Ne bucurăm că acest articol nu a suferit încă această ultimă optimizare (ați ajuns până la sfârșit), vă urăm un călduros live long and prosper.

Diodor Bițan este student în anul III la Universitatea Politehnică din București. Poate fi contactat prin e-mail la adresa b_diodor@hotmail.com.

[cuprins]