Strona używa cookies (ciasteczek). Dowiedz się więcej o celu ich używania i zmianach ustawień. Korzystając ze strony wyrażasz zgodę na używanie cookies, zgodnie z aktualnymi ustawieniami przeglądarki.    X

O wydajności w aplikacjach .NET cz. 2

Na wydajność nowotworzonego oprogramowania ma wpływ wiele czynników. Nie będę tu podejmował kwestii stosowanych algorytmów, ponieważ temat ten jest już dogłębnie opisany w wielu pozycjach książkowych. Poza tym każdy problem wymaga indywidualnego podejścia. W czym zatem, jak nie w algorytmice, szukać upragnionej wydajności? W rozwiązaniach uwzględniających specyficzną konstrukcję platformy .NET i jej wiodącego języka (C#).

Dziś, w ramach kontynuacji rozważań będących przedmiotem poprzedniego wpisu Wydajność w aplikacjach .NET, zajmiemy się tematem Opakowywania i Rozpakowywania (ang. boxing/unboxing) i związanymi z nim następstwami.

Nie każdy świeżo upieczony programista .NET zdaje sobie sprawę z tego jak zgubny wpływ na wydajność projektowanego oprogramowania może mieć nadmierne wykorzystanie przekształceń pewnych typów na inne, tym bardziej jeżeli całość odbywa się często w sposób niejawny ("po cichu"). Zagadnienie Opakowywania i Rozpakowywania nie jest jakoś nad wyraz skomplikowane i w zasadzie bardzo pokrótce opisane przez producenta. Z małym zastrzeżeniem:

"In relation to simple assignments, boxing and unboxing are computationally expensive processes"

W C# możemy wyróżnić trzy główne typy danych:

  • typy bezpośrednie (ang. value types), choć niektórzy korzystają z nazewnictwa "typ wartościowy"
  • typy referencyjne (ang. reference types)
  • typy wskaźnikowe (ang. pointer types)

Zmienne typów bezpośrednich przechowują wartości danego typu (np. int, float, double, bool). Zmienne typów referencyjnych przechowują odniesienia do obiektów, które to dopiero zawierają właściwe dane. Zmienna typu bezpośredniego tworzona jest i przechowuje dane na stosie (ang. stack), w odróżnieniu od zmiennej typu referencyjnego, która, choć alokowana jest na stosie, to odnosi się do wartości umieszczonej na stercie (ang. heap).

Mechanizm opakowywania i rozpakowywania w .NET umożliwia traktowanie typów bezpośrednich jak obiektów (tj. mogą być one przekształcane w typ i z typu Object). Problem w tym, że przekształcenie typu bezpośredniego w typ referencyjny przy użyciu mechanizmu opakowywania wiąże się z koniecznością utworzenia nowego obiektu na stercie i skopiowaniu wartości przechowywanej przez zmienną typu bezpośredniego - a to jest kosztowne przedsięwzięcie.

Pojedyncza operacja tego rodzaju nie wpływa negatywnie na wydajność oprogramowania, gdyż mowa jest o nanosekundach. Wykonajmy więc tę operację wielokrotnie. Powszechnym błędem jest stosowanie instancji klasy ArrayList, której elementami są obiekty, do przechowywania wartości zmiennych typu bezpośredniego. Niech w naszym przykładzie rolę typu bezpośredniego pełni struktura hmm... SpaceTime, opisująca współrzędne oraz czas.

using System; using System.Text; using System.ComponentModel; using System.Security; using System.Runtime.InteropServices; namespace TestPerformance2 { class Program { struct SpaceTime { public float x, y, z; public uint t; public SpaceTime(float x, float y, float z, uint t) { this.x = x; this.y = y; this.z = z; this.t = t; } } struct SpaceTime2 { public float x, y, z; public uint t; public SpaceTime2(float x, float y, float z, uint t) { this.x = x; this.y = y; this.z = z; this.t = t; } public override bool Equals(object ob) { if (ob is SpaceTime2) return Equals((SpaceTime2)ob); else return false; } private bool Equals(SpaceTime2 st) { return this.x == st.x && this.y == st.y && this.z == st.z && this.t == st.t; } } static void Main(string[] args) { PerformanceCounter counter = new PerformanceCounter(); int iterations = 2000000; System.Collections.ArrayList arrayList = new System.Collections.ArrayList(iterations); System.Collections.Generic.List<SpaceTime> list = new System.Collections.Generic.List<SpaceTime>(iterations); System.Collections.Generic.List<SpaceTime2> list2 = new System.Collections.Generic.List<SpaceTime2>(iterations); SpaceTime value; SpaceTime2 value2; counter.Start(); for (uint i = 0; i < iterations; i++) { SpaceTime point = new SpaceTime(1.0f, 2.0f, 3.0f, i); arrayList.Add((object)point); // Jawna konwersja do typu object value = (SpaceTime)arrayList[(int)i]; } counter.Stop(); Console.WriteLine("Box/Unbox, wersja z ArrayList:"); Console.WriteLine("{0:F3} ns - pojedyncza iteracja", counter.ElapsedNanoseconds / iterations); Console.WriteLine("{0:F3} ms - całość", counter.ElapsedMiliseconds); counter.Start(); for (uint i = 0; i < iterations; i++) { SpaceTime point = new SpaceTime(1.0f, 2.0f, 3.0f, i); list.Add(point); // Brak konwersji value = list[(int)i]; } counter.Stop(); Console.WriteLine(); Console.WriteLine("wersja z List<SpaceTime>:"); Console.WriteLine("{0:F3} ns - pojedyncza iteracja", counter.ElapsedNanoseconds / iterations); Console.WriteLine("{0:F3} ms - całość", counter.ElapsedMiliseconds); arrayList.Clear(); list.Clear(); counter.Start(); for (uint i = 0; i < iterations; i++) { SpaceTime point = new SpaceTime(1.0f, 2.0f, 3.0f, i); arrayList.Add((object)point); // Jawna konwersja do typu object value = (SpaceTime)arrayList[(int)i]; value.Equals(value); } counter.Stop(); Console.WriteLine(); Console.WriteLine("Box/Unbox + domyślna Equals, wersja z ArrayList:"); Console.WriteLine("{0:F3} ns - pojedyncza iteracja", counter.ElapsedNanoseconds / iterations); Console.WriteLine("{0:F3} ms - całość", counter.ElapsedMiliseconds); counter.Start(); for (uint i = 0; i < iterations; i++) { SpaceTime point = new SpaceTime(1.0f, 2.0f, 3.0f, i); list.Add(point); // Brak konwersji value = list[(int)i]; value.Equals(value); } counter.Stop(); Console.WriteLine(); Console.WriteLine("domyślna Equals, wersja z List<SpaceTime>:"); Console.WriteLine("{0:F3} ns - pojedyncza iteracja", counter.ElapsedNanoseconds / iterations); Console.WriteLine("{0:F3} ms - całość", counter.ElapsedMiliseconds); counter.Start(); for (uint i = 0; i < iterations; i++) { SpaceTime2 point = new SpaceTime2(1.0f, 2.0f, 3.0f, i); list2.Add(point); // Brak konwersji value2 = list2[(int)i]; value2.Equals(value2); } counter.Stop(); Console.WriteLine(); Console.WriteLine("własna metoda Equals, wersja z List<SpaceTime2>:"); Console.WriteLine("{0:F3} ns - pojedyncza iteracja", counter.ElapsedNanoseconds / iterations); Console.WriteLine("{0:F3} ms - całość", counter.ElapsedMiliseconds); Console.ReadKey(); } } class PerformanceCounter { [DllImport("kernel32.dll"), SuppressUnmanagedCodeSecurity] private static extern bool QueryPerformanceCounter(out long lpPerformanceCount); [DllImport("kernel32.dll")] private static extern bool QueryPerformanceFrequency(out long lpFrequency); private long start; private long stop; private long frequency; Decimal nanoMultiplier = new Decimal(1.0e9); Decimal miliMultiplier = new Decimal(1.0e3); public PerformanceCounter() { // Więcej informacji na: // http://msdn.microsoft.com/en-us/library/windows/desktop/ms644905(v=vs.85).aspx if (QueryPerformanceFrequency(out frequency) == false) throw new Win32Exception(); } public void Start() { QueryPerformanceCounter(out start); } public void Stop() { QueryPerformanceCounter(out stop); } public double ElapsedNanoseconds { get { return (((double)(stop - start) * (double)nanoMultiplier) / (double)frequency); } } public double ElapsedMiliseconds { get { return (((double)(stop - start) * (double)miliMultiplier) / (double)frequency); } } } }

Po skompilowaniu programu możemy sprawdzić poprawność naszych przemyśleń korzystając z deasemblera języka pośredniego (ang. Common Intermediate Language) Ildasm i wyszukać wszystkie miejsca w programie, w którym następuje proces opakowywania:

ildasm TestPerformance2.exe /text | findstr box

W uzasadnionych przypadkach wykorzystanie struktur zamiast klas niesie ze sobą korzyści w postaci mniejszego zużycia pamięci oraz zauważalnej różnicy w szybkości wykonywania kodu. Warto uruchomić testową aplikację na komputerze starej daty bądź na netbooku, różnica będzie diametralna. Znajomość mechanizmu opakowywania i rozpakowywania typów bezpośrednich pełni więc kluczową rolę na drodze do optymalizacji. Warto pamiętać, że wraz ze wzrostem złożoności oprogramowania mnożą się także problemy natury projektowej. Coraz częściej sięgamy po rozwiązania "jak najbardziej ogólne" nie zdając sobie sprawy z konsekwencji podjętych działań a twórcy platformy .NET coraz częściej nam na to pozwalają.

Kiedy zachwycony rezultatami skończysz już przeglądać gamę swoich projektów w poszukiwaniu niezamierzonych konwersji typów bezpośrednich na typy referencyjne przyjmij ostatnie słowa porady w tej kwestii.

Equals()

Typy bezpośrednie niejawnie dziedziczą po klasie System.ValueType, dlatego też nawet na typie prostym (np. int) możesz wywołać metodę ToString bądź Equals.

Odziedziczona, domyślna metoda Equals wywołana na instancji struktury wykorzystuje zarówno mechanizm opakowywania jak i mechanizm refleksji w celu porównania dwóch obiektów, pole po polu. Oba te mechanizmy są kosztowne i niejednokrotnie zdarza się, że wydajniejsza będzie własna implementacja metody Equals dla tworzonej struktury (w naszym przypadku jest to SpaceTime2). Niedowiarkom pozostawiam analizę domyślnej metody Equals i ocenę powyższego testu.

Domyślna implementacja Equals (źródło: dekompilator)

using System; using System.Reflection; public override bool Equals(object obj) // System.ValueType { if (obj == null) { return false; } RuntimeType runtimeType = (RuntimeType)base.GetType(); RuntimeType left = (RuntimeType)obj.GetType(); if (left != runtimeType) { return false; } if (ValueType.CanCompareBits(this)) { return ValueType.FastEqualsCheck(this, obj); } FieldInfo[] fields = runtimeType.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); for (int i = 0; i < fields.Length; i++) { object obj2 = ((RtFieldInfo)fields[ i ]).InternalGetValue(this, false); object obj3 = ((RtFieldInfo)fields[ i ]).InternalGetValue(obj, false); if (obj2 == null) { if (obj3 != null) { return false; } } else { if (!obj2.Equals(obj3)) { return false; } } } return true; }  

windows porady programowanie

Komentarze

0 nowych
anakkin   6 #1 21.02.2012 18:09

Po przeniesieniu kodu na Java'e (konkretnie to Java 7) odpaliłem oba programy (.Net i Java).

Zmiany względem kodu C#:
1. Do mierzenia czasu w Java'ie użyłem System.nanoTime()
2. List w Java'ie jest interfejsem, więc korzystam z ArrayList jako implementacji ( List list = new ArrayList(iterations); )
3. W Java'ie nie ma czegoś takiego jak unsigned int. W związku z tym w użyłem klasycznego inta wszędzie, gdzie występował uint.

Oba programy odpalałem z konsoli (są różnice w czasach między programem odpalanym z konsoli, a programem odpalanym z IDE).
Oto moje wyniki:


.Net

Box/Unbox, wersja z ArrayList:
91,047 ns - pojedyncza iteracja
182,095 ms - cało˜ść

wersja z List:
42,435 ns - pojedyncza iteracja
84,870 ms - całość

Box/Unbox + domy˜lna Equals, wersja z ArrayList:
353,342 ns - pojedyncza iteracja
706,684 ms - całość

domy˜lna Equals, wersja z List:
127,530 ns - pojedyncza iteracja
255,059 ms - całość

wˆasna metoda Equals, wersja z List:
49,486 ns - pojedyncza iteracja
98,971 ms - całość


Java

Box/Unbox, wersja z ArrayList:
182,058515 ns - pojedyncza iteracja
364,117030 ms - całość

wersja z List:
178,715562 ns - pojedyncza iteracja
357,431123 ms - całość

Box/Unbox + domyślna Equals, wersja z ArrayList:
57,982248 ns - pojedyncza iteracja
115,964495 ms - całość

domyślna Equals, wersja z List:
222,037747 ns - pojedyncza iteracja
444,075493 ms - całość

własna metoda Equals, wersja z List:
16,688647 ns - pojedyncza iteracja
33,377294 ms - całość

alucosoftware   7 #2 21.02.2012 20:10

@anakkin
Brawo, obawiałem się czy ktokolwiek podejmie "wyzwanie" i skompiluje powyższy kod.

Mała przestroga: w .NET'ie podczas budowania projektu ustaw konfigurację na Release zamiast domyślnej Debug. Używając konfiguracji Release masz pewność, że kompilator dokona wszelkiej możliwej optymalizacji, a co za tym idzie szybkość wykonywania kodu wzrośnie. Uruchamianie aplikacji z poziomu Visual Studio w trybie debugowania nie będzie dobrym pomysłem, ponieważ wszelkie optymalizacje JIT będą wyłączone.

O ile Twoje wyniki .NET'owe nie są dla mnie jakimś zaskoczeniem, niepokoją mnie wyniki Javy.
Wykorzystaj typy ogólne (ech... generyczne) np. List lista = new ArrayList(iteracje). W przypadku List lista = new ArrayList() masz "boxing". Jak wytłumaczysz Javę "wersję z List" oraz "własna metoda Equals, wersja z List"? Zmień ilość iteracji na 1 oraz 10000... Wygląda na to jakby zabrakło zasobów i GC urządził małe odśmiecanie...

alucosoftware   7 #3 21.02.2012 20:14

Hmm... zjada nawiasy... Zamiast znaku mniejsze/większe użyję nawiasów klamrowych:
Lista{Typ} lista = new ArrayList{Typ}(iteracje)

revcorey   7 #4 21.02.2012 21:16

@anakkin
wrzuć kod javy na
http://paste.org/porg/home
i jak byś mógł też skompilowany kod w c# , nie chce mi się vs studio ściągać itd. Sobie porównam u siebie, rzeczywiście wynik javy dziwnie niepokojący.

matzu   5 #5 21.02.2012 22:20

We wrzuconym przez Ciebie kodzie, aż prosi się o oznaczenie przeciążonej wersji Equals jako public, żeby uniknąć niepotrzebnego rzutowania (public bool Equals(SpaceTime2 st)). Zakładam, że to drobne niedopatrzenie.

Mam pytanie ... widzę, że nie przeciążyłeś GetHashCode(). Rozumiem, że dlatego, że ta metoda nie jest potrzebna w tym przykładzie, czy też może z innego powodu?

djfoxer   18 #6 21.02.2012 22:40

Naprawdę ciekawe i przydatne, szkoda, że obecnie często nie zwraca się uwagi na optymalizację, przyjmując, że i tak sprzęt to uciągnie. A później na danych produkcyjnych, prawie zawsze większych od danych testowych, wychodzą problemy.

O ile można było się spodziewać różnicy pomiędzy ArrayList, a List, nie byłem jednak świadom, że jest całkiem znacząca.

@matzu
GetHashCode nigdzie nie jest wykorzystywane, więc po co.

@anakkin
Gratki za statystyki :)

matzu   5 #7 21.02.2012 22:57

@djfoxer
Racja, choć szkoda, że alucosoftware nie wrzucił swojej implementacji. IMO przeciążanie GetHashCode() to ciekawe zagadnienie (no chyba, że się pójdzie na łatwiznę i użyje XOR-a :P ).

alucosoftware   7 #8 21.02.2012 23:20

@matzu
Tak, zaleca się przeciążanie metody GetHashCode(), ale w naszym przykładzie nie jest to konieczne, nie dla takiego typu.

Rekomenduje się także, aby poza przeciążaniem domyślnej, odziedziczonej metody Equals(object) utworzyć metodę Equals (jak sugerujesz) dla własnego typu danych, zawsze to parę nanosekund dla nas. Poza tym drobnym niuansem powyższa metoda spełnia zalecenia Microsoftu dotyczące przeciążania metody Equals.

alucosoftware   7 #9 21.02.2012 23:42

@djfoxer
Zgadza się. Panuje powszechny pogląd, że sprzęt i tak to uciągnie.

Optymalnym rozwiązaniem byłoby wykorzystanie tablicy, ale chodziło przecież o bardziej życiowe zaprezentowanie mechanizmu opakowywania.

alucosoftware   7 #10 22.02.2012 00:50

@RaveStar
Nie wiem, czy Twoja wypowiedź była adresowana do mnie czy do kolegi prezentującego wyniki na własnej maszynie.

Tym niemniej wpis ten nie ma na celu porównywania platform, bo to byłoby pozbawione sensu. Cel jest jasno określony w pierwszym akapicie.

anakkin   6 #11 22.02.2012 09:43

Dodałem komentarz z odpowiednimi linkami, jednak nie pojawił się on wśród pozostałych komentarzy. Przypuszczam, że trafił do moderacji, ale pewności nie mam :/

anakkin   6 #12 22.02.2012 12:08

Tutaj jest:
1. skompilowany kod C# (release, x64)
2. runnable jar z klasami i źródłami
http://chomikuj.pl/anakkin/O+wydajnosci+w+aplikacjach+.net+i+java

alucosoftware   7 #13 22.02.2012 12:46

@anakkin
Wszystko dobrze, ale czy konstrukcja (zamiast mniejsze/większe użyję nawiasów klamrowych { }) List{SpaceTime} list = new ArrayList{}(iterations) jest poprawna?

W kodzie powyżej dodałem także arrayList.Clear() oraz list.Clear() - nikt nie zwrócił mi uwagi na ilość elementów w obu listach (a co za tym idzie i w pamięci) w kolejnym kroku.

anakkin   6 #14 22.02.2012 13:13

@alucosoftware

Kod przepisywałem jak jeszcze nie było .clear(), zaraz dodam.

Co do "List{SpaceTime} list = new ArrayList{}(iterations)":
W ten sposób otrzymujemy IoC. Wystarczy, że zmienimy ArrayList na inną klasę implementującą interfejs List, czyli np. LinkedList i już mamy pewność, że nie wystąpią nigdzie błędy kompilacji, a obiekt 'list' będzie już obiektem innej klasy.
Taka metoda nie sprawdzi się jednak, jeśli używamy metod nie istniejących w interfejsie (bo klasa X implements Y ma dodatkowo metodę doSth(), która nie jest zadeklarowana w Y)

alucosoftware   7 #15 22.02.2012 13:43

@anakkin
Chodziło mi o brak zadeklarowanego typu w ArrayList, pomimo zadeklarowania go w List. Może źle zadałem pytanie.

anakkin   6 #16 22.02.2012 14:48

To jest z kolei ficzur w Java 7 - operator 'diamond'. Bo po co pisać coś (w tym przypadku SpaceTime) 2 razy.

  #17 22.02.2012 17:47

świetny wpis, oby takich więcej!

_qaz7   6 #18 22.02.2012 18:23

trzeba zwrócić uwagę, że użycie struktur zamiast klas w C# sprawia, że w C# i w Javie te programy wykonują pod maską zupełnie coś innego.

alucosoftware   7 #19 22.02.2012 18:47

@_qaz7
Zgadza się, w Javie nie ma odpowiednika struct. Zaimplementowana klasa od razu jest typu referencyjnego, więc zachodzi tylko rzutowanie. Bardzo się cieszę, że podniosłeś ten wątek. Teraz warto by specjaliści od Javy oszacowali koszt rzutowania w dół i w górę.

_qaz7   6 #20 22.02.2012 18:57

@alucosoftware - to taka ogólna uwaga dla tych, którzy próbują porównać wydajność C# z Javą na podstawie Twojego kodu. Czekam na c.d.n.
pzdr.

alucosoftware   7 #21 22.02.2012 19:50

@_qaz7
Dlatego zawsze podkreślam, że porównania nie mają sensu. Trzeba się skupić na jak najlepszym wykorzystaniu ulubionych narzędzi programistycznych. Pozdrawiam. Udzielaj się dalej! :)

  #22 22.02.2012 20:03

KOd please nie kompilacja.... Nie bede tego dekompilowac.///

alucosoftware   7 #23 22.02.2012 21:30

@Anonim
anakkin dostarczył kod Javy w formacie .jar, ale przecież to jest tylko kontener, więc możesz go otworzyć np. archiwizatorem plików 7zip.

  #24 23.02.2012 10:49

fajnie piszesz. wpis super przydatny. szkoda, że na mojej uczelni taki temat nie był omawiany

alucosoftware   7 #25 25.02.2012 12:31

To są naprawdę podstawowe rzeczy. Zrozumienie podstaw pozwala nam unikać błędów w przyszłości, szczególnie tych, które dotyczą zużycia pamięci i szybkości działania aplikacji. Czasem oczom nie wierzę, gdy włączam Menedżer zadań i obserwuję marnotrawstwo zasobów jakiejś dotnetowej aplikacji... Dlatego uczmy się na własnych błędach.

anakkin   6 #26 26.02.2012 22:38

Dodałem wywołania clear()/Clear(). Najnowsze pliki są tutaj:
http://chomikuj.pl/anakkin/O+wydajnosci+w+aplikacjach+.net+i+java