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 int
Typen innerhalb der Fibo-Methode anstelle von long
Typen 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.
Antworten:
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.
quelle
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:
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:
n++;
Linie legen, wird die Leistung immer noch verbessert, jedoch nicht so sehr wie um den gesamten BlockArgumentException
in meinen Tests) feststellen, ist diese immer noch schnellSeltsam...
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
try
Block um alles zwischen den Variablendeklarationen und der return-Anweisung mit nur einemcatch{}
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:
Zerlegter Code für langsame Version:
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
n
das 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.
quelle
esi,edi
für einen der Longs anstelle des Stacks verwendet werden. Es wirdebx
als Zähler verwendet, wo die langsame Version verwendetesi
.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:
Eine ist eine generische Version der anderen. Das Ersetzen des generischen Typs durch
StructArray
würde die Methoden identisch machen. DaStructArray
es 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.quelle
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.
quelle
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.
quelle