web-dev-qa-db-ger.com

Versuchen Sie, meinen Code zu beschleunigen?

Ich habe einen Code zum Testen der Auswirkungen von Try-Catch geschrieben, aber einige überraschende Ergebnisse festgestellt.

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 wird dabei immer ein Wert um 0,96 ausgegeben.

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

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 aus ... - es läuft tatsächlich schneller! Aber wieso?

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

EDIT: Jon Skeets exzellente Analyse zeigt, dass Try-Catch dazu führt, dass die x86-CLR in diesem speziellen Fall die CPU-Register günstiger nutzt (und ich denke, wir müssen noch verstehen, warum). Ich bestätigte Jons Entdeckung, dass die x64-CLR diesen Unterschied nicht aufweist und schneller als die x86-CLR ist. Ich habe auch die Verwendung von int -Typen innerhalb der Fibo-Methode anstelle von long -Typen getestet, und dann war die x86-CLR genauso schnell wie die x64-CLR.


UPDATE: Dieses Problem wurde anscheinend von Roslyn behoben. Dieselbe Maschine, dieselbe CLR-Version - das Problem bleibt beim Kompilieren mit VS 2013 bestehen, aber das Problem tritt beim Kompilieren mit VS 2015 nicht mehr auf.

1445
Eren Ersönmez

Einer der Roslyn Ingenieure, der sich auf das Verständnis der Optimierung der Stack-Nutzung spezialisiert hat, hat sich dies angesehen und mir berichtet, dass es ein Problem in der Interaktion zwischen der Art und Weise zu geben scheint, in der der C # -Compiler lokale Variablenspeicher generiert So wie der Compiler JIT die Zeitplanung im entsprechenden x86-Code registriert. Das Ergebnis ist eine suboptimale Codegenerierung beim Laden und Speichern der Einheimischen.

Aus irgendeinem Grund, der uns allen unklar ist, wird der problematische Pfad zur Codegenerierung vermieden, wenn der JITter weiß, dass sich der Block in einem versuchsgeschützten Bereich befindet.

Das ist ziemlich komisch. Wir werden uns mit dem JITter-Team in Verbindung setzen und prüfen, ob ein Fehler gemeldet werden kann, damit das Problem behoben werden kann.

Außerdem arbeiten wir an Verbesserungen für Roslyn an den C # - und VB - Compileralgorithmen, um festzustellen, wann Ortsansässige als "kurzlebig" eingestuft werden können bestimmte Position auf dem Stapel für die Dauer der Aktivierung. Wir glauben, dass der JITter in der Lage sein wird, Register besser zuzuordnen, und was auch immer, wenn wir ihm bessere Hinweise geben, wann Einheimische früher "tot" gemacht werden können.

Vielen Dank, dass Sie uns darauf aufmerksam gemacht haben und entschuldigen sich für das seltsame Verhalten.

1007
Eric Lippert

Nun, die Art und Weise, wie Sie die Dinge planen, sieht für mich ziemlich böse aus. Es wäre viel sinnvoller, einfach die gesamte Schleife 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 winzigen Zeitabläufen, Gleitkomma-Arithmetik und akkumulierten Fehlern nicht ausgeliefert.

Prüfen Sie nach dieser Änderung, ob die "non-catch" -Version noch langsamer ist als die "catch" -Version.

EDIT: Okay, ich habe es selbst versucht - und ich sehe das gleiche Ergebnis. Sehr komisch. Ich habe mich gefragt, ob der Versuch/Fang schlechtes Inlining deaktiviert hat, aber die Verwendung von [MethodImpl(MethodImplOptions.NoInlining)] hat nicht geholfen ...

Grundsätzlich muss man sich den optimierten JITted-Code unter cordbg ansehen, vermute ich ...

EDIT: Noch ein paar Informationen:

  • Wenn Sie try/catch nur um die n++; -Zeile setzen, wird die Leistung immer noch verbessert, jedoch nicht so stark wie um den gesamten Block
  • Wenn Sie eine bestimmte Ausnahme feststellen (ArgumentException in meinen Tests), ist dies immer noch schnell
  • Wenn Sie die Ausnahme im catch-Block ausgeben, 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 dieser wieder langsam
  • Wenn Sie einen finally-Block sowie einen catch-Block verwenden, ist dies schnell

Seltsam...

EDIT: Okay, wir haben Demontage ...

Hierbei werden der C # 2-Compiler und die .NET 2-CLR (32-Bit) verwendet, die mit mdbg zerlegt werden (da ich kein cordbg auf meinem Computer habe). Ich sehe immer noch die gleichen Leistungseffekte, auch unter dem Debugger. Die schnelle Version verwendet einen try Block um alles zwischen den Variablendeklarationen und der return-Anweisung, mit nur einem catch{} Handler. Offensichtlich ist die langsame Version die gleiche, außer ohne den Versuch/Fang. Der aufrufende Code (d. H. Main) ist in beiden Fällen derselbe und hat die gleiche Assembly-Darstellung (es handelt sich also nicht um ein Inlining-Problem).

Disassemblierter 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

Disassemblierter 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 zeigt der *, wo der Debugger in einem einfachen "Einstieg" eingetreten ist.

EDIT: Okay, ich habe jetzt den Code durchgesehen und denke, ich kann sehen, wie jede Version funktioniert ... und ich glaube, die langsamere Version ist langsamer, weil sie weniger Register und mehr Stapelspeicher benötigt. Bei kleinen Werten von n ist dies möglicherweise schneller - aber wenn die Schleife die meiste Zeit in Anspruch nimmt, ist sie langsamer.

Möglicherweise wird der try/catch-Block erzwingt mehr Register gespeichert und wiederhergestellt, sodass die JIT diese auch für die Schleife verwendet ... was die Leistung insgesamt verbessert. Es ist nicht klar, ob es eine vernünftige Entscheidung für die JIT ist, nicht so viele Register im "normalen" Code zu verwenden.

EDIT: Habe es gerade auf meinem x64-Rechner versucht. Die x64-CLR ist viel schneller (ungefähr 3-4 mal schneller) als die x86-CLR in diesem Code, und unter x64 macht der Try/Catch-Block keinen merklichen Unterschied.

722
Jon Skeet

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

Der JIT-Compiler geht in Bezug auf die Verwendung von Registern für Code, der einen Try-Catch-Block enthält, von anderen Annahmen aus als für Code, der keinen enthält. Dies veranlasst es, unterschiedliche Registerzuweisungswahlen zu treffen. In diesem Fall wird der Code mit dem Try-Catch-Block bevorzugt. Ein anderer Code kann zu dem gegenteiligen Effekt führen, daher würde ich dies nicht als allgemeine Beschleunigungstechnik betrachten.

Am Ende ist es sehr schwer zu sagen, welcher Code am schnellsten ausgeführt wird. So etwas wie die Registerzuweisung und die Faktoren, die sie beeinflussen, sind so einfache Implementierungsdetails, dass ich nicht sehe, wie eine bestimmte Technik zuverlässig schnelleren Code erzeugen könnte.

Betrachten Sie beispielsweise die folgenden beiden Methoden. Sie wurden von einem realen Beispiel angepasst:

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

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

113
Jeffrey Sax

Dies sieht aus wie ein Fall von schlechtem Inlining. Auf einem x86-Core verfügt der Jitter über die Register ebx, edx, esi und edi, mit denen lokale Variablen für allgemeine Zwecke gespeichert werden können. Das ecx-Register wird in einer statischen Methode verfügbar, es muss nicht this gespeichert werden. Das eax-Register wird häufig für Berechnungen benötigt. Dies sind jedoch 32-Bit-Register. Für Variablen vom Typ long muss ein Registerpaar verwendet werden. Das sind edx: eax für Berechnungen und edi: ebx für die Speicherung.

Was bei der Demontage für die langsame Version auffällt, sind weder edi noch ebx.

Wenn der Jitter nicht genügend Register findet, um lokale Variablen zu speichern, muss er Code generieren, um sie aus dem Stapelrahmen zu laden und zu speichern. Dies verlangsamt den Code und verhindert eine Prozessoroptimierung mit dem Namen "register renaming" (Umbenennen des Registers), einen internen Trick zur Optimierung des Prozessorkerns, der mehrere Kopien eines Registers verwendet und eine superskalare Ausführung ermöglicht. Dadurch können mehrere Befehle gleichzeitig ausgeführt werden, auch wenn sie dasselbe Register verwenden. Nicht genügend Register sind ein häufiges Problem bei x86-Kernen, die in x64 mit 8 zusätzlichen Registern (r9 bis r15) angesprochen werden.

Der Jitter wird sein Bestes tun, um eine weitere Optimierung der Codegenerierung anzuwenden. Er wird versuchen, Ihre Fibo () -Methode zu integrieren. Mit anderen Worten, rufen Sie die Methode nicht auf, sondern generieren Sie den Code für die Methode inline in der Main () -Methode. Ziemlich wichtige Optimierung, die Eigenschaften einer C # -Klasse kostenlos macht und ihnen die Perfektion eines Feldes verleiht. Es vermeidet den Aufwand für das Ausführen des Methodenaufrufs und das Einrichten des Stack-Frames und spart ein paar Nanosekunden.

Es gibt mehrere Regeln, die genau festlegen, wann eine Methode eingebunden werden kann. Sie sind nicht genau dokumentiert, wurden aber in Blog-Posts erwähnt. Eine Regel ist, dass es nicht passieren wird, wenn der Methodenkörper zu groß ist. Dadurch wird der Gewinn durch Inlining zunichte gemacht und es wird zu viel Code generiert, der nicht so gut in den L1-Anweisungscache passt. Eine andere harte Regel, die hier gilt, ist, dass eine Methode nicht eingebunden wird, wenn sie eine try/catch-Anweisung enthält. Hintergrund ist ein Implementierungsdetail von Ausnahmen, die auf die in Windows integrierte Unterstützung für SEH (Structure Exception Handling) zurückgreifen, die auf Stack-Frames basiert.

Ein Verhalten des Registerzuweisungsalgorithmus im Jitter kann aus dem Spielen mit diesem Code abgeleitet werden. Es scheint bekannt zu sein, wann der Jitter versucht, eine Methode zu integrieren. Eine Regel scheint zu verwenden, dass nur das Registerpaar edx: eax für Inline-Code verwendet werden kann, der lokale Variablen vom Typ long hat. Aber nicht edi: ebx. Kein Zweifel, da dies zu schädlich für die Codegenerierung für die aufrufende Methode wäre, sind sowohl edi als auch ebx wichtige Speicherregister.

Sie erhalten also die schnelle Version, da der Jitter von vornherein weiß, dass der Methodenkörper try/catch-Anweisungen enthält. Es weiß, dass es niemals inline sein kann. Daher wird edi: ebx für die Speicherung der langen Variablen verwendet. Sie haben die langsame Version, weil der Jitter nicht wusste, dass Inlining nicht funktionieren würde. Es wurde erst herausgefunden, dass after den Code für den Methodenkörper generiert.

Der Fehler ist dann, dass es nicht zurückging und neu generieren den Code für die Methode. Das ist verständlich, wenn man bedenkt, in welcher Zeit es betrieben werden muss.

Diese Verlangsamung tritt bei x64 nicht auf, da es für eines 8 weitere Register gibt. Zum anderen, weil es einen Long in nur einem Register speichern kann (wie rax). Und die Verlangsamung tritt nicht auf, wenn Sie int statt long verwenden, da der Jitter viel flexibler bei der Auswahl von Registern ist.

69
Hans Passant

Ich hätte dies als Kommentar hinzugefügt, da ich nicht sicher bin, ob dies der Fall sein wird, aber, wie ich mich erinnere, beinhaltet eine try/except-Anweisung keine Änderung der Art und Weise, wie der Müllentsorgungsmechanismus von Der Compiler arbeitet dahingehend, dass er die Objektspeicherzuordnungen rekursiv vom Stapel löscht. In diesem Fall ist möglicherweise kein zu bereinigendes Objekt vorhanden, oder die for-Schleife stellt möglicherweise einen Abschluss dar, den der Speicherbereinigungsmechanismus als ausreichend erkennt, um eine andere Erfassungsmethode durchzusetzen. Wahrscheinlich nicht, aber ich fand es erwähnenswert, da ich es nirgendwo anders besprochen hatte.

20