Çok basit, kuyruk çağrı optimizasyonu nedir?
Daha spesifik olarak, neden açıklanabileceği, uygulanabileceği ve nerede uygulanmadığı bazı küçük kod parçacıkları nelerdir?
Çok basit, kuyruk çağrı optimizasyonu nedir?
Daha spesifik olarak, neden açıklanabileceği, uygulanabileceği ve nerede uygulanmadığı bazı küçük kod parçacıkları nelerdir?
Yanıtlar:
Kuyruk çağrısı optimizasyonu, bir işlev için yeni bir yığın çerçevesi ayırmaktan kaçınabileceğiniz yerdir, çünkü çağrı işlevi, çağrılan işlevden aldığı değeri döndürür. En yaygın kullanım, kuyruk çağrısı optimizasyonundan yararlanmak için yazılmış bir özyinelemeli fonksiyonun sabit yığın alanını kullanabileceği kuyruk özyineleme'dir.
Şema, herhangi bir uygulamanın bu optimizasyonu sağlaması gerektiğini (JavaScript de ES6 ile başlamaktadır) garanti eden birkaç programlama dilinden biridir , bu yüzden Scheme'deki faktöryel fonksiyonun iki örneği:
(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))
İlk işlev kuyruk özyinelemeli değildir, çünkü özyinelemeli çağrı yapıldığında, işlevin çağrı döndükten sonra sonuçla yapması gereken çarpımı izlemesi gerekir. Bu şekilde, yığın aşağıdaki gibi görünür:
(fact 3)
(* 3 (fact 2))
(* 3 (* 2 (fact 1)))
(* 3 (* 2 (* 1 (fact 0))))
(* 3 (* 2 (* 1 1)))
(* 3 (* 2 1))
(* 3 2)
6
Buna karşılık, kuyruk özyinelemeli faktöriyör için yığın izlemesi aşağıdaki gibi görünür:
(fact 3)
(fact-tail 3 1)
(fact-tail 2 3)
(fact-tail 1 6)
(fact-tail 0 6)
6
Gördüğünüz gibi, her gerçek-tail çağrısı için aynı miktarda veriyi takip etmemiz gerekiyor, çünkü sadece elde ettiğimiz değeri en üste döndürüyoruz. Bu, (aslında 1000000) arayacak olsam bile, (olgu 3) ile aynı miktarda alana ihtiyacım olduğu anlamına gelir. Kuyruk özyinelemeli olmayan gerçekte durum böyle değildir ve bu nedenle büyük değerler bir yığın taşmasına neden olabilir.
Basit bir örneği inceleyelim: C'de uygulanan faktöriyel fonksiyon.
Açık özyinelemeli tanımla başlıyoruz
unsigned fac(unsigned n)
{
if (n < 2) return 1;
return n * fac(n - 1);
}
İşlev dönmeden önceki son işlem başka bir işlev çağrısı ise, bir işlev kuyruk çağrısıyla biter. Bu çağrı aynı işlevi çağırırsa, kuyruk özyinelemelidir.
Olsa fac()
ilk bakışta görünüyor kuyruk özyinelemeli aslında ne olur gibi, değil mi
unsigned fac(unsigned n)
{
if (n < 2) return 1;
unsigned acc = fac(n - 1);
return n * acc;
}
yani son işlem işlev çağrısı değil çarpma işlemidir.
Bununla birlikte, fac()
biriken değeri ek bir argüman olarak çağrı zincirinden geçirerek ve yalnızca nihai sonucu tekrar dönüş değeri olarak ileterek kuyruk yinelemeli olarak yeniden yazmak mümkündür :
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);
}
Şimdi, bu neden faydalı? Kuyruk çağrısından hemen sonra geri döndüğümüz için, işlevi kuyruk konumunda çağırmadan önce önceki yığın çerçevesini atabiliriz veya yinelemeli işlevler durumunda yığın çerçevesini olduğu gibi yeniden kullanabiliriz.
Kuyruk çağrısı optimizasyonu özyinelemeli kodumuzu
unsigned fac_tailrec(unsigned acc, unsigned n)
{
TOP:
if (n < 2) return acc;
acc = n * acc;
n = n - 1;
goto TOP;
}
Bu içine girilebilir fac()
ve biz
unsigned fac(unsigned n)
{
unsigned acc = 1;
TOP:
if (n < 2) return acc;
acc = n * acc;
n = n - 1;
goto TOP;
}
eşdeğer
unsigned fac(unsigned n)
{
unsigned acc = 1;
for (; n > 1; --n)
acc *= n;
return acc;
}
Burada görebileceğimiz gibi, yeterince gelişmiş bir iyileştirici kuyruk yinelemesini yineleme ile değiştirebilir, bu da işlev çağrısı yükünü önlemek ve yalnızca sabit miktarda yığın alanı kullanmaktan çok daha verimlidir.
TCO (Kuyruk Çağrısı Optimizasyonu), akıllı bir derleyicinin bir işlevi çağırması ve ek yığın alanı almaması işlemidir. Bunun gerçekleşebileceği tek durum, bir f işlevinde yürütülen son komutun , g işlevine bir çağrı olmasıdır (Not: g , f olabilir ). Burada anahtar olmasıdır f basitçe çağırır - artık ihtiyaçları değerlendirmeleri yığın alanı g ve ne olursa o zaman döner gr dönecekti. Bu durumda, g'nin çalıştığı ve f olarak adlandırılan şeye sahip olması gereken değeri döndürdüğü optimizasyon yapılabilir.
Bu optimizasyon, yinelemeli çağrıların patlamak yerine sabit yığın alanı almasını sağlayabilir.
Örnek: bu faktöriyel fonksiyon TCOptimizable değildir:
def fact(n):
if n == 0:
return 1
return n * fact(n-1)
Bu işlev, return deyiminde başka bir işlevi çağırmanın yanı sıra bazı şeyler yapar.
Aşağıdaki fonksiyon 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)
Bunun nedeni, bu işlevlerin herhangi birinde gerçekleşen son şeyin başka bir işlevi çağırmak olmasıdır.
Muhtemelen en iyi üst düzey açıklama kuyruk çağrıları, özyinelemeli kuyruk çağrıları ve kuyruk çağrı optimizasyonu için buldum blog yazısı
"Lanet olsun: Bir kuyruk çağrısı"
Dan Sugalski tarafından. Kuyruk arama optimizasyonunda şunları yazıyor:
Bir an için bu basit işlevi düşünün:
sub foo (int a) { a += 15; return bar(a); }
Peki, ya da daha çok dil derleyiciniz ne yapabilirsiniz? Yapabileceği şey, formun kodunu
return somefunc();
düşük seviyeli diziye dönüştürmektirpop stack frame; goto somefunc();
. Örneğimizde, biz buna vasıta öncebar
,foo
kendisini temizler ve sonra yerine çağırmaktan dahabar
bir alt rutin olarak, bir alt düzey yapmakgoto
başlangıcına çalışmasınıbar
.Foo
'Şimdiye kadar ne zaman, yığının kendini temizlenmiş sbar
görünüyor başlar gibi denilen kimfoo
gerçekten çağırdıbar
ve ne zamanbar
onun değerini verir, doğrudan denilen her kim döndürürfoo
yerine getirmem dahafoo
sonra onun arayana geri hangi.
Ve kuyruk özyinelemesinde:
Kuyruk özyineleme, bir işlev son işlemi olarak çağrının sonucunu döndürürse gerçekleşir . Kuyruk özyineleme ile başa çıkmak daha kolaydır, çünkü bir yerlerde rastgele bir işlevin başlangıcına atlamak yerine, kendinizin başına geri dönersiniz, bu yapılması basit bir şeydir.
Böylece bu:
sub foo (int a, int b) { if (b == 1) { return a; } else { return foo(a*a + a, b - 1); }
sessizce dönüşür:
sub foo (int a, int b) { label: if (b == 1) { return a; } else { a = a*a + a; b = b - 1; goto label; }
Bu açıklama hakkında ne gibi bir zorunluluk dil arka plan (C, C ++, Java) gelenler için kavramak ne kadar özlü ve kolay olduğunu
foo
fonksiyon kuyruk çağrısı optimize değil mi? Bir işlevi yalnızca son adımı olarak çağırıyor ve sadece bu değeri döndürüyor, değil mi?
Öncelikle tüm diller tarafından desteklenmediğini unutmayın.
TCO özel bir özyineleme davasına başvurur. Bunun bir amacı, bir işlevde yaptığınız son şey kendini çağırmaksa (örneğin, kendisini "kuyruk" konumundan çağırıyorsa), bu derleyici tarafından standart özyineleme yerine yineleme gibi davranmak için optimize edilebilir.
Gördüğünüz gibi, normalde özyineleme sırasında, çalışma zamanının tüm özyinelemeli çağrıları takip etmesi gerekir, böylece bir geri döndüğünde önceki çağrıda devam edebilir vb. (Bunun nasıl çalıştığına dair görsel bir fikir edinmek için özyinelemeli bir çağrının sonucunu el ile yazmayı deneyin.) Tüm çağrıları takip etmek alan kaplar, bu da işlev kendini çok çağırdığında önemli hale gelir. Ancak TCO ile, sadece "başlangıca geri dön, sadece bu sefer parametre değerlerini bu yenileriyle değiştir" diyebilir. Bunu yapabilir, çünkü özyinelemeli çağrıdan sonra hiçbir şey bu değerleri ifade etmez.
foo
yöntem kuyruk çağrısı optimize edilmemiş mi?
X86 sökme analizi ile GCC minimal çalıştırılabilir örnek
Oluşturulan montaja bakarak GCC'nin bizim için otomatik olarak nasıl kuyruk çağrısı optimizasyonu yapabileceğini görelim.
Bu, https://stackoverflow.com/a/9814654/895245 gibi diğer yanıtlarda bahsedilenlerin , optimizasyonun özyinelemeli işlev çağrılarını bir döngüye dönüştürebileceği konusunda son derece somut bir örnek olacaktır .
Bellek erişimi çoğu zaman günümüzde programları yavaşlatan ana şey olduğundan , bu da bellek tasarrufu sağlar ve performansı artırır .
Bir girdi olarak, GCC'ye optimize edilmemiş saf yığın tabanlı bir faktöriyel veriyoruz:
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;
}
Derleyin ve sökün:
gcc -O1 -foptimize-sibling-calls -ggdb3 -std=c99 -Wall -Wextra -Wpedantic \
-o tail_call.out tail_call.c
objdump -d tail_call.out
nereye -foptimize-sibling-calls
göre kuyruk aramaların genelleme adıdır man gcc
:
-foptimize-sibling-calls
Optimize sibling and tail recursive calls.
Enabled at levels -O2, -O3, -Os.
belirtildiği gibi: gcc'nin kuyruk özyineleme optimizasyonu yapıp yapmadığını nasıl kontrol edebilirim?
Ben seçiyorum -O1
çünkü:
-O0
. Bunun gerekli ara dönüşümlerin eksik olmasından kaynaklandığından şüpheleniyorum.-O3
kuyruğu da optimize edilmiş olmasına rağmen çok eğitici olmayacak ungodly verimli kod üretir.İle sökme -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
İle -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
İkisi arasındaki en önemli fark şudur:
-fno-optimize-sibling-calls
kullanımları callq
tipik olmayan optimize işlev çağrısı,.
Bu talimat, dönüş adresini yığına doğru iter, dolayısıyla onu artırır.
Bundan başka, bu versiyonu da yok push %rbx
ki, iter %rbx
yığına .
GCC bunu yapar edi
, çünkü ilk işlev argümanı ( n
) olan depolar ebx
, sonra çağırır factorial
.
GCC'nin bunu yapması gerekiyor, çünkü factorial
yeniyi kullanacak başka bir çağrıya hazırlanıyor edi == n-1
.
ebx
Bu kayıt callee-kaydedilmiş olduğu için seçer : Bir linux x86-64 işlev çağrısı ile hangi kayıtlar korunur, böylece alt aramafactorial
onu değiştirmez ve kaybetmez n
.
-foptimize-sibling-calls
yığınına itme hiçbir talimat kullanmaz: sadece does goto
içinde atlar factorial
talimatlar je
ve jne
.
Bu nedenle, bu sürüm herhangi bir işlev çağrısı olmadan while döngüsüne denktir. Yığın kullanımı sabittir.
Ubuntu 18.10, GCC 8.2'de test edilmiştir.
Buraya bak:
http://tratt.net/laurie/tech_articles/articles/tail_call_optimization
Muhtemelen bildiğiniz gibi, özyinelemeli işlev çağrıları bir yığına zarar verebilir; yığın yerinin hızla tükenmesi kolaydır. Kuyruk çağrısı optimizasyonu, sabit yığın alanı kullanan özyinelemeli bir stil algoritması oluşturmanın bir yoludur, bu nedenle büyümez ve büyümez ve yığın hataları alırsınız.
Fonksiyonun kendisinde hiçbir goto ifadesi olmadığından emin olmalıyız .. callee fonksiyonundaki son şey olan fonksiyon çağrısı ile ilgilenilir.
Büyük ölçekli özyinelemeler bunu optimizasyonlar için kullanabilir, ancak küçük ölçekte, bir işlev çağrısının kuyruk çağrısı yapmasına yönelik talimat yükü gerçek amacı azaltır.
TCO sonsuza dek çalışan bir işleve neden olabilir:
void eternity()
{
eternity();
}
Özyinelemeli işlev yaklaşımının bir sorunu vardır. Toplam bellek maliyetimizi O (n) yapan O (n) boyutunda bir çağrı yığını oluşturur. Bu, çağrı yığınının çok büyük hale geldiği ve alan bittiği bir yığın taşma hatasına karşı savunmasız hale getirir.
Kuyruk çağrısı optimizasyonu (TCO) şeması. Uzun bir çağrı yığını oluşturmaktan kaçınmak için özyinelemeli işlevleri optimize edebildiği ve dolayısıyla bellek maliyetinden tasarruf ettiği yerlerde.
TCO gibi birçok dil var (JavaScript, Ruby ve az C), Python ve Java ise TCO yapmıyor.
JavaScript dili :) http://2ality.com/2015/06/tail-call-optimization.html kullanılarak onaylandı
İşlevsel bir dilde, kuyruk çağrısı optimizasyonu, bir işlev çağrısı, sonuç olarak kısmen değerlendirilen bir ifadeyi döndürebilir, bu da daha sonra arayan tarafından değerlendirilir.
f x = g x
f 6, g 6'ya indirgenir. Dolayısıyla, uygulama sonuç olarak g 6'yı döndürürse ve o ifadeyi çağırırsa, bir yığın çerçevesini kaydeder.
Ayrıca
f x = if c x then g x else h x.
F 6'yı g 6 veya h 6'ya düşürür. Dolayısıyla, uygulama c 6'yı değerlendirir ve doğru olduğunu tespit ederse,
if true then g x else h x ---> g x
f x ---> h x
Basit bir kuyruksuz arama optimizasyonu yorumlayıcısı şöyle görünebilir,
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();
}
}
}
Kuyruk çağrısı optimizasyon yorumlayıcısı şöyle görünebilir,
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;
}
}
}