Jak posiadanie zmiennej dynamicznej wpływa na wydajność?

128

Mam pytanie dotyczące wydajności dynamicw języku C #. Czytałem, dynamicże kompilator działa ponownie, ale co robi?

Czy musi przekompilować całą metodę ze dynamiczmienną używaną jako parametr, czy tylko te wiersze z dynamicznym zachowaniem / kontekstem?

Zauważyłem, że użycie dynamiczmiennych może spowolnić prostą pętlę for o 2 rzędy wielkości.

Kod, którym się bawiłem:

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));
    }
Łukasz Madon
źródło
Nie, nie uruchamia kompilatora, więc karanie byłoby powolne przy pierwszym przebiegu. Trochę podobny do Reflection, ale z dużą ilością sprytu, aby śledzić, co zostało zrobione wcześniej, aby zminimalizować obciążenie. „Dynamiczne środowisko wykonawcze języka” Google, aby uzyskać więcej informacji. I nie, nigdy nie osiągnie prędkości „rodzimej” pętli.
Hans Passant,

Odpowiedzi:

234

Przeczytałem Dynamic sprawia, że ​​kompilator działa ponownie, ale co robi. Czy musi przekompilować całą metodę z dynamiką używaną jako parametr, czy raczej te linie z dynamicznym zachowaniem / kontekstem (?)

Oto oferta.

Dla każdego wyrażenia w programie, które jest typu dynamicznego, kompilator emituje kod, który generuje pojedynczy „obiekt witryny wywołania dynamicznego”, który reprezentuje operację. Na przykład, jeśli masz:

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

wtedy kompilator wygeneruje kod moralnie podobny. (Rzeczywisty kod jest nieco bardziej złożony; jest to uproszczone do celów prezentacji).

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

Widzisz, jak to działa do tej pory? Generujemy witrynę wywoławczą raz , bez względu na to, ile razy dzwonisz do M. Witryna wywoławcza działa wiecznie po tym, jak ją raz wygenerujesz. Miejsce wywołania to obiekt, który reprezentuje „tutaj będzie dynamiczne wywołanie Foo”.

OK, więc teraz, gdy masz witrynę wywołań, jak działa wywołanie?

Witryna telefoniczna jest częścią środowiska wykonawczego języka dynamicznego. DLR mówi: „hmm, ktoś próbuje wykonać dynamiczne wywołanie metody foo na tym obiekcie. Czy ja coś o tym wiem? Nie. Więc lepiej się dowiedzę”.

DLR następnie przesłuchuje obiekt w d1, aby sprawdzić, czy jest coś specjalnego. Może jest to starszy obiekt COM, obiekt Iron Python, obiekt Iron Ruby lub obiekt IE DOM. Jeśli nie jest żadnym z nich, to musi to być zwykły obiekt C #.

To jest punkt, w którym kompilator uruchamia się ponownie. Nie ma potrzeby stosowania leksera ani parsera, więc DLR uruchamia specjalną wersję kompilatora C #, która ma tylko analizator metadanych, analizator semantyczny dla wyrażeń i emiter, który emituje drzewa wyrażeń zamiast IL.

Analizator metadanych używa Reflection do określenia typu obiektu w d1, a następnie przekazuje go do analizatora semantycznego, aby zapytać, co się dzieje, gdy taki obiekt jest wywoływany metodą Foo. Analizator rozpoznawania przeciążenia wykrywa to, a następnie tworzy drzewo wyrażeń - tak jak w przypadku wywołania Foo w wyrażeniu lambda - które reprezentuje to wywołanie.

Kompilator C # następnie przekazuje to drzewo wyrażeń z powrotem do DLR wraz z zasadami pamięci podręcznej. Zasadą jest zazwyczaj, że „gdy drugi raz zobaczysz obiekt tego typu, możesz ponownie użyć tego drzewa wyrażeń, zamiast oddzwaniać do mnie ponownie”. Następnie DLR wywołuje Compile w drzewie wyrażeń, które wywołuje kompilator drzewa wyrażeń do IL i wypluwa blok dynamicznie generowanego IL w delegacie.

Następnie DLR buforuje tego delegata w pamięci podręcznej skojarzonej z obiektem wywołania lokacji.

Następnie wywołuje delegata i następuje wywołanie Foo.

Gdy drugi raz dzwonisz do M, mamy już witrynę internetową. DLR ponownie przesłuchuje obiekt, a jeśli obiekt jest tego samego typu, co ostatnim razem, pobiera delegata z pamięci podręcznej i wywołuje go. Jeśli obiekt jest innego typu, to pamięć podręczna nie działa i cały proces zaczyna się od nowa; wykonujemy analizę semantyczną połączenia i przechowujemy wynik w pamięci podręcznej.

Dzieje się tak w przypadku każdego wyrażenia zawierającego dynamikę. Na przykład, jeśli masz:

int x = d1.Foo() + d2;

wtedy są trzy witryny dynamicznych połączeń. Jeden do dynamicznego wywołania Foo, jeden do dynamicznego dodawania i jeden do dynamicznej konwersji z dynamicznej na int. Każdy z nich ma własną analizę w czasie wykonywania i własną pamięć podręczną wyników analizy.

Ma sens?

Eric Lippert
źródło
Tak z ciekawości, specjalna wersja kompilatora bez parsera / leksera jest wywoływana przez przekazanie specjalnej flagi do standardowego csc.exe?
Roman Royter,
@Eric, czy mogę cię prosić o wskazanie mi poprzedniego posta na twoim blogu, w którym mówisz o niejawnych konwersjach krótkich, int, itd.? Jak pamiętam, wspomniałeś tam, jak / dlaczego użycie dynamiki z Convert.ToXXX powoduje uruchomienie kompilatora. Jestem pewien, że dręczyłem szczegóły, ale mam nadzieję, że wiesz, o czym mówię.
Adam Rackis
4
@Roman: Nie. Csc.exe jest napisany w C ++ i potrzebowaliśmy czegoś, co moglibyśmy łatwo wywołać z C #. Ponadto kompilator mainline ma własne obiekty typu, ale musieliśmy mieć możliwość korzystania z obiektów typu Reflection. Wyodrębniliśmy odpowiednie fragmenty kodu C ++ z kompilatora csc.exe i przetłumaczyliśmy je wiersz po wierszu na C #, a następnie zbudowaliśmy z tego bibliotekę do wywołania DLR.
Eric Lippert,
9
@Eric „My ekstrakcji odpowiednie fragmenty kodu C ++ z csc.exe kompilator i przetłumaczył je linia po linii w C #” było to o czym ludzie myśleli Roslyn może być wart realizacji :)
ShuggyCoUk
5
@ShuggyCoUk: Pomysł posiadania kompilatora jako usługi pojawiał się już od jakiegoś czasu, ale tak naprawdę potrzeba wykonania usługi runtime do analizy kodu była dużym impulsem do tego projektu, tak.
Eric Lippert,
108

Aktualizacja: Dodano wstępnie skompilowane i leniwie skompilowane testy porównawcze

Aktualizacja 2: Okazuje się, że się mylę. Pełna i poprawna odpowiedź znajduje się w poście Erica Lipperta. Zostawiam to tutaj ze względu na wartości wzorcowe

* Aktualizacja 3: Dodano testy porównawcze Emitowane przez IL i Leniwe emisje IL, w oparciu o odpowiedź Marka Gravella na to pytanie .

O ile mi wiadomo, użycie dynamicsłowa kluczowego samo w sobie nie powoduje żadnej dodatkowej kompilacji w czasie wykonywania (chociaż wyobrażam sobie, że mogłoby to zrobić w określonych okolicznościach, w zależności od typu obiektów, które stanowią kopię zapasową zmiennych dynamicznych).

Jeśli chodzi o wydajność, z dynamicnatury wprowadza pewne narzuty, ale nie tak bardzo, jak mogłoby się wydawać. Na przykład właśnie przeprowadziłem test porównawczy, który wygląda następująco:

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

Jak widać z kodu, próbuję wywołać prostą metodę no-op na siedem różnych sposobów:

  1. Bezpośrednie wywołanie metody
  2. Za pomocą dynamic
  3. Przez refleksję
  4. Użycie elementu, Actionktóry został wstępnie skompilowany w czasie wykonywania (w ten sposób wykluczając czas kompilacji z wyników).
  5. Użycie Actionzmiennej, która jest kompilowana za pierwszym razem, gdy jest potrzebna, przy użyciu zmiennej Lazy, która nie jest bezpieczna dla wątków (w ten sposób obejmuje czas kompilacji)
  6. Korzystanie z dynamicznie generowanej metody, która jest tworzona przed testem.
  7. Korzystanie z dynamicznie generowanej metody, która jest leniwie tworzona podczas testu.

Każdy jest wywoływany milion razy w prostej pętli. Oto wyniki pomiaru czasu:

Bezpośredni: 3,4248 ms
Dynamiczny: 45,0728 ms
Odbicie: 888,4011 ms
Prekompilowany: 21,9166
ms
LazyCompiled: 30,2045
ms ILE,

Tak więc, chociaż użycie dynamicsłowa kluczowego trwa o rząd wielkości dłużej niż bezpośrednie wywołanie metody, nadal udaje mu się zakończyć operację milion razy w około 50 milisekund, co czyni ją znacznie szybszą niż odbicie. Gdyby wywoływana przez nas metoda próbowała wykonać coś intensywnego, na przykład połączyć kilka ciągów razem lub wyszukać w kolekcji wartość, operacje te prawdopodobnie znacznie przeważyłyby nad różnicą między wywołaniem bezpośrednim a dynamicwywołaniem.

Wydajność to tylko jeden z wielu dobrych powodów, dla których nie należy używać ich dynamicniepotrzebnie, ale gdy masz do czynienia z prawdziwymi dynamicdanymi, może zapewnić korzyści znacznie przewyższające wady.

Zaktualizuj 4

Opierając się na komentarzu Johnbota, podzieliłem obszar refleksji na cztery oddzielne testy:

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

... a oto wyniki testów porównawczych:

wprowadź opis obrazu tutaj

Jeśli więc możesz z góry określić konkretną metodę, którą będziesz musiał często wywoływać, wywołanie delegata z pamięci podręcznej odnoszącego się do tej metody jest prawie tak szybkie, jak wywołanie samej metody. Jeśli jednak chcesz określić, którą metodę wywołać w momencie, gdy masz zamiar ją wywołać, utworzenie delegata jest bardzo kosztowne.

StriplingWarrior
źródło
2
Taka szczegółowa odpowiedź, dzięki! Zastanawiałem się również nad faktycznymi liczbami.
Sergey Sirotkin
4
Cóż, kod dynamiczny uruchamia importer metadanych, analizator semantyczny i emiter drzewa wyrażeń kompilatora, a następnie uruchamia kompilator drzewa wyrażeń do il na tym wyjściu, więc myślę, że można powiedzieć, że zaczyna kompilator w czasie wykonywania. Tylko dlatego, że nie uruchamia leksera, a parser wydaje się mało istotny.
Eric Lippert,
6
Twoje dane dotyczące wydajności z pewnością pokazują, jak opłaca się agresywna polityka buforowania DLR. Jeśli twój przykład zrobił głupawe rzeczy, na przykład gdybyś miał inny typ odbioru za każdym razem, gdy wykonywałeś połączenie, zobaczysz, że wersja dynamiczna jest bardzo powolna, gdy nie może wykorzystać swojej pamięci podręcznej wcześniej skompilowanych wyników analizy . Ale kiedy może to wykorzystać, święta dobroć jest zawsze szybka.
Eric Lippert,
1
Coś głupiego, jak na sugestię Erica. Przetestuj, zamieniając, która linia jest komentowana. 8964 ms vs 814 ms, dynamicoczywiście przegrywając: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
Bądź uczciwy wobec refleksji i stwórz delegata na podstawie informacji o metodzie:var methodDelegate = (Action)method.CreateDelegate(typeof(Action), foo);
Johnbot