LLVM a Go szemszögéből

A fordítóprogram fejlesztése nagyon nehéz feladat. De szerencsére az olyan projektek fejlesztésével, mint az LLVM, a probléma megoldása nagymértékben leegyszerűsödik, ami lehetővé teszi, hogy akár egyetlen programozó is létrehozzon egy új nyelvet, amely teljesítményében közel áll a C-hez. Az LLVM-mel való munkát nehezíti az a tény, hogy ez rendszert hatalmas mennyiségű kód képviseli, kevés dokumentációval felszerelve. Ennek a hiányosságnak a kijavítása érdekében az anyag szerzője, amelynek fordítását ma közzétesszük, példákat mutat be a Go nyelven írt kódokra, és bemutatja, hogyan fordítják le először Irány az SSA, majd LLVM IR-ben a fordító segítségével tinyGO. A Go SSA és LLVM IR kódot kissé módosítottuk, hogy eltávolítsuk azokat a dolgokat, amelyek nem relevánsak az itt adott magyarázatokhoz, hogy a magyarázatok érthetőbbek legyenek.

LLVM a Go szemszögéből

Első példa

Az első függvény, amelyet itt megnézek, egy egyszerű mechanizmus a számok hozzáadására:

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

Ez a funkció nagyon egyszerű, és talán semmi sem lehetne egyszerűbb. A következő Go SSA kódra fordítódik:

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

Ebben a nézetben az adattípusra vonatkozó tippek a jobb oldalon helyezkednek el, és a legtöbb esetben figyelmen kívül hagyhatók.

Ez a kis példa már lehetővé teszi az SSA egy aspektusának a lényegét. Ugyanis a kód SSA-formába konvertálásakor minden kifejezést a legelemibb részekre bontanak, amelyekből áll. Esetünkben a parancs return a + b, valójában két műveletet jelent: két szám összeadását és az eredmény visszaadását.

Ezenkívül itt láthatja a program alapvető blokkjait, ebben a kódban csak egy blokk található - a beviteli blokk. A blokkokról alább többet fogunk beszélni.

A Go SSA kód könnyen konvertálható LLVM IR-re:

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

Amit észre lehet venni, hogy bár itt különböző szintaktikai struktúrákat használunk, a függvény szerkezete alapvetően változatlan. Az LLVM IR kód egy kicsit erősebb, mint a Go SSA kód, hasonlóan a C-hez. Itt a függvénydeklarációban először az általa visszaadott adattípus leírása található, az argumentum típusa az argumentum neve előtt. Ezenkívül az infravörös elemzés egyszerűsítése érdekében a globális entitások neve előtt a szimbólum szerepel @, és a helyi nevek előtt van egy szimbólum % (egy függvény globális entitásnak is számít).

Egy dolog, amit meg kell jegyezni ezzel a kóddal, az a Go típusábrázolási döntése int, amely a fordítótól és a fordítás céljától függően 32 bites vagy 64 bites értékként ábrázolható, elfogadásra kerül, amikor az LLVM előállítja az IR kódot. Ez az egyik oka annak, hogy az LLVM IR kód nem platformfüggetlen, ahogy azt sokan gondolják. Az ilyen, egy platformra készített kódot nem lehet egyszerűen átvenni és lefordítani egy másik platformra (hacsak nem alkalmas ennek a probléma megoldására rendkívül óvatosan).

Egy másik érdekesség, amit érdemes megjegyezni, hogy a típus i64 nem előjeles egész szám: a szám előjelének ábrázolása szempontjából semleges. Az utasítástól függően előjeles és előjel nélküli számokat is jelenthet. Az összeadási művelet ábrázolása esetén ennek nincs jelentősége, így nincs különbség az előjeles vagy előjel nélküli számokkal való munkavégzésben. Itt szeretném megjegyezni, hogy a C nyelvben az előjeles egész változó túlcsordulása definiálatlan viselkedéshez vezet, így a Clang frontend jelzőt ad a művelethez nsw (nincs aláírt wrap), ami azt mondja az LLVM-nek, hogy feltételezheti, hogy a kiegészítés soha nem csordul túl.

Ez bizonyos optimalizálásoknál fontos lehet. Például két érték hozzáadása i16 32 bites platformon (32 bites regiszterekkel) a hozzáadás után előjelbővítési műveletre van szükség a tartományban maradáshoz i16. Emiatt gyakran hatékonyabb egész számmal végzett műveletek végrehajtása a gépi regiszterméretek alapján.

Hogy mi történik ezután ezzel az IR kóddal, az most nem különösebben érdekel bennünket. A kódot optimalizálják (de egy olyan egyszerű példa esetében, mint a miénk, semmit sem optimalizálnak), majd gépi kóddá alakítják.

Második példa

A következő példa, amelyet megvizsgálunk, egy kicsit bonyolultabb lesz. Nevezetesen egy olyan függvényről beszélünk, amely egy egész szám szeletét összegzi:

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 konvertálódik:

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 több, a kód SSA formában történő megjelenítésére jellemző konstrukció látható. A kód talán legnyilvánvalóbb jellemzője az, hogy nincsenek strukturált áramlásvezérlő parancsok. A számítási folyamat szabályozására csak feltételes és feltétel nélküli ugrások vannak, és ha ezt a parancsot az áramlás vezérlésére szolgáló parancsnak tekintjük, akkor egy visszatérési parancsot.

Valójában itt arra lehet figyelni, hogy a program nincs blokkokra osztva kapcsos zárójelekkel (mint a C nyelvcsaládban). Az assembly nyelvekre emlékeztető címkékkel tagolják, és alapvető blokkok formájában jelenítik meg. Az SSA-ban az alapblokkok összefüggő kódszekvenciákként vannak definiálva, amelyek egy címkével kezdődnek, és alapvető blokk-kiegészítő utasításokkal végződnek, mint pl. return и jump.

Ennek a kódnak egy másik érdekes részletét az utasítás képviseli phi. Az utasítások meglehetősen szokatlanok, és eltarthat egy ideig, amíg megértik. Emlékezz arra SSA a Static Single Assignment rövidítése. Ez a fordítók által használt kód köztes reprezentációja, amelyben minden változóhoz csak egyszer van hozzárendelve érték. Ez nagyszerű az olyan egyszerű függvények kifejezésére, mint a mi függvényünk myAddfent látható, de nem alkalmas bonyolultabb funkciókhoz, mint például az ebben a részben tárgyalt függvény sum. Különösen a változók változnak a ciklus végrehajtása során i и n.

Az SSA megkerüli a változó értékek egyszeri hozzárendelésére vonatkozó korlátozást egy úgynevezett utasítás segítségével phi (neve a görög ábécéből származik). A helyzet az, hogy ahhoz, hogy a kód SSA-reprezentációja létrejöjjön olyan nyelvekhez, mint a C, néhány trükkhöz kell folyamodnia. Az utasítás meghívásának eredménye a változó aktuális értéke (i vagy n), és az alapvető blokkok listáját használják paramétereiként. Vegyük például ezt az utasítást:

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

Jelentése a következő: ha az előző alapblokk blokk volt entry (bemenet), akkor t0 egy állandó 0, és ha az előző alapblokk volt for.body, akkor ki kell venni az értéket t6 ebből a blokkból. Mindez meglehetősen titokzatosnak tűnhet, de ez a mechanizmus az, amitől az SSA működik. Emberi szempontból mindez megnehezíti a kód megértését, de az a tény, hogy minden érték csak egyszer van hozzárendelve, sok optimalizálást sokkal könnyebbé tesz.

Ne feledje, hogy ha saját fordítóprogramot ír, akkor általában nem kell ilyen dolgokkal foglalkoznia. Még a Clang sem generálja ezeket az utasításokat phi, egy mechanizmust használ alloca (hasonlít a szokásos helyi változókkal való munkához). Ezután, amikor fut egy LLVM optimalizálási pass, hívják mem2reg, utasítás alloca SSA űrlapra konvertálva. A TinyGo azonban bemenetet kap a Go SSA-tól, amely kényelmesen már SSA-formára van konvertálva.

A vizsgált közbenső kódrészlet másik újítása az, hogy a szeletelemekhez való hozzáférést indexen keresztül a cím kiszámításának művelete és az eredményül kapott mutató hivatkozásának megszüntetésére szolgáló művelet formájában ábrázolják. Itt láthatja a konstansok közvetlen hozzáadását az IR kódhoz (például - 1:int). A példában a függvénnyel myAdd ezt nem használták. Most, hogy ezeket a funkciókat kivontuk az útból, nézzük meg, mivé válik ez a kód, ha LLVM IR formára konvertáljuk:

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
}

Itt, mint korábban, ugyanazt a szerkezetet láthatjuk, amely más szintaktikai struktúrákat is tartalmaz. Például a hívásoknál phi az értékek és a címkék felcserélve. Van azonban itt valami, amire érdemes külön odafigyelni.

Kezdetben itt egy teljesen más függvényaláírást láthat. Az LLVM nem támogatja a szeleteket, és ennek eredményeként optimalizálásként a köztes kódot előállító TinyGo fordító részekre osztotta ennek az adatszerkezetnek a leírását. Három szeletelemet jelenthet (ptr, len и cap) struktúraként (struct), de ezek három különálló entitásként való megjelenítése lehetővé tesz bizonyos optimalizálásokat. Más fordítók a szeletet más módon is ábrázolhatják, a célplatform funkcióinak hívási konvencióitól függően.

A kód másik érdekessége az utasítás használata getelementptr (gyakran GEP-ként rövidítik).

Ez az utasítás mutatókkal működik, és egy szeletelemre mutató mutató lekérésére szolgál. Például hasonlítsuk össze a következő, C-ben írt kóddal:

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

Vagy ennek a következő megfelelőjével:

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

A legfontosabb dolog itt az, hogy az utasításokat getelementptr nem hajt végre hivatkozási műveleteket. Csak egy új mutatót számol ki a meglévő alapján. Utasításnak tekinthető mul и add hardver szinten. A GEP utasításokról bővebben olvashat itt.

Ennek a köztes kódnak egy másik érdekessége az utasítás használata icmp. Ez egy általános célú utasítás, amely egész számok összehasonlítására szolgál. Ennek az utasításnak az eredménye mindig egy típusú érték i1 — logikai érték. Ebben az esetben az összehasonlítás a kulcsszó használatával történik slt (kisebb előjellel), mivel összehasonlítunk két, a típussal korábban ábrázolt számot int. Ha két előjel nélküli egész számot hasonlítanánk össze, akkor ezt használnánk icmp, és az összehasonlításban használt kulcsszó a következő lenne ult. A lebegőpontos számok összehasonlításához egy másik utasítást használunk, fcmp, amely hasonló módon működik.

Eredményei

Úgy gondolom, hogy ebben az anyagban bemutattam az LLVM IR legfontosabb jellemzőit. Persze itt még sok minden van. Különösen a kód köztes ábrázolása tartalmazhat sok olyan megjegyzést, amelyek lehetővé teszik az optimalizálási lépések során, hogy figyelembe vegyék a kód bizonyos, a fordító által ismert jellemzőit, amelyeket egyébként infravörösben nem lehet kifejezni. Például ez egy zászló inbounds GEP utasítások vagy zászlók nsw и nuw, amely hozzáadható az utasításokhoz add. Ugyanez vonatkozik a kulcsszóra is private, jelezve az optimalizálónak, hogy az általa megjelölt függvényre nem hivatkozik az aktuális fordítási egységen kívülről. Ez sok érdekes eljárásközi optimalizálást tesz lehetővé, mint például a fel nem használt argumentumok eltávolítása.

Az LLVM-ről bővebben itt olvashat dokumentáció, amelyre gyakran fog hivatkozni, amikor saját LLVM-alapú fordítóját fejleszti. Itt vezetés, amely egy nagyon egyszerű nyelv fordítóprogramjának fejlesztését vizsgálja. Mindkét információforrás hasznos lesz a saját fordítóprogram elkészítésekor.

Kedves olvasók! LLVM-et használsz?

LLVM a Go szemszögéből

Forrás: will.com

Hozzászólás