1 ポイント 投稿者 GN⁺ 2023-07-10 | 1件のコメント | WhatsAppで共有

-.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.WriteLineRet だけを含むCIL本体を構成した
  • この方法で、ファイルサイズは従来の約半分まで縮んだ

64ビットPEでimportsとbase relocationsを削除

  • 生成された実行ファイルには .text.reloc の2つのセクションがあり、ImportsBase Relocations のデータディレクトリが大きな領域を占めていた
  • AnyCPUまたは32ビット .NET実行ファイルでは、アンマネージドエントリポイントが mscoree!_CorExeMain を呼び出さなければならないため、importsディレクトリが必要になる
  • 標準的な .NET実行ファイルは再配置可能 (relocatable) なので、このimported function呼び出しのためのbase relocationも必要になる
  • base relocationは基本的に別セクションに入れられ、各セクションは最低 0x200 バイト単位でアラインされるため、ファイルは最低でも 1KB 以上に膨らむ
  • 64ビット .NET実行ファイルではアンマネージドエントリポイントが不要なので、次の設定でこの2つのディレクトリと .reloc セクションを削除できる
    • module.PEKind = OptionalHeaderMagic.PE64
    • module.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文字列へのポインタを引数に取る
  • コンパイル時点で文字列のランタイムアドレスを知るため、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 名も削除される
  • .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件のコメント

 
GN⁺ 2023-07-10
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"

    • "Go-inspired" な C# の話を続けると、.NET には System.Threading.Channels がある
      https://learn.microsoft.com/en-us/dotnet/core/extensions/cha...
    • C# アプリが、ユーザーがダウンロード時間を即時だと感じるほど小さくできるなら、C# WebAssembly をより現実的にするのに使えるのではないかと気になる
      記憶では、ダウンロードサイズのかなりの部分は .NET ライブラリ自体だった気がする
      ブラウザを対象にしつつ C# を使い、しかも小さなバイナリを作れる 2D ゲームフレームワーク/エンジン があるのかも気になる
    • Michal と CoreRT チームのおかげで C# を見直すようになったし、特に Go がある今、今日の NativeAOT がなければ C# を検討することは決してなかったと思う
  • この記事は混乱を招くかもしれない。この実行ファイルはシステムにインストールされたランタイムを必要とし、実際には 2023 年に .NET Framework 4.7.2 を使っている
    このやり方と公平に比較するなら、.jar や .py ファイルのほうが適切かもしれない
    実際の実行ファイルという基準なら、Windows、Linux、macOS で動作する Native AOT バイナリ のほうがより適切だ
    ただし GC、ThreadPool、Console のような補助コードまで含める必要があるため、はるかに大きくなるのは自然だ

    • 必ずしもそうではない。実際の出力 exe は、main 呼び出し以外では .NET Framework を事実上使っていない
      コンソールにバイトを出力するロジックは、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 サイズを比較するのがよさそうだ。完全に公平ではなくても、明らかな変数は減らせる
      さらに考えると、コンテナ内部の圧縮まで反映するために 最大圧縮後のイメージサイズ を比較するほうが、より公平かもしれない。もちろん同じアルゴリズムとパラメータを使うか、圧縮ベンチマークのように展開側のサイズを最終結果に加える必要はある
    • 私の hello world は "bash" フレームワークが必要で、サイズは 0 バイトだ
      こう呼べばよい: echo "hello world"
    • .NET Framework は Windows に組み込まれており、少なくとも Vista 以降は必須コンポーネントだった
      具体的には、.NET Framework 4.7.2 はサポートされている Windows バージョンにデフォルトで含まれているはずだ
    • 私たちが気にすべきなのが「公平性」なのか「現実」なのかは分からない
      新しい Windows インストールに .NET が入っていないことはありえず、Linux や macOS にはデフォルトで入っていないのに、それを公平性の基準にするのは奇妙に思える
  • ついでに、作者の所在地が 南極のMcMurdo Station になっているのに気づいた人がいるのか気になる
    あの基地にこんな技術スタックの人がいるとは思わなかった: https://usscar.org/directory?combine=&field_usscar_nsf_progr...
    かなり面白い話になりそう

    • 研究旅行でMcMurdoに行ったことがある
      外部の人が驚くのは、あそこにいる人の大半が支援要員で、科学者はごく一部、たぶん20%程度しかいないこと
      支援要員には、消防士、大工、料理人、整備士など、現代都市を動かすのに必要なあらゆる職種の人がいる
      選抜過程のせいか、ほぼ全員が非常に優秀で、それぞれの分野のトップクラスだった。幅広い技能を持つ スイスアーミーナイフ型の人材 が驚くほど多かった
      あそこで暮らして働くことの困難さは、独特で強い動機を持つ人たちを、灯りに群がる虫のように引き寄せる。そして多くの人が毎年戻ってくる。素晴らしい共同体だ
    • この場合の「土曜日を無駄にした」という文はよく分からない
      あそこで他に何ができるんだ?
    • https://brr.fyi/ を見るといい
    • どうやって見つけたのか気になる
      .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の、もはや推奨されていないレガシーであり、新しいアプリをその上に作るべきではない

    • 結果のexeは、アンマネージドな pinvoke 呼び出しで露出している下位のucrt.dllライブラリの実装詳細に依存しているだけで、実際にフレームワークを「使って」いるわけではない
    • Office/COM APIに依存するアプリを作る場合は例外かもしれない
  • 「本物の」バイナリ、つまり .NET AOT のようなネイティブコンパイル済みの.NET実行ファイルの話かと期待していた
    https://learn.microsoft.com/en-us/dotnet/core/deploying/nati...

  • おめでとう。Unicodeサポートを捨てて、puts へのC呼び出しを作ったわけだ
    そのままCコンパイラでコンパイルしたほうが簡単ではなかったか?
    thats_just_c_with_extra_steps.jpg

    • コンパイルを事実上スキップしているのは、少し拍子抜けする
  • Oberon+ IDEで今試してみたら、このモジュールから 2048バイトのHello.dll が出てきた
    module Hello
    begin
    println("hello world")
    end Hello
    https://github.com/rochus-keller/Pelib のアセンブリ生成器を使っている

    • Nicklaus Wirthは正しかった
      Lazarus/FreePascalを初めて使ったときにそう感じたし、彼の著作を読むほど、ますますその通りだと思うようになった
      最近では、この種の思考の「輪廻」から抜け出す道はWASMかもしれないとも思うが、ハードウェアをそこまで手放すことはできない
      机の上でハンマーで叩き壊せないような霊的な機械を受け入れるには、私はあまりにも原始的すぎる
      他人のハードウェアの上で、何千ドル分もの計算時間を使って動き、RPiより遅いEthereumのような 壊せないグローバルコンピュータ は、何か根本的な意味で私には禁忌のように感じられる
      今でもWirthの顔と一緒に「Wirth was right」をTシャツに入れたいと思っている
    • Oberon+なら セルフホスティング であるべきではないかと思う
      C++エコシステムの残骸が多すぎる
      GitHubやGitLabの代替だと宣伝されるオープンソースのソフトウェアホスティングforgeプロジェクトを思い出す。いざコードをcloneしたりバグを報告しに行くと、結局どれもGitHub上にある
    • ファイルの末尾が0バイトで埋められているのか気になる
      これを無効にするオプションがありそうだ
  • .exe1KB未満まで削減できるというのは目から鱗の内容だった
    誰かが Flutter の Hello World .exe を 1MB まで削減できたら、本当にすごいと思う

    • 最初の段階は、Microsoft を説得して Windows に Flutter を含めてもらうことになりそう
    • Dart で "Hello world" だけをコンパイルしたものでも認められるのか気になる