Marek Winiarski

Zarządzanie pamięcią w Unity

Jedną z cech gier, na które gracze najczęściej narzekają to kiepska optymalizacja. Na optymalizację gry składa się kilka elementów. Optymalizacja zużycia procesora, karty graficznej oraz pamięci. Właśnie tym ostatnim elementem zajmiemy się dzisiaj.

Od razu ostrzegam nie będzie tutaj wymyślnych teorii, ale wiedza którą udostępnia Unity zebrana w jednym miejscu w przystępny sposób. Bierzmy się do roboty!

Zrozumieć Pamięć

Zanim zaczniemy się uczyć o tym, jak zarządzanie pamięcią optymalizować, wypadałoby zrozumieć jak pamięć w ogóle działa. Żeby sobie ładnie wyobrazić jak wygląda pamięć, warto skorzystać z narzędzia, które udostępnia Unity. Memory Profiler (bo o nim mowa) jest udostępniony jako open source i pozwala w ciekawy sposób przedstawić pamięć wykorzystaną przez grę.

Wizualizacja wykorzystania pamięci zaprezentowana przez Memory Profiler.
Źródło: https://docs.unity3d.com

W ten sposób może zaprezentować pamięć zastosowaną w grze. Jak widać na podanym przykładzie gra wykorzystuje większość swojej pamięci na obsługę dość sporych tekstur. Problematyczne w przetrzymywaniu danych są wszelkiego rodzaju efekty filmowe (cinematic). Tego typu efekty są w stanie zarezerwować w pamięci miejsce dla kilku tymczasowych buforów zajmując zdecydowaną większość dostępnej pamięci.

Jednak to tylko wizualizacja zajętej pamięci. W programowaniu mówimy o alokowaniu pamięci, tylko czym to jest tak naprawdę? Alokując pamięć, rezerwujemy pewną przestrzeń w pamięci fizycznej do ewentualnego wykorzystania. Standardowy int zajmuje 32 bity, czyli 4 bajty. Załóżmy, że urządzenie na które robimy grę ma pamięci 64MB (tak programujemy na toster).  Daje nam to 67 108 864 bajtów, tym samym deklarując 16 777 216 intów zapchamy całkowicie pamięć. Liczba wydaje się abstrakcyjna. Tylko, że w ten sposób zapełniamy pamięć deklarując cokolwiek. Liczby, stringi, tablice, obiekty i każdy bardziej skomplikowany obiekt zajmuje pamięci coraz więcej. Przykładowo 100-elementowa tablica intów będzie zajmowała już ponad 400 bajtów (pamięć potrzebna na każdy element * liczba elementów + parę bajtów dodatkowych na potrzeby samej tablicy).

Pamięć w Unity
Źródło: https://docs.unity3d.com

Unity wykorzystuje język C#, który jest językiem wysokiego poziomu. Tym samym wyposażony jest w Garbage Collector (GC), czyli zbieracza śmieci. GC uruchamiany jest co jakiś czas, a jego zadaniem jest zwolnienie już niepotrzebnej pamięci. W porównaniu do np. Assemblera jest to pozornie wygodne dla programisty, bo nie musi pamiętać o tym, żeby pamięć zwalniać. Niestety takie narzędzie ma również swoje ograniczenia, które nieświadomego programistę mogę w pewnym momencie zajść od tyłu i wbić nóż w plecy.

To co widzicie na obrazku powyżej to nasza przestrzeń pamięci. Załóżmy, że przestaliśmy używać jednej z tablicy i GC usunął ją z pamięci, natomiast w naszym kodzie pojawia się deklaracja nowej tablicy. Trafiamy na bardzo podstawowy problem w zarządzaniu pamięcią w Unity:

Nowa tablica nie mieści się w zwolnionym miejscu.
Źródło: https://docs.unity3d.com

Zwolniona pamięć, nie trafia do ogólnej puli wolnej pamięci – komórki pamięci nie podlegają fragmentacji. Tym samym nasza przestrzeń wygląda jak ser, gdzie w blokach danych pojawiają się dziury, które zostaną wypełnione tylko gdy deklarowany obiekt zajmuje tyle samo, bądź mniej bitów niż mamy ich w danej dziurze. Spytacie co w takim przypadku gdy obiekt w ogóle się nie mieści? Tworzona jest nowa strona. To ile dodatkowych bajtów zostanie zajęte zależy od platformy, ale większość platform obsługiwanych przez Unity po prostu podwaja rozmiar dozwolonej pamięci.

[stextbox id=’info’ defcaption=”true”]Problem fragmentacji, czyli braku ciągłości w pamięci pojawił się już dawno. Dlatego jednym z pomysłów rozwiązania tego problemu wyszedł Jacek Karpiński w jego komputerze K-202 w latach 1970-73. Pomysł stronnicowania był bardzo prosty. Pamięć fizyczną dzielono na ramki, natomiast pamięć logiczną na strony. Podzielenie posiadanej pamięci na mniejsze obszary o stałej (lub zmiennej) długości, pozwala uniknąć braków ciągłości, ponieważ może ona wystąpić tylko w obszarze danej ramki lub strony, a nie na przestrzeni całej posiadanej pamięci. [/stextbox]

Jakie zasadniczo mogą być problemy ze przestrzenią pamięci wg. Unity?

[stextbox id=’info’ defcaption=”true”]Przestrzeń adresowa można sobie wyobrazić jako mapę faktycznej pamięci. Jeżeli jakiś proces chce odwołać się do pamięci, to wykorzystuje przestrzeń adresową (też zapisaną w pamięci) żeby dowiedzieć się, gdzie szukać wolnej przestrzeni, lub konkretnej danej. [/stextbox]

Jeżeli wierzyć badaniom Unity Technologies, to wiele projektów stworzonych w Unity wykorzystuje dziesiątki, albo setki kilobajtów pamięci na dane tymczasowe. Może się to wydawać mało przy obecnych sprzętach, ale wykonajmy prostą matematykę. Jeżeli Twoja gra wykorzystuje 1kb danych w ciągu klatki, to w grze odtwarzanej w 60 klatkach na sekundę wykorzystujemy 60kb pamięci w ciągu sekundy. W ciągu minuty robi się już 3,6mb. Do tego co jakiś czas uruchamiany jest Garbage Collector, tym samym dla urządzeń o słabej wydajności może to być już śmiertelne.

Dodatkowo w sytuacji gdzie wczytujemy dużą liczbę obiektów na raz, Garbage Collector nie może zwolnić pamięci dopóki cały proces ładowania się nie zakończy. Tym samym Unity może być zmuszone do powiększenia stosu, nawet jeśli część z zapisanej pamięci miałaby zaraz być zwolniona.

Profilowanie pamięci

Jeżeli już wiemy o co chodzi w pamięci to wypadałoby się nauczyć jak dowiedzieć się czy nie przesadzamy z jej użyciem. Na szczęście Unity udostępnia profiler, który takie dane pokazuje. Żeby otworzyć okno profilera należy wybrać z menu Window -> Profiler, albo skorzystać ze skrótu CTRL + 7.

Widok Profilera

Kolumna GC Alloc pokazuje ile pamięci zużywamy w danej klatce. Trzeba pamiętać że jest to pełna pula wykorzystanej pamięci, czyli pokazane tu będą bajty nawet kiedy część z nich jest ponownie wykorzystana lub przekazana. Nie jest to jednoznaczne z nowo alokowaną pamięcią.

Jeżeli włączymy sobie przełącznik “Deep Profile” będziemy w stanie rozwinąć zakładki na liście Hierachy aż do poziomu pojedynczej metody, dzięki czemu łatwo można dociec która funkcja powoduje duże zużycie pamięci.

Dodatkowe dwie uwagi: Głównie należy uważać na wykorzystanie dużej ilości pamięci w interaktywnych scenach gry. Jeżeli wykorzystujemy dużą ilość pamięci w nie interaktywnej scenie (np. na ekranie ładowania) to nie jest to szczególnie problematyczne. Druga sprawa, to fakt, że niektóre funkcję wykorzystują pamięć w edytorze, ale nie robią tego w zbudowanej wersji gry. Najpopularniejszy przykład takiej funkcji to GetComponent. Wywołanie jej w edytorze wykorzysta pamięć, natomiast w już zbudowanej grze nie zrobi tego. Warto to mieć na uwadze w trakcie testów. Zaleca się też wykonanie testów optymalizacji na zbudowanej wersji gry.

Scripting Backends

Unity pozwala pisać w C# bądź w UnityScript, natomiast stworzone w ten sposób gry mogą być wydane na wiele różnych platform. Nie jest to zasługa platform, a samego Unity. Silnik dysponuje technologią, która potrafi przełożyć C# na C++. Z Unity współpracują dwa rozwiązania. IL2CPP oraz Mono. Każde z nich ma swoje zalety i wady. Na jedno z nich możemy zdecydować się z menu [Edit > Project Settings > Player.]

Tłumacząc dokumentację Unity, podstawowe różnice między nimi to:

IL2CPP

Mono

Pojawia się kilka interesujących zwrotów, które warto wyjaśnić. Zacznijmy od tego czym jest Code Stripping? Okazuje się, że Unity jest na tyle mądre, żeby w trakcie budowania gry wyrzucić z naszej plątaniny folderów wszystkie skrypty, a nawet metody czy struktury ze skryptów, które nie są nigdzie używane. Mało tego, potrafi to samo zrobić z natywnymi skryptami silnika, czyli z kodem źródłowym samego Unity. W ten sposób, szczególnie na platformach mobilnych możemy oszczędzić sporo pamięci. Dzieje się to na etapie, kiedy IL2CPP przekształca nasz kod w kod pośredni pomiędzy C# a C++. Usuwanie zbędnego kodu możemy również aktywować z Player Settings, dokładniej chodzi o menu rozwijane “Stripping Level”.

[stextbox id=’info’ defcaption=”true”]Unity ma w planach dodanie usuwania modułów silnika. Obecnie obsługuje to jedynie WebGL. Jako przykład podane jest usunięcie modułu Fizyki, który zajmuje 5MB. Samo usunięcie go zmniejsza rozmiar pustego projektu z 17MB do 12MB. [/stextbox]

Czym natomiast różni się kompilacja JIT ot AOT? W zasadzie nazwy obu metod to wyjaśniają. W przypadku AOT kompilacja programu przebiega w pełni przed uruchomieniem. Natomiast w przypadku JIT kompilacja może się odbywać w trakcie działania programu. Sprawia to, że zbudowanie programu jest szybsze, ale może działać mniej stabilnie.

Wspominałem, że Unity potrafi usunąć część nieużywanego kodu. To prawda, ale również możemy mu samodzielnie pokazać jaka część kodu powinna być usunięta, bądź zachowana. Jednak jest to zagadnienie nieco bardziej skomplikowane i nadaje się do omówienia w oddzielnym artykule.

Jest jeszcze druga kwestia, która potrafi przyspieszyć nieco kompilację. Zasadniczo wszystkie pliki jakie tworzymy (skrypty) są w trakcie kompilacji upychane do Assembly-CSharp.dll. Nie jest to złe tak ogólnie, ale zasadniczo tworzy problem. Kiedy masz już gotowe builda gry pozmieniasz coś w paru plikach to przekompilować trzeba całą bibliotekę. Unity daje narzędzia do rozbicia tego na kilka mniejszych bibliotek.

Rozbijanie bibliotek
Źródło: https://blogs.unity3d.com

Kiedy wszystkie skrypty są w jednej bibliotece, każda zmiana wymaga jej odtworzenia, natomiast w podanym na obrazku przypadku, gdy dokonamy zmiany w bibliotece Stuff.dll to przekompilowane muszą zostać tylko Main.dll i Stuff.dll. Co zrobić żeby otrzymać taki system kompilowania? Wystarczy stworzyć plik definicji zależności [Assets > Create > Assembly Definition]

Definicja Zależności
Źródło: https://blogs.unity3d.com

Co musimy tutaj ustalić? Po pierwsze nazwę. Na obrazku mamy Example, co sprawi że wszystkie skrypty z folderu zostaną upchnięte do Example.dll. Druga sprawa to zależności, czyli jakich innych bibliotek wymaga nasza do działania? Na poprzednim obrazku dla biblioteki Stuff.dll musielibyśmy tutaj podać Main.dll. Po co te zależności? Jeśli dana bibliotekę potrzebuje metod z plików innej biblioteki, musi ona trafić do zależności, żeby mieć pewność, że biblioteka z którą nasza ma współpracować istnieje i jest aktualna. Ostatnia rzecz do wybrania to platformy. Jeśli dana biblioteka jest potrzebna tylko na urządzeniach mobilnych (np. biblioteka ze sterowaniem przez ekran dotykowy) to możemy zaznaczyć tutaj tylko urządzenia mobilne i zaoszczędzimy nieco miejsca w buildach na inne platformy.

Bibliotekę (plik .dll) utworzą wszystkie pliki, które są w folderze z danym plikiem zależności. Jeśli plików mamy więcej, to skrypt trafia do biblioteki zdefiniowanej w pliku najbliżej niego. Czyli np. najpierw skrypt będzie szukał definicji w swoim folderze, jeśli nie znajdzie jej to powędruje do folderu wyżej i tak dalej.

Na koniec wróćmy do tego od czego zaczęliśmy ten rozdział IL2CPP i Mono. Z czego korzystać? Unity ma proste zalecenie. Mono szybciej się kompiluje więc polecają go w trakcie produkcji – szkoda czasu na kompilację w trakcie testów. Natomiast finalna gra powinna być budowana przez stabilniejsze i lepiej zoptymalizowane IL2CPP.

Podstawy dbania o pamięć

To o czym na razie mówiliśmy to sztuczki magiczne i kwestie związane z silnikiem, jednak sporo możemy zdziałać w kwestii optymalizacji pamięci tylko pracując nad jakością kodu.

Staraj się ponownie używać tablic i obiektów

Ta sprawa jest bardzo prosta. Powiedzmy, że potrzebujesz znaleźć listę obiektów i wykonać na nich operację. Deklarujesz do tego tablicę. Takiej tablicy nigdy nie deklaruj w funkcji Update czy wewnątrz pętli, a zawsze poza nią. Używaj tej samej tablicy wielokrotnie. Zdecydowanie lepsze dla pamięci niż ciągłe alokowanie pamięci i jej zwalnianie.

Unikaj funkcji jako parametrów (domknięcie)

Konstrukcja w której parametrem jednej funkcji, jest inna funkcja to jedna z bardziej zajmujących pamięć konstrukcji. Jeżeli już koniecznie musi pojawić się taka konstrukcja, to bardziej sprawdzają się wtedy metody anonimowe, niż predefiniowane. Trzeba tutaj uważać, bo jeśli najpierw do jakiejś zmiennej przypiszemy wartość funkcji, a później tą zmienną wykorzystamy w innej funkcji, to również mamy do czynienia z domknięciem.

// To jest domknięcie
function mnozenie_przez(x) {
  return function(y) {
    return x * y;
  };
}

var iloczyn_5_przez = mnozenie_przez(5);
iloczyn_5_przez(12)

// To też jest domknięcie:
var los_l = losowaLiczba();
function mnozenie_przez(x) {
  return x * los_l;
}

[stextbox id=’info’ defcaption=”true”]

Funkcja anonimowa, to taka, która nie posiada swojej nazwy i deklaracji. Przykładowa funkcja anonimowa może wyglądać tak:

Action<int> sendToLog = (arg) => { Debug.Log(arg); };

[/stextbox]

Boxing

Boxing to zmyślna nazwa dla upychania zmiennych o typie prymitywnym (np. int) do typów złożonych jak obiekt, żeby móc je wykorzystać.

int i = 123;
object o = i; // Boxing!

Niepotrzebnie zajmujemy pamięć deklaracją zmiennej i, która jest potrzebna tylko na krótki moment. Jednak Garbage Collector, nie odróżnia zmiennych potrzebnych na moment od tych na dłużej. Jeśli taki kod pojawi nam się w funkcji Update, możemy dość szybko zapchać spory obszar pamięci.

Bardzo prostym i powszechnym przykładem Boxingu jest stosowanie Enum-ów jako kluczy dla tablic asocjacyjnych (Słowników).

Pętla Foreach

Okazuje się, że pętla foreach w Unity to nic innego jak Boxing. Unity tworzy sobie Enuma do iteracji po pętli, zajmując zawsze taki sam obszar pamięci bez względu na to czy pętla będzie miała 2 czy 200 iteracji. Zjawisko boxingu dla pętli foreach nie występuje tylko dla klasycznych tablic. Natomiast jeśli korzystamy z dynamicznych kolekcji (np. Dictrionary, List), wtedy korzystniej jest skorzystać z klasycznej pętli for. Jednak sam typ kolekcji ma już znaczenie. Ponieważ Unity w wersji 5.6 wiele z nich zoptymalizowano. Poniżej zamieszczam tabelkę jak dużo bajtów śmieci zostawia pętla foreach dla różnych typów kolekcji.

Jak dużo śmieci w bajtach zostawia pętla foreach wykonana na różnych typach kolekcji.
Źródło: https://jacksondunstan.com

Dla pewności krótkie wyjaśnienie jak czytać tabelkę. Po lewej mamy różne typy kolekcji, a wraz z nimi przedstawione ile bajtów w pętli foreach wykorzystują różne akcje. Jeśli nie chcecie się zagłębiać w temat to wystarczy rzucić okiem na ostatnie 4 kolumny. Mamy tam 5.6 i 5.2 oznaczające wersję Unity. Kolumnę dla 5.6 można uznać za stan faktyczny (przed ostatnia aktualizacja kompilatora C#), natomiast 5.2 to wersja starsza. 1st i 2nd oznaczają który obrót pętli sprawdzamy.

Wnioski z tabelki są następujące. Jeśli operujemy na klasycznej tablicy nie ma problemu i problemu też nigdy nie było. Natomiast dużą poprawę widzimy w przypadku List<T>, gdzie kiedyś mieliśmy 40 bajtów śmieci w każdym obrocie, teraz nie ma nic. Również nie najgorzej wyjdziemy na pętli foreach dla typów Queue<T>, LinkedList<T>, Dictionary<T>, Stack<T> i HashSet<T>, co prawda rzucimy parę bajtów śmieci przy pierwszym obrocie pętli, ale później mamy porządek.

Względem reszty typów i tak widzimy poprawę i oszczędność paru bajtów względem poprzenich wersji, jednak problem dalej zostaje.

Na koniec warto dodać, że wypadałoby zrobić nowy test dla Unity 2018.1, gdzie ponownie zaktualizowano kompilator C# oraz dodano obsługę .NET i C# w wyższych wersjach.

Tablice w Unity API

Jako Unity API rozumiemy tu różne biblioteki związane z Unity. Część danych np. w komponencie Transform czy Mesh jest trzymana w tablicy. Bardzo powszechnym błędem jest odwoływanie się do nich bezpośrednio w pętli czy funkcji Update.

// Przykład 1 - Źle
for ( int i = 0; i < Input.touches.Length; i++ ) {
   Touch touch = Input.touches[i];
}

// Przykład 2 - Całkiem dobrze
Touch[] touches = Input.touches;
for ( int i = 0; i < touches.Length; i++ ) {
   Touch touch = touches[i];
}

// Przykład 3 - Najlepiej
int touchCount = Input.touchCount;
for ( int i = 0; i < touchCount; i++ ) {
   Touch touch = Input.GetTouch(i);
}

W pierwszym przykładzie odwołujemy się bezpośrednio do tablicy. Przy każdym obrocie pętli alokujemy pamięć za każdym razem gdy wywoływane jest .touches. W drugim przykładzie odwołanie do tablicy wykonane jest tylko raz, zamiast w każdym obrocie pętli. Alokacja występuje tylko raz. Jednak możemy pójść dalej i stosować metody przygotowane przez Unity takie jak GetTouch, które pomijają alokację.

Zwracanie pustej tablicy

Wielu programistów lubi zwrócić pustą tablicę zamiast wartości null. Faktycznie często tak jest wygodniej. Jednak ciągle deklarowanie pustych tablic może zaśmiecić pamięć. Dlatego warto stworzyć sobie singleton pustej tablicy i zwracać go każdorazowo, zamiast w kółko deklarować puste tablice.

[stextbox id=’definicja’ defcaption=”true”]Singleton to wzorzec projektowy w programowaniu, który mówi że w kodzie programu może istnieć na raz tylko jedna instancja danej klasy. [/stextbox]

Inne metody dbania o pamięć

Zużycie pamięci to nie tylko skrypty, ale również różnego rodzaju assety. Unity ma kilka prostych zaleceń, które pomogą trzymać pamięć w ryzach:

Exit mobile version