LLVM Go vaatenurgast

Kompilaatori arendamine on vĂ€ga keeruline ĂŒlesanne. Kuid Ă”nneks on projektide nagu LLVM arenguga see probleem mĂ€rgatavalt lihtsamaks muutunud, vĂ”imaldades isegi ĂŒhe mehe programmeerijal luua uue keele, mis on jĂ”udluselt C-le lĂ€hedane. LLVM-iga töötamine on keeruline, sest see sĂŒsteem sisaldab tohutut koodihulka, mille kohta on vĂ€he dokumentatsiooni. KĂ€esoleva materjali autori, keda me tĂ€na esitame, eesmĂ€rk on demonstreerida Go-s kirjutatud koodinĂ€iteid ja nĂ€idata, kuidas need kĂ”igepealt tĂ”lgitakse Go SSA, ja seejĂ€rel — LLVM IR-ks, kasutades kompilaatorit TinyGO. Go SSA ja LLVM IR-i kood on veidi redigeeritud ning eemaldatud on need osad, mis ei ole antud seletustega seotud, et need seletused oleksid arusaadavamad.

LLVM Go vaatenurgast

Esimene nÀide

Esimene funktsioon, mida ma siin kÀsitlen, on lihtne mehhanism kahe arvu liitmiseks:

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

See funktsioon on vÀga lihtne ning ilmselt ei ole midagi lihtsamat. See tÔlgitakse jÀrgmisse Go SSA koodi:

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

Selle funktsiooni esituse korral kuvatakse tĂŒĂŒpide vihjed paremal ja enamikul juhtudel vĂ”ib neile mitte tĂ€helepanu pöörata.

See vĂ€ike nĂ€ide vĂ”imaldab juba nĂ€ha ĂŒht SSA aspekti. Nimelt, koodi konverteerimisel SSA vormi jaguneb iga vĂ€ljendus oma elementaarseteks osadeks. Meie puhul on kĂ€sk return a + b, tegelikult, esindab kahte operatsiooni: kahe numbri liitmine ja tulemuse tagastamine.

Lisaks on siin nĂ€htavad ka programmi pĂ”hiblokid, selles koodis on ainult ĂŒks blok — sissejuhatav (entry block). Blokkidest rÀÀgime lĂ€hemalt allpool.

Go SSA kood on lihtsalt konverteeritav LLVM IR-iks:

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

On vĂ”ib mĂ€rgata, et kuigi siin kasutatakse teistsuguseid sĂŒntaktilisi konstruktsioone, on funktsiooni struktuur peamiselt muutumatuks jÀÀnud. LLVM IR kood on natuke tugevam kui Go SSA kood, olles sarnane C-le. Funktsiooni deklaratsioonis jĂ€rgnevad esmalt tagastatava andmetĂŒĂŒbi kirjeldus, argumentide tĂŒĂŒp on nĂ€idatud argumendi nime ees. Lisaks, IR-parsingu lihtsustamiseks, on globaalsete entiteetide nimede ees sĂŒmbol @, ja kohalike nimede ees sĂŒmbol % (funktsioon loetakse samuti globaalseks entiteediks).

Üks selle koodi eripĂ€ra, millele tasub tĂ€helepanu pöörata, on see, et Go tĂŒĂŒbi esitlemise otsus int, mis vĂ”ib olla esindatud 32-bitise vĂ”i 64-bitise vÀÀrtusena, olenevalt kompilaatorist ja kompileerimise eesmĂ€rgist, tehakse LLVM IR koodi loomisel. See on ĂŒks paljusid pĂ”hjusi, miks LLVM IR kood ei ole, nagu paljud arvatavad, platvormist sĂ”ltumatu. Sellist koodi, mis on loodud ĂŒhe platvormi jaoks, ei saa lihtsalt vĂ”tta ja kompileerida teisele platvormile (kui mitte lĂ€heneda sellele ĂŒlesande lahendamisele erilise ettevaatusega).

Veel huvitav moment, mida tuleks mĂ€rkida, on see, et tĂŒĂŒp i64 — ei ole signeeritud tĂ€isarv: see on numbrimĂ€rgistuse osas neutraalne. Vastavalt juhisele vĂ”ib see esindada nii signeeritud kui ka mitte-signeeritud arve. Summeerimise operatsiooni puhul ei mĂ€ngi see rolli, seega ei ole siin vahet signeeritud vĂ”i mitte-signeeritud arvude töötlemisel. Tuleb mĂ€rkida, et C keeles pĂ”hjustab signeeritud tĂ€isarvu ĂŒletĂ€itumine mÀÀramatut kĂ€itumist, mistĂ”ttu Clangi esiplaan lisab operatsioonile lipu nsw (no signed wrap), mis nĂ€itab LLVM-le, et see vĂ”ib eeldada, et summade liitmisel ei toimu kunagi ĂŒletĂ€itumist.

See vÔib olla oluline mÔningate optimeerimiste jaoks. NÀiteks kahe i16 vÀÀrtuse liitmine 32-bitises platvormis (32-bitiste registritega) vajab pÀrast liitmist mÀrke laiendamise operatsiooni, et jÀÀda vahemikku i16. Selle tÔttu on sageli efektiivsem tÀisarvute operatsioonide tÀitmine, arvestades masinaregistri suurusi.

See, mis juhtub selle IR-koodiga edasi, ei huvita meid praegu eriti. Kood optimeeritakse (kuid sellise lihtsa nÀite puhul, nagu meie oma, ei optimeerita enam midagi) ja seejÀrel muudetakse see masinkoodiks.

Teine nÀide

JÀrgmine nÀide, millega me tegeleme, on veidi keerulisem. TÀpsemalt öeldes rÀÀgime funktsioonist, mis summeerib tÀisarvude slice'i:

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

See kood muudetakse jÀrgmisesse Go SSA koodi:

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

Siin on juba rohkem nĂ€ha konstruktsioone, mis on iseloomulikud SSA-s vormistatud koodi esitlemisele. Eriti silmatorkav omadus selle koodi juures on see, et siin puuduvad struktureeritud kĂ€sklused arvutuste voogude juhtimiseks. Arvutuste voogude juhtimiseks on siin vaid tingimuslikud ja tingimusteta ĂŒleminekud ning kui pidada seda kĂ€sku voo juhtimise kĂ€suks, siis on see tagasipöördumise kĂ€sk.

Tegelikult vĂ”ib tĂ€hele panna, et programm ei ole plokkidesse jagatud, nagu pereliikmete C-keeltel, kasutadesid kaar- vĂ”i nurksulgusid. See jaguneb siltide jĂ€rgi, mis meenutab assemblerikeeli, ja on esitatud baasplokkidena. SSA-s nimetatakse baasplokkideks pidevaid koodijadasid, mis algavad sildiga ja lĂ”ppevad baasploki lĂ”petamise kĂ€skudega, nĂ€iteks — return ja jump.

Veel ĂŒks huvitav detail selle koodi juures on kĂ€sk phi. See kĂ€sk on ĂŒsna eriline ning selle mĂ”istmiseks vĂ”ib kuluda veidi aega. Pidage meeles, et SSA — on lĂŒhend Static Single Assignment. See on vahepealne kood esitus, mida kompilaatorid kasutavad, kus igale muutujale antakse vÀÀrtus ainult ĂŒks kord. See sobib suurepĂ€raselt lihtsate funktsioonide, nagu meie funktsioon myAdd, nagu on nĂ€idatud eespool, kuid ei sobi keerukamate funktsioonide jaoks — nagu nĂ€iteks selle jaotise kĂ€sitletav funktsioon sum. EelkĂ”ige muudetakse silmuse kĂ€igus muutujad i ja n.

SSA ĂŒletab piirangu, et muutujaid vĂ”ib muuta ainult ĂŒhe korra, kasutades nn phi-kĂ€sku phi (tema nimi on tulnud Kreeka tĂ€hestikust). Nimelt, et SSA-koodi esitamine oleks vĂ”imalik, tuleb kasutada teatud trikke keeltes nagu C. Selle kĂ€su kutsumise tulemus on muutuja praegune vÀÀrtus (i vĂ”i n), samas kui tema parameetriteks on pĂ”hiblokkide loetelu. NĂ€iteks vaatame sellist kĂ€sku:

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

Selle sisu on jĂ€rgmine: kui eelmine pĂ”hiblokk oli blokk entry (sissepÀÀsu), siis t0 on konstant 0, ja kui eelmine pĂ”hiblokk oli for.body, siis tuleb vĂ”tta vÀÀrtus t6 sellest plokist. KĂ”ik see vĂ”ib tunduda ĂŒsna salapĂ€rane, kuid tĂ€nu sellele mehhanismile tagatakse SSA toimimine. Inimese seisukohalt muudab see koodi mĂ”istmise keerulisemaks, kuid fakt, et iga vÀÀrtus mÀÀratakse vaid ĂŒks kord, lihtsustab palju optimeerimisi.

Pange tÀhele, et kui kirjutate enda kompilaatorit, siis ei pea te tavaliselt tegelema selliste asjadega. Isegi Clang ei genereeri kÔiki neid kÀske phi, ta kasutab mehhanismi alloca (see sarnaneb tavapÀraste kohalike muutujatega töötamisega). SeejÀrel, LLVM-i optimeerimise lÀbimise kÀigus, mis on nimetatud mem2reg, kÀsud alloca muudetakse SSA vormi. TinyGo saab siiski sisendi Go SSA-st, mis on mugavalt juba SSA vormi muudetud.

Veel ĂŒks uudsus antud vahepealse koodi fragmendis on see, et juurdepÀÀs sliisi elementidele indeksi jĂ€rgi on esitatud aadressi arvutamise ja saadud nĂ€idiku lahendamise operatsioonina. Siin on nĂ€ha ka konstandi otsene lisamine IR-koodile (nĂ€iteks — 1:int). NĂ€ites funktsiooniga myAdd sarnaseid ei olnud kasutatud. NĂŒĂŒd, kui oleme need omadused lĂ€bi vaadanud, vaatame, milliseks see kood muutub, kui see konverteeritakse LLVM IR formaati:

define 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
}

Siin, nagu ennegi, saame nĂ€ha sama struktuuri, mis sisaldab muid sĂŒntaktilisi konstruktsioone. NĂ€iteks on funktsiooni kutsungites phi vÀÀrtused ja silbid omavahel vahetanud. Siiski on siin ka midagi, millele tasub erilist tĂ€helepanu pöörata.

Esiteks, siin on tĂ€iesti erinev funktsiooni signatuur. LLVM ei toeta lĂ”ike, mistĂ”ttu optimeerimise kĂ€igus jagas TinyGo kompilaator, mis genereeris selle vahe- koodi, nende andmestruktuuride kirjelduse osadeks. Ta oleks vĂ”inud esitada kolm lĂ”ike elementi (ptr, len ja cap) struktuuri (struct), kuid nende esitlemine kolmeks eraldi ĂŒksuseks vĂ”imaldab teatud optimeerimisi. Teised kompilaatorid vĂ”ivad viilutust esitada ka muul viisil, sĂ”ltuvalt sihtplatvormi funktsiooni kutsumise kokkulepetest.

Veel ĂŒhe huvitava omadusena on selle koodi kasutamine getelementptr (sageli lĂŒhendatakse GEP-iks).

See kÀsk töötab osutitega ja seda kasutatakse viilu elemendi osutaja saamiseks. NÀiteks, vaatame, kuidas see sobitub jÀrgmise C keeles kirjutatud koodiga:

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

VÔi jÀrgmise, mis on sellele ekvivalentne:

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

Peamine asi siin on see, et kĂ€sk getelementptr ei teosta dereferentseerimise operatsioone. See arvutab vaid uue osutaja, tuginedes olemasolevale. Seda vĂ”ib tĂ”lgendada kĂ€skudena mul ja add riistvara tasemel. GEP kĂ€su kohta vĂ”ib lugeda rohkem ĂŒksikasju. siit.

Veel ĂŒks huvitav omadus sellest vahekoodeksist on kĂ€sku icmp. See on ĂŒldine juhend, mida kasutatakse tĂ€isarvude vĂ”rdlemise teostamiseks. Selle kĂ€su tĂ€itmise tulemus on alati tĂŒĂŒpi vÀÀrtus i1 — loogiline vÀÀrtus. Antud juhul toimub vĂ”rdlemine mĂ€rksĂ”na slt (signed less than) abil, kuna vĂ”rreldavad on kaks arvu, mis on varem esitatud tĂŒĂŒpi int. Kui me oleksime vĂ”rrelnud kahte mitte-mĂ€rgiga tĂ€isarvu, oleksime kasutanud icmp, samas kui vĂ”rdlemisel oleks mĂ€rksĂ”na ult. TĂ”husate liikuvate punktide vĂ”rdlemiseks kasutatakse muud kĂ€sku, fcmp, mis töötab sarnaselt.

KokkuvÔte

Arvan, et selles materjalis olen kĂ€sitlenud LLVM IR-i kĂ”ige olulisemaid omadusi. Loomulikult on siin veel palju muud. EelkĂ”ige vĂ”ivad vahepealse koodi esitus sisaldada mitmeid annotatsioone, mis vĂ”imaldavad optimeerimise kĂ€ikudel arvesse vĂ”tta teatud koodi omadusi, mis on kompilaatorile teada ja mida ei saa muul viisil IR-is vĂ€ljendada. NĂ€iteks on see lipp inbounds GEP-kĂ€skluses, vĂ”i lipud nsw ja nuw, mis vĂ”ivad olla lisatud kĂ€sule add. Sama kehtib ka mĂ€rksĂ”na private, nĂ€idates optimeerijale, et tĂ€histatud funktsiooni ei kutsuta kĂ€esoleva kompileerimise ĂŒhikust vĂ€lja. See vĂ”imaldab teha mitmeid huvitavaid vaheprotseduurilisi optimeerimisi, nagu kasutamata argumentide eemaldamine.

LLVM kohta saab rohkem lugeda dokumentatsioonis, mille poole te sageli pöördute, arendades oma kompilaatorit, mis pÔhineb LLVM. Siin on juhend, kus kÀsitletakse kompilaatori arendust vÀga lihtsa keele jaoks. Need kaks allikat on kasulikud oma kompilaatori loomisel.

Lugupidamisega lugejad! Kas kasutate LLVM?

LLVM Go vaatenurgast

Allikas: habr.com

Osta usaldusvÀÀrne veebihosting DDoS kaitsega, VPS VDS serverid đŸ”„ Osta usaldusvÀÀrne veebihosting DDoS kaitsega, VPS VDS serverid | ProHoster