.NET 6 ではパフォーマンス向上のために Dynamic PGO という機能が JIT に追加されています。
名前の通り PGO を実行時に行ってパフォーマンス向上に役立てるという機能で、Azure AD の Gateway では .NET 6 と Dynamic PGO を組み合わせることで CPU 使用率を 33% 下げて、これまでより 50% もアプリケーションの実行効率を向上出来たらしいです。
.NET Core 3.0 で Tiered Compilation が追加されて Tier 0 コードから Tier 1 に差し替え出来るようになりましたが、Dynamic PGO は Tier 0 から Tier 1 のコード生成時に、実行時に収集されたプロファイルデータに従って最適なコードを生成できるようになりました。
パフォーマンス向上の機能としては同じく .NET Core 3.0 で Tier 0 相当のコードを事前生成する ReadyToRun がありますが、Dynamic PGO は ReadyToRun と同時利用できないので、アプリケーションでどちらを利用するか判断する必要があります。
ReadyToRun に関しては .NET Core 3.0 リリース時に書いたエントリがあるので参照してください。
実行時にプロファイルデータを収集して最適なコードを生成する仕組み上、ASP.NET Core アプリケーションのように長時間の稼働が予想される場合は、Tier 0 のコードを事前に生成するだけの ReadyToRun よりも高いパフォーマンスが期待できます。是非 .NET 6 への移行時には有効化を検討しましょう。
では Azure Functions のような FaaS ではどうするべきか、というのが今回の話です。
Azure Functions の特性から考える
既に広く知られていると思いますが Azure Functions は FaaS なので、アプリケーションは必要な時に都度起動されて、実行が終わったらすぐに捨てられるというライフサイクルを辿ります。つまり実行時よりもスタートアップのパフォーマンスの方が優先されます。
とは言いつつも Azure Functions の実行基盤は以下の 3 種類が用意されているので、アプリケーションの要件によって適切なものを使っていると思います。
- Consumption Plan
- Functions Premium
- App Service Plan
中でも Consumption Plan は完全従量課金で、純粋な FaaS となるのでコールドスタートの方が重要になるので、ReadyToRun を利用するのが適切と言えるでしょう。Dynamic PGO では折角最適化したコードを生成しても、すぐに捨てられてしまいます。
Functions Premium と App Service Plan に関しては少し毛色が違っていて、常時稼働するインスタンス上でホストされているので、コールドスタートによるオーバーヘッドはほぼ無視できます。この 2 つの使う場合には実行時のパフォーマンスを優先して Dynamic PGO が適切と考えられます。
ここから先は実際に Azure Functions v4 上に Dynamic PGO と ReadyToRun のそれぞれを有効化した Function App をデプロイして試した結果を含みます。
Dynamic PGO の有効化
パッと見は公式ドキュメントには Dynamic PGO 周りの設定は載っておらず、今は以下の Gist が実質的なドキュメントとして存在している状況のようです。設定はとても簡単です。
Dynamic PGO in .NET 6.0.md · GitHub
Dynamic PGO を有効化するには環境変数に以下の 3 つの設定を追加します。ReadyToRun とは異なりコンパイル時の設定は必要ありません。
DOTNET_TieredPGO = 1
DOTNET_TC_QuickJitForLoops = 1
DOTNET_ReadyToRun = 0
Azure Functions の場合は App Settings に追加すると、自動的に同じ名前で環境変数に設定されるので有効化出来ます。ARM Template や Terraform を使っていても有効化するのは簡単です。
これで Dynamic PGO が有効になりました。前述の通り Functions Premium や App Service Plan で常時稼働するインスタンスが存在する場合には有利でしょう。
ReadyToRun の有効化
.NET 6 では ReadyToRun も若干機能が増えているようですが、基本は自己完結型でデプロイした場合に限られるものなので、Azure Functions で利用する際には関係ないです。
Azure Functions での ReadyToRun の有効化方法は既に Azure Functions v3 がリリースされたタイミングで書いているので、こちらを参照してください。.NET 6 になっても方法は同じです。
コンパイル後は Tier 0 のコードを含むのでアセンブリサイズが大きくなります。今回は Consumption Plan でのコールドスタート計測はしていないですが、良くなることはあっても悪くなることはないでしょう。
Dynamic PGO と ReadyToRun の比較
実際のところ本当に効果があるのかは謎だったので、loader.io を使って単純な HttpTrigger について雑にパフォーマンス測定をしました。中身はテンプレートで生成されるような HttpTrigger なので、実際のアプリケーションでは結果が変わるはずですが、Azure Functions Runtime 部分の参考までに。
測定に使用した条件は以下になります。5 分間で 250 RPS の負荷を掛けたので合計 75000 リクエストです。
- Azure Functions v4 (v4.0.1)
- Windows - P1v2 (Premium V2)
- 64bit 有効
- 250 RPS, 5 分間
loader.io のグラフ表示機能は結構弱いので、久し振りに Excel で雑グラフを作りました。
本当は Azure Functions v3 と v4 の比較のために今月頭から定期的に回していたのですが、最後に追加した ReadyToRun が 10 日前だったのでデータ数は少し少ないです。
予想通り HttpTrigger のテンプレート実装しかないので大きな差は出ていないです。Dynamic PGO と ReadyToRun 共に未設定の場合よりも 3~9% 程向上していますが、今回のケースでは ReadyToRun の方が良い結果が出ていました。
Azure Functions Runtime 部分から Dynamic PGO が実行されると、もう少し差が出るかなと思いましたが、若干予想が外れました。設定を入れる前には必ず検証するようにしましょう。