1 पॉइंट द्वारा GN⁺ 2024-09-02 | 1 टिप्पणियां | WhatsApp पर शेयर करें
  • {fmt} एक C++ formatting library है जो type erasure के ज़रिए template bloat को कम करती रही है, और इस प्रयोग में एक साधारण fmt::print executable को 75kB से 14kB तक घटाया गया
  • इसका मुख्य ढांचा ऐसा है कि format अपना काम non-template vformat को सौंपता है और output types को भी buffer API के पीछे छिपाता है, इसलिए binary size और build time दोनों कम किए जा सकते हैं
  • aarch64 Ubuntu 22.04 और GCC 11.4.0 पर {fmt} 11.0.2 का stripped executable 75kB था, और locale बंद करने, built-in types घटाने, तथा size optimization macro लगाने से यह 71kB → 31kB → 27kB → 23kB तक घटा
  • C++ runtime हटाना इस तरह संभव हुआ कि exceptions को FMT_THROW से abort पर मैप किया गया, -fno-exceptions, -nodefaultlibs, -lc के साथ build किया गया, और basic_memory_buffer के default allocator को malloc/free आधारित बनाया गया
  • अंतिम executable 14kB का रहा; उसी सिस्टम पर खाली C main का आकार 6kB है, इसलिए {fmt} द्वारा जोड़ा गया आकार 10kB से कम है, और ldd में भी C++ runtime dependency नहीं दिखती

{fmt} छोटे binary कैसे बनाता है

  • {fmt} formatting library, IOStreams, Boost Format, tinyformat जैसे विकल्पों की तुलना में, कई बार प्रति function call कई गुना कम generated code बनाती है
  • इसकी कुंजी कई स्तरों पर type erasure लागू कर template bloat को कम करने वाली संरचना है
  • formatting arguments को format_args के रूप में type-erased किया जाता है
    • format template function असली काम non-template vformat को सौंपता है
    • output iterators और अन्य output types को भी अलग buffer API के ज़रिए type-erased किया जाता है
  • template का उपयोग सिर्फ सबसे ऊपर की पतली layer तक सीमित रहता है, और यही संरचना छोटे binary तथा तेज़ C++ compile time में मदद करती है

printf के करीब code size, लेकिन अधिक मजबूत safety

  • उदाहरण प्रोग्राम सिर्फ fmt::print("The answer is {}.", 42); को call करता है
  • compile किया गया परिणाम IOStreams से बहुत छोटा है और printf उदाहरण के लगभग बराबर है
    • {fmt} उदाहरण Godbolt: godbolt
    • printf उदाहरण Godbolt: godbolt
  • printf से अलग, {fmt} runtime type safety देता है
    • format string की गलतियाँ compile time पर पकड़ी जा सकती हैं
    • format string अगर runtime पर तय हो, तब भी errors को exception के रूप में handle किया जा सकता है, जिससे undefined behavior, memory corruption और संभावित crash से बचाव होता है
  • C variadic arguments के साथ अच्छी तरह न चलने वाले positional arguments का उपयोग हो, तो {fmt} calls आम तौर पर अधिक efficient होते हैं

baseline size और locale हटाना

  • 2020 की library size optimization में {fmt} को 100kB से नीचे, और -Os -flto पर लगभग 57kB तक घटाया गया था
  • इसके बाद {fmt} ने Junekey Jeon के योगदान वाले Dragonbox algorithm को floating-point formatting में अपनाया
  • इस बार का मापन end user को दिखने वाले executable size के आधार पर किया गया, और यह aarch64 Ubuntu 22.04 तथा GCC 11.4.0 पर किया गया
  • {fmt} 11.0.2 की baseline build, -Os -flto -DNDEBUG और strip के बाद, 75kB थी
    • पिछले 4 वर्षों में कई बदलाव हुए, लेकिन size में बड़ा regression नहीं आया
  • FMT_STATIC_THOUSANDS_SEPARATOR से locale support बंद करने पर binary size 71kB रह गया
    • {fmt} की formatting डिफ़ॉल्ट रूप से locale-independent है
    • locale को वैकल्पिक रूप से L format specifier से उपयोग किया जा सकता है

built-in types घटाना और “जिसका उपयोग नहीं, उसका खर्च नहीं” मॉडल

  • Bloaty analysis में numeric formatting, खासकर floating-point formatting, binary size का बड़ा हिस्सा निकला
    • floating-point formatting tables का भी उपयोग करती है, लेकिन वे Bloaty output में दिखाई नहीं देतीं
  • मूल overhead इस बात से आता है कि formatting function को format किए जा सकने वाले सभी types की जानकारी होनी चाहिए
    • यह तरीका C standard के printf के लिए उपयुक्त है, लेकिन {fmt} के लिए अनिवार्य नहीं है
    • {fmt} ऐसा extension API भी देता है जिससे पूरे type set को पहले से जाने बिना arbitrary types format किए जा सकते हैं
  • प्रयोगात्मक implementation में FMT_BUILTIN_TYPES=0 सेट किया गया, ताकि सिर्फ int को special-case किया जाए और बाकी types को सामान्य extension API पर भेजा जाए
    • int, dynamic width और precision handling के लिए ज़रूरी है
    • उदाहरण: fmt::print("{:{}}\n", "hello", 10); "hello " प्रिंट करता है
  • यह तरीका जिन types का उपयोग नहीं हुआ, उनके लिए लागत न देने वाला मॉडल देता है, हालांकि प्रति call binary size थोड़ा बढ़ जाता है
    • floating-point या अन्य types को वास्तव में format करने पर संबंधित code फिर भी build में शामिल होगा
  • FMT_BUILTIN_TYPES=0 लागू करने के बाद उदाहरण binary 31kB तक घट गया
  • इसके बाद locale से जुड़े बचे हुए अंश e582d37 और b3ccc2d में हटाए गए, और FMT_USE_LOCALE macro से इसे और स्पष्ट रूप से बंद किया जा सका, जिससे size 27kB हो गई

speed और size के बीच चुनाव, और C++ runtime हटाना

  • library के अंदर कई जगह ऐसी हैं जहाँ speed के लिए size खर्च की जाती है
  • दशमलव अंकों की संख्या निकालने वाला do_count_digits 256-byte table का उपयोग करता है
    • इस implementation को हर हाल में बदल देना अन्य use cases को नुकसान पहुँचा सकता है
    • constexpr जैसे मामलों, जहाँ __builtin_clz उपलब्ध नहीं होता, के लिए fallback implementation पहले से मौजूद है
  • FMT_OPTIMIZE_SIZE macro जोड़ा गया ताकि user नियंत्रित कर सके कि fallback implementation का उपयोग करना है या नहीं
    • इस tuning और कुछ समान बदलावों के बाद binary size 23kB रह गई
  • C++ standard library dependency हटाने के लिए exceptions को FMT_THROW के माध्यम से बंद किया जा सकता है
    • उदाहरण में FMT_THROW(s)=abort() और -fno-exceptions का उपयोग किया गया
    • यह सामान्यतः recommended नहीं है, लेकिन कुछ ऐसे use cases में ठीक हो सकता है जहाँ अधिकांश errors compile time पर पकड़ ली जाती हैं
  • -nodefaultlibs -lc के साथ build करने पर बची हुई C++ runtime dependency fmt::basic_memory_buffer से आती है
    • यह buffer एक छोटा stack-allocated buffer है, जो ज़रूरत पड़ने पर dynamic memory तक फैलता है
    • fmt::print आम तौर पर सीधे FILE buffer में लिख सकता है, इसलिए dynamic allocation की ज़रूरत नहीं पड़ती
  • अधिक सामान्य समाधान के रूप में default allocator को new/delete की जगह malloc/free आधारित बनाया गया
    • इस बदलाव के बाद अंतिम binary size 14kB हो गई
    • उसी सिस्टम पर खाली C main प्रोग्राम 6kB का है, इसलिए {fmt} द्वारा जोड़ा गया आकार 10kB से कम है
  • ldd a.out के परिणाम में सिर्फ libc.so.6 और loader दिखाई देते हैं; C++ runtime dependency नहीं दिखती
  • अंतिम परिणाम दिखाता है कि embedded और memory-constrained environments में {fmt} को और छोटे रूप में इस्तेमाल किया जा सकता है

1 टिप्पणियां

 
GN⁺ 2024-09-02
Hacker News की राय
  • यह असल में committee की प्रवृत्ति से जुड़ा मामला है, इसलिए किसी third-party library fmt से यह उम्मीद नहीं है कि उसके defaults ज़रूर गलत ही होंगे
    हैरानी की बात है कि जब यह फीचर C++20 के std::format के रूप में standardize हुआ, तो committee ने standard के कई दूसरे हिस्सों में मौजूद इस गलती को फिर से नहीं जोड़ा
    इसलिए उन प्रस्तावकों के लिए भी थोड़ी उम्मीद है जो C++ को “consistent” बनाने के नाम पर उसे बेवजह और खराब न बनाने की अपील करते हैं

  • floating-point formatting के लिए जितना code चाहिए, वह देखकर काफी झटका लगता है
    लिंक किया गया Dragonbox [1] project भी पढ़ने लायक है, और उसमें बहुत कम इस्तेमाल होने वाली branches तक को काफी optimize किया गया है
    [1] https://github.com/jk-jeon/dragonbox

    • हाल ही में Zig पर काम करते हुए पता चला कि floating-point formatting के लिए कितना code चाहिए
      आम तौर पर Zig compiler Windows पर C runtime पर निर्भर नहीं होता, इसलिए वह MSVC की तुलना में छोटे binaries बना सकता है, लेकिन इस बार tool जो काम कर रहा था उसके मुकाबले binary अजीब तरह से बड़ा था
      Binary Ninja में खोलकर देखा तो code का ज्यादातर हिस्सा floating-point formatting support के लिए था, और output से पहले floating-point numbers को integer में cast करने पर size उम्मीद के मुताबिक घट गया
    • https://github.com/jk-jeon/dragonbox/discussions/57#discussioncomment-9340182
      size optimization के experiments चल रहे हैं, और अभी 8-bit AVR पर इसे करीब 3k तक घटाया जा सकता है
      इसमें केवल single-precision binary32 के लिए implementation और tables शामिल हैं; double-precision के लिए काफी ज्यादा चाहिए होगा, लेकिन साथ ही bloat का बड़ा हिस्सा AVR की limitations की वजह से भी है
      x64 जैसे platforms पर यह काफी छोटा हो सकता है, हालांकि 3k को भी फिर भी बड़ा कहा जा सकता है
    • अगर इसे तेज बनाना है तो बहुत code चाहिए
      reference implementation भी आखिरकार arbitrary-precision arithmetic implementation ही है, लेकिन वह इतना बुरा नहीं है
      [1] https://research.swtch.com/ftoa
      [2] https://go.dev/src/strconv/ftoa.go
    • {fmt} में पुराने Dragon4 algorithm का optional implementation है; code size छोटा है, लेकिन speed धीमी है
    • ज्यादातर use cases शायद output में decimal places की संख्या limit करेंगे
      सोच रहा हूं कि decimal places के हिसाब से multiply करके integer में convert करना, फिर itoa() से गुजारना और सही जगह decimal point insert करना ज्यादा efficient होगा या नहीं
  • C++ beginner के तौर पर जानना चाहता हूं: libc++ का default allocator, यानी default new/delete implementation, internally libc के malloc/free को call करने से असल में कुछ अलग करता है क्या? अगर हां, तो क्यों?

    • मैं C++ में बहुत मजबूत नहीं हूं, लेकिन new[] memory पाने के लिए new operator को call करता है और फिर हर element का constructor चलाने की कोशिश करता है
      delete[] memory free करने से पहले हर element का destructor चलाने की कोशिश करता है
      delete[] के काम करने के लिए C++ को कहीं allocation size track करना पड़ता है; यह जानकारी allocation area के पास रखी जा सकती है या किसी अलग structure में
      अलग structure इस्तेमाल करने पर object के पीछे की memory गलती से लिखे जाने पर यह जानकारी overwrite होने की संभावना कम होती है, लेकिन lookup cost और extra code चाहिए
      एक ठीक-ठाक C++ library इससे भी ज्यादा काम करेगी, लेकिन इतना अंदाजा मिल जाता है कि new/delete malloc/free जैसे नहीं हैं
    • ISO C++ यह require नहीं करता कि new/delete का default implementation malloc()/free() को call करे
      कई implementations ऐसा इसलिए करते हैं क्योंकि वह पहले से मौजूद है और इस्तेमाल करना आसान है
    • aligned allocation overloads को छोड़ दें तो मूल रूप से कोई फर्क नहीं है
      हालांकि applications उन platforms पर भी default standard library operator new को अपने implementation से replace कर सकती हैं जहां ELF symbol interposition जैसा feature नहीं है
    • malloc पर बदलने की मुख्य वजह यह है कि new std::bad_alloc throw करता है, और इसे इस्तेमाल करने पर C++ runtime से link करना पड़ता है
  • छोटी formatting library जो strings और integers output करने के लिए design की गई हो, उससे मैं लगभग 50 bytes की उम्मीद करता
    string के लिए null terminator check, character output, और दो steps पीछे branch—करीब 4 instructions काफी हैं
    integer के लिए negative check के बाद '-' output और sign invert, R1 में 1000000000 डालना, divide और remainder store करना, ASCII '0' जोड़ना, character output, R1 को 10 से divide करना, remainder को input में डालना, और R1=0 होने तक repeat करना—करीब 20 instructions काफी हैं
    floating-point बहुत से programs में इस्तेमाल नहीं होता, इसलिए जरूरत पड़ने पर ही compile होना चाहिए; hexadecimal, pointer, और leading zero padding के लिए भी यही बात है
    जब code space 2KB वाले microcontroller के लिए code लिखता हूं, तो 14KB की string formatting library नहीं डालता

    • यह modifiers के बिना कोई धीमी integer/string output library नहीं है, बल्कि feature-rich formatting library है
      ऐसी library एक साथ feature-rich, fast और small नहीं बन सकती
    • microcontroller के लिए library design और सामान्य end-user applications के लिए “equivalent” library design लगभग हर अहम point पर अलग हो जाते हैं
      यह बात सिर्फ fmt पर लागू होती है या public forum में की जाने वाली सामान्य शिकायत से कैसे अलग है, समझ नहीं आता
      Dragonbox या Dragon4 जैसे algorithm code ही size budget पार कर देते हैं, इसलिए “optional” features बहुत मायने नहीं रखते
      और वह उन करीब 20 features में से सिर्फ एक है जिन्हें लोग चाहते हैं
    • तो फिर बेहतर होगा कि जो library वास्तव में इस्तेमाल करते हैं उसे publish करें और document करें कि वह कौन-कौन से formatting features support करती है
      तब शायद दूसरे लोग ज्यादा features को और smart तरीके से fit करने के तरीके खोज सकें
      वरना point समझ नहीं आता
    • किसी खास programming niche की requirements का language पर इस तरह असर नहीं पड़ना चाहिए
      वे requirements valid हैं, लेकिन उन्हें language specification नहीं, बल्कि lowest-end microcontroller compiler को solve करना चाहिए
    • इस library का मुख्य लक्ष्य छोटा होना नहीं है, बल्कि complete string formatting library बनाना है, जिसमें size एक महत्वपूर्ण secondary goal है
      अगर basic features भी support न करने के बदले बेहद छोटा होना चाहिए, तो निश्चित रूप से बेहतर विकल्प मौजूद हैं
      अगर code space सिर्फ 2KB है, तो इसे इस्तेमाल नहीं करना चाहिए
      सौभाग्य से ज्यादातर modern microcontrollers इससे कहीं बड़े हैं; उदाहरण के लिए esp32 1MB से शुरू होता है, इसलिए 14KB formatting library इस्तेमाल करना भी पूरी तरह reasonable है
  • थोड़ा प्रचार करूं तो, output buffering वाली libc शामिल करके भी 1008-byte executable में printf(Hello, World!\n"); संभव है: https://github.com/pts/minilibc686
    बेशक सीधे compare करना apples और oranges compare करने जैसा है

    • वह इसलिए है क्योंकि compiler इसे fputs में बदल देता है
  • “अगर empty main function वाला C program इस system पर 6kB है, तो {fmt} अब binary में 10kB से कम ही जोड़ता है” वाला हिस्सा दिलचस्प है
    ऐसा test मैंने कभी नहीं किया

    • यह इस पर बहुत निर्भर करता है कि C library को dynamically link किया गया है या statically, और application तथा C library को कैसे build किया गया है
      कौन-सी C library इस्तेमाल हो रही है, यह भी महत्वपूर्ण है, और ELF इस्तेमाल हो रहा है या कोई दूसरा container, इसका भी थोड़ा असर पड़ता है
  • समस्या हमेशा fmt ही होती है
    यह बेहद मजेदार है कि अगर आप काफी सारे numbers, खासकर floating-point और decimal formatting/parsing को छूते हैं, तो linker floating-point और BigInt से जुड़ा ढेर सारा code खींच लाता है और binary size बढ़ जाता है—अब .NET में भी यही चीज़ वैसी ही हो रही है

    • Native AOT में भी अभी Delphi जैसा experience उम्मीद कर रहा हूं, और अच्छी बात है कि यह धीरे-धीरे बेहतर हो रहा है
  • बहुत मजेदार
    ऐसी सोच बदलने वाली optimizations मुझे पसंद हैं

  • पता नहीं मैं धीमा हूं या क्या, लेकिन title में “14k” का मतलब 14kB है, यह समझने में मुझे थोड़ा समय लगा

    • और क्या मतलब हो सकता था
      कम से कम historical तौर पर k, kB का common shorthand रहा है