Codingly

Continuer l’optimisation avec la Lightweight Code Generation (LCG)

Posted in Articles by Romain Verdier on mai 6, 2008

Cet article est un complément du précédent. Vous pouviez y lire dans la conclusion :

Nous nous sommes contentés d’évoquer la solution d’optimisation impliquant la génération de code IL. Dans le contexte de la problématique discutée, elle n’offrait aucun avantage par rapport à celle que nous avons exposée. Pire, elle imposait une étape inutile. Toutefois, certains besoins plus complexes dépassent le cadre des invocations dynamiques de méthodes et peuvent tout de même être adressés efficacement en recourant à la génération de bytecode.

S’il existe des scénarios dans lesquels le recourt à la LCG est inutile voire pénalisant, il n’y a parfois aucune autre alternative lorsqu’il s’agit de mettre en place une solution où les performances sont aussi importantes que la dynamicité.

Vous ne trouverez pas ici un tutorial sur l’utilisation de Reflection.Emit, mais plutôt un exemple d’utilisation de cette technique pour répondre de façon optimale à un besoin bien spécifique. Nous essaierons en parallèle de faire ressortir quelques guidelines relatives à l’usage de la LCG.

Mais c’est quoi, ce truc ?

La LCG, ou Lightweight Code Generation, fait référence à une nouveauté apparue dans la seconde version du Framework .NET.

Il a toujours été possible en .NET d’utiliser l’API du namespace System.Reflection.Emit pour générer des assemblies, des modules et des types dynamiquement. Le principe est simple : on autorise via cette API les développeurs de la plateforme .NET à produire directement du code intermédiaire. C’est extrêmement puissant, mais très rapidement complexe. Le fait que le CIL ainsi obtenu soit (quasiment) impossible à débuguer ne vient pas arranger les choses.

C’était en quelque sorte la Heavyweight Code Generation : pour générer dynamiquement le bytecode correspondant à un traitement, il fallait la plupart du temps se taper la création d’un assembly, d’un module et d’un type pour finalement héberger la méthode encapsulant l’opération. Tout cela est souvent nécessaire, mais le reste du temps, c’est juste lourd.

Typiquement, lorsqu’on utilise la génération de CIL pour créer un proxy dynamiquement, on veut définir un type complet avec ses membres et ses méthodes. En revanche, pour créer une simple méthode au runtime on préfèrerait s’en passer.

La LCG autorise justement la création de méthodes dynamiques pouvant être réclamées par le garbage collector, et surtout ayant la capacité d’être hébergées anonymement, sans que l’on ait à créer d’assembly, de module ou de type. Outre le fait que de telles méthodes soient relativement faciles à créer (je ne parle pas de la génération de leurs corps), elles peuvent également être invoquées via des délégués. Et ça on connait, c’est efficace.

Parfois, c’est inutile

Dans l’article précédent, nous avons vu comment il était possible de créer un délégué pointant sur un MethodInfo pour considérablement optimiser les invocations dynamiques. L’exemple s’y prêtait bien : nous connaissions la signature de la méthode à appeler dynamiquement donc nous pouvions :

  • Définir un type de délégué correspondant
  • Solliciter la méthode CreateDelegate de la classe Delegate
  • Invoquer le délégué ainsi récupéré

C’est vraiment ce qu’il faut retenir. Il n’y a aucune raison d’utiliser la LCG si les signatures des méthodes à appeler dynamiquement sont connues et qu’il est possible de définir les délégués correspondants.

Un exemple moins ingrat

Vous aurez compris que lorsque la signature des méthodes à appeler dynamiquement n’est pas connue durant le design, il est impossible de déclarer un délégué correspondant à la méthode que l’on veut appeler. Il en découle que l’invocation via délégué est à oublier, et qu’il ne reste donc que la bonne vieille méthode Invoke sur le MethodInfo.

Oui, celle-la même qui ruine les performances.

A l’origine, je voulais trouver un exemple ni trop idiot ni trop complexe pour mettre ce cas en évidence et introduire la LCG. Finalement, je n’ai pas été capable de trouver quelque chose respectant cet équilibre. Vous aurez donc droit à un exemple vraiment simple et super idiot : le cloneur.

Commençons par en définir l’interface :

public interface ICloner
{
    object Clone(object toClone);
}

Les types respectant ce contrat devront fournir via la méthode Clone un service capable de retourner un shallow clone de l’objet passé en paramètre. Pour simplifier ici, nous ne considèrerons que les propriétés publiques des objets.

Ecrivons directement un test unitaire :

[Test]
public void Test()
{
    var cloner = GetCloner();
    var p = new Person
            {
               Id = 1,
               Firstname = "Romain",
               Lastname = "Verdier",
               BirthDate = new DateTime(1976, 03, 02),
               Height = 1.65
            };
    var p2 = cloner.Clone(p) as Person;
    Assert.AreNotEqual(p,p2);
    Assert.AreEqual(p.Id, p2.Id);
    Assert.AreEqual(p.Firstname, p2.Firstname);
    Assert.AreEqual(p.Lastname, p2.Lastname);
    Assert.AreEqual(p.BirthDate, p2.BirthDate);
    Assert.AreEqual(p.Height, p2.Height);
}

En utilisant simplement la réflexion, on peut proposer l’implémentation non optimisée suivante :

public class Cloner : ICloner
{
    private readonly Func<Type, Func<object, object>> clonerLocator;

    public Cloner()
    {
        this.clonerLocator = ((Func<Type, Func<object, object>>)GetCloner).Memoize();
    }

    public object Clone(object toClone)
    {
        var cloner = this.clonerLocator(toClone.GetType());
        return cloner(toClone);
    }

    private Func<object, object> GetCloner(Type type)
    {
        var constructorInfo = type.GetConstructor(Type.EmptyTypes);
        if (constructorInfo == null)
        {
            throw new ArgumentException(string.Format("'{0}' type doesn't have a default constructor.", type.Name));
        }

        return toClone =>
        {
            var clone = Activator.CreateInstance(type);
            var propertyInfos = type.GetProperties(BindingFlags.Instance | BindingFlags.Public);
            foreach (var propertyInfo in propertyInfos)
            {
                var setterInfo = propertyInfo.GetSetMethod();
                var getterInfo = propertyInfo.GetGetMethod();
                if (setterInfo != null && getterInfo != null)
                {
                    // Deux invocations dynamiques ont lieu ici sans que l'on puisse
                    // utiliser de délégués.
                    setterInfo.Invoke(clone, new object[] {getterInfo.Invoke(toClone, null)});
                }
            }
            return clone;
        }
    }
}

Notons à propos du code précédent :

  • Peu de vérifications sont faites, c’est à la fois volontaire et mal.
  • Nous n’utilisons pas GetValue et SetValue sur les PropertyInfo pour rendre les invocations dynamiques de méthodes explicites : ici, on fait deux invocations dynamiques par propriété. Une sur le getter, et une sur le setter.
  • Ne pas connaitre le type de chaque propriété à l’avance signifie que l’on ne connait pas la signature des getters et des setters. Ne pas connaître la signature des getters et des setters nous empêche d’utiliser la méthode décrite dans l’article précédent.
  • La memoization et l’utilisation d’un locateur ne servent qu’à faciliter la comparaison que l’on pourra faire avec la prochaine solution, puisqu’on ne peut pas utiliser de caching dans celle-ci.

Puisque vous aimez les chiffres comme tout le monde, j’ai créé un test de performances effectuant un million d’appels à la méthode de clonage. Et houlala, c’est lent : 1000000 appels en plus de 26 secondes.

En utilisant la LCG, il va être possible de générer une méthode encapsulant la logique de clonage relative à un type donné. On pourra également créer un délégué pour cette méthode, afin qu’elle puisse être invoquée sans que les performances ne soient dégradées. Ce délégué – et on rejoint ici le principe d’optimisation commun à toutes les solutions – pourra être mis en cache pour éviter que la méthode dynamique ne soit regénérée systématiquement.

Voyons ce que ça donne :

public class CilCloner : ICloner
{
    private readonly Func<Type, Func<object, object>> clonerLocator;

    public Cloner()
    {
        this.clonerLocator = ((Func<Type, Func<object, object>>) GetCloner).Memoize();
    }

    public object Clone(object toClone)
    {
        var cloner = this.clonerLocator(toClone.GetType());
        return cloner(toClone);
    }

    private Func<object, object> GetCloner(Type type)
    {
        var constructorInfo = type.GetConstructor(Type.EmptyTypes);
        if (constructorInfo == null)
        {
            throw new ArgumentException(string.Format("'{0}' type doesn't have a default constructor.", type.Name));
        }

        var dynamicMethod = new DynamicMethod(string.Format("<{0}>DoClone", type.Name),
                                              typeof (object),
                                              new []{typeof (object)},
                                              this.GetType());

        var propertyInfos = type.GetProperties(BindingFlags.Instance | BindingFlags.Public);

        var gen = dynamicMethod.GetILGenerator();
        var local = gen.DeclareLocal(type);
        gen.Emit(OpCodes.Newobj, constructorInfo);
        gen.Emit(OpCodes.Stloc, local);
        foreach(var propertyInfo in propertyInfos)
        {
            var setterInfo = propertyInfo.GetSetMethod();
            var getterInfo = propertyInfo.GetGetMethod();
            if (setterInfo != null && getterInfo != null)
            {
                gen.Emit(OpCodes.Ldloc, local);
                gen.Emit(OpCodes.Ldarg_0);
                gen.Emit(OpCodes.Callvirt, getterInfo);
                gen.Emit(OpCodes.Callvirt, setterInfo);
            }
        }
        gen.Emit(OpCodes.Ldloc, local);
        gen.Emit(OpCodes.Ret);

        return (Func<object, object>) dynamicMethod.CreateDelegate(typeof (Func<object, object>));
    }
}

Décortiquons une fois de plus la solution, en gardant à l’esprit que le but de l’article n’est pas d’apprendre à coder en CIL :

  • Le membre clonerLocator est gardé en champ d’instance afin que l’on puisse le mémoizer.
  • Le constructeur s’occupe de cette tâche en faisant appel à la méthode d’extension Memoize dont vous pourrez (re)trouver la définition dans l’article précédent.
  • La méthode Clone, correspondant à l’implémentation de l’interface ICloner, se contente de récupérer via le locateur un délégué capable d’effectuer le clone de l’objet passé en paramètre. Elle l’invoque directement ensuite.
  • Le cœur de la solution réside donc dans la méthode GetCloner :
    • Une guard clause permet de vérifier si le type de l’objet à créer expose bien un constructeur par défaut.
    • Une DynamicMethod est crée. Il s’agit d’une méthode prenant en paramètre un object et retournant un object, qui sera capable de cloner un objet du type type
    • Grâce à la réflexion, on récupère les métadonnées de toutes les propriétés publiques du type type.
    • On récupère le générateur de code IL de la méthode dynamique afin de pouvoir émettre le corps de cette dernière.
    • On génère le code IL correspondant à la déclaration d’une variable locale de type type. On l’initialise avec une nouvelle instance de type.
    • On itère sur toutes les métadonnées des propriétés, et on ne considère que celles qui sont à la fois accessibles en lecture et en écriture.
    • A chaque fois que cette condition est vérifiée, on produit le code correspondant à l’affectation de la propriété du clone (appel au setter). La valeur utilisée pour l’affectation est celle récupérée par le getter sur la propriété de l’objet à cloner.
    • On termine la construction du corps de la DynamicMethod en émettant les instructions IL correspondant au retour du clone.
    • Enfin, la méthode GetCloner crée un délégué à partir de la méthode dynamique via CreateDelegate, et le renvoit.

C’est beau ! La mesure des performances en utilisant le même test que précédemment indique cette fois qu’un million de clones ont été créés en 305 ms. Pour information, la méthode MemberwiseClone donne un résultat de 234 ms.

Conclusion

La Lightweight Code Generation, et plus globalement l’utilisation de Reflection.Emit, permet d’apporter des solutions insoupçonnées à certains problèmes bien spécifiques. Cependant, elle n’est pas gratuite et demande un investissement non négligeable de la part de ceux qui veulent la maitriser ou bien même s’en servir ponctuellement. Les développements peuvent être nettement ralentis tandis que les phases de debug et de maintenance risquent de devenir critiques.

Il est donc surtout important de :

  • Savoir que la technique existe.
  • Savoir reconnaitre les scénarios qui rendent son emploi envisageable.

Il existe assez peu de bonnes ressources permettant d’apprendre à maitriser cette technique. Je suis en train de lire CIL Programming: Under the Hood of .NET de Jason Bock, sans être spécialement séduit. Je conseille aux plus curieux de commencer par la MSDN et Google, puis de consulter les sources de projets qui utilisent l’émission de bytecode. Le blog de Jean-Baptiste Evain est également riche en infos au sujet du CIL : il est l’auteur entre autres de Mono.Cecil.

Mais j’y reviendrai.

Tagged with: , , , , ,

Une Réponse

Subscribe to comments with RSS.

  1. […] compilé pour obtenir un delegate capable d’être exécuté sans ruiner les perfs. Ca peut vous rappeler quelque chose… mais l’idée n’était pas de moi […]


Votre commentaire

Entrez vos coordonnées ci-dessous ou cliquez sur une icône pour vous connecter:

Logo WordPress.com

Vous commentez à l’aide de votre compte WordPress.com. Déconnexion /  Changer )

Image Twitter

Vous commentez à l’aide de votre compte Twitter. Déconnexion /  Changer )

Photo Facebook

Vous commentez à l’aide de votre compte Facebook. Déconnexion /  Changer )

Connexion à %s

%d blogueurs aiment cette page :