Description
Increased Memory Usage with Lazy Loading in EF Core 8
I migrated the project from EF Core 7 to EF Core 8. After deploying the updated code, I noticed an increase in memory usage by the application, as well as some out-of-memory exceptions.
Upon investigation, I found the issue detailed in #32345 - there is now a List in the LazyLoaderFactory, which retains every created lazy loader instance throughout the whole DbContext lifespan.
In cases where we enumerate over a large number of rows, thousands or more, this new list leads to enormous memory allocations.
This issue is blocking the migration process of our project. Within our codebase, there are segments where we iterate over thousands and millions of rows, and we have historically implemented lazy loading extensively, a practice dating back to the LinqToSql days.
We utilize lazy-loader delegate injection in the entities' constructors. This approach functioned well with Entity Framework 7, as ILazyLoader instances for enumerated rows were disposed of in response to memory pressure. However, with the Entity Framework 8, all created ILazyLoader instances are only disposed of when the DbContext is either disposed of or reset.
I created a simple reproducible benchmark to demonstrate this and have also attached the stack trace from one of the out-of-memory exceptions.
Include your code
The full source code is available here.
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using Microsoft.EntityFrameworkCore;
namespace MyApp
{
internal class Program
{
static void Main(string[] args)
{
BenchmarkRunner.Run<EfLazyLoading>();
}
}
[MemoryDiagnoser]
public class EfLazyLoading
{
[GlobalSetup]
public void Setup()
{
using var context = new EntityDbContext();
context.Database.EnsureDeleted();
context.Database.EnsureCreated();
for (var i = 0; i < 1000000; i++)
{
context.Entities.Add(new Entity());
}
context.SaveChanges();
}
[Benchmark]
public long Enumerate()
{
using var context = new EntityDbContext();
long id = 0;
foreach (var entity in context.Entities)
{
id = entity.Id;
}
return id;
}
}
public class Entity
{
private readonly Action<object, string>? _lazyLoader;
public int Id { get; set; }
public Entity() : this(null)
{
}
protected Entity(Action<object, string>? lazyLoader)
{
_lazyLoader = lazyLoader;
}
}
public class EntityDbContext : DbContext
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseInMemoryDatabase("TestDatabase");
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Entity>(b =>
{
b.HasKey(e => e.Id);
b.Property(e => e.Id).ValueGeneratedOnAdd();
});
}
public DbSet<Entity> Entities { get; set; }
}
}
Include stack traces
System.OutOfMemoryException: Exception of type 'System.OutOfMemoryException' was thrown.
?, in void List<T>.set_Capacity(int value)
?, in void List<T>.AddWithResize(T item)
?, in ILazyLoader LazyLoaderFactory.Create()
?, in object ResolveService(ILEmitResolverBuilderRuntimeContext, ServiceProviderEngineScope)
?, in object InfrastructureExtensions.GetService(IInfrastructure<IServiceProvider> accessor, Type serviceType)
?, in WorkItemDataItem lambda_method2182(Closure, QueryContext, DbDataReader, ResultContext, SingleQueryResultCoordinator)
?, in bool Enumerator.MoveNext()
File "BusinessTransactions/BulkOperations/Tasks/BulkOperationTask.cs", line 580, col 25, in WorkItemProcessingStatistics BulkOperationTask.GetRowProcessingStatisticsForWorkItemDataItems(WorkItem workItem, ModelContext modelContext)
Include verbose output
Results with lazy-loading
BenchmarkDotNet v0.13.12, macOS Sonoma 14.3 (23D56) [Darwin 23.3.0]
Apple M1 Pro, 1 CPU, 10 logical and 10 physical cores
.NET SDK 8.0.203
[Host] : .NET 8.0.3 (8.0.324.11423), Arm64 RyuJIT AdvSIMD
DefaultJob : .NET 8.0.3 (8.0.324.11423), Arm64 RyuJIT AdvSIMD
Microsoft.EntityFrameworkCore 8.0.3
| Method | Mean | Error | StdDev | Gen0 | Gen1 | Gen2 | Allocated |
|---------- |--------:|---------:|---------:|------------:|------------:|----------:|----------:|
| Enumerate | 3.104 s | 0.0120 s | 0.0100 s | 281000.0000 | 104000.0000 | 2000.0000 | 1.81 GB |
Microsoft.EntityFrameworkCore 7.0.17
| Method | Mean | Error | StdDev | Gen0 | Gen1 | Gen2 | Allocated |
|---------- |--------:|---------:|---------:|------------:|-----------:|----------:|----------:|
| Enumerate | 1.968 s | 0.0381 s | 0.0356 s | 168000.0000 | 46000.0000 | 1000.0000 | 1.16 GB |
Results without lazy-loading (remove all constructors from the Entity)
BenchmarkDotNet v0.13.12, macOS Sonoma 14.3 (23D56) [Darwin 23.3.0]
Apple M1 Pro, 1 CPU, 10 logical and 10 physical cores
.NET SDK 8.0.203
[Host] : .NET 8.0.3 (8.0.324.11423), Arm64 RyuJIT AdvSIMD
DefaultJob : .NET 8.0.3 (8.0.324.11423), Arm64 RyuJIT AdvSIMD
Microsoft.EntityFrameworkCore 8.0.3
| Method | Mean | Error | StdDev | Gen0 | Gen1 | Gen2 | Allocated |
|---------- |--------:|---------:|---------:|------------:|-----------:|----------:|----------:|
| Enumerate | 1.182 s | 0.0093 s | 0.0087 s | 117000.0000 | 34000.0000 | 1000.0000 | 861.84 MB |
Microsoft.EntityFrameworkCore 7.0.17
| Method | Mean | Error | StdDev | Gen0 | Gen1 | Gen2 | Allocated |
|---------- |--------:|---------:|---------:|------------:|-----------:|----------:|----------:|
| Enumerate | 1.266 s | 0.0102 s | 0.0096 s | 132000.0000 | 40000.0000 | 1000.0000 | 953.39 MB |
Include provider and version information
EF Core version: 8.0.3
Database provider: Microsoft.EntityFrameworkCore.SqlServer, Microsoft.EntityFrameworkCore.InMemory
Target framework: .NET 8.0
Operating system: ubuntu 22, macOS Sonoma 14.3