1. はじめに
まず、.NET のリリース履歴についてご紹介します。以前の .NET フレームワークは、最終的なコンパイル結果を単一ファイルで発行する機能をネイティブでサポートしていませんでした(サードパーティ製ツールに依存する必要がありました)。ここでシンプルな ASP.NET Core プロジェクトを作成し、発行後のディレクトリを確認すると、下図のように多数の *.dll ファイルやその他の様々なファイルが含まれていることがわかります。

.NET Core 2.1 時代に、単一ファイル発行機能が導入されました。発行コマンドに -p:PublishSingleFile=true パラメータを追加するだけで使用でき、これ以降、発行フォルダにはそれほど多くのファイルはなくなり、1 つの *.exe ファイルと対応する設定ファイル、デバッグ用の *.pdb ファイルだけになりました。以下に示します。

しかし、この時点の .NET では、実行に 50~130MB 程度の .NET Runtime をインストールする必要がありました。これはクライアントシナリオでのプログラム配布には不利であり、読者の皆さんは、あるソフトウェアをインストールする前に .NET Framework をインストールしなければならなかった場面を思い出せるでしょう。

単一ファイル発行の導入と同時に、--self-contained true パラメータを使用して、ランタイムも発行ファイルに含めることが可能になりました。これにより、ターゲットマシンに .NET Runtime をインストールする必要がなくなります。しかし、ランタイムを含むため、発行フォルダ全体のサイズは非常に大きくなり、.NET Runtime をインストールするよりもさらに大きくなる可能性があります(実に 82.4MB)。

プログラムは本質的にはファイルです。圧縮機能を使ってサイズを小さくすることもできます。-p:EnableCompressionInSingleFile=true パラメータを追加するだけで、80MB のプログラムを約 44MB まで圧縮できます。

単一ファイル発行のサイズが大きい理由は、実行時に必要となる可能性のあるすべての依存関係が含まれているためです。ただし、実際のプログラムで使用しない依存関係も多く含まれているため、発行時に -p:PublishTrimmed=true パラメータを追加することで、未使用の依存関係を削除し、サイズを大幅に削減できます(44MB から 35MB へ)。

もちろん、未使用の依存関係の削除と圧縮は同時に使用できます。これにより、発行後のサイズはさらに小さくなり、約 20MB になります。

この時点でも .NET の実行にはランタイムを内蔵する必要があり、.NET プログラムの実行時には JIT が関与します。そのため、アプリケーションの起動時には JIT が MSIL をターゲットプラットフォームのマシンコードにコンパイルするための時間が必要です。その後、.NET はプレビュー版の Native-AOT を導入し、コンパイル時に直接コードをターゲットプラットフォームのマシンコードにコンパイルできるようになり、起動速度を向上させました。さらに、ランタイムを内蔵する必要がないため、全体のサイズも非常に小さくなりました。

デバッグ用の pdb ファイルは非常に大きくなりますが、実際のリリースではこのファイルは不要なので削除できます。AOT 後のサイズは約 20MB です。ただし、AOT は銀の弾丸ではありません。JIT がないため、多くのコンパイル時最適化が行えません。Java の GraalVM をリリースする際には五角形の図があり、JIT と AOT のトレードオフを明確に示していました。

AOT は、より高速な起動速度、より低いメモリ使用量、より小さなプログラムサイズを実現します。ただし、スループットと最大レイテンシのパフォーマンスはそれほど良くありません(さらに、多くの動的機能を失い、プログラミング効率が低下する可能性もあります)。
疑問が浮かびます。このような発行方法はプログラムのパフォーマンスに影響を与えるのでしょうか? AOT によってプログラムの起動速度が速くなると言われていますが、どの程度速くなるのでしょうか?
2. 評価結果
この問題について少し時間をかけて調査することにしました。週末に上記の疑問を持って一連のテストを設計しましたが、時間が限られていたため不正確な点も多く、参考程度にご覧いただき、ご指摘やご容赦いただければ幸いです。全部で 12 のグループを設計し、主に単一ファイル発行、AOT 発行、通常発行の違いを比較しました。また、PGO、TC、OSR、OSA などの JIT パラメータも追加し、異なる JIT パラメータの影響を確認しました。
PGO:PGO は Profile Guided Optimization(プロファイルに基づく最適化)の略で、実行時情報を収集して JIT がコードを最適化する方法を導きます。以前の PGO なしの状態と比較して、これまで困難だった最適化が可能になります。詳細は hez 氏のブログや、その他のリンク1、リンク2、リンク3を参照してください。
TC:TC は Tiered Compilation(階層型コンパイル)の略で、実行時にコードを最適化する技術です。各 C# 関数は JIT によってターゲットプラットフォームのマシンコードにコンパイルされますが、メソッドを素早く実行するために、JIT は通常は粗い(最適ではない、生成コードの効率が低い)コンパイルを行います。そのため、JIT は TC を導入し、特定のメソッドが頻繁に呼び出される場合、JIT はより最適なコードをコンパイルし、次回そのメソッドが呼び出されたときに効率的に実行できるようにします。.NET の階層型コンパイルの詳細については、このリンクを参照してください。
OSR:OSR は On-Stack Replacement(スタック上置換)の略で、実行中の関数/メソッドのスタックフレームを置き換える技術です。これは階層型コンパイルのために導入されました。時には
while(true)のような無限ループメソッドを実行している場合、階層型コンパイルでは低最適化コードを高最適化コードに置き換えるタイミングを見つけられないため、スタック上置換が導入され、メソッドの実行中により最適なメソッドに置き換えられるようになりました。リンク1、リンク2
OSA:OSA は Object Stack Allocation(オブジェクトのスタック割り当て)の略です。.NET では参照オブジェクトはデフォルトでヒープに割り当てられ、解放時にはガベージコレクタの介入が必要であり、オブジェクト割り当て時にはメモリの初期化(すべて 0 に初期化)が必要です。オブジェクトのライフサイクルが制御可能であれば、スタックに割り当てることができます。これにより、GC の負荷を軽減(メソッドスタックの終了時にオブジェクトが自動解放される)し、パフォーマンスを向上(スカラー置換によりアクセスが高速化)できます。リンク1
各グループの名前とパラメータは以下の通りです。
| プロジェクト | 備考 |
|---|---|
| Normal | 通常発行、対照グループ |
| Normal-WksGC | 通常方式、WorkStationGC を使用 |
| Normal_PGO | 通常発行、PGO を使用 |
| Normal_PGO_OSR | 通常発行、OSR を使用 |
| Normal_PGO_OSR_OSA | 通常発行、PGO+OSR+OSA を使用 |
| SingleFilePublish | 通常の単一ファイル発行 |
| SingleFilePublish-SelfContained | ランタイムを含む単一ファイル発行 |
| SingleFilePublish-SelfContained-Trim | ランタイムを含む単一ファイル発行 + アセンブリトリミング |
| SingleFilePublish-SelfContained-Compress | ランタイムを含む単一ファイル発行 + アセンブリ圧縮 |
| SingleFilePublish-SelfContained-Trim-Compress | ランタイムを含む単一ファイル発行 + トリミング + 圧縮 |
| AOT-Size | AOT コンパイル、Size モード使用 |
| AOT-Speed | AOT コンパイル、Speed モード使用 |
以下の小見出しは、評価項目と評価結果を示しています。各項目は 5 回実行し、平均値を取っています。
2.1 発行関連
このセクションでは、Normal のいくつかの項目はコンパイルパラメータが同一であるため、結果にほとんど差はなく、注目する必要はありません。無視してください。
2.1.1 発行時間
発行時間は、dotnet publish の時間を記録したものです。/bin、/obj などのフォルダをクリーンアップし、キャッシュの影響を避けています。

単一ファイル発行と AOT 発行はパフォーマンスをかなり消費することがわかります。特に AOT シナリオでは、シンプルな ASP.NET Core プロジェクトでも発行時間が約 30 秒に達し、Rust や C++ プロジェクトのコンパイル速度に匹敵します。さらに大規模なプロジェクトではさらに長くなるでしょう。ただし、通常の発行は非常に高速で、1~2 秒以内に完了します。
2.1.2 ディレクトリサイズ
ディレクトリサイズは、発行後のディレクトリが占有するディスク容量を直接集計したものです。注意:Normal 発行では、.NET Runtime の占有容量 67.5MB を含めて計算しています。

なぜ AOT のディレクトリサイズが大きくなるのでしょうか?主な理由は、前述のデバッグ用 pdb ファイルが非常に大きくなることです。これは、AOT 後はプログラム自体に多くのデバッグデータが失われ、pdb ファイルに格納する必要があるためです。ただし、使用には影響がなく、-p:DebugType=false および -p:DebugSymbols=false パラメータを使用して pdb ファイルを生成しないようにすることもできます。
2.1.3 プログラムサイズ
プログラムサイズは、発行ファイルの中でプログラムの実行に必要なものだけを集計したものです。これは配布プロジェクトに直接関係し、プログラムのサイズが小さいほど配布が容易になります。注意:Normal 発行では、.NET Runtime の占有容量 67.5MB を含めて計算しています。

ターゲットプラットフォームにすでに .NET Runtime がインストールされている場合、通常発行が最も効率的で、サイズは数百 KB です。次に、単一ファイル発行+自己完結型ランタイム+トリミング+圧縮で、サイズは約 20MB と、配布にも適しています。AOT のパフォーマンスも同様に優れています。
2.2 プログラム実行関連
プログラム実行関連の指標は、起動時間、アプリケーション起動時間、メモリ使用量の 3 つです。起動時の CPU はほぼ 0 で参考にならないため、CPU 関連の指標は設定していません。以下のフローチャートは、これらの指標の収集タイミングを示しています。

2.2.1 起動時間
プログラムの起動時間の結果は以下の通りです。

2 つの極値が見られます。最大は単一ファイル+自己完結型ランタイム+圧縮で、起動時間が 170ms です。アセンブリのトリミングを行っていないため、解凍する依存関係が大きく、起動時間が長くなります。最小の AOT-Speed モードはわずか 16.8ms で起動でき、JIT コンパイルやアセンブリ読み込みのプロセスがないため、確かに高速です。
2.2.2 アプリケーション起動時間

アプリケーション起動時間とプログラム起動時間の並びはほぼ同じです。単一ファイル+自己完結型ランタイム+圧縮は 0.5 秒以上かかりますが、AOT モードはわずか 70ms で、約 7~8 倍の差があります。ただし、通常発行の起動速度も非常に速く、200ms 未満です。
2.2.3 メモリ使用量

メモリ使用量は各方式で大きな差はありませんが、メモリ使用量を抑えたい場合は WorkstationGC モードを使用できることが示唆されています。動的 PGO などの JIT 強化機能を導入すると、それに伴ってメモリを多く消費します。
2.3 性能負荷テスト
マシン構成:
CPU:I7 8750H ハイパースレッディング無効 RAM:48GB Client:CPU アフィニティを設定、3 コアにバインド Server:CPU アフィニティを設定、2 コアにバインド
筆者のマシン構成の制限により、Client と Server の環境分離は行わず、単純な CPU コアバインドのみ行いました。そのため、得られたデータは参考程度にお考えください。
2.3.1 負荷テスト QPS

各方式の差はそれほど大きくなく、いずれも 4.7Wqps 以上の成績を達成しており、最大と最小の差は 4% 以内です。これは I/O 集中型のタスクであるため、JIT や PGO の利点は現れていません。今後は計算集中型のタスクを試すか、前述の hez 氏のブログを直接参照することをお勧めします(PGO の紹介にリンクあり)。
2.3.2 単一リクエスト時間
下図の棒グラフ内の大きい値は 単一リクエスト時間(MAX)、棒グラフ外の 0.x のデータは 単一リクエスト時間(AVG) です。単位は ms です。

平均時間は約 0.3ms であることがわかります。AOT と単一ファイル+自己完結型ランタイム+トリミング+圧縮のパフォーマンスが非常に優れており、約 370ms です。
2.3.3 負荷テスト メモリ使用量
下図の濃い色は メモリ使用量(MAX)、薄い色は メモリ使用量(AVG) を示します。単位は MB です。

AOT 以外の方式では、メモリ使用量はほぼ同じです。4.7Wqps で約 25MB のメモリ使用量は非常に良好で、近似値は誤差と見なせます。また、JIT 機能を有効にすると、より多くのメモリを消費する必要があります。AOT のメモリ使用量は比較的多く、おそらく AOT 環境での GC アルゴリズムの最適化が不十分である可能性があります。
2.3.4 負荷テスト CPU 使用率
下図の濃い色は CPU使用率(MAX)、薄い色は CPU使用率(AVG) を示します。単位は パーセンテージ です。1 CPU コアは 100%、5 コアを占有する場合は 500% です。

基本的に差はありませんが、AOT 方式では使用率が大幅に低くなっています。これは JIT のステップが存在しないためです。
3. まとめ
この結論は参考程度にご覧ください。現在 AOT はまだ正式リリースされていません(すでにメインブランチにマージされ、.NET 7 で正式リリース予定)ので、まだ最適化すべき点が多くあります。また、OSR や OSA などの機能もまだ完全には決定されていません。以下は、対照グループと比較したいくつかのパーセンテージデータです。元データとテストコードは GitHub を参照してください。.NET 7 が正式リリースされたら、再度実行してみます。


最初の質問に戻ると、全体的に AOT はソフトウェアサイズの縮小とアプリケーション起動速度の向上に大きな効果がありますが、現時点では長い発行時間とより多くのメモリ使用量が必要です。
また、PGO などの JIT 機能は通常よりも多くのメモリを消費し、そのパフォーマンス上の利点はこの I/O 集中型のシナリオでは十分に発揮されませんでした。
最後に、少し余談ですが、C# は優れた言語であり、.NET は優れたプラットフォームであると常に考えています。2002 年の登場から今年で .NET は 20 年目を迎え、様々な新機能が次々と追加され、パフォーマンスも第一線に立っています。今後さらに発展していくことを期待しています。
PS:先日更新された Benchmarks Game のデータでは、C# .NET は JIT 言語の中で最速となり、C、C++、Rust などのコンパイル型言語に次ぐ結果となっています。詳細はリンク1、リンク2を参照してください。

原文著者:InCerry 原文タイトル:单文件发布对程序性能的影响 原文リンク:https://www.cnblogs.com/InCerry/p/Single-File-And-AOT-Publish.html