Napisałem kod do testowania wpływu try-catch, ale widzę zaskakujące wyniki.
static void Main(string[] args)
{
Thread.CurrentThread.Priority = ThreadPriority.Highest;
Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.RealTime;
long start = 0, stop = 0, elapsed = 0;
double avg = 0.0;
long temp = Fibo(1);
for (int i = 1; i < 100000000; i++)
{
start = Stopwatch.GetTimestamp();
temp = Fibo(100);
stop = Stopwatch.GetTimestamp();
elapsed = stop - start;
avg = avg + ((double)elapsed - avg) / i;
}
Console.WriteLine("Elapsed: " + avg);
Console.ReadKey();
}
static long Fibo(int n)
{
long n1 = 0, n2 = 1, fibo = 0;
n++;
for (int i = 1; i < n; i++)
{
n1 = n2;
n2 = fibo;
fibo = n1 + n2;
}
return fibo;
}
Na moim komputerze powoduje to konsekwentne drukowanie wartości około 0,96 ..
Kiedy owijam pętlę for wewnątrz Fibo () blokiem try-catch, takim jak to:
static long Fibo(int n)
{
long n1 = 0, n2 = 1, fibo = 0;
n++;
try
{
for (int i = 1; i < n; i++)
{
n1 = n2;
n2 = fibo;
fibo = n1 + n2;
}
}
catch {}
return fibo;
}
Teraz konsekwentnie drukuje 0,69 ... - faktycznie działa szybciej! Ale dlaczego?
Uwaga: skompilowałem to przy użyciu konfiguracji wydania i bezpośrednio uruchomiłem plik EXE (poza Visual Studio).
EDYCJA: Doskonała analiza Jona Skeeta pokazuje, że try-catch w jakiś sposób powoduje, że CLR x86 korzysta z rejestrów procesora w bardziej korzystny sposób w tym konkretnym przypadku (i myślę, że jeszcze nie rozumiemy, dlaczego). Potwierdziłem odkrycie Jona, że x64 CLR nie ma tej różnicy i że jest szybszy niż x86 CLR. Testowałem również przy użyciu int
typów wewnątrz metody Fibo zamiast long
typów, a następnie CLR x86 był równie szybki jak CLR x64.
AKTUALIZACJA: Wygląda na to, że ten problem został rozwiązany przez Roslyn. Ta sama maszyna, ta sama wersja CLR - problem pozostaje jak wyżej po skompilowaniu z VS 2013, ale problem zniknie po skompilowaniu z VS 2015.
Odpowiedzi:
Jeden z inżynierów Roslyn, który specjalizuje się w zrozumieniu optymalizacji wykorzystania stosu, przyjrzał się temu i doniósł mi, że wydaje się, że istnieje problem w interakcji między sposobem, w jaki kompilator C # generuje lokalne magazyny zmiennych, a sposobem, w jaki kompilator JIT rejestruje planowanie w odpowiednim kodzie x86. Rezultatem jest nieoptymalne generowanie kodu dla obciążeń i zapasów mieszkańców.
Z jakiegoś powodu dla nas wszystkich niejasnego unika się problematycznej ścieżki generowania kodu, gdy JITter wie, że blok znajduje się w regionie chronionym przed próbą.
To jest dość dziwne. Później skontaktujemy się z zespołem JITter i sprawdzimy, czy możemy wprowadzić błąd, aby mogli go naprawić.
Pracujemy również nad ulepszeniem Roslyn algorytmów kompilatorów C # i VB w celu określenia, kiedy można określić, że locals mogą stać się „efemeryczne” - to znaczy wystarczy je wcisnąć i wyskoczyć na stosie, zamiast przypisywać określoną lokalizację na stosie czas trwania aktywacji. Wierzymy, że JITter będzie w stanie wykonać lepszą pracę przy przydzielaniu rejestrów, a co więcej, jeśli damy lepsze wskazówki, kiedy miejscowi mogą zostać „martwi” wcześniej.
Dziękujemy za zwrócenie nam na to uwagi i przepraszamy za dziwne zachowanie.
źródło
Sposób, w jaki mierzysz czas, wydaje mi się dość paskudny. O wiele rozsądniej byłoby po prostu zmierzyć całą pętlę:
W ten sposób nie jesteś na łasce drobnych czasów, arytmetyki zmiennoprzecinkowej i skumulowanego błędu.
Po dokonaniu tej zmiany sprawdź, czy wersja „non-catch” jest wolniejsza niż wersja „catch”.
EDYCJA: OK, sam tego próbowałem - i widzę ten sam rezultat. Bardzo dziwne. Zastanawiałem się, czy try / catch wyłącza jakieś złe wstawianie, ale użycie
[MethodImpl(MethodImplOptions.NoInlining)]
zamiast tego nie pomogło ...Zasadniczo musisz spojrzeć na zoptymalizowany kod JITted pod cordbg, podejrzewam ...
EDYCJA: Kilka dodatkowych informacji:
n++;
linii wciąż poprawia wydajność, ale nie tak bardzo, jak na całym blokuArgumentException
w moich testach), to wciąż jest szybkiDziwne...
EDYCJA: OK, mamy demontaż ...
Korzysta z kompilatora C # 2 i CLR .NET 2 (32-bit), dezasembluje się z mdbg (ponieważ nie mam cordbg na moim komputerze). Nadal widzę te same efekty wydajnościowe, nawet pod debuggerem. Wersja szybka wykorzystuje
try
blok wokół wszystkiego między deklaracjami zmiennych a instrukcją return, z tylkocatch{}
funkcją obsługi. Oczywiście wolna wersja jest taka sama, chyba że bez try / catch. Kod wywołujący (tj. Główny) jest taki sam w obu przypadkach i ma tę samą reprezentację zestawu (więc nie jest to kwestia kluczowa).Zdemontowany kod dla szybkiej wersji:
Zdemontowany kod dla wolnej wersji:
W każdym przypadku
*
pokazuje, gdzie debuger wszedł w prosty „krok”.EDYCJA: OK, przejrzałem kod i myślę, że widzę, jak działa każda wersja ... i uważam, że wolniejsza wersja jest wolniejsza, ponieważ wykorzystuje mniej rejestrów i więcej miejsca na stosie. W przypadku małych wartości
n
jest to prawdopodobnie szybsze - ale gdy pętla zajmuje większość czasu, jest wolniejsza.Być może blok try / catch wymusza zapisywanie i przywracanie większej liczby rejestrów, więc JIT wykorzystuje je również w pętli ... co poprawia ogólną wydajność. Nie jest jasne, czy uzasadnione jest, aby JIT nie używał tylu rejestrów w „normalnym” kodzie.
EDYCJA: Właśnie wypróbowałem to na moim komputerze x64. CLR x64 jest znacznie szybszy (około 3-4 razy szybszy) niż CLR x86 w tym kodzie, a pod x64 blok try / catch nie robi zauważalnej różnicy.
źródło
esi,edi
dla jednego z długich zamiast stosu. Używaebx
jako licznika, w którym używana jest wersja wolnaesi
.Z dezasemblacji Jona wynika, że różnica między dwiema wersjami polega na tym, że szybka wersja używa pary rejestrów (
esi,edi
) do przechowywania jednej z lokalnych zmiennych, gdzie nie działa wolna wersja.Kompilator JIT przyjmuje różne założenia dotyczące wykorzystania rejestru do kodu zawierającego blok try-catch w porównaniu do kodu, który tego nie robi. To powoduje, że dokonuje różnych wyborów przydziału rejestrów. W tym przypadku faworyzuje to kod z blokiem try-catch. Inny kod może prowadzić do odwrotnego efektu, więc nie liczyłbym tego jako techniki przyspieszania ogólnego zastosowania.
Ostatecznie bardzo trudno jest stwierdzić, który kod skończy się najszybciej. Coś takiego jak przydział rejestrów i czynniki, które na to wpływają, to tak szczegółowe informacje o implementacji niskiego poziomu, że nie rozumiem, w jaki sposób jakakolwiek konkretna technika mogłaby niezawodnie wytwarzać szybszy kod.
Rozważmy na przykład następujące dwie metody. Zostały zaadaptowane z prawdziwego przykładu:
Jedna jest ogólną wersją drugiej. Zastąpienie typu ogólnego typem
StructArray
spowoduje, że metody będą identyczne. PonieważStructArray
jest to typ wartości, otrzymuje własną skompilowaną wersję ogólnej metody. Rzeczywisty czas działania jest jednak znacznie dłuższy niż w przypadku metody specjalistycznej, ale tylko dla x86. W przypadku x64 czasy są prawie identyczne. W innych przypadkach zaobserwowałem również różnice dla x64.źródło
To wygląda na przypadek zepsucia się. Na rdzeniu x86 jitter ma dostępny rejestr ebx, edx, esi i edi do ogólnego przechowywania zmiennych lokalnych. Rejestr ECX będzie dostępny w metodzie statycznej, nie trzeba przechowywać ten . Rejestr eax jest często potrzebny do obliczeń. Ale są to rejestry 32-bitowe, dla zmiennych typu long musi używać pary rejestrów. Które są edx: eax do obliczeń i edi: ebx do przechowywania.
To, co wyróżnia się w demontażu dla wersji wolnej, nie są używane ani edi, ani ebx.
Kiedy jitter nie może znaleźć wystarczającej liczby rejestrów do przechowywania zmiennych lokalnych, musi wygenerować kod, aby załadować i zapisać je z ramki stosu. Spowalnia to kod, zapobiega optymalizacji procesora o nazwie „zmiana nazwy rejestru”, wewnętrznej sztuczki optymalizacji rdzenia procesora, która wykorzystuje wiele kopii rejestru i umożliwia wykonanie super-skalarne. Dzięki temu kilka instrukcji może działać jednocześnie, nawet jeśli używają tego samego rejestru. Brak wystarczającej liczby rejestrów jest powszechnym problemem na rdzeniach x86, rozwiązanym w x64, który ma 8 dodatkowych rejestrów (od r9 do r15).
Jitter dołoży wszelkich starań, aby zastosować kolejną optymalizację generowania kodu, spróbuje wprowadzić metodę Fibo (). Innymi słowy, nie należy wywoływać metody, ale generować kod metody wbudowanej w metodzie Main (). Całkiem ważna optymalizacja, która, na przykład, czyni właściwości klasy C # za darmo, dając im doskonałe pole. Pozwala to uniknąć narzutu wywołania metody i ustawienia ramki stosu, co pozwala zaoszczędzić kilka nanosekund.
Istnieje kilka reguł, które określają dokładnie, kiedy można wprowadzić metodę. Nie są dokładnie udokumentowane, ale zostały wspomniane w postach na blogu. Jedną z zasad jest to, że nie stanie się to, gdy treść metody jest zbyt duża. To niweczy zysk z wbudowania, generuje zbyt dużo kodu, który nie pasuje tak dobrze do pamięci podręcznej instrukcji L1. Inną trudną zasadą, która ma tutaj zastosowanie, jest to, że metoda nie będzie wstawiana, gdy będzie zawierać instrukcję try / catch. Tłem tego jest szczegół implementacji wyjątków, które przywracają do wbudowanej w Windows obsługi SEH (obsługa wyjątków struktury), która jest oparta na ramce stosu.
Jedno zachowanie algorytmu alokacji rejestru w fluktuacji można wywnioskować z gry z tym kodem. Wygląda na to, że zdaje sobie sprawę, kiedy fluktuacja próbuje wprowadzić metodę. Wydaje się, że jedną zasadą jest stosowanie tylko pary rejestru edx: eax dla kodu wstawionego, który ma lokalne zmienne typu long. Ale nie edi: ebx. Bez wątpienia, ponieważ byłoby to zbyt szkodliwe dla generowania kodu dla metody wywołującej, zarówno edi, jak i ebx są ważnymi rejestrami pamięci.
Otrzymujesz szybką wersję, ponieważ jitter z góry wie, że treść metody zawiera instrukcje try / catch. Wie, że nigdy nie da się go wstawić, dlatego z łatwością używa edi: ebx do przechowywania długiej zmiennej. Masz wolną wersję, ponieważ jitter nie wiedział z góry, że inlining nie zadziała. Dowiedział się to dopiero po wygenerowaniu kodu dla treści metody.
Wada polega na tym, że nie cofnął się i nie wygenerował ponownie kodu dla metody. Co jest zrozumiałe, biorąc pod uwagę ograniczenia czasowe, w jakich musi działać.
To spowolnienie nie występuje na x64, ponieważ dla jednego ma jeszcze 8 rejestrów. Po drugie, ponieważ może przechowywać długi w jednym rejestrze (np. Rax). Zwolnienie nie występuje, gdy używasz int zamiast długiego, ponieważ fluktuacja ma znacznie większą elastyczność w pobieraniu rejestrów.
źródło
Umieściłbym to w komentarzu, ponieważ tak naprawdę nie jestem pewien, czy tak się stanie, ale o ile pamiętam, nie jest to instrukcja try / try, która obejmuje modyfikację mechanizmu usuwania śmieci kompilator działa w tym sensie, że usuwa rekursywnie przydziały pamięci obiektów w sposób rekurencyjny ze stosu. W tym przypadku może nie istnieć obiekt do wyczyszczenia lub pętla for może stanowić zamknięcie, które mechanizm wyrzucania elementów bezużytecznych uznaje za wystarczające do wymuszenia innej metody zbierania. Prawdopodobnie nie, ale pomyślałem, że warto o tym wspomnieć, ponieważ nie widziałem o tym nigdzie indziej.
źródło