Performances d'expression lambda C # compilées avec imbrication

c# expression-trees lambda linq

Question

Considérant cette classe:

/// <summary>
/// Dummy implementation of a parser for the purpose of the test
/// </summary>
class Parser
{
    public List<T> ReadList<T>(Func<T> readFunctor)
    {
        return Enumerable.Range(0, 10).Select(i => readFunctor()).ToList();
    }

    public int ReadInt32()
    {
        return 12;
    }

    public string ReadString()
    {
        return "string";
    }
}

J'essaie de générer l'appel suivant avec un arbre d'expression lambda compilé:

Parser parser = new Parser();
List<int> list = parser.ReadList(parser.ReadInt32);

Cependant, la performance n'est pas tout à fait la même ...

class Program
{
    private const int MAX = 1000000;

    static void Main(string[] args)
    {
        DirectCall();
        LambdaCall();
        CompiledLambdaCall();
    }

    static void DirectCall()
    {
        Parser parser = new Parser();
        var sw = new Stopwatch();
        sw.Start();
        for (int i = 0; i < MAX; i++)
        {
            List<int> list = parser.ReadList(parser.ReadInt32);
        }
        sw.Stop();
        Console.WriteLine("Direct call: {0} ms", sw.ElapsedMilliseconds);
    }

    static void LambdaCall()
    {
        Parser parser = new Parser();
        var sw = new Stopwatch();
        sw.Start();
        for (int i = 0; i < MAX; i++)
        {
            List<int> list = parser.ReadList(() => parser.ReadInt32());
        }
        sw.Stop();
        Console.WriteLine("Lambda call: {0} ms", sw.ElapsedMilliseconds);
    }

    static void CompiledLambdaCall()
    {
        var parserParameter = Expression.Parameter(typeof(Parser), "parser");

        var lambda = Expression.Lambda<Func<Parser, List<int>>>(
            Expression.Call(
                parserParameter,
                typeof(Parser).GetMethod("ReadList").MakeGenericMethod(typeof(int)),
                Expression.Lambda(
                    typeof(Func<int>),
                    Expression.Call(
                        parserParameter,
                        typeof(Parser).GetMethod("ReadInt32")))),
            parserParameter);
        Func<Parser, List<int>> func = lambda.Compile();

        Parser parser = new Parser();
        var sw = new Stopwatch();
        sw.Start();
        for (int i = 0; i < MAX; i++)
        {
            List<int> list = func(parser);
        }
        sw.Stop();
        Console.WriteLine("Compiled lambda call: {0} ms", sw.ElapsedMilliseconds);
    }
}

Voici les résultats en millisecondes sur mon ordinateur:

Direct call:          647 ms
Lambda call:          641 ms
Compiled lambda call: 5861 ms

Je ne comprends pas pourquoi l'appel lambda compilé est si lent.

Et j'ai oublié de dire que mon test est exécuté en mode validation avec l'option "Optimiser le code" activée.

Mise à jour : modification de l'analyse comparative basée sur DateTime.Now en Stopwatch .

Est-ce que quelqu'un sait comment peaufiner l'expression lambda pour obtenir de meilleures performances dans l'appel lambda compilé?

Réponse acceptée

Le test est invalide pour deux raisons:

DateTime.Now n'est pas assez précis pour la micro-analyse comparative de tests courts.

Utilisez plutôt la classe Stopwatch . Lorsque je le fais, j'obtiens les résultats suivants (en utilisant MAX = 100000), en millisecondes:

Lambda call: 86.3196
Direct call: 74.057
Compiled lambda call: 814.2178

En effet, "l'appel direct" est plus rapide que "l'appel lambda", ce qui a du sens - cet "appel direct" implique des appels à un délégué qui fait directement référence à une méthode sur un objet Parser . "L'appel lambda" nécessite un appel à un délégué qui fait référence à une méthode sur un objet de fermeture généré par le compilateur, qui à son tour appelle la méthode sur l'objet Parser . Cette indirection supplémentaire introduit un ralentissement mineur.


"L'appel lambda compilé" n'est pas le même que "l'appel Lambda"

Le "Lambda" ressemble à ceci:

() => parser.ReadInt32()

alors que le "Compiled lambda" ressemble à ceci:

parser => parser.ReadList(() => parser.ReadInt32())

Il existe une étape supplémentaire: créer le délégué incorporé pour le lambda intérieur. Dans une boucle serrée, cela coûte cher.

EDIT :

Je suis allé de l'avant et j'ai inspecté l'IL du "lambda" par rapport au "lambda compilé" et je les ai décompilés en C "plus simple" (voir: Visualisation du code IL généré à partir d'une expression compilée ).

Pour le lambda "non compilé", cela ressemble à ceci:

for (int i = 0; i < 100000; i++)
{
    if (CS$<>9__CachedAnonymousMethodDelegate1 == null)
    {
        CS$<>9__CachedAnonymousMethodDelegate1 = new Func<int>(CS$<>8__locals3.<LambdaCall>b__0);
    }

    CS$<>8__locals3.parser.ReadList<int>(CS$<>9__CachedAnonymousMethodDelegate1);
}

Notez qu'un seul délégué est créé une fois et mis en cache.

Alors que pour le "lambda compilé", cela ressemble à ceci:

Func<Parser, List<int>> func = lambda.Compile();
Parser parser = new Parser();
for (int i = 0; i < 100000; i++)
{
    func(parser);
}

Où la cible du délégué est:

public static List<int> Foo(Parser parser)
{
    object[] objArray = new object[] { new StrongBox<Parser>(parser) };
    return ((StrongBox<Parser>) objArray[0]).Value.ReadList<int>
      (new Func<int>(dyn_type.<ExpressionCompilerImplementationDetails>{1}lambda_method));
}

Notez que bien que le délégué "externe" ne soit créé qu'une seule fois et mis en cache, un nouveau délégué "interne" est créé à chaque itération de la boucle. Sans parler d'autres allocations pour le tableau d' object et l' StrongBox<T> .


Réponse populaire

  1. La principale raison pour laquelle le lambda compilé est plus lent est parce que le délégué est créé à maintes reprises. Les délégués anonymes sont une race spéciale: ils ne sont utilisés qu’à un seul endroit. Ainsi, le compilateur peut effectuer certaines optimisations spéciales, telles que la mise en cache de la valeur lors de la première appel du délégué. C'est ce qui se passe ici.

  2. Je n'ai pas été capable de reproduire la grande différence entre l'appel direct et l'appel lambda. En fait, dans mes mesures, l’appel direct est légèrement plus rapide.

Lorsque vous utilisez des repères comme celui-ci, vous souhaiterez peut-être utiliser un chronomètre plus précis. La classe Stopwatch dans System.Diagnostics est idéale. Vous voudrez peut-être aussi augmenter votre nombre d'itérations. Le code en tant que tel ne fonctionne que pendant quelques millisecondes.

En outre, le premier des trois cas encourra une légère surcharge de JIT'ing la Parser classe. Essayez d'exécuter le premier cas deux fois et voyez ce qui se passe. Ou mieux encore: utilisez le nombre d'itérations en tant que paramètre dans chaque méthode et appelez chaque méthode en premier pour une itération, afin qu'elles commencent toutes sur un terrain de jeu égal.




Sous licence: CC-BY-SA with attribution
Non affilié à Stack Overflow
Est-ce KB légal? Oui, apprenez pourquoi
Sous licence: CC-BY-SA with attribution
Non affilié à Stack Overflow
Est-ce KB légal? Oui, apprenez pourquoi