Como ter uma variável dinâmica afeta o desempenho?

127

Eu tenho uma pergunta sobre o desempenho do dynamicem c #. Eu li que dynamicfaz o compilador funcionar novamente, mas o que ele faz?

Ele precisa recompilar todo o método com a dynamicvariável usada como parâmetro ou apenas as linhas com comportamento / contexto dinâmico?

Notei que o uso de dynamicvariáveis ​​pode retardar um loop for simples em 2 ordens de magnitude.

Código com o qual joguei:

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
fonte
Não, ele não roda o compilador, o que tornaria a punição lenta na primeira passagem. Um pouco semelhante ao Reflection, mas com muita inteligência para acompanhar o que foi feito antes para minimizar a sobrecarga. Google "dynamic language runtime" do Google para mais informações. E não, nunca chegará à velocidade de um loop 'nativo'.
Hans Passant

Respostas:

233

Eu li dinâmico que faz o compilador rodar novamente, mas o que ele faz. É necessário recompilar todo o método com a dinâmica usada como parâmetro ou melhor, as linhas com comportamento / contexto dinâmico (?)

Aqui está o acordo.

Para cada expressão no seu programa que é do tipo dinâmico, o compilador emite código que gera um único "objeto de site de chamada dinâmica" que representa a operação. Então, por exemplo, se você tiver:

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

o compilador gerará um código moralmente semelhante a este. (O código real é um pouco mais complexo; isso é simplificado para fins de apresentação.)

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

Veja como isso funciona até agora? Geramos o site de chamada uma vez , não importa quantas vezes você chame M. O site de chamada permanece para sempre depois que você o gera uma vez. O site de chamada é um objeto que representa "haverá uma chamada dinâmica para Foo aqui".

OK, agora que você recebeu o site de chamada, como funciona a chamada?

O site de chamada faz parte do Dynamic Language Runtime. O DLR diz "hmm, alguém está tentando fazer uma invocação dinâmica de um método para esse objeto aqui. Eu sei alguma coisa sobre isso? Não. Então é melhor descobrir."

O DLR, em seguida, interroga o objeto em d1 para ver se é algo especial. Talvez seja um objeto COM herdado, ou um objeto Iron Python, ou um objeto Iron Ruby, ou um objeto DOM do IE. Se não for um desses, deve ser um objeto C # comum.

Este é o ponto em que o compilador é iniciado novamente. Não há necessidade de um lexer ou analisador; portanto, o DLR inicia uma versão especial do compilador C # que possui apenas o analisador de metadados, o analisador semântico para expressões e um emissor que emite Árvores de expressão em vez de IL.

O analisador de metadados usa o Reflection para determinar o tipo do objeto em d1 e depois o passa para o analisador semântico para perguntar o que acontece quando esse objeto é invocado no método Foo. O analisador de resolução de sobrecarga descobre isso e cria uma árvore de expressão - como se você tivesse chamado Foo em uma árvore de expressão lambda - que representa essa chamada.

O compilador C # então passa essa árvore de expressão de volta para o DLR junto com uma política de cache. A política geralmente é "a segunda vez que você vê um objeto desse tipo, pode reutilizar essa árvore de expressão em vez de me ligar de volta". O DLR chama Compile na árvore de expressão, que invoca o compilador expressão-árvore-para-IL e cospe um bloco de IL gerada dinamicamente em um delegado.

O DLR armazena em cache esse delegado em um cache associado ao objeto do site de chamada.

Em seguida, ele invoca o delegado e a chamada Foo acontece.

Na segunda vez em que você ligar para M, já temos um site de chamadas. O DLR interroga o objeto novamente e, se o objeto for do mesmo tipo da última vez, buscará o delegado no cache e o chamará. Se o objeto é de um tipo diferente, o cache falha e todo o processo é iniciado novamente; fazemos análise semântica da chamada e armazenamos o resultado no cache.

Isso acontece para todas as expressões que envolvem dinâmico. Então, por exemplo, se você tiver:

int x = d1.Foo() + d2;

existem três sites de chamadas dinâmicas. Um para a chamada dinâmica para Foo, um para a adição dinâmica e outro para a conversão dinâmica de dinâmico para int. Cada um tem sua própria análise de tempo de execução e seu próprio cache de resultados de análise.

Faz sentido?

Eric Lippert
fonte
Por curiosidade, a versão especial do compilador sem analisador / lexer é invocada ao passar um sinalizador especial para o csc.exe padrão?
Roman Royter 20/09/11
@ Eric, posso incomodá-lo em me indicar um post anterior do seu blog, onde você fala sobre conversões implícitas de curtas, int, etc? Pelo que me lembro, você mencionou lá como / por que usar dinâmico com o Convert.ToXXX faz com que o compilador seja iniciado. Tenho certeza de que estou analisando os detalhes, mas espero que você saiba do que estou falando.
precisa saber é o seguinte
4
@ Roman: Não. O csc.exe está escrito em C ++, e precisávamos de algo que poderíamos chamar facilmente de C #. Além disso, o compilador da linha principal possui seus próprios objetos de tipo, mas é necessário poder usar objetos do tipo Reflexão. Extraímos as partes relevantes do código C ++ do compilador csc.exe e as traduzimos linha a linha em C # e, em seguida, construímos uma biblioteca para que o DLR chame.
Eric Lippert
9
@Eric, "Extraímos as partes relevantes do código ++ do compilador csc.exe C e traduzido-los linha por linha em C #" foi sobre, em seguida, as pessoas pensavam Roslyn pode ser vale a pena perseguir :)
ShuggyCoUk
5
@ShuggyCoUk: A idéia de ter um compilador como serviço já estava em andamento há algum tempo, mas realmente precisar de um serviço de tempo de execução para fazer a análise de código foi um grande impulso para esse projeto, sim.
Eric Lippert
107

Atualização: Adicionados benchmarks pré-compilados e preguiçosos

Atualização 2: Acontece que eu estou errado. Veja a publicação de Eric Lippert para obter uma resposta completa e correta. Estou deixando isso aqui por causa dos números de referência

* Atualização 3: Adicionados parâmetros de referência emitidos por IL e preguiçosos emitidos por IL, com base na resposta de Mark Gravell a esta pergunta .

Que eu saiba, o uso da dynamicpalavra - chave não causa nenhuma compilação extra em tempo de execução por si só (embora eu imagine que poderia fazê-lo em circunstâncias específicas, dependendo do tipo de objeto que está apoiando suas variáveis ​​dinâmicas).

Em relação ao desempenho, dynamicintroduz inerentemente alguma sobrecarga, mas não tanto quanto você imagina. Por exemplo, eu apenas executei uma referência parecida com esta:

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 você pode ver no código, tento invocar um método simples não operacional de sete maneiras diferentes:

  1. Chamada de método direta
  2. Usando dynamic
  3. Pela reflexão
  4. Usando um Actionque foi pré-compilado em tempo de execução (excluindo assim o tempo de compilação dos resultados).
  5. Usando um Actionque é compilado na primeira vez em que é necessário, usando uma variável Lazy não segura para thread (incluindo o tempo de compilação)
  6. Usando um método gerado dinamicamente que é criado antes do teste.
  7. Usando um método gerado dinamicamente que é instanciado preguiçosamente durante o teste.

Cada um é chamado 1 milhão de vezes em um loop simples. Aqui estão os resultados do tempo:

Direto: 3.4248ms
Dinâmico: 45.0728ms
Reflexo: 888.4011ms
Pré-compilado: 21.9166ms
PreguiçosoCompilado: 30.2045ms
ILEmitido: 8.4918ms
PreguiçosoILEmitido: 14.3483ms

Portanto, enquanto o uso da dynamicpalavra-chave leva uma ordem de magnitude maior que a chamada direta do método, ele ainda consegue concluir a operação um milhão de vezes em cerca de 50 milissegundos, tornando-a muito mais rápida que a reflexão. Se o método que chamamos estava tentando fazer algo intensivo, como combinar algumas cadeias ou procurar um valor em uma coleção, essas operações provavelmente superariam em muito a diferença entre uma chamada direta e uma dynamicchamada.

O desempenho é apenas uma das muitas boas razões para não usar dynamicdesnecessariamente, mas quando você lida com dynamicdados reais , pode oferecer vantagens que superam as desvantagens.

Atualização 4

Baseado no comentário de Johnbot, dividi a área de reflexão em quatro testes separados:

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

... e aqui estão os resultados de referência:

insira a descrição da imagem aqui

Portanto, se você pode predeterminar um método específico que precisará chamar muito, chamar um delegado em cache que se refere a esse método é tão rápido quanto chamar o próprio método. No entanto, se você precisar determinar qual método chamar, assim que estiver prestes a invocá-lo, a criação de um representante para ele será muito cara.

StriplingWarrior
fonte
2
Uma resposta tão detalhada, obrigado! Eu estava pensando sobre os números reais também.
Sergey Sirotkin 20/09/11
4
Bem, o código dinâmico inicia o importador de metadados, o analisador semântico e o emissor da árvore de expressão do compilador e, em seguida, executa um compilador expressão-árvore-para-il na saída disso, então acho que é justo dizer que ele começa o compilador em tempo de execução. Só porque ele não executa o lexer e o analisador dificilmente parece relevante.
Eric Lippert
6
Seus números de desempenho certamente mostram como a política de cache agressivo do DLR compensa. Se o seu exemplo for ridículo, como, por exemplo, se você tiver um tipo de recebimento diferente toda vez que fizer a chamada, verá que a versão dinâmica é muito lenta quando não pode tirar proveito do cache de resultados de análises compiladas anteriormente . Mas quando se pode tirar proveito disso, santa bondade é sempre rápida.
Eric Lippert
1
Algo pateta conforme a sugestão de Eric. Teste trocando a linha comentada. 8964ms vs 814ms, com dynamicobviamente perdendo: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
Seja justo com reflexão e criar um delegado do método info:var methodDelegate = (Action)method.CreateDelegate(typeof(Action), foo);
Johnbot