Po prostu, czym jest optymalizacja połączeń ogonowych?
Mówiąc dokładniej, jakie są małe fragmenty kodu, w których można je zastosować, a gdzie nie, z wyjaśnieniem, dlaczego?
Po prostu, czym jest optymalizacja połączeń ogonowych?
Mówiąc dokładniej, jakie są małe fragmenty kodu, w których można je zastosować, a gdzie nie, z wyjaśnieniem, dlaczego?
Odpowiedzi:
Optymalizacja wywołania ogonowego polega na tym, że można uniknąć przydzielenia nowej ramki stosu dla funkcji, ponieważ funkcja wywołująca zwróci po prostu wartość otrzymaną z wywołanej funkcji. Najczęstszym zastosowaniem jest rekurencja ogona, gdzie funkcja rekurencyjna napisana w celu skorzystania z optymalizacji wywołania ogona może wykorzystywać stałą przestrzeń stosu.
Schemat jest jednym z niewielu języków programowania, które gwarantują w specyfikacji, że każda implementacja musi zapewniać tę optymalizację (JavaScript również, począwszy od ES6) , więc oto dwa przykłady funkcji silni w Schemacie:
Pierwsza funkcja nie jest rekurencyjna, ponieważ po wykonaniu wywołania rekurencyjnego funkcja musi śledzić mnożenie, które musi wynikać z wyniku po powrocie połączenia. W związku z tym stos wygląda następująco:
W przeciwieństwie do tego, ślad stosu dla rekurencyjnej silni rekurencyjnej wygląda następująco:
Jak widać, musimy tylko śledzić tę samą ilość danych dla każdego wezwania do oględzin faktów, ponieważ po prostu zwracamy wartość, którą docieramy na sam szczyt. Oznacza to, że nawet gdybym zadzwonił (fakt 1000000), potrzebuję tylko tyle samo miejsca co (fakt 3). Nie dzieje się tak w przypadku faktu nierekurencyjnego, dlatego tak duże wartości mogą spowodować przepełnienie stosu.
źródło
Przejdźmy przez prosty przykład: funkcja silnia zaimplementowana w C.
Zaczynamy od oczywistej rekurencyjnej definicji
Funkcja kończy się wywołaniem ogona, jeśli ostatnią operacją przed powrotem funkcji jest kolejne wywołanie funkcji. Jeśli to wywołanie wywołuje tę samą funkcję, jest rekurencyjne.
Choć
fac()
na pierwszy rzut oka wygląda rekurencyjnie, nie jest tak, jak się naprawdę dziejetzn. ostatnia operacja to mnożenie, a nie wywołanie funkcji.
Możliwe jest jednak przepisanie w
fac()
celu rekurencji przez przekazanie skumulowanej wartości w dół łańcucha połączeń jako dodatkowy argument i przekazanie tylko końcowego wyniku w górę jako wartości zwracanej:Dlaczego to jest przydatne? Ponieważ natychmiast wracamy po wywołaniu tail, możemy odrzucić poprzednią ramkę stosu przed wywołaniem funkcji w pozycji końcowej lub, w przypadku funkcji rekurencyjnych, ponownie użyć ramki stosu bez zmian.
Optymalizacja wywołania ogona przekształca nasz kod rekurencyjny w
Można to wprowadzić
fac()
i doszliśmy doco jest równoważne z
Jak widzimy tutaj, wystarczająco zaawansowany optymalizator może zastąpić rekurencję ogonem iteracją, co jest znacznie bardziej wydajne, ponieważ unikasz narzutu wywołania funkcji i używasz tylko stałej ilości miejsca na stosie.
źródło
TCO (Tail Call Optimization) to proces, w którym inteligentny kompilator może wywoływać funkcję i nie zajmować dodatkowego miejsca na stosie. Jedynie sytuacja, w której to się dzieje, jeśli ostatnia instrukcja wykonywana w funkcji f jest wywołanie funkcji g (uwaga: g może być f ). Kluczem tutaj jest to, że f przestrzeni nie potrzebuje już Stack - to po prostu wywołuje g , a następnie wraca cokolwiek g wróci. W takim przypadku można dokonać optymalizacji, aby g po prostu uruchomił i zwrócił dowolną wartość do rzeczy, która wywołała f.
Ta optymalizacja może powodować, że wywołania rekurencyjne zajmują stałe miejsce na stosie, zamiast eksplodować.
Przykład: tej funkcji czynnikowej nie można zoptymalizować za pomocą TCO:
Ta funkcja wykonuje inne czynności niż wywoływanie innej funkcji w instrukcji return.
Poniższą funkcję można zoptymalizować za pomocą TCO:
Wynika to z faktu, że ostatnią rzeczą, która wydarzy się w którejkolwiek z tych funkcji, jest wywołanie innej funkcji.
źródło
(cons a (foo b))
lub(+ c (bar d))
w pozycji ogona w ten sam sposób.Prawdopodobnie najlepszym opisem na wysokim poziomie, jaki znalazłem dla wezwań ogona, rekurencyjnych wezwań ogona i optymalizacji ogona ogona jest post na blogu
„Co do cholery: wezwanie do ogona”
autor: Dan Sugalski. Na temat optymalizacji wezwania ogona pisze:
I na rekurencji ogona:
Aby to:
cicho zamienia się w:
W tym opisie podoba mi się to, jak zwięzłe i łatwe jest uchwycenie osób wywodzących się z imperatywnego języka (C, C ++, Java)
źródło
foo
funkcja wywołania ogona nie jest zoptymalizowana? Wywołuje funkcję tylko jako ostatni krok i po prostu zwraca tę wartość, prawda?Zauważ przede wszystkim, że nie wszystkie języki to obsługują.
TCO dotyczy specjalnego przypadku rekurencji. Istotą tego jest to, że jeśli ostatnią rzeczą, którą robisz w funkcji, jest wywołanie samego siebie (np. Wywoływanie się z pozycji „ogona”), kompilator może to zoptymalizować, aby działał jak iteracja zamiast standardowej rekurencji.
Widzisz, zwykle podczas rekurencji środowisko wykonawcze musi śledzić wszystkie rekurencyjne połączenia, aby po powrocie można było wznowić poprzednie połączenie i tak dalej. (Spróbuj ręcznie zapisać wynik wywołania rekurencyjnego, aby uzyskać wizualny obraz tego, jak to działa.) Śledzenie wszystkich wywołań zajmuje miejsce, co staje się znaczące, gdy funkcja wywołuje samą siebie. Ale dzięki TCO można po prostu powiedzieć „wróć do początku, tylko tym razem zmień wartości parametrów na nowe”. Może to zrobić, ponieważ nic po wywołaniu rekurencyjnym nie odnosi się do tych wartości.
źródło
foo
wywołanie metody nie jest zoptymalizowane?Przykład minimalnego działania GCC z analizą dezasemblacji x86
Zobaczmy, jak GCC może automatycznie wykonać dla nas optymalizację wywołania ogona, patrząc na wygenerowany zespół.
Będzie to służyć jako niezwykle konkretny przykład tego, co wspomniano w innych odpowiedziach, takich jak https://stackoverflow.com/a/9814654/895245, że optymalizacja może przekształcić rekurencyjne wywołania funkcji w pętlę.
To z kolei oszczędza pamięć i poprawia wydajność, ponieważ dostęp do pamięci jest często główną rzeczą, która powoduje, że programy są obecnie wolne .
Jako dane wejściowe dajemy GCC niezoptymalizowany czynnikowy oparty na stosie:
tail_call.c
GitHub w górę .
Kompiluj i demontuj:
gdzie
-foptimize-sibling-calls
jest nazwa uogólnienia połączeń ogonowych wedługman gcc
:jak wspomniano w: Jak sprawdzić, czy gcc przeprowadza optymalizację rekurencji ogona?
Wybieram,
-O1
ponieważ:-O0
. Podejrzewam, że dzieje się tak, ponieważ brakuje wymaganych pośrednich transformacji.-O3
produkuje bezbożny, efektywny kod, który nie byłby zbyt pouczający, chociaż jest również zoptymalizowany dla ogona.Demontaż za pomocą
-fno-optimize-sibling-calls
:Z
-foptimize-sibling-calls
:Kluczowa różnica między nimi polega na tym, że:
te
-fno-optimize-sibling-calls
zastosowaniacallq
, które jest typowe dla zoptymalizowanej wywołanie funkcji.Ta instrukcja wypycha adres zwrotny na stos, zwiększając go.
Co więcej, ta wersja również działa
push %rbx
, co przesuwa%rbx
się na stos .GCC robi to, ponieważ przechowuje
edi
, który jest pierwszym argumentem funkcji (n
)ebx
, a następnie wywołujefactorial
.GCC musi to zrobić, ponieważ przygotowuje się do kolejnego połączenia z
factorial
, które użyje nowegoedi == n-1
.Wybiera,
ebx
ponieważ ten rejestr jest zachowywany przez callee : Jakie rejestry są zachowywane przez wywołanie funkcji linux x86-64, aby wywołanie podrzędnefactorial
nie zmieniło go i nie straciłon
.-foptimize-sibling-calls
nie korzysta z żadnych instrukcji, które popychają do stosu: to tylko robigoto
skoki wewnątrzfactorial
z instrukcjamije
ijne
.Dlatego ta wersja jest odpowiednikiem pętli while, bez żadnych wywołań funkcji. Wykorzystanie stosu jest stałe.
Testowane w Ubuntu 18.10, GCC 8.2.
źródło
Popatrz tutaj:
http://tratt.net/laurie/tech_articles/articles/tail_call_optimization
Jak zapewne wiesz, wywołania funkcji rekurencyjnych mogą siać spustoszenie na stosie; szybko brakuje miejsca na stosie. Optymalizacja wywołania ogona to sposób, w jaki można utworzyć algorytm stylu rekurencyjnego, który wykorzystuje stałą przestrzeń stosu, dlatego nie rośnie i rośnie, a pojawiają się błędy stosu.
źródło
Powinniśmy upewnić się, że w samej funkcji nie ma żadnych instrukcji goto. Należy zachować ostrożność, ponieważ wywołanie funkcji jest ostatnią rzeczą w funkcji callee.
Rekurencje na dużą skalę mogą to wykorzystać do optymalizacji, ale w małej skali, narzut instrukcji do wywołania funkcji wywołanie ogona zmniejsza rzeczywisty cel.
TCO może spowodować, że funkcja będzie działać wiecznie:
źródło
Podejście do funkcji rekurencyjnej ma problem. Tworzy stos wywołań o rozmiarze O (n), co czyni nasz całkowity koszt pamięci O (n). To sprawia, że jest podatny na błąd przepełnienia stosu, gdy stos wywołań staje się zbyt duży i zabraknie miejsca.
Schemat optymalizacji ogona (TCO). Gdzie może zoptymalizować funkcje rekurencyjne, aby uniknąć tworzenia wysokiego stosu wywołań, a tym samym oszczędza koszt pamięci.
Istnieje wiele języków, które wykonują TCO (JavaScript, Ruby i kilka C), podczas gdy Python i Java nie robią TCO.
Język JavaScript potwierdził użycie :) http://2ality.com/2015/06/tail-call-optimization.html
źródło
W języku funkcjonalnym optymalizacja wywołania ogonowego jest tak, jakby wywołanie funkcji mogło zwrócić jako wynik częściowo wyrażone wyrażenie, które następnie zostałoby ocenione przez wywołującego.
f 6 zmniejsza się do g 6. Jeśli więc implementacja może zwrócić wynik g 6, a następnie wywołać to wyrażenie, zapisuje ramkę stosu.
Również
Zmniejsza do f 6 do g 6 lub h 6. Więc jeśli implementacja oceni c 6 i stwierdzi, że jest to prawda, może zmniejszyć,
Prosty interpreter optymalizacji połączeń innych niż ogon może wyglądać tak:
Interpretator optymalizacji połączeń końcowych może wyglądać tak:
źródło