TL: DR:
- Elementy wewnętrzne kompilatora prawdopodobnie nie są skonfigurowane tak, aby łatwo wyszukiwać tę optymalizację, i prawdopodobnie jest to przydatne tylko w przypadku małych funkcji, a nie w dużych funkcjach między wywołaniami.
- W większości przypadków lepszym rozwiązaniem jest chęć tworzenia dużych funkcji
- Może wystąpić opóźnienie w stosunku do kompromisu przepustowości, jeśli
foo
zdarzy się, że nie zapisze / nie przywróci RBX.
Kompilatory to złożone elementy maszyn. Nie są „inteligentni” jak ludzie, a drogie algorytmy pozwalające znaleźć każdą możliwą optymalizację często nie są warte kosztów w dodatkowym czasie kompilacji.
Zgłosiłem to jako błąd GCC 69986 - możliwy mniejszy kod z -Os poprzez użycie push / pop do rozlewania / przeładowywania z powrotem w 2016 roku ; nie było żadnej aktywności ani odpowiedzi od twórców GCC. : /
Nieznacznie powiązane: błąd GCC 70408 - ponowne użycie tego samego rejestru zachowanego wywołania dałoby w niektórych przypadkach mniejszy kod - twórcy kompilatora powiedzieli mi, że zajmie to dużo pracy, aby GCC mógł wykonać tę optymalizację, ponieważ wymaga to kolejności sortowania oceny dwóch foo(int)
wywołań w oparciu o to, co uprościłoby cel asm.
Jeśli foo
się nie zapisuje / nie przywraca rbx
, istnieje kompromis między przepływnością (liczbą instrukcji) a dodatkowym opóźnieniem przechowywania / przeładowania w x
łańcuchu zależności -> retval.
Kompilatory zwykle preferują opóźnienie nad przepustowością, np. Używając 2x LEA zamiast imul reg, reg, 10
(3-cyklowe opóźnienie, 1 / przepustowość zegara), ponieważ większość kodu średnio znacznie mniej niż 4 uops / zegar na typowych 4-szerokich potokach, takich jak Skylake. (Więcej instrukcji / uopsów zajmuje więcej miejsca w ROB, zmniejszając jednak to, jak daleko może zobaczyć to samo okno poza kolejnością, a wykonanie jest w rzeczywistości pęknięte, a przeciągnięcia prawdopodobnie odpowiadają za mniej niż 4 uops / średnia zegara).
Jeśli foo
push / pop RBX, to niewiele można zyskać na opóźnieniu. Przywracanie odbywa się tuż przed, a ret
nie zaraz po nim, chyba nie ma to znaczenia, chyba że wystąpi błąd w ret
przepowiedni lub błąd I-cache, który opóźnia pobranie kodu z adresu zwrotnego.
Większość nietrywialnych funkcji zapisuje / przywraca RBX, więc często nie jest dobrym założeniem, że pozostawienie zmiennej w RBX w rzeczywistości oznacza, że naprawdę pozostała w rejestrze przez połączenie. (Chociaż losowe wybieranie funkcji rejestrów z zachowaniem połączeń może być czasem dobrym rozwiązaniem).
Więc tak push rdi
/ pop rax
byłby bardziej wydajny w tym przypadku, i prawdopodobnie jest to pominięta optymalizacja dla drobnych funkcji nie-liściowych, w zależności od tego, co foo
robi i równowagi między dodatkowym opóźnieniem przechowywania / przeładowania w x
porównaniu do większej liczby instrukcji zapisywania / przywracania dzwoniącego rbx
.
Możliwe jest, że metadane rozwijania stosu reprezentują tutaj zmiany w RSP, tak jak gdyby używał sub rsp, 8
do przelania / przeładowania x
do slotu stosu. (Ale kompilatory też nie znają tej optymalizacji wykorzystania push
rezerwy miejsca i inicjalizacji zmiennej. Jaki kompilator C / C ++ może używać instrukcji push pop do tworzenia zmiennych lokalnych, zamiast tylko zwiększać esp raz? I robić to więcej niż jeden lokalny var doprowadziłby do zwiększenia .eh_frame
metadanych związanych z odwijaniem stosu, ponieważ przesuwasz wskaźnik stosu oddzielnie z każdym wypchnięciem. Nie powstrzymuje to jednak kompilatorów przed użyciem push / pop do zapisywania / przywracania zachowanych połączeń.
IDK, gdyby warto uczyć kompilatory, jak szukać tej optymalizacji
Może to dobry pomysł na całą funkcję, a nie na jedno wywołanie wewnątrz funkcji. I jak powiedziałem, opiera się na pesymistycznym założeniu, że i foo
tak uratuje / przywróci RBX. (Lub optymalizacja pod kątem przepustowości, jeśli wiesz, że opóźnienie od x do wartości zwracanej nie jest ważne. Ale kompilatory nie wiedzą o tym i zwykle optymalizują pod kątem opóźnienia).
Jeśli zaczniesz przyjmować to pesymistyczne założenie w wielu kodach (jak w przypadku pojedynczych wywołań funkcji w funkcjach), zaczniesz otrzymywać więcej przypadków, w których RBX nie zostanie zapisany / przywrócony i mógłbyś skorzystać.
Nie chcesz także tego dodatkowego zapisu / przywracania push / pop w pętli, po prostu zapisz / przywróć RBX poza pętlą i użyj rejestrów zachowanych w pętli, które wykonują wywołania funkcji. Nawet bez pętli, w ogólnym przypadku większość funkcji wykonuje wiele wywołań funkcji. Ten pomysł optymalizacji może być zastosowany, jeśli naprawdę nie używasz x
żadnego z wywołań, tuż przed pierwszym i po ostatnim, w przeciwnym razie masz problem z utrzymaniem wyrównania stosu 16 bajtów dla każdego, call
jeśli wykonujesz jeden pop po zadzwoń, przed kolejnym połączeniem.
Kompilatory nie są świetne w drobnych funkcjach. Ale nie jest to również świetne dla procesorów. Wywołania funkcji innych niż wbudowane mają najlepszy wpływ na optymalizację, chyba że kompilatory widzą elementy wewnętrzne odbiorcy i przyjmują więcej założeń niż zwykle. Nieliniowe wywołanie funkcji jest niejawną barierą pamięci: osoba dzwoniąca musi założyć, że funkcja może odczytać lub zapisać dane globalnie dostępne, więc wszystkie takie zmienne muszą być zsynchronizowane z maszyną abstrakcyjną C. (Analiza ucieczki pozwala przechowywać mieszkańców w rejestrach między połączeniami, jeśli ich adres nie uniknął funkcji). Ponadto kompilator musi założyć, że wszystkie rejestry z zablokowanymi wywołaniami są zablokowane. To zasysa zmiennoprzecinkowe w systemie V 86-64, który nie ma rejestrów XMM z zachowaniem wywołania.
Małe funkcje, takie jak, bar()
lepiej wpasowują się w swoich rozmówców. Skompiluj, -flto
aby w większości przypadków mogło się to zdarzyć nawet ponad granicami plików. (Wskaźniki funkcji i granice biblioteki współużytkowanej mogą to pokonać).
Myślę, że jednym z powodów, dla których kompilatory nie zadały sobie trudu przeprowadzenia tych optymalizacji, jest to, że wymagałoby to całej gamy różnych kodów we wnętrzu kompilatora , innych niż normalny stos vs. kod alokacji rejestru, który wie, jak zapisać zachowane wywołanie rejestruje i używa ich.
tj. byłoby dużo pracy do wdrożenia i dużo kodu do utrzymania, a jeśli zrobi się to zbyt entuzjastycznie, może to pogorszyć kod.
A także, że (miejmy nadzieję) nie ma to znaczenia; jeśli ma to znaczenie, powinieneś być wbudowany bar
w jego rozmówcę lub foo
w bar
. Jest to w porządku, chyba że istnieje wiele różnych bar
funkcji i foo
jest duże, a z jakiegoś powodu nie mogą włączyć się do swoich rozmówców.