O que é um modelo compilado no EF Core 11 e quando vale a pena habilitá-lo?
Um modelo compilado é código C# gerado que recria o seu modelo do EF Core sem executar OnModelCreating. Medido no EF Core 11 RC1: o carregamento do modelo cai de 714 ms para 215 ms com 500 entidades, mas o caminho via MSBuild (que substituiu EFOptimizeContext) é mais lento, modelos desatualizados falham em silêncio e filtros de consulta bloqueiam a geração.
Resposta curta: um modelo compilado é código-fonte C#, gerado por dotnet ef dbcontext optimize (ou pelo pacote MSBuild Microsoft.EntityFrameworkCore.Tasks), que reconstrói o seu modelo do EF Core diretamente, em vez de executar as convenções e o OnModelCreating no primeiro uso do DbContext. Vale a pena habilitá-lo quando a primeira consulta em um processo novo custa dinheiro ou latência de verdade (serverless, contêineres com autoescalonamento, ferramentas de CLI, apps desktop) e o seu modelo tem cerca de 100 tipos de entidade ou mais, ou quando você publica com Native AOT, onde ele é obrigatório. No EF Core 11 a propriedade EFOptimizeContext não existe mais: você opta por usá-lo com EFScaffoldModelStage. Para uma API web com 20 tabelas que inicia uma vez por semana, não vale a manutenção.
Todos os números e mensagens de erro abaixo vêm de execuções com Microsoft.EntityFrameworkCore.Sqlite 11.0.0-rc.1.26425.128 e dotnet-ef 11.0.0-rc.1.26425.128 no SDK do .NET 11 RC1 (11.0.100-rc.1.26425.128), builds Release, em um MacBook com Apple silicon. Uma verificação cruzada usou o EF Core 10.0.12 no SDK 10.0.302.
O que o EF Core faz no primeiro uso do DbContext
Criar um DbContext é barato. A parte cara acontece na primeira vez que algo toca em DbContext.Model: uma consulta, Add, SaveChanges ou a leitura direta de Model. Nesse momento o EF Core executa o pipeline de convenções sobre todos os tipos de entidade que consegue alcançar, descobre propriedades e relacionamentos por reflexão, aplica a configuração do seu OnModelCreating, valida o resultado e então converte o modelo mutável em um RuntimeModel somente leitura. O resultado fica em cache por tipo de contexto (mais precisamente, por chave de cache do modelo) durante toda a vida do processo, então você paga uma vez por processo.
Um modelo compilado pula o pipeline. Em vez de descobrir o modelo, o EF Core instancia classes geradas que chamam RuntimeModel.AddEntityType, AddProperty, AddKey, AddForeignKey e assim por diante com as respostas já resolvidas. Este é um trecho do que dotnet ef dbcontext optimize emitiu para uma entidade no meu projeto de teste:
// <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);
O maxLength: 200 veio de HasMaxLength(200) no OnModelCreating. Essa configuração agora está embutida no código gerado, que é o ponto central e também a origem de todas as armadilhas mais adiante neste post.
O comando escreve um arquivo <Entity>EntityType.cs por tipo de entidade, mais três: <Context>Model.cs (uma subclasse de RuntimeModel com um Instance estático), <Context>ModelBuilder.cs e <Context>AssemblyAttributes.cs. O último contém a linha que faz o EF Core encontrar o modelo sem nenhuma mudança de código do seu lado:
[assembly: DbContextModel(typeof(BenchContext), typeof(BenchContextModel), ProviderName = "Microsoft.EntityFrameworkCore.Sqlite")]
Desde o EF Core 9, um modelo compilado no mesmo assembly do contexto é descoberto automaticamente por meio desse atributo. Você só precisa de optionsBuilder.UseModel(BenchContextModel.Instance) quando o modelo compilado está em outro assembly, ou quando quer escolher entre vários modelos compilados em tempo de execução.
Medindo o que ele entrega
A página do Microsoft Learn sobre modelos compilados diz que eles ajudam “aplicativos com modelos grandes”, ou seja, “centenas a milhares de tipos de entidade”. Isso é vago o bastante para ser inútil na hora de decidir, então eu medi. Um script Python gerou modelos com 10, 100 e 500 tipos de entidade. Cada entidade tem oito propriedades escalares, uma FK anulável para a entidade anterior, um índice único e uma chamada HasMaxLength, para que as convenções tenham trabalho de verdade. O programa mede o tempo do primeiro acesso ao modelo e da primeira consulta em um processo novo:
// .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 é um contador estático incrementado dentro de OnModelCreating, então cada execução prova qual caminho seguiu. O banco de dados era um arquivo SQLite criado previamente. Medianas de sete inicializações de processo a frio por linha:
| Tipos de entidade | Carga do modelo, construído em runtime | Carga do modelo, compilado | Primeira consulta, runtime | Primeira consulta, compilado | Tamanho do assembly, runtime / compilado |
|---|---|---|---|---|---|
| 10 | 185 ms | 65 ms | 277 ms | 171 ms | 23 KB / 43 KB |
| 100 | 280 ms | 92 ms | 383 ms | 213 ms | 158 KB / 342 KB |
| 500 | 714 ms | 215 ms | 868 ms | 401 ms | 888 KB / 1,8 MB |
Duas coisas se destacam. Primeiro, a economia é real mesmo com 10 tipos de entidade, cerca de 110 ms, porque boa parte do custo em runtime é a compilação JIT do próprio pipeline de convenções, e não apenas a sua execução por entidade. Segundo, ela escala: com 500 tipos de entidade, o modelo compilado economiza meio segundo em cada inicialização a frio. OnModelCreating foi chamado zero vezes em todas as execuções compiladas e uma vez em todas as execuções em runtime.
Se 110 ms importam ou não é uma questão de produto. Não importam para um app ASP.NET Core atrás de um balanceador de carga que se aquece antes de receber tráfego. Importam para um AWS Lambda ou um plano de consumo do Azure Functions, onde a inicialização a frio é visível para o usuário, para uma dotnet tool que roda por dois segundos e para um app desktop ou MAUI em que a primeira tela espera por uma consulta.
Para onde foi o EFOptimizeContext no EF Core 11
O EF Core 9 trouxe uma integração com MSBuild no pacote Microsoft.EntityFrameworkCore.Tasks que regenera o modelo compilado durante o build ou o publish, de modo que ele não possa divergir do código. No EF Core 9 e 10 você o ligava com EFOptimizeContext=true e depois escolhia a etapa com EFScaffoldModelStage e EFPrecompileQueriesStage.
O EF Core 11 removeu EFOptimizeContext (dotnet/efcore#35079). As duas propriedades de etapa agora funcionam sozinhas, e definir a propriedade antiga faz o build falhar. Com PublishAot definido como true, a geração durante o publish vem ativada por padrão. Sem AOT, este é o equivalente no EF Core 11 do antigo opt-in:
<!-- .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>
Os arquivos gerados vão para obj/<Configuration>/<TFM>/ como *.g.cs e são adicionados à compilação, então nada vai para o controle de versão. Os valores de etapa válidos são build, publish e qualquer outro (por convenção none) para desativar. A referência das tarefas do MSBuild lista DbContextName, EFTargetNamespace, EFOutputDir e EFNullable para um controle mais fino. Se você atualizou um projeto que tinha a propriedade antiga e o build quebrou, o post sobre PublishAot com EFOptimizeContext cobre esse erro e o bug de esgotamento de memória que o antecedeu.
Então, vale a pena habilitar o caminho via MSBuild? Para Native AOT, sim: você precisa de um modelo compilado e de consultas pré-compiladas de qualquer forma, e regenerá-los no publish é a maneira mais segura de mantê-los sincronizados. Para um app JIT comum, minhas medições dizem que não, por dois motivos.
O caminho via MSBuild gera o modelo mais lento, com sabor de AOT
A documentação do MSBuild observa que a integração “will always generate additional code in the compiled model that’s required for NativeAOT”. Na prática isso significa um arquivo extra <Entity>UnsafeAccessors.g.cs por tipo de entidade: 203 arquivos gerados para o meu modelo de 100 entidades, em vez de 103. Esse código não é de graça na inicialização. Mesmo modelo de 100 entidades, mesma máquina:
| Origem do modelo compilado | Carga do modelo | Primeira consulta |
|---|---|---|
dotnet ef dbcontext optimize | 92 ms | 213 ms |
dotnet ef dbcontext optimize --nativeaot | 231 ms | 394 ms |
EFScaffoldModelStage=build | 235 ms | 393 ms |
| Sem modelo compilado | 280 ms | 383 ms |
O modelo gerado pelo MSBuild carrega apenas cerca de 45 ms mais rápido do que não ter modelo compilado algum, e a primeira consulta não ficou mais rápida. A saída da CLI simples carrega 2,5 vezes mais rápido. Se você não publica com AOT, o código NativeAOT é puro custo extra.
Um build limpo falha, e a nova tentativa pula a geração em silêncio
O segundo motivo é um problema de ordem de build que encontrei tanto no 11.0.0-rc.1 quanto no 10.0.12. O pacote Tasks encaixa a geração em TargetsTriggeredByCompilation, que roda logo após CoreCompile, mas a tarefa OptimizeDbContext carrega o assembly de bin/, que só é preenchido mais tarde no build. A partir de um checkout limpo, sem a pasta bin, o primeiro build falha:
Optimizing DbContext...
Microsoft.EntityFrameworkCore.Tasks.targets(105,5): error : File '.../bin/Debug/net11.0/Bench.dll' not found.
Build FAILED.
Rodar dotnet build de novo então informa Build succeeded, porque a etapa de compilação está atualizada e a geração é pulada. O app resultante roda sem um modelo compilado: meu teste imprimiu RuntimeModel e OnModelCreating=1. Só depois de tocar em um arquivo-fonte o build seguinte imprimiu Optimizing DbContext... e produziu BenchContextModel. No CI, onde todo build começa limpo, isso é um build vermelho a cada execução. Não encontrei uma issue rastreando isso em dotnet/efcore no momento em que escrevo, então confira as notas de versão quando o 11.0 GA for lançado.
O caminho via CLI: gere uma vez, faça o commit
Para um app JIT, a melhor configuração é a antiga: rodar a CLI, fazer o commit da saída e regenerar quando o modelo mudar.
# .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
Em um projeto já compilado, a geração levou entre 1,3 e 2,3 segundos para os meus três tamanhos de modelo. Dois detalhes que vale conhecer: o projeto precisa ter sido restaurado antes (caso contrário você recebe Unable to retrieve project metadata), e, se o contexto é configurado em outro projeto de inicialização, passe --startup-project ou adicione um IDesignTimeDbContextFactory<T>.
O risco do caminho via CLI é a divergência, e a divergência é pior do que a documentação faz parecer. O post sobre aquecimento de abril dizia que o EF Core detecta um modelo compilado desatualizado e lança uma exceção. Testando no EF Core 11 RC1, o resultado é outro. Gerei um modelo compilado, depois adicionei uma propriedade Sku a uma entidade e renomeei uma coluna com HasColumnName("DisplayName") sem 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"
Nenhuma exceção, nenhum aviso: o modelo desatualizado gerou SQL com o nome antigo da coluna. A nova propriedade Sku de fato falhou, mas só quando uma consulta a usou, com o erro genérico “The LINQ expression could not be translated … Translation of member ‘Sku’ on entity type ‘Entity0’ failed”, que não aponta para nada perto da causa real.
A correção é transformar a divergência em uma falha de CI. O gerador é determinístico, exceto por uma linha, o GUID modelId em <Context>ModelBuilder.cs, que muda a cada execução. O Git pode ignorar essa linha:
# .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
Se alguém mudou o modelo sem regenerar, o diff não fica vazio e o job falha. É a mesma ideia de verificar migrações pendentes, e cabe na mesma etapa do CI.
O que um modelo compilado não consegue fazer
A página do Learn tem uma lista de limitações, mas ela está parcialmente desatualizada. Verificado contra o EF Core 11 RC1:
- Filtros de consulta globais bloqueiam a geração por completo. Um único
HasQueryFilterem qualquer ponto do modelo edotnet ef dbcontext optimizepara comThe entity type 'Entity1' has a query filter configured. Compiled model can't be generated, because query filters are not supported.A issue de acompanhamento, dotnet/efcore#24897, continua aberta no backlog. Se você usa exclusão lógica (soft delete) ou multitenancy via filtros de consulta, modelos compilados estão fora de cogitação hoje. - O modelo precisa ser regenerado manualmente (caminho via CLI) sempre que uma entidade, uma chamada de configuração ou uma convenção muda. Veja a seção sobre divergência acima.
- Implementações personalizadas de
IModelCacheKeyFactorynão são suportadas. Se o seu modelo varia por tenant ou por esquema, gere um modelo compilado por variante em pastas e namespaces separados, e escolha um comUseModel(...)a partir do estado em runtime, como mostra a página do Learn. - Conversores de valor que referenciam métodos privados não podem ser gerados; torne os métodos
internaloupublic. EnsureCreatede as migrações ainda executamOnModelCreating. ChamarDatabase.EnsureCreated()com um modelo compilado presente incrementou o meu contador deOnModelCreatingpara 1, porque as operações de esquema precisam do modelo completo de design-time, não do modelo de runtime reduzido. Um modelo compilado acelera consultas eSaveChanges, não a criação do esquema na sua inicialização. Se uma suíte de testes chamaEnsureCreatedem cada fixture, não espere que ela fique mais rápida.- Proxies de lazy-loading e de change-tracking ainda constam na lista do Learn, mas a issue de acompanhamento, dotnet/efcore#24902, foi fechada para o EF Core 7.0. Não testei proxies de novo para este post, então trate esse item como desatualizado, e não como suporte verificado.
- Métodos parciais
Customize()no modelo gerado funcionam com o caminho via CLI, mas não com o caminho via MSBuild, porque o projeto precisa compilar antes de o modelo existir.
Uma regra de decisão que se sustenta
Juntando as medições e as limitações:
- Publicando com Native AOT? Você precisa dele. Defina
PublishAot, referencieMicrosoft.EntityFrameworkCore.Taskse deixe o publish gerar o modelo e as consultas pré-compiladas. Sem isso, a primeira consulta lança “Model building is not supported when publishing with NativeAOT”, como tratado em a versão desse erro no MAUI iOS. - Inicializações a frio são visíveis para o usuário (serverless, contêineres com scale-to-zero, ferramentas de CLI, apps desktop) e você não tem filtros de consulta? Use o caminho via CLI, faça o commit da saída e adicione a verificação de divergência ao CI. Espere cerca de 100 ms economizados em modelos pequenos e 500 ms com 500 tipos de entidade. Combina bem com os outros truques em reduzir o tempo de inicialização a frio de um AWS Lambda com .NET 11.
- Servidor de longa duração que se aquece antes do tráfego? Pule. Um aquecimento na inicialização que toca em
DbContext.Modeldá o mesmo resultado para o usuário, sem código gerado para manter. - Usando
EFOptimizeContextdo EF Core 9 ou 10 em um app JIT? Na atualização para o EF Core 11, considere apagar a integração com o MSBuild em vez de traduzi-la paraEFScaffoldModelStage=build. Você obtém um modelo mais rápido pela CLI e evita a falha no build limpo.
O modelo é apenas metade do custo da primeira consulta. Cada nova forma de LINQ também é traduzida e compilada uma vez por processo; se um punhado de consultas quentes domina, as consultas compiladas para caminhos quentes do EF Core atacam essa segunda metade.
Veja também
- Como aquecer o modelo do EF Core antes da primeira consulta, a alternativa sem manutenção para servidores de longa duração.
- Correção: PublishAot com EFOptimizeContext esgota a memória durante um build do EF Core, para o bug de build do EF Core 10 e o erro de remoção no EF Core 11.
- Como usar consultas compiladas com o EF Core em caminhos quentes, a contrapartida de consultas de um modelo compilado.
- O que é uma shadow property no EF Core 11, útil ao ler um arquivo
EntityType.csgerado e se perguntar de onde veio uma propriedade extra.
Fontes
- Compiled models, documentação do EF Core, incluindo a lista de limitações.
- EF Core MSBuild tasks, documentação do EF Core, para
EFScaffoldModelStage,EFPrecompileQueriesStagee a nota sobre o código NativeAOT. - EF Core 11 breaking changes:
EFOptimizeContextMSBuild property has been removed. - What’s new in EF Core 9: auto-compiled models, para a descoberta automática e a integração original com o MSBuild.
dotnet ef dbcontext optimize, referência da CLI para--output-dir,--namespace,--nativeaote--precompile-queries.- dotnet/efcore#24897 (filtros de consulta em modelos compilados, aberta) e dotnet/efcore#35079 (remoção de
EFOptimizeContext).
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.