Как влияет динамическая переменная на производительность?

У меня есть вопрос о производительности dynamic в С#. Я прочитал dynamic заставляет компилятор работать снова, но что он делает?

Должен ли он перекомпилировать весь метод с dynamic переменной, используемой в качестве параметра, или только те строки с динамическим поведением/контекстом?

Я заметил, что использование dynamic переменных может замедлить простой цикл for на 2 порядка.

Код, с которым я играл:

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

Ответ 1

Я читаю динамический, заставляет компилятор работать снова, но что он делает. Нужно ли перекомпилировать весь метод с динамикой, используемой в качестве параметра или, скорее, с линиями с динамическим поведением/контекстом (?)

Здесь сделка.

Для каждого выражения в вашей программе, имеющего динамический тип, компилятор испускает код, который генерирует один "объект сайта динамического вызова", который представляет операцию. Так, например, если у вас есть:

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

тогда компилятор будет генерировать код, который морально подобен этому. (Фактический код немного сложнее, это упрощено для целей презентации.)

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

Посмотрите, как это работает до сих пор? Мы генерируем сайт вызова один раз, независимо от того, сколько раз вы вызываете M. Звонящий сайт живет вечно после того, как вы его генерируете один раз. Сайт вызова - это объект, который представляет собой "динамический вызов Foo здесь".

ОК, так что теперь, когда у вас есть сайт вызова, как работает вызов?

Сайт вызова является частью времени выполнения динамического языка. DLR говорит: "Хм, кто-то пытается сделать динамический вызов метода foo на этом объекте. Знаю ли я что-нибудь об этом? Нет. Тогда мне лучше узнать".

Затем DLR запрашивает объект в d1, чтобы убедиться, что это что-то особенное. Возможно, это устаревший COM-объект или объект Iron Python, или объект Iron Ruby, или объект IE DOM. Если это не любой из них, то это должен быть обычный объект С#.

Это тот момент, когда компилятор снова запускается. Нет необходимости в лексере или синтаксическом анализаторе, поэтому DLR запускает специальную версию компилятора С#, у которой есть только анализатор метаданных, семантический анализатор для выражений и эмиттер, который испускает деревья выражений вместо IL.

Анализатор метаданных использует Reflection для определения типа объекта в d1, а затем передает его семантическому анализатору, чтобы спросить, что происходит, когда такой объект вызывается методом Foo. Анализатор разрешения перегрузки показывает это, а затем создает дерево выражений - так же, как если бы вы вызвали Foo в дереве выражений lambda -, который представляет этот вызов.

Затем компилятор С# передает это дерево выражений обратно в DLR вместе с политикой кэширования. Обычно эта политика "во второй раз, когда вы видите объект такого типа, вы можете повторно использовать это дерево выражений вместо того, чтобы перезвонить мне снова". Затем DLR вызывает компиляцию в дереве выражений, которая вызывает компилятор expression-tree-to-IL и выплескивает блок динамически сгенерированного IL в делегате.

Затем DLR кэширует этот делегат в кеше, связанном с объектом сайта вызова.

Затем он вызывает делегата, и происходит вызов Foo.

Во второй раз, когда вы вызываете M, у нас уже есть сайт вызова. DLR снова запрашивает объект, и если объект тот же тип, что и в прошлый раз, он извлекает делегат из кэша и вызывает его. Если объект имеет другой тип, тогда кеш промахивается, и весь процесс начинается снова; мы выполняем семантический анализ вызова и сохраняем результат в кеше.

Это происходит для каждого выражения, которое включает динамическое. Например, если у вас есть:

int x = d1.Foo() + d2;

тогда есть три сайта динамических вызовов. Один для динамического вызова Foo, один для динамического добавления и один для динамического преобразования из динамического в int. Каждый из них имеет свой собственный анализ времени выполнения и собственный кеш результатов анализа.

Имеют смысл?

Ответ 2

Обновление: добавлены предварительно скомпилированные и ленивые скомпилированные тесты

Обновление 2: Оказывается, я ошибаюсь. См. Сообщение Эрика Липперта для полного и правильного ответа. Я оставляю это здесь ради эталонных номеров

* Обновление 3: Добавлены тесты IL-Emited и Lazy IL-Emitted, основанные на Mark Gravell, отвечая на этот вопрос.

Насколько мне известно, использование ключевого слова dynamic не приводит к какой-либо дополнительной компиляции во время выполнения и само по себе (хотя я предполагаю, что он может сделать это при определенных обстоятельствах, в зависимости от того, какие типы объектов поддерживают ваши динамические переменные).

Что касается производительности, dynamic по сути вводит некоторые накладные расходы, но не так сильно, как вы думаете. Например, я просто запустил тест, который выглядит следующим образом:

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

Как вы можете видеть из кода, я пытаюсь использовать простой метод no-op по-разному:

  • Прямой вызов метода
  • Использование dynamic
  • Отражением
  • Использование Action, которое было предварительно скомпилировано во время выполнения (таким образом исключая время компиляции из результатов).
  • Используя Action, который скомпилируется в первый раз, когда это необходимо, используйте не зависящую от потока Lazy переменную (включающую время компиляции)
  • Использование динамически созданного метода, созданного перед тестом.
  • Использование динамически генерируемого метода, который лениво создается при тестировании.

Каждый вызов называется 1 миллион раз в простом цикле. Вот результаты синхронизации:

Прямой: 3.4248ms
Динамический: 45.0728мс
Отражение: 888.4011ms
Предварительно скомпилировано: 21.9166мс
LazyCompiled: 30.2045ms
Отказано: 8.4918мс
LazyILEmitted: 14.3483мс

Таким образом, при использовании ключевого слова dynamic выполняется на порядок больше времени, чем вызов метода напрямую, ему все же удается выполнить операцию миллион раз примерно за 50 миллисекунд, делая ее намного быстрее, чем отражение. Если метод, который мы называем, пытается сделать что-то интенсивное, например, объединение нескольких строк или поиск коллекции для значения, эти операции, вероятно, намного перевесят разницу между прямым вызовом и вызовом dynamic.

Производительность - лишь одна из многих причин не использовать dynamic без необходимости, но когда вы имеете дело с действительно dynamic данными, это может обеспечить преимущества, которые намного перевешивают недостатки.

Обновление 4

Основываясь на комментарии Джонбота, я разбил область отражения на четыре отдельных теста:

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

... и вот результаты теста:

введите описание изображения здесь

Итак, если вы можете предопределить конкретный метод, который вам нужно будет вызвать много, вызов кэшированного делегата, ссылающегося на этот метод, примерно так же быстро, как вызов самого метода. Однако, если вам нужно определить, какой метод вызывать так, как вы собираетесь его вызывать, создание делегата для него очень дорого.