LLVM Go-näkökulmasta

Kääntäjän kehittäminen on erittäin vaikea tehtävä. Mutta onneksi LLVM:n kaltaisten projektien kehityksen myötä tämän ongelman ratkaisu yksinkertaistuu huomattavasti, minkä ansiosta jopa yksi ohjelmoija voi luoda uuden kielen, jonka suorituskyky on lähellä C:tä. LLVM:n kanssa työskentelyä vaikeuttaa se, että tämä Järjestelmää edustaa valtava määrä koodia, joka on varustettu vähäisellä dokumentaatiolla. Tämän puutteen korjaamiseksi materiaalin, jonka käännöksen julkaisemme tänään, kirjoittaja aikoo näyttää esimerkkejä Go-kielellä kirjoitetusta koodista ja kuinka ne käännetään ensimmäisen kerran Mene SSA, ja sitten LLVM IR:ssä kääntäjän avulla tinyGO. Go SSA- ja LLVM IR -koodia on hieman muokattu poistamaan asiat, jotka eivät liity tässä annettuihin selityksiin, jotta selitykset olisivat ymmärrettävämpiä.

LLVM Go-näkökulmasta

Ensimmäinen esimerkki

Ensimmäinen toiminto, jota aion tarkastella tässä, on yksinkertainen mekanismi numeroiden lisäämiseksi:

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

Tämä toiminto on hyvin yksinkertainen, ja ehkä mikään ei voisi olla yksinkertaisempaa. Se muunnetaan seuraavaksi Go SSA -koodiksi:

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

Tässä näkymässä tietotyyppivihjeet sijoitetaan oikealle, ja ne voidaan jättää huomiotta useimmissa tapauksissa.

Tämän pienen esimerkin avulla voit jo nähdä SSA:n yhden näkökohdan olemuksen. Nimittäin, kun koodi muunnetaan SSA-muotoon, jokainen lauseke jaetaan alkeellisimpiin osiin, joista se koostuu. Meidän tapauksessamme komento return a + bItse asiassa edustaa kahta operaatiota: kahden luvun lisäämistä ja tuloksen palauttamista.

Lisäksi täällä näet ohjelman peruslohkot; tässä koodissa on vain yksi lohko - syöttölohko. Puhumme lisää lohkoista alla.

Go SSA -koodi muuntaa helposti LLVM IR:ksi:

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

Voit huomata, että vaikka tässä käytetään erilaisia ​​syntaktisia rakenteita, funktion rakenne on periaatteessa muuttumaton. LLVM IR -koodi on hieman vahvempi kuin Go SSA -koodi, samanlainen kuin C. Tässä funktion määrittelyssä on ensin kuvaus sen palauttamasta tietotyypistä, argumentin tyyppi ilmoitetaan ennen argumentin nimeä. Lisäksi IR-jäsentämisen yksinkertaistamiseksi globaalien entiteettien nimiä edeltää symboli @, ja ennen paikallisia nimiä on symboli % (funktiota pidetään myös globaalina kokonaisuutena).

Yksi asia, joka on huomioitava tässä koodissa, on Go:n tyyppiesityspäätös int, joka voidaan esittää 32- tai 64-bittisenä arvona kääntäjästä ja käännöksen kohteesta riippuen, hyväksytään, kun LLVM luo IR-koodin. Tämä on yksi monista syistä, miksi LLVM IR -koodi ei ole alustariippumaton, kuten monet ihmiset ajattelevat. Tällaista yhdelle alustalle luotua koodia ei voi yksinkertaisesti ottaa ja kääntää toiselle alustalle (ellet ole sopiva ratkaisemaan tätä ongelmaa äärimmäisellä huolellisuudella).

Toinen mielenkiintoinen huomionarvoinen seikka on, että tyyppi i64 ei ole etumerkillinen kokonaisluku: se on neutraali luvun etumerkin esittämisen kannalta. Ohjeesta riippuen se voi edustaa sekä etumerkillisiä että etumerkittömiä numeroita. Summausoperaation esityksen tapauksessa tällä ei ole merkitystä, joten etumerkittyjen tai etumerkittömien numeroiden kanssa työskentelyssä ei ole eroa. Tässä haluaisin huomauttaa, että C-kielessä etumerkillisen kokonaislukumuuttujan ylivuoto johtaa määrittelemättömään toimintaan, joten Clang-käyttöliittymä lisää toimintoon lipun nsw (ei allekirjoitettua rivitystä), mikä kertoo LLVM:lle, että se voi olettaa, että lisäys ei koskaan vuoda yli.

Tämä voi olla tärkeää joidenkin optimointien kannalta. Esimerkiksi kahden arvon lisääminen i16 32-bittisellä alustalla (32-bittisillä rekistereillä) vaatii lisäyksen jälkeen merkkilaajennusoperaation pysyäkseen alueella i16. Tämän vuoksi on usein tehokkaampaa suorittaa kokonaislukuoperaatioita konerekisterin kokojen perusteella.

Se, mitä tämän IR-koodin kanssa tapahtuu seuraavaksi, ei kiinnosta meitä nyt. Koodi optimoidaan (mutta meidän kaltaisessa yksinkertaisessa esimerkissä mitään ei optimoida) ja muunnetaan sitten konekoodiksi.

Toinen esimerkki

Seuraava esimerkki, jota tarkastelemme, on hieman monimutkaisempi. Puhumme nimittäin funktiosta, joka summaa osan kokonaislukuja:

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

Tämä koodi muuntaa seuraavaksi Go SSA -koodiksi:

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

Täältä näet jo enemmän SSA-muodossa koodin esittämiselle tyypillisiä konstruktioita. Ehkä tämän koodin ilmeisin ominaisuus on se, että siinä ei ole strukturoituja vuonohjauskomentoja. Laskelmien kulun ohjaamiseksi on olemassa vain ehdollisia ja ehdottomia hyppyjä, ja jos katsomme tämän komennon kulun ohjaamiseksi, paluukomento.

Itse asiassa tässä voit kiinnittää huomiota siihen, että ohjelmaa ei ole jaettu lohkoihin kiharasulkeilla (kuten C-kieliperheessä). Se on jaettu kokoonpanokieliä muistuttavilla etiketeillä ja esitetään peruslohkoina. SSA:ssa peruslohkot määritellään peräkkäisiksi koodisarjoiksi, jotka alkavat tunnisteella ja päättyvät peruslohkon täydennysohjeisiin, kuten - return и jump.

Toinen tämän koodin mielenkiintoinen yksityiskohta on ohje phi. Ohjeet ovat melko epätavallisia ja niiden ymmärtäminen voi kestää jonkin aikaa. muista se SSA on lyhenne sanoista Static Single Assignment. Tämä on kääntäjien käyttämän koodin väliesitys, jossa jokaiselle muuttujalle annetaan arvo vain kerran. Tämä sopii mainiosti yksinkertaisten funktioiden, kuten funktiomme, ilmaisemiseen myAddkuvassa yllä, mutta se ei sovellu monimutkaisempiin toimintoihin, kuten tässä osiossa käsiteltyyn funktioon sum. Erityisesti muuttujat muuttuvat silmukan suorittamisen aikana i и n.

SSA ohittaa rajoituksen, joka koskee muuttujien arvojen määrittämistä kerran käyttämällä ns. käskyä phi (sen nimi on otettu kreikkalaisista aakkosista). Tosiasia on, että jotta koodin SSA-esitys voidaan luoda C:n kaltaisille kielille, sinun on turvauduttava joihinkin temppuihin. Tämän käskyn kutsumisen tulos on muuttujan (i tai n), ja sen parametreina käytetään luetteloa peruslohkoista. Harkitse esimerkiksi tätä ohjetta:

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

Sen merkitys on seuraava: jos edellinen peruslohko oli lohko entry (syöttö), sitten t0 on vakio 0, ja jos edellinen peruslohko oli for.body, sinun on otettava arvo t6 tästä lohkosta. Tämä kaikki saattaa tuntua melko salaperäiseltä, mutta tämä mekanismi saa SSA:n toimimaan. Ihmisen näkökulmasta tämä kaikki tekee koodista vaikeasti ymmärrettävän, mutta se tosiasia, että jokainen arvo annetaan vain kerran, tekee monista optimoinneista paljon helpompaa.

Huomaa, että jos kirjoitat oman kääntäjän, sinun ei yleensä tarvitse käsitellä tällaisia ​​​​juttuja. Jopa Clang ei luo kaikkia näitä ohjeita phi, se käyttää mekanismia alloca (se muistuttaa tavallisten paikallisten muuttujien kanssa työskentelemistä). Sitten, kun suoritetaan LLVM-optimointipassia, kutsutaan muisti2reg, ohjeet alloca muunnetaan SSA-lomakkeeksi. TinyGo saa kuitenkin syötteen Go SSA:lta, joka on kätevästi jo muutettu SSA-muotoon.

Toinen tarkasteltavana olevan välikoodin fragmentin innovaatio on se, että pääsy viipaleelementteihin indeksin avulla esitetään osoitteen laskentaoperaationa ja tuloksena olevan osoittimen viittauksen purkamisena. Täältä näet vakioiden suoran lisäyksen IR-koodiin (esim. 1:int). Esimerkissä funktiolla myAdd tätä ei ole käytetty. Nyt kun olemme saaneet nämä ominaisuudet pois tieltä, katsotaanpa, mitä tästä koodista tulee, kun se muunnetaan LLVM IR -lomakkeeksi:

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
}

Tässä, kuten ennenkin, voimme nähdä saman rakenteen, joka sisältää muita syntaktisia rakenteita. Esimerkiksi puheluissa phi arvot ja tarrat vaihdettu. Tässä on kuitenkin jotain, johon kannattaa kiinnittää erityistä huomiota.

Aluksi täällä voit nähdä täysin erilaisen funktion allekirjoituksen. LLVM ei tue viipaleita, ja tämän seurauksena tämän välikoodin luonut TinyGo-kääntäjä jakoi tämän tietorakenteen kuvauksen osiin optimoinnin vuoksi. Se voisi edustaa kolmea siivuelementtiä (ptr, len и cap) rakenteena (struct), mutta niiden esittäminen kolmena erillisenä kokonaisuutena mahdollistaa joitain optimointeja. Muut kääntäjät voivat edustaa viipaletta muilla tavoilla riippuen kohdealustan toimintojen kutsukäytännöistä.

Toinen tämän koodin mielenkiintoinen ominaisuus on ohjeen käyttö getelementptr (usein lyhennettynä GEP).

Tämä ohje toimii osoittimien kanssa ja sitä käytetään osoittimen saamiseksi viipaleen elementtiin. Verrataan sitä esimerkiksi seuraavaan C-kielellä kirjoitettuun koodiin:

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

Tai seuraavalla tätä vastaavalla:

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

Tärkeintä tässä on, että ohjeet getelementptr ei suorita viittauksen poistotoimintoja. Se vain laskee uuden osoittimen olemassa olevan perusteella. Se voidaan ottaa ohjeeksi mul и add laitteistotasolla. Voit lukea lisää GEP-ohjeista täällä.

Toinen mielenkiintoinen ominaisuus tässä välikoodissa on ohjeen käyttö icmp. Tämä on yleiskäyttöinen ohje, jota käytetään kokonaislukuvertailujen toteuttamiseen. Tämän käskyn tulos on aina tyypin arvo i1 - looginen arvo. Tässä tapauksessa vertailu tehdään avainsanalla slt (merkitty vähemmän kuin), koska vertaamme kahta numeroa, joita tyyppi edustaa aiemmin int. Jos vertailisimme kahta etumerkitöntä kokonaislukua, käyttäisimme icmp, ja vertailussa käytetty avainsana olisi ult. Liukulukujen vertailuun käytetään toista ohjetta, fcmp, joka toimii samalla tavalla.

Tulokset

Uskon, että olen käsitellyt tässä materiaalissa LLVM IR:n tärkeimmät ominaisuudet. Tietysti täällä on paljon muutakin. Erityisesti koodin väliesitys voi sisältää monia huomautuksia, jotka sallivat optimointiprosesseissa huomioiden tietyt kääntäjän tuntemat koodin ominaisuudet, joita ei muuten voida ilmaista IR:llä. Tämä on esimerkiksi lippu inbounds GEP-ohjeet tai liput nsw и nuw, jonka voi lisätä ohjeisiin add. Sama koskee avainsanaa private, joka osoittaa optimoijalle, että sen merkitsemään funktioon ei viitata nykyisen käännösyksikön ulkopuolelta. Tämä mahdollistaa monia mielenkiintoisia prosessien välisiä optimointeja, kuten käyttämättömien argumenttien poistamisen.

Voit lukea lisää LLVM:stä osoitteesta dokumentointi, johon viittaat usein, kun kehität omaa LLVM-pohjaista kääntäjää. Tässä руководство, jossa tarkastellaan kääntäjän kehittämistä hyvin yksinkertaiselle kielelle. Molemmat näistä tietolähteistä ovat hyödyllisiä sinulle, kun luot omaa kääntäjää.

Hyvä lukijat! Käytätkö LLVM:ää?

LLVM Go-näkökulmasta

Lähde: will.com

Lisää kommentti