Pourquoi les évaluateurs optimaux de λ-calcul sont-ils capables de calculer de grandes exponentiations modulaires sans formules?


135

Les nombres d'église sont un encodage de nombres naturels en tant que fonctions.

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

Soigneusement, vous pouvez exponentiellement 2 numéros d'églises en les appliquant simplement. Autrement dit, si vous appliquez 4 à 2, vous obtenez le numéro d'église 16, ou 2^4. De toute évidence, ce n'est absolument pas pratique. Les numéros d'église ont besoin d'une quantité linéaire de mémoire et sont vraiment très lents. 10^10Calculer quelque chose comme - ce que GHCI répond rapidement correctement - prendrait des années et ne pourrait de toute façon pas tenir la mémoire de votre ordinateur.

J'ai récemment expérimenté des évaluateurs λ optimaux. Lors de mes tests, j'ai accidentellement tapé ce qui suit sur mon λ-calculateur optimal:

10 ^ 10 % 13

C'était censé être une multiplication, pas une exponentiation. Avant que je puisse bouger mes doigts pour abandonner le programme permanent en désespoir de cause, il a répondu à ma demande:

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

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

Avec mon "bug alert" clignotant, je suis allé sur Google et vérifié, en 10^10%13 == 3effet. Mais le λ-calculateur n'était pas censé trouver ce résultat, il peut à peine stocker 10 ^ 10. J'ai commencé à le souligner, pour la science. Il m'a répondu instantanément 20^20%13 == 3, 50^50%13 == 4, 60^60%3 == 0. J'ai dû utiliser des outils externes pour vérifier ces résultats, car Haskell lui-même n'était pas en mesure de le calculer (en raison d'un débordement d'entier) (c'est si vous utilisez des nombres entiers et non des nombres entiers, bien sûr!). En le poussant à ses limites, c'était la réponse à 200^200%31:

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

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

Si nous avions une copie de l'univers pour chaque atome de l'univers, et que nous avions un ordinateur pour chaque atome que nous avions au total, nous ne pourrions pas stocker le numéro de l'église 200^200. Cela m'a incité à me demander si mon mac était vraiment aussi puissant. Peut-être que l'évaluateur optimal a pu sauter les branches inutiles et arriver directement à la réponse de la même manière que Haskell le fait avec une évaluation paresseuse. Pour tester cela, j'ai compilé le programme λ sur 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)

Cela génère correctement 1( 5 ^ 5 % 4) - mais lancez tout ce qui est ci 10^10- dessus et il sera bloqué, éliminant l'hypothèse.

L' évaluateur optimal que j'ai utilisé est un programme JavaScript non optimisé de 160 lignes qui n'incluait aucune sorte de module mathématique exponentiel - et la fonction de module lambda-calcul que j'ai utilisée était tout aussi 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))))))

Je n'ai utilisé aucun algorithme ou formule arithmétique modulaire spécifique. Alors, comment l'évaluateur optimal est-il capable d'arriver aux bonnes réponses?


2
Pouvez-vous nous en dire plus sur le type d'évaluation optimale que vous utilisez? Peut-être une citation papier? Merci!
Jason Dagit

11
J'utilise l'algorithme abstrait de Lamping, comme expliqué dans le livre The Optimal Implementation of Functional Programming Languages . Remarquez que je n'utilise pas le "oracle" (pas de croissants / croissants) car ce terme est typable EAL. De plus, au lieu de réduire au hasard les ventilateurs en parallèle, je parcours séquentiellement le graphique pour ne pas réduire les nœuds inaccessibles, mais j'ai bien peur que ce ne soit pas sur la littérature AFAIK ...
MaiaVictor

7
D'accord, au cas où quelqu'un serait curieux, j'ai mis en place un référentiel GitHub avec le code source de mon évaluateur optimal. Il contient de nombreux commentaires et vous pouvez le tester en cours d'exécution node test.js. Faites moi savoir si vous avez des questions.
MaiaVictor

1
Belle trouvaille! Je n'en sais pas assez sur l'évaluation optimale, mais je peux dire que cela me rappelle le petit théorème de Fermat / le théorème d'Euler. Si vous n'en êtes pas conscient, cela pourrait être un bon point de départ.
luqui le

5
C'est la première fois que je n'ai pas la moindre idée de la nature de la question, mais je soulève néanmoins la question, et en particulier la remarquable première réponse.
Marco13

Réponses:


124

Le phénomène provient de la quantité d'étapes de réduction bêta partagées, qui peuvent être radicalement différentes dans l'évaluation paresseuse de style Haskell (ou appel par valeur habituel, ce qui n'est pas si loin à cet égard) et dans Vuillemin-Lévy-Lamping- Kathail-Asperti-Guerrini- (et al…) évaluation «optimale». Il s'agit d'une fonctionnalité générale, totalement indépendante des formules arithmétiques que vous pourriez utiliser dans cet exemple particulier.

Partager signifie avoir une représentation de votre terme lambda dans lequel un «nœud» peut décrire plusieurs parties similaires du terme lambda réel que vous représentez. Par exemple, vous pouvez représenter le terme

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

en utilisant un graphe (acyclique dirigé) dans lequel il n'y a qu'une seule occurrence du sous-graphe représentant (\y.y)a, et deux arêtes ciblant ce sous-graphe. En termes de Haskell, vous avez un thunk, que vous évaluez une seule fois, et deux pointeurs vers ce thunk.

La mémorisation de style Haskell implémente le partage de sous-termes complets. Ce niveau de partage peut être représenté par des graphes acycliques dirigés. Le partage optimal n'a pas cette restriction: il peut également partager des sous-termes "partiels", ce qui peut impliquer des cycles dans la représentation graphique.

Pour voir la différence entre ces deux niveaux de partage, considérons le terme

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

Si votre partage est limité à des sous-termes complets comme c'est le cas dans Haskell, vous ne pouvez avoir qu'une seule occurrence de \z.z, mais les deux bêta-redexes ici seront distincts: l'un est (\z.z) xet l'autre est (\z.z) ((\z.z) x), et comme ils ne sont pas des termes égaux ils ne peuvent pas être partagés. Si le partage de sous-termes partiels est autorisé, alors il devient possible de partager le terme partiel (\z.z) [](qui n'est pas seulement la fonction \z.z, mais «la fonction \z.zappliquée à quelque chose ), qui s'évalue en une seule étape à quelque chose , quel que soit cet argument. vous pouvez avoir un graphe dans lequel un seul nœud représente les deux applications de\z.zà deux arguments distincts, et dans lesquels ces deux applications peuvent être réduites en une seule étape. Remarquez qu'il y a un cycle sur ce nœud, puisque l'argument de la "première occurrence" est précisément la "seconde occurrence". Enfin, avec un partage optimal, vous pouvez passer de (un graphique représentant) \x. (\z.z) ((\z.z) x))à (un graphique représentant) le résultat \x.xen une seule étape de réduction bêta (plus une certaine comptabilité). C'est essentiellement ce qui se passe dans votre évaluateur optimal (et la représentation graphique est également ce qui empêche l'explosion spatiale).

Pour des explications un peu étendues, vous pouvez consulter l'article L' optimalité faible et la signification du partage (ce qui vous intéresse, c'est l'introduction et la section 4.1, et peut-être certains des pointeurs bibliographiques à la fin).

Pour revenir à votre exemple, le codage des fonctions arithmétiques fonctionnant sur les entiers de l'Église est l'une des mines d'exemples "bien connues" où les évaluateurs optimaux peuvent mieux fonctionner que les langages traditionnels (dans cette phrase, bien connu signifie en fait qu'une poignée de les spécialistes connaissent ces exemples). Pour plus d'exemples de ce type, jetez un œil à l'article Safe Operators: Brackets Closed Forever par Asperti et Chroboczek (et en passant, vous trouverez ici des termes lambda intéressants qui ne sont pas typables en EAL; donc je vous encourage à prendre un regard sur les oracles, en commençant par cet article Asperti / Chroboczek).

Comme vous l'avez dit vous-même, ce type d'encodage n'est absolument pas pratique, mais il représente quand même une belle façon de comprendre ce qui se passe. Et permettez-moi de conclure par un défi pour une enquête plus approfondie: serez-vous en mesure de trouver un exemple sur lequel une évaluation optimale sur ces censés mauvais encodages est en fait comparable à l'évaluation traditionnelle sur une représentation raisonnable des données? (pour autant que je sache, c'est une vraie question ouverte).


34
C'est un premier article particulièrement détaillé. Bienvenue dans StackOverflow!
dfeuer le

2
Rien de moins que perspicace. Merci et bienvenue dans la communauté!
MaiaVictor

7

Ce n'est pas une réponse mais c'est une suggestion de l'endroit où vous pourriez commencer à chercher.

Il existe un moyen simple de calculer des exponentiations modulaires dans un petit espace, en particulier en réécrivant

(a * x ^ y) % z

comme

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

Si un évaluateur évalue comme ceci et conserve le paramètre d'accumulation asous sa forme normale, vous éviterez d'utiliser trop d'espace. Si en effet votre évaluateur est optimal, alors il ne doit probablement pas faire plus de travail que celui-ci, donc en particulier ne peut pas utiliser plus d'espace que le temps que celui-ci prend pour évaluer.

Je ne suis pas vraiment sûr de ce qu'est vraiment un évaluateur optimal, alors j'ai peur de ne pas pouvoir rendre cela plus rigoureux.


4
@Viclib Fibonacci comme le dit @Tom est un bon exemple. fibnécessite un temps exponentiel de manière naïve, qui peut être réduit à linéaire avec une simple mémorisation / programmation dynamique. Même le temps logarithmique (!) Est possible en calculant la puissance de la n-ième matrice de [[0,1],[1,1]](tant que vous comptez chaque multiplication pour avoir un coût constant).
chi

1
Même temps constant si vous êtes assez audacieux pour approcher :)
J. Abrahamson

5
@TomEllis Pourquoi quelque chose qui ne sait que réduire les expressions arbitraires de calcul lambda aurait-il une idée de cela (a * b) % n = ((a % n) * b) % n? C'est sûrement la partie mystérieuse.
Reid Barton

2
@ReidBarton je l'ai sûrement essayé! Mêmes résultats, cependant.
MaiaVictor

2
@TomEllis et Chi, il y a juste une petite remarque, cependant. Tout cela suppose que la fonction récursive traditionnelle est l'implémentation "naïve" du fib, mais l'OMI il existe une autre manière de l'exprimer qui est beaucoup plus naturelle. La forme normale de cette nouvelle représentation a la moitié de la taille de la traditionnelle), et Optlam parvient à la calculer linéairement! Je dirais donc que c'est la définition «naïve» du fib en ce qui concerne le λ-calcul. Je ferais un article de blog mais je ne suis pas sûr que cela en vaille vraiment la peine ...
MaiaVictor
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.