Twój program C++ działa idealnie, a potem zaczyna zjadać RAM. Ten benchmark pokazuje dlaczego


Twój program C++ działa idealnie, a potem zaczyna zjadać RAM. Ten benchmark pokazuje dlaczego

Programy napisane w C++ potrafią działać szybko, stabilnie i bez najmniejszych problemów podczas testów, a mimo to po dłuższym czasie zacząć zużywać coraz więcej pamięci. W takiej sytuacji łatwo podejrzewać wyciek pamięci albo pojedynczy błąd w kodzie. Maksim Martynov, główny programista w Playrix, twierdzi jednak, że źródło problemu może być znacznie bardziej podstępne.

Chodzi o sposób, w jaki program wykorzystuje pamięć w czasie. Standardowe benchmarki alokatorów potrafią bardzo dobrze mierzyć szybkość przydzielania i zwalniania pamięci, ale często nie pokazują tego, co dzieje się po kilkudziesięciu minutach albo kilku godzinach działania programu. Martynov zaproponował metodologię, w której czas życia obiektów staje się jednym z najważniejszych parametrów eksperymentu.

To podejście może być szczególnie interesujące dla twórców gier, silników, serwerów i innych aplikacji C++, które przez długi czas wykonują miliony operacji na pamięci.

Główny programista jednego z największych na świecie wydawców gier mobilnych opracował oryginalną metodologię, która traktuje czas życia obiektu jako podstawową zmienną eksperymentalną w ocenie alokatora C++.

Benchmark może być zbyt krótki, aby zobaczyć prawdziwy problem

Wydajność alokatora zwykle ocenia się za pomocą kontrolowanych testów. Program tworzy ogromną liczbę obiektów, zwalnia pamięć, uruchamia wiele wątków i mierzy przepustowość albo opóźnienia. Taki pomiar jest bardzo przydatny podczas porównywania rozwiązań, ale ma ograniczenie: dobrze opisuje zachowanie w konkretnym scenariuszu, niekoniecznie zaś długotrwałą pracę aplikacji.

Martynov zwraca uwagę na parametr, który w takich testach często pozostaje na dalszym planie. Jest nim czas życia obiektu.

W rzeczywistym programie jedne obiekty istnieją przez kilka mikrosekund, inne przez kilka sekund, a jeszcze inne przez cały czas działania procesu. Wszystkie mogą korzystać z tej samej sterty. Jeżeli ich cykle życia mocno się różnią, sposób rozmieszczenia danych w pamięci może z czasem stać się coraz mniej korzystny. To właśnie wtedy pojawia się fragmentacja.

Nie musi istnieć pojedyncza błędna alokacja. Nie musi być też klasycznego wycieku pamięci. Program może prawidłowo zwalniać wszystkie obiekty, a mimo tego jego proces będzie stopniowo utrzymywał coraz większy obszar pamięci.

„Zarządzanie uczy, że systemy nie zawodzą od razu po uruchomieniu. Widziałem, jak zawodzą w siódmym miesiącu, kiedy procesy, które trzymały się razem pod presją, zaczęły się sypać. Pamięć działa tak samo. Test porównawczy trwający dziesięć minut nie powie ci prawie nic przydatnego o zachowaniu produkcji” – mówi Martynov.

Długowieczne obiekty mogą blokować pamięć dla krótkich zadań

Wyobraźmy sobie program, w którym część danych jest potrzebna przez cały czas działania aplikacji, a obok nich pojawiają się tysiące krótkotrwałych obiektów tworzonych i usuwanych w szybkim tempie.

Obiekty krótkotrwałe mogą wielokrotnie zajmować i zwalniać fragmenty pamięci. Jeżeli jednak pomiędzy nimi znajdują się dane potrzebne przez długi czas, alokator nie zawsze będzie w stanie odzyskać całe strony pamięci i oddać je systemowi operacyjnemu. W efekcie proces może utrzymywać pamięć, która z punktu widzenia aktualnego zestawu aktywnych obiektów wydaje się znacznie większa, niż powinna.

Dla gry komputerowej taki scenariusz jest szczególnie istotny. Podczas jednej sceny mogą powstawać ogromne ilości krótkotrwałych struktur związanych z renderowaniem, fizyką czy przetwarzaniem danych sieciowych. Jednocześnie w pamięci pozostają zasoby i obiekty związane z całym poziomem albo sesją gracza.

Po kilku minutach problem może być niewidoczny. Po kilkudziesięciu minutach sytuacja może wyglądać zupełnie inaczej.

Martynov chce mierzyć pamięć w kolejnych fazach działania programu

W swojej pracy „Methodology for the Experimental Evaluation of Memory Allocation Strategies and Object Lifetime Models in C++ Systems” Martynov proponuje podział testu na kilka charakterystycznych etapów.

Pierwszym jest RampUp, czyli faza rozgrzewania i inicjalizacji sterty. Program tworzy podstawowy zestaw danych i przygotowuje środowisko do właściwej pracy.

Następnie pojawia się Steady. To okres stabilnego obciążenia, w którym alokacje i zwolnienia pamięci odbywają się przez dłuższy czas. W tej fazie można dodatkowo zastosować profil Churn, symulujący intensywną rotację obiektów przy utrzymującej się objętości danych.

Ostatnim etapem jest BulkReclaim. Tutaj program masowo usuwa obiekty, na przykład podczas zmiany sceny w grze, zakończenia zadania albo zwolnienia dużego zestawu danych.

Takie rozdzielenie pozwala zobaczyć, w którym momencie pojawia się problem. Alokator może być bardzo szybki podczas inicjalizacji, a jednocześnie zachowywać dużą ilość pamięci po masowym zwalnianiu obiektów. Inny może wypadać przeciętnie w pierwszej fazie, ale znacznie lepiej radzić sobie z długotrwałym obciążeniem. Jeden wynik końcowy może tego nie pokazać.

Pięć modeli czasu życia zamiast jednego syntetycznego obciążenia

Jednym z elementów zaproponowanej metodologii jest wykorzystanie różnych polityk czasu życia obiektów. Martynov opisuje między innymi modele FIFO, LIFO, losowy, ograniczony oraz długotrwały. Każdy z nich generuje inny sposób wykorzystania pamięci.

FIFO odzwierciedla sytuację, w której obiekty są zwalniane mniej więcej w kolejności ich utworzenia. LIFO działa odwrotnie i preferuje usuwanie najnowszych danych. Model losowy pozwala sprawdzić zachowanie alokatora przy mniej przewidywalnych cyklach życia, natomiast profil długotrwały pozwala badać sytuacje, w których część obiektów pozostaje aktywna znacznie dłużej niż reszta.

To ważne, ponieważ dwa alokatory mogą osiągnąć podobny wynik w jednym scenariuszu, a zupełnie inaczej zachowywać się przy zmianie struktury obciążenia. W praktyce oznacza to, że wybór alokatora nie powinien być dokonywany wyłącznie na podstawie jednego rankingu szybkości.

Problem może pojawić się dopiero po 45 minutach

Jednym z najbardziej wymownych przykładów jest aplikacja, która przechodzi standardowy test trwający kilka lub kilkanaście minut. Na początku wszystko wygląda prawidłowo. Zużycie pamięci stabilizuje się, czas odpowiedzi pozostaje niski, a benchmark nie pokazuje żadnego alarmującego trendu. Po dłuższym czasie sytuacja może zacząć się jednak zmieniać.

Jeżeli określone grupy obiektów mają mocno różne czasy życia, fragmentacja może narastać stopniowo. W przypadku gry problem może stać się widoczny dopiero podczas długiej sesji. W przypadku serwera podobny efekt może ujawnić się dopiero po wielu godzinach obsługi żądań.

Wtedy znalezienie przyczyny bywa znacznie trudniejsze. Kod odpowiedzialny za konkretny wzorzec alokacji może znajdować się w wielu komponentach rozwijanych przez lata. Martynov proponuje więc, aby długotrwałe zachowanie pamięci było elementem benchmarku od samego początku, a nie problemem analizowanym dopiero po wystąpieniu incydentu.

Nie każdy szybki alokator będzie dobry dla każdego programu

W drugiej pracy, „Architectural Patterns of Specialized Memory Allocators in C++ and Trade-Offs between Latency and Fragmentation”, Martynov analizuje różne rodzaje architektur alokatorów. Wśród nich znajdują się między innymi bump i arena allocators, pule pamięci, stosowe mechanizmy alokacji, rozwiązania segregated-list oraz rozproszone sterty przypisane do wątków. Każda z tych technik ma swoje zalety. Problem zaczyna się wtedy, gdy sposób działania alokatora nie odpowiada rzeczywistemu profilowi obciążenia.

Rozwiązanie zaprojektowane pod kątem bardzo krótkich cykli życia może oferować świetne opóźnienia, lecz zachowywać się znacznie gorzej, gdy część obiektów pozostaje aktywna przez długi czas. Z kolei mechanizm nastawiony na stabilność pamięci może zapłacić za to większym kosztem pojedynczej operacji.

Dlatego Martynov proponuje patrzeć na wydajność alokatora jako na właściwość zależną od warunków pracy, a nie stałą cechę konkretnej biblioteki.

W grach problem może być wyjątkowo kosztowny

Doświadczenie Martynova jest szczególnie interesujące z powodu jego pracy w Playrix, firmie odpowiedzialnej między innymi za popularną serię Homescapes. Gry mobilne są wymagającym środowiskiem dla zarządzania pamięcią. Program musi przez długi czas obsługiwać zmieniające się sceny, dane użytkownika, zasoby graficzne, komunikację sieciową oraz obiekty tworzone podczas rozgrywki.

Jednocześnie urządzenia mobilne dysponują ograniczonymi zasobami. Stopniowe zwiększanie zajętości pamięci może więc prowadzić do problemów, które nie pojawiają się podczas krótkiego testu przeprowadzanego na wydajnym komputerze deweloperskim. W takim środowisku liczy się nie tylko maksymalna szybkość alokacji. Równie ważne staje się przewidywalne zachowanie po długim czasie pracy.

Zamiast szukać wycieku, warto sprawdzić strukturę pamięci

Jedną z najciekawszych konsekwencji tej metodologii jest zmiana sposobu diagnozowania problemów.

Gdy proces zaczyna zużywać coraz więcej RAM-u, pierwszym podejrzeniem często jest wyciek pamięci. Tymczasem nie każdy wzrost zajętości pamięci musi oznaczać utratę referencji do niepotrzebnych danych.

Czasem wszystkie obiekty są poprawnie zwalniane, lecz sposób ich rozmieszczenia uniemożliwia efektywne odzyskanie większych fragmentów pamięci.

W takiej sytuacji naprawa pojedynczego miejsca w kodzie może nie przynieść oczekiwanego efektu. Problem znajduje się wyżej, na poziomie architektury zarządzania pamięcią.

Jednym z rozwiązań może być rozdzielenie obiektów według ich przewidywanego czasu życia i skierowanie ich do różnych obszarów pamięci. Dane długotrwałe nie muszą wtedy blokować odzyskiwania przestrzeni zajmowanej wcześniej przez obiekty krótkotrwałe.

C++ nadal wymaga myślenia o tym, co dzieje się po uruchomieniu programu

Badania Martynova dotyczą bardzo konkretnego problemu, ale pokazują też charakterystyczną cechę C++. Wysoka wydajność daje programistom ogromną kontrolę nad pamięcią, lecz ta kontrola wymaga rozumienia zachowania aplikacji w czasie.

Benchmark wykonywany przez kilka minut może być świetnym narzędziem do porównania dwóch implementacji. Nie powinien jednak automatycznie być traktowany jako dowód, że rozwiązanie będzie dobrze działało podczas wielogodzinnej pracy.

Proponowana metodologia próbuje wprowadzić do testów właśnie ten brakujący wymiar.

Zamiast pytać wyłącznie „jak szybko alokator przydziela pamięć?”, można zapytać również „jak zachowuje się po milionach cykli alokacji i zwolnień?”, „jak reaguje na obiekty o różnych czasach życia?” oraz „co dzieje się po masowym zwolnieniu danych?”. To różnica pomiędzy pomiarem pojedynczej operacji a obserwacją całego cyklu życia aplikacji.

Benchmark, który ma wykrywać problem zanim trafi do użytkownika

Najważniejszym celem prac Martynova nie jest stworzenie kolejnego rankingu alokatorów. Chodzi o przygotowanie sposobu testowania, który pozwoli zespołom znaleźć problemy z pamięcią wcześniej.

Jeżeli degradacja pojawia się dopiero po kilkudziesięciu minutach, benchmark musi być w stanie ją zobaczyć. Jeżeli problem zależy od czasu życia obiektów, parametr ten powinien zostać uwzględniony w eksperymencie. Jeżeli konkretny alokator zachowuje się dobrze w jednej fazie, a źle w innej, wynik powinien pokazać tę różnicę zamiast ukrywać ją za jedną średnią.

To podejście jest szczególnie wartościowe dla dużych projektów C++, w których zmiana architektury pamięci po pojawieniu się problemu produkcyjnego może być kosztowna i ryzykowna.

W przypadku systemów działających przez wiele godzin odpowiedź może tkwić nie w pojedynczej instrukcji kodu, lecz w tym, jak długo żyją obiekty i jak przez ten czas zmienia się cała struktura pamięci.

Spodobało Ci się? Podziel się ze znajomymi!

Pokaż / Dodaj komentarze do:

Twój program C++ działa idealnie, a potem zaczyna zjadać RAM. Ten benchmark pokazuje dlaczego
 0