2011-06-18 11 views
15

Znalazłem to dziwne zachowanie w .NET i nawet po zaglądaniu w CLR via C# znowu jestem wciąż zdezorientowany. Załóżmy, że mamy interfejs z jednej metody i klasy, która imlements go:C#: Dlaczego wywoływanie zaimplementowanej metody interfejsu jest szybsze dla zmiennej klasy niż dla zmiennej interfejsu?

interface IFoo 
{ 
    void Do(); 
} 

class TheFoo : IFoo 
{ 
    public void Do() 
    { 
     //do nothing 
    } 
} 

Następnie chcemy po prostu instancję tej klasy i wywołać tej metody Do() wiele razy na dwa sposoby: za pomocą zmiennej klasy betonu i za pomocą zmiennej interfejs:

TheFoo foo1 = new TheFoo(); 

Stopwatch stopwatch = new Stopwatch(); 
stopwatch.Start(); 
for (long i = 0; i < 1000000000; i++) 
    foo1.Do(); 
stopwatch.Stop(); 
Console.Out.WriteLine("Elapsed time: " + stopwatch.ElapsedMilliseconds); 

IFoo foo2 = foo1; 

stopwatch = new Stopwatch(); 
stopwatch.Start(); 
for (long i = 0; i < 1000000000; i++) 
    foo2.Do(); 
stopwatch.Stop(); 
Console.Out.WriteLine("Elapsed time: " + stopwatch.ElapsedMilliseconds); 

Zaskakująco (przynajmniej dla mnie), że minęły czasy są o 10% innego:

Elapsed time: 6005 
Elapsed time: 6667 

różnica nie jest tak dużo, więc nie będzie W większości przypadków bardzo się o to martwimy. Jednak nie mogę pojąć, dlaczego tak się dzieje, nawet po przejrzeniu kodu IL, więc byłbym wdzięczny, gdyby ktoś wskazał mi coś oczywistego, czego mi brakuje.

+0

Przeprowadziłem twoje testy i faktycznie otrzymałem zupełnie inny wynik.Upływający czas: 12125 Upłynął czas: 11682. Oczywiście korzystam z wolniejszej maszyny. Pomiar wydajności w ten sposób jest trudny, ponieważ istnieją czynniki, które mogą być w grze, które nie są jasne. –

+1

@Craig Prawdopodobnie jakiś proces przeszkadzał. Mam bardzo konsekwentne wyniki w tym mini-benchmarku 10 razy. –

+0

@Ivan Uruchomiłem to wiele razy, zanim opublikowałem i uzyskałem spójne wyniki. Ja też po prostu poszedłem i odtworzyłem to, wprowadzając go w pętlę i uruchamiając go 10 razy z powrotem do tyłu. Mam bardzo mało innych maszyn i ciągle widzę, że pierwszy przypadek jest wolniejszy. Jest to teraz znacząca różnica, prawie 2: 1. Dodałem także inną klasę, która jawnie implementuje interfejs i przeprowadził test na tym. To działało mniej więcej tak samo, jak w drugim przypadku. Z jakiej wersji platformy korzystasz? Może to wyjaśnia różnice. –

Odpowiedz

17

Musisz sprawdzić kod maszynowy, aby zobaczyć, co się dzieje. Gdy to zrobisz, zobaczysz, że optymalizator jittera ma całkowicie usunąć połączenie z foo1.Do(). Małe metody, takie jak te, są optymalizowane przez optymalizatora. Ponieważ treść metody nie zawiera kodu, w ogóle nie generuje się kodu maszynowego. Nie może dokonać tej samej optymalizacji w wywołaniu interfejsu, nie jest wystarczająco inteligentny, aby odwrócić proces inżynieryjny, który wskazuje, że wskaźnik metody interfejsu wskazuje na pustą metodę.

Aby uzyskać listę typowych optymalizacji przeprowadzonych przez jitter, należy sprawdzić, w której wersji znajduje się this answer. Zwróć uwagę na ostrzeżenia o profilowaniu wymienione w tej odpowiedzi.

UWAGA: przeglądanie kodu maszynowego w wersji wydania wymaga zmiany opcji. Domyślnie optymalizator jest wyłączany podczas debugowania kodu, nawet w wersji wydania. Narzędzia + Opcje, Usuwanie błędów, Ogólne, odznaczanie "Pomiń optymalizację JIT przy ładowaniu modułu".

+0

Chodzi o to, że faktycznie widziałem wynikowy kod assemblera w debugerze. Nie został zoptymalizowany przez jittera. Ale i tak jest to dobry punkt. +1 –

+0

Aby wykluczyć wpływ na optymalizację, właśnie dodałem statyczną zmienną int, która jest inkrementowana dla każdego wywołania Do(). I na końcu 'x' idzie na konsolę, więc kompilator też nie może tego wyeliminować. Czasy stają się nieco wyższe, ale trend jest taki sam. –

+0

@Ivan: * Jak * zauważyłeś, że wynikowy kod nie został zoptymalizowany przez jitter? Ludzie często robią to źle. Musisz tylko upewnić się, że kod jest wciśnięty * przed * załączeniem debuggera. Jitter wie, czy debugger jest podłączony, czy nie, i staje się mniej agresywny pod względem optymalizacji, które utrudniają ustawianie punktów przerwania, jeśli wie, że dołączony jest debugger. –

1

Cóż, kompilator nie może w ogólnym przypadku opisać, która treść metody powinna zostać wykonana po wywołaniu metody interfejsu, ponieważ różne klasy mogą mieć różne implementacje.

Tak więc, gdy CLR staje w obliczu wywołania interfejsu, widzi w interfejsie mapowania typu obejmującego i sprawdza, jaką konkretną metodę powinien wywołać. W rzeczywistości jest to mniej niż IL.

UPD: IMO to nie jest różnica między call i callvirt.

Co powinien zrobić CLR po napotkaniu callvirt na typie klasy? Uzyskaj typ odbierającego, spójrz na jego wirtualną tabelę metod, znajdź tam wywoływaną metodę i wywołaj ją.

Co należy zrobić, gdy napotkasz callvirt na typie interfejsu? Cóż, oprócz punktów prev powinien również sprawdzić takie rzeczy, jak implementacja jawnego interfejsu. Ponieważ MOŻESZ mieć dwie metody z identycznymi sygnaturami - jedna to metoda klasy, a druga to jawna implementacja interfejsu. Takie rzeczy po prostu nie istnieją, gdy mamy do czynienia z typami klasowymi. Myślę, że to główna różnica tutaj.

UPD2: Teraz jestem pewien, że tak jest. Szczegółowe informacje dotyczące implementacji można znaleźć w dokumencie this.

Powiązane problemy