Optymalizacja – wstęp

Optymalizacja jest ważnym tematem w każdym projekcie, w szczególności w grach. Jako programiści chcemy zapewnić naszym użytkownikom produkty wysokiej jakości, które działają płynnie na ich urządzeniach. Nie ma nic bardziej rozczarowującego niż negatywne odczucia użytkownika tylko dlatego, że gra się zacina, działa wolno lub długo się ładuje. Praca wielu osób zaangażowanych w projekt zostanie zmarnowana, jeśli nie będziemy zwracać uwagi na ten aspekt. Nikt nie chce być odpowiedzialny za taką porażkę, prawda 🙂 ?
To jest miejsce, w którym zaczyna się nasza praca. Naszym zadaniem jest przekonać menedżerów, że dodatkowy czas poświęcony na optymalizację zwróci się później.

Pomiary

Powiedzmy, że postanowiliśmy poświęcić trochę czasu na optymalizację, ale jak zacząć? Czy wystarczy subiektywne odczucie podczas gry? Czy możemy łatwo ocenić, że po naszych zmianach gra działa płynniej? Byłoby wspaniale mieć trochę danych do porównania.

Wyobraź sobie, że pracujemy nad grą mobilną. Na rynku mamy setki dostępnych urządzeń. Nigdy nie będziemy mieli okazji przetestować gry na każdym z obsługiwanych urządzeń. Ale możemy zacząć od wybrania jakichś przedstawicieli. Ponieważ mamy dwie główne platformy: iOS i Android, dobrze byłoby mieć co najmniej jedno mocne i słabe urządzenie na platformę.
Jeśli jesteś niezależnym programistą i nie masz dostępnych wielu urządzeń, zawsze możesz poprosić znajomych lub rodzinę o szybki test 💡 . Bez danych możemy skupić się na niewłaściwych rzeczach i na końcu uzyskać słabe rezultaty.

W następnym kroku będziemy potrzebować prostego narzędzia, które pozwoli nam zmierzyć liczbę klatek na sekundę w naszej grze. Zwykle stworzenie takiego narzędzia nie trwa dłużej niż jeden lub dwa dni. Narzędzie powinno pozwolić nam obliczyć kilka wskaźników, takich jak min / maks / śr. FPS w sekcji gry, którą chcemy zoptymalizować np. rozgrywka, określony tryb gry lub interfejs użytkownika w menu. Nazwijmy je Performance Tracker 😉
Narzędzie to jest lepsze niż zwykły licznik FPS, ponieważ otrzymujemy wyniki nadające się później do porównania. Dysponując takim narzędziem, możemy przetestować grę na wybranych urządzeniach docelowych i zapisać sobie wyniki. Zalecam przechowywać danych w arkuszu kalkulacyjnym, takim jak ten zaproponowany poniżej.

Rys. 1. Przykład arkusza kalkulacyjnego do przechowywania wyników FPS z naszych docelowych urządzeń

Arkusz kalkulacyjny będzie służył jako archiwum, więc później, jeśli dodamy do gry nowe funkcje, możemy sprawdzić, jak wpłynęły one na wydajność naszej gry. Mamy również punkt początkowy, do którego możemy porównać wyniki po każdej wdrożonej optymalizacji. Jedyne, co musimy zrobić, to utworzyć nową kartę za każdym razem, gdy mierzymy wydajność.

Gdzie jest wąskie gardło?

Zmierzyliśmy wydajność na kilku urządzeniach za pomocą naszego Performance Trackera. Teraz nadszedł czas na analizę danych. Na urządzeniach mobilnych można szybko zauważyć, że istnieją dwa istotne progi:

  • 60 FPS – maksimum dla większości urządzeń mobilnych (częstotliwość odświeżania ekranu), średnie i dobre urządzenia powinny mieć średnio 60 fps,
  • 30 FPS – dopuszczalne dla słabych urządzeń, gra renderuje nową klatkę co drugie odświeżenie ekranu.

Weźmy urządzenie, która działa poniżej naszych oczekiwań. Zazwyczaj problem znajduje się w jednym z trzech podstawowych obszarów gry:

  • CPU – procesor odpowiedzialny za logikę gry, obliczenia fizyki i renderowanie,
  • GPU – karta graficzna odpowiedzialna w większości za renderowanie,
  • Dostęp do pamięci – ładowanie zasobów, alokacja i czyszczenie pamięci przez GC (ang. Garbage Collector).
Rys. 2. Wąskie gardło związane z wydajnością dotyczy zazwyczaj jednego z trzech podstawowych obszarów: CPU, GPU lub dostępu do pamięci.

Zależnie od urządzenia problem może pojawiać się w innym miejscu. Jest to powód, dlaczego warto testować na wielu urządzeniach. W najlepszym wypadku od różnych producentów.

Rys. 3. Przykład obciążenia głównych systemów urządzenia.

Często zdarza się taka sytuacja jak na rys. 3. Mamy trzy różne urządzenia i każde z nich ma problem związany z innym obszarem. Zazwyczaj optymalizacja jednego z obszarów powoduje większe obciążenie drugiego chyba, że mieliśmy do czynienia z wysoce niezoptymalizowanym rozwiązaniem. Dla przykładu:

  • dynamiczny batching na CPU (łączenie obiektów w jeden przed renderowaniem) pomaga przeciążonemu GPU,
  • dynamiczny culling (wyłączanie obiektów niewidocznych dla kamery) zwiększa ilość logiki przetwarzanej przez CPU ale zmniejsza liczbę draw calli lub redukuje efekt overdraw tym samym redukując obciążenie GPU,
  • droższe materiały mogą zmniejszyć liczbę potrzebnych tekstur i pomóc w problemach z pamięcią ale zwiększają czas przetwarzania przez GPU,
  • implementacja puli obiektów może zmniejszyć czas potrzebny na dostęp do pamięci ale CPU musi później poświęcić czas na zarządzanie pulą.

Tego typu przykładów jest pełno przez co kluczem podczas optymalizacji jest odpowiedni balans w taki sposób, aby obciążenie każdego z tych obszarów było mniej więcej podobne na każdym urządzeniu.

Profilowanie i analiza

Aby zagłębić się bardziej w grze i znaleźć miejsca, które muszą zostać zoptymalizowane będziemy musieli użyć dodatkowych narzędzi. Unity ma wiele wbudowanych rozwiązań, które są naprawdę pomocne w znajdywaniu słabych punktów w naszej grze.

Okno Statystyk (ang. Statistics Window)

Rozpoczynając od najprostszego z nich. W edytorze Unity możemy po prostu otworzyć okno statystyk, gdzie znajdują się podstawowe informacje na temat renderowania. Analizując te liczby możemy szybko określić czy rendering jest problemem. Jest to najczęstsza przyczyna (szczególnie na urządzeniach mobilnych), gdyż często rendering zużywa nawet do 90% czasu generowania klatki.

Rys. 4. Przykład okna statystyk z dokumentacji Unity.

Z tego okna możemy się dowiedzieć następujących rzeczy:

  • jak wiele draw calli (batches) jest potrzebne do wyświetlenia aktualnego widoku,
    • jest to najważniejsza liczba i powinniśmy ją utrzymywać jak najmniejszą (na urządzeniach mobilnych staram się mieć ją poniżej 50),
    • liczba draw calli, czyli odwołań do karty graficznej jest zależna od wielu czynników: liczby używanych materiałów, złożoności materiałów (mogą one wymagać wielu odwołań), liczby statycznych / dynamicznych obiektów, ustawienia świateł, batchingu UI (atlasów) itp.
  • jak dużo geometrii wyświetlamy (trójkątów i wierzchołków),
    • zasada jest prosta: czym więcej geometrii mamy, tym drożej jest ją wyświetlić (na urządzeniach mobilnych staram się mieć poniżej 50-100 tys. wierzchołków),
    • jeżeli liczba jest wysoka, nasze modele 3d mogą być zbyt dokładne i powinny zostać zoptymalizowane,
    • innym częstym problemem jest zjawisko overdraw, czyli wielokrotne renderowanie tego samego piksela. W tym wypadku powinniśmy wyłączyć obiekty, które są ukryte za innymi ale wciąż renderowane przez kamerę (przy pomocy Occlusion Culling, albo własnego skryptu).
  • jak duże (w pikselach) jest obecnie okno gry,
    • większe okno jest bardziej wymagające, jeżeli chodzi o rendering, ponieważ jest więcej pikseli do przetworzenia. Zmieniając rozmiar okno można sprawdzić wpływ rozdzielczości ekranu na wydajność.
  • jaka jest obecnie częstotliwość wyświetlania ramek (FPS),
    • zależnie od mocy komputera może być niższa niż na docelowym urządzeniu, gdyż edytor ma swój własny narzut wydajnościowy.
Debugger Ramek (ang. Frame Debugger)

Kolejnym użytecznym narzędziem w edytorze Unity jest Frame Debugger. Jeżeli nie jesteśmy pewni skąd pochodzą odwołania do karty graficznej, to narzędzie pozwoli nam sprawdzić je jedno po drugim. Kiedy jest włączone tworzy zrzut pojedynczej ramki i listuje wszystkie draw calle potrzebne do jej wyświetlenia. Wtedy przeszukując listę możemy wybrać dowolne odwołanie i sprawdzić w podglądzie lub oknie gry, co dokładnie zostało wyświetlone.

Rys. 5. Przykład Debuggera Ramek z dokumentacji Unity.

Jest kilka powodów czemu kolejne odwołanie do karty graficznej może być potrzebne (część z nich została już wspomniana). Frame Debugger zazwyczaj wyświetla powód wśród informacji znajdujących się nad podglądem. Powinniśmy szukać statycznych elementów, które są wyświetlane osobno mimo, że używają tego samego materiału, tekstur, które mogą być połączone w atlas, obiektów, które mogłyby być statyczne, elementów z różnymi ustawieniami świateł czy takich, które w ogóle nie powinny zostać wyświetlone itp.

Profiler

Na koniec Profiler, który jest najbardziej złożonym i potężnym narzędziem do mierzenia wydajności dostępnym w edytorze Unity. Bez Profilera ciężko byłoby zebrać i przetworzyć dane na temat obciążenia CPU i zużycia pamięci.

Rys. 6. Przykład okna Profilera z dokumentacji Unity.

Profiler zawiera wiele sekcji związanych z różnymi obszarami aplikacji. Analizę najlepiej rozpocząć od sekcji CPU Usage, która pozwala przeglądać metody wywołane w poszczególnym ramkach gry wraz z czasem ich wykonania (kolumna Time ms) i ilością alokowanej pamięci (kolumna GC alloc). Opcja Deep Profile pozwala bardziej zagłębić się w stos wywołań metod, jednak nie zawsze jest dostępna. Jeżeli kod aplikacji jest bardzo skomplikowany (np. bazuje na złożonych frameworkach), to opcja może być zablokowana. Wtedy trzeba używać metod BeginSample/EndSample do pokrycia kodu, który chcielibyśmy przebadać (jak w przykładzie poniżej).

public class ExampleClass : MonoBehaviour
{
    void Example()
    {
        Profiler.BeginSample("MyPieceOfCode");
        // Code to measure...
        Profiler.EndSample();
    }
}

Pamiętaj, żeby nie mierzyć wydajności podczas profilowania, gdyż aplikacja będzie działać znacznie wolniej niż w normalnych warunkach (szczególnie gdy włączony jest Deep Profile). Ponadto lepiej jest profilować grę na urządzeniu docelowym niż w edytorze. Do tego trzeba zbudować aplikację w trybie deweloperskim (ang. development build). Podczas profilowania w edytorze można znaleźć w stosie wywołań metody, które są związane z funkcjami edytora i nie występują na urządzeniu.

Rys. 7. Przykład sekcji CPU Usage z dokumentacji Unity.

Wracając do danych dostępnych w sekcji CPU Usage. Kolumna GC Alloc może nam wskazać czy istnieją problemy z dostępem do pamięci. Garbage Collector w językach zarządzanych (jak C#) jest odpowiedzialny za śledzenie i zwalnianie nieużywanej pamięci. To zły znak, gdy nasza gra alokuje pamięć w każdej ramce. Nawet kilka KB na ramkę może drastycznie obniżyć wydajność. Mój rekord to wzrost o ponad 20 FPS jedynie po optymalizacji związanej z alokowaniem pamięci (na Samsungu S5 z 2014 roku, częstotliwość renderowania skoczyła z około 25 do stabilnych 45-50 ramek).

Garbage Collector to cichy zabójca wydajności. Podczas alokowania sporych ilości danych, te same operacje stają się coraz droższe i droższe z upływem czasu. System musi znaleźć wolną przestrzeń na nowy obiekt. Garbage Collector nieustanie aktualizuje liczniki referencji. Jeżeli trzeba musi przejść przez stertę pamięci i zwolnić pamięć po nieużywanych elementach co powoduje punktowe spadki wydajności i przycięcia w naszej grze. Fragmentacja pamięci postępuje, co w skrajnym przypadku może skończyć się wyjątkiem StackOverflowException i wyłączeniem aplikacji.

Drugim istotnym parametrem jest czas wykonania (kolumna Time ms). Pokazuje ile czasu w danej ramce zostało zużyte przez konkretną metodę. Zazwyczaj większość czasu zabiera rendering, w śledzeniu czego są przydatne wcześniej wspomniane: okno statystyk i debugger klatek. Jeżeli chcemy znaleźć problem związany z zużyciem CPU, powinniśmy skupić się na metodach dotyczących logiki gry, fizyki lub przetwarzania danych wejściowych itp. Gdy znajdziemy metodę, która zabiera więcej czasu niż oczekiwaliśmy, warto sprawdzić również kolumnę Calls (liczba wywołań). Często metody są wywoływane dziesiątki razy w jednej ramce. W tym wypadku można zoptymalizować grę zapisując pośrednie lub końcowe wyniki drogich operacji. Warto unikać nadmiarowych wywołań np. raycastingu, znajdowania ścieżek, tworzenia instancji obiektów i innych złożonych metod.

Zdarza się, że czas przetwarzania pojedynczej ramki może się bardzo różnić. Często średnia ramka jest przetwarzana w rozsądnym czasie, jednak co jakiś czas zdarza się skok lub przycięcie. Gdy sprawdziliśmy, że to nie jest wina Garbage Collectora, może okazać się, że powodem jest regularnie wywoływany fragment złożonej logiki. Przykładowo ładowanie zbioru nowych obiektów albo fragmentów mapy. W tym wypadku rozwiązaniem może być asynchroniczne wywołanie. Logikę można podzielić i przetwarzać w współprogramie (ang. Coroutine) lub osobnym wątku.

Podsumowanie

Optymalizacja wydajność jest dość specyficznym aspektem, który sporo zależy od projektu. Nie ma jednej zasady pozwalającej rozwiązać wszystkie problemy. Jednak mając u standaryzowane i systematyczne podejście jestem pewien, że uda Ci się osiągnąć swój cel 😉 . Mierzenie wydajności powinno rozpocząć się we wczesnej fazie projektu. Pozwoli to na śledzenie zmian i szybsze reagowanie. Jeżeli poczekasz do końcowej fazy projektu z optymalizacją, to zadanie okaże się dużo trudniejsze. Jest to spowodowane kilkoma rzeczami:

  • złożona logika jest trudniejsza do profilowania i debugowania,
  • wąskie gardła mogą być ukryte głęboko w logice gry,
  • czasami architektura jest problem i optymalizacja wymaga zmian wpływających na cały system,
  • zasoby graficzne mogą wymagać sporej ilości zmian.

Ja staram się mierzyć wydajność gry raz w miesiącu, jednak może to się sporo różnić. Harmonogram powinien być dopasowany do rozmiaru projektu i liczby osób w nim pracujących.

Dziękuje za przeczytanie całości, mam nadzieje, że post był ciekawy 🙂 . Więcej szczegółów i przykładów optymalizacji związanych z każdym ze wspomnianych obszarów: CPU, GPU i dostępu do pamięci, będzie dodane w nadchodzących artykułach.

Materiały

5 thoughts on “Optymalizacja – wstęp

  1. Long time supporter, and thought I’d drop a comment.

    Your wordpress site is very sleek – hope you don’t mind me asking what theme you’re
    using? (and don’t mind if I steal it? :P)

    I just launched my site –also built in wordpress like yours– but the theme slows (!) the site down quite a bit.

    In case you have a minute, you can find it by searching for “royal cbd” on Google (would appreciate any feedback) –
    it’s still in the works.

    Keep up the good work– and hope you all take care of yourself during
    the coronavirus scare!

    ~Alex

    1. Hi, theme is called “Personalio”. I suppose that responsiveness of the website depends on amount of resources. I have quite simple layout and just a few images. Second thing is a server which hosts your website, it may vary a lot.

  2. I and my buddies happened to be reviewing the excellent tactics found on the blog and at once came up with a horrible feeling I never expressed respect to the web site owner for them. The people came so happy to study all of them and already have pretty much been using them. Thanks for simply being considerably thoughtful as well as for picking out such brilliant issues most people are really desirous to understand about. My honest regret for not expressing appreciation to sooner.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *