Frage Try-Catch beschleunigt meinen Code?


Ich habe einen Code geschrieben, mit dem ich die Auswirkungen von Versuchen testen kann, aber ich habe einige überraschende Ergebnisse gesehen.

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;
}

Auf meinem Computer gibt dies konsistent einen Wert um 0,96 aus.

Wenn ich die for-Schleife in Fibo () mit einem try-catch-Block wie folgt umschließe:

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;
}

Jetzt druckt es konstant 0,69 ... - es läuft tatsächlich schneller! Aber warum?

Hinweis: Ich habe dies mit der Release-Konfiguration kompiliert und direkt die EXE-Datei (außerhalb von Visual Studio) ausgeführt.

BEARBEITEN: Jon Skeets Ausgezeichnet Analyse zeigt, dass try-catch irgendwie dazu führt, dass die x86-CLR die CPU-Register in diesem speziellen Fall günstiger nutzt (und ich denke, wir müssen noch verstehen, warum). Ich bestätigte Jons Feststellung, dass x64 CLR diesen Unterschied nicht hat und dass es schneller war als die x86 CLR. Ich habe auch getestet int Typen innerhalb der Fibo-Methode statt long Typen, und dann war die x86 CLR genauso schnell wie die x64 CLR.


AKTUALISIEREN: Es sieht so aus, als ob dieses Problem von Roslyn behoben wurde. Derselbe Computer, dieselbe CLR-Version - das Problem bleibt bei der Kompilierung mit VS 2013 bestehen, aber das Problem verschwindet, wenn es mit VS 2015 kompiliert wird.


1338
2018-01-19 15:10


Ursprung


Antworten:


Einer der Roslyn Ingenieure, die sich auf das Verständnis der Optimierung der Stack-Nutzung spezialisiert haben, haben sich das angesehen und berichten mir, dass es ein Problem in der Interaktion zwischen der Art, wie der C # -Compiler lokale Variablen speichert, und der Art, wie der C # -Compiler erzeugt wird JIT Der Compiler registriert die Planung im entsprechenden x86-Code. Das Ergebnis ist eine suboptimale Codegenerierung auf den Ladungen und Speichern der Einheimischen.

Aus irgendeinem Grund, der für uns alle unklar ist, wird der Pfad zur Generierung problematischer Codes vermieden, wenn der JITter weiß, dass sich der Block in einer try-geschützten Region befindet.

Das ist ziemlich komisch. Wir werden mit dem JITter-Team weitermachen und sehen, ob wir einen Fehler bekommen können, damit sie das beheben können.

Wir arbeiten auch an Verbesserungen für Roslyn für die C # - und VB-Compileralgorithmen, um zu bestimmen, wann Einheimische "ephemer" gemacht werden können - das heißt, einfach gedrückt und auf den Stapel gestellt werden, anstatt eine bestimmte Stelle auf dem Stapel zuzuordnen die Dauer der Aktivierung. Wir glauben, dass der JITter in der Lage sein wird, die Registerzuweisung besser zu erledigen, und was nicht, wenn wir ihm bessere Hinweise geben, wenn Einheimische früher "tot" gemacht werden können.

Danke, dass Sie uns darauf aufmerksam gemacht haben, und wir entschuldigen uns für das seltsame Verhalten.


927
2018-01-20 20:14



Nun, die Art, wie du die Dinge zeitlich festlegst, sieht für mich ziemlich gemein aus. Es wäre viel sinnvoller, nur die ganze Zeit zu messen:

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

Auf diese Weise sind Sie nicht den winzigen Zeitvorgaben, der Gleitkommaarithmetik und dem akkumulierten Fehler ausgeliefert.

Wenn Sie diese Änderung vorgenommen haben, sehen Sie, ob die "non-catch" -Version immer noch langsamer ist als die "catch" -Version.

EDIT: Okay, ich habe es selbst ausprobiert - und ich sehe das gleiche Ergebnis. Sehr komisch. Ich fragte mich, ob der Versuch / Fang einige schlechte Inlining deaktivieren, aber verwenden würde [MethodImpl(MethodImplOptions.NoInlining)]stattdessen hat nicht geholfen ...

Im Grunde müssen Sie den optimierten JITted-Code unter cordbg betrachten, vermute ich ...

EDIT: Ein paar weitere Informationen:

  • Setzen Sie den Versuch / Fang nur um die n++; Linie verbessert immer noch die Leistung, aber nicht so sehr, wie es um den ganzen Block gelegt wird
  • Wenn Sie eine bestimmte Ausnahme (ArgumentException in meinen Tests) ist es immer noch schnell
  • Wenn Sie die Ausnahme im Catch-Block drucken, ist sie immer noch schnell
  • Wenn Sie die Ausnahme im Catch-Block erneut auslösen, ist sie wieder langsam
  • Wenn Sie einen finally-Block anstelle eines catch-Blocks verwenden, ist es wieder langsam
  • Wenn Sie einen finally-Block verwenden ebenso gut wie Ein Catch-Block, es ist schnell

Seltsam...

EDIT: Okay, wir haben Demontage ...

Dies verwendet den C # 2-Compiler und die .NET 2 (32-Bit) -CLR, die mit mdbg zerlegt wird (da ich kein cordbg auf meinem Rechner habe). Ich sehe immer noch die gleichen Leistungseffekte, selbst unter dem Debugger. Die schnelle Version verwendet a try blockiere alles zwischen den Variablendeklarationen und der return-Anweisung mit nur a catch{} Handler. Offensichtlich ist die langsame Version die gleiche, außer ohne try / catch. Der aufrufende Code (d. H. Main) ist in beiden Fällen derselbe und hat die gleiche Assemblierungsdarstellung (es ist also kein Inline-Problem).

Demontierter Code für die schnelle 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

Demontierter Code für langsame Version:

 [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

In jedem Fall die * zeigt an, wo der Debugger in einem einfachen "Step-In" eingegeben hat.

EDIT: Okay, ich habe jetzt den Code durchgesehen und ich denke, ich kann sehen, wie jede Version funktioniert ... und ich glaube, die langsamere Version ist langsamer, weil sie weniger Register und mehr Stack-Speicherplatz verwendet. Für kleine Werte von n das ist möglicherweise schneller - aber wenn die Schleife den größten Teil der Zeit beansprucht, ist sie langsamer.

Möglicherweise der Try / Catch-Block Kräfte mehr Register, die gespeichert und wiederhergestellt werden sollen, also verwendet das JIT auch solche für die Schleife ... was insgesamt die Performance verbessert. Es ist nicht klar, ob es eine vernünftige Entscheidung für die JIT ist nicht Verwenden Sie so viele Register im "normalen" Code.

EDIT: Probieren Sie dies auf meinem x64-Rechner. Die x64 CLR ist viel schneller (etwa 3-4 mal schneller) als die x86 CLR in diesem Code, und unter x64 macht der try / catch-Block keinen merklichen Unterschied.


702
2018-01-19 15:15



Jons Disassemblies zeigen, dass der Unterschied zwischen den beiden Versionen darin besteht, dass die schnelle Version ein Paar Register verwendet (esi,edi) um eine der lokalen Variablen zu speichern, wo die langsame Version nicht.

Der JIT-Compiler macht unterschiedliche Annahmen in Bezug auf die Registerbenutzung für Code, der einen try-catch-Block im Gegensatz zum Code enthält, der dies nicht tut. Dies führt dazu, dass es unterschiedliche Registerzuweisungs-Auswahlen trifft. In diesem Fall begünstigt dies den Code mit dem try-catch-Block. Unterschiedlicher Code kann zu dem gegenteiligen Effekt führen, also würde ich dies nicht als allgemeine Beschleunigungstechnik zählen.

Am Ende ist es sehr schwer zu sagen, welcher Code am schnellsten läuft. Etwas wie die Registerzuordnung und die Faktoren, die es beeinflussen, sind solche Low-Level-Implementierungsdetails, dass ich nicht sehe, wie eine bestimmte Technik zuverlässig schneller Code produzieren könnte.

Betrachten Sie beispielsweise die folgenden zwei Methoden. Sie wurden aus einem realen Beispiel adaptiert:

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;
}

Einer ist eine generische Version des anderen. Ersetzen des generischen Typs durch StructArray würde die Methoden identisch machen. weil StructArray Ist ein Werttyp, erhält er eine eigene kompilierte Version der generischen Methode. Die tatsächliche Laufzeit ist jedoch wesentlich länger als die der spezialisierten Methode, aber nur für x86. Für x64 sind die Timings ziemlich identisch. In anderen Fällen habe ich Unterschiede auch für x64 beobachtet.


110
2018-01-19 18:27



Das sieht nach einem Fall aus, in dem schlecht gegangen ist. Auf einem x86-Core verfügt der Jitter über das Register ebx, edx, esi und edi, das für die allgemeine Speicherung lokaler Variablen verfügbar ist. Das ecx-Register wird in einer statischen Methode verfügbar, es muss nicht gespeichert werden Dies. Das eax-Register wird oft für Berechnungen benötigt. Aber das sind 32-Bit-Register, für Variablen vom Typ long muss ein Registerpaar verwendet werden. Welches sind edx: eax für Berechnungen und edi: ebx für Speicher.

Was bei der Disassembly für die langsame Version auffällt, werden weder edi noch ebx verwendet.

Wenn der Jitter nicht genügend Register finden kann, um lokale Variablen zu speichern, muss er Code generieren, um sie vom Stapelrahmen zu laden und zu speichern. Das verlangsamt den Code und verhindert eine Prozessor-Optimierung namens "Register-Umbenennung", einen internen Prozessorkern-Optimierungstrick, der mehrere Kopien eines Registers verwendet und super-skalare Ausführung ermöglicht. Dadurch können mehrere Anweisungen gleichzeitig ausgeführt werden, auch wenn sie dasselbe Register verwenden. Nicht genug Register zu haben ist ein häufiges Problem bei x86-Kernen, adressiert in x64, das 8 zusätzliche Register hat (r9 bis r15).

Der Jitter wird sein Bestes geben, um eine weitere Optimierung der Codegenerierung anzuwenden, er wird versuchen, Ihre Fibo () - Methode zu inline zu bringen. Mit anderen Worten: Rufen Sie die Methode nicht auf, sondern generieren Sie den Code für die Methode inline in der Main () -Methode. Eine ziemlich wichtige Optimierung, die zum einen die Eigenschaften einer C # -Klasse umsonst macht und ihnen die Leistungsfähigkeit eines Feldes verleiht. Es vermeidet den Aufwand, den Aufruf der Methode zu machen und seinen Stack-Frame einzurichten, spart ein paar Nanosekunden.

Es gibt mehrere Regeln, die genau bestimmen, wann eine Methode inline sein kann. Sie sind nicht genau dokumentiert, wurden aber in Blogposts erwähnt. Eine Regel ist, dass dies nicht geschieht, wenn der Methodenkörper zu groß ist. Das vereitelt den Gewinn vom Inlining, es erzeugt zu viel Code, der nicht so gut in den L1-Befehlscache passt. Eine andere harte Regel, die hier gilt, ist, dass eine Methode nicht inline ist, wenn sie eine try / catch-Anweisung enthält. Der Hintergrund dahinter ist ein Implementierungsdetail von Ausnahmen, die auf die integrierte Windows-Unterstützung für SEH (Structure Exception Handling) auf Stack-Frame-Basis zurückgreifen.

Ein Verhalten des Registerzuordnungsalgorithmus in dem Jitter kann aus dem Spielen mit diesem Code abgeleitet werden. Es scheint zu wissen, wann der Jitter versucht, eine Methode zu verknüpfen. Eine Regel scheint es zu verwenden, dass nur das edx: eax-Registerpaar für inline-Code verwendet werden kann, der lokale Variablen vom Typ long hat. Aber nicht edi: ebx. Zweifellos, da dies für die Code-Generierung für die Aufrufmethode zu schädlich wäre, sind sowohl edi als auch ebx wichtige Speicherregister.

Sie erhalten also die schnelle Version, weil der Jitter weiß, dass der Methoden-Body try / catch-Anweisungen enthält. Es weiß, dass es nie inline verwendet werden kann, so verwendet edi: ebx für die Speicherung der langen Variable. Du hast die langsame Version, weil der Jitter nicht wusste, dass Inlining nicht funktionieren würde. Es hat nur herausgefunden nach Erzeugen des Codes für den Methodenkörper.

Der Fehler ist dann, dass es nicht zurück ging und regenerieren der Code für die Methode. Was angesichts der zeitlichen Beschränkungen, in denen es operieren muss, verständlich ist.

Diese Verlangsamung tritt bei x64 nicht auf, weil es zum einen 8 weitere Register hat. Zum anderen, weil es in nur einem Register lange speichern kann (wie Rax). Und die Verlangsamung tritt nicht auf, wenn Sie int anstelle von long verwenden, da der Jitter viel mehr Flexibilität bei der Auswahl von Registern hat.


65
2017-08-03 10:42



Ich würde dies als Kommentar hinzufügen, da ich mir wirklich nicht sicher bin, dass dies wahrscheinlich der Fall ist, aber wie ich mich erinnere, beinhaltet eine Versuch / Ausnahme-Anweisung keine Änderung an der Art, wie der Müllentsorgungsmechanismus von der Compiler funktioniert, indem er die Objektspeicherzuweisungen rekursiv vom Stack löscht. In diesem Fall muss möglicherweise kein Objekt aufgeklärt werden, oder die for-Schleife kann eine Schließung darstellen, die der Speicherbereinigungsmechanismus ausreichend erkennt, um ein anderes Sammlungsverfahren durchzusetzen. Wahrscheinlich nicht, aber ich dachte, es wäre eine Erwähnung wert, da ich es nirgendwo anders gesehen hatte.


18
2018-01-20 13:15