1 puan yazan GN⁺ 2024-09-02 | 1 yorum | WhatsApp'ta paylaş
  • {fmt}, type erasure ile şablon şişmesini azaltan bir C++ biçimlendirme kütüphanesi ve bu deneyde basit bir fmt::print yürütülebilir dosyası 75kB'den 14kB'ye indirildi
  • Temel yapı, formatın şablon olmayan vformata devretmesi ve çıktı türünün de tampon API'siyle gizlenmesi; böylece ikili boyut ve derleme süresi birlikte azaltılabiliyor
  • aarch64 Ubuntu 22.04 ve GCC 11.4.0 üzerinde {fmt} 11.0.2'nin stripped yürütülebilir dosyası 75kB idi; locale devre dışı bırakma, yerleşik türleri azaltma ve boyut optimizasyon makrolarıyla bu değer 71kB → 31kB → 27kB → 23kB seviyesine indi
  • C++ çalışma zamanını kaldırmak, istisnaları FMT_THROW ile abort olarak ele alıp -fno-exceptions, -nodefaultlibs, -lc ile derledikten sonra basic_memory_bufferın varsayılan ayırıcısını malloc/free tabanlı hale getirerek mümkün oldu
  • Nihai yürütülebilir dosya 14kB; aynı sistemde boş bir C maininin 6kB olduğu düşünülürse {fmt}'nin eklediği boyut 10kB'den az ve ldd çıktısında da C++ çalışma zamanı bağımlılığı görünmüyor

{fmt} küçük ikilileri nasıl üretiyor

  • {fmt} formatting library, IOStreams, Boost Format ve tinyformat gibi alternatiflere kıyasla işlev çağrısı başına çoğu zaman kat kat daha az üretilmiş kod oluşturuyor
  • Bunun anahtarı, birden çok katmanda type erasure uygulanarak şablon şişmesinin azaltılması
  • Biçimlendirme argümanları format_args ile type erasure'dan geçiriliyor
    • format şablon işlevi, gerçek işi şablon olmayan vformata devrediyor
    • Çıktı yineleyicileri ve diğer çıktı türleri de ayrı bir tampon API'si üzerinden type erasure ile gizleniyor
  • Şablon kullanımı en üstteki ince katmanla sınırlı tutuluyor; bu yapı hem daha küçük ikililere hem de daha hızlı C++ derleme sürelerine katkı sağlıyor

printfe yakın kod boyutu ve daha güçlü güvenlik

  • Örnek program yalnızca fmt::print("The answer is {}.", 42); çağrısı yapıyor
  • Derleme sonucu, IOStreams'ten çok daha küçük ve printf örneğiyle benzer seviyede
  • printften farklı olarak {fmt}, çalışma zamanı tür güvenliği sunuyor
    • Biçim dizgesi hataları derleme zamanında yakalanabiliyor
    • Biçim dizgesi çalışma zamanında belirlense bile hatalar istisna ile ele alındığından tanımsız davranış, bellek bozulması ve olası çökme önlenebiliyor
  • C değişkenli argümanlarıyla pek uyumlu olmayan konumsal argümanlar (positional arguments) kullanıldığında, {fmt} çağrısı genellikle daha verimli oluyor

Başlangıç boyutu ve locale'ın kaldırılması

  • 2020 tarihli kütüphane boyutu optimizasyonu çalışmasında {fmt}, 100kB'nin altına, -Os -flto ile yaklaşık 57kB seviyesine kadar indirilmişti
  • Sonrasında {fmt}, Junekey Jeon'un katkıda bulunduğu Dragonbox algoritmasını kayan noktalı sayı biçimlendirmede kullanmaya başladı
  • Bu ölçüm, son kullanıcının hissettiği yürütülebilir dosya boyutunu temel alıyor ve aarch64 Ubuntu 22.04 ile GCC 11.4.0 üzerinde yapıldı
  • {fmt} 11.0.2'nin temel derlemesi -Os -flto -DNDEBUG ve strip sonrası 75kB
    • Son 4 yılda çeşitli değişiklikler olsa da boyut belirgin biçimde gerilemedi
  • Locale desteği FMT_STATIC_THOUSANDS_SEPARATOR ile kapatıldığında ikili boyut 71kB'ye düşüyor
    • {fmt} biçimlendirmesi varsayılan olarak locale'dan bağımsız
    • Locale, isteğe bağlı olarak L biçim belirteciyle kullanılabiliyor

Yerleşik türleri azaltma ve “kullanmadığın şeyin maliyetini ödeme” modeli

  • Bloaty analizi, sayı biçimlendirme kodunun, özellikle kayan noktalı sayı biçimlendirme kısmının ikili boyutun büyük bölümünü oluşturduğunu gösteriyor
    • Kayan noktalı sayı biçimlendirme tablolar da kullanıyor ve bu tablolar Bloaty çıktısında görünmüyor
  • Temel yük, biçimlendirme işlevinin biçimlendirilebilen tüm türleri bilmek zorunda olmasından kaynaklanıyor
    • Bu yaklaşım C standardındaki printf için uygun, ancak {fmt} için zorunlu değil
    • {fmt}, tüm tür kümesini önceden bilmeden de rastgele türleri biçimlendirebilen bir genişletme API'si sunuyor
  • Deneysel uygulamada FMT_BUILTIN_TYPES=0 ayarlanarak yalnızca int özel işlem görüyor, diğer türler ise genel genişletme API'sine gönderiliyor
    • int, dinamik genişlik ve hassasiyet işlemleri için gerekli
    • Örnek: fmt::print("{:{}}\n", "hello", 10); ifadesi "hello " çıktısını üretir
  • Bu yaklaşım, kullanılmayan türlerin maliyetini ödememe modeli sağlıyor; ancak çağrı başına ikili boyut biraz artıyor
    • Kayan noktalı sayı veya başka türler gerçekten biçimlendirilirse ilgili kod yine derlemeye dahil ediliyor
  • FMT_BUILTIN_TYPES=0 sonrasında örnek ikili 31kB'ye düştü
  • Ardından kalan locale izleri e582d37 ve b3ccc2d ile temizlendi; FMT_USE_LOCALE makrosuyla bunun daha açık biçimde kapatılabilmesi sağlandı ve boyut 27kB oldu

Hız ve boyut arasında seçim, ardından C++ çalışma zamanını kaldırma

  • Kütüphane içinde hız için boyuttan feragat edilen birden fazla bölüm var
  • Ondalık basamak sayısını hesaplayan do_count_digits, 256 baytlık bir tablo kullanıyor
    • Bu uygulamayı koşulsuz değiştirmek başka kullanım senaryolarını olumsuz etkileyebilir
    • __builtin_clz kullanılamayan constexpr gibi durumlar için bir fallback uygulama zaten mevcut
  • FMT_OPTIMIZE_SIZE makrosu eklenerek kullanıcının fallback uygulamayı kullanıp kullanmayacağını kontrol etmesi sağlandı
    • Bu ayar ve benzer birkaç değişiklikle ikili boyut 23kB'ye indi
  • C++ standart kütüphanesi bağımlılığını kaldırmak için istisnalar FMT_THROW ile devre dışı bırakılabiliyor
    • Örnek, FMT_THROW(s)=abort() ve -fno-exceptions kullanıyor
    • Genel olarak önerilmese de hataların çoğunun derleme zamanında yakalandığı bazı kullanım senaryolarında kabul edilebilir olabilir
  • -nodefaultlibs -lc ile derlendiğinde geriye kalan C++ çalışma zamanı bağımlılığı fmt::basic_memory_buffer kaynaklı
    • Bu tampon, küçük bir yığın içi tahsisli tampon ve gerekirse dinamik belleğe genişleyebiliyor
    • fmt::print genellikle doğrudan FILE tamponuna yazabildiğinden dinamik tahsis gerekmiyor
  • Daha genel bir çözüm olarak varsayılan ayırıcı, new/delete yerine malloc/free tabanlı olacak şekilde değiştirildi
    • Bu değişiklikten sonra nihai ikili boyut 14kB oldu
    • Aynı sistemde boş bir C main programı 6kB olduğundan, {fmt}'nin eklediği boyut 10kB'den az
  • ldd a.out çıktısı yalnızca libc.so.6 ve yükleyiciyi gösteriyor; C++ çalışma zamanı bağımlılığı görünmüyor
  • Nihai sonuç, {fmt}'nin gömülü ve bellek kısıtlı ortamlarda daha küçük boyutla kullanılabileceğini gösteriyor

1 yorum

 
GN⁺ 2024-09-02
Hacker News yorumları
  • Bu aslında daha çok komite eğilimiyle ilgili bir sorun; bu yüzden üçüncü taraf bir kütüphane olan fmt’nin illa hatalı varsayılanlara sahip olmasını beklemem
    Şaşırtıcı biçimde, bu özellik C++20’de std::format olarak standartlaştırılırken komite, standardın başka birçok yerindeki bu hatayı tekrar eklemedi
    Bu yüzden C++’ı “tutarlı” yapmak adına gereksiz yere daha kötü hale getirmemeleri için çağrıda bulunan öneri sahipleri için de biraz umut var

  • Kayan nokta biçimlendirme için gereken kod miktarına bakınca epey şaşırtıcı
    Bağlantısı verilen Dragonbox [1] projesi de okunmaya değer; neredeyse hiç kullanılmayan dallanmalar bile oldukça optimize edilmiş
    [1] https://github.com/jk-jeon/dragonbox

    • Yakın zamanda Zig ile çalışırken kayan nokta biçimlendirme için ne kadar çok kod gerektiğini fark ettim
      Normalde Zig derleyicisi Windows’ta C runtime’a bağımlı olmadığı için MSVC’den daha küçük ikililer üretebiliyor; ama bu sefer araç yaptığı işe kıyasla garip biçimde büyüktü
      Binary Ninja ile açınca kodun büyük kısmının kayan nokta biçimlendirme desteği için olduğunu gördüm; çıktıya vermeden önce kayan nokta sayıları tamsayıya cast edince beklediğim boyuta kadar küçüldü
    • https://github.com/jk-jeon/dragonbox/discussions/57#discussioncomment-9340182
      Boyut optimizasyonu denemeleri yapılıyor ve şu anda 8-bit AVR üzerinde yaklaşık 3k’ye kadar küçültülebiliyor
      Yalnızca tek duyarlıklı binary32 için implementasyon ve tablolar dahil; çift duyarlık çok daha fazlasını gerektiriyor, ama aynı zamanda şişmenin önemli bir kısmı AVR’nin kısıtlarından kaynaklanıyor
      x64 gibi platformlarda çok daha küçük olabilir; yine de 3k’nin hâlâ büyük olduğu söylenebilir
    • Hızlı yapmak istiyorsanız çok kod gerekir
      Referans implementasyon da sonuçta keyfi duyarlıklı aritmetik implementasyonu ama o kadar da kötü değil
      [1] https://research.swtch.com/ftoa
      [2] https://go.dev/src/strconv/ftoa.go
    • {fmt}’de eski Dragon4 algoritmasının isteğe bağlı bir implementasyonu var; kod boyutu daha küçük ama hızı daha yavaş
    • Çoğu kullanım senaryosu, basılacak ondalık basamak sayısını sınırlayacak gibi duruyor
      Ondalık basamak sayısı kadar çarpıp sonra tamsayıya dönüştürmek, itoa()’dan geçirmek ve ardından ondalık noktayı uygun yere eklemek daha verimli olur mu merak ediyorum
  • C++’a yeni başlayan biri olarak merak ediyorum: libc++’ın varsayılan ayırıcısı, yani varsayılan new/delete implementasyonu, içeride libc’nin malloc/free’sini çağırmaktan gerçekten farklı bir şey yapıyor mu? Yapıyorsa neden?

    • C++ konusunda çok güçlü değilim ama new[], bellek almak için new operatörünü çağırdıktan sonra her elemanın constructor’ını çalıştırmaya çalışır
      delete[] ise belleği serbest bırakmadan önce her elemanın destructor’ını çalıştırmaya çalışır
      delete[]’ın çalışması için C++’ın bir yerde ayırma boyutunu takip etmesi gerekir; bu bilgi ayrılan alanın yakınına da konabilir, ayrı bir yapıda da tutulabilir
      Ayrı bir yapı kullanılırsa, nesnenin arkasındaki belleğin yanlışlıkla kullanılması durumunda bilginin üzerine yazılma olasılığı azalır; ama arama maliyeti ve ek kod gerekir
      Düzgün bir C++ kütüphanesi daha fazla iş yapacaktır, ama new/delete’ın malloc/free ile aynı olmadığına dair fikir verir
    • ISO C++, new/delete’ın varsayılan implementasyonunun malloc()/free() çağırmasını şart koşmaz
      Birçok implementasyonun bunu yapmasının sebebi, zaten var olması ve kullanımının kolay olmasıdır
    • Hizalı ayırma overload’ları hariç, temelde farklı değildir
      Ancak uygulama, ELF sembol interposition’a karşılık gelen bir özellik bulunmayan platformlarda bile varsayılan standart kütüphane operator new’ini kendi implementasyonuyla değiştirebilir
    • malloc’a geçmenin temel nedeni, new’in std::bad_alloc fırlatmasıdır; bunu kullanırsanız C++ runtime’a linklemek gerekir
  • Küçük olup string ve tamsayı yazdırabilecek şekilde tasarlanmış bir biçimlendirme kütüphanesinin kabaca 50 bayt civarında olmasını beklerdim
    String için null sonlandırıcı kontrolü, karakter yazdırma ve iki adım geriye dallanma gibi yaklaşık 4 komut yeter
    Tamsayı için negatif kontrolü ardından '-' yazdırma ve işareti ters çevirme, 1000000000’ı R1’e koyma, bölme ve kalanı saklama, ASCII '0' ekleme, karakter yazdırma, R1’i 10’a bölme, kalanı girdi olarak verme, R1=0 olana kadar yineleme gibi yaklaşık 20 komut yeter
    Kayan nokta birçok programda kullanılmadığı için yalnızca gerektiğinde derlenmeli; onaltılık, pointer ve başa 0 doldurma da aynı şekilde
    Kod alanı 2KB olan mikrodenetleyiciler için kod yazarken 14KB’lik string biçimlendirme kütüphanesi koymazsınız

    • Bu, değiştiricisiz yavaş bir tamsayı/string yazdırma kütüphanesi değil; çok özellikli bir biçimlendirme kütüphanesi
      Aynı anda hem çok özellikli, hem hızlı, hem de küçük bir kütüphane yapamazsınız
    • Mikrodenetleyiciler için kütüphane tasarlamakla genel son kullanıcı uygulamaları için “eşdeğer” kütüphane tasarlamak neredeyse tüm önemli noktalarda farklıdır
      Bunun fmt’ye özgü bir şey olmaktan ziyade, kamusal ortamda yapılan genel bir yakınmadan ne farkı var pek emin değilim
      Dragonbox veya Dragon4 gibi algoritma kodları tek başına bile boyut bütçesini aştığı için “isteğe bağlı” özellikler çok da önemli değil
      Üstelik bu, insanların istediği yaklaşık 20 özellikten yalnızca biri
    • O halde gerçekten kullandığınız kütüphaneyi yayımlayıp hangi biçimlendirme özelliklerini desteklediğini belgelemek doğru olmaz mı?
      Böylece başkaları daha fazla özelliği daha akıllıca sığdırmanın yollarını bulabilir
      Aksi halde ne anlatılmak istendiğini pek anlamıyorum
    • Belirli bir programlama nişinin gereksinimlerinin dili bu şekilde etkilememesi gerektiğini düşünüyorum
      Bu gereksinimler geçerli, ama bunları dil spesifikasyonu değil, en düşük seviye mikrodenetleyici derleyicisi çözmeli
    • Bu kütüphanenin ana amacı küçüklük değil; eksiksiz bir string biçimlendirme kütüphanesi yapmak ve boyutu önemli bir ikincil hedef olarak tutmak
      Temel özellikleri bile desteklememek pahasına aşırı küçük olması gerekiyorsa, kesinlikle daha iyi seçenekler var
      Kod alanı yalnızca 2KB ise bunu kullanmamalısınız
      Neyse ki modern mikrodenetleyicilerin çoğu bundan çok daha büyük; örneğin esp32 1MB’tan başladığı için 14KB’lik bir biçimlendirme kütüphanesi kullanmak gayet makul
  • Biraz tanıtım yapacak olursam, çıktı buffering’i olan libc dahil olsa bile 1008 baytlık yürütülebilir dosya ile printf(Hello, World!\n"); yapılabiliyor: https://github.com/pts/minilibc686
    Elbette doğrudan kıyaslarsak elmayla armudu karşılaştırmış oluruz

    • Bunun sebebi derleyicinin bunu fputs’a çevirmesi
  • “Boş main fonksiyonu olan bir C programı bu sistemde 6kB ise, {fmt} artık ikili dosyaya 10kB’den az ekliyor” kısmı ilginç
    Böyle bir test hiç yapmadım

    • C kütüphanesini dinamik mi statik mi linklediğinize, uygulamayı ve C kütüphanesini nasıl derlediğinize bağlı olarak çok değişir
      Hangi C kütüphanesini kullandığınız da önemlidir; ELF mi yoksa başka bir container mı kullandığınız da biraz etkiler
  • Sorun her zaman fmt
    Yeterince çok sayıyla, özellikle kayan nokta ve decimal biçimlendirme/ayrıştırma ile uğraşınca linker’ın kayan nokta ve BigInt ile ilgili çok kodu çekip ikili boyutunu büyütmesinin artık .NET’te de aynı şekilde yaşanması çok komik

    • Native AOT’ta da hâlâ Delphi benzeri bir deneyim bekliyorum; neyse ki giderek iyileşiyor
  • Çok ilginç
    Bu tür bakış açısını değiştiren optimizasyonları seviyorum

  • Ben mi yavaşım bilmiyorum ama başlıktaki “14k”nin 14kB anlamına geldiğini fark etmem biraz zaman aldı

    • Başka ne anlama gelebilirdi ki?
      En azından tarihsel olarak k, kB’nin yaygın bir kısaltmasıdır