So beschleunigen Sie Ihren Code mithilfe von CPU-Caches

Der Cache der CPU reduziert die Speicherlatenz, wenn auf Daten aus dem Hauptsystemspeicher zugegriffen wird. Entwickler können und sollten den CPU-Cache nutzen, um die Anwendungsleistung zu verbessern.

Wie CPU-Caches funktionieren

Moderne CPUs haben normalerweise drei Cache-Ebenen mit den Bezeichnungen L1, L2 und L3, die die Reihenfolge widerspiegeln, in der die CPU sie überprüft. CPUs haben häufig einen Datencache, einen Anweisungscache (für Code) und einen einheitlichen Cache (für alles). Der Zugriff auf diese Caches ist viel schneller als der Zugriff auf den RAM: Normalerweise ist der L1-Cache etwa 100-mal schneller als der RAM für den Datenzugriff, und der L2-Cache ist 25-mal schneller als der RAM für den Datenzugriff.

Wenn Ihre Software ausgeführt wird und Daten oder Anweisungen abrufen muss, werden zuerst die CPU-Caches überprüft, dann der langsamere System-RAM und schließlich die viel langsameren Festplatten. Aus diesem Grund möchten Sie Ihren Code optimieren, um zuerst zu ermitteln, was wahrscheinlich aus dem CPU-Cache benötigt wird.

Ihr Code kann nicht angeben, wo sich Datenanweisungen und Daten befinden - die Computerhardware übernimmt dies -, sodass Sie bestimmte Elemente nicht in den CPU-Cache zwingen können. Sie können Ihren Code jedoch optimieren, um die Größe des L1-, L2- oder L3-Caches in Ihrem System mithilfe von Windows Management Instrumentation (WMI) abzurufen und zu optimieren, wann Ihre Anwendung auf den Cache zugreift und damit dessen Leistung.

CPUs greifen niemals byteweise auf den Cache zu. Stattdessen lesen sie den Speicher in Cache-Zeilen, die im Allgemeinen 32, 64 oder 128 Byte groß sind.

Die folgende Codeliste zeigt, wie Sie die L2- oder L3-CPU-Cache-Größe in Ihrem System abrufen können:

public static uint GetCPUCacheSize (Zeichenfolge cacheType) {try {using (ManagementObject managementObject = neues ManagementObject ("Win32_Processor.DeviceID = 'CPU0'")) {return (uint) (managementObject [cacheType]); }} catch {return 0; }} static void Main (string [] args) {uint L2CacheSize = GetCPUCacheSize ("L2CacheSize"); uint L3CacheSize = GetCPUCacheSize ("L3CacheSize"); Console.WriteLine ("L2CacheSize:" + L2CacheSize.ToString ()); Console.WriteLine ("L3CacheSize:" + L3CacheSize.ToString ()); Console.Read (); }}

Microsoft verfügt über zusätzliche Dokumentation zur WMI-Klasse Win32_Processor.

Programmierung für Leistung: Beispielcode

Wenn Sie Objekte im Stapel haben, entsteht kein Speicherbereinigungsaufwand. Wenn Sie Heap-basierte Objekte verwenden, sind mit der generellen Garbage Collection immer Kosten verbunden, um Objekte im Heap zu sammeln oder zu verschieben oder den Heap-Speicher zu komprimieren. Eine gute Möglichkeit, den Aufwand für die Speicherbereinigung zu vermeiden, besteht darin, Strukturen anstelle von Klassen zu verwenden.

Caches funktionieren am besten, wenn Sie eine sequentielle Datenstruktur verwenden, z. B. ein Array. Durch die sequentielle Reihenfolge kann die CPU vorauslesen und auch spekulativ vorauslesen, in Erwartung dessen, was als nächstes wahrscheinlich angefordert wird. Somit ist ein Algorithmus, der sequentiell auf den Speicher zugreift, immer schnell.

Wenn Sie in zufälliger Reihenfolge auf den Speicher zugreifen, benötigt die CPU bei jedem Zugriff auf den Speicher neue Cache-Zeilen. Das reduziert die Leistung.

Das folgende Codefragment implementiert ein einfaches Programm, das die Vorteile der Verwendung einer Struktur gegenüber einer Klasse veranschaulicht:

 struct RectangleStruct {public int width; öffentliche int Höhe; } class RectangleClass {public int width; öffentliche int Höhe; }}

Der folgende Code beschreibt die Leistung der Verwendung eines Arrays von Strukturen gegenüber einem Array von Klassen. Zur Veranschaulichung habe ich eine Million Objekte für beide verwendet, aber normalerweise benötigen Sie nicht so viele Objekte in Ihrer Anwendung.

statische Leere Main (string [] args) {const int size = 1000000; var structs = new RectangleStruct [Größe]; var classes = new RectangleClass [Größe]; var sw = neue Stoppuhr (); sw.Start (); für (var i = 0; i <Größe; ++ i) {structs [i] = new RectangleStruct (); Strukturen [i] .Breite = 0 Strukturen [i]. Höhe = 0; } var structTime = sw.ElapsedMilliseconds; sw.Reset (); sw.Start (); für (var i = 0; i <Größe; ++ i) {classes [i] = new RectangleClass (); Klassen [i] .Breite = 0; Klassen [i]. Höhe = 0; } var classTime = sw.ElapsedMilliseconds; sw.Stop (); Console.WriteLine ("Zeit, die ein Array von Klassen benötigt:" + classTime.ToString () + "Millisekunden."); Console.WriteLine ("Zeit, die ein Array von Strukturen benötigt:" + structTime.ToString () + "Millisekunden."); Console.Read (); }}

Das Programm ist einfach: Es erstellt 1 Million Objekte von Strukturen und speichert sie in einem Array. Außerdem werden 1 Million Objekte einer Klasse erstellt und in einem anderen Array gespeichert. Der Breite und Höhe der Eigenschaften wird für jede Instanz der Wert Null zugewiesen.

Wie Sie sehen können, bietet die Verwendung von Cache-freundlichen Strukturen einen enormen Leistungsgewinn.

Faustregeln für eine bessere CPU-Cache-Nutzung

Wie schreibt man Code, der den CPU-Cache am besten nutzt? Leider gibt es keine Zauberformel. Es gibt jedoch einige Faustregeln:

  • Vermeiden Sie die Verwendung von Algorithmen und Datenstrukturen, die unregelmäßige Speicherzugriffsmuster aufweisen. Verwenden Sie stattdessen lineare Datenstrukturen.
  • Verwenden Sie kleinere Datentypen und organisieren Sie die Daten so, dass keine Ausrichtungslöcher vorhanden sind.
  • Berücksichtigen Sie die Zugriffsmuster und nutzen Sie lineare Datenstrukturen.
  • Verbessern Sie die räumliche Lokalität, indem jede Cache-Zeile maximal verwendet wird, sobald sie einem Cache zugeordnet wurde.