LLVM a Go szempontjából

A fordítóprogram fejlesztése nagyon nehéz feladat. De szerencsére, olyan projektek fejlődésével, mint az LLVM, ez a feladat jelentősen megkönnyítődik, lehetővé téve még egy egyedülálló programozó számára is, hogy új nyelvet hozzon létre, amely teljesítményben közel áll a C-hez. Az LLVM-mel való munka nehezedik, mivel ez a rendszer hatalmas mennyiségű kódot tartalmaz, amelynek kis dokumentációja van. Az anyag szerzője, akinek a fordítása ma publikálásra kerül, azt mutatja be, hogy a Go nyelven írt kód példáit, hogyan fordítják le először Go SSA, majd LLVM IR-be, a TinyGOfordító használatával. A Go SSA és LLVM IR kódot kissé módosították, eltávolítva azokat a részeket, amelyek nem kapcsolódnak a bemutatott magyarázatokhoz, hogy ezek a magyarázatok érthetőbbek legyenek.

LLVM a Go szempontjából

Első példa

Az első funkció, amelyet itt meg fogok vizsgálni, egy egyszerű mechanizmusként szolgál a számok összeadására:

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

Ez a funkció nagyon egyszerű, és valószínűleg nincs is ennél egyszerűbb. A következő Go SSA kódra fordul:

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

Ilyen megjelenítés mellett az adattípusokra vonatkozó nyújtások jobbra kerülnek, amelyeket a legtöbb esetben figyelmen kívül lehet hagyni.

Ez a kis példa már lehetővé teszi a SSA egyik aspektusának a megértését. Nevezetesen, a kód SSA formátumra alakításakor minden kifejezést a legkisebb elemi részekre bonthatunk, amelyekből áll. Az esetünkben a parancs return a + b, valójában két műveletet képvisel: két szám összeadása és az eredmény visszaadása.

Ezen kívül itt alapblokkok is láthatók, a megadott kódban mindössze egy blokk található — a belépési (entry) blokk. A blokkokról később részletesebben beszélünk.

A Go SSA kód könnyedén átalakítható LLVM IR-re:

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

Megjegyezhető, hogy bár itt más szintaktikai konstrukciók kerülnek használatra, a függvény struktúrája alapvetően nem változott. Az LLVM IR kód kissé erősebb, mint a Go SSA kód, hasonlít a C-re. Itt, a függvény deklarációjában először a visszatérési adat típusa szerepel, az argumentum típusa pedig az argumentum neve előtt található. Ezen kívül, az IR-parsing egyszerűsítése érdekében a globális entitások neve előtt szimbólum áll @, míg a lokális nevek előtt szimbólum található % (a függőség is globális entitásnak számít).

A kód egyik tulajdonsága, amire figyelmet kell fordítani, az az, hogy a Go típusának reprezentálásáról egész, amely 32 bites vagy 64 bites értékként is megjeleníthető, a fordítótól és a fordítás céljától függően, az LLVM IR kód létrehozásakor döntenek. Ez az egyik sok oka annak, hogy az LLVM IR kód nem platformfüggetlen, mint ahogy sokan gondolják. Egyik platformra készült kódot nem lehet egyszerűen átvenni és másik platformra fordítani (kivéve, ha a feladat megoldásához különös óvatossággal közelítünk)).

Egy másik érdekes dolog, amit érdemes megemlíteni, az, hogy a típus i64 nem egy aláírt egész szám: semleges a szám jelének reprezentálásával kapcsolatban. Az utasítástól függően reprezentálhat aláírt és aláíratlan számokat is. Az összeadás műveletének reprezentálásánál ez nem játszik szerepet, ezért itt nincs különbség az aláírt és az aláíratlan számok kezelésében. Itt meg kell említeni, hogy a C nyelvben az aláírt egész változó túlcsordulása meghatározatlan viselkedést eredményez, ezért a Clang frontend hozzáad egy zászlót az összeadáshoz, nsw (no signed wrap), ami arra utal az LLVM számára, hogy feltételezheti, hogy az összeadás során soha nem történik túlcsordulás.

Ez fontos lehet néhány optimalizálás szempontjából. Például, ha két i16 érték összeadására van szükségünk 32 bites platformon (32 bites regiszterekkel), akkor az összeadás végrehajtása után szükség van egy aláírás bővítési műveletre, hogy a tartományban maradhasson i16. Emiatt gyakran hatékonyabb a számítástechnikai műveleteket a gépi regiszter méreteihez igazítva végrehajtani.

Ami történik ezzel az IR-kóddal, az jelenleg nem különösebben érdekes számunkra. A kód optimalizálódik (de egy ilyen egyszerű példa esetében, mint a miénk, már nem optimalizálódik), majd gépi kóddá alakul.

Második példa

A következő példa, amit megvizsgálunk, egy kicsit bonyolultabb lesz. Nevezetesen egy olyan függvényről van szó, amely összegzi egy egész számok listáját:

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

Ez a kód a következő Go SSA-kódra alakul:

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

Itt már látható több, a kód SSA-formában való megjelenítésére jellemző szerkezet. Valószínűleg a legnyilvánvalóbb jellemzője ennek a kódnak, hogy itt nincsenek strukturált vezérlési utasítások a számítások folyamatának irányítására. A számítások folyamatának irányítására itt csak feltételes és feltétel nélküli ugrások érhetők el, és ha ezt a parancsot arra irányítónak tekintjük, akkor ez az utasítás a visszatérés.

Valójában itt észlelhető, hogy a program nincs blokkokra osztva kapcsos zárójelek használatával (mint a C család nyelveiben). Címkékkel van megosztva, ami az assembly nyelvekre emlékeztet, és alapblokkokként van megjelenítve. Az SSA alapblokkok olyan folytonos kódpéldák, amelyek címkével kezdődnek és a blokk befejező utasításaival zárulnak, például — return és jump.

Még egy érdekes részlet ebben a kódban a következő utasítás phi. Ez az utasítás meglehetősen szokatlan, és lehet, hogy egy kis időbe telik megérteni. Ne feledje, hogy SSA — ez a Static Single Assignment (SSA) rövidítése. Ez egy köztes kód reprezentáció, amelyet a fordítók használnak, és amelyben minden változó értékét csak egyszer adják meg. Ez kiválóan alkalmas egyszerű függvények kifejezésére, mint például a mi példánkban szereplő függvény. myAdd, amelyet fentebb bemutattunk, de nem megfelelő bonyolultabb függvényekhez — mint például a jelen szakaszban tárgyalt függvényhez. összeg. Különösen a ciklus végrehajtása során változnak a változók. i és n.

Az SSA megkerüli a változók egyszeri értékadásának korlátozását a úgynevezett phi utasítással. phi (a neve a görög ábécéből származik). A lényeg, hogy ahhoz, hogy SSA-kód reprezentációt lehessen létrehozni olyan nyelvekhez, mint a C, néhány trükköt kell alkalmazni. Ennek az utasításnak a hívása a változó aktuális értékét adja vissza (i vagy n) és a paraméterei egy alapblokk listája. Például vegyük ezt az utasítást:

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

A jelentése a következő: ha az előző alap blokk az entry (belépési), akkor t0 — ez egy konstans 0, ha pedig az előző alap blokk a for.body, akkor az értéket kell venni a t6 blokkból. Ez egészen rejtélyesnek tűnhet, de ennek a mechanizmusnak köszönhetően működik az SSA. Emberi szemszögből nézve mindez megnehezíti a kód megértését, de az a tény, hogy minden érték csak egyszer van hozzárendelve, jelentősen leegyszerűsíti a sok optimalizálást.

Vegyük figyelembe, hogy ha saját fordítót írsz, általában nem kell ilyen dolgokkal foglalkoznod. Még a Clang sem generálja ezeket az utasításokat phi, ő az alloca mechanizmust alkalmazza (ez emlékeztet a hagyományos helyi változókkal való munkára). Aztán, amikor az LLVM optimalizáló átmenetét végrehajtják, amit mem2reg, az utasításokat átalakítják SSA formába. A TinyGo azonban a Go SSA-ból kap bemenetet, ami kényelmes, mivel ez már SSA formára van átalakítva. alloca A tárgyalt köztes kód töredékének egy másik újítása az, hogy a slice elemeihez való hozzáférés index alapján címképzési és dereferálási műveletként van ábrázolva. Itt közvetlen értékadásokat is láthatunk az IR-kódban (például —

1:int ). A függvénypélda esetében.). В примере с функцией myAdd Ilyen nem volt használatban. Most, hogy megértettük ezeket a sajátosságokat, nézzük meg, hogyan alakul át ez a kód, amikor LLVM IR formára konvertáljuk:

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
}

Itt, ahogyan az előzőekben is, láthatjuk ugyanazt a struktúrát, amely más szintaktikai konstrukciókat is tartalmaz. Például a hívásokban phi kicserélődtek az értékek és a címkék. Azonban van itt valami, amire érdemes külön figyelmet fordítani.

Először is, itt teljesen más a funkció aláírása. Az LLVM nem támogatja a szeleteket, így optimalizálásként a TinyGo fordító, amely ezt a köztes kódot generálta, felosztotta ezt az adatszerkezetet részekre. Három szelet elemet ábrázolhatott volna (ptr, len és cap) struktúraként, de az, hogy három különálló entitásként ábrázolja őket, lehetővé teszi bizonyos optimalizálások végrehajtását. Más fordítók a szeleteket másképp is ábrázolhatják, ez a célplatform hívási konvencióitól függ.

Ennek a kódnak egy másik érdekes sajátossága az utasítás használata getelementptr (gyakran rövidítve GEP-nek hívják).

Ez az utasítás pointerekkel dolgozik, és az szelet egy elemére mutató pointer megszerzésére szolgál. Például hasonlítsuk össze a következő kóddal, amely C nyelven íródott:

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

Vagy a következővel, amely ennek megfelelő:

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

A legfontosabb itt az, hogy az utasítás getelementptr nem hajt végre dereferálási műveleteket. Csak egy új pointert számít ki a meglévő alapján. Úgy is lehet tekinteni, mint egy intézkedést mul és add hardver szinten. A GEP utasítással kapcsolatos részleteket itt olvashatunk itt.

Egy másik érdekes sajátosság ennek a köztes kódnak a használata icmp. Ez egy általános célú utasítás, amelyet egész számok összehasonlításának megvalósítására használnak. Ennek az utasításnak a végrehajtásának eredménye mindig egy érték, amely i1 — logikai érték. Ebben az esetben az összehasonlítás a slt (aláírt kisebb mint) kulcsszó használatával történik, mivel két számot hasonlítunk össze, amelyeket korábban egésztípus képviselt. Ha két előjel nélküli egész számot hasonlítanánk össze, akkor az utasítást icmphasználhatnánk, míg az összehasonlítás kulcsszava ult. Lebegőpontos számok összehasonlítására egy másik utasítás, fcmp, működik hasonló módon.

Összegzés

Úgy gondolom, hogy ebben az anyagban a legfontosabb jellemzőket tárgyaltam az LLVM IR-rel kapcsolatban. Természetesen itt még nagyon sok minden van. Különösen az átmeneti kód reprezentációjában számos annotáció létezhet, amelyek figyelembe veszik az optimalizációs lépések során a kódra vonatkozó, a fordító által ismert sajátosságokat, amelyeket más módon nem lehet kifejezni az IR-ben. Például ez a flag inbounds a GEP utasításra vonatkozik, vagy a flag nsw és nuw, amely hozzáadható az utasításhoz add. Ugyanez vonatkozik a privatekulcsszóra is, amely jelzi az optimalizálónak, hogy a megjelölt funkcióra nem fognak hivatkozni a jelenlegi fordítási egységen kívül. Ez lehetővé teszi érdekes interprocedurális optimalizálások végrehajtását, mint például a nem használt argumentumok eltávolítása.

Az LLVM részleteiről olvashat a dokumentáció, amelyet gyakran fog használni, ha saját, LLVM alapú fordítót fejleszt. Íme egy a kézikönyvet, amely a nagyon egyszerű nyelv fordítójának fejlesztését tárgyalja. Mindkét forrás hasznos lesz az ön saját fordítójának megalkotásához.

Használjátok a CI/CD technológiákat a projektjeitekben? Használja az LLVM-et?

LLVM a Go szempontjából

Forrás: habr.com

Megbízható hosting vásárlása weboldalak számára, DDoS védelemmel, VPS VDS szerverek. 🔥 Megbízható webtárhely vásárlása DDoS védelemmel, VPS VDS szerverek | ProHoster