Що дуже просто, що таке оптимізація хвоста?
Більш конкретно, що таке невеликі фрагменти коду, де його можна було застосувати, а де ні, з поясненням чому?
Що дуже просто, що таке оптимізація хвоста?
Більш конкретно, що таке невеликі фрагменти коду, де його можна було застосувати, а де ні, з поясненням чому?
Відповіді:
Оптимізація зворотного дзвінка - це те, коли ви можете уникнути виділення нового кадру стека для функції, оскільки функція виклику просто поверне значення, яке воно отримує від викличеної функції. Найбільш поширене використання - це рекурсія хвоста, де рекурсивна функція, записана для використання переваги оптимізації виклику хвоста, може використовувати постійний простір стеку.
Схема є однією з небагатьох мов програмування, яка гарантує в специфікації, що будь-яка реалізація повинна забезпечувати цю оптимізацію (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). Це не так у випадку з рекурсивним фактом без хвоста, і тому такі великі значення можуть спричинити переповнення стека.
Проведемо простий приклад: факторна функція, реалізована в С.
Почнемо з очевидного рекурсивного визначення
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;
}
Як ми бачимо тут, досить вдосконалений оптимізатор може замінити рекурсію хвоста ітерацією, що набагато ефективніше, оскільки ви уникаєте накладних викликів функцій та використовуєте лише постійну кількість місця у стеку.
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)
Це тому, що останнє, що трапиться в будь-якій з цих функцій - викликати іншу функцію.
Мабуть, найкращий опис високого рівня, який я знайшов для хвостових дзвінків, рекурсивних викликів хвоста та оптимізації хвостових викликів - це повідомлення в блозі
Дана Сугальського. Про оптимізацію хвостових викликів він пише:
Розглянемо на хвилину цю просту функцію:
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)
foo
оптимізована початкова функція хвостового виклику? Це лише виклик функції як її останній крок, і це просто повернення цього значення, правда?
Спершу зауважте, що не всі мови підтримують це.
ТСО застосовується до особливого випадку рекурсії. Суть його полягає в тому, що якщо останнє, що ви робите у функції, - це сам виклик (наприклад, він викликає себе з позиції "хвіст"), це може бути оптимізовано компілятором, щоб діяти як ітерація замість стандартної рекурсії.
Розумієте, зазвичай під час рекурсії час виконання повинен відслідковувати всі рекурсивні дзвінки, щоб після повернення він міг відновитись при попередньому дзвінку тощо. (Спробуйте записати результат рекурсивного дзвінка вручну, щоб отримати візуальне уявлення про те, як це працює.) Відстеження всіх дзвінків займає простір, який стає значущим, коли функція сама викликає багато. Але з TCO він може просто сказати "поверніться до початку, лише цього разу змінити значення параметрів на ці нові". Це може зробити так, оскільки нічого після рекурсивного виклику не стосується цих значень.
foo
оптимізовано початковий метод хвостового виклику?
Мінімальний приклад 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;
}
Складіть і розберіть:
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.
Послухайте:
http://tratt.net/laurie/tech_articles/articles/tail_call_optimization
Як ви, напевно, знаєте, рекурсивні виклики функцій можуть спричинити хаос на стеку; швидко не вистачає місця у стеку. Оптимізація хвостових викликів - це спосіб, за допомогою якого можна створити алгоритм рекурсивного стилю, який використовує постійний простір стеків, тому він не росте і не росте, і ви отримуєте помилки стеку.
Ми повинні переконатися, що у самій функції немає жодних операторів goto. Про це слід подбати за викликом функції, який є останнім у функції callee.
Рекурсії великого масштабу можуть використовувати це для оптимізації, але в невеликих масштабах накладні накладні інструкції для здійснення функції виклику хвоста виклику знижують фактичну мету.
TCO може викликати функцію, що постійно працює:
void eternity()
{
eternity();
}
Підхід до рекурсивної функції має проблему. Він створює стек викликів розміром O (n), що робить нашу загальну вартість пам'яті O (n). Це робить його вразливим до помилки переповнення стека, коли стек викликів стає занадто великим і не вистачає місця.
Схема оптимізації виклику хвоста (TCO). Де можна оптимізувати рекурсивні функції, щоб уникнути нарощування високого стека викликів, а значить, економиться вартість пам'яті.
Є багато мов, які роблять TCO на зразок (JavaScript, Ruby і кілька C), тоді як Python та Java не мають TCO.
Мова JavaScript підтверджена за допомогою :) http://2ality.com/2015/06/tail-call-optimization.html
У функціональній мові оптимізація хвостових викликів - це так, як якщо б виклик функції міг повернути частково оцінене вираження як результат, який потім буде оцінений абонентом.
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;
}
}
}