¿De qué manera tener una variable dinámica afecta el rendimiento?

128

Tengo una pregunta sobre el rendimiento de dynamicen C #. He leído dynamicque el compilador vuelve a funcionar, pero ¿qué hace?

¿Tiene que volver a compilar todo el método con la dynamicvariable utilizada como parámetro o solo aquellas líneas con comportamiento / contexto dinámico?

Me di cuenta de que el uso de dynamicvariables puede ralentizar un bucle simple en 2 órdenes de magnitud.

Código con el que he jugado:

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
fuente
No, no ejecuta el compilador, eso haría que castigar lentamente en la primera pasada. Algo similar a Reflection pero con mucha inteligencia para realizar un seguimiento de lo que se hizo antes para minimizar la sobrecarga. Google "Dynamic Language Runtime" para obtener más información. Y no, nunca se acercará a la velocidad de un bucle 'nativo'.
Hans Passant

Respuestas:

234

He leído que el compilador dinámico vuelve a ejecutarse, pero lo que hace. ¿Tiene que recompilar todo el método con la dinámica utilizada como parámetro o más bien aquellas líneas con comportamiento / contexto dinámico (?)

Aquí está el trato.

Para cada expresión en su programa que sea de tipo dinámico, el compilador emite un código que genera un único "objeto de sitio de llamada dinámica" que representa la operación. Entonces, por ejemplo, si tiene:

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

entonces el compilador generará código que es moralmente así. (El código real es bastante más complejo; esto se simplifica para fines de presentación).

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

¿Ves cómo funciona esto hasta ahora? Generamos el sitio de llamada una vez , no importa cuántas veces llame a M. El sitio de llamada vive para siempre después de que lo genera una vez. El sitio de la llamada es un objeto que representa "aquí va a haber una llamada dinámica a Foo".

Bien, ahora que tiene el sitio de la llamada, ¿cómo funciona la invocación?

El sitio de la llamada es parte del Dynamic Language Runtime. El DLR dice "hmm, alguien está intentando hacer una invocación dinámica de un método para este objeto aquí. ¿Sé algo sobre eso? No. Entonces será mejor que lo averigüe".

El DLR luego interroga al objeto en d1 para ver si es algo especial. Tal vez sea un objeto COM heredado, o un objeto Iron Python, o un objeto Iron Ruby, o un objeto IE DOM. Si no es ninguno de ellos, debe ser un objeto C # ordinario.

Este es el punto donde el compilador se inicia nuevamente. No hay necesidad de un analizador o analizador, por lo que el DLR inicia una versión especial del compilador de C # que solo tiene el analizador de metadatos, el analizador semántico para expresiones y un emisor que emite árboles de expresión en lugar de IL.

El analizador de metadatos usa Reflection para determinar el tipo de objeto en d1, y luego lo pasa al analizador semántico para preguntar qué sucede cuando se invoca dicho objeto en el método Foo. El analizador de resolución de sobrecarga se da cuenta de eso, y luego construye un árbol de expresión, como si hubiera llamado a Foo en un árbol de expresión lambda, que representa esa llamada.

El compilador de C # luego pasa el árbol de expresión al DLR junto con una política de caché. La política suele ser "la segunda vez que ve un objeto de este tipo, puede reutilizar este árbol de expresión en lugar de volver a llamarme". El DLR luego llama a Compilar en el árbol de expresión, que invoca el compilador de árbol de expresión a IL y escupe un bloque de IL generado dinámicamente en un delegado.

El DLR luego almacena en caché este delegado en un caché asociado con el objeto del sitio de la llamada.

Luego invoca al delegado y se produce la llamada de Foo.

La segunda vez que llama a M, ya tenemos un sitio de llamadas. El DLR interroga nuevamente al objeto, y si el objeto es del mismo tipo que la última vez, extrae al delegado del caché y lo invoca. Si el objeto es de un tipo diferente, la memoria caché falla y todo el proceso comienza de nuevo; Hacemos un análisis semántico de la llamada y almacenamos el resultado en el caché.

Esto sucede para cada expresión que implica dinámica. Entonces, por ejemplo, si tienes:

int x = d1.Foo() + d2;

entonces hay tres sitios de llamadas dinámicas. Uno para la llamada dinámica a Foo, uno para la adición dinámica y otro para la conversión dinámica de dinámico a int. Cada uno tiene su propio análisis de tiempo de ejecución y su propio caché de resultados de análisis.

¿Tener sentido?

Eric Lippert
fuente
Solo por curiosidad, la versión especial del compilador sin analizador / lexer se invoca pasando una bandera especial al estándar csc.exe?
Roman Royter
@Eric, ¿puedo molestarte para que me señales una publicación de blog tuya anterior donde hables sobre conversiones implícitas de short, int, etc.? Como recuerdo, mencionaste allí cómo / por qué usar Dynamic con Convert.ToXXX hace que el compilador se inicie. Estoy seguro de que estoy descifrando los detalles, pero espero que sepas de lo que estoy hablando.
Adam Rackis
44
@Roman: No. csc.exe está escrito en C ++, y necesitábamos algo que pudiéramos llamar fácilmente desde C #. Además, el compilador de línea principal tiene sus propios objetos de tipo, pero necesitábamos poder usar objetos de tipo Reflection. Extrajimos las partes relevantes del código C ++ del compilador csc.exe y las tradujimos línea por línea a C #, y luego construimos una biblioteca a partir de eso para que la DLR llame.
Eric Lippert
9
@Eric, "Extrajimos las partes relevantes del código de C ++ del compilador csc.exe y las tradujimos línea por línea a C #". Fue entonces cuando la gente pensó que valía la pena perseguir a Roslyn :)
ShuggyCoUk
55
@ShuggyCoUk: La idea de tener un compilador como servicio había estado dando vueltas por algún tiempo, pero en realidad la necesidad de un servicio de tiempo de ejecución para codificar el análisis fue un gran ímpetu hacia ese proyecto, sí.
Eric Lippert
108

Actualización: Se agregaron puntos de referencia precompilados y compilados de manera diferida

Actualización 2: Resulta que estoy equivocado. Vea la publicación de Eric Lippert para obtener una respuesta completa y correcta. Dejo esto aquí por el bien de los números de referencia

* Actualización 3: Se agregaron puntos de referencia IL-Emitted y Lazy IL-Emitted, según la respuesta de Mark Gravell a esta pregunta .

Que yo sepa, el uso de la dynamicpalabra clave no causa ninguna compilación adicional en tiempo de ejecución en sí misma (aunque imagino que podría hacerlo en circunstancias específicas, dependiendo de qué tipo de objetos respaldan sus variables dinámicas).

Con respecto al rendimiento, dynamicinherentemente introduce algo de sobrecarga, pero no tanto como podría pensar. Por ejemplo, acabo de ejecutar un punto de referencia que se ve así:

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

Como puede ver en el código, trato de invocar un método simple sin operación de siete maneras diferentes:

  1. Llamada al método directo
  2. Utilizando dynamic
  3. Por reflexión
  4. Usando un Actionprecompilado en tiempo de ejecución (excluyendo así el tiempo de compilación de los resultados).
  5. Usando una Actionque se compila la primera vez que se necesita, usando una variable Lazy no segura para subprocesos (incluyendo el tiempo de compilación)
  6. Usando un método generado dinámicamente que se crea antes de la prueba.
  7. Usando un método generado dinámicamente que se instancia perezosamente durante la prueba.

Cada uno se llama 1 millón de veces en un bucle simple. Aquí están los resultados de tiempo:

Directo: 3.4248ms
Dinámico: 45.0728ms
Reflexión: 888.4011ms
Precompilado: 21.9166ms
LazyCompiled: 30.2045ms
ILEmitted: 8.4918ms
LazyILEmitmit: 14.3483ms

Entonces, aunque usar la dynamicpalabra clave toma un orden de magnitud más largo que llamar al método directamente, aún logra completar la operación un millón de veces en aproximadamente 50 milisegundos, por lo que es mucho más rápido que la reflexión. Si el método que llamamos intentara hacer algo intensivo, como combinar algunas cadenas o buscar un valor en una colección, esas operaciones probablemente superarían con creces la diferencia entre una llamada directa y una dynamicllamada.

El rendimiento es solo una de las muchas buenas razones para no usar dynamicinnecesariamente, pero cuando se trata de dynamicdatos reales , puede proporcionar ventajas que superan con creces las desventajas.

Actualización 4

Basado en el comentario de Johnbot, dividí el área de Reflection en cuatro pruebas separadas:

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

... y aquí están los resultados de referencia:

ingrese la descripción de la imagen aquí

Entonces, si puede predeterminar un método específico que necesitará llamar mucho, invocar a un delegado en caché que se refiera a ese método es tan rápido como llamar al método en sí. Sin embargo, si necesita determinar a qué método llamar justo cuando está a punto de invocarlo, crear un delegado es muy costoso.

StriplingWarrior
fuente
2
Una respuesta tan detallada, gracias! Me preguntaba sobre los números reales también.
Sergey Sirotkin
44
Bueno, el código dinámico inicia el importador de metadatos, el analizador semántico y el emisor del árbol de expresión del compilador, y luego ejecuta un compilador de árbol de expresión a il en la salida de eso, por lo que creo que es justo decir que comienza el compilador en tiempo de ejecución. Solo porque no ejecuta el lexer y el analizador apenas parece relevante.
Eric Lippert
66
Sus números de rendimiento ciertamente muestran cómo vale la pena la política agresiva de almacenamiento en caché del DLR. Si su ejemplo hiciera cosas tontas, como por ejemplo, si tuviera un tipo de recepción diferente cada vez que realizara la llamada, vería que la versión dinámica es muy lenta cuando no puede aprovechar su caché de resultados de análisis previamente compilados . Pero cuando puede aprovechar eso, la bondad santa es siempre rápida.
Eric Lippert
1
Algo tonto según la sugerencia de Eric. Pruebe cambiando la línea que se comenta. 8964 ms frente a 814 ms, por dynamicsupuesto con pérdida: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
Sea justo con la reflexión y cree un delegado a partir de la información del método:var methodDelegate = (Action)method.CreateDelegate(typeof(Action), foo);
Johnbot