Table of Contents

04. Introduction to LLVM

LLVM conține o serie de biblioteci și utilitare care pot fi folosite pentru a construi compilatoare, interpretoare și alte tool-uri similare. Găsiți aici o introducere și câteva exemple interesante de utilizare a LLVM-ului. Pentru și mai multe exemple, există o pagină cu proiecte care folosesc LLVM.

Printre cele mai importante componente ale LLVM se află engine-ul de optimizări independente de platformă și de limbaj. Acesta lucrează cu o reprezentare intermediară a programelor, pe care o vom prezenta în cadrul acestui laborator. Această reprezentare este de asemenea liantul între front-end-uri (precum Clang pentru C/C++/Objective-C) și back-end-uri (în prezent, LLVM are back-end-uri pentru x86, ARM, PowerPC, MIPS, Sparc etc).

Pentru a instala LLVM pe un sistem Linux aveți nevoie de pachetele llvm pentru a folosi tool-urile, llvm-dev pentru a putea folosi bibliotecile, clang pentru front-end-ul C/C++ .

Reprezentarea intermediară a LLVM (LLVM IR)

Reprezentarea intermediară a LLVM-ului are 3 forme:

Tool-uri pentru lucrul cu IR-ul

Obținerea reprezentării textuale a unui fișier C sau C++:

clang -S -emit-llvm yourfile.c -o yourfile.ll

Obținerea reprezentării binare a unui fișier C sau C++:

clang -c -emit-llvm yourfile.c -o yourfile.bc

Observați utilizarea flag-urilor -S, ca atunci când dorim obținerea unui fișier în limbaj de asamblare, și -c, ca atunci când dorim obținerea unui fișier obiect, urmate de -emit-llvm pentru a obține IR (fie în forma textuală, asemănătoare unui limbaj de asamblare, fie în forma binară, similară unui format obiect). Fără flagul -emit-llvm, clang va general cod asamblare sau cod obiect nativ, la fel ca orice alt compilator, de exemplu gcc.

Pentru conversia între cele 2 reprezentări se pot folosi tool-urile llvm-as și llvm-dis:

llvm-as yourfile.ll -o yourfile.bc
llvm-dis yourfile.bc -o yourfile.ll

Alte utilitare care merită menționate sunt opt, care reprezintă o interfață către engine-ul de optimizări (primește ca input fișiere IR și scoate tot fișiere IR), llc, care poate compila un fișier IR pentru o anumită arhitectură (primește un fișier IR și produce un fișier în limbaj de asamblare sau un fișier obiect), și lli, care poate interpreta un fișier IR (primește un fișier IR și produce rezultatele rulării programului respectiv pe platforma curentă). Mai multe informații despre utilitarele din suita LLVM găsiți aici.

lli yourfile.ll                # Interpretează IR-ul
llc yourfile.ll -o yourfile.s  # Genereaza un fișier în limbajul de asamblare nativ

Organizarea IR-ului

În general, fiecare fișier .ll sau .bc va conține un modul alcătuit din mai multe declarații de funcții, variabile globale, tipuri etc.

IR-ul de LLVM pentru fiecare funcție este organizat sub forma unui control flow graph, alcătuit din basic block-uri. Un basic block este o secvență de instrucțiuni neîntreruptă de transferuri de control. Transferurile de control se realizează prin intermediul unor instrucțiuni de tip branch, return, switch etc (cunoscute sub numele de terminators).

Atenție! In LLVM, ultima instrucțiune din fiecare basic block trebuie neapărat să fie un terminator.

Control flow graph-ul poate fi vizualizat grafic sub forma unui fișier DOT. Pentru obținerea fișierelor, se poate utiliza utilitarul opt:

opt -dot-cfg yourfile.ll

Rezultatul rulării va consta în câte un fișier DOT pentru fiecare funcție din modul (cfg.f.dot, cfg.g.dot etc). Pentru a vizualiza fișierele în mașina virtuală puteți instala pachetul xdot:

sudo apt-get install xdot
xdot cfg.f.dot

Fiecare nod din graful plotat de xdot reprezintă un basic block, în timp ce arcele reprezintă posibile căi de execuție. Fiecare nod va conține secvența de instrucțiuni corepunzătoare basic block-ului pe care îl reprezintă. O descriere completă a tuturor instrucțiunilor din IR-ul de LLVM se găsește aici. Majoritatea instrucțiunilor sunt destul de intuitive: add, sub, mul, fadd, fsub, fmul etc pentru operații aritmetice, load și store pentru citiri și scrieri din memorie, icmp și fcmp pentru realizarea comparațiilor, și așa mai departe. Spre deosebire de limbajele de asamblare tradiționale, IR-ul de LLVM are noțiunea de tipuri de date - fiecare valoare are un anumit tip și tipurile pe care se poate aplica o anumită operație sunt bine definite.

Sistemul de tipuri

Tipurile care pot să apară în IR-ul de LLVM includ (lista completă o găsiți aici):

Numere întregi

Numerele întregi pot fi reprezentate pe un număr oarecare de biți - de exemplu i32, i16, i1, i24. Numărul de biți poate lua orice valoare între 1 și 2^23 - 1.

Valorile sunt reprezentate în complement față de 2; un aspect important este faptul că valorile întregi nu au semn - în schimb, instrucțiunile care lucrează cu întregi pot ține cont de semn dacă este cazul. De exemplu, operația de adunare este identică pentru numere cu semn și fără semn (și ca urmare există o singură instrucțiune add), în schimb împărțirea nu este (drept urmare există instrucțile sdiv pentru împărțiri cu semn și udiv pentru împărțiri fără semn). Un alt exemplu este instrucțiunea icmp, care poate lua flag-uri diferite - de exemplu sgt pentru > între numere cu semn și ugt între numere fără semn.

Numere în virgulă mobilă

LLVM conține un număr limitat de reprezentări în virgulă mobilă, dintre care merită menționate double (pe 64 de biți), float (pe 32 de biți), half (pe 16 biți). Instrucțiunile care lucrează cu numere în virgulă mobilă sunt în general prefixate cu litera f: fadd, fmul, fcmp etc.

Vectori

Vectorii reprezintă valori ce pot fi manipulate de unitățile vectoriale (SIMD) prezente în arhitecturile moderne, precum MMX și SSE pentru x86, NEON pentru ARM, AltiVec pentru PowerPC. Exemple de tipuri vectoriale sunt <4 x i32>, <2 x double> etc. Acestea pot să existe în IR fie pentru că apar explicit în program (unele limbaje precum OpenCL oferă suport pentru tipuri vectoriale, iar pentru C/C++ există de regulă extensii de compilator care pot fi folosite), fie ca urmare a optimizărilor realizate de LLVM.

Array-uri

Reprezintă zone contigue de memorie, ca în C - de exemplu [100 x i32], [10 x [10 x float]].

Pointeri

Reprezintă o locație de memorie, ca în C - de exemplu i32*, float *, [10 x i32] *.

Structuri

Asemănătoare cu struct-urile din C - de exemplu { i16, float *, [10 x i8] }. Membrii structurilor nu au nume - ei vor fi accesați prin intremediul unui indice (0 pentru primul membru, 1 pentru al doilea și așa mai departe).

Tipuri de funcții

Reprezintă semnătura unei funcții - de exemplu i32 (i8 *, i8 *) este tipul unei funcții care primește ca parametri doi pointeri la i8 și întoarce un i32.

Instrucțiuni

Am menționat deja câteva tipuri importante de instrucțiuni - cele aritmetice (add, fmul etc), cele de lucru cu memoria (load, store), cele relaționale (icmp, fcmp), cele de transfer de control (br, ret etc).

Alte instrucțiuni care merită menționate sunt:

Instrucțiunile de conversie

Acestea sunt necesare deoarece fiecare instrucțiune din LLVM are cerințe foarte precise asupra tipurilor pe care le acceptă. Exemple de instrucțiuni de conversie sunt trunc - pentru trunchierea valorilor la un număr mai mic de biți, sext și zext pentru extinderea la un număr mai mare de biți (ținând cont de semn sau nu), sitofp - signed integer to floating point etc.

Instrucțiunea ''getelementptr'' (''GEP'')

Această instrucțiune realizează calcule de adrese, fără a accesa însă memoria. Pornind de la un pointer, este folosită pentru a indexa în array-uri și structuri, ca în exemplul următor:

struct data {
  int x, y;
};
 
struct lots_of_data {
  struct data storage[10];
  int n;
};
 
// [...]
// struct lots_of_data *my_data
my_data->storage[5].y = 42;
 
// IR-ul va conține instrucțiuni de genul:
// %addr = getelementptr inbounds %struct.lots_of_data* %my_data, i32 0, i32 0, i32 5, i32 1
// store i32 42, i32* %addr, align 4

Primul indice 0 este folosit pentru a indexa în %my_data (gândiți-vă că my_data→storage e echivalent cu my_data[0].storage). Al doilea indice 0 este folosit pentru a obține primul membru al structurii lots_of_data (storage). Următorul indice, 5, este folosit pentru a indexa în my_data→storage, iar ultimul indice este folosit pentru a obține membrul y al structurii data.

Este important de observat faptul că accesul la memorie se face cu ajutorul instrucțiunii store, independent de calcularea adresei la care va fi stocată informația.

Mai multe explicații găsiți aici.

Instrucțiunea ''phi''. Forma SSA

Un aspect important al IR-ului de LLVM este faptul că este în formă SSA - Static Single Assignment. În această formă, fiecare valoare poate fi asignată o singură dată (dar folosită de ori câte ori). De exemplu, variabila x din codul de mai jos este asignată de două ori, lucru întâlnit frecvent în limbajele procedurale precum C.

x = m * n;
a = x + y;
x = m + n;
b = x + y;

La transformarea în formă SSA, vor exista două versiuni diferite ale variabilei x - una reprezentând valoarea de după prima atribuire, una reprezentând valoarea de după cea de-a doua atribuire:

x_1 = m * n;
  a = x_1 + y;
x_2 = m + n;
  b = x_2 + y;

Această transformare simplifică multe optimizări. Dacă în primul exemplu am fi vrut să calculăm x + y o singură dată și să îl folosim atât în atribuirea lui a cât și a lui b, ar fi trebuit întâi să verificăm că valorile lui x și y nu se schimbă între cele două folosiri. În cel de-al doilea exemplu, este evident de la bun început faptul că cele două expresii sunt diferite - nu e nevoie de nicio analiză suplimentară.

Să considerăm un exemplu un pic mai complicat:

if (cond) {
  x = m * n;
} else {
  x = m + n;
}
a = x + y;

Chiar dacă se află pe ramuri diferite ale if-ului, cele două atribuiri ale lui x trebuie versionate: vom avea un x_1 pe prima ramură și un x_2 pe cea de-a doua. Atribuirea lui a va trebui să folosească fie x_1, fie x_2, în funcție de valoarea lui cond. Pentru a exprima acest lucru, în forma SSA s-a introdus instrucțiunea phi:

if (cond)
  x_1 = m * n;
} else {
  x_2 = m + n;
}
x_3 = phi (x_1, x_2)
a = x_3 + y;

Astfel, atribuirea lui a va folosi o a treia versiune a lui x, care poate fi fie x_1, fie x_2, în funcție de branch-ul ales la runtime. În sintaxa LLVM, instrucțiunea phi va lua câte o pereche de operanzi pentru fiecare basic block din care ar putea ajunge execuția la blocul curent - primul membru al perechii reprezintă valoarea care se va atribui, iar a doua label-ul basic block-ului din care s-a transferat controlul:

if.then:
  %x.1 = mul i32 %m, %n
  br label %if.end
 
if.else:
  %x.2 = add i32 %m, %n
  br label %if.end
 
if.end:
  %x.3 = phi i32 [ %x.1, %if.then], [ %x.2, %if.else]
  %a = add i32 %x.3, %y
Instrucținea ''alloca''

Această instrucțiune alocă spațiu pe stivă, care va fi automat eliberat atunci când funcția curentă își termină execuția.

2015/04/26 23:26

Exerciții de laborator (10p)

În rezolvarea laboratorului folosiți arhiva de sarcini lab04-tasks.zip

Exercițiul 1 - Control flow (1p)

Intrați în directorul 1-control-flow și inspectați fișierul simple-loop.c. Câte instrucțiuni de tip branch vă așteptați să existe în reprezentarea în LLVM IR?

Rulați make simple-loop.ll. Acesta va produce fișierele simple-loop.ll și cfg.simple.dot. Vizualizați CFG-ul folosind utilitarul xdot și încercați să urmăriți control flow-ul prin funcție.

Exercițiul 2 - Get element pointer (1p)

Intrați în directorul 2-gep și inspectați fișierul gep.c. Ce indici credeți că vor folosi instrucțiunile de tip GEP din funcțiile get1, get2 și get3? Există vreo diferență între get2 și get3?

Verificați rulând make gep.ll și inspectând fișierul produs.

Exercițiul 3 - Phi (1p)

Intrați în directorul 3-phi și inspectați fișierul phi.c. Câte instrucțiuni de tip phi credeți că vor exista în IR și ce parametri vor primi?

Verificați rulând make phi.ll și inspectând fișierul produs (puteți de asemenea să vizualizați cfg.nonsense.dot).

Exercițiul 4 - Variabile globale (1p)

Intrați în directorul 4-globals și inspectați fișierul globals.c. Câte instrucțiuni de tip phi credeți că vor exista în IR și ce parametri vor primi?

Rulați make globals.ll și inspectați fișierul produs. Cum este tratată variabila globală? De ce?

Exercițiul 5 - Alloca (1p)

Intrați în directorul 5-alloca și inspectați fișierul alloca.c. Observați variabila locală x.

Rulați make alloca.ll și inspectați fișierul produs. De ce este nevoie de instrucțiunea alloca?

Rulați make alloca.raw.ll și inspectați fișierul produs. Ce diferă?

alloca.raw.ll este output-ul nemodificat al rulării clang pentru obținerea IR-ului. Pentru claritate, în cadrul laboratorului am folosit flag-ul -mem2reg al optimizorului. Pe baza diferențelor dintre alloca.raw.ll (obținut fără -mem2reg) și alloca.ll (obținut cu -mem2reg), ce credeți că face acest flag?

Exercițiul 6 - Putting it all together (5p)

Intrați în directorul 6-reverse și inspectați fișierul reverse.ll (sau vizualizați cfg.f.dot). Încercați să scrieți un exemplu de cod C care să producă IR-ul din reverse.ll sau ceva cât mai apropiat. Pentru a obține IR-ul corespunzător unui fișier yourfile.c puteți rula make yourfile.rev.ll.

BONUS

Clase

Intrați în directorul 7-classes și inspectați fișierul classes.cpp. Cum credeți că va arăta definiția lui A? Dar a lui B? Cum va diferenția compilatorul între cele 2 metode g ale lui B?

Rulați make classes.ll și inspectați fișierul produs.

Metode virtuale

Intrați în directorul 8-virtual și inspectați fișierul classes.cpp. Cum credeți că vor arăta definițiile claselor A și B în acest caz? Ce metode se vor apela și cum pe obiectele de tip A și B în cadrul funcției val? Dar în cadrul funcției ref? De ce?

Rulați make classes.simple.ll și inspectați fișierul produs.

Rulați make classes.ll și inspectați fișierul produs. Ce s-a schimbat?