Ich habe versucht, den Unterschied zwischen a for
und a foreach
beim Zugriff auf Listen mit Werttypen und Referenztypen zu messen .
Ich habe die folgende Klasse verwendet, um die Profilerstellung durchzuführen.
public static class Benchmarker
{
public static void Profile(string description, int iterations, Action func)
{
Console.Write(description);
// Warm up
func();
Stopwatch watch = new Stopwatch();
// Clean up
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
watch.Start();
for (int i = 0; i < iterations; i++)
{
func();
}
watch.Stop();
Console.WriteLine(" average time: {0} ms", watch.Elapsed.TotalMilliseconds / iterations);
}
}
Ich habe double
für meinen Werttyp verwendet. Und ich habe diese 'gefälschte Klasse' erstellt, um Referenztypen zu testen:
class DoubleWrapper
{
public double Value { get; set; }
public DoubleWrapper(double value)
{
Value = value;
}
}
Schließlich habe ich diesen Code ausgeführt und die Zeitunterschiede verglichen.
static void Main(string[] args)
{
int size = 1000000;
int iterationCount = 100;
var valueList = new List<double>(size);
for (int i = 0; i < size; i++)
valueList.Add(i);
var refList = new List<DoubleWrapper>(size);
for (int i = 0; i < size; i++)
refList.Add(new DoubleWrapper(i));
double dummy;
Benchmarker.Profile("valueList for: ", iterationCount, () =>
{
double result = 0;
for (int i = 0; i < valueList.Count; i++)
{
unchecked
{
var temp = valueList[i];
result *= temp;
result += temp;
result /= temp;
result -= temp;
}
}
dummy = result;
});
Benchmarker.Profile("valueList foreach: ", iterationCount, () =>
{
double result = 0;
foreach (var v in valueList)
{
var temp = v;
result *= temp;
result += temp;
result /= temp;
result -= temp;
}
dummy = result;
});
Benchmarker.Profile("refList for: ", iterationCount, () =>
{
double result = 0;
for (int i = 0; i < refList.Count; i++)
{
unchecked
{
var temp = refList[i].Value;
result *= temp;
result += temp;
result /= temp;
result -= temp;
}
}
dummy = result;
});
Benchmarker.Profile("refList foreach: ", iterationCount, () =>
{
double result = 0;
foreach (var v in refList)
{
unchecked
{
var temp = v.Value;
result *= temp;
result += temp;
result /= temp;
result -= temp;
}
}
dummy = result;
});
SafeExit();
}
Ich habe Release
und Any CPU
Optionen ausgewählt , das Programm ausgeführt und die folgenden Zeiten erhalten:
valueList for: average time: 483,967938 ms
valueList foreach: average time: 477,873079 ms
refList for: average time: 490,524197 ms
refList foreach: average time: 485,659557 ms
Done!
Dann habe ich die Optionen Release und x64 ausgewählt, das Programm ausgeführt und die folgenden Zeiten erhalten:
valueList for: average time: 16,720209 ms
valueList foreach: average time: 15,953483 ms
refList for: average time: 19,381077 ms
refList foreach: average time: 18,636781 ms
Done!
Warum ist die x64-Bit-Version so viel schneller? Ich habe einen Unterschied erwartet, aber nichts so Großes.
Ich habe keinen Zugriff auf andere Computer. Könnten Sie dies bitte auf Ihren Maschinen ausführen und mir die Ergebnisse mitteilen? Ich verwende Visual Studio 2015 und habe einen Intel Core i7 930.
Hier ist die SafeExit()
Methode, damit Sie selbst kompilieren / ausführen können:
private static void SafeExit()
{
Console.WriteLine("Done!");
Console.ReadLine();
System.Environment.Exit(1);
}
Wie gewünscht, double?
anstelle von DoubleWrapper
:
Beliebige CPU
valueList for: average time: 482,98116 ms
valueList foreach: average time: 478,837701 ms
refList for: average time: 491,075915 ms
refList foreach: average time: 483,206072 ms
Done!
x64
valueList for: average time: 16,393947 ms
valueList foreach: average time: 15,87007 ms
refList for: average time: 18,267736 ms
refList foreach: average time: 16,496038 ms
Done!
Last but not least: Wenn x86
ich ein Profil erstelle, bekomme ich fast die gleichen Ergebnisse bei der VerwendungAny CPU
.
quelle
double
zufloat
,long
oderint
und Sie ähnliche Ergebnisse erhalten.Antworten:
Ich kann dies am 4.5.2 reproduzieren. Kein RyuJIT hier. Sowohl x86- als auch x64-Demontagen sehen vernünftig aus. Bereichsprüfungen usw. sind gleich. Die gleiche Grundstruktur. Kein Abrollen der Schleife.
x86 verwendet einen anderen Satz von Float-Anweisungen. Die Leistung dieser Anweisungen scheint mit Ausnahme der Unterteilung mit den x64-Anweisungen vergleichbar zu sein :
Die Teilungsoperation macht die 32-Bit-Version extrem langsam. Wenn Sie die Division nicht kommentieren, wird die Leistung in hohem Maße ausgeglichen (32 Bit von 430 ms auf 3,25 ms).
Peter Cordes weist darauf hin, dass die Befehlslatenzen der beiden Gleitkommaeinheiten nicht so unterschiedlich sind. Möglicherweise sind einige der Zwischenergebnisse denormalisierte Zahlen oder NaN. Diese können einen langsamen Pfad in einer der Einheiten auslösen. Oder möglicherweise weichen die Werte zwischen den beiden Implementierungen aufgrund der Float-Genauigkeit von 10 Byte gegenüber 8 Byte ab.
Peter Cordes weist auch darauf hin, dass alle Zwischenergebnisse NaN sind ... Das Entfernen dieses Problems (
valueList.Add(i + 1)
so dass kein Divisor Null ist) gleicht die Ergebnisse größtenteils aus. Anscheinend mag der 32-Bit-Code überhaupt keine NaN-Operanden. Drucken wir einige Zwischenwerte aus :if (i % 1000 == 0) Console.WriteLine(result);
. Dies bestätigt, dass die Daten jetzt gesund sind.Beim Benchmarking müssen Sie eine realistische Arbeitsbelastung messen. Aber wer hätte gedacht, dass eine unschuldige Spaltung Ihren Benchmark durcheinander bringen kann?!
Summieren Sie einfach die Zahlen, um einen besseren Benchmark zu erhalten.
Division und Modulo sind immer sehr langsam. Wenn Sie den BCL-
Dictionary
Code so ändern, dass der Modulo-Operator nicht zur Berechnung der messbaren Leistung des Bucket-Index verwendet wird, verbessert sich die Leistung. So langsam ist die Teilung.Hier ist der 32-Bit-Code:
64-Bit-Code (gleiche Struktur, schnelle Teilung):
Dies wird trotz Verwendung von SSE-Anweisungen nicht vektorisiert.
quelle
fdiv
hat eine Latenz von 7 bis 27 Zyklen (und denselben wechselseitigen Durchsatz).divsd
beträgt 7-22 Zyklen.addsd
bei 3c Latenz 1 / c Durchsatz. Division ist die einzige Nicht-Pipeline-Ausführungseinheit in Intel / AMD-CPUs. C # JIT vektorisiert die Schleife für x86-64 (mitdivPd
) nicht.MXCSR
) getrennt. Ein anderer Umgang mit Denormalen oderNaN
s könnte meiner Meinung nach den Faktor 26 perf diff erklären. C # kann im MXCSR Denormals-are-Zero setzen.valueList[i] = i
, beginnend miti=0
, also die erste Schleifeniteration tut0.0 / 0.0
. Jede Operation in Ihrem gesamten Benchmark wird also mitNaN
s ausgeführt. Diese Abteilung sieht immer weniger unschuldig aus! Ich bin kein Experte für Leistung mitNaN
s oder den Unterschied zwischen x87 und SSE, aber ich denke, dies erklärt den 26-fachen Perf-Unterschied. Ich wette, Ihre Ergebnisse werden zwischen 32 und 64 Bit viel näher liegen, wenn Sie initialisierenvalueList[i] = i+1
.double
wäre ziemlich selten, einen Wert zu erhalten, der als 64-Bit darstellbar wäre. Eines der Hauptverwendungsmuster für den 80-Bit-Typ bestand darin, die Summierung mehrerer Zahlen zu ermöglichen, ohne die Ergebnisse bis zum Ende eng runden zu müssen. Unter diesem Muster sind Überläufe einfach kein Problem.valueList[i] = i
Ausgehend voni=0
der ersten Schleifeniteration0.0 / 0.0
. Jede Operation in Ihrem gesamten Benchmark wird also mitNaN
s ausgeführt.Wie @usr in der Demontage-Ausgabe zeigte , verwendete die 32-Bit-Version x87-Gleitkomma, während 64-Bit-SSE-Gleitkomma verwendete.
Ich bin kein Experte für Leistung mit
NaN
s oder den Unterschied zwischen x87 und SSE, aber ich denke, dies erklärt den 26-fachen Perf-Unterschied. Ich wette, Ihre Ergebnisse werden zwischen 32 und 64 Bit viel näher liegen, wenn Sie initialisierenvalueList[i] = i+1
. (Update: usr hat bestätigt, dass dies die Leistung von 32 und 64 Bit ziemlich nahe gebracht hat.)Die Teilung ist im Vergleich zu anderen Operationen sehr langsam. Siehe meine Kommentare zur Antwort von @ usr. Unter http://agner.org/optimize/ finden Sie auch eine Menge großartiger Informationen zur Hardware und zur Optimierung von ASM und C / C ++, von denen einige für C # relevant sind. Er verfügt über Anweisungstabellen für Latenz und Durchsatz für die meisten Anweisungen für alle neueren x86-CPUs.
10B x87
fdiv
ist jedochdivsd
für normale Werte nicht viel langsamer als die doppelte 8B-Genauigkeit von SSE2 . IDK über Leistungsunterschiede mit NaNs, Unendlichkeiten oder Denormalen.Sie haben jedoch unterschiedliche Steuerelemente für das, was mit NaNs und anderen FPU-Ausnahmen geschieht. Das x87-FPU- Steuerwort ist vom SSE-Rundungs- / Ausnahmesteuerregister (MXCSR) getrennt. Wenn x87 für jede Abteilung eine CPU-Ausnahme erhält, SSE jedoch nicht, erklärt dies leicht den Faktor 26. Oder es gibt nur einen so großen Leistungsunterschied beim Umgang mit NaNs. Die Hardware ist nicht für das Durchlaufen
NaN
nachher optimiertNaN
.IDK, wenn die SSE-Kontrollen zur Vermeidung von Verlangsamungen mit Denormalen hier ins Spiel kommen, da ich glaube,
result
dass dies dieNaN
ganze Zeit sein wird. IDK, wenn C # im MXCSR das Denormals-are-zero-Flag oder das Flush-to-Zero-Flag setzt (das zuerst Nullen schreibt, anstatt Denormals beim Zurücklesen als Null zu behandeln).Ich habe einen Intel-Artikel über SSE-Gleitkomma-Steuerelemente gefunden, der dem x87-FPU-Steuerwort gegenübersteht. Es gibt jedoch nicht viel zu sagen
NaN
. Es endet damit:IDK, wenn dies bei der Division durch Null hilft.
für vs. foreach
Es könnte interessant sein, einen durch den Durchsatz begrenzten Schleifenkörper zu testen, anstatt nur eine einzige von Schleifen getragene Abhängigkeitskette zu sein. So wie es ist, hängt die gesamte Arbeit von früheren Ergebnissen ab; Die CPU kann nichts parallel tun (außer Grenzen - überprüfen Sie die nächste Array-Last, während die Mul / Div-Kette ausgeführt wird).
Möglicherweise sehen Sie einen größeren Unterschied zwischen den Methoden, wenn die "echte Arbeit" mehr Ausführungsressourcen der CPUs belegt. Außerdem gibt es bei Intel vor Sandybridge einen großen Unterschied zwischen einer Schleifenanpassung im 28uop-Schleifenpuffer oder nicht. Wenn nicht, erhalten Sie Engpässe beim Dekodieren von Anweisungen. wenn die durchschnittliche Befehlslänge länger ist (was bei SSE der Fall ist). Anweisungen, die auf mehr als ein UOP dekodieren, begrenzen auch den Decoderdurchsatz, es sei denn, sie haben ein für die Decoder geeignetes Muster (z. B. 2-1-1). Eine Schleife mit mehr Anweisungen zum Schleifen-Overhead kann also den Unterschied zwischen einer Schleifenanpassung im UOP-Cache mit 28 Einträgen oder nicht ausmachen, was für Nehalem eine große Sache ist und manchmal für Sandybridge und später hilfreich ist.
quelle
NaN
s in der Praxis wirklich selten sind? Ich habe all das Zeug über Denormals und den Link zu Intels Zeug hinterlassen, hauptsächlich zum Nutzen der Leser, nicht weil ich dachte, dass es wirklich viel Einfluss auf diesen speziellen Fall haben würde.Wir haben die Beobachtung, dass 99,9% aller Gleitkommaoperationen NaNs betreffen, was zumindest sehr ungewöhnlich ist (zuerst von Peter Cordes gefunden). Wir haben ein weiteres Experiment von usr, bei dem festgestellt wurde, dass durch Entfernen der Teilungsanweisungen der Zeitunterschied fast vollständig verschwindet.
Tatsache ist jedoch, dass die NaNs nur erzeugt werden, weil die allererste Division 0,0 / 0,0 berechnet, was das anfängliche NaN ergibt. Wenn die Teilungen nicht durchgeführt werden, ist das Ergebnis immer 0.0 und wir berechnen immer 0.0 * temp -> 0.0, 0.0 + temp -> temp, temp - temp = 0.0. Das Entfernen der Teilung entfernte also nicht nur die Teilungen, sondern auch die NaNs. Ich würde erwarten, dass die NaNs tatsächlich das Problem sind und dass eine Implementierung die NaNs sehr langsam handhabt, während die andere das Problem nicht hat.
Es lohnt sich, die Schleife bei i = 1 zu starten und erneut zu messen. Die vier Operationen ergeben * temp, + temp, / temp, - temp addieren effektiv (1 - temp), sodass wir für die meisten Operationen keine ungewöhnlichen Zahlen (0, unendlich, NaN) haben.
Das einzige Problem könnte sein, dass die Division immer ein ganzzahliges Ergebnis liefert und einige Divisionsimplementierungen Verknüpfungen haben, wenn das richtige Ergebnis nicht viele Bits verwendet. Zum Beispiel ergibt das Teilen von 310.0 / 31.0 10.0 als die ersten vier Bits mit einem Rest von 0.0, und einige Implementierungen können die Auswertung der verbleibenden etwa 50 Bits beenden, während andere dies nicht können. Wenn es einen signifikanten Unterschied gibt, würde das Starten der Schleife mit result = 1.0 / 3.0 einen Unterschied machen.
quelle
Es kann mehrere Gründe geben, warum dies in 64-Bit auf Ihrem Computer schneller ausgeführt wird. Der Grund, warum ich gefragt habe, welche CPU Sie verwenden, war, dass AMD und Intel bei der ersten Veröffentlichung von 64-Bit-CPUs unterschiedliche Mechanismen für den Umgang mit 64-Bit-Code hatten.
Prozessorarchitektur:
Intels CPU-Architektur war rein 64-Bit. Um 32-Bit-Code auszuführen, mussten die 32-Bit-Befehle vor der Ausführung (innerhalb der CPU) in 64-Bit-Befehle konvertiert werden.
Die CPU-Architektur von AMD sollte 64-Bit direkt auf der 32-Bit-Architektur aufbauen. Das heißt, es war im Wesentlichen eine 32-Bit-Architektur mit 64-Bit-Erweiterungen - es gab keinen Codekonvertierungsprozess.
Dies war offensichtlich vor ein paar Jahren, daher habe ich keine Ahnung, ob / wie sich die Technologie geändert hat, aber im Wesentlichen würde man erwarten, dass 64-Bit-Code auf einem 64-Bit-Computer eine bessere Leistung erbringt, da die CPU mit der doppelten Menge an arbeiten kann Bits pro Befehl.
.NET JIT
Es wird argumentiert, dass .NET (und andere verwaltete Sprachen wie Java) Sprachen wie C ++ übertreffen können, da der JIT-Compiler Ihren Code entsprechend Ihrer Prozessorarchitektur optimieren kann. In dieser Hinsicht stellen Sie möglicherweise fest, dass der JIT-Compiler eine 64-Bit-Architektur verwendet, die möglicherweise nicht verfügbar war oder bei der Ausführung in 32-Bit eine Problemumgehung erfordert.
Hinweis:
Haben Sie überlegt, anstelle von DoubleWrapper die
Nullable<double>
Syntax oder die Kurzschrift zu verwenden:double?
- Es würde mich interessieren, ob sich dies auf Ihre Tests auswirkt.Anmerkung 2: Einige Leute scheinen meine Kommentare zur 64-Bit-Architektur mit IA-64 zu verbinden. Zur Verdeutlichung bezieht sich in meiner Antwort 64 Bit auf x86-64 und 32 Bit auf x86-32. Nichts hier verwies auf IA-64!
quelle