LLVM nga një këndvështrim Go

Zhvillimi i një përpiluesi është një detyrë shumë e vështirë. Por, për fat të mirë, me zhvillimin e projekteve si LLVM, zgjidhja e këtij problemi është thjeshtuar shumë, gjë që lejon edhe një programues të vetëm të krijojë një gjuhë të re që është afër performancës me C. Puna me LLVM është e ndërlikuar nga fakti se kjo sistemi përfaqësohet nga një sasi e madhe kodi, e pajisur me pak dokumentacion. Në mënyrë që të përpiqet të korrigjojë këtë mangësi, autori i materialit, përkthimin e të cilit po botojmë sot, do të demonstrojë shembuj të kodit të shkruar në Go dhe do të tregojë se si ato përkthehen fillimisht në Shko SSA, dhe më pas në LLVM IR duke përdorur përpiluesin vogël GO. Kodi Go SSA dhe LLVM IR është modifikuar pak për të hequr gjërat që nuk janë të rëndësishme për shpjegimet e dhëna këtu, në mënyrë që shpjegimet të bëhen më të kuptueshme.

LLVM nga një këndvështrim Go

Shembulli i parë

Funksioni i parë që do të shikoj 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, asgjë nuk mund të jetë më e thjeshtë. Ai përkthehet në kodin e mëposhtëm Go SSA:

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

Me këtë pamje, sugjerimet e tipit të të dhënave vendosen në të djathtë dhe mund të injorohen në shumicën e rasteve.

Ky shembull i vogël tashmë ju lejon të shihni thelbin e një aspekti të SSA. Gjegjësisht, gjatë konvertimit të kodit në formë SSA, çdo shprehje ndahet në pjesët më elementare nga të cilat përbëhet. Në rastin tonë, komanda return a + b, në fakt, përfaqëson dy operacione: mbledhjen e dy numrave dhe kthimin e rezultatit.

Për më tepër, këtu mund të shihni blloqet bazë të programit; në këtë kod ka vetëm një bllok - bllokun e hyrjes. Më poshtë do të flasim për blloqet.

Kodi Go SSA konvertohet lehtësisht në LLVM IR:

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

Ajo që mund të vini re është se megjithëse këtu përdoren struktura të ndryshme sintaksore, struktura e funksionit është në thelb e pandryshuar. Kodi LLVM IR është pak më i fortë se kodi Go SSA, i ngjashëm me C. Këtu, në deklaratën e funksionit, fillimisht ka një përshkrim të llojit të të dhënave që kthen, lloji i argumentit tregohet përpara emrit të argumentit. Përveç kësaj, për të thjeshtuar analizimin IR, emrat e entiteteve globale paraprihen nga simboli @, dhe para emrave vendas ka një simbol % (një funksion konsiderohet gjithashtu një entitet global).

Një gjë për t'u theksuar në lidhje me këtë kod është se vendimi i përfaqësimit të tipit Go int, e cila mund të përfaqësohet si një vlerë 32-bit ose 64-bit, në varësi të përpiluesit dhe objektivit të përpilimit, pranohet kur LLVM gjeneron kodin IR. Kjo është një nga arsyet e shumta që kodi LLVM IR nuk është, siç mendojnë shumë njerëz, i pavarur nga platforma. Një kod i tillë, i krijuar për një platformë, nuk mund të merret thjesht dhe të përpilohet për një platformë tjetër (përveç nëse jeni i përshtatshëm për zgjidhjen e këtij problemi me kujdes ekstrem).

Një tjetër pikë interesante që vlen të përmendet është se lloji i64 nuk është një numër i plotë me shenjë: është neutral për sa i përket paraqitjes së shenjës së numrit. Në varësi të udhëzimit, ai mund të përfaqësojë numra të nënshkruar dhe të panënshkruar. Në rastin e paraqitjes së veprimit të mbledhjes, kjo nuk ka rëndësi, kështu që nuk ka dallim në punën me numra të nënshkruar ose të panënshkruar. Këtu do të doja të vëreja se në gjuhën C, tejmbushja e një ndryshoreje të plotë të nënshkruar çon në sjellje të pacaktuar, kështu që pjesa e përparme Clang shton një flamur në operacion nsw (pa mbështjellje të nënshkruar), e cila i thotë LLVM se mund të supozojë se shtesa nuk del kurrë.

Kjo mund të jetë e rëndësishme për disa optimizime. Për shembull, duke shtuar dy vlera i16 në një platformë 32-bitësh (me regjistra 32-bitësh) kërkon, pas shtimit, një operacion zgjerimi të shenjës në mënyrë që të mbetet në interval i16. Për shkak të kësaj, shpesh është më efikase të kryhen operacione me numra të plotë bazuar në madhësitë e regjistrave të makinës.

Ajo që ndodh më pas me këtë kod IR nuk është me interes të veçantë për ne tani. Kodi është optimizuar (por në rastin e një shembulli të thjeshtë si i yni, asgjë nuk optimizohet) dhe më pas konvertohet në kodin e makinës.

Shembulli i dytë

Shembulli tjetër që do të shikojmë do të jetë pak më i komplikuar. Domethënë, ne po flasim për një funksion që përmbledh një pjesë të numrave të plotë:

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

Ky kod konvertohet në kodin e mëposhtëm Go SSA:

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ë shihni më shumë ndërtime tipike për përfaqësimin e kodit në formën SSA. Ndoshta tipari më i dukshëm i këtij kodi është fakti se nuk ka komanda të strukturuara të kontrollit të rrjedhës. Për të kontrolluar rrjedhën e llogaritjeve, ekzistojnë vetëm kërcime të kushtëzuara dhe të pakushtëzuara dhe, nëse e konsiderojmë këtë komandë si një komandë për të kontrolluar rrjedhën, një komandë kthimi.

Në fakt, këtu mund t'i kushtoni vëmendje faktit që programi nuk është i ndarë në blloqe duke përdorur mbajtëse kaçurrelë (si në familjen e gjuhëve C). Ai ndahet sipas etiketave, që të kujtojnë gjuhët e asamblesë dhe paraqitet në formën e blloqeve bazë. Në SSA, blloqet bazë përcaktohen si sekuenca të njëpasnjëshme të kodit duke filluar me një etiketë dhe duke përfunduar me udhëzimet bazë të plotësimit të bllokut, si p.sh. return и jump.

Një tjetër detaj interesant i këtij kodi përfaqësohet nga udhëzimi phi. Udhëzimet janë mjaft të pazakonta dhe mund të kërkojnë pak kohë për t'u kuptuar. mos harroni se S.S.A. është shkurtim për Static Single Assignment. Ky është një paraqitje e ndërmjetme e kodit të përdorur nga përpiluesit, në të cilin çdo ndryshore i caktohet një vlerë vetëm një herë. Kjo është e shkëlqyeshme për të shprehur funksione të thjeshta si funksioni ynë myAddtreguar më lart, por nuk është i përshtatshëm për funksione më komplekse siç është funksioni i diskutuar në këtë seksion sum. Në veçanti, variablat ndryshojnë gjatë ekzekutimit të ciklit i и n.

SSA anashkalon kufizimin në caktimin e vlerave të ndryshueshme një herë duke përdorur një të ashtuquajtur instruksion phi (emri i tij është marrë nga alfabeti grek). Fakti është se në mënyrë që përfaqësimi i kodit SSA të gjenerohet për gjuhë si C, duhet të drejtoheni në disa truke. Rezultati i thirrjes së këtij udhëzimi është vlera aktuale e ndryshores (i ose n), dhe një listë e blloqeve bazë përdoret si parametra të saj. Për shembull, merrni parasysh këtë udhëzim:

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

Kuptimi i tij është si vijon: nëse blloku themelor i mëparshëm ishte një bllok entry (hyrje), pastaj t0 është një konstante 0, dhe nëse blloku bazë i mëparshëm ishte for.body, atëherë ju duhet të merrni vlerën t6 nga ky bllok. E gjithë kjo mund të duket mjaft misterioze, por ky mekanizëm është ai që e bën SSA të funksionojë. Nga këndvështrimi njerëzor, e gjithë kjo e bën kodin të vështirë për t'u kuptuar, por fakti që çdo vlerë caktohet vetëm një herë i bën shumë optimizime shumë më të lehta.

Vini re se nëse shkruani përpiluesin tuaj, zakonisht nuk do t'ju duhet të merreni me gjëra të tilla. Edhe Clang nuk i gjeneron të gjitha këto udhëzime phi, përdor një mekanizëm alloca (i ngjan punës me variabla të zakonshëm lokalë). Pastaj, kur ekzekutohet një kalim i optimizimit LLVM thirret mem2reg, udhëzime alloca konvertuar në formën SSA. TinyGo, megjithatë, merr të dhëna nga Go SSA, e cila, në mënyrë të përshtatshme, tashmë është konvertuar në formën SSA.

Një risi tjetër e fragmentit të kodit të ndërmjetëm në shqyrtim është se qasja në elementët e pjesëve sipas indeksit përfaqësohet në formën e një operacioni të llogaritjes së adresës dhe një operacioni të çreferencimit të treguesit që rezulton. Këtu mund të shihni shtimin e drejtpërdrejtë të konstanteve në kodin IR (për shembull - 1:int). Në shembullin me funksionin myAdd kjo nuk është përdorur. Tani që i kemi hequr ato veçori, le të hedhim një vështrim se çfarë bëhet ky kod kur konvertohet 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 më parë, mund të shohim të njëjtën strukturë, e cila përfshin struktura të tjera sintaksore. Për shembull, në thirrje phi vlerat dhe etiketat e këmbyera. Sidoqoftë, këtu ka diçka që ia vlen t'i kushtohet vëmendje e veçantë.

Për të filluar, këtu mund të shihni një nënshkrim funksioni krejtësisht të ndryshëm. LLVM nuk mbështet feta, dhe si rezultat, si një optimizim, përpiluesi TinyGo që gjeneroi këtë kod të ndërmjetëm e ndau përshkrimin e kësaj strukture të të dhënave në pjesë. Ai mund të përfaqësojë tre elementë feta (ptr, len и cap) si strukturë (strukturë), por përfaqësimi i tyre si tre entitete të veçanta lejon disa optimizime. Përpilues të tjerë mund të përfaqësojnë pjesën në mënyra të tjera, në varësi të konventave të thirrjes së funksioneve të platformës së synuar.

Një veçori tjetër interesante e këtij kodi është përdorimi i instruksionit getelementptr (shpesh i shkurtuar si GEP).

Ky udhëzim funksionon me tregues dhe përdoret për të marrë një tregues në një element fetë. Për shembull, le ta krahasojmë atë me kodin e mëposhtëm të shkruar në C:

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

Ose me ekuivalentin e mëposhtëm me këtë:

int* sliceptr(int *ptr, int index) {
    return ptr + index;
}

Gjëja më e rëndësishme këtu është se udhëzimet getelementptr nuk kryen operacione çreferencimi. Ai thjesht llogarit një tregues të ri bazuar në atë ekzistues. Mund të merret si udhëzim mul и add në nivelin e harduerit. Mund të lexoni më shumë rreth udhëzimeve të GEP këtu.

Një veçori tjetër interesante e këtij kodi të ndërmjetëm është përdorimi i instruksionit icmp. Ky është një udhëzim për qëllime të përgjithshme që përdoret për të zbatuar krahasimet e numrave të plotë. Rezultati i këtij udhëzimi është gjithmonë një vlerë e llojit i1 - vlera logjike. Në këtë rast, bëhet një krahasim duke përdorur fjalën kyçe slt (nënshkruar më pak se), pasi po krahasojmë dy numra të paraqitur më parë nga lloji int. Nëse do të krahasonim dy numra të plotë të panënshkruar, atëherë do të përdornim icmp, dhe fjala kyçe e përdorur në krahasim do të ishte ult. Për të krahasuar numrat me pikë lundruese, përdoret një udhëzim tjetër, fcmp, i cili funksionon në mënyrë të ngjashme.

Rezultatet e

Besoj se në këtë material kam mbuluar veçoritë më të rëndësishme të LLVM IR. Sigurisht, këtu ka shumë më tepër. Në veçanti, paraqitja e ndërmjetme e kodit mund të përmbajë shumë shënime që lejojnë kalimet e optimizimit për të marrë parasysh disa veçori të kodit të njohura për përpiluesin që nuk mund të shprehen ndryshe në IR. Për shembull, ky është një flamur inbounds Udhëzime GEP, ose flamuj nsw и nuw, të cilat mund t'i shtohen udhëzimeve add. E njëjta gjë vlen edhe për fjalën kyçe private, duke i treguar optimizuesit që funksioni që ai shënon nuk do të referohet nga jashtë njësisë aktuale të përpilimit. Kjo lejon shumë optimizime interesante ndërprocedurale si eliminimi i argumenteve të papërdorura.

Mund të lexoni më shumë rreth LLVM në dokumentacionin, të cilit do t'i referoheni shpesh kur zhvilloni përpiluesin tuaj të bazuar në LLVM. Këtu udhëheqja, i cili shikon zhvillimin e një përpiluesi për një gjuhë shumë të thjeshtë. Të dyja këto burime informacioni do të jenë të dobishme për ju kur krijoni përpiluesin tuaj.

Të nderuar lexues! A po përdorni LLVM?

LLVM nga një këndvështrim Go

Burimi: www.habr.com

Shto një koment