- {fmt} เป็นไลบรารีฟอร์แมตสำหรับ C++ ที่ลดการพองตัวของเทมเพลตด้วย type erasure และในการทดลองครั้งนี้ลดขนาดไฟล์ปฏิบัติการ
fmt::printแบบง่ายจาก 75kB เหลือ 14kB - โครงสร้างหลักคือให้
formatส่งงานต่อไปยังvformatที่ไม่ใช่เทมเพลต และซ่อนชนิดเอาต์พุตไว้หลัง buffer API จึงช่วยลดได้ทั้ง ขนาดไบนารี และเวลา build - บน aarch64 Ubuntu 22.04 และ GCC 11.4.0 ไฟล์ปฏิบัติการที่ strip แล้วของ {fmt} 11.0.2 มีขนาด 75kB และเมื่อตัด locale ออก ลดชนิด built-in และใช้แมโครปรับให้เหมาะกับขนาด ก็ลดลงเป็น 71kB → 31kB → 27kB → 23kB
- การตัด C++ runtime ออกทำได้โดยให้ exception ถูกจัดการด้วย
FMT_THROWเป็นabort, build ด้วย-fno-exceptions,-nodefaultlibs,-lcแล้วเปลี่ยน allocator เริ่มต้นของbasic_memory_bufferให้ใช้ malloc/free - ไฟล์ปฏิบัติการสุดท้ายมีขนาด 14kB และเมื่อพิจารณาว่า C
mainเปล่าในระบบเดียวกันมีขนาด 6kB ขนาดที่ {fmt} เพิ่มเข้ามาจึงน้อยกว่า 10kB และในlddก็ไม่เห็น dependency ต่อ C++ runtime
วิธีที่ {fmt} สร้างไบนารีขนาดเล็ก
- {fmt} formatting library มักสร้างโค้ดต่อการเรียกฟังก์ชันน้อยกว่าทางเลือกอย่าง IOStreams, Boost Format, tinyformat หลายเท่า
- แก่นหลักอยู่ที่โครงสร้างซึ่งใช้ type erasure ในหลายชั้น เพื่อลดการพองตัวของเทมเพลต
- อาร์กิวเมนต์สำหรับฟอร์แมตถูกทำ type erasure ด้วย
format_args- ฟังก์ชันเทมเพลต
formatส่งงานจริงต่อไปยังvformatที่ไม่ใช่เทมเพลต - output iterator และชนิดเอาต์พุตอื่น ๆ ก็ถูกทำ type erasure ผ่าน buffer API แยกต่างหาก
- ฟังก์ชันเทมเพลต
- การใช้เทมเพลตถูกจำกัดไว้ที่ชั้นบาง ๆ ด้านบนสุด และโครงสร้างนี้ช่วยให้ไบนารีเล็กลงและ เวลา compile C++ เร็วขึ้น
ขนาดโค้ดใกล้เคียง printf พร้อมความปลอดภัยที่แข็งแรงกว่า
- โปรแกรมตัวอย่างเรียกเพียง
fmt::print("The answer is {}.", 42); - ผลการ compile เล็กกว่า IOStreams มาก และอยู่ในระดับใกล้เคียงตัวอย่าง
printf - ต่างจาก
printfตรงที่ {fmt} ให้ ความปลอดภัยด้านชนิดใน runtime- ข้อผิดพลาดใน format string สามารถตรวจจับได้ตอน compile time
- แม้ format string จะถูกกำหนดตอน runtime ก็จัดการข้อผิดพลาดด้วย exception เพื่อหลีกเลี่ยง undefined behavior, ความเสียหายของหน่วยความจำ และ crash ที่อาจเกิดขึ้น
- เมื่อใช้ positional arguments ซึ่งไม่เข้ากับ variadic arguments ของ C เท่าไร การเรียก {fmt} โดยทั่วไปจะมีประสิทธิภาพกว่า
ขนาดตั้งต้นและการตัด locale ออก
- ในบทความปี 2020 เรื่อง การปรับขนาดไลบรารีให้เล็กลง เคยลด {fmt} ให้ต่ำกว่า 100kB และเหลือราว 57kB เมื่อใช้
-Os -flto - หลังจากนั้น {fmt} เริ่มใช้ algorithm Dragonbox ที่ Junekey Jeon มีส่วนร่วมพัฒนา สำหรับการฟอร์แมต floating-point
- การวัดครั้งนี้อิงจากขนาดไฟล์ปฏิบัติการที่ผู้ใช้ปลายทางรับรู้ และทำบน aarch64 Ubuntu 22.04 กับ GCC 11.4.0
- build ตั้งต้นของ {fmt} 11.0.2 มีขนาด 75kB หลังใช้
-Os -flto -DNDEBUGและstrip- แม้ตลอด 4 ปีที่ผ่านมาจะมีการเปลี่ยนแปลงหลายอย่าง แต่ขนาดไม่ได้ถดถอยมากนัก
- เมื่อตัดการรองรับ locale ด้วย
FMT_STATIC_THOUSANDS_SEPARATORขนาดไบนารีลดลงเป็น 71kB- การฟอร์แมตของ {fmt} โดยพื้นฐานไม่ขึ้นกับ locale
- locale สามารถเลือกใช้ได้ผ่าน format specifier
L
การลดชนิด built-in และโมเดล “ไม่ใช้ก็ไม่ต้องจ่ายต้นทุน”
- การวิเคราะห์ด้วย Bloaty พบว่าการฟอร์แมตตัวเลข โดยเฉพาะ การฟอร์แมต floating-point กินสัดส่วนใหญ่ของขนาดไบนารี
- การฟอร์แมต floating-point ใช้ตารางด้วย และตารางดังกล่าวไม่ปรากฏใน output ของ Bloaty
- ภาระพื้นฐานเกิดจากฟังก์ชันฟอร์แมตต้องรู้จักทุกชนิดที่ฟอร์แมตได้
- วิธีนี้เหมาะกับ
printfในมาตรฐาน C แต่ไม่ใช่เงื่อนไขจำเป็นสำหรับ {fmt} - {fmt} รองรับ API ส่วนขยายที่สามารถฟอร์แมตชนิดใด ๆ ได้โดยไม่ต้องรู้ชุดชนิดทั้งหมดล่วงหน้า
- วิธีนี้เหมาะกับ
- ใน implementation ทดลอง มีการตั้งค่า
FMT_BUILTIN_TYPES=0เพื่อจัดการพิเศษเฉพาะintและส่งชนิดที่เหลือไปยัง API ส่วนขยายทั่วไปintจำเป็นสำหรับการจัดการความกว้างและความแม่นยำแบบ dynamic- ตัวอย่าง:
fmt::print("{:{}}\n", "hello", 10);จะพิมพ์"hello "
- วิธีนี้ให้ โมเดลที่ไม่ต้องจ่ายต้นทุนสำหรับชนิดที่ไม่ได้ใช้ แต่ขนาดไบนารีต่อการเรียกจะเพิ่มขึ้นเล็กน้อย
- หากฟอร์แมต floating-point หรือชนิดอื่นจริง โค้ดที่เกี่ยวข้องก็ยังจะถูกใส่เข้ามาใน build
- หลังใช้
FMT_BUILTIN_TYPES=0ไบนารีตัวอย่างลดลงเหลือ 31kB - จากนั้นจึงลบร่องรอยที่เกี่ยวกับ locale ที่ยังเหลืออยู่ใน e582d37 และ b3ccc2d พร้อมทำให้ปิดได้ชัดเจนขึ้นด้วยแมโคร
FMT_USE_LOCALEทำให้ขนาดเหลือ 27kB
การเลือกระหว่างความเร็วกับขนาด และการตัด C++ runtime ออก
- ภายในไลบรารีมีหลายจุดที่แลกขนาดเพื่อความเร็ว
do_count_digitsซึ่งคำนวณจำนวนหลักในเลขฐานสิบใช้ตาราง 256 ไบต์- หากเปลี่ยน implementation นี้แบบบังคับทั้งหมด อาจส่งผลเสียต่อ use case อื่น ๆ
- มี fallback implementation อยู่แล้วสำหรับกรณีอย่าง
constexprที่ใช้__builtin_clzไม่ได้
- เพิ่มแมโคร
FMT_OPTIMIZE_SIZEเพื่อให้ผู้ใช้ควบคุมว่าจะใช้ fallback implementation หรือไม่- การปรับนี้และการเปลี่ยนแปลงคล้ายกันอีกบางส่วนทำให้ขนาดไบนารีลดลงเป็น 23kB
- เพื่อตัด dependency ต่อ C++ standard library สามารถปิด exception ด้วย
FMT_THROWได้- ตัวอย่างใช้
FMT_THROW(s)=abort()และ-fno-exceptions - โดยทั่วไปไม่แนะนำ แต่สำหรับบาง use case ที่ข้อผิดพลาดส่วนใหญ่ถูกจับได้ตอน compile time ก็อาจยอมรับได้
- ตัวอย่างใช้
- เมื่อ build ด้วย
-nodefaultlibs -lcdependency ต่อ C++ runtime ที่ยังเหลือมาจากfmt::basic_memory_buffer- buffer นี้เป็น buffer ขนาดเล็กที่ allocate บน stack และจะขยายด้วยหน่วยความจำแบบ dynamic เมื่อจำเป็น
- โดยทั่วไป
fmt::printสามารถเขียนลง buffer ของFILEได้โดยตรง จึงไม่จำเป็นต้อง allocate แบบ dynamic
- วิธีแก้ที่ทั่วไปกว่าคือเปลี่ยน allocator เริ่มต้นจาก
new/deleteเป็นแบบใช้ malloc/free- หลังการเปลี่ยนแปลงนี้ ขนาดไบนารีสุดท้ายเป็น 14kB
- โปรแกรม C
mainเปล่าในระบบเดียวกันมีขนาด 6kB ดังนั้นขนาดที่ {fmt} เพิ่มเข้ามาจึงน้อยกว่า 10kB
- ผลลัพธ์ของ
ldd a.outแสดงเฉพาะlibc.so.6กับ loader และไม่พบ dependency ต่อ C++ runtime - ผลลัพธ์สุดท้ายแสดงให้เห็นว่าสามารถใช้ {fmt} ให้เล็กลงได้ใน embedded และสภาพแวดล้อมที่มีข้อจำกัดด้านหน่วยความจำ
1 ความคิดเห็น
ความคิดเห็นบน Hacker News
จริง ๆ แล้วเรื่องนี้ออกจะเป็นปัญหาแนว รสนิยมของคณะกรรมการ มากกว่า จึงไม่ได้คาดหวังว่า fmt ซึ่งเป็นไลบรารีจากบุคคลที่สามจะต้องมีค่าเริ่มต้นที่ผิดพลาดเสียเอง
น่าแปลกที่เมื่อฟีเจอร์นี้ถูกทำให้เป็นมาตรฐานใน std::format ของ C++20 คณะกรรมการไม่ได้ใส่ความผิดพลาดนี้กลับเข้าไปเหมือนในหลายส่วนอื่น ๆ ของมาตรฐาน
ดังนั้นสำหรับผู้เสนอที่พยายามร้องขอว่าอย่าทำให้ C++ แย่ลงโดยไม่จำเป็นเพียงเพื่อให้มัน “สอดคล้องกัน” ก็ยังมีความหวังอยู่บ้าง
ถ้าดูปริมาณโค้ดที่จำเป็นสำหรับ การฟอร์แมตเลขทศนิยมลอยตัว แล้วค่อนข้างน่าตกใจ
โปรเจกต์ Dragonbox [1] ที่ลิงก์ไว้ก็น่าอ่าน และแม้แต่แขนงที่แทบไม่ถูกใช้งานก็ยังถูกปรับแต่งค่อนข้างมาก
[1] https://github.com/jk-jeon/dragonbox
โดยปกติคอมไพเลอร์ Zig บน Windows ไม่พึ่งพา C runtime จึงสร้างไบนารีที่เล็กกว่า MSVC ได้ แต่ครั้งนี้ไบนารีกลับใหญ่ผิดปกติเมื่อเทียบกับสิ่งที่เครื่องมือนั้นทำ
พอเปิดดูด้วย Binary Ninja ก็พบว่าโค้ดส่วนใหญ่เป็นส่วนรองรับการฟอร์แมตเลขทศนิยมลอยตัว และเมื่อแคสต์เลขทศนิยมลอยตัวเป็นจำนวนเต็มก่อนพิมพ์ ขนาดก็ลดลงมาเท่าที่คาดไว้
กำลังทดลองปรับขนาดให้เล็กลงอยู่ และตอนนี้บน AVR 8 บิต สามารถลดลงได้ถึงประมาณ 3k
โดยมีเฉพาะอิมพลีเมนต์และตารางสำหรับ binary32 ความแม่นยำเดี่ยว ส่วนความแม่นยำคู่ต้องใช้อีกมากกว่าเดิม แต่ในขณะเดียวกันส่วนที่บวมขึ้นจำนวนมากก็เป็นเพราะข้อจำกัดของ AVR
บนแพลตฟอร์มอย่าง x64 อาจเล็กลงได้มากกว่านี้ แต่ก็ยังพูดได้ว่า 3k ก็ยังใหญ่พอสมควร
อิมพลีเมนต์อ้างอิงก็สุดท้ายแล้วเป็นการทำ เลขคณิตความแม่นยำตามอำเภอใจ อยู่ดี แต่ก็ไม่ได้แย่ขนาดนั้น
[1] https://research.swtch.com/ftoa
[2] https://go.dev/src/strconv/ftoa.go
เลยสงสัยว่าวิธีคูณตามจำนวนตำแหน่งทศนิยมแล้วแปลงเป็นจำนวนเต็ม ผ่าน itoa() จากนั้นค่อยแทรกจุดทศนิยมในตำแหน่งที่เหมาะสม จะมีประสิทธิภาพกว่าหรือไม่
ในมุมของมือใหม่ C++ ขอถามหน่อยว่า allocator เริ่มต้นของ libc++ หรือก็คืออิมพลีเมนต์ new/delete เริ่มต้น ทำอะไรที่ต่างจากการเรียก malloc/free ของ libc ภายในจริง ๆ ไหม? ถ้าต่าง ทำไมถึงเป็นเช่นนั้น?
delete[] จะพยายามรันดีสตรัคเตอร์ของแต่ละองค์ประกอบก่อนคืนหน่วยความจำ
เพื่อให้ delete[] ทำงานได้ C++ ต้องติดตามขนาดที่จัดสรรไว้ที่ไหนสักแห่ง และข้อมูลนี้อาจวางใกล้บริเวณที่จัดสรร หรือเก็บในโครงสร้างแยกก็ได้
ถ้าใช้โครงสร้างแยก โอกาสที่ข้อมูลจะถูกเขียนทับจากการเขียนหน่วยความจำผิดพลาดหลังอ็อบเจ็กต์จะลดลง แต่ต้องมีต้นทุนการค้นหาและโค้ดเพิ่มเติม
ไลบรารี C++ ที่ทำอย่างถูกต้องคงทำงานมากกว่านี้ แต่ก็พอเห็นภาพได้ว่า new/delete ไม่เหมือน malloc/free
ที่หลายอิมพลีเมนต์ทำแบบนั้นก็เพราะมันมีอยู่แล้วและใช้งานง่ายเท่านั้น
อย่างไรก็ตาม แอปพลิเคชันสามารถแทนที่ operator new เริ่มต้นของไลบรารีมาตรฐานด้วยอิมพลีเมนต์ของตัวเองได้ แม้บนแพลตฟอร์มที่ไม่มีความสามารถเทียบเท่า ELF symbol interposition
ถ้าเป็นไลบรารีฟอร์แมตที่ออกแบบมาให้เล็กและพิมพ์สตริงกับจำนวนเต็มได้ ผมคาดหวังว่าควรอยู่ราว ๆ 50 ไบต์
สำหรับสตริงก็แค่ตรวจอักขระสิ้นสุดแบบ null, พิมพ์อักขระ, กระโดดย้อนกลับสองขั้น ประมาณ 4 คำสั่งก็พอ
สำหรับจำนวนเต็มก็ตรวจค่าลบแล้วพิมพ์ '-' และกลับเครื่องหมาย, ใส่ 1000000000 ใน R1 แล้วหารและเก็บเศษ, บวก ASCII '0', พิมพ์อักขระ, หาร R1 ด้วย 10, ใส่เศษกลับเป็นอินพุต, ทำซ้ำจน R1=0 ประมาณ 20 คำสั่งก็พอ
เลขทศนิยมลอยตัวไม่ได้ใช้ในหลายโปรแกรม จึงควรถูกคอมไพล์เข้ามาเฉพาะเมื่อจำเป็น และเลขฐานสิบหก·พอยน์เตอร์·การเติม 0 ด้านหน้าก็เช่นกัน
ตอนเขียนโค้ดสำหรับไมโครคอนโทรลเลอร์ที่มีพื้นที่โค้ด 2KB คงไม่ใส่ไลบรารีฟอร์แมตสตริงขนาด 14KB เข้าไป
คุณไม่สามารถสร้างไลบรารีที่ฟีเจอร์เยอะ เร็ว และเล็กได้พร้อมกันทั้งหมด
ไม่ค่อยเข้าใจว่านี่ต่างจากการบ่นทั่วไปในที่สาธารณะอย่างไร หรือเป็นเรื่องเฉพาะกับ fmt ตรงไหน
แค่โค้ดอัลกอริทึมอย่าง Dragonbox หรือ Dragon4 ก็เกินงบขนาดแล้ว ดังนั้นฟีเจอร์ “เสริม” จึงไม่ใช่สาระสำคัญนัก
และนั่นก็เป็นเพียงหนึ่งในราว 20 ฟีเจอร์ที่ผู้คนต้องการ
แล้วคนอื่นอาจหาวิธีใส่ฟีเจอร์ได้มากขึ้นอย่างฉลาดกว่าเดิม
ไม่อย่างนั้นก็ไม่ค่อยเห็นประเด็น
ความต้องการนั้นสมเหตุสมผล แต่เป็นสิ่งที่ คอมไพเลอร์ไมโครคอนโทรลเลอร์สเปกต่ำสุด ควรจัดการ ไม่ใช่ข้อกำหนดของภาษา
ถ้าต้องเล็กสุดขีดแลกกับการไม่รองรับแม้แต่ฟีเจอร์พื้นฐาน ก็ย่อมมีตัวเลือกที่ดีกว่าแน่นอน
ถ้ามีพื้นที่โค้ดแค่ 2KB ก็ไม่ควรใช้ตัวนี้
โชคดีที่ไมโครคอนโทรลเลอร์สมัยใหม่ส่วนใหญ่ใหญ่กว่านั้นมาก เช่น esp32 เริ่มที่ 1MB ดังนั้นการใช้ไลบรารีฟอร์แมตขนาด 14KB ก็สมเหตุสมผลพอสมควร
ขอโปรโมตนิดหนึ่ง แม้รวม libc ที่มีการบัฟเฟอร์เอาต์พุต ก็สามารถทำ
printf(Hello, World!\n");เป็น ไฟล์ปฏิบัติการ 1008 ไบต์ ได้: https://github.com/pts/minilibc686แน่นอนว่าถ้าเทียบกันตรง ๆ ก็เหมือนเอาแอปเปิลไปเทียบกับส้ม
ส่วนที่ว่า “ถ้าโปรแกรม C ที่มีฟังก์ชัน main ว่าง ๆ บนระบบนี้มีขนาด 6kB ตอนนี้ {fmt} ก็เพิ่มเข้าไปในไบนารีไม่ถึง 10kB” น่าสนใจดี
ไม่เคยลองทดสอบแบบนี้มาก่อน
ใช้ C library ตัวไหนก็สำคัญ และใช้ ELF หรือคอนเทนเนอร์อื่นก็มีผลอยู่บ้าง
ปัญหาเป็น fmt เสมอ
ตลกมากที่พอไปแตะตัวเลขจำนวนมากพอ โดยเฉพาะ การฟอร์แมต/พาร์สเลขทศนิยมลอยตัวและ decimal ลิงเกอร์ก็จะดึงโค้ดเกี่ยวกับทศนิยมลอยตัวและ BigInt เข้ามาเยอะจนทำให้ขนาดไบนารีใหญ่ขึ้น และตอนนี้เรื่องเดียวกันก็เกิดใน .NET แล้ว
น่าสนุกมาก
ชอบ การปรับแต่งแบบพลิกมุมคิด แบบนี้
ไม่รู้ว่าผมช้าเองหรือเปล่า แต่ใช้เวลาสักพักกว่าจะนึกได้ว่า “14k” ในชื่อเรื่องหมายถึง 14kB
อย่างน้อยในเชิงประวัติศาสตร์ k ก็เป็นตัวย่อที่ใช้กันทั่วไปของ kB