anasayfa / blog / motor
Yayın: 12 Haziran 2026 11 dk okuma motor
Oyun motorunda C++ bellek yönetimi: kareyi kim yiyor?

Oyun motorunda C++ bellek yönetimi: kareyi kim yiyor?

Kare içinde new çağırmak profiler'da 13 ms'lik sıçramalar üretiyordu; frame arena ve object pool sonrası tepe değer 7.9 ms'e indi. Nasıl yaptığımı anlatıyorum.

Profiler'da 3 milisaniyelik ani sıçrama

Geçen kışın sonunda kendi motorumuzun savaş sahnesini profillerken kare süresi ortalama 9 ms'te duruyordu, ama her birkaç saniyede bir 13 ms'lik kareler geliyordu. Ekranda gözle görülür bir takılma yoktu; yine de 60 FPS hedefinde bu sıçramalar hissediliyordu. Önce render tarafına baktık, çünkü herkes önce oraya bakar. Oysa render süresi kare kare neredeyse sabitti, yani suçlu orada değildi.

Suçlu DamageNumberSystem içindeki tek bir satırdı: her isabet için new DamageLabel(). Saniyede 400 isabetin olduğu bir dalgada bu, kare başına 6-7 heap tahsisi demekti. Tahsislerin çoğu 60 ns sürüyordu, ama arada bir 40 mikrosaniyeyi geçen tek bir tanesi bütün kareyi bozuyordu. Ortalamaya bakan bir grafik bunu asla göstermez, çünkü 400 tahsisin 399'u ucuzdur.

Bu tür sıçramaların hepsi tahsis kaynaklı olmaz; ilk işim nedenleri ayırt etmekti. Kare içindeki her operator new çağrısını sayan basit bir sayaç koyduk ve sayacın tepe yaptığı kareler ile kare süresinin tepe yaptığı kareler birebir örtüştü. Ondan sonrası tartışma konusu olmaktan çıktı.

Oyun motorunda C++ bellek yönetimi tam olarak bu yüzden ayrı bir konu: ortalama maliyet değil, en kötü durum maliyeti önemli. 16.6 ms'lik bir bütçede tek bir kuyruk gecikmesi kareyi kaçırtır ve oyuncu bunu sayılardan önce elinde hisseder.

new/delete kare içinde neden tehlikeli

Genel amaçlı bir allocator deterministik değildir. malloc serbest listelerde uygun bloğu arar, bir kilit alır, bazen işletim sisteminden yeni sayfa ister. Son durumda maliyet nanosaniyeden mikrosaniyeye atlar ve bunun hangi karede olacağını kodu okuyarak bilemezsiniz. Windows tarafında ölçtüğümüz sıçramaların çoğu tam olarak sayfa hatasına denk geliyordu.

İkinci sorun parçalanma. Uzun bir oturumda farklı boyutlarda binlerce kısa ömürlü tahsis adres alanını delik deşik eder; bir süre sonra aynı boyuttaki istek daha pahalıya gelir. Bizde 40 dakikalık bir test oturumunun sonunda aynı kod yolu, oyunun ilk dakikasındakinin iki katı sürüyordu. Konsol tarafında durum daha kötü, çünkü orada aynı sanal bellek esnekliğiniz yok.

Üçüncüsü, çok iş parçacıklı bir motorda her tahsis noktası gizli bir senkronizasyon noktasıdır. Dört WorkerThread aynı anda tahsis yaparsa allocator'ın iç kilidi hepsini sıraya sokar. Profiler'da bunu net bir bekleme bloğu olarak değil, her yere dağılmış küçük gecikmeler olarak görürsünüz; bu yüzden de geç fark edilir.

Dördüncü nokta ölçmenin kendisi. Genel amaçlı allocator'ın maliyeti tek bir yerde toplanmaz, yüzlerce çağrı noktasına dağılır ve hiçbiri tek başına dikkat çekecek kadar büyük görünmez. Bu yüzden bellek yönetimi problemleri sistem sistem bakarak değil, ancak toplamları ölçüldüğünde ortaya çıkar.

Frame arena: kare başına tek sıfırlama

Arena allocator, yani linear allocator, basit bir fikirdir: açılışta büyük bir blok ayır, tahsis istendiğinde sadece bir imleci ilerlet, tek tek serbest bırakma yapma. Kare sonunda imleci başa al ve iş biter. Bizim FrameArena sınıfımız 8 MB ile açılıyor ve tahsis maliyeti bir hizalama ile bir toplamaya iniyor. Boyutu ölçülen tepe kullanımın iki katı seçtik ve her kare sonunda kullanılan bayt sayısını telemetriye yazıyoruz.

Kural şu: arenaya sadece ömrü kareyi aşmayan şeyler konur. Görünürlük listesi, geçici DrawCommand dizisi, fizik broadphase'inin çift listesi, arayüzün o kare için ürettiği metin geometrisi. Kare sınırını aşan hiçbir işaretçi arenadan gelmemeli; yoksa bir sonraki karede üzerine yazarsınız ve bu hata sessizdir.

Reset() çağrısı yıkıcı çalıştırmaz. Bu yüzden arenaya yalnızca önemsiz yıkıcıya sahip, yani trivially destructible tipleri koyuyoruz. Diğerleri için ayrı bir yıkıcı listesi tutmak gerekir, o da arenanın hız avantajının bir kısmını geri yer; pratikte o yolu hiç kullanmadık. Derleme zamanında bir static_assert koyup bu kuralı zorunlu hale getirmek, sonradan çıkacak sızıntıları baştan engelliyor.

Her worker thread'e kendi arenasını verdik. Böylece tahsis yolunda tek bir atomik işlem bile kalmadı ve iş parçacıkları arasındaki gizli çekişme tamamen kayboldu. Arena zaten paylaşılmıyorsa kilide de ihtiyacı yoktur. Dört arenanın toplamı 32 MB tutuyor; kare süresinde kazandığımızın yanında bu bedel önemsizdi.

ArenaAllocator.h
#pragma once #include <cstddef> #include <cstdint> // Bump-pointer arena: allocate cheaply, reset once per frame. class ArenaAllocator { public: ArenaAllocator(void* block, std::size_t bytes) : base_(static_cast<std::uint8_t*>(block)), size_(bytes), head_(0) {} // Returns nullptr when the arena is exhausted; the caller must check. void* Allocate(std::size_t bytes, std::size_t align = 16) { const std::uintptr_t start = reinterpret_cast<std::uintptr_t>(base_); const std::uintptr_t aligned = (start + head_ + align - 1) & ~(align - 1); const std::size_t next = (aligned - start) + bytes; if (next > size_) return nullptr; head_ = next; return reinterpret_cast<void*>(aligned); } // Destructors are NOT run here: store trivially destructible types only. void Reset() { head_ = 0; } private: std::uint8_t* base_; std::size_t size_; std::size_t head_; };
cppArenaAllocator.h

Sabit boyutlu nesneler için object pool

Arena, ömrü kareyi aşan nesneler için işe yaramaz. Mermiler, ses kaynakları ve parçacık emitter'ları saniyelerce yaşar ve rastgele sırayla ölür. Bunlar için pool allocator kullanıyoruz: eşit boyutlu bloklardan oluşan tek bir dizi ve boş blokları gösteren bir serbest liste. Blok boyutu sabit olduğu için parçalanma diye bir sorun da kalmıyor.

Bellek havuzunun güzel yanı, serbest listeyi blokların kendi içinde tutabilmeniz. Boş bir blok zaten kullanılmadığı için ilk 8 baytına bir sonraki boş bloğun adresini yazarsınız; ayrı bir veri yapısı, ayrı bir tahsis yok. Acquire() ve Release() böylece sabit zamanlı ve neredeyse dallanmasız hale gelir.

Havuzdan gelen nesnelerin işaretçisini dışarıya vermiyoruz. Her mermiye 32 bitlik bir indeks ve bir nesil sayacı veriyoruz; mermi öldüğünde nesil artıyor ve başka bir yerde kalmış eski referans kendiliğinden geçersiz hale geliyor. Bu, use-after-free hatasını çökmeden önce yakalayabildiğiniz sessiz bir başarısızlığa çeviriyor.

ProjectilePool için kapasiteyi 4096 seçtik; ölçtüğümüz en yoğun dalgada tepe kullanım 2870'ti. Havuz dolarsa büyütmüyoruz, en eski mermiyi geri dönüştürüyoruz. Kare içinde büyüme yapmak, arenayı ve havuzu kullanma sebebimizi ortadan kaldırırdı. Kapasiteyi her sürümde bir kez, telemetriden gelen tepe değere bakarak gözden geçiriyoruz.

Cache locality, hizalama ve false sharing

Allocator değiştirmenin asıl kazancı tahsis süresinden değil, nesnelerin bellekte yan yana durmasından geliyor. Havuzdan gelen 2870 mermi bitişik duruyor; güncelleme döngüsü 64 baytlık cache line'ları sırayla okuyor ve donanım prefetcher'ı devreye giriyor. Aynı sayıda mermiyi new ile dağıttığımızda cache miss oranı üç katına çıkıyordu.

Sıcak verinin küçük kalması bunun ikinci yarısı. Projectile yapısını 96 bayttan 48 bayta indirdiğimizde tek cache line'a iki mermi sığdı ve güncelleme döngüsü 0.9 ms'ten 0.6 ms'e düştü. Mesh işaretçisi ve ses kimliği gibi soğuk alanları paralel bir diziye taşımak yeterliydi. Bu ölçümde değişen tek şey alanların yerleşimiydi; matematik aynı kaldı.

False sharing ise ölçmeden fark edilmez. Dört worker thread'in her biri kendi sayacını artırıyordu, ama sayaçlar aynı cache line üzerindeydi; her yazma diğer çekirdeklerin satırını geçersiz kılıyordu. Sayaçları alignas(64) ile ayırmak o sistemde 1.2 ms kazandırdı ve tek satırlık bir değişiklikti.

Hizalama SIMD tarafında ise zorunluluk. Arenanın Allocate fonksiyonu hizalama parametresi alıyor; __m128 tutan bir tampon istendiğinde 16, AVX yolunda 32 veriyoruz. Hizasız erişimin çökmediği platformlarda bile ölçülebilir biçimde yavaşladığını gördük. Arena zaten hizalı blok döndürdüğü için bu kontrol tek bir yerde duruyor.

std::pmr yeter mi, kendi allocator'ın mı?

Ölçümün özeti şu: savaş sahnesinde ortalama kare süresi 9.2 ms'ten 7.1 ms'e indi, ama asıl fark 99. persentilde. 13.4 ms olan tepe değer 7.9 ms'e düştü ve profiler'daki periyodik sıçramalar tamamen kayboldu. Aynı ölçümü iki farklı makinede tekrarladık, oran değişmedi. Toplam iş üç haftaydı ve tamamı iki başlık dosyasına sığıyor.

Bu işin büyük kısmını std::pmr ile de yapabilirdiniz. std::pmr::monotonic_buffer_resource zaten bir arenadır, unsynchronized_pool_resource ise havuz. Standart konteynerlerle çalışıyorsanız ölçüm yapıp altlarına bir memory resource takmak ihtiyacınızın büyük kısmını karşılar ve bakım maliyeti neredeyse sıfırdır. Ekipteki herkesin tanıdığı standart bir arayüz olması da ayrı bir kazanç.

Kendi custom allocator'ınızı yazmanız gereken yerler dardır: sanal fonksiyon çağrısını bile istemediğiniz sıcak döngüler, işaretçi yerine 32 bitlik indeks tuttuğunuz sistemler, konsol tarafındaki özel bellek bölgeleri ve kare bütçesini rapor eden kendi telemetriniz. Bu dörtten hiçbiri sizde yoksa pmr ile kalın.

Tek bir şey yapacaksanız, önce kare içindeki tahsisleri sayın. Motora bir sayaç koyup her karede kaç tahsis yapıldığını ekrana basmak yarım saatlik iştir ve genelde kimsenin tahmin etmediği bir sayı çıkar. O sayı sıfıra yaklaştıkça geri kalan her şey kendiliğinden kolaylaşıyor.

← Tüm yazılar