Dlaczego optymalne wartościowanie rachunku λ są w stanie obliczyć duże modułowe wykładniki bez wzorów?


135

Liczby kościelne to kodowanie liczb naturalnych jako funkcji.

(\ f x  (f x))             -- church number 1
(\ f x  (f (f (f x))))     -- church number 3
(\ f x  (f (f (f (f x))))) -- church number 4

Zgrabnie, możesz potęgować 2 liczby kościołów, po prostu je stosując. To znaczy, jeśli zastosujesz 4 do 2, otrzymasz numer kościoła 16, lub 2^4. Oczywiście jest to całkowicie niepraktyczne. Liczby kościelne wymagają liniowej ilości pamięci i są naprawdę, bardzo powolne. Obliczanie czegoś takiego 10^10- na które GHCI szybko odpowiada poprawnie - zajęłoby wieki i i tak nie mieściłoby się w pamięci komputera.

Ostatnio eksperymentowałem z optymalnymi oceniającymi λ. Podczas moich testów przypadkowo wpisałem na moim optymalnym kalkulatorze λ:

10 ^ 10 % 13

Miało to być mnożenie, a nie potęgowanie. Zanim zdążyłem ruszyć palcami, aby przerwać wiecznie działający program w rozpaczy, odpowiedział na moją prośbę:

3
{ iterations: 11523, applications: 5748, used_memory: 27729 }

real    0m0.104s
user    0m0.086s
sys     0m0.019s

Z migającym „alertem o błędzie” udałem się do Google i zweryfikowałem 10^10%13 == 3. Ale kalkulator λ nie miał znaleźć tego wyniku, ledwo może przechowywać 10 ^ 10. Zacząłem to podkreślać dla nauki. To natychmiast odpowiedział mi 20^20%13 == 3, 50^50%13 == 4, 60^60%3 == 0. Musiałem użyć zewnętrznych narzędzi, aby zweryfikować te wyniki, ponieważ sam Haskell nie był w stanie tego obliczyć (z powodu przepełnienia liczb całkowitych) (oczywiście jeśli używasz liczb całkowitych, a nie Intów!). Pchając go do granic, oto odpowiedź na 200^200%31:

5
{ iterations: 10351327, applications: 5175644, used_memory: 23754870 }

real    0m4.025s
user    0m3.686s
sys 0m0.341s

Gdybyśmy mieli jedną kopię wszechświata dla każdego atomu we wszechświecie i mielibyśmy komputer dla każdego atomu, który mieliśmy w sumie, nie moglibyśmy przechowywać numeru kościoła 200^200. To skłoniło mnie do pytania, czy mój Mac jest naprawdę tak potężny. Być może optymalny oceniający był w stanie pominąć niepotrzebne gałęzie i uzyskać właściwą odpowiedź w taki sam sposób, jak Haskell robi z leniwą oceną. Aby to przetestować, skompilowałem program λ do Haskella:

data Term = F !(Term -> Term) | N !Double
instance Show Term where {
    show (N x) = "(N "++(if fromIntegral (floor x) == x then show (floor x) else show x)++")";
    show (F _) = "(λ...)"}
infixl 0 #
(F f) # x = f x
churchNum = F(\(N n)->F(\f->F(\x->if n<=0 then x else (f#(churchNum#(N(n-1))#f#x)))))
expMod    = (F(\v0->(F(\v1->(F(\v2->((((((churchNum # v2) # (F(\v3->(F(\v4->(v3 # (F(\v5->((v4 # (F(\v6->(F(\v7->(v6 # ((v5 # v6) # v7))))))) # v5))))))))) # (F(\v3->(v3 # (F(\v4->(F(\v5->v5)))))))) # (F(\v3->((((churchNum # v1) # (churchNum # v0)) # ((((churchNum # v2) # (F(\v4->(F(\v5->(F(\v6->(v4 # (F(\v7->((v5 # v7) # v6))))))))))) # (F(\v4->v4))) # (F(\v4->(F(\v5->(v5 # v4))))))) # ((((churchNum # v2) # (F(\v4->(F(\v5->v4))))) # (F(\v4->v4))) # (F(\v4->v4))))))) # (F(\v3->(((F(\(N x)->F(\(N y)->N(x+y)))) # v3) # (N 1))))) # (N 0))))))))
main = print $ (expMod # N 5 # N 5 # N 4)

To poprawnie wyprowadza 1( 5 ^ 5 % 4) - ale wrzuci cokolwiek powyżej, 10^10a utknie, eliminując hipotezę.

Optymalne oceniający użyłem jest 160-linie długie, unoptimized Program JavaScript, który nie zawierał jakichkolwiek wykładniczej modułu matematyki - i funkcja lambda-rachunek moduł Kiedyś była równie prosta:

ab.(bcd.(ce.(dfg.(f(efg)))e))))(λc.(cde.e)))(λc.(a(bdef.(dg.(egf))))(λd.d)(λde.(ed)))(bde.d)(λd.d)(λd.d))))))

Nie użyłem żadnego konkretnego modularnego algorytmu ani wzoru arytmetycznego. Jak więc optymalny oceniający jest w stanie dojść do właściwych odpowiedzi?


2
Czy możesz nam powiedzieć więcej o rodzaju optymalnej oceny, której używasz? Może cytat z papieru? Dzięki!
Jason Dagit

11
Używam abstrakcyjnego algorytmu Lampinga, jak wyjaśniono w książce The Optimal Implementation of Functional Programming Languages . Zauważ, że nie używam słowa „wyrocznia” (bez rogalików / nawiasów), ponieważ ten termin jest typu EAL. Poza tym zamiast losowo zmniejszać liczbę wentylatorów równolegle, przechodzę sekwencyjnie po wykresie, aby nie redukować nieosiągalnych węzłów, ale obawiam się, że to nie jest w literaturze AFAIK ...
MaiaVictor.

7
OK, gdyby ktoś był ciekawy, utworzyłem repozytorium GitHub z kodem źródłowym dla mojego optymalnego oceniającego. Ma wiele komentarzy i możesz go przetestować node test.js. Daj mi znać, jeśli masz jakieś pytania.
MaiaVictor

1
Schludnie znaleźć! Nie wiem wystarczająco dużo na temat oceny optymalnej, ale mogę powiedzieć, że przypomina mi to Małe Twierdzenie Fermata / Twierdzenie Eulera. Jeśli nie jesteś tego świadomy, może to być dobry punkt wyjścia.
luqui

5
Jest to pierwszy raz, kiedy nie mam najmniejszego pojęcia, o co chodzi w tym pytaniu, ale mimo to zagłosuję za pytaniem, a zwłaszcza z doskonałą odpowiedzią na pierwszy post.
Marco13

Odpowiedzi:


124

Zjawisko to wynika z liczby wspólnych kroków redukcji beta, które mogą być dramatycznie różne w przypadku leniwej oceny w stylu Haskella (lub zwykłej oceny wartości, która nie jest tak daleko pod tym względem) oraz w przypadku Vuillemin-Lévy-Lamping- Kathail-Asperti-Guerrini- (i wsp.…) „Optymalna” ocena. Jest to ogólna funkcja, która jest całkowicie niezależna od formuł arytmetycznych, których możesz użyć w tym konkretnym przykładzie.

Dzielenie się oznacza posiadanie reprezentacji wyrażenia lambda, w którym jeden „węzeł” może opisywać kilka podobnych części rzeczywistego wyrażenia lambda, które reprezentujesz. Na przykład możesz przedstawić termin

\x. x ((\y.y)a) ((\y.y)a)

używając (skierowanego acyklicznego) wykresu, na którym występuje tylko jedno wystąpienie podgrafu reprezentującego (\y.y)ai dwie krawędzie celujące w ten podgraf. Używając terminologii Haskella, masz jedną uwagę, którą oceniasz tylko raz, i dwa wskaźniki do tej pozycji.

Zapamiętywanie w stylu Haskella polega na udostępnianiu pełnych subterminów. Ten poziom udostępniania można przedstawić za pomocą skierowanych grafów acyklicznych. Optymalne współdzielenie nie ma tego ograniczenia: może również dzielić „częściowe” podterminy, co może oznaczać cykle w reprezentacji wykresu.

Aby zobaczyć różnicę między tymi dwoma poziomami udostępniania, rozważ ten termin

\x. (\z.z) ((\z.z) x)

Jeśli twoje udostępnianie jest ograniczone do pełnych podterminów, jak ma to miejsce w Haskell, możesz mieć tylko jedno wystąpienie \z.z, ale dwa beta-redexes tutaj będą różne: jeden jest, (\z.z) xa drugi jest (\z.z) ((\z.z) x), a ponieważ nie są to równe warunki nie można ich udostępniać. Jeśli zezwala się na współdzielenie częściowych podwyrażeń, możliwe staje się współdzielenie częściowego terminu (\z.z) [](to jest nie tylko funkcji \z.z, ale „funkcji \z.zzastosowanej do czegoś ), który w jednym kroku wylicza po prostu coś , bez względu na ten argument. możesz mieć wykres, na którym tylko jeden węzeł reprezentuje dwie aplikacje\z.zdo dwóch różnych argumentów, w których te dwie aplikacje można zredukować w jednym kroku. Zwróć uwagę, że w tym węźle istnieje cykl, ponieważ argumentem „pierwszego wystąpienia” jest dokładnie „drugie wystąpienie”. Wreszcie, dzięki optymalnemu udostępnianiu możesz przejść od (wykres reprezentujący) \x. (\z.z) ((\z.z) x))do (wykres reprezentujący) wynik \x.xw zaledwie jednym kroku redukcji beta (plus trochę księgowości). To jest w zasadzie to, co dzieje się w twoim optymalnym oceniającym (a reprezentacja wykresu jest również tym, co zapobiega eksplozji przestrzeni).

Aby uzyskać nieco rozszerzone wyjaśnienia, możesz spojrzeć na artykuł Słaba optymalność i znaczenie dzielenia się (interesuje Cię wprowadzenie i sekcja 4.1, a może niektóre wskaźniki bibliograficzne na końcu).

Wracając do twojego przykładu, kodowanie funkcji arytmetycznych działających na kościelnych liczbach całkowitych jest jedną z „dobrze znanych” kopalni przykładów, w których optymalni ewaluatorzy mogą działać lepiej niż języki głównego nurtu (w tym zdaniu dobrze znane oznacza właściwie, że garść specjaliści są świadomi tych przykładów). Po więcej takich przykładów zajrzyj do artykułu Safe Operators: Brackets Closed Forever autorstwa Aspertiego i Chroboczka (a przy okazji znajdziesz tu ciekawe wyrażenia lambda, które nie są typu EAL; więc zachęcam do spojrzeć na wyrocznie, zaczynając od tego artykułu Asperti / Chroboczek).

Jak sam powiedziałeś, ten rodzaj kodowania jest całkowicie niepraktyczny, ale nadal stanowi dobry sposób na zrozumienie tego, co się dzieje. I pozwól mi zakończyć wyzwaniem do dalszych badań: czy będziesz w stanie znaleźć przykład, na którym optymalna ocena tych rzekomo złych kodowań jest rzeczywiście porównywalna z tradycyjną oceną rozsądnej reprezentacji danych? (o ile wiem, jest to prawdziwa otwarta kwestia).


34
To niezwykle dokładny pierwszy post. Witamy w StackOverflow!
dfeuer

2
Nic mniej niż wnikliwe. Dziękujemy i witamy w społeczności!
MaiaVictor

7

To nie jest odpowiedź, ale jest to sugestia, gdzie możesz zacząć szukać.

Istnieje trywialny sposób obliczania wykładników modularnych na małej przestrzeni, w szczególności przez przepisywanie

(a * x ^ y) % z

tak jak

(((a * x) % z) * x ^ (y - 1)) % z

Jeśli oceniający dokonuje takiej oceny i zachowuje kumulowany parametr aw normalnej formie, unikniesz używania zbyt dużej ilości miejsca. Jeśli rzeczywiście Twój ewaluator jest optymalny, to przypuszczalnie nie może wykonywać więcej pracy niż ta, więc w szczególności nie może wykorzystać więcej miejsca niż czas potrzebny na ocenę.

Nie jestem do końca pewien, co tak naprawdę jest optymalnym oceniającym, więc obawiam się, że nie mogę uczynić tego bardziej rygorystycznym.


4
@Viclib Fibonacci, jak mówi @Tom, jest dobrym przykładem. fibwymaga wykładniczego czasu w naiwny sposób, który można zredukować do liniowego za pomocą prostego zapamiętywania / programowania dynamicznego. Nawet czas logarytmiczny (!) Jest możliwy dzięki obliczeniu potęgi n-tej macierzy [[0,1],[1,1]](o ile liczysz każde mnożenie, aby mieć stały koszt).
chi

1
Nawet stały czas, jeśli masz dość odwagi, aby zbliżyć się :)
J. Abrahamson

5
@TomEllis Dlaczego ktoś, kto tylko wie, jak zredukować dowolne wyrażenia rachunku lambda, miałby o tym myśleć (a * b) % n = ((a % n) * b) % n? To z pewnością tajemnicza część.
Reid Barton

2
@ReidBarton na pewno próbowałem! Jednak te same wyniki.
MaiaVictor

2
@TomEllis i Chi, jest jednak tylko mała uwaga. To wszystko zakłada, że ​​tradycyjna funkcja rekurencyjna jest „naiwną” implementacją fib, ale IMO istnieje alternatywny sposób wyrażenia tego, który jest znacznie bardziej naturalny. Normalna forma tej nowej reprezentacji jest o połowę mniejsza od tradycyjnej), a Optlam potrafi obliczyć to liniowo! Twierdziłbym więc, że jest to „naiwna” definicja fib, jeśli chodzi o rachunek λ. Zrobiłbym wpis na blogu, ale nie jestem pewien, czy naprawdę warto ...
MaiaVictor
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.