Optymalizacja kodu C# – część II  

Udostępnij na: Facebook

Autor: Piotr Zieliński

Opublikowano: 2013-02-05

Wprowadzenie

W poprzedniej części artykułu zaprezentowano kilka prostych sposobów badania wydajności kodu. Pokazano, jak zmiana w wewnętrznej implementacji StringBuilder wpływa na wydajność. Porównano również kilka sposobów dostępu do tablic w języku C#. W drugiej części zostaną zaprezentowane poszczególne elementy w C#, które mają wpływ na wydajność, a zwykle używane są przez programistów bez wiedzy o ich wewnętrznej implementacji. C# dostarcza wiele mechanizmów ułatwiających codzienną pracę. Dzięki nim wydawałoby się, że sztuka programowania jest o wiele łatwiejsza. Oczywiście ma to same pozytywne strony, ale niesie ze sobą pułapkę. Nauka samego języka może okazać się bardzo łatwa, ale bez wiedzy dotyczącej funkcji poszczególnych instrukcji powstałe programy mogą działać wolno i przynosić nieoczekiwane rezultaty. W czasach, gdy programiści korzystali z asemblera, w celu napisania jakiegokolwiek programu, musieli dysponować ogromną wiedzą o architekturze komputera czy poszczególnych instrukcjach CPU. Dzisiaj wiele osób zapomina o tym, przez to programy często działają wolno lub wykonywane operacje przynoszą efekty uboczne.

Programowanie dynamiczne – słowo kluczowe dynamic

Słowo dynamic potrafi zdecydowanie uprościć kod w przypadku wykorzystania bibliotek COM lub w sytuacjach, kiedy użycie mechanizmu refleksji jest niezbędne. Mało osób jednak wie, jak wewnętrznie działa dynamic i jakie są tego konsekwencje. Warto przeanalizować kilka scenariuszy, aby wiedzieć jak się zachować w przypadku pisania kluczowych fragmentów systemu.

Pierwszy test przedstawiony w artykule polega na porównaniu wydajności dodawania dwóch liczb:

private static void TestStatic()
{
   var stopwatch = Stopwatch.StartNew();
   int a = 10;
   int b = 45;
   int c = a + b;
   stopwatch.Stop();
   Console.WriteLine("Static:{0}", stopwatch.ElapsedTicks);
}
private static void TestDynamic()
{
   var stopwatch = Stopwatch.StartNew();
   dynamic a = 10;
   dynamic b = 45;
   dynamic c = a + b;
   stopwatch.Stop();
   Console.WriteLine("Dynamic:{0}",stopwatch.ElapsedTicks);
}

TestStatic bada wydajność kodu opartego o statyczne typowanie – wszystkie typy zmiennych znane są już na etapie kompilacji. Z kolei, TestDynamic używa słowa kluczowego dynamic, co oznacza, że typ pola zostanie wyznaczony dopiero w trakcie wykonywania kodu.Wykonując pierwszy raz powyższe metody otrzymano następujące wyniki:

Static:2
Dynamic:125810

Różnica jest oczywiście kolosalna i nie do zaakceptowania w większości przypadków. Dynamiczne rozwiązanie okazało się wolniejsze o ponad 60 000 razy! Aby w pełni zrozumieć otrzymany wynik, należy przeanalizować, jak działa DLR. Gdy pierwszy raz wykonywana jest metoda TestDynamic, DLR sprawdza, czy została już wcześniej skompilowana. Oczywiście, za pierwszym razem nie została, więc używając specjalnego kompilatora sprawdzany jest rodzaj zmiennej i wstawiany jest silnie typowany (w tym przypadku int). Wynik buforowany jest w taki sposób, że następne wywołania nie muszą już wykonywać tej logiki. Dla CLR “skompilowany” kod niczym nie różni się od TestStatic. Słowo dynamic przeznaczone jest dla DLR, a nie CLR – w momencie wykonywania kodu, dynamic zastępowany jest normalnym, statycznym typem.

Z powyższych rozważań wynika, że następne wywołania TestDynamic powinny być wydajnościowo zbliżone do TestStatic. Sprawdźmy to:

private static void Main(string[] args)
{
   TestStatic(); // szybkie
   TestDynamic();//wolne

   TestStatic(); // szybkie
   TestDynamic();// również szybkie
}

Wyniki są następujące: 2 dla TestStatic oraz 3 dla TestDynamic. Oczywiście to zbieg okoliczności, że TestDynamic ma wartość trochę większą. Należy traktować obydwa wywołania, tak jakby miały identyczny lub BARDZO zbliżony poziom wydajności.  Wniosek jest taki, że dla metod, które często są wywoływane, dynamic może być dobrym rozwiązaniem. W przypadku pojedynczego zastosowania, zbyt wiele czasu poświęcane jest przez DLR, a korzyści z zastosowania dynamic nie są na tyle wystarczające, aby z niego skorzystać.

Oczywiście powyższe przykłady nie są praktyczne. Dla prostych operacji, typu dodanie dwóch liczb, zawsze lepiej wykorzystać statyczne typowanie. Istnieje jednak bardzo ważny scenariusz użycia dynamic – mechanizm refleksji. Korzystanie z niego jest dość skomplikowane, a powstały kod jest bardzo trudny w czytaniu. Sytuacja jest szczególnie zła w przypadku wykonywania właściwości typu indexer. Należy zaznaczyć, że refleksja jest wolniejsza niż klasyczne wywoływanie metod. Z tego względu, poniższe metody testują wydajność trzech scenariuszy:

  • klasyczne wywołanie metody,
  • wywołanie metody za pomocą refleksji,
  • wywołanie metody za pomocą słowa kluczowego dynamic.

Test:

internal class Program
{
    private const int N = 10000000;
    private static void TestReflectionInvoke()
    {
        MethodInfo toUpper = typeof(string).GetMethod("ToUpper",new Type[0]);
        var stopwatch = Stopwatch.StartNew();
        object test = "Hello World";
        for (int i = 0; i < N; i++)
            test = toUpper.Invoke(test, null);
        stopwatch.Stop();
        Console.WriteLine("Reflection:{0}", stopwatch.ElapsedTicks);
    }
    private static void TestStaticInvoke()
    {
        var stopwatch = Stopwatch.StartNew();
        string test = "Hello World";
        for (int i = 0; i < N; i++)
            test = test.ToUpper();
        stopwatch.Stop();
        Console.WriteLine("Static:{0}", stopwatch.ElapsedTicks);
    }
    private static void TestDynamicInvoke()
    {
        var stopwatch = Stopwatch.StartNew();
        dynamic test = "Hello World";
        for (int i = 0; i < N;i++ )
            test = test.ToUpper();
        stopwatch.Stop();
        Console.WriteLine("Dynamic:{0}", stopwatch.ElapsedTicks);
    }
    private static void Main(string[] args)
    {
        TestStaticInvoke();
        TestDynamicInvoke();
        TestReflectionInvoke();
    }
}

Wyniki:

Static:15549636
Dynamic:15835424
Reflection:20069418

Dynamic wygrywa z refleksją, ponieważ metoda została wykonana wiele razy. W przypadku, gdy N=1, refleksja jest znacząco szybsza. Dynamic jednak wygrywa z refleksją, gdy DLR zbuforował już kod (następne wykonania). Pierwsze wykonanie jest zawsze wolniejsze od wywołania statycznego i refleksji. Ponadto, łatwiej jest korzystać z dynamic, pisząc po prostu:

test = test.ToUpper();

Zamiast:

MethodInfo toUpper = typeof(string).GetMethod("ToUpper",new Type[0]);
test = toUpper.Invoke(test, null);

Warto zaobserwować w oknie output, że przy pierwszym wykonaniu dynamic, ładowane są dodatkowe biblioteki (co oczywiście ma wpływ na wydajność):

'ConsoleApplication5.vshost.exe' (Managed (v4.0.30319)): Loaded 'C:\Windows\Microsoft.Net\assembly\GAC_MSIL\System.Dynamic\v4.0_4.0.0.0__b03f5f7f11d50a3a\System.Dynamic.dll'

'ConsoleApplication5.vshost.exe' (Managed (v4.0.30319)): Loaded 'Anonymously Hosted DynamicMethods Assembly'

Inicjalizacja pól a konstruktory

Konstruktory znane są jako mechanizm inicjalizacji obiektów. C# również umożliwia inicjalizację pól w momencie ich deklaracji (inline). Z punktu widzenia użytkownika kodu wszystko wygląda bardzo prosto, choć nie jest już to takie proste od strony CLR. Przykład inicjalizacji inline:

public class SampleClass
{
    private int _value = 10;
}

W rzeczywistości wygenerowany zostanie konstruktor, ustawiający pole _value na 10. Kod IL:

.method public hidebysig specialname rtspecialname instance void .ctor() cil managed
{
    .maxstack 8
    L_0000: ldarg.0
    L_0001: ldc.i4.s 10
    L_0003: stfld int32 SampleClass::_value
    L_0008: ldarg.0
    L_0009: call instance void [mscorlib]System.Object::.ctor()
    L_000e: nop
    L_000f: ret
}

Na razie wszystko wygląda świetnie… Został wygenerowany konstruktor, który ustawia zmienną na daną wartość, a programista nie musi pisać zbyt dużo kodu (inicjalizacja inline). Z przykładu wynika, że wartość ustawiona jest najpierw na 10, a potem dopiero wywołany zostaje konstruktor klasy bazowej.

Jednak, po jakimś czasie, okazało się, że należy dodać kilka innych pól oraz dodatkowe konstruktory, tzn.:

public class SampleClass
{
    private int _value1 = 10;
    private int _value2 = 10;
    private int _value3 = 10;
    private int _value4 = 10;
    public SampleClass()
    {
    }
    public SampleClass(int arg1)
    {
    }
    public SampleClass(int arg1, int arg2)
    {
    }
    public SampleClass(int arg1, int arg2, int arg3)
    {
    }
}

Kod IL już nie wygląda tak prosto:

// Konstruktor bezparametrowy
.method public hidebysig specialname rtspecialname instance void .ctor() cil managed
{
    .maxstack 8
    L_0000: ldarg.0
    L_0001: ldc.i4.s 10
    L_0003: stfld int32 SampleClass::_value1
    L_0008: ldarg.0
    L_0009: ldc.i4.s 10
    L_000b: stfld int32 SampleClass::_value2
    L_0010: ldarg.0
    L_0011: ldc.i4.s 10
    L_0013: stfld int32 SampleClass::_value3
    L_0018: ldarg.0
    L_0019: ldc.i4.s 10
    L_001b: stfld int32 SampleClass::_value4
    L_0020: ldarg.0
    L_0021: call instance void [mscorlib]System.Object::.ctor()
    L_0026: nop
    L_0027: nop
    L_0028: nop
    L_0029: ret
}
// ctor 2
.method public hidebysig specialname rtspecialname instance void .ctor(int32 arg1) cil managed
{
    .maxstack 8
    L_0000: ldarg.0
    L_0001: ldc.i4.s 10
    L_0003: stfld int32 SampleClass::_value1
    L_0008: ldarg.0
    L_0009: ldc.i4.s 10
    L_000b: stfld int32 SampleClass::_value2
    L_0010: ldarg.0
    L_0011: ldc.i4.s 10
    L_0013: stfld int32 SampleClass::_value3
    L_0018: ldarg.0
    L_0019: ldc.i4.s 10
    L_001b: stfld int32 SampleClass::_value4
    L_0020: ldarg.0
    L_0021: call instance void [mscorlib]System.Object::.ctor()
    L_0026: nop
    L_0027: nop
    L_0028: nop
    L_0029: ret
}

Niestety, nie została dokonana żadna optymalizacja. Dla każdego konstruktora dodano inicjalizacje pól. Zamiast w jednym konstruktorze inicjalizować pola, a potem wywoływać go z pozostałych, kod duplikowany jest w każdym z nich. Przy wielu polach i konstruktorach nie jest to optymalne rozwiązanie. Każdy wygenerowany konstruktor składa się z trzech części: inicjalizacja pól, wywołanie konstruktora bazowego, a na końcu wykonanie ciała właściwego konstruktora.

Warto zatem zastanowić się nad następującym rozwiązaniem problemu:

public class SampleClass
{
    private int _value1;
    private int _value2;
    private int _value3;
    private int _value4;
    public SampleClass()
    {
        _value1 = 10;
        _value2 = 10;
        _value3 = 10;
        _value4 = 10;
    }
    public SampleClass(int arg1):this()
    {
    }
    public SampleClass(int arg1, int arg2):this()
    {
    }
    public SampleClass(int arg1, int arg2, int arg3):this()
    {
    }
}

Oczywiście, powyższe przykłady nie mają dominującego wpływu na wydajność aplikacji. Warto o tym jednak pamiętać, bo jak się okazuje, napisanie kodu, trochę wydajniejszego, nie wymaga żadnego nakładu pracy. Wystarczy po prostu wiedzieć, jaki kod IL jest wygenerowany w przypadku inicjalizacji inline.

Boxing, unboxing–test

Na szczęście pojęcia, takie jak boxing oraz unboxing są dobrze znane programistom. Warto jednak zastanowić się nad wydajnością i przeprowadzić krótki eksperyment, pokazujący wpływ powyższych rozwiązań na efektywność działania aplikacji. Na początek, porównanie boxing z unboxing:

private static void TestBoxingAndUnboxing()
{
   object boxedValue = null;
   // boxing
   Stopwatch stopwatch = Stopwatch.StartNew();
   for (int i = 0; i < Iterations; i++)
   {
       boxedValue = i;
   }
   stopwatch.Stop();
   Console.WriteLine("Boxing:{0}",stopwatch.ElapsedMilliseconds);
   // unboxing
   stopwatch = Stopwatch.StartNew();
   for (int i = 0; i < Iterations; i++)
   {
       int unboxed = (int) boxedValue;
   }
   stopwatch.Stop();
   Console.WriteLine("Unboxing:{0}", stopwatch.ElapsedMilliseconds);
}

Wyniki są następujące: boxing:1178, unboxing:128. Nie powinno to dziwić – boxing jest dużo wolniejszy. Ponadto, proszę zwrócić uwagę, że przy boxingu GC musi zwolnić stworzone obiekty, co jest dodatkową utratą wydajności.

Kolejny test to dodawanie elementów do listy. Pierwsza lista (slowList) wymaga boxingu przy dodawaniu elementu oraz unboxingu przy każdym czytaniu. Z kolei, druga kolekcja przyjmuje właściwy typ, a nie generyczny object:

private static void TestLists()
{
   List<int> fastList=new List<int>();
   List<object> slowList=new List<object>();

   // boxing
   Stopwatch stopwatch = Stopwatch.StartNew();
   for (int i = 0; i < Iterations; i++)
   {
       slowList.Add(i);
       int read = (int)slowList[i];
   }
   stopwatch.Stop();
   Console.WriteLine("Boxing:{0}",stopwatch.ElapsedMilliseconds);

   stopwatch = Stopwatch.StartNew();
   for (int i = 0; i < Iterations; i++)
   {
       fastList.Add(i);
       int read = fastList[i];
   }
   stopwatch.Stop();
   Console.WriteLine("Fast list:{0}", stopwatch.ElapsedMilliseconds);
}

Wyniki są następujące: fastList-235, slowList-2065. Jak pokazuje prosty eksperyment, różnica jest ogromna i unikanie boxingu, to nie tylko czytelniejszy kod, ale również dużo wydajniejszy.

Optymalizacja klas z wieloma zdarzeniami – EventHandlerList

Zdarzenia stanowią bardzo wygodny mechanizm monitorowania stanu obiektów. W .NET można spotkać je na każdym kroku. Kontrolki, zarówno w WinForms, jak i w WPF, posiadają wiele zdarzeń, często kilkadziesiąt. Niestety, każda deklaracja zdarzenia pochłania zasoby. Nie ma to większego znaczenia, gdy obiekt posiada tylko kilka zdarzeń, ale może to być zauważalne dla skomplikowanych klas, np. kontrolek w WinForms. Każda kontrolka eksponuje dziesiątki zdarzeń, a użytkownicy zwykłe korzystają wyłącznie z kilku – rzadko ma miejsce sytuacja, w której trzeba do wszystkich się podpiąć. Warto zobaczyć, co naprawdę generuje prosta deklaracja zdarzenia:

internal class Program
{
    public event EventHandler SampleEvent;
    private static void Main(string[] args)
    {
    }
}

Reflector:

.class private auto ansi beforefieldinit Program
   extends [mscorlib]System.Object
{
    .event [mscorlib]System.EventHandler SampleEvent
    {
        .addon instance void Program::add_SampleEvent(class [mscorlib]System.EventHandler)
        .removeon instance void Program::remove_SampleEvent(class [mscorlib]System.EventHandler)
    }
    .method public hidebysig specialname rtspecialname instance void .ctor() cil managed
    {
    }
    .method private hidebysig static void Main(string[] args) cil managed
    {
        .entrypoint
    }
    .field private class [mscorlib]System.EventHandler SampleEvent
}

Jak widać, została wygenerowana specjalna właściwość oraz delegat. Pytanie brzmi: po co generować tyle pól delegate, skoro tylko kilka zdarzeń jest zwykle wykorzystywanych?

Rozwiązaniem problemu jest EventHandlerList. Klasa EventHandlerList to po prostu kolekcja zdarzeń. Przykład:

class SampleClass
{
    protected EventHandlerList _events = new EventHandlerList();
    public event EventHandler SampleEvent1
    {
        add { _events.AddHandler("SampleEvent1", value); }
        remove { _events.RemoveHandler("SampleEvent1", value); }
    }
    public event EventHandler SampleEvent2
    {
        add { _events.AddHandler("SampleEvent2", value); }
        remove { _events.RemoveHandler("SampleEvent2", value); }
    }
    public void TestEvent1()
    {
        EventHandler eh = _events["SampleEvent1"] as EventHandler;
        if (eh != null)
        {
            eh(this, null);
        }
    }
}

EventHandlerList stanowi pojemnik na zdarzenia. Zamiast tworzyć za każdym razem „delegate”, w powyższym kodzie tworzone są one wyłącznie, gdy zajdzie taka potrzeba. Klasa, która posiada 50 zdarzeń, zużyje pamięć wyłącznie na EventHandlerList i potrzebne zdarzenia. Wywołanie takiego zdarzenia jest proste, ponieważ do EventHandlera można dostać się jak do słownika – poprzez przekazanie klucza. Częstym wzorcem jest użycie klucza, jako statycznego pola read-only:

internal class SampleClass
{
    protected EventHandlerList _events = new EventHandlerList();
    private static readonly object SampleEventKey = new object();
    public event EventHandler SampleEvent1
    {
        add { _events.AddHandler(SampleEventKey, value); }
        remove { _events.RemoveHandler(SampleEventKey, value); }
    }
    public void TestEvent1()
    {
        EventHandler eh = _events[SampleEventKey] as EventHandler;
        if (eh != null)
        {
            eh(this, null);
        }
    }
}

EventHandlerList jest powszechnie wykorzystywany w bibliotekach Microsoftu. Na przykład, każda kontrolka w WinForms posiada właściwość Events, typu EventHandlerList. Z tego względu poniższy kod można uznać za anty-wzorzec:

public class CustomButton : Button
{
   public event EventHandler SampleEvent;
}

Dużo lepiej jest napisać:

public class CustomButton : Button
{
    private static readonly object SampleEventKey = new object();
    public event EventHandler SampleEvent1
    {
        add { Events.AddHandler(SampleEventKey, value); }
        remove { Events.RemoveHandler(SampleEventKey, value); }
    }
    public void TestEvent1()
    {
        EventHandler eh = Events[SampleEventKey] as EventHandler;
        if (eh != null)
        {
            eh(this, null);
        }
    }
}

Powyższe rozwiązania są przykładem optymalizacji pamięciowej. Jeśli chodzi o prędkość wykonania kodu, będzie ona taka sama albo ewentualnie trochę wolniejsza (nie ma to jednak znaczenia, bo korzyści są dużo większe). Warto jednak podkreślić, że w optymalizacji często trzeba wybierać pomiędzy wydajnością, rozumianą jako szybkość wykonania kodu, a zużyciem pamięci. Na przykład w aplikacjach webowych, można przyśpieszyć przetwarzanie zadania za pomocą bufora (cache), co jednak skutkuje oczywiście  większym zużyciem zasobów pamięciowych.

Zakończenie

Reflector okazał się doskonałym narzędziem na sprawdzenie, co się tak naprawdę kryje pod instrukcjami C#. Bardzo dobrze, że C# upraszcza wiele zadań i warto znać sytuacje, kiedy utrata wydajności może mieć miejsce. Oczywiście inne wymagania są stawiane systemom czasu rzeczywistego, a inne prostszym aplikacjom, gdzie np. przejrzysta składnia jest dużo ważniejsza niż bardzo szybki kod.

 


 


          

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.