Bu yazıyı okuyorsanız, çok yüksek bir ihtimal ile hayatınızda en az 1 kere if-else kondisyonlarının bulunduğu bir kod parçasını gördüğünüzü varsayıyorum. if-else kondisyonları, sadece bilgisayar bilimleri özelinde değil, bilgisayar bilimlerinin genel kültürde yer edinmesini desteklemesinden ötürü genel olarak pek çok kişinin hafızasına girmiş bir fenomendir. Fakat bu fenomenin, arka planda bir maliyet ile mühendisleri uzun süre meşgul ettiğini de unutmamak gerekir. Peki nasıl bir maliyetten bahsediyoruz?
if-else Kondisyonlarının Çalışma Mantığı
Yukarıdaki kod parçasında, çok basit bir if-else kondisyonu tanımladık. Önce, 5 elemanlı bir int listesi oluşturduk. Bu listeyi tek tek gezicek bir for döngüsü içerisinde liste elemanlarının 30’dan büyük olup olmadığına bakıyoruz. Eğer büyükse ayrı bir loglama, küçükse ayrı bir loglama yapıyoruz. Arkaplanda, bu metodun çalıştırılmasını kısaca şöyle özetleyebiliriz:
1-) Unity’nin mesaj sistemi, Start metodunu otomatik olarak çağıracak ve bu metot çağrıldığı anda stack belleğinde bir stack frame oluşturuluyor olacak.
2-) CMP adını verdiğimiz CPU’daki bir instruction, if döngüsünde verilen iki sayıyı karşılaştırır ve sonuca göre bir flag döndürür. Bu flag, karşılaştırmanın türüne göre değişkenlik gösterir.
3-) Flag değerine göre ya if döngüsüne, ya da else döngüsüne girilip metodun çalıştırılmasına devam edilir.
Basit haliyle bu şekilde özetleyebiliriz ama bahsettiğimiz 2.adım, aslında bu if-else kontrol yapılarının çalıştırılmasındaki en kompleks kısımdır. Peki 2.adımda tam olarak ne oluyor?
CPU’da if-else Karşılaştırması
Modern CPU’lar, günümüzde oldukça hızlı bir şekilde çalışma kapasitesine sahiptirler. Aynı anda pek çok işlemi yapabilmelerinin yanı sıra, bu kadar işlemi çok hızlı da yapabilmeleri söz konusudur. CPU’daki mantıksal işlemler, çeşitli instructionlar aracılığıyla gerçekleştirilir. Bu instructionlar, ihtiyaca göre devreye girer ve görevleri dahilinde yazdığımız algoritmaların işlemden geçirilmesini sağlar. Yukarıda bahsettiğimiz CMP instruction, if-else döngülerindeki karşılaştırmalardan sorumludur. Bu instructionlar, CPU’daki birimler aracılığı ile yürütülür.
Bu birimlerin çalışması ve instructionları uygulamasının zamanla daha hızlı ve verimliliği kaybetmeden yapılmak istenmesi ihtiyacı ortaya çıkmıştır. Bunun sebebi de genel olarak üretilen yazılım ve programların daha komplike bir yapıya ulaşması ve eski basitliklerini kaybetmesidir. Zamanla CPU’lar, buna daha dayanıklı bir şekilde oluşturulmuştur. Fakat sadece CPU’ların aynı işi daha hızlı yapması, ortaya doğan ihtiyacı çözmek için yeterli değildi.
Pipelining
CPU’da çalıştırılan instructionların, daha hızlı çalıştırılmasının yanı sıra aynı anda çoklu bir yapıda çalıştırılabilmesi çözümü ortaya çıktı. Böylece birden fazla instruction aynı anda CPU’da çalıştırılabilecekti. Bu da programların daha da hızlı çalıştırılması anlamına geliyor olacaktı. Bu çözümü uygulamak adına pipelining adı verilen bir teknik geliştirildi. Bu teknikte amaç, çalıştırılan kod parçasında CPU’daki bir çekirdeğin bir instruction üzerinde çalışılırken diğer yandan da kodun sonraki aşamalarını inceleyerek çalıştırılacak instructionları belirlemektir. Bunu belirledikten sonra, instructionlar çeşitli parçalara bölünür ve CPU’daki diğer çekirdeklere ya da tek bir çekirdek içerisinde bu instructionlar dağıtılır. Diğer çekirdeklerin devreye girmesi, kodunuzun multi-threaded bir yapıda olup olmamasına bağlıdır. Single threaded bir kod çalıştırıyorsanız tek çekirdekte işlemler halloluyor olacakken multi-threaded yapıda diğer çekirdeklere de işlem yükünü yayabilirsiniz.
Bu yaklaşımın teoride oldukça kullanışlı ve avantaj sağlayan bir teknik olduğunu söyleyebiliyor olsak da, dezavantajları da bulunmaktadır. Eğer asenkron bir şekilde çalıştırılan bu parçalar, birbirlerindeki dataya ihtiyaç duyarsa o zaman bir bekleme sırası oluşur ve bu tekniğin avantajı kaybolur. Datası istenen instructionın çalışmasının bitmesi beklenir ve sonra diğer instructionlar kaldığı yerden devam eder.
Branch Prediction
if-else kontrol yapıları, yukarıda bahsettiğimiz pipeline tekniği doğrultusunda çalıştırılan döngülerdir. if-else kontrol yapılarının CPU tarafında instructionlar tarafından çalıştırılması, bazı durumlarda oldukça maliyetli bir şekil alabilir. Bunun sebebi de yazılacak if-else kontrol yapılarının potansiyel olarak uzun sürme ihtimallerinin olmasıdır. Yani pipeline tekniği ile aynı anda çalıştırılan instructionların bazılarının, eğer bu if-else kontrol yapılarından çıkacak sonuçlara bir bağlılığı varsa bütün pipeline’ın beklemeye geçmesi durumu oluşur (pipeline stall).
Pipeline stall, if-else kontrol yapılarında, tahmin edilen sonuçların yanlış çıkması durumunda tüm pipeline'ın duraklamasına neden olur. Bu duraklama, tahmin edilen instructionların iptal edilmesi ve doğru instructionların tekrar yüklenmesiyle sonuçlanır. Bu işleme de pipeline flush adı verilir.
Bunun önüne geçmek için de, if-else kontrol yapılarının sonuçlarının bir şekilde tahmin edilip diğer instructionlara bu tahmin sonucunun gönderilmesi ihtiyacı ortaya çıkmıştır. Buna da branch prediction adı verilir. Branch prediction sonucuna göre, sonraki aşamanın instructionları çalıştırılır ve pipelinedaki asenkron çalışmanın devamı sağlanır.
Her bir if-else bloğuna giriş yapıldığında yapılacak ilk instruction, Branch Target Buffer(BTB) adını verdiğimiz bir nevi depo görevi gören bir yapı tarafından bellek adresi olarak kaydedilir. BTB, sadece ilk instruction adresini kaydetmekle kalmaz, aynı zamanda branch prediction tarafından tahmin edilen yolun doğru bir şekilde çalıştırılmasına da olanak sağlar. Eğer prediction sonunda bir herhangi bir if-else kod bloğuna girileceği öngörülürse, BTB bloktaki ilk instruction’a giden hedef bellek adresini sağlar ve böylece pipeline'daki talimatlar kesintisiz bir şekilde yürütümeye devam eder. Burada amaç, if-else kontrol yapılarını doğru tahmin ederek önden bu kontrol yapılarının içindeki kod bloklarındaki instructionları çalıştırmaktır. Böylece diğer instructionlara da ihtiyacı olan veriler önceden sunulabilir hale getirilmiş olur.
NOT: BTB, if-else bloklarının içindeki ilk talimata giden hedef bellek adresini sağlar ancak tahmin yapmakta bir rolü yoktur. Tahmin işlemi, CPU'nun branch predictor mekanizması tarafından yapılır. BTB, yalnızca tahmin edilen branch doğruysa hızlı bir şekilde ilgili instructionlara erişim sağlanması için bellek adreslerini tutmakla görevlidir.
C# ve Unity’de Branch Prediction
C# ve Unity özelinde, branch prediction’ın çalışma mantığı değişkenlik gösterebilir. C#’da JIT (Just In Time) compiler özelinde kullanılan branch doğrultma adı verilen bir teknik kullanılır. Bu teknik, doğru tahmin edilmesi muhtemel if-else kontrol yapılarından en muhtemel olanını bir nevi daha üste taşımaktır. Bu taşıma işlemi, bizzat kodu değiştirmek olarak yapılmaz. Mantıksal olarak o if bloğuna girmenin ihtimali daha yüksek olarak hesaplanır. Bundan dolayı da öncelik, o bloğa tahsis edilir.
Branch doğrultmanın amacı, yukarıda bahsettiğimiz pipeline stall riskini azaltmaktır. Eğer bir tahmin yanlış yapılırsa, bu bütün instructionların iptal edilip baştan çalıştırılması anlamına gelir. Bunun ciddi bir maliyeti vardır ve pipeline tekniğinin yetmediği durumların ortaya çıkması oldukça muhtemeldir. Aynı zamanda, doğru tahmin yapılan durumlarda oldukça akıcı bir şekilde instructionlar çalıştırılmaya devam edilir. Kesintisiz bir şekilde bunun sürmesi, CPU’yu ideal bir şekilde kullanmak anlamına gelir ve performans kazancı anlamına da gelir.
JIT compiler’da bunun yapılması, runtime’da elde edilen sonuçlara göre gerçekleşir. Runtime’da en çok hangi if-else bloğuna girildiyse, o en üst sıraya alınır. Compile time’da buna karar verilmez.
Fakat Unity özelinde, işler biraz değişiyor. Unity’de eğer Mono runtime kullanırsanız Mono’daki JIT compilerı, .NET’in kendi JIT compiler’ındaki branch doğrultma yöntemini kullanır. Ama IL2CPP kullanıldığı durumlarda branch doğrultma yöntemi direkt aynı şekilde kullanılamaz. Çünkü IL2CPP, AOT derleme mantığı ile yani yüksek seviye bir programlama dilinin düşük seviye programlama diline çevrildiği bir derleme mantığı ile çalışır ve spesifik bir C++ compiler tarafından desteklenir. Bu nedenle branch doğrultma ve tahmin yöntemleri, o compilerın bu işlemleri ne kadar verimli halledebildiğine bağlı olarak değişkenlik gösterebilir. Bazı durumlarda compile time’da bir tahmin yapılır fakat bu tahminin her zaman en iyi tahmin olması söz konusu değildir.
Tekrardan bu kod örneğine geri dönelim. Bu kod örneği, basit kalabilir ama branch prediction mantığını iyi bir şekilde açıklayacaktır. Adım adım arka planda if-else döngüsünün nasıl işleneceğini özetleyelim:
İlk başta, elimizde herhangi bir tahmin bulunmaz, çünkü ilk defa bu kod bloğunu çalıştırıyoruz. O yüzden if döngüsüne geldiğimizde, if döngüsü içerisindeki “sayiListesi[0] > 30” kontrolü herhangi bir tahmin olmaksızın CMP instructionı kullanılarak bir doğru ya da yanlış sorgusu yapılacak.
Listenin ilk elemanı 10 olduğu için, if döngüsünün içindeki kondisyon yanlış dönecektir. Bundan dolayı bir sonraki döngü olan else döngüsünün bellek adresine geçiş yapılacaktır ve başka kondisyon kontrolü olmadığı için bu döngü doğru kabul edilecektir. Branch doğrulama yapıldığı durumda, runtime’da baskın döngü else döngüsü olduğu için, yukarıdaki örnekteki else döngüsüne bir sonraki işlemlerde öncelik tanımlanacaktır.
BTB içerisine, else döngüsünün içindeki ilk kod satırının bellek adresi kaydedilecektir. Böylece bir sonraki branch tahminlerinin doğru çıktığı durumda çalıştırılmaya devam edilecek kod bloğu belirlenmiş olacaktır. Bu kod bloğunun bellekteki adresi BTB’ye kaydedilecek ve bir sonraki doğru branch tahminlerinde zaman kaybetmeden kodun çalıştırılması devam edicek.
Listenin 4.elemanı ziyaret edilene kadar, oldukça optimize bir şekilde for döngüsü ve if-else kontrolleri yapılmaya devam edilecek çünkü listenin 4.elemanından önceki tüm elemanlar 30 sayısından küçük veya eşittir.
Listenin 4.elemanına geldiğimizde, bu sefer if kontrolü doğru çıkacaktır ve branch prediction’da hata oluşacaktır. Çünkü 40, 30’dan büyük bir sayıdır. Bu nedenle bir pipeline flush işlemi gerçekleştirilecektir.
for döngüsündeki son iterasyonda, 50 sayısı 30’dan büyük olduğu için güncellenen pipeline instructionları aynı şekilde çalışmaya devam edecektir.
Sonuç
Modern CPU’lar, branch tahmin maliyetlerini oldukça düşüren bir yapıya sahiptir. İmkanların artması ve CPU’ların daha donanımlı hale gelmesi, bu işlemlerin de oldukça verimli çalıştırılmasını sağlamıştır. Yine de, arka planda neler olduğunu bilmek önemlidir, çünkü nispeten if-else kontrol yapılarının yaygın olduğu bir geliştirme yaptığınızda bu kontrol yapılarını olabilecek en optimize şekilde tanımlamak bir şart haline gelebilir. Performansın kritik önem taşıdığı durumlarda, branchleri optimize etmeyi bilmek önemli bir fark yaratabilir.
Herkese iyi çalışmalar diliyorum!