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
foo
RBX 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 foo
Push / Pop RBX verwendet wird, gibt es nicht viel für die Latenz zu gewinnen. Es ret
ist wahrscheinlich nicht relevant, dass die Wiederherstellung kurz vor dem statt kurz danach erfolgt, es sei denn, es liegt ein ret
Fehlvorhersage- 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 rax
wäre in diesem Fall effizienter , und dies ist wahrscheinlich eine verpasste Optimierung für winzige Nicht-Blatt-Funktionen, abhängig davon, was foo
funktioniert und das Gleichgewicht zwischen zusätzlicher Speicher- / Nachlade-Latenz für x
und 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, 8
verschüttet / neu geladen x
wurden. (Aber Compiler kennen diese Optimierung auch nicht, push
um 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_frame
Stack-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 foo
RBX 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 x
der Aufrufe unmittelbar vor dem ersten und nach dem letzten verwenden. Andernfalls besteht das Problem, dass die 16-Byte-Stapelausrichtung für jeden call
Aufruf 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, -flto
damit 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 bar
in seine Anrufer oder inlining foo
in bar
. Dies ist in Ordnung, es sei denn, es gibt viele verschiedene bar
ähnliche Funktionen und es foo
ist groß, und aus irgendeinem Grund können sie nicht in ihre Anrufer eingebunden werden.