Très simplement, qu'est-ce que l'optimisation des appels de queue?
Plus précisément, quels sont quelques petits extraits de code où il pourrait être appliqué, et où non, avec une explication de pourquoi?
Très simplement, qu'est-ce que l'optimisation des appels de queue?
Plus précisément, quels sont quelques petits extraits de code où il pourrait être appliqué, et où non, avec une explication de pourquoi?
Réponses:
L'optimisation des appels de queue est l'endroit où vous pouvez éviter d'allouer une nouvelle trame de pile pour une fonction car la fonction appelante retournera simplement la valeur qu'elle obtient de la fonction appelée. L'utilisation la plus courante est la récursivité de queue, où une fonction récursive écrite pour tirer parti de l'optimisation des appels de queue peut utiliser un espace de pile constant.
Scheme est l'un des rares langages de programmation qui garantissent dans la spécification que toute implémentation doit fournir cette optimisation (JavaScript le fait également, à partir d'ES6) , voici donc deux exemples de la fonction factorielle dans Scheme:
La première fonction n'est pas récursive de queue car lorsque l'appel récursif est effectué, la fonction doit garder une trace de la multiplication qu'elle doit faire avec le résultat après le retour de l'appel. En tant que telle, la pile se présente comme suit:
En revanche, la trace de pile pour la factorielle récursive de queue se présente comme suit:
Comme vous pouvez le voir, nous avons seulement besoin de garder la trace de la même quantité de données pour chaque appel à fact-tail parce que nous renvoyons simplement la valeur que nous obtenons tout en haut. Cela signifie que même si je devais appeler (fait 1000000), je n'ai besoin que de la même quantité d'espace que (fait 3). Ce n'est pas le cas avec le fait non récursif de queue, et en tant que telles grandes valeurs peuvent provoquer un débordement de pile.
la source
Voyons un exemple simple: la fonction factorielle implémentée en C.
Nous commençons par la définition récursive évidente
Une fonction se termine par un appel de fin si la dernière opération avant le retour de la fonction est un autre appel de fonction. Si cet appel invoque la même fonction, il est récursif de queue.
Même si cela
fac()
semble récursif à première vue, ce n'est pas ce qui se passe réellementc'est-à-dire que la dernière opération est la multiplication et non l'appel de fonction.
Cependant, il est possible de réécrire
fac()
pour être récursif en faisant passer la valeur accumulée vers le bas de la chaîne d'appel en tant qu'argument supplémentaire et en ne transmettant à nouveau que le résultat final comme valeur de retour:Maintenant, pourquoi est-ce utile? Parce que nous revenons immédiatement après l'appel de queue, nous pouvons ignorer le stackframe précédent avant d'appeler la fonction en position de queue, ou, en cas de fonctions récursives, réutiliser le stackframe tel quel.
L'optimisation des appels de queue transforme notre code récursif en
Cela peut être intégré
fac()
et nous arrivons àce qui équivaut à
Comme nous pouvons le voir ici, un optimiseur suffisamment avancé peut remplacer la récursivité de queue par l'itération, ce qui est beaucoup plus efficace car vous évitez la surcharge des appels de fonction et n'utilisez qu'une quantité constante d'espace de pile.
la source
TCO (Tail Call Optimization) est le processus par lequel un compilateur intelligent peut appeler une fonction et ne prendre aucun espace de pile supplémentaire. La seule situation dans laquelle cela se produit est si la dernière instruction exécutée dans une fonction f est un appel à une fonction g (Remarque: g peut être f ). La clé ici est que f n'a plus besoin d'espace de pile - il appelle simplement g puis retourne tout ce que g retournerait. Dans ce cas, l'optimisation peut être faite pour que g s'exécute et renvoie la valeur qu'il aurait à la chose qui a appelé f.
Cette optimisation peut faire en sorte que les appels récursifs prennent un espace de pile constant plutôt que d'exploser.
Exemple: cette fonction factorielle n'est pas TCOptimisable:
Cette fonction fait des choses en plus d'appeler une autre fonction dans sa déclaration de retour.
Cette fonction ci-dessous est TCOptimisable:
En effet, la dernière chose qui se produit dans l'une de ces fonctions est d'appeler une autre fonction.
la source
(cons a (foo b))
ou(+ c (bar d))
en position de queue de la même manière.La meilleure description de haut niveau que j'ai trouvée pour les appels de queue, les appels de queue récursifs et l'optimisation des appels de queue est probablement le billet de blog
"Qu'est-ce que c'est que ça: un appel de queue"
par Dan Sugalski. Sur l'optimisation des appels de queue, il écrit:
Et sur la récursivité de la queue:
Pour que ceci:
devient tranquillement transformé en:
Ce que j'aime dans cette description, c'est à quel point il est succinct et facile à saisir pour ceux qui viennent d'un contexte de langage impératif (C, C ++, Java)
la source
foo
fonction initial n'est-il pas optimisé? Il n'appelle une fonction que comme sa dernière étape, et il renvoie simplement cette valeur, non?Notez tout d'abord que toutes les langues ne le prennent pas en charge.
Le TCO s'applique à un cas particulier de récursivité. L'essentiel est que si la dernière chose que vous faites dans une fonction est de s'appeler elle-même (par exemple, elle s'appelle à partir de la position "tail"), cela peut être optimisé par le compilateur pour agir comme une itération au lieu d'une récursivité standard.
Vous voyez, normalement pendant la récursivité, le runtime doit garder une trace de tous les appels récursifs, de sorte que lorsque l'un revient, il peut reprendre à l'appel précédent et ainsi de suite. (Essayez d'écrire manuellement le résultat d'un appel récursif pour avoir une idée visuelle de la façon dont cela fonctionne.) Le suivi de tous les appels prend de la place, ce qui devient significatif lorsque la fonction s'appelle souvent. Mais avec TCO, il peut simplement dire "retour au début, mais cette fois changez les valeurs des paramètres en ces nouveaux". Il peut le faire car rien après l'appel récursif ne fait référence à ces valeurs.
la source
foo
méthode initiale n'est-il pas optimisé?Exemple exécutable minimal GCC avec analyse de démontage x86
Voyons comment GCC peut automatiquement effectuer des optimisations d'appel de queue pour nous en regardant l'assembly généré.
Cela servira d'exemple extrêmement concret de ce qui a été mentionné dans d'autres réponses telles que https://stackoverflow.com/a/9814654/895245 que l'optimisation peut convertir les appels de fonctions récursives en boucle.
À son tour, cela économise de la mémoire et améliore les performances, car les accès à la mémoire sont souvent le principal facteur qui ralentit les programmes de nos jours .
En entrée, nous donnons à GCC une factorielle basée sur une pile naïve non optimisée:
tail_call.c
GitHub en amont .
Compilez et démontez:
où
-foptimize-sibling-calls
est le nom de la généralisation des appels de queue selonman gcc
:comme mentionné à: Comment puis-je vérifier si gcc effectue une optimisation de récursivité de queue?
Je choisis
-O1
car:-O0
. Je soupçonne que c'est parce qu'il manque des transformations intermédiaires requises.-O3
produit un code impie efficace qui ne serait pas très éducatif, bien qu'il soit également optimisé pour les appels de queue.Démontage avec
-fno-optimize-sibling-calls
:Avec
-foptimize-sibling-calls
:La principale différence entre les deux est que:
les
-fno-optimize-sibling-calls
utilisationscallq
, qui est l'appel de fonction non optimisé typique.Cette instruction pousse l'adresse de retour vers la pile, donc en l'augmentant.
De plus, cette version le fait aussi
push %rbx
, ce qui pousse%rbx
à la pile .GCC fait cela parce qu'il stocke
edi
, qui est le premier argument de fonction (n
) dansebx
, puis appellefactorial
.GCC doit le faire car il se prépare pour un autre appel à
factorial
, qui utilisera le nouveauedi == n-1
.Il choisit
ebx
parce que ce registre est sauvegardé par appel: quels registres sont conservés via un appel de fonction linux x86-64 afin que le sous- appel ne le modifiefactorial
pas et ne perde pasn
.le
-foptimize-sibling-calls
n'utilise pas d'instructions qui poussent vers la pile: il ne fait quegoto
sauterfactorial
avec les instructionsje
etjne
.Par conséquent, cette version équivaut à une boucle while, sans aucun appel de fonction. L'utilisation de la pile est constante.
Testé dans Ubuntu 18.10, GCC 8.2.
la source
Regardez ici:
http://tratt.net/laurie/tech_articles/articles/tail_call_optimization
Comme vous le savez probablement, les appels de fonction récursifs peuvent faire des ravages sur une pile; il est facile de manquer rapidement d'espace de pile. L'optimisation des appels de queue est un moyen par lequel vous pouvez créer un algorithme de style récursif qui utilise un espace de pile constant, donc il ne grandit pas et vous obtenez des erreurs de pile.
la source
Nous devons nous assurer qu'il n'y a pas d'instructions goto dans la fonction elle-même.
Les récursions à grande échelle peuvent l'utiliser pour des optimisations, mais à petite échelle, la surcharge d'instructions pour faire de la fonction appeler un appel de queue réduit le but réel.
Le TCO peut provoquer une fonction toujours active:
la source
L'approche de la fonction récursive a un problème. Il crée une pile d'appels de taille O (n), ce qui fait que notre mémoire totale coûte O (n). Cela le rend vulnérable à une erreur de dépassement de pile, où la pile d'appels devient trop grande et manque d'espace.
Schéma d'optimisation des appels de queue (TCO). Où il peut optimiser les fonctions récursives pour éviter de constituer une pile d'appels élevée et donc d'économiser le coût de la mémoire.
Il existe de nombreux langages qui font du TCO comme (JavaScript, Ruby et quelques C) alors que Python et Java ne font pas de TCO.
La langue JavaScript a confirmé l'utilisation de :) http://2ality.com/2015/06/tail-call-optimization.html
la source
Dans un langage fonctionnel, l'optimisation des appels de queue est comme si un appel de fonction pouvait renvoyer une expression partiellement évaluée comme résultat, qui serait ensuite évaluée par l'appelant.
f 6 se réduit à g 6. Donc, si l'implémentation pouvait renvoyer g 6 comme résultat, puis appeler cette expression, elle enregistrerait une trame de pile.
Aussi
Réduit à f 6 à g 6 ou h 6. Donc, si l'implémentation évalue c 6 et trouve que c'est vrai, alors elle peut réduire,
Un simple interpréteur d'optimisation d'appel non-queue pourrait ressembler à ceci,
Un interpréteur d'optimisation des appels de queue pourrait ressembler à ceci,
la source