Warum würde die Einführung nutzloser MOV-Anweisungen eine enge Schleife in der x86_64-Assembly beschleunigen?

222

Hintergrund:

Beim Optimieren von Pascal- Code mit eingebetteter Assemblersprache bemerkte ich eine unnötige MOVAnweisung und entfernte sie.

Zu meiner Überraschung wurde mein Programm durch das Entfernen der nicht erforderlichen Anweisungen langsamer .

Ich fand heraus, dass das Hinzufügen beliebiger, nutzloser MOVAnweisungen die Leistung noch weiter steigerte .

Der Effekt ist unregelmäßig und ändert sich je nach Ausführungsreihenfolge: Dieselben Junk-Anweisungen , die von einer einzelnen Zeile nach oben oder unten transponiert werden, führen zu einer Verlangsamung .

Ich verstehe, dass die CPU alle Arten von Optimierungen und Rationalisierungen vornimmt, aber dies scheint eher schwarze Magie zu sein.

Die Daten:

Eine Version meines Codes kompiliert bedingt drei Junk-Operationen in der Mitte einer Schleife, die 2**20==1048576mal ausgeführt wird. (Das umgebende Programm berechnet nur SHA-256- Hashes).

Die Ergebnisse auf meiner ziemlich alten Maschine (Intel (R) Core (TM) 2 CPU 6400 bei 2,13 GHz):

avg time (ms) with -dJUNKOPS: 1822.84 ms
avg time (ms) without:        1836.44 ms

Die Programme wurden 25 Mal in einer Schleife ausgeführt, wobei sich die Ausführungsreihenfolge jedes Mal zufällig änderte.

Auszug:

{$asmmode intel}
procedure example_junkop_in_sha256;
  var s1, t2 : uint32;
  begin
    // Here are parts of the SHA-256 algorithm, in Pascal:
    // s0 {r10d} := ror(a, 2) xor ror(a, 13) xor ror(a, 22)
    // s1 {r11d} := ror(e, 6) xor ror(e, 11) xor ror(e, 25)
    // Here is how I translated them (side by side to show symmetry):
  asm
    MOV r8d, a                 ; MOV r9d, e
    ROR r8d, 2                 ; ROR r9d, 6
    MOV r10d, r8d              ; MOV r11d, r9d
    ROR r8d, 11    {13 total}  ; ROR r9d, 5     {11 total}
    XOR r10d, r8d              ; XOR r11d, r9d
    ROR r8d, 9     {22 total}  ; ROR r9d, 14    {25 total}
    XOR r10d, r8d              ; XOR r11d, r9d

    // Here is the extraneous operation that I removed, causing a speedup
    // s1 is the uint32 variable declared at the start of the Pascal code.
    //
    // I had cleaned up the code, so I no longer needed this variable, and 
    // could just leave the value sitting in the r11d register until I needed
    // it again later.
    //
    // Since copying to RAM seemed like a waste, I removed the instruction, 
    // only to discover that the code ran slower without it.
    {$IFDEF JUNKOPS}
    MOV s1,  r11d
    {$ENDIF}

    // The next part of the code just moves on to another part of SHA-256,
    // maj { r12d } := (a and b) xor (a and c) xor (b and c)
    mov r8d,  a
    mov r9d,  b
    mov r13d, r9d // Set aside a copy of b
    and r9d,  r8d

    mov r12d, c
    and r8d, r12d  { a and c }
    xor r9d, r8d

    and r12d, r13d { c and b }
    xor r12d, r9d

    // Copying the calculated value to the same s1 variable is another speedup.
    // As far as I can tell, it doesn't actually matter what register is copied,
    // but moving this line up or down makes a huge difference.
    {$IFDEF JUNKOPS}
    MOV s1,  r9d // after mov r12d, c
    {$ENDIF}

    // And here is where the two calculated values above are actually used:
    // T2 {r12d} := S0 {r10d} + Maj {r12d};
    ADD r12d, r10d
    MOV T2, r12d

  end
end;

Versuch es selber:

Der Code ist online bei GitHub, wenn Sie ihn selbst ausprobieren möchten.

Meine Fragen:

  • Warum sollte das unnötige Kopieren des Inhalts eines Registers in den Arbeitsspeicher jemals die Leistung steigern?
  • Warum sollte dieselbe nutzlose Anweisung in einigen Zeilen eine Beschleunigung und in anderen eine Verlangsamung bewirken?
  • Ist dieses Verhalten etwas, das von einem Compiler vorhersehbar ausgenutzt werden könnte?
Tangentensturm
quelle
7
Es gibt alle Arten von "nutzlosen" Anweisungen, die tatsächlich dazu dienen können, Abhängigkeitsketten zu durchbrechen, physische Register als in den Ruhestand versetzt zu markieren usw. Die Nutzung dieser Vorgänge erfordert einige Kenntnisse der Mikroarchitektur . Ihre Frage sollte eine kurze Folge von Anweisungen als minimales Beispiel enthalten, anstatt die Leute zum Github zu führen.
Brett Hale
1
@ BrettHale guter Punkt, danke. Ich habe einen Code-Auszug mit einigen Kommentaren hinzugefügt. Würde das Kopieren des Wertes eines Registers in den RAM das Register als zurückgezogen markieren, selbst wenn der darin enthaltene Wert später verwendet wird?
Tangentstorm
9
Können Sie die Standardabweichung auf diese Durchschnittswerte setzen? In diesem Beitrag gibt es keinen tatsächlichen Hinweis darauf, dass es einen echten Unterschied gibt.
Starwed
2
Können Sie bitte versuchen, die Anweisungen mit der Anweisung rdtscp zeitlich abzustimmen und die Taktzyklen für beide Versionen zu überprüfen?
Jakobbotsch
2
Kann es auch an der Speicherausrichtung liegen? Ich habe nicht selbst
gerechnet

Antworten:

144

Die wahrscheinlichste Ursache für die Geschwindigkeitsverbesserung ist:

  • Durch das Einfügen eines MOV werden die nachfolgenden Anweisungen auf verschiedene Speicheradressen verschoben
  • Eine dieser verschobenen Anweisungen war ein wichtiger bedingter Zweig
  • Dieser Zweig wurde aufgrund von Aliasing in der Zweigvorhersage-Tabelle falsch vorhergesagt
  • Durch Verschieben des Zweigs wurde der Alias ​​entfernt und die korrekte Vorhersage des Zweigs ermöglicht

Ihr Core2 führt nicht für jeden bedingten Sprung einen separaten Verlaufsdatensatz. Stattdessen wird ein gemeinsamer Verlauf aller bedingten Sprünge gespeichert. Ein Nachteil der globalen Verzweigungsvorhersage besteht darin, dass der Verlauf durch irrelevante Informationen verwässert wird, wenn die verschiedenen bedingten Sprünge nicht korreliert sind.

Dieses kleine Tutorial zur Verzweigungsvorhersage zeigt, wie Verzweigungsvorhersagepuffer funktionieren. Der Cache-Puffer wird durch den unteren Teil der Adresse des Verzweigungsbefehls indiziert. Dies funktioniert gut, es sei denn, zwei wichtige unkorrelierte Zweige teilen sich die gleichen unteren Bits. In diesem Fall kommt es zu einem Aliasing, das viele falsch vorhergesagte Verzweigungen verursacht (wodurch die Anweisungspipeline blockiert und Ihr Programm verlangsamt wird).

Wenn Sie wissen möchten, wie sich Branchenvorhersagen auf die Leistung auswirken, sehen Sie sich diese hervorragende Antwort an: https://stackoverflow.com/a/11227902/1001643

Compiler verfügen normalerweise nicht über genügend Informationen, um zu wissen, welche Zweige einen Alias ​​haben und ob diese Aliase von Bedeutung sind. Diese Informationen können jedoch zur Laufzeit mit Tools wie Cachegrind und VTune ermittelt werden .

Raymond Hettinger
quelle
2
Hmm. Das klingt vielversprechend. Die einzigen bedingten Verzweigungen in dieser sha256-Implementierung sind die Überprüfungen für das Ende der FOR-Schleifen. Zu der Zeit hatte ich diese Revision als eine Kuriosität in Git markiert und weiter optimiert. Einer meiner nächsten Schritte bestand darin, die pascal FOR-Schleife selbst in der Montage neu zu schreiben. Zu diesem Zeitpunkt wirkten sich diese zusätzlichen Anweisungen nicht mehr positiv aus. Vielleicht war der generierte Code von Free Pascal für den Prozessor schwerer vorherzusagen als der einfache Zähler, durch den ich ihn ersetzt habe.
Tangentstorm
1
@tangentstorm Das klingt nach einer guten Zusammenfassung. Die Verzweigungsvorhersage-Tabelle ist nicht sehr groß, sodass sich ein Tabelleneintrag möglicherweise auf mehr als eine Verzweigung bezieht. Dies kann einige Vorhersagen unbrauchbar machen. Das Problem kann leicht behoben werden, wenn einer der in Konflikt stehenden Zweige in einen anderen Teil der Tabelle verschoben wird. Fast jede kleine Änderung kann dies bewirken :-)
Raymond Hettinger
1
Ich denke, dies ist die vernünftigste Erklärung für das spezifische Verhalten, das ich beobachtet habe, daher werde ich dies als Antwort markieren. Vielen Dank. :)
Tangentstorm
3
Es gibt eine absolut ausgezeichnete Diskussion über ein ähnliches Problem, auf das einer der Mitwirkenden an Bochs gestoßen ist. Vielleicht möchten Sie dies zu Ihrer Antwort hinzufügen: emulators.com/docs/nx25_nostradamus.htm
leander
3
Insn-Ausrichtung ist viel mehr als nur Verzweigungsziele. Dekodierungsengpässe sind ein großes Problem für Core2 und Nehalem: Es fällt oft schwer, die Ausführungseinheiten zu beschäftigen. Sandybridges Einführung des UOP-Cache erhöhte den Frontend-Durchsatz erheblich. Das Ausrichten von Verzweigungszielen erfolgt aufgrund dieses Problems, betrifft jedoch den gesamten Code.
Peter Cordes
80

Möglicherweise möchten Sie http://research.google.com/pubs/pub37077.html lesen

TL; DR: Das zufällige Einfügen von NOP-Anweisungen in Programme kann die Leistung leicht um 5% oder mehr steigern, und nein, Compiler können dies nicht einfach ausnutzen. Es ist normalerweise eine Kombination aus Verzweigungsprädiktor und Cache-Verhalten, aber es kann genauso gut z. B. ein Reservierungsstationsstillstand sein (selbst wenn es keine unterbrochenen Abhängigkeitsketten oder offensichtliche Ressourcenüberbelegungen gibt).

Jonas Maebe
quelle
1
Interessant. Aber ist der Prozessor (oder die FPC) intelligent genug, um zu erkennen, dass das Schreiben in den RAM in diesem Fall ein NOP ist?
Tangentstorm
8
Assembler ist nicht optimiert.
Marco van de Voort
5
Compiler könnten dies ausnutzen, indem sie unglaublich teure Optimierungen wie das wiederholte Erstellen und Profilieren und anschließende Variieren der Compiler-Ausgabe mit einem simulierten Annealing- oder genetischen Algorithmus durchführen. Ich habe über einige Arbeiten in diesem Bereich gelesen. Wir sprechen jedoch von mindestens 5-10 Minuten 100% CPU zum Kompilieren, und die daraus resultierenden Optimierungen wären wahrscheinlich ein CPU-Kernmodell und sogar eine Kern- oder Mikrocode-Revision.
AdamIerymenko
Ich würde es nicht als zufälliges NOP bezeichnen, sie erklären, warum NOPs sich positiv auf die Leistung auswirken können (tl; dr: stackoverflow.com/a/5901856/357198 ) und das zufällige Einfügen von NOP zu Leistungseinbußen führte. Das Interessante an dem Papier ist, dass die Entfernung von 'strategischem' NOP durch GCC keinen Einfluss auf die Leistung insgesamt hatte!
PuercoPop
15

Ich glaube, in modernen CPUs sind die Montageanweisungen, obwohl sie für einen Programmierer die letzte sichtbare Schicht für die Bereitstellung von Ausführungsanweisungen für eine CPU sind, tatsächlich mehrere Schichten von der tatsächlichen Ausführung durch die CPU entfernt.

Moderne CPUs sind RISC / CISC- Hybride, die CISC x86-Anweisungen in interne Anweisungen mit mehr RISC-Verhalten übersetzen. Darüber hinaus gibt es Ausführungsanalysatoren außerhalb der Reihenfolge, Verzweigungsprädiktoren und Intels "Micro-Ops-Fusion", die versuchen, Anweisungen in größeren Chargen gleichzeitiger Arbeit zu gruppieren (ähnlich wie beim VLIW / Itanium- Titanic). Es gibt sogar Cache-Grenzen, die dazu führen können, dass der Code schneller ausgeführt wird, wenn er größer ist (möglicherweise steckt der Cache-Controller ihn intelligenter ein oder hält ihn länger).

CISC hatte schon immer eine Übersetzungsschicht von Assembler zu Mikrocode, aber der Punkt ist, dass mit modernen CPUs die Dinge viel, viel komplizierter sind. Mit all den zusätzlichen Transistorflächen in modernen Halbleiterfertigungsanlagen können CPUs wahrscheinlich mehrere Optimierungsansätze parallel anwenden und dann den am Ende auswählen, der die beste Beschleunigung bietet. Die zusätzlichen Anweisungen können die CPU dazu veranlassen, einen Optimierungspfad zu verwenden, der besser als andere ist.

Die Auswirkung der zusätzlichen Anweisungen hängt wahrscheinlich vom CPU-Modell / der Generation / dem Hersteller ab und ist wahrscheinlich nicht vorhersehbar. Die Optimierung der Assemblersprache auf diese Weise würde die Ausführung für viele Generationen der CPU-Architektur erfordern, möglicherweise unter Verwendung von CPU-spezifischen Ausführungspfaden, und wäre nur für wirklich sehr wichtige Codeabschnitte wünschenswert, obwohl Sie dies wahrscheinlich bereits wissen, wenn Sie Assembler ausführen.

Cowarldlydragon
quelle
6
Ihre Antwort ist etwas verwirrend. An vielen Orten scheint es, als würden Sie raten, obwohl das meiste, was Sie sagen, richtig ist.
Alcuadrado
2
Vielleicht sollte ich das klarstellen. Was ich verwirrend finde, ist der Mangel an Sicherheit
Alcuadrado
3
Vermutungen, die sinnvoll und mit guter Argumentation sind, sind völlig gültig.
Jturolla
7
Niemand kann wirklich sicher wissen, warum das OP dieses seltsame Verhalten beobachtet, es sei denn, es war ein Ingenieur bei Intel, der Zugang zu speziellen Diagnosegeräten hatte. Alles, was andere tun können, ist zu raten. Das ist nicht die Schuld von @ cowarldlydragon.
Alex D
2
Downvote; Nichts von dem, was Sie sagen, erklärt das Verhalten, das OP sieht. Ihre Antwort ist nutzlos.
Fuz
0

Cache vorbereiten

Verschiebungsvorgänge in den Speicher können den Cache vorbereiten und nachfolgende Verschiebungsvorgänge beschleunigen. Eine CPU hat normalerweise zwei Ladeeinheiten und eine Speichereinheit. Eine Ladeeinheit kann aus dem Speicher in ein Register lesen (ein Lesevorgang pro Zyklus), eine Speichereinheit speichert von Register zu Speicher. Es gibt auch andere Einheiten, die Operationen zwischen Registern ausführen. Alle Einheiten arbeiten parallel. Daher können wir in jedem Zyklus mehrere Operationen gleichzeitig ausführen, jedoch nicht mehr als zwei Ladevorgänge, einen Speicher und mehrere Registeroperationen. Normalerweise sind es bis zu 4 einfache Operationen mit einfachen Registern, bis zu 3 einfache Operationen mit XMM / YMM-Registern und 1-2 komplexe Operationen mit jeder Art von Registern. Ihr Code hat viele Operationen mit Registern, so dass eine Dummy-Speicheroperation frei ist (da es sowieso mehr als 4 Registeroperationen gibt). Es bereitet jedoch den Speichercache für den nachfolgenden Speichervorgang vor. Informationen zur Funktionsweise von Speicherspeichern finden Sie in derReferenzhandbuch zur Optimierung von Intel 64- und IA-32-Architekturen .

Die falschen Abhängigkeiten brechen

Dies bezieht sich zwar nicht genau auf Ihren Fall, aber manchmal werden 32-Bit-Mov-Operationen unter dem 64-Bit-Prozessor (wie in Ihrem Fall) verwendet, um die höheren Bits (32-63) zu löschen und die Abhängigkeitsketten zu unterbrechen.

Es ist bekannt, dass unter x86-64 die Verwendung von 32-Bit-Operanden die höheren Bits des 64-Bit-Registers löscht. Bitte lesen Sie den entsprechenden Abschnitt - 3.4.1.1 - des Entwicklerhandbuchs für Intel® 64- und IA-32-Architekturen, Band 1 :

32-Bit-Operanden erzeugen ein 32-Bit-Ergebnis, das im Allzweckregister des Ziels auf ein 64-Bit-Ergebnis erweitert wird

Die Mov-Anweisungen, die auf den ersten Blick nutzlos erscheinen, löschen also die höheren Bits der entsprechenden Register. Was gibt es uns? Es unterbricht Abhängigkeitsketten und ermöglicht die parallele Ausführung der Anweisungen in zufälliger Reihenfolge durch den seit Pentium Pro 1995 intern von CPUs implementierten Out-of-Order-Algorithmus .

Ein Zitat aus dem Referenzhandbuch zur Optimierung von Intel® 64- und IA-32-Architekturen , Abschnitt 3.5.1.8:

Codesequenzen, die Teilregister modifizieren, können eine gewisse Verzögerung in ihrer Abhängigkeitskette erfahren, können jedoch durch die Verwendung von Redewendungen zum Unterbrechen von Abhängigkeiten vermieden werden. In Prozessoren, die auf der Intel Core-Mikroarchitektur basieren, kann eine Reihe von Anweisungen dazu beitragen, die Ausführungsabhängigkeit zu beseitigen, wenn die Software diese Anweisungen verwendet, um den Registerinhalt auf Null zu setzen. Unterbrechen Sie Abhängigkeiten von Teilen von Registern zwischen Befehlen, indem Sie 32-Bit-Register anstelle von Teilregistern bearbeiten. Bei Bewegungen kann dies mit 32-Bit-Bewegungen oder mithilfe von MOVZX erreicht werden.

Assembler- / Compiler-Codierungsregel 37. (M-Auswirkung, MH-Allgemeinheit) : Unterbrechen Sie Abhängigkeiten von Teilen von Registern zwischen Befehlen, indem Sie 32-Bit-Register anstelle von Teilregistern verwenden. Bei Bewegungen kann dies mit 32-Bit-Bewegungen oder mithilfe von MOVZX erreicht werden.

MOVZX und MOV mit 32-Bit-Operanden für x64 sind äquivalent - sie alle unterbrechen Abhängigkeitsketten.

Deshalb wird Ihr Code schneller ausgeführt. Wenn keine Abhängigkeiten bestehen, kann die CPU die Register intern umbenennen, obwohl es auf den ersten Blick so aussieht, als ob der zweite Befehl ein vom ersten Befehl verwendetes Register modifiziert und die beiden nicht parallel ausgeführt werden können. Aber aufgrund der Umbenennung des Registers können sie.

Das Umbenennen von Registern ist eine Technik, die intern von einer CPU verwendet wird und die falschen Datenabhängigkeiten beseitigt, die sich aus der Wiederverwendung von Registern durch aufeinanderfolgende Anweisungen ergeben, zwischen denen keine echten Datenabhängigkeiten bestehen.

Ich denke, Sie sehen jetzt, dass es zu offensichtlich ist.

Maxim Masiutin
quelle
Dies ist alles wahr, hat aber nichts mit dem in der Frage dargestellten Code zu tun.
Cody Gray
@CodyGray - danke für dein Feedback. Ich habe die Antwort bearbeitet und ein Kapitel über den Fall hinzugefügt - das Verschieben in den Speicher, umgeben von Registeroperationen, bereitet den Cache vor und ist kostenlos, da die Speichereinheit sowieso inaktiv ist. Der nachfolgende Speichervorgang ist also schneller.
Maxim Masiutin
1
Es gibt kein MOVZX für 32-Bit-Operanden, da alle Befehle mit 32-Bit-Ziel den oberen Teil des vollständigen 64-Bit-Registers auf
Null setzen