- {fmt} एक C++ formatting library है जो type erasure के ज़रिए template bloat को कम करती रही है, और इस प्रयोग में एक साधारण
fmt::printexecutable को 75kB से 14kB तक घटाया गया - इसका मुख्य ढांचा ऐसा है कि
formatअपना काम non-templatevformatको सौंपता है और 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 किया जाता हैformattemplate function असली काम non-templatevformatको सौंपता है- 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उदाहरण के लगभग बराबर है 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 को वैकल्पिक रूप से
Lformat 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 किए जा सकते हैं
- यह तरीका C standard के
- प्रयोगात्मक 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_LOCALEmacro से इसे और स्पष्ट रूप से बंद किया जा सका, जिससे size 27kB हो गई
speed और size के बीच चुनाव, और C++ runtime हटाना
- library के अंदर कई जगह ऐसी हैं जहाँ speed के लिए size खर्च की जाती है
- दशमलव अंकों की संख्या निकालने वाला
do_count_digits256-byte table का उपयोग करता है- इस implementation को हर हाल में बदल देना अन्य use cases को नुकसान पहुँचा सकता है
constexprजैसे मामलों, जहाँ__builtin_clzउपलब्ध नहीं होता, के लिए fallback implementation पहले से मौजूद है
FMT_OPTIMIZE_SIZEmacro जोड़ा गया ताकि 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 dependencyfmt::basic_memory_bufferसे आती है- यह buffer एक छोटा stack-allocated buffer है, जो ज़रूरत पड़ने पर dynamic memory तक फैलता है
fmt::printआम तौर पर सीधेFILEbuffer में लिख सकता है, इसलिए 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 टिप्पणियां
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 compiler Windows पर C runtime पर निर्भर नहीं होता, इसलिए वह MSVC की तुलना में छोटे binaries बना सकता है, लेकिन इस बार tool जो काम कर रहा था उसके मुकाबले binary अजीब तरह से बड़ा था
Binary Ninja में खोलकर देखा तो code का ज्यादातर हिस्सा floating-point formatting support के लिए था, और output से पहले floating-point numbers को integer में cast करने पर size उम्मीद के मुताबिक घट गया
size optimization के experiments चल रहे हैं, और अभी 8-bit AVR पर इसे करीब 3k तक घटाया जा सकता है
इसमें केवल single-precision binary32 के लिए implementation और tables शामिल हैं; double-precision के लिए काफी ज्यादा चाहिए होगा, लेकिन साथ ही bloat का बड़ा हिस्सा AVR की limitations की वजह से भी है
x64 जैसे platforms पर यह काफी छोटा हो सकता है, हालांकि 3k को भी फिर भी बड़ा कहा जा सकता है
reference implementation भी आखिरकार arbitrary-precision arithmetic implementation ही है, लेकिन वह इतना बुरा नहीं है
[1] https://research.swtch.com/ftoa
[2] https://go.dev/src/strconv/ftoa.go
सोच रहा हूं कि decimal places के हिसाब से multiply करके integer में convert करना, फिर itoa() से गुजारना और सही जगह decimal point insert करना ज्यादा efficient होगा या नहीं
C++ beginner के तौर पर जानना चाहता हूं: libc++ का default allocator, यानी default new/delete implementation, internally libc के malloc/free को call करने से असल में कुछ अलग करता है क्या? अगर हां, तो क्यों?
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 जैसे नहीं हैं
कई implementations ऐसा इसलिए करते हैं क्योंकि वह पहले से मौजूद है और इस्तेमाल करना आसान है
हालांकि applications उन platforms पर भी default standard library operator new को अपने implementation से replace कर सकती हैं जहां ELF symbol interposition जैसा feature नहीं है
छोटी 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 नहीं डालता
ऐसी library एक साथ feature-rich, fast और small नहीं बन सकती
यह बात सिर्फ fmt पर लागू होती है या public forum में की जाने वाली सामान्य शिकायत से कैसे अलग है, समझ नहीं आता
Dragonbox या Dragon4 जैसे algorithm code ही size budget पार कर देते हैं, इसलिए “optional” features बहुत मायने नहीं रखते
और वह उन करीब 20 features में से सिर्फ एक है जिन्हें लोग चाहते हैं
तब शायद दूसरे लोग ज्यादा features को और smart तरीके से fit करने के तरीके खोज सकें
वरना point समझ नहीं आता
वे requirements valid हैं, लेकिन उन्हें language specification नहीं, बल्कि lowest-end microcontroller compiler को solve करना चाहिए
अगर 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 करने जैसा है
“अगर empty main function वाला C program इस system पर 6kB है, तो {fmt} अब binary में 10kB से कम ही जोड़ता है” वाला हिस्सा दिलचस्प है
ऐसा test मैंने कभी नहीं किया
कौन-सी C library इस्तेमाल हो रही है, यह भी महत्वपूर्ण है, और ELF इस्तेमाल हो रहा है या कोई दूसरा container, इसका भी थोड़ा असर पड़ता है
समस्या हमेशा fmt ही होती है
यह बेहद मजेदार है कि अगर आप काफी सारे numbers, खासकर floating-point और decimal formatting/parsing को छूते हैं, तो linker floating-point और BigInt से जुड़ा ढेर सारा code खींच लाता है और binary size बढ़ जाता है—अब .NET में भी यही चीज़ वैसी ही हो रही है
बहुत मजेदार
ऐसी सोच बदलने वाली optimizations मुझे पसंद हैं
पता नहीं मैं धीमा हूं या क्या, लेकिन title में “14k” का मतलब 14kB है, यह समझने में मुझे थोड़ा समय लगा
कम से कम historical तौर पर k, kB का common shorthand रहा है