毎年恒例 Stephen Toub さんの Performance Improvements in .NET 7 が8月末に投稿されました。
毎年.NETの改善状況を見るのを楽しみにしているのですが、今回はC#erならだれもが夢見る、C#でネイティブコード出力する機能、 NativeAOT がとても気になります。
記事ではWindowsの例が書いてありますが linuxで試してみたい と思います。
Native AoTとは
Native AOTはCPUネイティブコード(機械語)を直接出力する機能です。
@yajuさんの「dotnet-6.0におけるNativeAOTについて」がとても詳しいのでそちらを是非みてください。
ネイティブ化によって
- パフォーマンス向上
- バイナリーサイズの縮小
- dotnet未インストールマシンでの実行
などの利点が見込めます。例えばラズパイや小さな組み込みマシンでdotnetランタイムをインストールしなくともプログラムが動くとなればかなりのメリットになります。
現在はコンソールアプリのみ等の制約があり、まだまだ改善されていく機能だと思いますが今後の発展がとても楽しみです。
dotnet7.0-preview7 のインストール
ここは本題ではないので簡単に。
今回はRHEL9互換の AlmaLinux 9 を最小インストールした状態から始めます。最小だとtarコマンドすらないのでそこからです。
$ sudo dnf install tar gzip libicu
$ curl -O https://download.visualstudio.microsoft.com/download/pr/aabf15d3-f201-4a6c-9a7e-def050d054af/0a8eba2d8abcf1c28605744f3a48252f/dotnet-sdk-7.0.100-preview.7.22377.5-linux-x64.tar.gz
$ mkdir -p $HOME/dotnet && tar zxf dotnet-sdk-7.0.100-preview.7.22377.5-linux-x64.tar.gz -C $HOME/dotnet
$ export DOTNET_ROOT=$HOME/dotnet
$ export PATH=$PATH:$HOME/dotnet
$ dotnet --version
7.0.100-preview.7.22377.5
exportの2行は .bashrc
にでも書いておきましょう。
Hello, World
パフォーマンスを見たかったのですが今回は出力バイナリを見ることにします。「Hello,World」でいきます。
$ dotnet new console -o con1
Console.WriteLine("Hello, World!");
ILコード出力
まずは普通にIL版。シングルファイルにしてみます。
$ dotnet publish --os linux -c release -o ~/bin/ -p:PublishSingleFile=true
$ ls -l ~/bin
-rwxr-xr-x. 186058 con1
ライブラリ依存で186KB。
$ dotnet publish --os linux -c release -o ~/bin/ \
--self-contained=true \
-p:PublishSingleFile=true \
-p:PublishReadyToRun=true \
-p:PublishTrimmed=true
$ ls -l ~/bin
-rwxr-xr-x. 1 16697946 con1
dotnetランタイムをすべて内包した場合は16MBになりました。
Hello,World で 16MB は大きいですね・・。
Native AOTを使えるようにする
ここからが本番。記事を読む限りcsprojファイルに <PublishAot>true</PublishAot>
を追記することでコンパイル出来るように見えます。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
</PropertyGroup>
</Project>
これでdotnet publish
を実行してみると以下のようにエラーが出ます。
error : Platform linker ('clang' or 'gcc') not found in PATH. Try installing appropriate package for clang or gcc to resolve the problem. [/home/xxx/con1/con1.csproj]
ということで足りないツール、ライブラリを追加でインストールします。
必要なものはgcc
, g++
, zlib
です。
$ sudo dnf install gcc gcc-c++ zlib-devel
Ubuntuなら sudo apt-get install clang zlib1g-dev
でしょうか(公式情報)。
これでネイティブコンパイルできるようになります。出来上がったバイナリーサイズを見てみます。
$ dotnet publish -r linux-x64 -c release -o ~/bin2/
$ ls ~/bin2/
-rwxr-xr-x. 1 15609816 con1
15MB。ちょっと大きいですね。
バイナリーを小さくする。
Performance Improvements in .NET 7 ではWindowsのバイナリーは3MBなのでもっと小さくなる余地はあるはずです。この記事にある以下のコンパイルオプションを適用して再度確認してみます。
-
InvariantGlobalization = true
カルチャー情報を無視 -
UseSystemResourceKeys = true
例外メッセージを除去 -
IlcGenerateStackTraceData = false
スタックトレース情報を最低限に -
DebuggerSupport = false
デバッガサポートなし
その他今回は関係なさそうなSerializerやHttp関連も含めて記述してみました。
<InvariantGlobalization>true</InvariantGlobalization>
<UseSystemResourceKeys>true</UseSystemResourceKeys>
<IlcOptimizationPreference>Size</IlcOptimizationPreference>
<IlcGenerateStackTraceData>false</IlcGenerateStackTraceData>
<DebuggerSupport>false</DebuggerSupport>
<EnableUnsafeBinaryFormatterSerialization>false</EnableUnsafeBinaryFormatterSerialization>
<EventSourceSupport>false</EventSourceSupport>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<MetadataUpdaterSupport>false</MetadataUpdaterSupport>
11MB まで削減できました。
$ dotnet publish -r linux-x64 -c release -o ~/bin3/
$ ls -l ~/bin3/con1
-rwxr-xr-x. 11199792 con1
refrection 関連を除去
今回のコードではrefrectionは使っていないので このドキュメントを参考にしてrefrection関連のコードを落とします。
<RootAllApplicationAssemblies>false</RootAllApplicationAssemblies>
<IlcGenerateCompleteTypeMetadata>false</IlcGenerateCompleteTypeMetadata>
<IlcDisableReflection>true</IlcDisableReflection>
<IlcOptimizationPreference>Size</IlcOptimizationPreference>
<IlcFoldIdenticalMethodBodies>true</IlcFoldIdenticalMethodBodies>
これで6MBまで減りました。
$ ls -l ~/bin4/
-rwxr-xr-x. 1 6233888 con1
埋め込みデバッグ情報を除去
バイナリーを覗いてみるとデバッグ用文字列が入っています。
$ strings ~/bin4/con1
...
S_P_CoreLib_System_Buffers_SpanAction_2<Char__S_P_CoreLib_System_ValueTuple_3<IntPtr__Int32__S_P_CoreLib_System_HexConverter_Casing>>
S_P_CoreLib_System_ValueTuple_2<System___Canon__Int32>
S_P_CoreLib_System_Buffers_SpanAction_2<Char__S_P_CoreLib_System_ValueTuple_2<System___Canon__Int32>>
S_P_CoreLib_System_ValueTuple_3<System___Canon__System___Canon__Int32>
S_P_CoreLib_System_Buffers_SpanAction_2<Char__S_P_CoreLib_System_ValueTuple_3<System___Canon__System___Canon__Int32>>
...
公式ドキュメントでも「MacやLinuxではデバッグ情報がデフォルトで入るのでstripで削ってね」と書いてあるので除去してみます。
$ cp con1 con2
$ strip con2
$ ls -l
-rwxr-xr-x. 6233888 con1
-rwxr-xr-x. 1428120 con2
1.4MB まで減りました。
2022年9月10日追記
linux/mac用にcsprojのオプション <StripSymbols>true</StripSymbols>
がありました。これを使うとstripコマンドと同じ効果があり、デバッグシンボルを *.dbgとして別ファイルに出力してくれます。
出力バイナリーの確認と考察
出来上がったバイナリーは以下の依存関係を持っています。
$ ldd ~/bin3/con1
linux-vdso.so.1 // linuxシステムコール用ライブラリ
libstdc++.so.6 // GNU C++ 標準ライブラリ
libm.so.6 // 数学系の標準ライブラリ
libc.so.6 // Linux Cライブラリ
ld-linux-x86-64.so.2 // ELF形式ファイルのローダー
libgcc_s.so.1 // gccのライブラリ
どんなコードが出力されているのかまでは追ってません。
コンパイルの様子を見るとcppやcファイルは出力されず、DLLが一度出力されています。
ILコードを何らかの形でネイティブにしていると思われますが、UnityのIL2CPPのようにcppにするのではなく、JITのコンパイル結果と同じようなものなのかもしれません。その辺はいつか逆アセンブリして確かめてみたいです。
また、「Hello,World」が 1.4MB は納得できない人が多いかもしれません。
が、Console.WriteLine()はStream 実装が残るでしょうし、文字列を使うため string
の実装が、さらにGC(ガベージコレクション)も入っているのでしょう。まずはしょうがないのかなと思います。
参考
公式ドキュメント(GitHub)
https://github.com/dotnet/runtime/tree/main/src/coreclr/nativeaot/docs
Optimizing programs targeting Native AOT
https://github.com/dotnet/runtime/blob/main/src/coreclr/nativeaot/docs/optimizing.md