Baştan Uyarayım: Baya bir uzun yazı olacak. Eğer compiler'ın nasıl çalıştığını merak etmiyorsanız geçin. Anlatımı C dili üzerinden yapacağım.
Derleyiciler, yazdığınız yüksek seviyeli kodu doğrudan makine diline çeviremez. Bunun yerine, birçok aşamadan geçirerek işler.
Eki Görüntüle 139370
İşte tam da bu yazıda bu aşamaları açıklayacağım:
1. Lexical Analysis (Sözcüksel Analiz):
Derleyici, insan gibi yazdığınız
float mrb = 12.0f; gibi bir satırı doğrudan anlayamaz. Önce bu satırı parçalara ayırarak
tokenize etmesi gerekir, bunu da bu şekilde yapar:
- float → anahtar kelime (veri türü)
- mrb → değişken ismi
- = → atama operatörü
- 12.0f → sayı
- ; → noktalama
Gerçek hayattan örnek:
"Ben elma yedim."
→ Ben (zamir), elma (isim), yedim (fiil), . (noktalama)
2. Syntax Analysis (Sözdizimsel Analiz):
Token'ler sıralama olmadan bir araya gelirse hiçbir şey oluşturmaz, derleyici, bir
ağaç yapısı (parse tree) oluşturarak bunu kontrol eder:
Eğer yazım hatası varsa burada hata verir.
Gerçek hayattan örnek:
"Ben elma yedim." doğrudur ama "Yedim elma ben." bozuk bir cümledir, hata verir.
3. Semantic Analysis (Anlamsal Analiz):
Bu aşamada, sözdizimi doğru bile olsa,
anlam hataları tespit edilir. Örnek:
kod.c
C:
#include <stdio.h>
void Fonksiyonum();
int main()
{
int test = 20.2f; // Hatalı, integer'a float değerinde bir sayı veremezsiniz çünkü tamsayı bekler, ya hata alırsınız ya da uyarı alıp kayıplı bir dönüştürme yapmak zorunda kalırsınız.
float test = 212.0f; // Hatalı, "test" isimli değişken zaten yukarıda tanımlanmış.
int mrb = mrb2; // Hatalı, mrb2 diye bir değişken yok.
Fonksiyonum(); // Doğru, Fonksiyonun adresini tespit etmek derleyicinin işi değil bağlayıcının işi, eğer sorun varsa bağlayıcı hata verir.
Fonksiyonum2(); // Hatalı, Fonksiyonum2() hiçbir yerde tanımlanmamış.
return 0;
}
Gerçek hayattan örnek:
"Ben televizyonu kustum." cümlesinde gramer doğru olabilir ama mantık hatası içerir, yani hatalı.
4. Intermediate Code Generation (Ara Kod Oluşturma):
Makine koduna geçmeden önce, derleyici kodu bir
ara dile (örneğin LLVM IR) çevirir. Bu sayede platformdan bağımsız çalışabilir ve bir sonraki aşamaları daha verimli yapabilir.
Örneğin:
Kod:
// C kodu
int topla(int a, int b) { return a + b; }
Kod:
// Rust kodu
pub fn topla(a: i32, b: i32) -> i32 { a + b }
LLVM IR üzerinde aynı koda dönüşür:
Kod:
// LLVM-IR Kodu (LLVM-IR'de yorum yok aslında, ama aramızda kalsın :P)
define i32 @topla(i32 %a, i32 %b) {
%result = add i32 %a, %b
ret i32 %result
}
Gerçek hayattan örnek:
Diyelim ki elinizde Türkçe, İspanyolca ve Almanca bir cümle var, derleyici bunlarla ayrı ayrı uğraşmak yerine hepsini İngilizce diline çevirip oradan devam ediyor, böylelikle daha rahat iş yapabiliyor.
5. Optimizasyon:
Derleyici, kodunuzu daha verimli çalışması için
optimize eder. Örneğin:
C:
int a = 44 + 38; // Derleyici bunu doğrudan int a = 82; yapar, böylelikle işlemsiz olur ve kod hızlanır.
Kayan sayılarla ilgili çok daha ilginç bir örnek vereyim:
C:
void add4(float *a, float *b, float *result)
{
for (int i = 0; i < 4; i++) {
result[i] = a[i] + b[i];
}
}
Makine dilinde kayan noktalı sayılar (floating point) tamsayılar gibi doğrudan aritmetik birimlerde (ALU) işlenemez. Bu işlemler özel olarak tasarlanmış
FPU (Floating Point Unit) birimlerinde gerçekleştirilir. Ancak bu x86 üzerinde ana FPU (x87) yaklaşık 45 yıllık bir mimari temele dayandığı için berbat çalışır.
Günümüzde matris çarpımı gibi vektör-tabanlı işlemler için
SIMD (Single Instruction, Multiple Data) tabanlı genişletmeler (örneğin
SSE,
AVX) kullanılır. Derleyiciler, derleme sırasında verdiğimiz
optimizasyon bayraklarına göre bu işlemleri klasik FPU yerine SIMD registerları ile optimize edebilir.
Aşağıda aynı floating point işlemini hem klasik
x87 FPU hem de
AVX komut seti kullanarak derleyip elde ettiğimiz Assembly çıktısını karşılaştıracağız.
X87:
Kod:
add4: ; Fonksiyon girişi
pushl %ebp ; ebp'yi (base pointer) stack'e kaydeder
movl %esp, %ebp ; ebp'yi şu anki esp (stack pointer) değerine ayarlar, fonksiyon içi stack frame oluşturur
subl $16, %esp ; lokal değişkenler için yer ayır (4 * 4)
movl $0, -4(%ebp) ; döngüyü başlatan i değişkenini 4 bytelık bir şekilde 0 sayısına ayarla (int 4 byte'dır)
.L2: ; döngü başlangıcı
movl -4(%ebp), %eax ; eax = i
leal 0(,%eax,4), %edx ; edx = i * 4 (float 4 byte'dır)
movl 8(%ebp), %eax ; eax = a (ilk argümanın adresi float* a)
addl %edx, %eax ; eax = a + i*4 (c dilinde a[i] tarafına karşılık veriyoruz)
flds (%eax) ; st(0) = a[i] (x87 FPU stackine yükle)
movl -4(%ebp), %eax ; eax = i
leal 0(,%eax,4), %edx ; edx = i * 4
movl 12(%ebp), %eax ; eax = b (ikinci argümanın adresi float* b)
addl %edx, %eax ; eax = b + i*4 (b[i]'nin adresi)
flds (%eax) ; st(0) = b[i], st(1) = a[i]
faddp %st, %st(1) ; st(1) = a[i] + b[i], sonra st(0) pop edilir
; faddp: FPU stackinin en üstündeki iki değeri toplar (st(0) + st(1))
; sonucu st(1)'e kaydeder ve st(0)'ı stack'ten çıkarır (pop)
movl -4(%ebp), %eax ; eax = i
leal 0(,%eax,4), %edx ; edx = i * 4
movl 16(%ebp), %eax ; eax = result (üçüncü argümanın adresi float* result)
addl %edx, %eax ; eax = result + i*4 (result[i]'nin adresi)
fstps (%eax) ; result[i] = st(0); sonra st(0) pop edilir
addl $1, -4(%ebp) ; i++
cmpl $3, -4(%ebp) ; karşılaştırma, i 3 veya altında mı kontrol eder
jle .L2 ; eğer öyleyse döngü tekrardan devam eder
leave ; üstteki stackler temizlenir
ret ; fonksiyon döndürülür
Kodu anlamanıza gerek yok, sadece ne kadar uzun olduğuna bakın. İşlemciler uzun talimatlarda daha düşük performansla çalışır ve daha yüksek güç tüketir. Aynısını
AVX ile yapalım:
Kod:
add4:
vmovups xmm0, XMMWORD PTR [rdi] ; a[0..3] -> xmm0
vmovups xmm1, XMMWORD PTR [rsi] ; b[0..3] -> xmm1
vaddps xmm0, xmm0, xmm1 ; xmm0 = a[0]+b[0], a[1]+b[1], a[2]+b[2], a[3]+b[3] (element-wise)
vmovups XMMWORD PTR [rdx], xmm0 ; result = xmm0 (ama unaligned)
ret ; fonkisyon döndürür
xmm registerları 4 adresi aynı anda işleyebilir, resmen GPU gibi çalışır ve bu yapılara
SIMD (Tek talimat, çoklu veri) deriz.
6. Code Generation (Kod Üretimi)
Son aşamada ara kod platforma özgü
Assembly'ye çevrilir ve çalıştırılmaya hazır hale gelir.
Eki Görüntüle 139372
Peki bunları neden anlattım?
Şu sorunun cevabı için:
GPU’lar neden derleyici gibi davranamaz?
Bir GPU’nun bu 6 aşamayı CPU kadar verimli yapamamasının sebebi mimarisinin ve komut setinin farklı olmasıdır.
Amaç?
CPU:
- Genel amaçlıdır (ALU, branch prediction, cache'ler ile optimize).
- Sıralı ve mantıksal işlemlerde mükemmel.
- Talimat başına düşük gecikme, yüksek esneklik (çok fazla şey yapabiliyor).
GPU:
- Paralel işleme uygundur (kriptografi, yapay zeka, blockchain ve rendering gibi yerlerde mükemmel).
- SIMD yapısı vardır (aynı işlemi çok veri üzerinde uygular).
- Dallanma, sıralı karar alma, token analizi gibi işlerde verimsiz.
Derleyiciyi CPU'da kullanmak mantıklı bir iştir çünkü analiz, karar verme, hata denetimi gibi çok adımlı, sıralı mantıksal işlemleri yapabilir (GPU'da her bir karmaşık adımın bütün threadlarda yapıldığını hayal edin, kaos çıkar).
GPU ise bu iş için fazla hantal, fazla paralel, fazla "
aynı anda aynı şeyi yap" mantığına sahiptir. Bu yüzden derleyicileri GPU üzerinde kullanırsanız tam da bu hissiyata kapılırsınız:
Eki Görüntüle 139369
Şaka bir yana, CPU'lar 10 parmakla 10 marifet görürken GPU'lar 10 parmakla 1 marifet görüyor, ama CPU'dan milyon kez daha iyi yapıyor o marifeti.
Makale tarzında oldu biraz ama idare edin, umarım beğenirsiniz