Object Relational Mappers (ORMs) can help increase our productivity. The ORM pattern can handle the integration between our C# models and the database by providing an abstraction that limits our need to understand the underlying data-access mechanisms (not an excuse to be ignorant).
The concept can be helpful when starting development of our applications, but we always want to stay vigilant for low-effort optimizations that can help our users.
In this post, we’ll be seeing a simple technique that can yield up to 400% speed improvements and cut our memory utilization by 100% when using Entity Framework Core (EF Core).
Object Tracking
The general goal of an ORM is to help us manage changes between our in-memory state and the state stored in our database. This feature of ORMs is commonly known as Object Tracking, and each ORM can choose to do this slightly differently. We may also hear folks mention the change tracker
when speaking of Entity Framework Core.
If an entity is tracked, any changes detected in the entity will be persisted to the database during
SaveChanges()
–Microsoft
In regards to Entity Framework Core, by default, entity tracking occurs on all entities that are part of our data context. As we may have guessed, nothing happens for free. There are memory and processing costs to tracking entities that pass through our data context.
The Issue
Let’s start with an underlying assumption. Most applications are read heavy, which means we retrieve more data from our databases than we write. If we accept that fact, that means we are wasting resources on unnecessary object tracking.
The Solution
Entity Framework Core developers understand there is a cost to object tracking, and have allowed us to disable it in an ad hoc fashion. We may have seen the use of the AsNoTracking
construct in our codebase.
var blogs = context
.Blogs
.AsNoTracking()
.ToList();
The call to AsNoTracking
does two things of note:
- It tells Entity Framework Core not to track the entities resulting from our query.
- It keeps Entity Framework Core from using memory to cache any of the results.
We can also control this behavior at the context level.
context
.ChangeTracker
.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
The Approach
There are several ways to turn off object tracking, and we saw a way to ask EF Core to stop tracking explicitly for each query. While the approach works, it can be fraught with human error. Let’s take a look at an example using SQLite
and Entity Framework Core. For this example, we’ve seeded 1000 records into our Item
table.
public class ShopDbContext : DbContext
{
public DbSet<Item> Items { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.UseSqlite("Data Source=example.db");
}
}
public class Item
{
public int Id { get; set; }
public string Name { get; set; }
public string Category { get; set; }
public decimal Price { get; set; }
}
We can inherit from our ShopDbContext
and create a LightShopDbContext
that turns object tracking off by default.
public sealed class LightShopDbContext : ShopDbContext
{
public LightShopDbContext()
{
// light sessions only
// this will improve performance with no tracking
ChangeTracker.QueryTrackingBehavior =
QueryTrackingBehavior.NoTracking;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// leave the heavy lifting to the base class
}
}
The main benefit of this approach is we know we are explicitly dealing with a no tracking context. There is no confusion in our codebase of what is happening and what the expectations are. Let’s take a look at a sample usage utilizing Razor Pages.
public async Task OnGet([FromServices] LightShopDbContext db)
{
Results = await db
.Items
.Where(x => (double) x.Price > 100)
.ToListAsync();
}
We can test the difference between having object tracking turned on and off. Note, we throw away the first execution because it is an outlier due to start-up costs.
public async Task OnGet()
{
var services = HttpContext.RequestServices;
Light = await ExecuteAsync(
() => services.GetService(typeof(LightShopDbContext)) as ShopDbContext,
NumberOfExecutions
);
Heavy = await ExecuteAsync(
() => services.GetService(typeof(ShopDbContext)) as ShopDbContext,
NumberOfExecutions
);
}
public static async Task<List<QueryResult>> ExecuteAsync(
Func<ShopDbContext> databaseFactory,
int numOfExecutions = 10)
{
var queryResults = new List<QueryResult>();
var random = new Random();
// the first execution is an outlier
for (var i = 0; i < numOfExecutions + 1; i++)
{
// create a database each time
// closer to what happens in a real-world app
await using var db = databaseFactory();
var stopwatch = Stopwatch.StartNew();
var skip = random.Next(1, 100);
var items =
await db.Items
.Where(x => (double)x.Price > 0)
.Skip(skip)
.Take(100)
.ToListAsync();
stopwatch.Stop();
queryResults.Add(new QueryResult
{
Elapsed = stopwatch.Elapsed
});
}
return queryResults.Skip(1).ToList();
}
When we execute the request, we can see the LightShopDbContext
on average is performing faster than the tracked ShopDbContext
.
For this test, we registered each context as Transient
to be able to mimic the behavior of a new context per request.
public void ConfigureServices(IServiceCollection services)
{
services.AddRazorPages();
services.AddTransient<ShopDbContext>();
services.AddTransient<LightShopDbContext>();
}
Running the queries in a loop with a Scoped
instance would have resulted in the ShopDbContext
being better since each query would have likely pulled its results from the in-memory cache. It is highly recommended you register a DbContext
as Scoped
to take advantage of caching and tracking when needed.
Query Benchmarks
We may be saying, “well, this isn’t very scientific.” We would be right!
I wrote a Benchmark.NET harness to see the performance and memory profile of this method under a rigorous test.
[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.NetCoreApp31)]
public class NoTrackingBenchmarks
{
[Params(1, 10, 100)]
public int NumberOfExecutions { get; set; }
[GlobalSetup]
public void Setup()
{
using var db = new ShopDbContext();
db.Database.EnsureCreated();
}
[Benchmark]
public void Light()
{
IndexModel.ExecuteAsync(
() => new LightShopDbContext(),
NumberOfExecutions);
}
[Benchmark]
public void Heavy()
{
IndexModel.ExecuteAsync(
() => new ShopDbContext(),
NumberOfExecutions);
}
}
We get the following results after running the benchmark harness.
We can see the query performance is faster with the LightShopDbContext
and that it has much less memory allocation. We get this because we are no longer tracking our EF Core query results.
Method | NumberOfExecutions | Mean | Error | StdDev | Median | Gen 0 | Gen 1 | Gen 2 | Allocated |
---|---|---|---|---|---|---|---|---|---|
Light | 1 | 7.057 ms | 1.157 ms | 3.226 ms | 5.896 ms | - | - | - | 167.22 KB |
Heavy | 1 | 12.488 ms | 2.013 ms | 5.935 ms | 15.364 ms | 140.6250 | - | - | 317.04 KB |
Light | 10 | 27.664 ms | 1.343 ms | 3.721 ms | 27.155 ms | 428.5714 | - | - | 879.29 KB |
Heavy | 10 | 44.481 ms | 1.976 ms | 5.308 ms | 43.291 ms | - | - | - | 1742.6 KB |
Light | 100 | 233.770 ms | 4.599 ms | 10.475 ms | 231.551 ms | 3000.0000 | - | - | 8069.05 KB |
Heavy | 100 | 265.456 ms | 5.247 ms | 9.461 ms | 264.513 ms | 7000.0000 | - | - | 15987.02 KB |
Awesome!
Conclusion
Object tracking is an essential feature of any ORM, but more of the applications we write are read-heavy, meaning we don’t see the benefit unless we are editing our models. Disabling this feature can yield performance and memory gains that will make all our stakeholders happy. Remember, this approach will vary based on other factors.
Explicitly creating a no-tracking context will make sure there is no confusion in our codebase. Even if we don’t end up taking the no-tracking context approach, we must evaluate our query executions for the proper use of AsNoTracking
when working with EF Core.
I hope you found this post enlightening and helpful. You can download the solution at this GitHub repository.