Start Debugging

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 entidadeCarga do modelo, construído em runtimeCarga do modelo, compiladoPrimeira consulta, runtimePrimeira consulta, compiladoTamanho do assembly, 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

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 compiladoCarga do modeloPrimeira consulta
dotnet ef dbcontext optimize92 ms213 ms
dotnet ef dbcontext optimize --nativeaot231 ms394 ms
EFScaffoldModelStage=build235 ms393 ms
Sem modelo compilado280 ms383 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:

Uma regra de decisão que se sustenta

Juntando as medições e as limitações:

  1. Publicando com Native AOT? Você precisa dele. Defina PublishAot, referencie Microsoft.EntityFrameworkCore.Tasks e 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.
  2. 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.
  3. Servidor de longa duração que se aquece antes do tráfego? Pule. Um aquecimento na inicialização que toca em DbContext.Model dá o mesmo resultado para o usuário, sem código gerado para manter.
  4. Usando EFOptimizeContext do 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 para EFScaffoldModelStage=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

Fontes

Comments

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

< Voltar