1 điểm bởi GN⁺ 2023-07-10 | 1 bình luận | Chia sẻ qua WhatsApp
  • Với một tệp thực thi trên .NET Framework 4.x chỉ in "Hello World", kết quả biên dịch C# thông thường 4,6KB được giảm xuống còn 834 byte bằng cách tự dựng PE thủ công
  • Thử nghiệm đặt ra các điều kiện: entry point được quản lý, một tệp duy nhất có thể chạy trên Windows, chỉ dùng BCL hoặc các thư viện có sẵn trong bản cài Windows thông thường, và không tính các byte 0 ở cuối tệp
  • Việc giảm kích thước được thực hiện bằng cách loại bỏ chú thích nullable reference type và metadata không cần thiết, chuyển sang PE 64-bit để loại bỏ imports và base relocations
  • Thay vì Console.WriteLine, gọi ucrtbase!puts bằng P/Invoke để loại bỏ stream #US UTF-16, và chuyển chuỗi ASCII vào vùng padding giữa PE header và section đầu tiên
  • Tệp thực thi cuối cùng, không tính trailing zero, còn 834B(0x342); khi nén ZIP còn 476B, cho thấy giới hạn cấu hình tối thiểu của .NET PE và CLR metadata

Điều kiện thử nghiệm và mốc cơ sở

  • Mục tiêu là chỉ đưa vào tệp thực thi .NET số byte tối thiểu cần thiết để CLR in "Hello World" ra standard output
  • Quy tắc thử nghiệm như sau
    • Ứng dụng phải chạy một entry point được quản lý được triển khai bằng C# hoặc CIL
    • Runtime mục tiêu là .NET Framework 4.x.x
    • Cấm phụ thuộc bên thứ ba; chỉ cho phép BCL là mscorlib hoặc các thư viện có sẵn trong bản cài Windows thông thường
    • Do các ràng buộc căn chỉnh của PE file và CLR, tệp .NET PE tối thiểu về mặt lý thuyết có thể chạy trên Windows 10 trở lên không thể nhỏ hơn 1KB, nhưng trailing zero bytes ở cuối tệp không được tính vào kích thước
  • Khi build chương trình C# Hello World thông thường với net472, C# 10 và bật nullable, kích thước tệp thực thi là 4,6KB
  • Mã nguồn cuối cùng nằm tại Full Source Code

Những phần có thể giảm từ kết quả biên dịch C#

  • Nullable reference type của C# 8.0 được nhúng tĩnh vào tệp thực thi dưới dạng custom attribute, và các attribute này làm tăng kích thước đáng kể
  • Nếu tắt kiểm tra nullable reference type trong .csproj bằng <Nullable>disable</Nullable>, các attribute sẽ bị loại bỏ, nhưng do căn chỉnh PE file, kích thước toàn bộ tệp vẫn là 4,6KB
  • Kết quả decompile cho thấy trình biên dịch C# vẫn tạo metadata không cần thiết ngay cả khi đã tắt nullable
    • assembly attributes như phiên bản tệp assembly, thông tin bản quyền
    • Kiểu <Module> ẩn ngoài lớp Program
  • Để thu nhỏ cực đoan, thay vì tìm thêm thiết lập trình biên dịch C#, tác giả quyết định trực tiếp tạo tệp thực thi .NET bằng AsmResolver
  • Module tự tạo đặt hàm main trong kiểu <Module> và cấu thành thân CIL chỉ gồm Ldstr "Hello World", Console.WriteLine, Ret
  • Cách này giúp kích thước tệp giảm còn khoảng một nửa so với trước

Loại bỏ imports và base relocations bằng PE 64-bit

  • Tệp thực thi được tạo có hai section là .text.reloc, trong đó các data directory ImportsBase Relocations chiếm nhiều không gian
  • Tệp thực thi .NET AnyCPU hoặc 32-bit cần imports directory vì entry point không quản lý phải gọi mscoree!_CorExeMain
  • Tệp thực thi .NET mặc định có thể relocate, nên cũng cần base relocation cho lời gọi imported function này
  • Base relocation mặc định nằm trong section riêng, và mỗi section được căn chỉnh tối thiểu theo đơn vị 0x200 byte, khiến tệp phình lên tối thiểu trên 1KB
  • Tệp thực thi .NET 64-bit không cần entry point không quản lý, nên có thể loại bỏ hai directory này và section .reloc bằng các thiết lập sau
    • module.PEKind = OptionalHeaderMagic.PE64
    • module.MachineType = MachineType.Amd64
  • Ở bước này, tệp đạt đến kích thước tối thiểu về mặt lý thuyết của một .NET PE hợp lệ là 1KB

Loại bỏ tên metadata, GUID, public key token

  • Từ đây, kích thước được đo không phải theo kích thước tệp trong Windows Explorer, mà theo vị trí ngay sau byte non-zero cuối cùng trong hex editor
  • Theo tiêu chí này, tệp PE 1KB đã giảm xuống còn 991 byte(0x3DF)
  • Heap #Strings là stream metadata lớn thứ hai trong binary .NET, lưu tên kiểu và phương thức mà stream bảng #~ sử dụng
  • Ngay cả khi đặt các tên không quan trọng với runtime thành null, chương trình vẫn chạy bình thường; thao tác này giảm được 32 byte
  • Stream #GUID chứa GUID mà Mvid của bảng Module tham chiếu, nhưng trong thử nghiệm không cần định danh phiên bản nên bị loại bỏ
    • Loại bỏ 16 byte của Mvid
    • Loại bỏ toàn bộ stream #GUID rỗng cùng header 16 byte
    • Tổng cộng giảm 32 byte
  • mscorlib được tham chiếu để gọi Console::WriteLine thường có public key token 8 byte đi kèm
  • Nếu không có public key token, CLR không xác minh tính xác thực của assembly token này; trong thử nghiệm, nó được loại bỏ
  • Sau bước này, kích thước tệp là 918 byte

Loại bỏ Console.WriteLine và dùng chuỗi ASCII

  • "Hello World" được lưu trong stream metadata #US của .NET, được biểu diễn dưới dạng mảng ký tự 16-bit có length prefix và một byte 0 bổ sung
  • Tất cả ký tự của chuỗi cần in đều có code point nhỏ hơn 0xFF, nên có thể biểu diễn bằng ASCII 1 byte, nhưng không có cách nào khiến CLR diễn giải chuỗi trong stream #US là ASCII
  • Thay vì Console::WriteLine, gọi hàm không quản lý ucrtbase!puts bằng P/Invoke
    • Dạng khai báo C# là [DllImport("ucrtbase")] static extern int puts(nint str);
    • puts nhận con trỏ tới chuỗi ASCII kết thúc bằng zero làm đối số
  • Để biết địa chỉ runtime của chuỗi tại thời điểm biên dịch, tắt cờ DynamicBase trong DllCharacteristics của PE optional header
    • Thiết lập image base cố định
    • Đặt chuỗi ASCII trong section .text
    • Địa chỉ runtime được tính bằng module_base_address + rva_ascii_string
  • Cũng bỏ cờ ILOnly trong .NET data directory để CLR tôn trọng cờ này
  • Thay đổi này loại bỏ chuỗi wide-character, stream #US, tham chiếu System.Console::WriteLine, và chỉ thêm định nghĩa puts cùng metadata P/Invoke
  • Kích thước kết quả là 889 byte(0x379)

Các tối ưu vi mô cuối cùng

  • Định nghĩa chuẩn của puts trả về số ký tự đã in dưới dạng int32, nhưng chương trình không dùng giá trị này
  • Trong PE 64-bit, theo x64 calling convention của Microsoft, giá trị trả về nằm trong thanh ghi RAX, không nằm trên evaluation stack của .NET
  • Khi đổi định nghĩa P/Invoke sang trả về void, có thể loại bỏ lệnh pop trong phương thức main vốn dùng để bỏ giá trị trả về
  • Kích thước PE header nhỏ hơn 0x200, nên có padding data giữa header và section đầu tiên
  • Windows PE Loader ánh xạ PE header vào vùng nhớ có thể đọc, và phạm vi này cũng bao gồm cả padding data
  • Chuyển chuỗi ASCII vào vùng padding này giúp giảm thêm 13 byte cho tệp
  • Vì không còn tham chiếu Console::WriteLine, cũng loại bỏ tham chiếu mscorlib
    • Giảm một bảng trong tables stream #~
    • Tên mscorlib cũng bị loại khỏi stream #Strings
  • VersionString của .NET metadata directory mặc định là "v4.0.30319", và sau khi padding theo bội số 4 byte thì chiếm tổng cộng 12 byte
  • Ngay cả khi rút chuỗi này xuống v4.0., CLR 4.0 vẫn boot và chương trình vẫn chạy; sau padding, giảm thêm tổng cộng 4 byte
  • Dấu . trailing dường như là bắt buộc; nếu giảm thêm nữa, chương trình không boot bình thường

Kết quả cuối cùng

  • Kích thước binary cuối cùng, không tính trailing zero, là 834 byte(0x342)
  • Kết quả nén ZIP là 476 byte, nhỏ hơn 582 byte khi không tối ưu thêm sau khi đã đạt giới hạn 1KB
  • Thử nghiệm bắt đầu từ Hello World 4,6KB do trình biên dịch C# tạo ra và giảm xuống tệp PE tự dựng 834B
  • Chương trình cuối cùng vẫn in "Hello World" bình thường
  • Công việc này thiên về một thử nghiệm khám phá giới hạn cấu hình tối thiểu của định dạng tệp module .NET và CLR metadata hơn là tính thực dụng

1 bình luận

 
GN⁺ 2023-07-10
Ý kiến Hacker News
  • Mình rất thích các thử nghiệm kiểu chương trình tối thiểu có thể chạy được
    Michal Strehovský đã dùng framework hiện nay được biết đến là .NET để tạo ra một game Snake dưới 8KB mà không phụ thuộc vào .NET runtime
    Medium không hay lắm và Twitter giờ cũng không hữu ích, nhưng mình nhớ mang máng là "hello world" đại khái còn hỗ trợ cả 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
    Có thể xem các công việc mới nhất và tài trợ tại https://flattened.net: "bflat - C# as you know it but with Go-inspired tooling"

    • Nhân nói về C# "Go-inspired", .NET có System.Threading.Channels
      https://learn.microsoft.com/en-us/dotnet/core/extensions/cha...
    • Nếu ứng dụng C# có thể nhỏ đến mức người dùng cảm thấy thời gian tải xuống là tức thì, mình tự hỏi liệu điều đó có thể giúp C# WebAssembly trở nên thực tế hơn không
      Mình nhớ phần lớn dung lượng tải xuống là do chính thư viện .NET
      Cũng tò mò không biết có framework/engine game 2D nào nhắm tới trình duyệt, dùng C#, mà tạo được binary nhỏ hay không
    • Nhờ Michal và đội CoreRT mà mình nhìn lại C#, và nếu ngày nay không có NativeAOT thì có lẽ mình sẽ không bao giờ cân nhắc C# nữa, nhất là khi đã có Go
  • Bài này có thể gây hiểu nhầm. File thực thi đó cần runtime được cài trên hệ thống, và thực tế là dùng .NET Framework 4.7.2 vào năm 2023
    Để so sánh công bằng với cách này thì có lẽ .jar hoặc file .py sẽ phù hợp hơn
    Nếu lấy file thực thi thực sự làm tiêu chuẩn thì binary Native AOT chạy được trên Windows, Linux, macOS sẽ thích hợp hơn
    Tuy nhiên vì phải bao gồm cả mã phụ trợ như GC, ThreadPool, Console nên việc nó lớn hơn nhiều là điều tự nhiên

    • Không hẳn vậy. File exe đầu ra thực tế về cơ bản không dùng .NET Framework ngoài việc gọi main
      Logic ghi byte ra console được xử lý bằng lời gọi pinvoke tới mã unmanaged do ucrt.dll cung cấp
      Bài viết về tối giản Snake được chia sẻ ở bình luận khác cũng làm điều tương tự trên .NET Core, tức .NET mới không được bundle cùng Windows, và thực sự tạo ra binary không phải .NET
      [0]: https://medium.com/@MStrehovsky/building-a-self-contained-ga...
    • Việc so sánh một binary native được liên kết tĩnh hoàn toàn với một binary tải động một phần hoặc toàn bộ thư viện cũng khá thiếu công bằng
      Có lẽ so sánh container image sẽ là điểm khởi đầu tốt. Hãy dùng image nền nhỏ nhất có thể, hoặc nếu được thì dùng "FROM scratch", nhưng chỉ so sánh trong cùng kiến trúc
      Để loại bỏ các biến số như chạy 32-bit trên 64-bit hoặc nén con trỏ, mình nghiêng về 32-bit
      Sau đó có thể so sánh kích thước export tar chưa nén. Dù không hoàn toàn công bằng, nó vẫn giúp giảm bớt các biến số hiển nhiên
      Nghĩ kỹ hơn thì có lẽ so sánh kích thước image sau khi nén tối đa còn công bằng hơn để phản ánh cả việc nén bên trong container. Tất nhiên phải dùng cùng thuật toán và tham số, hoặc giống benchmark nén thì phải cộng cả kích thước bộ giải nén vào kết quả cuối cùng
    • Hello world của tôi cần framework "bash" và có kích thước 0 byte
      Chỉ cần gọi thế này: echo "hello world"
    • .NET Framework được tích hợp sẵn trong Windows, và ít nhất từ Vista trở đi đã là thành phần bắt buộc
      Cụ thể thì .NET Framework 4.7.2 có lẽ sẽ được bao gồm mặc định trong các phiên bản Windows được hỗ trợ
    • Mình không chắc điều chúng ta nên quan tâm là "công bằng" hay "thực tế"
      Một bản cài Windows hiện đại thì gần như không thể không có .NET, còn Linux và macOS thì mặc định không có, nên lấy đó làm tiêu chí công bằng có vẻ hơi kỳ lạ
  • Nhân tiện, không biết có ai để ý tác giả ghi địa điểm là McMurdo Station ở Nam Cực không
    Tôi không ngờ ở căn cứ đó lại có người dùng kiểu tech stack này: https://usscar.org/directory?combine=&field_usscar_nsf_progr...
    Chuyện này có thể là một câu chuyện khá thú vị

    • Tôi từng đến McMurdo trong một chuyến đi nghiên cứu
      Điều khiến người ngoài ngạc nhiên là phần lớn người ở đó là nhân sự hỗ trợ; các nhà khoa học chỉ chiếm một tỷ lệ nhỏ, chắc khoảng 20%
      Nhân sự hỗ trợ đến từ đủ mọi nghề cần thiết để vận hành một thành phố hiện đại: lính cứu hỏa, thợ mộc, đầu bếp, thợ máy, v.v.
      Có lẽ do quy trình tuyển chọn, gần như ai cũng cực kỳ giỏi và thuộc nhóm hàng đầu trong lĩnh vực của mình. Có rất nhiều nhân tài kiểu dao đa năng Thụy Sĩ với bộ kỹ năng rộng đến mức đáng kinh ngạc
      Những khó khăn rất riêng của việc sống và làm việc ở đó thu hút những con người độc đáo và có động lực mạnh mẽ như côn trùng lao vào ánh đèn, và nhiều người quay lại năm này qua năm khác. Đó là một cộng đồng tuyệt vời
    • Trong trường hợp này tôi không hiểu câu "đã phí phạm ngày thứ Bảy một cách ngớ ngẩn"
      Ở đó thì còn có thể làm gì khác được?
    • Nên xem https://brr.fyi/
    • Tôi tò mò là bạn đã tìm ra bằng cách nào
      Trong metadata của .NET hay about cũng không thấy
  • Người ta nói file bị đội lên ít nhất chừng đó vì phải căn tất cả các section theo giá trị alignment tối thiểu có thể là 0x200 byte, nhưng 0x200 là 512 byte, tức 0,5KB
    Tôi từng làm kiểu tối ưu kích thước PE này từ lâu rồi; nếu nhớ không nhầm thì Windows 9x lấy tối thiểu là 512, còn NT chấp nhận alignment nhỏ tới 1
    Tôi cũng không thấy mẹo chồng DOS MZ và PE header từng là kỹ thuật tối tân ngày xưa: http://www.phreedom.org/research/tinype/
    Và rồi người ta nhận ra rằng trong demoscene, chuyện này còn được làm gọn trong binary 1KB:
    https://www.pouet.net/prodlist.php?type%5B%5D=1k&platform%5B...

  • Đây tuyệt đối không phải là phí thời gian một cách ngớ ngẩn
    Khi đào sâu vào chi tiết của binary, bạn sẽ có được hiểu biết rất sâu về cách chúng được tạo ra, và những khái niệm học được từ đó sẽ làm giàu thêm kiến thức của bạn
    Nếu thấy thú vị và học được điều gì đó thì lúc nào nó cũng đáng để bỏ thời gian và công sức

  • Cái này liên quan và thú vị hơn nhiều: https://twitter.com/MStrehovsky/status/1669502394827419648
    Nội dung là: "một chương trình C# Hello World biên dịch native, hoàn toàn self-contained, bao gồm cả GC và mọi thứ, có thể được thu nhỏ xuống khoảng 440KB"
    .NET classic framework là di sản cũ của Windows không còn được khuyến nghị nữa, và ứng dụng mới không nên xây trên đó

    • File exe tạo ra chỉ phụ thuộc vào chi tiết triển khai của thư viện ucrt.dll bên dưới được lộ ra thông qua lời gọi pinvoke unmanaged, chứ thực ra không "dùng" framework
    • Có thể ngoại lệ nếu bạn đang làm ứng dụng phụ thuộc vào Office/COM API
  • Tôi đã hy vọng đây là nói về binary "thật", tức file thực thi .NET đã được biên dịch native như .NET AOT
    https://learn.microsoft.com/en-us/dotnet/core/deploying/nati...

  • Xin chúc mừng. Vậy là bạn đã bỏ hỗ trợ Unicode và tạo một lời gọi C tới puts
    Chẳng phải cứ biên dịch bằng trình biên dịch C sẽ dễ hơn sao?
    thats_just_c_with_extra_steps.jpg

    • Việc về cơ bản là bỏ qua bước biên dịch thì hơi cụt hứng
  • Tôi vừa thử bằng Oberon+ IDE, và nó tạo ra Hello.dll 2048 byte từ module này
    module Hello
    begin
    println("hello world")
    end Hello
    Dùng trình tạo assembly https://github.com/rochus-keller/Pelib

    • Nicklaus Wirth đã đúng
      Tôi đã có cảm giác đó khi lần đầu dùng Lazarus/FreePascal, và càng đọc các tác phẩm của ông thì tôi càng thấy điều đó đúng hơn
      Gần đây tôi nghĩ có lẽ WASM là con đường để thoát khỏi kiểu "luân hồi" của lối tư duy này, nhưng tôi không thể từ bỏ phần cứng theo cách đó
      Tôi quá nguyên thủy để chấp nhận một cỗ máy mang tính tinh thần mà tôi không thể lấy búa trên bàn đập vỡ
      Một máy tính toàn cục không thể phá hủy như Ethereum, chạy bằng cách tiêu tốn hàng nghìn đô thời gian tính toán trên phần cứng của người khác mà còn chậm hơn RPi, đem lại cho tôi cảm giác gần như cấm kỵ theo một cách rất căn bản
      Tôi vẫn muốn in câu "Wirth was right" lên áo phông cùng khuôn mặt của Wirth
    • Với Oberon+, tôi cứ nghĩ nó phải là self-hosting chứ nhỉ
      Có quá nhiều tàn dư của hệ sinh thái C++
      Nó làm tôi nhớ tới các dự án forge lưu trữ phần mềm mã nguồn mở tự quảng bá là lựa chọn thay thế GitHub và GitLab. Nhưng khi bạn thực sự tới để clone code hay báo lỗi thì tất cả đều nằm trên GitHub
    • Tôi tự hỏi liệu phần cuối file có đang được đệm bằng các byte 0 hay không
      Có vẻ phải có tùy chọn để tắt việc này chứ
  • Việc có thể giảm .exe xuống dưới 1KB thật sự rất mở mang tầm mắt
    Nếu ai đó có thể giảm file .exe Hello World của Flutter xuống 1MB thì đúng là quá ấn tượng

    • Có lẽ bước đầu tiên là thuyết phục Microsoft đưa Flutter vào Windows
    • Không biết chỉ cần biên dịch mỗi "Hello world" bằng Dart thì có được tính hay không