しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

.NET Core 3.0 で有効化される Tiered Compilation と ReadyToRun について

.NET Core 3.0 の新機能として紹介されている Tiered Compilation と ReadyToRun について気になっていたので、夜なべして調べました。

2.2 の例があったので RTM 前に書くのは心配ですが、流石に大丈夫かなと思ったので。*1

Tiered Compilation

ufcpp にぃにが .NET Core 3.0 での Tiered Compilation について書いてくれるだろうと思ってたのですが、全然ブログとかで触れてくれないので軽く触れます。

.NET Core 2.2 でデフォルト有効化されるはずだったのが、リリース直前で撤回されたやつです。.NET Core 3.0 ではデフォルトで有効化されています。

細かい説明はにぃにのブログに任せますが、あまり最適化されてない Tier 0 と最適化された Tier 1 というように JIT の動作を分けて、アプリケーションの起動速度とパフォーマンスを改善する仕組みです。

撤回された理由は以下の Issue 曰く、いくつかの ASP.NET Core 向けシナリオで Regression が見つかったからのようです。この Issue は Tiered Compilation について凄くまとまっているので良いです。

.NET Core 2.2 で実装された時から Tiered Compilation は大きく変わっていて、.NET Core 3.0 Preview 4 の時にブログでも紹介されています。上の Issue も Preview 4 の時に書かれているので、ちょっと古めです。

.NET Core 3.0 でのパフォーマンス改善を紹介するブログで、少しだけ Tiered Compilation に触れられています。本当に少しだけですが、これが最新かも知れません。

Tiered Compilation が実装されて、使用頻度の高いメソッドは実行環境へ最適化されたコードへの差し替えが出来るようになったので、一部のシナリオではプリコンパイル済みのコードより速くなっているようです。

ReadyToRun (R2R) が何回も出てきますが、コンパイル時に Tier 0 なネイティブコードも同時に生成する仕組みです。Tier 1 ではないのがミソで、ReadyToRun で生成済みコードは実行時に Tier 1 へ差し替え対象となります。なので .NET Native や NGEN とは大きく異なります。*2

最近の CPU はいろんな拡張命令を積んでいますが、事前にネイティブコードを生成する方法だと利用に限界があるので、NGEN よりも ReadyToRun + Tiered Compilation の方が速いようです。

アセンブリが ReadyToRun になっていない場合は、現在のバージョンでは QuickJit がデフォルトで有効化されているっぽいので、ループを含まないメソッドは Tier 0 としてコードが生成されます。

いろんなシナリオで計測して、バランスを取った結果がデフォルトのオプションに反映されています。

Tiered Compilation の設定は環境変数なども使えますが、csproj で以下のプロパティを設定するとオンオフが行えます。これが一番楽な方法だと思います。QuickJit をオフにすると Tier 1 としてコード生成されるので、アプリケーションの起動速度は改善しません。

  • TieredCompilation
  • TieredCompilationQuickJit

大抵の場合はデフォルトのオプションのまま使えば、起動速度とパフォーマンスのバランスが取れた状態になるはずですが、有効化されている QuickJit ではほぼ最適化はされないので、JIT 時の末尾呼び出し最適化などを期待したコードを書くと死ぬようです。

コールドスタートのパフォーマンスを計測するのは大変なので、AWS Lambda で Preview 4 で試していた記事を紹介しておきます。QuickJit を有効にしないと実際遅いのが読み取れます。

初期化が .NET Core 2.2 と比べて大幅に遅くなっているのは、System.Private.CoreLib.dll を NGEN*3 から ReadyToRun に 3.0 から切り替えたのが理由と推測されています。実際に 2.2 と 3.0 で調べてみると、2.2 は NGEN で 3.0 は ReadyToRun になっていました。

起動パフォーマンス改善は FaaS では重要なので、Azure Functions も .NET Core 3.0 向けの v3 ランタイムを 10 月にパブリックプレビューとしてリリースするらしいです。

Consumption Plan で動かすようなコードでは、明らかに Tier 1 として最初にコードを生成するのは勿体ないので、QuickJit との組み合わせでかなり良い感じになるのではと思っています。

実体としては TFM を netcoreapp3.0 にするぐらいっぽいので、2.2 が使われている v2 ランタイムとは互換性があるはずですし、実際に Issue でもランタイムの更新だけでアップグレード可能と書かれています。

将来的には .NET Standard 2.1 に拡張側が対応して、新しい Span<T> とかが使えるようになるのでしょう。

ReadyToRun (R2R)

既に少し書きましたが ReadyToRun は .NET Framework 時代の NGEN とは異なり、Tier 0 のコードだけ予め生成しておいて JIT を減らし、スタートアップのパフォーマンスを改善するものです。

.NET Native みたいに事前に完全にネイティブコードを生成する方法は GC やリフレクションで制限が出ますが、ReadyToRun は IL とネイティブコードの両方を含んでいるので制限を受けず、必要であれば Tier 1 のコードに差し替えることで定常状態のパフォーマンスを改善できるという仕組みです。

殆どのケースで Self-contained で配布するであろう WinForms や WPF アプリでは、有効にしない手はないです。ちなみに AOT かつクロスコンパイルには非対応なので、ビルドは指定した RID と同じプラットフォーム上で行う必要があります。そこは CI で解決しましょう。

事前にネイティブコードを生成する必要があるので、現在のところ Self-contained でしか利用することが出来ません。なので Azure Functions で使いたいと思っても、ランタイムが分離されているので無理です。ひとまずは素直に QuickJit に頼る形になるでしょう。

WinForms / WPF 以外で現実的に利用できそうなのが、ASP.NET Core アプリケーションです。Self-contained で扱う必要がありますが、Docker は元々イメージにランタイムが含まれているので妥協できそうです。

とはいえ、イメージのキャッシュが効きにくくなるのは事実なので、出来るだけ Dockerfile を工夫して必要最低限のファイルだけ含めるようにします。テンプレートで一緒に生成される Dockerfile を弄って ReadyToRun 対応にしたのが以下の例です。

FROM mcr.microsoft.com/dotnet/core/runtime-deps:3.0-alpine AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

FROM mcr.microsoft.com/dotnet/core/sdk:3.0-alpine AS build
WORKDIR /src
COPY ["WebApplication10/WebApplication10.csproj", "WebApplication10/"]
RUN dotnet restore "WebApplication10/WebApplication10.csproj"
COPY . .
WORKDIR "/src/WebApplication10"
RUN dotnet build "WebApplication10.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "WebApplication10.csproj" -c Release -o /app/publish -r linux-musl-x64 -p:PublishReadyToRun=true

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["./WebApplication10"]

ベースイメージは .NET Core 3.0 に必要なパッケージだけ含まれたものが公開されているので、それを使いました。Alpine ベースのイメージを使ってサイズを減らします。

ReadyToRun として発行する場合は PublishReadyToRun と RID を付けるだけです。これでビルドしたイメージのサイズは 46MB 程なので、まあまあ小さく出来ていると思います。

f:id:shiba-yan:20190912204753p:plain

サンプルアプリ程度だと規模が小さすぎて ReadyToRun の有無で差は殆ど出ないと思いますが、そこそこの規模になると効いてくるのではないかと。

特に数多くのインスタンスで動かす場合は、各マシンで実行される初回の JIT コストが馬鹿にならないので、予め Tier 0 のコードでも生成しておけば起動時に差が出るはずです。

追記 : Framework Dependent での R2R

上で ReadyToRun は Self-contained でしか使えないと書きましたが、その後も調べていると preview9 ぐらいから Framework Dependent でも ReadyToRun としてビルド出来るようになっているようです。

実際に .NET Core 3.0 RC1 の環境で試しました。単純に PublishReadyToRun を有効にすると自動的に Self-contained になりますが、明示的に false を指定すると Framework Dependent としてビルド出来ました。RTM で変わるかも知れないですが、どうにも罠っぽい挙動です。

dotnet publish -c Release -r win10-x86 --self-contained false -p:PublishReadyToRun=true -o ./publish

Framework Dependent なのでビルド結果として最小限のファイルだけ生成されました。

f:id:shiba-yan:20190919182249p:plain

生成された dll を R2RDump に食わせてみると、ちゃんと ReadyToRun になっていることが確認出来ます。

f:id:shiba-yan:20190919181930p:plain

サイズが大きくなってしまう Self-contained 以外でも ReadyToRun の恩恵を受けられるのはかなり良さそうです。Azure Functions の .NET Core 3.0 対応周りでは力を発揮してくれそうです。

*1:まだ変わるかも知れません

*2:ランタイムに含まれているアセンブリは既に R2R になっています

*3:.NET Core なので実際には crossgen を使ったネイティブコード生成と思われる