ÖNEMLİ NOT: Eğer C# özelinde bellek yönetimi konusunda bilgi aşinalığınız yoksa, bu yazıda anlatılan bazı yerler anlaşılmayabilir. Bu konuda daha önce yazdığım C# Özelinde Bellek Yönetimi ve opsiyonel olarak Garbage Collection yazılarımı okumanızı tavsiye ederim. Eğer bu konularda bilginiz varsa, direkt yazıyı okumaya başlayabilirsiniz.
Performans odaklı kod yazma, yazılım mühendisliğinin odak noktalarından biri oldu ve olmaya da devam ediyor. Bunun için eski dönemlerde daha fazla dikkat edilmesi gereken adımlar varken artık bazı süreçler otomatikleştirildi. Donanım tarafındaki bu otomatikleştirmelerin de üst seviyede kod yazan yazılım mühendislerinin işlerini hızlandırdığından bahsedebiliriz. Ama buna rağmen, kod yazdığımız durumlara göre, mevcut pratiklerin yetersiz kaldığı ve bizim kendi tarafımızda önlemler almamızı gerektiren senaryolar ile mutlaka karşılaşıyoruz.
Tam da bu noktada, performansın kritik öneme sahip olduğu projelerde çalıştığımız durumlarda kendimizin de ek önlem alması gereken durumlar ortaya çıkıyor. Yazdığımız kodun daha performanslı çalışmasını sağlamak, biz yazılım mühendislerine düşüyor. Bu konuda neler yapabileceğimize bir bakalım:
Önbellekler
Yazılım mühendisliğinde, yazdığımız kodlardaki data erişimini hızlandırmak için bazı yöntemler keşfedilmiştir. Bunlardan biri de önbellek adını verdiğimiz CPU’da yer alan özel bir depo alanıdır. Önbellekler, sık erişim yapıldığı tespit edilen dataların kopyasının tutarlar. Bellekten bir datanın çekilmesi gerektiğinde, eğer önbellekte bu data yer alıyorsa, bellekten data çekme yapılmadan direkt önbellekten data çekilir ve CPU registerlarında gerekli işlemleri görmeye başlar. Bu da, bellekteki data arama maliyetini ciddi anlamda azaltır çünkü günümüz belleklerinin boyutunun GB’lar seviyesinde olduğunu düşünürsek ciddi bir arama maliyetinden bahsediyoruz.
Önbellekler, asıl bellekler kadar büyük değildirler. Bunun sebebi de, önbellek boyutunun büyümesinin amacından sapması olarak yorumlanabilir. Boyutun artması demek, aranan datanın kolay bir şekilde bulunamaması anlamına gelir. Bu nedenle önbelleklerin boyutu mümkün olduğu kadar küçük ama pek çok arama işleminden de maliyet kısacak şekilde ayarlanmıştır.
Moden CPU’larda önbellek, 3 kısma ayrılır. Bu kısımlar hiyerarşik bir yapıdadır. Daha hızlı önbellekte veri erişimi yapmak için hiyerarşik yapıya ayrılmışlardır. Bu kısımları şöyle inceleyebiliriz:
L1 Önbelleği: Birincil önbellek olarak da bilinir. En küçük ama en hızlı önbellek katmanıdır. Değişkenlik göstermekle birlikte, 128 KB boyutuna kadar data barındırabilir. Etkinliğini daha da arttırmak adına, bu katman da iki parçaya bölünmüştür. L1-D olarak ayrılan ilk parça, sadece data depolamasıyla ilgilenir ve bellekte daha önce erişilmiş dataların kopyasını bünyesinde barındırır. L1-I olarak ayrılan ikinci parça, daha önceden bu data üzerinde yapılan CPU talimatlarını depolar ve CPU’da yer alan Fetch Unit adını verdiğimiz birim direkt olarak bu talimatları çekip CPU’da işlem uygulanmasını sağlar. CPU talimatları, bilgisayar belleklerinde yer alan kod segmenti adı verilen bölümde depolanırlar. Bu da, kodumuzun compile edilirken düşük seviyede çalıştırılabilmesi adına makine koduna çevrilmesi ile gerçekleştirilir. CPU talimatları da tam bu noktada segmentte kaydedilir. Daha önceden belirli bir data üzerinde çalıştırılan talimatlar da CPU’daki L1-I önbelleğinde depolanır ve böylece kod segmentine tekrardan gidip talimat çekme ihtiyacı doğmaz.
L2 Önbelleği: L1 önbelleğinden daha büyüktür. Genelde CPU’daki çekirdeklere eşit olarak dağıtılır fakat bazı CPU’larda tek bir L2 önbelleği yer alır ve diğer CPU çekirdekleri tarafından ortak kullanılır. L1 önbelleğinde bulunamayan data, bu önbellekte kontrol edilir. Toplamda 32 MB boyutuna kadar çıkabilir ama çoğunlukla bu boyutun altındadır. L1 önbelleği kadar hızlı olmasa da RAM üzerinde arama yapmaktan yaklaşık 25 kat daha hızlıdır.
L3 Önbelleği: L2 önbelleğinde de aradığımız data bulunmadıysa o zaman buraya bakılır. Her bir çekirdekte bulunan bir önbellek değildir. CPU üzerinde ayrı bir önbellek olarak yer alır ve her çekirdeğin buraya erişim sağlaması mümkündür. Bu nedenle de hız avantajının oldukça kaybedildiği bir önbellektir. RAM üzerinde arama yapmaktan yaklaşık 2 kat daha hızlıdır. L1 ve L2 önbellekleri kadar hız avantajı sağlamaz. Fakat RAM arama maliyetinden daha hızlı olduğu için cache kaçırma (cache miss) ihtimalini düşürür. Yani bellekten çekmekten daha hızlı olduğu için yine de kullanım avantajı vardır. 64 MB boyutuna kadar çıkabilir fakat genelde L2 önbelleğinin boyutuna yakındır.
Önbellekte datalar, tek başına ayrı ayrı tutulmazlar. Önbellek satırları (cache lines) adı verilen formatlar şeklinde tutulurlar. Her satır, aşağı yukarı 64 byte kadar data depolar. x86 ve ARM işlemcilerinde standart boyut 64 byte olsa da 32 veya 128 byte olarak da boyut değişkenlik gösterebilir.
Önbellek satırlarının hazırlanması, CPU tahmin algoritmaları ile gerçekleşir. Önbellek satırları, runtime’da oluşturulur. Yani projeyi compile ederken oluşturulmazlar. Eğer önbellekte data yoksa, bellekten data çekilir ve önbellek satırı oluşturularak L1 önbelleğine yerleştirilir. Eğer L1 önbelleği dolduysa, bu önbellekteki en az erişilen önbellek satırları L2 önbelleğine taşınır. Aynı şekilde L2 önbelleği dolduğunda L3 önbelleğine taşıma yapılır. Fakat herhangi bir önbellekten çıkarılan önbellek satırı, eğer hiç bir önbelleğe yerleştirilemiyorsa ya RAM belleğine geri gönderilir ya da tamamen silinir. Bu işlem yapılırken de, çıkartılan veya silinen önbellek satırlarının en az kullanılan satırlar olmasına dikkat edilir. Burada amaç, önbelleği mümkün olduğu kadar güncel tutmaktır.
Yerellik
Önbellek satırlarının önbellekte verimli bir şekilde yerleştirilmesi önem taşır. Buna yerellik (locality) adı verilir. Önbellek satırları oluşturulurken, CPU çeşitli varsayımlar yaparak bellekten çektiği dataları bir önbellek satırına çevirir. Yerellik, bunun ne kadar efektif yapıldığı ile alakalıdır.
2 tür yerellik yaklaşımı vardır. Bunların ne olduğuna daha detaylı bakalım:
Zamansal Yerellik (Temporal Locality)
Yazdığımız kodda işlem gören bir datanın, devamlı olarak erişileceği varsayımı ile önbellek satırının hazırlandığı yerellik yöntemidir. Zamansallıktan kasıt, belirli bir süre daha aynı data ve aynı CPU işlemlerinin çağırılacağı üzerine belirtilir. Bir örnekle bunu açıklayalım:
Yukarıdaki kodumuzda, bir for döngüsü yer alıyor. Bu döngüde, bir toplamDeger değişkenimiz var. For döngüsü içerisinde bu değişkene devamlı olarak ekleme yapıyoruz.
Bu kodumuzda, yer alan toplamDeger değişkenimiz, for döngüsü içerisinde sürekli erişim yapılan bir değerdir. Aynı zamanda da her for döngüsünde bu değişkene bir toplama işlemi yapıyoruz ve sayıyı arttırıyoruz. Bu kodu ilk kez çalıştırdığımızda, CPU önbelleğinde bu kod üzerinde işlem gören değişkenin ve değişken üzerinde yapılan CPU talimatlarının bilgisi bulunmadığı için öncellikle bellekten hem değişkenin adresi ile bir kopyası, hem de yapılması istenen CPU talimatlarının bilgisi çekiliyor. CPU tahmin algoritması, bunun sürekli tekrar eden bir işlem olduğunu tespit ediyor. Bundan dolayı da toplamDeger değişkeni üzerinde yapılan CPU talimatlarını (yani toplama ve değeri güncelleme talimatlarını) ve değişkenin bir kopyasını bir önbellek satırına kaydediyor. Değişkenin bellekteki adresini de etiketleyerek bünyesinde barındırıyor. Böylece bu değişkene hızlı bir şekilde erişim sağlayıp, her toplama işlemi sonunda, değişkeni güncelleyebiliyor.
Bu işlemin sık tekrarlanmasından ötürü, oluşturulan önbellek satırı L1 önbelleğine kaydediliyor. L1 önbelleğinin L1-I kısmına, bu toplama işleminde kullanılan CPU talimatları kaydedilirken L1-D kısmına ise değişkenin kopyası ve bellekteki adresini tutan bir etiket data kaydediliyor. Tabii ki bütün bunlar birer önbellek satırına CPU tarafından yazılıyor ve önbelleğe bundan sonra kaydediliyor.
Fakat bu metodun çağırılmadığı durumlarda, artık önbellekte gereksiz yer tutmaya başlar. Önbelleklerdeki alan doluysa, bu metoda ait dataları barındıran önbellek satırı önbellekten çıkartılır. Eğer diğer önbelleklere de bu önbellek satırı yerleştirilemiyorsa o zaman burada iki seçenek vardır. Duruma göre ya RAM’e geri yazılır, ya da işlev görmediği için silinir. (Yazıyı daha kısa tutmak için bu kısmı detaylandırmıyorum ama İngilizce bilginiz yeterliyse, Cache Eviction başlığı altında MicroChip sitesinde bunun mantığını anlatan yazıya erişebilirsiniz.)
Mekansal Yerellik (Spacial Locality)
Zamansal yerellikten farklı olarak, bir data grubunun sıklıkla erişileceği varsayımı yapılarak önbellek satırının oluşturulması yöntemine mekansal yerellik denir. Bu yerellik mantığı ile oluşturulan önbellek satırları, bir datanın komşu datalarına da benzer sıklıkta erişileceğini düşünerek belirli bir data grubunu bir arada barındırır. Bunu da bir örnekle açıklayalım:
Bu kodumuzda bir sayiListesi liste değişkenimiz bulunuyor. Bir for döngüsü içerisinde, her indexi bu listeye bir değer olarak ekliyoruz. Bu kodu ilk kez çalıştırdığımızda, CPU önbelleğinde bu kod üzerinde işlem gören değişkenin ve değişken üzerinde yapılan CPU talimatlarının bilgisi bulunmadığı için öncellikle bellekten hem değişkenin adresi ile bir kopyası, hem de yapılması istenen CPU talimatlarının bilgisi çekiliyor. CPU tahmin algoritması, burada bir liste değişkeni olduğunu ve bunların bir grup halinde koleksiyon olarak tutulacağını ön görüyor. Bundan dolayı da bu liste değişkenini ve içeriğini bir önbellek satırına, yine mekansal yerellik oluşturulmasındaki teknik aşamaları gerçekleştirerek, kaydediyor. Böylece liste elemanları, mümkün olduğu kadar bir arada tutuluyor ve bellekten devamlı olarak liste elemanlarını arayıp çekme ve liste operasyonlarını sürekli bellekten isteme gereksinimi oluşmuyor.
Dikkat Edilmesi Gerekenler
CPU’ların bünyesindeki önbellek işleyişi, çoğu zaman yeterli olsa da biz yazılım geliştiricilerinin de dikkat etmesi gerektiği konular vardır. Yazdığımız kodun mümkün olduğu kadar önbellek dostu bir kod olması, projelerimizin daha performanslı ve efektif çalışmasını sağlar. Bu konuda yapabileceğimiz basit ama önemli değişimler ile daha performanslı kodlar yazabiliriz. Bunların bazılarına şu şekilde değinebiliriz:
1-) Doğru Data Yapılarını Tercih Etme
Önbellek oluşturulurken, önbelleğin kaçırılması ve önbelleklerin tekrar organize olması durumunu minimale indirmek için doğru data yapılarını kullanmalıyız. Mesela en son verdiğimiz liste örneğine geri dönelim:
Bu örnekte, önbellek satırlarını etkileyebilecek çok önemli bir detay var. Listeler, dinamik yapıda oldukları için sabit bir boyuta sahip değildir. Yani, runtimeda listenin boyutunun yetmediği durumlarda yeni bir liste değişkeninin bellekte oluşturulması ve yeni alanın tahsis edilmesi gerekir. Bu da hem bellek yönetimi konusunda olumsuz bir durum oluşturur ama hem de önbellek satırlarının geçersiz olmasını sağlar. Çünkü önbellek satırında listeye ait tutulan datalar, her liste değişiminde geçersiz hale gelir çünkü listenin güncel hali ile önbellekteki liste bilgisi birbiri ile uyuşmaz. Bu da cache miss adını verdiğimiz önbellek kaçışına sebep olur ve önbellek satırı silinir. Bunu engellemek için yapılabilecek bazı şeyler vardır:
Listeleri kapasite ile tanımlayabiliriz. Bu da, eğer bir listenin belirli bir eleman sayısından fazla elemana sahip olmayacağı ön görülebiliyorsa, bellekte yeni alan tahsisi yapılması ihtiyacını ortadan kaldırır. Bu nedenle cache miss olasılığını azaltmış oluruz.
Fakat listeler, dinamik yapılar oldukları için içerisine eleman ekleme ve çıkarma yapılabilir. Bu da, önbellekteki liste datasının geçersiz olması ihtimalini arttırır. Çünkü listeler, değişebildikleri için önbellekte her liste operasyonunda listenin tıpkı önbellekte olduğu gibi olması gerekse de sadece bir eleman çıkarma veya silme operasyonu bile listenin değiştiğini sinyaller. Yani datalar uyuşmaz ve önbellek satırının iptal edilmesi gerekir. Bu nedenle listeler kullanıldığında, mümkün olduğu kadar az işlem görmelerini sağlarsak cache miss ihtimalini düşürürürüz.
Array ve Span gibi statik yapıdaki data yapıları daha sık kullanılabilir. Eğer bir grup elemanın runtime’da neredeyse hiç işlem görmeyeceği tahmin edilebiliyorsa, liste gibi dinamik bir yapı yerine array gibi daha statik yapıda bir değişken tanımlaması yapılabilir. Yukarıdaki liste örneğimizdeki listeyi array olarak kullanalım:
Bu şekilde yaptığımız düzenlemede, arrayın boyutunun değiştirilememesinden ötürü bellekteki yerini sabitledik. Aynı zamanda listeye ekleme ve çıkarma yapılmasının mümkün olmayacağından ötürü de önbellek satırında tutulan arrayin geçerli kalmaya devam etmesi ihtimalinin de yüksek olduğunu onayladık. Burada yapılacak en fazla değişiklik, array içerisindeki bir elemanın değerinin değiştirilmesidir. Ama bu da cache miss olacağı anlamına gelmez. Sadece bu önbellek satırında bir değişim gerçekleştiği not edilir, her değişim geçiren önbellek satırının da geçersiz kılınması gerekmez. Bazı durumlarda RAM belleğine geri yazılması mümkündür ama bu da spesifik bir durumdur ve liste manipülasyonu kadar pahalı bir işlem değildir.
2-) Class ve Struct Arasında Doğru Tercih Yapma
Classlar, referans türünde değişkenlerdir. Yani heap belleğinde depolanırlar. Bu da, her ne kadar kullanışlı olsalar da, performans maliyetini beraberinde getirdiklerine bir işarettir. Kullanışlı olsalar da, nispeten basit data depolama yapılacağı durumlarda, struct kullanmak çok daha iyi bir çözümdür.
Struct, değer tipindeki bir değişkendir. Yani stack belleğinde depolanır. Buna ek olarak da, bünyesinde kendisi gibi değer değişkenlerini barındırdığında aslında bir grup halinde kendi içerisindeki değişkenleri barındıracağı garantisini sunar. Bu da, struct barındıran önbellek satırlarının değişiklik yaşama ihtimalini azaltır. Eğer int,float,bool gibi ilkel değişkenleri bir grup halinde tutmanız gerekiyorsa mutlaka class yerine struct kullanın. Spesifik ihtiyaçlar dışında struct kullandığınız takdirde, hem daha iyi bir bellek yönetimi sağlamış hem de daha optimize bir önbellek satırı dizaynını desteklemiş olursunuz.
3-) Array Of Structs (AoS) ve Struct of Arrays (SoA)
Önbellek satırlarını oluştururken, mümkün olduğu kadar bir satırda yerellik destekleyen şekilde data depolamak oldukça önemlidir. Fakat class ve struct gibi data depolarının olması gerektiğinden büyük olduğu senaryoda, bu class ve structlara erişim sağladığımız takdirde sadece ilgili dataya değil diğer alakasız datalara da erişim sağlamış olacağız. Bir örnek üzerinden açıklayalım:
Yukarıda bir struct oluşturduk. Burada bir oyundaki karakterlerin kimlik numarasını ve yaşını depoluyoruz. Buraya kadar problem yok, fakat problem aşağıdaki örnekte başlıyor:
Az önce tanımladığımız struct değişkenini bir array içerisinde topladık. Çünkü bu karakter bilgilerini bir array içerisinde depolamak istiyoruz ki istediğimiz zaman bu koleksiyon dataya erişebilelim. 1000 kapasiteli bir array içerisinde bu bilgileri tutuyoruz. Bir sonraki metot içerisinde de karakterlerin yaş bilgisini çekip konsola yazdırıyoruz. Fakat bu arraydaki her elemanın içinde sadece yaş bilgisi bulunmuyor, aynı zamanda kimlik numarası bilgisi de bulunuyor. Yani sadece yaş bilgisine ihtiyacımız varken bu sefer kimlik numarası bilgisini de gereksiz yere çekmiş oluyoruz ve önbellek satırında daha az yaş bilgisi olduğu için bu sefer cache miss yaşama ihtimalimiz artıyor. Eğer sadece yaş bilgisini bir struct içerisinde tutsaydık, ya da yaş değerlerini ayrı bir float listesine kaydetseydik, önbellek satırına daha fazla yaş bilgisi sığdırabilirdik. Her bir önbellek satırının 64 byte data kapasitesine sahip olduğunu düşünürsek, sadece yaş değerlerini barındıran bir data toplamda 16 yaş bilgisini bünyesinde barındırabilir. Bu da ilk 16 elemanda arama yaptığımız durumların daha optimize olmasını sağlar. Depoladığımız veri tiplerinin büyük olması durumunda ise, bu arama maliyeti daha yüksek olacaktır çünkü ilk önbellek satırında daha az yaş bilgisi barındırılır. Bu da diğer önbellek satırlarına bakma ihtiyacının artması anlamına geliyor ve cache miss ihtimali artıyor. Yukarıda bahsettiğimiz gibi, bunun önbellekleri yeniden organize etme sıklığını da arttırdığını belirtelim çünkü L1-L2-L3 önbelleklerinin güncel tutulması gerekiyor yoksa önbellek olma avantajlarını kaybederler.
Sonuç
Bu yazıda, mümkün olduğu kadar CPU’da önbellek mantığının nasıl çalıştığına ve bu çalışma mantığını en etkili şekilde nasıl kullanabileceğimize değindim. Şunu unutmamak gerekiyor: Eğer ki nispeten yüksek seviye bir projede çalışıyorsanız çok ince performans hesaplamaları ile kod yazmanız gereken durumlar nispeten daha az karşınıza gelecektir. Fakat mümkün olduğu kadar optimizasyon ihtiyacı olan veya daha düşük seviye projelerde çalışıyorsanız, CPU önbelleğini etkili bir şekilde kullanmak adına bu yazıda anlattığım bilgileri kullanabilirsiniz. Optimize kod yazabilmek, çok güçlü bir yazılım mühendisliği kazanımıdır. Fakat bazen de doğru çalışan kod yazmak ve optimizasyonu birinci öncelik yapmamak da doğru yer ve zamanda önemlidir. Gereksiz optimizasyon yerine doğru optimizasyonu tercih edin.
Herkese iyi çalışmalar diliyorum! Yorumlarınızı ve eklemek istediğiniz şeyleri yazının altında paylaşabilirsiniz!