Optymalizacja kodu C# – część I  

Udostępnij na: Facebook

Autor: Piotr Zieliński

Opublikowano: 2013-01-25

Wprowadzenie

Wydajność jest ważna w każdym typie aplikacji. To, jak szybko działa dana aplikacja ma ogromny wpływ na jej sukces. Oczywiście nie zawsze jest sens optymalizować każdy fragment programu – czasami lepiej użyć wolniejszego rozwiązania, ale ładniejszego z punktu widzenia programisty, co może znacząco ułatwić późniejsze utrzymanie takiego kodu. Skala optymalizacji zależy od zastosowania oprogramowania i zaakceptowanych wymagań. Dobór technologii oraz samego języka jest również kwestią zasadniczą, szczególnie dla systemów czasu rzeczywistego. Artykuł jednak jest ograniczony wyłącznie do języka C#. Optymalizacja powinna mieć charakter ciągły i musi być analizowana na bieżącą wraz z pisaniem każdej nowej linii kodu, która znajdzie się w końcowym produkcie. Z tego względu, bardzo ważna jest znajomość wydajności poszczególnych elementów C# już przed napisaniem kodu – z pewnością zaoszczędzi to dużo czasu programistom. Artykuł pokazuje, w jaki sposób różne konstrukcje wpływają na końcową wydajność.

Klasa Stopwatch

W badaniu wydajności kodu bardzo pomocną klasą jest Stopwatch, która stanowi po prostu „stoper”. Umożliwia ona sprawdzenie, jak długo dany fragment kodu był wykonywany. Obsługa klasy jest bardzo prosta i sprowadza się do wywołania metod Stopwatch.Start() oraz Stopwatch.Stop, na początku i na końcu badanego kodu. Później, za pomocą różnych właściwości można uzyskać dane o czasie wykonywania kodu:

static void Main(string[] args)
{
     Stopwatch stopWatch = new Stopwatch();            
     stopWatch.Start();
     Thread.Sleep(10000);
     stopWatch.Stop();
         
     TimeSpan ts = stopWatch.Elapsed;
         
     string elapsedTime = String.Format("{0:00}:{1:00}:{2:00}.{3:00}",
                                        ts.Hours, ts.Minutes, ts.Seconds,
                                        ts.Milliseconds/10);
     Console.WriteLine("RunTime " + elapsedTime);
}

Przydatną metodą jest Stopwatch.Startnew(), który tworzy instancję oraz jednocześnie wywołuje na niej Start:

Stopwatch stopwatch = Stopwatch.StartNew();
Thread.Sleep(100);
stopwatch.Stop();
Console.WriteLine(stopwatch.ElapsedMilliseconds);

Profiler w Visual Studio

Czasami ciężko określić, który fragment kodu dokładnie powoduje problemy wydajnościowe. Na rynku istnieje wiele profilerów przeznaczonych do analizy wydajnościowej czy pamięciowej kodu. Visual Studio również dostarcza wbudowane narzędzie, które można wykorzystać w wielu scenariuszach.

Załóżmy, że należy przeanalizować wydajność następującego programu:

class Program
{
    static private string GenerateText()
    {
        string text = "a";
        for (int i = 0; i < 10000; i++)
        text += "test" + i.ToString();

        return text;
    }
    static void Main(string[] args)
    {
        string text = GenerateText();
        Console.WriteLine(text);
    }
}

W celu uruchomienia profilera należy wybrać z menu głównego Analyze->Launch Performance Wizard:

Do dyspozycji są cztery typy analizy:

  • CPU Sampling – badanie zużycia (procentowego) procesora,
  • Instrumentation – oparte na czasie wykonania poszczególnych funkcji,
  • .NET Memory Allocation – obliczaną metryką jest zużycie pamięci,
  • Concurrency – bardzo przydatne w aplikacji wielowątkowej. Pozwala prześledzić ile wątków jest tworzonych. Bardzo możliwe, że w kodzie istnieje jakiś błąd i zbyt duża liczba wątków jest tworzona.

Najlepiej zacząć profiling od pierwszej opcji. Na następnym ekranie należy wybrać, jaka aplikacja będzie dokładnie monitorowana (możliwe przecież, że w solucji jest kilka projektów).

Po przejściu do końca kreatora i zaakceptowaniu etapów, aplikacja zostanie uruchomiona. Jeśli aplikacja wymaga interakcji (zwykle tak jest), należy wówczas wykonać potrzebne kroki – w tym czasie profiler zbiera informacje o wydajności. W prezentowanym przykładzie jest to aplikacja konsolowa, która oczywiście sama się zakończy (interakcja użytkownika jest niepotrzebna). W praktyce jednak, często testowane są klasyczne aplikacje z interfejsem użytkownika. Należy więc obsłużyć aplikację w taki sposób, że kod, który jest badany, zostanie uruchomiony.

Po zakończeniu zostanie wygenerowany raport:

Jeśli istnieje kilka wygenerowanych raportów, można przełączać się pomiędzy nimi w oknie Performance Explorer:

Warto jednak przyjrzeć się dokładnie samemu raportowi. Częścią rzucającą się w oczy od razu jest wygenerowany wykres zużycia procesora:

Oś pionowa (y) zawiera procentowe zużycie procesora. Z kolei na osi poziomej (x) zlokalizowane są po prostu wartości czasu (t). Wszelkie wysokie wartości na osi y powinny być sygnałem, że coś poszło nie tak. Tak samo, nagłe wzrosty wartości (tzw. spikes) również są zwykle sygnałem, że wydajność może zostać zoptymalizowana lub należy zastanowić się na zrównolegleniem zaplanowanych zadań. Warto zwrócić uwagę, że wykres jest interaktywny, można go przybliżać, oddalać, a nawet zaznaczać konkretne jego części. Zaznaczenie powoduje odświeżenie wszystkich tabel, o których mowa w dalszej części artykułu.

Jeśli nastąpiła zmiana kodu, można ponownie uruchomić profiler, klikając odpowiednią ikonę w Performance Explorer:

Kolejną ważną częścią głównego raportu jest tzw. hot path:

Rubryka pokazuje, które wywołania zużywają najwięcej procesora. Inclusive Sample dotyczy zarówno samej funkcji, jak i wszystkich funkcji z niej wywołanych. Z kolei Exclusive Samples nie bierze pod uwagę innych wywołanych metod. Skoro metoda Program_Main sama w sobie niewiele robi, a tylko wywołuje inne metody, wtedy Exclusive Sample jest bliski zera. Dzięki rozdzieleniu tego na dwie rubryki (inclusive i exclusive), łatwo dowiedzieć się, czy sama funkcja wykonuje skomplikowane operacje, czy tak naprawdę deleguje do innych metod, które to dopiero zawierają jakiś problem wydajnościowy. Niewątpliwie w powyższym przykładzie metoda concat powoduje zbyt duże zużycie procesora.

Ostatnią częścią jest tabelka pokazująca, które funkcje robią najwięcej operacji (a konkretniej, które zużywają najwięcej CPU):

Widać, że wykorzystywana jest metryka Exclusive, bo chodzi tutaj o indywidualne zużycie. Jeśli metoda A wywołuje B, która z kolei ma wysokie zużycie CPU, wtedy problem leży w funkcji B a nie A, która tylko oddelegowuje pracę, a nie ją wykonuje.

Oprócz podsumowania raportu jest wiele innych widoków, do których można przełączać się za pomocą ComboBox:

Analizowany kod nie jest zbyt skomplikowany, ale warto kliknąć w podsumowaniu na GenerateText, a następnie przejść do okna FunctionDetails:

Widać wyraźnie, która linia jest najbardziej skomplikowana. Reszta linii jest tak prosta, że zużywa po prostu około 0% CPU. Gdyby było inaczej, w profiler byłoby zaznaczone, która linia, ile zużywa CPU.

Z okna podsumowanie można również kliknąć „View Guidelines” i zobaczyć zasugerowane poprawki:

Oczywiście w metodzie GenerateText tworzonych jest zbyt wiele tymczasowych stringów, które potem muszą być połączone w jeden kawałek. W takich przypadkach lepszym rozwiązaniem jest stworzenie StringBuilder, która posiada wewnętrzny bufor. Poniższa linia tworzy kilka obiektów string:

text += "test" + i.ToString();

Jeden obiekt musi zostać stworzony dla „test”, kolejny na i.ToString, następny na połączenie „test” z i.ToString(), a wreszcie ostatni, zawierający połączenie text z „test+i.ToString()”. Oprócz tworzenia nowych obiektów, dużo czasu jest po prostu marnowane przez GC do usunięcia ich z pamięci. W każdej iteracji (a jest ich 10 000) tworzonych jest kilka tymczasowych obiektów! Dużo lepszym rozwiązaniem jest następujący kod (zawiera wciąż kilka poważnych wad, ale o tym później):

static private string GenerateText()
{
         var stringBuilder=new StringBuilder("a");
         for (int i = 0; i < 10000; i++)
         {
             stringBuilder.Append("test");
             stringBuilder.Append(i.ToString()); // !!!
         }
         return stringBuilder.ToString();
}

Po ponownym uruchomieniu profilera można porównać ze sobą dwa raporty. Wystarczy zaznaczyć je w Performance Explorer i wybrać z menu kontekstowego Compare Performance Reports:

Po chwili pojawi się interaktywny raport, zawierający zmiany:

Łatwo dostrzec, że teraz Concact nie jest problemem i najdłuższą operacją jest wywołanie i.ToString(), który oczywiście tworzy również tymczasowy string. Rozwiązaniem tego problemu jest użycie odpowiedniego przeładowania Append, które akceptuje integer, a nie string:

static private string GenerateText()
{
    var stringBuilder=new StringBuilder("a");

    for (int i = 0; i < 10000; i++)
    {
        stringBuilder.Append("test");
        stringBuilder.Append(i);
     }
     return stringBuilder.ToString();
}

Dalsze eksperymentowanie i ulepszanie kodu zostawiam czytelnikowi.

Opis wszystkich opcji wykracza poza zakres tego artykułu. Moim celem było wyłącznie wprowadzenie do profilera. Pozostałe widoki są jednak na tyle proste, że wystarczy je po prostu przejrzeć. Podobnie, jeśli ktoś jest już zadowolony ze swojego aktualnego profilera, nic nie stoi na przeszkodzie, aby z niego korzystać w dalszej części artykułu.

Tablice danych

W C# istnieje wiele typów tablic i każdy wybór ma swoje zalety i wady. W poniższych rozważaniach, wydajność jest oczywiście najważniejszą metryką. Pod uwagę zostały wzięte następujące przypadki:

- tablica wielowymiarowa,

- tablica tablic – tzw. jagged array,

- tablica unsafe.

Tablice wielowymiarowe w C# są najwolniejsze, ponieważ CLR nie wykonuje wszystkich optymalizacji. Poniższy kod testuje wydajność dostępu do powyższych typów tablic:

internal class Program
{
    private static void DoSomething(int arg)
    {
    }
    private static void MultiDimensionalArrayTest(int xCount, int yCount)
    {
        int[,] array = new int[xCount, yCount];
        var stopWatch = Stopwatch.StartNew();
        for (int i = 0; i < xCount; i++)
            for (int j = 0; j < yCount; j++)
                DoSomething(array[i, j]);

        stopWatch.Stop();
        Console.WriteLine("MultiArray:{0}", stopWatch.ElapsedMilliseconds);
    }
    private static void JaggedArrayTest(int xCount, int yCount)
    {
        int[][] array = new int[xCount][];
        for (int i = 0; i < array.Length; i++)
            array[i] = new int[yCount];

        var stopWatch = Stopwatch.StartNew();

        for (int i = 0; i < xCount; i++)
            for (int j = 0; j < yCount; j++)
                DoSomething(array[i][j]);

        stopWatch.Stop();
        Console.WriteLine("JaggedArray:{0}", stopWatch.ElapsedMilliseconds);
    }
    private unsafe static void UnsafeArrayTest(int xCount, int yCount)
    {
        int[,] array = new int[xCount, yCount];

        fixed (int* pointer = array)
        {
            var stopWatch = Stopwatch.StartNew();
            for (int i = 0; i < yCount; i++)
            {
                int baseIndex = i * xCount;
                for (int j = 0; j < xCount; j++)
                    DoSomething(pointer[baseIndex + j]);
            }
            stopWatch.Stop();
            Console.WriteLine("Unsafe:{0}", stopWatch.ElapsedMilliseconds);
        }
    }
    private static void Main(string[] args)
    {
        const int xCount = 13000;
        const int yCount = 13000;
        UnsafeArrayTest(xCount, yCount);
        JaggedArrayTest(xCount, yCount);
        MultiDimensionalArrayTest(xCount, yCount);
    }
}

Otrzymany wynik w trybie release to:

Unsafe: 555
Jagged: 363
MultiArray: 787

Najwolniejsza jest oczywiście zwykła wielowymiarowa tablica. CLR przede wszystkim w każdej iteracji musi sprawdzać, czy indeks nie wykracza poza rozmiar, tzn. wykonuje następujący kod:

if(0 >= a.GetLowerBound(0)) && ((i) <= a.GetUpperBound(0))

W przypadku jednowymiarowych tablic, kod zostanie w większości przypadków wykonany raz, przed pętlą:

(0 >= a.GetLowerBound(0)) && ((elementsCount – 1) <= a.GetUpperBound(0)).

Ponadto, tablice wielowymiarowe zachowują się podobnie do tych, które nie zaczynają się od indeksu zero (w C# można również takie tablice tworzyć). Z tego względu w każdej iteracji, CLR ma o jedną rzecz więcej do roboty, a mianowicie obliczenie prawidłowego indeksu dostępowego.

Dostęp do tablic jagged jest najszybszy, ponieważ jest to zwykła jednowymiarowa tablica. Niestety alokacja takich tablic jest zdecydowanie wolniejsza, ponieważ tworzonych jest wiele obiektów, które muszą potem zostać zebrane przez GC. W przypadku wielowymiarowej tablicy tworzony jest wyłącznie jeden obiekt – dla jagged tworzone są one dla każdego wymiaru.

W przypadkach, gdy tablica jest tworzona tylko raz, a za to często należy uzyskiwać dostęp do elementów, jagged jest najlepszym rozwiązaniem. Dostęp przez wskaźnik jest za to kompromisem – szybsza inicjalizacja niż jagged, ale trochę wolniejszy dostęp. Wydajnościowo takie podejście jest umiejscowione pomiędzy tymi dwoma rozwiązaniami. Niestety unsafe jest trudniejszy w implementacji, łatwo popełnić błąd i czytać elementy, które znajdują się poza tablicą – zamiast tablicy może okazać się, że jest czytana pamięć przechowująca np. hasło.

Różnice w implementacji wewnętrznej klasy StringBuilder

Od wersji .NET 4.0 zmieniła się wewnętrzna implementacja i reprezentacja StringBuilder. Z tego względu kod, który kiedyś działał szybko, może działać dużo wolniej na frameworkach 4.0 czy 4.5.

Przed pojawieniem się wersji 4.0, StringBuilder używał tablicę w reprezentacji wewnętrznej. Dzięki temu, wywołanie ToString było bardzo szybkie, ponieważ sprowadzało się do zwrócenia tablicy znaków. Od wersji 4.0, w celu optymalizacji dodawania nowych elementów, zmieniono tablicę na listę danych, prawdopodobnie jednokierunkową. Wywołanie funkcji Clear, która wewnętrznie sprowadza się do ustawienia właściwości Length na zero, powoduje znaczący spadek wydajności. Prawdopodobnie jest to spowodowane, że realokacja listy jest wolniejsza, a kod nie przewidział, że ktoś może ustawić długość na zero (Clear).  Poniższy kod, uruchomiony na frameworkach 2.0, 3.5, 4.0 oraz 4.5, daje następujące wyniki:

class Program
{
    static private void Test()
    {
        var strBuilder = new StringBuilder();
        var stopWatch = Stopwatch.StartNew();

         for (int i = 0; i < 10000; i++)
        {
            for (char j = 'A'; j < 'Z'; j++)
                strBuilder.Insert(0,"test");

            strBuilder.Length = 0; // lub StringBuilder.Clear()
        }
        stopWatch.Stop();
        Console.WriteLine(stopWatch.ElapsedMilliseconds);
    }
    static void Main(string[] args)
    {
        Test();
    }
}

.NET Framework 2.0: 21
.NET Framework 3.5: 21
.NET Framework 4.0: 2165
.NET Framework 4.5: 2107

Rozwiązaniem jest tworzenie za każdym razem nowej instancji StringBuilder zamiast wywoływanie Clear we wszystkich iteracjach. Innym podejściem (najszybszym) jest stworzenie jednej instancji StringBuilder, wygenerowanie jednego, długiego ciągu znaków, a następnie odseparowanie od siebie poszczególnych stringów.

Zakończenie

Badanie wydajności kodu potrafi być bardzo ciężkie, aczkolwiek narzędzia dostępne na dzisiejszym rynku znacząco ułatwiają pracę. Profiling oraz code review powinny być wykonywane regularnie, a nie dopiero w momencie, gdy coś działa zbyt wolno. Z pewnością programiści zaoszczędzą czas, gdy ewentualne problemy zostaną wykryte na początku. Poza tym, umożliwia to deweloperom zdobycie nowego doświadczenia, co pozwoli po prostu uniknąć tych samych błędów w przyszłości. W kolejnych artykułach omówione zostanie zagadnienie dotyczące błędów popełnianych w C#, a nie na technikach monitorowania aplikacji. Poznanie innych narzędzi (głównie profilery oraz .NET Reflector)  pozostawiam w gestii czytelnika.

 


 


          

Piotr Zieliński

Absolwent informatyki o specjalizacji inżynieria oprogramowania Uniwersytetu Zielonogórskiego. Posiada szereg certyfikatów z technologii Microsoft (MCP, MCTS, MCPD). W 2011 roku wyróżniony nagrodą MVP w kategorii Visual C#. Aktualnie pracuje w General Electric pisząc oprogramowanie wykorzystywane w monitorowaniu transformatorów . Platformę .NET zna od wersji 1.1 – wcześniej wykorzystywał głównie MFC oraz C++ Builder. Interesuje się wieloma technologiami m.in. ASP.NET MVC, WPF, PRISM, WCF, WCF Data Services, WWF, Azure, Silverlight, WCF RIA Services, XNA, Entity Framework, nHibernate. Oprócz czystych technologii zajmuje się również wzorcami projektowymi, bezpieczeństwem aplikacji webowych i testowaniem oprogramowania od strony programisty. W wolnych chwilach prowadzi blog o .NET i tzw. patterns & practices.