1 puan yazan GN⁺ 2024-08-20 | 1 yorum | WhatsApp'ta paylaş
  • Clang'e musttail eklendikten 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ı call yerine jmp'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 + switch yapı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 musttail standart 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 upb pull/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 call yerine jmp komutu üretir
    • Yeni yığın çerçevesi oluşturma ve dönüş adresi saklama adımları atlanır
    • Çağıran f(), çağrılan g()'ye doğrudan atlar
    • g(), doğrudan f()yi çağırmış olan fonksiyona döner
  • Bu özellik sayesinde tail call, tekrar yapısının yerini alabilir
    • Art arda gelen n adet tail call durumunda bile yığın kullanımı O(n)'den O(1)'e düşer
    • call ek yükü ortadan kalktığı için fonksiyon çağrıları normal dallanma gibi ele alınabilir
  • 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 -O2 gibi optimize derlemelerde tail call'u optimize edebiliyordu, ancak önceki davranış best-effort niteliğindeydi
    • Optimize edilmemiş derlemelerde bunun gerçek call olarak 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

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 while döngüsü içinde switch kullanan 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
    • data içinden alan bilgisini decode ediyor
    • wire type uyuşmazsa fallback() fonksiyonuna MUSTTAIL return ediyor
    • 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 fallback veya dispatch için kullanılan jmp
    • Argümanlar zaten doğru register'larda olduğu için ayrıca parametre taşıma kodu da gerekmiyor
  • 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 noinline ile inline edilmesi engellenebilir
    • Hızlı yolun assembly dizisi fiilen sabitlenebilir

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 ADDVN fonksiyonu ş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 jmp oluşuyor
    • jmp qword ptr [rsi + 8*rax] yerine, önce rax'e yükleyip ardından jmp rax kullanılıyor
  • 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
    • fallback fonksiyonuna 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
    • musttail olmadığında, kavramsal döngünün her tekrarında en az bir gerçek return gerekir
    • upb'de bu fallback henüz uygulanmış değil ve musttail kullanı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 upbnin musttail kullanacak ş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

 
GN⁺ 2024-08-20
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 erdiriyor
      Hı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
    • C’ye yeni özellikler ekleme hareketinin yeniden güçlenmesi hem heyecan verici hem de epey tedirgin edici
      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 become anahtar sözcüğünü eklemeyi amaçlayan eski bir RFC var
    Baş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 switch dö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 çıkar

    • Yazıda da söylendiği gibi, computed goto kullansanız bile fonksiyonun control flow graph’ı fazla karmaşık olduğundan sık kullanılan değişkenlerin register allocation’ı kırılgan olur
      Her fonksiyon küçükse ve önemli değişkenleri argüman olarak alıyorsa register allocation çok daha az kırılgan hale gelir
    • “Statik olarak her opcode başına bir dolaylı dal” ifadesini merak ettim. Tek bir dolaylı dalla karşılaştırınca bunun ne anlama geldiğini ve nasıl başarıldığını biraz daha ayrıntılı açıklarsanız iyi olur
    • Bu yaklaşımın bir süredir modasının geçtiğini duymuştum. Branch predictor’ların yeterince iyileştiği ve artık buna gerek kalmadığı söyleniyordu
      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/

    • Son birkaç yılda bazı dillerin JIT katmanını kaldırıp sonra yeniden eklediğini gördüm. Bunun bir kısmı programcı yetkinliği ve çıkarılan derslerle ilgili, ama bir kısmı da CPU nesillerindeki değişimden kaynaklanıyor
      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
    • Clang’e yakın zamanda bu tür tail call’ları çok daha ucuz hale getiren yeni bir çağrı kuralı eklendi. Çağıranın bazı register’ları koruma ihtiyacını ortadan kaldırıyor
      Adı her seferinde kafamı karıştırıyor; preserve_all ya da preserve_none olmalı. Sorun, koruma bakış açısının kimin açısından olduğu
  • musttail ö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.
    • Zor bir mesele. Birçok ABI, argüman ve dönüş tipi uyuşan harici fonksiyon çağrısı gibi son derece temel durumlarda bile kuyruk çağrısı yapamaz.
      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 bunu noplt çağrısına çeviriyor. Bunlar Clang belgelerinde yok: https://clang.llvm.org/docs/AttributeReference.html#musttail
      Pratikte 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.
    • GNUC'de Scheme'e benzeyen epey özellik var; bu özellikte geride kalması şaşırtıcı.
  • 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 sonra a yok edilir.

    • Derleyici bu satırlar arasında uzaktan etkili yan etkiler olmadığını kanıtlayabiliyorsa, 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.

    • Derleyicilerin uzun zamandır kuyruk çağrısı eliminasyonu yaptığı doğru, ama bu teknikte “oldukça güvenilir biçimde üretiliyor” yeterli değil. Mutlaka garanti edilmeli ya da derleme başarısız olmalı.
      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.
    • Yazıdaki tersine assembly çıktısının gösterdiği gibi, fallback yolunun sorun olmasının nedeni çağrının kendisinin ne kadar hızlı olduğu değil. Sırf o çağrının varlığı bile derleyicinin tüm fonksiyon için bir stack frame oluşturmasına ve register'ları oraya dökmesine yol açabilir. Hızlı yol da dahil olmak üzere etkilenir.
      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 report gibi 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ı.

    • C, üst düzey dil derleyicileri için sık sık hedef dil olarak kullanılır.
      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....
    • Kuyruk çağrısı optimizasyonunun hızlı olmasının nedeni, ortaya çıkan döngünün öngörülebilir olması ve CPU instruction prefetch ile bellek prefetch'inin iyi çalışması diye düşünüyorum.
      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.

    • “Genel durumda Protobuf parsing, yinelenen işleme nedeniyle streaming yapılamaz” kısmını anlayamadım.
      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-calls seç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...