web-dev-qa-db-fra.com

Haskell a-t-il une optimisation récursive de queue?

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 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 destinés à être utilisés avec ce projet, donc je n'ai pas pris la peine de vérifier les zéros ou les nombres négatifs.

Cependant, lors de l'écriture d'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 délimitant la récursive de queue. Ceci est contraire à ce que j'avais entendu à propos de l'optimisation récursive de queue dans LISP. Quelle en est la raison?

82
haskell rascal

Haskell utilise lazy-evaluation 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 sont réduits autant que nécessaire pour continuer, pas plus. Cela ressemble à la façon dont vous simplifiez une expression mathématiquement, il est donc utile de penser de cette façon. Le fait que l'ordre d'évaluation 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 queue que vous aviez l'habitude de faire. Compilez avec -O2 Si vous voulez une optimisation!

Voyons comment nous évaluons facSlow 5 Comme étude de cas:

facSlow 5
5 * facSlow 4            -- Note that the `5-1` only got evaluated to 4
5 * (4 * facSlow 3)       -- because it has to be checked against 1 to see
5 * (4 * (3 * facSlow 2))  -- which definition of `facSlow` to apply.
5 * (4 * (3 * (2 * facSlow 1)))
5 * (4 * (3 * (2 * 1)))
5 * (4 * (3 * 2))
5 * (4 * 6)
5 * 24
120

Donc, juste comme vous étiez inquiet, nous avons une accumulation de nombres avant tout calcul, mais contrairement à vous étiez inquiet, il n'y a pas de pile d'appels de fonction facSlow qui attendent de terminer - chaque réduction est appliquée et disparaît, laissant un frame de pile dans son sillage (c'est parce que (*) est strict et déclenche donc 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înent sont les multiplications elles-mêmes. Si (*) Est considéré comme un constructeur de données strict, c'est ce qu'on appelle gardé récursion (bien qu'il soit généralement désigné comme tel avec non = -strict constructeurs de données, où ce qui reste dans son sillage sont les constructeurs de données - lorsqu'ils sont forcés par un accès supplémentaire).

Maintenant, regardons la queue récursive fac 5:

fac 5
fac' 5 1
fac' 4 {5*1}       -- Note that the `5-1` only got evaluated to 4
fac' 3 {4*{5*1}}    -- because it has to be checked against 1 to see
fac' 2 {3*{4*{5*1}}} -- which definition of `fac'` to apply.
fac' 1 {2*{3*{4*{5*1}}}}
{2*{3*{4*{5*1}}}}        -- the thunk "{...}" 
(2*{3*{4*{5*1}}})        -- is retraced 
(2*(3*{4*{5*1}}))        -- to create
(2*(3*(4*{5*1})))        -- the computation
(2*(3*(4*(5*1))))        -- on the stack
(2*(3*(4*5)))
(2*(3*20))
(2*60)
120

Ainsi, vous pouvez voir comment la récursivité de la queue en elle-même ne vous a pas fait gagner de temps ou d'espace. Non seulement cela prend plus d'étapes que 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.

Ce thunk est ensuite démêlé en parcourant it vers le bas, recréant le calcul sur la pile. Il existe également 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éfinir

facSlim :: (Integral a) => a -> a
facSlim x = facS' x 1 where
    facS' 1 y = y
    facS' x y = facS' (x-1) $! (x*y) 

Cela force facS' À être strict dans son deuxième argument. (Il est déjà strict dans son premier argument car cela doit être évalué pour décider quelle définition de facS' Appliquer.)

Parfois, la rigueur peut énormément aider, parfois c'est une grosse erreur car la paresse est plus efficace. Ici c'est une bonne idée:

facSlim 5
facS' 5 1
facS' 4 5 
facS' 3 20
facS' 2 60
facS' 1 120
120

C'est ce que vous vouliez réaliser je pense.

Sommaire

  • Si vous souhaitez optimiser votre code, la première étape consiste à compiler avec -O2
  • La récursivité de la queue n'est bonne que lorsqu'il n'y a pas d'accumulation de thunk, et l'ajout de rigueur aide généralement à l'empêcher, si et où cela est approprié. Cela se produit lorsque vous créez un résultat qui est nécessaire plus tard en même temps.
  • Parfois, la récursivité de la queue est un mauvais plan et la récursion surveillée est un meilleur ajustement, c'est-à-dire lorsque le résultat que vous construisez sera nécessaire petit à petit, par portions. Voir cette question à propos de foldr et foldl par exemple, et testez-les l'un contre l'autre.

Essayez ces deux:

length $ foldl1 (++) $ replicate 1000 
    "The size of intermediate expressions is more important than tail recursion."
length $ foldr1 (++) $ replicate 1000 
    "The number of reductions performed is more important than tail recursion!!!"

foldl1 Est récursif de queue, tandis que foldr1 Effectue une récursivité surveillée de sorte que le premier élément est immédiatement présenté pour un traitement/accès ultérieur. (La première "parenthèses" à gauche à la fois, (...((s+s)+s)+...)+s, Forçant sa liste d'entrée complètement à sa fin et construisant un gros thunk de calcul futur beaucoup plus tôt que ses résultats complets ne sont nécessaires; la deuxième parenthèse à droite progressivement, s+(s+(...+(s+s)...)), en consommant la liste d'entrée bit par bit, pour que le tout puisse fonctionner dans un espace constant, avec optimisations).

Vous devrez peut-être ajuster le nombre de zéros en fonction du matériel que vous utilisez.

153
AndrewC

Il convient de mentionner que la fonction fac n'est pas un bon candidat pour la récursivité surveillée. La récursivité de la queue est le chemin à parcourir ici. En raison de la paresse, vous n'obtenez pas l'effet du TCO dans votre fac' fonctionne car les arguments de l'accumulateur continuent de construire de gros thunks, qui, une fois évalués, nécessiteront une énorme pile. Pour éviter cela et obtenir l'effet souhaité du TCO, vous devez rendre ces arguments d'accumulateur stricts.

{-# LANGUAGE BangPatterns #-}

fac :: (Integral a) => a -> a
fac x = fac' x 1 where
  fac' 1  y = y
  fac' x !y = fac' (x-1) (x*y)

Si vous compilez à l'aide de -O2 (ou juste -O) GHC le fera probablement de lui-même dans la phase analyse de rigueur .

15
is7s

Vous devriez consulter l'article wiki sur récursion de queue dans Haskell . En particulier, en raison de l'évaluation des expressions, le type de récursivité que vous souhaitez est récursion gardé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écursion 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, tandis que la récursion surveillée fonctionne plus naturellement).

(Et en apprenant Haskell, le reste de ces pages wiki est génial aussi!)

6

Si je me souviens bien, GHC optimise automatiquement les fonctions récursives simples en fonctions optimisées récursives de queue.

0
Ncat