Versuchen Sie, meinen Code zu beschleunigen?

1503

Ich habe Code geschrieben, um die Auswirkungen von Try-Catch zu testen, aber 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 wird dadurch konstant ein Wert um 0,96 ausgegeben.

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

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

EDIT: Jon Skeets exzellente Analyse zeigt, dass Try-Catch 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 die x64-CLR diesen Unterschied nicht aufweist und dass sie schneller als die x86-CLR ist. Ich habe auch intTypen innerhalb der Fibo-Methode anstelle von longTypen getestet , und dann war die x86-CLR genauso schnell wie die x64-CLR.


UPDATE: Es sieht so aus, als ob dieses Problem von Roslyn behoben wurde. Gleicher Computer, gleiche CLR-Version - das Problem bleibt beim Kompilieren mit VS 2013 wie oben, aber das Problem verschwindet beim Kompilieren mit VS 2015.

Eren Ersönmez
quelle
111
@Lloyd er versucht eine Antwort auf seine Frage zu bekommen "es läuft tatsächlich schneller! Aber warum?"
Andreas Niedermair
137
Also, jetzt ging "Swallowing Exceptions" von einer schlechten Praxis zu einer guten Leistungsoptimierung über: P
Luciano
2
Befindet sich dies in einem ungeprüften oder geprüften arithmetischen Kontext?
Random832
7
@ taras.roshko: Obwohl ich Eric keinen schlechten Dienst erweisen möchte, ist dies keine C # -Frage, sondern eine JIT-Compiler-Frage. Die ultimative Schwierigkeit besteht darin, herauszufinden, warum die x86-JIT ohne try / catch nicht so viele Register verwendet wie mit dem try / catch-Block.
Jon Skeet
63
Süß, wenn wir diese Versuche fangen, können wir sogar noch schneller gehen, oder?
Chuck Pinkert

Antworten:

1053

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

Aus irgendeinem Grund, der für uns alle unklar ist, wird der problematische Codegenerierungspfad vermieden, wenn der JITter weiß, dass sich der Block in einer versuchsgeschützten Region befindet.

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

Außerdem arbeiten wir an Verbesserungen für Roslyn an den Algorithmen der C # - und VB-Compiler, um zu bestimmen, wann Einheimische "kurzlebig" gemacht werden können - das heißt, sie werden nur auf den Stapel verschoben und dort abgelegt, anstatt eine bestimmte Position auf dem Stapel zuzuweisen 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, wann Einheimische früher "tot" gemacht werden können.

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

Eric Lippert
quelle
8
Ich habe mich immer gefragt, warum der C # -Compiler so viele fremde Einheimische generiert. Beispielsweise generieren neue Array-Initialisierungsausdrücke immer ein lokales, sind jedoch niemals erforderlich, um ein lokales zu generieren. Wenn es dem JITter ermöglicht, messbar leistungsfähigeren Code zu erzeugen, sollte der C # -Compiler möglicherweise etwas vorsichtiger sein, um unnötige Einheimische zu generieren ...
Timwi,
33
@ Timwi: Auf jeden Fall. In nicht optimiertem Code erzeugt der Compiler unnötige Einheimische mit großer Hingabe, da sie das Debuggen erleichtern. In optimiertem Code sollten unnötige Provisorien nach Möglichkeit entfernt werden. Leider hatten wir im Laufe der Jahre viele Fehler, bei denen wir den Optimierer für die temporäre Eliminierung versehentlich deoptimiert haben. Der oben genannte Ingenieur macht den gesamten Code für Roslyn von Grund auf neu, und wir sollten daher das optimierte Verhalten im Roslyn-Codegenerator erheblich verbessert haben.
Eric Lippert
24
Gab es jemals eine Bewegung zu diesem Thema?
Robert Harvey
10
Es sieht so aus, als hätte Roslyn das Problem behoben.
Eren Ersönmez
56
Sie haben Ihre Gelegenheit verpasst, es als "JITter-Fehler" zu bezeichnen.
mbomb007
734

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

Nachdem Sie diese Änderung vorgenommen haben, prüfen Sie, ob die "non-catch" -Version noch langsamer als die "catch" -Version ist.

EDIT: Okay, ich habe es selbst versucht - und ich sehe das gleiche Ergebnis. Sehr komisch. Ich fragte mich, ob der Versuch / Fang ein schlechtes Inlining deaktivierte, aber [MethodImpl(MethodImplOptions.NoInlining)]stattdessen half es nicht ...

Grundsätzlich müssen Sie sich den optimierten JITted-Code unter cordbg ansehen, vermute ich ...

EDIT: Noch ein paar Informationen:

  • Wenn Sie den Versuch / Fang nur um die n++;Linie legen, wird die Leistung immer noch verbessert, jedoch nicht so sehr wie um den gesamten Block
  • Wenn Sie eine bestimmte Ausnahme ( ArgumentExceptionin meinen Tests) feststellen, ist diese 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 dieser wieder langsam
  • Wenn Sie sowohl einen finally-Block als auch einen catch-Block verwenden, ist dies schnell

Seltsam...

EDIT: Okay, wir haben Demontage ...

Dies verwendet den C # 2-Compiler und .NET 2 (32-Bit) CLR, 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 tryBlock um alles zwischen den Variablendeklarationen und der return-Anweisung mit nur einem catch{}Handler. Offensichtlich ist die langsame Version dieselbe, außer ohne try / catch. Der aufrufende Code (dh Main) ist in beiden Fällen derselbe und hat dieselbe Assembly-Darstellung (es handelt sich also nicht um ein Inlining-Problem).

Zerlegter Code für 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

Zerlegter 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 das , 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 Stapelspeicher verwendet. Für kleine Werte ist ndas möglicherweise schneller - aber wenn die Schleife den größten Teil der Zeit in Anspruch nimmt, ist sie langsamer.

Möglicherweise erzwingt der Try / Catch-Block , dass mehr Register gespeichert und wiederhergestellt werden, 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: Hab das gerade auf meinem x64 Rechner ausprobiert. 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.

Jon Skeet
quelle
4
@GordonSimpson, aber in dem Fall, in dem nur eine bestimmte Ausnahme abgefangen wird, werden alle anderen Ausnahmen nicht abgefangen, sodass der in Ihrer Hypothese für No-Try enthaltene Overhead weiterhin erforderlich ist.
Jon Hanna
45
Es sieht nach einem Unterschied in der Registerzuordnung aus. Die schnelle Version kann esi,edifür einen der Longs anstelle des Stacks verwendet werden. Es wird ebxals Zähler verwendet, wo die langsame Version verwendet esi.
Jeffrey Sax
13
@ JeffreySax: Es ist nicht nur, welche Register verwendet werden, sondern wie viele. Die langsame Version benötigt mehr Stapelspeicher und berührt weniger Register. Ich habe keine Ahnung warum ...
Jon Skeet
2
Wie werden CLR-Ausnahmerahmen in Bezug auf Register und Stapel behandelt? Könnte das Einrichten eines Registers ein Register für die Verwendung freigegeben haben?
Random832
4
IIRC x64 verfügt über mehr Register als x86. Die Beschleunigung, die Sie gesehen haben, würde mit dem Versuch / Fang übereinstimmen, der eine zusätzliche Registernutzung unter x86 erzwingt.
Dan spielt
116

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

Der JIT-Compiler nimmt unterschiedliche Annahmen bezüglich der Registernutzung für Code vor, der einen Try-Catch-Block enthält, im Vergleich zu Code, der dies nicht tut. Dies führt dazu, dass unterschiedliche Registerzuordnungsoptionen getroffen werden. In diesem Fall wird der Code mit dem Try-Catch-Block bevorzugt. Unterschiedlicher 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 Registerzuordnung und die Faktoren, die sie beeinflussen, sind so einfache Implementierungsdetails, dass ich nicht sehe, wie eine bestimmte Technik zuverlässig schnelleren Code erzeugen kann.

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

Eine ist eine generische Version der anderen. Das Ersetzen des generischen Typs durch StructArraywürde die Methoden identisch machen. Da StructArrayes sich um einen Wertetyp handelt, erhält er eine eigene kompilierte Version der generischen Methode. Die tatsächliche Laufzeit ist jedoch erheblich länger als bei der Spezialmethode, 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.

Jeffrey Sax
quelle
6
Wenn dies gesagt ist ... können Sie verschiedene Registerzuweisungsoptionen erzwingen, ohne Try / Catch zu verwenden? Entweder als Test für diese Hypothese oder als allgemeiner Versuch, die Geschwindigkeit zu optimieren?
WernerCD
1
Es gibt eine Reihe von Gründen, warum dieser spezielle Fall anders sein kann. Vielleicht ist es der Versuch. Vielleicht liegt es daran, dass die Variablen in einem inneren Bereich wiederverwendet werden. Was auch immer der spezifische Grund ist, es ist ein Implementierungsdetail, auf das Sie nicht zählen können, um erhalten zu bleiben, selbst wenn genau derselbe Code in einem anderen Programm aufgerufen wird.
Jeffrey Sax
4
@WernerCD Ich würde sagen, dass die Tatsache, dass C und C ++ ein Schlüsselwort haben, um darauf hinzuweisen, dass (A) von vielen modernen Compilern ignoriert wird und (B) entschieden wurde, C # nicht einzufügen, darauf hindeutet, dass dies nicht etwas ist, was wir ' Ich werde direkter sehen.
Jon Hanna
2
@WernerCD - Nur wenn Sie die Baugruppe selbst schreiben
OrangeDog
72

Dies sieht aus wie ein Fall von Inlining, das schlecht geworden ist. Auf einem x86-Kern stehen dem Jitter die Register ebx, edx, esi und edi zur allgemeinen Speicherung lokaler Variablen zur Verfügung. Das ECX - Register wird in einer statischen Methode, es muss nicht speichert diese . 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. Welches sind edx: eax für Berechnungen und edi: ebx für die Speicherung.

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

Wenn der Jitter nicht genügend Register zum Speichern lokaler Variablen finden kann, muss er Code generieren, um sie aus dem Stapelrahmen zu laden und zu speichern. Dies verlangsamt den Code und verhindert eine Prozessoroptimierung namens "Register Renaming", einen internen Trick zur Optimierung des Prozessorkerns, der mehrere Kopien eines Registers verwendet und eine superskalare Ausführung ermöglicht. Dadurch können mehrere Anweisungen gleichzeitig ausgeführt werden, selbst wenn sie dasselbe Register verwenden. Nicht genügend Register zu haben, ist ein häufiges Problem bei x86-Kernen, die in x64 mit 8 zusätzlichen Registern (r9 bis r15) behoben 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 zum einen die Eigenschaften einer C # -Klasse kostenlos macht und ihnen die Perfektion eines Feldes verleiht. Es vermeidet den Aufwand für den Methodenaufruf und das Einrichten des Stapelrahmens und spart einige Nanosekunden.

Es gibt mehrere Regeln, die genau bestimmen, wann eine Methode eingefügt 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. Der Hintergrund dahinter ist ein Implementierungsdetail von Ausnahmen, die auf die integrierte Unterstützung von Windows für SEH (Structure Exception Handling) zurückgreifen, das auf Stack-Frames basiert.

Ein Verhalten des Registerzuordnungsalgorithmus 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 enthält. Aber nicht edi: ebx. Kein Zweifel, da dies für die Codegenerierung für die aufrufende Methode zu schädlich wäre, sind sowohl edi als auch ebx wichtige Speicherregister.

Sie erhalten also die schnelle Version, da der Jitter im Voraus weiß, dass der Methodenkörper try / catch-Anweisungen enthält. Es weiß, dass es niemals so leicht inline geschrieben werden kann, dass edi: ebx für die Speicherung der langen Variablen verwendet wird. Sie haben die langsame Version erhalten, weil der Jitter von vornherein nicht wusste, dass Inlining nicht funktionieren würde. Dies wurde erst nach dem Generieren des Codes für den Methodenkörper herausgefunden.

Der Fehler ist dann, dass es nicht zurückgegangen ist und den Code für die Methode neu generiert hat . Was angesichts der Zeitbeschränkungen, in denen es arbeiten muss, verständlich ist.

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

Hans Passant
quelle
21

Ich hätte dies als Kommentar eingefügt, da ich wirklich nicht sicher bin, ob dies wahrscheinlich der Fall ist, aber wenn ich mich recht erinnere, handelt es sich bei einer try / Except-Anweisung nicht um eine Änderung der Art und Weise, wie der Müllentsorgungsmechanismus von Der Compiler arbeitet, indem er die Objektspeicherzuordnungen rekursiv vom Stapel entfernt. In diesem Fall muss möglicherweise kein Objekt gelöscht werden, 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.

Müller der Gorilla
quelle