Wydajność metod wirtualnych i niewirtualnych

Kiedyś pisałem o modyfikatorze sealed i dlaczego warto go używać jeśli chodzi o kwestie dobrych praktyk. Podobne mam zdanie co do modyfikatora virtual – używam wyłącznie jak mam takie wymagania. Zawsze zaczynam od najbardziej restrykcyjnych modyfikatorów. Klasy deklaruję jako sealed internal, a metody jako private. Nie zaznaczam metod jako virtual “na zapas”, ponieważ skoro nie są one zaprojektowane pod tym kątem to może przynieść to więcej kłopotów niż korzyści. Analogiczne zasady stosuje się np. w bezpieczeństwie – użytkownikowi zawsze nadaje się minimalny zestaw niezbędnych pozwoleń.

Dzisiaj jednak nie o dobrych praktykach a o wydajności. Bardzo możliwe, że niektóre metody zostaną skonwertowane do inline. Co to oznacza? Zamiast wywoływać daną metodę, po prostu jej ciało zostanie skopiowanie. Rozważmy następujący przykład:

private int Sum(int a,int b)
{
    return a+b;
}


private void Main()
{
    // jakas logika
    int c= Sum(a,b);
    // dalszy ciag logiki.
}

Skonwertowanie Sum do inline będzie wyglądało następująco:

private void Main()
{
    // jakas logika
    int c = a+b;
    // dalszy ciag logiki
}

Bardzo możliwe, że nawet a i b zostaną zastąpione konkretnymi wartościami jeśli to tylko możliwe. Bez metody inline, system operacyjny musi skoczyć do innej metody, wykonać ją a potem z powrotem powrócić w dane miejsce. Ze strony wydajnościowej jest to wiele wolniejsze (pamiętajmy o stosie, przekazywaniu parametrów) oraz może mieć konsekwencje jeśli chodzi o caching i wielowątkowość.

Metody wirtualne nigdy oczywiście nie mogą być inline co jest oczywiście bardzo naturalne. Napiszmy zatem program, który porówna taką samą metodę oznaczoną virtual i bez virtual:

class Sample
{
   public virtual int VirtualMethod(int a,int b)
   {
       return a + b;
   }

   public int NonVirtualMethod(int a, int b)
   {
       return a + b;
   }
}
internal class Test
{
   private const int Tests = 10;
   private const int n = 1000000000;

   private static void Main(string[] args)
   {
       TestVirtual();
       TestNonVirtual();
   }

   private static void TestVirtual()
   {
       Sample sample = new Sample();

       for (int i = 0; i < Tests; i++)
       {
           Stopwatch stopwatch = Stopwatch.StartNew();

           for (int j = 0; j < n; j++)
           {
               int c = sample.VirtualMethod(5, 10);
           }

           Console.WriteLine("Virtual: {0}", stopwatch.ElapsedMilliseconds);
       } 
   }
   private static void TestNonVirtual()
   {
       Sample sample = new Sample();

       for (int i = 0; i < Tests; i++)
       {
           Stopwatch stopwatch = Stopwatch.StartNew();

           for (int j = 0; j < n; j++)
           {
               int c = sample.NonVirtualMethod(5, 10);                    
           }
           Console.WriteLine("NonVirtual: {0}", stopwatch.ElapsedMilliseconds);
       }           
   }
}

W trybie DEBUG wyniki są następujące:

image

Nie powinno dziwić, że wyniki są bardzo podobne, ponieważ w DEBUG nie powinny być dokonywane optymalizacje.

Spróbujmy zatem przełączyć się do Release i wykonać analogiczny eksperyment:

image

Tutaj wyniki są już znaczące ponieważ niewirtualna wersja jest wywoływana w sposób inline.

Ostatnia rzecz, to porównanie metody niewirtualnej, która nie może zostać wywołana inline, z wirtualną. Wystarczy użyć atrybutu [MethodImpl(MethodImplOptions.NoInlining)], który zabrania wywołania danej metody w sposób inline:

class Sample
{
   public virtual int VirtualMethod(int a,int b)
   {
       return a + b;
   }
   [MethodImpl(MethodImplOptions.NoInlining)]
   public int NonVirtualMethod(int a, int b)
   {
       return a + b;
   }
}

Rezultat:

image

Różnica bardzo mało znacząca ponieważ zarówno wirtualne, jak i niewirtualne metody w .NET są wywoływane za pomocą instrukcji callvirt. Zdecydowano się na taki krok, ponieważ callvirt sprawdza, czy wskaźnik nie jest NULL. Z tego względu, wywołanie metody na wskaźniku, który wskazuje NULL spowoduje wyjątek NullReferenceException. Metody statyczne z kolei korzystają z innej instrukcji(call), która nie sprawdza wskaźnika NULL. Z tego względu, wywołanie metody rozszerzającej, która jest zawsze statyczna, na obiekcie NULL nie spowoduje żadnego wyjątku.

Leave a Reply

Your email address will not be published.