Try-catch przyspiesza mój kod?

Napisałem kod do testowania wpływu try-catch, ale widząc 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, to konsekwentnie wypisuje wartość Około 0.96..

Kiedy zawijam pętlę for wewnątrz fibo () blokiem try-catch w ten sposób:

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... -- to działa szybciej! Ale dlaczego?

Uwaga: skompilowałem to używając konfiguracji Wydania i bezpośrednio uruchomiłem plik EXE (poza Visual Studio).

EDIT: doskonała analiza Jona Skeeta pokazuje, że try-catch w jakiś sposób powoduje, że x86 CLR korzysta z rejestrów procesora w bardziej korzystny sposób w tym konkretnym przypadku (i myślę, że jeszcze nie rozumiemy dlaczego). Potwierdziłem stwierdzenie Jona, że x64 CLR nie ma tej różnicy i że był szybszy niż x86 CLR. Testowałem również typy int wewnątrz metody Fibo zamiast typów long, a następnie x86 CLR był równie szybki jak x64 CLR.


Aktualizacja: wygląda na to, że ten problem został naprawiony przez Roslyn. Ta sama maszyna, ta sama wersja CLR -- problem pozostaje jak powyżej, gdy skompilowany z VS 2013, ale problem znika, gdy skompilowany z VS 2015.

Author: Community, 2012-01-19

6 answers

Jeden z inżynierów Roslyn, specjalizujący się w optymalizacji wykorzystania stosu, przyjrzał się temu i poinformował mnie, że wydaje się, że istnieje problem w interakcji między sposobem, w jaki kompilator C# generuje lokalne zmienne, A sposobem, w jaki kompilator JIT rejestruje scheduling w odpowiednim kodzie x86. Rezultatem jest nieoptymalne generowanie kodu na ładunkach i magazynach lokalnych.

Z jakichś niejasnych dla nas wszystkich powodów, problematyczna ścieżka generowania kodu jest unikana, gdy JITter wie, że blok znajduje się w regionie chronionym próbą.

To jest dość dziwne. Skontaktujemy się z zespołem JITter i zobaczymy, czy możemy wprowadzić błąd, aby mogli to naprawić.

Pracujemy również nad ulepszeniami dla algorytmów kompilatorów C# i VB do określania, kiedy lokalne mogą być "efemeryczne" - czyli po prostu pchane i wyskakujące na stosie, zamiast przydzielać konkretną lokalizację na stosie stos na Czas aktywacji. Wierzymy, że JITter będzie w stanie wykonać lepszą pracę przy alokacji rejestrów i tak dalej, jeśli damy mu lepsze wskazówki, kiedy miejscowi mogą być wcześniej "martwi".

Dziękujemy za zwrócenie na to uwagi i przepraszamy za dziwne zachowanie.

 1079
Author: Eric Lippert,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2016-10-16 23:15:02

Sposób, w jaki mierzysz czas, wygląda dla mnie paskudnie. O wiele bardziej sensowne byłoby po prostu zmierzenie całej pętli:

var stopwatch = Stopwatch.StartNew();
for (int i = 1; i < 100000000; i++)
{
    Fibo(100);
}
stopwatch.Stop();
Console.WriteLine("Elapsed time: {0}", stopwatch.Elapsed);

W ten sposób nie jesteś na łasce małych czasów, arytmetyki zmiennoprzecinkowej i nagromadzonego błędu.

Po wprowadzeniu tej zmiany sprawdź, czy wersja " non-catch "jest nadal wolniejsza niż wersja" catch".

EDIT: ok, sam próbowałem-i widzę ten sam wynik. Bardzo dziwne. Zastanawiałem się, czy próba / łapanie jest wyłączenie złej inlining, ale użycie [MethodImpl(MethodImplOptions.NoInlining)] nie pomogło...

Zasadniczo musisz spojrzeć na zoptymalizowany kod JITted pod cordbg, podejrzewam...

EDIT: jeszcze kilka bitów informacji:

  • umieszczenie try / catch wokół linii n++; nadal poprawia wydajność, ale nie tak bardzo, jak umieszczenie jej wokół całego bloku
  • jeśli złapiesz konkretny wyjątek (ArgumentException W moich testach) to nadal jest szybki
  • jeśli wydrukujesz wyjątek w catch block it ' s still fast
  • Jeśli zmienisz wyjątek w bloku catch to znowu będzie wolno
  • jeśli użyjesz finally block zamiast catch block, to znowu będzie wolny
  • Jeśli używasz finally block , a także blok catch, jest szybki

Dziwne...

EDIT: ok, mamy demontaż...

To jest przy użyciu kompilatora C # 2 i. Net 2 (32-bit) CLR, demontaż z mdbg (ponieważ nie mam cordbg na mojej maszynie). I still see the te same efekty wydajności, nawet pod debugerem. Wersja fast używa bloku try wokół wszystkiego pomiędzy deklaracjami zmiennej i instrukcją return, z tylko obsługą catch{}. Oczywiście wersja slow jest taka sama tylko bez try/catch. Kod wywołujący (tj. Main) jest taki sam w obu przypadkach i ma tę samą reprezentację asemblera (więc nie jest to problem inliningowy).

Disassembled code for fast version:

 [0000] push        ebp
 [0001] mov         ebp,esp
 [0003] push        edi
 [0004] push        esi
 [0005] push        ebx
 [0006] sub         esp,1Ch
 [0009] xor         eax,eax
 [000b] mov         dword ptr [ebp-20h],eax
 [000e] mov         dword ptr [ebp-1Ch],eax
 [0011] mov         dword ptr [ebp-18h],eax
 [0014] mov         dword ptr [ebp-14h],eax
 [0017] xor         eax,eax
 [0019] mov         dword ptr [ebp-18h],eax
*[001c] mov         esi,1
 [0021] xor         edi,edi
 [0023] mov         dword ptr [ebp-28h],1
 [002a] mov         dword ptr [ebp-24h],0
 [0031] inc         ecx
 [0032] mov         ebx,2
 [0037] cmp         ecx,2
 [003a] jle         00000024
 [003c] mov         eax,esi
 [003e] mov         edx,edi
 [0040] mov         esi,dword ptr [ebp-28h]
 [0043] mov         edi,dword ptr [ebp-24h]
 [0046] add         eax,dword ptr [ebp-28h]
 [0049] adc         edx,dword ptr [ebp-24h]
 [004c] mov         dword ptr [ebp-28h],eax
 [004f] mov         dword ptr [ebp-24h],edx
 [0052] inc         ebx
 [0053] cmp         ebx,ecx
 [0055] jl          FFFFFFE7
 [0057] jmp         00000007
 [0059] call        64571ACB
 [005e] mov         eax,dword ptr [ebp-28h]
 [0061] mov         edx,dword ptr [ebp-24h]
 [0064] lea         esp,[ebp-0Ch]
 [0067] pop         ebx
 [0068] pop         esi
 [0069] pop         edi
 [006a] pop         ebp
 [006b] ret

Disassembled code for slow Wersja:

 [0000] push        ebp
 [0001] mov         ebp,esp
 [0003] push        esi
 [0004] sub         esp,18h
*[0007] mov         dword ptr [ebp-14h],1
 [000e] mov         dword ptr [ebp-10h],0
 [0015] mov         dword ptr [ebp-1Ch],1
 [001c] mov         dword ptr [ebp-18h],0
 [0023] inc         ecx
 [0024] mov         esi,2
 [0029] cmp         ecx,2
 [002c] jle         00000031
 [002e] mov         eax,dword ptr [ebp-14h]
 [0031] mov         edx,dword ptr [ebp-10h]
 [0034] mov         dword ptr [ebp-0Ch],eax
 [0037] mov         dword ptr [ebp-8],edx
 [003a] mov         eax,dword ptr [ebp-1Ch]
 [003d] mov         edx,dword ptr [ebp-18h]
 [0040] mov         dword ptr [ebp-14h],eax
 [0043] mov         dword ptr [ebp-10h],edx
 [0046] mov         eax,dword ptr [ebp-0Ch]
 [0049] mov         edx,dword ptr [ebp-8]
 [004c] add         eax,dword ptr [ebp-1Ch]
 [004f] adc         edx,dword ptr [ebp-18h]
 [0052] mov         dword ptr [ebp-1Ch],eax
 [0055] mov         dword ptr [ebp-18h],edx
 [0058] inc         esi
 [0059] cmp         esi,ecx
 [005b] jl          FFFFFFD3
 [005d] mov         eax,dword ptr [ebp-1Ch]
 [0060] mov         edx,dword ptr [ebp-18h]
 [0063] lea         esp,[ebp-4]
 [0066] pop         esi
 [0067] pop         ebp
 [0068] ret

W każdym przypadku * pokazuje, gdzie debugger został wprowadzony w prostym "step-into".

EDIT: ok, przejrzałem kod i myślę, że widzę, jak działa każda wersja... i wierzę, że wolniejsza wersja jest wolniejsza, ponieważ używa mniej rejestrów i więcej miejsca na stosie. Dla małych wartości n jest to prawdopodobnie szybsze - ale gdy pętla zajmuje większość czasu, jest wolniejsze.

Prawdopodobnie blok try/catch zmusza więcej rejestry do zapisania i przywrócenia, więc JIT używa tych dla pętli, jak również... co zdarza się poprawić ogólną wydajność. Nie jest jasne, czy jest to rozsądna decyzja dla JIT, aby nie używać tak wielu rejestrów w "normalnym" kodzie.

EDIT: właśnie próbowałem tego na mojej maszynie x64. X64 CLR jest dużo szybszy (około 3-4 razy szybciej) niż x86 CLR na tym kodzie, a pod x64 blok try/catch nie robi zauważalnej różnicy.

 746
Author: Jon Skeet,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2012-01-19 19:38:49

Demasembly Jona pokazują, że różnica między tymi dwoma wersjami polega na tym, że szybka wersja używa pary rejestrów (esi,edi) do przechowywania jednej z lokalnych zmiennych, gdzie wolna wersja nie.

Kompilator JIT przyjmuje różne założenia dotyczące użycia rejestru w kodzie zawierającym blok try-catch w porównaniu z kodem, który go nie używa. powoduje to, że dokonuje on różnych wyborów przydziału rejestru. W tym przypadku sprzyja to kodowi z blokiem try-catch. Inny kod może prowadzić do efekt odwrotny, więc nie liczyłbym tego jako techniki przyspieszania ogólnego przeznaczenia.

W końcu bardzo trudno powiedzieć, który kod będzie działał najszybciej. Coś w rodzaju alokacji rejestrów i czynników, które na to wpływają, to takie szczegóły implementacji niskiego poziomu, że nie widzę, jak jakaś konkretna technika mogłaby niezawodnie produkować szybszy kod.

Na przykład, rozważmy następujące dwie metody. Zostały one zaadaptowane z prawdziwego przykładu:

interface IIndexed { int this[int index] { get; set; } }
struct StructArray : IIndexed { 
    public int[] Array;
    public int this[int index] {
        get { return Array[index]; }
        set { Array[index] = value; }
    }
}

static int Generic<T>(int length, T a, T b) where T : IIndexed {
    int sum = 0;
    for (int i = 0; i < length; i++)
        sum += a[i] * b[i];
    return sum;
}
static int Specialized(int length, StructArray a, StructArray b) {
    int sum = 0;
    for (int i = 0; i < length; i++)
        sum += a[i] * b[i];
    return sum;
}

Jeden jest ogólna wersja tego drugiego. Zastąpienie typu generycznego przez StructArray sprawiłoby, że metody byłyby identyczne. Ponieważ StructArray jest typem wartości, otrzymuje własną skompilowaną wersję metody generycznej. Jednak rzeczywisty czas pracy jest znacznie dłuższy niż wyspecjalizowane metody, ale tylko dla x86. Dla x64 czasy są prawie identyczne. W innych przypadkach zaobserwowałem różnice również dla x64.

 121
Author: Jeffrey Sax,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2012-01-19 22:43:59

Wygląda na to, że sprawa inliningu poszła źle. Na rdzeniu x86 jitter ma rejestr ebx, edx, ESI i edi dostępny do ogólnego przechowywania zmiennych lokalnych. Rejestr ecx staje się dostępny w metodzie statycznej, nie musi przechowywać tego . Rejestr eax jest często potrzebny do obliczeń. Są to jednak rejestry 32-bitowe, dla zmiennych typu long musi być używana para rejestrów. Które są edx: eax do obliczeń i edi: ebx do przechowywania.

Które jest to, co wyróżnia się w demontażu dla wersji slow, ani edi, ani ebx nie są używane.

Gdy jitter nie może znaleźć wystarczającej ilości rejestrów do przechowywania zmiennych lokalnych, musi wygenerować kod, aby załadować i zapisać je z ramki stosu. To spowalnia kod, zapobiega optymalizacji procesora o nazwie "register renaming", wewnętrznej sztuczki optymalizacji rdzenia procesora, która wykorzystuje wiele kopii rejestru i pozwala na super-skalarne wykonanie. Który pozwala na uruchomienie kilku instrukcji jednocześnie, nawet jeśli używają tego samego rejestru. Brak wystarczającej ilości rejestrów jest częstym problemem na rdzeniach x86, adresowanym 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 wykonuj wywołania metody, ale Wygeneruj kod dla metody inline w metodzie Main (). Dość ważna optymalizacja, która sprawia, że właściwości klasy C# za darmo, dając im perf pola. Unika kosztów wywołania metody i konfigurowania ramki stosu, oszczędza kilka nanosekund.

Istnieje kilka zasad, które określają dokładnie, kiedy metoda może być inlined. Nie są one dokładnie udokumentowane, ale zostały wymienione w postach na blogu. Jedna zasada jest taka, że nie dojdzie do tego, gdy ciało metody jest zbyt duże. To pokonuje zysk z inliningu, generuje zbyt dużo kodu, który nie pasuje tak dobrze do instrukcji L1 cache. Inną trudną zasadą, która ma tutaj zastosowanie, jest to, że metoda nie będzie inlined, gdy zawiera instrukcję try / catch. Tłem za tym jest implementacja wyjątków, które mają wbudowaną obsługę systemu Windows dla Seh (Structure Exception Handling), która jest oparta na stosie.

Jedno zachowanie algorytmu alokacji rejestrów w jitterze można wywnioskować z gry tym kodem. Wydaje się być świadomy, kiedy jitter próbuje inline a metoda. Wydaje się, że używa się tylko pary rejestru edx:eax dla kodu inlined, który ma zmienne lokalne 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.

Więc dostajesz szybką wersję, ponieważ jitter wie z góry, że ciało metody zawiera instrukcje try / catch. Wie, że nigdy nie może być inlined tak łatwo używa edi: ebx do przechowywania dla zmiennej długiej. Masz wersję slow, bo jitter nie wiedział z góry, że inlining nie zadziała. Okazało się tylko po wygenerowaniu kodu dla ciała metody.

Wadą jest to, że nie wrócił i ponownie wygenerował Kod dla metody. Co jest zrozumiałe, biorąc pod uwagę ograniczenia czasowe, w których musi działać.

To spowolnienie nie występuje na x64, ponieważ dla jednego ma 8 więcej rejestrów. Dla innego, ponieważ może przechowywać long w jednym rejestrze (jak rax). A spowolnienie nie występuje, gdy używasz int zamiast long, ponieważ jitter ma o wiele większą elastyczność w zbieraniu rejestrów.

 75
Author: Hans Passant,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2012-08-03 10:42:14

Umieściłbym to w komentarzu, ponieważ nie jestem pewien, czy tak się stanie, ale z tego co pamiętam, nie jest to Instrukcja try/except związana z modyfikacją sposobu działania mechanizmu usuwania śmieci w kompilatorze, ponieważ usuwa alokacje pamięci obiektów w sposób rekurencyjny ze stosu. W tym przypadku może nie być obiektu do oczyszczenia lub pętla for może stanowić zamknięcie, które mechanizm zbierania śmieci uznaje za wystarczające do wymuszenia inna metoda zbierania. Prawdopodobnie nie, ale pomyślałem, że warto o tym wspomnieć, ponieważ nie widziałem go omawianego nigdzie indziej.

 22
Author: miller the gorilla,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2012-01-20 13:15:06

9 lat później i pluskwa wciąż tam jest! Można to łatwo zobaczyć za pomocą:

   static void Main( string[] args )
    {
      int hundredMillion = 1000000;
      DateTime start = DateTime.Now;
      double sqrt;
      for (int i=0; i < hundredMillion; i++)
      {
        sqrt = Math.Sqrt( DateTime.Now.ToOADate() );
      }
      DateTime end = DateTime.Now;

      double sqrtMs = (end - start).TotalMilliseconds;

      Console.WriteLine( "Elapsed milliseconds: " + sqrtMs );

      DateTime start2 = DateTime.Now;

      double sqrt2;
      for (int i = 0; i < hundredMillion; i++)
      {
        try
        {
          sqrt2 = Math.Sqrt( DateTime.Now.ToOADate() );
        }
        catch (Exception e)
        {
          int br = 0;
        }
      }
      DateTime end2 = DateTime.Now;

      double sqrtMsTryCatch = (end2 - start2).TotalMilliseconds;

      Console.WriteLine( "Elapsed milliseconds: " + sqrtMsTryCatch );

      Console.WriteLine( "ratio is " + sqrtMsTryCatch / sqrtMs );

      Console.ReadLine();
    }

Współczynnik jest mniejszy niż jeden na moim komputerze, z najnowszą wersją MSVS 2019,. NET 4.6.1

 2
Author: Markus,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2020-11-27 14:27:10