Wie wirkt sich eine dynamische Variable auf die Leistung aus?

127

Ich habe eine Frage zur Leistung von dynamicin C #. Ich habe gelesen, dynamicdass der Compiler wieder ausgeführt wird, aber was macht er?

Muss die gesamte Methode mit der dynamicals Parameter verwendeten Variablen neu kompiliert werden oder nur mit den Zeilen mit dynamischem Verhalten / Kontext?

Ich habe festgestellt, dass die Verwendung von dynamicVariablen eine einfache for-Schleife um 2 Größenordnungen verlangsamen kann.

Code, mit dem ich gespielt habe:

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
quelle
Nein, der Compiler wird nicht ausgeführt, wodurch die Bestrafung beim ersten Durchgang langsam wird. Etwas ähnlich wie Reflection, aber mit vielen intelligenten Funktionen, um zu verfolgen, was zuvor getan wurde, um den Overhead zu minimieren. Google "Dynamic Language Runtime" für mehr Einblick. Und nein, es wird niemals die Geschwindigkeit einer "nativen" Schleife erreichen.
Hans Passant

Antworten:

232

Ich habe dynamisch gelesen, dass der Compiler wieder ausgeführt wird, aber was er tut. Muss die gesamte Methode mit der als Parameter verwendeten Dynamik neu kompiliert werden oder vielmehr mit den Zeilen mit dynamischem Verhalten / Kontext (?)

Das ist der Deal.

Für jeden Ausdruck in Ihrem Programm, der vom dynamischen Typ ist, gibt der Compiler Code aus, der ein einzelnes "dynamisches Aufrufstandortobjekt" generiert, das die Operation darstellt. So zum Beispiel, wenn Sie haben:

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

dann generiert der Compiler Code, der moralisch so ist. (Der eigentliche Code ist etwas komplexer; dies wird zu Präsentationszwecken vereinfacht.)

class C
{
    static DynamicCallSite FooCallSite;
    void M()
    {
        object d1 = whatever;
        object d2;
        if (FooCallSite == null) FooCallSite = new DynamicCallSite();
        d2 = FooCallSite.DoInvocation("Foo", d1);

Sehen Sie, wie das bisher funktioniert? Wir generieren die Anrufstelle einmal , egal wie oft Sie M anrufen. Die Anrufstelle lebt für immer, nachdem Sie sie einmal generiert haben. Die Call-Site ist ein Objekt, das darstellt, dass "hier ein dynamischer Aufruf von Foo stattfinden wird".

OK, jetzt, wo Sie die Anrufseite haben, wie funktioniert der Aufruf?

Die Call-Site ist Teil der Dynamic Language Runtime. Das DLR sagt: "Hmm, jemand versucht, eine dynamische Methode für dieses Objekt aufzurufen. Weiß ich etwas darüber? Nein. Dann sollte ich es besser herausfinden."

Das DLR fragt dann das Objekt in d1 ab, um festzustellen, ob es etwas Besonderes ist. Möglicherweise handelt es sich um ein älteres COM-Objekt oder ein Iron Python-Objekt oder ein Iron Ruby-Objekt oder ein IE DOM-Objekt. Wenn es keines davon ist, muss es ein gewöhnliches C # -Objekt sein.

Dies ist der Punkt, an dem der Compiler erneut gestartet wird. Es ist kein Lexer oder Parser erforderlich, daher startet das DLR eine spezielle Version des C # -Compilers, die nur den Metadatenanalysator, den semantischen Analysator für Ausdrücke und einen Emitter enthält, der Ausdrucksbäume anstelle von IL ausgibt.

Der Metadatenanalysator verwendet Reflection, um den Typ des Objekts in d1 zu bestimmen, und übergibt diesen dann an den semantischen Analysator, um zu fragen, was passiert, wenn ein solches Objekt mit der Methode Foo aufgerufen wird. Der Überlastungsauflösungsanalysator ermittelt dies und erstellt dann einen Ausdrucksbaum - so als hätten Sie Foo in einem Ausdrucksbaum-Lambda aufgerufen -, der diesen Aufruf darstellt.

Der C # -Compiler übergibt diesen Ausdrucksbaum dann zusammen mit einer Cache-Richtlinie an das DLR zurück. Die Richtlinie lautet normalerweise "Wenn Sie ein Objekt dieses Typs zum zweiten Mal sehen, können Sie diesen Ausdrucksbaum wiederverwenden, anstatt mich erneut zurückzurufen". Das DLR ruft dann Compile für den Ausdrucksbaum auf, der den Ausdrucksbaum-zu-IL-Compiler aufruft und einen Block dynamisch generierter IL in einem Delegaten ausspuckt.

Das DLR speichert diesen Delegaten dann in einem Cache zwischen, der dem Aufrufstandortobjekt zugeordnet ist.

Dann ruft es den Delegaten auf und der Foo-Aufruf erfolgt.

Wenn Sie M zum zweiten Mal anrufen, haben wir bereits eine Anrufstelle. Das DLR fragt das Objekt erneut ab. Wenn das Objekt vom selben Typ ist wie beim letzten Mal, holt es den Delegaten aus dem Cache und ruft ihn auf. Wenn das Objekt von einem anderen Typ ist, fehlt der Cache und der gesamte Prozess beginnt von vorne. Wir führen eine semantische Analyse des Aufrufs durch und speichern das Ergebnis im Cache.

Dies geschieht für jeden Ausdruck , der dynamisch ist. Also zum Beispiel, wenn Sie haben:

int x = d1.Foo() + d2;

Dann gibt es drei dynamische Anrufseiten. Eine für den dynamischen Aufruf von Foo, eine für die dynamische Addition und eine für die dynamische Konvertierung von dynamisch nach int. Jeder hat seine eigene Laufzeitanalyse und seinen eigenen Cache mit Analyseergebnissen.

Sinn ergeben?

Eric Lippert
quelle
Aus Neugier wird die spezielle Compiler-Version ohne Parser / Lexer aufgerufen, indem ein spezielles Flag an die Standard-csc.exe übergeben wird.
Roman Royter
@Eric, kann ich Sie beunruhigen, mich auf einen früheren Blog-Beitrag von Ihnen zu verweisen, in dem Sie über implizite Konvertierungen von short, int usw. sprechen? Wie ich mich erinnere, haben Sie dort erwähnt, wie / warum die Verwendung von Dynamic mit Convert.ToXXX dazu führt, dass der Compiler gestartet wird. Ich bin sicher, ich schlachte die Details ab, aber hoffentlich wissen Sie, wovon ich spreche.
Adam Rackis
4
@Roman: Nein. Csc.exe ist in C ++ geschrieben und wir brauchten etwas, das wir leicht von C # aus aufrufen konnten. Der Mainline-Compiler hat auch eigene Typobjekte, aber wir mussten in der Lage sein, Reflection-Typobjekte zu verwenden. Wir haben die relevanten Teile des C ++ - Codes aus dem csc.exe-Compiler extrahiert, sie zeilenweise in C # übersetzt und daraus eine Bibliothek erstellt, die das DLR aufrufen kann.
Eric Lippert
9
@Eric „extrahiert wir die relevanten Teile des C ++ Code aus dem csc.exe Compiler und übersetzt sie line-by-line in C #“ ging es um dann Leute dachten , Roslyn erstrebenswert sein könnte :)
ShuggyCoUk
5
@ShuggyCoUk: Die Idee, einen Compiler-as-a-Service zu haben, hatte sich schon seit einiger Zeit herumgesprochen, aber tatsächlich einen Laufzeitdienst für die Codeanalyse zu benötigen, war ein großer Impuls für dieses Projekt, ja.
Eric Lippert
107

Update: Vorkompilierte und faul kompilierte Benchmarks hinzugefügt

Update 2: Es stellt sich heraus, ich liege falsch. Eine vollständige und korrekte Antwort finden Sie in Eric Lipperts Beitrag. Ich lasse dies hier wegen der Benchmark-Zahlen

* Update 3: IL-Emitted- und Lazy IL-Emitted-Benchmarks hinzugefügt, basierend auf Mark Gravells Antwort auf diese Frage .

Meines Wissens führt die Verwendung des dynamicSchlüsselworts zur Laufzeit an und für sich nicht zu einer zusätzlichen Kompilierung (obwohl ich mir vorstellen kann, dass dies unter bestimmten Umständen möglich ist, je nachdem, welche Art von Objekten Ihre dynamischen Variablen unterstützen).

In Bezug auf die Leistung führt dynamicdies von Natur aus zu einem gewissen Overhead, jedoch bei weitem nicht so viel, wie Sie vielleicht denken. Zum Beispiel habe ich gerade einen Benchmark erstellt, der so aussieht:

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

Wie Sie dem Code entnehmen können, versuche ich, eine einfache No-Op-Methode auf sieben verschiedene Arten aufzurufen:

  1. Direkter Methodenaufruf
  2. Verwenden von dynamic
  3. Durch Reflexion
  4. Verwenden eines Action, das zur Laufzeit vorkompiliert wurde (wodurch die Kompilierungszeit von den Ergebnissen ausgeschlossen wird).
  5. Verwenden einer Action, die beim ersten Mal kompiliert wird, wenn sie benötigt wird, Verwenden einer nicht threadsicheren Lazy-Variablen (einschließlich Kompilierungszeit)
  6. Verwenden einer dynamisch generierten Methode, die vor dem Test erstellt wird.
  7. Verwenden einer dynamisch generierten Methode, die während des Tests träge instanziiert wird.

Jeder wird in einer einfachen Schleife 1 Million Mal aufgerufen. Hier sind die Timing-Ergebnisse:

Direkt: 3,4248 ms
Dynamisch: 45,0728 ms
Reflexion: 888,4011 ms
Vorkompiliert: 21,9166
ms
LazyCompiled: 30,2045
ms ILEmitted: 8,4918 ms LazyILEmitted: 14,3483 ms

Während die Verwendung des dynamicSchlüsselworts eine Größenordnung länger dauert als der direkte Aufruf der Methode, gelingt es ihm dennoch, den Vorgang millionenfach in etwa 50 Millisekunden abzuschließen, was ihn weitaus schneller als die Reflexion macht. Wenn die von uns aufgerufene Methode versuchen würde, etwas Intensives zu tun, z. B. einige Zeichenfolgen miteinander zu kombinieren oder eine Sammlung nach einem Wert zu durchsuchen, würden diese Operationen wahrscheinlich den Unterschied zwischen einem direkten Aufruf und einem dynamicAufruf bei weitem überwiegen .

Die Leistung ist nur einer von vielen guten Gründen, sie nicht dynamicunnötig zu verwenden. Wenn Sie jedoch mit echten dynamicDaten arbeiten, kann sie Vorteile bieten, die die Nachteile bei weitem überwiegen.

Update 4

Basierend auf Johnbots Kommentar habe ich den Reflexionsbereich in vier separate Tests unterteilt:

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

... und hier sind die Benchmark-Ergebnisse:

Geben Sie hier die Bildbeschreibung ein

Wenn Sie also eine bestimmte Methode vorgeben können, die Sie häufig aufrufen müssen, ist das Aufrufen eines zwischengespeicherten Delegaten, der auf diese Methode verweist, ungefähr so ​​schnell wie das Aufrufen der Methode selbst. Wenn Sie jedoch festlegen müssen, welche Methode aufgerufen werden soll, während Sie sie aufrufen, ist das Erstellen eines Delegaten dafür sehr teuer.

StriplingWarrior
quelle
2
Solch eine detaillierte Antwort, danke! Ich habe mich auch über die tatsächlichen Zahlen gewundert.
Sergey Sirotkin
4
Nun, dynamischer Code startet den Metadatenimporter, den semantischen Analysator und den Ausdrucksbaum-Emitter des Compilers und führt dann einen Ausdrucksbaum-zu-Il-Compiler für die Ausgabe aus. Ich denke also, es ist fair zu sagen, dass er startet Starten Sie den Compiler zur Laufzeit. Nur weil es den Lexer nicht ausführt und der Parser kaum relevant erscheint.
Eric Lippert
6
Ihre Leistungszahlen zeigen sicherlich, wie sich die aggressive Caching-Politik des DLR auszahlt. Wenn Ihr Beispiel doofe Dinge getan hat, wie zum Beispiel, wenn Sie jedes Mal, wenn Sie den Anruf getätigt haben, einen anderen Empfangstyp hatten, werden Sie feststellen, dass die dynamische Version sehr langsam ist, wenn sie den Cache der zuvor kompilierten Analyseergebnisse nicht nutzen kann . Aber wenn es das ausnutzen kann , ist es immer schnell.
Eric Lippert
1
Etwas doof nach Erics Vorschlag. Testen Sie, indem Sie die kommentierte Zeile austauschen. 8964ms vs 814ms, mit dynamicnatürlich verlieren: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
1
Seien Sie fair zu reflektieren und erstellen Sie einen Delegierten aus den Methodeninformationen:var methodDelegate = (Action)method.CreateDelegate(typeof(Action), foo);
Johnbot