ง่ายมากการเพิ่มประสิทธิภาพการโทรแบบหางคืออะไร
โดยเฉพาะอย่างยิ่งมีโค้ดขนาดเล็กอะไรบ้างที่สามารถนำไปใช้และไม่ได้มีคำอธิบายว่าทำไม
ง่ายมากการเพิ่มประสิทธิภาพการโทรแบบหางคืออะไร
โดยเฉพาะอย่างยิ่งมีโค้ดขนาดเล็กอะไรบ้างที่สามารถนำไปใช้และไม่ได้มีคำอธิบายว่าทำไม
คำตอบ:
การเพิ่มประสิทธิภาพ Tail-call เป็นที่ที่คุณสามารถหลีกเลี่ยงการจัดสรรเฟรมสแต็กใหม่สำหรับฟังก์ชั่นได้เพราะฟังก์ชั่นการโทรจะส่งกลับค่าที่ได้รับจากฟังก์ชั่นที่เรียก การใช้งานทั่วไปคือ tail-recursion ซึ่งฟังก์ชั่น recursive ที่เขียนขึ้นเพื่อใช้ประโยชน์จาก tail-call optimization สามารถใช้พื้นที่สแต็คคงที่
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);
}
ทีนี้ทำไมมันถึงมีประโยชน์ เนื่องจากเรากลับมาทันทีหลังจากการเรียก tail เราสามารถทิ้ง stackframe ก่อนหน้านี้ก่อนที่จะเรียกใช้ฟังก์ชันในตำแหน่ง tail หรือในกรณีที่มีการเรียกใช้ฟังก์ชันแบบเรียกซ้ำ
การเพิ่มประสิทธิภาพหางเรียกแปลงรหัสซ้ำของเราเป็น
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 (หมายเหตุ: กรัมสามารถฉ ) กุญแจนี่คือfไม่ต้องการพื้นที่สแต็คอีกต่อไป - มันเรียกgแล้วส่งคืนสิ่งที่gจะกลับมา ในกรณีนี้การเพิ่มประสิทธิภาพสามารถทำให้ g เพียงแค่เรียกใช้และส่งคืนค่าใด ๆ ก็ตามที่มันจะต้องมีกับสิ่งที่เรียกว่า f
การเพิ่มประสิทธิภาพนี้สามารถทำการเรียกซ้ำแบบเรียกใช้พื้นที่สแต็คคงที่มากกว่าการระเบิด
ตัวอย่าง: ฟังก์ชันแฟกทอเรียลนี้ไม่ใช่ TCO ปรับขนาดได้:
def fact(n):
if n == 0:
return 1
return n * fact(n-1)
ฟังก์ชันนี้ทำสิ่งต่าง ๆ นอกเหนือจากเรียกใช้ฟังก์ชันอื่นในคำสั่ง return
ฟังก์ชั่นด้านล่างนี้เป็น TCO ปรับขนาดได้:
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)
เนื่องจากสิ่งสุดท้ายที่เกิดขึ้นในฟังก์ชันใด ๆ เหล่านี้คือการเรียกใช้ฟังก์ชันอื่น
น่าจะเป็นคำอธิบายระดับสูงที่ดีที่สุดที่ฉันได้พบสำหรับการโทรหางโทรหางซ้ำและการเพิ่มประสิทธิภาพการโทรหางเป็นโพสต์บล็อก
โดย Dan Sugalski ในการเพิ่มประสิทธิภาพการโทรหางเขาเขียน:
ลองพิจารณาฟังก์ชั่นง่าย ๆ สักครู่:
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
และการเรียกซ้ำหาง:
recursion หางขึ้นถ้าฟังก์ชั่นการดำเนินงานที่ผ่านมาของผลตอบแทนของการเรียกตัวเอง การเรียกคืนหางนั้นง่ายกว่าที่จะจัดการเพราะแทนที่จะกระโดดข้ามไปที่จุดเริ่มต้นของฟังก์ชั่นสุ่มบางแห่งคุณเพียงแค่ข้ามกลับไปที่จุดเริ่มต้นของตัวเองซึ่งเป็นสิ่งที่ง่ายมากที่ต้องทำ
ดังนั้นสิ่งนี้:
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
วิธีการเริ่มต้นหางเรียกไม่ได้รับการปรับปรุงหรือไม่
ตัวอย่างที่เรียกใช้งานได้น้อยที่สุดของ 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
เพราะการลงทะเบียนนี้เป็น callee บันทึก: สิ่งที่ลงทะเบียนจะถูกเก็บไว้ผ่านสายงานลินุกซ์ x86-64เพื่อ subcall เพื่อจะไม่เปลี่ยนมันและการสูญเสียfactorial
n
-foptimize-sibling-calls
ไม่ได้ใช้คำแนะนำใด ๆ ที่จะผลักดันไปยังกอง: มันเพียง แต่goto
กระโดดภายในfactorial
คำแนะนำและje
jne
ดังนั้นเวอร์ชันนี้เทียบเท่ากับ while loop โดยไม่มีการเรียกฟังก์ชันใด ๆ การใช้สแต็กคงที่
ทดสอบใน Ubuntu 18.10, GCC 8.2
ดูนี่:
http://tratt.net/laurie/tech_articles/articles/tail_call_optimization
อย่างที่คุณอาจจะรู้ว่าการเรียกใช้ฟังก์ชันแบบเรียกซ้ำสามารถสร้างความเสียหายให้กับกองได้ มันง่ายที่จะหมดพื้นที่สแต็คอย่างรวดเร็ว การเพิ่มประสิทธิภาพการโทรท้ายเป็นวิธีที่คุณสามารถสร้างอัลกอริทึมแบบเรียกซ้ำที่ใช้พื้นที่สแต็คคงที่ดังนั้นจึงไม่เติบโตและเติบโตและคุณได้รับข้อผิดพลาดสแต็ก
เราควรตรวจสอบให้แน่ใจว่าไม่มีคำสั่ง goto ในฟังก์ชั่นนั้น .. ได้รับการดูแลโดยการเรียกใช้ฟังก์ชั่นเป็นสิ่งสุดท้ายในฟังก์ชั่น callee
การเรียกซ้ำขนาดใหญ่สามารถใช้สิ่งนี้เพื่อการปรับให้เหมาะสมที่สุด แต่ในขนาดเล็กคำแนะนำค่าใช้จ่ายสำหรับการเรียกใช้ฟังก์ชันการโทรแบบหางจะช่วยลดวัตถุประสงค์ที่แท้จริง
TCO อาจทำให้เกิดการทำงานตลอดไป:
void eternity()
{
eternity();
}
ฟังก์ชั่นการเรียกซ้ำมีปัญหา มันสร้าง call stack ที่มีขนาด O (n) ซึ่งทำให้หน่วยความจำทั้งหมดของเรามีค่าใช้จ่าย O (n) สิ่งนี้ทำให้เสี่ยงต่อการเกิดข้อผิดพลาดของสแต็กโอเวอร์โฟลว์ที่สแต็กการโทรมีขนาดใหญ่เกินไปและไม่มีพื้นที่เหลือ
แบบแผน tail optimization optimization (TCO) ที่ซึ่งมันสามารถปรับฟังก์ชั่นการเรียกซ้ำเพื่อหลีกเลี่ยงการสร้าง call stack สูงและดังนั้นจึงประหยัดค่าใช้จ่ายหน่วยความจำ
มีหลายภาษาที่กำลังทำ 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;
}
}
}