Warum bestehen Compiler darauf, hier ein von Angerufenen gespeichertes Register zu verwenden?


10

Betrachten Sie diesen C-Code:

void foo(void);

long bar(long x) {
    foo();
    return x;
}

Wenn ich es auf GCC 9.3 mit entweder -O3oder kompiliere -Os, erhalte ich Folgendes:

bar:
        push    r12
        mov     r12, rdi
        call    foo
        mov     rax, r12
        pop     r12
        ret

Die Ausgabe von clang ist identisch, außer dass rbxanstelle des r12als Angerufene gespeicherten Registers ausgewählt wird.

Ich möchte / erwarte jedoch eine Baugruppe, die eher so aussieht:

bar:
        push    rdi
        call    foo
        pop     rax
        ret

Auf Englisch sehe ich Folgendes:

  • Schieben Sie den alten Wert eines von Angerufenen gespeicherten Registers auf den Stapel
  • Bewegen Sie sich xin dieses von Angerufenen gespeicherte Register
  • Anruf foo
  • Verschieben Sie xvon dem Angerufenen gespeicherten Register in das Rück-Wertregister
  • Pop den Stapel, um den alten Wert des von Angerufenen gespeicherten Registers wiederherzustellen

Warum sollte man sich überhaupt mit einem von Angerufenen gespeicherten Register herumschlagen? Warum nicht stattdessen? Es scheint kürzer, einfacher und wahrscheinlich schneller:

  • Drücken Sie xauf den Stapel
  • Anruf foo
  • Pop xvom Stapel in das Rückgaberegister

Ist meine Montage falsch? Ist es irgendwie weniger effizient als mit einem zusätzlichen Register herumzuspielen? Wenn die Antwort auf beide "Nein" lautet, warum dann nicht entweder GCC oder Clang auf diese Weise?

Godbolt Link .


Bearbeiten: Hier ist ein weniger triviales Beispiel, um zu zeigen, dass dies auch dann geschieht, wenn die Variable sinnvoll verwendet wird:

long foo(long);

long bar(long x) {
    return foo(x * x) - x;
}

Ich verstehe das:

bar:
        push    rbx
        mov     rbx, rdi
        imul    rdi, rdi
        call    foo
        sub     rax, rbx
        pop     rbx
        ret

Ich hätte lieber folgendes:

bar:
        push    rdi
        imul    rdi, rdi
        call    foo
        pop     rdi
        sub     rax, rdi
        ret

Diesmal ist es nur eine Anweisung aus zwei, aber das Kernkonzept ist das gleiche.

Godbolt Link .


4
Interessante verpasste Optimierung.
Fuz

1
höchstwahrscheinlich die Annahme, dass der übergebene Parameter verwendet wird, sodass Sie ein flüchtiges Register speichern und den übergebenen Parameter in einem Register behalten möchten, das sich nicht auf dem Stapel befindet, da nachfolgende Zugriffe auf diesen Parameter vom Register aus schneller sind. Übergebe x an foo und du wirst es sehen. Daher ist es wahrscheinlich nur ein allgemeiner Teil ihres Stack-Frame-Setups.
old_timer

Zugegeben, ich sehe, dass ohne foo der Stack nicht verwendet wird. Es handelt sich also um eine verpasste Optimierung, aber etwas, das jemand hinzufügen müsste, um die Funktion zu analysieren und wenn der Wert nicht verwendet wird und es keinen Konflikt mit diesem Register gibt (im Allgemeinen vorhanden) ist).
old_timer

Das Arm-Backend macht dies auch auf gcc. also wahrscheinlich nicht das backend
old_timer

Clang 10 gleiche Geschichte (Arm Backend).
old_timer

Antworten:


5

TL: DR:

  • Compiler-Interna sind wahrscheinlich nicht so eingerichtet, dass sie leicht nach dieser Optimierung suchen können, und sie sind wahrscheinlich nur für kleine Funktionen nützlich, nicht für große Funktionen zwischen Aufrufen.
  • Inlining, um große Funktionen zu erstellen, ist meistens eine bessere Lösung
  • Es kann zu einem Kompromiss zwischen Latenz und Durchsatz kommen, wenn fooRBX nicht gespeichert / wiederhergestellt wird.

Compiler sind komplexe Maschinen. Sie sind nicht "intelligent" wie ein Mensch, und teure Algorithmen, um jede mögliche Optimierung zu finden, sind die Kosten für zusätzliche Kompilierungszeit oft nicht wert.

Ich habe dies als GCC-Fehler 69986 gemeldet - kleinerer Code mit -Os möglich, indem Push / Pop verwendet wird, um 2016 zu verschütten / neu zu laden ; Es gab keine Aktivitäten oder Antworten von GCC-Entwicklern. : /

Etwas verwandt: Der GCC-Fehler 70408 - die Wiederverwendung des gleichen aufruferhaltenen Registers würde in einigen Fällen zu kleinerem Code führen - Compiler-Entwickler sagten mir, dass es eine Menge Arbeit kosten würde, wenn GCC diese Optimierung durchführen könnte, da die Auswahl der Auswertungsreihenfolge erforderlich ist von zwei foo(int)Aufrufen basierend darauf, was das Ziel asm einfacher machen würde.


Wenn foo es sich nicht selbst speichert / wiederherstellt rbx, gibt es einen Kompromiss zwischen Durchsatz (Befehlsanzahl) und einer zusätzlichen Latenz beim Speichern / Neuladen in der x-> Retval-Abhängigkeitskette.

Compiler bevorzugen normalerweise die Latenz gegenüber dem Durchsatz, z. B. die Verwendung von 2x LEA anstelle von imul reg, reg, 10(3-Zyklus-Latenz, 1 / Takt-Durchsatz), da der meiste Code in typischen 4-breiten Pipelines wie Skylake im Durchschnitt deutlich weniger als 4 Uops / Takt beträgt. (Mehr Anweisungen / Uops beanspruchen mehr Platz im ROB, wodurch sich die Entfernung verringert, die dasselbe Fenster außerhalb der Reihenfolge sehen kann, und die Ausführung ist tatsächlich sehr schnell, da Stände wahrscheinlich für einige der weniger als 4 Uops verantwortlich sind. Uhrendurchschnitt.)

Wenn fooPush / Pop RBX verwendet wird, gibt es nicht viel für die Latenz zu gewinnen. Es retist wahrscheinlich nicht relevant, dass die Wiederherstellung kurz vor dem statt kurz danach erfolgt, es sei denn, es liegt ein retFehlvorhersage- oder I-Cache-Fehler vor, der das Abrufen von Code an der Rücksprungadresse verzögert.

Die meisten nicht trivialen Funktionen speichern / stellen RBX wieder her, daher ist es oft keine gute Annahme, dass das Belassen einer Variablen in RBX tatsächlich bedeutet, dass sie während des Aufrufs tatsächlich in einem Register verbleibt. (Obwohl es eine gute Idee sein kann, zu wählen, welche Funktionen für aufrufkonservierte Register ausgewählt werden sollen, um dies manchmal zu mildern.)


Ja push rdi/ pop raxwäre in diesem Fall effizienter , und dies ist wahrscheinlich eine verpasste Optimierung für winzige Nicht-Blatt-Funktionen, abhängig davon, was foofunktioniert und das Gleichgewicht zwischen zusätzlicher Speicher- / Nachlade-Latenz für xund mehr Anweisungen zum Speichern / Wiederherstellen des Anrufers rbx.

Es ist möglich, dass Stack-Unwind-Metadaten die Änderungen an RSP hier darstellen, genau wie wenn sie früher in einen Stack-Slot sub rsp, 8verschüttet / neu geladen xwurden. (Aber Compiler kennen diese Optimierung auch nicht, pushum Speicherplatz zu reservieren und eine Variable zu initialisieren. Welcher C / C ++ - Compiler kann Push-Pop-Anweisungen zum Erstellen lokaler Variablen verwenden, anstatt esp nur einmal zu erhöhen? Und das für mehr als Eine lokale Variable würde zu größeren .eh_frameStack-Abwicklungs-Metadaten führen, da Sie den Stapelzeiger bei jedem Push separat verschieben. Dies hindert Compiler jedoch nicht daran, Push / Pop zum Speichern / Wiederherstellen von aufruferhaltenen Regs zu verwenden.)


IDK, wenn es sich lohnen würde, Compilern beizubringen, nach dieser Optimierung zu suchen

Es ist vielleicht eine gute Idee für eine ganze Funktion, nicht für einen Aufruf innerhalb einer Funktion. Und wie gesagt, es basiert auf der pessimistischen Annahme, dass fooRBX trotzdem gespeichert / wiederhergestellt wird. (Oder die Optimierung des Durchsatzes, wenn Sie wissen, dass die Latenz von x zum Rückgabewert nicht wichtig ist. Compiler wissen das jedoch nicht und optimieren normalerweise die Latenz.)

Wenn Sie diese pessimistische Annahme in vielen Codes treffen (z. B. bei einzelnen Funktionsaufrufen innerhalb von Funktionen), werden Sie mehr Fälle erhalten, in denen RBX nicht gespeichert / wiederhergestellt wird und Sie dies möglicherweise ausgenutzt haben.

Sie möchten dieses zusätzliche Speichern / Wiederherstellen von Push / Pop auch nicht in einer Schleife, sondern nur RBX außerhalb der Schleife speichern / wiederherstellen und aufruferhaltene Register in Schleifen verwenden, die Funktionsaufrufe ausführen. Auch ohne Schleifen führen die meisten Funktionen im Allgemeinen mehrere Funktionsaufrufe durch. Diese Optimierungsidee kann angewendet werden, wenn Sie wirklich keinen xder Aufrufe unmittelbar vor dem ersten und nach dem letzten verwenden. Andernfalls besteht das Problem, dass die 16-Byte-Stapelausrichtung für jeden callAufruf beibehalten wird, wenn Sie einen Pop nach a ausführen Anruf, vor einem weiteren Anruf.

Compiler eignen sich im Allgemeinen nicht für winzige Funktionen. Aber es ist auch nicht gut für CPUs. Nicht-Inline-Funktionsaufrufe wirken sich im besten Fall auf die Optimierung aus, es sei denn, Compiler können die Interna des Angerufenen sehen und mehr Annahmen treffen als gewöhnlich. Ein Nicht-Inline-Funktionsaufruf ist eine implizite Speicherbarriere: Ein Aufrufer muss davon ausgehen, dass eine Funktion global zugängliche Daten lesen oder schreiben kann, sodass alle diese Variablen mit der abstrakten C-Maschine synchronisiert sein müssen. (Die Escape-Analyse ermöglicht es, Ortsansässige über Aufrufe hinweg in Registern zu halten, wenn ihre Adresse der Funktion nicht entgangen ist.) Außerdem muss der Compiler davon ausgehen, dass alle Register mit Anrufüberlastung überlastet sind. Dies ist für Gleitkomma in x86-64 System V geeignet, das keine aufruferhaltenen XMM-Register hat.

Winzige Funktionen wie bar()sind besser dran, ihre Anrufer einzuschleusen. Kompilieren Sie mit, -fltodamit dies in den meisten Fällen sogar über Dateigrenzen hinweg geschehen kann. (Funktionszeiger und Grenzen für gemeinsam genutzte Bibliotheken können dies verhindern.)


Ich denke, ein Grund, warum sich Compiler nicht die Mühe gemacht haben, diese Optimierungen durchzuführen, ist, dass sie eine ganze Reihe unterschiedlicher Codes in den Compiler-Interna erfordern würden, die sich vom normalen Stapel- oder Registerzuordnungscode unterscheiden, der weiß, wie aufruferhaltene Daten gespeichert werden registriert und verwendet sie.

Das heißt, es wäre viel Arbeit zu implementieren und viel Code zu warten, und wenn es übermäßig begeistert ist, könnte dies zu schlechterem Code führen.

Und auch, dass es (hoffentlich) nicht signifikant ist; wenn es darauf ankommt, sollten Sie inlining werden barin seine Anrufer oder inlining fooin bar. Dies ist in Ordnung, es sei denn, es gibt viele verschiedene barähnliche Funktionen und es fooist groß, und aus irgendeinem Grund können sie nicht in ihre Anrufer eingebunden werden.


Ich bin mir nicht sicher, ob ich Sinn habe, zu fragen, warum einige Compiler Code auf diese Weise übersetzen, wann er möglicherweise besser verwendet werden kann. Zum Beispiel möglich fragen, warum Clang so seltsam (nicht optimiert) diese Schleife thranslated , vergleichen Sie mit gcc, icc und sogar msvc
RbMm

1
@RbMm: Ich verstehe deinen Standpunkt nicht. Das sieht nach einer völlig separaten, fehlenden Optimierung für Clang aus, unabhängig davon, worum es bei dieser Frage geht. Fehlende Optimierungsfehler sind vorhanden und sollten in den meisten Fällen behoben werden. Mach
Peter Cordes

Ja, mein Codebeispiel hat absolut nichts mit der ursprünglichen Frage zu tun. einfach ein weiteres Beispiel für eine seltsame (für meinen Look) Übersetzung (und nur für einen Single-Clang-Compiler). aber result asm code trotzdem korrekt. nur nicht am besten und eveen nicht native vergleichen gcc / icc / msvc
RbMm
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.