Start Debugging

Qué es un modelo compilado en EF Core 11 y cuándo vale la pena activarlo

Un modelo compilado es C# generado que recrea tu modelo de EF Core sin ejecutar OnModelCreating. Medido en EF Core 11 RC1: la carga del modelo baja de 714 ms a 215 ms con 500 entidades, pero la vía de MSBuild (que reemplazó a EFOptimizeContext) es más lenta, los modelos desactualizados fallan en silencio y los filtros de consulta bloquean la generación.

Respuesta corta: un modelo compilado es código fuente C#, generado por dotnet ef dbcontext optimize (o por el paquete de MSBuild Microsoft.EntityFrameworkCore.Tasks), que reconstruye tu modelo de EF Core directamente en lugar de ejecutar las convenciones y OnModelCreating en el primer uso del DbContext. Vale la pena activarlo cuando la primera consulta en un proceso nuevo te cuesta dinero o latencia reales (serverless, contenedores con escalado automático, herramientas de CLI, aplicaciones de escritorio) y tu modelo tiene unos 100 tipos de entidad o más, o cuando publicas con Native AOT, donde es obligatorio. En EF Core 11 la propiedad EFOptimizeContext ya no existe: ahora se activa con EFScaffoldModelStage. Para una API web de 20 tablas que arranca una vez por semana, no vale el costo de mantenimiento.

Todos los números y mensajes de error de abajo provienen de ejecuciones con Microsoft.EntityFrameworkCore.Sqlite 11.0.0-rc.1.26425.128 y dotnet-ef 11.0.0-rc.1.26425.128 sobre el SDK de .NET 11 RC1 (11.0.100-rc.1.26425.128), compilaciones Release, en una MacBook con Apple silicon. Una verificación cruzada usó EF Core 10.0.12 con el SDK 10.0.302.

Qué hace EF Core en el primer uso del DbContext

Crear un DbContext es barato. Lo costoso ocurre la primera vez que algo toca DbContext.Model: una consulta, Add, SaveChanges, o leer Model tú mismo. En ese momento EF Core ejecuta su pipeline de convenciones sobre cada tipo de entidad alcanzable, descubre propiedades y relaciones por reflexión, aplica tu configuración de OnModelCreating, valida el resultado y luego convierte el modelo mutable en un RuntimeModel de solo lectura. El resultado se almacena en caché por tipo de contexto (más exactamente, por clave de caché del modelo) durante toda la vida del proceso, así que se paga una vez por proceso.

Un modelo compilado se salta el pipeline. En lugar de descubrir el modelo, EF Core instancia clases generadas que llaman a RuntimeModel.AddEntityType, AddProperty, AddKey, AddForeignKey y así sucesivamente con las respuestas ya resueltas. Este es un fragmento de lo que dotnet ef dbcontext optimize emitió para una entidad en mi proyecto de prueba:

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

El maxLength: 200 vino de HasMaxLength(200) en OnModelCreating. Esa configuración ahora queda incrustada en el código generado, que es justamente el objetivo y también el origen de cada problema que aparece más adelante en este artículo.

El comando escribe un archivo <Entity>EntityType.cs por cada tipo de entidad, más tres adicionales: <Context>Model.cs (una subclase de RuntimeModel con un Instance estático), <Context>ModelBuilder.cs y <Context>AssemblyAttributes.cs. Este último contiene la línea que hace que EF Core encuentre el modelo sin ningún cambio de código de tu parte:

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

Desde EF Core 9, un modelo compilado en el mismo ensamblado que el contexto se descubre automáticamente a través de ese atributo. Solo necesitas optionsBuilder.UseModel(BenchContextModel.Instance) cuando el modelo compilado vive en un ensamblado distinto, o cuando quieres elegir entre varios modelos compilados en tiempo de ejecución.

Medir qué ganas realmente

La página de Microsoft Learn sobre modelos compilados dice que ayudan a “aplicaciones con modelos grandes”, es decir, “cientos a miles de tipos de entidad”. Eso es demasiado vago para decidir algo, así que lo medí. Un script de Python generó modelos con 10, 100 y 500 tipos de entidad. Cada entidad tiene ocho propiedades escalares, una FK anulable a la entidad anterior, un índice único y una llamada a HasMaxLength, de modo que las convenciones tengan trabajo real que hacer. El programa mide el primer acceso al modelo y la primera consulta en un proceso nuevo:

// .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 es un contador estático que se incrementa dentro de OnModelCreating, de modo que cada ejecución demuestra qué camino tomó. La base de datos fue un archivo SQLite creado de antemano. Medianas de siete arranques en frío por fila:

Tipos de entidadCarga del modelo, construido en runtimeCarga del modelo, compiladoPrimera consulta, runtimePrimera consulta, compiladoTamaño del ensamblado, runtime / compilado
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

Dos cosas destacan. Primero, el ahorro es real incluso con 10 tipos de entidad, unos 110 ms, porque buena parte del costo en runtime es la compilación JIT del propio pipeline de convenciones, no solo su ejecución por entidad. Segundo, escala: con 500 tipos de entidad el modelo compilado ahorra medio segundo en cada arranque en frío. OnModelCreating se llamó cero veces en cada ejecución compilada y una vez en cada ejecución en runtime.

Si 110 ms importan o no es una decisión de producto. No importan para una aplicación ASP.NET Core detrás de un balanceador de carga que se calienta antes de recibir tráfico. Sí importan para AWS Lambda o un plan de consumo de Azure Functions, donde el arranque en frío lo percibe el usuario, para un dotnet tool que corre dos segundos, y para una aplicación de escritorio o MAUI cuya primera pantalla espera una consulta.

Adónde fue EFOptimizeContext en EF Core 11

EF Core 9 incluyó una integración con MSBuild en el paquete Microsoft.EntityFrameworkCore.Tasks que regenera el modelo compilado durante la compilación o la publicación, de modo que no se desfase respecto al código. En EF Core 9 y 10 se activaba con EFOptimizeContext=true y luego se elegía la etapa con EFScaffoldModelStage y EFPrecompileQueriesStage.

EF Core 11 eliminó EFOptimizeContext (dotnet/efcore#35079). Las dos propiedades de etapa ahora funcionan por sí solas, y definir la propiedad antigua hace fallar la compilación. Con PublishAot en true, la generación durante la publicación está activada por defecto. Sin AOT, este es el equivalente en EF Core 11 de la antigua activación opcional:

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

Los archivos generados van a obj/<Configuration>/<TFM>/ como *.g.cs y se agregan a la compilación, así que nada llega al control de código fuente. Los valores válidos de etapa son build, publish y cualquier otro (por convención none) para desactivarla. La referencia de tareas de MSBuild enumera DbContextName, EFTargetNamespace, EFOutputDir y EFNullable para un control más fino. Si actualizaste un proyecto que tenía la propiedad antigua y la compilación explotó, el artículo sobre PublishAot más EFOptimizeContext cubre ese error y el bug de agotamiento de memoria que lo precedió.

Entonces, ¿vale la pena activar la vía de MSBuild? Para Native AOT, sí: de todos modos necesitas un modelo compilado y consultas precompiladas, y regenerarlos al publicar es la forma más segura de mantenerlos sincronizados. Para una aplicación JIT normal, mis mediciones dicen que no, por dos razones.

La vía de MSBuild genera el modelo más lento, con sabor a AOT

La documentación de MSBuild señala que la integración “will always generate additional code in the compiled model that’s required for NativeAOT”. En la práctica eso significa un archivo extra <Entity>UnsafeAccessors.g.cs por cada tipo de entidad: 203 archivos generados para mi modelo de 100 entidades en lugar de 103. Ese código no es gratis al arrancar. Mismo modelo de 100 entidades, misma máquina:

Origen del modelo compiladoCarga del modeloPrimera consulta
dotnet ef dbcontext optimize92 ms213 ms
dotnet ef dbcontext optimize --nativeaot231 ms394 ms
EFScaffoldModelStage=build235 ms393 ms
Sin modelo compilado280 ms383 ms

El modelo generado por MSBuild carga solo unos 45 ms más rápido que no tener ningún modelo compilado, y la primera consulta no fue más rápida. La salida de la CLI simple carga 2,5 veces más rápido. Si no publicas con AOT, el código de NativeAOT es puro sobrecosto.

Una compilación limpia falla, y el reintento omite la generación en silencio

La segunda razón es un problema de orden de compilación que encontré tanto en 11.0.0-rc.1 como en 10.0.12. El paquete Tasks engancha la generación en TargetsTriggeredByCompilation, que se ejecuta justo después de CoreCompile, pero la tarea OptimizeDbContext carga el ensamblado desde bin/, que no se llena hasta más tarde en la compilación. Desde un checkout limpio, sin carpeta bin, la primera compilación falla:

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

Al ejecutar dotnet build de nuevo se reporta Build succeeded, porque el paso de compilación está al día y la generación se omite. La aplicación resultante corre sin modelo compilado: mi prueba imprimió RuntimeModel y OnModelCreating=1. Solo después de tocar un archivo fuente la siguiente compilación imprimió Optimizing DbContext... y produjo BenchContextModel. En CI, donde cada compilación parte de cero, eso es una compilación en rojo en cada ejecución. No encontré un issue que lo registre en dotnet/efcore al momento de escribir esto, así que revisa las notas de la versión cuando salga 11.0 GA.

La vía de la CLI: generar una vez y subirlo al repositorio

Para una aplicación JIT, la mejor configuración es la antigua: ejecutar la CLI, hacer commit de la salida y regenerar cuando el modelo cambie.

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

En un proyecto ya compilado, la generación tardó entre 1,3 y 2,3 segundos para mis tres tamaños de modelo. Dos detalles que conviene saber: el proyecto debe estar restaurado primero (de lo contrario aparece Unable to retrieve project metadata), y si el contexto está configurado en otro proyecto de inicio, pasa --startup-project o agrega un IDesignTimeDbContextFactory<T>.

El riesgo de la vía de la CLI es el desfase, y el desfase es peor de lo que sugiere la documentación. El artículo de calentamiento de abril decía que EF Core detecta un modelo compilado desactualizado y lanza una excepción. Probarlo en EF Core 11 RC1 dice otra cosa. Generé un modelo compilado, luego agregué una propiedad Sku a una entidad y renombré una columna con HasColumnName("DisplayName") sin regenerar:

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

Ni excepción ni advertencia: el modelo desactualizado generó SQL con el nombre de columna antiguo. La nueva propiedad Sku sí falló, pero solo cuando una consulta la usaba, con el error genérico “The LINQ expression could not be translated … Translation of member ‘Sku’ on entity type ‘Entity0’ failed”, que no te acerca en nada a la causa real.

La solución es convertir el desfase en un fallo de CI. El generador es determinista salvo por una línea, el GUID modelId en <Context>ModelBuilder.cs, que cambia en cada ejecución. Git puede ignorar esa línea:

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

Si alguien cambió el modelo sin regenerar, el diff no está vacío y el job falla. Es la misma idea que revisar migraciones pendientes, y cabe en el mismo paso de CI.

Lo que un modelo compilado no puede hacer

La página de Learn tiene una lista de limitaciones, pero está en parte desactualizada. Verificado contra EF Core 11 RC1:

Una regla de decisión que se sostiene

Juntando las mediciones y las limitaciones:

  1. ¿Publicas con Native AOT? Lo necesitas. Define PublishAot, referencia Microsoft.EntityFrameworkCore.Tasks y deja que la publicación genere el modelo y las consultas precompiladas. Sin él, la primera consulta lanza “Model building is not supported when publishing with NativeAOT”, como se explica en la versión de MAUI iOS de ese error.
  2. ¿Los arranques en frío son visibles para el usuario (serverless, contenedores que escalan a cero, herramientas de CLI, aplicaciones de escritorio) y no tienes filtros de consulta? Usa la vía de la CLI, haz commit de la salida y agrega la verificación de desfase a CI. Espera ahorrar unos 100 ms con modelos pequeños y 500 ms con 500 tipos de entidad. Se combina bien con los otros trucos de reducir el tiempo de arranque en frío de un AWS Lambda en .NET 11.
  3. ¿Servidor de larga ejecución que se calienta antes de recibir tráfico? Sáltatelo. Un calentamiento al inicio que toque DbContext.Model te da el mismo resultado de cara al usuario sin código generado que mantener.
  4. ¿Usabas EFOptimizeContext de EF Core 9 o 10 en una aplicación JIT? Al actualizar a EF Core 11, considera eliminar la integración con MSBuild en lugar de traducirla a EFScaffoldModelStage=build. Obtienes un modelo más rápido desde la CLI y evitas el fallo de la compilación limpia.

El modelo es solo la mitad del costo de la primera consulta. Cada nueva forma de LINQ también se traduce y se compila una vez por proceso; si un puñado de consultas calientes domina, las consultas compiladas para rutas críticas de EF Core atacan esa segunda mitad.

Sources

Comments

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

< Volver