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ë
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
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 myAdd
treguar 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 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
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ë
Të nderuar lexues! A po përdorni LLVM?
Burimi: www.habr.com