Comment le fait d'avoir une variable dynamique affecte-t-il les performances?

128

J'ai une question sur les performances de dynamicen C #. J'ai lu dynamicque le compilateur s'exécute à nouveau, mais que fait-il?

Doit-il recompiler toute la méthode avec la dynamicvariable utilisée comme paramètre ou juste ces lignes avec un comportement / contexte dynamique?

J'ai remarqué que l'utilisation de dynamicvariables 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));
    }
Lukasz Madon
la source
Non, il ne lance pas le compilateur, ce qui le rendrait lent au premier passage. Un peu similaire à Reflection mais avec beaucoup d'intelligence pour garder une trace de ce qui a été fait auparavant pour minimiser les frais généraux. Google "Dynamic Language Runtime" pour plus d'informations. Et non, il n'approche jamais la vitesse d'une boucle «native».
Hans Passant

Réponses:

234

J'ai lu dynamic fait redémarrer le compilateur, mais ce qu'il fait. Doit-il recompiler toute la méthode avec la dynamique utilisée comme paramètre ou plutôt ces lignes avec comportement / contexte dynamique (?)

Voici l'affaire.

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

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

alors le compilateur générera du code qui est moralement comme ça. (Le code réel est un peu plus complexe; cela est simplifié à des fins de 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 , peu importe le nombre de fois que vous appelez M. Le site d'appel vit pour toujours après l'avoir 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, maintenant que vous avez le site d'appel, comment fonctionne l'invocation?

Le site d'appel 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 here. Est-ce que je sais quelque chose à ce sujet? Non. Alors je ferais mieux de le découvrir."

Le DLR interroge ensuite l'objet en d1 pour voir s'il s'agit de quelque chose de spécial. Peut-être s'agit-il d'un objet COM hérité, d'un objet Iron Python, d'un objet Iron Ruby ou d'un objet DOM IE. S'il ne s'agit pas de l'un de ces objets, il doit s'agir d'un objet C # ordinaire.

C'est le moment où le compilateur redémarre. Il n'y a pas besoin d'un lexer ou d'un analyseur, donc le DLR démarre une version spéciale du compilateur C # qui n'a que l'analyseur de métadonnées, l'analyseur sémantique pour les expressions et un émetteur qui émet des arbres d'expression 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 demander ce qui se passe lorsqu'un tel objet est appelé sur la méthode Foo. L'analyseur de résolution de surcharge comprend cela, puis construit 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 politique 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". Le DLR appelle ensuite Compile sur l'arborescence d'expression, qui appelle le compilateur expression-tree-to-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 se produit.

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 récupère le délégué hors du cache et l'appelle. Si l'objet est d'un type différent, alors le cache manque et tout le 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 une dynamique. Donc par exemple si vous avez:

int x = d1.Foo() + d2;

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

Ça a du sens?

Eric Lippert
la source
Juste par curiosité, la version spéciale du compilateur sans parser / lexer est invoquée en passant un drapeau spécial au csc.exe standard?
Roman Royter
@Eric, puis-je vous demander de me diriger vers un article de blog précédent dans lequel vous parlez de conversions implicites de short, int, etc.? Comme je me souviens, vous y avez mentionné comment / pourquoi utiliser dynamic avec Convert.ToXXX provoque le démarrage du compilateur. Je suis sûr que je dépense les détails, mais j'espère que vous savez de quoi je parle.
Adam Rackis
4
@Roman: Non. Csc.exe est écrit en C ++, et nous avions besoin de quelque chose que nous pourrions facilement appeler à partir de C #. De plus, le compilateur principal a ses propres objets de type, mais nous devions pouvoir utiliser des objets de type Reflection. Nous avons extrait les parties pertinentes du code C ++ du compilateur csc.exe et les avons traduites ligne par ligne en C #, puis avons construit une bibliothèque à partir de cela pour que le DLR puisse les appeler.
Eric Lippert
9
@Eric, « Nous avons extrait les parties pertinentes du code C ++ du compilateur csc.exe et les traduire ligne par ligne en C # » était - il alors les gens pensé Roslyn pourrait être une valeur POURSUITE :)
ShuggyCoUk
5
@ShuggyCoUk: L'idée d'avoir un compilateur en tant que service circulait depuis un certain temps, mais en fait, avoir besoin d'un service d'exécution pour l'analyse du code était une grande impulsion vers ce projet, oui.
Eric Lippert
108

Mise à jour: Ajout de benchmarks précompilés et paresseux

Mise à jour 2: Il s'avère que je me trompe. Voir l'article d'Eric Lippert pour une réponse complète et correcte. Je laisse ça ici pour le bien des chiffres de référence

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

À ma connaissance, l'utilisation du dynamicmot-clé ne provoque pas de compilation supplémentaire au moment de l'exécution en soi (même si j'imagine qu'il pourrait le faire dans des circonstances spécifiques, en fonction du type d'objets qui sauvegardent vos variables dynamiques).

En ce qui concerne les performances, dynamicintroduit intrinsèquement des frais généraux, mais pas autant que vous pourriez le penser. Par exemple, je viens de lancer un benchmark 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 voir dans le code, j'essaie d'appeler une méthode simple sans opération de sept manières différentes:

  1. Appel de méthode direct
  2. En utilisant dynamic
  3. Par réflexion
  4. Utilisation d'un Actionqui a été précompilé à l'exécution (excluant ainsi le temps de compilation des résultats).
  5. Utiliser un Actionqui est compilé la première fois que cela est nécessaire, en utilisant une variable Lazy non thread-safe (incluant ainsi le temps de compilation)
  6. Utilisation d'une méthode générée dynamiquement qui est 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 est appelé 1 million de fois dans une boucle simple. 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
LazyILEmitted: 14.3483ms

Ainsi, même si l'utilisation du dynamicmot - clé prend un ordre de grandeur plus long que d'appeler directement la méthode, elle parvient toujours à 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 essayait de faire quelque chose d'intensif, comme combiner quelques chaînes ensemble ou rechercher une collection pour une valeur, ces opérations l'emporteraient probablement de loin sur la différence entre un appel direct et un dynamicappel.

Les performances ne sont que l'une des nombreuses bonnes raisons de ne pas utiliser dynamicinutilement, mais lorsque vous traitez avec de véritables dynamicdonnées, elles peuvent offrir des avantages qui l'emportent largement sur 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:

entrez la description de l'image ici

Donc, si vous pouvez prédéterminer une méthode spécifique que vous devrez appeler beaucoup, invoquer un délégué mis en cache faisant référence à cette méthode est à peu près aussi rapide que d'appeler la méthode elle-même. Cependant, si vous avez besoin de déterminer la méthode à appeler au moment où vous êtes sur le point de l'invoquer, la création d'un délégué pour elle est très coûteuse.

StriplingGuerrier
la source
2
Une réponse si détaillée, merci! Je me posais également des questions sur les chiffres réels.
Sergey Sirotkin
4
Eh bien, le code dynamique démarre l'importateur de métadonnées, l'analyseur sémantique et l'émetteur d'arbre d'expression du compilateur, puis exécute un compilateur expression-tree-to-il sur la sortie de cela, donc je pense qu'il est juste de dire qu'il démarre le compilateur au moment de l'exécution. Juste parce qu'il n'exécute pas le lexer et que l'analyseur semble à peine pertinent.
Eric Lippert
6
Vos chiffres de performance montrent certainement à quel point la politique de mise en cache agressive du DLR est payante. Si votre exemple a fait des choses loufoques, comme par exemple si vous aviez un type de réception différent à chaque fois que vous avez fait l'appel, vous verrez que la version dynamique est très lente lorsqu'elle ne peut pas profiter de son cache de résultats d'analyse précédemment compilés. . Mais quand il peut en profiter, la sainte bonté est toujours rapide.
Eric Lippert
1
Quelque chose de maladroit selon la suggestion d'Eric. Testez en changeant la ligne commentée. 8964 ms contre 814 ms, avec dynamicbien sûr une défaite:public class ONE<T>{public object i { get; set; }public ONE(){i = typeof(T).ToString();}public object make(int ix){ if (ix == 0) return i;ONE<ONE<T>> x = new ONE<ONE<T>>();/*dynamic x = new ONE<ONE<T>>();*/return x.make(ix - 1);}}ONE<END> x = new ONE<END>();string lucky;Stopwatch sw = new Stopwatch();sw.Start();lucky = (string)x.make(500);sw.Stop();Trace.WriteLine(sw.ElapsedMilliseconds);Trace.WriteLine(lucky);
Brian le
1
Soyez juste à la réflexion et créez un délégué à partir de la méthode info:var methodDelegate = (Action)method.CreateDelegate(typeof(Action), foo);
Johnbot