Performances des expressions Lambda C # compilées

91

Considérez la manipulation simple suivante sur une collection:

static List<int> x = new List<int>() { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
var result = x.Where(i => i % 2 == 0).Where(i => i > 5);

Utilisons maintenant des expressions. Le code suivant est à peu près équivalent:

static void UsingLambda() {
    Func<IEnumerable<int>, IEnumerable<int>> lambda = l => l.Where(i => i % 2 == 0).Where(i => i > 5);
    var t0 = DateTime.Now.Ticks;
    for (int j = 1; j < MAX; j++) 
        var sss = lambda(x).ToList();

    var tn = DateTime.Now.Ticks;
    Console.WriteLine("Using lambda: {0}", tn - t0);
}

Mais je veux créer l'expression à la volée, voici donc un nouveau test:

static void UsingCompiledExpression() {
    var f1 = (Expression<Func<IEnumerable<int>, IEnumerable<int>>>)(l => l.Where(i => i % 2 == 0));
    var f2 = (Expression<Func<IEnumerable<int>, IEnumerable<int>>>)(l => l.Where(i => i > 5));
    var argX = Expression.Parameter(typeof(IEnumerable<int>), "x");
    var f3 = Expression.Invoke(f2, Expression.Invoke(f1, argX));
    var f = Expression.Lambda<Func<IEnumerable<int>, IEnumerable<int>>>(f3, argX);

    var c3 = f.Compile();

    var t0 = DateTime.Now.Ticks;
    for (int j = 1; j < MAX; j++) 
        var sss = c3(x).ToList();

    var tn = DateTime.Now.Ticks;
    Console.WriteLine("Using lambda compiled: {0}", tn - t0);
}

Bien sûr, ce n'est pas exactement comme ce qui précède, donc pour être honnête, je modifie légèrement le premier:

static void UsingLambdaCombined() {
    Func<IEnumerable<int>, IEnumerable<int>> f1 = l => l.Where(i => i % 2 == 0);
    Func<IEnumerable<int>, IEnumerable<int>> f2 = l => l.Where(i => i > 5);
    Func<IEnumerable<int>, IEnumerable<int>> lambdaCombined = l => f2(f1(l));
    var t0 = DateTime.Now.Ticks;
    for (int j = 1; j < MAX; j++) 
        var sss = lambdaCombined(x).ToList();

    var tn = DateTime.Now.Ticks;
    Console.WriteLine("Using lambda combined: {0}", tn - t0);
}

Viennent maintenant les résultats pour MAX = 100000, VS2008, débogage ON:

Using lambda compiled: 23437500
Using lambda:           1250000
Using lambda combined:  1406250

Et avec le débogage désactivé:

Using lambda compiled: 21718750
Using lambda:            937500
Using lambda combined:  1093750

Surprise . L'expression compilée est environ 17 fois plus lente que les autres alternatives. Maintenant, voici les questions:

  1. Est-ce que je compare des expressions non équivalentes?
  2. Existe-t-il un mécanisme pour que .NET «optimise» l'expression compilée?
  3. Comment exprimer le même appel en chaîne par l.Where(i => i % 2 == 0).Where(i => i > 5);programme?

Quelques statistiques supplémentaires. Visual Studio 2010, débogage activé, optimisations désactivées:

Using lambda:           1093974
Using lambda compiled: 15315636
Using lambda combined:   781410

Débogage ON, optimisations ON:

Using lambda:            781305
Using lambda compiled: 15469839
Using lambda combined:   468783

Débogage OFF, optimisations ON:

Using lambda:            625020
Using lambda compiled: 14687970
Using lambda combined:   468765

Nouvelle surprise. Le passage de VS2008 (C # 3) à VS2010 (C # 4), rend le UsingLambdaCombinedplus rapide que le lambda natif.


Ok, j'ai trouvé un moyen d'améliorer les performances compilées lambda de plus d'un ordre de grandeur. Voici une astuce; après l'exécution du profileur, 92% du temps est consacré à:

System.Reflection.Emit.DynamicMethod.CreateDelegate(class System.Type, object)

Hmmmm ... Pourquoi crée-t-il un nouveau délégué à chaque itération? Je ne suis pas sûr, mais la solution suit dans un article séparé.

Hugo Sereno Ferreira
la source
3
Ces minutages ne sont-ils pas exécutés dans Visual Studio? Si tel est le cas, répétez les minutages en utilisant une version en mode Release et exécutez sans débogage (c'est-à-dire Ctrl + F5 dans Visual Studio ou à partir de la ligne de commande). Pensez également à utiliser Stopwatchpour les horaires plutôt que pour DateTime.Now.
Jim Mischel le
12
Je ne sais pas pourquoi c'est plus lent, mais votre technique de référence n'est pas très bonne. Tout d'abord, DateTime.Now n'est précis qu'à 1/64 de seconde, donc votre erreur d'arrondi de mesure est importante. Utilisez plutôt Chronomètre; il est précis à quelques nanosecondes. Deuxièmement, vous mesurez à la fois le temps de jit le code (le premier appel) et chaque appel suivant; cela peut faire baisser les moyennes. (Bien que dans ce cas, un MAX de cent mille soit probablement suffisant pour faire la moyenne du fardeau de la jit, c'est quand même une mauvaise pratique de l'inclure dans la moyenne.)
Eric Lippert
7
@Eric, l'erreur d'arrondi ne peut être présente que si dans chaque opération DateTime.Now.Ticks est utilisé, avant le début et après la fin, le nombre de millisecondes est suffisamment élevé pour montrer la différence de performances.
Akash Kava
1
si vous utilisez un chronomètre, je recommande de suivre cet article pour garantir des résultats précis: codeproject.com/KB/testing/stopwatch-measure-precise.aspx
Zach Green
1
@Eric, même si je suis d'accord que ce n'est pas la technique de mesure la plus précise disponible, nous parlons d'un ordre de grandeur de différence. MAX est suffisamment élevé pour réduire les écarts importants.
Hugo Sereno Ferreira

Réponses:

43

Se pourrait-il que les lambdas internes ne soient pas compilés?!? Voici une preuve de concept:

static void UsingCompiledExpressionWithMethodCall() {
        var where = typeof(Enumerable).GetMember("Where").First() as System.Reflection.MethodInfo;
        where = where.MakeGenericMethod(typeof(int));
        var l = Expression.Parameter(typeof(IEnumerable<int>), "l");
        var arg0 = Expression.Parameter(typeof(int), "i");
        var lambda0 = Expression.Lambda<Func<int, bool>>(
            Expression.Equal(Expression.Modulo(arg0, Expression.Constant(2)),
                             Expression.Constant(0)), arg0).Compile();
        var c1 = Expression.Call(where, l, Expression.Constant(lambda0));
        var arg1 = Expression.Parameter(typeof(int), "i");
        var lambda1 = Expression.Lambda<Func<int, bool>>(Expression.GreaterThan(arg1, Expression.Constant(5)), arg1).Compile();
        var c2 = Expression.Call(where, c1, Expression.Constant(lambda1));

        var f = Expression.Lambda<Func<IEnumerable<int>, IEnumerable<int>>>(c2, l);

        var c3 = f.Compile();

        var t0 = DateTime.Now.Ticks;
        for (int j = 1; j < MAX; j++)
        {
            var sss = c3(x).ToList();
        }

        var tn = DateTime.Now.Ticks;
        Console.WriteLine("Using lambda compiled with MethodCall: {0}", tn - t0);
    }

Et maintenant, les horaires sont:

Using lambda:                            625020
Using lambda compiled:                 14687970
Using lambda combined:                   468765
Using lambda compiled with MethodCall:   468765

Woot! Non seulement il est rapide, mais il est plus rapide que le lambda natif. ( Tête à gratter ).


Bien sûr, le code ci-dessus est tout simplement trop pénible à écrire. Faisons de la magie simple:

static void UsingCompiledConstantExpressions() {
    var f1 = (Func<IEnumerable<int>, IEnumerable<int>>)(l => l.Where(i => i % 2 == 0));
    var f2 = (Func<IEnumerable<int>, IEnumerable<int>>)(l => l.Where(i => i > 5));
    var argX = Expression.Parameter(typeof(IEnumerable<int>), "x");
    var f3 = Expression.Invoke(Expression.Constant(f2), Expression.Invoke(Expression.Constant(f1), argX));
    var f = Expression.Lambda<Func<IEnumerable<int>, IEnumerable<int>>>(f3, argX);

    var c3 = f.Compile();

    var t0 = DateTime.Now.Ticks;
    for (int j = 1; j < MAX; j++) {
        var sss = c3(x).ToList();
    }

    var tn = DateTime.Now.Ticks;
    Console.WriteLine("Using lambda compiled constant: {0}", tn - t0);
}

Et quelques timings, VS2010, optimisations ON, débogage OFF:

Using lambda:                            781260
Using lambda compiled:                 14687970
Using lambda combined:                   468756
Using lambda compiled with MethodCall:   468756
Using lambda compiled constant:          468756

Vous pouvez maintenant affirmer que je ne génère pas l'expression entière de manière dynamique; juste les invocations de chaînage. Mais dans l'exemple ci-dessus, je génère l'expression entière. Et les horaires correspondent. Ceci est juste un raccourci pour écrire moins de code.


D'après ce que j'ai compris, ce qui se passe, c'est que la méthode .Compile () ne propage pas les compilations aux lambdas internes, et donc l'invocation constante de CreateDelegate. Mais pour vraiment comprendre cela, je serais ravi d'avoir un commentaire d'un gourou .NET sur les choses internes en cours.

Et pourquoi , oh pourquoi est-ce maintenant plus rapide qu'un lambda natif !?

Hugo Sereno Ferreira
la source
1
Je pense en acceptant ma propre réponse, car c'est celle qui a le plus de voix. Dois-je attendre encore un peu?
Hugo Sereno Ferreira
À propos de ce qui se passe lorsque vous obtenez du code plus rapidement que le lambda natif, vous pouvez consulter cette page sur les microbenchmarks (qui n'a rien de vraiment spécifique à Java, malgré le nom): code.google.com/p/caliper/wiki / JavaMicrobenchmarks
Blaisorblade
Quant à savoir pourquoi le lambda compilé dynamiquement est plus rapide, je soupçonne que "utiliser lambda", exécuté en premier, est pénalisé par le fait d'avoir à JIT du code.
Oskar Berggren
Je ne sais pas ce qui se passe, une fois que j'ai testé l'expression compilée et créé un délégué pour définir et obtenir des champs et des propriétés, createdelegate était beaucoup plus rapide pour les propriétés, mais la compilation était très légèrement plus rapide pour les champs
nawfal
10

Récemment, j'ai posé une question presque identique:

Performances de l'expression compilée pour déléguer

La solution pour moi était que je ne devais pas appeler Compilele Expression, mais que je devais l'appeler CompileToMethodet le compiler Expressionen une staticméthode dans un assembly dynamique.

Ainsi:

var assemblyBuilder = AppDomain.CurrentDomain.DefineDynamicAssembly(
  new AssemblyName("MyAssembly_" + Guid.NewGuid().ToString("N")), 
  AssemblyBuilderAccess.Run);

var moduleBuilder = assemblyBuilder.DefineDynamicModule("Module");

var typeBuilder = moduleBuilder.DefineType("MyType_" + Guid.NewGuid().ToString("N"), 
  TypeAttributes.Public));

var methodBuilder = typeBuilder.DefineMethod("MyMethod", 
  MethodAttributes.Public | MethodAttributes.Static);

expression.CompileToMethod(methodBuilder);

var resultingType = typeBuilder.CreateType();

var function = Delegate.CreateDelegate(expression.Type,
  resultingType.GetMethod("MyMethod"));

Ce n'est cependant pas idéal. Je ne suis pas tout à fait certain à quels types cela s'applique exactement, mais je pense que les types qui sont pris comme paramètres par le délégué ou renvoyés par le délégué doivent être publicet non génériques. Il doit être non générique parce que les types génériques accèdent apparemment System.__Canonqui est un type interne utilisé par .NET sous le capot pour les types génériques et cela viole la publicrègle "doit être une règle de type).

Pour ces types, vous pouvez utiliser le plus lent Compile. Je les détecte de la manière suivante:

private static bool IsPublicType(Type t)
{

  if ((!t.IsPublic && !t.IsNestedPublic) || t.IsGenericType)
  {
    return false;
  }

  int lastIndex = t.FullName.LastIndexOf('+');

  if (lastIndex > 0)
  {
    var containgTypeName = t.FullName.Substring(0, lastIndex);

    var containingType = Type.GetType(containgTypeName + "," + t.Assembly);

    if (containingType != null)
    {
      return containingType.IsPublic;
    }

    return false;
  }
  else
  {
    return t.IsPublic;
  }
}

Mais comme je l'ai dit, ce n'est pas l'idéal et j'aimerais quand même savoir pourquoi la compilation d'une méthode en un assemblage dynamique est parfois un ordre de grandeur plus rapide. Et je dis parfois parce que j'ai aussi vu des cas où un Expressioncompilé avec Compileest tout aussi rapide qu'une méthode normale. Voir ma question pour cela.

Ou si quelqu'un connaît un moyen de contourner la publiccontrainte «pas de non- types» avec l'assembly dynamique, c'est également le bienvenu.

JulianR
la source
4

Vos expressions ne sont pas équivalentes et vous obtenez ainsi des résultats biaisés. J'ai écrit un banc d'essai pour tester cela. Les tests incluent l'appel lambda régulier, l'expression compilée équivalente, une expression compilée équivalente faite à la main, ainsi que des versions composées. Ces chiffres devraient être plus précis. Fait intéressant, je ne vois pas beaucoup de variation entre les versions simples et composées. Et les expressions compilées sont naturellement plus lentes mais seulement de très peu. Vous avez besoin d'une entrée et d'un nombre d'itérations suffisamment importants pour obtenir de bons nombres. Cela fait une différence.

En ce qui concerne votre deuxième question, je ne sais pas comment vous pourriez en tirer plus de performances, donc je ne peux pas vous aider. Ça a l'air aussi beau que ça va l'être.

Vous trouverez ma réponse à votre troisième question dans la HandMadeLambdaExpression()méthode. Ce n'est pas l'expression la plus facile à construire en raison des méthodes d'extension, mais c'est faisable.

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

using System.Diagnostics;
using System.Linq.Expressions;

namespace ExpressionBench
{
    class Program
    {
        static void Main(string[] args)
        {
            var values = Enumerable.Range(0, 5000);
            var lambda = GetLambda();
            var lambdaExpression = GetLambdaExpression().Compile();
            var handMadeLambdaExpression = GetHandMadeLambdaExpression().Compile();
            var composed = GetComposed();
            var composedExpression = GetComposedExpression().Compile();
            var handMadeComposedExpression = GetHandMadeComposedExpression().Compile();

            DoTest("Lambda", values, lambda);
            DoTest("Lambda Expression", values, lambdaExpression);
            DoTest("Hand Made Lambda Expression", values, handMadeLambdaExpression);
            Console.WriteLine();
            DoTest("Composed", values, composed);
            DoTest("Composed Expression", values, composedExpression);
            DoTest("Hand Made Composed Expression", values, handMadeComposedExpression);
        }

        static void DoTest<TInput, TOutput>(string name, TInput sequence, Func<TInput, TOutput> operation, int count = 1000000)
        {
            for (int _ = 0; _ < 1000; _++)
                operation(sequence);
            var sw = Stopwatch.StartNew();
            for (int _ = 0; _ < count; _++)
                operation(sequence);
            sw.Stop();
            Console.WriteLine("{0}:", name);
            Console.WriteLine("  Elapsed: {0,10} {1,10} (ms)", sw.ElapsedTicks, sw.ElapsedMilliseconds);
            Console.WriteLine("  Average: {0,10} {1,10} (ms)", decimal.Divide(sw.ElapsedTicks, count), decimal.Divide(sw.ElapsedMilliseconds, count));
        }

        static Func<IEnumerable<int>, IList<int>> GetLambda()
        {
            return v => v.Where(i => i % 2 == 0).Where(i => i > 5).ToList();
        }

        static Expression<Func<IEnumerable<int>, IList<int>>> GetLambdaExpression()
        {
            return v => v.Where(i => i % 2 == 0).Where(i => i > 5).ToList();
        }

        static Expression<Func<IEnumerable<int>, IList<int>>> GetHandMadeLambdaExpression()
        {
            var enumerableMethods = typeof(Enumerable).GetMethods();
            var whereMethod = enumerableMethods
                .Where(m => m.Name == "Where")
                .Select(m => m.MakeGenericMethod(typeof(int)))
                .Where(m => m.GetParameters()[1].ParameterType == typeof(Func<int, bool>))
                .Single();
            var toListMethod = enumerableMethods
                .Where(m => m.Name == "ToList")
                .Select(m => m.MakeGenericMethod(typeof(int)))
                .Single();

            // helpers to create the static method call expressions
            Func<Expression, ParameterExpression, Func<ParameterExpression, Expression>, Expression> WhereExpression =
                (instance, param, body) => Expression.Call(whereMethod, instance, Expression.Lambda(body(param), param));
            Func<Expression, Expression> ToListExpression =
                instance => Expression.Call(toListMethod, instance);

            //return v => v.Where(i => i % 2 == 0).Where(i => i > 5).ToList();
            var exprParam = Expression.Parameter(typeof(IEnumerable<int>), "v");
            var expr0 = WhereExpression(exprParam,
                Expression.Parameter(typeof(int), "i"),
                i => Expression.Equal(Expression.Modulo(i, Expression.Constant(2)), Expression.Constant(0)));
            var expr1 = WhereExpression(expr0,
                Expression.Parameter(typeof(int), "i"),
                i => Expression.GreaterThan(i, Expression.Constant(5)));
            var exprBody = ToListExpression(expr1);
            return Expression.Lambda<Func<IEnumerable<int>, IList<int>>>(exprBody, exprParam);
        }

        static Func<IEnumerable<int>, IList<int>> GetComposed()
        {
            Func<IEnumerable<int>, IEnumerable<int>> composed0 =
                v => v.Where(i => i % 2 == 0);
            Func<IEnumerable<int>, IEnumerable<int>> composed1 =
                v => v.Where(i => i > 5);
            Func<IEnumerable<int>, IList<int>> composed2 =
                v => v.ToList();
            return v => composed2(composed1(composed0(v)));
        }

        static Expression<Func<IEnumerable<int>, IList<int>>> GetComposedExpression()
        {
            Expression<Func<IEnumerable<int>, IEnumerable<int>>> composed0 =
                v => v.Where(i => i % 2 == 0);
            Expression<Func<IEnumerable<int>, IEnumerable<int>>> composed1 =
                v => v.Where(i => i > 5);
            Expression<Func<IEnumerable<int>, IList<int>>> composed2 =
                v => v.ToList();
            var exprParam = Expression.Parameter(typeof(IEnumerable<int>), "v");
            var exprBody = Expression.Invoke(composed2, Expression.Invoke(composed1, Expression.Invoke(composed0, exprParam)));
            return Expression.Lambda<Func<IEnumerable<int>, IList<int>>>(exprBody, exprParam);
        }

        static Expression<Func<IEnumerable<int>, IList<int>>> GetHandMadeComposedExpression()
        {
            var enumerableMethods = typeof(Enumerable).GetMethods();
            var whereMethod = enumerableMethods
                .Where(m => m.Name == "Where")
                .Select(m => m.MakeGenericMethod(typeof(int)))
                .Where(m => m.GetParameters()[1].ParameterType == typeof(Func<int, bool>))
                .Single();
            var toListMethod = enumerableMethods
                .Where(m => m.Name == "ToList")
                .Select(m => m.MakeGenericMethod(typeof(int)))
                .Single();

            Func<ParameterExpression, Func<ParameterExpression, Expression>, Expression> LambdaExpression =
                (param, body) => Expression.Lambda(body(param), param);
            Func<Expression, ParameterExpression, Func<ParameterExpression, Expression>, Expression> WhereExpression =
                (instance, param, body) => Expression.Call(whereMethod, instance, Expression.Lambda(body(param), param));
            Func<Expression, Expression> ToListExpression =
                instance => Expression.Call(toListMethod, instance);

            var composed0 = LambdaExpression(Expression.Parameter(typeof(IEnumerable<int>), "v"),
                v => WhereExpression(
                    v,
                    Expression.Parameter(typeof(int), "i"),
                    i => Expression.Equal(Expression.Modulo(i, Expression.Constant(2)), Expression.Constant(0))));
            var composed1 = LambdaExpression(Expression.Parameter(typeof(IEnumerable<int>), "v"),
                v => WhereExpression(
                    v,
                    Expression.Parameter(typeof(int), "i"),
                    i => Expression.GreaterThan(i, Expression.Constant(5))));
            var composed2 = LambdaExpression(Expression.Parameter(typeof(IEnumerable<int>), "v"),
                v => ToListExpression(v));

            var exprParam = Expression.Parameter(typeof(IEnumerable<int>), "v");
            var exprBody = Expression.Invoke(composed2, Expression.Invoke(composed1, Expression.Invoke(composed0, exprParam)));
            return Expression.Lambda<Func<IEnumerable<int>, IList<int>>>(exprBody, exprParam);
        }
    }
}

Et les résultats sur ma machine:

Lambda:
  Temps écoulé: 340971948 123230 (ms)
  Moyenne: 340.971948 0.12323 (ms)
Expression Lambda:
  Temps écoulé: 357077202 129051 (ms)
  Moyenne: 357.077202 0.129051 (ms)
Expression Lambda faite à la main:
  Temps écoulé: 345029281 124696 (ms)
  Moyenne: 345.029281 0.124696 (ms)

Composé:
  Temps écoulé: 340409238 123027 (ms)
  Moyenne: 340.409238 0.123027 (ms)
Expression composée:
  Temps écoulé: 350800599 126782 (ms)
  Moyenne: 350.800599 0.126782 (ms)
Expression composée à la main:
  Temps écoulé: 352811359 127509 (ms)
  Moyenne: 352.811359 0.127509 (ms)
Jeff Mercado
la source
3

Les performances lambda compilées sur les délégués peuvent être plus lentes car le code compilé lors de l'exécution peut ne pas être optimisé, mais le code que vous avez écrit manuellement et celui compilé via le compilateur C # est optimisé.

Deuxièmement, plusieurs expressions lambda signifient plusieurs méthodes anonymes, et l'appel de chacune d'elles prend peu de temps supplémentaire par rapport à l'évaluation d'une méthode simple. Par exemple, appeler

Console.WriteLine(x);

et

Action x => Console.WriteLine(x);
x(); // this means two different calls..

sont différents, et avec une seconde, un peu plus de surcharge est nécessaire car du point de vue du compilateur, il s'agit en fait de deux appels différents. Appelez d'abord x lui-même, puis dans l'instruction d'appel de x.

Ainsi, votre Lambda combiné aura certainement peu de performances lentes par rapport à une seule expression lambda.

Et cela est indépendant de ce qui s'exécute à l'intérieur, car vous évaluez toujours la logique correcte, mais vous ajoutez des étapes supplémentaires à exécuter par le compilateur.

Même après la compilation de l'arbre d'expression, il n'aura pas d'optimisation, et il conservera toujours sa petite structure complexe, son évaluation et son appel peuvent avoir une validation supplémentaire, une vérification nulle, etc., ce qui pourrait ralentir les performances des expressions lambda compilées.

Akash Kava
la source
2
Si vous regardez de plus près, le UsingLambdaCombinedtest combine plusieurs fonctions lambda et ses performances sont très proches de UsingLambda. Concernant les optimisations, j'étais convaincu qu'elles étaient gérées par le moteur JIT, et donc le code généré à l'exécution (après compilation), serait également la cible de toute optimisation JIT.
Hugo Sereno Ferreira
1
L'optimisation JIT et l'optimisation du temps de compilation sont deux choses différentes que vous pouvez désactiver l'optimisation du temps de compilation dans les paramètres du projet. Deuxièmement, la compilation d'expressions émettra probablement un MSIL dynamique qui sera encore un peu plus lent car sa logique et sa séquence d'opération contiendront des vérifications nulles et une validité selon les besoins. Vous pouvez regarder dans le réflecteur comment il est compilé.
Akash Kava le
2
Bien que votre raisonnement soit solide, je ne suis pas d'accord avec vous sur ce problème particulier (c'est-à-dire que la différence d'ordre de grandeur n'est pas due à la compilation statique). Premièrement, parce que si vous désactivez réellement les optimisations au moment de la compilation, la différence est encore considérable. Deuxièmement, parce que j'ai déjà trouvé un moyen d'optimiser la génération dynamique pour qu'elle soit légèrement plus lente. Laissez-moi essayer de comprendre "pourquoi" et je publierai les résultats.
Hugo Sereno Ferreira