Zhvillimi i një kompajleri është një detyrë shumë e vështirë. Por, me fat, me zhvillimin e projekteve si LLVM, zgjidhja e kësaj detyre bëhet shumë më e thjeshtë, duke lejuar edhe një programues të vetëm të krijojë një gjuhë të re, e cila është afër performancës së C. Puna me LLVM ndërlikohet nga fakti se ky sistem paraqitet me një volum të madh kodi, të shoqëruar me pak dokumentacion. Për të përmirësuar këtë mangësi, autori i materialit, të cilin po e publikojmë sot, ka përqendruar përpjekjet për të demonstruar shembuj kodi të shkruar në Go dhe për të treguar se si ata përkthehen fillimisht në , dhe më pas — në LLVM IR duke përdorur kompajlerin . Kodi Go SSA dhe LLVM IR është modifikuar pak, duke hequr ato pjesë që nuk lidhen me shpjegimet e dhëna këtu, për t'i bërë këto shpjegime më të kuptueshme.
Shembulli i parë
Funksioni i parë që do të shqyrtoj këtu është një mekanizëm i thjeshtë për mbledhjen e numrave:
func myAdd(a, b int) int{
return a + b
}Ky funksion është shumë i thjeshtë, dhe ndoshta nuk ka asgjë më të thjeshtë. Ai përkthehet në kodin në vazhdim Go SSA:
func myAdd(a int, b int) int:
entry:
t0 = a + b int
return t0Me këtë paraqitje të funksionit, sugjerimet për tipet e të dhënave janë vendosur përbri, të cilat në shumicën e rasteve nuk ka nevojë të merren parasysh.
Ky shembull i vogël tashmë lejon të shikojmë thelbin e njërit nga aspektet e SSA. Në të vërtetë, kur kodin e kthejmë në formën SSA, çdo shprehje ndahet në pjesët më elementare nga të cilat përbëhet. Në rastin tonë, komanda return a + b, në të vërtetë përfaqëson dy operacione: mbledhjen e dy numrave dhe kthimin e rezultatit.
Për më tepër, këtu mund të shihni edhe bloket bazë të programit, në këtë kod ka vetëm një blok — blloku hyrës (entry block). Më shumë rreth blloqeve do të flasim më poshtë.
Kodi Go SSA shndërrohet lehtësisht në LLVM IR:
define i64 @myAdd(i64 %a, i64 %b) {
entry:
%0 = add i64 %a, %b
ret i64 %0
} Është e dukshme se, megjithëse këtu përdoren sintaksat e ndryshme, struktura e funksionit në përgjithësi nuk është ndryshuar. Kodi LLVM IR është pak më i fortë se kodi Go SSA, i ngjan C. Këtu, në deklaratën e funksionit, së pari jepet përshkrimi i tipit të të dhënave që kthehet, tipi i argumentit jepet përpara emrit të argumentit. Për më tepër, për të thjeshtuar parsimimin e IR, para emrave të entiteteve globale duhet të vendoset simboli @, ndërsa për emrat lokalë - simboli % (funksioni gjithashtu konsiderohet si një entitet global).
Një nga veçoritë e këtij Kodi, që duhet të vëmendsohet, është se vendimi për përfaqësimin e tipit Go int, i cili mund të përfaqësohet nga një vlerë 32-bit ose 64-bit, në varësi të kompilatorit dhe qëllimit të kompilimit, merret gjatë krijimit të kodit LLVM IR. Kjo është një nga shumë arsyet për të cilat Kodi LLVM IR nuk është, siç mendojnë shumë, i pavarur nga platforma. Ky kod, i krijuar për një platformë, nuk mund të marret dhe të kompilohen thjesht për një platformë tjetër (përveç nëse qasemi në zgjidhjen e këtij problemi ).
Një tjetër pikë interesante që duhet theksuar është se tipi i64 nuk është një numër i plotë me shenjë: ai është neutral në përfaqësimin e shenjës së numrit. Sipas instrukcionit, ai mund të përfaqësojë si numra me shenjë, ashtu edhe numra pa shenjë. Në rastin e përfaqësimit të operacionit të shtimit, kjo nuk ka rëndësi, prandaj këtu nuk ka ndryshim në punën me numra me shenjë ose pa shenjë. Duhet të theksohet se në gjuhën C, tejkalimi i një variabli të plotë me shenjë çon në sjellje të paqartë, prandaj frontend-i Clang i shton operacionit një flamur nsw (no signed wrap), që tregon LLVM se mund të nxjerrë përfundim se në procesin e shtimit nuk ndodh kurrë tejkalim.
Kjo mund të jetë e rëndësishme për disa optimizime. Për shembull, shtimi i dy vlerave i16 në një platformë 32-bit (me regjistra 32-bit) ka nevojë, pas kryerjes së shtimit, për një operacion zgjatjeje të shenjës për të mbetur brenda gamës i16. Për shkak të kësaj, shpesh rezulton më efikase të realizohen operacionet e numërimit duke marrë parasysh madhësitë e regjistrit të makinës.
Ajo që ndodh më pas me këtë kod IR, nuk na intereson shumë tani. Kodi optimizohet (por në rastin e një shembulli kaq të thjeshtë si i yni, nuk ka asgjë për të optimizuar), dhe pastaj konvertohet në kodin makinerik.
Shembulli i dytë
Shembulli tjetër që do të shqyrtojmë do të jetë pak më i komplikuar. Konkret, bëhet fjalë për një funksion që bën shumën e një slice numrash të tërë:
func sum(numbers []int) int {
n := 0
for i := 0; i < len(numbers); i++ {
n += numbers[i]
}
return n
}Ky kod konvertohet në kodin Go SSA të mëposhtëm:
func sum(numbers []int) int:
entry:
jump for.loop
for.loop:
t0 = phi [entry: 0:int, for.body: t6] #n int
t1 = phi [entry: 0:int, for.body: t7] #i int
t2 = len(numbers) int
t3 = t1 < t2 bool
if t3 goto for.body else for.done
for.body:
t4 = &numbers[t1] *int
t5 = *t4 int
t6 = t0 + t5 int
t7 = t1 + 1:int int
jump for.loop
for.done:
return t0Këtu mund të shihen më shumë konstrukte karakteristike për përfaqësimin e kodit në formën SSA. E veçanta më e dukshme e këtij kodi është fakti se nuk ka komanda të strukturuara për menaxhimin e flukseve të llogaritjeve. Për menaxhimin e flukseve të llogaritjeve, ka vetëm kalime kushtore dhe të pakushtore, dhe, nëse e marrim këtë komandë si një komandë për menaxhimin e flukseve, komandën e kthimit.
Në të vërtetë, vërehet se programi nuk ndahet në blloqe duke përdorur kllapa (si në gjuhët e familjes C). Ai ndahet me etiketat, që i ngjan gjuhëve assembly dhe paraqitet në formën e blloqeve bazë. Në SSA, blloqet bazë quhen sekuenca të vazhdueshme të kodit që fillojnë me një etiketë dhe përfundojnë me instruksione përfundimtare të bllokut bazë, për shembull — return dhe jump.
Një detaj tjetër interesant i këtij kodi është instruksioni phi. Kjo instrukcion është mjaft e pazakontë, dhe mund të kërkojë pak kohë për ta kuptuar. Mbani mend se — është një shkurtim për Static Single Assignment. Kjo është një përfaqësim ndërmjetës i kodit që përdorin kompilatorët, ku çdo variabël i jepet një vlerë vetëm një herë. Kjo i përshtatet shumë shprehjeve të thjeshta, të ngjashme me funksionin tonë myAdd, i shfaqur më sipër, por nuk përshtatet për funksionet më komplekse — si funksioni i shqyrtuar në këtë seksion sum. Në veçanti, gjatë ekzekutimit të ciklit ndryshojnë variablat i dhe n.
SSA e shmang kufizimin e një herë të caktimit të vlerave të variablave duke përdorur instruksionin e quajtur phi (emri i tij është marrë nga alfabeti grek). E vërteta është se për të formuar një përfaqësim SSA të kodit për gjuhë si C, duhet të përdoren disa truqe. Rezultati i thirrjes së këtij instruksioni është vlera aktuale e variablit (i или n), dhe si parametrat e saj përdoret një listë e blloqeve bazë. Për shembull, le të shqyrtojmë një instrukcion të tillë:
t0 = phi [entrance: 0:int, for.body: t6] #n Kuptimi i tij është si vijon: nëse blloku paraardhës është blloku entrance (hyrës), atëherë t0 është një konstantë 0, dhe nëse blloku paraardhës është for.body, atëherë duhet të merret vlera t6 nga ky bllok. Të gjitha këto mund të duken mjaft misterioze, por falë këtij mekanizmi realizohet funksionimi i SSA. Nga pikëpamja njerëzore, gjithçka kjo e komplikon kuptimin e kodit, por fakti që çdo vlerë caktohet vetëm një herë, e thjeshton ndjeshëm shumë optimizime.
Kujtoni se nëse po shkruani kompilatorin tuaj, zakonisht nuk keni të bëni me këto gjëra. Edhe Clang nuk gjeneron të gjithë këto instrukcione phi, ai përdor mekanizmin alloca (ai i ngjan punës me variabla lokale të zakonshëm). Pastaj, gjatë ekzekutimit të kalimit të optimizimit të LLVM, të quajtur , instrukcionet alloca konevertohen në formën SSA. TinyGo, megjithatë, merr të dhënat nga Go SSA, të cilat, për fat të mirë, janë tashmë të konvertuara në formën SSA.
Një novitet tjetër i fragmentit të shqyrtuar të kodit ndërmjetës është se qasja në elementët e slice-s me indeks tregohet si një operacion i llogaritjes së adresës dhe një operacion i çmontimit të pointerit të marrë. Këtu mund të shihni edhe shtimin e drejtpërdrejtë të konstantave në IR-kod (për shembull — 1:int). Në shembullin me funksionin myAdd Këtë nuk e kemi përdorur më parë. Tani, pasi e kuptuam këtë veçori, le të shohim se çfarë do të ndodhë me këtë kod kur e shndërrojmë në formën LLVM IR:
define i64 @sum(i64* %ptr, i64 %len, i64 %cap) {
entry:
br label %for.loop
for.loop: ; preds = %for.body, %entry
%0 = phi i64 [ 0, %entry ], [ %5, %deref.next ]
%1 = phi i64 [ 0, %entry ], [ %6, %deref.next ]
%2 = icmp slt i64 %1, %len
br i1 %2, label %for.body, label %for.done
for.body: ; preds = %for.loop
%3 = getelementptr i64, i64* %ptr, i64 %1
%4 = load i64, i64* %3
%5 = add i64 %0, %4
%6 = add i64 %1, 1
br label %for.loop
for.done: ; preds = %for.loop
ret i64 %0
} Këtu, siç kemi parë më parë, mund të shohim një të njëjtë strukturë, që përfshin ndërtesa të tjera sintaksore. Për shembull, në thirrjet phi vlerat dhe etiketat janë këmbyer. Por këtu ka diçka që meriton vëmendje të veçantë.
Për fillim, këtu mund të shihni një nënshkrim krejtësisht ndryshe të funksionit. LLVM nuk mbështet slices, për pasojë, si një optimizim, kompaktori TinyGo, i cili krijoi këtë kod ndërmjetës, e ndau përshkrimin e kësaj strukture të dhënash në pjesë. Ai do të mundte të paraqiste tre elemente të slices (ptr, len dhe cap) në formë strukture (struct), por paraqitja e tyre si tri entitete të veçanta lejon të bëhen disa optimizime. Kompaktorë të tjerë mund të paraqesin slices ndryshe, kjo varet nga marrëveshjet e thirrjes së funksioneve të platformës objektiv.
Një tjetër veçori interesante e këtij kodi është përdorimi i instruksionit getelementptr (shpesh e quajnë shkurt GEP).
Ky instruksion punon me tregues dhe përdoret për të marrë një tregues për një element të slices. Për shembull, le të krahasojmë atë me kodin e mëposhtëm, të shkruar në C:
int* sliceptr(int *ptr, int index) {
return &ptr[index];
}Ose me këtë, ekuivalent me këtë:
int* sliceptr(int *ptr, int index) {
return ptr + index;
} Më e rëndësishmja këtu është se instruksioni getelementptr nuk kryen operacione dereferencimi. Ai thjesht llogarit një tregues të ri, duke u bazuar në ekzistuesin. Mund të perceptohet si instruksionet mul dhe add në nivelin harduerik. Detajet rreth instruksionit GEP mund të lexohen .
Një tjetër veçori interesante e këtij kodi ndërmjetës është përdorimi i instruksionit icmp. Kjo është një udhëzim i përgjithshëm që përdoret për realizimin e krahasimit të numrave të plotë. Rezultati i ekzekutimit të këtij udhëzimi është gjithmonë një vlerë e tipit i1 — një vlerë logjike. Në këtë rast, krahasimi bëhet duke përdorur fjalinë kyçe slt (më i vogël se), pasi krahasojmë dy numra, të paraqitur më parë si tip int. Nëse do të ishim duke krahasuar dy numra të plotë pa nënshkrim, atëherë do të përdornim si udhëzim icmp, dhe fjala kyçe që përdoret gjatë krahasimit do të ishte ult. Për krahasimin e numrave me pikë flutuese përdoret një udhëzim tjetër, fcmp, që funksionon në mënyrë të ngjashme.
Përfundime
Mendoj që në këtë material kam shqyrtuar karakteristikat më të rëndësishme të LLVM IR. Sigurisht, ka ende shumë më tepër për të shikuar. Në veçanti, përfaqësimi i ndërmjetëm i kodit mund të ketë shumë annotime që lejojnë marrjen parasysh të disa veçorive të kodit gjatë kalimeve të optimizimit, veçori që janë të njohura për kompajlerin, të cilat nuk mund të shprehen ndryshe në IR. Për shembull, ky është flaga inbounds e udhëzimit GEP, ose flamujt nsw dhe nuw, të cilat mund të shtohen në udhëzimin add. E njëjta gjë vlen edhe për fjalën kyçe private, e cila tregon optimizuesit se funksioni i shënuar nga ajo nuk do të referohet nga jashtë njësisë aktuale të optimizimit. Kjo lejon realizimin e shumë optimizimeve interesante ndërmjet procedurave si eliminimi i argumenteve të papërdorura.
Mund të lexoni më shumë rreth LLVM në , të cilën do ta referoni shpesh kur zhvilloni kompajlerin tuaj, i bazuar në LLVM. Këtu është , i cili shqyrton zhvillimin e një kompajleri për një gjuhë shumë të thjeshtë. Të dy këto burime informacioni do t'ju jenë të dobishme gjatë krijimit të kompajlerit tuaj.
Të nderuar lexues! A po përdorni LLVM?
Burimi: habr.com
