In che modo avere una variabile dinamica influisce sulle prestazioni?


128

Ho una domanda sull'esecuzione di dynamicin C #. ho lettodynamic fa funzionare di nuovo il compilatore, ma cosa fa?

Deve ricompilare l'intero metodo con dynamic variabile utilizzata come parametro o solo quelle righe con comportamento / contesto dinamico?

Ho notato che usando dynamic variabili può rallentare un semplice ciclo per 2 ordini di grandezza.

Codice con cui ho giocato:

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

No, non esegue il compilatore, il che renderebbe la punizione lenta al primo passaggio. Un po 'simile a Reflection ma con un sacco di intelligenza per tenere traccia di ciò che è stato fatto prima per ridurre al minimo le spese generali. "Runtime linguistico dinamico" di Google per ulteriori informazioni. E no, non si avvicinerà mai alla velocità di un loop "nativo".
Hans Passant,

Risposte:


234

Ho letto dinamico fa funzionare di nuovo il compilatore, ma cosa fa. Deve ricompilare l'intero metodo con la dinamica utilizzata come parametro o piuttosto quelle righe con comportamento / contesto dinamico (?)

Ecco l'accordo.

Per ogni espressione nel tuo programma di tipo dinamico, il compilatore emette codice che genera un singolo "oggetto sito di chiamata dinamica" che rappresenta l'operazione. Quindi, ad esempio, se hai:

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

quindi il compilatore genererà codice moralmente simile a questo. (Il codice attuale è un po 'più complesso; questo è semplificato per scopi di presentazione.)

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

Vedi come funziona finora? Generiamo il sito di chiamata una volta , indipendentemente da quante volte chiami M. Il sito di chiamata vive per sempre dopo averlo generato una volta. Il sito di chiamata è un oggetto che rappresenta "qui ci sarà una chiamata dinamica a Foo".

OK, quindi ora che hai il sito di chiamata, come funziona l'invocazione?

Il sito di chiamata fa parte di Dynamic Language Runtime. Il DLR dice "hmm, qualcuno sta tentando di invocare dinamicamente un metodo foo su questo oggetto qui. Ne so qualcosa? No. Allora lo farei meglio a scoprire."

Il DLR interroga quindi l'oggetto in d1 per vedere se è qualcosa di speciale. Forse è un oggetto COM legacy o un oggetto Iron Python o un oggetto Iron Ruby o un oggetto DOM IE. Se non è uno di quelli, allora deve essere un normale oggetto C #.

Questo è il punto in cui il compilatore si riavvia. Non è necessario un lexer o un parser, quindi il DLR avvia una versione speciale del compilatore C # che ha solo l'analizzatore di metadati, l'analizzatore semantico per le espressioni e un emettitore che emette Expression Trees invece di IL.

L'analizzatore di metadati utilizza Reflection per determinare il tipo di oggetto in d1, quindi lo passa all'analizzatore semantico per chiedere cosa succede quando un tale oggetto viene invocato sul metodo Foo. L'analizzatore della risoluzione del sovraccarico lo capisce e quindi crea un albero delle espressioni - proprio come se avessi chiamato Foo in un albero delle espressioni lambda - che rappresenta quella chiamata.

Il compilatore C # quindi restituisce l'albero delle espressioni al DLR insieme a un criterio cache. La politica è di solito "la seconda volta che vedi un oggetto di questo tipo, puoi riutilizzare questo albero delle espressioni invece di richiamarmi di nuovo". Il DLR chiama quindi Compile sull'albero delle espressioni, che richiama il compilatore albero-espressione-IL e sputa un blocco di IL generato dinamicamente in un delegato.

Il DLR quindi memorizza nella cache questo delegato in una cache associata all'oggetto sito di chiamata.

Quindi invoca il delegato e si verifica la chiamata Foo.

La seconda volta che chiami M, abbiamo già un sito di chiamata. Il DLR interroga nuovamente l'oggetto e, se l'oggetto è dello stesso tipo dell'ultima volta, recupera il delegato dalla cache e lo richiama. Se l'oggetto è di un tipo diverso, la cache manca e l'intero processo ricomincia da capo; facciamo un'analisi semantica della chiamata e memorizziamo il risultato nella cache.

Questo accade per ogni espressione che coinvolge dinamica. Ad esempio, se hai:

int x = d1.Foo() + d2;

quindi ci sono tre siti di chiamate dinamiche. Uno per la chiamata dinamica a Foo, uno per l'aggiunta dinamica e uno per la conversione dinamica da dinamico a int. Ognuno ha la propria analisi di runtime e la propria cache di risultati dell'analisi.

Ha senso?


Solo per curiosità, la speciale versione del compilatore senza parser / lexer viene invocata passando un flag speciale allo standard csc.exe?
Roman Royter,

@Eric, posso disturbarti a indicarmi un tuo precedente post sul blog in cui parli di conversioni implicite di short, int, ecc? Come ricordo, hai menzionato lì come / perché l'uso dinamico con Convert.ToXXX provoca l'accensione del compilatore. Sono sicuro di macellare i dettagli, ma spero che tu sappia di cosa sto parlando.
Adam Rackis,

4
@Roman: No. csc.exe è scritto in C ++ e avevamo bisogno di qualcosa che potremmo facilmente chiamare da C #. Inoltre, il compilatore mainline ha i propri oggetti di tipo, ma era necessario poter utilizzare gli oggetti di tipo Reflection. Abbiamo estratto le parti pertinenti del codice C ++ dal compilatore csc.exe e le abbiamo tradotte riga per riga in C #, quindi abbiamo creato una libreria da quella che il DLR può chiamare.
Eric Lippert,

9
@Eric, "Abbiamo estratto le parti rilevanti del codice C ++ dal compilatore csc.exe e le abbiamo tradotte riga per riga in C #", era allora che la gente pensava che valesse la pena di
cercare

5
@ShuggyCoUk: L'idea di avere un compilatore come servizio era in atto da un po 'di tempo, ma in realtà il bisogno di un servizio di runtime per l'analisi del codice è stato un grande impulso verso quel progetto, sì.
Eric Lippert,

108

Aggiornamento: aggiunti benchmark precompilati e compilati in modo pigro

Aggiornamento 2: risulta, mi sbaglio. Vedi il post di Eric Lippert per una risposta completa e corretta. Lascio questo qui per il bene dei numeri di riferimento

* Aggiornamento 3: aggiunti benchmark IL-Emitted e Lazy IL-Emitted, basati sulla risposta di Mark Gravell a questa domanda .

Per quanto ne sappia, l'uso della dynamicparola chiave non causa alcuna compilazione aggiuntiva in fase di esecuzione in sé e per sé (anche se immagino che potrebbe farlo in circostanze specifiche, a seconda del tipo di oggetti che supportano le variabili dinamiche).

Per quanto riguarda le prestazioni, dynamicintroduce intrinsecamente un certo sovraccarico, ma non tanto quanto si potrebbe pensare. Ad esempio, ho appena eseguito un benchmark simile al seguente:

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

Come puoi vedere dal codice, provo a invocare un semplice metodo no-op in sette modi diversi:

  1. Chiamata diretta al metodo
  2. utilizzando dynamic
  3. Per riflessione
  4. Utilizzo di uno Actionche è stato precompilato in fase di esecuzione (escludendo così i tempi di compilazione dai risultati).
  5. Usare un file Actionche viene compilato la prima volta che è necessario, usando una variabile Lazy non thread-safe (includendo quindi il tempo di compilazione)
  6. Utilizzando un metodo generato dinamicamente che viene creato prima del test.
  7. Utilizzando un metodo generato dinamicamente che viene pigramente istanziato durante il test.

Ciascuno viene chiamato 1 milione di volte in un semplice ciclo. Ecco i risultati dei tempi:

Diretto: 3,4248 ms
Dinamico: 45,0728 ms
Riflessione: 888,4011
ms
Precompilato: 21,9166
ms Pigro
Compilato: 30,2045 ms ILEmmesso: 8,4918 ms LazyILEmesso: 14,3483 ms

Quindi, mentre l'utilizzo della dynamicparola chiave richiede un ordine di grandezza più lungo rispetto alla chiamata diretta del metodo, riesce comunque a completare l'operazione un milione di volte in circa 50 millisecondi, rendendolo molto più veloce della riflessione. Se il metodo che chiamiamo cercasse di fare qualcosa di intensivo, come combinare alcune stringhe insieme o cercare un valore in una raccolta, quelle operazioni probabilmente supererebbero di gran lunga la differenza tra una chiamata diretta e una dynamicchiamata.

Le prestazioni sono solo una delle molte buone ragioni per non usarle dynamicinutilmente, ma quando si ha a che fare con dynamicdati reali , possono fornire vantaggi che superano di gran lunga gli svantaggi.

Aggiornamento 4

Sulla base del commento di Johnbot, ho suddiviso l'area Reflection in quattro test separati:

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

... ed ecco i risultati del benchmark:

inserisci qui la descrizione dell'immagine

Quindi, se riesci a predeterminare un metodo specifico che dovrai chiamare molto, invocare un delegato memorizzato nella cache facendo riferimento a quel metodo è veloce quanto chiamare il metodo stesso. Tuttavia, se è necessario determinare quale metodo chiamare mentre si sta per invocarlo, la creazione di un delegato è molto costosa.


2
Una risposta così dettagliata, grazie! Mi chiedevo anche i numeri reali.
Sergey Sirotkin,

4
Bene, il codice dinamico avvia l'importatore di metadati, l'analizzatore semantico e l'emettitore dell'albero delle espressioni del compilatore, quindi esegue un compilatore da albero ad albero sull'output di quello, quindi penso che sia giusto dire che inizia il compilatore in fase di esecuzione. Solo perché non esegue il lexer e il parser difficilmente sembra rilevante.
Eric Lippert,

6
I numeri delle prestazioni mostrano certamente come la politica di memorizzazione nella cache aggressiva del DLR ripaga. Se il tuo esempio ha fatto cose sciocche, come ad esempio se hai avuto un tipo di ricezione diverso ogni volta che hai fatto la chiamata, vedresti che la versione dinamica è molto lenta quando non può sfruttare la sua cache di risultati di analisi precedentemente compilati . Ma quando può trarne vantaggio, la bontà è sempre veloce.
Eric Lippert,

1
Qualcosa di sciocco come suggerito da Eric. Prova scambiando quale riga è commentata. 8964ms contro 814ms, con dynamicovviamente perdita: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
Sii leale alla riflessione e crea un delegato dalle informazioni sul metodo:var methodDelegate = (Action)method.CreateDelegate(typeof(Action), foo);
Johnbot,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.