2013-06-10 9 views
10

grałem z permutation w kilku programach i natknęliśmy się na ten mały eksperyment:Prolog wydajność i rekurencji typu

metody permutacji 1:

permute([], []). 
permute([X|Rest], L) :- 
    permute(Rest, L1), 
    select(X, L, L1). 

metoda Permutacja 2:

permute([], []). 
permute(L, [P | P1]) :- 
    select(P, L, L1), 
    permute(L1, P1). 

Metoda permutacji 3 (użyj wbudowanego):

permute(L, P) :- permutation(L, P). 

Rozumiem, że dobrą praktyką jest używanie rekurencji ogona, a ogólnie używanie wbudowanych powinno być wydajne. Ale kiedy biegnę, co następuje:

time(findall(P, permute([1,2,3,4,5,6,7,8,9], P), L)). 

Mam następujące wyniki, które są względnie spójne w kilku seriach:

Metoda 1:

% 772,064 inferences, 1.112 CPU in 2.378 seconds (47% CPU, 694451 Lips) 

Metoda 2:

% 3,322,118 inferences, 2.126 CPU in 4.660 seconds (46% CPU, 1562923 Lips) 

Metoda 3:

% 2,959,245 inferences, 1.967 CPU in 4.217 seconds (47% CPU, 1504539 Lips) 

Metoda rekurencyjna nienastawiona na ogon jest znacznie bardziej wydajna w czasie rzeczywistym.

Czy dany typ rekursji jest generalnie bardziej efektywny w czasie rzeczywistym, wszystkie inne rzeczy są równe (wiem, że nie zawsze jest to proste założenie)? To, co mówi mi ten eksperyment, to fakt, że nie chcę zawsze dążyć do rekurencji ogona, ale najpierw muszę wykonać analizę wydajności, a następnie rozważyć korzyści związane z wydajnością z innymi korzyściami, jakie ma rekurencja ogona.

Odpowiedz

4

Naprawdę miłe pytanie.

Oczekiwanie na kogoś, kto opublikuje analizę czasu/przestrzeni. Jedyne zastrzeżenie, jakie mogę zaoferować, to ta, że ​​metoda 1 & 2 nie kończy się, gdy pierwszy argument jest wolny, podczas gdy metoda 3 ma.

W każdym razie metoda 1 wydaje się znacznie bardziej wydajna niż wbudowana. Dobrze wiedzieć.

edit, a biorąc pod uwagę, że realizacja biblioteka jedynie dostosować instancji argumentów i zwraca metoda 1, zamierzam dyskutować na liście dyskusyjnej SWI-Prolog metodę 2 jako alternatywa (lub wolisz zrobić to sam , daj mi znać).

więcej edycja: Zapomniałam wcześniej podkreślić, że permutacji/3 (powiedzmy, sposób 2) pozwala leksykograficznie zamówione rozwiązań, a metoda 1 nie. Myślę, że może to być silny wymóg preferencyjny, ale powinien być wyrażony jako opcja, biorąc pod uwagę wzrost wydajności, który pozwala metoda 1.

?- time(call_nth(permute1([0,1,2,3,4,5,6,7,8,9],P),1000000)). 
% 3,112,758 inferences, 3,160 CPU in 3,162 seconds (100% CPU, 984974 Lips) 
P = [1, 4, 8, 3, 7, 6, 5, 9, 2|...] . 

?- time(call_nth(permute2([0,1,2,3,4,5,6,7,8,9],P),1000000)). 
% 10,154,843 inferences, 9,779 CPU in 9,806 seconds (100% CPU, 1038398 Lips) 
P = [2, 7, 8, 3, 9, 1, 5, 4, 6|...] . 

YAP daje jeszcze więcej zysk!

?- time(call_nth(permute1([0,1,2,3,4,5,6,7,8,9],P),1000000)). 
% 0.716 CPU in 0.719 seconds (99% CPU) 
P = [1,4,8,3,7,6,5,9,2,0] 

?- time(call_nth(permute2([0,1,2,3,4,5,6,7,8,9],P),1000000)). 
% 8.357 CPU in 8.368 seconds (99% CPU) 
P = [2,7,8,3,9,1,5,4,6,0] 

edit: Mam napisali komentarz na SWI-Prolog doc page dotyczące tego tematu.

+1

Nie pamiętam, żebym kiedykolwiek użył (wszystkich) permutacji bez potrzeby i polegając na kolejności leksykograficznej. Permutacje są tradycyjnie wymieniane w porządku leksykograficznym, a wszystkie biblioteki zapewniające permutacje tworzą je w tej kolejności. W tym sensie byłoby trochę dziwnie i bardzo mylnie zmienić kolejność. –

+0

@Boris: Masz rację, [C++] (http://en.cppreference.com/w/cpp/algorithm/next_permutation) na przykład wywołaj funkcję next_permutation i po prostu radzisz o kolejności leksykograficznej w dokumentacji. Ale współczynnik 3 (lub więcej) przyspieszenia może być znaczący. – CapelliC

+0

Patrząc na podany link, wygląda na to, że nie jest to "po prostu rada", ale w specyfikacji. W każdym razie, zakładam, że kolejność jest ważna tylko dlatego, że wszystkie biblioteki, które sprawdziłem, trzymają się jej (podobnie jak podręczniki matematyczne). Czy istnieje zapotrzebowanie na zamawianie lub czy jest to po prostu konwencja? –

4

Podejrzewam, że przyczyną tego śledztwa było the discussion about tail-recursive sum/2 using an accumulator kontra nie. Przykład sum/2 jest bardzo cięty i suchy; jedna wersja wykonuje arytmetykę na stosie, druga używa akumulatora. Jednak, jak większość rzeczy w świecie rzeczywistym, ogólna prawda jest "to zależy". Na przykład porównać skuteczność metod 1 i 2 przy użyciu pełnej instancji:

?- time(permute([1,2,3,4,5,6,7,8,9], [1,2,3,4,5,6,7,8,9])). 
% 18 inferences, 0.000 CPU in 0.000 seconds (66% CPU, 857143 Lips) 
true ; 
% 86,546 inferences, 0.022 CPU in 0.022 seconds (100% CPU, 3974193 Lips) 
false. 

?- time(permute([1,2,3,4,5,6,7,8,9], [1,2,3,4,5,6,7,8,9])). 
% 18 inferences, 0.000 CPU in 0.000 seconds (62% CPU, 857143 Lips) 
true ; 
% 47 inferences, 0.000 CPU in 0.000 seconds (79% CPU, 940000 Lips) 
false. 

Metoda 1 Metoda 2 uderzeń podczas generowania rozwiązań (jak w badaniach), ale metoda 2 uderzenia metoda 1, gdy jesteś po prostu sprawdzam. Patrząc na kod łatwo zrozumieć, dlaczego: pierwszy musi powtórzyć cały ogon z listy, a drugi po prostu spróbować wybrać jeden element. W takim przypadku łatwo jest wskazać przypadek generowania i powiedzieć, że jest bardziej pożądany. Ta determinacja jest po prostu jedną z kompromisów, o których należy pamiętać przy kontaktach z Prologiem. Bardzo trudno jest tworzyć predykaty, które są wszystkim dla wszystkich ludzi i zawsze świetnie się sprawdzają; musisz zdecydować, które są "uprzywilejowane ścieżki", a które nie.

Przypominam sobie, że ktoś niedawno pokazał przykład dołączania list "podczas powrotu" i jak można wziąć coś, co nie jest lub nie powinno być rekursywne i czy działa dzięki unifikacji, ale ja nie " t mieć połączenie pod ręką. Mam nadzieję, że kto go przyniósł ostatni raz (Will?) Pojawi się i podzieli się tym.

Świetne pytanie, przy okazji. Twoja metoda dochodzeniowa jest ważna, po prostu musisz wziąć pod uwagę również inne wzorce tworzenia instancji. Mówiąc osobiście, zazwyczaj staram się bardziej martwić o poprawność i ogólność niż z góry. Jeśli zobaczę natychmiast, jak korzystać z akumulatora, to zrobię to, ale w przeciwnym razie nie zrobię tego w ten sposób, dopóki nie napotkam na rzeczywistą potrzebę lepszej wydajności. Rekursja ogona jest tylko jedną z metod poprawy wydajności; często są inne rzeczy, którymi należy się zająć jako źle lub gorzej.

+1

z [docs] (http://www.swi-prolog.org/pldoc/doc_for?object=permutation/2): 'is_permutation (Xs, Ys): - msort (Xs, posortowane), msort (Ys , Posortowane). " – CapelliC

+1

Dzięki Daniel. Wziąłem odpowiedź na 'sums' jako dobrą radę wartości nominalnej i było to zgodne ze wszystkim, co przygotowałem (preferowana jest rekurencja ogona). Potem natknąłem się na mój nieco banalny przykład i szukałem w nim myśli w stosunku do wszechobecnej porady, że rekurencja ogona jest najlepsza. Twoje wyjaśnienie jest bardzo pomocne. – lurker

+0

s (X): Dziękuję za zbadanie innych zastosowań 'perm (Xs, Ys)' niż 'ground (Xs), var (Ys)'! Sposób w jaki widzę teraz, "metoda 2" jest drogą do zrobienia. A nawet więcej, jeśli obecne są dodatkowe ograniczenia. – repeat

6

Naprawdę miłe pytanie, +1!

wezwanie Tail (i, jako szczególny przypadek, ogon rekurencji) optymalizacja odnosi się tylko wtedy, gdy orzeczenie jest deterministyczny! Tak nie jest w tym przypadku, więc twój predykat zawsze będzie wymagał miejsca na stosie lokalnym, bez względu na to, w jakiej kolejności postawisz cele. Wersja rekurencyjna nienastawiona na ogon jest bardziej efektywna (czasowo) przy generowaniu wszystkich rozwiązań, ponieważ musi wykonać mniej unifikacji podczas cofania.

EDIT: Rozwijam się w tej kwestii, ponieważ warto bardziej szczegółowo przyjrzeć się różnicy w wydajności.

Po pierwsze, dla jasności, zmienić nazwę dwie różne wersje, aby wyjaśnić, która wersja mówię:

Wariant 1: Non-tail rekurencyjne:

permute1([], []). 
permute1([X|Rest], L) :- 
    permute1(Rest, L1), 
    select(X, L, L1). 

Wariant 2 : Rekurencyjny tył:

permute2([], []). 
permute2(L, [P|P1]) :- 
    select(P, L, L1), 
    permute2(L1, P1). 

Należy ponownie zauważyć, że chociaż drugi wariant n jest wyraźnie rekursywny, wywołanie ogona (a więc i rekurencja ogona) optymalizacja pomaga tylko wtedy, gdy predykat jest deterministyczny, a zatem nie może pomóc, gdy generujemy wszystkie permutacje, ponieważ punkty wyboru pozostają w tym przypadku.

Należy również pamiętać, że świadomie zachowuję oryginalne nazwy zmiennych i główną nazwę orzecznika, aby uniknąć wprowadzania kolejnych wariantów. Osobiście wolę konwencję nazewnictwa, która wyjaśnia, które zmienne oznaczają , wyliczając, dołączając do ich nazw s, analogicznie do zwykłej angielskiej liczby mnogiej. Ponadto wolę nazwy predykatów, które wyraźniej wykazują (przynajmniej zamierzony i pożądany) deklaratywny, relacyjny charakter kodu i zalecają unikanie imperatywnych nazw z tego powodu.


Rozważmy teraz rozkładanie pierwszym wariancie i częściowo oceniając go na liście 3 elementów. Zaczynamy prosty cel:

?- Xs = [A,B,C], permute1(Xs, L). 

a następnie stopniowo rozwijać go podłączając definicji permute1/2, robiąc wszystkie unifikacje głowy wyraźne. W pierwszej iteracji otrzymujemy:

 
?- Xs = [A,B,C], Xs1 = [B,C], permute1(Xs1, L1), select(A, L, L1). 

Oznaczam wyróżnienia czołowe pogrubieniem.

Pozostaje jeszcze jeden cel: permute1/2. Więc powtórzyć proces, ponownie podłączając zastosowanie tylko ciałem reguły orzecznika w miejscu jego głowie:

 
?- Xs = [A,B,C], Xs1 = [B,C], Xs2 = [C], permute1(Xs2, L2), select(B, L1, L2), select(A, L, L1). 

jeszcze jeden przejściu tego, a my otrzymujemy:

 
?- Xs = [A,B,C], Xs1 = [B,C], Xs2 = [C], select(C, L2, []), select(B, L1, L2), select(A, L, L1). 

To co oryginał cel wygląda tak, jakbyśmy tylko kilkakrotnie rozkładali definicję permute1/2.


A co z drugim wariantem?Znowu zaczynamy prosty cel:

?- Xs = [A,B,C], permute2(Xs, Ys). 

Jedna iteracja rozkładanie permute2/2 daje równoważną wersję:

 
?- Xs = [A,B,C], Ys = [P|P1], select(P, Xs, L1), permute2(L1, P1). 

a drugi plony iteracji:

 
?- Xs = [A,B,C], Ys = [P|P1], select(P, Xs, L1), Ys1 = [P1|P2], select(P1, L1, L2), permute2(L2, P2). 

Zostawiam trzecią i ostatnia iteracja jako proste ćwiczenie, które zalecam, aby zrobić.


A z tym jest oczywiste, co początkowo zapewne nie spodziewał: Duża różnica leży w głowy unifikacji, którego pierwsza wersja wykonuje deterministycznie na samym początku, a druga wersja wykonuje w kółko w trakcie cofania.

Ten słynny przykład ładnie pokazuje, że wbrew powszechnym oczekiwaniom rekurencja ogona może być dość powolna, jeśli kod nie jest deterministyczny.

+0

"... ponieważ musi wykonać mniej unifikacji podczas cofania." To nie jest dokładne. Powodem, dla którego wersja 2 jest wolniejsza, jest liczba wywołań do 'permute/2'. Dla każdej odpowiedzi 'select' będzie wywoływać' permute/2', podczas gdy wersja 1 nazywa 'permute/2' tylko raz przed wywołaniem' select/3'' – false

+0

To prawda, ale jak dowiemy się, gdzie faktycznie jest czas ? Czy to jest zjednoczenie głowy, czy też narzut wywołania predykatu? – mat

+0

Liczba inferencji jest dość solidną ** abstrakcyjną ** miarą. Analiza wyników pomiarów betonu jest prawie niemożliwa. I właściwie nie warto. W konkretnym przypadku różnica między 'findall (L, Goal, Ls)' i '(Goal, false)' jest czynnikiem 5 ... – false