Rất đơn giản, tối ưu hóa cuộc gọi đuôi là gì?
Cụ thể hơn, một số đoạn mã nhỏ có thể được áp dụng ở đâu và ở đâu không, với lời giải thích tại sao?
Rất đơn giản, tối ưu hóa cuộc gọi đuôi là gì?
Cụ thể hơn, một số đoạn mã nhỏ có thể được áp dụng ở đâu và ở đâu không, với lời giải thích tại sao?
Câu trả lời:
Tối ưu hóa cuộc gọi đuôi là nơi bạn có thể tránh phân bổ khung ngăn xếp mới cho một hàm vì hàm gọi sẽ đơn giản trả về giá trị mà nó nhận được từ hàm được gọi. Việc sử dụng phổ biến nhất là đệ quy đuôi, trong đó một hàm đệ quy được viết để tận dụng tối ưu hóa cuộc gọi đuôi có thể sử dụng không gian ngăn xếp không đổi.
Lược đồ là một trong số ít các ngôn ngữ lập trình đảm bảo trong thông số kỹ thuật rằng bất kỳ triển khai nào cũng phải cung cấp tối ưu hóa này (JavaScript cũng vậy, bắt đầu với ES6) , vì vậy đây là hai ví dụ về chức năng giai thừa trong Lược đồ :
(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))
Hàm đầu tiên không phải là đệ quy đuôi vì khi thực hiện cuộc gọi đệ quy, hàm cần theo dõi phép nhân mà nó cần thực hiện với kết quả sau khi cuộc gọi trở lại. Như vậy, ngăn xếp trông như sau:
(fact 3)
(* 3 (fact 2))
(* 3 (* 2 (fact 1)))
(* 3 (* 2 (* 1 (fact 0))))
(* 3 (* 2 (* 1 1)))
(* 3 (* 2 1))
(* 3 2)
6
Ngược lại, dấu vết ngăn xếp cho giai thừa đệ quy đuôi trông như sau:
(fact 3)
(fact-tail 3 1)
(fact-tail 2 3)
(fact-tail 1 6)
(fact-tail 0 6)
6
Như bạn có thể thấy, chúng tôi chỉ cần theo dõi cùng một lượng dữ liệu cho mỗi cuộc gọi đến đuôi thực tế bởi vì chúng tôi chỉ đơn giản là trả lại giá trị mà chúng tôi nhận được ngay từ đầu. Điều này có nghĩa là ngay cả khi tôi gọi (thực tế 1000000), tôi chỉ cần cùng một dung lượng như (thực tế 3). Đây không phải là trường hợp với thực tế không đệ quy và vì các giá trị lớn như vậy có thể gây ra tràn ngăn xếp.
Chúng ta hãy đi qua một ví dụ đơn giản: hàm giai thừa được triển khai trong C.
Chúng tôi bắt đầu với định nghĩa đệ quy rõ ràng
unsigned fac(unsigned n)
{
if (n < 2) return 1;
return n * fac(n - 1);
}
Một hàm kết thúc bằng một cuộc gọi đuôi nếu thao tác cuối cùng trước khi hàm trả về là một lệnh gọi hàm khác. Nếu cuộc gọi này gọi cùng chức năng, thì đó là đệ quy đuôi.
Mặc dù fac()
thoạt nhìn có vẻ đệ quy, nó không giống như những gì thực sự xảy ra
unsigned fac(unsigned n)
{
if (n < 2) return 1;
unsigned acc = fac(n - 1);
return n * acc;
}
tức là thao tác cuối cùng là phép nhân và không phải là hàm gọi.
Tuy nhiên, có thể viết lại fac()
thành đệ quy đuôi bằng cách chuyển giá trị tích lũy xuống chuỗi cuộc gọi dưới dạng đối số bổ sung và chỉ chuyển kết quả cuối cùng lên một lần nữa dưới dạng giá trị trả về:
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);
}
Bây giờ, tại sao điều này hữu ích? Vì chúng tôi ngay lập tức quay lại sau lệnh gọi đuôi, chúng tôi có thể loại bỏ stackframe trước đó trước khi gọi hàm ở vị trí đuôi hoặc trong trường hợp các hàm đệ quy, sử dụng lại stackframe như hiện tại.
Tối ưu hóa cuộc gọi đuôi biến đổi mã đệ quy của chúng tôi thành
unsigned fac_tailrec(unsigned acc, unsigned n)
{
TOP:
if (n < 2) return acc;
acc = n * acc;
n = n - 1;
goto TOP;
}
Điều này có thể được đưa vào fac()
và chúng tôi đến
unsigned fac(unsigned n)
{
unsigned acc = 1;
TOP:
if (n < 2) return acc;
acc = n * acc;
n = n - 1;
goto TOP;
}
tương đương với
unsigned fac(unsigned n)
{
unsigned acc = 1;
for (; n > 1; --n)
acc *= n;
return acc;
}
Như chúng ta có thể thấy ở đây, một trình tối ưu hóa đủ tiên tiến có thể thay thế đệ quy đuôi bằng phép lặp, hiệu quả hơn nhiều khi bạn tránh được chi phí gọi hàm và chỉ sử dụng một lượng không gian ngăn xếp không đổi.
TCO (Tối ưu hóa cuộc gọi đuôi) là quá trình mà trình biên dịch thông minh có thể thực hiện cuộc gọi đến một chức năng và không mất thêm không gian ngăn xếp. Các tình huống duy nhất mà điều này xảy ra là nếu các hướng dẫn mới nhất thực hiện trong một hàm f là một cuộc gọi đến một hàm g (Lưu ý: g có thể f ). Chìa khóa ở đây là f không còn cần không gian ngăn xếp - nó chỉ đơn giản gọi g và sau đó trả về bất cứ thứ gì g sẽ trả về. Trong trường hợp này, việc tối ưu hóa có thể được thực hiện khi g chỉ chạy và trả về bất kỳ giá trị nào nó sẽ có cho thứ gọi là f.
Tối ưu hóa này có thể làm cho các cuộc gọi đệ quy mất không gian ngăn xếp liên tục, thay vì phát nổ.
Ví dụ: hàm giai thừa này không phải là TCOptimizable:
def fact(n):
if n == 0:
return 1
return n * fact(n-1)
Hàm này thực hiện mọi việc ngoài việc gọi hàm khác trong câu lệnh return.
Chức năng dưới đây là 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)
Điều này là do điều cuối cùng xảy ra trong bất kỳ chức năng nào trong số này là gọi một chức năng khác.
Có lẽ mô tả cấp cao tốt nhất mà tôi đã tìm thấy cho các cuộc gọi đuôi, cuộc gọi đuôi đệ quy và tối ưu hóa cuộc gọi đuôi là bài đăng trên blog
"Cái quái gì thế này: Một cuộc gọi đuôi"
bởi Dan Sugalski. Về tối ưu hóa cuộc gọi, ông viết:
Hãy xem xét, trong một khoảnh khắc, chức năng đơn giản này:
sub foo (int a) { a += 15; return bar(a); }
Vì vậy, những gì bạn có thể, hoặc đúng hơn là trình biên dịch ngôn ngữ của bạn, làm gì? Chà, những gì nó có thể làm là biến mã của biểu mẫu
return somefunc();
thành chuỗi cấp thấppop stack frame; goto somefunc();
. Trong ví dụ của chúng tôi, điều đó có nghĩa là trước khi chúng tôi gọibar
,foo
tự dọn dẹp và sau đó, thay vì gọibar
như một chương trình con, chúng tôi thực hiện mộtgoto
thao tác cấp thấp để bắt đầubar
.Foo
Nó đã tự dọn sạch khỏi ngăn xếp, vì vậy khibar
bắt đầu, có vẻ như bất kỳ ai được gọifoo
đã thực sự được gọibar
và khibar
trả về giá trị của nó, nó sẽ trả lại trực tiếp cho bất kỳ ai được gọifoo
, thay vìfoo
trả lại cho người gọi.
Và trên đệ quy đuôi:
Đệ quy đuôi xảy ra nếu một chức năng, như hoạt động cuối cùng của nó, trả về kết quả của việc gọi chính nó . Đệ quy đuôi dễ đối phó hơn vì thay vì phải nhảy vào đầu một số chức năng ngẫu nhiên ở đâu đó, bạn chỉ cần thực hiện một goto trở lại từ đầu của chính mình, đó là một việc đơn giản dễ làm.
Vì vậy, điều này:
sub foo (int a, int b) { if (b == 1) { return a; } else { return foo(a*a + a, b - 1); }
lặng lẽ biến thành:
sub foo (int a, int b) { label: if (b == 1) { return a; } else { a = a*a + a; b = b - 1; goto label; }
Điều tôi thích về mô tả này là cách cô đọng và dễ nắm bắt đối với những người đến từ nền tảng ngôn ngữ bắt buộc (C, C ++, Java)
foo
cuộc gọi đuôi chức năng ban đầu được tối ưu hóa? Nó chỉ gọi một chức năng là bước cuối cùng của nó, và nó chỉ đơn giản là trả về giá trị đó, phải không?
Lưu ý trước hết rằng không phải tất cả các ngôn ngữ đều hỗ trợ nó.
TCO áp dụng cho một trường hợp đệ quy đặc biệt. Ý chính của nó là, nếu điều cuối cùng bạn làm trong một hàm là tự gọi (ví dụ: nó tự gọi từ vị trí "đuôi"), thì trình biên dịch có thể được tối ưu hóa để hoạt động như phép lặp thay vì đệ quy chuẩn.
Bạn thấy, thông thường trong quá trình đệ quy, bộ thực thi cần theo dõi tất cả các cuộc gọi đệ quy, để khi một cuộc gọi trở lại, nó có thể tiếp tục ở cuộc gọi trước, v.v. (Thử viết thủ công kết quả của một cuộc gọi đệ quy để có ý tưởng trực quan về cách thức hoạt động của nó.) Theo dõi tất cả các cuộc gọi chiếm không gian, điều này trở nên quan trọng khi hàm tự gọi rất nhiều. Nhưng với TCO, nó chỉ có thể nói "quay lại từ đầu, chỉ lần này thay đổi các giá trị tham số thành những giá trị mới này". Nó có thể làm điều đó bởi vì không có gì sau cuộc gọi đệ quy đề cập đến các giá trị đó.
foo
cuộc gọi đuôi phương thức ban đầu được tối ưu hóa?
Ví dụ có thể chạy tối thiểu GCC với phân tích tháo gỡ x86
Chúng ta hãy xem GCC có thể tự động thực hiện tối ưu hóa cuộc gọi đuôi cho chúng ta bằng cách nhìn vào tổ hợp được tạo.
Điều này sẽ phục vụ như một ví dụ cực kỳ cụ thể về những gì đã được đề cập trong các câu trả lời khác, chẳng hạn như https://stackoverflow.com/a/9814654/895245 rằng việc tối ưu hóa có thể chuyển đổi các lệnh gọi hàm đệ quy thành một vòng lặp.
Điều này lần lượt tiết kiệm bộ nhớ và cải thiện hiệu suất, vì truy cập bộ nhớ thường là điều chính làm cho các chương trình chậm hiện nay .
Là một đầu vào, chúng tôi cung cấp cho GCC một giai thừa dựa trên ngăn xếp ngây thơ không được tối ưu hóa:
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;
}
Biên dịch và tháo rời:
gcc -O1 -foptimize-sibling-calls -ggdb3 -std=c99 -Wall -Wextra -Wpedantic \
-o tail_call.out tail_call.c
objdump -d tail_call.out
nơi -foptimize-sibling-calls
là tên của tổng quát của cuộc gọi đuôi theo man gcc
:
-foptimize-sibling-calls
Optimize sibling and tail recursive calls.
Enabled at levels -O2, -O3, -Os.
như đã đề cập tại: Làm cách nào để kiểm tra xem gcc có thực hiện tối ưu hóa đệ quy đuôi không?
Tôi chọn -O1
vì:
-O0
. Tôi nghi ngờ rằng điều này là do thiếu các biến đổi trung gian cần thiết.-O3
tạo ra mã hiệu quả vô duyên sẽ không mang tính giáo dục cao, mặc dù nó cũng được gọi là đuôi được tối ưu hóa.Tháo gỡ với -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
Với -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
Sự khác biệt chính giữa hai là:
việc -fno-optimize-sibling-calls
sử dụng callq
, đó là cách gọi hàm không được tối ưu hóa điển hình.
Hướng dẫn này đẩy địa chỉ trả về ngăn xếp, do đó tăng nó.
Hơn nữa, phiên bản này cũng làm push %rbx
, mà đẩy %rbx
đến ngăn xếp .
GCC thực hiện điều này bởi vì nó lưu trữ edi
, là đối số hàm đầu tiên ( n
) vào ebx
, sau đó gọi factorial
.
GCC cần phải làm điều này bởi vì nó đang chuẩn bị cho một cuộc gọi khác factorial
, sẽ sử dụng cuộc gọi mới edi == n-1
.
Nó chọn ebx
vì thanh ghi này được lưu callee: Những thanh ghi nào được lưu giữ thông qua lệnh gọi hàm linux x86-64 để subcall factorial
không thay đổi và mất n
.
những -foptimize-sibling-calls
không sử dụng bất kỳ hướng dẫn mà đẩy vào stack: nó chỉ thực hiện goto
nhảy trong factorial
các hướng dẫn je
và jne
.
Do đó, phiên bản này tương đương với một vòng lặp while, không có bất kỳ lệnh gọi chức năng nào. Sử dụng ngăn xếp là không đổi.
Đã thử nghiệm trong Ubuntu 18.10, GCC 8.2.
Nhìn đây:
http://tratt.net/laurie/tech_articles/articles/tail_call_optimization
Như bạn có thể biết, các lệnh gọi hàm đệ quy có thể tàn phá một ngăn xếp; thật dễ dàng để nhanh chóng hết không gian ngăn xếp. Tối ưu hóa cuộc gọi đuôi là cách mà bạn có thể tạo một thuật toán kiểu đệ quy sử dụng không gian ngăn xếp không đổi, do đó nó không tăng trưởng và phát triển và bạn gặp lỗi ngăn xếp.
Chúng ta nên đảm bảo rằng không có câu lệnh goto nào trong hàm .. được chăm sóc bằng cách gọi hàm là điều cuối cùng trong hàm callee.
Thu hồi quy mô lớn có thể sử dụng điều này để tối ưu hóa, nhưng ở quy mô nhỏ, chi phí hướng dẫn để thực hiện chức năng gọi một cuộc gọi đuôi làm giảm mục đích thực tế.
TCO có thể gây ra chức năng chạy mãi mãi:
void eternity()
{
eternity();
}
Cách tiếp cận hàm đệ quy có một vấn đề. Nó xây dựng một ngăn xếp cuộc gọi có kích thước O (n), làm cho tổng bộ nhớ của chúng ta có chi phí O (n). Điều này làm cho nó dễ bị lỗi tràn ngăn xếp, trong đó ngăn xếp cuộc gọi quá lớn và hết dung lượng.
Chương trình tối ưu hóa cuộc gọi đuôi (TCO). Nơi nó có thể tối ưu hóa các chức năng đệ quy để tránh xây dựng ngăn xếp cuộc gọi cao và do đó tiết kiệm chi phí bộ nhớ.
Có nhiều ngôn ngữ đang làm TCO như (JavaScript, Ruby và vài C) trong khi Python và Java không làm TCO.
Ngôn ngữ JavaScript đã được xác nhận bằng cách sử dụng :) http://2ality.com/2015/06/tail-call-optimization.html
Trong một ngôn ngữ chức năng, tối ưu hóa cuộc gọi đuôi như thể một lệnh gọi hàm có thể trả về một biểu thức được đánh giá một phần làm kết quả, sau đó sẽ được người gọi đánh giá.
f x = g x
f 6 giảm xuống g 6. Vì vậy, nếu việc thực hiện có thể trả về g 6 như kết quả, và sau đó gọi biểu thức đó, nó sẽ lưu một khung stack.
Cũng thế
f x = if c x then g x else h x.
Giảm xuống f 6 xuống g 6 hoặc h 6. Vì vậy, nếu việc triển khai đánh giá c 6 và thấy nó đúng thì nó có thể giảm,
if true then g x else h x ---> g x
f x ---> h x
Một trình thông dịch tối ưu hóa cuộc gọi không đuôi đơn giản có thể trông như thế này,
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();
}
}
}
Một trình thông dịch tối ưu hóa cuộc gọi đuôi có thể trông như thế này,
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;
}
}
}