Transcript Wyklad_2

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):
1. 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
2. prowadzą do fragmentacji pamięci
Ogólne dyrektywy:
1.
Nie przydzielać dynamicznie pamięci przy wykorzystaniu standardowego
heap-based allocation w toku rozgrywki
2.
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ą
3.
(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ą
(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)
sizeof(S1) = 12
sizeof(S2) = 8
(x86, Visual C++ 2010)
• (one-ended) stack allocator
• double-ended stack allocator
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)
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.)
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: Fight memory fragmentation with templated freelists, w: Game Programming Gems 4;
również: N. Mefford: Improving freelists with policy based design, w Game Programming Gems 5)
• 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 heap allocator, 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)
• 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 alokatora z
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ć:
• 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
1. Choć większość implementacji STL spełnia wymagania standardu ANSI C++,
to jednak same implementacje różnią się od siebie.
2. W rezultacie własny alokator zdefiniowany w kontekście danej
implementacji może nie działać w innej.
3. 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).
4. 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 Electronic Arts)
1.
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.
2.
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.
3.
„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.
(patrz: S. Bilas: Automatyczne singletony, w: Perełki programowania gier 1)
4.
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:
5.
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.
6.
Przykład wykorzystania: