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:
- Vähendatud täitmisaeg: Programm lõpetab töö kiiremini.
- Vähendatud mälukasutus: Programm kasutab vähem mälu.
- Vähendatud energiatarbimine: Programm kasutab vähem energiat, mis on eriti oluline mobiil- ja manussüsteemide puhul.
- Väiksem koodi maht: Vähendab salvestus- ja edastuskulusid.
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:
- -O0: Optimeerimine puudub. See on tavaliselt vaikeväärtus ja seab esikohale kiire kompileerimise. Kasulik silumiseks.
- -O1: Põhilised optimeerimised. Hõlmab lihtsaid teisendusi nagu konstantide väärtustamine, surnud koodi eemaldamine ja põhiplokkide ajastamine.
- -O2: Mõõdukad optimeerimised. Hea tasakaal jõudluse ja kompileerimisaja vahel. Lisab keerukamaid tehnikaid nagu ühiste alamavaldiste elimineerimine, tsükli lahtikerimine (piiratud ulatuses) ja käskude ajastamine.
- -O3: Agressiivsed optimeerimised. Teostab ulatuslikumat tsüklite lahtikerimist, inlainimist ja vektoriseerimist. Võib oluliselt suurendada kompileerimisaega ja koodi mahtu.
- -Os: Optimeeri suuruse järgi. Seab esikohale koodi mahu vähendamise toore jõudluse asemel. Kasulik manussüsteemide puhul, kus mälu on piiratud.
- -Ofast: Lubab kõik `-O3` optimeerimised pluss mõned agressiivsed optimeerimised, mis võivad rikkuda ranget standardivastavust (nt eeldades, et ujukomaaritmeetika on assotsiatiivne). Kasutada ettevaatusega.
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.
- Tsükli lahtikerimine: Kordab tsükli keha mitu korda, et vähendada tsükli üldkulusid (nt tsükliloenduri suurendamine ja tingimuse kontroll). Võib suurendada koodi mahtu, kuid parandab sageli jõudlust, eriti väikeste tsüklikehade puhul.
Näide:
for (int i = 0; i < 3; i++) { a[i] = i * 2; }
Tsükli lahtikerimine (faktoriga 3) võiks selle teisendada järgmiseks:
a[0] = 0 * 2; a[1] = 1 * 2; a[2] = 2 * 2;
Tsükli üldkulu on täielikult eemaldatud.
- Tsükli invariantse koodi väljatõstmine: Teisaldab koodi, mis tsükli sees ei muutu, tsüklist välja.
Näide:
for (int i = 0; i < n; i++) {
int x = y * z; // y ja z ei muutu tsükli sees
a[i] = a[i] + x;
}
Tsükli invariantse koodi väljatõstmine teisendaks selle järgmiseks:
int x = y * z;
for (int i = 0; i < n; i++) {
a[i] = a[i] + x;
}
Korrutamine `y * z` tehakse nüüd ainult üks kord, mitte `n` korda.
Näide:
for (int i = 0; i < n; i++) {
a[i] = b[i] + 1;
}
for (int i = 0; i < n; i++) {
c[i] = a[i] * 2;
}
Tsüklite ühendamine võiks selle teisendada järgmiseks:
for (int i = 0; i < n; i++) {
a[i] = b[i] + 1;
c[i] = a[i] * 2;
}
See vähendab tsükli üldkulusid ja võib parandada vahemälu kasutamist.
Näide (Fortranis):
DO j = 1, N
DO i = 1, N
A(i,j) = B(i,j) + C(i,j)
ENDDO
ENDDO
Kui `A`, `B` ja `C` on salvestatud veeru-põhises järjestuses (nagu Fortranis on tüüpiline), põhjustab `A(i,j)`-le ligipääs sisemises tsüklis mittejärjestikuseid mälupöördumisi. Tsüklite vahetamine vahetaks tsüklid:
DO i = 1, N
DO j = 1, N
A(i,j) = B(i,j) + C(i,j)
ENDDO
ENDDO
Nüüd pääseb sisemine tsükkel ligi `A`, `B` ja `C` elementidele järjestikku, parandades vahemälu jõudlust.
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:
- Protseduuridevaheline optimeerimine (IPO): Teostab optimeerimisi üle funktsioonide piiride. See võib hõlmata funktsioonide inlainimist erinevatest kompileerimisüksustest, globaalset konstantide levitamist ja surnud koodi eemaldamist kogu programmis. Linkimisaja optimeerimine (LTO) on IPO vorm, mida tehakse linkimise ajal.
- Profiilipõhine optimeerimine (PGO): Kasutab programmi täitmise ajal kogutud profileerimisandmeid optimeerimisotsuste tegemiseks. Näiteks suudab see tuvastada sageli täidetavaid kooditeid ja eelistada nendes piirkondades inlainimist ja tsükli lahtikerimist. PGO võib sageli pakkuda märkimisväärset jõudluse kasvu, kuid nõuab profileerimiseks representatiivset töökoormust.
- Automaatne paralleelistamine: Teisendab järjestikuse koodi automaatselt paralleelseks koodiks, mida saab käitada mitmel protsessoril või tuumal. See on keeruline ülesanne, kuna nõuab sõltumatute arvutuste tuvastamist ja nõuetekohase sünkroniseerimise tagamist.
- Spekulatiivne täitmine: Kompilaator võib ennustada hargnemise tulemust ja täita koodi ennustatud teel enne, kui hargnemise tingimus on tegelikult teada. Kui ennustus on õige, jätkub täitmine viivituseta. Kui ennustus on vale, visatakse spekulatiivselt täidetud kood ära.
Praktilised kaalutlused ja parimad praktikad
- Mõistke oma kompilaatorit: Tutvuge oma kompilaatori toetatud optimeerimislippude ja -valikutega. Üksikasjaliku teabe saamiseks vaadake kompilaatori dokumentatsiooni.
- Mõõtke jõudlust regulaarselt: Mõõtke oma koodi jõudlust pärast iga optimeerimist. Ärge eeldage, et konkreetne optimeerimine parandab alati jõudlust.
- Profileerige oma koodi: Kasutage profileerimisvahendeid jõudluse kitsaskohtade tuvastamiseks. Keskenduge oma optimeerimispüüdlustes valdkondadele, mis annavad suurima panuse üldisesse täitmisaega.
- Kirjutage puhast ja loetavat koodi: Hästi struktureeritud koodi on kompilaatoril lihtsam analüüsida ja optimeerida. Vältige keerulist ja käänulist koodi, mis võib optimeerimist takistada.
- Kasutage sobivaid andmestruktuure ja algoritme: Andmestruktuuride ja algoritmide valik võib jõudlust oluliselt mõjutada. Valige oma konkreetse probleemi jaoks kõige tõhusamad andmestruktuurid ja algoritmid. Näiteks räsivõtme kasutamine otsinguteks lineaarse otsingu asemel võib paljudes stsenaariumides jõudlust drastiliselt parandada.
- Kaaluge riistvaraspetsiifilisi optimeerimisi: Mõned kompilaatorid võimaldavad teil sihtida konkreetseid riistvaraarhitektuure. See võib võimaldada optimeerimisi, mis on kohandatud sihtprotsessori omadustele ja võimekusele.
- Vältige enneaegset optimeerimist: Ärge kulutage liiga palju aega koodi optimeerimisele, mis ei ole jõudluse kitsaskoht. Keskenduge kõige olulisematele valdkondadele. Nagu Donald Knuth kuulsalt ütles: "Enneaegne optimeerimine on kõige kurja juur (või vähemalt enamiku sellest) programmeerimises."
- Testige põhjalikult: Veenduge, et teie optimeeritud kood on õige, testides seda põhjalikult. Optimeerimine võib mõnikord tekitada peeneid vigu.
- Olge teadlik kompromissidest: Optimeerimine hõlmab sageli kompromisse jõudluse, koodi mahu ja kompileerimisaja vahel. Valige oma konkreetsetele vajadustele vastav õige tasakaal. Näiteks võib agressiivne tsükli lahtikerimine parandada jõudlust, kuid ka oluliselt suurendada koodi mahtu.
- Kasutage kompilaatori vihjeid (pragmad/atribuudid): Paljud kompilaatorid pakuvad mehhanisme (nt pragmad C/C++ keeles, atribuudid Rustis), et anda kompilaatorile vihjeid teatud koodilõikude optimeerimiseks. Näiteks võite kasutada pragmasid, et soovitada funktsiooni inlainimist või tsükli vektoriseerimist. Kompilaator ei ole siiski kohustatud neid vihjeid järgima.
Näiteid globaalsetest koodi optimeerimise stsenaariumitest
- Kõrgsageduslik kauplemine (HFT): Finantsturgudel võivad isegi mikrosekundilised parandused tähendada märkimisväärset kasumit. Kompilaatoreid kasutatakse laialdaselt kauplemisalgoritmide optimeerimiseks minimaalse latentsusajaga. Need süsteemid kasutavad sageli PGO-d, et peenhäälestada täitmisteid reaalsete turuandmete põhjal. Vektoriseerimine on ülioluline suurte turuandmete mahtude paralleelseks töötlemiseks.
- Mobiilirakenduste arendus: Aku kestvus on mobiilikasutajate jaoks kriitiline mure. Kompilaatorid saavad optimeerida mobiilirakendusi energiatarbimise vähendamiseks, minimeerides mälupöördumisi, optimeerides tsüklite täitmist ja kasutades energiatõhusaid käske. `-Os` optimeerimist kasutatakse sageli koodi mahu vähendamiseks, parandades veelgi aku kestvust.
- Manussüsteemide arendus: Manussüsteemidel on sageli piiratud ressursid (mälu, protsessori võimsus). Kompilaatoritel on nende piirangute jaoks koodi optimeerimisel oluline roll. Tehnikad nagu `-Os` optimeerimine, surnud koodi eemaldamine ja tõhus registrite eraldamine on hädavajalikud. Reaalaja operatsioonisüsteemid (RTOS) tuginevad samuti tugevalt kompilaatori optimeerimistele ennustatava jõudluse saavutamiseks.
- Teadusarvutused: Teaduslikud simulatsioonid hõlmavad sageli arvutusmahukaid arvutusi. Kompilaatoreid kasutatakse koodi vektoriseerimiseks, tsüklite lahtikerimiseks ja muude optimeerimiste rakendamiseks nende simulatsioonide kiirendamiseks. Eelkõige Fortrani kompilaatorid on tuntud oma täiustatud vektoriseerimisvõimaluste poolest.
- Mänguarendus: Mänguarendajad püüdlevad pidevalt kõrgemate kaadrisageduste ja realistlikuma graafika poole. Kompilaatoreid kasutatakse mängukoodi jõudluse optimeerimiseks, eriti sellistes valdkondades nagu renderdamine, füüsika ja tehisintellekt. Vektoriseerimine ja käskude ajastamine on GPU ja CPU ressursside maksimaalseks ärakasutamiseks üliolulised.
- Pilvandmetöötlus: Tõhus ressursside kasutamine on pilvekeskkondades esmatähtis. Kompilaatorid saavad optimeerida pilverakendusi, et vähendada protsessori kasutust, mälujalajälge ja võrgu ribalaiuse tarbimist, mis toob kaasa madalamad tegevuskulud.
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.