Start Debugging

EF Core 11 のコンパイル済みモデルとは何か、有効にする価値があるのはどんなときか

コンパイル済みモデルとは、OnModelCreating を実行せずに EF Core のモデルを再構築する生成済み C# コードです。EF Core 11 RC1 での実測では、500 エンティティでモデルの読み込みが 714 ms から 215 ms に短縮されます。ただし EFOptimizeContext の後継である MSBuild 経由の方法は遅く、古くなったモデルはエラーなしで使われ続け、クエリフィルターがあると生成そのものができません。

結論から言うと、コンパイル済みモデルとは dotnet ef dbcontext optimize(または Microsoft.EntityFrameworkCore.Tasks MSBuild パッケージ)が生成する C# ソースコードで、最初に DbContext を使うときに規約と OnModelCreating を実行する代わりに、EF Core のモデルを直接構築します。新しいプロセスでの最初のクエリが実際のコストやレイテンシにつながる場合(サーバーレス、オートスケールされるコンテナー、CLI ツール、デスクトップアプリ)で、モデルのエンティティ型が 100 前後かそれ以上あるなら、有効にする価値があります。Native AOT で公開する場合は必須です。EF Core 11 では EFOptimizeContext プロパティがなくなり、代わりに EFScaffoldModelStage で有効にします。週に一度しか起動しない 20 テーブルの Web API では、保守の手間に見合いません。

以下の数値とエラーメッセージはすべて、.NET 11 RC1 SDK(11.0.100-rc.1.26425.128)上で Microsoft.EntityFrameworkCore.Sqlite 11.0.0-rc.1.26425.128 と dotnet-ef 11.0.0-rc.1.26425.128 を使い、Apple silicon の MacBook で Release ビルドを実行した結果です。照合用に、SDK 10.0.302 上の EF Core 10.0.12 も一度使いました。

最初の DbContext 使用時に EF Core が行うこと

DbContext の作成自体は軽い処理です。コストが大きいのは、最初に DbContext.Model に触れたとき(クエリ、Add、SaveChanges、あるいは自分で Model を読んだとき)です。この時点で EF Core は、到達可能なすべてのエンティティ型に規約パイプラインを実行し、リフレクションでプロパティとリレーションシップを検出し、OnModelCreating の構成を適用し、結果を検証してから、変更可能なモデルを読み取り専用の RuntimeModel に変換します。結果はコンテキスト型ごと(正確にはモデルキャッシュキーごと)にプロセスの存続期間中キャッシュされるため、支払うのはプロセスごとに一度だけです。

コンパイル済みモデルはこのパイプラインを省略します。モデルを検出する代わりに、EF Core は生成されたクラスをインスタンス化し、解決済みの答えを渡して RuntimeModel.AddEntityType、AddProperty、AddKey、AddForeignKey などを呼び出します。テスト用プロジェクトで dotnet ef dbcontext optimize が 1 つのエンティティに対して出力したコードの一部を示します。

// <auto-generated /> by dotnet-ef 11.0.0-rc.1.26425.128
var runtimeEntityType = model.AddEntityType(
    "Bench.Entity1",
    typeof(Entity1),
    baseEntityType,
    propertyCount: 8,
    navigationCount: 1,
    foreignKeyCount: 1,
    unnamedIndexCount: 2,
    keyCount: 1);

var name = runtimeEntityType.AddProperty(
    "Name",
    typeof(string),
    propertyInfo: typeof(Entity1).GetProperty("Name", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
    fieldInfo: typeof(Entity1).GetField("<Name>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
    maxLength: 200);

maxLength: 200 は OnModelCreating 内の HasMaxLength(200) に由来します。この構成が生成コードに焼き込まれていることが要点であり、この記事の後半で扱う落とし穴の原因でもあります。

このコマンドは、エンティティ型ごとに 1 つの <Entity>EntityType.cs ファイルを書き出し、さらに 3 つのファイルを書き出します。<Context>Model.cs(静的な Instance を持つ RuntimeModel のサブクラス)、<Context>ModelBuilder.cs、<Context>AssemblyAttributes.cs です。最後のファイルには、こちら側のコードを変更しなくても EF Core がモデルを見つけられるようにする行が入っています。

[assembly: DbContextModel(typeof(BenchContext), typeof(BenchContextModel), ProviderName = "Microsoft.EntityFrameworkCore.Sqlite")]

EF Core 9 以降、コンテキストと同じアセンブリにあるコンパイル済みモデルは、この属性によって自動的に検出されます。optionsBuilder.UseModel(BenchContextModel.Instance) が必要なのは、コンパイル済みモデルが別のアセンブリにある場合や、実行時に複数のコンパイル済みモデルから選びたい場合だけです。

実際にどれだけ効くかを測る

Microsoft Learn のコンパイル済みモデルのページには、コンパイル済みモデルが “applications with large models”、つまり “hundreds to thousands of entity types” に有効だと書かれています。判断材料にするにはあいまいすぎるので、実測しました。Python スクリプトで、10、100、500 のエンティティ型を持つモデルを生成しました。各エンティティには 8 個のスカラープロパティ、直前のエンティティへの null 許容の外部キー、一意インデックス、HasMaxLength の呼び出しがあり、規約が実際に仕事をする構成です。プログラムは、新しいプロセスでの最初のモデルアクセスと最初のクエリを計測します。

// .NET 11, C# 14, EF Core 11.0.0-rc.1.26425.128, Microsoft.EntityFrameworkCore.Sqlite
using System.Diagnostics;
using Bench;
using Microsoft.EntityFrameworkCore;

var sw = Stopwatch.StartNew();
using var ctx = new BenchContext();
var model = ctx.Model;                                     // first touch: build or load the model
var modelMs = sw.Elapsed.TotalMilliseconds;
var n = ctx.Set<Entity1>().Where(e => e.IsActive).Count(); // first query
var firstQueryMs = sw.Elapsed.TotalMilliseconds;

Console.WriteLine($"{model.GetType().Name},{model.GetEntityTypes().Count()}," +
    $"model={modelMs:F0}ms,firstQuery={firstQueryMs:F0}ms," +
    $"OnModelCreating={BenchContext.ModelCreatingCalls}");

BenchContext.ModelCreatingCalls は OnModelCreating 内でインクリメントされる静的カウンターで、各実行がどちらの経路を通ったかを示します。データベースは事前に作成した SQLite ファイルです。行ごとに、コールドスタート 7 回の中央値を示します。

エンティティ型の数モデル読み込み(実行時に構築)モデル読み込み(コンパイル済み)最初のクエリ(実行時)最初のクエリ(コンパイル済み)アセンブリサイズ(実行時 / コンパイル済み)
10185 ms65 ms277 ms171 ms23 KB / 43 KB
100280 ms92 ms383 ms213 ms158 KB / 342 KB
500714 ms215 ms868 ms401 ms888 KB / 1.8 MB

目立つ点が 2 つあります。1 つ目は、エンティティ型が 10 でも約 110 ms の短縮があることです。実行時コストの大部分は、エンティティごとに規約を実行する処理だけでなく、規約パイプライン自体の JIT コンパイルにあるためです。2 つ目は、規模に応じて効果が伸びることです。エンティティ型が 500 になると、コンパイル済みモデルはコールドスタートのたびに 0.5 秒近くを節約します。OnModelCreating は、コンパイル済みの実行ではすべて 0 回、実行時構築の実行ではすべて 1 回呼ばれました。

110 ms が重要かどうかは、プロダクトの事情によります。トラフィックを受ける前にウォームアップするロードバランサー配下の ASP.NET Core アプリでは、問題になりません。コールドスタートがユーザーに見える AWS Lambda や Azure Functions の従量課金プラン、2 秒だけ動く dotnet tool、最初の画面がクエリを待つデスクトップアプリや MAUI アプリでは、問題になります。

EF Core 11 で EFOptimizeContext はどうなったか

EF Core 9 には、Microsoft.EntityFrameworkCore.Tasks パッケージに MSBuild 統合が含まれていました。これはビルドまたは公開の際にコンパイル済みモデルを再生成するので、コードとずれることがありません。EF Core 9 と 10 では EFOptimizeContext=true で有効にし、EFScaffoldModelStage と EFPrecompileQueriesStage で段階を選びました。

EF Core 11 は EFOptimizeContext を削除しました(dotnet/efcore#35079)。2 つのステージプロパティは単独で機能するようになり、古いプロパティを設定するとビルドが失敗します。PublishAot が true のときは、公開時の生成が既定で有効です。AOT を使わない場合、従来のオプトインに相当する EF Core 11 の設定は次のとおりです。

<!-- .NET 11, EF Core 11.0.0-rc.1.26425.128 -->
<PropertyGroup>
  <EFScaffoldModelStage>build</EFScaffoldModelStage>
  <EFPrecompileQueriesStage>none</EFPrecompileQueriesStage>
</PropertyGroup>
<ItemGroup>
  <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="11.0.0-rc.1.26425.128" PrivateAssets="all" />
  <PackageReference Include="Microsoft.EntityFrameworkCore.Tasks" Version="11.0.0-rc.1.26425.128" PrivateAssets="all" />
</ItemGroup>

生成されたファイルは obj/<Configuration>/<TFM>/ に *.g.cs として出力されてコンパイルに追加されるので、ソース管理には何も入りません。有効なステージ値は build、publish、それ以外(慣例的には none)で、それ以外は無効を意味します。MSBuild タスクのリファレンスには、より細かい制御のための DbContextName、EFTargetNamespace、EFOutputDir、EFNullable が載っています。古いプロパティを持つプロジェクトをアップグレードしてビルドが壊れた場合は、PublishAot と EFOptimizeContext の記事で、そのエラーと、その前に存在したメモリ枯渇バグを扱っています。

では、MSBuild 経由の方法を有効にする価値はあるでしょうか。Native AOT なら、あります。どのみちコンパイル済みモデルと事前コンパイル済みクエリが必要で、公開時に再生成するのがそれらを同期させる最も安全な方法だからです。通常の JIT アプリについては、2 つの理由から、私の測定では価値がありません。

MSBuild 経由では、遅い AOT 向けのモデルが生成される

MSBuild のドキュメントには、この統合は “will always generate additional code in the compiled model that’s required for NativeAOT” と書かれています。実際には、エンティティ型ごとに <Entity>UnsafeAccessors.g.cs ファイルが追加で生成されることを意味します。100 エンティティのモデルでは、103 ではなく 203 個の生成ファイルになりました。このコードは起動時に無料ではありません。同じ 100 エンティティのモデル、同じマシンでの結果です。

コンパイル済みモデルの生成元モデル読み込み最初のクエリ
dotnet ef dbcontext optimize92 ms213 ms
dotnet ef dbcontext optimize --nativeaot231 ms394 ms
EFScaffoldModelStage=build235 ms393 ms
コンパイル済みモデルなし280 ms383 ms

MSBuild が生成したモデルは、コンパイル済みモデルをまったく使わない場合より約 45 ms 速く読み込めるだけで、最初のクエリは速くなりませんでした。通常の CLI の出力は、読み込みが 2.5 倍速くなります。AOT で公開しないなら、NativeAOT 向けのコードは純粋なオーバーヘッドです。

クリーンビルドは失敗し、再試行すると生成が黙ってスキップされる

2 つ目の理由は、11.0.0-rc.1 と 10.0.12 の両方で遭遇したビルド順序の問題です。Tasks パッケージは生成を TargetsTriggeredByCompilation にフックしており、これは CoreCompile の直後に実行されます。ところが OptimizeDbContext タスクは bin/ からアセンブリを読み込み、そこはビルドの後半まで作られません。bin フォルダーのないクリーンなチェックアウトからでは、最初のビルドが失敗します。

Optimizing DbContext...
Microsoft.EntityFrameworkCore.Tasks.targets(105,5): error : File '.../bin/Debug/net11.0/Bench.dll' not found.
Build FAILED.

その後で dotnet build をもう一度実行すると Build succeeded と表示されます。コンパイルのステップが最新の状態なので、生成がスキップされるためです。できあがったアプリはコンパイル済みモデルなしで動きます。テストでは RuntimeModel と OnModelCreating=1 が表示されました。ソースファイルを触ってから次のビルドを実行して初めて、Optimizing DbContext... が表示され、BenchContextModel が生成されました。毎回クリーンな状態からビルドが始まる CI では、実行のたびにビルドが赤くなります。執筆時点で dotnet/efcore にこれを追跡する issue は見つからなかったので、11.0 GA がリリースされたらリリースノートを確認してください。

CLI 経由の方法: 一度生成してチェックインする

JIT アプリには、従来どおりの設定のほうが適しています。CLI を実行し、出力をコミットし、モデルが変わったら再生成する、というものです。

# .NET 11 SDK, dotnet-ef 11.0.0-rc.1.26425.128
dotnet tool install --global dotnet-ef --version 11.0.0-rc.1.26425.128
dotnet ef dbcontext optimize --output-dir CompiledModels --namespace MyApp.CompiledModels

ビルド済みのプロジェクトでは、3 種類のモデルサイズで生成に 1.3 秒から 2.3 秒かかりました。知っておくべき点が 2 つあります。プロジェクトを先に復元しておく必要があること(そうしないと Unable to retrieve project metadata になります)、そしてコンテキストが別のスタートアッププロジェクトで構成されている場合は --startup-project を渡すか IDesignTimeDbContextFactory<T> を追加する必要があることです。

CLI 経由の方法のリスクはずれ(ドリフト)であり、ドキュメントの印象よりも深刻です。4 月のウォームアップに関する記事では、EF Core は古くなったコンパイル済みモデルを検出して例外を投げると書きました。EF Core 11 RC1 で試すと、そうではありませんでした。コンパイル済みモデルを生成した後、再生成せずに、あるエンティティに Sku プロパティを追加し、別の列を HasColumnName("DisplayName") で改名しました。

// .NET 11, EF Core 11.0.0-rc.1.26425.128, compiled model generated BEFORE these changes
Console.WriteLine(ctx.Set<Entity2>().Select(e => e.Name).ToQueryString());
// With the stale compiled model:   SELECT "t"."Name" FROM "T2" AS "t"
// Without any compiled model:      SELECT "t"."DisplayName" FROM "T2" AS "t"

例外も警告もありません。古いコンパイル済みモデルは、古い列名で SQL を生成しました。新しい Sku プロパティのほうは失敗しましたが、それはクエリで使ったときだけで、しかも “The LINQ expression could not be translated … Translation of member ‘Sku’ on entity type ‘Entity0’ failed” という汎用的なエラーでした。本当の原因からはまったく見当がつかないメッセージです。

対策は、ドリフトを CI の失敗にすることです。ジェネレーターは、<Context>ModelBuilder.cs の modelId GUID という 1 行を除いて決定的で、この行は実行のたびに変わります。Git にこの行を無視させることができます。

# .NET 11 SDK, dotnet-ef 11.0.0-rc.1.26425.128, git 2.30+
dotnet ef dbcontext optimize --output-dir CompiledModels --namespace MyApp.CompiledModels
git diff --exit-code -I 'modelId:' -- CompiledModels

誰かが再生成せずにモデルを変更した場合、差分が空にならず、ジョブが失敗します。保留中のマイグレーションを確認するのと同じ考え方で、同じ CI ステップに組み込めます。

コンパイル済みモデルにできないこと

Learn のページには制限事項のリストがありますが、一部は古くなっています。EF Core 11 RC1 で確認した内容は次のとおりです。

実用的な判断基準

測定結果と制限事項を合わせると、次のようになります。

  1. Native AOT で公開する場合は、必須です。 PublishAot を設定し、Microsoft.EntityFrameworkCore.Tasks を参照して、公開時にモデルと事前コンパイル済みクエリを生成させます。これがないと、最初のクエリで “Model building is not supported when publishing with NativeAOT” が発生します。詳しくはMAUI iOS 版のこのエラーを参照してください。
  2. コールドスタートがユーザーに見え(サーバーレス、ゼロまでスケールダウンするコンテナー、CLI ツール、デスクトップアプリ)、クエリフィルターがない場合は、 CLI 経由の方法を使い、出力をコミットして、CI にドリフトチェックを追加します。小さなモデルで約 100 ms、500 エンティティ型で約 500 ms の短縮が見込めます。.NET 11 AWS Lambda のコールドスタート時間の短縮にある他の工夫とも相性が良いです。
  3. トラフィックの前にウォームアップする長時間稼働のサーバーなら、 使わなくて構いません。DbContext.Model に触れる起動時のウォームアップで、ユーザーから見た結果は同じになり、保守すべき生成コードもゼロです。
  4. EF Core 9 または 10 で、JIT アプリに EFOptimizeContext を使っている場合は、 EF Core 11 へのアップグレード時に、EFScaffoldModelStage=build へ置き換えるのではなく、MSBuild 統合を削除することを検討してください。CLI のほうが速いモデルが得られ、クリーンビルドの失敗も避けられます。

モデルは、最初のクエリのコストの半分にすぎません。新しい LINQ の形ごとに、プロセスごとに一度、変換とコンパイルも行われます。少数のホットなクエリが支配的なら、ホットパス向けの EF Core のコンパイル済みクエリが、その残り半分に効きます。

関連記事

参考資料

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< 戻る