Znam ideę podstawowej eliminacji rekurencji ogona, w której funkcje, które zwracają bezpośredni wynik wywołania do siebie, można przepisać jako pętle iteracyjne.
foo(...):
# ...
return foo(...)
Rozumiem również, że jako szczególny przypadek funkcja może być nadal przepisana, jeśli wywołanie rekurencyjne jest zawinięte w wywołanie do cons
.
foo(...):
# ...
return (..., foo(...))
Jaka właściwość na cons
to pozwala? Jakie funkcje poza cons
zawijaniem rekurencyjnego wywołania ogona bez niszczenia naszej możliwości jego iteracyjnego przepisywania?
GCC (ale nie Clang) jest w stanie zoptymalizować ten przykład „ mnożenia modulo rekurencji ogona ”, ale nie jest jasne, jaki mechanizm pozwala mu to odkryć ani jak dokonuje jego transformacji.
pow(x, n):
if n == 0: return 1
else if n == 1: return x
else: return x * pow(x, n-1)
if(n==0) return 0;
(nie zwraca 1 jak w twoim pytaniu).x^0 = 1
, więc to jest błąd. Ale to nie ma znaczenia dla reszty pytania; iteracyjny asm sprawdza najpierw ten szczególny przypadek. Ale, co dziwne, iteracyjna implementacja wprowadza wielokrotność tego,1 * x
czego nie było w źródle, nawet jeśli stworzymyfloat
wersję. gcc.godbolt.org/z/eqwine (i gcc tylko się udaje-ffast-math
.)return 0
Został naprawiony. Mnożenie przez 1 jest interesujące. Nie jestem pewien, co z tym zrobić.float
bez-ffast-math
, chociaż jest to ta sama wartość mnożona za każdym razem. (Z wyjątkiem 1.0f`, który może być punktem spornym?)Odpowiedzi:
Chociaż GCC prawdopodobnie stosuje reguły ad-hoc, możesz je uzyskać w następujący sposób. Użyję
pow
do zilustrowania, ponieważ jesteśfoo
tak niejasno zdefiniowany. Można równieżfoo
najlepiej rozumieć jako przykład optymalizacji ostatniego wywołania w odniesieniu do zmiennych pojedynczego przypisania, jak ma język Oz i jak omówiono w Pojęciach, technikach i modelach programowania komputerowego . Zaletą stosowania zmiennych pojedynczego przypisania jest to, że pozwala pozostać w deklaratywnym paradygmacie programowania. Zasadniczo możesz mieć każde polefoo
zwrotów struktury reprezentowane przez zmienne pojedynczego przypisania, które są następnie przekazywanefoo
jako dodatkowe argumenty.foo
następnie staje się rekurencyjnymvoid
funkcja powrotu Nie jest do tego wymagana szczególna spryt.Wracając
pow
najpierw do transformacji w styl kontynuacji przejścia .pow
staje się:Wszystkie połączenia są teraz połączeniami ogonowymi. Jednak stos kontrolny został przeniesiony do przechwyconych środowisk w zamknięciach reprezentujących kontynuacje.
Następnie zdefunkcjonalizuj kontynuacje. Ponieważ istnieje tylko jedno wywołanie rekurencyjne, wynikowa struktura danych reprezentująca zdefunkcjonalizowane kontynuacje jest listą. Otrzymujemy:
Wystarczy
applyPow(k, acc)
wziąć listę, czyli wolną monoidę, polub jąk=Cons(x, Cons(x, Cons(x, Nil)))
i zrób z niejx*(x*(x*acc))
. Ale ponieważ*
jest asocjacyjny i generalnie tworzy monoid z jednostką1
, możemy ponownie powiązać to z((x*x)*x)*acc
, i, dla uproszczenia, przyczepić się1
do rozpoczęcia produkcji(((1*x)*x)*x)*acc
. Kluczową sprawą jest to, że możemy częściowo obliczyć wynik, zanim jeszcze to zrobimyacc
. Oznacza to, że zamiast przekazywaćk
jako listę, która jest w zasadzie jakąś niepełną „składnią”, którą „zinterpretujemy” na końcu, możemy „zinterpretować” ją na bieżąco. Rezultatem jest to, że możemy zastąpićNil
jednostką monoidu,1
w tym przypadku, iCons
działaniem monoidu*
, a terazk
reprezentuje „działający produkt”.applyPow(k, acc)
staje się właśniek*acc
tym, w co możemy ponownie włączyćpow2
i uprościć tworzenie:Oryginalna wersja w stylu rekurencyjnej, przypominającej akumulator
pow
.Oczywiście nie mówię, że GCC robi cały ten tok rozumowania w czasie kompilacji. Nie wiem, jakiej logiki używa GCC. Chodzi mi o to, że raz to rozumowałem, stosunkowo łatwo jest rozpoznać wzorzec i natychmiast przetłumaczyć oryginalny kod źródłowy na ostateczną formę. Jednak transformacja CPS i transformacja defunkcjonalizacyjna są całkowicie ogólne i mechaniczne. Stamtąd można zastosować techniki syntezy, wylesiania lub superkompilacji, aby spróbować wyeliminować powtórzone kontynuacje. Transformacje spekulacyjne można odrzucić, jeśli nie można wyeliminować całego przydziału powtórzonych kontynuacji. Podejrzewam jednak, że byłoby to zbyt kosztowne, aby robić to przez cały czas, w ogólnym ujęciu, stąd więcej podejść ad hoc.
Jeśli chcesz być śmieszny, możesz przeczytać artykuł Recycling Continuations, który również wykorzystuje CPS i reprezentacje kontynuacji jako danych, ale robi coś podobnego, ale różniącego się od modulo-konsolidacji ogona-rekurencji. Opisuje, w jaki sposób można tworzyć algorytmy odwracania wskaźnika przez transformację.
Ten wzorzec transformacji i defekcjonalizacji CPS jest dość potężnym narzędziem do zrozumienia i jest stosowany z dobrym skutkiem w serii artykułów, które tu wymieniam .
źródło
Będę przez jakiś czas owijać w bawełnę, ale jest sens.
Półgrupy
Odpowiedź brzmi: asocjacyjna właściwość operacji binarnej redukcji .
To dość abstrakcyjne, ale mnożenie jest dobrym przykładem. Jeśli x , y i z są pewnymi liczbami naturalnymi (lub liczbami całkowitymi, liczbami wymiernymi, liczbami rzeczywistymi, liczbami zespolonymi, macierzami N × N lub dowolną liczbą innych elementów), to x × y jest tego samego rodzaju liczby jako zarówno x, jak i y . Zaczęliśmy od dwóch liczb, więc jest to operacja binarna, i otrzymaliśmy jedną, więc zmniejszyliśmy liczbę liczb o jedną, dzięki czemu jest to operacja redukcji. A ( x × y ) × z jest zawsze takie samo jak x × ( y ×z ), która jest własnością asocjacyjną.
(Jeśli już to wszystko wiesz, możesz przejść do następnej sekcji).
Kilka innych rzeczy, które często widzisz w informatyce, które działają w ten sam sposób:
"a"+"b"+"c"
to"abc"
, czy rozpocząć"ab"+"c"
lub"a"+"bc"
)[a]++[b]++[c]
jest podobnie[a,b,c]
albo od tyłu do przodu lub od przodu do tyłu.cons
na głowie i ogonie, jeśli myślisz o głowie jako liście singletonów. To tylko połączenie dwóch list.&
,|
i^
Niektóre rzeczy, które nie:
int print2(int x, int y) { return printf( "%d %d\n", x, y ); }
, jakprint2( print2(x,y), z );
iprint2( x, print2(y,z) );
mają różne dane wyjściowe.Nazwaliśmy go wystarczająco przydatną koncepcją. Zestaw z operacją, która ma te właściwości, jest półgrupą . Tak więc liczby rzeczywiste po pomnożeniu są półgrupą. A twoje pytanie okazuje się jednym ze sposobów, w jaki taka abstrakcja staje się przydatna w prawdziwym świecie. Operacje na półgrupach można zoptymalizować tak, jak pytasz.
Wypróbuj to w domu
O ile mi wiadomo, technika ta została po raz pierwszy opisana w 1974 r. W pracy Daniela Friedmana i Davida Wise'a „Składanie stylizowanych rekurencji w iteracjach” , chociaż przyjęli oni kilka więcej właściwości, niż się okazało, że musieli.
Haskell jest świetnym językiem do zilustrowania tego, ponieważ ma
Semigroup
klasę w swojej standardowej bibliotece. Nazywa to operacją rodzajowąSemigroup
operator<>
. Ponieważ listy i łańcuchy są instancjamiSemigroup
, ich instancje definiują na przykład<>
jako operator konkatenacji++
. A przy odpowiednim imporcie[a] <> [b]
jest alias[a] ++ [b]
, który jest[a,b]
.Ale co z liczbami? Właśnie widzieliśmy, że typy liczbowe to półgrupy z dodawaniem lub mnożeniem! Który może być
<>
dlaDouble
? Cóż, jedno z nich! Haskell określa typyProduct Double
,where (<>) = (*)
(to znaczy rzeczywista definicja Haskell), a takżeSum Double
,where (<>) = (+)
.Jedna zmarszczka polega na tym, że wykorzystałeś fakt, że 1 to tożsamość multiplikatywna. Półgrupa z tożsamością nazywana jest monoidem i jest zdefiniowana w pakiecie Haskell
Data.Monoid
, który wywołuje ogólny element tożsamości klasymempty
.Sum
,Product
a lista ma każdy element tożsamości (odpowiednio 0, 1 i[]
), więc są to zarówno wystąpienia,Monoid
jak iSemigroup
. (Nie mylić z monadą , więc zapomnij, że nawet je wychowałem.)To wystarczająca ilość informacji, aby przetłumaczyć twój algorytm na funkcję Haskella za pomocą monoidów:
Co ważne, zauważ, że jest to półgrupa modulo rekurencji ogona: każdy przypadek jest albo wartością, wywołaniem rekurencyjnym ogona, albo iloczynem półgrupy obu. Ten przykład przydał się również
mempty
w jednym z przypadków, ale gdybyśmy go nie potrzebowali, moglibyśmy to zrobić przy użyciu bardziej ogólnej klasySemigroup
.Załadujmy ten program do GHCI i zobaczmy, jak to działa:
Pamiętasz, jak zadeklarowaliśmy
pow
rodzaj ogólnyMonoid
, którego typ nazwaliśmya
? Daliśmy GHCI wystarczających informacji, aby wywnioskować, że typa
o toProduct Integer
, co stanowiinstance
oMonoid
których<>
praca jest liczbą całkowitą mnożenia. Takpow 2 4
rozwija się rekurencyjnie2<>2<>2<>2
, który jest2*2*2*2
lub16
. Jak na razie dobrze.Ale nasza funkcja używa tylko ogólnych operacji monoidów. Wcześniej mówiłem, że istnieje inna instancja
Monoid
wywołanaSum
, której<>
operacją jest+
. Czy możemy tego spróbować?To samo rozszerzenie daje nam teraz
2+2+2+2
zamiast2*2*2*2
. Mnożenie jest dodawaniem, ponieważ potęgowanie polega na mnożeniu!Podałem jednak jeszcze jeden przykład monoidu Haskella: listy, których operacja polega na konkatenacji.
Pisanie
[2]
informuje kompilator, że jest to lista,<>
na listach jest++
, tak[2]++[2]++[2]++[2]
jest[2,2,2,2]
.Wreszcie algorytm (dwa, w rzeczywistości)
Przez prostą wymianę
x
z[x]
, konwertowanie ogólny algorytm, który używa rekursji modulo półgrupa na taki, który tworzy listę. Która lista Lista elementów, których dotyczy algorytm<>
. Ponieważ użyliśmy tylko operacji na półgrupach, które również mają listy, wynikowa lista będzie izomorficzna w stosunku do pierwotnego obliczenia. A ponieważ pierwotna operacja była asocjacyjna, równie dobrze możemy ocenić elementy od tyłu do przodu lub od przodu do tyłu.Jeśli Twój algorytm kiedykolwiek osiągnie przypadek podstawowy i zakończy działanie, lista nie będzie pusta. Ponieważ sprawa terminalu zwróciła coś, będzie to ostatni element listy, więc będzie miała co najmniej jeden element.
Jak zastosować binarną operację redukcji do każdego elementu listy w kolejności? Zgadza się, fold. Więc można zastąpić
[x]
przezx
, uzyskać listę elementów, aby zmniejszyć przez<>
, a następnie albo w prawo lub w lewo-fold-krotnie listy:Wersja z
foldr1
faktycznie istnieje w standardowej bibliotece, zarównosconcat
dla , jakSemigroup
imconcat
dlaMonoid
. Robi leniwy prawy fold na liście. Oznacza to, że rozszerza[Product 2,Product 2,Product 2,Product 2]
się2<>(2<>(2<>(2)))
.W tym przypadku nie jest to skuteczne, ponieważ nie możesz nic zrobić z poszczególnymi warunkami, dopóki nie wygenerujesz wszystkich z nich. (W pewnym momencie miałem tutaj dyskusję na temat tego, kiedy stosować prawe fałdy, a kiedy ścisłe lewe fałdy, ale poszło to zbyt daleko.)
Wersja z
foldl1'
jest ściśle ocenianą lewą zakładką. Oznacza to, że funkcja rekurencyjna ogona ze ścisłym akumulatorem. To(((2)<>2)<>2)<>2
oblicza, obliczane natychmiast, a nie później, gdy jest to potrzebne. (Przynajmniej nie ma żadnych opóźnień w owczarni się. Listę zagiętą generowany jest tutaj przez inną funkcję, które mogą zawierać ocenę leniwy) Tak, zagięcia oblicza(4<>2)<>2
, a następnie natychmiast oblicza8<>2
, potem16
. Właśnie dlatego potrzebowaliśmy, aby operacja była asocjacyjna: właśnie zmieniliśmy grupowanie nawiasów!Ściśle lewy pas jest odpowiednikiem tego, co robi GCC. Najbardziej wysunięta w lewo liczba w poprzednim przykładzie to akumulator, w tym przypadku działający produkt. Na każdym kroku jest on mnożony przez następny numer na liście. Innym sposobem wyrażenia tego jest: iterujesz wartości, które chcesz pomnożyć, utrzymując działający produkt w akumulatorze, a przy każdej iteracji mnożymy akumulator przez następną wartość. Oznacza to, że jest to
while
pętla w przebraniu.Czasami może być tak samo wydajny. Kompilator może być w stanie zoptymalizować strukturę danych listy w pamięci. Teoretycznie ma wystarczająco dużo informacji w czasie kompilacji, aby dowiedzieć się, że powinien to zrobić tutaj:
[x]
jest singletonem, więc[x]<>xs
jest taki sam jakcons x xs
. Każda iteracja funkcji może być w stanie ponownie użyć tej samej ramki stosu i zaktualizować parametry w miejscu.W danym przypadku bardziej odpowiednie może być prawe lub ścisłe lewe pasowanie, więc wiedz, który z nich chcesz. Są też rzeczy, które może wykonać tylko prawy fold (np. Generowanie interaktywnego wyjścia bez czekania na wszystkie dane wejściowe i działanie na nieskończonej liście). Tutaj jednak redukujemy sekwencję operacji do prostej wartości, więc chcemy ścisłego zagięcia w lewo.
Tak więc, jak widać, możliwe jest automatyczne optymalizowanie modułu rekurencji ogona dowolnej półgrupy (jednym z przykładów jest dowolny z typowych typów numerycznych pod zwielokrotnieniem) do leniwego prawego fałdu lub ścisłego lewego fałdu w jednej linii Haskell.
Dalsze uogólnianie
Dwa argumenty operacji binarnej nie muszą być tego samego typu, o ile wartość początkowa jest tego samego typu co wynik. (Oczywiście możesz zawsze odwracać argumenty, aby dopasować kolejność składania, którą wykonujesz, w lewo lub w prawo.) Możesz więc wielokrotnie dodawać łaty do pliku, aby uzyskać zaktualizowany plik, lub zaczynając od wartości początkowej 1.0, podziel przez liczby całkowite, aby zgromadzić wynik zmiennoprzecinkowy. Lub dodaj elementy do pustej listy, aby uzyskać listę.
Innym typem uogólnienia jest stosowanie fałd nie do list, ale do innych
Foldable
struktur danych. Często niezmienna liniowo połączona lista nie jest strukturą danych, którą chcesz dla danego algorytmu. Jedną kwestią, o której nie wspomniałem powyżej, jest to, że o wiele bardziej wydajne jest dodawanie elementów z przodu listy niż z tyłu, a gdy operacja nie jest przemienna, zastosowaniex
po lewej i prawej stronie operacji nie jest to samo. Musisz więc użyć innej struktury, takiej jak para list lub drzewo binarne, aby przedstawić algorytm, który można zastosować zarównox
po prawej,<>
jak i po lewej stronie.Należy również pamiętać, że właściwość asocjacyjna umożliwia przegrupowanie operacji na inne użyteczne sposoby, takie jak podział i podbij:
Lub automatyczny paralelizm, w którym każdy wątek redukuje podzakres do wartości, która jest następnie łączona z innymi.
źródło
pow(float x, unsigned n)
wersja gcc.godbolt.org/z/eqwine optymalizuje się tylko-ffast-math
(co oznacza-fassociative-math
. Ścisła zmiennoprzecinkowa oczywiście nie jest powiązana, ponieważ różne tymczasowe = inne zaokrąglenie). Wprowadza1.0f * x
nieobecny w abstrakcyjnej maszynie C (ale zawsze da identyczny wynik). Zatem mnożenia n-1do{res*=x;}while(--n!=1)
są takie same jak rekurencyjne, więc jest to pominięta optymalizacja.