web-dev-qa-db-fra.com

Qu'est-ce que l'optimisation d'appel de queue?

Très simplement, qu'est-ce que l'optimisation de l'appel final? Plus précisément, quelqu'un peut-il montrer des extraits de code de petite taille où il pourrait être appliqué, et où pas, avec une explication de pourquoi?

731
majelbstoat

L’optimisation d’appel en attente permet d’éviter l’attribution d’un nouveau cadre de pile à une fonction, car la fonction appelante renvoie simplement la valeur qu’elle obtient de la fonction appelée. L'utilisation la plus courante est la récursion finale, où une fonction récursive écrite pour tirer parti de l'optimisation de l'appel final peut utiliser un espace de pile constant.

Scheme est l'un des rares langages de programmation garantissant dans la spécification que toute implémentation doit fournir cette optimisation (JavaScript le fait aussi, à partir de ES6), voici donc deux exemples de la fonction factorielle dans Scheme:

(define (fact x)
  (if (= x 0) 1
      (* x (fact (- x 1)))))

(define (fact x)
  (define (fact-tail x accum)
    (if (= x 0) accum
        (fact-tail (- x 1) (* x accum))))
  (fact-tail x 1))

La première fonction n'est pas récursive car, lorsque l'appel récursif est effectué, la fonction doit garder trace de la multiplication nécessaire du résultat après le retour de l'appel. En tant que tel, la pile se présente comme suit:

(fact 3)
(* 3 (fact 2))
(* 3 (* 2 (fact 1)))
(* 3 (* 2 (* 1 (fact 0))))
(* 3 (* 2 (* 1 1)))
(* 3 (* 2 1))
(* 3 2)
6

En revanche, la trace de pile pour la factorielle récursive de queue se présente comme suit:

(fact 3)
(fact-tail 3 1)
(fact-tail 2 3)
(fact-tail 1 6)
(fact-tail 0 6)
6

Comme vous pouvez le constater, nous n'avons besoin que de garder la même quantité de données pour chaque appel à fact-tail car nous renvoyons simplement la valeur que nous obtenons jusqu'au bout. Cela signifie que même si je devais appeler (fait 1000000), je n’aurais besoin que de la même quantité d’espace que (fait 3). Ce n'est pas le cas avec le fait non récursif et, de ce fait, de grandes valeurs peuvent provoquer un débordement de pile.

686
Kyle Cronin

Voyons un exemple simple: la fonction factorielle implémentée en C.

Nous commençons avec la définition récursive évidente

unsigned fac(unsigned n)
{
    if (n < 2) return 1;
    return n * fac(n - 1);
}

Une fonction se termine par un appel final si la dernière opération avant son retour est un autre appel de fonction. Si cet appel appelle la même fonction, il est récursif.

Même si fac() a l'air au premier abord très récursif, ce n'est pas ce qui se passe réellement

unsigned fac(unsigned n)
{
    if (n < 2) return 1;
    unsigned acc = fac(n - 1);
    return n * acc;
}

c'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 qu'il soit récursif en transmettant la valeur accumulée dans la chaîne d'appels en tant qu'argument supplémentaire et en ne transmettant que le résultat final en tant que valeur de retour:

unsigned fac(unsigned n)
{
    return fac_tailrec(1, n);
}

unsigned fac_tailrec(unsigned acc, unsigned n)
{
    if (n < 2) return acc;
    return fac_tailrec(n * acc, n - 1);
}

Maintenant, pourquoi est-ce utile? Étant donné que nous revenons immédiatement après l'appel de queue, nous pouvons supprimer la pile précédente avant d'appeler la fonction en position de queue ou, dans le cas de fonctions récursives, réutiliser la pile en l'état.

L’optimisation des appels en sortie transforme notre code récursif en

unsigned fac_tailrec(unsigned acc, unsigned n)
{
TOP:
    if (n < 2) return acc;
    acc = n * acc;
    n = n - 1;
    goto TOP;
}

Ceci peut être intégré dans fac() et nous arrivons à

unsigned fac(unsigned n)
{
    unsigned acc = 1;

TOP:
    if (n < 2) return acc;
    acc = n * acc;
    n = n - 1;
    goto TOP;
}

ce qui équivaut à

unsigned fac(unsigned n)
{
    unsigned acc = 1;

    for (; n > 1; --n)
        acc *= n;

    return acc;
}

Comme nous pouvons le voir ici, un optimiseur suffisamment avancé peut remplacer la récursion de la queue par une itération, ce qui est bien plus efficace si vous évitez la surcharge des appels de fonction et utilisez uniquement un espace de pile constant.

517
Christoph

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. Dans la seule situation dans laquelle cela se produit, 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 de pile - il appelle simplement g et retourne ensuite ce que g renverrait. Dans ce cas, l'optimisation peut être faite pour que g s'exécute simplement et renvoie la valeur qu'il aurait à l'élément qui s'appelle 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 TCOptimizable:

def fact(n):
    if n == 0:
        return 1
    return n * fact(n-1)

Cette fonction fait des choses en plus d’appeler une autre fonction dans son instruction return.

Cette fonction ci-dessous est TCOptimizable:

def fact_h(n, acc):
    if n == 0:
        return acc
    return fact_h(n-1, acc*n)

def fact(n):
    return fact_h(n, 1)

En effet, la dernière chose qui se passe dans l’une de ces fonctions est d’appeler une autre fonction.

180
Claudiu

La publication de blog est probablement 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

"Qu'est-ce que c'est que le diable? Un coup de queue"

par Dan Sugalski. Sur l'optimisation de l'appel final, il écrit:

Considérons un instant cette fonction simple:

sub foo (int a) {
  a += 15;
  return bar(a);
}

Alors, que pouvez-vous, ou plutôt votre compilateur de langage, faire? Ce que vous pouvez faire, c’est transformer le code de la forme return somefunc(); en séquence de bas niveau pop stack frame; goto somefunc();. Dans notre exemple, cela signifie qu'avant d'appeler bar, foo se nettoie, puis plutôt que d'appeler bar comme sous-programme, nous effectuons une opération de bas niveau goto. au début de bar. Foo s'est déjà effacé de la pile. Ainsi, lorsque bar commence, il ressemble à celui qui a appelé foo et appelé réellement bar, et lorsque bar renvoie sa valeur , il le renvoie directement à celui qui a appelé foo, plutôt que de le renvoyer à foo qui le renverrait ensuite à son appelant.

Et sur la récursion de la queue:

La récursion de la queue se produit si une fonction, lors de sa dernière opération, renvoie le résultat de l'appel lui-même . La récursion de la queue est plus facile à gérer car plutôt que de devoir sauter au début d'une fonction aléatoire quelque part, vous devez revenir au début de vous-même, ce qui est une chose extrêmement simple à faire.

Alors que ça:

sub foo (int a, int b) {
  if (b == 1) {
    return a;
  } else {
    return foo(a*a + a, b - 1);
  }

devient discrètement transformé en:

sub foo (int a, int b) {
  label:
    if (b == 1) {
      return a;
    } else {
      a = a*a + a;
      b = b - 1;
      goto label;
   }

Ce que j’aime dans cette description, c’est la facilité avec laquelle il est facile à saisir pour ceux qui viennent d’un contexte de langage impératif (C, C++, Java).

58
btiernay

Notez tout d'abord que toutes les langues ne le supportent pas.

Le TCO s'applique à un cas particulier de récursivité. En résumé, si la dernière chose que vous faites dans une fonction est de s’appeler elle-même (par exemple, elle s’appelle de la position "tail"), cela peut être optimisé par le compilateur pour agir comme une itération au lieu de la récursivité standard.

Vous voyez, normalement pendant la récursion, le moteur d’exécution doit garder une trace de tous les appels récursifs, de sorte que lorsqu’un retourne, il puisse reprendre lors de 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 son fonctionnement.) Le suivi de tous les appels prend de la place, ce qui devient significatif lorsque la fonction s'appelle beaucoup. Mais avec le coût total de possession, il peut simplement dire "revenir au début, mais cette fois, changez les valeurs des paramètres pour qu'elles soient nouvelles". Il peut le faire car rien après l'appel récursif ne fait référence à ces valeurs.

13
J Cooper

Exemple exécutable minimal de GCC avec analyse de désassemblage x86

Voyons comment GCC peut automatiquement procéder à l'optimisation des appels d'appels en examinant 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 fonction récursifs en une boucle.

Cela économise de la mémoire et améliore les performances, puisque les accès en mémoire sont souvent le principal facteur de ralentissement des programmes .

En entrée, nous donnons à GCC une factorielle non optimisée basée sur une pile naïve:

tail_call.c

#include <stdio.h>
#include <stdlib.h>

unsigned factorial(unsigned n) {
    if (n == 1) {
        return 1;
    }
    return n * factorial(n - 1);
}

int main(int argc, char **argv) {
    int input;
    if (argc > 1) {
        input = strtoul(argv[1], NULL, 0);
    } else {
        input = 5;
    }
    printf("%u\n", factorial(input));
    return EXIT_SUCCESS;
}

GitHub en amont .

Compiler et désassembler:

gcc -O1 -foptimize-sibling-calls -ggdb3 -std=c99 -Wall -Wextra -Wpedantic \
  -o tail_call.out tail_call.c
objdump -d tail_call.out

-foptimize-sibling-calls est le nom de la généralisation des appels différés selon man gcc:

   -foptimize-sibling-calls
       Optimize sibling and tail recursive calls.

       Enabled at levels -O2, -O3, -Os.

comme mentionné à: Comment puis-je vérifier si gcc effectue l'optimisation de la récursion finale?

J'ai choisi -O1 parce que:

  • l'optimisation n'est pas faite avec -O0. J'imagine que c'est parce qu'il manque des transformations intermédiaires requises.
  • -O3 produit un code impie et efficace qui ne serait pas très instructif, même s'il est également optimisé.

Démontage avec -fno-optimize-sibling-calls:

0000000000001145 <factorial>:
    1145:       89 f8                   mov    %edi,%eax
    1147:       83 ff 01                cmp    $0x1,%edi
    114a:       74 10                   je     115c <factorial+0x17>
    114c:       53                      Push   %rbx
    114d:       89 fb                   mov    %edi,%ebx
    114f:       8d 7f ff                lea    -0x1(%rdi),%edi
    1152:       e8 ee ff ff ff          callq  1145 <factorial>
    1157:       0f af c3                imul   %ebx,%eax
    115a:       5b                      pop    %rbx
    115b:       c3                      retq
    115c:       c3                      retq

Avec -foptimize-sibling-calls:

0000000000001145 <factorial>:
    1145:       b8 01 00 00 00          mov    $0x1,%eax
    114a:       83 ff 01                cmp    $0x1,%edi
    114d:       74 0e                   je     115d <factorial+0x18>
    114f:       8d 57 ff                lea    -0x1(%rdi),%edx
    1152:       0f af c7                imul   %edi,%eax
    1155:       89 d7                   mov    %edx,%edi
    1157:       83 fa 01                cmp    $0x1,%edx
    115a:       75 f3                   jne    114f <factorial+0xa>
    115c:       c3                      retq
    115d:       89 f8                   mov    %edi,%eax
    115f:       c3                      retq

La principale différence entre les deux est que:

  • le -fno-optimize-sibling-calls utilise callq, qui est l'appel de fonction typique non optimisé.

    Cette instruction pousse l’adresse de retour dans la pile et l’augmente donc.

    De plus, cette version fait aussi Push %rbx, qui pousse %rbx vers la pile .

    GCC fait cela parce qu'il stocke edi, qui est le premier argument de la fonction (n) dans ebx, puis appelle factorial.

    GCC doit le faire car il se prépare à un autre appel à factorial, qui utilisera le nouveau edi == n-1.

    Il choisit ebx car ce registre est appelé: Les registres sont conservés via un appel de fonction linux x86-64 afin que le sous-appel à factorial ne le modifie pas et ne soit perdu n.

  • le -foptimize-sibling-calls n'utilise pas d'instructions qui poussent dans la pile: il ne fait que goto sauter dans factorial avec les instructions je et jne.

    Par conséquent, cette version est équivalente à une boucle while, sans appel de fonction. L'utilisation de la pile est constante.

Testé sous Ubuntu 18.10, GCC 8.2.

Regardez ici:

http://tratt.net/laurie/tech_articles/articles/tail_call_optimization

Comme vous le savez probablement, les appels de fonction récursifs peuvent causer des ravages sur une pile; il est facile de manquer rapidement de pile. L’optimisation d’appel en queue est un moyen par lequel vous pouvez créer un algorithme de style récursif qui utilise un espace de pile constant. Par conséquent, il ne grandit pas et vous obtenez des erreurs de pile.

6
BobbyShaftoe
  1. Nous devons nous assurer qu'il n'y a pas d'instruction goto dans la fonction elle-même. Pris en charge par l'appel de fonction étant la dernière chose dans la fonction appelée.

  2. Les récursions à grande échelle peuvent l'utiliser pour des optimisations, mais à petite échelle, le temps système d'instruction nécessaire pour que l'appel de fonction devienne un appel final réduit l'objectif réel.

  3. Le coût total de possession peut entraîner une fonction permanente:

    void eternity()
    {
        eternity();
    }
    
4
grillSandwich

Approche de la fonction récursive a un problème. Il crée une pile d'appels de taille O (n), ce qui rend notre coût total en mémoire O (n). Cela le rend vulnérable à une erreur de débordement de pile, où la pile d'appels devient trop volumineuse et manque d'espace. Optimisation du coût de revient (TCO). Où il peut optimiser les fonctions récursives pour éviter la constitution d’une pile d’appel de grande taille et économiser ainsi le coût en mémoire.

Beaucoup de langues font du TCO, comme (Javascript, Ruby et quelques C), alors que Python et Java ne font pas du TCO.

Le langage JavaScript a confirmé en utilisant :) http://2ality.com/2015/06/tail-call-optimization.html

3
Rupesh Kumar Tiwari