J'ai découvert la commande "time" sous Unix aujourd'hui et j'ai pensé l'utiliser pour vérifier la différence de temps d'exécution entre les fonctions récursives de queue et récursives normales dans Haskell.
J'ai écrit les fonctions suivantes:
--tail recursive
fac :: (Integral a) => a -> a
fac x = fac' x 1 where
fac' 1 y = y
fac' x y = fac' (x-1) (x*y)
--normal recursive
facSlow :: (Integral a) => a -> a
facSlow 1 = 1
facSlow x = x * facSlow (x-1)
Ceux-ci sont valides en gardant à l'esprit qu'ils étaient uniquement à utiliser avec ce projet, donc je n'ai pas pris la peine de vérifier les zéros ou les nombres négatifs.
Cependant, en écrivant une méthode principale pour chacun, en les compilant et en les exécutant avec la commande "time", les deux avaient des temps d'exécution similaires avec la fonction récursive normale bordant la dernière récursive. Ceci est contraire à ce que j'avais entendu en ce qui concerne l'optimisation récursive de queue dans lisp. Quelle en est la raison?
la source
fac
savez plus ou moins comment ghc calcule à l'product [n,n-1..1]
aide d'une fonction auxiliaireprod
, mais ceproduct [1..n]
serait bien sûr plus simple. Je ne peux que supposer qu'ils ne l'ont pas rendu strict dans son deuxième argument au motif que c'est le genre de chose que ghc est très sûr de pouvoir compiler jusqu'à un simple accumulateur.Réponses:
Haskell utilise l'évaluation paresseuse pour implémenter la récursivité, donc traite tout comme une promesse de fournir une valeur en cas de besoin (c'est ce qu'on appelle un thunk). Les thunks ne sont réduits que dans la mesure nécessaire pour continuer, pas plus. Cela ressemble à la façon dont vous simplifiez mathématiquement une expression, il est donc utile de la penser de cette façon. Le fait que l'ordre d'évaluation ne soit pas spécifié par votre code permet au compilateur de faire beaucoup d'optimisations encore plus intelligentes que la simple élimination des appels de fin à laquelle vous êtes habitué. Compilez avec
-O2
si vous voulez une optimisation!Voyons comment nous évaluons en
facSlow 5
tant qu'étude de cas:Donc, tout comme vous vous inquiétez, nous avons une accumulation de nombres avant que les calculs ne se produisent, mais contrairement à ce que vous craigniez, il n'y a pas de pile d'
facSlow
appels de fonction qui attendent de se terminer - chaque réduction est appliquée et disparaît, laissant un cadre de pile dans son wake (c'est parce qu'il(*)
est strict et déclenche ainsi l'évaluation de son deuxième argument).Les fonctions récursives de Haskell ne sont pas évaluées de manière très récursive! La seule pile d'appels qui traîne sont les multiplications elles-mêmes. Si
(*)
est considéré comme un constructeur de données strict, c'est ce que l'on appelle la récursivité protégée (bien qu'elle soit généralement appelée comme telle avec les constructeurs de données non restreints, où ce qui reste dans son sillage sont les constructeurs de données - lorsqu'ils sont forcés par un accès supplémentaire).Regardons maintenant la queue-récursive
fac 5
:Vous pouvez donc voir comment la récursivité de queue en elle-même ne vous a pas fait gagner du temps ni de l'espace. Non seulement cela prend plus de pas dans l'ensemble
facSlow 5
, mais il construit également un thunk imbriqué (montré ici comme{...}
) - nécessitant un espace supplémentaire pour cela - qui décrit le calcul futur, les multiplications imbriquées à effectuer.Cette thunk est ensuite démêlé en traversant ce vers le bas, de recréer le calcul sur la pile. Il y a aussi ici un risque de provoquer un débordement de pile avec des calculs très longs, pour les deux versions.
Si nous voulons optimiser cela manuellement, tout ce que nous devons faire est de le rendre strict. Vous pouvez utiliser l'opérateur d'application strict
$!
pour définirCela oblige
facS'
à être strict dans son deuxième argument. (C'est déjà strict dans son premier argument car cela doit être évalué pour décider quelle définition defacS'
appliquer.)Parfois, la rigueur peut aider énormément, parfois c'est une grosse erreur car la paresse est plus efficace. Ici c'est une bonne idée:
C'est ce que vous vouliez réaliser je pense.
Résumé
-O2
foldr
etfoldl
par exemple, et testez-les les uns par rapport aux autres.Essayez ces deux:
foldl1
est récursif en queue, alors qu'ilfoldr1
effectue une récursivité protégée de sorte que le premier élément soit immédiatement présenté pour un traitement / accès ultérieur. (Le premier "met entre parenthèses" à gauche à la fois,(...((s+s)+s)+...)+s
forçant sa liste d'entrée complètement à sa fin et créant un gros bruit de calcul futur beaucoup plus tôt que ses résultats complets ne sont nécessaires; le second entre parenthèses vers la droite progressivements+(s+(...+(s+s)...))
, consommant l'entrée listez petit à petit, donc le tout est capable de fonctionner dans un espace constant, avec des optimisations).Vous devrez peut-être ajuster le nombre de zéros en fonction du matériel que vous utilisez.
la source
Il convient de mentionner que la
fac
fonction n'est pas un bon candidat pour la récursivité protégée. La récursivité de la queue est la voie à suivre ici. En raison de la paresse, vous n'obtenez pas l'effet du TCO dans votrefac'
fonction car les arguments de l'accumulateur continuent de créer de gros thunks, qui, lorsqu'ils sont évalués, nécessiteront une énorme pile. Pour éviter cela et obtenir l'effet désiré du TCO, vous devez rendre ces arguments d'accumulateur stricts.Si vous compilez en utilisant
-O2
(ou juste-O
) GHC le fera probablement seul dans le phase d' analyse de rigueur .la source
$!
qu'avecBangPatterns
, mais c'est une bonne réponse. Surtout la mention de l'analyse de rigueur.Vous devriez consulter l'article du wiki sur la récursion des queues dans Haskell . En particulier, en raison de l'évaluation des expressions, le type de récursivité que vous souhaitez est la récursivité protégée . Si vous travaillez sur les détails de ce qui se passe sous le capot (dans la machine abstraite pour Haskell), vous obtenez le même genre de chose qu'avec la récursivité de queue dans des langages stricts. Parallèlement à cela, vous avez une syntaxe uniforme pour les fonctions paresseuses (la récursivité de queue vous liera à une évaluation stricte, alors que la récursivité surveillée fonctionne plus naturellement).
(Et en apprenant Haskell, le reste de ces pages wiki est également génial!)
la source
Si je me souviens bien, GHC optimise automatiquement les fonctions récursives simples en fonctions optimisées récursives de queue.
la source