最小の .NET Hello World バイナリはどこまで小さくできるのか?
(blog.washi.dev)-.NET Framework 4.xで"Hello World"だけを出力する実行ファイルを対象に、通常のC#コンパイル結果である 4.6KB を、手作業で構成したPEにより 834バイト まで縮小
- 実験では、マネージドエントリポイント、Windowsで実行可能な単一ファイル、BCLまたは一般的なWindowsインストール済みライブラリのみを使用し、ファイル末尾の 0バイトは除外 して計測するという条件を置いた
- サイズ削減は、nullable参照型注釈と不要なメタデータを取り除き、64ビットPEへ切り替えてimportsとbase relocationsを削除する方法で進められた
Console.WriteLineの代わりにP/Invokeでucrtbase!putsを呼び出してUTF-16の#USストリームをなくし、ASCII文字列はPEヘッダーと最初のセクションの間の パディング領域 へ移した- 最終的な実行ファイルはtrailing zeroを除いて 834B(0x342)、ZIP圧縮時には 476B まで小さくなり、.NET PEとCLRメタデータの最小構成の限界を示した
実験条件とベースライン
- 目標は、.NET実行ファイルの中に必要最小限のバイトだけを入れ、CLRが標準出力に
"Hello World"を出力するようにすること - 実験ルールは以下のとおり
- アプリケーションは、C#またはCILで実装した マネージドエントリポイント を実行しなければならない
- 対象ランタイムは .NET Framework 4.x.x
- サードパーティ依存は不可で、BCLである
mscorlibまたは一般的なWindowsインストール済みライブラリのみ許可 - PEファイルとCLRのアラインメント制約のため、Windows 10以降で実行可能な理論上の最小 .NET PEファイルは1KB未満にはできないが、ファイル末尾の trailing zero bytes はサイズ計算から除外する
- 通常のC# Hello Worldプログラムを
net472、C# 10、nullable enabled設定でビルドすると、実行ファイルのサイズは 4.6KB になる - 最終ソースコードは Full Source Code にある
C#コンパイル結果から削れる部分
- C# 8.0の nullable参照型 はカスタム属性の形で実行ファイルに静的に含まれ、これらの属性がサイズを大きく増やす
.csprojで<Nullable>disable</Nullable>としてnullable参照型チェックを無効にすると属性は削除されるが、PEファイルのアラインメントのため全体のファイルサイズは依然として 4.6KB のまま- 逆コンパイル結果を見ると、C#コンパイラはnullableを無効にしても不要なメタデータを生成し続ける
- アセンブリファイルのバージョンや著作権情報などの assembly attributes
Programクラス以外の隠れた<Module>型
- 極端な縮小のためにC#コンパイラ設定をさらに探す代わりに、AsmResolver を使って .NET実行ファイルを直接作ることにした
- 手作業で作ったモジュールでは、
<Module>型の中にmain関数を置き、Ldstr "Hello World"、Console.WriteLine、Retだけを含むCIL本体を構成した - この方法で、ファイルサイズは従来の約半分まで縮んだ
64ビットPEでimportsとbase relocationsを削除
- 生成された実行ファイルには
.textと.relocの2つのセクションがあり、Imports と Base Relocations のデータディレクトリが大きな領域を占めていた - AnyCPUまたは32ビット .NET実行ファイルでは、アンマネージドエントリポイントが
mscoree!_CorExeMainを呼び出さなければならないため、importsディレクトリが必要になる - 標準的な .NET実行ファイルは再配置可能 (relocatable) なので、このimported function呼び出しのためのbase relocationも必要になる
- base relocationは基本的に別セクションに入れられ、各セクションは最低
0x200バイト単位でアラインされるため、ファイルは最低でも 1KB 以上に膨らむ - 64ビット .NET実行ファイルではアンマネージドエントリポイントが不要なので、次の設定でこの2つのディレクトリと
.relocセクションを削除できるmodule.PEKind = OptionalHeaderMagic.PE64module.MachineType = MachineType.Amd64
- この段階で、有効な .NET PEファイルの理論上の最小サイズである 1KB に到達した
メタデータ名、GUID、public key tokenの削除
- 以降のサイズ測定はWindows Explorerのファイルサイズではなく、16進エディタで 最後のnon-zero byteの次の位置 を基準にした
- 1KBのPEファイルは、この基準ですでに 991バイト(0x3DF) まで小さくなっていた
#Stringsヒープは .NETバイナリで2番目に大きいメタデータストリームであり、テーブルストリーム#~が使う型名・メソッド名を保存している- ランタイム上重要でない名前を
nullにしてもプログラムは正常に動作し、この作業で 32バイト 削減できた #GUIDストリームはModuleテーブルのMvidが参照するGUIDを含むが、この実験ではバージョン識別が不要なため削除したMvidの16バイトを削除- 空の
#GUIDストリーム全体と16バイトのヘッダーも削除 - 合計で 32バイト 削減
Console::WriteLine呼び出しのために参照するmscorlibには通常8バイトのpublic key tokenが付いている- public key tokenがなければ、CLRはこのassembly tokenの真正性を検証せず、この実験ではこれも削除した
- この段階以降、ファイルサイズは 918バイト になった
Console.WriteLine の削除とASCII文字列の使用
"Hello World"は .NETの#USメタデータストリームに保存され、長さプレフィックス付きの 16ビット文字配列 と追加の0バイトで表現される- 出力する文字列の全ての文字はコードポイントが
0xFF未満なので1バイトのASCIIで表現できるが、CLRに#USストリームの文字列をASCIIとして解釈させる方法はない Console::WriteLineの代わりにアンマネージド関数ucrtbase!putsをP/Invokeで呼び出す- C#宣言の形は
[DllImport("ucrtbase")] static extern int puts(nint str); putsはzero-terminated ASCII文字列へのポインタを引数に取る
- C#宣言の形は
- コンパイル時点で文字列のランタイムアドレスを知るため、PE optional headerの
DllCharacteristicsから DynamicBase フラグを無効にした- 固定のimage baseを設定
- ASCII文字列を
.textセクション内に配置 - ランタイムアドレスは
module_base_address + rva_ascii_stringとして計算
- CLRがこのフラグを尊重するよう、.NET data directoryの ILOnly フラグも解除した
- この変更により、ワイド文字列、
#USストリーム、System.Console::WriteLine参照を削除し、その代わりにputs定義とP/Invokeメタデータだけを追加した - 結果のサイズは 889バイト(0x379) になった
最後のマイクロ最適化
putsの標準定義では、出力した文字数をint32で返すが、プログラムではこの値を使用しない- 64ビットPEでは、Microsoftの x64 calling convention に従って戻り値は .NET評価スタックではなく
RAXレジスタに入る - P/Invoke定義を
void戻り値に変更すると、mainメソッドで戻り値を捨てるためのpop命令をなくせる - PEヘッダーのサイズは
0x200より小さいため、ヘッダーと最初のセクションの間にpadding dataが生じる - Windows PE LoaderはPEヘッダーを読み取り可能なメモリにマップし、この範囲にはpadding dataも含まれる
- ASCII文字列をこの パディング領域 に移すと、ファイルをさらに13バイト削減できる
Console::WriteLineをもう参照しないので、mscorlib参照も削除した- tables stream
#~のテーブルが1つ減る #Stringsストリームからmscorlib名も削除される
- tables stream
- .NET metadata directoryの
VersionStringはデフォルトで"v4.0.30319"で、4バイト境界にパディングされるため合計12バイトを占める - この文字列を
v4.0.まで短くしてもCLR 4.0は起動してプログラムを実行でき、パディング後の合計でさらに 4バイト 削減できた - 末尾の
.は必要であり、それ以上削るとプログラムは正常に起動しなかった
最終結果
- 最終バイナリサイズはtrailing zeroを除いて 834バイト(0x342)
- ZIP圧縮結果は 476バイト で、1KB制限到達後に追加最適化をしなかった場合の582バイトより小さい
- 実験はC#コンパイラが生成した 4.6KB のHello Worldから始まり、手作業で構成したPEファイルの 834B まで縮小した
- 最終プログラムは依然として正常に
"Hello World"を出力する - この作業は実用性よりも、.NETモジュールファイル形式とCLRメタデータの 最小構成の限界 を探る実験に近い
1件のコメント
Hacker Newsのコメント
最小実行可能プログラム系の実験は本当に素晴らしい
Michal Strehovský は、現在 .NET として知られているフレームワークを使って、.NET ランタイムへの依存なしに 8KB 未満の Snake ゲームを作った
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 ライブラリ自体だった気がする
ブラウザを対象にしつつ C# を使い、しかも小さなバイナリを作れる 2D ゲームフレームワーク/エンジン があるのかも気になる
この記事は混乱を招くかもしれない。この実行ファイルはシステムにインストールされたランタイムを必要とし、実際には 2023 年に .NET Framework 4.7.2 を使っている
このやり方と公平に比較するなら、.jar や .py ファイルのほうが適切かもしれない
実際の実行ファイルという基準なら、Windows、Linux、macOS で動作する Native AOT バイナリ のほうがより適切だ
ただし GC、ThreadPool、Console のような補助コードまで含める必要があるため、はるかに大きくなるのは自然だ
コンソールにバイトを出力するロジックは、ucrt.dll が公開しているアンマネージドコードへの pinvoke 呼び出しで処理されている
別のコメントで共有されていた Snake の最小化記事は、Windows に同梱されていない新しい .NET、つまり .NET Core をベースに同じことをしており、実際に非 .NET バイナリを生成している
[0]: https://medium.com/@MStrehovsky/building-a-self-contained-ga...
コンテナイメージ を比較するのがよい出発点だと思う。可能な限り最小のベースイメージを使うか、可能なら "FROM scratch" を使うべきで、同じアーキテクチャ同士でのみ比較すべきだ
64 ビット上で 32 ビットを動かすことやポインタ圧縮のような変数を除くには、32 ビットを優先したい
そのうえで、圧縮していない export tar サイズを比較するのがよさそうだ。完全に公平ではなくても、明らかな変数は減らせる
さらに考えると、コンテナ内部の圧縮まで反映するために 最大圧縮後のイメージサイズ を比較するほうが、より公平かもしれない。もちろん同じアルゴリズムとパラメータを使うか、圧縮ベンチマークのように展開側のサイズを最終結果に加える必要はある
こう呼べばよい:
echo "hello world"具体的には、.NET Framework 4.7.2 はサポートされている Windows バージョンにデフォルトで含まれているはずだ
新しい Windows インストールに .NET が入っていないことはありえず、Linux や macOS にはデフォルトで入っていないのに、それを公平性の基準にするのは奇妙に思える
ついでに、作者の所在地が 南極のMcMurdo Station になっているのに気づいた人がいるのか気になる
あの基地にこんな技術スタックの人がいるとは思わなかった: https://usscar.org/directory?combine=&field_usscar_nsf_progr...
かなり面白い話になりそう
外部の人が驚くのは、あそこにいる人の大半が支援要員で、科学者はごく一部、たぶん20%程度しかいないこと
支援要員には、消防士、大工、料理人、整備士など、現代都市を動かすのに必要なあらゆる職種の人がいる
選抜過程のせいか、ほぼ全員が非常に優秀で、それぞれの分野のトップクラスだった。幅広い技能を持つ スイスアーミーナイフ型の人材 が驚くほど多かった
あそこで暮らして働くことの困難さは、独特で強い動機を持つ人たちを、灯りに群がる虫のように引き寄せる。そして多くの人が毎年戻ってくる。素晴らしい共同体だ
あそこで他に何ができるんだ?
.NETのメタデータやaboutには出てこない
すべてのセクションを可能な最小セクションアラインメント値の0x200バイトに合わせる必要があるので、ファイルは少なくともその分だけ膨らむと言っていたが、0x200は512バイト、つまり0.5KBだ
この手のPEサイズ最適化はずっと前にやったことがあるが、Windows 9xでは512が最小で、NTでは1まで小さいアラインメントも受け付けていた記憶がある
昔の最先端テクニックだった、DOS MZとPEヘッダーを重ねるトリックも見当たらなかった: http://www.phreedom.org/research/tinype/
しかもデモシーンでは、これを 1KBバイナリ の中でやってのけていることに気づく:
https://www.pouet.net/prodlist.php?type%5B%5D=1k&platform%5B...
これは決して時間の無駄ではない
バイナリの細部まで掘り下げれば、どう作られているかについて深い洞察が得られるし、そこで学ぶ概念は知識を豊かにしてくれる
面白くて何かを学べるなら、時間と労力をかける価値は常にある
こちらのほうがずっと関連性が高くて面白い: https://twitter.com/MStrehovsky/status/1669502394827419648
「GCなどすべてを含む完全自己完結型のネイティブコンパイルC# Hello Worldは、およそ 440KB まで小さくできる」という内容だ
.NET classic frameworkはWindowsの、もはや推奨されていないレガシーであり、新しいアプリをその上に作るべきではない
「本物の」バイナリ、つまり .NET AOT のようなネイティブコンパイル済みの.NET実行ファイルの話かと期待していた
https://learn.microsoft.com/en-us/dotnet/core/deploying/nati...
最も削り込んだAOTコンパイルビルドは、まもなく1MBの壁を破りそうだ:
https://github.com/AustinWise/SmallestDotnetHelloWorlds
おめでとう。Unicodeサポートを捨てて、
putsへのC呼び出しを作ったわけだそのままCコンパイラでコンパイルしたほうが簡単ではなかったか?
thats_just_c_with_extra_steps.jpgOberon+ IDEで今試してみたら、このモジュールから 2048バイトのHello.dll が出てきた
module Hellobeginprintln("hello world")end Hellohttps://github.com/rochus-keller/Pelib のアセンブリ生成器を使っている
Lazarus/FreePascalを初めて使ったときにそう感じたし、彼の著作を読むほど、ますますその通りだと思うようになった
最近では、この種の思考の「輪廻」から抜け出す道はWASMかもしれないとも思うが、ハードウェアをそこまで手放すことはできない
机の上でハンマーで叩き壊せないような霊的な機械を受け入れるには、私はあまりにも原始的すぎる
他人のハードウェアの上で、何千ドル分もの計算時間を使って動き、RPiより遅いEthereumのような 壊せないグローバルコンピュータ は、何か根本的な意味で私には禁忌のように感じられる
今でもWirthの顔と一緒に「Wirth was right」をTシャツに入れたいと思っている
C++エコシステムの残骸が多すぎる
GitHubやGitLabの代替だと宣伝されるオープンソースのソフトウェアホスティングforgeプロジェクトを思い出す。いざコードをcloneしたりバグを報告しに行くと、結局どれもGitHub上にある
これを無効にするオプションがありそうだ
.exeを 1KB未満まで削減できるというのは目から鱗の内容だった誰かが Flutter の Hello World
.exeを 1MB まで削減できたら、本当にすごいと思う