Go perspektivindən LLVM

Kompilyator hazırlamaq çox çətin işdir. Amma xoşbəxtlikdən, LLVM kimi layihələrin inkişafı ilə bu problemin həlli xeyli sadələşdi ki, bu da hətta tək bir proqramçıya C-yə yaxın olan yeni dil yaratmağa imkan verir. LLVM ilə işləmək ona görə çətinləşir ki, bu sistem kiçik sənədlərlə təchiz edilmiş böyük miqdarda kodla təmsil olunur. Bu nöqsanı düzəltməyə çalışmaq üçün bu gün tərcüməsini dərc etdiyimiz materialın müəllifi Go-da yazılmış kod nümunələrini nümayiş etdirəcək və onların ilk olaraq necə tərcümə edildiyini göstərəcək. Get SSA, sonra isə tərtibçidən istifadə edərək LLVM IR-də tinyGO. Go SSA və LLVM IR kodu izahatları daha başa düşülən etmək üçün burada verilən izahatlara aid olmayan şeyləri silmək üçün bir qədər redaktə edilmişdir.

Go perspektivindən LLVM

Birinci misal

Burada baxacağım ilk funksiya ədədlərin əlavə edilməsi üçün sadə mexanizmdir:

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

Bu funksiya çox sadədir və bəlkə də bundan sadə heç nə ola bilməz. Aşağıdakı Go SSA koduna tərcümə olunur:

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

Bu görünüşlə, məlumat növü göstərişləri sağda yerləşdirilir və əksər hallarda nəzərə alına bilər.

Bu kiçik nümunə artıq SSA-nın bir aspektinin mahiyyətini görməyə imkan verir. Məhz, kodu SSA formasına çevirərkən, hər bir ifadə tərtib olunduğu ən elementar hissələrə bölünür. Bizim vəziyyətimizdə əmr return a + b, əslində, iki əməliyyatı təmsil edir: iki ədəd əlavə etmək və nəticəni qaytarmaq.

Bundan əlavə, burada proqramın əsas bloklarını görə bilərsiniz, bu kodda yalnız bir blok var - giriş bloku. Aşağıda bloklar haqqında daha çox danışacağıq.

Go SSA kodu asanlıqla LLVM IR-ə çevrilir:

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

Diqqət edə biləcəyiniz odur ki, burada müxtəlif sintaktik strukturlardan istifadə olunsa da, funksiyanın strukturu əsasən dəyişməzdir. LLVM IR kodu C-yə bənzər Go SSA kodundan bir qədər güclüdür. Burada funksiya bəyannaməsində əvvəlcə onun qaytardığı məlumat növünün təsviri var, arqumentin adından əvvəl arqument tipi göstərilir. Bundan əlavə, IR təhlilini sadələşdirmək üçün qlobal qurumların adlarından əvvəl simvol qoyulur @, və yerli adlardan əvvəl simvol var % (funksiya həm də qlobal varlıq hesab olunur).

Bu kod haqqında qeyd etmək lazım olan bir şey, Go-nun tipli təmsilçilik qərarıdır intLLVM IR kodunu yaradan zaman kompilyatordan və kompilyasiya hədəfindən asılı olaraq 32-bit və ya 64-bit dəyər kimi təqdim oluna bilən , qəbul edilir. Bu, LLVM IR kodunun bir çox insanın düşündüyü kimi platformadan müstəqil olmamasının bir çox səbəblərindən biridir. Bir platforma üçün yaradılmış belə kodu sadəcə başqa platforma üçün götürmək və tərtib etmək olmaz (əgər siz bu problemi həll etmək üçün uyğun deyilsinizsə) son dərəcə diqqətlə).

Diqqət yetirməyə dəyər başqa bir maraqlı məqam növüdür i64 işarəli tam ədəd deyil: ədədin işarəsini ifadə etmək baxımından neytraldır. Təlimatdan asılı olaraq həm işarəli, həm də işarəsiz nömrələri təmsil edə bilər. Toplama əməliyyatının təmsil olunması vəziyyətində bunun əhəmiyyəti yoxdur, ona görə də işarəli və ya işarəsiz nömrələrlə işləməkdə heç bir fərq yoxdur. Burada qeyd etmək istərdim ki, C dilində işarələnmiş tam dəyişənin daşması qeyri-müəyyən davranışa gətirib çıxarır, buna görə də Clang frontend əməliyyata bir bayraq əlavə edir. nsw (imzalı paket yoxdur), bu da LLVM-ə əlavənin heç vaxt daşmayacağını güman edə biləcəyini bildirir.

Bu, bəzi optimallaşdırmalar üçün vacib ola bilər. Məsələn, iki dəyər əlavə etmək i16 32-bit platformada (32-bit registrlərlə) əlavə edildikdən sonra diapazonda qalmaq üçün işarənin genişləndirilməsi əməliyyatı tələb olunur. i16. Buna görə də, maşın registrinin ölçüləri əsasında tam əməliyyatları yerinə yetirmək çox vaxt daha səmərəli olur.

Bu IR kodu ilə sonra baş verənlər indi bizim üçün xüsusi maraq kəsb etmir. Kod optimallaşdırılır (lakin bizimki kimi sadə nümunədə heç bir şey optimallaşdırılmır) və sonra maşın koduna çevrilir.

İkinci nümunə

Baxacağımız növbəti nümunə bir az daha mürəkkəb olacaq. Məhz, biz tam ədədlər dilimini cəmləyən funksiyadan danışırıq:

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

Bu kod aşağıdakı Go SSA koduna çevrilir:

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

Burada SSA formasında kodu təmsil etmək üçün xarakterik olan daha çox konstruksiya görə bilərsiniz. Bəlkə də bu kodun ən bariz xüsusiyyəti heç bir strukturlaşdırılmış axın idarəetmə əmrlərinin olmamasıdır. Hesablamaların axınına nəzarət etmək üçün yalnız şərti və qeyd-şərtsiz sıçrayışlar var və əgər bu əmri axını idarə etmək əmri kimi qəbul etsək, geri qaytarma əmri.

Əslində, burada proqramın əyri mötərizələrdən istifadə edərək bloklara bölünməməsinə diqqət yetirmək olar (C ailəsində olduğu kimi). O, montaj dillərini xatırladan etiketlərlə bölünür və əsas bloklar şəklində təqdim olunur. SSA-da əsas bloklar etiketlə başlayan və əsas blokun tamamlanması təlimatları ilə bitən bitişik kod ardıcıllığı kimi müəyyən edilir, məsələn - return и jump.

Bu kodun başqa bir maraqlı detalı təlimatla təmsil olunur phi. Təlimatlar olduqca qeyri-adidir və başa düşmək üçün bir az vaxt lazım ola bilər. yadda saxla ki SSA Static Single Assignment üçün qısaldılmışdır. Bu, hər bir dəyişənə yalnız bir dəfə dəyər təyin edilən kompilyatorlar tərəfindən istifadə olunan kodun aralıq təsviridir. Bu, bizim funksiyamız kimi sadə funksiyaları ifadə etmək üçün əladır myAddyuxarıda göstərilmişdir, lakin bu bölmədə müzakirə olunan funksiya kimi daha mürəkkəb funksiyalar üçün uyğun deyil sum. Xüsusilə, dövrənin icrası zamanı dəyişənlər dəyişir i и n.

SSA sözdə təlimatdan istifadə edərək bir dəfə dəyişən dəyərlərin təyin edilməsinə qoyulan məhdudiyyəti aşır. phi (onun adı yunan əlifbasından götürülmüşdür). Fakt budur ki, kodun SSA təqdimatının C kimi dillər üçün yaradılması üçün bəzi fəndlərə müraciət etməlisiniz. Bu təlimatın çağırılmasının nəticəsi dəyişənin cari dəyəridir (i və ya n) və onun parametrləri kimi əsas blokların siyahısı istifadə olunur. Məsələn, bu təlimatı nəzərdən keçirin:

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

Onun mənası belədir: əgər əvvəlki əsas blok blok idisə entry (giriş), sonra t0 sabitdir 0, və əgər əvvəlki əsas blok olsaydı for.body, onda dəyəri götürməlisiniz t6 bu blokdan. Bütün bunlar olduqca sirli görünə bilər, lakin bu mexanizm SSA-nı işə salır. İnsan nöqteyi-nəzərindən bütün bunlar kodu başa düşməyi çətinləşdirir, lakin hər bir dəyərin yalnız bir dəfə təyin edilməsi bir çox optimallaşdırmanı xeyli asanlaşdırır.

Nəzərə alın ki, öz kompilyatorunuzu yazsanız, adətən bu cür işlərlə məşğul olmayacaqsınız. Hətta Clang bütün bu təlimatları yaratmır phi, mexanizmdən istifadə edir alloca (adi yerli dəyişənlərlə işləməyə bənzəyir). Sonra, bir LLVM optimallaşdırma keçidini işləyərkən çağırılır mem2reg, təlimatlar alloca SSA formasına çevrildi. Bununla belə, TinyGo Go SSA-dan giriş alır, bu da rahatlıqla artıq SSA formasına çevrilir.

Nəzərdən keçirilən aralıq kodun fraqmentinin başqa bir yeniliyi ondan ibarətdir ki, dilim elementlərinə indekslə daxil olmaq ünvanı hesablama əməliyyatı və nəticədə olan göstəriciyə istinadın ləğvi əməliyyatı şəklində təqdim olunur. Burada IR koduna sabitlərin birbaşa əlavə edilməsini görə bilərsiniz (məsələn - 1:int). Funksiya ilə nümunədə myAdd bu istifade olunmayib. İndi bu funksiyaları əlimizdən götürdükdən sonra gəlin bu kodun LLVM IR formasına çevrildikdə nəyə çevrildiyinə nəzər salaq:

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
}

Burada da əvvəlki kimi, digər sintaktik strukturları özündə birləşdirən eyni quruluşu görə bilərik. Məsələn, zənglərdə phi dəyərlər və etiketlər dəyişdirildi. Ancaq burada xüsusi diqqət yetirməyə dəyər bir şey var.

Başlamaq üçün burada tamamilə fərqli bir funksiya imzasını görə bilərsiniz. LLVM dilimləri dəstəkləmir və nəticədə optimallaşdırma olaraq bu ara kodu yaradan TinyGo tərtibçisi bu məlumat strukturunun təsvirini hissələrə ayırdı. Üç dilim elementini təmsil edə bilər (ptr, len и cap) struktur (struktur) kimi, lakin onları üç ayrı obyekt kimi təqdim etmək bəzi optimallaşdırmalara imkan verir. Digər tərtibçilər hədəf platformanın funksiyalarının çağırış konvensiyalarından asılı olaraq dilimi başqa yollarla təmsil edə bilər.

Bu kodun digər maraqlı xüsusiyyəti təlimatın istifadəsidir getelementptr (çox vaxt GEP kimi qısaldılır).

Bu təlimat göstəricilərlə işləyir və dilim elementinə göstərici əldə etmək üçün istifadə olunur. Məsələn, onu C-də yazılmış aşağıdakı kodla müqayisə edək:

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

Və ya bunun aşağıdakı ekvivalenti ilə:

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

Burada ən vacib şey təlimatdır getelementptr istinaddan çıxarma əməliyyatlarını yerinə yetirmir. O, sadəcə mövcud olana əsaslanaraq yeni göstərici hesablayır. Təlimat kimi qəbul edilə bilər mul и add hardware səviyyəsində. GEP təlimatları haqqında daha çox oxuya bilərsiniz burada.

Bu aralıq kodun digər maraqlı xüsusiyyəti təlimatın istifadəsidir icmp. Bu, tam müqayisələri həyata keçirmək üçün istifadə edilən ümumi məqsədli təlimatdır. Bu təlimatın nəticəsi həmişə növün dəyəridir i1 - məntiqi dəyər. Bu zaman açar sözdən istifadə etməklə müqayisə aparılır slt (daha az imzalanmışdır), çünki biz əvvəllər növü ilə təmsil olunan iki ədədi müqayisə edirik int. Əgər iki işarəsiz tam ədədi müqayisə etsəydik, onda istifadə edərdik icmp, və müqayisədə istifadə olunan açar söz olacaq ult. Üzən nöqtəli nömrələri müqayisə etmək üçün başqa bir təlimat istifadə olunur, fcmp, oxşar şəkildə işləyir.

Nəticələri

İnanıram ki, bu materialda LLVM IR-nin ən vacib xüsusiyyətlərini əhatə etmişəm. Təbii ki, burada daha çox şey var. Xüsusilə, kodun aralıq təsviri optimallaşdırma keçidlərinin tərtibçiyə məlum olan kodun IR-də başqa cür ifadə edilə bilməyən bəzi xüsusiyyətlərini nəzərə almağa imkan verən çoxlu annotasiyalardan ibarət ola bilər. Məsələn, bu bayraqdır inbounds GEP təlimatları və ya bayraqlar nsw и nuw, bu təlimatlara əlavə edilə bilər add. Eyni şey açar söz üçün də gedir private, optimallaşdırıcıya işarələdiyi funksiyaya cari kompilyasiya vahidindən kənar istinad edilməyəcəyini göstərir. Bu, istifadə olunmamış arqumentləri aradan qaldırmaq kimi bir çox maraqlı prosedurlararası optimallaşdırmalara imkan verir.

LLVM haqqında ətraflı oxuya bilərsiniz sənədləşdirmə, öz LLVM əsaslı kompilyatorunuzu inkişaf etdirərkən tez-tez müraciət edəcəyiniz. Burada bələdçi, çox sadə bir dil üçün tərtibçinin hazırlanmasına baxır. Bu məlumat mənbələrinin hər ikisi öz kompilyatorunuzu yaradarkən sizin üçün faydalı olacaqdır.

Hörmətli oxucular! LLVM istifadə edirsiniz?

Go perspektivindən LLVM

Mənbə: www.habr.com

Добавить комментарий