Showing posts with label performance. Show all posts
Showing posts with label performance. Show all posts

Sunday, December 6, 2009

Список интересных подкастов: выпуск #3

Продолжаем традицию публикации интересных подкастов после почти полугодового перерыва:

Подкаст о том, что должны знать разработчики о базах данных в целом, и SQL Server в частности. Например, стоит ли использовать GUID (и, главное, какого типа) или identity field, что делать для улучшения производительности SQL Server, как правильно использовать и обслуживать базу данных в больших приложениях и многое другое, включая советы по железу и виртуализации. В общем, не совсем про программирование, но для общего развития однозначно полезно, а на некоторых проектах даже необходимо.

Советую всем ASP.NET-разработчикам: что будет нового в ASP.NET 4.0 от одного из програм менеджеров Microsoft. Новый взгляд на будущее WebForms и MVC, интеграция с Dynamic Data, ASP.NET Ajax, улучшенная генерация HTML кода в стандартных контролах, и т.д. На самом деле, они проходятся по далеко не всем фичам, но в целом неплохо. Больше можно увидеть здесь, или почитать полный список на официальном сайте. Наконец-то Microsoft сделало реальное обновление ASP.NET, которого все ждали уже несколько лет. WebForms избавляется от "лишнего жирка", что не может не радовать.

Сказал А, нужно говорить и Б - дал ссылку на подкаст про WebForms 4.0, нужно давать аналогичную на MVC 2.0 :) Скотт и Фил, которые вместе участвовали в создании MVC 1.0, обсуждают новые возможности MVC 2.0. Надо сказать, что изменения не столь радикальны, как в 1.0, полный список можно глянуть здесь. Часть команды перебросили на внедрение фич MVC в WebForms и другие улучшения в ASP.NET, поэтому в результате получилось меньше, чем мы все ожидали, но все-таки развитие продолжается.

Отличный подкаст с одним из авторов StackOverflow.com, Jeff Atwood, с которым Скотт обсуждает принципы и практики правильного создания высоконагруженных вебсайтов в .NET-стеке на примере StackOverflow.com. Использование LINQ to SQL, оптимизация Ajax, JavaScript minification, GZIP-компрессия, CDN, переключение SQL Server в менее агрессивный locking-режим, и много другое.

И напоследок немного не о .NET, а о Ruby. Отличный подкаст с человеком, который перешел из .NET в Ruby on Rails и теперь сравнивает не только эти технологии, но и языки программирования. Просто потрясающе, не знал, что в Ruby все так интересно - надо бы присмотреться к динамическим языкам и к Ruby on Rails.

У меня уже почти готова подборка на следующий выпуск. Stay tuned! ...если вам это интересно :)

Sunday, January 25, 2009

Методы оптимизации производительности Linq to SQL и Entity Framework

Я хотел бы закончить начатое в предыдущих нескольких заметках (часть 1, часть 2) и докладе дело и привести набор методов по оптимизации производительности L2S и EF. В какой-то степени эту заметку можно считать текстом к презентации. Наверняка, это не все, что можно сделать с этой точки зрения, но это как минимум основные способы, которые могут стать отправной точкой для ваших собственных действий.

Зачем нам все это нужно?

Первым делом давайте обсудим, а зачем вообще нужно оптимизировать производительность этих инструментов? Неужели она настолько плоха, что ими нельзя пользоваться без этого? Вовсе нет, можно и даже нужно! Просто нужно понимать, что за огромное ускорение времени разработки, исчезновение нудного процесса создания Data Access Layer вручную и снижение затрат на дальнейшие изменения в базе данных и модели нужно чем-то платить. А платить приходится двумя вещами: временем на изучение нового инструмента (однажды) и некоторой потерей гибкости и производительности. В разных ORM потери производительности разные, но все же вы должны отдавать себе отчет в том, где и когда использовать ORM, а где и когда - нет.

За год работы с Entity Framework на достаточно крупном проекте я использовал в лучшем случае пару способов из приведенных ниже. И это несмотря на то, что как оказалось для нашего приложения использование ORM было, возможно, не самым лучшим выбором. У нас было несколько бизнес правил, которые заставляли нас вытаскивать чуть ли не полбазы в память, чтобы подсчитать некоторые данные для всего лишь одной сущности! При этом из-за иерархичности данных считать их в базе было еще большим злом, чем считать в памяти (поверьте, мы пробовали и меряли производительность). Поэтому, как вы сами понимаете, за dotTrace я брался довольно часто. Но, как правило, обычно все заканчивалось тем, что я находил либо ляп в коде, либо добавлял локальный кеш, чтобы не вытаскивать одни и те же данные из EF сотни, а то и тысячи раз. Надо сказать, что почти все проблемы с производительностью мы побороли через кеширование данных и правильное использование контекста EF. Но ближе к релизу приложения я начал замечать, что мы уже оптимизировали почти все, что можно и что bottleneck медленно, но уверенно переместился к границе EF, и что дальше придется оптимизировать уже его. Следствием этого и стало исследование, результаты которого я привожу сейчас.

Основные места потери производительности

Сначала я хотел бы остановиться на основных местах, где теряется производительность, т.к. от этого зависят и методы оптимизации:

1. Инициализация инфраструктуры. При "холодном старте" и L2S и EF теряют здесь какое-то время. Это происходит всего лишь один раз для апп-домена, но тем не менее может стать неприятной неожиданностью. L2S справляется с этим быстрее, но в EF можно пре-генерировать его внутренние views еще до запуска приложения и таким образом сократить время инициализации в разы. То есть для EF эту потерю (иногда очень значительную при первом запросе) оптимизировать можно.

2. Накладные расходы, связанные с маппингом. И L2S, и EF имеют эти расходы, но в EF они больше, т.к. в нем намного больше вариантов маппинга: наследование, тип на таблицу, несколько типов на таблицу, несколько таблиц на тип, сложные типы. К сожалению, тут особо не разбежишься, т.к. все происходит внутри ORM. Возможно, есть какие-то хаки, но я о них на данный момент не знаю. Поэтому будем считать, что оптимизация этой задачи нам недоступна.

3. Анализ запросов. Это могут быть linq-запросы (Linq to SQL, Linq to Entities) или EntitySQL для EF. Здесь ситуация выглядит получше. Разобранные EntitySQL запросы кешируются на уровне апп-домена, что позволяет не заниматься этим несколько раз. Ключом выступает как раз само текстовое представление запроса, так что будьте внимательнее и используйте параметры. С linq-запросами сложнее. Так как linq - это дерево объектов, которое представляет собой запрос, использовать его в качестве ключа довольно сложно. Но в таком случае запрос можно "откомпилировать" один раз (то есть, грубо говоря, закешировать его разобранный вид и команду, которая будет построена на нем) и потом выполнять его многократно. Тоже помогает.

4. Генерация SQL-запросов. Как и маппинг, происходит глубоко внутри, поэтому добраться туда и как-то повлиять сложно. Но нужно сказать, что почти все провайдеры реализуют внутри себя различные способы оптимизации, такие как кеширование планов запросов. В то же время во всех ORM есть одна и та же проблема: генерируемый SQL-результат не всегда оптимален (хотя как правило это так). Причем, что интересно, EF генерирует запросы в большинстве своем более оптимально, чем L2S. Один из основных советов здесь - в случае получения на выходе неоптимального запроса просто перестройте запрос в коде: поменяйте джойны, разбейте на несколько более мелких. Это помогает.

5. Материализация. Это процесс создания объектов по полученным реляционным данным. Занимает львиную долю времени от времени выполнения самого запроса. Однако оптимизирован за счет того, что контекст как L2S, так и EF (да и сессия NHibernate тоже) сохраняет у себя внутри материализованные объекты и при повторном обращении к этому же объекту уже не создает его повторно (если вы сами не скажете). Конечно, это сделано не для оптимизации, а для того, чтобы в рамках одного контекста у вас был только один объект Заказ с ID=7 в независимости от количества запросов. Ну, и чтобы трекать изменения в этих самых объектах. Но подобное "кеширование" еще и увеличивает производительность, что тоже является хорошей новостью. В защиту процесса материализации нужно добавить еще и то, что даже если вы пишете свой Data Access Layer, то вы все равно в том или ином виде занимаетесь материализацией. Вам же тоже нужно превратить записи из DataReader в какие-то объекты доменной модели. Да, ручной код более оптимален, т.к. заточен для решения лишь одной задачи, но, поверьте, ORM тоже умные люди пишут :)

Методы оптимизации производительности

Итак, какие же методы оптимизации производительности можно предложить. За конкретными цифрами и сравнениями вы можете обратиться к презентации.

1. Пре-генерация views в Entity Framework. По шагам расписана в MSDN и частично здесь, поэтому подробно останавливаться не буду.

2. Отключение трекинга объектов (tracking). Дает возможность немного улучшить производительность за счет того, что материализованные объекты не отслеживаются контекстом. Обратная сторона - сделанные в объектах изменения не могут быть сохранены в базу. Плюс (здесь я могу ошибиться, но вряд ли) отключается кеширование материализованных объектов в контексте, что в большинстве своем намного сильнее ухудшает производительность для большого количества запросов. Так что решайте сами, но мы у себя не использовали.

В L2S трекингом можно управлять через свойство контекста:

context.ObjectTrackingEnabled = false;

В EF - через ObjectQuery<T>, что не так удобно:

entities.Orders.MergeOption = MergeOption.NoTracking;
entities.Customers.MergeOption = MergeOption.NoTracking;

Подробнее про трекинг в EF можно почитать в MSDN.

3. Кеширование материализованных объектов. Происходит автоматически в контексте в том случае, если включен трекинг. В интернете иногда можно прочитать советы по использованию одного контекста на запрос. Да, можно, если вы пишете тестовое приложение или вам совершенно не важна производительность. Однако, в то же время и слишком долго держать контекст в памяти - неправильно. В конце-концов он вытянет в память всю вашу базу данных, не говоря уже о том, что придется каким-то образом вручную управлять конкуренцией, т.к. данные в базе могут обновиться, а в памяти - еще нет. В любом случае, судя по цифрам, использование одного контекста на несколько запросов улучшает производительность в несколько раз.

В нашем веб-приложении мы использовали интересную стратегию: создавали один контекст на http request и сохраняли его в Request. Все запросы в рамках одного http request работали через этот контекст, таким образом обеспечивая определенное кеширование. Плюс к этому у нас не было проблем, связанных с тем, что одна сущность была вытянута из одного контекста, а другая - из второго. Ну, и наконец, т.к. обработка http request занимает от силы несколько секунд, то проблемы с конкуренцией были сведены до минимума. В случае же, когда нам нужно было сохранять состояние объектов на протяжении нескольких http request'ов (например, для реализации многостраничных диалогов или многостраничных таблиц с поддержкой сохранения изменений), мы временно переносили контекст в сессию, а когда нужно было сохранять данные, вызывали context.ApplyPendingChanges(), и убирали контекст из сессии. Таким образом, на следующем запросе он снова создавался в рамках http request и все продолжало замечательно работать.

Но не переборщите с этим методом. Настоятельно не рекомендуется использовать один и тот же контекст из нескольких сессий или запросов (например, поместив его в Application). Мало того, что вы получите себе на голову все вышеупомянутые проблемы, так еще и в случае параллельного выполнения нескольких запросов (а каждый http request - это отдельный поток, если вы не знали) вы получите красивый exception :)

4. Компиляция linq-запросов. Как я уже говорил, linq-запросы, в отличие от EntitySQL, не кешируются автоматически. Для того, чтобы скомпилировать запрос, нужно выполнить очень простой вызов:

protected static Func<NorthwindClassesDataContext, IQueryable<Order>> compiledGetOrders =
    CompiledQuery.Compile(
        (NorthwindClassesDataContext ne) =>
            (from o in ne.Orders
             select o));

При этом не имеет особого значения, используете вы Linq to SQL или Linq to Entities. Вы просто передаете туда либо контекст L2S, либо контекст EF.

Однако, хотел бы предостеречь тех людей, которые уже побежали переписывать свой код. Дело в том, что компиляция запроса - процесс довольно длительный, поэтому он имеет смысл лишь в том случае, если вы делаете несколько вызовов одного и того же запроса (пусть и с разными параметрами). И еще лишь в том случае, если у вас действительно сложный linq-запрос. Для случая, приведенного выше, компиляция запросов не дает существенной выгоды. Для случая с одним запросом откомпилированный запрос может работать дольше неоткомпилированного. Так что it depends.

Подробнее про компиляцию запросов можно прочитать все в том же MSDN. Также советую глянуть статью о потоке выполнения запроса в EF.

5. Использование EntitySQL. Во всех источниках написано, что самый быстрый способ работы с EF - это EntitySQL. И, как показывают тесты, это действительно так. В то же время, EntitySQL не так удобен, как linq, не поддерживает code completion (это же простая строка), не типобезопасен (у вас нет проверки уровня компиляции) и в целом выглядит как обычный SQL-код в вашем C#-коде, что не придает ему эстетичности. Да и работает не намного быстрее компилированного linq. Так что я бы советовал использовать его лишь в самых серьезных случаях. Хотя в таких случаях еще лучше написать хранимую процедуру.

Выглядит это где-то так:

string query = "SELECT VALUE p FROM NorthwindEntities.Products AS p"
    + " JOIN NorthwindEntities.Order_Details AS od ON od.ProductID == p.ProductID"
    + " JOIN NorthwindEntities.Orders AS o ON o.OrderID == od.OrderID"
    + " WHERE o.Customers.CustomerID == @customerId";
var products = 
    entities.CreateQuery<Product>(query, new[] { new ObjectParameter("customerId", customerId) }).ToList();

Подробнее почитать про EntitySQL можно в одной из заметок на ADO.NET blogs и в MSDN.

6. Использование жадной загрузки (eager loading). Во-первых, нужно сказать, что и L2S, и EF по умолчанию реализуют ленивую загрузку (lazy loading). Только L2S делает это неявно, то есть сразу при обращении к навигационному свойству или коллекции, а в EF нужно явно вызвать метод Load(). Что лучше, что хуже - можно спорить долго. Жаль, что в EF не сделали включение/выключение явного/неявного режима через какое-нибудь свойство. Поэтому нам вот пришлось самим сделать неявную ленивую загрузку через кодогенерацию (я как-нибудь расскажу об этом подробнее). Но разговор сейчас не об этом. По тестам жадная загрузка, конечно же, выигрывает у ленивой, причем иногда весьма существенно. Однако если вы загрузите кучу данных, а потом не будете ее использовать - какой вам от нее прок? В этом случае, ленивая загрузка оказывается на высоте. Как же написать запрос в "жадном" стиле? В Entity Framework это делается через ObjectQuery.Include():

(from o in entities.Orders.Include("Order_Details")
select o).ToList();

Также в особо тяжелых случаях я бы рекомендовал писать один запрос для получения данных, а не идти через навигационные свойства. Например:

var allProductsData = 
    (from cust in entities.Customers
    where cust.CustomerID == customerId
    select new
        {Orders = 
            from ord in cust.Orders
            select new
                {Products = 
                    from det in ord.Order_Details
                    select det.Product}}).ToList();

List<Product> products = new List<Product>();
var productsData = allProductsData.FirstOrDefault();
if (productsData != null)
    foreach (var ordersData in productsData.Orders)
    {
        foreach (Product product in ordersData.Products)
        {
            products.Add(product);
        }
    }

Хоть это и не eager loading в чистом виде, но в то же время этот способ обходит вариант прохождения от Customer до его Products через навигационные свойства, которые бы подняли данные через lazy loading.

7. Оптимизация обновления данных. Здесь в первую очередь хочется отметить, что по тестам EF значительно опережает L2S в этом аспекте. Причем дело не в SQL-запросах, так как их производительность, за некоторыми исключениями, почти одинакова. Проблема кроется где-то внутри L2S, поэтому учитывайте и этот аспект при выборе способа доступа к базе данных. Для оптимизации выполнения сгенерированных SQL-запросов в L2S советую глянуть еще на атрибут UpdateCheck, который определяет будет ли сгенерирован where по всем полям сущности или только по ее Id (это делается для реализации оптимистической конкуренции). В EF такая проверка делается только по измененным колонкам, а в L2S - по всем. Также стоит подумать об реализации колонки Version для этой самой конкуренции.

8. Оптимизация SQL-запросов. Как я уже говорил, единственный способ - следить за ними и менять входные linq или EntitySQL запросы. Также по результатам осмотра можно наставить индексов в базе данных. Ну, и никто не отменял хранимые процедуры, куда можно поместить наиболее серьезные запросы. Так что основной совет здесь - не бояться браться за SQL профайлер в случае необходимости :)

Еще интересен тот факт, что EF в целом генерирует более оптимальные запросы, чем L2S. Так что имейте и это в виду.

Выводы

В целом, я описал все основные средства оптимизации производительности. Если вам интересны подробности, для EF существует целая статья в MSDN, посвященная этому вопросу. Также советую интересную серию заметок в блогах ADO.NET. Для Linq to SQL советую почитать следующую серию заметок, хотя она немного устарела.

В целом, из своего опыта могу сказать, что производительность Entity Framework уже достаточна для реализации кучи приложений. Думаю, в следующей версии она станет еще лучше. Если же вы знаете, что этого вам будет мало, то можете посмотреть на Linq to SQL. И пусть вас не пугает, что MS решила не развивать L2S в той же мере, что и EF - уже есть примеры высоконагруженных приложений, успешно использующих L2S, не говоря о том, что это просто замечательная замена всяким типизированным и прочим датасетам (брррр). Ну, а в особо критичных к производительности случаях нам все равно никуда не деться от старого-доброго ADO.NET :)

Если сравнивать Entity Framework с другими полноценными ORM, такими как NHibernate, LLBLGen Pro и др, то на данный момент я не могу ничего толком сказать. Надо попробовать, благо тестовое приложение, которое я написал, с легкостью расширяется другими тестовыми провайдерами. Могу лишь кое-что сказать об NHibernate, т.к. я успел его попробовать и почитал дополнительные материалы о нем. NH по производительности занимает место где-то в промежутке между L2S и EF. Плюс этот прекрасный ORM уже давно на рынке и обладает множеством других интересных способностей, например, поддержкой Persistence ignorance и кешом второго уровня, которых пока нет в Entity Framework. Так что при выборе ORM я бы однозначно смотрел и в его сторону.

На этой замечательной ноте я бы хотел завершить цикл постов про производительность L2S и EF :) Вполне возможно, в ближайшем будущем у нас появятся дополнительные данные по производительности NHibernate и еще нескольких интересных ORM. Если у вас есть желание помочь в этом - можете скачать тестовое приложение, написать свой тестовый провайдер (это делается абсолютно несложно, поверьте мне), и расшарить результаты.

Всем спасибо!

Saturday, January 24, 2009

Доклад на харьковской UNETA. Материалы

Вчера прошел мой первый доклад на харьковской встрече UNETA. Я попытался разобраться с производительностью Linq to SQL и Entity Framework, взяв в качестве бенчмарка производительность аналогичных запросов в чистом ADO.NET, а также дать какие-то советы по их улучшению. Надеюсь, тем, кто пришел, было не очень скучно :)

Возможно, у кого-то есть комментарии: что было хорошо, что плохо, как со стороны темы доклада, так и со стороны самого выступления. Возможно, остались какие-то незаданные вопросы. Прошу всех в комментарии - я постараюсь ответить на вопросы и учесть все пожелания (особенно, отрицательные).

Как я и обещал, выкладываю материалы доклада:

Скачать тестовое приложение и презентацию можно отсюда:

Тестовое приложение: http://data-access-orm-comparison.googlecode.com/files/DataAccessPerformanceTest_v2.zip

Презентация: http://data-access-orm-comparison.googlecode.com/files/L2S%20and%20EF%20performance%20analysis.zip

Постоянная ссылка проекта: http://code.google.com/p/data-access-orm-comparison/

Для запуска тестового приложения вам понадобится лишь поставить базу данных Northwind и изменить строки подключения. Приложение достаточно расширяемо, так что вы можете добавить туда свои собственные тестовые провайдеры, например, для работы с NHibernate или LLBLGen Pro.

Список полезных ссылок есть в конце доклада, так что здесь я его не привожу.

Удачи в ваших исследованиях!

Monday, December 22, 2008

Доклад на харьковской UNETA

Судя по всему, я буду одним из докладчиков на следующей харьковской UNETA, которая ориентировочно будет в начале-середине января. Тема у меня будет не совсем обычная, она будет связана с производительностью ORM. Точно будут L2S и EF, возможно, добавлю что-то еще, если хватит времени. Так как тема не связана с какой-то конкретной технологией, скорее, это разбор и сравнение, то я могу немного варьировать ее содержание. В связи с этим у меня возник вопрос, что бы вам хотелось услышать? В принципе, скелет доклада я уже продумал, но все равно есть место для различных деталей, тестов, которые я могу провести и т.д. Я постараюсь учесть все пожелания и осветить их в докладе по-максимуму.

В докладе я постараюсь рассказать о том, как работают рассматриваемые ORM внутри, что именно влияет на их производительность, проведу некоторые наиболее интересные и показательные тесты для их сравнения между собой и с чистым ADO.NET, и постараюсь рассмотреть генерируемые каждой ORM запросы.

Итак, у меня есть несколько вопросов к вам:

  1. Стоит ли включать в доклад NHibernate? Или достаточно будет L2S и EF?
  2. Какие тесты вы бы хотели увидеть?
  3. Какие сопутствующие темы вам были бы интересны? Что из неописанного выше можно осветить еще?

Ответы прошу оставлять в комментариях. Прошу поучаствовать в опросе не только харьковчан, которые планируют идти на UNETA, но и всех, кому это интересно. После доклада я сделаю отдельный пост, посвященный результатам, в котором выложу и презентацию, и тесты, и их результаты.

Спасибо за помощь :)

Thursday, November 13, 2008

Сравнение производительности .NET ORM: Часть 2. Генерация SQL-запросов

И снова здравствуйте. В первой части серии я постарался рассмотреть производительность L2S, EF и AR/NH по сравнению с классическим ADO (тестовое приложение можно найти здесь). NET и друг с другом. Для этого были написаны специальные тесты, но замеры мы производили на достаточно высоком уровне, не выделяя в результатах базу данных. Как я и обещал, во второй части я бы хотел опуститься ниже, в базу, и сравнить запросы, генерируемые нашими подопытными. Должен сказать, что я был несколько удивлен полученными результатами, но обо всем по порядку.

Способ сравнения

Способ сравнения я выбрал до предела простой. У нас уже есть написанный код для получения продуктов по заказчику. Это не ахти какой сложный запрос, конечно, но тем не менее что-то мы из него получим. При этом у нас есть два варианта получения данных: одним запросом с джойнами (тест CustomerProducts) или при помощи простых операций вроде получения сначала заказчика, потом его заказов, потом деталей заказа и уж в самом конце – продуктов, то есть при помощи пачки запросов (тест CustomerProductsComplex).

Значит, заряжаем SQL Server Profiler, запускаем наши тесты, вытаскиваем пойманные SQL-запросы, садим их в Management Studio, включаем “Include Actual Execution Plan”. Я сделал 4 теста. В первом сравниваются запросы из теста CustomerProducts для всех четырех способов доступа к данным. В последующих трех я сравнивал попарно запросы для теста CustomerProducts и для теста CustomerProductsComplex для каждого из ORM отдельно (для классического ADO.NET я не делал этот тест).

Какова цель этих тестов? Цель первого теста – сравнить качество генерируемого SQL-кода по сравнению с конкурентами и самым простым SQL-запросом, написанным вручную и получающим эти же данные. Цель остальных тестов – выяснить, какой способ получения данных, через один или несколько запросов, выгоднее и насколько.

Итак, поехали.

Сюрпризы

Уже на проведении первого же теста (того, где должен быть один результирующий запрос) меня ждало несколько интересных открытий:

  1. L2S зачем-то сгенерировал два запроса вместо одного. Первый запрос у них вытаскивает заказчика, а второй уже выполняет реальную работу. Я проверил свой код – вроде бы там простой LINQ-запрос, который должен превратиться в один SQL-запрос. Более того, такой же LINQ-запрос в EF действительно превращается в один SQL-запрос. Что ж, сами напросились, L2S будет тестироваться с двумя запросами, тем более что первый запрос по сравнению со вторым выполняется почти моментально, поэтому какая разница.
  2. AR/NH пошел еще дальше, чем L2S. Я ожидал, что написанный мною HQL-запрос выльется в один SQL-запрос. Не срослось. NH сгенерировал и выполнил 23 запроса. Но, присмотревшись к природе этих запросов, я увидел, что это запросы к таблице Supplier, которая в тесте никак не участвует, но зато есть в моем маппинге. Я понял, что, по всей видимости, не отключил lazy loading, поэтому просто в очередной раз отметил свое незнание AR/NH, исправил свойство Supplier класса Product на SupplierID, а атрибут [BelongsTo] поменял на [Property], после чего просто перезапустил тесты и получил корректные SQL-запросы. Замечание: Да, вы правильно подумали, результаты общих тестов для AR из предыдущей части тоже учитывают эти дополнительные запросы, но исходя из того, что я увидел в результатах выполнения тестов, мы не получили здесь особого прироста производительности.
  3. Я наконец-то убедился, что L2S и EF писали разные люди :) Даже несмотря на то, что я специально сделал LINQ-запросы в L2S и EF идентичными, генерируемые SQL-запросы отличаются. Более того, они отличаются не только текстом, но и производительностью, что вы увидите в результатах.

Анализ результатов

Результаты тестов несколько необычны. Для простоты я запустил все запросы каждого теста в одном наборе и посмотрел на Query cost каждого запроса, который считается относительно всего набора.

В первом тесте я получил следующие результаты:

Классический ADO.NET – 14%
LINQ 2 SQL – 2 + 48 = 50%
Entity Framework – 22 %
Active Record (NHibernate) – 14%

Вот и еще один сюрприз: запрос L2S оказался существенно медленнее запроса EF, не говоря уже про победителей – NH и наш контрольный запрос. Зато NH превзошел все ожидания. HQL очень чисто трансформировался в SQL, его план выполнения в точности совпал с планом выполнения контрольного запроса. План выполнения EF тоже неплох, а вот L2S намудрил. Да, здесь есть важный момент: это всего лишь ОДИН тест, на основании которого не стоит судить об ORM в целом. Вполне возможно, есть запросы, где намудрит EF или даже NH. В любом случае, среднее время выполнения этих запросов намного меньше результирующего времени, учитывающего дополнительные расходы на построение запроса и материализацию результата (в среднем где-то 50-150 мс согласно профайлеру). И тут уже L2S несомненно опережает EF и AR/NH с большим отрывом.

Во втором тесте я сравнил сложный запрос с джойнами и набор простых запросов для L2S:

Сложный запрос с джойнами – 25%
Набор простых запросов – 75%

Как видите, один запрос выиграл, что неудивительно. Однако, в целом, можно сказать, что с точки зрения базы данных потери на lazy loading не такие уж и большие.

Третий тест был посвящен EF:

Сложный запрос с джойнами – 18%
Набор простых запросов – 72%

Как видите, результаты похожи до безобразия.

И, наконец, в четвертом тесте я сравнил запросы для NH:

Сложный запрос с джойнами – 6%
Набор простых запросов – 94%

Вот уж где для увеличения производительности стоит отказаться от удобных переборов коллекций в пользу HQL, так это здесь :)

Выводы

Какие выводы мы можем сделать по результатам этих тестов?

  1. Прежде всего, если у вас есть возможность выполнить один сложный запрос – выполняйте, не бегайте по коллекциям и не инициализируйте их отдельно, это немного сэкономит вам драгоценное время. Особенно это касается ситуаций, где bottleneck находится в базе данных, а не внутри ORM.
  2. Похоже, оптимизация запросов в L2S сделана немного хуже, чем в EF. Я слышал, что в EF team привлекли очень толковых специалистов по оптимизации запросов, так что, возможно, это результат их работы.
  3. Как показывает практика, генерируемые запросы достаточно неплохи по сравнению с контрольным, так что основная проблема в производительности ORM находится все-таки за пределами базы данных, а именно внутри ORM, в алгоритмах генерации SQL-запроса, работы с контекстом и материализации данных.
  4. Я протестировал LINQ-запросы для L2S и EF. Было бы еще интересно посмотреть, как с заданием справится eSQL (Entity SQL) для EF. Если у кого-то есть опыт – поделитесь.
  5. Аналогично я хотел бы узнать у специалистов насчет HQL в NH. Насколько я понял, в NH есть еще и альтернативный способ создавать сложные запросы через джойны – при помощи критериев. Какие у них плюсы и минусы?

Бонус

Ну, и напоследок бонус тем, кто дочитал до этого места :) Собственно, сгенерированные сложные запросы с джойнами для NH, EF и L2S. Не пугайтесь их, они не такие страшные, как кажутся на первый взгляд :)

NHibernate:

exec sp_executesql N'select product2_.ProductID as ProductID4_, product2_.ProductName as ProductN2_4_, product2_.UnitPrice as UnitPrice4_, product2_.SupplierId as SupplierId4_ from [Order Details] orderdetai0_ inner join Orders order1_ 
on orderdetai0_.OrderID=order1_.OrderID inner join Products product2_ on orderdetai0_.ProductID=product2_.ProductID where (product2_.ProductID=product2_.ProductID )AND(order1_.OrderID=order1_.OrderID )AND(order1_.CustomerID=@p0 
)',N'@p0 nvarchar(5)',@p0=N'BERGS'

Entity Framework:

exec sp_executesql N'SELECT 
[Project3].[CustomerID] AS [CustomerID], 
[Project3].[C1] AS [C1], 
[Project3].[C3] AS [C2], 
[Project3].[OrderID] AS [OrderID], 
[Project3].[C2] AS [C3], 
[Project3].[ProductID] AS [ProductID], 
[Project3].[ProductName] AS [ProductName], 
[Project3].[QuantityPerUnit] AS [QuantityPerUnit], 
[Project3].[UnitPrice] AS [UnitPrice], 
[Project3].[UnitsInStock] AS [UnitsInStock], 
[Project3].[UnitsOnOrder] AS [UnitsOnOrder], 
[Project3].[ReorderLevel] AS [ReorderLevel], 
[Project3].[Discontinued] AS [Discontinued], 
[Project3].[CategoryID] AS [CategoryID], 
[Project3].[SupplierID] AS [SupplierID]
FROM ( SELECT 
    [Extent1].[CustomerID] AS [CustomerID], 
    1 AS [C1], 
    [Project2].[OrderID] AS [OrderID], 
    [Project2].[ProductID] AS [ProductID], 
    [Project2].[ProductName] AS [ProductName], 
    [Project2].[SupplierID] AS [SupplierID], 
    [Project2].[CategoryID] AS [CategoryID], 
    [Project2].[QuantityPerUnit] AS [QuantityPerUnit], 
    [Project2].[UnitPrice] AS [UnitPrice], 
    [Project2].[UnitsInStock] AS [UnitsInStock], 
    [Project2].[UnitsOnOrder] AS [UnitsOnOrder], 
    [Project2].[ReorderLevel] AS [ReorderLevel], 
    [Project2].[Discontinued] AS [Discontinued], 
    [Project2].[C1] AS [C2], 
    [Project2].[C2] AS [C3]
    FROM  [dbo].[Customers] AS [Extent1]
    LEFT OUTER JOIN  (SELECT 
        [Extent2].[OrderID] AS [OrderID], 
        [Extent2].[CustomerID] AS [CustomerID], 
        [Project1].[ProductID] AS [ProductID], 
        [Project1].[ProductName] AS [ProductName], 
        [Project1].[SupplierID] AS [SupplierID], 
        [Project1].[CategoryID] AS [CategoryID], 
        [Project1].[QuantityPerUnit] AS [QuantityPerUnit], 
        [Project1].[UnitPrice] AS [UnitPrice], 
        [Project1].[UnitsInStock] AS [UnitsInStock], 
        [Project1].[UnitsOnOrder] AS [UnitsOnOrder], 
        [Project1].[ReorderLevel] AS [ReorderLevel], 
        [Project1].[Discontinued] AS [Discontinued], 
        [Project1].[C1] AS [C1], 
        1 AS [C2]
        FROM  [dbo].[Orders] AS [Extent2]
        LEFT OUTER JOIN  (SELECT 
            [Extent3].[OrderID] AS [OrderID], 
            [Extent4].[ProductID] AS [ProductID], 
            [Extent4].[ProductName] AS [ProductName], 
            [Extent4].[SupplierID] AS [SupplierID], 
            [Extent4].[CategoryID] AS [CategoryID], 
            [Extent4].[QuantityPerUnit] AS [QuantityPerUnit], 
            [Extent4].[UnitPrice] AS [UnitPrice], 
            [Extent4].[UnitsInStock] AS [UnitsInStock], 
            [Extent4].[UnitsOnOrder] AS [UnitsOnOrder], 
            [Extent4].[ReorderLevel] AS [ReorderLevel], 
            [Extent4].[Discontinued] AS [Discontinued], 
            1 AS [C1]
            FROM  [dbo].[Order Details] AS [Extent3]
            LEFT OUTER JOIN [dbo].[Products] AS [Extent4] ON [Extent3].[ProductID] = [Extent4].[ProductID] ) AS [Project1] ON [Extent2].[OrderID] = [Project1].[OrderID] ) AS [Project2] ON [Extent1].[CustomerID] = 
[Project2].[CustomerID]
    WHERE [Extent1].[CustomerID] = @p__linq__1
)  AS [Project3]
ORDER BY [Project3].[CustomerID] ASC, [Project3].[C3] ASC, [Project3].[OrderID] ASC, [Project3].[C2] ASC',N'@p__linq__1 nvarchar(5)',@p__linq__1=N'BERGS'

LINQ 2 SQL:

exec sp_executesql N'SELECT [t0].[CustomerID]
FROM [dbo].[Customers] AS [t0]
WHERE [t0].[CustomerID] = @p0',N'@p0 nvarchar(5)',@p0=N'BERGS'

exec sp_executesql N'SELECT [t2].[ProductID], [t2].[ProductName], [t2].[SupplierID], [t2].[CategoryID], [t2].[QuantityPerUnit], [t2].[UnitPrice], [t2].[UnitsInStock], [t2].[UnitsOnOrder], [t2].[ReorderLevel], [t2].[Discontinued], (
    SELECT COUNT(*)
    FROM [dbo].[Order Details] AS [t3]
    INNER JOIN [dbo].[Products] AS [t4] ON [t4].[ProductID] = [t3].[ProductID]
    WHERE [t3].[OrderID] = [t0].[OrderID]
    ) AS [value]
FROM [dbo].[Orders] AS [t0]
LEFT OUTER JOIN ([dbo].[Order Details] AS [t1]
    INNER JOIN [dbo].[Products] AS [t2] ON [t2].[ProductID] = [t1].[ProductID]) ON [t1].[OrderID] = [t0].[OrderID]
WHERE [t0].[CustomerID] = @x1
ORDER BY [t0].[OrderID], [t1].[ProductID]',N'@x1 nchar(5)',@x1=N'BERGS'

Monday, November 10, 2008

Сравнение производительности .NET ORM: Часть 1. Выборки данных

Сегодня я хотел бы начать серию заметок о сравнении производительности Entity Framework, LINQ to SQL, Active Record (NHibernate) и классического ADO.NET. Я еще не знаю, во что выльется эта идея, и к чему мы придем в результате, но основными целями этого сравнения я бы назвал:

  1. получение адекватных результатов сравнения и попытка их объяснить
  2. обсуждения различных вариантов увеличения производительности вышеуказанных способов доступа к базе данных
  3. получение понимания, какие варианты доступа к базе данных являются наиболее приемлемыми в той или иной ситуации с точки зрения производительности

Сразу скажу, что, несмотря на то, что я уже почти год использую Entity Framework, я не буду стараться его выгораживать. Откровенно говоря, сейчас мы в нашем проекте подошли к той границе, за которой bottleneck начинается уже в самом Entity Framework, а я пока и близко не в восторге от производительности нашей системы. Поэтому я постараюсь быть максимально объективным в данном вопросе, а если все же где-нибудь буду говорить чушь, вы меня поправите.

Изначально я хотел выяснить производительность EF и L2S, а также сравнить их с классическим ADO.NET, т.к. последний на данный момент является наиболее быстрым способом доступа к базе данных. Естественно, любая ORM будет медленнее. Потом меня эта идея настолько увлекла, что я для полноты картины добавил еще и Active Record, который, как известно, базируется на NHibernate. Еще один важный момент: я вижу NHibernate впервые в жизни, поэтому: а) не судите строго тот код, который я с горем пополам в нем написал, б) если у вас есть советы и комментарии, как по коду, так и по улучшению производительности – буду счастлив услышать :)

Все, довольно вступлений. Итак, для начала я покажу результаты моих тестов read-запросов. В следующих заметках серии я постараюсь уделить время генерируемым запросам, а также CUD-операциям. Пока же ограничимся выборками.

Используемые тесты

Использовал я всеми любимую базу Northwind, в частности таблицы Customers, Orders, Order Details и Products. Для каждого из способов доступа к данным я сделал 8 простых тестов:

Тест Описание Цель
Инициализация Создание контекста, инициализация, чтение строки подключения Получить время инициализации, возможно, оптимизировать его
Получение всех заказов Простая выборка всех строк из таблицы Orders Самый простой запрос, в то же время можно сравнить время материализации, т.к. там больше 800 записей
Многократное получение всех заказов Выборка всех строк из таблицы Orders 3 раза Сравнить степень кеширования запросов и их результатов
Получение всех продуктов одного заказчика Один сложный запрос через 3 таблицы Сравнить эффективность генерации SQL-скриптов, материализация минимальна
Многократное получение всех продуктов одного заказчика Тот же запрос 3 раза То же самое + кеширование
Получение всех продуктов одного заказчика (сложная версия) Пошаговое получение того же результата, что и в предыдущем случае. Берем коллекцию, проходимся foreach'ем, берем связанные записи и т.д. Сравнить данный способ обращения к данным с предыдущим
Многократное получение всех продуктов одного заказчика (сложная версия) То же самое 3 раза То же самое + кеширование
Микс: получение заказов + продуктов заказчика Синтетический тест, эмулирующий разные запросы Посмотреть, как влияют результаты выполнения одного запроса на следующий
Тест более серьезной нагрузкой Все предыдущие запросы последовательно за один присест Попробовать нагрузить базу большим количеством последовательных запросов, и посмотреть, что получится

Код я здесь приводить не буду, а лучше дам вам ссылочку, где можно скачать приложение, чтобы глянуть запросы и код и надавать автору по голове за него посоветовать мне, что и как можно улучшить :) Для запуска приложения вам понадобится база Northwind, VS2008 SP1 (там есть EF и L2S), а также установленный Active Record (скачать можно здесь). Не забудьте сконфигурировать строки подключения к базе и параметры Active Record в классе ActiveRecordProvider (да, знаю, что за такое нужно отбивать руки, но для текущей задачи такой реализации с головой).

Пара слов перед тем, как показать результаты. Тестировал я достаточно просто, вручную запуская один тест за другим, но в то же время я старался учесть кеширование запросов в SQL Server'е. Не знаю, насколько мне это удалось, но я старался запускать каждый тест отдельно вразнобой, и с некоторыми временными интервалами. К сожалению, тесты с базой данных – это такая вещь, которую бывает трудно воспроизвести, результаты варьируются, но, надеюсь, показанные числа дадут хотя бы какое-то общее представление о порядках. В любом случае, вы можете скачать исходники, установить L2S, EF, AR (если еще не установлены) и поиграться сами. Машинка у меня дома относительно небыстрая (Athlon 64 3200+ с 3 гигами мозгов), поэтому делайте скидку и на это.

Анализ результатов

Итак, результаты в миллисекундах:

Test Classic L2S EF NH/AR
Initialization 15 78 101 410
Orders 125 172 1078 1062
OrdersMultiple 187 218 1093 1187
CustomerProducts 109 234 1328 828
CustomerProductsMultiple 125 281 1344 1156
CustomerProductsComplex 140 464 1453 937
CustomerProductsComplexMultiple 203 511 1515 1109
Mixed 140 296 1265 1187
All 187 515 1656 1546

Что сразу бросается в глаза, так это тотальное отставание EF и AR от L2S (в 3-6 раза), а также то, насколько быстро L2S работает с базой – в среднем всего лишь где-то в 2 раза медленнее. При ближайшем рассмотрении можно сделать вывод, что AR работает быстрее, чем EF, по крайней мере, в запросах, возвращающих небольшое количество данных. У меня есть подозрение, что AR медленнее материализует объекты, чем EF (запрос по Orders – самый простой, но в то же время самый большой по количеству данных), но это нужно взять выборку побольше, чтобы проверить. При этом инициализация контекста у EF происходить почти так же быстро, как и у L2S, а AR тут немного отстает, хотя это и не страшно. Что еще важно: все ORM отлично справляются с кешированием повторных запросов: 3 запроса выполняются ненамного дольше, чем один. Связано это, прежде всего, с тем, что они кешируют не только материализованные объекты, чем существенно экономят время, но и, в случае EF – деревья промежуточных запросов (CQT, Canonical Query Tree). Так что одинаковые и даже похожие запросы выполняются намного быстрее. Также показателен последний тест: он дает возможность увидеть, что каждый запускаемый поодиночке тест все-таки несет определенный накладные расходы, потому что один общий тест лишь очень ненамного превышает самый медленный из своих компонентов. А это говорит о том, что НЕ НУЖНО создавать контексты на каждый запрос (это касается, в первую очередь L2S и EF), как бы вас этому ни учили в умных статьях. Если хотите добиться хорошей производительности – старайтесь использовать контексты как можно дольше, но в разумных пределах. Мы вот у себя нашли этот самый разумный предел – контекст живет в пределах обработки одной страницы, то есть контекст создается на каждый HttpRequest и умирает вместе с ним. В сложных случаях многостраничных диалогов мы продлеваем время жизни контекста, перемещая его временно в сессию.

Теперь пара слов о том, почему же мы видим такие результаты. Ну, во-первых, L2S у нас завязан лишь на один SQL Server и, если я не ошибаюсь, не строит никаких промежуточных CQT, чтобы общаться с тысячей различных провайдеров, как это делает EF. Во-вторых, EF – это не просто ORM, способная работать с разными базами данных и на лету подменять их без изменения кода. EF также обладает различными продвинутыми фичами маппинга, которых как минимум нет в L2S (не буду говорить про NH, ибо не знаю всех его возможностей). В-третьих, судя по проанализированным мною сгенерированным SQL-запросам, здесь особой разницы между ORM нет (подробнее об этом мы поговорим в следующей заметке), поэтому сюда особо рыть не стоит. И, в-четвертых, EF еще только-только зарелизился, думаю, мы еще увидим более быстро работающие версии.

Советы по использованию подопытных

Итак, определенные советы по использованию этих ORM (в широком смысле, L2S – это тоже ORM) можно дать уже сейчас. Если вам нужно написать простенькое приложение – используйте L2S – это САМЫЙ быстрый способ. Накидали табличек в базу, сгенерировали модель 2-мя кликами мыши – и вуаля, можно работать. Для более серьезных приложений уже нужно смотреть внимательнее. С точки зрения скорости написания кода равных L2S и EF нет. При всем уважении, AR/NH пока медленнее за счет того, что нет тула по созданию и поддержке модели. Если вам нужны продвинутые возможности маппинга, вы делаете разработку в рамках DDD или нужна поддержка различных баз данных – смотрите в сторону AR/NH и EF. Тут уже что больше по душе. Если же у вас на первом месте производительность и вы работаете лишь с MS SQL Server – тогда вам, наоборот, нужно смотреть либо в сторону L2S, либо вообще в сторону классического ADO.NET. Однако, в случае с классическим ADO.NET время разработки, а также последующей поддержки Data Access Layer увеличивается не просто в разы, а на порядки. Да, и пусть вас не смущают результаты запросов секунда и выше – как можно заметить, реально занимают время первые запросы, а остальные идут очень быстро и по накатанной. Более того, могу сказать по опыту, что где-то для 50-60% приложений производительности EF и AR/NH должно хватить с головой. Так что, если у вас не rocket science, не стоит заморачиваться.

Все это напоминает классическую проблему менеджмента проектов: можно выбрать одновременно лишь два пункта из трех: либо мы уменьшаем время разработки и набор функциональности, но тогда увеличиваем стоимость, либо другие вариации на тему. Или другой пример: выбор спальника :) Спальник может быть либо легким и теплым, но дорогим, либо легким и дешевым, но тогда холодным, либо теплым и дешевым, но тогда тяжелым. Выбирайте ;)

Надеюсь, что в свете того, что у нас теперь появляется все больше выбора, я смог пролить хотя бы немного света на производительность различных способов обращения к данным. Если у кого-то уже сейчас есть комментарии по улучшению производительности той или иной ORM – пожалуйста. Мои тесты покрывают лишь дефолтные настройки.

Полезные ссылки:

Тестовый проект для сравнения производительности L2S, EF и AR/NH между собой и с классическим ADO.NET

Exploring the Performance of the ADO.NET Entity Framework - Part 1
Exploring the Performance of the ADO.NET Entity Framework - Part 2
ADO.NET Entity Framework Performance Comparison
Entity Framework Instantiation Times with a 120-Table Database
Linq to SQL vs NHibernate Part 1: What do they have in common?