Writing Faster Managed Code: Know What Things Cost (Schnelleren verwalteten Code schreiben: Überblick über Kosten behalten)

 

Jan Gray
Microsoft CLR Performance Team

Juni 2003

Gilt für:
   Microsoft® .NET Framework

Zusammenfassung: In diesem Artikel wird ein niedriges Kostenmodell für die Ausführungszeit für verwalteten Code vorgestellt, das auf den gemessenen Betriebszeiten basiert, damit Entwickler besser informierte Codierungsentscheidungen treffen und schneller Code schreiben können. (30 gedruckte Seiten)

Laden Sie den CLR-Profiler herunter. (330 KB)

Inhalte

Einführung (und Versprechen)
Hin zu einem Kostenmodell für verwalteten Code
Kosten für verwalteten Code
Zusammenfassung
Ressourcen

Einführung (und Versprechen)

Es gibt unzählige Möglichkeiten, eine Berechnung zu implementieren, und einige sind viel besser als andere: einfacher, sauberer, einfacher zu verwalten. Einige Wege sind blitzschnell und andere erstaunlich langsam.

Führen Sie keinen langsamen und fetten Code auf der Welt durch. Veranworten Sie diesen Code nicht? Code, der in passt und startet? Code, der die Benutzeroberfläche für Sekunden sperrt? Code, der die CPU angibt oder den Datenträger drosselt?

Machen Sie es nicht. Stehen Sie stattdessen auf und versprechen Sie mit mir:

"Ich verspreche, dass ich keinen langsamen Code liefern werde. Geschwindigkeit ist ein Feature, das mir wichtig ist. Jeden Tag werde ich auf die Leistung meines Codes achten. Ich werde regelmäßig und methodisch seine Geschwindigkeit und Größe messen . Ich lerne, baue oder kaufe die Tools, die ich dazu benötige. Das liegt in meiner Verantwortung."

(Wirklich.) Haben Sie das versprochen? Gut für Sie.

Wie schreiben Sie also täglich den schnellsten, strengsten Code? Es geht darum, bewusst den genügsamen Weg zu wählen, dem extravaganten, aufgeblähten Weg, immer wieder und immer wieder, und eine Frage des Durchdenkens der Konsequenzen. Jede angegebene Codeseite erfasst Dutzende solcher kleinen Entscheidungen.

Aber Sie können keine intelligenten Entscheidungen zwischen Alternativen treffen, wenn Sie nicht wissen, was die Kosten kosten: Sie können keinen effizienten Code schreiben, wenn Sie nicht wissen, was die Dinge kosten.

Es war einfacher in der guten alten Zeit. Gute C-Programmierer wussten es. Jeder Operator und Vorgang in C, sei es Zuweisung, Ganzzahl- oder Gleitkommaberechnung, Dereference oder Funktionsaufruf, wurden mehr oder weniger 1:1 einem einzelnen primitiven Computervorgang zugeordnet. Es stimmt, manchmal waren mehrere Computeranweisungen erforderlich, um die richtigen Operanden in den richtigen Registern zu platzieren, und manchmal konnte eine einzelne Anweisung mehrere C-Vorgänge erfassen (berühmt), *dest++ = *src++;aber Sie konnten normalerweise eine Zeile C-Code schreiben (oder lesen) und wissen, wo die Zeit hingeht. Sowohl für Code als auch für Daten war der C-Compiler WYWIWYG – "Was Sie schreiben, ist das, was Sie erhalten". (Die Ausnahme waren und sind Funktionsaufrufe. Wenn Sie nicht wissen, was die Funktion kostet, wissen Sie nicht, was sie kostet.)

In den 1990er Jahren hat die PC-Softwareindustrie einen Übergang von C zu C++ unternommen, um die vielen Vorteile der Softwareentwicklung und Produktivität der Datenstraktion, der objektorientierten Programmierung und der Wiederverwendung von Code zu nutzen.

C++ ist eine Übermenge von C und ist "pay as you go" ( die neuen Features kosten nichts, wenn Sie sie nicht verwenden -, sodass C-Programmierkenntnisse, einschließlich des internalisierten Kostenmodells, direkt anwendbar sind. Wenn Sie einen funktionierenden C-Code verwenden und ihn für C++ neu kompilieren, sollten sich die Ausführungszeit und der Speicherplatzaufwand nicht wesentlich ändern.

Auf der anderen Seite führt C++ viele neue Sprachfeatures ein, einschließlich Konstruktoren, Destruktoren, neu, löschen, single, mehrfache und virtuelle Vererbung, Umwandlungen, Memberfunktionen, virtuelle Funktionen, überladene Operatoren, Zeiger auf Member, Objektarrays, Ausnahmebehandlung und Kompositionen derselben, die nicht triviale versteckte Kosten verursachen. Beispielsweise kosten virtuelle Funktionen zwei zusätzliche Indirektionen pro Aufruf und fügen jedem instance ein ausgeblendetes Vtable-Zeigerfeld hinzu. Oder denken Sie daran, dass dieser harmlos aussehende Code:

{ complex a, b, c, d; … a = b + c * d; }

kompiliert in ungefähr dreizehn implizite Memberfunktionsaufrufe (hoffentlich inline).

Vor neun Jahren haben wir dieses Thema in meinem Artikel C++: Under the Hood untersucht. Ich habe geschrieben:

"Es ist wichtig zu verstehen, wie Ihre Programmiersprache implementiert wird. Dieses Wissen zerstreut die Angst und das Wunder: "Was macht der Compiler hier?" vermittelt Vertrauen bei der Verwendung der neuen Features; und bietet Einblicke beim Debuggen und Lernen anderer Sprachfeatures. Es gibt auch ein Gefühl für die relativen Kosten der verschiedenen Codierungsoptionen, die erforderlich sind, um täglich den effizientesten Code zu schreiben."

Nun werden wir einen ähnlichen Blick auf verwalteten Code werfen. In diesem Artikel werden die niedrigen Zeit- und Raumkosten der verwalteten Ausführung untersucht, sodass wir bei der täglichen Codierung intelligentere Kompromisse machen können .

Und halten Sie unsere Versprechen.

Warum verwalteter Code?

Für die überwiegende Mehrheit der Entwickler von nativem Code ist verwalteter Code eine bessere und produktivere Plattform für die Ausführung ihrer Software. Es entfernt ganze Kategorien von Fehlern, z. B. Heapbeschädigungen und Array-Index-out-of-bound-Fehler, die so oft zu frustrierenden nächtlichen Debugsitzungen führen. Es unterstützt moderne Anforderungen wie sicheren mobilen Code (über Codezugriffssicherheit) und XML-Webdienste, und im Vergleich zum alternden Win32/COM/ATL/MFC/VB ist die .NET Framework ein erfrischendes sauber Schieferdesign, bei dem Sie mit weniger Aufwand mehr erledigen können.

Für Ihre Benutzercommunity ermöglicht verwalteter Code umfangreichere, robustere Anwendungen – eine bessere Nutzung durch bessere Software.

Was ist das Geheimnis für das Schreiben von schnellerem verwaltetem Code?

Nur weil Sie mit weniger Aufwand mehr erreichen können, ist keine Lizenz, um Ihre Verantwortung für code weise zu übernehmen. Zuerst müssen Sie es sich selbst zugeben: "Ich bin ein Neuling." Sie sind ein Neuling. Ich bin auch ein Neuling. Wir sind alle Babes im Land mit verwaltetem Code. Wir alle lernen noch die Seile – einschließlich der Kosten.

Wenn es um die reichen und bequemen .NET Framework geht, ist es, als ob wir Kinder im Süßwarenladen sind. "Wow, ich muss nicht all das lästige strncpy Zeug machen, ich kann einfach '+' Zeichenfolgen zusammen! Wow, ich kann ein Megabyte XML in ein paar Codezeilen laden! Whoo-hoo!"

Es ist alles so einfach. So einfach, tatsächlich. So einfach, Megabyte an RAM-Analyse-XML-Infoets zu brennen, nur um ein paar Elemente daraus zu ziehen. In C oder C++ war es so schmerzhaft, dass Sie zweimal nachdenken würden, vielleicht würden Sie einen Zustandscomputer auf einer SAX-ähnlichen API erstellen. Mit dem .NET Framework laden Sie einfach das gesamte Infoset in einem Gulp. Vielleicht machen Sie es sogar immer wieder. Dann scheint Ihre Anwendung vielleicht nicht mehr so schnell zu sein. Vielleicht hat es einen Arbeitssatz von vielen Megabytes. Vielleicht sollten Sie zweimal darüber nachdenken, was diese einfachen Methoden kosten...

Leider werden in der aktuellen .NET Framework Dokumentation die Leistungsauswirkungen von Frameworktypen und -methoden meiner Meinung nach nicht ausreichend detailliert beschrieben . Sie gibt nicht einmal an, welche Methoden neue Objekte erstellen könnten. Die Leistungsmodellierung ist kein einfaches Thema, um sie abzudecken oder zu dokumentieren. Aber trotzdem macht es das "Nicht-Wissen" für uns viel schwieriger, fundierte Entscheidungen zu treffen.

Da wir hier alle Neulinge sind, und da wir nicht wissen, was etwas kostet, und da die Kosten nicht klar dokumentiert sind, was sollen wir tun?

Messen Sie sie. Das Geheimnis besteht darin, es zu messen und wachsam zu sein. Wir alle müssen uns angewöhnen, die Kosten der Dinge zu messen. Wenn wir uns der Mühe machen, zu messen, was die Dinge kosten, dann werden wir nicht diejenigen sein, die versehentlich eine neue Methode aufrufen, die zehnmal so viel kostet, wie wir angenommen haben.

(Um übrigens einen tieferen Einblick in die Leistungsuntermauern der BCL (Basisklassenbibliothek) oder der CLR selbst zu erhalten, sollten Sie einen Blick auf die Shared Source-CLI( Rotor) werfen. Rotorcode teilt eine Blutlinie mit dem .NET Framework und der CLR. Es ist nicht derselbe Code, aber trotzdem verspreche ich Ihnen, dass eine nachdenkliche Studie von Rotor Ihnen neue Einblicke in das Geschehen unter der Haube der CLR geben wird. Überprüfen Sie jedoch unbedingt zuerst die SSCLI-Lizenz!)

Das Wissen

Wenn Sie in London Taxifahrer werden möchten, müssen Sie sich zuerst Das Wissen verdienen. Die Studenten lernen viele Monate, um die Tausenden von kleinen Straßen in London auswendig zu lernen und die besten Routen von Ort zu Ort zu lernen. Und sie gehen jeden Tag auf Rollern, um herumzu scouten und ihr Buch lernen zu verstärken.

Wenn Sie ein Entwickler von verwaltetem Code mit hoher Leistung sein möchten, müssen Sie sich die Kenntnisse von Verwaltetem Code aneignen. Sie müssen lernen, was jeder Low-Level-Vorgang kostet. Sie müssen lernen, welche Features wie Delegaten und Codezugriff sicherheitskosten. Sie müssen die Kosten für die Typen und Methoden, die Sie verwenden, und die, die Sie schreiben, kennen lernen. Und es schadet nicht, herauszufinden, welche Methoden für Ihre Anwendung möglicherweise zu teuer sind – und vermeiden Sie sie.

Das Wissen ist leider in keinem Buch enthalten. Sie müssen auf Ihren Roller steigen und erkunden – d. h. csc, ildasm, den VS.NET Debugger, den CLR Profiler, Ihren Profiler, einige Perf-Timer usw. kurbeln und sehen, was Ihr Code in Zeit und Raum kostet.

Hin zu einem Kostenmodell für verwalteten Code

Abgesehen von den Vorabmodellen sehen wir uns ein Kostenmodell für verwalteten Code an. Auf diese Weise können Sie sich eine Blattmethode ansehen und auf einen Blick erkennen, welche Ausdrücke und Anweisungen teurer sind. und Sie können intelligentere Entscheidungen treffen, wenn Sie neuen Code schreiben.

(Dadurch werden die transitiven Kosten für den Aufruf Ihrer Methoden oder Methoden der .NET Framework nicht adresst. Das muss auf einen anderen Artikel an einem anderen Tag warten.)

Zuvor habe ich darauf hingewiesen, dass der Großteil des C-Kostenmodells weiterhin in C++-Szenarien gilt. Ebenso gilt ein Großteil des C/C++-Kostenmodells weiterhin für verwalteten Code.

Wie kann das sein? Sie kennen das CLR-Ausführungsmodell. Sie schreiben Ihren Code in einer von mehreren Sprachen. Sie kompilieren es in das CIL-Format (Common Intermediate Language), das in Assemblys verpackt ist. Sie führen die Standard Anwendungsassembly aus, und sie beginnt mit der Ausführung der CIL. Aber ist das nicht eine Größenordnung langsamer, wie die Bytecodeinterpreter von früher?

Just-in-Time-Compiler

Nein, ist es nicht. Die CLR verwendet einen JIT-Compiler (Just-In-Time), um jede Methode in CIL in nativen x86-Code zu kompilieren und anschließend den nativen Code aus führt. Obwohl es eine geringe Verzögerung bei der JIT-Kompilierung jeder Methode gibt, wie sie zuerst aufgerufen wird, führt jede Methode, die aufgerufen wird, reinen nativen Code ohne interpretativen Mehraufwand aus.

Im Gegensatz zu einem herkömmlichen Offline-C++-Kompilierungsprozess ist die im JIT-Compiler aufgewendete Zeit eine "Wanduhrzeit"-Verzögerung im Gesicht jedes Benutzers, sodass der JIT-Compiler nicht den Luxus vollständiger Optimierungsdurchläufe hat. Dennoch ist die Liste der Optimierungen, die der JIT-Compiler ausführt, beeindruckend:

  • Konstantenfaltung
  • Konstante und Kopierweitergabe
  • Entfernen gemeinsamer Teilausdrücke
  • Codebewegung von Schleifeninvarianten
  • Entfernen von unzustellbarem Speicher und toter Code
  • Registrieren der Zuordnung
  • Methodeninlining
  • Schleifenaufrollen (kleine Schleifen mit kleinen Körpern)

Das Ergebnis ist vergleichbar mit herkömmlichem nativem Code – zumindest im selben Ballpark.

Was Daten angeht, verwenden Sie eine Mischung aus Wert- oder Verweistypen. Werttypen, einschließlich integraler Typen, Gleitkommatypen, Enumerationen und Strukturen, befinden sich in der Regel im Stapel. Sie sind genauso klein und schnell wie Lokale und Strukturen in C/C++. Wie bei C/C++ sollten Sie es wahrscheinlich vermeiden, große Strukturen als Methodenargumente oder Rückgabewerte zu übergeben, da der Kopieraufwand unerschwinglich teuer sein kann.

Verweistypen und Boxwerttypen befinden sich im Heap. Sie werden durch Objektverweise adressiert, die einfach Computerzeiger sind, genau wie Objektzeiger in C/C++.

Jitted Managed Code kann also schnell sein. Wenn Sie ein Bauchgefühl für die Kosten eines Ausdrucks im nativen C-Code haben, gehen Sie mit einigen Ausnahmen, die unten erläutert werden, nicht viel falsch, wenn Sie die Kosten in verwaltetem Code als gleichwertig modellieren.

Ich sollte auch NGEN Erwähnung, ein Tool, das das CIL "im Voraus" in native Codeassemblys kompiliert. Während die NGEN-Generierung Ihrer Assemblys derzeit keine wesentlichen (guten oder schlechten) Auswirkungen auf die Ausführungszeit hat, kann dies den Gesamtarbeitssatz für freigegebene Assemblys reduzieren, die in viele AppDomains und Prozesse geladen werden. (Das Betriebssystem kann eine Kopie des NGEN-Codes für alle Clients freigeben, während jitted Code derzeit nicht über AppDomains oder Prozesse hinweg freigegeben wird. Siehe aber auch LoaderOptimizationAttribute.MultiDomain.)

Automatic Memory Management

Die wichtigste Abkehr von verwaltetem Code (vom nativen Code) ist die automatische Speicherverwaltung. Sie ordnen neue Objekte zu, aber der CLR Garbage Collector (GC) gibt sie automatisch frei, wenn sie nicht mehr erreichbar sind. GC wird hin und wieder ausgeführt, oft unmerklich, hindert Ihre Anwendung in der Regel nur für ein oder zwei Millisekunden – gelegentlich länger.

In mehreren anderen Artikeln werden die Auswirkungen des Garbage Collector auf die Leistung erläutert, und wir werden sie hier nicht rekapitulieren. Wenn Ihre Anwendung den Empfehlungen in diesen anderen Artikeln folgt, können die Gesamtkosten für die Garbage Collection unbedeutend sein, einige Prozent der Ausführungszeit, konkurrierend oder überlegen mit herkömmlichen C++-Objekten new und delete. Die amortisierten Kosten für das Erstellen und später die automatische Freigabe eines Objekts sind so niedrig, dass Sie viele zehn Millionen kleine Objekte pro Sekunde erstellen können.

Die Objektzuordnung ist jedoch immer noch nicht kostenlos. Objekte nehmen Platz ein. Die grassierende Objektzuordnung führt zu häufigeren Garbage Collection-Zyklen.

Viel schlimmer ist, dass die unnötige Beibehaltung von Verweisen auf nutzlose Objektgraphen sie am Leben hält. Manchmal sehen wir bescheidene Programme mit beklagenswerten 100+ MB Arbeitssätzen, deren Autoren ihre Schuld verweigern und stattdessen ihre schlechte Leistung auf ein mysteriöses, nicht identifiziertes (und somit unlösbares) Problem mit verwaltetem Code selbst zurückführen. Es ist tragisch. Aber dann eine einstündige Studie mit dem CLR Profiler und Änderungen an ein paar Codezeilen reduziert die Heapnutzung um den Faktor zehn oder mehr. Wenn Sie mit einem großen Arbeitssatzproblem konfrontiert sind, besteht der erste Schritt darin, die Spiegel zu überprüfen.

Erstellen Sie also keine Objekte unnötigerweise. Nur weil die automatische Speicherverwaltung die vielen Komplexitäten, Probleme und Fehler bei der Objektzuordnung und -freigabe auflöst, weil es so schnell und bequem ist, neigen wir natürlich dazu, immer mehr Objekte zu erstellen, als ob sie auf Bäumen wachsen. Wenn Sie wirklich schnellen verwalteten Code schreiben möchten, erstellen Sie Objekte mit Bedacht und angemessen.

Dies gilt auch für den API-Entwurf. Es ist möglich, einen Typ und seine Methoden so zu entwerfen, dass Clients neue Objekte mit wilder Aufgabe erstellen müssen . Tu das nicht.

Kosten in verwaltetem Code

Betrachten wir nun die Zeitkosten für verschiedene vorgänge mit verwaltetem Code auf niedriger Ebene.

Tabelle 1 enthält die ungefähren Kosten einer Vielzahl von verwalteten Codevorgängen auf niedriger Ebene in Nanosekunden auf einem ruhenden 1,1 GHz Pentium-III-PC unter Windows XP und .NET Framework v1.1 ("Everett"), die mit einer Reihe einfacher Zeitschleifen erfasst werden.

Der Testtreiber ruft jede Testmethode auf und gibt eine Reihe von durchzuführenden Iterationen an, die automatisch zwischen 218 und 230 Iterationen skaliert werden, sofern erforderlich, um jeden Test mindestens 50 ms lang durchzuführen. Im Allgemeinen ist dies lang genug, um mehrere Zyklen der Garbage Collection der Generation 0 in einem Test zu beobachten, der eine intensive Objektzuordnung durchführt. In der Tabelle sind die Ergebnisse von durchschnittlich über 10 Studien sowie die beste (Mindestzeit) für jeden Testprobanden aufgeführt.

Jede Testschleife wird bei Bedarf 4- bis 64-mal aufgerollt, um den Mehraufwand für die Testschleife zu verringern. Ich überprüfte den nativen Code, der für jeden Test generiert wurde, um sicherzustellen, dass der JIT-Compiler den Test nicht optimiert. In mehreren Fällen habe ich den Test beispielsweise so geändert, dass zwischengeschaltete Ergebnisse während und nach der Testschleife live bleiben. Ebenso habe ich Änderungen vorgenommen, um die Beseitigung gemeinsamer Teilausdrücke in mehreren Tests auszuschließen.

Tabelle 1 Primitive Zeiten (Mittelwert und Minimum) (ns)

Avg Min Primitiv Avg Min Primitiv Avg Min Primitiv
0.0 0.0 Control 2.6 2.6 neuer valtype L1 0.8 0.8 isinst up 1
1.0 1.0 Int add 4.6 4.6 neuer valtype L2 0.8 0.8 isinst down 0
1.0 1.0 Int sub 6.4 6.4 neuer valtype L3 6.3 6.3 isinst down 1
2.7 2.7 Int mul 8.0 8.0 neuer valtype L4 10.7 10.6 isinst (up 2) down 1
35,9 35,7 Int div 23.0 22.9 new valtype L5 6.4 6.4 isinst down 2
2.1 2.1 Int-Schicht 22.0 20.3 neuer Reftype L1 6.1 6.1 isinst down 3
2.1 2.1 long add 26.1 23,9 Neuer Verweistyp L2 1.0 1.0 Get-Feld
2.1 2.1 long sub 30,2 27,5 neuer Reftype L3 1.2 1.2 Get prop
34.2 34.1 long mul 34.1 30.8 Neuer Verweistyp L4 1.2 1.2 Set-Feld
50,1 50.0 long div 39.1 34,4 neuer Reftype L5 1.2 1.2 set prop
5,1 5,1 lange Schicht 22.3 20.3 Neuer Verweistyp leerer Ctor L1 0.9 0.9 Dieses Feld abrufen
1.3 1.3 float add 26,5 23,9 neuer Verweistyp leerer Ctor L2 0.9 0.9 Get this prop
1.4 1.4 float sub 38.1 34.7 neuer verweistype empty ctor L3 1.2 1.2 Dieses Feld festlegen
2.0 2.0 float mul 34.7 30.7 neuer verweistype empty ctor L4 1.2 1.2 Set this prop
27,7 27.6 float div 38.5 34.3 Neuer Verweistyp leerer Ctor L5 6.4 6.3 Virtuelles Prop abrufen
1.5 1.5 double add 22.9 20,7 new reftype ctor L1 6.4 6.3 Virtuelles Prop festlegen
1.5 1.5 double sub 27.8 25.4 new reftype ctor L2 6.4 6.4 Schreibbarriere
2.1 2.0 double mul 32.7 29.9 new reftype ctor L3 1.9 1.9 load int array elem
27,7 27.6 double div 37.7 34.1 new reftype ctor L4 1.9 1.9 store int array elem
0.2 0.2 Statischer Inlineaufruf 43.2 39.1 new reftype ctor L5 2.5 2.5 laden sie obj array elem
6.1 6.1 statischer Aufruf 28,6 26.7 new reftype ctor no-inl L1 16,0 16,0 store obj array elem
1.1 1.0 Inline-instance-Aufruf 38.9 36.5 new reftype ctor no-inl L2 29,0 21.6 box int
6,8 6,8 instance Anruf 50.6 47.7 new reftype ctor no-inl L3 3.0 3.0 unbox int
0.2 0.2 inline diesen Inst-Aufruf 61.8 58.2 new reftype ctor no-inl L4 41.1 40.9 Delegataufruf
6.2 6.2 dieser instance Aufruf 72.6 68.5 new reftype ctor no-inl L5 2.7 2.7 summe array 1000
5.4 5.4 virtueller Anruf 0,4 0,4 Umwandlung 1 2.8 2.8 summe array 10000
5.4 5.4 dieser virtuelle Aufruf 0,3 0,3 cast down 0 (0) 2.9 2.8 summe array 100000
6.6 6,5 Schnittstellenaufruf 8,9 8,8 umwandlung 1 5.6 5.6 Summenarray 1000000
1.1 1.0 inst itf instance call 9,8 9.7 Umwandlung (2) nach unten 1 3,5 3,5 Summenliste 1000
0.2 0.2 dieser itf-instance-Aufruf 8,9 8,8 umwandlung 2 6.1 6.1 Summenliste 10000
5.4 5.4 inst itf virtual call 8,7 8.6 umwandlung 3 22.0 22.0 Summenliste 100000
5.4 5.4 dieser virtuelle Itf-Aufruf       21,5 21,4 Summenliste 1000000

Ein Haftungsausschluss: Bitte nehmen Sie diese Daten nicht zu wörtlich. Zeittests sind mit der Gefahr unerwarteter Effekte zweiter Ordnung behaftet. Bei einer zufälligen Zufälligkeit kann der jittierte Code oder einige wichtige Daten so platziert werden, dass er Cachezeilen umfasst, etwas anderes stört oder was Sie haben. Es ist ein bisschen wie das Unsicherheitsprinzip: Zeit- und Zeitunterschiede von 1 Nanosekunde oder so liegen an den Grenzen des beobachtbaren.

Ein weiterer Haftungsausschluss: Diese Daten sind nur für kleine Code- und Datenszenarien relevant, die vollständig in den Cache passen. Wenn die "heißen" Teile Ihrer Anwendung nicht in den On-Chip-Cache passen, haben Sie möglicherweise andere Leistungsprobleme. Wir haben noch viel mehr über Caches am Ende des Papiers zu sagen.

Und noch ein weiterer Haftungsausschluss: Einer der hervorragenden Vorteile des Versands Ihrer Komponenten und Anwendungen als Assemblys von CIL ist, dass Ihr Programm automatisch jede Sekunde schneller wird und jedes Jahr schneller wird – "schneller jede Sekunde", weil die Runtime (theoretisch) den kompilierten JIT-Code während der Ausführung Ihres Programms neu abstimmen kann; und "immer schneller", denn mit jedem neuen Release der Runtime können bessere, intelligentere und schnellere Algorithmen einen neuen Stab bei der Optimierung Ihres Codes übernehmen. Wenn also einige dieser Zeitsteuerungen in .NET 1.1 weniger als optimal erscheinen, sollten Sie sich daran halten, dass sie sich in nachfolgenden Versionen des Produkts verbessern sollten. Daraus folgt, dass sich jede in diesem Artikel gemeldete codenative Codesequenz in zukünftigen Versionen der .NET Framework ändern kann.

Abgesehen von den Haftungsausschlüssen bieten die Daten ein angemessenes Bauchgefühl für die aktuelle Leistung verschiedener Grundtypen. Die Zahlen sind sinnvoll, und sie untermauern meine Behauptung, dass der meiste jittierte verwaltete Code wie kompilierten nativen Code "in der Nähe des Computers" ausgeführt wird. Die primitiven ganzzahligen und unverankerten Vorgänge sind schnell, Methodenaufrufe verschiedener Arten weniger, aber (vertrauen Sie mir) immer noch vergleichbar mit nativem C/C++; Und dennoch sehen wir auch, dass einige Vorgänge, die in nativem Code normalerweise billig sind (Umwandlungen, Array- und Feldspeicher, Funktionszeiger (Delegaten)), jetzt teurer sind. Warum? Mal sehen.

Arithmetische Operationen

Tabelle 2 Arithmetische Operationszeiten (ns)

Avg Min Primitiv Avg Min Primitiv
1.0 1.0 int add 1.3 1.3 float add
1.0 1.0 int sub 1.4 1.4 float sub
2.7 2.7 int mul 2.0 2.0 float mul
35,9 35,7 int div 27,7 27.6 float div
2.1 2.1 int-Schicht      
2.1 2.1 long add 1.5 1.5 double add
2.1 2.1 long sub 1.5 1.5 double sub
34.2 34.1 long mul 2.1 2.0 double mul
50,1 50.0 long div 27,7 27.6 double div
5,1 5,1 lange Schicht      

Früher war Gleitkommaberechnung vielleicht eine Größenordnung langsamer als ganzzahlige Mathematik. Wie Tabelle 2 zeigt, scheint es bei modernen gleitkommabasierten Gleitkommaeinheiten wenig oder gar keinen Unterschied zu geben. Es ist erstaunlich zu denken, dass ein durchschnittlicher Notebook-PC jetzt ein Gigaflop-Klassencomputer ist (für Probleme, die in den Cache passen).

Sehen wir uns eine Zeile mit jittiertem Code aus den Ganzzahl- und Gleitkomma-Add-Tests an:

Disassemblierung 1 Int add und float add

int add               a = a + b + c + d + e + f + g + h + i;
0000004c 8B 54 24 10      mov         edx,dword ptr [esp+10h] 
00000050 03 54 24 14      add         edx,dword ptr [esp+14h] 
00000054 03 54 24 18      add         edx,dword ptr [esp+18h] 
00000058 03 54 24 1C      add         edx,dword ptr [esp+1Ch] 
0000005c 03 54 24 20      add         edx,dword ptr [esp+20h] 
00000060 03 D5            add         edx,ebp 
00000062 03 D6            add         edx,esi 
00000064 03 D3            add         edx,ebx 
00000066 03 D7            add         edx,edi 
00000068 89 54 24 10      mov         dword ptr [esp+10h],edx 

float add            i += a + b + c + d + e + f + g + h;
00000016 D9 05 38 61 3E 00 fld         dword ptr ds:[003E6138h] 
0000001c D8 05 3C 61 3E 00 fadd        dword ptr ds:[003E613Ch] 
00000022 D8 05 40 61 3E 00 fadd        dword ptr ds:[003E6140h] 
00000028 D8 05 44 61 3E 00 fadd        dword ptr ds:[003E6144h] 
0000002e D8 05 48 61 3E 00 fadd        dword ptr ds:[003E6148h] 
00000034 D8 05 4C 61 3E 00 fadd        dword ptr ds:[003E614Ch] 
0000003a D8 05 50 61 3E 00 fadd        dword ptr ds:[003E6150h] 
00000040 D8 05 54 61 3E 00 fadd        dword ptr ds:[003E6154h] 
00000046 D8 05 58 61 3E 00 fadd        dword ptr ds:[003E6158h] 
0000004c D9 1D 58 61 3E 00 fstp        dword ptr ds:[003E6158h] 

Hier sehen wir, dass der jittierte Code nahezu optimal ist. In diesem int add Fall hat der Compiler sogar fünf der lokalen Variablen registriert. Im Fall des Float-Add-Vorgangs war ich gezwungen, Variablen a durch h Klassenstatiken zu erstellen, um die beseitigung gemeinsamer Teilausdrücke zu besiegen.

Methodenaufrufe

In diesem Abschnitt untersuchen wir die Kosten und Implementierungen von Methodenaufrufen. Der Testantragsteller ist eine Klasse T , die eine Schnittstelle Iimplementiert, mit verschiedenen Arten von Methoden. Siehe Eintrag 1.

Auflisten 1 Methodenaufruftestmethoden

interface I { void itf1();… void itf5();… }
public class T : I {
    static bool falsePred = false;
    static void dummy(int a, int b, int c, …, int p) { }

    static void inl_s1() { } …    static void s1()     { if (falsePred) dummy(1, 2, 3, …, 16); } …    void inl_i1()        { } …    void i1()            { if (falsePred) dummy(1, 2, 3, …, 16); } …    public virtual void v1() { } …    void itf1()          { } …    virtual void itf5()  { } …}

Betrachten Sie Tabelle 3. Es scheint, dass eine Methode zu einer ersten Näherung entweder inlineiert ist (die Abstraktion kostet nichts) oder nicht (die Abstraktion kostet >5X einen ganzzahligen Vorgang). Es scheint keinen signifikanten Unterschied bei den Rohkosten eines statischen Aufrufs, instance Anrufs, virtuellen Anrufs oder Schnittstellenaufrufs zu geben.

Table 3 Method Call Times (ns)

Avg Min Primitiv Aufgerufener Avg Min Primitiv Aufgerufener
0.2 0.2 Statischer Inlineaufruf inl_s1 5.4 5.4 virtueller Anruf v1
6.1 6.1 statischer Aufruf s1 5.4 5.4 dieser virtuelle Aufruf v1
1.1 1.0 Inline-instance-Aufruf inl_i1 6.6 6,5 Schnittstellenaufruf itf1
6,8 6,8 instance Anruf i1 1.1 1.0 inst itf instance call itf1
0.2 0.2 inline diesen Inst-Aufruf inl_i1 0.2 0.2 dieser itf-instance-Aufruf itf1
6.2 6.2 dieser instance Aufruf i1 5.4 5.4 inst itf virtual call itf5
        5.4 5.4 dieser virtuelle Itf-Aufruf itf5

Diese Ergebnisse sind jedoch nicht repräsentative BestFälle, die sich auf das Ausführen von engen Zeitschleifen millionenfach auswirken. In diesen Testfällen sind die Aufrufstellen der virtuellen Und Schnittstellenmethode monomorph (z. B. pro Aufrufstandort ändert sich die Zielmethode im Laufe der Zeit nicht), sodass die Kombination aus zwischenspeichernden Verteilungsmechanismen der virtuellen Methode und der Schnittstellenmethode (Methodentabelle und Schnittstellenzuordnungszeiger und -einträge) und einer spektakulär bereitgestellten Verzweigungsvorhersage dem Prozessor ermöglicht, eine unrealistisch effektive Aufgabe durch aufrufen, die ansonsten schwer vorherzusagen ist, datenabhängige Verzweigungen. In der Praxis können und werden virtuelle Aufrufe und Schnittstellenaufrufe um Dutzende von Zyklen verlangsamt, wenn ein Datencache auf einem der Verteilermechanismusdaten fehlt oder eine Verzweigung fehleinschätzung (sei es ein obligatorischer Kapazitätsfehler oder eine polymorphe Aufrufwebsite).

Sehen wir uns die einzelnen Methodenaufrufzeiten genauer an.

Im ersten Fall, statischer Inlineaufruf, rufen wir eine Reihe leerer statischer Methoden s1_inl() usw. auf. Da der Compiler alle Aufrufe vollständig einnetzt, wird eine leere Schleife ausgeführt.

Um die ungefähren Kosten eines statischen Methodenaufrufs zu messen, machen wir die statischen Methoden s1() usw. so groß, dass sie für die Inline in den Aufrufer nicht rentabel sind.

Beachten Sie, dass wir sogar eine explizite false-Prädikatvariable falsePredverwenden müssen. Wenn wir geschrieben haben

static void s1() { if (false) dummy(1, 2, 3, …, 16); }

Der JIT-Compiler würde den unzustellbaren Aufruf dummy von beseitigen und den gesamten (jetzt leeren) Methodentext wie zuvor inlineieren. Übrigens müssen hier einige der 6,1 n der Aufrufzeit dem (falschen) Prädikattest zugeordnet werden und innerhalb der aufgerufenen statischen Methode s1springen. (Eine bessere Möglichkeit zum Deaktivieren des Inlinings ist übrigens das CompilerServices.MethodImpl(MethodImplOptions.NoInlining) -Attribut.)

Der gleiche Ansatz wurde für den inlineierten instance Anruf und die reguläre instance Anrufsteuerung verwendet. Da die C#-Sprachspezifikation jedoch sicherstellt, dass jeder Aufruf eines NULL-Objektverweises eine NullReferenceException auslöst, muss jede Aufrufwebsite sicherstellen, dass die instance nicht NULL ist. Dies erfolgt durch Dereferenzieren des instance-Verweises. Wenn er NULL ist, wird ein Fehler generiert, der in diese Ausnahme umgewandelt wird.

In Disassembly 2 verwenden wir eine statische Variable t als instance, da, wenn wir eine lokale Variable verwendet haben.

    T t = new T();

Der Compiler hat den NULL-instance Auschecken aus der Schleife herausgehoben.

Disassembly 2 Instance Method call site with NULL instance "check"

               t.i1();
00000012 8B 0D 30 21 A4 05 mov         ecx,dword ptr ds:[05A42130h] 
00000018 39 09             cmp         dword ptr [ecx],ecx 
0000001a E8 C1 DE FF FF    call        FFFFDEE0 

Die Fälle des Inline-instance-Aufrufs und dieses instance-Aufrufs sind identisch, mit der Ausnahme, dass der instance istthis. Hier wurde die NULL-Überprüfung aufgehoben.

Disassemblierung 3 Dieser instance Methodenaufrufstandort

               this.i1();
00000012 8B CE            mov         ecx,esi
00000014 E8 AF FE FF FF   call        FFFFFEC8

Aufrufe virtueller Methoden funktionieren wie bei herkömmlichen C++-Implementierungen. Die Adresse jeder neu eingeführten virtuellen Methode wird in einem neuen Slot in der Methodentabelle des Typs gespeichert. Die Methodentabelle jedes abgeleiteten Typs entspricht und erweitert die des Basistyps, und jede virtuelle Methodenüberschreibung ersetzt die virtuelle Methodenadresse des Basistyps durch die virtuelle Methodenadresse des abgeleiteten Typs im entsprechenden Slot in der Methodentabelle des abgeleiteten Typs.

Am Aufrufstandort verursacht ein Aufruf der virtuellen Methode zwei zusätzliche Ladevorgänge im Vergleich zu einem instance-Aufruf: eine zum Abrufen der Adressen der Methodentabelle (immer unter *(this+0)) und eine andere zum Abrufen der entsprechenden virtuellen Methodenadresse aus der Methodentabelle und zum Aufrufen. Siehe Disassemblierung 4.

Disassembly 4 Virtual method call site (Aufrufstandort für virtuelle Methoden)

               this.v1();
00000012 8B CE            mov         ecx,esi 
00000014 8B 01            mov         eax,dword ptr [ecx] ; fetch method table address
00000016 FF 50 38         call        dword ptr [eax+38h] ; fetch/call method address

Schließlich kommen wir zu Schnittstellenmethodenaufrufen (Disassembly 5). Diese weisen in C++ keine genaue Entsprechung auf. Jeder bestimmte Typ kann eine beliebige Anzahl von Schnittstellen implementieren, und jede Schnittstelle erfordert logisch eine eigene Methodentabelle. Um eine Schnittstellenmethode zu versenden, suchen wir die Methodentabelle, ihre Schnittstellenzuordnung, den Eintrag der Schnittstelle in dieser Zuordnung und rufen dann indirekt über den entsprechenden Eintrag im Abschnitt der Schnittstelle der Methodentabelle auf.

Disassembly 5 Interface Method Call Site

               i.itf1();
00000012 8B 0D 34 21 A4 05 mov        ecx,dword ptr ds:[05A42134h]; instance address
00000018 8B 01             mov        eax,dword ptr [ecx]         ; method table addr
0000001a 8B 40 0C          mov        eax,dword ptr [eax+0Ch]     ; interface map addr
0000001d 8B 40 7C          mov        eax,dword ptr [eax+7Ch]     ; itf method table addr
00000020 FF 10             call       dword ptr [eax]             ; fetch/call meth addr

Der Rest der primitiven Timings, inst itf instance Aufruf, dieser itf instance Aufruf, inst itf virtual call, this itf virtual call, dieser itf virtual call unterstreicht die Idee, dass immer wenn die Methode eines abgeleiteten Typs eine Schnittstellenmethode implementiert, sie über eine instance Methodenaufrufwebsite aufgerufen werden kann.

Beispielsweise wird für den Test dieses itf-instance Aufrufs einer Schnittstellenmethodenimplementierung über einen instance -Verweis (keine Schnittstelle) die Schnittstellenmethode erfolgreich inlineiert, und die Kosten gehen auf 0 ns. Sogar eine Schnittstellenmethodenimplementierung ist potenziell inlineierbar, wenn Sie sie als instance-Methode aufrufen.

Aufrufe von Methoden, die noch jitted werden sollen

Für statische und instance Methodenaufrufe (aber keine virtuellen Aufrufe und Schnittstellenmethodenaufrufe) generiert der JIT-Compiler derzeit unterschiedliche Methodenaufrufsequenzen, je nachdem, ob die Zielmethode zum Zeitpunkt des Jittings der Aufrufwebsite bereits ge jitted wurde.

Wenn die aufgerufene (Zielmethode) noch nicht jitted wurde, gibt der Compiler einen Aufruf indirekt über einen Zeiger aus, der zuerst mit einem "Prejit-Stub" initialisiert wird. Der erste Aufruf der Zielmethode erreicht den Stub, der die JIT-Kompilierung der Methode auslöst, nativen Code generiert und den Zeiger aktualisiert, um den neuen nativen Code zu adressieren.

Wenn der Aufgerufene bereits jitted wurde, ist seine native Codeadresse bekannt, sodass der Compiler einen direkten Aufruf an ihn ausgibt.

Erstellen eines neuen Objekts

Die Erstellung neuer Objekte besteht aus zwei Phasen: Objektzuordnung und Objektinitialisierung.

Bei Verweistypen werden Objekte auf dem Garbage Collection-Heap zugeordnet. Bei Werttypen, ob stapelresident oder eingebettet in einen anderen Verweis oder Werttyp, wird das Werttypobjekt in einem konstanten Offset der einschließenden Struktur gefunden– es ist keine Zuordnung erforderlich.

Bei typischen kleinen Verweistypobjekten erfolgt die Heapzuordnung sehr schnell. Nach jeder Garbage Collection, mit Ausnahme von angehefteten Objekten, werden live-Objekte aus dem Heap der Generation 0 komprimiert und auf Generation 1 heraufgestuft, sodass die Speicherzuweisung über eine schöne große zusammenhängende freie Speicherfläche verfügt. Bei den meisten Objektzuordnungen wird nur ein Zeiger-Inkrement und eine Begrenzungsprüfung ausgeführt, was billiger ist als die typische freie C/C++-Listenzuordnung (malloc/operator new). Der Garbage Collector berücksichtigt sogar die Cachegröße Ihres Computers, um zu versuchen, die Gen 0-Objekte im schnellen Sweet Spot der Cache-/Speicherhierarchie zu halten.

Da der bevorzugte verwaltete Codestil darin besteht, die meisten Objekte mit kurzer Lebensdauer zuzuordnen und sie schnell wieder freizugeben, schließen wir auch (in den Zeitkosten) die amortisierten Kosten für die Garbage Collection dieser neuen Objekte ein.

Beachten Sie, dass der Garbage Collector keine Zeit damit verbringt, tote Objekte zu beklagen. Wenn ein Objekt tot ist, sieht GC es nicht, geht es nicht, gibt ihm keine Nanosekunde gedanken. GC geht es nur um das Wohlergehen der Lebenden.

(Ausnahme: Finalisierbare tote Objekte sind ein Sonderfall. GC verfolgt diese Objekte und fördert insbesondere tote finalisierbare Objekte bis zur nächsten Generation, die bis zur Finalisierung steht. Dies ist teuer und kann im schlimmsten Fall große Diagramme für tote Objekte transitiv heraufstufen. Machen Sie Daher Objekte nicht finalisierbar, es sei denn, es ist unbedingt erforderlich; und wenn dies erforderlich ist, sollten Sie das Dispose-Muster verwenden und nach Möglichkeit aufrufen GC.SuppressFinalizer .) Speichern Sie keine Verweise von Ihrem finalisierbaren Objekt auf andere Objekte, es sei denn, dies ist für Ihre Finalize Methode erforderlich.

Natürlich sind die amortisierten GC-Kosten eines großen kurzlebigen Objekts größer als die Kosten eines kleinen kurzlebigen Objekts. Jede Objektzuordnung bringt uns so viel näher an den nächsten Garbage Collection-Zyklus; größere Objekte tun dies viel früher als kleine. Früher (oder später) wird der Moment der Abrechnung kommen. GC-Zyklen, insbesondere Sammlungen der Generation 0, sind sehr schnell, aber nicht frei, auch wenn die überwiegende Mehrheit der neuen Objekte tot ist: Um die aktiven Objekte zu finden (zu markieren), ist es zunächst notwendig, Threads anzuhalten und dann Stapel und andere Datenstrukturen zu durchlaufen, um Stammobjektverweise in den Heap zu sammeln.

(Wichtiger ist vielleicht, dass weniger größere Objekte in die gleiche Menge an Cache passen wie kleinere Objekte. Cachefehlereffekte können die Effekte der Codepfadlänge leicht dominieren.)

Sobald der Platz für das Objekt zugeordnet ist, bleibt es, es zu initialisieren (zu konstruieren). Die CLR garantiert, dass alle Objektverweise auf NULL vorinitialisiert werden, und alle primitiven Skalartypen mit 0, 0,0, false usw. initialisiert werden. (Daher ist es unnötig, dies in Ihren benutzerdefinierten Konstruktoren redundant zu tun. Fühlen Sie sich natürlich frei. Beachten Sie jedoch, dass der JIT-Compiler Ihre redundanten Speicher derzeit nicht unbedingt optimiert.)

Zusätzlich zum Nullstellen instance Feldern initialisiert die CLR (nur Verweistypen) die internen Implementierungsfelder des Objekts: den Methodentabellenzeiger und das Objektheaderwort, das dem Methodentabellenzeiger vorangestellt ist. Arrays rufen auch ein Length-Feld und Objektarrays die Felder Länge und Elementtyp ab.

Anschließend ruft die CLR ggf. den Konstruktor des Objekts auf. Der Konstruktor jedes Typs, ob benutzerdefinierter Typ oder Compiler generiert, ruft zuerst den Konstruktor des Basistyps auf und führt dann ggf. eine benutzerdefinierte Initialisierung aus.

In der Theorie könnte dies für tiefgehende Vererbungsszenarien teuer sein. Wenn E D erweitert, erweitert C B erweitert A (erweitert System.Object), dann würde die Initialisierung eines E immer fünf Methodenaufrufe verursachen. In der Praxis sind die Dinge nicht so schlecht, da der Compiler inline away (ins Nichts) leere Basistypkonstruktoren aufruft.

Beachten Sie in Bezug auf die erste Spalte von Tabelle 4, dass wir eine Struktur D mit vier int-Feldern in etwa 8 int-add-times erstellen und initialisieren können. Disassembly 6 ist der generierte Code aus drei verschiedenen Zeitschleifen, die A-, C- und E-Instanzen erstellen. (Innerhalb jeder Schleife ändern wir jeden neuen instance, sodass der JIT-Compiler nicht alles optimiert.)

Tabelle 4: Erstellungszeiten für Wert- und Verweistypobjekte (ns)

Avg Min Primitiv Avg Min Primitiv Avg Min Primitiv
2.6 2.6 neuer valtype L1 22.0 20.3 neuer Reftype L1 22.9 20,7 new rt ctor L1
4.6 4.6 neuer valtype L2 26.1 23,9 neuer Reftype L2 27.8 25.4 new rt ctor L2
6.4 6.4 new valtype L3 30,2 27,5 neuer Reftype L3 32.7 29.9 new rt ctor L3
8.0 8.0 new valtype L4 34.1 30.8 neuer Reftype L4 37.7 34.1 new rt ctor L4
23.0 22.9 new valtype L5 39.1 34,4 neuer Reftype L5 43.2 39.1 new rt ctor L5
      22.3 20.3 new rt empty ctor L1 28,6 26.7 new rt no-inl L1
      26,5 23,9 new rt empty ctor L2 38.9 36.5 new rt no-inl L2
      38.1 34.7 new rt empty ctor L3 50.6 47.7 new rt no-inl L3
      34.7 30.7 new rt empty ctor L4 61.8 58.2 new rt no-inl L4
      38.5 34.3 new rt empty ctor L5 72.6 68.5 new rt no-inl L5

Disassembly 6 Werttyp Objektkonstruktion

               A a1 = new A(); ++a1.a;
00000020 C7 45 FC 00 00 00 00 mov     dword ptr [ebp-4],0 
00000027 FF 45 FC         inc         dword ptr [ebp-4] 

               C c1 = new C(); ++c1.c;
00000024 8D 7D F4         lea         edi,[ebp-0Ch] 
00000027 33 C0            xor         eax,eax 
00000029 AB               stos        dword ptr [edi] 
0000002a AB               stos        dword ptr [edi] 
0000002b AB               stos        dword ptr [edi] 
0000002c FF 45 FC         inc         dword ptr [ebp-4] 

               E e1 = new E(); ++e1.e;
00000026 8D 7D EC         lea         edi,[ebp-14h] 
00000029 33 C0            xor         eax,eax 
0000002b 8D 48 05         lea         ecx,[eax+5] 
0000002e F3 AB            rep stos    dword ptr [edi] 
00000030 FF 45 FC         inc         dword ptr [ebp-4] 

Die nächsten fünf Timings (neuer Reftype L1, ... new reftype L5) sind für fünf Vererbungsebenen von Verweistypen A, ..., E, ohne benutzerdefinierte Konstruktoren:

    public class A     { int a; }
    public class B : A { int b; }
    public class C : B { int c; }
    public class D : C { int d; }
    public class E : D { int e; }

Beim Vergleichen der Verweistypzeiten mit den Werttypzeiten sehen wir, dass die amortisierte Zuordnung und das Freigeben der Kosten für jeden instance auf dem Testcomputer ungefähr 20 ns (20x int Add-Time) betragen. Das ist schnell: Zuweisung, Initialisierung und Rückgewinnung von etwa 50 Millionen kurzlebigen Objekten pro Sekunde, nachhaltig. Bei Objekten, die nur fünf Felder klein sind, entfallen Zuordnung und Auflistung nur auf die Hälfte der Objekterstellungszeit. Siehe Disassembly 7.

Disassemblierung 7 Objektkonstruktion des Verweistyps

               new A();
0000000f B9 D0 72 3E 00   mov         ecx,3E72D0h 
00000014 E8 9F CC 6C F9   call        F96CCCB8 

               new C();
0000000f B9 B0 73 3E 00   mov         ecx,3E73B0h 
00000014 E8 A7 CB 6C F9   call        F96CCBC0 

               new E();
0000000f B9 90 74 3E 00   mov         ecx,3E7490h 
00000014 E8 AF CA 6C F9   call        F96CCAC8 

Die letzten drei Sätze von fünf Zeitpunkten weisen Variationen dieses geerbten Klassenkonstruktionsszenarios auf.

  1. New rt empty ctor L1, ..., new rt empty ctor L5: Jeder Typ A, ..., E verfügt über einen leeren benutzerdefinierten Konstruktor. Diese sind alle inline entfernt, und der generierte Code ist mit dem oben genannten identisch.

  2. New rt ctor L1, ..., new rt ctor L5: Jeder Typ A, ..., verfügt über einen benutzerdefinierten Konstruktor, E der seine instance Variable auf 1 festlegt:

        public class A     { int a; public A() { a = 1; } }
        public class B : A { int b; public B() { b = 1; } }
        public class C : B { int c; public C() { c = 1; } }
        public class D : C { int d; public D() { d = 1; } }
        public class E : D { int e; public E() { e = 1; } }
    

Der Compiler inlineiert jeden Satz geschachtelter Basisklassenkonstruktoren, die an der new Website aufgerufen werden. (Disassemblierung 8).

Disassemblierung 8 Tief eingegrenzte geerbte Konstruktoren

               new A();
00000012 B9 A0 77 3E 00   mov         ecx,3E77A0h 
00000017 E8 C4 C7 6C F9   call        F96CC7E0 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 

               new C();
00000012 B9 80 78 3E 00   mov         ecx,3E7880h 
00000017 E8 14 C6 6C F9   call        F96CC630 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 
00000023 C7 40 08 01 00 00 00 mov     dword ptr [eax+8],1 
0000002a C7 40 0C 01 00 00 00 mov     dword ptr [eax+0Ch],1 

               new E();
00000012 B9 60 79 3E 00   mov         ecx,3E7960h 
00000017 E8 84 C3 6C F9   call        F96CC3A0 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 
00000023 C7 40 08 01 00 00 00 mov     dword ptr [eax+8],1 
0000002a C7 40 0C 01 00 00 00 mov     dword ptr [eax+0Ch],1 
00000031 C7 40 10 01 00 00 00 mov     dword ptr [eax+10h],1 
00000038 C7 40 14 01 00 00 00 mov     dword ptr [eax+14h],1 
  1. New rt no-inl L1, ..., new rt no-inl L5: Jeder Typ A, ..., verfügt über einen benutzerdefinierten Konstruktor, E der absichtlich geschrieben wurde, um zu teuer zu inline zu sein. In diesem Szenario werden die Kosten für die Erstellung komplexer Objekte mit tiefen Vererbungshierarchien und largishen Konstruktoren simuliert.

      public class A     { int a; public A() { a = 1; if (falsePred) dummy(…); } }
      public class B : A { int b; public B() { b = 1; if (falsePred) dummy(…); } }
      public class C : B { int c; public C() { c = 1; if (falsePred) dummy(…); } }
      public class D : C { int d; public D() { d = 1; if (falsePred) dummy(…); } }
      public class E : D { int e; public E() { e = 1; if (falsePred) dummy(…); } }
    

Die letzten fünf Zeitpläne in Tabelle 4 zeigen den zusätzlichen Mehraufwand für das Aufrufen der geschachtelten Basiskonstruktoren an.

Zwischenspiel: CLR Profiler-Demo

Jetzt sehen Sie sich eine kurze Demo des CLR-Profilers an. Der CLR-Profiler, früher als Zuordnungsprofiler bekannt, verwendet die CLR-Profilerstellungs-APIs, um Ereignisdaten zu sammeln, insbesondere Aufruf-, Rückgabe- und Objektzuordnungs- und Garbage Collection-Ereignisse, während Ihre Anwendung ausgeführt wird. (Der CLR Profiler ist ein "invasiver" Profiler, was bedeutet, dass er die profilierte Anwendung leider erheblich verlangsamt.) Nachdem Ereignisse erfasst wurden, verwenden Sie CLR Profiler, um die Speicherzuordnung und das GC-Verhalten Ihrer Anwendung zu untersuchen, einschließlich der Interaktion zwischen Ihrem hierarchischen Aufrufdiagramm und Ihren Speicherzuordnungsmustern.

CLR Profiler lohnt sich, da für viele Anwendungen mit "leistungsgeforderten" verwaltetem Code das Verständnis Ihres Datenzuordnungsprofils die wichtigen Erkenntnisse liefert, die erforderlich sind, um Ihren Arbeitssatz zu reduzieren und so schnelle und sparsame Komponenten und Anwendungen bereitzustellen.

Der CLR-Profiler kann auch anzeigen, welche Methoden mehr Speicher als erwartet zuweisen, und Fälle aufdecken, in denen Sie versehentlich Verweise auf nutzlose Objektdiagramme speichern, die andernfalls von GC zurückgewonnen werden könnten. (Ein häufiges Problementwurfsmuster ist ein Softwarecache oder eine Nachschlagetabelle von Elementen, die nicht mehr benötigt werden oder später neu erstellt werden können. Es ist tragisch, wenn ein Cache Objektgraphen über ihre Nutzungsdauer am Leben hält. Achten Sie stattdessen darauf, Verweise auf objekte, die Sie nicht mehr benötigen, null zu löschen.)

Abbildung 1 ist eine Zeitleiste Ansicht des Heaps während der Ausführung des Timingtesttreibers. Das Sägezahnmuster gibt die Zuordnung von vielen Tausend Instanzen von Objekten C (Magenta), D (lila) und E (blau) an. Alle paar Millisekunden kauen wir im heap des neuen Objekts (Generation 0) weitere ca. 150 KB RAM, und der Garbage Collector wird kurz ausgeführt, um ihn zu recyceln und alle Liveobjekte auf Gen 1 hochzustufen. Es ist bemerkenswert, dass wir selbst unter dieser invasiven (langsamen) Profilerstellungsumgebung im Intervall von 100 ms (2,8 s bis 2,9 s) ca. 8 GC-Zyklen der Generation 0 durchlaufen. Dann bei 2,977 s macht der Garbage Collector Platz für eine weitere E instance, führt eine Garbage Collection der Generation 1 durch, die den Gen-1-Heap sammelt und komprimiert – und so fährt das Sägezahn fort, von einer niedrigeren Startadresse aus.

Abbildung1 CLR Profiler-Zeitlinienansicht

Beachten Sie, dass je größer das Objekt (E größer als D größer als C), desto schneller füllt sich der Gen0-Heap und desto häufiger wird der GC-Zyklus.

Überprüfungen von Umwandlungs- und Instanztyp

Die Grundlage für sicheren, sicheren und überprüfbaren verwalteten Code ist die Typsicherheit. Wenn es möglich wäre, ein Objekt in einen Typ zu umwandeln, der es nicht ist, wäre es einfach, die Integrität der CLR zu kompromittieren und es daher dem nicht vertrauenswürdigen Code ausgeliefert zu machen.

Tabelle 5 Cast and isinst Times (ns)

Avg Min Primitiv Avg Min Primitiv
0,4 0,4 cast up 1 0.8 0.8 isinst up 1
0,3 0,3 cast down 0 0.8 0.8 isinst down 0
8,9 8,8 abwerfen 1 6.3 6.3 isinst down 1
9,8 9.7 cast (up 2) down 1 10.7 10.6 isinst (up 2) down 1
8,9 8,8 cast down 2 6.4 6.4 isinst down 2
8,7 8.6 cast down 3 6.1 6.1 isinst down 3

Tabelle 5 zeigt den Mehraufwand dieser obligatorischen Typprüfungen. Eine Umwandlung von einem abgeleiteten Typ in einen Basistyp ist immer sicher – und frei; während eine Umwandlung von einem Basistyp in einen abgeleiteten Typ typgeprüft sein muss.

Eine (aktivierte) Umwandlung konvertiert den Objektverweis in den Zieltyp oder löst aus InvalidCastException.

Im Gegensatz dazu wird die isinst CIL-Anweisung verwendet, um die C#- as Schlüsselwort (keyword) zu implementieren:

bac = ac as B;

Wenn ac nicht B oder von Babgeleitet ist, ist nulldas Ergebnis , keine Ausnahme.

Listing 2 zeigt eine der Umformzeitschleifen, und Disassembly 9 zeigt den generierten Code für eine Umwandlung in einen abgeleiteten Typ an. Um die Umwandlung durchzuführen, gibt der Compiler einen direkten Aufruf an eine Hilfsroutine aus.

Auflisten von 2 Zeitdauern für die Schleife zum Testen der Umwandlung

public static void castUp2Down1(int n) {
    A ac = c; B bd = d; C ce = e; D df = f;
    B bac = null; C cbd = null; D dce = null; E edf = null;
    for (n /= 8; --n >= 0; ) {
        bac = (B)ac; cbd = (C)bd; dce = (D)ce; edf = (E)df;
        bac = (B)ac; cbd = (C)bd; dce = (D)ce; edf = (E)df;
    }
}

Demontage 9 Nach unten

               bac = (B)ac;
0000002e 8B D5            mov         edx,ebp 
00000030 B9 40 73 3E 00   mov         ecx,3E7340h 
00000035 E8 32 A7 4E 72   call        724EA76C 

Eigenschaften

In verwaltetem Code ist eine Eigenschaft ein Paar von Methoden, ein Eigenschafts getter und ein Eigenschaftensetter, die sich wie ein Feld eines Objekts verhalten. Die get_ -Methode ruft die -Eigenschaft ab. die set_ -Methode aktualisiert die -Eigenschaft auf einen neuen Wert.

Darüber hinaus verhalten sich Eigenschaften und kosten genau wie reguläre instance Methoden und virtuellen Methoden. Wenn Sie eine Eigenschaft verwenden, um einfach ein instance Feld abzurufen oder zu speichern, ist es in der Regel inline, wie bei jeder kleinen Methode.

Tabelle 6 zeigt die Zeit, die zum Abrufen (und Hinzufügen) und zum Speichern einer Reihe ganzzahliger instance Feldern und Eigenschaften erforderlich ist. Die Kosten für das Abrufen oder Festlegen einer Eigenschaft sind in der Tat identisch mit dem direkten Zugriff auf das zugrunde liegende Feld, es sei denn , die Eigenschaft wird als virtuell deklariert. In diesem Fall entsprechen die Kosten ungefähr denen eines aufrufs einer virtuellen Methode. Kein Wunder.

Tabelle 6 Feld- und Eigenschaftszeiten (ns)

Avg Min Primitiv
1.0 1.0 Feld abrufen
1.2 1.2 Prop abrufen
1.2 1.2 Feld festlegen
1.2 1.2 set prop
6.4 6.3 Virtuelles Prop abrufen
6.4 6.3 Virtuelles Prop festlegen

Schreibbarrieren

Der CLR-Garbage Collector nutzt die "Generationenhypothese" – die meisten neuen Objekte sind jung –, um den Sammelaufwand zu minimieren.

Der Heap wird logisch in Generationen partitioniert. Die neuesten Objekte leben in generation 0 (gen 0). Diese Objekte haben noch keine Auflistung überlebt. Während einer Gen0-Auflistung bestimmt GC, falls vorhanden, welche Gen0-Objekte aus dem GC-Stammsatz erreichbar sind, der Objektverweise in Computerregistern, auf dem Stapel, Statische Feldobjektverweise der Klasse usw. enthält. Transitiv erreichbare Objekte werden "live" und auf Generation 1 heraufgestuft (kopiert).

Da die gesamte Heapgröße Hunderte von MB betragen kann, während die Heapgröße gen 0 nur 256 KB betragen kann, ist die Begrenzung des Umfangs der Objektgraphablaufverfolgung des GC auf den Gen0-Heap eine wesentliche Optimierung, um die sehr kurzen Anlaufzeiten der CLR-Sammlung zu erreichen.

Es ist jedoch möglich, einen Verweis auf ein Gen0-Objekt in einem Objektreferenzfeld eines Gen1- oder Gen2-Objekts zu speichern. Da wir während einer Gen 0-Auflistung keine Gen1- oder Gen2-Objekte scannen, kann dieses Objekt fälschlicherweise von GC zurückgefordert werden, wenn dies der einzige Verweis auf das angegebene Gen 0-Objekt ist. Das können wir nicht zulassen!

Stattdessen entsteht für alle Speicher für alle Objektverweisfelder im Heap eine Schreibsperre. Hierbei handelt es sich um Buchhaltungscode, der die Speicher von Objektverweisen der neuen Generation effizient in Feldern von Objekten älterer Generation notiert. Solche alten Objektverweisfelder werden dem GC-Stammsatz der nachfolgenden GC(s) hinzugefügt.

Der Mehraufwand für die Schreibbarriere pro Objektreferenz-Feldspeicher ist mit den Kosten eines einfachen Methodenaufrufs vergleichbar (Tabelle 7). Es ist eine neue Ausgabe, die nicht im nativen C/C++-Code vorhanden ist, aber es ist in der Regel ein kleiner Preis für die superschnelle Objektzuordnung und gc sowie die vielen Produktivitätsvorteile der automatischen Speicherverwaltung.

Tabelle 7 Schreibbarrierezeit (ns)

Avg Min Primitiv
6.4 6.4 Schreibsperre

Schreibbarrieren können in engen inneren Schleifen teuer sein. Aber in den kommenden Jahren können wir uns auf fortschrittliche Kompilierungstechniken freuen, die die Anzahl der vorgenommenen Schreibbarrieren und die gesamten amortisierten Kosten reduzieren.

Sie könnten denken, Dass Schreibbarrieren nur für Speicher erforderlich sind, um Objektverweisfelder von Verweistypen zu verwenden. Innerhalb einer Werttypmethode werden die Speicher in den Objektverweisfeldern (sofern vorhanden) jedoch auch durch Schreibbarrieren geschützt. Dies ist erforderlich, da der Werttyp selbst manchmal in einen Verweistyp eingebettet werden kann, der sich im Heap befindet.

Arrayelementzugriff

Zum Diagnostizieren und Ausschließen von Array-Out-of-Bounds-Fehlern und Heapbeschädigungen und zum Schutz der Integrität der CLR selbst werden Arrayelementladungen und -speicher überprüft, um sicherzustellen, dass sich der Index innerhalb des Intervalls [0,Arrays befindet. Länge-1] inklusive oder Auslösen von IndexOutOfRangeException.

Unsere Tests messen die Zeit zum Laden oder Speichern von Elementen eines int[] Arrays und eines Arrays A[] . (Tabelle 8).

Tabelle 8 Arrayzugriffszeiten (ns)

Avg Min Primitiv
1.9 1.9 Laden des int-Arrays elem
1.9 1.9 int array elem speichern
2.5 2.5 laden sie obj array elem
16,0 16,0 store obj array elem

Für die Begrenzungsprüfung muss der Arrayindex mit dem impliziten Array verglichen werden. Längenfeld. Wie Disassembly 10 zeigt, überprüfen wir in nur zwei Anweisungen, dass der Index weder kleiner als 0 noch größer als oder gleich Array ist. Länge: Wenn dies der Grund ist, verzweigen wir eine Out-of-Line-Sequenz, die die Ausnahme auslöst. Das gleiche gilt für Lasten von Objektarrayelementen und für Speicher in Arrays von Ints und anderen einfachen Werttypen. (Load obj array elem time ist aufgrund eines leichten Unterschieds in der inneren Schleife (unbedeutend) langsamer.)

Disassembly 10 Load int Array-Element

                          ; i in ecx, a in edx, sum in edi
               sum += a[i];
00000024 3B 4A 04         cmp         ecx,dword ptr [edx+4] ; compare i and array.Length
00000027 73 19            jae         00000042 
00000029 03 7C 8A 08      add         edi,dword ptr [edx+ecx*4+8] 
…                         ; throw IndexOutOfRangeException
00000042 33 C9            xor         ecx,ecx 
00000044 E8 52 78 52 72   call        7252789B 

Durch seine Codequalitätsoptimierungen beseitigt der JIT-Compiler häufig redundante Begrenzungsprüfungen.

Wenn Wir an frühere Abschnitte erinnern, können wir davon ausgehen, dass Objektarrayelementspeicher erheblich teurer sein werden. Um einen Objektverweis in einem Array von Objektverweise zu speichern, muss die Runtime Folgendes ausführen:

  1. überprüfen, ob sich der Arrayindex in Grenzen befindet;
  2. check-Objekt ist ein instance des Arrayelementtyps.
  3. eine Schreibsperre ausführen (Notieren eines intergenerationalen Objektverweiss vom Array auf das -Objekt).

Diese Codesequenz ist ziemlich lang. Anstatt sie an jedem Objektarrayspeicherstandort auszustrahlen, gibt der Compiler einen Aufruf einer freigegebenen Hilfsfunktion aus, wie in Disassembly 11 gezeigt. Dieser Aufruf und diese drei Aktionen entfallen auf die zusätzliche Zeit, die in diesem Fall benötigt wird.

Disassembly 11 Store-Objektarrayelement

                          ; objarray in edi
                          ; obj      in ebx
               objarray[1] = obj;
00000027 53               push        ebx  
00000028 8B CF            mov         ecx,edi 
0000002a BA 01 00 00 00   mov         edx,1 
0000002f E8 A3 A0 4A 72   call        724AA0D7   ; store object array element helper

Boxing und Unboxing

Eine Partnerschaft zwischen .NET-Compilern und der CLR ermöglicht es Werttypen, einschließlich primitiver Typen wie int (System.Int32), so teilzunehmen, als wären sie Verweistypen , die als Objektverweise behandelt werden. Dieses Angebot – dieser syntaktische Zucker – ermöglicht es, Werttypen als Objekte an Methoden zu übergeben, in Sammlungen als Objekte usw. gespeichert zu werden.

Beim "Boxen" eines Werttyps wird ein Verweistypobjekt erstellt, das eine Kopie des Werttyps enthält. Dies ist vom Konzept her identisch mit dem Erstellen einer Klasse mit einem unbenannten instance Feld desselben Typs wie der Werttyp.

Zum "Unboxen" eines Boxwerttyps müssen Sie den Wert aus dem Objekt in eine neue instance des Werttyps kopieren.

Wie Tabelle 9 (im Vergleich zu Tabelle 4) zeigt, ist die amortisierte Zeit, die zum Boxen eines int und später zum Garbage Collect benötigt wird, mit der Zeit vergleichbar, die zum Instanziieren einer kleinen Klasse mit einem int-Feld benötigt wird.

Tabelle 9 Box- und Unbox-Int-Zeiten (ns)

Avg Min Primitiv
29,0 21.6 box int
3.0 3.0 unbox int

Zum Entpacken eines boxed int-Objekts ist eine explizite Umwandlung in int erforderlich. Dies kompiliert zu einem Vergleich des Objekttyps (dargestellt durch seine Methodentabellenadresse) und der Tabellenadresse der boxed int-Methode. Wenn sie gleich sind, wird der Wert aus dem Objekt kopiert. Andernfalls wird eine Ausnahme ausgelöst. Siehe Disassembly 12.

Disassembly 12 Box and Unbox int

box               object o = 0;
0000001a B9 08 07 B9 79   mov         ecx,79B90708h 
0000001f E8 E4 A5 6C F9   call        F96CA608 
00000024 8B D0            mov         edx,eax 
00000026 C7 42 04 00 00 00 00 mov         dword ptr [edx+4],0 

unbox               sum += (int)o;
00000041 81 3E 08 07 B9 79 cmp         dword ptr [esi],79B90708h ; "type == typeof(int)"?
00000047 74 0C            je          00000055 
00000049 8B D6            mov         edx,esi 
0000004b B9 08 07 B9 79   mov         ecx,79B90708h 
00000050 E8 A9 BB 4E 72   call        724EBBFE                   ; no, throw exception
00000055 8D 46 04         lea         eax,[esi+4]
00000058 3B 08            cmp         ecx,dword ptr [eax] 
0000005a 03 38            add         edi,dword ptr [eax]        ; yes, fetch int field

Delegaten

In C ist ein Zeiger auf Funktion ein primitiver Datentyp, der buchstäblich die Adresse der Funktion speichert.

C++ fügt Memberfunktionen Zeiger hinzu. Ein Zeiger auf Memberfunktion (PMF) stellt einen Verzögerten Memberfunktionsaufruf dar. Die Adresse einer nicht virtuellen Memberfunktion kann eine einfache Codeadresse sein, aber die Adresse einer virtuellen Memberfunktion muss einen bestimmten Aufruf einer virtuellen Memberfunktion verkörpern. Der Rückschluss einer solchen PMF ist ein virtueller Funktionsaufruf.

Zum Dereferenzieren einer C++-PMF müssen Sie eine instance angeben:

    A* pa = new A;
    void (A::*pmf)() = &A::af;
    (pa->*pmf)();

Vor Jahren haben wir uns im Entwicklungsteam des Visual C++-Compilers gefragt, welche Art von Beastie ist der nackte Ausdruck pa->*pmf (ohne Funktionsaufrufoperator)? Wir haben es als gebundenen Zeiger auf die Memberfunktion bezeichnet, aber der Aufruf der latenten Memberfunktion ist genauso treffend.

Wenn sie zum Land mit verwaltetem Code zurückkehren, ist ein Delegatobjekt genau das – ein latenter Methodenaufruf. Ein Delegatobjekt stellt sowohl die methode dar, die aufgerufen werden soll, als auch die instance zum aufrufen – oder für einen Delegat an eine statische Methode nur die statische Methode, die aufgerufen werden soll.

(Wie in unserer Dokumentation beschrieben: Eine Delegatdeklaration definiert einen Verweistyp, der verwendet werden kann, um eine Methode mit einer bestimmten Signatur zu kapseln. Ein Delegat instance eine statische oder eine instance-Methode kapselt. Delegaten ähneln in etwa Funktionszeigern in C++; Delegaten sind jedoch typsicher und sicher.)

Delegattypen in C# sind abgeleitete Typen von MulticastDelegate. Dieser Typ bietet umfassende Semantik, einschließlich der Möglichkeit, eine Aufrufliste von (Objekt,Methode)-Paaren zu erstellen, die beim Aufrufen des Delegaten aufgerufen werden sollen.

Delegaten bieten auch eine Möglichkeit für asynchronen Methodenaufruf. Nachdem Sie einen Delegattyp definiert und mit einem latenten Methodenaufruf initialisiert haben, können Sie ihn synchron (Methodenaufrufsyntax) oder asynchron über BeginInvokeaufrufen. Wenn BeginInvoke aufgerufen wird, stellt die Laufzeit den Aufruf in die Warteschlange und gibt sofort an den Aufrufer zurück. Die Zielmethode wird später in einem Threadpoolthread aufgerufen.

All diese reichen Semantiken sind nicht preiswert. Wenn Sie Tabelle 10 und Tabelle 3 vergleichen, beachten Sie, dass der Delegataufruf ** etwa achtmal langsamer ist als ein Methodenaufruf. Erwarten Sie, dass sich dies im Laufe der Zeit verbessert.

Tabelle 10 Delegataufrufzeit (ns)

Avg Min Primitiv
41.1 40.9 Delegataufruf

Von Cachefehlern, Seitenfehlern und Computerarchitektur

Damals in den "guten alten Zeiten", ca. 1983, waren Prozessoren langsam (~,5 Millionen Anweisungen/s), und relativ gesehen war der RAM schnell genug, aber klein (~ 300 ns Zugriffszeiten auf 256 KB DRAM), und Datenträger waren langsam und groß (~ 25 ms Zugriffszeiten auf 10 MB Datenträgern). PC-Mikroprozessoren waren skalare CISCs, die meisten Gleitkommapunkte waren Software, und es gab keine Caches.

Nach zwanzig weiteren Jahren des Moore's Law, ca. 2003, sind Prozessoren schnell (ausgeben bis zu drei Vorgänge pro Zyklus bei 3 GHz), RAM ist relativ sehr langsam (~ 100 ns Zugriffszeiten auf 512 MB DRAM), und Datenträger sind eiszeitlich langsam und enorm (~ 10 ms Zugriffszeiten auf 100 GB Datenträgern). PC-Mikroprozessoren sind jetzt superskalare Hyperthreading-Ablaufverfolgungscache-RISCs (ausführung decoded CISC-Anweisungen) und es gibt mehrere Cacheebenen – beispielsweise verfügt ein bestimmter serverorientierter Mikroprozessor über 32 KB Datencache der Ebene 1 (vielleicht zwei Latenzzyklen), 512 KB L2-Datencache und 2 MB L3-Datencache (vielleicht ein Dutzend Latenzzyklen). alles auf Chip.

In der guten alten Zeit konnten Sie die Bytes des geschriebenen Codes zählen und die Anzahl der Zyklen zählen, die der Code zum Ausführen benötigt. Eine Last oder ein Speicher dauerte etwa die gleiche Anzahl von Zyklen wie ein Add. Der moderne Prozessor verwendet Branchvorhersage, Spekulation und Out-of-Order-Ausführung (Dataflow) über mehrere Funktionseinheiten hinweg, um Parallelität auf Anweisungsebene zu ermitteln und so an mehreren Fronten gleichzeitig Fortschritte zu erzielen.

Jetzt können unsere schnellsten PCs bis zu ~9.000 Vorgänge pro Mikrosekunde ausgeben, aber in derselben Mikrosekunde laden oder speichern Sie nur dram ca. 10 Cachezeilen. In Computerarchitekturkreisen wird dies als Treffen der Speicherwand bezeichnet. Caches blenden die Speicherlatenz aus, jedoch nur bis zu einem bestimmten Punkt. Wenn Code oder Daten nicht in den Cache passen und/oder eine schlechte Referenzlokalität aufweisen, verkommt unser Überschallstrahl mit 9000 Vorgängen pro Mikrosekunden zu einem Dreirad mit 10 Lasten pro Mikrosekunden.

Und (lassen Sie dies nicht zu), sollte der Arbeitssatz eines Programms den verfügbaren physischen RAM überschreiten und das Programm beginnt, harte Seitenfehler zu nehmen, dann verpassen wir in jedem 10.000-Mikrosekunden-Seitenfehlerdienst (Datenträgerzugriff) die Möglichkeit, den Benutzer bis zu 90 Millionen Vorgänge seiner Antwort näher zu bringen. Das ist einfach so schrecklich, dass ich darauf vertraue, dass Sie von diesem Tag an darauf achten werden, Ihren Arbeitssatz (vadump) zu messen und Tools wie CLR Profiler zu verwenden, um unnötige Zuordnungen und versehentliche Aufbewahrungen von Objektgraphen zu beseitigen.

Aber was hat all dies mit der Kenntnis der Kosten von Grundtypen für verwalteten Code zu tun?Alles*.*

In Tabelle 1, der Omnibusliste der primitiven Zeiten für verwalteten Code, gemessen auf einem 1,1-GHz-P-III-Wert, müssen Sie feststellen, dass jedes einzelne Mal, selbst die amortisierten Kosten für die Zuweisung, Initialisierung und Rückgewinnung eines Fünffeldobjekts mit fünf Ebenen expliziter Konstruktoraufrufe , schneller ist als ein einzelner DRAM-Zugriff. Nur eine Last, bei der alle Ebenen des On-Chip-Caches fehlen, kann länger dauern als fast jeder einzelne Vorgang mit verwaltetem Code.

Wenn Sie sich also für die Geschwindigkeit Ihres Codes interessieren, ist es unerlässlich, die Cache-/Speicherhierarchie beim Entwerfen und Implementieren Ihrer Algorithmen und Datenstrukturen zu berücksichtigen und zu messen .

Zeit für eine einfache Demonstration: Ist es schneller, ein Array von Ints zu summieren oder eine entsprechende verknüpfte Liste von Ints zu summieren? Was, wie viel und warum?

Denken Sie eine Minute lang darüber nach. Bei kleinen Elementen wie z. B. ints beträgt der Speicherbedarf pro Arrayelement ein Viertel der verknüpften Liste. (Jeder verknüpfte Listenknoten verfügt über zwei Wörter mit Objektmehraufwand und zwei Wörter von Feldern (nächster Link und int-Element).) Dies wird die Cacheauslastung beeinträchtigen. Bewertet eins für den Arrayansatz.

Beim Arraydurchlauf kann es jedoch zu einer Überprüfung der Arraygrenzen pro Element kommen. Sie haben gerade gesehen, dass die Begrenzungsprüfung ein wenig Zeit in Anspruch nimmt. Vielleicht ist dies ein Tipp für die Skalierung zugunsten der verknüpften Liste?

Disassembly 13 Sum int array versus sum int linked list

sum int array:            sum += a[i];
00000024 3B 4A 04         cmp         ecx,dword ptr [edx+4]       ; bounds check
00000027 73 19            jae         00000042 
00000029 03 7C 8A 08      add         edi,dword ptr [edx+ecx*4+8] ; load array elem
               for (int i = 0; i < m; i++)
0000002d 41               inc         ecx  
0000002e 3B CE            cmp         ecx,esi 
00000030 7C F2            jl          00000024 


sum int linked list:         sum += l.item; l = l.next;
0000002a 03 70 08         add         esi,dword ptr [eax+8] 
0000002d 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
00000030 03 70 08         add         esi,dword ptr [eax+8] 
00000033 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
00000036 03 70 08         add         esi,dword ptr [eax+8] 
00000039 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
0000003c 03 70 08         add         esi,dword ptr [eax+8] 
0000003f 8B 40 04         mov         eax,dword ptr [eax+4] 
               for (m /= 4; --m >= 0; ) {
00000042 49               dec         ecx  
00000043 85 C9            test        ecx,ecx 
00000045 79 E3            jns         0000002A 

In Bezug auf Disassembly 13 habe ich das Deck zugunsten des verknüpften Listendurchlaufs gestapelt, die Registrierung viermal aufgehoben, sogar die übliche NULL-Zeiger-End-of-List-Überprüfung entfernt. Jedes Element in der Arrayschleife erfordert sechs Anweisungen, während jedes Element in der verknüpften Listenschleife nur 11/4 = 2,75 Anweisungen benötigt. Was ist ihrer Annahme nach schneller?

Testbedingungen: Erstellen Sie zunächst ein Array von einer Million Ints und eine einfache, herkömmliche verknüpfte Liste mit einer Million Ints (1 M Listenknoten). Dann dauert es, wie lange es pro Element dauert, bis die ersten 1.000, 10.000, 100.000 und 1.000.000 Elemente addieren. Wiederholen Sie jede Schleife mehrmals, um das schmeichelhaftste Cacheverhalten für jeden Fall zu messen.

Was ist schneller? Nachdem Sie raten, lesen Sie die Antworten: die letzten acht Einträge in Tabelle 1.

Das ist interessant. Die Zeiten werden erheblich langsamer, da die referenzierten Daten größer werden als die nachfolgenden Cachegrößen. Die Arrayversion ist immer schneller als die Version der verknüpften Liste, obwohl sie doppelt so viele Anweisungen ausführt. bei 100.000 Elementen ist die Arrayversion siebenmal schneller!

Woran liegt das? Erstens passen weniger verknüpfte Listenelemente in eine bestimmte Cacheebene. All diese Objektheader und -links verschwenden Speicherplatz. Zweitens kann unser moderner Out-of-Order-Dataflow-Prozessor potenziell nach vorne zoomen und mehrere Elemente im Array gleichzeitig voranschreiten. Im Gegensatz zur verknüpften Liste kann der Prozessor nicht beginnen, den nächsten Link zum Knoten abzurufen, bis sich der aktuelle Listenknoten im Cache befindet.

Im Fall von 100.000 Elementen gibt der Prozessor (im Durchschnitt) ungefähr (22-3,5)/22 = 84 % seiner Zeit aus, um seine Daumen zu twidddieren und darauf zu warten, dass die Cachezeile eines Listenknotens aus DRAM gelesen wird. Das klingt schlecht, aber die Dinge könnten viel schlimmer sein. Da die verknüpften Listenelemente klein sind, passen viele von ihnen in eine Cachezeile. Da wir die Liste in Zuordnungsreihenfolge durchlaufen und der Garbage Collector die Zuordnungsreihenfolge behält, auch wenn er tote Objekte aus dem Heap komprimiert, ist es wahrscheinlich, dass sich nach dem Abrufen eines Knotens in einer Cachezeile auch die nächsten Knoten im Cache befinden. Wenn die Knoten größer waren oder sich die Listenknoten in einer zufälligen Adressreihenfolge befanden, könnte jeder besuchte Knoten ein vollständiger Cachefehler sein. Das Hinzufügen von 16 Bytes zu jedem Listenknoten verdoppelt die Durchlaufzeit pro Element auf 43 ns. +32 Bytes, 67 ns/Element; und das Hinzufügen von 64 Bytes verdoppelt dies erneut auf 146 ns/Item, wahrscheinlich die durchschnittliche DRAM-Latenz auf dem Testcomputer.

Was ist also die Lektion zum Mitnehmen hier? Vermeiden Sie verknüpfte Listen mit 100.000 Knoten? Nein. Die Lektion besteht darin, dass Cacheeffekte jede Berücksichtigung der niedrigen Effizienz von verwaltetem Code im Vergleich zu nativem Code dominieren können. Wenn Sie leistungskritischen verwalteten Code schreiben, insbesondere Code, der große Datenstrukturen verwaltet, beachten Sie Cacheeffekte, durchdenken Sie Ihre Datenstrukturzugriffsmuster und streben Sie nach kleineren Datenabdrucken und einer guten Referenzortität.

Übrigens ist der Trend, dass die Speicherwand, das Verhältnis der DRAM-Zugriffszeit dividiert durch die CPU-Betriebszeit, im Laufe der Zeit weiter zunehmen wird.

Hier sind einige "Cache-bewusste Design"-Faustregeln:

  • Experimentieren Sie mit Ihren Szenarien und messen Sie sie, weil es schwierig ist, Effekte zweiter Ordnung vorherzusagen, und weil Faustregeln das Papier, auf dem sie gedruckt werden, nicht wert sind.
  • Einige Datenstrukturen, die durch Arrays veranschaulicht werden, verwenden implizite Adjazenz , um eine Beziehung zwischen Daten darzustellen. Andere, die durch verknüpfte Listen veranschaulicht werden, verwenden explizite Zeiger (Verweise), um die Beziehung darzustellen. Implizite Adjacency ist im Allgemeinen vorzuziehen – "Implizitkeit" spart Platz im Vergleich zu Zeigern; und Adjacency bietet eine stabile Referenzortität und kann es dem Prozessor ermöglichen, mehr Arbeit zu beginnen, bevor er den nächsten Zeiger verfolgt.
  • Einige Verwendungsmuster bevorzugen Hybridstrukturen– Listen mit kleinen Arrays, Arrays von Arrays oder B-Strukturen.
  • Vielleicht sollten Algorithmen für datenträgerzugriffssensitive Planungen, die zurück entwickelt wurden, wenn Datenträgerzugriffe nur 50.000 CPU-Anweisungen kosten, jetzt wiederverwendet werden, da DRAM-Zugriffe Tausende von CPU-Vorgängen in Anspruch nehmen können.
  • Da der CLR-Mark-and-Compact-Garbage Collector die relative Reihenfolge der Objekte behält, bleiben Objekte, die in der Zeit (und im selben Thread) zugeordnet sind, im Raum zusammen. Möglicherweise können Sie dieses Phänomen verwenden, um Cliquish-Daten in gemeinsamen Cachezeilen nachzudenken.
  • Möglicherweise möchten Sie Ihre Daten in heiße Teile partitionieren, die häufig durchlaufen werden und in den Cache passen müssen, und in kalte Teile, die selten verwendet werden und "zwischengespeichert" werden können.

Do-It-Yourself-Zeitexperimente

Für die Zeitmessung in diesem Dokument habe ich den hochauflösenden Win32-Leistungsindikator QueryPerformanceCounter (und QueryPerformanceFrequency) verwendet.

Sie können einfach über P/Invoke aufgerufen werden:

    [System.Runtime.InteropServices.DllImport("KERNEL32")]
    private static extern bool QueryPerformanceCounter(
        ref long lpPerformanceCount);

    [System.Runtime.InteropServices.DllImport("KERNEL32")]
    private static extern bool QueryPerformanceFrequency(
        ref long lpFrequency);

Sie rufen QueryPerformanceCounter kurz vor und direkt nach Ihrer Timingschleife auf, subtrahieren die Anzahl, multiplizieren sie mit 1,0e9, dividieren nach Häufigkeit, dividieren Sie durch die Anzahl der Iterationen, und das ist Ihre ungefähre Zeit pro Iteration in ns.

Aufgrund von Platz- und Zeiteinschränkungen haben wir keine Sperren, Ausnahmebehandlungen oder das Codezugriffssicherheitssystem behandelt. Betrachten Sie es als Übung für den Leser.

Übrigens habe ich die Disassemblies in diesem Artikel mithilfe des Disassemblierungsfensters in VS.NET 2003 erstellt. Es gibt jedoch einen Trick. Wenn Sie Ihre Anwendung im VS.NET Debugger ausführen, wird sie auch als optimierte ausführbare Datei ausgeführt, die im Releasemodus erstellt wurde, im "Debugmodus", in dem Optimierungen wie inlining deaktiviert sind. Die einzige Möglichkeit, einen Blick auf den optimierten nativen Code zu erhalten, den der JIT-Compiler ausgibt, bestand darin, meine Testanwendung außerhalb des Debuggers zu starten und sie dann mithilfe von Debug.Processes.Attach anzufügen.

Ein Raumkostenmodell?

Ironischerweise schließen Raumüberlegungen eine gründliche Diskussion des Raums aus. Ein paar kurze Absätze also.

Überlegungen auf niedriger Ebene (einige sind C# (Default TypeAttributes.SequentialLayout) und x86-spezifisch):

  • Die Größe eines Werttyps entspricht im Allgemeinen der Gesamtgröße seiner Felder, wobei 4-Byte- oder kleinere Felder an ihren natürlichen Grenzen ausgerichtet sind.
  • Es ist möglich, Attribute und [FieldOffset(n)] zu verwenden[StructLayout(LayoutKind.Explicit)], um Unions zu implementieren.
  • Die Größe eines Verweistyps beträgt 8 Bytes plus die Gesamtgröße seiner Felder, aufgerundet auf die nächste 4-Byte-Grenze und mit 4-Byte- oder kleineren Feldern, die an ihren natürlichen Grenzen ausgerichtet sind.
  • In C# können Enumerationsdeklarationen einen beliebigen integralen Basistyp (mit Ausnahme von char) angeben, sodass es möglich ist, 8-Bit-, 16-Bit-, 32-Bit- und 64-Bit-Enumerationen zu definieren.
  • Wie in C/C++ können Sie oft ein paar zehn Prozent des Platzes von einem größeren Objekt rasieren, indem Sie Die integralen Felder entsprechend dimensionieren.
  • Sie können die Größe eines zugeordneten Verweistyps mit dem CLR-Profiler überprüfen.
  • Große Objekte (viele Dutzend KB oder mehr) werden in einem separaten großen Objektheap verwaltet, um teures Kopieren auszuschließen.
  • Finalisierbare Objekte benötigen eine zusätzliche GC-Generation, um wiederzuerobern– verwenden Sie sie sparsam und erwägen Sie, das Dispose-Muster zu verwenden.

Überlegungen zu einem großen Gesamtbild:

  • Für jede AppDomain entsteht derzeit ein erheblicher Speicherplatzaufwand. Viele Runtime- und Frameworkstrukturen werden nicht appdomainsübergreifend freigegeben.
  • Innerhalb eines Prozesses wird jitted Code in der Regel nicht appdomainsübergreifend freigegeben. Wenn die Runtime speziell gehostet wird, ist es möglich, dieses Verhalten zu überschreiben. Weitere Informationen finden Sie in der Dokumentation für CorBindToRuntimeEx und das STARTUP_LOADER_OPTIMIZATION_MULTI_DOMAIN Flag.
  • In jedem Fall wird jitted Code nicht prozessübergreifend freigegeben. Wenn Sie über eine Komponente verfügen, die in viele Prozesse geladen wird, sollten Sie eine Vorkompilierung mit NGEN in Erwägung ziehen, um den nativen Code zu teilen.

Spiegelung

Es wurde gesagt, dass "Wenn Sie fragen müssen, was Reflexion kostet, können Sie es sich nicht leisten". Wenn Sie so weit gelesen haben, wissen Sie, wie wichtig es ist, sich zu fragen, was die Dinge kosten, und diese Kosten zu messen.

Reflektion ist nützlich und leistungsstark, aber im Vergleich zu jitted nativem Code ist sie weder schnell noch klein. Sie wurden gewarnt. Messen Sie es selbst.

Zusammenfassung

Jetzt wissen Sie (mehr oder weniger), was verwalteter Code auf der niedrigsten Ebene kostet. Sie verfügen jetzt über das grundlegende Verständnis, das erforderlich ist, um intelligentere Implementierungs-Kompromisse zu machen und schneller verwalteten Code zu schreiben.

Wir haben gesehen, dass jitted managed code so "pedal to the metal" wie nativer Code sein kann. Ihre Herausforderung besteht darin, mit Bedacht zu programmieren und die vielen umfangreichen und einfach zu verwendenden Einrichtungen im Framework mit Bedacht zu wählen.

Es gibt Einstellungen, in denen die Leistung keine Rolle spielt, und Einstellungen, in denen sie das wichtigste Feature eines Produkts ist. Vorzeitige Optimierung ist die Wurzel allen Übels. Aber so ist unachtsame Unaufmerksamkeit der Effizienz. Sie sind Profi, Künstler, Handwerker. Stellen Sie also sicher, dass Sie die Kosten der Dinge kennen. Wenn Sie es nicht wissen oder sogar, wenn Sie denken, dass Sie es tun , messen Sie es regelmäßig.

Was das CLR-Team betrifft, arbeiten wir weiterhin daran, eine Plattform bereitzustellen, die wesentlich produktiver als nativer Code ist und dennoch schneller als nativer Code ist. Erwarten Sie, dass die Dinge besser und besser werden. Halten Sie sich auf dem Laufenden.

Erinnern Sie sich an Ihr Versprechen.

Ressourcen