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 ose 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:
defino i64 @sum(i64* %ptr, i64 %len, i64 p) {
entry:
br label %for.loop
for.loop: ; preds = %for.body, %entry
%0 = phi i64 [ 0, %entry ], [ %5, ref.next ]
%1 = phi i64 [ 0, %entry ], [ %6, ref.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
