Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix to #35239 - EF9: SaveChanges() is significantly slower in .NET9 vs. .NET8 when using .ToJson() Mapping vs. PostgreSQL Legacy POCO mapping #35326

Merged
merged 1 commit into from
Jan 6, 2025

Conversation

maumar
Copy link
Contributor

@maumar maumar commented Dec 13, 2024

Problem was that as part of AOT refactoring we changed way that we build comparers. Specifically, comparers of collections - ListOfValueTypesComparer, ListOfNullableValueTypesComparer and ListOfReferenceTypesComparer. Before those list comparer Compare, Hashcode and Snapshot methods would take as argument element comparer, which was responsible for comparing elements. We need to be able to express these in code for AOT but we are not able to generate constant of type ValueComparer (or ValueComparer) that was needed. As a solution, each comparer now stores expression describing how it can be constructed, so we use that instead (as we are perfectly capable to expressing that in code form). Problem is that now every time compare, snapshot or hashcode method is called for array type, we construct new ValueComparer for the element type. As a result in the reported case we would generate 1000s of comparers which all have to be compiled and that causes huge overhead.

Fix is to pass relevant func from the element comparer to the outer comparer. We only passed the element comparer object to the outer Compare/Hashcode/Snapshot function to call that relevant func. This way we avoid constructing redundant comparers.

Fixes #35239

@maumar maumar requested a review from Copilot December 13, 2024 11:25

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

@maumar maumar marked this pull request as ready for review December 13, 2024 20:21
@maumar maumar requested a review from a team as a code owner December 13, 2024 20:21
@maumar maumar requested review from AndriySvyryd and a team and removed request for a team December 13, 2024 20:21
@maumar
Copy link
Contributor Author

maumar commented Dec 13, 2024

note: this PR only addresses ListOfValueTypesComparer and ListOfNullableValueTypesComparer - the issue also exists for ListOfReferenceTypesComparer and in Cosmos, but those are more tricky to fix as the TElement of the parent comparer doesn't necessarily match the TElement of the element comparer, so it's not always safe to copy over lambdas from element to parent.
THe aim is to make this PR safe enough for potential servicing and as it fixes the reported case (List<Guid>)

@maumar maumar force-pushed the fix35239 branch 2 times, most recently from 41306eb to 56e51cf Compare December 14, 2024 02:36
@roji
Copy link
Member

roji commented Dec 14, 2024

@maumar for reference, could you include the benchmark results before and after this change, as well as the benchmark code itself?

@maumar maumar force-pushed the fix35239 branch 2 times, most recently from 934f69c to da82547 Compare December 19, 2024 09:10
@maumar
Copy link
Contributor Author

maumar commented Dec 19, 2024

I've updated the pr with cleaner approach. We no longer need the legacy code path, instead we reason about compatibility between lambda signatures (expected in the list comparer method and actual that we get from element comparer). I'm ok with going with the converting comparer as well, which works across the board, is more elegant and terse solution for sure. Pros of the solution here is no new APIs and a bit simpler Equals/Hashcode/Snapshot code - we don't always need to convert, e.g. when case of list of arrays (List<int[]>), outer comparer expects Equals of signature Func<int[], int[], bool> but element selector is typed as ValueComparer<IEnumerable>. However in this case we can get away with just passing the Func<IE<int>, IE<int>, bool> into Func<int[], int[], bool>. Problem only appears for snapshot (can't fit Func<IE<int>, IE<int>> into Func<int[], int[]>) so we do the rewite only there. ended up incorporating @roji 's concept of ConvertingValueComparer, but took advantage of contravariance - only doing conditional conversion based on assignability of target comparer type from the source type..

@maumar maumar force-pushed the fix35239 branch 2 times, most recently from a949c83 to c8d3f3f Compare December 19, 2024 09:56
@maumar
Copy link
Contributor Author

maumar commented Dec 19, 2024

perf numbers

with warmup, so that comparers are/should be compiled:
8.0 - 77ms
9.0 - 593ms
9.0.2 - 98ms

no warmup:
8.0 - 84ms
9.0 - 650 ms
9.0.2 - 132ms

Will convert it to proper BDN and post code and more accurate numbers. But the improvement is significant.

@maumar
Copy link
Contributor Author

maumar commented Dec 20, 2024

BDN numbers:

8.0.11

Method Mean Error StdDev
SaveChangesTest 172.1 ms 1.78 ms 1.58 ms

9.0

Method Mean Error StdDev
SaveChangesTest 5.487 s 0.0621 s 0.0551 s

9.0.2 (with fix)

Method Mean Error StdDev
SaveChangesTest 179.8 ms 1.71 ms 1.43 ms

benchmark code:

// See https://aka.ms/new-console-template for more information
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Running;
using Microsoft.EntityFrameworkCore;

BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, DefaultConfig.Instance);

public class SaveChangesBenchmark
{
    private MyContext _ctx = new MyContext();

    [GlobalSetup]
    public virtual async Task Initialize()
    {
        var ctx = new MyContext();
        await ctx.Database.EnsureDeletedAsync();
        await ctx.Database.EnsureCreatedAsync();

        var seedData = ReturnSeedData();
        ctx.SampleEntities.AddRange(seedData);
        ctx.SaveChanges();
    }

    [IterationSetup]
    public void Setup()
    {
        _ctx = new MyContext();
        _ctx.ChangeTracker.Clear();

        foreach (var testId in Enumerable.Range(1, 200))
        {
            var src = _ctx.SampleEntities.Where(a => a.TestId == testId).FirstOrDefault();
        }
    }

    [Benchmark]
    public async Task SaveChangesTest()
    {
        await _ctx.SaveChangesAsync();
    }

    private static List<SampleEntity> ReturnSeedData()
    {
        var list = new List<SampleEntity>();

        for (int i = 0; i < 200; i++)
        {
            var s = new SampleEntity
            {
                TestId = i,
                RockId = Guid.NewGuid(),
                SccId = Guid.NewGuid(),
                AnotherId = Guid.NewGuid(),
                IsPushed = false,
                SeId = Guid.NewGuid(),
                SampleId = Guid.NewGuid(),
                Jsons = []
            };

            for (int j = 0; j < 300; j++)
            {
                var studentGradingNeed = new SampleJson
                {
                    ComponentGroupId = Guid.NewGuid(),
                    ComponentId = Guid.NewGuid(),
                    MonthId = Guid.NewGuid(),
                    IsGroupLevel = false,
                    MeasureId = Guid.NewGuid(),
                    OrderedComponentTrackingId = Guid.NewGuid(),
                    Result = new ResultJson
                    {
                        CreatedBy = Guid.NewGuid(),
                        CreatedByName = "Test",
                        CreatedDate = DateTime.UtcNow,
                        LastModifiedBy = Guid.NewGuid(),
                        LastModifiedByName = "Test",
                        LastModifiedDate = DateTime.UtcNow,
                        MarkIdValue = Guid.NewGuid(),
                        NumericValue = 44,
                        CommentIds = new List<Guid> { Guid.NewGuid() },
                        TextValue = "FF"
                    }
                };

                s.Jsons.Add(studentGradingNeed);
            }

            list.Add(s);
        }

        return list;
    }
}

public class MyContext : DbContext
{
    public DbSet<SampleEntity> SampleEntities { get; set; } = null!;

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=ReproSaveChangesBDN;Trusted_Connection=True;MultipleActiveResultSets=true");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        modelBuilder.Entity<SampleEntity>().OwnsMany(c => c.Jsons, d =>
        {
            d.ToJson();
            d.OwnsOne(e => e.Result);
        });
    }
}


public class SampleEntity
{
    public Guid Id { get; set; }
    public Guid SampleId { get; set; }
    public Guid AnotherId { get; set; }
    public int TestId { get; set; }
    public Guid RockId { get; set; }
    public Guid SccId { get; set; }
    public Guid SeId { get; set; }

    public List<SampleJson> Jsons { get; set; } = [];
    public bool IsPushed { get; set; }
}

public record SampleJson
{

    public Guid TrackingId { get; set; }
    public Guid ComponentGroupId { get; set; }
    public Guid ComponentId { get; set; }
    public Guid? OrderedComponentTrackingId { get; set; }
    public Guid? MonthId { get; set; }
    public Guid? MeasureId { get; set; }
    public bool IsGroupLevel { get; set; }
    public ResultJson? Result { get; set; }
}

public record ResultJson
{
    public string? TextValue { get; set; }
    public decimal? NumericValue { get; set; }
    public Guid? MarkIdValue { get; set; }
    public List<Guid> CommentIds { get; set; } = [];
    public Guid CreatedBy { get; set; }
    public string CreatedByName { get; set; } = null!;
    public DateTime CreatedDate { get; set; }
    public Guid LastModifiedBy { get; set; }
    public string LastModifiedByName { get; set; } = null!;
    public DateTime LastModifiedDate { get; set; }
}

public interface ISampleDbContext
{
    DbSet<SampleEntity> SampleEntities { get; set; }
    void SetConnectionString(string connectionString);
}

/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </remarks>
public class ConvertingValueComparer<TTo, TFrom> : ValueComparer<TTo>, IInfrastructure<ValueComparer>
{
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the constraint as conversions happen both ways

in case of object[] { 1, 2, 3 } target is target is object and source is int
in case of nested lists (List<List> target is List and source is object (because element comparer is ListOfReferenceTypesComparer which is typed as ValueComparer

@maumar maumar force-pushed the fix35239 branch 2 times, most recently from 338fd9d to 14e0dc6 Compare December 21, 2024 02:23
@maumar maumar force-pushed the fix35239 branch 2 times, most recently from 0a3ffd3 to 24796f0 Compare December 30, 2024 12:04
@maumar maumar force-pushed the fix35239 branch 2 times, most recently from 4b0f773 to 9448534 Compare December 31, 2024 06:23
…s. .NET8 when using .ToJson() Mapping vs. PostgreSQL Legacy POCO mapping

Problem was that as part of AOT refactoring we changed way that we build comparers. Specifically, comparers of collections - ListOfValueTypesComparer, ListOfNullableValueTypesComparer and ListOfReferenceTypesComparer. Before those list comparer Compare, Hashcode and Snapshot methods would take as argument element comparer, which was responsible for comparing elements. We need to be able to express these in code for AOT but we are not able to generate constant of type ValueComparer (or ValueComparer) that was needed. As a solution, each comparer now stores expression describing how it can be constructed, so we use that instead (as we are perfectly capable to expressing that in code form). Problem is that now every time compare, snapshot or hashcode method is called for array type, we construct new ValueComparer for the element type. As a result in the reported case we would generate 1000s of comparers which all have to be compiled and that causes huge overhead.

Fix is to pass relevant func from the element comparer to the outer comparer. We only passed the element comparer object to the outer Compare/Hashcode/Snapshot function to call that relevant func. This way we avoid constructing redundant comparers.

In order to do that safely we need to make sure that type of the element comparer and the type on the list comparer are compatible (so that when func from element comparer is passed to the list comparer Equals/Hashcode/Snapshot method the resulting expression is valid. We do that by introducing a comparer that converts from one type to another, so that they are always aligned.

Also removed ConstructorExpression from the ValueComparer (it was marked as experimental) as it is no longer used and was the underlying source of the bug.

Fixes #35239
@maumar maumar merged commit c434d6c into main Jan 6, 2025
7 checks passed
@maumar maumar deleted the fix35239 branch January 6, 2025 21:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
3 participants