Start Debugging

Was ist ein Compiled Model in EF Core 11, und wann lohnt sich die Aktivierung?

Ein Compiled Model ist generierter C#-Code, der Ihr EF Core Modell ohne Ausführung von OnModelCreating neu aufbaut. Gemessen mit EF Core 11 RC1: Das Laden des Modells sinkt bei 500 Entitäten von 714 ms auf 215 ms, aber der MSBuild-Weg (der EFOptimizeContext ersetzt hat) ist langsamer, veraltete Modelle schlagen unbemerkt fehl und Query Filter verhindern die Generierung.

Kurze Antwort: Ein Compiled Model ist C#-Quellcode, erzeugt von dotnet ef dbcontext optimize (oder vom MSBuild-Paket Microsoft.EntityFrameworkCore.Tasks), der Ihr EF Core Modell direkt aufbaut, statt bei der ersten Verwendung von DbContext die Konventionen und OnModelCreating auszuführen. Die Aktivierung lohnt sich, wenn die erste Abfrage in einem frischen Prozess echtes Geld oder Latenz kostet (Serverless, automatisch skalierte Container, CLI-Tools, Desktop-Apps) und Ihr Modell etwa 100 Entitätstypen oder mehr hat, oder wenn Sie mit Native AOT veröffentlichen, wo es Pflicht ist. In EF Core 11 gibt es die Eigenschaft EFOptimizeContext nicht mehr: Sie aktivieren das Feature stattdessen mit EFScaffoldModelStage. Für eine Web-API mit 20 Tabellen, die einmal pro Woche startet, lohnt sich der Wartungsaufwand nicht.

Jede Zahl und jede Fehlermeldung unten stammt aus Läufen mit Microsoft.EntityFrameworkCore.Sqlite 11.0.0-rc.1.26425.128 und dotnet-ef 11.0.0-rc.1.26425.128 auf dem .NET 11 RC1 SDK (11.0.100-rc.1.26425.128), Release-Builds, auf einem MacBook mit Apple Silicon. Ein Gegencheck verwendete EF Core 10.0.12 mit SDK 10.0.302.

Was EF Core bei der ersten Verwendung von DbContext tut

Das Erzeugen eines DbContext ist billig. Der teure Teil passiert, sobald zum ersten Mal etwas auf DbContext.Model zugreift: eine Abfrage, Add, SaveChanges oder Ihr eigenes Lesen von Model. Dann führt EF Core seine Konventions-Pipeline über jeden erreichbaren Entitätstyp aus, entdeckt Eigenschaften und Beziehungen per Reflection, wendet Ihre OnModelCreating-Konfiguration an, validiert das Ergebnis und wandelt das veränderliche Modell schließlich in ein schreibgeschütztes RuntimeModel um. Das Ergebnis wird pro Kontexttyp (genauer: pro Model Cache Key) für die Lebensdauer des Prozesses zwischengespeichert, Sie zahlen also einmal pro Prozess.

Ein Compiled Model überspringt die Pipeline. Statt das Modell zu entdecken, instanziiert EF Core generierte Klassen, die RuntimeModel.AddEntityType, AddProperty, AddKey, AddForeignKey und so weiter mit den bereits aufgelösten Antworten aufrufen. Hier ein Ausschnitt dessen, was dotnet ef dbcontext optimize in meinem Testprojekt für eine Entität erzeugt hat:

// <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);

Das maxLength: 200 stammt aus HasMaxLength(200) in OnModelCreating. Diese Konfiguration ist jetzt fest im generierten Code verankert, was der ganze Sinn der Sache ist und zugleich die Quelle aller späteren Stolperfallen in diesem Beitrag.

Der Befehl schreibt pro Entitätstyp eine Datei <Entity>EntityType.cs sowie drei weitere: <Context>Model.cs (eine RuntimeModel-Unterklasse mit statischer Instance), <Context>ModelBuilder.cs und <Context>AssemblyAttributes.cs. Die letzte enthält die Zeile, durch die EF Core das Modell ohne jede Codeänderung auf Ihrer Seite findet:

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

Seit EF Core 9 wird ein Compiled Model in derselben Assembly wie der Kontext über dieses Attribut automatisch gefunden. optionsBuilder.UseModel(BenchContextModel.Instance) brauchen Sie nur, wenn das Compiled Model in einer anderen Assembly liegt oder wenn Sie zur Laufzeit zwischen mehreren Compiled Models wählen wollen.

Messung: Was es Ihnen bringt

Die Microsoft-Learn-Seite zu Compiled Models sagt, sie helfen “applications with large models”, also “hundreds to thousands of entity types”. Das ist zu vage für eine Entscheidung, also habe ich gemessen. Ein Python-Skript hat Modelle mit 10, 100 und 500 Entitätstypen erzeugt. Jede Entität hat acht skalare Eigenschaften, einen nullbaren Fremdschlüssel auf die vorherige Entität, einen eindeutigen Index und einen HasMaxLength-Aufruf, damit die Konventionen echte Arbeit haben. Das Programm misst den ersten Modellzugriff und die erste Abfrage in einem frischen Prozess:

// .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 ist ein statischer Zähler, der in OnModelCreating hochgezählt wird, sodass jeder Lauf beweist, welchen Weg er genommen hat. Die Datenbank war eine vorab erstellte SQLite-Datei. Mediane aus sieben Kaltstarts pro Zeile:

EntitätstypenModell laden, zur Laufzeit gebautModell laden, kompiliertErste Abfrage, LaufzeitErste Abfrage, kompiliertAssembly-Größe, Laufzeit / kompiliert
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

Zwei Dinge fallen auf. Erstens ist die Ersparnis schon bei 10 Entitätstypen real, rund 110 ms, weil ein guter Teil der Laufzeitkosten im JIT-Kompilieren der Konventions-Pipeline selbst steckt und nicht nur in ihrer Ausführung pro Entität. Zweitens skaliert sie: Bei 500 Entitätstypen spart das Compiled Model bei jedem Kaltstart eine halbe Sekunde. OnModelCreating wurde in jedem kompilierten Lauf null Mal und in jedem Laufzeitlauf genau einmal aufgerufen.

Ob 110 ms eine Rolle spielen, ist eine Produktfrage. Bei einer ASP.NET Core App hinter einem Load Balancer, die sich aufwärmt, bevor sie Datenverkehr annimmt, spielt es keine Rolle. Bei AWS Lambda oder einem Azure-Functions-Verbrauchsplan, wo der Kaltstart für Benutzer sichtbar ist, bei einem dotnet tool, das zwei Sekunden läuft, und bei einer Desktop- oder MAUI-App, deren erster Bildschirm auf eine Abfrage wartet, schon.

Wohin EFOptimizeContext in EF Core 11 verschwunden ist

EF Core 9 lieferte im Paket Microsoft.EntityFrameworkCore.Tasks eine MSBuild-Integration mit, die das Compiled Model beim Build oder Publish neu generiert, sodass es nicht vom Code abdriften kann. In EF Core 9 und 10 schalteten Sie sie mit EFOptimizeContext=true ein und wählten dann die Phase mit EFScaffoldModelStage und EFPrecompileQueriesStage.

EF Core 11 hat EFOptimizeContext entfernt (dotnet/efcore#35079). Die beiden Stage-Eigenschaften funktionieren nun eigenständig, und das Setzen der alten Eigenschaft lässt den Build fehlschlagen. Ist PublishAot auf true gesetzt, ist die Generierung beim Publish standardmäßig aktiv. Ohne AOT sieht das Äquivalent zum alten Opt-in in EF Core 11 so aus:

<!-- .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>

Die generierten Dateien landen in obj/<Configuration>/<TFM>/ als *.g.cs und werden zur Kompilierung hinzugefügt, sodass nichts in die Versionskontrolle gelangt. Gültige Stage-Werte sind build, publish und alles andere (üblicherweise none), um die Funktion zu deaktivieren. Die MSBuild-Tasks-Referenz listet DbContextName, EFTargetNamespace, EFOutputDir und EFNullable für feinere Steuerung auf. Falls Sie ein Projekt mit der alten Eigenschaft aktualisiert haben und der Build explodiert ist, behandelt der Beitrag zu PublishAot plus EFOptimizeContext diesen Fehler und den vorangegangenen Bug mit erschöpftem Speicher.

Lohnt sich also der MSBuild-Weg? Für Native AOT ja: Sie brauchen ohnehin ein Compiled Model und vorkompilierte Abfragen, und deren Neugenerierung beim Publish ist der sicherste Weg, sie synchron zu halten. Für eine normale JIT-App sagen meine Messungen nein, aus zwei Gründen.

Der MSBuild-Weg erzeugt das langsamere, AOT-geprägte Modell

Die MSBuild-Dokumentation vermerkt, dass die Integration “will always generate additional code in the compiled model that’s required for NativeAOT”. In der Praxis bedeutet das eine zusätzliche Datei <Entity>UnsafeAccessors.g.cs pro Entitätstyp: 203 generierte Dateien für mein Modell mit 100 Entitäten statt 103. Dieser Code ist beim Start nicht gratis. Dasselbe Modell mit 100 Entitäten, dieselbe Maschine:

Quelle des Compiled ModelsModell ladenErste Abfrage
dotnet ef dbcontext optimize92 ms213 ms
dotnet ef dbcontext optimize --nativeaot231 ms394 ms
EFScaffoldModelStage=build235 ms393 ms
Kein Compiled Model280 ms383 ms

Das per MSBuild generierte Modell lädt nur etwa 45 ms schneller als gar kein Compiled Model, und die erste Abfrage war nicht schneller. Die Ausgabe der einfachen CLI lädt 2,5-mal schneller. Wenn Sie nicht mit AOT veröffentlichen, ist der NativeAOT-Code reiner Overhead.

Ein sauberer Build schlägt fehl, und der Wiederholungsversuch überspringt die Generierung unbemerkt

Der zweite Grund ist ein Problem mit der Build-Reihenfolge, auf das ich sowohl mit 11.0.0-rc.1 als auch mit 10.0.12 gestoßen bin. Das Tasks-Paket hängt die Generierung in TargetsTriggeredByCompilation ein, das direkt nach CoreCompile läuft, aber der Task OptimizeDbContext lädt die Assembly aus bin/, das erst später im Build befüllt wird. Bei einem frischen Checkout ohne bin-Ordner schlägt der erste Build fehl:

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

Ein erneutes dotnet build meldet dann Build succeeded, weil der Kompilierungsschritt aktuell ist und die Generierung übersprungen wird. Die entstandene App läuft ohne Compiled Model: Mein Test gab RuntimeModel und OnModelCreating=1 aus. Erst nachdem ich eine Quelldatei angefasst hatte, gab der nächste Build Optimizing DbContext... aus und erzeugte BenchContextModel. In CI, wo jeder Build sauber startet, ist das bei jedem Lauf ein roter Build. Ein zugehöriges Issue in dotnet/efcore habe ich zum Zeitpunkt des Schreibens nicht gefunden, prüfen Sie also die Release Notes, wenn 11.0 GA erscheint.

Der CLI-Weg: einmal generieren, einchecken

Für eine JIT-App ist das bessere Setup das alte: die CLI ausführen, die Ausgabe committen, bei Modelländerungen neu generieren.

# .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

Bei einem bereits gebauten Projekt dauerte die Generierung für meine drei Modellgrößen zwischen 1,3 und 2,3 Sekunden. Zwei wissenswerte Details: Das Projekt muss zuvor wiederhergestellt sein (sonst erhalten Sie Unable to retrieve project metadata), und wenn der Kontext in einem anderen Startprojekt konfiguriert ist, übergeben Sie --startup-project oder fügen eine IDesignTimeDbContextFactory<T> hinzu.

Das Risiko des CLI-Wegs ist Drift, und Drift ist schlimmer, als die Dokumentation vermuten lässt. Der Warm-up-Beitrag vom April besagte, dass EF Core ein veraltetes Compiled Model erkennt und eine Exception wirft. Ein Test mit EF Core 11 RC1 zeigt etwas anderes. Ich habe ein Compiled Model generiert, dann einer Entität eine Eigenschaft Sku hinzugefügt und eine Spalte mit HasColumnName("DisplayName") umbenannt, ohne neu zu generieren:

// .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"

Keine Exception, keine Warnung: Das veraltete Modell erzeugte SQL für den alten Spaltennamen. Die neue Eigenschaft Sku schlug zwar fehl, aber nur, wenn eine Abfrage sie verwendete, und zwar mit dem generischen Fehler “The LINQ expression could not be translated … Translation of member ‘Sku’ on entity type ‘Entity0’ failed”, der Sie nicht einmal in die Nähe der eigentlichen Ursache führt.

Die Lösung ist, Drift zu einem CI-Fehler zu machen. Der Generator ist deterministisch bis auf eine Zeile, die GUID modelId in <Context>ModelBuilder.cs, die sich bei jedem Lauf ändert. Git kann diese Zeile ignorieren:

# .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

Hat jemand das Modell geändert, ohne neu zu generieren, ist der Diff nicht leer und der Job schlägt fehl. Es ist dieselbe Idee wie die Prüfung auf ausstehende Migrationen, und sie passt in denselben CI-Schritt.

Was ein Compiled Model nicht kann

Die Learn-Seite hat eine Liste von Einschränkungen, die teilweise veraltet ist. Verifiziert mit EF Core 11 RC1:

Eine Entscheidungsregel, die trägt

Aus den Messungen und den Einschränkungen ergibt sich:

  1. Veröffentlichung mit Native AOT? Dann brauchen Sie es. Setzen Sie PublishAot, referenzieren Sie Microsoft.EntityFrameworkCore.Tasks und lassen Sie das Publish das Modell und die vorkompilierten Abfragen generieren. Ohne wirft die erste Abfrage “Model building is not supported when publishing with NativeAOT”, wie in der MAUI-iOS-Variante dieses Fehlers beschrieben.
  2. Kaltstarts sind für Benutzer sichtbar (Serverless, Scale-to-Zero-Container, CLI-Tools, Desktop-Apps) und Sie haben keine Query Filter? Nutzen Sie den CLI-Weg, committen Sie die Ausgabe und fügen Sie die Drift-Prüfung zu CI hinzu. Rechnen Sie mit etwa 100 ms Ersparnis bei kleinen Modellen und 500 ms bei 500 Entitätstypen. Es ergänzt sich gut mit den anderen Tricks aus Kaltstartzeit einer .NET 11 AWS Lambda reduzieren.
  3. Langlaufender Server, der sich vor dem Datenverkehr aufwärmt? Lassen Sie es. Ein Start-Warm-up, das DbContext.Model berührt, liefert dasselbe für den Benutzer sichtbare Ergebnis ohne generierten Code, den Sie warten müssten.
  4. Sie nutzen EFOptimizeContext aus EF Core 9 oder 10 für eine JIT-App? Erwägen Sie beim Upgrade auf EF Core 11, die MSBuild-Integration zu löschen, statt sie auf EFScaffoldModelStage=build zu übertragen. Sie erhalten ein schnelleres Modell von der CLI und vermeiden den Fehler beim sauberen Build.

Das Modell ist nur die Hälfte der Kosten der ersten Abfrage. Jede neue LINQ-Form wird ebenfalls einmal pro Prozess übersetzt und kompiliert; wenn einige wenige heiße Abfragen dominieren, greifen Compiled Queries für heiße Pfade in EF Core diese zweite Hälfte an.

Verwandte Beiträge

Quellen

Comments

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

< Zurück