ไฟล์ไบนารี .NET Hello World ที่เล็กที่สุดจะย่อได้แค่ไหน?
(blog.washi.dev)-.NET Framework 4.x โดยใช้ไฟล์ปฏิบัติการที่พิมพ์เพียง "Hello World" เป็นเป้าหมาย ได้ลดผลลัพธ์จากการคอมไพล์ C# ปกติขนาด 4.6KB เหลือ 834 ไบต์ ด้วยการประกอบ PE ด้วยมือ
- การทดลองกำหนดเงื่อนไขว่า ต้องใช้ managed entry point, เป็นไฟล์เดี่ยวแบบ Windows executable, ใช้ได้เฉพาะ BCL หรือไลบรารีที่มีอยู่ใน Windows ปกติเท่านั้น และ ไม่นับ 0 ไบต์ท้ายไฟล์
- การลดขนาดทำโดยตัด nullable reference type annotations และ metadata ที่ไม่จำเป็นออก แล้วเปลี่ยนเป็น 64-bit PE เพื่อลบ imports และ base relocations
- แทนที่จะใช้
Console.WriteLineมีการเรียกucrtbase!putsผ่าน P/Invoke เพื่อตัดสตรีม UTF-16#USออก และย้ายสตริง ASCII ไปไว้ใน พื้นที่ padding ระหว่าง PE header กับเซกชันแรก - ไฟล์ปฏิบัติการสุดท้ายมีขนาด 834B (0x342) เมื่อตัด trailing zero ออก และเมื่อบีบอัด ZIP เหลือ 476B แสดงให้เห็นขีดจำกัดขององค์ประกอบขั้นต่ำใน .NET PE และ CLR metadata
เงื่อนไขการทดลองและค่าตั้งต้น
- เป้าหมายคือใส่เฉพาะไบต์ขั้นต่ำที่จำเป็นในไฟล์ปฏิบัติการ .NET เพื่อให้ CLR พิมพ์
"Hello World"ออกทาง standard output - กติกาของการทดลองมีดังนี้
- แอปพลิเคชันต้องรัน managed entry point ที่เขียนด้วย C# หรือ CIL
- รันไทม์เป้าหมายคือ .NET Framework 4.x.x
- ห้ามมี dependency ของ third-party โดยอนุญาตเฉพาะ BCL อย่าง
mscorlibหรือไลบรารีที่มีอยู่ใน Windows ปกติเท่านั้น - เนื่องจากข้อจำกัดด้านการจัดแนวของไฟล์ PE และ CLR ไฟล์ .NET PE ขั้นต่ำตามทฤษฎีที่รันได้บน Windows 10 ขึ้นไปจึงเล็กกว่า 1KB ไม่ได้ แต่ trailing zero bytes ท้ายไฟล์จะไม่นำมาคิดในขนาด
- หากบิลด์โปรแกรม Hello World แบบ C# ปกติด้วย
net472, C# 10 และเปิด nullable ไว้ ไฟล์ปฏิบัติการจะมีขนาด 4.6KB - ซอร์สโค้ดสุดท้ายทั้งหมดอยู่ที่ Full Source Code
ส่วนที่ลดได้จากผลลัพธ์การคอมไพล์ C#
- nullable reference types ของ C# 8.0 จะถูกฝังแบบ static ลงในไฟล์ปฏิบัติการในรูปของ custom attributes และ attribute เหล่านี้เพิ่มขนาดไฟล์อย่างมาก
- หากปิดการตรวจ nullable reference types ด้วย
<Nullable>disable</Nullable>ใน.csprojattribute เหล่านี้จะหายไป แต่ด้วยข้อจำกัดการจัดแนวของไฟล์ PE ขนาดรวมยังคงเป็น 4.6KB - จากผลการ decompile พบว่าแม้จะปิด nullable แล้ว C# compiler ก็ยังสร้าง metadata ที่ไม่จำเป็นต่อไป
- assembly attributes อย่าง file version หรือข้อมูลลิขสิทธิ์
- ชนิด
<Module>แบบซ่อนที่อยู่นอกเหนือจากคลาสProgram
- แทนที่จะไล่หาการตั้งค่าเพิ่มใน C# compiler เพื่อบีบให้เล็กสุด จึงเลือกสร้างไฟล์ปฏิบัติการ .NET โดยตรงด้วย AsmResolver
- โมดูลที่สร้างเองนี้ใส่ฟังก์ชัน
mainไว้ในชนิด<Module>และมี CIL body เพียงLdstr "Hello World",Console.WriteLine,Ret - วิธีนี้ทำให้ขนาดไฟล์ลดลงเหลือประมาณครึ่งหนึ่งของเดิม
ลบ imports และ base relocations ด้วย 64-bit PE
- ไฟล์ปฏิบัติการที่สร้างขึ้นมีสองเซกชันคือ
.textและ.relocโดย data directory ของ Imports และ Base Relocations กินพื้นที่ค่อนข้างมาก - ไฟล์ปฏิบัติการ .NET แบบ AnyCPU หรือ 32 บิต ต้องมี unmanaged entry point ที่เรียก
mscoree!_CorExeMainจึงจำเป็นต้องมี imports directory - ไฟล์ปฏิบัติการ .NET ปกติมักรองรับการ relocate ได้ จึงต้องมี base relocation สำหรับการเรียก imported function นี้ด้วย
- base relocation โดยทั่วไปจะอยู่ในเซกชันแยก และแต่ละเซกชันต้องจัดแนวอย่างน้อยทีละ
0x200ไบต์ ทำให้ขนาดไฟล์พองเป็นอย่างน้อย 1KB - ไฟล์ปฏิบัติการ .NET แบบ 64 บิตไม่ต้องมี unmanaged entry point จึงลบทั้งสอง directory และเซกชัน
.relocได้ด้วยการตั้งค่าดังนี้module.PEKind = OptionalHeaderMagic.PE64module.MachineType = MachineType.Amd64
- ขั้นตอนนี้ทำให้ไปถึงขนาดขั้นต่ำตามทฤษฎีของไฟล์ .NET PE ที่ใช้งานได้ คือ 1KB
ลบชื่อ metadata, GUID และ public key token
- หลังจากนั้น การวัดขนาดจะไม่ดูจากขนาดไฟล์ใน Windows Explorer แต่ดูจาก ตำแหน่งถัดจาก non-zero byte ตัวสุดท้าย ใน hex editor
- ไฟล์ PE ขนาด 1KB ในเกณฑ์นี้ลดลงมาอยู่ที่ 991 ไบต์ (0x3DF) แล้ว
- heap
#Stringsเป็น metadata stream ที่มีขนาดใหญ่เป็นอันดับสองในไบนารี .NET และเก็บชื่อ type กับ method ที่ tables stream#~ใช้งาน - สามารถตั้งชื่อที่ไม่สำคัญต่อรันไทม์เป็น
nullได้โดยที่โปรแกรมยังทำงานปกติ และลดไปได้ 32 ไบต์ - สตรีม
#GUIDเก็บ GUID ที่Mvidในตาราง Module อ้างอิง แต่ในการทดลองนี้ไม่ต้องใช้ตัวระบุเวอร์ชัน จึงลบออกได้- ลบ
Mvidขนาด 16 ไบต์ - ลบทั้งสตรีม
#GUIDที่ว่างเปล่าและ header 16 ไบต์ของมัน - รวมแล้วลดได้ 32 ไบต์
- ลบ
mscorlibที่ถูกอ้างอิงเพื่อเรียกConsole::WriteLineโดยทั่วไปจะมี public key token ขนาด 8 ไบต์ติดมาด้วย- หากไม่มี public key token, CLR จะไม่ตรวจสอบความถูกต้องของ assembly token นี้ และในการทดลองนี้จึงลบมันออก
- หลังขั้นตอนนี้ ขนาดไฟล์เหลือ 918 ไบต์
ลบ Console.WriteLine และใช้สตริง ASCII
"Hello World"ถูกเก็บไว้ใน metadata stream#USของ .NET โดยอยู่ในรูป อาร์เรย์ตัวอักษร 16 บิต ที่มี length prefix และมี 0 ไบต์เพิ่มท้าย- อักขระทุกตัวในสตริงที่จะพิมพ์มี code point ต่ำกว่า
0xFFจึงแทนเป็น ASCII แบบ 1 ไบต์ได้ แต่ไม่มีวิธีให้ CLR ตีความสตริงใน#USstream เป็น ASCII - จึงเรียก unmanaged function
ucrtbase!putsผ่าน P/Invoke แทนConsole::WriteLine- รูปแบบประกาศใน C# คือ
[DllImport("ucrtbase")] static extern int puts(nint str); putsรับพอยน์เตอร์ไปยังสตริง ASCII ที่ลงท้ายด้วยศูนย์
- รูปแบบประกาศใน C# คือ
- เพื่อให้รู้ runtime address ของสตริงตั้งแต่ตอนคอมไพล์ จึงปิดแฟลก DynamicBase ใน
DllCharacteristicsของ PE optional header- ตั้ง image base แบบคงที่
- วางสตริง ASCII ไว้ในเซกชัน
.text - คำนวณ runtime address เป็น
module_base_address + rva_ascii_string
- เพื่อให้ CLR เคารพแฟลกนี้ จึงปิดแฟลก ILOnly ใน .NET data directory ด้วย
- การเปลี่ยนนี้ทำให้ลบ wide-character string, สตรีม
#USและการอ้างอิงSystem.Console::WriteLineออก แล้วแทนด้วย definition ของputsและ metadata สำหรับ P/Invoke - ผลลัพธ์คือขนาดเหลือ 889 ไบต์ (0x379)
micro-optimization ขั้นสุดท้าย
- definition มาตรฐานของ
putsจะคืนค่าจำนวนอักขระที่พิมพ์เป็นint32แต่โปรแกรมนี้ไม่ได้ใช้ค่าดังกล่าว - ใน 64-bit PE ตาม x64 calling convention ของ Microsoft ค่าที่คืนมาจะอยู่ในรีจิสเตอร์
RAXไม่ได้อยู่บน .NET evaluation stack - หากเปลี่ยน P/Invoke definition ให้คืนค่าเป็น
voidก็จะลบคำสั่งpopที่ใช้ทิ้งค่าคืนในเมธอดmainได้ - ขนาด PE header เล็กกว่า
0x200จึงเกิด padding data ระหว่าง header กับเซกชันแรก - Windows PE Loader จะ map PE header เข้าหน่วยความจำแบบอ่านได้ และช่วงนี้รวม padding data ด้วย
- หากย้ายสตริง ASCII ไปไว้ใน พื้นที่ padding นี้ จะลดขนาดไฟล์ได้อีก 13 ไบต์
- เนื่องจากไม่อ้างอิง
Console::WriteLineอีกต่อไป จึงลบการอ้างอิงmscorlibออกได้ด้วย- มีตารางหนึ่งใน tables stream
#~ที่ลดลง - ชื่อ
mscorlibก็ถูกลบออกจากสตรีม#Stringsด้วย
- มีตารางหนึ่งใน tables stream
VersionStringใน .NET metadata directory โดยปกติเป็น"v4.0.30319"และมี padding ให้เป็นจำนวนเท่าของ 4 ไบต์ รวมเป็น 12 ไบต์- หากย่อสตริงนี้เหลือแค่
v4.0.ก็ยังบูต CLR 4.0 และรันโปรแกรมได้ ทำให้ลดได้อีก 4 ไบต์ หลังรวม padding - จุด
.ท้ายสตริงดูเหมือนจำเป็น เพราะหากตัดให้สั้นกว่านี้ โปรแกรมจะบูตไม่สำเร็จ
ผลลัพธ์สุดท้าย
- ไบนารีสุดท้ายมีขนาด 834 ไบต์ (0x342) เมื่อตัด trailing zero ออก
- เมื่อบีบอัด ZIP จะเหลือ 476 ไบต์ ซึ่งเล็กกว่ากรณีที่ยังไม่ได้ทำ optimization เพิ่มหลังแตะขีดจำกัด 1KB ซึ่งอยู่ที่ 582 ไบต์
- การทดลองนี้เริ่มจาก Hello World ขนาด 4.6KB ที่ C# compiler สร้าง และลดลงจนเหลือไฟล์ PE ที่ประกอบเองขนาด 834B
- โปรแกรมสุดท้ายยังคงพิมพ์
"Hello World"ได้ตามปกติ - งานนี้ใกล้เคียงกับการทดลองเพื่อสำรวจ ขีดจำกัดขององค์ประกอบขั้นต่ำ ของรูปแบบไฟล์โมดูล .NET และ CLR metadata มากกว่าการใช้งานจริง
1 ความคิดเห็น
ความเห็นจาก Hacker News
ชอบการทดลองแนว โปรแกรมที่เล็กที่สุดที่ยังรันได้จริง แบบนี้มาก
Michal Strehovský เคยใช้เฟรมเวิร์กที่ตอนนี้รู้จักกันในชื่อ .NET สร้างเกม Snake ที่มีขนาดไม่ถึง 8KB โดยไม่ต้องพึ่ง .NET runtime ภายนอก
Medium ไม่ค่อยโอเค และตอนนี้ Twitter ก็ไม่ค่อยมีประโยชน์ แต่จำได้คร่าว ๆ ว่า "hello world" ตัวนั้นรองรับถึง Windows 3.11 ด้วย
https://medium.com/@MStrehovsky/building-a-self-contained-ga... [https://web.archive.org/web/20200103110836/https://medium.co... | https://archive.ph/b6qXE]
https://news.ycombinator.com/item?id=22010159 (2020)
https://news.ycombinator.com/item?id=22104734
งานล่าสุดและช่องทางสนับสนุนดูได้ที่ https://flattened.net: "bflat - C# as you know it but with Go-inspired tooling"
https://learn.microsoft.com/en-us/dotnet/core/extensions/cha...
จำได้ว่าขนาดดาวน์โหลดส่วนใหญ่เป็นตัวไลบรารีของ .NET เอง
และก็สงสัยว่ามี เฟรมเวิร์ก/เอนจินเกม 2D ที่ทำไบนารีขนาดเล็กได้ โดยเจาะเป้าเบราว์เซอร์และใช้ C# หรือไม่
โพสต์นี้อาจทำให้สับสนได้ ไฟล์ executable นี้ต้องใช้ runtime ที่ติดตั้งอยู่ในระบบ และจริง ๆ แล้วในปี 2023 ก็ยังใช้ .NET Framework 4.7.2
ถ้าจะเทียบวิธีนี้อย่างยุติธรรม อาจควรเทียบกับไฟล์ .jar หรือ .py มากกว่า
ถ้าเอาเกณฑ์เป็นไฟล์ executable จริง ๆ Native AOT binary ที่รันได้บน Windows, Linux, macOS น่าจะเหมาะกว่า
แต่ก็เป็นธรรมดาที่มันจะใหญ่กว่ามาก เพราะต้องรวมโค้ดช่วยเหลืออย่าง GC, ThreadPool, Console เข้าไปด้วย
ลอจิกที่พิมพ์ไบต์ออกคอนโซลทำผ่านการเรียก pinvoke ไปยัง unmanaged code ที่ ucrt.dll เปิดให้ใช้
บทความลดขนาด Snake ที่แชร์ในคอมเมนต์อื่นก็ทำสิ่งเดียวกันบน .NET Core ซึ่งเป็น .NET รุ่นใหม่ที่ไม่ได้ bundle มากับ Windows และได้ไบนารีที่ไม่ใช่ .NET ออกมาจริง ๆ
[0]: https://medium.com/@MStrehovsky/building-a-self-contained-ga...
การเทียบ container image น่าจะเป็นจุดเริ่มต้นที่ดี ใช้ base image ที่เล็กที่สุดเท่าที่ทำได้ หรือถ้าเป็นไปได้ก็ใช้ "FROM scratch" แต่ต้องเทียบกันในสถาปัตยกรรมเดียวกันเท่านั้น
ถ้าจะตัดตัวแปรอย่างการรัน 32 บิตบน 64 บิต หรือ pointer compression ออกไป ก็น่าจะเลือก 32 บิต
จากนั้นอาจเทียบขนาด export tar แบบไม่บีบอัด ถึงจะไม่ยุติธรรมสมบูรณ์แต่ก็ช่วยลดตัวแปรที่ชัดเจนได้
คิดไปคิดมา การเทียบ ขนาด image หลังบีบอัดสูงสุด อาจยุติธรรมกว่าด้วยซ้ำ ถ้าต้องการนับผลจากการบีบอัดภายในคอนเทนเนอร์ด้วย แน่นอนว่าต้องใช้อัลกอริทึมและพารามิเตอร์เดียวกัน หรือไม่ก็เหมือน benchmark การบีบอัดที่ต้องบวกขนาดตัวถอดบีบอัดเข้าไปในผลลัพธ์สุดท้ายด้วย
เรียกแบบนี้ได้เลย:
echo "hello world"โดยเฉพาะ .NET Framework 4.7.2 ก็น่าจะมีมาให้เป็นค่าเริ่มต้นใน Windows เวอร์ชันที่ยังรองรับอยู่
บน Windows ติดตั้งใหม่ในปัจจุบันแทบเป็นไปไม่ได้ที่จะไม่มี .NET แต่บน Linux และ macOS ไม่มีมาให้โดยปริยาย การเอาเรื่องนั้นมาตั้งเป็นเกณฑ์ความยุติธรรมก็ดูแปลก ๆ
เสริมอีกนิด สงสัยว่ามีใครสังเกตเห็นไหมว่าผู้เขียนอยู่ที่ McMurdo Station, Antarctica
ไม่คิดเลยว่าจะมีคนที่ใช้เทคสแต็กแบบนี้อยู่ที่ฐานนั้นด้วย: https://usscar.org/directory?combine=&field_usscar_nsf_progr...
น่าจะเป็นเรื่องที่น่าสนใจทีเดียว
สิ่งที่คนนอกมักแปลกใจคือ คนส่วนใหญ่ที่นั่นเป็นบุคลากรสนับสนุน ส่วนนักวิทยาศาสตร์มีสัดส่วนเล็กน้อย น่าจะราว ๆ 20% เท่านั้น
บุคลากรสนับสนุนมาจากแทบทุกสายอาชีพที่จำเป็นต่อการขับเคลื่อนเมืองสมัยใหม่ เช่น นักดับเพลิง ช่างไม้ พ่อครัว ช่างซ่อมบำรุง ฯลฯ
อาจเป็นเพราะกระบวนการคัดเลือก คนแทบทั้งหมดจึงเก่งมากและอยู่ระดับแนวหน้าของสายงานตัวเอง มีคนแบบ Swiss Army knife ที่มีทักษะกว้างขวางอยู่มากจนน่าทึ่ง
ความยากลำบากอันเป็นเอกลักษณ์ของการใช้ชีวิตและทำงานที่นั่นดึงดูดคนที่มีแรงขับสูงและมีความเป็นเอกลักษณ์เข้ามาเหมือนแมลงเข้าหาแสงไฟ และหลายคนก็กลับไปทุกปี เป็นชุมชนที่ยอดเยี่ยม
อยู่ที่นั่นแล้วจะไปทำอะไรอย่างอื่นได้อีกล่ะ?
ในเมตาดาต้า .NET หรือ about ก็ไม่เห็นมี
มีคนบอกว่ามันทำให้ไฟล์บวมขึ้นอย่างน้อยเท่านั้น เพราะทุกเซกชันต้องจัดแนวตามค่าการจัดแนวเซกชันขั้นต่ำที่เป็นไปได้คือ 0x200 ไบต์ แต่ 0x200 คือ 512 ไบต์ หรือ 0.5KB
ผมเคยทำ optimization ขนาด PE ระดับนี้มานานมากแล้ว แต่จำได้ว่า Windows 9x มีขั้นต่ำที่ 512 ส่วน NT รับค่าการจัดแนวที่เล็กได้ถึง 1
และผมก็ไม่เห็นทริกสมัยก่อนที่เคยล้ำที่สุดอย่างการซ้อนทับ DOS MZ กับ PE header: http://www.phreedom.org/research/tinype/
แล้วยังทำให้นึกได้ว่าใน demoscene เขาทำสิ่งนี้กันใน 1KB binary ได้ด้วย:
https://www.pouet.net/prodlist.php?type%5B%5D=1k&platform%5B...
นี่ไม่ใช่การเสียเวลาแบบโง่ ๆ เลย
การเจาะลึกลงไปในรายละเอียดของไบนารีทำให้ได้ความเข้าใจลึกซึ้งว่ามันถูกสร้างขึ้นอย่างไร และแนวคิดที่เรียนรู้จากตรงนั้นก็ช่วยเพิ่มพูนความรู้ได้มาก
ถ้ามันน่าสนใจและทำให้ได้เรียนรู้อะไรสักอย่าง มันก็คุ้มค่ากับเวลาและความพยายามเสมอ
อันนี้เกี่ยวข้องและน่าสนใจกว่ามาก: https://twitter.com/MStrehovsky/status/1669502394827419648
ใจความคือ "C# Hello World แบบ native compile ที่ self-contained สมบูรณ์รวมทุกอย่างแม้แต่ GC สามารถย่อเหลือประมาณ 440KB ได้"
.NET classic framework เป็นมรดกเก่าของ Windows ที่ไม่แนะนำให้ใช้อีกต่อไปแล้ว และไม่ควรเอาไปเป็นฐานสำหรับแอปใหม่
ผมหวังว่าจะเป็นเรื่องของไบนารี "ของจริง" คือไฟล์รัน .NET ที่ native compile แล้วแบบ .NET AOT
https://learn.microsoft.com/en-us/dotnet/core/deploying/nati...
บิลด์ AOT ที่ลดขนาดได้มากที่สุดดูเหมือนใกล้จะทะลุกำแพง 1MB เร็ว ๆ นี้:
https://github.com/AustinWise/SmallestDotnetHelloWorlds
ยินดีด้วย คุณเพิ่งตัดการรองรับ Unicode ทิ้ง แล้วสร้าง C call ไปที่
putsแบบนี้ใช้ C compiler คอมไพล์ตรง ๆ เลยจะไม่ง่ายกว่าหรือ?
thats_just_c_with_extra_steps.jpgผมเพิ่งลองใน Oberon+ IDE แล้วได้ Hello.dll ขนาด 2048 ไบต์ จากโมดูลนี้
module Hellobeginprintln("hello world")end Helloใช้ตัวสร้างแอสเซมบลี https://github.com/rochus-keller/Pelib
ผมรู้สึกแบบนั้นตั้งแต่ครั้งแรกที่ใช้ Lazarus/FreePascal และยิ่งอ่านงานเขียนของเขามากขึ้นก็ยิ่งรู้สึกว่าใช่
ช่วงหลังผมก็คิดว่าทางออกจาก "การเวียนว่าย" ของแนวคิดแบบนี้อาจเป็น WASM แต่ผมยังปล่อยมือจากฮาร์ดแวร์แบบนั้นไม่ได้
ผมมันดิบเกินกว่าจะยอมรับเครื่องจักรทางจิตวิญญาณที่คุณเอาค้อนบนโต๊ะมาทุบไม่ได้
คอมพิวเตอร์ส่วนกลางระดับโลกแบบ ทำลายไม่ได้ อย่าง Ethereum ที่รันอยู่บนฮาร์ดแวร์ของคนอื่น ใช้เวลาคำนวณมูลค่าหลายพันดอลลาร์ และยังช้ากว่า RPi ให้ความรู้สึกเหมือนเป็นของต้องห้ามสำหรับผมในระดับพื้นฐานบางอย่าง
ผมยังอยากทำเสื้อยืดที่มีหน้า Wirth พร้อมคำว่า "Wirth was right"
มีเศษซากของ ecosystem C++ เยอะเกินไป
มันทำให้นึกถึงโปรเจกต์ forge สำหรับโฮสต์ซอฟต์แวร์โอเพนซอร์สที่ชอบโปรโมตตัวเองว่าเป็นทางเลือกแทน GitHub กับ GitLab แต่พอจะไป clone โค้ดหรือรายงานบั๊กจริง ๆ กลับอยู่บน GitHub กันหมด
น่าจะมีออปชันสำหรับปิดสิ่งนี้
เนื้อหาที่ทำให้มุมมองเปิดกว้างคือการที่สามารถลดขนาด
.exeให้ต่ำกว่า 1KB ได้ถ้าใครสามารถลดขนาด Hello World
.exeของ Flutter ลงมาเหลือ 1MB ได้ก็คงสุดยอดมาก"Hello world"ด้วย Dart จะนับว่าใช้ได้ไหม