Quelle propriété des inconvénients permet d'éliminer les inconvénients de la récursion modulo de la queue?


14

Je connais l'idée de l' élimination de base de la récursivité de la queue, où les fonctions qui renvoient le résultat direct d'un appel peuvent être réécrites sous forme de boucles itératives.

foo(...):
    # ...
    return foo(...)

Je comprends également que, comme cas spécial, la fonction peut toujours être réécrite si l'appel récursif est encapsulé dans un appel à cons.

foo(...):
    # ...
    return (..., foo(...))

Quelle propriété de conspermet cela? Quelles fonctions autres que conspeuvent encapsuler un appel de queue récursif sans détruire notre capacité à le réécrire de manière itérative?

GCC (mais pas Clang) est capable d'optimiser cet exemple de « multiplication modulo de récursivité de queue », mais on ne sait pas quel mécanisme lui permet de découvrir cela ou comment il effectue ses transformations.

pow(x, n):
    if n == 0: return 1
    else if n == 1: return x
    else: return x * pow(x, n-1)

1
Dans votre lien d'explorateur de compilateur Godbolt, votre fonction a if(n==0) return 0;(pas de retour 1 comme dans votre question). x^0 = 1, c'est donc un bug. Mais ce n'est pas important pour le reste de la question; l'asm itératif vérifie d'abord ce cas spécial. Mais étrangement, l'implémentation itérative introduit une multiplicité de ce 1 * xqui n'était pas présent dans la source, même si nous faisons une floatversion. gcc.godbolt.org/z/eqwine (et gcc ne réussit qu'avec -ffast-math.)
Peter Cordes

@PeterCordes Bonne prise. Le return 0a été corrigé. La multiplication par 1 est intéressante. Je ne sais pas quoi en penser.
Maxpm

Je pense que c'est un effet secondaire de la façon dont GCC se transforme en le transformant en boucle. Il est clair que gcc a des optimisations manquées ici, par exemple, les manquer floatsans -ffast-math, même s'il s'agit de la même valeur multipliée à chaque fois. (Sauf pour le 1.0f` qui pourrait être le point de friction?)
Peter Cordes

Réponses:


12

Bien que GCC utilise probablement des règles ad hoc, vous pouvez les dériver de la manière suivante. Je vais utiliser powpour illustrer puisque vous êtes foosi vaguement défini. En outre, il foopourrait être mieux compris comme une instance d'optimisation du dernier appel en ce qui concerne les variables à affectation unique comme le langage Oz et comme discuté dans Concepts, Techniques et Modèles de Programmation Informatique . L'avantage d'utiliser des variables à affectation unique est qu'il permet de rester dans un paradigme de programmation déclarative. Essentiellement, vous pouvez avoir chaque champ des fooretours de structure représenté par des variables à affectation unique qui sont ensuite passées en footant qu'arguments supplémentaires. foodevient alors une queue récursivevoidfonction de retour. Aucune intelligence particulière n'est nécessaire pour cela.

Revenant à pow, d'abord, se transformer en style de passage de continuation . powdevient:

pow(x, n):
    return pow2(x, n, x => x)

pow2(x, n, k):
    if n == 0: return k(1)
    else if n == 1: return k(x)
    else: return pow2(x, n-1, y => k(x*y))

Tous les appels sont maintenant des appels de queue. Cependant, la pile de contrôle a été déplacée dans les environnements capturés dans les fermetures représentant les suites.

Ensuite, défonctionnalisez les suites. Puisqu'il n'y a qu'un seul appel récursif, la structure de données résultante représentant les suites non fonctionnalisées est une liste. On a:

pow(x, n):
    return pow2(x, n, Nil)

pow2(x, n, k):
    if n == 0: return applyPow(k, 1)
    else if n == 1: return applyPow(k, x)
    else: return pow2(x, n-1, Cons(x, k))

applyPow(k, acc):
    match k with:
        case Nil: return acc
        case Cons(x, k):
            return applyPow(k, x*acc)

Que applyPow(k, acc)fait est de prendre une liste, à savoir monoïde libre, comme k=Cons(x, Cons(x, Cons(x, Nil)))et d' en faire x*(x*(x*acc)). Mais comme il *est associatif et forme généralement un monoïde avec l'unité 1, nous pouvons le réassocier ((x*x)*x)*acc, et, pour plus de simplicité, clouer un point 1de départ, la production devient alors juste(((1*x)*x)*x)*acc . L'essentiel est que nous pouvons réellement calculer partiellement le résultat avant même de l'avoir acc. Cela signifie qu'au lieu de la diffuser kcomme une liste qui est essentiellement une "syntaxe" incomplète que nous "interpréterons" à la fin, nous pouvons "l'interpréter" au fur et à mesure. Le résultat est que nous pouvons remplacer Nilpar l'unité du monoïde, 1dans ce cas, et Conspar le fonctionnement du monoïde *, et kreprésente maintenant le "produit en cours d'exécution".applyPow(k, acc)k*acc lequel nous pouvons réintégrer pow2et simplifier la production:

pow(x, n):
    return pow2(x, n, 1)

pow2(x, n, k):
    if n == 0: return k
    else if n == 1: return k*x
    else: return pow2(x, n-1, k*x)

Une version récursive de la queue qui passe par l'accumulateur de l'original pow.

Bien sûr, je ne dis pas que GCC fait tout ce raisonnement au moment de la compilation. Je ne sais pas quelle logique GCC utilise. Mon point est simplement d'avoir fait ce raisonnement une fois, il est relativement facile de reconnaître le modèle et de traduire immédiatement le code source original dans cette forme finale. Cependant, la transformation CPS et la transformation de défonctionnalisation sont complètement générales et mécaniques. À partir de là, des techniques de fusion, de déforestation ou de supercompilation pourraient être utilisées pour tenter d'éliminer les suites réifiées. Les transformations spéculatives pourraient être rejetées s'il n'est pas possible d'éliminer toute l'allocation des suites réifiées. Je soupçonne, cependant, que ce serait trop coûteux à faire tout le temps, en toute généralité, d'où des approches plus ad hoc.

Si vous voulez devenir ridicule, vous pouvez consulter le papier Continuations de recyclage qui utilise également CPS et des représentations de continuations comme données, mais fait quelque chose de similaire mais différent de tail-recursion-modulo-cons. Ceci décrit comment vous pouvez produire des algorithmes d'inversion de pointeur par transformation.

Ce modèle de transformation et de défonctionnalisation CPS est un outil assez puissant pour la compréhension, et est utilisé à bon escient dans une série d'articles que je liste ici .


La technique que GCC utilise à la place du style de continuation que vous montrez ici est, je crois, le formulaire d'attribution unique statique.
Davislor

@Davislor Bien que lié à CPS, SSA n'affecte pas le flux de contrôle d'une procédure ni ne réifie la pile (ou n'introduit pas autrement des structures de données qui devraient être allouées dynamiquement). En ce qui concerne la SSA, le CPS "en fait trop", c'est pourquoi le formulaire administratif normal (ANF) est plus adapté à la SSA. GCC utilise donc SSA, mais SSA ne conduit pas à ce que la pile de contrôle soit visible comme une structure de données manipulable.
Derek Elkins a quitté le SE

Droite. Je répondais: «Je ne dis pas que GCC fait tout ce raisonnement au moment de la compilation. Je ne sais pas quelle logique GCC utilise. »Ma réponse, de la même manière, montrait que la transformation était théoriquement justifiée, ne disant pas que c'était la méthode d'implémentation utilisée par un compilateur donné. (Bien que, comme vous le savez, de nombreux compilateurs transforment un programme en CPS lors de l'optimisation.)
Davislor

8

Je vais tourner autour du pot pendant un moment, mais il y a un point.

Demi-groupes

La réponse est la propriété associative de l'opération de réduction binaire .

C'est assez abstrait, mais la multiplication est un bon exemple. Si x , y et z sont des nombres naturels (ou des nombres entiers, ou des nombres rationnels, ou des nombres réels, ou des nombres complexes, ou des matrices N × N , ou tout un tas d'autres choses), alors x × y est du même genre du nombre à la fois x et y . Nous avons commencé avec deux nombres, c'est donc une opération binaire, et nous en avons obtenu un, nous avons donc réduit le nombre de nombres que nous avions d'un, ce qui en fait une opération de réduction. Et ( x × y ) × z est toujours identique à x × ( y ×z ), qui est la propriété associative.

(Si vous savez déjà tout cela, vous pouvez passer à la section suivante.)

Quelques autres choses que vous voyez souvent en informatique qui fonctionnent de la même manière:

  • en ajoutant l'un de ces types de nombres au lieu de multiplier
  • concaténer des chaînes ( "a"+"b"+"c"est "abc"que vous commencez avec "ab"+"c"ou "a"+"bc")
  • Épissage de deux listes ensemble. [a]++[b]++[c]est similaire [a,b,c]de l'arrière vers l'avant ou de l'avant vers l'arrière.
  • conssur une tête et une queue, si vous pensez à la tête comme une liste singleton. C'est simplement concaténer deux listes.
  • prendre l'union ou l'intersection d'ensembles
  • Booléen et, booléen ou
  • au niveau du bit &, |et^
  • composition des fonctions: ( fg ) ∘ h x = f ∘ ( gh ) x = f ( g ( h ( x )))
  • maximum et minimum
  • addition modulo p

Certaines choses qui ne le font pas:

  • soustraction, car 1- (1-2) ≠ (1-1) -2
  • xy = tan ( x + y ), car tan (π / 4 + π / 4) n'est pas défini
  • multiplication sur les nombres négatifs, car -1 × -1 n'est pas un nombre négatif
  • division d'entiers, qui a les trois problèmes!
  • logique non, car il n'a qu'un opérande, pas deux
  • int print2(int x, int y) { return printf( "%d %d\n", x, y ); }, as print2( print2(x,y), z );et print2( x, print2(y,z) );ont une sortie différente.

C'est un concept suffisamment utile que nous l'avons nommé. Un ensemble avec une opération qui possède ces propriétés est un semi - groupe . Ainsi, les nombres réels sous multiplication sont un semi-groupe. Et votre question s'avère être l'une des façons dont ce type d'abstraction devient utile dans le monde réel. Les opérations de semi-groupe peuvent toutes être optimisées comme vous le demandez.

Essayez ceci à la maison

Pour autant que je sache, cette technique a été décrite pour la première fois en 1974, dans l'article de Daniel Friedman et David Wise, «Folding Stylized Recursions into Iterations» , bien qu'ils aient assumé quelques propriétés de plus qu'il ne leur en fallait.

Haskell est un excellent langage pour illustrer cela, car il a la Semigroupclasse de types dans sa bibliothèque standard. Il appelle l'opération d'un générique Semigroupl'opérateur <>. Comme les listes et les chaînes sont des instances de Semigroup, leurs instances se définissent <>comme l'opérateur de concaténation ++, par exemple. Et avec la bonne importation, [a] <> [b]est un alias pour[a] ++ [b] , ce qui est [a,b].

Mais qu'en est-il des chiffres? Nous avons vu juste que les types numériques sont semigroupes sous soit plus ou multiplication! Alors, lequel devient <>un Double? Eh bien, l'un ou l'autre! Haskell définit les types Product Double, where (<>) = (*)(qui est la définition même dans Haskell), et aussi Sum Double, where (<>) = (+).

Une ride est que vous avez utilisé le fait que 1 est l'identité multiplicative. Un semi-groupe avec une identité est appelé un monoïde et est défini dans le package Haskell Data.Monoid, qui appelle l'élément d'identité générique d'une classe de types mempty. Sum, ProductEt une liste chacun a un élément d'identité (0, 1 et [], respectivement), de sorte qu'ils sont des instances de Monoidainsi que Semigroup. (A ne pas confondre avec un monade , alors oubliez juste que j'en ai même parlé.)

C'est assez d'informations pour traduire votre algorithme en une fonction Haskell à l'aide de monoïdes:

module StylizedRec (pow) where

import Data.Monoid as DM

pow :: Monoid a => a -> Word -> a
{- Applies the monoidal operation of the type of x, whatever that is, by
 - itself n times.  This is already in Haskell as Data.Monoid.mtimes, but
 - let’s write it out as an example.
 -}
pow _ 0 = mempty -- Special case: Return the nullary product.
pow x 1 = x      -- The base case.
pow x n = x <> (pow x (n-1)) -- The recursive case.

Il est important de noter qu'il s'agit du semi-groupe modulo de récursion de queue: chaque cas est soit une valeur, un appel récursif de queue, soit le produit du semi-groupe des deux. En outre, cet exemple est arrivé à utilisermempty pour l'un des cas, mais si nous n'en avions pas eu besoin, nous aurions pu le faire avec la classe de type plus générale Semigroup.

Chargeons ce programme dans GHCI et voyons comment cela fonctionne:

*StylizedRec> getProduct $ pow 2 4
16
*StylizedRec> getProduct $ pow 7 2
49

Rappelez-vous comment nous avons déclaré powpour un générique Monoid, dont nous avons appelé le type a? Nous avons donné suffisamment d' informations GHCi pour en déduire que le type aest là Product Integer, qui est un instancede Monoiddont le <>fonctionnement est la multiplication entier. Doncpow 2 4S'étend récursivement à 2<>2<>2<>2, qui est 2*2*2*2ou16 . Jusqu'ici tout va bien.

Mais notre fonction n'utilise que des opérations monoïdes génériques. Auparavant, j'ai dit qu'il y avait un autre exemple de Monoidappelé Sum, dont<> fonctionnement est +. Pouvons-nous essayer cela?

*StylizedRec> getSum $ pow 2 4
8
*StylizedRec> getSum $ pow 7 2
14

La même expansion nous donne maintenant 2+2+2+2 au lieu de 2*2*2*2. La multiplication, c'est l'addition comme l'exponentiation, c'est la multiplication!

Mais j'ai donné un autre exemple d'un monoïde Haskell: les listes, dont le fonctionnement est la concaténation.

*StylizedRec> pow [2] 4
[2,2,2,2]
*StylizedRec> pow [7] 2
[7,7]

L' écriture [2]indique au compilateur que ceci est une liste, <>sur les listes est ++, donc [2]++[2]++[2]++[2]est [2,2,2,2].

Enfin, un algorithme (deux, en fait)

En remplaçant simplement xpar [x], vous convertissez l'algorithme générique qui utilise le module de récursivité un semi-groupe en un qui crée une liste. Quelle liste? La liste des éléments auxquels l'algorithme s'applique <>. Étant donné que nous avons utilisé uniquement les opérations de semi-groupe que les listes possèdent également, la liste résultante sera isomorphe au calcul d'origine. Et comme l'opération d'origine était associative, on peut tout aussi bien évaluer les éléments d'arrière en avant ou d'avant en arrière.

Si votre algorithme atteint un cas de base et se termine, la liste ne sera pas vide. Puisque le cas terminal a renvoyé quelque chose, ce sera le dernier élément de la liste, donc il aura au moins un élément.

Comment appliquez-vous une opération de réduction binaire à chaque élément d'une liste dans l'ordre? C'est vrai, un pli. Ainsi , vous pouvez remplacer [x]pour x, obtenir une liste des éléments pour réduire par <>, puis soit à droite ou à gauche fois fois la liste:

*StylizedRec> getProduct $ foldr1 (<>) $ pow [Product 2] 4
16
*StylizedRec> import Data.List
*StylizedRec Data.List> getProduct $ foldl1' (<>) $ pow [Product 2] 4
16

La version avec foldr1existe en fait dans la bibliothèque standard, comme sconcatpour Semigroupet mconcatpour Monoid. Il fait un pli paresseux à droite sur la liste. Autrement dit, il se développe [Product 2,Product 2,Product 2,Product 2]à 2<>(2<>(2<>(2))).

Ce n'est pas efficace dans ce cas, car vous ne pouvez rien faire avec les termes individuels tant que vous ne les avez pas tous générés. (À un moment donné, j'ai eu une discussion ici sur le moment d'utiliser les plis droits et le moment d'utiliser des plis gauches stricts, mais cela allait trop loin.)

La version avec foldl1'est un pli gauche strictement évalué. C'est-à-dire une fonction récursive de queue avec un accumulateur strict. Cela évalue à (((2)<>2)<>2)<>2, calculé immédiatement et pas plus tard lorsque cela est nécessaire. (Au moins, il n'y a aucun retard dans le pli lui-même: la liste en cours de pliage est générée ici par une autre fonction qui pourrait contenir une évaluation paresseuse.) Ainsi, le pli calcule (4<>2)<>2, puis calcule immédiatement 8<>2, puis16 . C'est pourquoi nous avions besoin que l'opération soit associative: nous venons de changer le regroupement des parenthèses!

Le pli gauche strict est l'équivalent de ce que fait GCC. Le nombre le plus à gauche dans l'exemple précédent est l'accumulateur, dans ce cas un produit en cours d'exécution. À chaque étape, il est multiplié par le nombre suivant de la liste. Une autre façon de l'exprimer est la suivante: vous parcourez les valeurs à multiplier, en gardant le produit en cours d'exécution dans un accumulateur, et à chaque itération, vous multipliez l'accumulateur par la valeur suivante. Autrement dit, c'est une whileboucle déguisée.

Il peut parfois être rendu aussi efficace. Le compilateur peut peut-être optimiser la structure des données de la liste en mémoire. En théorie, il a suffisamment d'informations au moment de la compilation pour comprendre qu'il devrait le faire ici: [x]est un singleton, [x]<>xsest donc le même que cons x xs. Chaque itération de la fonction peut être en mesure de réutiliser le même cadre de pile et de mettre à jour les paramètres en place.

Un pli droit ou un pli gauche strict pourrait être plus approprié, dans un cas particulier, alors sachez lequel vous voulez. Il y a aussi certaines choses que seul un pli droit peut faire (comme générer une sortie interactive sans attendre toutes les entrées et fonctionner sur une liste infinie). Ici, cependant, nous réduisons une séquence d'opérations à une valeur simple, donc un pli gauche strict est ce que nous voulons.

Ainsi, comme vous pouvez le voir, il est possible d'optimiser automatiquement le module de récursion de queue de n'importe quel semi-groupe (dont un exemple est l'un des types numériques habituels en cours de multiplication) en un pli droit paresseux ou un pli gauche strict, en une seule ligne de Haskell.

Généraliser davantage

Les deux arguments de l'opération binaire ne doivent pas nécessairement être du même type, tant que la valeur initiale est du même type que votre résultat. (Vous pouvez bien sûr toujours retourner les arguments pour qu'ils correspondent à l'ordre du type de pli que vous faites, à gauche ou à droite.) Vous pouvez donc ajouter plusieurs fois des correctifs à un fichier pour obtenir un fichier mis à jour, ou en commençant par une valeur initiale de 1.0, divisez par des nombres entiers pour accumuler un résultat en virgule flottante. Ou ajoutez des éléments à la liste vide pour obtenir une liste.

Un autre type de généralisation consiste à appliquer les plis non à des listes mais à d'autres Foldablestructures de données. Souvent, une liste liée linéaire immuable n'est pas la structure de données que vous souhaitez pour un algorithme donné. Un problème que je n'ai pas abordé ci-dessus est qu'il est beaucoup plus efficace d'ajouter des éléments au début d'une liste qu'à l'arrière, et lorsque l'opération n'est pas commutative, l'application xà gauche et à droite de l'opération ne l'est pas. le même. Il vous faudrait donc utiliser une autre structure, telle qu'une paire de listes ou un arbre binaire, pour représenter un algorithme qui pourrait s'appliquer xà droite <>comme à gauche.

Notez également que la propriété associative vous permet de regrouper les opérations d'autres manières utiles, telles que diviser pour mieux régner:

times :: Monoid a => a -> Word -> a
times _ 0 = mempty
times x 1 = x
times x n | even n    = y <> y
          | otherwise = x <> y <> y
  where y = times x (n `quot` 2)

Ou le parallélisme automatique, où chaque thread réduit une sous-gamme à une valeur qui est ensuite combinée avec les autres.


1
Nous pouvons faire une expérience pour tester que l'associativité est la clé de la capacité de GCC à faire cette optimisation: une pow(float x, unsigned n)version gcc.godbolt.org/z/eqwine n'optimise qu'avec -ffast-math, (ce qui implique -fassociative-math. La virgule flottante stricte n'est bien sûr pas associative car différentes temporelles = arrondi différent). Le introduit un 1.0f * xqui n'était pas présent dans la machine abstraite C (mais qui donnera toujours un résultat identique). Ensuite, les multiplications n-1 comme do{res*=x;}while(--n!=1)sont les mêmes que celles récursives, il s'agit donc d'une optimisation manquée.
Peter Cordes
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.