간단히 말해서 테일 콜 최적화 란 무엇입니까?
더 구체적으로, 왜 적용 할 수 있고, 그렇지 않은 이유에 대한 설명과 함께 작은 코드 스 니펫은 무엇입니까?
간단히 말해서 테일 콜 최적화 란 무엇입니까?
더 구체적으로, 왜 적용 할 수 있고, 그렇지 않은 이유에 대한 설명과 함께 작은 코드 스 니펫은 무엇입니까?
답변:
테일 콜 최적화는 호출 함수가 단순히 호출 된 함수에서 얻은 값을 반환하기 때문에 함수에 새 스택 프레임을 할당하지 않아도되는 곳입니다. 가장 일반적으로 사용되는 테일 재귀는 테일 콜 최적화를 활용하기 위해 작성된 재귀 함수가 일정한 스택 공간을 사용할 수 있습니다.
Scheme은 구현 시이 최적화를 제공해야한다는 사양을 보장하는 몇 가지 프로그래밍 언어 중 하나입니다 (JavaScript는 ES6부터 시작) . 따라서 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))
첫 번째 함수는 재귀 호출이 아닙니다. 왜냐하면 재귀 호출이 이루어질 때 함수는 호출이 반환 된 후 결과와 관련된 곱셈을 추적해야하기 때문입니다. 따라서 스택은 다음과 같습니다.
(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)과 동일한 공간 만 필요하다는 것을 의미합니다. 이는 비 꼬리 재귀 사실의 경우에는 해당되지 않으며 값이 크면 스택 오버플로가 발생할 수 있습니다.
간단한 예 : 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;
}
여기서 알 수 있듯이, 충분히 고급화 된 최적화 프로그램은 테일 재귀를 반복으로 대체 할 수 있습니다. 이는 함수 호출 오버 헤드를 피하고 일정한 양의 스택 공간 만 사용하면 훨씬 효율적입니다.
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)
이러한 함수 중 하나에서 마지막으로 발생하는 것은 다른 함수를 호출하는 것입니다.
아마도 꼬리 호출, 재귀 꼬리 호출 및 꼬리 호출 최적화에 대해 찾은 최고의 높은 수준의 설명은 블로그 게시물입니다.
댄 수갈 스키 테일 콜 최적화에서 그는 다음과 같이 씁니다.
잠시 동안이 간단한 기능을 고려하십시오.
sub foo (int a) { a += 15; return bar(a); }
그렇다면 언어 컴파일러가 무엇을 할 수 있습니까? 그것이 할 수있는 일은 폼의 코드를
return somefunc();
낮은 수준의 시퀀스로 바꾸는 것pop stack frame; goto somefunc();
입니다. 우리의 예에서 우리가 부르는 그 수단 전에bar
,foo
자신을 정리하고, 오히려 전화보다bar
서브 루틴으로, 우리는 낮은 수준의 수행goto
의 시작 동작을bar
.Foo
'이미 너무 때, 스택의 자체를 청소 s의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는 특별한 재귀 사례에 적용됩니다. 요점은 함수에서 마지막으로 수행하는 작업이 자체 호출 (예 : "꼬리"위치에서 자체 호출) 인 경우 표준 재귀 대신 반복처럼 작동하도록 컴파일러에서 최적화 할 수 있다는 것입니다.
일반적으로 재귀 중에 런타임은 모든 재귀 호출을 추적해야하므로 하나가 반환되면 이전 호출에서 다시 시작할 수 있습니다. (재귀 호출 결과를 수동으로 작성하여 이것이 어떻게 작동하는지 시각적으로 알 수 있습니다.) 모든 호출을 추적하면 공간이 많이 차지되므로 함수 자체를 많이 호출 할 때 공간이 많이 차지합니다. 그러나 TCO를 사용하면 "처음으로 돌아가서 이번에 만 매개 변수 값을 새로운 값으로 변경합니다"라고 말할 수 있습니다. 재귀 호출 후 해당 값을 참조하는 것이 없기 때문에 그렇게 할 수 있습니다.
foo
메소드 테일 호출이 최적화되지 않습니까?
x86 분해 분석을 통한 GCC 최소 실행 가능 예
생성 된 어셈블리를보고 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
이 레지스터는 수신자가 저장되기 때문에 선택됩니다 : 어떤 레지스터가 리눅스 x86-64 함수 호출을 통해 유지 되므로 서브 콜 이 factorial
변경되지 않고 잃게됩니다 n
.
는 -foptimize-sibling-calls
스택으로 푸시되는 명령어를 사용하지 않습니다 . 명령어 및 로만 goto
점프합니다 .factorial
je
jne
따라서이 버전은 함수 호출없이 while 루프와 동일합니다. 스택 사용량은 일정합니다.
우분투 18.10, GCC 8.2에서 테스트되었습니다.
이봐:
http://tratt.net/laurie/tech_articles/articles/tail_call_optimization
아시다시피 재귀 함수 호출은 스택에 혼란을 초래할 수 있습니다. 스택 공간이 빨리 소진되기 쉽습니다. 테일 콜 최적화는 일정한 스택 공간을 사용하는 재귀 적 스타일 알고리즘을 생성 할 수있는 방법으로, 커지거나 커지지 않으며 스택 오류가 발생합니다.
함수 자체에 goto 문이 없어야합니다. .. 함수 호출에 의해 호출 수신자 함수의 마지막 항목이됩니다.
대규모 재귀에서는이를 최적화에 사용할 수 있지만 소규모에서는 함수 호출을 꼬리 호출로 만들기위한 명령 오버 헤드가 실제 목적을 줄입니다.
TCO는 영원히 실행되는 기능을 일으킬 수 있습니다.
void eternity()
{
eternity();
}
재귀 함수 접근 방식에 문제가 있습니다. 크기 O (n)의 호출 스택을 빌드하여 총 메모리 비용 O (n)을 만듭니다. 이로 인해 호출 스택이 너무 커지고 공간이 부족한 스택 오버플로 오류에 취약 해집니다.
테일 콜 최적화 (TCO) 체계. 높은 호출 스택을 작성하지 않기 위해 재귀 함수를 최적화 할 수있는 경우 메모리 비용이 절약됩니다.
(JavaScript, Ruby 및 C와 같은) TCO를 수행하는 많은 언어가 있지만 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;
}
}
}