Что такое оптимизация хвостового звонка?

Очень просто, что такое оптимизация хвостового вызова? Более конкретно, может ли кто-нибудь показать некоторые небольшие фрагменты кода, где он может быть применен, а где нет, с объяснением причины?

Ответ 1

Оптимизация Tail-call - это то, где вы можете избежать выделения нового фрейма стека для функции, потому что вызывающая функция просто вернет значение, которое оно получает от вызываемой функции. Чаще всего используется хвостовая рекурсия, где рекурсивная функция, написанная для использования оптимизации хвостового вызова, может использовать постоянное пространство стека.

Схема - один из немногих языков программирования, которые гарантируют в спецификации, что любая реализация должна обеспечить эту оптимизацию (JavaScript также начинается с ES6), так что вот два примера факториальной функции в Схеме:

(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))

Первая функция не является хвостовой рекурсивной, так как при рекурсивном вызове функция должна отслеживать умножение, которое необходимо выполнить с результатом после возврата вызова. Таким образом, стек выглядит следующим образом:

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

Напротив, трассировка стека для хвостового рекурсивного факториала выглядит следующим образом:

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

Как вы можете видеть, нам нужно всего лишь отслеживать один и тот же объем данных для каждого звонка на хвост факта, потому что мы просто возвращаем значение, которое мы получаем до самого верха. Это означает, что даже если я должен был позвонить (факт 1000000), мне нужно только то же пространство, что и (факт 3). Это не относится к не-хвостовому рекурсивному факту, и поскольку такие большие значения могут привести к переполнению стека.

Ответ 2

Пройдем через простой пример: факториальная функция, реализованная в C.

Начнем с очевидного рекурсивного определения

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

Функция заканчивается хвостовым вызовом, если последняя операция перед возвратом функции - это другой вызов функции. Если этот вызов вызывает ту же функцию, он является хвостовым рекурсивным.

Даже если fac() выглядит на первый взгляд хвостом рекурсивным, это не так, как на самом деле происходит.

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

т.е. последняя операция - это умножение, а не вызов функции.

Однако можно переписать fac() как хвост-рекурсивный, передав накопленное значение в цепочку вызовов в качестве дополнительного аргумента и снова передав только окончательный результат в качестве возвращаемого значения:

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);
}

Теперь, почему это полезно? Поскольку мы сразу возвращаемся после вызова хвоста, мы можем отбросить предыдущий стек, прежде чем вызывать функцию в позиции хвоста, или, в случае рекурсивных функций, повторно использовать стек-кадр как есть.

Оптимизация хвостового вызова преобразует наш рекурсивный код в

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

Это может быть включено в fac(), и мы приходим к

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

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

что эквивалентно

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

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

    return acc;
}

Как мы видим здесь, достаточно продвинутый оптимизатор может заменить хвостовую рекурсию на итерацию, что намного эффективнее, так как вы избегаете служебных вызовов функций и используете только постоянное количество пространства стека.

Ответ 3

TCO (Tail Call Optimization) - это процесс, с помощью которого интеллектуальный компилятор может сделать вызов функции и не использовать дополнительное пространство стека. Единственная ситуация, в которой это происходит, заключается в том, что последняя команда, выполняемая в функции f, является вызовом функции g (Примечание: g может быть f). Ключевым моментом здесь является то, что f больше не требует пространства стека - он просто вызывает g, а затем возвращает все возвращаемое g. В этом случае можно сделать оптимизацию, чтобы g просто запускал и возвращал любое значение, которое он должен был бы для того, что называется f.

Эта оптимизация может заставить рекурсивные вызовы принимать постоянное пространство стека, а не взрываться.

Пример: эта факториальная функция не является TCOptimizable:

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

Эта функция делает вещи помимо вызова другой функции в своем операторе return.

Эта функция ниже 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)

Это потому, что последнее, что должно произойти в любой из этих функций, - вызвать другую функцию.

Ответ 4

Вероятно, лучшее описание высокого уровня, которое я нашел для хвостовых вызовов, рекурсивных хвостовых вызовов и оптимизации хвостовых вызовов - это сообщение в блоге

"Что это такое: хвостовой вызов"

Дэн Сугалски. При оптимизации хвостового вызова он пишет:

Рассмотрим на мгновение эту простую функцию:

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

Итак, что вы, или, скорее, ваш компилятор языка? Ну, что он может сделать, это превратить код формы return somefunc(); в низкоуровневую последовательность pop stack frame; goto somefunc();. В нашем примере это означает, что до того, как мы назовем bar, foo очистится, а затем вместо вызова bar в качестве подпрограммы мы выполняем операцию goto на низком уровне до начала bar. foo уже очистился от стека, поэтому, когда начинается bar, похоже, что тот, кто вызвал foo, действительно вызвал bar, а когда bar возвращает свое значение, он возвращает его непосредственно кому бы то ни было, вызывающему foo, а не возвращать его в foo, который затем возвращает его вызывающему.

И при рекурсии хвоста:

Рекурсия хвоста происходит, если функция в качестве последней операции возвращает результат вызова себя. Рекурсия хвоста легче справиться с потому что вместо того, чтобы прыгать к началу какого-то случайного где-нибудь, вы просто переходите к началу себя, что является проклятой простой задачей.

Чтобы это:

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

тихо превращается в:

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

Что мне нравится в этом описании, так это то, насколько кратким и легким это понять для тех, кто приходит с императивного языка (C, С++, Java)

Ответ 5

Обратите внимание, что не все языки поддерживают его.

TCO применяется к частному случаю рекурсии. Суть его в том, что последнее, что вы делаете в функции, это сам вызов (например, он вызывается из позиции "хвост" ), это может быть оптимизировано компилятором, чтобы действовать как итерация вместо стандартной рекурсии.

Как вы видите, обычно во время рекурсии среда выполнения должна отслеживать все рекурсивные вызовы, чтобы при возвращении она могла возобновиться при предыдущем вызове и так далее. (Попробуйте вручную выписать результат рекурсивного вызова, чтобы получить визуальное представление о том, как это работает.) Отслеживание всех вызовов занимает пробел, что становится значительным, когда функция называет себя много. Но с TCO он может просто сказать "вернуться к началу, только на этот раз изменить значения параметров на эти новые". Он может это сделать, потому что ничто после рекурсивного вызова не ссылается на эти значения.

Ответ 6

Посмотрите здесь:

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

Как вы, вероятно, знаете, рекурсивные вызовы функций могут нанести ущерб стеку; легко быстро разрядиться из пространства стека. Оптимизация вызовов хвоста - это способ, которым вы можете создать алгоритм рекурсивного стиля, который использует постоянное пространство стека, поэтому он не растет и не растет, и вы получаете ошибки стека.

Ответ 7

  • Мы должны убедиться, что в самой функции нет операторов goto. Позаботился о том, чтобы вызов функции был последним в функции вызываемого абонента.

  • Репликация большого масштаба может использовать это для оптимизации, но в небольших масштабах служебные накладные расходы для вызова функции вызова хвоста уменьшают фактическую цель.

  • TCO может вызывать вечную работу:

    void eternity()
    {
        eternity();
    }
    

Ответ 8

Проблема с рекурсивными функциями имеет проблему. Он создает стек вызовов размера O (n), что делает нашу общую стоимость памяти O (n). Это делает его уязвимым для ошибки, где стек вызовов становится слишком большим и заканчивается из пространства. Оптимизация затрат на хвост (TCO). Там, где он может оптимизировать рекурсивные функции, чтобы не создавать высокий стек вызовов и, следовательно, экономить стоимость памяти.

Есть много языков, которые делают TCO как (Javascript, Ruby и несколько C), где Python и Java не делают TCO.

Язык JavaScript подтвердил использование: http://2ality.com/2015/06/tail-call-optimization.html

Ответ 9

Пример минимального запуска GCC с анализом разборки x86

Давайте посмотрим, как GCC может автоматически выполнять оптимизацию хвостовых вызовов для нас, посмотрев на сгенерированную сборку.

Это послужит чрезвычайно конкретным примером того, что было упомянуто в других ответах, таких как fooobar.com/info/3525/... что оптимизация может преобразовывать рекурсивные вызовы функций в цикл.

Это, в свою очередь, экономит память и повышает производительность, так как доступ к памяти часто является главной причиной замедления работы программ.

В качестве входных данных мы даем GCC неоптимизированный факториал на основе наивного стека:

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 вверх по течению.

Скомпилируйте и разберите:

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 - это имя обобщения хвостовых вызовов согласно man gcc:

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

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

как упомянуто в: Как я проверяю, выполняет ли gcc оптимизацию хвостовой рекурсии?

Я выбираю -O1 потому что:

  • оптимизация не выполняется с -O0. Я подозреваю, что это потому, что отсутствуют необходимые промежуточные преобразования.
  • -O3 производит нечестиво эффективный код, который не будет очень познавательным, хотя он также оптимизирован с помощью хвостового вызова.

Разборка с помощью -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

С -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

Основное различие между ними заключается в том, что:

  • -fno-optimize-sibling-calls callq использует callq, который является типичным неоптимизированным вызовом функции.

    Эта инструкция помещает адрес возврата в стек, увеличивая его.

    Кроме того, эта версия также push %rbx, который %rbx в стек.

    GCC делает это потому, что сохраняет edi, который является первым аргументом функции (n) в ebx, а затем вызывает factorial.

    GCC должен сделать это, потому что он готовится к другому вызову factorial, который будет использовать новый edi == n-1.

    Он выбирает ebx потому что этот регистр сохранен вызываемым пользователем: какие регистры сохраняются с помощью вызова функции linux x86-64, поэтому подзвон к factorial не изменит его и не потеряет n.

  • -foptimize-sibling-calls не использует никаких инструкций, которые -foptimize-sibling-calls в стек: он только выполняет переходы goto внутри factorial с инструкциями je и jne.

    Следовательно, эта версия эквивалентна циклу while без каких-либо вызовов функций. Использование стека постоянно.

Протестировано в Ubuntu 18.10, GCC 8.2.