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; }