Falsche gcc-generierte Baugruppenreihenfolge führt zu Leistungseinbußen

8

Ich habe den folgenden Code, der Daten aus dem Speicher in den DMA-Puffer kopiert:

%Vor%

So sieht gcc Assembly-Ausgabe aus:

%Vor%

Beachten Sie die Neuanordnung der letzten Anweisungen vmovdqa und vmovntdq . Mit dem oben generierten Code gcc kann ich in meiner Anwendung einen Durchsatz von ~ 10 227 571 Paketen pro Sekunde erreichen.

Als nächstes ordne ich diese Anweisungen manuell im Hexeditor an. Das bedeutet, dass die Schleife nun folgendermaßen aussieht:

%Vor%

Mit den richtig geordneten Anweisungen bekomme ich ~ 13 668 313 Pakete pro Sekunde. Es ist also offensichtlich, dass die von gcc eingeführte Neuordnung die Leistung verringert.

Haben Sie das bemerkt? Ist das ein bekannter Fehler oder sollte ich einen Fehlerbericht ausfüllen?

Kompilierungsflags:

%Vor%

Meine gcc-Version:

%Vor%     
Piotr Jurkiewicz 11.09.2014, 02:51
quelle

2 Antworten

10

Ich finde dieses Problem interessant. GCC ist dafür bekannt, weniger als optimalen Code zu produzieren, aber ich finde es faszinierend, Wege zu finden, um ihn zu "ermutigen", besseren Code zu produzieren (natürlich nur für den heißesten / Engpass-Code), ohne zu viel Micro-Management zu betreiben. In diesem speziellen Fall habe ich mir drei "Werkzeuge" angesehen, die ich für solche Situationen verwende:

  • volatile : Wenn es wichtig ist, dass die Speicherzugriffe in einer bestimmten Reihenfolge erfolgen, ist volatile ein geeignetes Werkzeug. Beachten Sie, dass es übertrieben sein kann und jedes Mal, wenn ein volatile -Zeiger dereferenziert wird, zu einer separaten Ladung führt.

    SSE / AVX laden / speichern intrinsics kann nicht mit volatile Zeigern verwendet werden, da sie Funktionen sind. Wenn Sie etwas wie _mm256_load_si256((volatile __m256i *)src); verwenden, wird dies implizit auf const __m256i* und das% code_%% -Qualifier ausgegeben.

    Wir können flüchtige Zeiger jedoch direkt dereferenzieren. (Laden / Speichern von Eigenschaftsdaten werden nur benötigt, wenn wir dem Compiler mitteilen müssen, dass die Daten möglicherweise nicht ausgerichtet sind oder dass wir einen Streaming-Speicher benötigen.)

    %Vor%

    Leider hilft das bei den Stores nicht, weil wir Streaming-Stores ausgeben wollen. A volatile gibt uns nicht, was wir wollen.

  • *(volatile...)dst = tmp; als Compiler-Umordnungsbarriere.

    Dies ist das GNU C war eine Compiler-Speicher-Barriere zu schreiben. (Die Neuordnung der Kompilierungszeit wird angehalten, ohne dass eine tatsächliche Barrierebefehlsanweisung wie __asm__ __volatile__ (""); ausgegeben wird). Es verhindert, dass der Compiler Speicherzugriffe über diese Anweisung neu anordnet.

  • Verwenden eines Indexlimits für Schleifenstrukturen.

    GCC ist für eine ziemlich schlechte Registerbenutzung bekannt. Frühere Versionen machten viele unnötige Bewegungen zwischen den Registern, obwohl das heutzutage ziemlich minimal ist. Das Testen von x86-64 über viele GCC-Versionen hinweg zeigt jedoch, dass es in Schleifen besser ist, für optimale Ergebnisse eine Indexgrenze als eine unabhängige Schleifenvariable zu verwenden.

Kombiniere alles oben, konstruierte ich die folgende Funktion (nach ein paar Iterationen):

%Vor%

Kompilieren Sie es ( mfence ) mit GCC-4.8.4 mit

%Vor%

ergibt ( example.c ):

%Vor%

Die Demontage des tatsächlich kompilierten ( example.s anstelle von -c ) Codes ist

%Vor%

Ohne irgendwelche Optimierungen ist der Code völlig widerlich, voll von unnötigen Bewegungen, weshalb einige Optimierungen notwendig sind. (Das obige Beispiel verwendet -S , was im Allgemeinen die von mir verwendete Optimierungsstufe ist.)

Wenn Sie die Größe optimieren ( -O2 ), sieht der Code auf den ersten Blick ausgezeichnet aus,

%Vor%

bis Sie bemerken, dass die letzte -Os für den Vergleich ist, im Wesentlichen eine jmp , jmp und eine cmp bei jeder Iteration, was wahrscheinlich ziemlich schlechte Ergebnisse ergibt.

Hinweis: Wenn Sie etwas Ähnliches für echten Code tun, fügen Sie bitte Kommentare hinzu (speziell für jae ) und denken Sie daran, regelmäßig mit allen verfügbaren Compilern zu überprüfen, um sicherzustellen, dass der Code nicht zu schlecht kompiliert wird any.

Wenn ich Peter Cordes 'ausgezeichnete Antwort ansehe, habe ich beschlossen, die Funktion ein wenig weiter zu machen, nur zum Spaß.

Wie Ross Ridge in den Kommentaren erwähnt, wird bei der Verwendung von __asm__ __volatile__ (""); der Zeiger nicht dereferenziert (bevor er in den ausgerichteten _mm256_load_si256() als Parameter der Funktion umgewandelt wird), daher wird __m256i * nicht helfen mit volatile . In einem weiteren Kommentar schlägt Seb eine Problemumgehung vor: _mm256_load_si256() , die die Funktion mit einem Zeiger auf _mm256_load_si256((__m256i []){ *(volatile __m256i *)(src) }) versorgt, indem sie über einen flüchtigen Zeiger auf das Element zugreift und es in ein Array umwandelt. Für eine einfache ausgerichtete Last bevorzuge ich den direkten flüchtigen Zeiger; es entspricht meiner Absicht im Code. (Ich ziele auf KISS, obwohl ich oft nur den blöden Teil davon getroffen habe.)

Auf x86-64 ist der Anfang der inneren Schleife auf 16 Bytes ausgerichtet, so dass die Anzahl der Operationen im Funktionsteil "header" nicht wirklich wichtig ist. Dennoch ist die Vermeidung des überflüssigen binären AND (das Maskieren der fünf niedrigstwertigen Bits des zu kopierenden Betrags in Bytes) sicherlich im Allgemeinen nützlich.

GCC bietet zwei Optionen dafür. Eines ist die src -Installation, die es einem Programmierer ermöglicht, alle Arten der Ausrichtung zu übermitteln Informationen zum Compiler. Der andere Typ gibt einen Typ mit zusätzlichen Attributen an, hier __builtin_assume_aligned() , der zum Beispiel dazu verwendet werden kann, die Ausrichtung von Funktionsparametern zu vermitteln. Beide sollten in clang verfügbar sein (obwohl die Unterstützung aktuell ist, noch nicht in 3.5) und möglicherweise in anderen wie icc verfügbar ist (obwohl ICC, AFAIK __attribute__((aligned (32))) verwendet).

Eine Möglichkeit, die Registerumordnung, die GCC durchführt, abzuschwächen, ist die Verwendung einer Hilfsfunktion. Nach einigen weiteren Iterationen bin ich bei __assume_aligned() :

angekommen %Vor%

was im Wesentlichen mit another.c kompiliert wird (Kommentare und Anweisungen werden der Kürze halber weggelassen):

%Vor%

Weitere Optimierung bei gcc -march=x86-64 -mtune=generic -mavx2 -O2 -S another.c schließt nur die Hilfsfunktion ein,

%Vor%

und sogar mit -O3 ist der generierte Code sehr schön,

%Vor%

Natürlich ohne Optimierungen GCC-4.8.4 produziert immer noch ziemlich schlechten Code. Mit -Os und clang-3.5 -march=x86-64 -mtune=generic -mavx2 -O2 erhalten wir im Wesentlichen

%Vor%

Ich mag den -Os -Code (es passt zu meinem Codierungsstil), und ich bin glücklich mit dem Code, der von GCC-4.8.4 und clang-3.5 erzeugt wurde, bei another.c , -O1 , -O2 , und -O3 auf beiden, also denke ich, dass es gut genug für mich ist. (Beachten Sie jedoch, dass ich keinen dieser Punkte bewertet habe, da ich nicht über den entsprechenden Code verfüge. Wir verwenden sowohl temporäre als auch nicht-temporäre (nt) Speicherzugriffe und Cache-Verhalten (und Cache-Interaktion mit der Umgebung) Code) ist für solche Dinge von größter Bedeutung, also würde es keinen Sinn machen, dies zu mikrobenbenken, denke ich.)

    
Nominal Animal 02.02.2016 07:41
quelle
5

Zuallererst verwenden normale Leute gcc -O3 -march=native -S und bearbeiten dann die .s , um kleine Änderungen an der Compilerausgabe zu testen. Ich hoffe, du hattest Spaß dabei, diese Änderung zu hexen. : P Sie könnten auch Agner Fogs exzellenten objconv verwenden, um eine Disassemblierung zu erstellen, die mit der von Ihnen gewählten NASM-, YASM-, MASM- oder AT & T-Syntax wieder zusammengesetzt werden kann.

Mit einigen der gleichen Ideen wie "Nominal Animal" habe ich eine Version erstellt, die ähnlich gute Ergebnisse liefert . Ich bin zuversichtlich, dass warum es zu gutem Code kompiliert, und ich habe eine Ahnung, warum die Reihenfolge so wichtig ist:

CPUs haben nur ein paar (~ 10?) Schreib-kombinierende Füllpuffer für NT Loads / speichert .

Siehe diesen Artikel zum Kopieren von Videospeicher mit Streaming-Ladungen und Schreiben in den Hauptspeicher mit Streaming-Speichern . Es ist tatsächlich schneller, die Daten durch einen kleinen Puffer (viel kleiner als L1) zu prellen, um zu vermeiden, dass die Streaming-Ladungen und Streaming-Speicher um Füllpuffer konkurrieren (insbesondere bei der Out-of-Order-Ausführung). Beachten Sie, dass die Verwendung von "Streaming" -NT-Ladeoperationen aus dem normalen Speicher nicht sinnvoll ist. Wie ich es verstehe, sind Streaming-Ladungen nur nützlich für I / O (einschließlich Sachen wie Video-RAM, die in den Adressraum der CPU in einer Uncacheable Software-Write-Combining (USWC) -Region abgebildet werden). Der Hauptspeicher-RAM ist WB (Writeback) zugeordnet, so dass die CPU es im Gegensatz zu USWC spekulativ vorab abholen und zwischenspeichern kann. Wie auch immer, obwohl ich einen Artikel über die Verwendung von Streaming-Loads verlinke, stimme ich nicht dazu zu, Streaming-Loads zu verwenden . Es ist nur um zu veranschaulichen, dass die Konkurrenz für Füllpuffer mit ziemlicher Sicherheit der Grund dafür ist, dass der seltsame Code von gcc ein großes Problem verursacht, wo er bei normalen Nicht-NT-Speichern nicht auftreten würde.

Siehe auch John McAlpins Kommentar am Ende von diesem Thread , als eine weitere Quelle, die bestätigt, dass WC auf mehrere Cache-Zeilen gleichzeitig speichert, kann eine große Verlangsamung sein.

Die Ausgabe von gcc für Ihren ursprünglichen Code (aus irgendeinem Grund, den ich mir nicht vorstellen kann), enthielt die zweite Hälfte der ersten Cacheline, dann beide Hälften der zweiten Cacheline, dann die erste Hälfte der ersten Cacheline. Wahrscheinlich wurde manchmal der Schreib-Kombinationspuffer für die erste Cachezeile gelöscht, bevor beide Hälften geschrieben wurden, was zu einer weniger effizienten Verwendung von externen Bussen führte.

clang macht keine seltsame Nachbestellung mit einer unserer 3 Versionen (Mine, OP's und Nominal Animals).

Wie auch immer, mit Compiler-Only-Barrieren, die die Neuanordnung des Compilers stoppen , aber nicht emittieren Eine Barriereanweisung ist eine Möglichkeit, sie zu stoppen. In diesem Fall ist es eine Möglichkeit, den Compiler über den Kopf zu schlagen und "dummer Compiler, tu das nicht" zu sagen. Ich denke nicht, dass du das normalerweise überall machen solltest, aber klarerweise kannst du gcc nicht mit Schreibkombinationsläden vertrauen (wo das Bestellen wirklich wichtig ist). Es ist also wahrscheinlich eine gute Idee, sich den Asm zumindest mit dem Compiler anzuschauen, mit dem man gerade arbeitet, wenn man NT Loads und / oder Stores benutzt. Ich habe dies für gcc gemeldet . Richard Biener weist darauf hin, dass -fno-schedule-insns2 eine Art Workaround ist.

Linux (der Kernel) hat bereits einen barrier() -Makro, der als Compiler-Speicherbarriere fungiert. Es ist fast sicher nur ein GNU asm volatile("") . Außerhalb von Linux können Sie weiterhin diese GNU-Erweiterung verwenden oder Sie können die C11 stdatomic.h -Funktionen verwenden. Sie sind im Grunde die gleichen wie die C ++ 11 std::atomic -Funktionen, mit AFAIK identische Semantik (Gott sei Dank).

Ich lege eine Schranke zwischen jeden Laden, weil sie frei sind, wenn sowieso keine sinnvolle Nachbestellung möglich ist. Es stellt sich heraus, dass nur eine Barriere innerhalb der Schleife alles in Ordnung hält, was die Antwort von Nominal Animal tut. Es verbietet dem Compiler nicht wirklich, Speicher neu zu ordnen, die keine Barriere haben, die sie trennt; der Compiler hat es einfach nicht gewählt. Deshalb habe ich zwischen jedem Geschäft gehindert.

Ich habe den Compiler nur nach einer Schreibbarriere gefragt, weil ich erwarte, dass nur die Reihenfolge der NT-Speicher zählt, nicht die Lasten. Selbst wechselnde Lade- und Speicheranweisungen wären wahrscheinlich egal, da die Ausführung von OOO alles sowieso pipeliert. (Beachten Sie, dass der Intel Copy-from-Video-mem-Artikel sogar mfence verwendet hat, um Überlappungen zwischen Streaming-Speichern und Streaming-Ladevorgängen zu vermeiden.)

atomic_signal_fence dokumentiert nicht direkt, was alle verschiedenen Speicherordnungsoptionen damit tun. Die C ++ Seite für atomic_thread_fence ist der einzige Ort auf cppreference, wo es Beispiele und mehr dazu gibt.

Dies ist der Grund, warum ich die Idee von Nominaltier nicht benutzt habe, um src als Zeiger-flüchtig zu deklarieren. gcc entscheidet sich dafür, die Lasten in der gleichen Reihenfolge wie Geschäfte zu halten.

Vorausgesetzt, dass das Abrollen nur um 2 wahrscheinlich keinen Durchsatzunterschied in Mikrobenchmarks verursacht, wird in der Produktion überschüssiger Cachespeicherplatz gespart. Jede Iteration würde immer noch eine vollständige Cache-Zeile machen, was gut aussieht.

CPUs der SnB-Familie können die 2-Kanal-Adressierungsmodi nicht mikrosicher schalten Daher funktioniert die naheliegende Methode, Schleifenoverhead zu minimieren (Zeiger auf das Ende von src und dst zu bekommen und dann einen negativen Index gegen Null zu zählen), nicht. Die Geschäfte würden nicht mikrofusionieren. Sie würden sehr schnell die Füllpuffer bis zu dem Punkt füllen, wo die zusätzlichen Ups sowieso keine Rolle spielen. Diese Schleife läuft wahrscheinlich nicht annähernd 4 Ups pro Zyklus.

Trotzdem gibt es eine Möglichkeit, den Schleifen-Overhead zu reduzieren: mit meinem lächerlich hässlichen und unlesbaren C-Hack, um den Compiler dazu zu bringen, nur einen sub (und einen cmp/jcc ) als Loop-Overhead auszuführen, nein das Abwickeln würde überhaupt eine 4-Uop-Schleife ergeben, die bei einer Iteration pro Takt sogar auf SnB ausgegeben werden sollte. (Beachten Sie, dass vmovntdq AVX2 ist, während vmovntps nur AVX1 ist. Clang verwendet bereits vmovaps / vmovntps für die si256 intrinsics in diesem Code! Sie haben die gleiche Ausrichtungsanforderung, und egal, was Bits speichern sie. Es speichert keine Insn Bytes, nur Kompatibilität.)

Siehe den ersten Absatz für einen Gottbolt-Link dazu.

Ich vermutete, dass Sie das innerhalb des Linux-Kernels gemacht haben, also habe ich #ifdef s eingegeben, damit dies als Kernel-Code korrekt ist oder wenn es für User-Space kompiliert wird.

%Vor%

Es kompiliert zu (gcc 5.3.0 -O3 -march=haswell ):

%Vor%

Clang macht eine sehr ähnliche Schleife, aber das Intro ist viel länger: clang geht nicht davon aus, dass src und dest tatsächlich beide ausgerichtet sind. Vielleicht nutzt es nicht das Wissen, dass die Lasten und Speicher fehlerhaft sind, wenn sie nicht 32B-ausgerichtet sind? (Es weiß, dass es ...aps -Anweisungen anstelle von ...dqa verwenden kann, also macht es sicherlich mehr Compiler-Stil-Optimierung von intrinsics, dass gcc (wo sie häufiger immer in die entsprechende Anweisung verwandeln). Clang kann ein Paar von links / der rechte Vektor verschiebt sich beispielsweise von einer Konstanten in eine Maske.)

    
Peter Cordes 02.02.2016 10:43
quelle