Go perspektifinden LLVM

Bir derleyici geliştirmek çok zor bir iştir. Ancak, neyse ki, LLVM gibi projelerin geliştirilmesiyle bu sorunun çözümü büyük ölçüde basitleştirilmiştir; bu, tek bir programcının bile performans açısından C'ye yakın yeni bir dil yaratmasına olanak tanır. sistem çok az dokümantasyonla donatılmış çok miktarda kodla temsil edilir. Bu eksikliği gidermeye çalışmak için, bugün çevirisini yayınladığımız materyalin yazarı, Go'da yazılmış kod örneklerini gösterecek ve bunların ilk olarak Go'ya nasıl çevrildiğini gösterecek. SSA'ya gitve ardından derleyiciyi kullanarak LLVM IR'de minikGO. Açıklamaları daha anlaşılır kılmak amacıyla, Go SSA ve LLVM IR kodu, burada verilen açıklamalarla ilgisi olmayan hususları ortadan kaldıracak şekilde biraz düzenlenmiştir.

Go perspektifinden LLVM

İlk örnek

Burada bakacağım ilk fonksiyon, sayıları toplamaya yönelik basit bir mekanizmadır:

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

Bu işlev çok basittir ve belki de hiçbir şey bundan daha basit olamaz. Aşağıdaki Go SSA koduna çevrilir:

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

Bu görünümde veri türü ipuçları sağ tarafa yerleştirilir ve çoğu durumda göz ardı edilebilir.

Bu küçük örnek zaten SSA'nın bir yönünün özünü görmenize olanak sağlıyor. Yani kodu SSA formuna dönüştürürken her ifade kendisini oluşturan en temel parçalara bölünür. Bizim durumumuzda komut return a + baslında iki işlemi temsil eder: iki sayının toplanması ve sonucun döndürülmesi.

Ayrıca burada programın temel bloklarını görebilirsiniz; bu kodda yalnızca bir blok vardır - giriş bloğu. Aşağıda bloklar hakkında daha fazla konuşacağız.

Go SSA kodu kolayca LLVM IR'ye dönüştürülür:

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

Burada farklı sözdizimsel yapılar kullanılmasına rağmen işlevin yapısının temelde değişmediğini fark edebilirsiniz. LLVM IR kodu, C'ye benzer şekilde Go SSA kodundan biraz daha güçlüdür. Burada, işlev bildiriminde, önce döndürdüğü veri türünün açıklaması vardır, bağımsız değişken adından önce bağımsız değişken türü belirtilir. Ek olarak, IR ayrıştırmayı basitleştirmek için küresel varlıkların adlarının önüne şu sembol gelir: @ve yerel adlardan önce bir sembol var % (bir işlev aynı zamanda küresel bir varlık olarak kabul edilir).

Bu kodla ilgili dikkat edilmesi gereken bir nokta, Go'nun tür temsil kararının intDerleyiciye ve derlemenin hedefine bağlı olarak 32 bit veya 64 bit değer olarak temsil edilebilen LLVM, IR kodunu oluşturduğunda kabul edilir. Bu, LLVM IR kodunun birçok kişinin düşündüğü gibi platformdan bağımsız olmamasının birçok nedeninden biridir. Bir platform için oluşturulan bu tür kod, başka bir platform için kolayca alınıp derlenemez (bu sorunu çözmeye uygun olmadığınız sürece) son derece dikkatli).

Dikkat edilmesi gereken bir diğer ilginç nokta ise türün i64 işaretli bir tam sayı değildir: sayının işaretini temsil etmesi açısından nötrdür. Talimata bağlı olarak hem imzalı hem de imzasız sayıları temsil edebilir. Toplama işleminin temsili durumunda bunun bir önemi yoktur, bu nedenle işaretli veya işaretsiz sayılarla çalışmanın bir farkı yoktur. Burada, C dilinde işaretli bir tamsayı değişkeninin taşmasının tanımsız davranışa yol açtığını, dolayısıyla Clang ön ucunun işleme bir bayrak eklediğini belirtmek isterim. nsw (imzalı sarma yok), bu da LLVM'ye eklemenin asla taşmayacağını varsayabileceğini söyler.

Bu, bazı optimizasyonlar için önemli olabilir. Örneğin iki değerin eklenmesi i16 32 bitlik bir platformda (32 bitlik kayıtlarla), aralıkta kalabilmek için ekleme sonrasında bir işaret genişletme işlemi gerekir i16. Bu nedenle, makine kayıt boyutlarına dayalı olarak tamsayı işlemlerini gerçekleştirmek genellikle daha verimlidir.

Bu IR koduyla bundan sonra ne olacağı şu anda bizi pek ilgilendirmiyor. Kod optimize edilir (ancak bizimki gibi basit bir örnekte hiçbir şey optimize edilmez) ve ardından makine koduna dönüştürülür.

İkinci örnek

Bakacağımız bir sonraki örnek biraz daha karmaşık olacak. Yani bir dilim tam sayının toplamını alan bir fonksiyondan bahsediyoruz:

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

Bu kod aşağıdaki Go SSA koduna dönüştürülür:

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 zaten kodu SSA formunda temsil etmeye yönelik daha fazla tipik yapıyı görebilirsiniz. Belki de bu kodun en belirgin özelliği, yapılandırılmış akış kontrol komutlarının bulunmamasıdır. Hesaplamaların akışını kontrol etmek için yalnızca koşullu ve koşulsuz atlamalar vardır ve bu komutu akışı kontrol etmeye yönelik bir komut olarak düşünürsek, bir dönüş komutu vardır.

Aslında burada programın küme parantezleri kullanılarak (C dil ailesindeki gibi) bloklara bölünmemesine dikkat edebilirsiniz. Assembly dillerini anımsatan etiketlerle bölünür ve temel bloklar halinde sunulur. SSA'da temel bloklar, bir etiketle başlayan ve temel blok tamamlama talimatlarıyla biten bitişik kod dizileri olarak tanımlanır; örneğin - return и jump.

Bu kodun bir başka ilginç detayı da talimatla temsil edilmektedir. phi. Talimatlar oldukça sıra dışıdır ve anlaşılması biraz zaman alabilir. bunu hatırla SSA Statik Tek Atama'nın kısaltmasıdır. Bu, her değişkene yalnızca bir kez değer atanan, derleyiciler tarafından kullanılan kodun ara temsilidir. Bu, bizim fonksiyonumuz gibi basit fonksiyonları ifade etmek için harikadır myAddyukarıda gösterilmiştir ancak bu bölümde tartışılan fonksiyon gibi daha karmaşık fonksiyonlar için uygun değildir sum. Özellikle döngünün yürütülmesi sırasında değişkenler değişir i и n.

SSA, sözde talimat kullanılarak değişken değerlerin atanmasına ilişkin kısıtlamayı atlar phi (adı Yunan alfabesinden alınmıştır). Gerçek şu ki, C gibi diller için kodun SSA temsilinin üretilmesi için bazı hilelere başvurmanız gerekiyor. Bu talimatın çağrılmasının sonucu değişkenin geçerli değeridir (i veya n) ve parametre olarak temel blokların bir listesi kullanılır. Örneğin şu talimatı göz önünde bulundurun:

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

Anlamı şu şekildedir: Eğer önceki temel blok bir blok ise entry (giriş), ardından t0 bir sabittir 0ve eğer önceki temel blok for.body, o zaman değeri almanız gerekir t6 bu bloktan. Bunların hepsi oldukça gizemli görünebilir, ancak SSA'nın çalışmasını sağlayan şey bu mekanizmadır. İnsan açısından bakıldığında, tüm bunlar kodun anlaşılmasını zorlaştırır, ancak her değerin yalnızca bir kez atanması, birçok optimizasyonu çok daha kolay hale getirir.

Kendi derleyicinizi yazarsanız genellikle bu tür şeylerle uğraşmanıza gerek kalmayacağını unutmayın. Clang bile tüm bu talimatları oluşturmuyor phibir mekanizma kullanır alloca (sıradan yerel değişkenlerle çalışmaya benzer). Daha sonra, bir LLVM optimizasyon geçişi çalıştırırken çağrıldı mem2reg, talimatlar alloca SSA formuna dönüştürüldü. Ancak TinyGo, zaten SSA formuna dönüştürülmüş olan Go SSA'dan girdi alır.

Söz konusu ara kod parçasının bir başka yeniliği, dilim elemanlarına indeks yoluyla erişimin, adresi hesaplama işlemi ve sonuçta ortaya çıkan işaretçinin referansını kaldırma işlemi şeklinde temsil edilmesidir. Burada sabitlerin IR koduna doğrudan eklendiğini görebilirsiniz (örneğin - 1:int). Fonksiyonun olduğu örnekte myAdd bu kullanılmadı. Artık bu özellikleri bir kenara bıraktığımıza göre, bu kodun LLVM IR formuna dönüştürüldüğünde neye dönüştüğüne bir göz atalım:

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
}

Daha önce olduğu gibi burada da diğer sözdizimsel yapıları içeren aynı yapıyı görebiliriz. Örneğin aramalarda phi değerler ve etiketler değiştirildi. Ancak burada özellikle dikkat edilmesi gereken bir şey var.

Başlangıç ​​olarak burada tamamen farklı bir fonksiyon imzasını görebilirsiniz. LLVM dilimleri desteklemez ve sonuç olarak bir optimizasyon olarak bu ara kodu oluşturan TinyGo derleyicisi bu veri yapısının açıklamasını parçalara ayırır. Üç dilim öğesini temsil edebilir (ptr, len и cap) bir yapı (yapı) olarak kullanılabilir, ancak bunları üç ayrı varlık olarak temsil etmek bazı optimizasyonlara izin verir. Diğer derleyiciler, hedef platformun işlevlerinin çağrı kurallarına bağlı olarak dilimi başka şekillerde temsil edebilir.

Bu kodun bir başka ilginç özelliği de talimatın kullanılmasıdır. getelementptr (genellikle GEP olarak kısaltılır).

Bu talimat işaretçilerle çalışır ve bir dilim öğesine işaretçi elde etmek için kullanılır. Örneğin C dilinde yazılmış aşağıdaki kodla karşılaştıralım:

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

Veya buna aşağıdaki eşdeğerle:

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

Burada en önemli şey talimatların getelementptr referans kaldırma işlemlerini gerçekleştirmez. Sadece mevcut işaretçiyi temel alarak yeni bir işaretçi hesaplar. Talimat olarak alınabilir mul и add donanım düzeyinde. GEP talimatları hakkında daha fazla bilgi edinebilirsiniz burada.

Bu ara kodun bir başka ilginç özelliği de talimatın kullanılmasıdır. icmp. Bu, tamsayı karşılaştırmalarını uygulamak için kullanılan genel amaçlı bir talimattır. Bu talimatın yürütülmesinin sonucu her zaman türünde bir değerdir. i1 — mantıksal değer. Bu durumda anahtar kelime kullanılarak bir karşılaştırma yapılır. slt (küçük olarak işaretlenmiştir), daha önce tür tarafından temsil edilen iki sayıyı karşılaştırdığımız için int. İki işaretsiz tam sayıyı karşılaştırıyorsak, şunu kullanırız: icmpve karşılaştırmada kullanılan anahtar kelime şu olacaktır: ult. Kayan nokta sayılarını karşılaştırmak için başka bir komut kullanılır: fcmp, benzer şekilde çalışır.

sonuçlar

Bu materyalde LLVM IR'nin en önemli özelliklerini ele aldığıma inanıyorum. Elbette burada çok daha fazlası var. Özellikle, kodun ara temsili, optimizasyon geçişlerinin, kodun derleyici tarafından bilinen ve IR'de başka türlü ifade edilemeyen belirli özelliklerini hesaba katmasına olanak tanıyan birçok ek açıklama içerebilir. Mesela bu bir bayrak inbounds GEP talimatları veya bayrakları nsw и nuwtalimatlara eklenebilecek add. Aynı şey anahtar kelime için de geçerli private, optimize ediciye, işaretlediği işleve geçerli derleme biriminin dışından başvurulmayacağını belirtir. Bu, kullanılmayan argümanların ortadan kaldırılması gibi birçok ilginç prosedürler arası optimizasyona olanak tanır.

LLVM hakkında daha fazla bilgiyi şurada bulabilirsiniz: belgelemeKendi LLVM tabanlı derleyicinizi geliştirirken sıklıkla başvuracağınız. Burada liderlik, çok basit bir dil için bir derleyici geliştirmeye bakıyor. Bu bilgi kaynaklarının her ikisi de kendi derleyicinizi oluştururken sizin için yararlı olacaktır.

Sevgili okuyucular! LLVM'yi kullanıyor musunuz?

Go perspektifinden LLVM

Kaynak: habr.com

Yorum ekle