Testy wydajnościowe: Zwalnianie pamięci oraz JIT

W dzisiejszym poście pokażę kilka błędów popełnianych podczas próby oszacowania efektów optymalizacji a raczej mikro-optymaliacji. Częściowo popełniałem te błędy na moim blogu, ale zawsze wykonywałem pomiary w pętli, co niwelowało te drobne różnice.

Rozważmy następujący przykład:

internal class Test
{
   private static void Main(string[] args)
   {
       Stopwatch stopwatch = Stopwatch.StartNew();
       Method();
       Console.WriteLine(stopwatch.ElapsedTicks);

       stopwatch = Stopwatch.StartNew();
       Method();
       Console.WriteLine(stopwatch.ElapsedTicks);
   }

   private static void Method()
   {
       for (int i = 0; i < 1000; i++)
       {
           
       }
   }
}

Testujemy tą samą funkcję więc spodziewalibyśmy się, że na ekranie wyświetli się dokładnie taka sama wartość.  Niestety w trybie debug zobaczymy np. 180 i 8 – ogromna różnica. Nie ma w tym jednak nic nadzwyczajnego. Każda metoda jest kompilowana do kodu natywnego (uwzględniającego architekturę CPU itp.) w momencie gdy jest ona potrzebna (JIT – Just-In-Time). W momencie gdy wywołujemy metodę pierwszy raz, taka kompilacja jest wykonywana. Drugi raz nie ma już takiej potrzeby i wywołanie jest dużo szybsze. Wniosek  taki, że nie ma sensu uwzględniać w naszych pomiarach, pierwszego, nieskompilowanego w pełni kodu.

Innym  często popełnianym błędem jest zapomnienie o tym, że alokacja pamięci w .NET jest bardzo szybka, ale już zwolnienie zasobów wiążę się ze skomplikowanym procesem. Pisałem o GC wiele razy, ale w skrócie, zwalnianie pamięci zależy od tego ile generacji trzeba przejść. W GEN0 jest mało obiektów i zwolnienie zasobów jest w miarę szybkie. Ciężko uwzględnić w pomiarach wpływ algorytmu na GC ale możemy przynajmniej wywołać GC przed i po algorytmie tzn.:

GC.Collect();
GC.WaitForPendingFinalizers();

Method();

var stopwatch = Stopwatch.StartNew();
Method();

GC.Collect();
GC.WaitForPendingFinalizers();

Console.WriteLine(stopwatch.ElapsedTicks);

Powyższy kod wyczyści zasoby przed danym algorytmem i po. Dzięki temu będziemy mogli oszacować jaki wpływ ma na pamięć dany kod. Oczywiście nie jest to perfekcyjne podejście. Ze względu na destruktory, dany obiekt może przejść do kolejnych generacji i nie zostanie to uwzględnione. Nie wiadomo kiedy normalnie GC zostałby wywołany. Powyższy benchmark, zakłada, że zaraz po wykonaniu algorytmu zostanie dokonana kolekcja, co w praktyce ma małe prawdopodobieństwo.

Należy jednak zadać sobie pytanie ile zajmie kolekcja po wykonaniu danego kodu. Na przykład jagged arrays są bardzo szybkie w porównaniu do tablic wielowymiarowych, ale zwolnienie zasobów po nich nich jest z kolei wiele wolniejsze – większość testów wydajnościowych nie uwzględnia  tego faktu.

Leave a Reply

Your email address will not be published.