Performances d’une application prévalente en .NET avec le framework Bamboo

Introduction

Lors d’un précédent article, je vous ai présenté le cycle de vie d’une application prévalente. Nous avons constaté qu’il était possible de mettre en place un système de mise à jour des objets métier en se basant sur une migration en deux temps, et que ceci pouvait se faire sans expansion incontrôlée de la mémoire utilisée pour la sérialisation des données.

Dans le présent article, je vais vous exposer quelques retours sur la performance de chargement, d’exécution et de snapshot, en me basant pour mes exemples sur la même application exemple que celle présentée précédemment. Pour ceux qui ont lu l’article, vous ne devriez pas vous sentir dépaysés : vous verrez simplement que j’ai étendu la fenêtre vers la droite pour ajouter des fonctionnalités de test et des affichages de benchmarks. Pour les autres, je vous conseille vivement de lire le premier article avant celui-ci, sauf si vous connaissez déjà la prévalence. Un lien pour les fainéants (ne le prenez pas mal : j’en fais partie, et il paraît que c’est l’ingrédient de base d’un bon programmeur).

greenshot_2010-01-26_22-59-27

Note : Je profite de cette capture d’écran pour vous donner un conseil lorsque vous benchmarkez une application : placez votre système de test à l’intérieur même de l’application, et si possible carrément dans l’interface graphique. On a souvent tendance à ne pas vouloir polluer le code de l’application avec des tests. Mais il y a de nombreuses bonnes raisons de procéder de la sorte :

  • Vous ne polluez rien tant que vous ne validez pas votre code dans votre gestionnaire source, pour des petits tests. Et pour des grosses campagnes de benchmarks, vous pouvez tout à fait créer une branche.
  • En plaçant votre code de test à l’intérieur même de l’application, vous pouvez tomber sur des impacts de performance sur des points que vous auriez pu négliger si vous l’aviez extériorisé. Le cas typique est un moteur de processus métier que vous optimisez, sans vous rendre compte que ce qui prenait du temps était en fait le chargement dans une grille de données.
  • Enfin, cette approche vous permet de livrer votre benchmark en production, et c’est souvent très utile pour prouver à un client que, vraiment, c’est son réseau ou son serveur qui est en question et pas votre logiciel.

Comme vous l’avez deviné sur la capture d’écran, on va regarder ce qui se passe dans le cas de création en masse de données prévalentes, puis lors de leur chargement et de leur sauvegarde. On jettera bien sûr un œil à la taille occupée sur disque, et inévitablement, on en arrivera à poser la comparaison avec une base de données. Pour les plus pressés, vous pouvez sauter directement à la conclusion.

Configuration utilisée

La configuration utilisée n’est véritablement pas un foudre de guerre, même si le disque dur utilisé, assez rapide, peut influer positivement sur les résultats. La prévalence a surtout besoin de CPU pour la sérialisation, de RAM bien sûr car tout le métier est contenu en mémoire à un moment donné. Mais la rapidité du moyen de stockage est aussi importante pour l’étape de snapshot et pour le stockage des logs de commande, surtout lors d’exécutions en masse.

CPU : AMD 64 X2 4400+ à 2.2 GHz (un seul core utilisé)

RAM : 2 Go, PC3200 DDR SDRAM, noname, 5 Go/s en lecture, 1,6 Go/s en écriture (Everest)

HD : Western Digital Raptor 10K RPM (donc vieux, mais toujours plutôt bon)

Chargement en masse

La première chose à tester, si on suit l’ordre d’utilisation de l’application, va être de valider le comportement de création de données en masse. Rappelez vous que chaque création d’objet passe par la création d’une commande sérialisée qui est ensuite exécutée pour générer elle-même des objets qui se retrouveront à terme eux aussi sérialisés par le moteur de prévalence. Cette deuxième étape est en général gérée en arrière-plan, et avec une gestion différée par le moteur de prévalence. Par contre, la première étape est synchrone, et prend évidemment plus de temps que si on ajoutait simplement les objets en mémoire.

On lance le test pour 10000 personnes avec 25 réservations chacune. Pour l’instant, je n’augmente pas la taille des objets métier : les personnes sont remplies avec un GUID 32 pour le nom et pareil pour le prénom, tandis que les réservations prennent une personne en paramètre, ainsi que deux dates. Aucune compression n’est activée.

Stopwatch Chrono = Stopwatch.StartNew();
for (int Index = 0; Index < 10000; Index++)
{
    Work.Personnes.Personne NouvellePersonne = (Work.Personnes.Personne)(new Commandes.CreationPersonne(Guid.NewGuid().ToString(), Guid.NewGuid().ToString()).Execute());
    for (int IndexResa = 0; IndexResa < 25; IndexResa++)
        new Commandes.CreationReservation(DateTime.Now, DateTime.Now.AddDays(3), NouvellePersonne).Execute();
}
Chrono.Stop();
MessageBox.Show(Chrono.Elapsed.ToString());

Résultat : 55 minutes pour la création des données, 18 Mo de snapshot, et l’application met désormais un peu plus de 200 secondes à se charger (comparé à un affichage quasi instantané avant remplissage).

Est-ce que c’est bon ? On peut poser le problème dans les deux sens. Il y a bien sûr des problèmes :

  • Une application qui met 3 minutes à se charger, ça n’est clairement pas bon.
  • 55 minutes à attendre pour la création de 10 000 entrées, même complexes, c’est beaucoup trop dans l’absolu.

Mais comme toujours, il faut contextualiser :

  • Une application prévalente est faite pour rester en mémoire, et ne se relancer qu’à la suite d’une catastrophe. Si votre serveur répond bien pendant un an avant le premier crash, et que vous mettez trois minutes en plus du boot pour que votre application soit en ligne, honnêtement, ça ne pose aucun problème.
  • 260 000 objets créés (10 000 personnes avec 25 réservations attachées à chaque fois), ça fait quand même un taux de 80 objets métier à la seconde. Si vous comparez à des INSERT loggés en base, on n’est pas très loin. Evidemment, en mode bulk, ça va aller beaucoup plus vite, mais on va voir plus bas que la prévalence peut aussi gérer un mode similaire.

<remarque mode=’grognon’>
Si le premier point vous donne envie d’écrire dans les commentaires “oui, mais moi, mon application, elle doit avoir un SLA de 99,999% et on ne peut pas se permettre un arrêt de 3 secondes”, merci de vous abstenir : ce pourcentage est à peu près équivalent à celui des entreprises qui exigent ce niveau alors qu’elles n’en ont pas besoin, simplement parce que ça fait bien et qu’elles ont du fric à jeter par la fenêtre. Les cas particuliers n’intéressent que leur propriétaire.
</remarque>

BULK INSERT en mode prévalence

Cette petite saute d’humeur étant passée, reprenons sur la remarque du mode bulk. La façon de faire que je vous ai présentée ci-dessus n’est tout simplement pas adaptée au traitement en masse, de la même manière que quand vous devez entrer des milliers de lignes dans une base de données, il ne faut pas multiplier les INSERT, mais plutôt utiliser les fonctions de BULK COPY pour insérer vos données sans exploser les logs. On va donc essayer de réaliser la même chose en prévalence :

Stopwatch Chrono = Stopwatch.StartNew();
new Commandes.CreationMasse().Execute();
Chrono.Stop();
MessageBox.Show(Chrono.Elapsed.ToString());

pour l’appel. Quant à l’implémentation de la commande, voici le code :

using System;
using System.Collections.Generic;

using Bamboo.Prevalence;

namespace PrevalenceQuery.Commandes
{
    [Serializable]
    public class CreationMasse : ICommand
    {
        public CreationMasse()
        {
        }

        public object Execute(object system)
        {
            for (int Index = 0; Index < 10000; Index++)
            {
                (system as Work.Ensemble).ListePersonnes.Add(new Work.Personnes.Personne(Guid.NewGuid().ToString(), Guid.NewGuid().ToString()));
                PrevalenceQuery.Work.Personnes.Personne DernierePersonneAjoutee = (system as Work.Ensemble).ListePersonnes[(system as Work.Ensemble).ListePersonnes.Count - 1];
                for (int IndexResa = 0; IndexResa < 25; IndexResa++)
                    (system as Work.Ensemble).ListeReservations.Add(new Work.Reservation.Reservation(DateTime.Now, DateTime.Now.AddDays(3), DernierePersonneAjoutee));
            }
            return null;
        }

        public object Execute()
        {
            return Prevalence.Racine.Moteur.ExecuteCommand(this);
        }
    }
}

Bref, un truc beaucoup plus intelligent plutôt que de créer une commande d’exécution prévalente pour chaque objet.

Résultat : l’application met toujours autant de temps à se charger après coup, ce qui est logique car il faut désérialiser autant de données, à savoir 18 Mo. Par contre, côté temps passé pour la création… 1,25 secondes !

Ce problème-là étant résolu, revenons sur le temps de chargement.

Quand même, trois minutes pour charger l’appli !

Eh ben oui, ça fait long… Et on a beau être dans le cas d’une application qui est censée ne redémarrer que lorsque le serveur tombe, c’est quant même bizarre, non ? Depuis quand on met 200 secondes pour désérialiser 18 Mo de données ?

Il doit bien y avoir un truc. Et en cherchant un peu, l’os est le chargement des réservations dans le combo. Tant qu’on est à quelques milliers, ça se passe bien, mais ensuite, ça se gâte. Modifions le code à la barbare pour valider ceci :

Reservations = (List<Work.Reservation.Reservation>)(new Commandes.ListerReservations().Execute());
for (int Index = 0; Index < 1000; Index++)
    EchantillonReservations.Add(Reservations[Index]);
listeReservations.DisplayMember = "Description";
listeReservations.DataSource = EchantillonReservations;

Résultats : l’application démarre désormais en 15 secondes ! Et la prise de snapshot finale sur la sortie est de l’ordre de 10 secondes, ce qui est cohérent.

Mais une fois que tout est en mémoire…

Maintenant qu’on s’est attardé sur les points à gérer dans la performance d’une application prévalente, on va regarder un aspect extrêmement positif de la situation : tout est désormais en mémoire, et les manipulations métier les plus complexes devraient aller au plus vite.

En plus, comme brièvement abordé dans le précédent article, on va pouvoir se servir de la puissance de Linq To Objects pour réaliser tous les traitements possibles et imaginables. Prenons le cas suivant :

private void btnCalculMetierComplexe_Click(object sender, EventArgs e)
{
    Stopwatch Chrono = Stopwatch.StartNew();
    decimal SommeMetier = Reservations
        .Where(Resa => Resa.UID.Split('a').Count() > 3)
        .Bareme(Resa => Resa.Description.Length, Resa => Resa.DateDebut)
        .Sum(Valeur => Valeur);
    Chrono.Stop();
    MessageBox.Show(SommeMetier.ToString() + " " + Chrono.Elapsed.ToString());
}

Il faut bien sûr pour que cela fonctionne l’accompagner de la fonction d’extension ci-dessous :

using System;
using System.Collections.Generic;
using System.Linq;

namespace PrevalenceQuery
{
    public static class Extensions
    {
        public static IEnumerable<decimal> Bareme<T>(this IEnumerable<T> Liste, Func<T, decimal> BaseCalcul, Func<T, DateTime> DateApplication)
        {
            foreach (T Atome in Liste)
            {
                int[] Coefficients = new int[] { 0, 4, 9, 12 };
                DateTime DateCoefficients = DateApplication(Atome);
                if (DateCoefficients.DayOfWeek == DayOfWeek.Wednesday) Coefficients = new int[] { 0, 5, 10, 15 };
                else if (DateCoefficients.DayOfYear > 210) Coefficients = new int[] { 0, 3, 8, 20 };

                decimal Resultat = 0;
                decimal ValeurReference = BaseCalcul(Atome);
                if (ValeurReference < Coefficients[0]) Resultat = -1;
                else if (ValeurReference < Coefficients[1]) Resultat = 0;
                else if (ValeurReference < Coefficients[2]) Resultat = 1;
                else if (ValeurReference >= Coefficients[3]) Resultat = 2;
                else Resultat = 3;
                yield return Resultat;
            }
        }
    }
}

 

Le résultat et le temps de calcul :

greenshot_2010-01-26_22-35-25

Oui, une seconde et demie pour un calcul sur 250 000 réservations, avec un barème sur une fonction qu’on peut encore faire en SQL, mais au prix d’un sacré boulot, et d’une capacité de maintenance… comment dire… quasi nulle.

L’exemple ci-dessus a beau reproduire une manipulation métier assez complexe (le calcul d’une somme, sur certaines réservations, d’un barème qui s’applique sur un champ avec des coefficients eux-mêmes dépendant de la date portée par un autre champ de la même réservation), le code est simple et aisément maintenable. Je vous laisse imaginer l’équivalent en SQL, avec des TO_CHAR(DateDebut, ‘Q’) pour indiquer que vous voulez vous baser sur le trimestre. Je passe bien entendu sur les problèmes de calendriers. En .NET, un appel de fonction et vous passez dans une autre culture ou une autre zone de temps. Quiconque a fouraillé une fois avec la gestion des codepages ou de l’UTC en SQL pleurerait de bonheur s’il pouvait faire aussi simple.

Et encore, ceci n’est qu’une petite fonction. Imaginez le gain de temps absolument fabuleux sur toutes les applications de calcul complexe lorsque toutes vos données sont en mémoire. Alors, bien sûr, vous pouvez remonter toutes vos données en mémoire depuis une base de données, mais en passant par le cauchemar du mapping O/R, et avec, quoi qu’on en dise, une adaptation même minime de votre métier à l’aspect tabulaire. Mais justement : pourquoi stocker en base pour remonter en mémoire dès qu’on a besoin des données ? C’est là le principe de base de la prévalence : autant tout stocker en mémoire tout le temps.

Conclusion

Vous l’avez compris : une application en mode prévalent ne change en rien d’une application avec base de données. Vous devez toujours faire attention à la façon de créer des données, ainsi que de les extraire.

Par contre, une fois ces problèmes résolus, vous pouvez exploser des temps de calcul comme vous ne l’imaginiez même pas possible avec des données en base. Et ce avec une simplicité de code extrême. Je ne parle pas des avantages inhérents à Linq en termes de typage fort. Là encore, c’est un énorme point fort pour la maintenance de vos projets.

[Edit]

Je reprends manuellement les commentaires, suite à la migration de mon ancienne plateforme de blog vers celle-ci.

takuan :

Bravo pour cette présentation. C’est une des seules source en français sur la prévalence.
La prévalence en elle-même est intéressante mais le temps de chargement (15 secondes dans votre démo) pour “seulement” 250 000 éléments(10 000 personnes * 25 réservations/personne) est trop long.
Il faudrait pouvoir charger juste les noms des 10 000 personnes et ensuite charger les réservations à la demande. Mais je ne sais pas si cela est possible (s’il y a un moyen je serais curieux de le connaitre).

Dans tous les cas merci pour l’effort.
Bonne continuation.

About JP Gouigoux

Jean-Philippe Gouigoux est Architecte Logiciel, MVP Connected Systems Developer. Il intervient régulièrement à l'Université de Bretagne Sud ainsi qu'à l'Agile Tour. Plus de détails sur la page "Curriculum Vitae" de ce blog.
This entry was posted in .NET, Prevalence and tagged , . Bookmark the permalink.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Captcha Captcha Reload