Як вплив динамічної змінної впливає на продуктивність?


128

У мене є питання щодо продуктивності dynamicв C #. Я читав, dynamicщо компілятор запускається знову, але що це робить?

Чи потрібно перекомпілювати весь метод із dynamicзмінною, яка використовується як параметр, або просто тими рядками з динамічною поведінкою / контекстом?

Я помітив, що використання dynamicзмінних може уповільнити простий цикл на 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 для отримання більш детальної інформації. І ні, він ніколи не наблизиться до швидкості «рідного» циклу.
Ганс Пасант

Відповіді:


234

Я читав, що динаміка змушує компілятор запуститися знову, але що він робить. Чи потрібно перекомпілювати цілий метод з динамікою, що використовується як параметр, або з тими рядками з динамічною поведінкою / контекстом (?)

Ось угода.

Для кожного вираження у вашій програмі динамічного типу компілятор висилає код, що генерує єдиний "об'єкт динамічного виклику сайту", який представляє операцію. Наприклад, якщо у вас є:

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

Бачите, як це працює досі? Ми генеруємо сайт для викликів один раз , незалежно від того, скільки разів ви телефонуєте. Сайт для викликів живе назавжди після того, як ви його генеруєте один раз. Сайт виклику - це об'єкт, який представляє "тут буде динамічний виклик Foo".

Гаразд, тепер, коли у вас є сайт для дзвінків, як працює виклик?

Сайт для викликів є частиною динамічного виконання мови. DLR каже, "гм, хтось намагається зробити динамічний виклик методу foo на цьому об'єкті тут. Чи знаю я щось про це? Ні. Тоді я б краще це дізнався".

Потім DLR допитує об'єкт у d1, щоб побачити, чи є він щось особливе. Можливо, це спадковий об'єкт COM, або об'єкт Iron Python, або об'єкт Iron Ruby, або об'єкт IE DOM. Якщо це не одна з цих, то це повинен бути звичайний об'єкт C #.

Це пункт, коли компілятор запускається знову. Немає потреби в лексері чи аналізаторі, тому DLR запускає спеціальну версію компілятора C #, який просто має аналізатор метаданих, семантичний аналізатор виразів та емітер, що випромінює дерева виразів замість IL.

Аналізатор метаданих використовує Reflection для визначення типу об'єкта в d1, а потім передає його семантичному аналізатору, щоб запитати, що відбувається, коли такий об'єкт викликається методом Foo. Аналізатор роздільної здатності перевантаження визначає це, а потім будує Дерево виразів - так само, як якщо б ви назвали Foo в лямбда-дереві виразів - що представляє цей виклик.

Потім компілятор C # передає це дерево виразів до DLR разом з політикою кешу. Зазвичай політика "вдруге, коли ви бачите об'єкт такого типу, ви можете повторно використовувати це дерево виразів, а не передзвонити мені знову". Потім DLR викликає компіляцію на дереві виразів, яка викликає компілятор «дерево-вираз» до «IL» і випилює блок динамічно генерованого ІЛ у делегата.

Потім DLR кешує цього делегата в кеші, пов'язаному з об'єктом сайту виклику.

Потім він викликає делегата, і відбувається виклик Foo.

Другий раз, коли ви телефонуєте на M, у нас уже є сайт для дзвінків. DLR знову допитує об’єкт, і якщо об'єкт того ж типу, що і минулого разу, він витягує делегата з кеша і викликає його. Якщо об’єкт іншого типу, кеш пропускає, і весь процес починається заново; ми робимо семантичний аналіз виклику і зберігаємо результат у кеші.

Це відбувається для кожного вираження, що включає динамічний. Так, наприклад, якщо у вас є:

int x = d1.Foo() + d2;

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

Мати сенс?


Щойно з цікавості викликається спеціальна версія компілятора без парсера / лексера, передаючи спеціальний прапор стандартному csc.exe?
Роман Ройтер

@Eric, чи можу я зашкодити вам вказати на попереднє повідомлення у вашому блозі, де ви говорите про неявні перетворення коротких, int тощо? Як я пам'ятаю, ви там згадували, як / чому використання динамічної функції Convert.ToXXX викликає заготівлю компілятора. Я впевнений, що я розбиваю деталі, але, сподіваюся, ви знаєте, про що я говорю.
Адам Ракіс

4
@ Роман: № csc.exe написано на C ++, і нам було потрібно щось, що ми могли б легко зателефонувати з C #. Також у компілятора магістральної лінії є свої об'єкти типу, але нам потрібно було використовувати об'єкти типу Reflection. Ми дістали відповідні частини коду C ++ з компілятора csc.exe і перевели їх по черзі в C #, а потім створили бібліотеку з цієї бібліотеки для виклику DLR.
Ерік Ліпперт

9
@Eric, "Ми дістали відповідні частини коду C ++ з компілятора csc.exe і перевели їх по черзі в C #", це було про те, тоді люди думали, що Рослін може бути варто переслідувати :)
ShuggyCoUk

5
@ShuggyCoUk: Ідея створення компілятора як послуга вже давно хиталася, але насправді потрібна послуга виконання, щоб зробити аналіз коду, був великим поштовхом до цього проекту, так.
Ерік Ліпперт

108

Оновлення: Додано попередньо складені та ліниво складені орієнтири

Оновлення 2: Виходить, я помиляюся. Повну і правильну відповідь див. У публікації Еріка Ліпперта. Я залишаю це тут заради показників орієнтирів

* Оновлення 3: Додано еталони IL- Eвидання та ледачий IL- викид на основі відповіді Марка Гравелла на це запитання .

Наскільки мені відомо, використання 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.4248ms
Динамічний: 45.0728ms
Віддзеркалення: 888.4011ms
Попередньо складений: 21.9166ms
LazyCompiled: 30.2045ms
ILEmitted: 8.4918ms
LazyILEвидано: 14.3483ms

Тож, використовуючи dynamicключове слово, на порядок більше, ніж виклик методу, він все-таки вдається завершити операцію мільйон разів за 50 мілісекунд, що робить його набагато швидшим, ніж відображення. Якби метод, який ми викликаємо, намагався зробити щось інтенсивне, наприклад поєднання декількох рядків разом або пошук колекції за значенням, ці операції, ймовірно, значно перевершать різницю між прямим викликом і dynamicвикликом.

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

Оновлення 4

На основі коментаря Johnbot я розбив область Reflection на чотири окремі тести:

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

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

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

Отже, якщо ви можете заздалегідь визначити певний метод, який вам потрібно буде зателефонувати багато, виклик кешованого делегата, що посилається на цей метод, настільки ж швидкий, як і виклик самого методу. Однак, якщо вам потрібно визначити, який метод викликати так само, як ви збираєтеся його викликати, створення делегата для нього дуже дорого.


2
Така детальна відповідь, дякую! Мені було цікаво і про фактичні цифри.
Сергій Сіроткін

4
Що ж, динамічний код запускає імпортер метаданих, семантичний аналізатор та випромінювач дерева виразів компілятора, а потім запускає компілятор виразного дерева-в-іл на виході цього, тому я думаю, що справедливо сказати, що він починається до компілятора під час виконання. Просто тому, що він не працює лексером, і аналізатор навряд чи здається актуальним.
Ерік Ліпперт

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
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.