J’ai été confronté à un problème de performances avec EF4.

Le principe de base de l’exercice est de “simplement” faire une pagination sur une table, pour réduire le nombre d’éléments à requêter. J’en avais déjà parlé ici, sans passer par EF4

Juste un point important pour la suite : Il s’agit aussi de faire quelques jointures pour ramener des informations nécessaires à l’affichage.

Je vais faire l’exemple sur la base AdventureWorks, même si celle ci est tout de même bien petite :) (en terme de nombre d’enregistrements)

Juste pour info, je veux afficher les informations de la table des détails de factures (+ les infos du produit et du client)

image

Etape 1 : Le premier jet

Bref, naturellement, je pars sur un truc du genre :

            using (AdventureWorks2008R2Entities context = new AdventureWorks2008R2Entities())
            {
                var skipNumber = IndexPage * PageSize;

                var items = (from sod in context.SalesOrderDetails
                             join soh in context.SalesOrderHeaders on sod.SalesOrderID
                                                       equals soh.SalesOrderID
                             join p in context.Product on sod.ProductID equals p.ProductID
                             join c in context.Customers on soh.CustomerID equals c.CustomerID
                             orderby sod.SalesOrderDetailID
                             select new
                             {
                                 sod.SalesOrderDetailID,
                                 c.AccountNumber,
                                 soh.ShipDate,
                                 p.Name,
                                 sod.OrderQty,
                                 sod.UnitPrice,
                                 sod.LineTotal
                             }).Skip(skipNumber).Take(PageSize);

                dataGrid1.ItemsSource = items.ToList();
            }

Et là WOW ! des performances déplorables ! (Bon sur AdventureWorks, c’est pas flagrant, mais sur une base avec quelques millions de lignes, c’est le drame …)

Ok, n’écoutant que mon courage, je sors mes meilleurs outils : SQL Server management studio et SQL Server Profiler

Le profiler m’indique un truc en substance :

image

Ok, j’analyse la requête et quelque chose me tracasse : Le calcul du row_number :

image

Bizarre… il calcul le row_number (donc un bon gros SCAN) et en plus il fait les jointures en même temps… C’est dramatique ça !!

Je vous passe le plan d’exécution :

imageOn remarque bien qu’il remonte via son scan, tous les header, product, et customer.

Etape 2 : Le fait main, sans EF4

Bon pour ne pas en rester là, je repars sur une bonne vieille CTE , à la “mano”, pour voir de quoi il en retourne, quand on ne passe pas par de la génération …

La requête ressemble alors à ça :

;with mCTE (RowNumber, SalesOrderID, SalesOrderDetailID, OrderQty, UnitPrice, LineTotal, ProductID)
as
(
SELECT  row_number() OVER (ORDER BY [SalesOrderDetailID] ASC) as RowNumber,
        [SalesOrderID] , [SalesOrderDetailID],
        [OrderQty], [UnitPrice], [LineTotal], [ProductID]
        From Sales.SalesOrderDetail
)
SELECT
    [mCTE].[SalesOrderDetailID] AS [SalesOrderDetailID],
    [mCTE].[SalesOrderID] AS [SalesOrderID],
    [Extent4].[AccountNumber] AS [AccountNumber],
    [Extent2].[ShipDate] AS [ShipDate],
    [Extent3].[Name] AS [Name],
    [mCTE].[OrderQty] AS [OrderQty],
    [mCTE].[UnitPrice] AS [UnitPrice],
    [mCTE].[LineTotal] AS [LineTotal]
From [mCTE]
INNER JOIN [Sales].[SalesOrderHeader] AS [Extent2] ON [mCTE].[SalesOrderID] = [Extent2].[SalesOrderID]
INNER JOIN [Production].[Product] AS [Extent3] ON [mCTE].[ProductID] = [Extent3].[ProductID]
INNER JOIN [Sales].[Customer] AS [Extent4] ON [Extent2].[CustomerID] = [Extent4].[CustomerID]
Where RowNumber between 50000 and 50100

Ok, allons voir du coté du Profiler (ou le résultat est sans appel)

image

Bon ben on divise les temps d’exécution par 10 (et sur une grosse base, c’est exponentiel) Le nombre de lecture est divisé par 100, bref, là on sent qu’’il y a quelque chose de différent (et performant)

Etape 3 : L’optimisation avec EF4

Alors on peut en rester là, mais on peut aussi tenter d’optimiser la requête générée par Linq to Entities.

Le truc, finalement, c’est de demander à EF4 de D’ABORD calculer le row_number et ENSUITE de faire ses jointures :

On va se la faire en 2 étapes:

using (AdventureWorks2008R2Entities context = new AdventureWorks2008R2Entities())
{
    var skipNumber = IndexPage * PageSize;

    var items2 = (from sod in context.SalesOrderDetails
                    orderby sod.SalesOrderDetailID
                    select new {
                        sod.SalesOrderDetailID, sod.SalesOrderID,
                        sod.ProductID, sod.OrderQty,
                        sod.UnitPrice, sod.LineTotal }).Skip(skipNumber).Take(PageSize);

    var items = from sod in items2.AsQueryable()
                join soh in context.SalesOrderHeaders
                    on sod.SalesOrderID equals soh.SalesOrderID
                join p in context.Product on sod.ProductID equals p.ProductID
                join c in context.Customers on soh.CustomerID equals c.CustomerID
                select new
                {
                    sod.SalesOrderDetailID,
                    c.AccountNumber,
                    soh.ShipDate,
                    p.Name,
                    sod.OrderQty,
                    sod.UnitPrice,
                    sod.LineTotal
                };

    dataGrid1.ItemsSource = items.ToList();
}
 

Notez qu’il n’y a PAS deux exécutions de deux requêtes hein, on utilise bien un IQueryable dans la deuxième requête LINQ.

Bref, le requête, comme d’hab, va être générée et exécutée lors de l’appel du ToList()

Résultat :

imageLes performances sont bien là, on a quasiment au même niveau que la requête fait main !

et je vous garantis que ça se sent au niveau du “lag” de l’interface utilisateur :)

Voilà voilà, que du bon !

Conclusion

Un petit tableau récapitulatif  de ce qu’on vient de voir: image

Note : On peut aussi faire la requête optimisée en “Une Passe” comme ça, mais bon c’est un peu moins maintenable je pense :

var items = from sod in (from sod in context.SalesOrderDetails
                        orderby sod.SalesOrderDetailID
                        select new
                        {
                            sod.SalesOrderDetailID,
                            sod.SalesOrderID,
                            sod.ProductID,
                            sod.OrderQty,
                            sod.UnitPrice,
                            sod.LineTotal
                        }).Skip(skipNumber).Take(PageSize)
            join soh in context.SalesOrderHeaders
                on sod.SalesOrderID equals soh.SalesOrderID
            join p in context.Product on sod.ProductID equals p.ProductID
            join c in context.Customers on soh.CustomerID equals c.CustomerID
            select new
            {
                sod.SalesOrderDetailID,
                c.AccountNumber,
                soh.ShipDate,
                p.Name,
                sod.OrderQty,
                sod.UnitPrice,
                sod.LineTotal
            };

 

Voilà, LINQ ça poutre, mais faut savoir regarder un peu plus loin, des fois ;)

,
  • http://www.paslatek.net lionel

    Un post à mettre en toute les mains ! Merci Seb !
    Comme quoi la rêgle d’or est tjrs là : filtrer au plus tôt ds la requette linq, en l’occurence ici enlever des lignes avant la jointure ….

  • Vko

    J’adore ce type d’article !

  • http://conseilit.wordpress.com/ Chris

    Comme quoi, du bon vieux SQL et des SP restent encore bien performants face à des surcouches qui, certes, facilitent l’écriture, mais au détriment de la perf …
    Ça me rappelle quelque chose :
    http://conseilit.wordpress.com/2010/09/12/pagination-d%e2%80%99un-jeu-de-donnees-sous-sql-server/

    :-)

    Ceci dit, je n’aurais jamais pu produire un post sur EF, ce n’est pas dans mon rayon d’action !!! Et tu as bien fait d’écrire cet article, je serais encore plus vigilant (difficile, j’étais déjà pas un grand supporter) côté serveurs quand on me dira que c’est codé avec cette techno …

    J’aurais bien voulu jeter un oeil au plans d’exécution pour comprendre d’où viennent les 100 lectures d’écart entre la CTE et le LINQ optimisé (un query hint ? un force order ? changement de prédicat physique pour les joins ? en Statistics IO ON, tu as une worktable ?).

  • autran benjamin

    Carrément d’accord avec Chris

  • http://www.dotmim.com Mimetis

    Oui Chris, je suis aussi partisant du SQL, mais EF bien utilisé, ça le fait aussi pas mal :)
    Pour les CTE, j’en avais parlé aussi déjà, c’était il y a bien longtemps , en 2007 !!
    http://www.dotmim.com/2007/10/25/pagination-avec-sql-server-2005-cte-row_number/

    Ah là là, la vieillesse :)

  • Matthieu Mezil

    J’aime beaucoup ce genre d’article (je me sens moins seul :) )
    Cependant, j’ai quelques remarques à te faire :
    1. je trouve que les navigation properties simplifient la requête par rapport à un join
    2. le AsQueryable sur items2 ne sert à rien
    3 et c’est LE point important : tes deux requêtes LINQ ont des perfs très différentes mais c’est surtout parce qu’elles ont des résultats différents.
    Sur la première, tu fais la pagination sur le total. Par conséquent, tu auras bien le nombre de pages escomptés. Dans la deuxième en revanche, tu fais la pagination avant de faire les jointures. Par conséquent, si tu n’as pas d’association dans les tables jointes, alors tu retourneras moins d’éléments que le nombre possible dans ta page.
    Je sais bien que je ne suis pas un expert SQL mais, dans ces conditions, je ne suis pas surpris ni choqué par la première requête générée par EF.
    Les deux requêtes ne sont iso fonctionnelles que dans le cas où les FKs sont toutes not nullable.
    Il est vrai qu’EF pourrait prendre cet élément en compte dans la génération de sa requête mais on est encore malheureusement assez loin de ce niveau d’optimisation…

  • Matthieu Mezil

    Soit dit en passant, si, dans le cas où les FKs sont not nullable, on peut reprocher à EF de ne pas avoir optimiser la requête, on peut tenir exactement le même propos avec le moteur de SQL Server.

  • Ouss

    Très bon article, Merci. C’est en effet domage qu’EF n’optimise pas (encore) ce genre de requête.
    Le commentaire de Mathieu me laisse sur la faim tout de même. Quelle est donc la solution EF équivalente à la CTE ? Avec les navigations properties.

    Oussama

  • Matthieu Mezil

    @Ouss : non les navigation properties permettent juste d’éviter les joins mais ça génèrera le même SQL. C’est juste que je trouve la requête LINQ plus lisible avec les navigation properties.

    EF ne gère pas les CTE.

    Cependant, si on peut reprocher à EF le fait de ne pas gérer les CTE, on ne peut pas lui reprocher les perfs de la première requête L2E par rapport à la deuxième si les FK sont nullable. Si elles ne le sont pas il faudra alors faire le même reproche au moteur de SQL Server comme expliqué précédemment.

  • http://paslatek.dreamhosters.com/2010/10/performances-sur-la-pagination-avec-linq2entities/ Performances sur la pagination avec Linq2Entities – Développement divers, Non classé -

    [...] [...]