Kompilierte C # Lambda-Expressionsleistung mit Verschachtelung

c# expression-trees lambda linq

Frage

Betrachtet diese Klasse:

/// <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";
    }
}

Ich versuche, den folgenden Aufruf mit einem kompilierten Lambda-Ausdrucksbaum zu generieren:

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

Allerdings ist die Leistung nicht ganz gleich ...

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

Dies sind die Ergebnisse in Millisekunden auf meinem Computer:

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

Ich verstehe nicht, warum der kompilierte Lambda-Aufruf so langsam ist.

Und ich habe vergessen zu sagen, dass mein Test im Freigabemodus mit der Option "Code optimieren" aktiviert ist.

Update : Changed Benchmarking basierend auf DateTime.Now zu Stopwatch .

Kann jemand den Lambda-Ausdruck optimieren, um eine bessere Leistung im kompilierten Lambda-Aufruf zu erhalten?

Akzeptierte Antwort

Der Test ist aus zwei Gründen ungültig:

DateTime.Now ist nicht genau genug für Micro-Benchmarking- DateTime.Now .

Verwenden Sie stattdessen die Stopwatch . Wenn ich dies tue, erhalte ich die folgenden Ergebnisse (mit MAX = 100000) in Millisekunden:

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

Tatsächlich ist der "direkte Aufruf" schneller als der "Lambda-Aufruf", was sinnvoll ist - der "direkte Aufruf" beinhaltet Aufrufe an einen Delegaten, der direkt auf eine Methode auf einem Parser Objekt verweist. Der "Lambda-Aufruf" erfordert einen Aufruf an einen Delegaten, der auf eine Methode in einem Compiler-generierten Closure-Objekt verweist, das wiederum die Methode für das Parser Objekt aufruft. Diese zusätzliche Indirektion führt zu einer kleinen Geschwindigkeitssteigerung.


Der "Compiled Lambda Call" ist nicht der gleiche wie der "Lambda Call"

Das "Lambda" sieht so aus:

() => parser.ReadInt32()

während das "Compiled Lambda" wie folgt aussieht:

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

Es gibt da noch einen zusätzlichen Schritt: Um den eingebetteten Delegaten für das innere Lambda zu erstellen. In einer engen Schleife ist das teuer.

EDIT :

Ich ging weiter und inspizierte die IL des "Lambda" gegen das "kompilierte Lambda" und dekompilierte sie zurück in "einfacheres" C # (siehe: Anzeigen des IL-Codes, der von einem kompilierten Ausdruck erzeugt wurde ).

Für das "nicht kompilierte" Lambda sieht es so aus:

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

Beachten Sie, dass ein einzelner Delegat einmal erstellt und zwischengespeichert wird.

Für das "kompilierte Lambda" sieht das so aus:

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

Wo das Ziel des Delegierten ist:

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

Beachten Sie, dass, obwohl der "äußere" Delegat nur einmal erstellt und zwischengespeichert wird, ein neuer "innerer" Delegierter bei jeder Iteration der Schleife erstellt wird. Ganz zu schweigen von anderen Zuweisungen für das object Array und die StrongBox<T> -Instanz.


Beliebte Antwort

  1. Der Hauptgrund, warum das kompilierte Lambda langsamer ist, liegt darin, dass der Delegat immer wieder neu erstellt wird. Anonyme Delegierte sind eine besondere Rasse: Sie werden nur an einem Ort verwendet. Der Compiler kann also einige spezielle Optimierungen vornehmen, wie den Wert beim ersten Aufruf des Delegates zwischenspeichern. Das passiert hier.

  2. Ich konnte den großen Unterschied zwischen dem direkten Anruf und dem Lambda-Anruf nicht reproduzieren. In meinen Messungen ist der direkte Anruf etwas schneller.

Wenn Sie solche Benchmarks durchführen, möchten Sie vielleicht einen genaueren Timer verwenden. Die Stoppuhrklasse in System.Diagnostics ist ideal. Sie können auch die Anzahl der Iterationen erhöhen. Der Code läuft nur für einige Millisekunden.

Außerdem verursacht der erste der drei Fälle einen leichten Overhead von der JIT'- Parser Klasse. Versuchen Sie, den ersten Fall zweimal auszuführen und sehen Sie, was passiert. Oder noch besser: Verwenden Sie die Anzahl der Iterationen als Parameter in jeder Methode und rufen Sie jede Methode für 1 Iteration zuerst auf, sodass alle auf einem Level-Playing-Field beginnen.



Lizenziert unter: CC-BY-SA with attribution
Nicht verbunden mit Stack Overflow
Lizenziert unter: CC-BY-SA with attribution
Nicht verbunden mit Stack Overflow