web-dev-qa-db-fra.com

Comment une variable dynamique affecte-t-elle les performances?

J'ai une question sur les performances de dynamic en C #. J'ai lu dynamic rend le compilateur exécuté à nouveau, mais que fait-il?

Doit-il recompiler la méthode entière avec la variable dynamic utilisée en tant que paramètre ou uniquement les lignes à comportement dynamique/contexte?

J'ai remarqué que l'utilisation de dynamic variables peut ralentir une simple boucle for de 2 ordres de grandeur.

Code avec lequel j'ai joué:

internal class Sum2
{
    public int intSum;
}

internal class Sum
{
    public dynamic DynSum;
    public int intSum;
}

class Program
{
    private const int ITERATIONS = 1000000;

    static void Main(string[] args)
    {
        var stopwatch = new Stopwatch();
        dynamic param = new Object();
        DynamicSum(stopwatch);
        SumInt(stopwatch);
        SumInt(stopwatch, param);
        Sum(stopwatch);

        DynamicSum(stopwatch);
        SumInt(stopwatch);
        SumInt(stopwatch, param);
        Sum(stopwatch);

        Console.ReadKey();
    }

    private static void Sum(Stopwatch stopwatch)
    {
        var sum = 0;
        stopwatch.Reset();
        stopwatch.Start();
        for (int i = 0; i < ITERATIONS; i++)
        {
            sum += i;
        }
        stopwatch.Stop();

        Console.WriteLine(string.Format("Elapsed {0}", stopwatch.ElapsedMilliseconds));
    }

    private static void SumInt(Stopwatch stopwatch)
    {
        var sum = new Sum();
        stopwatch.Reset();
        stopwatch.Start();
        for (int i = 0; i < ITERATIONS; i++)
        {
            sum.intSum += i;
        }
        stopwatch.Stop();

        Console.WriteLine(string.Format("Class Sum int Elapsed {0}", stopwatch.ElapsedMilliseconds));
    }

    private static void SumInt(Stopwatch stopwatch, dynamic param)
    {
        var sum = new Sum2();
        stopwatch.Reset();
        stopwatch.Start();
        for (int i = 0; i < ITERATIONS; i++)
        {
            sum.intSum += i;
        }
        stopwatch.Stop();

        Console.WriteLine(string.Format("Class Sum int Elapsed {0} {1}", stopwatch.ElapsedMilliseconds, param.GetType()));
    }

    private static void DynamicSum(Stopwatch stopwatch)
    {
        var sum = new Sum();
        stopwatch.Reset();
        stopwatch.Start();
        for (int i = 0; i < ITERATIONS; i++)
        {
            sum.DynSum += i;
        }
        stopwatch.Stop();

        Console.WriteLine(String.Format("Dynamic Sum Elapsed {0}", stopwatch.ElapsedMilliseconds));
    }
117
Lukasz Madon

J'ai lu dynamic fait fonctionner le compilateur à nouveau, mais ce qu'il fait. Doit-il recompiler la méthode entière avec la dynamique utilisée en tant que paramètre ou plutôt ces lignes avec comportement dynamique/contexte (?)

Voici le deal.

Pour chaque expression de votre programme de type dynamique, le compilateur émet un code qui génère un "objet de site d'appel dynamique" représentant l'opération. Donc, par exemple, si vous avez:

class C
{
    void M()
    {
        dynamic d1 = whatever;
        dynamic d2 = d1.Foo();

alors le compilateur générera un code qui est moralement comme ceci. (Le code lui-même est un peu plus complexe; ceci est simplifié pour la présentation.)

class C
{
    static DynamicCallSite FooCallSite;
    void M()
    {
        object d1 = whatever;
        object d2;
        if (FooCallSite == null) FooCallSite = new DynamicCallSite();
        d2 = FooCallSite.DoInvocation("Foo", d1);

Voyez comment cela fonctionne jusqu'à présent? Nous générons le site d’appel une fois , quel que soit le nombre de fois que vous appelez M. Le site d’appel vit à jamais après que vous l’ayez généré une fois. Le site d’appel est un objet qui représente "il va y avoir un appel dynamique à Foo ici".

OK, alors maintenant que vous avez le site d'appel, comment fonctionne l'invocation?

Le site d'appels fait partie du Dynamic Language Runtime. Le DLR dit "hmm, quelqu'un essaie de faire une invocation dynamique d'une méthode foo sur cet objet ici. Est-ce que je sais quelque chose à ce sujet? Non, je ferais mieux de le découvrir."

Le DLR interroge ensuite l'objet dans d1 pour voir s'il a quelque chose de spécial. Peut-être qu’il s’agit d’un objet COM hérité, ou d’un objet Iron Python, ou d’un objet Iron Ruby, ou IE Objet DOM.Si ce n'est pas le cas, il doit s'agir d'un objet C # ordinaire.

C'est à ce moment que le compilateur redémarre. Il n'est pas nécessaire d'utiliser un lexer ou un analyseur. Le DLR démarre donc une version spéciale du compilateur C # contenant uniquement l'analyseur de métadonnées, l'analyseur sémantique des expressions et un émetteur qui émet des arbres d'expressions au lieu de IL.

L'analyseur de métadonnées utilise Reflection pour déterminer le type de l'objet dans d1, puis le transmet à l'analyseur sémantique pour lui demander ce qu'il se passe lorsqu'un tel objet est appelé dans la méthode Foo. L'analyseur de résolution de surcharge découvre cela, puis crée un arbre d'expression, comme si vous aviez appelé Foo dans un arbre d'expression lambda, qui représente cet appel.

Le compilateur C # transmet ensuite cette arborescence d'expression au DLR avec une stratégie de cache. La règle est généralement "la deuxième fois que vous voyez un objet de ce type, vous pouvez réutiliser cet arbre d'expression plutôt que de me rappeler à nouveau". Le DLR appelle ensuite Compile sur l'arbre d'expression, qui appelle le compilateur expression-arbre-en-IL et crache un bloc d'IL généré dynamiquement dans un délégué.

Le DLR met ensuite ce délégué en cache dans un cache associé à l'objet de site d'appel.

Ensuite, il appelle le délégué et l'appel Foo est lancé.

La deuxième fois que vous appelez M, nous avons déjà un site d’appel. Le DLR interroge à nouveau l'objet et si l'objet est du même type que la dernière fois, il extrait le délégué du cache et l'invoque. Si l'objet est d'un type différent, le cache manque et l'ensemble du processus recommence; nous effectuons une analyse sémantique de l'appel et stockons le résultat dans le cache.

Cela se produit pour chaque expression qui implique dynamique. Donc par exemple si vous avez:

int x = d1.Foo() + d2;

alors il y a trois sites d'appels dynamiques. Un pour l'appel dynamique à Foo, un pour l'addition dynamique et un pour la conversion dynamique de dynamique en int. Chacun possède sa propre analyse d'exécution et son propre cache de résultats d'analyse.

Avoir un sens?

219
Eric Lippert

Update: Ajout de tests de performances précompilés et compilés paresseux

Mise à jour 2: Il se trouve que je me trompe. Voir l'article d'Eric Lippert pour une réponse complète et correcte. Je laisse cela ici pour les chiffres de référence

* Mise à jour 3: Ajout de points de repère IL-Emitted et Lazy IL-Emitted, basés sur réponse de Mark Gravell à cette question .

À ma connaissance, l'utilisation du mot clé dynamic ne provoque pas de compilation supplémentaire au moment de l'exécution (bien que j'imagine qu'elle puisse le faire dans des circonstances spécifiques, en fonction du type d'objets sauvegardant vos variables dynamiques). .

En ce qui concerne les performances, dynamic introduit intrinsèquement des frais généraux, mais pas autant que vous ne le pensez. Par exemple, je viens de lancer un point de repère qui ressemble à ceci:

void Main()
{
    Foo foo = new Foo();
    var args = new object[0];
    var method = typeof(Foo).GetMethod("DoSomething");
    dynamic dfoo = foo;
    var precompiled = 
        Expression.Lambda<Action>(
            Expression.Call(Expression.Constant(foo), method))
        .Compile();
    var lazyCompiled = new Lazy<Action>(() =>
        Expression.Lambda<Action>(
            Expression.Call(Expression.Constant(foo), method))
        .Compile(), false);
    var wrapped = Wrap(method);
    var lazyWrapped = new Lazy<Func<object, object[], object>>(() => Wrap(method), false);
    var actions = new[]
    {
        new TimedAction("Direct", () => 
        {
            foo.DoSomething();
        }),
        new TimedAction("Dynamic", () => 
        {
            dfoo.DoSomething();
        }),
        new TimedAction("Reflection", () => 
        {
            method.Invoke(foo, args);
        }),
        new TimedAction("Precompiled", () => 
        {
            precompiled();
        }),
        new TimedAction("LazyCompiled", () => 
        {
            lazyCompiled.Value();
        }),
        new TimedAction("ILEmitted", () => 
        {
            wrapped(foo, null);
        }),
        new TimedAction("LazyILEmitted", () => 
        {
            lazyWrapped.Value(foo, null);
        }),
    };
    TimeActions(1000000, actions);
}

class Foo{
    public void DoSomething(){}
}

static Func<object, object[], object> Wrap(MethodInfo method)
{
    var dm = new DynamicMethod(method.Name, typeof(object), new Type[] {
        typeof(object), typeof(object[])
    }, method.DeclaringType, true);
    var il = dm.GetILGenerator();

    if (!method.IsStatic)
    {
        il.Emit(OpCodes.Ldarg_0);
        il.Emit(OpCodes.Unbox_Any, method.DeclaringType);
    }
    var parameters = method.GetParameters();
    for (int i = 0; i < parameters.Length; i++)
    {
        il.Emit(OpCodes.Ldarg_1);
        il.Emit(OpCodes.Ldc_I4, i);
        il.Emit(OpCodes.Ldelem_Ref);
        il.Emit(OpCodes.Unbox_Any, parameters[i].ParameterType);
    }
    il.EmitCall(method.IsStatic || method.DeclaringType.IsValueType ?
        OpCodes.Call : OpCodes.Callvirt, method, null);
    if (method.ReturnType == null || method.ReturnType == typeof(void))
    {
        il.Emit(OpCodes.Ldnull);
    }
    else if (method.ReturnType.IsValueType)
    {
        il.Emit(OpCodes.Box, method.ReturnType);
    }
    il.Emit(OpCodes.Ret);
    return (Func<object, object[], object>)dm.CreateDelegate(typeof(Func<object, object[], object>));
}

Comme vous pouvez le constater à partir du code, j'essaie d'invoquer une méthode simple sans intervention de sept manières différentes:

  1. Appel de méthode directe
  2. Utiliser dynamic
  3. Par réflexion
  4. Utiliser un Action qui a été précompilé au moment de l'exécution (ce qui exclut la compilation des résultats).
  5. Utiliser un Action qui est compilé la première fois que cela est nécessaire, en utilisant une variable Lazy non thread-safe (incluant donc le temps de compilation)
  6. Utilisation d'une méthode générée dynamiquement créée avant le test.
  7. Utilisation d'une méthode générée dynamiquement qui est instanciée paresseusement pendant le test.

Chacun se fait appeler 1 million de fois en une simple boucle. Voici les résultats de chronométrage:

Direct: 3.4248ms
Dynamique: 45.0728ms
Réflexion: 888.4011ms
Précompilé: 21.9166ms
LazyCompiled: 30.2045ms
ILEmitted: 8.4918ms
LazyILE a envoyé: 14.3483ms

Ainsi, bien que l'utilisation du mot clé dynamic prenne un ordre de grandeur plus long que l'appel direct de la méthode, l'opération réussit néanmoins à terminer l'opération un million de fois en environ 50 millisecondes, ce qui la rend beaucoup plus rapide que la réflexion. Si la méthode que nous appelons tentait de faire quelque chose d'intensif, comme combiner quelques chaînes ou rechercher une collection pour une valeur, ces opérations dépasseraient probablement de loin la différence entre un appel direct et un appel dynamic.

Les performances ne sont qu’une des nombreuses bonnes raisons de ne pas utiliser dynamic sans nécessité, mais lorsque vous traitez avec des données réellement dynamic, elles peuvent offrir des avantages qui dépassent de loin les inconvénients.

Mise à jour 4

Sur la base du commentaire de Johnbot, j'ai divisé la zone de réflexion en quatre tests distincts:

    new TimedAction("Reflection, find method", () => 
    {
        typeof(Foo).GetMethod("DoSomething").Invoke(foo, args);
    }),
    new TimedAction("Reflection, predetermined method", () => 
    {
        method.Invoke(foo, args);
    }),
    new TimedAction("Reflection, create a delegate", () => 
    {
        ((Action)method.CreateDelegate(typeof(Action), foo)).Invoke();
    }),
    new TimedAction("Reflection, cached delegate", () => 
    {
        methodDelegate.Invoke();
    }),

... et voici les résultats de référence:

enter image description here

Ainsi, si vous pouvez prédéterminer une méthode spécifique dont vous aurez besoin d'appeler beaucoup, invoquer un délégué mis en cache faisant référence à cette méthode est aussi rapide que d'appeler la méthode elle-même. Toutefois, si vous devez déterminer la méthode à appeler au moment de l'appeler, il est très coûteux de créer un délégué.

99
StriplingWarrior