200 likes | 338 Views
Programowanie gier komputerowych Tomasz Martyn. Wykład 2. Zarządzanie pamięcią Start i zamykanie systemu. Dynamiczne przydziały i zwalnianie pamięci (standardowe implementacje malloc , new , free , delete ) na stercie ( heap-based allocation ): są czasochłonne, ponieważ
E N D
Programowanie gier komputerowychTomasz Martyn Wykład 2. Zarządzanie pamięcią Start i zamykanie systemu
Dynamiczne przydziały i zwalnianie pamięci (standardowe implementacje malloc, new, free, delete) na stercie (heap-basedallocation): • są czasochłonne, ponieważ • jest to system ogólnego przeznaczenia, który musi alokować pamięć dowolnej wielkości i w dowolnej chwili, a zatem, aby to zapewnić, w trakcie alokacji muszą być wykorzystywane rożne algorytmy i techniki, (poszukiwanie wolnego obszaru pamięci, pamięć wirtualna, defragmentacja,...) • wymaga odwołania do systemu operacyjnego (kosztowne przełączenie kontekstu procesora) • prowadzą do niekontrolowanego rozproszenia danych w pamięci (tzw. słaba lokalność odwołań – locality of reference), a to z kolei skutkuje chybianiem w pamięć cache procesora (cache misses) podczas operacji na pamięci • prowadzą do fragmentacji pamięci Zarządzanie pamięcią (1)Po co?
Ogólne dyrektywy: • Nie przydzielać dynamicznie pamięci przy wykorzystaniu standardowego heap-basedallocation w toku rozgrywki • Przydzielać pamięć z ciągłego bloku, prealokowanego w trakcie inicjalizacji gry (być może na stercie lub w pamięci statycznej) przy wykorzystaniu własnej, odpowiedniej strategii zarządzania pamięcią • (w celu minimalizacji chybiania w cache) Zorganizować dane w ciągłe i jak najmniejsze kawałki pamięci oraz posortować te dane tak, żeby ich odczyty były jak najbardziej sekwencyjne Jednakże Należy pamiętać o odpowiednim wyrównaniu danych atomowych w pamięci (data alignment), tzn. adres danej w pamięci powinien być wielokrotnością rozmiaru danej (typowo potęga 2). Kontroler pamięci procesora działa wówczas wydajnie (odczyt tylko jednego bloku pamięci, zamiast dwóch lub więcej). Co więcej, niektóre procesory w ogóle nie potrafią odczytywać „niewyrównanych” danych (np. większość procesorów RISC, AltiVec) – x86 potrafią Zarządzanie pamięcią (2) No i co z tym zrobić?
(w nawiasach rozmiary typów dla 32-bitowego x86) • dana 1-bajtowa (char) może znajdować się pod dowolnym adresem (8-bit alignment) • dana 2-bajtowa (short) powinna znajdować się pod adresem parzystym (16-bit alignement) • dana 4-bajtowa (int, long, float, wskaźnik) powinna znajdować się pod adresem będącym wielokrotnością 4 (32-bit alignment) • dana 8-bajtowa (double) powinna znajdować się pod adresem będącym wielokrotnością 8 (Windows – 64-bit alignment) albo 4 (Linux – 32-bit alignment) • dana 16-bajtowa (wektor 4 floatów SEE) musi znajdować się pod adresem będącym wielokrotnością 16 (128-bit alignment) Zarządzanie pamięcią (3) Naturalne położenie danych atomowych
sizeof(S2) = 8 sizeof(S1) = 12 Zarządzanie pamięcią (4) Wyrównanie struktur (padding) (x86, Visual C++ 2010)
(one-ended)stackallocator • double-endedstackallocator Zarządzanie pamięcią (5) Alokacja na stosie (1) Używane zwykle gdy gra jest liniowa i „zorientowana na poziomy” (tzn. gracz czeka na załadowanie poziomu, następnie przechodzi poziom, następnie czeka na załadowanie kolejnego poziomu, itd.) oraz każdy poziom mieści się całkowicie w pamięci.
(patrz np.: S. Ranck: Alokacja oparta na ramkach, w: Perełki programowania gier 1) Zarządzanie pamięcią (6) Alokacja na stosie (2) Klasa
Zarządzanie pamięcią (7) Alokacja na stosie (3)Konstrukcja i destrukcja
Zarządzanie pamięcią (8) Alokacja na stosie (4)Przydział i zwalnianie pamięci
Cechy: • udostępnia bloki pamięci równych rozmiarów • prealokowany duży, ciągły obszar pamięci będący wielokrotnością rozmiaru przechowywanych obiektów (po kompilacji, tzn. w sensie sizeof– padding!) Działanie: • wskaźniki do niezajętych bloków pamięci przechowywane są w tablicy wolnych elementów (podczas inicjalizacji tablica przechowuje wskaźniki na wszystkie bloki w przydzielonym obszarze pamięci) • tablice wolnych elementów można również zakodować bezpośrednio w wolnych blokach przechowując w tych blokach wskaźniki na kolejny wolny element (przy założeniu, że wielkość bloków jest nie mniejsza od rozmiaru wskaźnika) • przydział pamięci odbywa się poprzez pobranie ostatniego wskaźnika bloku z tablicy wolnych elementów i dekrementacji markera pamietającego indeks ostatniego wskaźnika w tablicy • zwolnienie bloku odbywa się poprzez dołączenie wskaźnika na ten blok do tablicy wolnych elementów i inkrementacje markera (j.w.) Zarządzanie pamięcią (9) Alokacja oparta na puli pamięci (1) Zastosowanie: • do przechowywania wielu (zwykle niewielkich) obiektów tego samego typu (np. macierzy, węzłów drzewa, instancji siatki trójkątów...) • do przechowywania zasobów, które można podzielić na kawałki (zwykle stosunkowo duże)
(patrz: P. Glinker: Fightmemoryfragmentation with templatedfreelists, w: Game Programming Gems 4; również: N. Mefford: Improvingfreelists with policy based design, w Game Programming Gems 5) Zarządzanie pamięcią (10) Alokacja oparta na puli pamięci (2)Klasa
Zarządzanie pamięcią (11) Alokacja oparta na puli pamięci (3)Konstrukcja i destrukcja
NewInstance() { Zarządzanie pamięcią (12) Alokacja oparta na puli pamięci (4)Przydział i zwalnianie pamięci
można zaimplementować swój własny system zarządzania pamięcią bazujący na strukturze sterty (lub podobnej – por. np.: D. Lazarov: High performance heapallocator, w: Game Programming Gems 7) • system może wykonywać częściową defragmentację w kolejnych iteracjach pętli gry bez widocznego wpływu na szybkość działania gry, przy założeniu, że przesuwane bloki zajętej pamięci nie są za duże (co zwykle jest spełnione, jeśli pamięć w ten sposób przydzielana jest dla dynamicznych obiektów gry, które na ogół są relatywnie niewielkie pod względem pamięciowym) Zarządzanie pamięcią (13) Alokacja na własnej stercie • jednakże dokonując defragmentacji należy aktualizować wskaźniki do przesuniętych bloków; z tego względu najlepiej stosować inteligentne wskaźniki, a jeszcze lepiej - uchwyty
(patrz np.: P. Isensee: Alokatory STL, w: Perełki programowania gier 3) • kontenery STL umożliwiają zdefiniowanie własnych strategii alokacji pamięci (alokatorów), np. definicja listy STL ma następującą postać: • na ogół zdefiniowanie własnego alokatora sprowadza się do skopiowania domyślnego alokatoraz pliku nagłówkowego <memory> i zastąpienie jego funkcji składowych allocate() i deallocate()(oraz ew. konstruktorów i destruktorów, rzadziej operatorów porównania) • allocate() musi zwrócić wskaźnik na pamięć o rozmiarze wystarczającym do pomieszczenia n obiektów typu T (nie zajmuje się konstrukcją tych obiektów). Na przykład alokator dla prealokowanej pamięci wskazywanej przez mpStack może mieć postać: Zarządzanie pamięcią (14) Alokatory STL (1) • deallocate(pointer p, size_type n) zwalnia pamięć wskazywaną przez p i zajmowaną przez n obiektów typu T; wskaźnik p musiał zostać wcześniej zwrócony przez allocate() tego samego obiektu alokatora; funkcja nie może zgłaszać wyjątku
Choć większość implementacji STL spełnia wymagania standardu ANSI C++, to jednak same implementacje różnią się od siebie. • W rezultacie własny alokator zdefiniowany w kontekście danej implementacji może nie działać w innej. • W szczególności dotyczy to alokatorów zawierających zmienne składowe (własne dane - np. wskaźnik na prealokowany z zewnątrz blok pamięci albo statyczny blok pamięci wewnątrz alokatora). • Dlatego wiele wieloplatformowych silników gier dostarcza własne implementacje kontenerów. Praktyka ta jest również powszechna w silnikach na konsole i platformy mobilne. (Np. EA STL by ElectronicArts) Zarządzanie pamięcią (15) Alokatory STL (2)
Silnik gry jest złożonym systemem, składającym się z wielu oddziałujących ze sobą podzespołów (modułów). Wzajemne zależności miedzy modułami znajdują m.in. swój wyraz w kolejności tworzenia modułów w trakcie uruchamiania systemu silnika, a także ich usuwania podczas zamykania tego systemu. • Wiele z modułów (w szczególności różne moduły zarządców) implementowanych jest przy wykorzystaniu wzorca projektowego singleton. Mając na uwadze pkt 1, istotne jest zatem, aby posiadać kontrolę nad kolejnością tworzenia i usuwania modułów-singletonów. • „Książkowy” singleton tworzony na żądanie implementowany jest na bazie wzorca: Niestety rozwiązanie takie pozbawia nas kontroli nad kolejnością usuwania obiektów opartych na takiej implementacji. {} Start i zamykanie systemu (1)
(patrz: S. Bilas: Automatyczne singletony, w: Perełki programowania gier 1) • Lepsze rozwiązanie, które umożliwia tworzenie i usuwanie singletonów przy użyciu operatorów new i delete, opiera się na następującym wzorcu: Start i zamykanie systemu (2)
Wyizolowanie z implementacji klasy cechy „singleton” w formie odpowiednego wzorca singletonu, po którym będą dziedziczyć klasy „singletonowe”, ma postać następującą: Podejście takie stosowane jest np. w siniku Ogre3D. Start i zamykanie systemu (3)
Przykład wykorzystania: Start i zamykanie systemu (4)