¿Por qué los evaluadores óptimos de cálculo λ pueden calcular grandes exponenciaciones modulares sin fórmulas?


135

Los números de iglesia son una codificación de números naturales como funciones.

(\ 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

Claramente, puede exponer 2 números de iglesia simplemente aplicándolos. Es decir, si aplica 4 a 2, obtiene el número de la iglesia 16, o 2^4. Obviamente, eso es completamente poco práctico. Los números de la iglesia necesitan una cantidad lineal de memoria y son muy, muy lentos. 10^10Calcular algo como , que GHCI responde rápidamente correctamente, llevaría años y, de todos modos, no podría caber en la memoria de su computadora.

Últimamente he estado experimentando con evaluadores λ óptimos. En mis pruebas, accidentalmente escribí lo siguiente en mi calculadora λ óptima:

10 ^ 10 % 13

Se suponía que era multiplicación, no exponenciación. Antes de que pudiera mover mis dedos para abortar el programa que se ejecuta para siempre con desesperación, respondió a mi solicitud:

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

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

Con mi "alerta de error" parpadeando, fui a Google y verifiqué, de 10^10%13 == 3hecho. Pero no se suponía que la calculadora λ encontrara ese resultado, apenas puede almacenar 10 ^ 10. Empecé a enfatizarlo, por la ciencia. En el acto se me respondió 20^20%13 == 3, 50^50%13 == 4, 60^60%3 == 0. Tuve que usar herramientas externas para verificar esos resultados, ya que el propio Haskell no pudo calcularlo (debido al desbordamiento de enteros) (por supuesto, si usa Integers not Ints). Llevándolo al límite, esta fue la respuesta a 200^200%31:

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

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

Si tuviéramos una copia del universo para cada átomo en el universo, y tuviéramos una computadora para cada átomo que teníamos en total, no podríamos almacenar el número de la iglesia 200^200. Esto me llevó a preguntarme si mi Mac era realmente tan poderoso. Quizás el evaluador óptimo pudo saltarse las ramas innecesarias y llegar directamente a la respuesta de la misma manera que Haskell lo hace con la evaluación perezosa. Para probar esto, compilé el programa λ para Haskell:

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)

Esto genera correctamente 1( 5 ^ 5 % 4), pero arroja cualquier cosa por encima 10^10y se atascará, eliminando la hipótesis.

El evaluador óptimo que utilicé es un programa JavaScript no optimizado de 160 líneas de largo que no incluía ningún tipo de matemática de módulo exponencial, y la función de módulo de cálculo lambda que utilicé fue igualmente simple:

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))))))

No utilicé ningún algoritmo o fórmula aritmética modular específica. Entonces, ¿cómo puede el evaluador óptimo llegar a las respuestas correctas?


2
¿Puede contarnos más sobre el tipo de evaluación óptima que utiliza? Tal vez una cita de papel? ¡Gracias!
Jason Dagit

11
Estoy usando el algoritmo abstracto de Lamping, como se explica en el libro La implementación óptima de lenguajes de programación funcional . Tenga en cuenta que no estoy usando el "oráculo" (sin croissants / corchetes) ya que ese término es EAL-typeable. Además, en lugar de reducir al azar ventiladores en paralelo, estoy recorriendo secuencialmente el gráfico como para no reducir nodos inalcanzables, pero me temo que esto no está en la literatura que yo sepa ...
MaiaVictor

77
De acuerdo, en caso de que alguien tenga curiosidad, he configurado un repositorio de GitHub con el código fuente para mi evaluador óptimo. Tiene muchos comentarios y puedes probarlo en ejecución node test.js. Hazme saber si tienes alguna pregunta.
MaiaVictor

1
Neat encontrar! No sé lo suficiente sobre la evaluación óptima, pero puedo decir que esto me recuerda al Pequeño Teorema de Fermat / Teorema de Euler. Si no lo sabe, podría ser un buen punto de partida.
luqui 29/07/2015

55
Esta es la primera vez que no tengo la menor idea de qué se trata la pregunta, pero, sin embargo, voté por la pregunta y, en particular, por la excelente primera respuesta posterior.
Marco13

Respuestas:


124

El fenómeno proviene de la cantidad de pasos de reducción beta compartidos, que pueden ser dramáticamente diferentes en la evaluación perezosa al estilo Haskell (o la llamada habitual por valor, que no está tan lejos a este respecto) y en Vuillemin-Lévy-Lamping- Kathail-Asperti-Guerrini- (et al ...) evaluación "óptima". Esta es una característica general, que es completamente independiente de las fórmulas aritméticas que podría usar en este ejemplo en particular.

Compartir significa tener una representación de su término lambda en el que un "nodo" puede describir varias partes similares del término lambda real que representa. Por ejemplo, puedes representar el término

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

usando un gráfico (acíclico dirigido) en el que solo hay una ocurrencia de la representación del subgrafo (\y.y)a, y dos bordes dirigidos a ese subgrafo. En términos de Haskell, tiene un thunk, que evalúa solo una vez, y dos punteros a este thunk.

La memorización al estilo Haskell implementa el intercambio de subterms completos. Este nivel de uso compartido se puede representar mediante gráficos acíclicos dirigidos. El intercambio óptimo no tiene esta restricción: también puede compartir subterms "parciales", lo que puede implicar ciclos en la representación gráfica.

Para ver la diferencia entre estos dos niveles de compartir, considere el término

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

Si su uso compartido está restringido a subterms completos, como es el caso en Haskell, es posible que solo ocurra una \z.z, pero los dos beta-redexes aquí serán distintos: uno es (\z.z) xy el otro es (\z.z) ((\z.z) x), y dado que no son términos iguales No se pueden compartir. Si se permite compartir subterráneos parciales, entonces es posible compartir el término parcial (\z.z) [](que no es solo la función \z.z, sino "la función \z.zaplicada a algo" ), que evalúa en un paso solo algo , sea cual sea este argumento. puede tener un gráfico en el que solo un nodo representa las dos aplicaciones de\z.za dos argumentos distintos, y en el que estas dos aplicaciones se pueden reducir en un solo paso. Observe que hay un ciclo en este nodo, ya que el argumento de la "primera aparición" es precisamente la "segunda aparición". Finalmente, con un uso compartido óptimo, puede pasar de (un gráfico que representa) \x. (\z.z) ((\z.z) x))a (un gráfico que representa) el resultado \x.xen solo un paso de la reducción beta (más algo de contabilidad). Esto es básicamente lo que sucede en su evaluador óptimo (y la representación gráfica también es lo que evita la explosión espacial).

Para explicaciones ligeramente extendidas, puede consultar el documento Optimización débil y el significado de compartir (lo que le interesa es la introducción y la sección 4.1, y quizás algunos de los punteros bibliográficos al final).

Volviendo a su ejemplo, la codificación de funciones aritméticas que trabajan en enteros de la Iglesia es una de las minas de ejemplos "bien conocidos" en los que los evaluadores óptimos pueden tener un mejor desempeño que los idiomas convencionales (en esta oración, bien conocido significa que un puñado de los especialistas conocen estos ejemplos). Para ver más ejemplos de este tipo, eche un vistazo al documento Safe Operators: Brackets Closed Forever de Asperti y Chroboczek (y, por cierto, encontrará aquí términos lambda interesantes que no son compatibles con EAL; por lo que le animo a que tome una mirada a los oráculos, comenzando con este artículo de Asperti / Chroboczek).

Como usted mismo dijo, este tipo de codificación es completamente poco práctico, pero aún representan una buena forma de entender lo que está sucediendo. Y permítanme concluir con un desafío para una mayor investigación: ¿podrá encontrar un ejemplo en el que la evaluación óptima de estas codificaciones supuestamente malas esté realmente a la par con la evaluación tradicional en una representación de datos razonable? (Hasta donde yo sé, esta es una pregunta realmente abierta).


34
Esa es una primera publicación inusualmente minuciosa. ¡Bienvenido a StackOverflow!
dfeuer

2
Nada menos que perspicaz. ¡Gracias y bienvenidos a la comunidad!
MaiaVictor

7

Esto no es una respuesta, pero es una sugerencia de dónde puede comenzar a buscar.

Hay una forma trivial de calcular exponenciaciones modulares en poco espacio, específicamente reescribiendo

(a * x ^ y) % z

como

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

Si un evaluador evalúa así y mantiene el parámetro de acumulación aen forma normal, entonces evitará usar demasiado espacio. Si su evaluador es realmente óptimo, entonces presumiblemente no debe hacer más trabajo que este, por lo que, en particular, no puede usar más espacio que el tiempo que le toma evaluar.

No estoy realmente seguro de qué es realmente un evaluador óptimo, así que me temo que no puedo hacer esto más riguroso.


44
@Viclib Fibonacci como @Tom dice es un buen ejemplo. fibrequiere tiempo exponencial de la manera ingenua, que puede reducirse a lineal con una simple memorización / programación dinámica. Incluso el tiempo logarítmico (!) Es posible calculando la enésima potencia matricial de [[0,1],[1,1]](siempre y cuando cuentes que cada multiplicación tiene un costo constante).
chi

1
Incluso tiempo constante si eres lo suficientemente atrevido como para aproximarse :)
J. Abrahamson

55
@TomEllis ¿Por qué algo que solo sabe cómo reducir las expresiones arbitrarias de cálculo lambda tiene alguna idea (a * b) % n = ((a % n) * b) % n? Esa es la parte misteriosa seguramente.
Reid Barton

2
@ReidBarton seguramente lo probé! Los mismos resultados, sin embargo.
MaiaVictor

2
@TomEllis y Chi, aunque solo hay un pequeño comentario. Todo eso supone que la función recursiva tradicional es la implementación fib "ingenua", pero en mi opinión, hay una forma alternativa de expresarla que es mucho más natural. ¡La forma normal de esa nueva representación tiene la mitad del tamaño de la tradicional), y Optlam logra calcularla linealmente! Entonces diría que esa es la definición "ingenua" de fib en lo que respecta al cálculo λ. Haría una publicación en el blog, pero no estoy seguro de que realmente valga la pena ...
MaiaVictor
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.