Cache Architecture
- Yusuf Hançar
- Jul 21, 2024
- 6 min read
Modern işlemciler performanslarını artırmak için çeşitli yöntemler kullanır. Bu yöntemlerin başında işlemcinin veriye hızlı erişimini sağlayan önbellek (cache) yapıları gelir. Önbellekler, işlemcinin kullandığı veriyi en hızlı şekilde erişebilmesi için kullanılan özel bellek alanlarıdır. Bu yazımızda, işlemcilerin çalışma prensiplerini ve önbelleklerin işlevlerini inceleyeceğiz.
İşlemci ve Thread Yapısı
Her modern işlemci, içinde bağımsız olarak çalışabilen çeşitli thread'lere sahiptir. Örneğin, bir işlemcide TO ve T1 thread'leri bulunabilir. Bu thread'lerin her biri özel L1 önbelleklerine sahiptir. L1 önbellekleri instruction cache ve data cache olarak ikiye ayrılır.
Instruction cache, işlemcinin çalıştıracağı talimatları (instructions) saklayan önbellektir. İşlemci, programın akışını yönetmek için bu talimatlara ihtiyaç duyar.
if (a > b)
{
// code
}
else
{
// code
}
if, else ve bu blokların içindeki talimatlar, instruction cache'de saklanır. Instruction cache, bu tür talimatları saklayarak işlemcinin hızlı bir şekilde bu kod parçalarını çalıştırmasını sağlar.
CMSIS-Core spesifikasyonu tarafından sağlanan küçük bir fonksiyon grubu tarafından kontrol edilir. Instruction catche etkinleştirmek ve devre dışı bırakmak için işlevler sağlanmıştır. Önbellek içeriğini geçersiz kılma işlevi de vardır. Instruction önbelleği geçersiz kılındığında, tüm geçerli bitler temizlenir ve yüklenen talimatların önbelleği etkili bir şekilde boşaltılır. Yürütme devam ettikçe instruction adresine getirme işlemleri önbelleği yeniden yüklemeye başlayacaktır.
Data cache, işlemcinin kullandığı veri ve değişkenleri saklayan önbellektir. Örneğin, yukarıdaki if-else yapısında kullanılan a ve b değişkenleri data cache'de saklanır. İşlemci, bu değişkenlere hızlı bir şekilde erişerek karşılaştırma işlemini yapar ve sonuçlara göre ilgili talimatları çalıştırır.
Instruction cache, programın talimatlarını sakladığı için işlemci, bu talimatları hızlı bir şekilde alıp çalıştırabilir. Bu sayede, işlemcinin RAM'den talimatları çekmesi gerekmez ve bu da zaman kazandırır. Örneğin, bir döngü (loop) yapısı düşünelim:
for (int idx{}; idx < 1000; ++idx)
{
// Döngü içinde yapılan işlemler
}
Bu döngüdeki talimatlar, instruction cache'de saklanarak işlemcinin her döngü adımında bu talimatları hızlı bir şekilde alıp çalıştırmasını sağlar. Data cache ise, işlemcinin veri ve değişkenlere hızlı erişimini sağlar. İşlemcinin aşağıda detaylıca inceleyeceğimiz branch prediction adı verilen bir mekanizması vardır. Branch prediction, instruction ve data'ları sürekli doğru şekilde örtüştürerek işlemci için hazır bulundurarak işlemcinin performansını artırır.
Cache ve Çalışma Prensipleri
İşlemcilerin, kullandıkları verilere en hızlı şekilde erişebilmek için kendi çalışma frekanslarında çalışan, doğrudan erişim sağlayabilecekleri bellek alanlarına ihtiyaçları vardır. Bu bellek alanlarına önbellek (cache) denir. Önbellekler, işlemcinin RAM'den çok daha hızlı bir şekilde veriye erişmesini sağlar.
İşlemcinin, çalıştırmak için ihtiyaç duyduğu bir veri ilk olarak RAM'den cache'lere aktarılır. Bu süreç, önce L3 cache, ardından diğer cache birimlerinin bu bilgiyi alması şeklinde gerçekleşir. Bir veriyi değiştirmek istediğimizde ise bu işlem tersi yönde devam eder. Tek bir byte bile değişiyor olsa, cache line bloklar halinde veri güncellenir. Bu, işlemcilerin sabit bir bilgi akış genişliğine sahip olmalarından kaynaklanır. Güncel işlemcilerde, cache line genişliği genellikle 64 byte'dır.
Cache Layers
Modern CPU'lar, genellikle L1, L2, L3 ve bazı durumlarda L4 önbellek katmanlarına sahiptir. Bu çok seviyeli önbellek yapısı, işlemcinin veriye erişim hızını artırır. Önbellek katmanlarının sayısı ne kadar düşükse, o kadar hızlı olur.
Önbellek Katmanı | Erişim Süresi | Özellikler |
L1 Cache | ~0.5 ns | En hızlı önbellek, instruction ve data cache olarak ikiye ayrılır. |
L2 Cache | ~7 ns | L1 cache'den sonra gelir, biraz daha yavaştır ama daha büyüktür. |
L3 Cache | 21-34 ns | Tüm çekirdeklerin ortak erişebildiği en büyük önbellektir. |
L4 Cache | 50-70 ns | Nadiren kullanılır, en yavaş ama en büyük kapasiteye sahiptir. |
İşlemci Performansına Etkisi
Önbellekler, işlemcinin performansını artırmada kritik bir rol oynar. İşlemcinin RAM üzerinden veri çekmesi, cache üzerinden çekmesine göre çok daha yavaştır. Bu nedenle, işlemci, sık kullanılan verileri cache'lerde saklayarak erişim süresini minimize eder. Önbellekler sayesinde, sistemdeki diğer bileşenlerin hızları çok daha yavaş olmasına rağmen, donanımlar işlemcinin hızı ile doğru orantılı çalışabilir.
İşlem | Gecikme Süresi |
CPU L1 dCACHE referansı | 0.5 ns |
CPU L1 iCACHE Branch mispredict | 5 ns |
CPU L2 CACHE referansı | 7 ns |
CPU L3 CACHE referansı (line unshared) | 21-34 ns |
Kendi DDR Bellek referansı | 100 ns |
CPU cross-QPI/NUMA en iyi durum | 71 ns |
CPU cross-QPI/NUMA en kötü durum | 202-325 ns |
1K byte veriyi Zippy ile sıkıştırma | 10,000 ns |
2K byte veriyi 1 Gbps ağ üzerinden gönderme | 20,000 ns |
1 MB veriyi hafızadan sıralı okuma | 250,000 ns |
Aynı veri merkezinde bir tur | 500,000 ns |
Disk araması | 10,000,000 ns |
1 MB veriyi ağ üzerinden sıralı okuma | 10,000,000 ns |
1 MB veriyi diskten sıralı okuma | 30,000,000 ns |
Ağ paketi uzak mesefeye gönderme | 150,000,000 ns |
Branch Prediction ve CPU Performansı
Branch prediction, modern CPU tasarımlarında önemli bir bileşendir. Koşullu bir işlemin sonucunu tahmin ederek, en olası sonuca hazırlanmaya çalışır. Bu işlemi gerçekleştiren dijital devreye branch predictor denir. Örneğin, if...else ifadesi gibi koşullu bir işlemin işlenmesi gerektiğinde, branch predictor hangi koşulun büyük olasılıkla karşılanacağını tahmin eder ve en olası sonucun gerektirdiği işlemleri önceden gerçekleştirir.
Bu şekilde, tahminin doğru olduğu durumlarda işlemcinin performansı önemli ölçüde artar. Tahmin yanlış çıktığında ise CPU diğer işlem dalını yürütür ve hafif bir gecikmeye neden olur. Ancak genel olarak, branch prediction doğru tahminlerde bulunduğunda işlemci performansını artırır.
False Sharing
Çoklu işlemci veya çok çekirdekli sistemlerde bellek alt sistemi ile ilgili bir performans sorunudur. Bu problem, farklı thread'lerin aynı cache line'ı paylaşması nedeniyle ortaya çıkar. Aynı cache line içinde bulunan ancak farklı thread'ler tarafından kullanılan veri, cache coherence protokollerinin gereksiz yere bu cache line'ı güncellemesine ve veri transferi yapmasına neden olur. Bu durum, performansın ciddi şekilde düşmesine yol açabilir.
False sharing, şu durumlarda meydana gelir:
Thread'lerin Paylaştığı Cache Line: İki veya daha fazla thread, aynı cache line'daki farklı verileri güncellemeye çalışır.
Cache Coherence Protokolü: Her işlemci çekirdeği kendi cache'ine sahiptir ve bu cache'lerin senkronize olması gereklidir. Cache coherence protokolleri (örneğin, MESI protokolü), bir cache line'ın birden fazla işlemci tarafından aynı anda güncellenmesini engellemek için kullanılır.
Gereksiz Güncellemeler: Aynı cache line'ı paylaşan veriler farklı thread'ler tarafından güncellendiğinde, cache coherence protokolleri bu cache line'ın güncellenmesi için sürekli olarak veri transferi yapar. Bu durum, gereksiz veri transferleri ve beklemelere yol açar, bu da performansın düşmesine neden olur.
#include <atomic>
#include <thread>
#include <iostream>
#include <random>
#include <algorithm>
#include <vector>
#include <chrono>
#include <sstream>
#include <mutex>
using namespace std;
using namespace std::chrono;
template <typename T>
void work(T& val)
{
for (int idx{0}; idx < 10000000; ++idx)
{
val++;
}
}
namespace sharing_single_atomic
{
inline int test_1()
{
atomic<int> val{0};
thread t1([&] { work(val); });
t1.join();
return val.load();
}
inline int test_2()
{
atomic<int> val{0};
thread t1([&] { work(val); });
thread t2([&] { work(val); });
t1.join();
t2.join();
return val.load();
}
inline int test_3()
{
atomic<int> val{0};
thread t1([&] { work(val); });
thread t2([&] { work(val); });
thread t3([&] { work(val); });
t1.join();
t2.join();
t3.join();
return val.load();
}
inline int test_4()
{
atomic<int> val{0};
thread t1([&] { work(val); });
thread t2([&] { work(val); });
thread t3([&] { work(val); });
thread t4([&] { work(val); });
t1.join();
t2.join();
t3.join();
t4.join();
return val.load();
}
}
namespace sharing_atomics_in_one_cache_line
{
inline int test_2()
{
atomic<int> val{0};
atomic<int> val1{0};
thread t1([&] { work(val); });
thread t2([&] { work(val1); });
t1.join();
t2.join();
return val.load() + val1.load();
}
inline int test_3()
{
atomic<int> val{0};
atomic<int> val1{0};
atomic<int> val2{0};
thread t1([&] { work(val); });
thread t2([&] { work(val1); });
thread t3([&] { work(val2); });
t1.join();
t2.join();
t3.join();
return val.load() + val1.load() + val2.load();
}
inline int test_4()
{
atomic<int> val{0};
atomic<int> val1{0};
atomic<int> val2{0};
atomic<int> val3{0};
thread t1([&] { work(val); });
thread t2([&] { work(val1); });
thread t3([&] { work(val2); });
thread t4([&] { work(val3); });
t1.join();
t2.join();
t3.join();
t4.join();
return val.load() + val1.load() + val2.load() + val3.load();
}
}
namespace false_sharing_resolved
{
struct alignas(64) aligned_type
{
atomic<int> val{0};
};
static_assert(sizeof(aligned_type)==64, "structure alignment must be same as cache line");
inline int test_1()
{
aligned_type a;
thread t1([&a] { work(a.val); });
t1.join();
return a.val.load();
}
inline int test_2()
{
aligned_type a;
aligned_type b;
thread t1([&a] { work(a.val); });
thread t2([&b] { work(b.val); });
t1.join();
t2.join();
return a.val.load() + b.val.load();
}
inline int test_3()
{
aligned_type a;
aligned_type b;
aligned_type c;
thread t1([&a] { work(a.val); });
thread t2([&b] { work(b.val); });
thread t3([&c] { work(c.val); });
t1.join();
t2.join();
t3.join();
return a.val.load() + b.val.load() + c.val.load();
}
inline int test_4()
{
aligned_type a;
aligned_type b;
aligned_type c;
aligned_type d;
thread t1([&a] { work(a.val); });
thread t2([&b] { work(b.val); });
thread t3([&c] { work(c.val); });
thread t4([&d] { work(d.val); });
t1.join();
t2.join();
t3.join();
t4.join();
return a.val.load() + b.val.load() + c.val.load() + d.val.load();
}
}
class benchmark {
private:
std::chrono::time_point<high_resolution_clock> t0, t1;
uint32_t* calc;
public :
explicit benchmark(uint32_t* res) : calc(res)
{
t0 = high_resolution_clock::now();
}
~benchmark()
{
t1 = high_resolution_clock::now();
milliseconds dur{duration_cast<milliseconds>(t1 - t0)};
*calc = dur.count();
}
};
struct safecout : std::stringstream {
~safecout()
{
std::lock_guard{ cmtx };
std::cout << rdbuf(); // bufferda ne varsa cout a verir.
std::cout.flush();
}
static inline std::mutex cmtx;
};
void benchmark_code(function<void()> cb)
{
uint32_t dur{0};
{
benchmark bench(&dur);
cb();
}
safecout{} << "duration is : " << dur << "\n";
}
void work_with_sentinel(atomic<int>& val)
{
thread_local uint32_t sentinel{0};
for (int idx = 0; idx < 10000000; ++idx)
{
++sentinel;
}
val += sentinel;
}
int in_test_with_sentinel()
{
atomic<int> val{0};
thread t1([&] { work_with_sentinel(val); });
thread t2([&] { work_with_sentinel(val); });
thread t3([&] { work_with_sentinel(val); });
thread t4([&] { work_with_sentinel(val); });
t1.join();
t2.join();
t3.join();
t4.join();
return val.load();
}
int main()
{
using namespace sharing_single_atomic;
//using namespace sharing_atomics_in_one_cache_line;
//using namespace false_sharing_resolved;
benchmark_code([]() {
auto var{in_test_with_sentinel()};
});
return 0;
}
// atomic<int> değişkenlerinin performansını ölçer ve false sharing sorununu nasıl çözeceğinizi gösterir.
benchmark sınıfı ve benchmark_code fonksiyonu, kod bloğunun çalışma süresini ölçmek için kullanılır.
safecout yapısı, multithread ortamında güvenli cout kullanımı sağlar.
Comments