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.