2GB/s Üzeri protobuf Ayrıştırma: C'de tail call kullanan yüksek hızlı yorumlayıcı tasarımı (2021)
(blog.reverberate.org)- Clang'e
musttaileklendikten sonra C ailesi dillerde garantili tail call kullanmak mümkün hale geldi ve bu, protobuf ayrıştırıcısına uygulanarak 2GB/s üzeri performans gösterildi - Temel fikir, fonksiyon çağrılarını
callyerinejmp'ye daha yakın hale getirerek art arda çağrılarda yığın kullanımını O(n)'den O(1)'e düşürmek ve bunu tekrar döngüsü gibi ele almak - protobuf wire format, etiket/değerleri yorumlayıp rastgele sıradaki alanlara dallanmak zorunda olduğundan, geleneksel
while+switchyapısı yorumlayıcı opcode dispatch ile benzer optimizasyon sorunlarına sahip - upb'nin deneysel ayrıştırıcısı, tek büyük bir fonksiyon yerine küçük ayrıştırıcı fonksiyonları tail call ile birbirine bağlayarak hızlı yolda yığın kullanımı, register spill, prolog/epilog maliyetlerinden kaçınıyor
- Bu yaklaşımda non-tail call karıştığında kod kalitesi ciddi biçimde düşüyor ve
musttailstandart dışı bir uzantı olduğu için, hızlı ayrıştırıcıyı gerçek dağıtımda kullanmak adına çağrı disiplini ve taşınabilirlik önlemleri gerekiyor
Clang musttail ile açılan yüksek hızlı protobuf ayrıştırma
- Clang main dalına
[[clang::musttail]]/__attribute__((musttail))ifade özniteliği eklendi; böylece C, C++ ve Objective-C'de tail call garantisi alınabiliyor - Burada tail call, fonksiyonel programlama tekniği olarak değil, ayrıştırıcı ve yorumlayıcılarda dallanma maliyetini azaltan bir optimizasyon aracı olarak kullanılıyor
- Bu teknik protobuf ayrıştırmaya uygulandığında
upbpull/310 içinde 2GB/s üzeri ayrıştırma performansı gösterildi- Bunun, önceki en iyi seviyeden iki kattan fazla hızlı olduğu belirtiliyor
- Birden fazla teknik birlikte katkı sağladığı için “yalnızca tail call ile 2 kat hızlandı” demek doğru değil
- tail call, bu performans artışını mümkün kılan temel unsurlardan biri
- Sonraki değişiklikler A Tail Calling Interpreter For Python (And Other Updates) yazısında ele alınıyor
Tail call neden tekrar yapısı gibi çalışıyor?
- tail call, bir fonksiyonun döndürmeden hemen önce yaptığı son fonksiyon çağrısıdır
- Tail call optimizasyonu uygulandığında derleyici, normal
callyerinejmpkomutu üretir- Yeni yığın çerçevesi oluşturma ve dönüş adresi saklama adımları atlanır
- Çağıran
f(), çağrılang()'ye doğrudan atlar g(), doğrudanf()yi çağırmış olan fonksiyona döner
- Bu özellik sayesinde tail call, tekrar yapısının yerini alabilir
- Art arda gelen
nadet tail call durumunda bile yığın kullanımı O(n)'den O(1)'e düşer callek yükü ortadan kalktığı için fonksiyon çağrıları normal dallanma gibi ele alınabilir
- Art arda gelen
- Bu fikir yeni değil; kökeni Guy Steele'in 1977 tarihli makalesine ve 1975~1980 arasındaki “Lambda Papers” çalışmalarına kadar gidiyor
- Clang zaten
-O2gibi optimize derlemelerde tail call'u optimize edebiliyordu, ancak önceki davranış best-effort niteliğindeydi- Optimize edilmemiş derlemelerde bunun gerçek
callolarak derlenmesi oldukça mümkündü - Tail call'u tekrar yapısı olarak güvenle kullanmak için tüm derleme modlarında optimizasyon garantisi gerekir
musttail, bu garantiyi sağlar
- Optimize edilmemiş derlemelerde bunun gerçek
Yorumlayıcı döngüsü ile protobuf ayrıştırıcısının paylaştığı darboğaz
- LuaJIT'ten Mike Pall, LuaJIT 2.x yorumlayıcısını C ile değil assembly ile yazdı ve bunu hızlı yorumlayıcının başlıca nedeni olarak gördü
- C derleyicileri özellikle yorumlayıcının ana döngüsünde iki temel sorun yaşar
- Fonksiyon büyüyüp kontrol akışı karmaşıklaştıkça register allocator, önemli verileri register'larda tutmakta zorlanır
- Hızlı yol ile yavaş yol aynı fonksiyon içinde karıştığında, yavaş yol hızlı yolun kod kalitesini de düşürür
- protobuf wire format da yorumlayıcıya benzer bir yapı taşır
- wire format, etiket/değer çiftleri dizisinden oluşur
- Etiket, alan numarası ile wire type bilgisini içerir
- Etiket, ilgili alan verisinin nasıl ayrıştırılacağını belirleyen bir opcode gibi davranır
- Alan numaraları rastgele sırada gelebileceği için kodun her yerine dispatch yapmaya hazır olmak gerekir
- Geleneksel protobuf ayrıştırıcıları genellikle
whiledöngüsü içindeswitchkullanan bir yapıya sahipti ve protobuf'un var olduğu sürenin büyük bölümünde bu yaklaşım en iyi seviye olarak kullanıldı - Gerçek ayrıştırma sırasında wire type uyumsuzluğu, bozuk veri, buffer sonuna ulaşma gibi istisnalar neredeyse her aşamada ortaya çıkabilir
- Hızlı yol mümkün olduğunca kısa ve kararlı tutulmalı
- Zor durumlar için daha büyük ve karmaşık fallback kodu, bazen de out-of-line fonksiyon çağrıları gerekir
Tail call tabanlı upb ayrıştırıcı tasarımı
- upb'nin deneysel ayrıştırıcısı, tek büyük bir ayrıştırma fonksiyonu kullanmak yerine her işlemi küçük bir fonksiyona ayırıyor
- Her fonksiyon sonraki işlemi tail call ile çağırıyor
- x86-64 çağrı kuralı sayesinde ortak ayrıştırma argümanları register'lar üzerinden taşınıyor
- Tüm ayrıştırma fonksiyonları aynı argüman kümesini kullandığı için çağrılar arasında değer taşıma azaltılıyor
- Örnekteki 4 bayt sabit genişlikli alan ayrıştırıcı fonksiyonu şu akışla çalışıyor
dataiçinden alan bilgisini decode ediyor- wire type uyuşmazsa
fallback()fonksiyonunaMUSTTAIL returnediyor - Etiketi atlayıp veriyi mesaja yazıyor
- Sonraki etiketi okuyup uygun alan ayrıştırıcısına dallanan
dispatch()fonksiyonuna tail call yapıyor
- Clang'in ürettiği assembly, hızlı yolda prolog, epilog, register spill ve yığın kullanımı içermiyor
- Bitiş noktaları yalnızca
fallbackveyadispatchiçin kullanılanjmp - Argümanlar zaten doğru register'larda olduğu için ayrıca parametre taşıma kodu da gerekmiyor
- Bitiş noktaları yalnızca
- Bu yapı, büyük yorumlayıcı döngüsünü kavramsal olarak tek karmaşık bir fonksiyon gibi görse de, gerçek uygulamada bunu temel blok düzeyindeki fonksiyonlara bölüp kontrol akışını tail call ile aktarıyor
- Hızlı yol ve yavaş yolu ayrı fonksiyonlara ayırmak, fallback kodundaki değişikliklerin hızlı yolun kod kalitesini bozma ihtimalini azaltıyor
- Gerekirse
noinlineile inline edilmesi engellenebilir - Hızlı yolun assembly dizisi fiilen sabitlenebilir
- Gerekirse
LuaJIT örneğinde görülen C kod üretim kalitesi
- Aynı desen LuaJIT örneğine de uygulandığında, elle yazılmış assembly'ye yakın sonuçlar C koduyla elde edilebiliyor
- Örnek
ADDVNfonksiyonu şu işlemleri yapıyor- Komuttan register ve sabit indekslerini çıkarıyor
- Tür kontrolü başarısız olursa fallback'e gidiyor
- Register değerine sabiti ekliyor
- Sonraki opcode'u okuyup opcode tablosundaki fonksiyona tail call yapıyor
- Üretilen assembly'de kalan iyileştirme noktaları görece küçük
- Koşullu dallanmadan sonra ek bir
jmpoluşuyor jmp qword ptr [rsi + 8*rax]yerine, öncerax'e yükleyip ardındanjmp raxkullanılıyor
- Koşullu dallanmadan sonra ek bir
- Bunlar, Clang tarafında iyileştirilebilecek küçük kod üretim sorunları olarak değerlendiriliyor
non-tail call ve taşınabilirlik kısıtları
- Bu yaklaşımın en büyük dikkat noktası, fonksiyon içine non-tail call girdiğinde assembly kalitesinin ciddi biçimde bozulması
- Tek bir non-tail call bile yığın çerçevesi oluşturulmasını zorunlu kılar
- Çok sayıda veri yığına spill edilebilir
- Bundan kaçınmak için diğer fonksiyon çağrılarını ya inline etmek ya da yalnızca tail call ile yapmak gerekir
- protobuf ayrıştırmada varint işleme bunun tipik zorluklarından biri
- Yaygın ve hızlı durum 1 baytlık varint
- Daha uzun varint'ler hata değildir ama daha az yaygındır
- Bu istisna işlemini inline etmek, hızlı yolun kod kalitesini düşürebilir
fallbackfonksiyonuna tail call yapılırsa, işleme sonrasında özgün işlemi kolayca sürdürmek mümkün olmaz; bu yüzden fallback işlemi sonuna kadar bitirmelidir- Bunun sonucunda kod tekrarları ve karmaşıklık oluşur
- 2025-01-27 güncellemesinde, bu sorunu çağrı kuralı üzerinden hafifletmenin bir yolu eklendi
__attribute__((preserve_most)), fallback fonksiyonlarında kullanılabilen bir çağrı kuralıdır; neredeyse tüm register koruma yükünü callee'ye taşıyarak spill maliyetini fallback tarafına iter- Bu öznitelikle ilgili Clang çökme hatası 2023'te düzeltildi
__attribute__((preserve_none)), tail-calling yapan fonksiyonlarda kullanılabilen bir çağrı kuralıdır; register koruma yükünü ortadan kaldırır ve argümanlar için daha fazla register kullanılmasını sağlar- İki seçenek arasında
preserve_none, daha az müdahaleci olduğu için daha iyi tercih olarak değerlendiriliyor
- Bir diğer kısıt da
musttailin standart dışı derleyici uzantısı olması- Bunun GCC, Visual C++ gibi derleyicilere yayılması ve standartlaşması umuluyor, ancak yakın vadede beklenmiyor
musttailolmadığında, kavramsal döngünün her tekrarında en az bir gerçekreturngerekir- upb'de bu fallback henüz uygulanmış değil ve
musttailkullanılabilirliğine göre dispatch'e tail call yapan ya da normal dönen bir makroya ihtiyaç duyulacağı öngörülüyor
upb'deki uygulama durumu ve genişleme potansiyeli
- 2GB/s üzeri ayrıştırıcı, C ile yazılmış küçük bir protobuf kütüphanesi olan
upb'ye gönderildi - Söz konusu kod tamamen çalışıyor ve tüm protobuf conformance testlerini geçiyor, ancak yazıldığı dönem itibarıyla henüz hiçbir yerde rollout edilmemişti
- protobuf'un C++ sürümünde bu tasarım uygulanmış değil
- Daha sonra
upbninmusttailkullanacak şekilde güncellenmesi ile hızlı ayrıştırıcının üretime alınmasının önündeki büyük engellerden biri kaldırıldı - Aynı teknik, C ile yazılmış başlıca dil yorumlayıcıları olan Python, Ruby, PHP ve Lua gibi projelerde de kayda değer performans avantajları sağlayabilir
1 yorum
Hacker News görüşleri
C standart önerisinde tail call için bir sözdizimi var; biçimi
return goto (expression);Standart
[[musttail]]e göre hoşuma giden yanı, yerel nesnelerin ömrünün sona ermesinin garanti edilmesi. Böylece kapsamlı escape analysis olmadan da uygulanabilir hale geliyor[0] https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3266.htm#5...
return gotonun neden daha kolay uygulanabildiğini merak ediyorum.[[musttail]]de görünüşe göre yerel nesne ömürlerini sona erdiriyorHızlıca göz atınca, tail konumunda çağrılan fonksiyonun çağrı hedefiyle aynı tipte olması gerektiği yazıyor. Bu, dönüş değeri dönüşümüne gerek kalmaması ve argüman geçirme alanı ile çağrı kuralının korunmasının garanti edilmesi için konmuş bir koşul
Clang’e uyguladığım
[[musttail]]hakkında sık gördüğüm şikâyet, bu kısıtlamanın gereksiz yere katı olduğu yönündeydi. Bazı mimariler, tipler tamamen eşleşmese bile tail call’a izin veriyor: https://github.com/llvm/llvm-project/issues/54964“O zaman kod taşınabilir olmaz” demek doğru, ama tail call optimizasyonunun kendisi zaten doğası gereği taşınabilir değil. Örneğin tail call genişletmesi olmayan WASM gibi bazı hedefler, temelde tail call optimizasyonunu desteklemiyor
Mutlaka girmesi gereken değişiklikler ve eklemeler, hatta netleştirilmesi gereken fikirler olduğu için heyecan verici; ama C++’ın agresif güncelleme döngüsü sonunda yamanın üstüne yama eklenmiş bir yapıya dönüşmüş gibi görünüyor
Özellikle özellikler beklenenden çok daha erken kötü biçimde etkileşime girdiğinde sorun oluyor. Standartlaştırma sürecinin yalnızca gerekçe belgelerine dayanmak yerine, büyük ve çeşitli kod tabanlarında özellikleri yeterince test ederek çok muhafazakâr seçimler yapmasını umuyorum
Rust tarafıyla ilgileniyorsanız, garantili tail call optimizasyonu sağlayan
becomeanahtar sözcüğünü eklemeyi amaçlayan eski bir RFC varBaşta 2018 edition hedeflerine odaklanmak için ertelenmişti ve bu doğru karardı; ancak son dönemde bu fikir yeniden değerlendirildi. Geri gelebilir
[0]: https://github.com/rust-lang/rfcs/pull/1888
[1]: https://github.com/rust-lang/rfcs/pull/3407
Yorumlayıcıların C++’ta genelde bu tür hız artışlarını elde etme yolu computed goto kullanmaktır. Böylece bir opcode’dan sonraki opcode’a giden yolda çağrı kuralıyla ilgili gürültü olmaz
Computed goto yaklaşımının veya tail call yaklaşımının klasik
switchdöngüsünden hızlı olmasının ana nedeni, branch predictor yükünü azaltmasıdır. Statik olarak her opcode başına bir dolaylı dal oluşur; statik olarak yalnızca tek bir dolaylı dalın olduğu bir yapı olmaktan çıkarHer fonksiyon küçükse ve önemli değişkenleri argüman olarak alıyorsa register allocation çok daha az kırılgan hale gelir
Yine de yorumlayıcı büyüdükçe bunun hâlâ geçerli olup olmadığını merak ediyorum
Bağlam değiştirme için tail call kullanmada kalan sorun, çağrı kuralı kullanmak zorunda olan fonksiyonları kullanmanız. Ne yazık ki fonksiyon çıkışında durumu geri yüklemek için register’lar boşa harcanıyor
Ayrıntılı analiz ve ara derleyici kullanan alternatif LuaJIT remake blogunda var: https://sillycross.github.io/2022/11/22/2022-11-22/
Bilgisayar bilimindeki diğer her şey gibi, işlem türlerine göre maliyet dengesi değiştiğinde en iyi algoritma 15, 20 yıl önce kullanılan yönteme geri dönebilir. Bu yüzden programlama büyük ölçüde moda gibi görünüyor. Bir şeyi yeniden canlandırmanın nedensiz olduğu anlamına gelmez; ama geçen sefer neden her derde deva olmadığını unutmak hâlâ sorun
Ana JIT hızlanır veya yavaşlarsa, çalıştırma maliyetine karşı elde edilen fayda değişir ve bunu tetikleyen eşikler de ayarlanır. O zaman farklı katmanda çalışan kod miktarı değişir ve o katmanın amortize maliyeti de kötüleşebilir. Çift sarkaç dengelemesi gibi
JIT katmanını yeterince hızlı ve kaba taslak hale getirebilirseniz yorumlayıcıyı tamamen atlayabilirsiniz. Dışarıdan bakınca, yorumlayıcı ile iki kadar JIT arasında hesap tutmanın bilişsel yükü yüksek olduğundan, bazı diller yorumlayıcıyı askıya alıp çıktı hızından çok derleme süresine optimize edilmiş bir JIT kullanmış gibi görünüyor
Hangi dil olduğunu hatırlamıyorum ama bildiğim kadarıyla en az bir ekip bu denge sorunu yüzünden ara derleyiciyi de sonunda kaldırmıştı. Üçüyle uğraşmaktansa ikiye odaklanmak daha iyi olmuştu
Adı her seferinde kafamı karıştırıyor;
preserve_allya dapreserve_noneolmalı. Sorun, koruma bakış açısının kimin açısından olduğumusttailözniteliğinin GCC'ye eklenmekte olduğunu biliyorum. Yama inceleme aşamasında ve semantiği Clang ile uyumlu.preserve_mostözniteliğinin ne olacağını merak ediyorum. Benzer bir şeyin GCC'ye girme ihtimali var mı? Bu olmadan kuyruk çağrısı olmayan çağrılar yorumlayıcıyı mahvediyor.Clang,
musttailçağrıları için çağrı dizisini değiştiren sezgisel kurallara sahip gibi görünüyor. Örneğin i686'da bununopltçağrısına çeviriyor. Bunlar Clang belgelerinde yok: https://clang.llvm.org/docs/AttributeReference.html#musttailPratikte mümkün olan, derleyicinin kuyruk çağrısı üretemediğinde bir tanı mesajı vermesiyle sınırlı. Birçok kullanıcı için muhtemelen bu kadarı yeterli olur. Scheme'deki gibi kuyruk çağrılarını garanti etmek pek olacak gibi görünmüyor.
C++ desteğinden de söz ediliyor, ama C++'ta kuyruk çağrılarının çok az olacağını düşünüyorum.
Örneğin
foo() { auto a = SomeClassWithADestructor(); return bar(); }bir kuyruk çağrısı değildir, çünkübar()çağrısından sonraayok edilir.barçalıştırılmadan önce yıkıcıyı çağırması mümkün olmaz mı?C++ standardının yıkıcının mutlaka blok sonunda çağrılmasını mı söylediğini, yoksa değişken artık kullanılmadığı anda çağrılmasına izin verip vermediğini merak ediyorum.
Örnek fazla basit olabilir, ama iyi kod üretimi için
__attribute__((musttail))şart gibi görünmüyor.Hata işleme fonksiyonu nadir bir yolsa, çağrı hızı da pek önemli olmayacaktır.
if (unlikely(malformed)) return error(); switch (data_type) { case x: return handle_x(); case y: return handle_y(); }gibi bir yapı, oldukça güvenilir biçimde iyi bir jump table üretiyor gibi görünüyor.Aksi halde bu yapı çalışmaz ve yığın hemen patlar.
[[musttail]]in amacı, kuyruk çağrısı eliminasyonunun zorunlu olmasıdır. Derleyicinin başka seçeneği yoktur.Elbette “zorlar” ifadesi tam doğru olmayabilir. Derleyicinin fonksiyonun tüm yürütme yollarında tek bir stack frame yapısına sahip olması gerektiği belirlenmiş değil; adresi alınmayan iç bağlantılı fonksiyonlar ya da anonim namespace fonksiyonları için standart ABI kullanması gerektiği de belirlenmiş değil. Ama Clang dahil gördüğüm tüm derleyiciler pratikte böyle yapıyor. Bu yüzden ABI için endişelenmemesini ve çağrılar arasında register korumaya zaman harcamamasını derleyiciye söylemenin bir yoluna ihtiyaç var.
Jump table elbette iyi üretiliyor. Ama ortaya çıkan kodu
perf reportgibi bir şeyle çalıştırır ve test bytecode'u kısa bir döngüyü temsil etmiyorsa iki şeyden birini görürsünüz: Her dispatch'te branch prediction miss vardır ya da derleyici “yorumlayıcı yazmaya çalışıyor gibisin” deyip dolaylı jump'ı her case'in sonuna taşır. Clang'de böyle bir şey gördüm. Her iki durumda da ortaya çıkan kodun register tahsisi büyük olasılıkla genel olarak berbat olacaktır.Trampoline, yani sonraki fonksiyonu bir fonksiyon pointer'ı olarak döndürüp dış döngüde çağırma yöntemiyle ne kadar hızlı olacağını merak ediyorum. Avantajı taşınabilir C olması.
Scheme programlama dili, tüm kuyruk çağrılarının stack'i büyütmemesini gerektirir. Bu yüzden implementasyon geliştiricileri trampoline dahil çeşitli teknikleri araştırdı.
Alıntı yapacak kaynağım yok, ama Scheme'i C'ye derleyen makalelerde cevabı bulabilirsiniz. Hedef dilde kuyruk çağrısı optimizasyonu garantisi yoksa üretilen program yavaşlar.
Ayrıca özellikle üst düzey dil implementasyonu yapanların JavaScript spesifikasyonundan kuyruk çağrısı optimizasyonunun kaldırılmasından şikâyet etmesinin nedeni de bu. Hem kuyruk çağrısı optimizasyonunu hem de stack kontrolünü koruyan çözümler de var.
https://github.com/schemedoc/bibliography/blob/master/page8....
Fonksiyon pointer'ıyla jump yaparsanız muhtemelen bu kadar öngörülebilir olmaz ve aynı kazancı elde etmek zorlaşır.
Tabii ölçmek gerekir; ben henüz ölçmedim.
C ile Protobuf decoder/encoder’ı, IML parser’ı ve Python binding’leri yazdım; parsing hızını ölçme konusunda söyleyeceklerim var.
Bu kütüphane yalnızca yönetilen diller için binding olarak sunuluyorsa, performans açısından diğer her şeyi gölgede bırakan ek bir değişken ortaya çıkar. Ruby veya PHP için bilemem, ama Python’da enumerator kullanmadığımda dramatik hız artışları gördüm. Protobuf enumerator’larını Python enum’larına dönüştürmek, C kodunda elde edilebilecek her türlü kazanımı çeşitli Python nesneleri oluşturma süresinin altında ezer. Fark birkaç basamak mertebesindedir. Daha da ileri gidip tüm yardımcı veri yapılarını C’de uygulayıp Python’a yalnızca minimal bir arayüz açmak da mümkün. Böyle bir karşılaştırmanın Python’ın yerleşik yapılarını kullanan kodlara karşı ne kadar adil olduğu ise yanıtlaması zor bir soru.
Google’ın Python için Protobuf parser’ı hâlâ 2GB/s üzerindekinden “daha hızlı” olabilir. Çünkü en üst düzey mesaj dışında hiçbir şeyi parse etmez. Mesajın iç yapısı gerektiğinde parse edilir. Kod, parse edilen içeriğin tamamını hemen okuyorsa muhtemelen 2GB/s’den yavaştır; ancak mesele, bu iki yaklaşımın pratikte nasıl karşılaştırılabileceğidir. Uygulamanın niteliğine göre gerçek sonuçlar değiştiğinden net bir yanıt yoktur.
Genel durumda Protobuf parsing, yinelenen işleme nedeniyle streaming yapılamaz. Pratikte Protobuf içeriğini parse eden kod I/O darboğazına takılır. Çünkü parsing’e başlamadan önce mesajın sonunu beklemek gerekir. Ayrı bir konu olarak, uygulamanın tipik Protobuf mesajlarına bağlı olarak parsing’i paralelleştirmek mümkün olabilir; bu da büyük olasılıkla tek iş parçacıklı parser’ların çoğunu geride bırakır. Ancak önceki örneklerde olduğu gibi, bunun genel olarak kazanan strateji olduğu söylenemez.
Genellikle parsing ile domain object oluşturmayı birleştirmek çok daha verimlidir. Uygulamalar neredeyse her zaman bu aşamadan geçmek zorundadır. Parser’dan bu işlevselliğe nasıl erişilebildiği, birçok durumda hangi parser’ın kazanacağını belirler.
Sonuç olarak Protobuf, hatta belki genel olarak parser’lar, hız ölçümü ve karşılaştırma hedefi olarak iyi değildir. Fazla düşük seviyelidir ve tasarımı da performans benchmark’ları için ölçüt alınamayacak kadar kötüdür.
Son alanın kazandığı kuralın streaming parsing’i nasıl engellediğini ayrıntılı açıklayabilirseniz iyi olur.
GCC ve Clang’da uzun zamandır
-foptimize-sibling-callsseçeneği var; bu sayede debug build’lerde bile kuyruk çağrıları elde edilebiliyordu.Elbette bunun standartlaştırılması, garanti altına alınması ve fonksiyon düzeyinde kontrol edilebilir hâle gelmesi büyük bir iyileştirme.
[1] https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html
[2] https://clang.llvm.org/docs/ClangCommandLineReference.html#t...