Eesti

Uurige kompilaatori optimeerimistehnikaid tarkvara jõudluse parandamiseks, alates põhioptimeerimistest kuni täiustatud teisendusteni. Juhend globaalsetele arendajatele.

Koodi optimeerimine: sügav sissevaade kompilaatori tehnikatesse

Tarkvaraarenduse maailmas on jõudlus esmatähtis. Kasutajad ootavad, et rakendused oleksid kiired ja tõhusad, ning selle saavutamiseks on koodi optimeerimine iga arendaja jaoks ülioluline oskus. Kuigi on olemas mitmesuguseid optimeerimisstrateegiaid, peitub üks võimsamaid kompilaatoris endas. Kaasaegsed kompilaatorid on keerukad tööriistad, mis suudavad teie koodile rakendada laia valikut teisendusi, tuues sageli kaasa märkimisväärse jõudluse kasvu ilma käsitsi koodimuudatusi tegemata.

Mis on kompilaatori optimeerimine?

Kompilaatori optimeerimine on protsess, mille käigus lähtekood teisendatakse samaväärseks vormiks, mis töötab tõhusamalt. See tõhusus võib väljenduda mitmel viisil, sealhulgas:

Oluline on, et kompilaatori optimeerimised püüavad säilitada koodi algset semantikat. Optimeeritud programm peaks andma sama väljundi kui originaal, lihtsalt kiiremini ja/või tõhusamalt. See piirang teebki kompilaatori optimeerimisest keerulise ja põneva valdkonna.

Optimeerimise tasemed

Kompilaatorid pakuvad tavaliselt mitut optimeerimistaset, mida sageli juhitakse lippudega (nt `-O1`, `-O2`, `-O3` GCC-s ja Clangis). Kõrgemad optimeerimistasemed hõlmavad üldiselt agressiivsemaid teisendusi, kuid pikendavad ka kompileerimisaega ja suurendavad riski peente vigade tekkeks (kuigi see on väljakujunenud kompilaatorite puhul haruldane). Siin on tüüpiline jaotus:

On ülioluline oma koodi erinevate optimeerimistasemetega testida, et leida oma konkreetse rakenduse jaoks parim kompromiss. Mis sobib ühele projektile, ei pruugi olla ideaalne teisele.

Levinud kompilaatori optimeerimistehnikad

Uurime mõningaid levinumaid ja tõhusamaid optimeerimistehnikaid, mida kaasaegsed kompilaatorid kasutavad:

1. Konstantide väärtustamine ja levitamine

Konstantide väärtustamine (Constant folding) hõlmab konstantsete avaldiste hindamist kompileerimise ajal, mitte käivitamise ajal. Konstantide levitamine (Constant propagation) asendab muutujad nende teadaolevate konstantsete väärtustega.

Näide:

int x = 10;
int y = x * 5 + 2;
int z = y / 2;

Konstantide väärtustamist ja levitamist teostav kompilaator võib selle teisendada järgmiseks:

int x = 10;
int y = 52;  // 10 * 5 + 2 väärtustatakse kompileerimise ajal
int z = 26;  // 52 / 2 väärtustatakse kompileerimise ajal

Mõnel juhul võib see isegi `x` ja `y` täielikult eemaldada, kui neid kasutatakse ainult nendes konstantsetes avaldistes.

2. Surnud koodi eemaldamine

Surnud kood on kood, millel pole programmi väljundile mingit mõju. See võib hõlmata kasutamata muutujaid, kättesaamatuid koodiplokke (nt kood pärast tingimusteta `return`-lauset) ja tingimuslauseid, mis annavad alati sama tulemuse.

Näide:

int x = 10;
if (false) {
  x = 20;  // Seda rida ei täideta kunagi
}
printf("x = %d\n", x);

Kompilaator eemaldaks rea `x = 20;`, kuna see asub `if`-lauses, mis on alati `false`.

3. Ühiste alamavaldiste elimineerimine (CSE)

CSE tuvastab ja eemaldab üleliigsed arvutused. Kui sama avaldis arvutatakse mitu korda samade operandidega, saab kompilaator selle ühe korra arvutada ja tulemust taaskasutada.

Näide:

int a = b * c + d;
int e = b * c + f;

Avaldis `b * c` arvutatakse kaks korda. CSE teisendaks selle järgmiseks:

int temp = b * c;
int a = temp + d;
int e = temp + f;

See säästab ühe korrutamistehte.

4. Tsükli optimeerimine

Tsüklid on sageli jõudluse kitsaskohad, seega pühendavad kompilaatorid nende optimeerimisele märkimisväärset vaeva.

5. Inlainimine

Inlainimine asendab funktsioonikutse funktsiooni tegeliku koodiga. See eemaldab funktsioonikutse üldkulud (nt argumentide virna lükkamine, funktsiooni aadressile hüppamine) ja võimaldab kompilaatoril teha inlainitud koodil täiendavaid optimeerimisi.

Näide:

int square(int x) {
  return x * x;
}

int main() {
  int y = square(5);
  printf("y = %d\n", y);
  return 0;
}

Funktsiooni `square` inlainimine teisendaks selle järgmiseks:

int main() {
  int y = 5 * 5; // Funktsioonikutse asendati funktsiooni koodiga
  printf("y = %d\n", y);
  return 0;
}

Inlainimine on eriti tõhus väikeste, sageli kutsutavate funktsioonide puhul.

6. Vektoriseerimine (SIMD)

Vektoriseerimine, tuntud ka kui Üks Käsk, Mitu Andmevoogu (Single Instruction, Multiple Data - SIMD), kasutab ära kaasaegsete protsessorite võimet teostada sama operatsiooni korraga mitmel andmeelemendil. Kompilaatorid saavad koodi, eriti tsükleid, automaatselt vektoriseerida, asendades skalaarsed operatsioonid vektorinstruktsioonidega.

Näide:

for (int i = 0; i < n; i++) {
  a[i] = b[i] + c[i];
}

Kui kompilaator tuvastab, et `a`, `b` ja `c` on joondatud ja `n` on piisavalt suur, saab ta selle tsükli vektoriseerida SIMD-instruktsioonide abil. Näiteks, kasutades SSE-instruktsioone x86-l, võib see töödelda nelja elementi korraga:

__m128i vb = _mm_loadu_si128((__m128i*)&b[i]); // Laadi 4 elementi b-st
__m128i vc = _mm_loadu_si128((__m128i*)&c[i]); // Laadi 4 elementi c-st
__m128i va = _mm_add_epi32(vb, vc);           // Liida 4 elementi paralleelselt
_mm_storeu_si128((__m128i*)&a[i], va);           // Salvesta 4 elementi a-sse

Vektoriseerimine võib pakkuda märkimisväärset jõudluse kasvu, eriti andmeparalleelsete arvutuste puhul.

7. Käskude ajastamine

Käskude ajastamine järjestab käske ümber, et parandada jõudlust, vähendades konveieri seisakuid. Kaasaegsed protsessorid kasutavad konveiertöötlust mitme käsu samaaegseks täitmiseks. Kuid andmesõltuvused ja ressursikonfliktid võivad põhjustada seisakuid. Käskude ajastamise eesmärk on neid seisakuid minimeerida, paigutades käskude järjestust ümber.

Näide:

a = b + c;
d = a * e;
f = g + h;

Teine käsk sõltub esimese käsu tulemusest (andmesõltuvus). See võib põhjustada konveieri seisaku. Kompilaator võib käsud ümber järjestada nii:

a = b + c;
f = g + h; // Teisalda sõltumatu käsk varasemaks
d = a * e;

Nüüd saab protsessor täita käsku `f = g + h`, oodates samal ajal käsu `b + c` tulemuse kättesaadavaks muutumist, vähendades seeläbi seisakut.

8. Registrite eraldamine

Registrite eraldamine määrab muutujad registritele, mis on protsessori kiireimad mälukohad. Andmetele ligipääs registrites on oluliselt kiirem kui andmetele ligipääs mälus. Kompilaator püüab võimalikult palju muutujaid registritele eraldada, kuid registrite arv on piiratud. Tõhus registrite eraldamine on jõudluse seisukohast ülioluline.

Näide:

int x = 10;
int y = 20;
int z = x + y;
printf("%d\n", z);

Kompilaator eraldaks ideaalis `x`, `y` ja `z` registritele, et vältida mälupöördumist liitmistehte ajal.

Põhitõdedest edasi: täiustatud optimeerimistehnikad

Kuigi ülaltoodud tehnikaid kasutatakse laialdaselt, rakendavad kompilaatorid ka keerukamaid optimeerimisi, sealhulgas:

Praktilised kaalutlused ja parimad praktikad

Näiteid globaalsetest koodi optimeerimise stsenaariumitest

Kokkuvõte

Kompilaatori optimeerimine on võimas vahend tarkvara jõudluse parandamiseks. Mõistes tehnikaid, mida kompilaatorid kasutavad, saavad arendajad kirjutada koodi, mis on optimeerimiseks sobivam, ja saavutada märkimisväärset jõudluse kasvu. Kuigi käsitsi optimeerimisel on endiselt oma koht, on kaasaegsete kompilaatorite võimsuse ärakasutamine oluline osa kõrge jõudlusega ja tõhusate rakenduste loomisel globaalsele publikule. Ärge unustage oma koodi jõudlust mõõta ja põhjalikult testida, et tagada, et optimeerimised annavad soovitud tulemusi ilma regressioone tekitamata.