Dies könnte sich wie eine subjektive Frage anhören, aber ich suche nach bestimmten Fällen, auf die Sie im Zusammenhang damit gestoßen sein könnten.
Wie macht man Code, Cache effektiv / Cache-freundlich (mehr Cache-Treffer, so wenig Cache-Fehler wie möglich)? Aus beiden Perspektiven, Datencache und Programmcache (Anweisungscache), dh welche Dinge im eigenen Code, die sich auf Datenstrukturen und Codekonstrukte beziehen, sollte man sich darum kümmern, dass der Cache effektiv ist.
Gibt es bestimmte Datenstrukturen, die verwendet / vermieden werden müssen, oder gibt es eine bestimmte Möglichkeit, auf die Mitglieder dieser Struktur usw. zuzugreifen, um den Code-Cache effektiv zu machen?
Gibt es Programmkonstrukte (if, for, switch, break, goto, ...), Code-Flow (für innerhalb eines if, wenn innerhalb eines for, etc ...), die man in dieser Angelegenheit befolgen / vermeiden sollte?
Ich freue mich auf individuelle Erfahrungen im Zusammenhang mit der Erstellung von Cache-effizientem Code im Allgemeinen. Dies kann eine beliebige Programmiersprache (C, C ++, Assembly, ...), ein beliebiges Hardwareziel (ARM, Intel, PowerPC, ...), ein beliebiges Betriebssystem (Windows, Linux, S ymbian, ...) usw. sein. .
Die Vielfalt wird dazu beitragen, es besser zu verstehen.
quelle
Antworten:
Der Cache ist es die Anzahl der Male zu verringern die CPU abgewürgt würde für eine Speicheranforderung warten erfüllt werden (die Speicher Vermeidung Latenz ) und als zweiter Effekt, möglicherweise die Gesamtdatenmenge zu reduzieren , die (Konservieren werden übertragen muss Speicherbandbreite ).
Techniken zur Vermeidung von Speicherabrufverzögerungen sind in der Regel das erste, was in Betracht gezogen werden muss, und helfen manchmal auf lange Sicht. Die begrenzte Speicherbandbreite ist auch ein begrenzender Faktor, insbesondere für Multicores und Multithread-Anwendungen, bei denen viele Threads den Speicherbus verwenden möchten. Eine andere Reihe von Techniken hilft, das letztere Problem anzugehen.
Durch die Verbesserung der räumlichen Lokalität stellen Sie sicher, dass jede Cache-Zeile vollständig verwendet wird, sobald sie einem Cache zugeordnet wurde. Wenn wir uns verschiedene Standard-Benchmarks angesehen haben, haben wir festgestellt, dass ein überraschend großer Teil davon nicht 100% der abgerufenen Cache-Zeilen verwendet, bevor die Cache-Zeilen entfernt werden.
Die Verbesserung der Cache-Zeilenauslastung hilft in dreierlei Hinsicht:
Übliche Techniken sind:
Wir sollten auch beachten, dass es andere Möglichkeiten gibt, die Speicherlatenz zu verbergen, als Caches zu verwenden.
Moderne CPUs verfügen häufig über einen oder mehrere Hardware-Prefetchers . Sie trainieren die Fehler in einem Cache und versuchen, Regelmäßigkeiten zu erkennen. Beispielsweise beginnt der hw-Prefetcher nach einigen Fehlern bei nachfolgenden Cache-Zeilen mit dem Abrufen von Cache-Zeilen in den Cache, um die Anforderungen der Anwendung zu antizipieren. Wenn Sie ein reguläres Zugriffsmuster haben, leistet der Hardware-Prefetcher normalerweise sehr gute Arbeit. Und wenn Ihr Programm keine regulären Zugriffsmuster anzeigt, können Sie die Dinge verbessern, indem Sie selbst Vorabrufanweisungen hinzufügen .
Wenn die Anweisungen so umgruppiert werden, dass diejenigen, die immer im Cache fehlen, nahe beieinander auftreten, kann die CPU diese Abrufe manchmal überlappen, sodass die Anwendung nur einen Latenztreffer aushält ( Parallelität auf Speicherebene ).
Um den Gesamtspeicherbusdruck zu reduzieren, müssen Sie sich mit der sogenannten zeitlichen Lokalität befassen . Dies bedeutet, dass Sie Daten wiederverwenden müssen, solange sie noch nicht aus dem Cache entfernt wurden.
Das Zusammenführen von Schleifen, die dieselben Daten berühren ( Schleifenfusion ), und das Verwenden von Umschreibtechniken, die als Kacheln oder Blockieren bekannt sind, versuchen, diese zusätzlichen Speicherabrufe zu vermeiden.
Obwohl es für diese Übung zum Umschreiben einige Faustregeln gibt, müssen Sie in der Regel die Abhängigkeiten von Schleifendaten sorgfältig berücksichtigen, um sicherzustellen, dass Sie die Semantik des Programms nicht beeinflussen.
Diese Dinge zahlen sich in der Multicore-Welt wirklich aus, in der Sie nach dem Hinzufügen des zweiten Threads normalerweise keine großen Durchsatzverbesserungen feststellen.
quelle
Ich kann nicht glauben, dass es darauf keine weiteren Antworten gibt. Ein klassisches Beispiel ist jedenfalls das Iterieren eines mehrdimensionalen Arrays "von innen nach außen":
Der Grund dafür, dass der Cache ineffizient ist, liegt darin, dass moderne CPUs die Cache-Zeile mit "nahen" Speicheradressen aus dem Hauptspeicher laden, wenn Sie auf eine einzelne Speicheradresse zugreifen. Wir durchlaufen die "j" (äußeren) Zeilen im Array in der inneren Schleife, sodass die Cache-Zeile bei jedem Durchlauf durch die innere Schleife geleert und mit einer Adresszeile geladen wird, die sich in der Nähe von [befindet j] [i] Eintrag. Wenn dies auf das Äquivalent geändert wird:
Es wird viel schneller laufen.
quelle
Die Grundregeln sind eigentlich ziemlich einfach. Es wird schwierig, wie sie auf Ihren Code angewendet werden.
Der Cache arbeitet nach zwei Prinzipien: Zeitliche Lokalität und räumliche Lokalität. Ersteres ist die Idee, dass Sie einen bestimmten Datenblock wahrscheinlich bald wieder benötigen, wenn Sie ihn kürzlich verwendet haben. Letzteres bedeutet, dass Sie wahrscheinlich bald die Adresse X + 1 benötigen, wenn Sie die Daten kürzlich an Adresse X verwendet haben.
Der Cache versucht dies zu berücksichtigen, indem er sich an die zuletzt verwendeten Datenblöcke erinnert. Es arbeitet mit Cache-Zeilen, die normalerweise eine Größe von 128 Byte haben. Selbst wenn Sie nur ein einziges Byte benötigen, wird die gesamte Cache-Zeile, die es enthält, in den Cache gezogen. Wenn Sie danach das folgende Byte benötigen, befindet es sich bereits im Cache.
Dies bedeutet, dass Sie immer möchten, dass Ihr eigener Code diese beiden Lokalitätsformen so weit wie möglich ausnutzt. Springe nicht über den ganzen Speicher. Arbeiten Sie so viel wie möglich an einem kleinen Bereich und fahren Sie dann mit dem nächsten fort. Arbeiten Sie dort so viel wie möglich.
Ein einfaches Beispiel ist die 2D-Array-Durchquerung, die die Antwort von 1800 zeigte. Wenn Sie es zeilenweise durchlaufen, lesen Sie den Speicher nacheinander. Wenn Sie dies spaltenweise tun, lesen Sie einen Eintrag, springen dann zu einer völlig anderen Stelle (dem Anfang der nächsten Zeile), lesen einen Eintrag und springen erneut. Und wenn Sie endlich zur ersten Zeile zurückkehren, befindet sie sich nicht mehr im Cache.
Gleiches gilt für Code. Sprünge oder Verzweigungen bedeuten eine weniger effiziente Cache-Nutzung (da Sie die Anweisungen nicht nacheinander lesen, sondern zu einer anderen Adresse springen). Natürlich ändern kleine if-Anweisungen wahrscheinlich nichts (Sie überspringen nur ein paar Bytes, sodass Sie immer noch im zwischengespeicherten Bereich landen), aber Funktionsaufrufe implizieren normalerweise, dass Sie zu einem völlig anderen springen Adresse, die möglicherweise nicht zwischengespeichert wird. Es sei denn, es wurde kürzlich aufgerufen.
Die Verwendung des Anweisungscaches ist jedoch in der Regel weitaus weniger problematisch. Worüber Sie sich normalerweise Sorgen machen müssen, ist der Datencache.
In einer Struktur oder Klasse sind alle Mitglieder zusammenhängend angeordnet, was gut ist. In einem Array sind alle Einträge auch zusammenhängend angeordnet. In verknüpften Listen wird jeder Knoten an einem völlig anderen Ort zugewiesen, was schlecht ist. Zeiger verweisen im Allgemeinen auf nicht verwandte Adressen, was wahrscheinlich zu einem Cache-Fehler führt, wenn Sie ihn dereferenzieren.
Und wenn Sie mehrere Kerne ausnutzen möchten, kann dies sehr interessant werden, da normalerweise jeweils nur eine CPU eine bestimmte Adresse im L1-Cache hat. Wenn also beide Kerne ständig auf dieselbe Adresse zugreifen, führt dies zu ständigen Cache-Fehlern, da sie um die Adresse streiten.
quelle
Ich empfehle den 9-teiligen Artikel Was jeder Programmierer über Speicher von Ulrich Drepper wissen sollte, wenn Sie daran interessiert sind, wie Speicher und Software interagieren. Es ist auch als 104-seitiges PDF verfügbar .
Abschnitte, die für diese Frage besonders relevant sind, können Teil 2 (CPU-Caches) und Teil 5 (Was Programmierer tun können - Cache-Optimierung) sein.
quelle
Neben Datenzugriffsmuster, ein wichtiger Faktor im Cache- Kommandocode ist Datengröße . Weniger Daten bedeuten, dass mehr davon in den Cache passt.
Dies ist hauptsächlich ein Faktor bei speicherausgerichteten Datenstrukturen. "Konventionelle" Weisheit besagt, dass Datenstrukturen an Wortgrenzen ausgerichtet werden müssen, da die CPU nur auf ganze Wörter zugreifen kann. Wenn ein Wort mehr als einen Wert enthält, müssen Sie zusätzliche Arbeit leisten (Lesen-Ändern-Schreiben anstelle eines einfachen Schreibens). . Caches können dieses Argument jedoch vollständig ungültig machen.
In ähnlicher Weise verwendet ein Java-Boolesches Array ein ganzes Byte für jeden Wert, um die direkte Bearbeitung einzelner Werte zu ermöglichen. Sie können die Datengröße um den Faktor 8 reduzieren, wenn Sie tatsächliche Bits verwenden. Der Zugriff auf einzelne Werte wird jedoch viel komplexer und erfordert Bitverschiebungs- und Maskenoperationen (die
BitSet
Klasse erledigt dies für Sie). Aufgrund von Cache-Effekten kann dies jedoch immer noch erheblich schneller sein als die Verwendung eines Booleschen [], wenn das Array groß ist. IIRC I hat auf diese Weise einmal eine Beschleunigung um den Faktor 2 oder 3 erreicht.quelle
Die effektivste Datenstruktur für einen Cache ist ein Array. Caches funktionieren am besten, wenn Ihre Datenstruktur nacheinander angeordnet ist, während CPUs ganze Cache-Zeilen (normalerweise 32 Byte oder mehr) gleichzeitig aus dem Hauptspeicher lesen.
Jeder Algorithmus, der in zufälliger Reihenfolge auf den Speicher zugreift, verwirft die Caches, da immer neue Cache-Zeilen benötigt werden, um den Speicher mit dem zufälligen Zugriff aufzunehmen. Andererseits ist ein Algorithmus, der nacheinander durch ein Array läuft, am besten, weil:
Dies gibt der CPU die Möglichkeit, vorauszulesen, z. B. spekulativ mehr Speicher in den Cache zu stellen, auf den später zugegriffen wird. Dieses Vorauslesen sorgt für einen enormen Leistungsschub.
Wenn Sie eine enge Schleife über ein großes Array ausführen, kann die CPU auch den in der Schleife ausgeführten Code zwischenspeichern. In den meisten Fällen können Sie einen Algorithmus vollständig aus dem Cache-Speicher ausführen, ohne den externen Speicherzugriff blockieren zu müssen.
quelle
Ein Beispiel, das ich in einer Spiel-Engine gesehen habe, war das Verschieben von Daten aus Objekten in ihre eigenen Arrays. An ein Spielobjekt, das der Physik unterworfen war, sind möglicherweise auch viele andere Daten angehängt. Während der Physik-Update-Schleife kümmerte sich der Motor jedoch nur um Daten zu Position, Geschwindigkeit, Masse, Begrenzungsrahmen usw. All dies wurde in eigenen Arrays abgelegt und so weit wie möglich für SSE optimiert.
Während der Physikschleife wurden die Physikdaten in Array-Reihenfolge unter Verwendung von Vektormathematik verarbeitet. Die Spielobjekte verwendeten ihre Objekt-ID als Index für die verschiedenen Arrays. Es war kein Zeiger, da Zeiger ungültig werden könnten, wenn die Arrays verschoben werden müssten.
In vielerlei Hinsicht verletzte dies objektorientierte Entwurfsmuster, aber es machte den Code viel schneller, indem Daten nahe beieinander platziert wurden, die in denselben Schleifen bearbeitet werden mussten.
Dieses Beispiel ist wahrscheinlich veraltet, da ich davon ausgehe, dass die meisten modernen Spiele eine vorgefertigte Physik-Engine wie Havok verwenden.
quelle
Es wurde nur ein Beitrag darauf angesprochen, aber beim Austausch von Daten zwischen Prozessen tritt ein großes Problem auf. Sie möchten vermeiden, dass mehrere Prozesse gleichzeitig versuchen, dieselbe Cache-Zeile zu ändern. Hier ist auf "falsche" Freigabe zu achten, bei der zwei benachbarte Datenstrukturen eine Cache-Zeile gemeinsam nutzen und Änderungen an einer die Cache-Zeile für die andere ungültig machen. Dies kann dazu führen, dass Cache-Zeilen zwischen Prozessor-Caches, die die Daten auf einem Multiprozessorsystem gemeinsam nutzen, unnötig hin und her verschoben werden. Eine Möglichkeit, dies zu vermeiden, besteht darin, Datenstrukturen auszurichten und aufzufüllen, um sie in verschiedenen Zeilen zu platzieren.
quelle
Eine Bemerkung zum "klassischen Beispiel" von Benutzer 1800 INFORMATION (zu lang für einen Kommentar)
Ich wollte die Zeitunterschiede für zwei Iterationsreihenfolgen ("outter" und "inner") überprüfen, also machte ich ein einfaches Experiment mit einem großen 2D-Array:
und der zweite Fall mit den
for
getauschten Schleifen.Die langsamere Version ("x first") war 0,88 Sekunden und die schnellere war 0,06 Sekunden. Das ist die Kraft des Caching :)
Ich habe verwendet
gcc -O2
und trotzdem wurden die Loops nicht optimiert. Der Kommentar von Ricardo, dass "die meisten modernen Compiler dies selbst herausfinden können", trifft nicht zuquelle
Ich kann antworten (2), indem ich sage, dass in der C ++ - Welt verknüpfte Listen den CPU-Cache leicht zerstören können. Arrays sind nach Möglichkeit eine bessere Lösung. Keine Erfahrung darüber, ob dies auch für andere Sprachen gilt, aber es ist leicht vorstellbar, dass dieselben Probleme auftreten würden.
quelle
Der Cache ist in "Cache-Zeilen" angeordnet und der (echte) Speicher wird aus Blöcken dieser Größe gelesen und in diese geschrieben.
Datenstrukturen, die in einer einzelnen Cache-Zeile enthalten sind, sind daher effizienter.
In ähnlicher Weise sind Algorithmen, die auf zusammenhängende Speicherblöcke zugreifen, effizienter als Algorithmen, die in zufälliger Reihenfolge durch den Speicher springen.
Leider variiert die Größe der Cache-Zeile zwischen den Prozessoren erheblich, sodass nicht garantiert werden kann, dass eine auf einem Prozessor optimale Datenstruktur auf einem anderen Prozessor effizient ist.
quelle
Wenn Sie fragen, wie Sie einen Code erstellen, der für den Cache effektiv ist, und die meisten anderen Fragen, müssen Sie normalerweise fragen, wie Sie ein Programm optimieren. Dies liegt daran, dass der Cache einen so großen Einfluss auf die Leistung hat, dass jedes optimierte Programm ein Cache ist Effektiv-Cache-freundlich.
Ich schlage vor, über Optimierung zu lesen. Auf dieser Website gibt es einige gute Antworten. In Bezug auf Bücher empfehle ich zu Computersystemen: Eine Programmiererperspektive, die einen feinen Text über die ordnungsgemäße Verwendung des Caches enthält.
(btw - so schlimm wie eine Cache-Miss sein kann, gibt es noch schlimmer - wenn ein Programm Paging von der Festplatte ...)
quelle
Es gab viele Antworten auf allgemeine Hinweise wie Datenstrukturauswahl, Zugriffsmuster usw. Hier möchte ich ein weiteres Code-Entwurfsmuster hinzufügen, das als Software-Pipeline bezeichnet wird und die aktive Cache-Verwaltung nutzt.
Die Idee stammt aus anderen Pipelining-Techniken, z. B. dem Pipelining von CPU-Anweisungen.
Diese Art von Muster gilt am besten für Verfahren, die
Nehmen wir einen einfachen Fall, in dem es nur eine Unterprozedur gibt. Normalerweise möchte der Code:
Um eine bessere Leistung zu erzielen, möchten Sie möglicherweise mehrere Eingaben in einem Stapel an die Funktion übergeben, um den Aufwand für Funktionsaufrufe zu amortisieren und die Lokalität des Code-Cache zu erhöhen.
Wie bereits erwähnt, können Sie den Code weiter verbessern, wenn die Ausführung des Schritts in etwa der RAM-Zugriffszeit entspricht:
Der Ausführungsablauf würde folgendermaßen aussehen:
Es könnten mehr Schritte erforderlich sein, dann können Sie eine mehrstufige Pipeline entwerfen, solange das Timing der Schritte und die Latenz des Speicherzugriffs übereinstimmen und Sie nur wenig Code- / Daten-Cache-Fehler erleiden. Dieser Prozess muss jedoch mit vielen Experimenten abgestimmt werden, um die richtige Gruppierung von Schritten und die Vorabrufzeit herauszufinden. Aufgrund des erforderlichen Aufwands wird die Verarbeitung von Daten / Paketströmen mit hoher Leistung verstärkt. Ein gutes Beispiel für einen Produktionscode finden Sie im DPDK QoS Enqueue-Pipeline-Design: http://dpdk.org/doc/guides/prog_guide/qos_framework.html Kapitel 21.2.4.3. Pipeline in die Warteschlange stellen.
Weitere Informationen finden Sie unter:
https://software.intel.com/en-us/articles/memory-management-for-optimal-performance-on-intel-xeon-phi-coprocessor-alignment-and
http://infolab.stanford.edu/~ullman/dragon/w06/lectures/cs243-lec13-wei.pdf
quelle
Schreiben Sie Ihr Programm so, dass es eine minimale Größe hat. Aus diesem Grund ist es nicht immer eine gute Idee, -O3-Optimierungen für GCC zu verwenden. Es nimmt eine größere Größe ein. Oft ist -Os genauso gut wie -O2. Es hängt jedoch alles vom verwendeten Prozessor ab. YMMV.
Arbeiten Sie jeweils mit kleinen Datenblöcken. Aus diesem Grund können weniger effiziente Sortieralgorithmen schneller ausgeführt werden als Quicksort, wenn der Datensatz groß ist. Finden Sie Möglichkeiten, Ihre größeren Datensätze in kleinere aufzuteilen. Andere haben dies vorgeschlagen.
Um die zeitliche / räumliche Lokalität von Anweisungen besser auszunutzen, sollten Sie untersuchen, wie Ihr Code in Assembly konvertiert wird. Beispielsweise:
Die beiden Schleifen erzeugen unterschiedliche Codes, obwohl sie lediglich ein Array analysieren. In jedem Fall ist Ihre Frage sehr architekturspezifisch. Die einzige Möglichkeit, die Cache-Nutzung genau zu steuern, besteht darin, die Funktionsweise der Hardware zu verstehen und den Code dafür zu optimieren.
quelle
Neben der Ausrichtung Ihrer Struktur und Felder möchten Sie möglicherweise auch Allokatoren verwenden, die ausgerichtete Zuordnungen unterstützen, wenn Ihre Struktur Heap zugewiesen ist. wie _aligned_malloc (sizeof (DATA), SYSTEM_CACHE_LINE_SIZE); Andernfalls kann es zu einer zufälligen falschen Freigabe kommen. Denken Sie daran, dass der Standardheap in Windows eine Ausrichtung von 16 Byte hat.
quelle