عملکرد کوئری‌های کامپایل‌شده در Entity Framework Core 2.0

قبل از اعمال هر بهینه‌سازی برای کدمان باید یک سؤال بپرسیم که هزینه بهسازی (improvement) چیست و آیا این واقعا بهسازی است. کوئری‌های کامپایل‌شده در Entity Framework 2.0 به عنوان قابلیتی با دسترسی بالا طبقه‌بندی شده‌اند، اما قبل از گرفتن هر تصمیمی باید بدانیم موفقیت واقعی چیست. این مقاله یک پایگاه داده کوچک برای مقایسه کوئری‌های کامپایل‌شده و کامپایل‌نشده در Entity Framework Core 2.0 را بررسی می‌کند.

عملکرد کوئری‌های کامپایل‌شده در Entity Framework Core 2.0

چه چیزی دقیقا بررسی می‌شود؟

برای جلوگیری از سردرگمی، قبل از پرداختن به بررسی، توضیحاتی را ارائه می‌دهیم. کوئری‌های کامپایل‌شده در Entity Framework Core کوئری‌های LINQ هستند که در برنامه یا کتابخانه برای ارسال به سرور پایگاه داده کامپایل می‌شوند. از دید سرور پایگاه داده این یک ویژگی سمت کلاینت است و به ویوهای از پیش کامپایل‌شده و دستورات SQL در سرور پایگاه داده مربوط نیست. به همین دلیل ما از ارائه دهنده (provider) داده‌های حافظه اصلی استفاده می‌کنیم زیرا بررسی با پایگاه داده واقعی نتایج را با انواع خاصی از سرور پایگاه داده ترکیب می‌کند که نمی‌توانیم آن‌ها را به راحتی از نتایج حذف کنیم.

context پایگاه داده تست

ما از کانتکست پایگاه داده ساده با provider حافظه اصلی استفاده می‌کنیم تا سر بار (overhead) را به حداقل برسانیم. در این روش همچنین از استفاده از پایگاه داده واقعی اجتناب می‌کنیم که سخت تر می‌تواند متوجه شود که کدام بخش از اجرا از کد گرفته شده است و کدام توسط اجرای بهینه پایگاه داده و کش شدن داده‌ها به دست آمده است. کانتکست پایگاه داده فقط یک نوع موجودیت به نام Category را می‌شناسد.

public class Category
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public Category Parent { get; set; }
}

علاوه براین کوئری کامپایل شده است تا اولین category با بالاترین سطح و متدها را به دست آورد تا category در بالاترین سطح را با کوئری کامپایل‌شده و بدون آن به دست آورد. این کانتکست دیتابیس ما است.

public class TestDbContext : DbContext
{
    private static Func<TestDbContext, Category> _getCategoryCompiled =
            EF.CompileQuery((TestDbContext ctx) =>
                ctx.Categories.Include(c => c.Parent)
                                    .Where(c => c.Parent == null)
                                    .OrderBy(c => c.Name)
                                    .FirstOrDefault());
    public TestDbContext(DbContextOptions<TestDbContext> options) : base(options)
    {    
    }
 
    public DbSet<Category> Categories { get; set; }
 
    public void FillCategories()
    {
        var foodCategory = new Category { Id = Guid.NewGuid(), Name = "Food", Parent = null };
 
        Categories.AddRange(
            foodCategory,
            new Category { Id = Guid.NewGuid(), Name = "Drinks", Parent = null },
            new Category { Id = Guid.NewGuid(), Name = "Clothing", Parent = null },
            new Category { Id = Guid.NewGuid(), Name = "Electronis", Parent = null }
        );
 
        for(var i = 0; i < 50; i++)
        {
            Categories.Add(new Category { Id = Guid.NewGuid(), Name = "Random", Parent = foodCategory });
        }
 
        SaveChanges(true);
    }
 
    public Category GetTopLevelCategory()
    {
        return Categories.Include(c => c.Parent)
                            .Where(c => c.Parent == null)
                            .OrderBy(c => c.Name)
                            .FirstOrDefault();
    }
 
    public Category GetTopLevelCategoryCompiled()
    {
        return _getCategoryCompiled(this);
    }
}

با استفاده از این پایگاه داده ساختگی یک بررسی ساده را انجام دادیم.

روش‌های بررسی

ما تصمیم گرفتیم تا چرخه کافی را انجام دهیم تا تفاوت‌هایی که می‌توانیم درک کنیم را ببینیم. در اینجا سه بررسی را انجام داده‌ایم:

1. کامپایل شدن کوئری

2. کوئری برای category سطح بالا

3. کوئری برای category سطح بالا با کوئری کامپایل شده

ما تمام این بررسی‌ها را در حلقه‌هایی با طول چرخه K100 اجرا کردیم و بررسی‌ها را در زمان‌های متفاوت انجام دادیم. تقریبا در تمام زمان‌ها نتایج یکسانی گرفتیم.

با استفاده از کلاس Stopwatch بررسی کردیم که چقدر زمان برای هر یک از این سه مورد برای اجرای K100 صرف می‌شود.

کامپایل شدن کوئری

اول ما کامپایل کردن کوئری را اجرا کردیم تا ببینیم هزینه این اقدام چقدر است. با استفاده از تعداد کمی از چرخه‌ها هیچ تفاوتی را ندیدیم، اما چرخه K100 متفاوت بود.

private static void MeasureCompiling()
{
    var watch = new Stopwatch();
    var cycles = 100000;
 
    watch.Start();
    for (var i = 0; i < cycles; i++)
    {
        EF.CompileQuery((TestDbContext ctx) =>
                ctx.Categories.Include(c => c.Parent)
                                    .Where(c => c.Parent == null)
                                    .OrderBy(c => c.Name)
                                    .FirstOrDefault());
    }
    watch.Stop();
 
    Console.Write("Compiling: ");
    Console.WriteLine(watch.Elapsed);
}

نتیجه روی سیستم ما به طور میانگین 6 ثانیه بود.

بررسی کوئری کامپایل‌نشده

ما K100 بعدی را با متد GetTopLevelCategory() از کانتکست پایگاه داده تست کامل کردیم. این متد بر منبع داده‌ها و هر بار که کوئری مجددا کامل می‌شود، کوئری می‌زند.

private static void MeasureUncompiledQuery()
{
    var options = new DbContextOptionsBuilder<TestDbContext>()
                            .UseInMemoryDatabase(Guid.NewGuid().ToString())
                            .Options;
    var context = new TestDbContext(options);
    var watch = new Stopwatch();
    var cycles = 100000;
 
    context.FillCategories();
    watch.Start();
 
    for (var i = 0; i < cycles; i++)
    {
        context.GetTopLevelCategory();
    }
    watch.Stop();
 
    Console.Write("Uncompiled query: ");
    Console.WriteLine(watch.Elapsed);
}

کوئری‌های K100 روی دستگاه ما تقریبا به طور میانگین ~34 ثانیه بود.

بررسی کوئری کامپایل‌شده

وقت آن رسیده است که ببینیم آیا کوئری کامپایل‌شده باعث ایجاد تفاوت می‌شود یا نه. برای این کار ما متد GetTopLevelCategoryCompiled() از کانتکست پایگاه داده تست K100 را اجرا کردیم.

private static void MeasureCompiledQuery()
{
    var options = new DbContextOptionsBuilder<TestDbContext>()
                            .UseInMemoryDatabase(Guid.NewGuid().ToString())
                            .Options;
    var context = new TestDbContext(options);
    var watch = new Stopwatch();
    var cycles = 100000;
 
    context.FillCategories();
    watch.Start();
            
    for (var i = 0; i < cycles; i++)
    {
        var top = context.GetTopLevelCategoryPrecompiled();
    }
    watch.Stop();
 
    Console.Write("Compiled query: ");
    Console.WriteLine(watch.Elapsed);
}

چرخه K100 با کوئری کامپایل‌شده روی دستگاه ما نتیجه بهتری داشت، ~17 ثانیه.

ما توانستیم نتایج مشابهی را روی همه اجراهای بررسی‌هایمان به دست آوریم. تنها تغییرات کوچکی (800ms±) وجود داشت، اما مورد بزرگی مثل 5 ثانیه یا همانند آن وجود نداشت.

محاسبه ساده‌ای نشان می‌دهد که ما می‌توانیم عملکرد را تا 50% با کوئری‌های کامپایل‌شده به دست آوریم.

نکته: این اعداد کاملا درست نیستند. افزایش عملکرد واقعی ممکن است کاملا متفاوت باشد، براساس برنامه و پایگاه داده واقعی بالاتر یا پایین‌تر باشد. اعداد موجود در اینجا از مجموعه ساده‌ای از تست‌ها گرفته شده‌اند و هنگام استفاده از کوئری‌های کامپایل‌شده، هیچ دیدی در مورد تأثیر کلی روی سیستم ندارند.

نتیجه گیری

وقتی فقط چند عملکرد در برنامه تست اجرا می‌کنیم، هیچ تفاوتی بین کوئری‌های کامپایل‌شده و کامپایل‌نشده نمی‌بینیم. اما اجرای این کوئری‌ها در حلقه K100 برای نشان دادن تفاوت‌ها در زمان اجرا کافی بود. پس از این تست‌ها احتمالا می‌فهمیم که چرا کوئری‌های کامپایل‌شده قسمتی از بخش دسترسی بالا در مستندات Entity Framework Core هستند. هر چند کوئری‌های کامپایل‌شده شیوه‌ای قدرتمند برای جلوگیری از تکرار کار در برنامه هنگام اجرا هستند، هنوز نمی‌توانیم تمام کوئری‌ها را با استفاده از آن بسازیم. در حال حاضر تمام کوئری‌هایی که بیش از یک نتیجه را بازمی‌گردانند (IEnumerable<T>, IQueryable<T>) پشتیبانی نمی‌شوند. امیدواریم این کوئری‌ها در نسخه‌های بعدی Entity Framework Core پشتیبانی شوند.