LLVM vanuit 'n Go-perspektief

Die ontwikkeling van 'n samesteller is 'n baie moeilike taak. Maar gelukkig, met die ontwikkeling van projekte soos LLVM, is die oplossing vir hierdie probleem baie vereenvoudig, wat selfs 'n enkele programmeerder in staat stel om 'n nuwe taal te skep wat na aan C in prestasie is. Werk met LLVM word bemoeilik deur die feit dat hierdie stelsel word verteenwoordig deur 'n groot hoeveelheid kode, toegerus met min dokumentasie. Om hierdie tekortkoming te probeer regstel, gaan die skrywer van die materiaal, waarvan die vertaling ons vandag publiseer, voorbeelde demonstreer van kode wat in Go geskryf is en wys hoe dit eers vertaal word in Gaan SSA, en dan in LLVM IR met behulp van die samesteller tinyGO. Die Go SSA en LLVM IR-kode is effens gewysig om dinge te verwyder wat nie relevant is vir die verduidelikings wat hier gegee word nie, om die verduidelikings meer verstaanbaar te maak.

LLVM vanuit 'n Go-perspektief

Eerste voorbeeld

Die eerste funksie waarna ek hier gaan kyk, is 'n eenvoudige meganisme om getalle by te tel:

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

Hierdie funksie is baie eenvoudig, en miskien kan niks eenvoudiger wees nie. Dit vertaal in die volgende Go SSA-kode:

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

Met hierdie aansig word die funksie se datatipe wenke aan die regterkant geplaas en kan dit in die meeste gevalle geïgnoreer word.

Hierdie klein voorbeeld laat jou reeds die essensie van een aspek van SSA sien. By die omskakeling van kode in SSA-vorm word elke uitdrukking naamlik opgebreek in die mees elementêre dele waaruit dit saamgestel is. In ons geval, die bevel return a + b, in werklikheid, verteenwoordig twee bewerkings: om twee getalle by te tel en die resultaat terug te gee.

Daarbenewens kan u hier die basiese blokke van die program sien; in hierdie kode is daar slegs een blok - die inskrywingsblok. Ons sal hieronder meer oor blokke praat.

Die Go SSA-kode word maklik omgeskakel na LLVM IR:

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

Wat jy kan oplet, is dat alhoewel verskillende sintaktiese strukture hier gebruik word, die struktuur van die funksie basies onveranderd is. Die LLVM IR-kode is 'n bietjie sterker as die Go SSA-kode, soortgelyk aan C. Hier, in die funksieverklaring, is daar eers 'n beskrywing van die datatipe wat dit terugstuur, die argumenttipe word voor die argumentnaam aangedui. Daarbenewens, om IR-ontleding te vereenvoudig, word die name van globale entiteite voorafgegaan deur die simbool @, en voor plaaslike name is daar 'n simbool % ('n funksie word ook as 'n globale entiteit beskou).

Een ding om op te let oor hierdie kode is dat Go se tipe verteenwoordiging besluit int, wat as 'n 32-bis of 64-bis waarde voorgestel kan word, afhangende van die samesteller en die teiken van die samestelling, word aanvaar wanneer die LLVM IR-kode gegenereer word. Dit is een van die vele redes waarom LLVM IR-kode nie, soos baie mense dink, platformonafhanklik is nie. Sodanige kode, geskep vir een platform, kan nie eenvoudig geneem en saamgestel word vir 'n ander platform nie (tensy jy geskik is om hierdie probleem op te los met uiterste sorg).

Nog 'n interessante punt wat opmerklik is, is dat die tipe i64 is nie 'n getekende heelgetal nie: dit is neutraal in terme van die voorstelling van die teken van die getal. Afhangende van die instruksie, kan dit beide getekende en ongetekende nommers verteenwoordig. In die geval van die voorstelling van die optelbewerking maak dit nie saak nie, so daar is geen verskil in die werk met getekende of ongetekende nommers nie. Hier wil ek daarop let dat die oorloop van 'n getekende heelgetalveranderlike in die C-taal tot ongedefinieerde gedrag lei, so die Clang-frontend voeg 'n vlag by die bewerking nsw (geen getekende omslag), wat aan LLVM sê dat hy kan aanvaar dat byvoeging nooit oorloop nie.

Dit kan belangrik wees vir sommige optimaliserings. Byvoorbeeld, die byvoeging van twee waardes i16 op 'n 32-bis-platform (met 32-bis-registers) vereis, na toevoeging, 'n tekenuitbreidingsbewerking om binne bereik te bly i16. As gevolg hiervan is dit dikwels meer doeltreffend om heelgetalbewerkings uit te voer gebaseer op masjienregistergroottes.

Wat volgende met hierdie IR-kode gebeur, is nie nou van besondere belang vir ons nie. Die kode word geoptimaliseer (maar in die geval van 'n eenvoudige voorbeeld soos ons s'n, word niks geoptimaliseer nie) en dan omgeskakel na masjienkode.

Tweede voorbeeld

Die volgende voorbeeld waarna ons sal kyk, sal 'n bietjie meer ingewikkeld wees. Ons praat naamlik van 'n funksie wat 'n stuk heelgetalle optel:

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

Hierdie kode word omgeskakel na die volgende Go SSA-kode:

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

Hier kan jy reeds meer konstruksies sien wat tipies is vir die verteenwoordiging van kode in die SSA-vorm. Miskien is die mees voor die hand liggende kenmerk van hierdie kode die feit dat daar geen gestruktureerde vloeibeheeropdragte is nie. Om die vloei van berekeninge te beheer, is daar slegs voorwaardelike en onvoorwaardelike spronge, en as ons hierdie opdrag beskou as 'n opdrag om die vloei te beheer, 'n terugkeeropdrag.

Trouens, hier kan jy aandag gee aan die feit dat die program nie in blokke verdeel word deur krulhakies (soos in die C-familie van tale) te gebruik nie. Dit word deur byskrifte gedeel, wat aan samestellingstale herinner, en in die vorm van basiese blokke aangebied. In SSA word basiese blokke gedefinieer as aaneenlopende kodereekse wat met 'n etiket begin en eindig met basiese blokvoltooiinstruksies, soos - return и jump.

Nog 'n interessante detail van hierdie kode word verteenwoordig deur die instruksie phi. Die instruksies is redelik ongewoon en kan 'n rukkie neem om te verstaan. onthou dat SSA is kort vir Static Single Assignment. Dit is 'n intermediêre voorstelling van die kode wat deur samestellers gebruik word, waarin elke veranderlike slegs een keer 'n waarde toegeken word. Dit is wonderlik om eenvoudige funksies soos ons funksie uit te druk myAddhierbo getoon, maar is nie geskik vir meer komplekse funksies soos die funksie wat in hierdie afdeling bespreek word nie sum. Veranderlikes verander veral tydens die uitvoering van die lus i и n.

SSA omseil die beperking op die toeken van veranderlike waardes een keer met behulp van 'n sogenaamde instruksie phi (sy naam is uit die Griekse alfabet geneem). Die feit is dat om die SSA-voorstelling van kode vir tale soos C te genereer, moet jy 'n paar truuks gebruik. Die resultaat van die oproep van hierdie instruksie is die huidige waarde van die veranderlike (i of n), en 'n lys van basiese blokke word as sy parameters gebruik. Oorweeg byvoorbeeld hierdie instruksie:

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

Die betekenis daarvan is soos volg: as die vorige basiese blok 'n blok was entry (invoer), dan t0 is 'n konstante 0, en as die vorige basiese blok was for.body, dan moet jy die waarde neem t6 uit hierdie blok. Dit mag alles nogal geheimsinnig lyk, maar hierdie meganisme is wat die SSA laat werk. Vanuit 'n menslike perspektief maak dit alles die kode moeilik om te verstaan, maar die feit dat elke waarde slegs een keer toegeken word, maak baie optimaliserings baie makliker.

Let daarop dat as jy jou eie samesteller skryf, jy gewoonlik nie hierdie soort goed hoef te hanteer nie. Selfs Clang genereer nie al hierdie instruksies nie phi, dit gebruik 'n meganisme alloca (dit lyk soos werk met gewone plaaslike veranderlikes). Dan, wanneer 'n LLVM-optimeringspas uitgevoer word, genoem mem2reg, instruksies alloca omgeskakel na SSA-vorm. TinyGo ontvang egter insette van Go SSA, wat, gerieflik, reeds na SSA-vorm omgeskakel is.

Nog 'n innovasie van die fragment van intermediêre kode wat oorweeg word, is dat toegang tot snyelemente volgens indeks voorgestel word in die vorm van 'n bewerking om die adres te bereken en 'n bewerking om die gevolglike wyser te herken. Hier kan jy die direkte toevoeging van konstantes by die IR-kode sien (byvoorbeeld - 1:int). In die voorbeeld met die funksie myAdd dit is nie gebruik nie. Noudat ons daardie kenmerke uit die weg geruim het, kom ons kyk na wat hierdie kode word wanneer dit na LLVM IR-vorm omgeskakel word:

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
}

Hier, soos voorheen, kan ons dieselfde struktuur sien, wat ander sintaktiese strukture insluit. Byvoorbeeld, in oproepe phi waardes en etikette omgeruil. Hier is egter iets wat die moeite werd is om spesiale aandag aan te gee.

Om mee te begin, hier kan jy 'n heeltemal ander funksie handtekening sien. LLVM ondersteun nie skywe nie, en as gevolg daarvan, as 'n optimalisering, het die TinyGo-samesteller wat hierdie tussenkode gegenereer het, die beskrywing van hierdie datastruktuur in dele verdeel. Dit kan drie snyelemente verteenwoordig (ptr, len и cap) as 'n struktuur (struktuur), maar om hulle as drie afsonderlike entiteite voor te stel maak voorsiening vir 'n paar optimaliserings. Ander samestellers kan die sny op ander maniere verteenwoordig, afhangende van die oproepkonvensies van die teikenplatform se funksies.

Nog 'n interessante kenmerk van hierdie kode is die gebruik van die instruksie getelementptr (dikwels afgekort as GEP).

Hierdie instruksie werk met wysers en word gebruik om 'n wyser na 'n snyelement te verkry. Kom ons vergelyk dit byvoorbeeld met die volgende kode wat in C geskryf is:

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

Of met die volgende ekwivalent hieraan:

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

Die belangrikste ding hier is dat die instruksies getelementptr voer nie afleidingsbewerkings uit nie. Dit bereken net 'n nuwe wyser gebaseer op die bestaande een. Dit kan as instruksies geneem word mul и add op hardeware vlak. Jy kan meer lees oor die GEP-instruksies hier.

Nog 'n interessante kenmerk van hierdie intermediêre kode is die gebruik van die instruksie icmp. Dit is 'n algemene instruksie wat gebruik word om heelgetalvergelykings te implementeer. Die resultaat van hierdie instruksie is altyd 'n waarde van tipe i1 - logiese waarde. In hierdie geval word 'n vergelyking gemaak deur die sleutelwoord te gebruik slt (minder geteken as), aangesien ons twee getalle vergelyk wat voorheen deur die tipe verteenwoordig is int. As ons twee ongetekende heelgetalle vergelyk, dan sou ons gebruik icmp, en die sleutelwoord wat in die vergelyking gebruik word, sou wees ult. Om swaaipuntgetalle te vergelyk, word 'n ander instruksie gebruik, fcmp, wat op 'n soortgelyke manier werk.

Resultate van

Ek glo dat ek in hierdie materiaal die belangrikste kenmerke van LLVM IR gedek het. Hier is natuurlik baie meer. In die besonder kan die tussenvoorstelling van die kode baie aantekeninge bevat wat optimaliseringspasse toelaat om sekere kenmerke van die kode wat aan die samesteller bekend is, in ag te neem wat nie andersins in IR uitgedruk kan word nie. Dit is byvoorbeeld 'n vlag inbounds GEP-instruksies, of vlae nsw и nuw, wat by die instruksies gevoeg kan word add. Dieselfde geld vir die sleutelwoord private, wat vir die optimeerder aandui dat die funksie wat dit nasien nie van buite die huidige samestellingseenheid verwys sal word nie. Dit maak voorsiening vir baie interessante interprosedurele optimaliserings soos om ongebruikte argumente uit te skakel.

Jy kan meer lees oor LLVM in dokumentasie, waarna jy gereeld sal verwys wanneer jy jou eie LLVM-gebaseerde samesteller ontwikkel. Hier leierskap, wat kyk na die ontwikkeling van 'n samesteller vir 'n baie eenvoudige taal. Beide hierdie inligtingsbronne sal vir jou nuttig wees wanneer jy jou eie samesteller skep.

Beste lesers! Gebruik jy LLVM?

LLVM vanuit 'n Go-perspektief

Bron: will.com

Voeg 'n opmerking