Prestazioni di espressione lambda in C # compilata con imbricatura

c# expression-trees lambda linq

Domanda

Considerando questa 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";
    }
}

Provo a generare la seguente chiamata con un albero di espressione lambda compilato:

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

Tuttavia, il peformance non è esattamente lo stesso ...

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);
    }
}

Questi sono i risultati in millisecondi sul mio computer:

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

Non capisco perché la chiamata lambda compilata sia così lenta.

E ho dimenticato di dire che il mio test viene eseguito in modalità di rilascio con l'opzione "Codice ottimizzato" abilitata.

Aggiornamento : benchmarking modificato basato su DateTime.Now su Stopwatch .

Qualcuno sa come modificare l'espressione lambda per ottenere prestazioni migliori nella chiamata lambda compilata?

Risposta accettata

Il test non è valido per due motivi:

DateTime.Now non è abbastanza preciso per i test di micro-benchmarking.

Utilizzare invece la classe Stopwatch . Quando lo faccio, ottengo i seguenti risultati (usando MAX = 100000), in millisecondi:

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

In effetti, la "chiamata diretta" è più veloce della "chiamata lambda", il che ha senso: la "chiamata diretta" implica chiamate a un delegato che fa riferimento direttamente a un metodo su un oggetto Parser . La "chiamata lambda" richiede una chiamata a un delegato che fa riferimento a un metodo su un oggetto di chiusura generato dal compilatore, che a sua volta chiama il metodo sull'oggetto Parser . Questo extraindirizzamento extra introduce un minore bump di velocità.


La "chiamata lambda compilata" non è uguale alla "chiamata lambda"

Il "Lambda" assomiglia a questo:

() => parser.ReadInt32()

mentre il "Compiled lambda" assomiglia a questo:

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

C'è un ulteriore passo in là: creare il delegato incorporato per il lambda interno. In un ciclo stretto, questo è costoso.

MODIFICA :

Sono andato avanti e ho ispezionato l'IL del "lambda" vs il "lambda compilato" e li ho decompilati al "più semplice" C # (vedi: Visualizzazione del codice IL generato da un'espressione compilata ).

Per il lambda "non compilato", sembra questo:

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);
}

Si noti che un singolo delegato viene creato una volta e memorizzato nella cache.

Mentre per il "lambda compilato", sembra che questo:

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

Dove l'obiettivo del delegato è:

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));
}

Si noti che sebbene il delegato "esterno" venga creato solo una volta e memorizzato nella cache, viene creato un nuovo delegato "interno" a ogni iterazione del ciclo. Per non parlare di altre allocazioni per l'array di object e l' StrongBox<T> .


Risposta popolare

  1. Il motivo principale per cui il lambda compilato è più lento è perché il delegato viene creato più e più volte. I delegati anonimi sono una razza speciale: vengono utilizzati solo in una posizione. Quindi il compilatore può fare alcune ottimizzazioni speciali, come il caching del valore la prima volta che viene chiamato il delegato. Questo è ciò che sta accadendo qui.

  2. Non ero in grado di riprodurre la grande differenza tra la chiamata diretta e la chiamata lambda. In effetti, nelle mie misurazioni la chiamata diretta è leggermente più veloce.

Quando fai benchmark come questo, potresti voler usare un timer più accurato. La classe di cronometro in System.Diagnostics è l'ideale. Potresti anche voler aumentare il numero di iterazioni. Il codice viene eseguito solo per pochi millisecondi.

Inoltre, il primo dei tre casi subirà un lieve overhead da parte di JIT'ing the Parser . Prova a eseguire il primo caso due volte e guarda cosa succede. O meglio: usa il numero di iterazioni come parametro in ogni metodo, e chiama prima ciascun metodo per 1 iterazione, in modo che tutti inizino su un campo di gioco in piano.



Autorizzato sotto: CC-BY-SA with attribution
Non affiliato con Stack Overflow
È legale questo KB? Sì, impara il perché
Autorizzato sotto: CC-BY-SA with attribution
Non affiliato con Stack Overflow
È legale questo KB? Sì, impara il perché