web-dev-qa-db-fra.com

foldl est récursif de queue, alors comment se fait-il que plis s'exécute plus rapidement que foldl?

Je voulais tester foldl vs foldr. D'après ce que j'ai vu, vous devriez utiliser foldl over foldr chaque fois que vous le pouvez en raison de l'optimisation de la reccursion de la queue.

C'est logique. Cependant, après avoir exécuté ce test, je suis confus:

foldr (prend 0,057 s lors de l'utilisation de la commande de temps):

a::a -> [a] -> [a]
a x = ([x] ++ )

main = putStrLn(show ( sum (foldr a [] [0.. 100000])))

foldl (prend 0,089 s lors de l'utilisation de la commande de temps):

b::[b] -> b -> [b]
b xs = ( ++ xs). (\y->[y])

main = putStrLn(show ( sum (foldl b [] [0.. 100000])))

Il est clair que cet exemple est trivial, mais je ne comprends pas pourquoi foldr bat foldl. Cela ne devrait-il pas être un cas clair où le foldl gagne?

69
Ori

Bienvenue dans le monde de l'évaluation paresseuse.

Quand on y pense en termes d'évaluation stricte, foldl semble "bon" et foldr semble "mauvais" parce que foldl est récursif à la queue, mais foldr devrait construire une tour dans la pile afin de pouvoir traiter le dernier élément en premier.

Cependant, une évaluation paresseuse change la donne. Prenons, par exemple, la définition de la fonction de carte:

map :: (a -> b) -> [a] -> [b]
map _ []     = []
map f (x:xs) = f x : map f xs

Ce ne serait pas trop bien si Haskell utilisait une évaluation stricte, car il devrait d'abord calculer la queue, puis ajouter l'élément (pour tous les éléments de la liste). La seule façon de le faire efficacement serait de construire les éléments à l'envers, semble-t-il.

Cependant, grâce à l'évaluation paresseuse de Haskell, cette fonction de carte est en fait efficace. Les listes dans Haskell peuvent être considérées comme des générateurs, et cette fonction de carte génère son premier élément en appliquant f au premier élément de la liste d'entrée. Lorsqu'il a besoin d'un deuxième élément, il fait à nouveau la même chose (sans utiliser d'espace supplémentaire).

Il s'avère que map peut être décrit en termes de foldr:

map f xs = foldr (\x ys -> f x : ys) [] xs

C'est difficile à dire en le regardant, mais une évaluation paresseuse entre en jeu car foldr peut donner à f son premier argument tout de suite:

foldr f z []     = z
foldr f z (x:xs) = f x (foldr f z xs)

Étant donné que f défini par map peut renvoyer le premier élément de la liste de résultats en utilisant uniquement le premier paramètre, le pli peut fonctionner paresseusement dans un espace constant.

Maintenant, l'évaluation paresseuse mord en arrière. Par exemple, essayez d'exécuter sum [1..1000000]. Il en résulte un débordement de pile. Pourquoi cela? Il faut juste évaluer de gauche à droite, non?

Voyons comment Haskell l'évalue:

foldl f z []     = z
foldl f z (x:xs) = foldl f (f z x) xs

sum = foldl (+) 0

sum [1..1000000] = foldl (+) 0 [1..1000000]
                 = foldl (+) ((+) 0 1) [2..1000000]
                 = foldl (+) ((+) ((+) 0 1) 2) [3..1000000]
                 = foldl (+) ((+) ((+) ((+) 0 1) 2) 3) [4..1000000]
                   ...
                 = (+) ((+) ((+) (...) 999999) 1000000)

Haskell est trop paresseux pour effectuer les ajouts au fur et à mesure. Au lieu de cela, il se retrouve avec une tour de thunks non évalués qui doivent être obligés d'obtenir un numéro. Le débordement de pile se produit pendant cette évaluation, car il doit récursivement profondément évaluer tous les thunks.

Heureusement, il existe une fonction spéciale dans Data.List appelée foldl' Qui fonctionne strictement. foldl' (+) 0 [1..1000000] n'empilera pas le débordement. (Remarque: j'ai essayé de remplacer foldl par foldl' Dans votre test, mais cela l'a en fait ralenti.)

93
Joey Adams

EDIT: En examinant à nouveau ce problème, je pense que toutes les explications actuelles sont quelque peu insuffisantes, j'ai donc écrit une explication plus longue.

La différence réside dans la façon dont foldl et foldr appliquent leur fonction de réduction. En regardant le cas foldr, nous pouvons le développer comme

foldr (\x -> [x] ++ ) [] [0..10000]
[0] ++ foldr a [] [1..10000]
[0] ++ ([1] ++ foldr a [] [2..10000])
...

Cette liste est traitée par sum, qui la consomme comme suit:

sum = foldl' (+) 0
foldl' (+) 0 ([0] ++ ([1] ++ ... ++ [10000]))
foldl' (+) 0 (0 : [1] ++ ... ++ [10000])     -- get head of list from '++' definition
foldl' (+) 0 ([1] ++ [2] ++ ... ++ [10000])  -- add accumulator and head of list
foldl' (+) 0 (1 : [2] ++ ... ++ [10000])
foldl' (+) 1 ([2] ++ ... ++ [10000])
...

J'ai omis les détails de la concaténation de la liste, mais c'est ainsi que la réduction se déroule. La partie importante est que tout est traité afin de minimiser les traversées de liste. foldr ne traverse la liste qu'une seule fois, les concaténations ne nécessitent pas de traversées de liste continues et sum consomme finalement la liste en une seule passe. De manière critique, l'en-tête de la liste est disponible de foldr immédiatement à sum, donc sum peut commencer à fonctionner immédiatement et les valeurs peuvent être gc'd lors de leur génération. Avec des frameworks de fusion tels que vector, même les listes intermédiaires seront probablement fusionnées.

Comparez cela à la fonction foldl:

b xs = ( ++xs) . (\y->[y])
foldl b [] [0..10000]
foldl b ( [0] ++ [] ) [1..10000]
foldl b ( [1] ++ ([0] ++ []) ) [2..10000]
foldl b ( [2] ++ ([1] ++ ([0] ++ [])) ) [3..10000]
...

Notez que maintenant le début de la liste n'est pas disponible tant que foldl n'est pas terminé. Cela signifie que la liste entière doit être construite en mémoire avant que sum puisse commencer à fonctionner. C'est beaucoup moins efficace dans l'ensemble. Exécution des deux versions avec +RTS -s montre les performances misérables de la récupération de place de la version foldl.

C'est également un cas où foldl' n'aidera pas. La rigueur supplémentaire de foldl' ne change pas la façon dont la liste intermédiaire est créée. La tête de la liste reste indisponible jusqu'à la fin du foldl ', donc le résultat sera toujours plus lent qu'avec foldr.

J'utilise la règle suivante pour déterminer le meilleur choix de fold

  • Pour les plis qui sont réduction, utilisez foldl' (par exemple, ce sera la seule/dernière traversée)
  • Sinon, utilisez foldr.
  • N'utilisez pas foldl.

Dans la plupart des cas, foldr est la meilleure fonction de repli car la direction de parcours est optimale pour l'évaluation paresseuse des listes. C'est aussi le seul capable de traiter des listes infinies. La rigueur supplémentaire de foldl' peut le rendre plus rapide dans certains cas, mais cela dépend de la façon dont vous utiliserez cette structure et de sa paresse.

26
John L

Je ne pense pas que quiconque ait réellement dit la vraie réponse à ce sujet, à moins que je manque quelque chose (ce qui pourrait bien être vrai et bien accueilli avec des downvotes).

Je pense que le plus grand différent dans ce cas est que foldr construit la liste comme ceci:

[0] ++ ([1] ++ ([2] ++ (... ++ [1000000])))

Alors que foldl construit la liste comme ceci:

((([0] ++ [1]) ++ [2]) ++ ...) ++ [999888]) ++ [999999]) ++ [1000000]

La différence est subtile, mais notez que dans la version foldr++ N'a toujours qu'un seul élément de liste comme argument de gauche. Avec la version foldl, il y a jusqu'à 999999 éléments dans l'argument gauche de ++ (En moyenne environ 500000), mais un seul élément dans l'argument droit.

Cependant, ++ Prend du temps proportionnel à la taille de l'argument de gauche, car il doit parcourir toute la liste des arguments de gauche jusqu'à la fin, puis rediriger ce dernier élément vers le premier élément de l'argument de droite (au mieux , il faut peut-être en faire une copie). La bonne liste d'arguments est inchangée, donc peu importe sa taille.

C'est pourquoi la version foldl est beaucoup plus lente. Cela n'a rien à voir avec la paresse à mon avis.

8
Clinton

Le problème est que l'optimisation de la récursivité de la queue est une optimisation de la mémoire, pas une optimisation du temps d'exécution!

L'optimisation de la récursivité de queue évite d'avoir à mémoriser des valeurs pour chaque appel récursif.

Ainsi, foldl est en fait "bon" et foldr est "mauvais".

Par exemple, en considérant les définitions de foldr et foldl:

foldl f z [] = z
foldl f z (x:xs) = foldl f (z `f` x) xs

foldr f z [] = z
foldr f z (x:xs) = x `f` (foldr f z xs)

C'est ainsi que l'expression "foldl (+) 0 [1,2,3]" est évaluée:

foldl (+) 0 [1, 2, 3]
foldl (+) (0+1) [2, 3]
foldl (+) ((0+1)+2) [3]
foldl (+) (((0+1)+2)+3) [ ]
(((0+1)+2)+3)
((1+2)+3)
(3+3)
6

Notez que foldl ne se souvient pas des valeurs 0, 1, 2 ..., mais passe l'expression entière (((0 + 1) +2) +3) comme argument paresseusement et ne l'évalue pas jusqu'à la dernière évaluation de foldl, où il atteint le cas de base et renvoie la valeur passée en tant que deuxième paramètre (z) qui n'est pas encore évalué.

D'un autre côté, c'est ainsi que foldr fonctionne:

foldr (+) 0 [1, 2, 3]
1 + (foldr (+) 0 [2, 3])
1 + (2 + (foldr (+) 0 [3]))
1 + (2 + (3 + (foldr (+) 0 [])))
1 + (2 + (3 + 0)))
1 + (2 + 3)
1 + 5
6

La différence importante ici est que, lorsque foldl évalue l'expression entière dans le dernier appel, en évitant d'avoir à revenir pour atteindre les valeurs mémorisées, foldr no. foldr mémorise un entier pour chaque appel et effectue un ajout à chaque appel.

Il est important de garder à l'esprit que foldr et foldl ne sont pas toujours équivalents. Par exemple, essayez de calculer ces expressions dans des câlins:

foldr (&&) True (False:(repeat True))

foldl (&&) True (False:(repeat True))

foldr et foldl ne sont équivalents que dans certaines conditions décrites ici

(Désolé pour mon mauvais anglais)

7
matiascelasco

Pour a, la liste [0.. 100000] Doit être développée immédiatement afin que foldr puisse commencer avec le dernier élément. Puis, comme il plie les choses, les résultats intermédiaires sont

[100000]
[99999, 100000]
[99998, 99999, 100000]
...
[0.. 100000] -- i.e., the original list

Étant donné que personne n'est autorisé à modifier cette valeur de liste (Haskell est un langage fonctionnel pur), le compilateur est libre de réutiliser la valeur. Les valeurs intermédiaires, comme [99999, 100000] Peuvent même être simplement des pointeurs dans la liste étendue [0.. 100000] Au lieu de listes séparées.

Pour b, regardez les valeurs intermédiaires:

[0]
[0, 1]
[0, 1, 2]
...
[0, 1, ..., 99999]
[0.. 100000]

Chacune de ces listes intermédiaires ne peut pas être réutilisée, car si vous modifiez la fin de la liste, vous avez modifié toutes les autres valeurs qui la pointent. Vous créez donc un tas de listes supplémentaires dont la création de mémoire prend du temps. Donc, dans ce cas, vous passez beaucoup plus de temps à allouer et à remplir ces listes qui sont des valeurs intermédiaires.

Étant donné que vous faites simplement une copie de la liste, un s'exécute plus rapidement car il commence par développer la liste complète, puis continue de déplacer un pointeur de l'arrière de la liste vers l'avant.

2
Harold L

Ni foldl ni foldr n'est optimisé pour la queue. C'est seulement foldl'.

Mais dans votre cas, utilisez ++ avec foldl' n'est pas une bonne idée car une évaluation successive de ++ fera traverser l'accumulateur en croissance encore et encore.

1