O korzyściach z modyfikatora sealed, od strony projektowej pisałem już tutaj. Dzisiaj postanowiłem jednak napisać prosty program, który pokaże nam czy faktycznie są jakieś różnice wydajnościowe. Oczywiście jest to raczej ciekawostka dla ludzi zajmujących się c# internals. Jeśli zależy nam na optymalizacji, zawsze zaczynajmy od ulepszenia samego algorytmu (zmniejszenia jego złożoności), a w ostateczności sięgajmy po mikro-optymalizacje. Warto stosować wspominane wskazówki, ale ze względu na dobre praktyki, a nie realny czas, który algorytm zyska dzięki nim.
Zacznijmy od sprawdzenia, czy słowo sealed jest rozpoznawane tylko na poziomie kompilatora, czy również IL:
class BaseClass { } sealed class ChildClass : BaseClass { }
IL:
.class private auto ansi sealed beforefieldinit ConsoleApplication3.ChildClass extends ConsoleApplication3.BaseClass { } // end of class ConsoleApplication3.ChildClass
Widzimy, że faktycznie sealed występuje w IL, więc jest szansa, że w jakiś sposób możemy zyskać na wydajności.
W swoim benchmark’u zadeklarowałem następujące metody:
class BaseClass { public virtual void VirtualMethod() { } public void BaseNonVirtualMethod() { } } sealed class ChildClass : BaseClass { public override void VirtualMethod() { base.VirtualMethod(); } public void ChildNotVirtualMethod() { } }
Innymi słowy, mamy wirtualną metodę, która potem jest rozszerzona w klasie potomnej, niewirtualną bazową metodę oraz niewirtualną metodę zadeklarowaną w klasie potomnej. Wiemy (ze wspomnianego wpisu), że jedynie kiedy możemy zyskać wydajność jest to wywołanie wirtualnej metody. Wywołując VirtualMethod na klasie “zapieczętowanej”, kompilator mógłby pominąć sprawdzanie wirtualnej tabeli. Szkielet testu wygląda następująco:
const int n = 1000000; TestBaseNonVirtualMethod(n); TestChildNotVirtualMethod(n); TestVirtualMethod(n);
Następnie stworzyłem metodę, która zwróci średni czas wykonania danej funkcji:
private static double GetAverageTime(Action action,int n) { const int samplingCount = 100; double sum = 0; for (int i = 0; i < samplingCount; i++) { Stopwatch stopwatch = Stopwatch.StartNew(); for (int j = 0; j < n; j++) { action(); } sum += stopwatch.ElapsedTicks; } return sum / samplingCount; }
Na końcu metody, które wywołują zaimplementowane funkcje:
private static void TestVirtualMethod(int n) { ChildClass childClass = new ChildClass(); Console.WriteLine("TestVirtualMethod: {0}", GetAverageTime(() => childClass.VirtualMethod(), n)); } static void TestBaseNonVirtualMethod(int n) { BaseClass childClass = new ChildClass(); Console.WriteLine("TestVirtualMethod: {0}", GetAverageTime(() => childClass.BaseNonVirtualMethod(), n)); } static void TestChildNotVirtualMethod(int n) { ChildClass childClass = new ChildClass(); Console.WriteLine("TestChildNotVirtualMethod: {0}", GetAverageTime(() => childClass.ChildNotVirtualMethod(), n)); }
Proszę zwrócić uwagę na TestVirtualMethod. Korzystamy tam z instancji ChildClass, a nie BaseClass. W przypadku BaseClass wirtualna tabela musiałaby być wykorzystywana, nawet w przypadku “zapieczętowanej” klasy. Teoretycznie teraz, kompilator ma wystarczającą wiedzę, aby wyemitować call zamiast callvirt.
Wyniki prezentującą się następująco. Klasa sealed:
Zwykła (unsealed):
Przedstawione wyniki kompletnie nic nie mówią. Zaglądając do IL, tylko dowiemy się to samo, co w poprzednim wpisie czyli, że zawsze callvirt jest emitowany:
IL_0001: ldfld class ConsoleApplication3.ChildClass ConsoleApplication3.Program/'<>c__DisplayClass1'::childClass IL_0006: callvirt instance void ConsoleApplication3.BaseClass::VirtualMethod() IL_000b: nop IL_000c: ret
Wiemy, że zawsze dla instance-methods emitowany jest callvirt. Przypuszczenie było, że callvirt dla klas zapieczętowanych może ominąć etap sprawdzania vtable. Powyższy test nie potwierdza tego, ale może być to spowodowane wersją .NET\C#, dużo bardziej skomplikowanymi regułami, które nie są udokumentowane, implementacją specyficznego języka (C#, VB,CPP) itp. Kiedyś pisałem o wydajności metod statycznych i metod wirtualnych. Bardzo szybko udało się uzyskać znaczące różnice w wydajności. O ile w przypadku metod wirtualnych, różnica między wywołaniem metody inline a zwykłą, mogła mieć realny wpływ na wydajność, to w przypadku modyfikatora sealed, możemy to bez problemu zignorować.
Jednym z ważnych powodów, dlaczego c# używa callvirt nawet dla niewirtualnych metod, jest sprawdzenie czy instancja nie jest NULL’em. Na przykład, poniższy kod nie wyrzuciłby wyjątku, gdyby byłby wywołany za pomocą instrukcji call, a nie callvirt:
AnyClass instance = null; instance.AnyMethod(); //OK jeśli AnyMethod nie korzysta z this i jest wywolana za pomoca call.
Tutaj kolejna ciekawostka odnośnie sealed. Załóżmy, że mamy:
new ChildClass().VirtualMethod();
W takim przypadku, mamy pewność, że nigdy nie będziemy mieli wartości NULL. Dla wirtualnej metody, wciąż niestety zostanie wyemitowany callvirt:
IL_0000: newobj instance void ConsoleApplication3.ChildClass::.ctor() IL_0005: callvirt instance void ConsoleApplication3.BaseClass::VirtualMethod()
Zmieńmy naszą deklaracje na method hiding:
class BaseClass { public virtual void VirtualMethod() { } } sealed class ChildClass : BaseClass { public new void Method() { } }
Wtedy ten sam kod, tzn:
new ChildClass().Method();
Wyemituje call, zamiast callvirt!:
IL_0000: newobj instance void ConsoleApplication3.ChildClass::.ctor() IL_0005: call instance void ConsoleApplication3.ChildClass::Method()
Jeśli tylko rozdzielimy inicjalizacje z wywołaniem:
ChildClass childClass=new ChildClass(); childClass.Method();
Kompilator wyemituje znów callvirt, ponieważ nie ma pewności czy instancja nie jest NULL:
IL_0000: newobj instance void ConsoleApplication3.ChildClass::.ctor() IL_0005: stloc.0 IL_0006: ldloc.0 IL_0007: callvirt instance void ConsoleApplication3.ChildClass::Method() IL_000c: ret
Spróbujmy jeszcze sprawdzić, co się stanie, gdy mamy instrukcję warunkową, teoretycznie gwarantującą, że wartość nigdy nie będzie NULL:
ChildClass childClass=new ChildClass(); if(childClass!=null) childClass.Method();
Kompilator tego nie rozpozna i wciąż będziemy mieli callvirt:
IL_0000: newobj instance void ConsoleApplication3.ChildClass::.ctor() IL_0005: stloc.0 IL_0006: ldloc.0 IL_0007: brfalse.s IL_000f IL_0009: ldloc.0 IL_000a: callvirt instance void ConsoleApplication3.ChildClass::Method()
Nic dziwnego, byłoby to zbyt ryzykowne przypuszczenie, biorąc pod uwagę np. wielowątkowość.
Takich przykładów można mnożyć, ale nie ma to według mnie sensu. Reguły dobierania callvirt, a call dla zapieczętowanych klas są skomplikowane i mogą w każdej chwili ulec zmianie – są to oczywiście sprawy czysto implementacje i w każdej aktualizacji framework’u, może to zmienić się. Wniosek taki, że aspekt wydajności kompletnie pomijamy i wiemy, że generalnie kompilator preferuje callvirt dla instance-method, call jest wykorzystywany wyłącznie w metodach statycznych oraz bardzo rzadko (wyłącznie w bardzo specyficznych sytuacjach), gdy korzystamy z sealed.