Rozważ ten kod C:
void foo(void);
long bar(long x) {
foo();
return x;
}
Kiedy kompiluję to na GCC 9.3 z jednym -O3
lub -Os
, otrzymuję to:
bar:
push r12
mov r12, rdi
call foo
mov rax, r12
pop r12
ret
Dane wyjściowe z clang są identyczne, z wyjątkiem wyboru rbx
zamiast r12
rejestru zapisanego przez użytkownika.
Jednak chcę / oczekuję, że zobaczę zespół, który wygląda mniej więcej tak:
bar:
push rdi
call foo
pop rax
ret
Po angielsku oto co się dzieje:
- Wciśnij starą wartość rejestru zapisanego przez odbiorcę na stos
- Przejdź
x
do rejestru zapisanego przez użytkownika - Połączenie
foo
- Przejdź
x
z rejestru zapisanego przez odbiorcę do rejestru wartości zwracanej - Pop stos, aby przywrócić starą wartość rejestru zapisanego przez odbiorcę
Po co w ogóle męczyć się z rejestrem zapisanym przez callee? Dlaczego nie zrobić tego zamiast tego? Wydaje się krótszy, prostszy i prawdopodobnie szybszy:
- Wciśnij
x
na stos - Połączenie
foo
- Wyskakuj
x
ze stosu do rejestru wartości zwracanej
Czy mój zespół się myli? Czy jest to w jakiś sposób mniej wydajne niż bałagan z dodatkowym rejestrem? Jeśli odpowiedź na oba z nich brzmi „nie”, to dlaczego GCC lub brzęk nie robią tego w ten sposób?
Edycja: Oto mniej trywialny przykład, aby pokazać, że tak się dzieje, nawet jeśli zmienna jest użyta w sposób znaczący:
long foo(long);
long bar(long x) {
return foo(x * x) - x;
}
Rozumiem:
bar:
push rbx
mov rbx, rdi
imul rdi, rdi
call foo
sub rax, rbx
pop rbx
ret
Wolę to:
bar:
push rdi
imul rdi, rdi
call foo
pop rdi
sub rax, rdi
ret
Tym razem jest tylko jedna instrukcja w porównaniu do dwóch, ale podstawowa koncepcja jest taka sama.
Odpowiedzi:
TL: DR:
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 przywracarbx
, istnieje kompromis między przepływnością (liczbą instrukcji) a dodatkowym opóźnieniem przechowywania / przeładowania wx
ł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, aret
nie zaraz po nim, chyba nie ma to znaczenia, chyba że wystąpi błąd wret
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, cofoo
robi i równowagi między dodatkowym opóźnieniem przechowywania / przeładowania wx
porównaniu do większej liczby instrukcji zapisywania / przywracania dzwoniącegorbx
.Możliwe jest, że metadane rozwijania stosu reprezentują tutaj zmiany w RSP, tak jak gdyby używał
sub rsp, 8
do przelania / przeładowaniax
do slotu stosu. (Ale kompilatory też nie znają tej optymalizacji wykorzystaniapush
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ę lubfoo
wbar
. Jest to w porządku, chyba że istnieje wiele różnychbar
funkcji ifoo
jest duże, a z jakiegoś powodu nie mogą włączyć się do swoich rozmówców.źródło