LLVM nga këndvështrimi i Go

Zhvillimi i një kompilerit është një detyrë shumë e vështirë. Por, për fat të mirë, me zhvillimin e projekteve si LLVM, zgjidhja e kësaj detyre bëhet shumë më e thjeshtë, duke lejuar madje që një programues i vetëm të krijojë një gjuhë të re, e cila është afër performancës së C. Puna me LLVM komplikohet nga fakti se ky sistem përfshin një volum të madh kodi, të shoqëruar me dokumentacion të vogël. Në përpjekje për të adresuar këtë mangësi, autori i materialit, përkthimi i të cilit po publikojmë sot, ka për qëllim të demonstrojë shembuj kode të shkruara në Go dhe të tregojë si ato transmetohen fillimisht në Go SSA, dhe më pas - në LLVM IR duke përdorur kompilerin TinyGO. Kodi Go SSA dhe LLVM IR është pak i edituar, duke eliminuar atë që nuk lidhet me shpjegimet e dhëna këtu, për të bërë këto shpjegime më të kuptueshme.

LLVM nga këndvështrimi i Go

Shembulli i parë

Funksioni i parë që do të trajtoj këtu është një mekanizëm i thjeshtë për shumimin e numrave:

func myAdd(a, b int) int{
    return a + b
}

Ky funksion është shumë i thjeshtë, ndoshta asgjë më e thjeshtë nuk është krijuar. Ai transmetohet në këtë kod Go SSA:

func myAdd(a int, b int) int:
entry:
    t0 = a + b                                            int
    return t0

Në një paraqitje të tillë të funksionit, sugjerimet për llojet e të dhënave vendosen të drejta, në shumicën e rasteve ato mund të injorohen.

Ky shembull i vogël tashmë lejon të shikohet një nga aspektet e SSA. Konkretisht, kur kodi transformohet në formën SSA, çdo shprehje ndahet në pjesët më elementare që përbëjnë atë. Në rastin tonë, komanda return a + b, në të vërtetë, përfaqëson dy operacione: shumimin e dy numrave dhe kthimin e rezultatit.

Për më tepër, këtu mund të shihni edhe blloqet bazë të programit, në këtë kod ka vetëm një bllok - blloku hyrës (entry block). Më shumë për blloqet do të flasim më poshtë.

Kodi Go SSA lehtë transformohet në LLVM IR:

define i64 @myAdd(i64 %a, i64 %b) {
entry:
  %0 = add i64 %a, %b
  ret i64 %0
}

Vërehet se, megjithëse përdoren struktura sintaksore të tjera, struktura e funksionit, në përgjithësi, mbetet e pandryshuar. Kodi LLVM IR është pak më i fortë se kodi Go SSA, duke u ngjashëm me C. Këtu, në deklaratën e funksionit, fillimisht shkon përshkrimi i llojit të të dhënave që ai ktheu, lloji i argumentit tregohet para emrit të argumentit. Për më tepër, për të thjeshtuar analizimin e IR, para emrave të entiteteve globale qëndron simboli @, ndërsa para emrave të atyre lokale - simboli % (funksioni gjithashtu konsiderohet si një entitet global).

Një nga veçoritë e këtij kodi, të cilën duhet ta vini re, është se vendimi për përfaqësimin e tipit Go int, i cili mund të përfaqësohet si një vlerë 32-bit ose 64-bit, në varësi të kompilerit dhe qëllimit të kompilimit, merret gjatë krijimit të kodit LLVM IR. Kjo është një nga shkaqet shumë që kodi LLVM IR nuk është, siç mendon shumë, i pavarur nga platforma. Një kod i tillë, i krijuar për një platformë, nuk mund të merret dhe të kompilohet thjesht për një platformë tjetër (nëse nuk i qasemi kësaj detyre me kujdes të veçantë).

. Një pikë tjetër interesante që merret parasysh ë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. Në varësi të instruktionit, ai mund të përfaqësojë si numra me shenjë ashtu edhe numra pa shenjë. Në rastin e përfaqësimit të operacionit të shumimit, kjo nuk ka rëndësi, prandaj këtu nuk ka asnjë ndryshim në punën me numrat me shenjë ose pa shenjë. Dëshiroj të përmend gjithashtu se në gjuhën C, mbipërfaqësimi i një variabli të plotë me shenjë rezulton në sjellje të pacaktuar, prandaj frontend Clang shton një flamur në operacionin nsw (no signed wrap), që i tregon LLVM se ai mund të supozojë se gjatë shumimit kurdoherë nuk ndodh mbipërfaqësimi.

Kjo mund të jetë e rëndësishme për disa optimizime. Për shembull, shumimi i dy vlerave i16 në një platformë 32-bit (me regjistrat 32-bit) kërkon, pasi të përfundojë shumimi, një operacion të zgjerimit të shenjës për të mbetur brenda gamës i16. Për këtë arsye, shpesh rezulton më efektive që operacionet e plota të bëhen duke marrë parasysh përmasat e regjistrit të makinës.

ÇfarĂ« ndodh mĂ« pas me kĂ«tĂ« kod IR, pĂ«r momentin nuk na intereson shumĂ«. Kodi optimizohet (por nĂ« rastin e njĂ« shembulli tĂ« tillĂ« tĂ« thjeshtĂ« si i yni, nuk optimizohet aspak), dhe pastaj transformohet nĂ« kodin e makinĂ«s.

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ë plotë:

func sum(numbers []int) int {
    n := 0
    for i := 0; i < len(numbers); i++ {
        n += numbers[i]
    }
    return n
}

Ky ky kod shndërrohet në kodin Go SSA si më poshtë:

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 t0

Këtu tashmë mund të shihen më shumë konstruksione karakteristike për përfaqësimin e kodit në formën SSA. Me siguri, veçoria më evidente e këtij kodi është se këtu nuk ka komanda të strukturuara për kontrollin e fluxit të llogarive. Për kontrollin e fluxit, ekzistojnë vetëm kalime kushtore dhe të pakushtore, dhe nëse e konsideroni këtë komandë si komandë për kontrollin e fluxit, është e njëjtë me komandën e kthimit.

NĂ« tĂ« vĂ«rtetĂ«, mund tĂ« vihet re se programi nuk ndahet nĂ« blloqe duke pĂ«rdorur kllapa (si nĂ« gjuhĂ«t e familjes C). Ai ndahet me etiketa, qĂ« i ngjason gjuhĂ«ve tĂ« asambleve, dhe paraqitet nĂ« formĂ« blloqesh bazĂ«. NĂ« SSA, blloqet bazĂ« quhen sekuenca tĂ« vazhdueshme tĂ« kodit qĂ« fillojnĂ« me njĂ« etiketĂ« dhe pĂ«rfundojnĂ« me instruktime pĂ«rfundimi tĂ« bllokut bazĂ«, pĂ«r shembull — kthehu dhe jump.

NjĂ« detaj tjetĂ«r interesant i kĂ«tij kodi paraqitet me instruktionin phi. Kjo instruktion Ă«shtĂ« mjaft e pazakontĂ«, dhe pĂ«r ta kuptuar mund tĂ« nevojitet pak kohĂ«. Mbani mend se SSA — Ă«shtĂ« shkurtim pĂ«r Static Single Assignment. Ky Ă«shtĂ« njĂ« pĂ«rfaqĂ«sim ndĂ«rmjetĂ«s tĂ« kodit qĂ« pĂ«rdoren nga kompajlerĂ«t, ku çdo variabĂ«l ndan njĂ« vlerĂ« vetĂ«m njĂ« herĂ«. Ky Ă«shtĂ« i pĂ«rshtatshĂ«m pĂ«r shprehjen e funksioneve tĂ« thjeshta, siç Ă«shtĂ« funksioni ynĂ« myAdd, i paraqitur mĂ« sipĂ«r, por nuk Ă«shtĂ« i pĂ«rshtatshĂ«m pĂ«r funksione mĂ« tĂ« komplikuara — siç Ă«shtĂ« funksioni qĂ« po shqyrtojmĂ« nĂ« kĂ«tĂ« seksion. sum. NĂ« veçanti, gjatĂ« ekzekutimit tĂ« ciklit, variablat ndryshojnĂ«. i dhe n.

SSA kapërthen kufizimin e caktimit të vetëm një herë të vlerave të variablave duke përdorur instruktionin e ashtuquajtur phi (emri i saj është marrë nga alfabeti grek). E vërteta është se për të formuar një përfaqësim SSA për gjuhët si C, duhet të përdoren disa truka. Rezultati i thirrjes së kësaj instruktioni është vlera aktuale e variablës (i ose n), dhe si parametrat e saj përdoret një listë e blloqeve bazë. Për shembull, le të shqyrtojmë një instruktion të tillë:

t0 = phi [entry: 0:int, for.body: t6] #n

Kuptimi i saj është si më poshtë: nëse blloku i mëparshëm ishte blloku entry (hyrës), atëherë t0 është një konstantë 0, dhe nëse blloku i mëparshëm ishte for.body, atëherë duhet të merret vlera t6 nga ky bllok. Kjo mund të duket mjaft misterioze, por përmes këtij mekanizmi, funksionon SSA. Nga pikëpamja njerëzore, 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 kompajlerin tuaj, zakonisht nuk keni nevojë të merrni përsipër gjëra të tilla. Edhe Clang nuk gjeneron të gjitha këto instruktime phi, ai përdor mekanizmin alloca (ato i ngjajnë punës me variablat lokale të zakonshëm). Pastaj, gjatë kaluar së optimizimit të LLVM, i njohur si mem2reg, instruktionet alloca shndërrohen në formën SSA. Megjithatë, TinyGo merr inputin nga Go SSA, i cili, çuditërisht, është tashmë i shndërruar në formën SSA.

NjĂ« tjetĂ«r novitet i fragmentit tĂ« shqyrtuar tĂ« kodit tĂ« ndĂ«rmjetĂ«m Ă«shtĂ« se qasja nĂ« elementet e slice sipas indeksit Ă«shtĂ« paraqitur nĂ« formĂ«n e njĂ« operacioni pĂ«rllogaritjeje tĂ« adresĂ«s dhe operacionit tĂ« çregjistrimit tĂ« treguesit 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 nuk Ă«shtĂ« pĂ«rdorur diçka e tillĂ«. Tani, pasi kemi kuptuar kĂ«to veçori, le tĂ« shikojmĂ« se nĂ« çfarĂ« do tĂ« shndĂ«rrohet ky kod kur shndĂ«rrohet 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, ashtu si më parë, ne mund të shohim të njëjtën strukturë, e cila përfshin konstruksione të tjera sintaksore. Për shembull, në thirrjet phi vlerat dhe etiketat janë shkëmbyer. Megjithatë, këtu ka edhe diçka tjetër që ka vlerë të veçantë.

Për fillim, këtu mund të shihni një nënshkrim funksioni krejtësisht ndryshe. LLVM nuk mbështet slice, si rezultat, në formën e optimizimit, kompileri TinyGo, i cili formoi këtë kod të ndërmjetëm, e ndau përshkrimin e kësaj strukture të dhënash në pjesë. Ai do të mund të paraqiste tre elementë të slice-it (ptr, len dhe cap) në formën e një strukture (struct), por paraqitja e tyre si tre entitete të veçanta lejon kryerjen e disa optimizimeve. Kompilerë të tjerë mund të paraqesin slice ndryshe, varësisht nga marrëveshjet mbi thirrjet e funksioneve të platformës së synuar.

Një karakteristikë tjetër interesante e këtij kodi është përdorimi i instrukcionit getelementptr (shpesh e quajtur shkurt GEP).

Ky instruksion punon me tregues dhe përdoret për të marrë një tregues për një element të slice-it. Për shembull, le të e krahasojmë atë me kodin e mëposhtëm, shkruar në C:

int* sliceptr(int *ptr, int index) {
    return &ptr[index];
}

Ose me këtë, ekvivalent me 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, bazuar në të tanishmin. Mund të merret si instruksionet mul dhe shto në nivelin harduerik. Detajet mbi instruksionin GEP mund të lexohen këtu.

NjĂ« karakteristikĂ« tjetĂ«r interesante e kĂ«tij kodi tĂ« ndĂ«rmjetĂ«m Ă«shtĂ« pĂ«rdorimi i instruksionit icmp. Ky Ă«shtĂ« njĂ« instruksion i pĂ«rgjithshĂ«m, i pĂ«rdorur pĂ«r realizimin e krahasimit tĂ« numrave tĂ« tĂ«rĂ«. Rezultati i ekzekutimit tĂ« kĂ«tij instruksioni gjithmonĂ« Ă«shtĂ« njĂ« vlerĂ« e tipit i1 — njĂ« vlerĂ« logjike. NĂ« kĂ«tĂ« rast, krahasimi bĂ«het duke pĂ«rdorur fjalĂ«n kyçe slt (nĂ« mĂ«nyrĂ« tĂ« nĂ«nshkruar mĂ« tĂ« vogĂ«l se), pasi krahasojmĂ« dy numra, tĂ« kaluar mĂ« parĂ« si tip int. NĂ«se do tĂ« kishim krahasuar dy numra tĂ« tĂ«rĂ« pa nĂ«nshkrim, atĂ«herĂ« do tĂ« kishim pĂ«rdorur icmp, ndĂ«rsa fjala kyçe e pĂ«rdorur pĂ«r krahasimin do tĂ« ishte ult. PĂ«r krahasimin e numrave me pikĂ« fluturuese pĂ«rdoret njĂ« instruksion tjetĂ«r, fcmp, i cili funksionon nĂ« mĂ«nyrĂ« tĂ« ngjashme.

Përfundimet

Mendoj se në këtë material kam trajtuar karakteristikat më të rëndësishme të LLVM IR. Sigurisht, këtu ka shumë më tepër. Në veçanti, në përfaqësimin e ndërmjetëm të kodit mund të ketë shumë anotime që lejojnë të merren parasysh disa veçori të kodit, të njohura nga kompileri, gjatë kalimeve të optimizimit, të cilat nuk mund të shprehen ndryshe në IR. Për shembull, kjo është një flag inbounds e instrukcionit GEP, ose flamujt nsw dhe nuw, të cilat mund të shtohen në instruksionin shto. E njëjta gjë vlen për fjalën kyçe private, e cila i tregon optimizuesit që funksioni i shënuar prej tij nuk do të referohet jashtë njësisë aktuale të kompilimit. Kjo lejon kryerjen e shumë optimizimeve interesante ndërproçesore si eliminimi i argumenteve të pa përdorura.

Detajet mbi LLVM mund të lexohen në dokumentacion, në të cilën do të referoheni shpesh teksa zhvilloni kompilerin tuaj të bazuar në LLVM. Ja një udhëzues, në të cilin gjithashtu trajtohet zhvillimi i një kompileri për një gjuhë shumë të thjeshtë. Të dy këta burime informacioni do t'ju ndihmojnë në krijimin e kompilerit tuaj.

Të nderuar lexues! A përdorni LLVM?

LLVM nga këndvështrimi i Go

Burimi: habr.com

Bleni hostim tĂ« besueshĂ«m pĂ«r faqe me mbrojtje nga DDoS, serverĂ« VPS VDS đŸ”„ Bleni hostim tĂ« besueshĂ«m pĂ«r faqe me mbrojtje nga DDoS, serverĂ« VPS VDS | ProHoster