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

128

У меня вопрос по производительности dynamicв C #. Я читал, 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));
    }
Лукаш Мадон
источник
Нет, он не запускает компилятор, это сильно замедлит его на первом проходе. Немного похоже на Reflection, но с множеством умных способностей, позволяющих отслеживать, что было сделано раньше, чтобы минимизировать накладные расходы. Google "среда выполнения динамического языка" для большего понимания. И нет, никогда не приблизится к скорости «родного» цикла.
Ханс Пассан
также см. stackoverflow.com/questions/3784317/…
nawfal

Ответы:

235

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

Вот сделка.

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

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 для этого объекта here. Знаю ли я что-нибудь об этом? Нет. Тогда мне лучше выяснить».

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

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

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

Затем компилятор C # передает это дерево выражений обратно в DLR вместе с политикой кэширования. Политика обычно такова: «когда вы второй раз видите объект этого типа, вы можете повторно использовать это дерево выражений, а не перезванивать мне». Затем DLR вызывает Compile для дерева выражений, который вызывает компилятор дерева выражений в IL и выдает блок динамически сгенерированного IL в делегате.

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

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

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

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

int x = d1.Foo() + d2;

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

Есть смысл?

Эрик Липперт
источник
Просто из любопытства вызывается специальная версия компилятора без парсера / лексера, передавая специальный флаг стандартному csc.exe?
Роман Ройтер,
@ Эрик, могу я попросить вас указать мне на предыдущий пост в вашем блоге, где вы говорите о неявных преобразованиях short, int и т. Д.? Насколько я помню, вы упомянули там, как / почему использование dynamic с Convert.ToXXX вызывает запуск компилятора. Я уверен, что не могу не отметить детали, но, надеюсь, вы понимаете, о чем я говорю.
Адам Рэкис,
4
@Roman: Нет. Csc.exe написан на C ++, и нам нужно было что-то, что мы могли бы легко вызвать из C #. Кроме того, у основного компилятора есть свои собственные объекты типа, но нам нужно было иметь возможность использовать объекты типа Reflection. Мы извлекли соответствующие части кода C ++ из компилятора csc.exe и построчно транслировали их на C #, а затем построили из этого библиотеку для вызова DLR.
Эрик Липперт,
9
@Eric, «Мы извлекли соответствующие части кода C ++ из компилятора csc.exe и построчно перевели их на C #», и тогда люди думали, что Roslyn заслуживает внимания :)
ShuggyCoUk
5
@ShuggyCoUk: Идея создания компилятора как услуги обсуждалась в течение некоторого времени, но на самом деле потребность в сервисе времени выполнения для анализа кода была большим стимулом для этого проекта, да.
Эрик Липперт,
108

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

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

* Обновление 3: добавлены тесты производительности IL-Emitted и Lazy IL-Emitted, основанные на ответе Марка Гравелла на этот вопрос .

Насколько мне известно, использование 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>));
}

Как видно из кода, я пытаюсь вызвать простой бездействующий метод семью разными способами:

  1. Прямой вызов метода
  2. С помощью dynamic
  3. По размышлению
  4. Использование, Actionкоторое было предварительно скомпилировано во время выполнения (таким образом, исключая время компиляции из результатов).
  5. Использование, Actionкоторое компилируется в первый раз, когда это необходимо, с использованием небезопасной для потоков переменной Lazy (включая время компиляции)
  6. Использование динамически сгенерированного метода, который создается перед тестом.
  7. Использование динамически генерируемого метода, который лениво создается во время теста.

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

Прямое: 3,4248 мс
Динамическое: 45,0728 мс
Отражение: 888,4011 мс
Предварительно скомпилированное: 21,9166
мс LazyCompiled: 30,2045
мс ILEmitted: 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();
    }),

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

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

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

StriplingWarrior
источник
2
Такой подробный ответ, спасибо! Меня тоже интересовали реальные цифры.
Сергей Сироткин
4
Ну, динамический код запускает импортер метаданных, семантический анализатор и эмиттер дерева выражений компилятора, а затем запускает компилятор дерева выражений в il на выходе этого, поэтому я думаю, что будет справедливо сказать, что он запускается компилятор во время выполнения. Просто потому, что он не запускает лексер, а анализатор вряд ли имеет отношение к делу.
Эрик Липперт,
6
Ваши показатели производительности определенно показывают, насколько окупается агрессивная политика кэширования DLR. Если бы в вашем примере выполнялись глупые вещи, например, если у вас был другой тип приема каждый раз, когда вы выполняли вызов, вы бы увидели, что динамическая версия очень медленная, когда она не может использовать свой кеш с ранее скомпилированными результатами анализа. , Но когда он может воспользоваться этим, святое благо, он всегда постится.
Эрик Липперт,
1
Что-то глупое по совету Эрика. Проверьте, поменяв местами, какая строка комментируется. 8964 мс против 814 мс, с dynamicконечно проигрышем: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);
Брайан
1
Будьте честны с размышлениями и создайте делегата из информации о методе:var methodDelegate = (Action)method.CreateDelegate(typeof(Action), foo);
Johnbot