Dlaczego TypedReference jest za kulisami? To takie szybkie i bezpieczne… prawie magiczne!

129

Ostrzeżenie: To pytanie jest trochę heretyckie ... religijni programiści zawsze przestrzegają dobrych praktyk, proszę go nie czytać. :)

Czy ktoś wie, dlaczego korzystanie z TypedReference jest tak odradzane (domyślnie z powodu braku dokumentacji)?

Znalazłem dla niego świetne zastosowania, na przykład podczas przekazywania parametrów ogólnych przez funkcje, które nie powinny być ogólne (gdy użycie objectmoże być przesadne lub wolne, jeśli potrzebujesz typu wartości), gdy potrzebujesz nieprzezroczystego wskaźnika lub gdy potrzebujesz szybko uzyskać dostęp do elementu tablicy, którego specyfikacje znajdziesz w czasie wykonywania (używając Array.InternalGetReference). Skoro CLR nie pozwala nawet na nieprawidłowe użycie tego typu, dlaczego jest odradzane? To nie wydaje się być niebezpieczne ani nic ...


Inne zastosowania, które znalazłem TypedReference:

„Specjalizacja” typów ogólnych w C # (to jest bezpieczne dla typów):

static void foo<T>(ref T value)
{
    //This is the ONLY way to treat value as int, without boxing/unboxing objects
    if (value is int)
    { __refvalue(__makeref(value), int) = 1; }
    else { value = default(T); }
}

Pisanie kodu, który działa z ogólnymi wskaźnikami (jest to bardzo niebezpieczne, jeśli zostanie niewłaściwie użyte, ale szybkie i bezpieczne, jeśli zostanie użyte poprawnie):

//This bypasses the restriction that you can't have a pointer to T,
//letting you write very high-performance generic code.
//It's dangerous if you don't know what you're doing, but very worth if you do.
static T Read<T>(IntPtr address)
{
    var obj = default(T);
    var tr = __makeref(obj);

    //This is equivalent to shooting yourself in the foot
    //but it's the only high-perf solution in some cases
    //it sets the first field of the TypedReference (which is a pointer)
    //to the address you give it, then it dereferences the value.
    //Better be 10000% sure that your type T is unmanaged/blittable...
    unsafe { *(IntPtr*)(&tr) = address; }

    return __refvalue(tr, T);
}

Pisanie wersji instrukcji metodysizeof , która może być czasami przydatna:

static class ArrayOfTwoElements<T> { static readonly Value = new T[2]; }

static uint SizeOf<T>()
{
    unsafe 
    {
        TypedReference
            elem1 = __makeref(ArrayOfTwoElements<T>.Value[0] ),
            elem2 = __makeref(ArrayOfTwoElements<T>.Value[1] );
        unsafe
        { return (uint)((byte*)*(IntPtr*)(&elem2) - (byte*)*(IntPtr*)(&elem1)); }
    }
}

Pisanie metody, która przekazuje parametr „stan”, który chce uniknąć pakowania:

static void call(Action<int, TypedReference> action, TypedReference state)
{
    //Note: I could've said "object" instead of "TypedReference",
    //but if I had, then the user would've had to box any value types
    try
    {
        action(0, state);
    }
    finally { /*Do any cleanup needed*/ }
}

Dlaczego więc takie zastosowania są „odradzane” (z powodu braku dokumentacji)? Jakieś szczególne względy bezpieczeństwa? Wydaje się całkowicie bezpieczne i sprawdzalne, jeśli nie jest pomieszane ze wskaźnikami (które i tak nie są bezpieczne ani weryfikowalne) ...


Aktualizacja:

Przykładowy kod, aby pokazać, że rzeczywiście TypedReferencemoże być dwukrotnie szybszy (lub więcej):

using System;
using System.Collections.Generic;
static class Program
{
    static void Set1<T>(T[] a, int i, int v)
    { __refvalue(__makeref(a[i]), int) = v; }

    static void Set2<T>(T[] a, int i, int v)
    { a[i] = (T)(object)v; }

    static void Main(string[] args)
    {
        var root = new List<object>();
        var rand = new Random();
        for (int i = 0; i < 1024; i++)
        { root.Add(new byte[rand.Next(1024 * 64)]); }
        //The above code is to put just a bit of pressure on the GC

        var arr = new int[5];
        int start;
        const int COUNT = 40000000;

        start = Environment.TickCount;
        for (int i = 0; i < COUNT; i++)
        { Set1(arr, 0, i); }
        Console.WriteLine("Using TypedReference:  {0} ticks",
                          Environment.TickCount - start);
        start = Environment.TickCount;
        for (int i = 0; i < COUNT; i++)
        { Set2(arr, 0, i); }
        Console.WriteLine("Using boxing/unboxing: {0} ticks",
                          Environment.TickCount - start);

        //Output Using TypedReference:  156 ticks
        //Output Using boxing/unboxing: 484 ticks
    }
}

(Edycja: edytowałem powyższy test porównawczy, ponieważ ostatnia wersja postu używała wersji kodu do debugowania [zapomniałem go zmienić do wydania] i nie naciskałem na GC. Ta wersja jest nieco bardziej realistyczna i w moim systemie jest TypedReferenceśrednio ponad trzy razy szybszy ).

user541686
źródło
Kiedy uruchomię Twój przykład, otrzymam zupełnie inne wyniki. TypedReference: 203 ticks, boxing/unboxing: 31 ticks. Bez względu na to, co próbuję (w tym różne sposoby mierzenia czasu), pakowanie / rozpakowywanie jest nadal szybsze w moim systemie.
Seph
1
@Seph: Właśnie zobaczyłem twój komentarz. To bardzo interesujące - wydaje się być szybszy na x64, ale wolniejszy na x86. Dziwne ...
user541686
1
Właśnie przetestowałem ten kod porównawczy na mojej maszynie x64 pod .NET 4.5. Zamieniłem Environment.TickCount na Diagnostics.Stopwatch i poszedłem z ms zamiast ticków. Uruchomiłem każdą kompilację (x86, 64, Any) trzy razy. Najlepsze z trzech wyników były następujące: x86: 205 / 27ms (ten sam wynik dla 2/3 uruchomień w tej kompilacji) x64: 218 / 109ms Dowolny: 205 / 27ms (ten sam wynik dla 2/3 uruchomień w tej kompilacji) -wszystkie skrzynki box / unboxing był szybszy.
kornman00
2
Dziwne pomiary prędkości można przypisać tym dwóm faktom: * (T) (obiekt) v faktycznie NIE dokonuje alokacji sterty. W .NET 4+ jest zoptymalizowany. Na tej ścieżce nie ma alokacji i jest cholernie szybko. * Używanie makeref wymaga, aby zmienna była faktycznie przydzielona na stosie (podczas gdy metoda kinda-box może zoptymalizować ją do rejestrów). Ponadto, patrząc na czasy, zakładam, że utrudnia to ustawienie liniowe nawet w przypadku flagi siły w linii. Więc kinda-box jest wbudowany i zarejestrowany, podczas gdy makeref wywołuje funkcję i obsługuje stos
hypersw
1
Aby zobaczyć korzyści płynące z rzucania typerefów, uczyń je mniej banalnymi. Np. Rzutowanie typu bazowego na typ wyliczeniowy ( int-> DockStyle). To pudełko naprawdę i jest prawie dziesięciokrotnie wolniejsze.
hypersw

Odpowiedzi:

43

Krótka odpowiedź: przenośność .

Chociaż __arglist, __makerefi __refvaluerozszerzeniami języka i nieudokumentowanych w C # Language Specification, konstrukty stosowane do ich wdrożenia pod maską ( varargnazywając konwencję, TypedReferencetyp, arglist, refanytype, mkanyref, i refanyvalinstrukcje) są doskonale udokumentowane w CLI Specification (ECMA-335) w biblioteka Vararg .

Zdefiniowanie w Bibliotece Vararg jasno pokazuje, że mają one przede wszystkim wspierać listy argumentów o zmiennej długości i niewiele więcej. Listy zmiennych argumentów mają niewielkie zastosowanie na platformach, które nie muszą łączyć się z zewnętrznym kodem C, który używa varargs. Z tego powodu biblioteka Varargs nie jest częścią żadnego profilu CLI. Legalne implementacje CLI mogą nie obsługiwać biblioteki Varargs, ponieważ nie jest ona uwzględniona w profilu jądra CLI:

4.1.6 Vararg

Zestaw funkcji vararg obsługuje listy argumentów o zmiennej długości i wskazówki wykonawcze wpisany.

Jeśli pominięto: Każda próba odniesienia do metody przy użyciu varargkonwencji wywoływania lub kodowania podpisu skojarzonego z metodami vararg (patrz Partycja II) spowoduje zgłoszenie System.NotImplementedExceptionwyjątku. Sposoby korzystania z instrukcji CIL arglist, refanytype, mkrefany, i refanyvalpowinna rzucić System.NotImplementedExceptionwyjątek. Dokładny czas wystąpienia wyjątku nie jest określony. Nie System.TypedReferencetrzeba określać typu.

Aktualizacja (odpowiedź na GetValueDirectkomentarz):

FieldInfo.GetValueDirectFieldInfo.SetValueDirectto nie część Base Class Library. Zauważ, że istnieje różnica między biblioteką klas .NET Framework a biblioteką klas podstawowych. BCL jest jedyną rzeczą wymaganą do zgodnej implementacji CLI / C # i jest udokumentowane w ECMA TR / 84 . (W rzeczywistości FieldInfojest częścią biblioteki Reflection i nie jest też uwzględniona w profilu jądra CLI).

Gdy tylko użyjesz metody poza BCL, rezygnujesz z nieco przenośności (a to staje się coraz ważniejsze wraz z pojawieniem się implementacji innych niż .NET CLI, takich jak Silverlight i MonoTouch). Nawet gdyby implementacja chciała zwiększyć kompatybilność z biblioteką klas Microsoft .NET Framework, mogłaby po prostu dostarczyć GetValueDirecti SetValueDirectwziąć plik TypedReferencebez tworzenia TypedReferencespecjalnie obsługiwanego przez środowisko wykonawcze (zasadniczo czyniąc je równoważnymi z ich objectodpowiednikami bez korzyści wydajnościowych).

Gdyby udokumentowali to w C #, miałoby to co najmniej kilka konsekwencji:

  1. Jak każda funkcja, może stać się przeszkodą dla nowych funkcji, zwłaszcza, że ​​ta tak naprawdę nie pasuje do projektu C # i wymaga dziwnych rozszerzeń składni i specjalnego traktowania typu przez środowisko wykonawcze.
  2. Wszystkie implementacje C # muszą w jakiś sposób zaimplementować tę funkcję i niekoniecznie jest to trywialne / możliwe dla implementacji C #, które w ogóle nie działają na CLI lub działają na CLI bez Varargs.
Mehrdad Afshari
źródło
4
Dobre argumenty przemawiające za przenośnością, +1. Ale co z FieldInfo.GetValueDirecti FieldInfo.SetValueDirect? Są częścią BCL i aby ich używać, potrzebujesz TypedReference , więc czy to w zasadzie nie wymusza TypedReferenceich definiowania, niezależnie od specyfikacji języka? (Kolejna uwaga: nawet jeśli słowa kluczowe nie istniały, o ile istniały instrukcje, nadal można było uzyskać do nich dostęp za pomocą dynamicznych metod ... tak długo, jak Twoja platforma współpracuje z bibliotekami C, możesz ich używać, czy C # ma słowa kluczowe, czy nie)
user541686
Aha, i inny problem: nawet jeśli nie jest przenośny, dlaczego nie udokumentowali słów kluczowych? Przynajmniej jest to konieczne podczas współdziałania z C varargs, więc przynajmniej mogli o tym wspomnieć?
user541686
@Mehrdad: Huh, to interesujące. Myślę, że zawsze zakładałem, że pliki w folderze BCL źródła .NET są częścią BCL, nigdy nie zwracając uwagi na część normalizacyjną ECMA. Jest to całkiem przekonujące ... z wyjątkiem jednej małej rzeczy: czy nie jest trochę bezcelowe nawet włączanie (opcjonalnej) funkcji do specyfikacji CLI, jeśli nie ma dokumentacji, jak jej używać w dowolnym miejscu? (Miałoby sens, gdyby TypedReferencezostało udokumentowane tylko dla jednego języka - powiedzmy, zarządzanego C ++ - ale jeśli żaden język tego nie dokumentuje, więc jeśli nikt nie może go naprawdę używać, to po co w ogóle zawracać sobie
głowę
@Mehrdad Podejrzewam, że główną motywacją była potrzeba wewnętrznie tej funkcji do współpracy ( np. [DllImport("...")] void Foo(__arglist); ) I zaimplementowali ją w C # na własny użytek. Projekt CLI jest pod wpływem wielu języków (adnotacje „Common Language Infrastructure Annotated Standard” demonstrują ten fakt). Stworzenie odpowiedniego środowiska wykonawczego dla jak największej liczby języków, w tym nieprzewidzianych, było zdecydowanie celem projektu (stąd name) i jest to funkcja, na której przykładowo hipotetyczna zarządzana implementacja języka C mogłaby prawdopodobnie skorzystać.
Mehrdad Afshari
@Mehrdad: Ach ... tak, to całkiem przekonujący powód. Dzięki!
user541686
15

Cóż, nie jestem Ericem Lippertem, więc nie mogę bezpośrednio mówić o motywacjach Microsoftu, ale gdybym zaryzykował zgadywanie, powiedziałbym, że TypedReferencei in. nie są dobrze udokumentowane, ponieważ, szczerze mówiąc, nie potrzebujesz ich.

Każde użycie tych funkcji, o którym wspomniałeś, można wykonać bez nich, aczkolwiek w niektórych przypadkach ze spadkiem wydajności. Ale C # (i ogólnie .NET) nie jest zaprojektowany jako język o wysokiej wydajności. (Domyślam się, że celem wydajności było „szybsze niż Java”).

Nie oznacza to, że nie uwzględniono pewnych kwestii dotyczących wydajności. Rzeczywiście, takie funkcje, jak wskaźniki stackalloci pewne zoptymalizowane funkcje struktury istnieją głównie w celu zwiększenia wydajności w określonych sytuacjach.

Leki generyczne, które, jak powiedziałbym, mają główną zaletę w postaci bezpieczeństwa typów, również poprawiają wydajność, podobnie jak TypedReferencedzięki unikaniu pakowania i rozpakowywania. Właściwie zastanawiałem się, dlaczego wolisz to:

static void call(Action<int, TypedReference> action, TypedReference state){
    action(0, state);
}

do tego:

static void call<T>(Action<int, T> action, T state){
    action(0, state);
}

Jak widzę, kompromisy polegają na tym, że ten pierwszy wymaga mniej JIT (i, co za tym idzie, mniej pamięci), podczas gdy drugi jest bardziej znany i, jak zakładam, nieco szybszy (unikając dereferencji wskaźnika).

Zadzwoniłem TypedReferencei znajomi o szczegóły realizacji. Wskazałeś dla nich kilka zgrabnych zastosowań i myślę, że warto je zbadać, ale obowiązuje zwykłe zastrzeżenie polegające na poleganiu na szczegółach implementacji - następna wersja może złamać twój kod.

P tato
źródło
4
Huh ... „nie potrzebujesz ich” - powinienem był to przewidzieć. :-) To prawda, ale to też nieprawda. Co definiujesz jako „potrzebę”? Czy na przykład metody rozszerzające są naprawdę „potrzebne”? Odnośnie twojego pytania o używanie typów generycznych w call(): To dlatego, że kod nie zawsze jest tak spójny - odnosiłem się bardziej do przykładu bardziej podobnego do tego IAsyncResult.State, w którym wprowadzenie typów generycznych byłoby po prostu niewykonalne, ponieważ nagle wprowadziłoby to generyczne dla każda klasa / metoda. +1 za odpowiedź ... szczególnie za wskazanie części „szybsza niż Java”. :]
user541686
1
Aha, i jeszcze jedna kwestia: TypedReferenceprawdopodobnie nie będzie w najbliższym czasie podlegać przełomowym zmianom, biorąc pod uwagę, że FieldInfo.SetValueDirect , który jest publiczny i prawdopodobnie używany przez niektórych programistów, zależy od tego. :)
user541686
Ach, ale nie potrzeba rozszerzenia metody, w celu wspierania LINQ. W każdym razie tak naprawdę nie mówię o różnicy między miłym posiadaniem a potrzebą posiadania. Nie zadzwoniłbym do TypedReferenceżadnego z nich. (Okropna składnia i ogólna nieporęczność dyskwalifikują go, moim zdaniem, z kategorii miłych do posiadania). Powiedziałbym, że dobrze jest mieć w pobliżu, gdy naprawdę trzeba skrócić kilka mikrosekund tu i tam. To powiedziawszy, myślę o kilku miejscach w moim własnym kodzie, którym zamierzam się teraz przyjrzeć, aby sprawdzić, czy mogę je zoptymalizować przy użyciu wskazanych przez Ciebie technik.
P Daddy
1
@Merhdad: Pracowałem w tym czasie nad serializatorem / deserializatorem obiektów binarnych do komunikacji międzyprocesowej / między hostami (TCP i potoki). Moim celem było uczynienie go tak małym (pod względem bajtów przesyłanych przez sieć) i szybkim (pod względem czasu spędzonego na serializacji i deserializacji), jak to tylko możliwe. Pomyślałem, że mógłbym uniknąć pakowania i unboxingu za pomocą TypedReferences, ale IIRC, jedyne miejsce, w którym udało mi się gdzieś uniknąć boksu, to elementy jednowymiarowych tablic prymitywów. Niewielka korzyść z szybkości nie była warta złożoności, jaką dodała do całego projektu, więc ją usunąłem.
P Daddy,
1
Biorąc pod uwagę delegate void ActByRef<T1,T2>(ref T1 p1, ref T2 p2);kolekcję typu, Tmoże dostarczyć metodę ActOnItem<TParam>(int index, ActByRef<T,TParam> proc, ref TParam param), ale JITter musiałby utworzyć inną wersję metody dla każdego typu wartości TParam. Użycie wpisanego odwołania pozwoliłoby jednej wersji metody JITted na pracę ze wszystkimi typami parametrów.
supercat
4

Nie mogę się dowiedzieć, czy tytuł tego pytania ma być sarkastyczny: od dawna ustalono, że TypedReferencejest powolnym, rozdętym, brzydkim kuzynem `` prawdziwych '' zarządzanych wskaźników, które otrzymujemy w C ++ / CLI interior_ptr<T> , lub nawet tradycyjne parametry przez odniesienie ( ref/ out) w C # . W rzeczywistości dość trudno jest osiągnąć TypedReferencenawet podstawową wydajność, używając tylko liczby całkowitej do ponownego indeksowania oryginalnej tablicy CLR za każdym razem.

Smutne szczegóły są tutaj , ale na szczęście nic z tego nie ma teraz znaczenia ...

To pytanie jest teraz rozwiązywane przez nowe lokalizacje referencyjne i funkcje zwracania referencji w C # 7

Te nowe funkcje językowe zapewniają widoczną, pierwszorzędną obsługę języka C # w celu deklarowania, udostępniania i manipulowania typami prawdziwie CLR zarządzanych typów referencyjnych w dokładnie określonych sytuacjach.

Ograniczenia użycia nie są bardziej rygorystyczne niż to, co było wcześniej wymagane TypedReference(a wydajność dosłownie przeskakuje od najgorszego do najlepszego ), więc nie widzę żadnego innego możliwego przypadku użycia w C # dla TypedReference. Na przykład wcześniej nie było sposobu, aby utrzymać się TypedReferencew GCstercie, więc to samo dotyczy lepszych wskaźników zarządzanych, teraz nie jest to kwestia na wynos.

I oczywiście upadek TypedReference- a przynajmniej jego prawie całkowite wycofanie się - oznacza również wyrzucenie __makerefna śmietnik.

Glenn Slayden
źródło