Das Hinzufügen einer Druckanweisung beschleunigt den Code um eine Größenordnung

8

Ich habe ein Stück extrem bizarres Verhalten in einem Stück C / C ++ - Code gefunden, wie im Titel angedeutet, von dem ich keine Ahnung habe, wie ich es erklären soll.

Hier ist ein so nah wie ich gefundenes minimales Arbeitsbeispiel [EDIT: siehe unten für ein kürzeres Beispiel]:

%Vor%

Hier sind die Ergebnisse der Ausführung auf meinem System:

%Vor%

Es ist nicht sehr wichtig, was dieser Code berechnet: Es ist nur eine Tonne komplexer Arithmetik auf Arrays der Länge 29. Es wurde von einer viel größeren Menge komplexer Arithmetik, die mir wichtig ist, "vereinfacht".

Das Verhalten scheint also so zu sein, wie es im Titel heißt: Wenn ich diese Druckanweisung wieder einfüge, wird der Code viel schneller.

Ich habe ein bisschen herumgespielt: Zum Beispiel gibt das Drucken einer konstanten Zeichenfolge nicht die Beschleunigung, aber Drucken der Uhrzeit tut. Es gibt einen ziemlich klaren Schwellenwert: Der Code ist entweder schnell oder langsam.

Ich habe die Möglichkeit in Betracht gezogen, dass irgendeine bizarre Compiler-Optimierung entweder eintritt oder nicht, vielleicht abhängig davon, ob der Code Nebenwirkungen hat oder nicht. Aber wenn es so ist, ist es ziemlich subtil: wenn ich mir die disassemblierten Binärdateien anschaue, sind sie scheinbar identisch, außer dass man eine extra Druckeranweisung hat und sie verschiedene austauschbare Register verwenden. Ich darf (muss?) Etwas Wichtiges verpasst haben.

Ich kann nicht erklären, was eine Erde dafür bewirken könnte. Schlimmer noch, es wirkt sich auf mein Leben aus, da ich häufig verwandten Code leite, und das Hineingehen mit zusätzlichen Druckanweisungen ist keine gute Lösung.

Jede plausible Theorie wäre sehr willkommen. Antworten nach dem Motto "Dein Computer ist kaputt" sind akzeptabel, wenn du erklären kannst, wie das alles erklären könnte.

UPDATE : Mit Entschuldigungen für die zunehmende Länge der Frage habe ich das Beispiel auf

geschrumpft %Vor%

Ich könnte es noch kleiner machen, aber schon ist der Maschinencode beherrschbar. Wenn ich fünf Bytes der dem Callq-Befehl entsprechenden Binärdatei für diesen ersten printf auf 0x90 ändere, geht die Ausführung von schnell auf langsam über.

Der kompilierte Code ist sehr schwer mit Funktionsaufrufen an __muldc3 (). Ich denke, es muss damit zu tun haben, wie die Broadwell-Architektur diese Sprünge gut beherrscht oder nicht: Beide Versionen haben die gleiche Anzahl an Anweisungen, so dass es einen Unterschied in Anweisungen / Zyklus gibt (etwa 0,16 gegenüber etwa 2,8).

Außerdem macht das Kompilieren von -static die Dinge wieder schnell.

Weitere schamlose Aktualisierung : Ich bin mir bewusst, dass ich der Einzige bin, der damit spielen kann, also hier noch ein paar Beobachtungen:

Es scheint, als ob eine Bibliotheksfunktion aufgerufen wird - einschließlich einiger törichter, die ich gemacht habe, die nichts tun - für das erste Mal , setzt die Ausführung in einen langsamen Zustand. Ein nachfolgender Aufruf von printf, fprintf oder sprintf löscht den Zustand irgendwie und die Ausführung ist wieder schnell. Also, beim ersten Aufruf von __muldc3 () gehen wir in den langsamen Zustand, und der nächste {, f, s} printf setzt alles zurück.

Sobald eine Bibliotheksfunktion einmal aufgerufen wurde und der Status zurückgesetzt wurde, wird diese Funktion frei und Sie können sie beliebig oft verwenden, ohne den Status zu ändern.

Also z. B.

%Vor%

ist schnell, aber das Auskommentieren von Zeile 1 oder das Auskommentieren von Zeile 2 macht es wieder langsam.

Es muss relevant sein, dass beim ersten Aufruf einer Bibliothek die "Trampoline" im PLT initialisiert wird, um auf die gemeinsame Bibliothek zu zeigen. Also, vielleicht verlässt dieser dynamische Ladecode das Prozessor-Frontend an einem schlechten Ort, bis es "gerettet" ist.

    
Freddie Manners 21.02.2017, 03:38
quelle

1 Antwort

2

Für die Aufzeichnung habe ich das schließlich herausgefunden.

Es stellt sich heraus, dass dies mit AVX-SSE-Übergangsstrafen zu tun hat. Um diese Exposition von Intel zu zitieren:

  

Bei der Verwendung von Intel® AVX-Anweisungen ist es wichtig zu wissen, dass das Mischen von 256-Bit-Intel® AVX-Anweisungen mit älteren (nicht VEX-codierten) Intel® SSE-Anweisungen zu Strafen führen kann, die die Leistung beeinträchtigen könnten. 256-Bit-Intel® AVX-Befehle arbeiten mit den 256-Bit-YMM-Registern, bei denen es sich um 256-Bit-Erweiterungen der vorhandenen 128-Bit-XMM-Register handelt. 128-Bit-Intel® AVX-Befehle arbeiten mit den unteren 128 Bits der YMM-Register und null der oberen 128 Bits. Ältere Intel® SSE-Befehle arbeiten jedoch mit den XMM-Registern und haben keine Kenntnis von den oberen 128 Bits der YMM-Register. Aus diesem Grund speichert die Hardware den Inhalt der oberen 128 Bits der YMM-Register beim Übergang von 256-Bit Intel® AVX zu Legacy Intel® SSE und stellt diese Werte beim Übergang von Intel® SSE zu Intel® AVX wieder her ( 256-Bit oder 128-Bit). Die Speicher- und Wiederherstellungsoperationen verursachen beide eine Strafe, die für jede Operation mehrere zehn Taktzyklen beträgt.

Die oben kompilierte Version meiner Hauptschleifen enthält Legacy-SSE-Anweisungen ( movapd und Freunde, glaube ich), während die Implementierung von __muldc3 in libgcc_s eine Menge fantastischer AVX-Anweisungen verwendet ( vmovapd , vmulsd etc.).

Dies ist die ultimative Ursache für die Verlangsamung. Tatsächlich zeigt die Intel-Leistungsdiagnose, dass diese AVX / SSE-Umschaltung fast genau einmal pro Aufruf von "__muldc3" erfolgt (in der letzten Version des oben genannten Codes):

%Vor%

(Ereigniscodes aus Tabelle 19.5 eines anderen Intel-Handbuchs ).

Es bleibt die Frage, warum die Verlangsamung beim erstmaligen Aufruf einer Bibliotheksfunktion einsetzt und beim Aufruf von printf , sprintf oder was auch immer wieder ausschaltet. Der Hinweis ist im ersten Dokument erneut :

  

Wenn es nicht möglich ist, die Übergänge zu entfernen, ist es oft möglich, den Nachteil zu vermeiden, indem die oberen 128 Bits der YMM-Register explizit auf Null gesetzt werden. In diesem Fall speichert die Hardware diese Werte nicht.

Ich denke, die ganze Geschichte ist daher wie folgt. Wenn Sie eine Bibliotheksfunktion zum ersten Mal aufrufen, verlässt der Trampolincode in ld-linux-x86-64.so , der den PLT einrichtet, die oberen Bits der MMY-Register in einem Nicht-Null-Zustand. Wenn Sie sprintf unter anderem aufrufen, setzt es die oberen Bits der MMY-Register auf Null (ob durch Zufall oder Design, ich bin mir nicht sicher).

Das Ersetzen des sprintf -Aufrufs durch asm("vzeroupper") - der den Prozessor explizit anweist, diese hohen Bits auf Null zu setzen - hat denselben Effekt.

Der Effekt kann durch Hinzufügen von -mavx oder -march=native zu den Kompilierflags eliminiert werden, so wie der Rest des Systems aufgebaut wurde. Warum dies nicht standardmäßig geschieht, ist nur ein Mysterium meines Systems, denke ich.

Ich bin mir nicht ganz sicher, was wir hier lernen, aber da ist es.

    
Freddie Manners 16.05.2017 04:09
quelle

Tags und Links