Що таке оптимізація хвостових викликів?


817

Що дуже просто, що таке оптимізація хвоста?

Більш конкретно, що таке невеликі фрагменти коду, де його можна було застосувати, а де ні, з поясненням чому?


10
TCO перетворює виклик функції в положенні хвоста в гото, стрибок.
Буде Несс

8
Це питання було задано повністю за 8 років до цього;)
majelbstoat

Відповіді:


754

Оптимізація зворотного дзвінка - це те, коли ви можете уникнути виділення нового кадру стека для функції, оскільки функція виклику просто поверне значення, яке воно отримує від викличеної функції. Найбільш поширене використання - це рекурсія хвоста, де рекурсивна функція, записана для використання переваги оптимізації виклику хвоста, може використовувати постійний простір стеку.

Схема є однією з небагатьох мов програмування, яка гарантує в специфікації, що будь-яка реалізація повинна забезпечувати цю оптимізацію (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). Це не так у випадку з рекурсивним фактом без хвоста, і тому такі великі значення можуть спричинити переповнення стека.


99
Якщо ви хочете дізнатися більше про це, пропоную прочитати перший розділ "Структура та інтерпретація комп'ютерних програм".
Кайл Кронін

3
Чудова відповідь, чудово пояснена.
Іона

15
Строго кажучи, оптимізація хвостових викликів не обов'язково замінює кадр стека абонента на виклики, але, скоріше, гарантує, що необмежена кількість викликів у положенні хвоста вимагає лише обмеженої кількості місця. Дивіться статтю Вілла Клінгера "Правильна рекурсія хвоста та ефективність простору": cesura17.net/~will/Professional/Research/Papers/tail.pdf
Джон Харроп

3
Це лише спосіб запису рекурсивних функцій у постійному просторі? Тому що ви не могли досягти однакових результатів, використовуючи ітеративний підхід?
dclowd9901

5
@ dclowd9901, TCO дозволяє віддати перевагу функціональному стилю, а не ітераційному циклу. Можна віддати перевагу імперативному стилю. Багато мов (Java, Python) не забезпечують TCO, тоді ви повинні знати, що функціональний дзвінок коштує пам'яті ... а імперативний стиль є кращим.
mcoolive

551

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

Почнемо з очевидного рекурсивного визначення

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

Як ми бачимо тут, досить вдосконалений оптимізатор може замінити рекурсію хвоста ітерацією, що набагато ефективніше, оскільки ви уникаєте накладних викликів функцій та використовуєте лише постійну кількість місця у стеку.


Ви можете пояснити, що точно означає стекфрейм? Чи є різниця між стеком викликів і стекфреймом?
Шасак

10
@Kasahs: кадр стека - це частина стека викликів, яка 'належить' даній (активній) функції; cf en.wikipedia.org/wiki/Call_stack#Structure
Крістоф

1
У мене просто була досить інтенсивна епіфанія, прочитавши цей пост після прочитання 2ality.com/2015/06/tail-call-optimization.html
agm1984

198

TCO (оптимізація виклику хвостів) - це процес, за допомогою якого розумний компілятор може здійснити виклик до функції та не займати додаткового місця у стеку. Єдина ситуація , в якій це відбувається, якщо остання команда виконується у функції F є викликом функції г (Примітка: г може бути е ). Ключовим тут є те, що f більше не потребує простору стеку - він просто викликає g і повертає те, що g повернеться. У цьому випадку можна зробити оптимізацію, що g просто запускається та повертає будь-яке значення, яке б воно мало для речі, що називається f.

Ця оптимізація може змусити рекурсивні дзвінки займати постійний простір стека, а не вибухати.

Приклад: ця факторіальна функція не може TIMptimizable:

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

Ця функція виконує функції, крім виклику іншої функції у своєму операторі зворотного зв'язку.

Ця нижче функція 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)

Це тому, що останнє, що трапиться в будь-якій з цих функцій - викликати іншу функцію.


3
Вся річ "функції g може бути f" була трохи заплутаною, але я розумію, що ви маєте на увазі, і приклади дійсно прояснили речі. Дуже дякую!
majelbstoat

10
Прекрасний приклад, який ілюструє концепцію. Просто врахуйте, що обрана вами мова повинна здійснювати усунення хвостових викликів або оптимізацію хвостових викликів. У прикладі, написаному на Python, якщо ви вводите значення 1000, ви отримуєте "RuntimeError: максимальна глибина рекурсії перевищена", оскільки реалізація Python за замовчуванням не підтримує Tail Recursion Elimination. Дивіться публікацію від самого Гвідо, де пояснюється, чому це: neopythonic.blogspot.pt/2009/04/tail-recursion-elimination.html .
rmcc

" Єдина ситуація" трохи надто абсолютна; Є також TRMC , принаймні теоретично, який би оптимізував (cons a (foo b))або (+ c (bar d))в положенні хвоста таким же чином.
Буде Несс

Мені сподобався ваш підхід f і g краще, ніж прийнята відповідь, можливо тому, що я людина з математики.
Нітін

Я думаю, ти маєш на увазі TCOptimized. Сказавши, що це не TCOptimizable підказує, що його ніколи не можна оптимізувати (коли це насправді може)
Жак Матьє

65

Мабуть, найкращий опис високого рівня, який я знайшов для хвостових дзвінків, рекурсивних викликів хвоста та оптимізації хвостових викликів - це повідомлення в блозі

"Що за чорт: хвіст"

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

Розглянемо на хвилину цю просту функцію:

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, C ++, Java)


4
404 Помилка. Однак він все ще доступний на archive.org: web.archive.org/web/20111030134120/http://www.sidhe.org/~dan/…
Томмі

Я цього не зрозумів, чи не fooоптимізована початкова функція хвостового виклику? Це лише виклик функції як її останній крок, і це просто повернення цього значення, правда?
SexyBeast

1
@TryinHard, можливо, не те, що ви мали на увазі, але я оновив його, щоб дати зрозуміти, про що йдеться. Вибачте, не збираюся повторювати всю статтю!
btiernay

2
Дякую, це простіше і зрозуміліше, ніж приклад схеми, що найбільше проголосується (не кажучи вже, схема не є загальною мовою, яку розуміють більшість розробників)
Sevin7,

2
Як людині, яка рідко занурюється у функціональні мови, приємно бачити пояснення на моєму діалекті. Існує (зрозуміла) тенденція функціональних програмістів до євангелізації у своїй мові на вибір, але, виходячи з імперативного світу, мені здається, що набагато простіше обернути голову навколо такої відповіді.
Джеймс Бенінгер

15

Спершу зауважте, що не всі мови підтримують це.

ТСО застосовується до особливого випадку рекурсії. Суть його полягає в тому, що якщо останнє, що ви робите у функції, - це сам виклик (наприклад, він викликає себе з позиції "хвіст"), це може бути оптимізовано компілятором, щоб діяти як ітерація замість стандартної рекурсії.

Розумієте, зазвичай під час рекурсії час виконання повинен відслідковувати всі рекурсивні дзвінки, щоб після повернення він міг відновитись при попередньому дзвінку тощо. (Спробуйте записати результат рекурсивного дзвінка вручну, щоб отримати візуальне уявлення про те, як це працює.) Відстеження всіх дзвінків займає простір, який стає значущим, коли функція сама викликає багато. Але з TCO він може просто сказати "поверніться до початку, лише цього разу змінити значення параметрів на ці нові". Це може зробити так, оскільки нічого після рекурсивного виклику не стосується цих значень.


3
Хвостові дзвінки можуть застосовуватися і до нерекурсивних функцій. Будь-яка функція, останньою обчислюваною перед поверненням, є викликом до іншої функції, може використовувати хвостовий виклик.
Брайан

Не обов'язково вірно на мові за мовою - 64-бітний компілятор C # може вставляти хвостові коди, тоді як 32-розрядна версія не буде; і збірка випусків F # буде, але налагодження F # за замовчуванням не буде.
Стів Гілхам

3
"TCO застосовується до особливого випадку рекурсії". Боюся, що це абсолютно неправильно. Хвостові дзвінки застосовуються до будь-якого дзвінка в положенні хвоста. Зазвичай обговорюється в контексті рекурсії, але насправді нічого спільного з рекурсією немає.
Джон Харроп

@Brian, перегляньте посилання @btiernay, надане вище. Чи не fooоптимізовано початковий метод хвостового виклику?
SexyBeast

13

Мінімальний приклад GCC з аналізом розбирання x86

Подивимося, як GCC може автоматично робити оптимізацію хвостових викликів для нас, переглянувши створену збірку.

Це послужить надзвичайно конкретним прикладом того, що згадувалося в інших відповідях, таких як https://stackoverflow.com/a/9814654/895245 що оптимізація може перетворити рекурсивні виклики функцій у цикл.

Це в свою чергу економить пам’ять і покращує продуктивність, оскільки часто доступ до пам'яті є головним, що робить програми нині повільними .

В якості вкладу ми надаємо 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, що є типовим викликом функції неоптимізованими.

    Ця інструкція підштовхує зворотну адресу до стеку, тому збільшуючи її.

    Крім того, ця версія також робить push %rbx, що натискає %rbxна стек .

    GCC робить це, тому що він зберігає edi, який є першим аргументом функції ( n) ebx, а потім викликає factorial.

    GCC повинен зробити це, тому що готується до чергового дзвінка до factorial, який буде використовувати новийedi == n-1 .

    Він вибирається ebxчерез те, що цей реєстр зберігається за допомогою виклику : Які регістри зберігаються через виклик функції Linux x86-64, щоб субклич factorialне міняв його та втрачав n.

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

    Тому ця версія еквівалентна циклу часу, без викликів функцій. Використання стека є постійним.

Випробувано в Ubuntu 18.10, GCC 8.2.


6

Послухайте:

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

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


3
  1. Ми повинні переконатися, що у самій функції немає жодних операторів goto. Про це слід подбати за викликом функції, який є останнім у функції callee.

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

  3. TCO може викликати функцію, що постійно працює:

    void eternity()
    {
        eternity();
    }
    

3 ще не оптимізовано. Це неоптимізоване подання, яке компілятор перетворює на ітеративний код, який використовує постійний простір стека замість рекурсивного коду. TCO не є причиною використання неправильної схеми рекурсії для структури даних.
номен

"TCO не є причиною використання неправильної схеми рекурсії для структури даних". Будь ласка, розкажіть, наскільки це стосується даного випадку. Вищенаведений приклад лише вказує на приклад, що кадри виділяються на стеку викликів з і без TCO.
grillSandwich

Ви вирішили використовувати необґрунтовану рекурсію для проходу (). Це не мало нічого спільного з TCO. Вічність буває позицією хвостового виклику, але позиція виклику хвоста не потрібна: void eternity () {eternity (); вихід(); }
номен.

Хоча ми це робимо, що таке "масштабна рекурсія"? Чому ми повинні уникати переходу Goto у функції? Це не є ні необхідним, ні достатнім, щоб дозволити TCO. А яка інструкція накладні? Вся справа в TCO полягає в тому, що компілятор замінює виклик функції в хвостовому положенні на goto.
номен.

TCO - це оптимізація простору, використовуваного для стеку викликів. Під великою масштабною рекурсією я маю на увазі розмір кадру. Щоразу, коли виникає рекурсія, якщо мені потрібно виділити величезний кадр під стеком викликів над функцією виклику, TCO буде кориснішим і дозволить мені більше рівнів рекурсії. Але у випадку, якщо розмір кадру менший, я можу обійтися без TCO і все-таки добре запустити свою програму (тут я не говорю про нескінченну рекурсію). Якщо у вас залишилася функція goto, функція "хвіст" насправді не є викликом хвоста, і TCO не застосовується.
grillSandwich

3

Підхід до рекурсивної функції має проблему. Він створює стек викликів розміром O (n), що робить нашу загальну вартість пам'яті O (n). Це робить його вразливим до помилки переповнення стека, коли стек викликів стає занадто великим і не вистачає місця.

Схема оптимізації виклику хвоста (TCO). Де можна оптимізувати рекурсивні функції, щоб уникнути нарощування високого стека викликів, а значить, економиться вартість пам'яті.

Є багато мов, які роблять TCO на зразок (JavaScript, Ruby і кілька C), тоді як Python та Java не мають TCO.

Мова JavaScript підтверджена за допомогою :) http://2ality.com/2015/06/tail-call-optimization.html


0

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

f x = g x

f 6 зменшується до g 6. Отже, якщо реалізація може повернути g 6 як результат, а потім викликає цей вираз, це збереже кадр стека.

Також

f x = if c x then g x else h x.

Зменшується до f 6 до g 6 або h 6. Отже, якщо реалізація оцінює c 6 і виявить, що це правда, то вона може зменшити,

if true then g x else h x ---> g x

f x ---> h x

Простий інтерпретатор оптимізації викликів без хвостів може виглядати так,

class simple_expresion
{
    ...
public:
    virtual ximple_value *DoEvaluate() const = 0;
};

class simple_value
{
    ...
};

class simple_function : public simple_expresion
{
    ...
private:
    simple_expresion *m_Function;
    simple_expresion *m_Parameter;

public:
    virtual simple_value *DoEvaluate() const
    {
        vector<simple_expresion *> parameterList;
        parameterList->push_back(m_Parameter);
        return m_Function->Call(parameterList);
    }
};

class simple_if : public simple_function
{
private:
    simple_expresion *m_Condition;
    simple_expresion *m_Positive;
    simple_expresion *m_Negative;

public:
    simple_value *DoEvaluate() const
    {
        if (m_Condition.DoEvaluate()->IsTrue())
        {
            return m_Positive.DoEvaluate();
        }
        else
        {
            return m_Negative.DoEvaluate();
        }
    }
}

Інтерпретатор оптимізації хвостових викликів може виглядати так,

class tco_expresion
{
    ...
public:
    virtual tco_expresion *DoEvaluate() const = 0;
    virtual bool IsValue()
    {
        return false;
    }
};

class tco_value
{
    ...
public:
    virtual bool IsValue()
    {
        return true;
    }
};

class tco_function : public tco_expresion
{
    ...
private:
    tco_expresion *m_Function;
    tco_expresion *m_Parameter;

public:
    virtual tco_expression *DoEvaluate() const
    {
        vector< tco_expression *> parameterList;
        tco_expression *function = const_cast<SNI_Function *>(this);
        while (!function->IsValue())
        {
            function = function->DoCall(parameterList);
        }
        return function;
    }

    tco_expresion *DoCall(vector<tco_expresion *> &p_ParameterList)
    {
        p_ParameterList.push_back(m_Parameter);
        return m_Function;
    }
};

class tco_if : public tco_function
{
private:
    tco_expresion *m_Condition;
    tco_expresion *m_Positive;
    tco_expresion *m_Negative;

    tco_expresion *DoEvaluate() const
    {
        if (m_Condition.DoEvaluate()->IsTrue())
        {
            return m_Positive;
        }
        else
        {
            return m_Negative;
        }
    }
}
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.