it-swarm.com.de

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

Hintergrund:

Bei der Optimierung von Pascal Code mit eingebetteter Assembler-Sprache habe ich eine unnötige MOV -Anweisung festgestellt und diese entfernt.

Zu meiner Überraschung führte das Entfernen der unnötigen Anweisung dazu, dass mein Programm langsamer wurde.

Ich fand, dass das Hinzufügen von willkürlichen, nutzlosen MOV -Anweisungen die Leistung noch weiter steigerte.

Der Effekt ist unberechenbar und ändert sich je nach Ausführungsreihenfolge: Dieselben Junk-Anweisungen werden um eine einzelne Zeile nach oben oder unten transponiert erzeugen eine Verlangsamung .

Ich verstehe, dass die CPU alle Arten von Optimierungen und Optimierungen durchführt, aber dies scheint eher wie schwarze Magie.

Die Daten:

Eine Version meines Codes kompiliert drei Junk-Operationen in der Mitte einer Schleife, die 2**20==1048576 Mal 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;

Probieren Sie es aus:

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

Meine Fragen:

  • Warum sollte das sinnlose Kopieren des Inhalts eines Registers nach RAM jemals die Leistung steigern?
  • Warum würde derselbe unnütze Befehl in einigen Zeilen zu einer Beschleunigung und in anderen zu einer Verlangsamung führen?
  • Kann ein Compiler dieses Verhalten vorhersehbar ausnutzen?
215
tangentstorm

Die wahrscheinlichste Ursache für die Geschwindigkeitsverbesserung ist:

  • durch das Einfügen eines MOV werden die nachfolgenden Anweisungen auf andere Speicheradressen verschoben
  • eine dieser verschobenen Anweisungen war eine wichtige bedingte Verzweigung
  • diese Verzweigung wurde aufgrund von Aliasing in der Verzweigungsvorhersage-Tabelle falsch vorhergesagt
  • durch Verschieben des Zweigs wurde der Alias ​​beseitigt und der Zweig korrekt vorhergesagt

Ihr Core2 speichert keinen separaten Verlaufsdatensatz für jeden bedingten Sprung. Stattdessen wird ein gemeinsamer Verlauf aller bedingten Sprünge gespeichert. Ein Nachteil von globale Verzweigungsvorhersage ist, dass die Historie 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 nicht korrelierte Zweige teilen sich die gleichen unteren Bits. In diesem Fall kommt es zu einem Aliasing, das viele falsch vorhergesagte Verzweigungen verursacht (was die Anweisungspipeline blockiert und Ihr Programm verlangsamt).

Wenn Sie wissen möchten, wie sich Zweigfehlvorhersagen auf die Leistung auswirken, lesen Sie diese hervorragende Antwort: https://stackoverflow.com/a/11227902/100164

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

140

Lesen Sie möglicherweise http://research.google.com/pubs/pub37077.html

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 Verzweigungsvorhersage und Cache-Verhalten, aber es kann genauso gut z. ein Reservierungsstationsstand (selbst wenn keine unterbrochenen Abhängigkeitsketten oder offensichtliche Ressourcenüberabonnements vorhanden sind).

79
Jonas Maebe

Ich glaube an moderne CPUs, dass die Assembly-Anweisungen, obwohl sie für einen Programmierer die letzte sichtbare Ebene sind, um einer CPU Ausführungsanweisungen bereitzustellen, tatsächlich mehrere Ebenen von der tatsächlichen Ausführung durch die CPU entfernt sind.

Moderne CPUs sind ZUFÄLLIGE / ZUFÄLLIGE Hybride, die CISC x86-Anweisungen in interne Anweisungen übersetzen, die sind mehr RISC im Verhalten. Zusätzlich gibt es Ausführungsanalysatoren, Verzweigungsvorhersageprogramme und Intels "Micro-Ops-Fusion", die versuchen, Anweisungen in größeren Mengen simultaner Arbeit zu gruppieren (eine Art wie VLIW / Itanium titanic). Es gibt sogar Cache-Grenzen, durch die der Code schneller ausgeführt werden kann, wenn er größer ist (möglicherweise steckt der Cache-Controller ihn intelligenter ein oder hält ihn länger in der Hand).

CISC hatte schon immer eine Übersetzungsschicht von Assembly zu Microcode, aber der Punkt ist, dass die Dinge mit modernen CPUs viel, viel komplizierter sind. Mit all der zusätzlichen Transistorfläche 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 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 von CPU-Architekturen erfordern, möglicherweise unter Verwendung von CPU-spezifischen Ausführungspfaden, und wäre nur für wirklich wirklich wichtige Codeabschnitte wünschenswert, obwohl Sie dies wahrscheinlich bereits wissen, wenn Sie Assembler ausführen.

14
cowarldlydragon