top of page

Cache Architecture

  • Writer: Yusuf Hançar
    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.

ree
İş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


ree

İş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:

  1. Thread'lerin Paylaştığı Cache Line: İki veya daha fazla thread, aynı cache line'daki farklı verileri güncellemeye çalışır.

  2. 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.

  3. 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


bottom of page