¿Por qué es TypedReference detrás de escena? Es tan rápido y seguro ... ¡casi mágico!

128

Advertencia: esta pregunta es un poco herética ... los programadores religiosos siempre respetan las buenas prácticas, por favor no la lean. :)

¿Alguien sabe por qué se desaconseja tanto el uso de TypedReference (implícitamente, por falta de documentación)?

He encontrado excelentes usos para él, como al pasar parámetros genéricos a través de funciones que no deberían ser genéricas (cuando se usa un objectpuede ser excesivo o lento, si necesita un tipo de valor), para cuando necesita un puntero opaco, o para cuando necesita acceder a un elemento de una matriz rápidamente, cuyas especificaciones encuentra en tiempo de ejecución (usando Array.InternalGetReference). Dado que el CLR ni siquiera permite el uso incorrecto de este tipo, ¿por qué se desaconseja? No parece ser inseguro ni nada ...


Otros usos que he encontrado para TypedReference:

Genéricos "especializados" en C # (esto es de tipo seguro):

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

Escribir código que funcione con punteros genéricos (esto es muy inseguro si se usa mal, pero rápido y seguro si se usa correctamente):

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

Escribir una versión del método de la sizeofinstrucción, que en ocasiones puede ser útil:

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

Escribir un método que pase un parámetro de "estado" que quiera evitar el boxeo:

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*/ }
}

Entonces, ¿por qué se desalientan usos como este (por falta de documentación)? ¿Alguna razón de seguridad en particular? Parece perfectamente seguro y verificable si no se mezcla con punteros (que de todos modos no son seguros ni verificables) ...


Actualizar:

Código de muestra para mostrar que, de hecho, TypedReferencepuede ser el doble de rápido (o más):

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

(Editar: edité el punto de referencia anterior, ya que la última versión de la publicación usaba una versión de depuración del código [olvidé cambiarlo para liberarlo], y no presioné al GC. Esta versión es un poco más realista, y en mi sistema, es más de tres veces más rápido con TypedReferenceun promedio).

usuario541686
fuente
Cuando ejecuto su ejemplo obtengo resultados completamente diferentes. TypedReference: 203 ticks, boxing/unboxing: 31 ticks. No importa lo que intente (incluidas las diferentes formas de cronometrar), el boxeo / unboxing es aún más rápido en mi sistema.
Seph
1
@Seph: Acabo de ver tu comentario. Eso es muy interesante: parece ser más rápido en x64, pero más lento en x86. Extraño ...
user541686
1
Acabo de probar ese código de referencia en mi máquina x64 en .NET 4.5. Reemplacé Environment.TickCount con Diagnostics.Stopwatch y elegí ms en lugar de ticks. Ejecuté cada compilación (x86, 64, Cualquiera) tres veces. Los mejores resultados de tres fueron los siguientes: x86: 205 / 27ms (mismo resultado para 2/3 ejecuciones en esta compilación) x64: 218 / 109ms Cualquiera: 205 / 27ms (mismo resultado para 2/3 ejecuciones en esta compilación) En -todos- los casos box / unboxing fue más rápido.
kornman00
2
Las extrañas mediciones de velocidad podrían atribuirse a estos dos hechos: * (T) (objeto) v NO realiza realmente una asignación de montón. En .NET 4+ está optimizado. No hay asignaciones en este camino, y es muy rápido. * El uso de makeref requiere que la variable se asigne realmente en la pila (mientras que el método tipo caja puede optimizarla en registros). Además, al observar los tiempos, supongo que perjudica la alineación incluso con la bandera de fuerza en línea. Así que un poco de la caja se colocarán en línea y enregistered, mientras makeref hace una llamada a la función y opera la pila
hypersw
1
Para ver los beneficios del lanzamiento de typeref, hazlo menos trivial. Por ejemplo, emitir un tipo subyacente al tipo de enumeración ( int-> DockStyle). Esta cajas de verdad, y es casi diez veces más lenta.
hypersw

Respuestas:

42

Respuesta corta: portabilidad .

Mientras __arglist, __makerefy __refvalueson extensiones del lenguaje y es indocumentado en el C # Language Specification, las construcciones utilizadas para ponerlas en práctica bajo el capó ( varargconvención de llamada, TypedReferencetipo, arglist, refanytype, mkanyref, y refanyvallas instrucciones) están perfectamente documentado en la especificación CLI (ECMA-335) en la biblioteca Vararg .

Estar definido en la Biblioteca Vararg deja bastante claro que están destinados principalmente a admitir listas de argumentos de longitud variable y no mucho más. Las listas de argumentos variables tienen poco uso en plataformas que no necesitan interactuar con el código C externo que usa varargs. Por este motivo, la biblioteca Varargs no forma parte de ningún perfil de CLI. Las implementaciones legítimas de CLI pueden optar por no admitir la biblioteca Varargs, ya que no está incluida en el perfil de Kernel de CLI:

4.1.6 Vararg

El conjunto de características vararg admite listas de argumentos de longitud variable y punteros de tiempo de ejecución.

Si se omite: Cualquier intento de hacer referencia a un método con la varargconvención de llamada o las codificaciones de firma asociadas con los métodos vararg (ver Partición II) arrojará la System.NotImplementedExceptionexcepción. Los métodos que utilizan las instrucciones CIL arglist, refanytype, mkrefany, y refanyvaldeberán tirar la System.NotImplementedExceptionexcepción. No se especifica el momento preciso de la excepción. El tipo System.TypedReferenceno necesita ser definido.

Actualización (respuesta al GetValueDirectcomentario):

FieldInfo.GetValueDirectson FieldInfo.SetValueDirectson no parte de la biblioteca de clases base. Tenga en cuenta que hay una diferencia entre .NET Framework Class Library y Base Class Library. BCL es lo único requerido para una implementación conforme de CLI / C # y está documentado en ECMA TR / 84 . (De hecho, FieldInfoes parte de la biblioteca Reflection y eso tampoco está incluido en el perfil CLI Kernel).

Tan pronto como use un método fuera de BCL, está renunciando a un poco de portabilidad (y esto se está volviendo cada vez más importante con el advenimiento de implementaciones de CLI no .NET como Silverlight y MonoTouch). Incluso si una implementación quisiera aumentar la compatibilidad con la biblioteca de clases de Microsoft .NET Framework, simplemente podría proporcionar GetValueDirecty SetValueDirecttomar una TypedReferencesin hacer que TypedReferenceel tiempo de ejecución maneje especialmente (básicamente, hacerlas equivalentes a sus objectcontrapartes sin el beneficio de rendimiento).

Si lo hubieran documentado en C #, habría tenido al menos un par de implicaciones:

  1. Al igual que cualquier característica, puede convertirse en un obstáculo para las nuevas características, especialmente porque esta realmente no encaja en el diseño de C # y requiere extensiones de sintaxis extrañas y una entrega especial de un tipo por el tiempo de ejecución.
  2. Todas las implementaciones de C # tienen que implementar de alguna manera esta característica y no es necesariamente trivial / posible para implementaciones de C # que no se ejecutan sobre una CLI o se ejecutan sobre una CLI sin Varargs.
Mehrdad Afshari
fuente
44
Buenos argumentos para la portabilidad, +1. Pero ¿qué pasa FieldInfo.GetValueDirecty FieldInfo.SetValueDirect? Forman parte del BCL, y para usarlos es necesario TypedReference , entonces, ¿no obliga eso TypedReferencea estar siempre definido, independientemente de las especificaciones del idioma? (Además, otra nota: incluso si las palabras clave no existieran, mientras existieran las instrucciones, aún podría acceder a ellas emitiendo métodos dinámicamente ... para que mientras su plataforma interopere con las bibliotecas C, puede usarlas, si C # tiene o no las palabras clave.)
user541686
Ah, y otro problema: incluso si no es portátil, ¿por qué no documentaron las palabras clave? Por lo menos, es necesario cuando interopera con C varargs, ¿entonces al menos podrían haberlo mencionado?
user541686
@Mehrdad: Huh, eso es interesante. Supongo que siempre asumí que los archivos en la carpeta BCL de la fuente .NET son parte de BCL, sin prestar realmente atención a la parte de estandarización de ECMA. Esto es bastante convincente ... excepto una pequeña cosa: ¿no es un poco inútil incluso incluir la función (opcional) en la especificación CLI, si no hay documentación sobre cómo usarla en algún lugar? (Tendría sentido si TypedReferencese documentara solo para un idioma, por ejemplo, C ++ administrado, pero si ningún idioma lo documenta y si nadie realmente puede usarlo, ¿por qué molestarse en definir la función?)
user541686
@Mehrdad Sospecho que la motivación principal era la necesidad de esta función internamente para interoperabilidad ( por ejemplo [DllImport("...")] void Foo(__arglist); ) y la implementaron en C # para su propio uso. El diseño de la CLI está influenciado por muchos idiomas (las anotaciones "El Estándar Anotado de Infraestructura de Lenguaje Común" demuestran este hecho). Ser un tiempo de ejecución adecuado para tantos idiomas como sea posible, incluidos los imprevistos, definitivamente ha sido un objetivo de diseño (de ahí que nombre) y esta es una característica que, por ejemplo, una implementación hipotética de C administrada probablemente podría beneficiarse.
Mehrdad Afshari
@Mehrdad: Ah ... sí, esa es una razón bastante convincente. ¡Gracias!
user541686
15

Bueno, no soy Eric Lippert, por lo que no puedo hablar directamente de las motivaciones de Microsoft, pero si tuviera que adivinar, diría que TypedReferenceet al. no están bien documentados porque, francamente, no los necesitas.

Cada uso que mencionó para estas características se puede lograr sin ellas, aunque con una penalización de rendimiento en algunos casos. Pero C # (y .NET en general) no está diseñado para ser un lenguaje de alto rendimiento. (Supongo que "el objetivo de rendimiento era" más rápido que Java ").

Eso no quiere decir que no se hayan tenido en cuenta ciertas consideraciones de rendimiento. De hecho, características tales como punteros stackallocy ciertas funciones de marco optimizadas existen en gran medida para aumentar el rendimiento en ciertas situaciones.

Los genéricos, que diría que tienen el beneficio principal de la seguridad de tipos, también mejoran el rendimiento de manera similar al TypedReferenceevitar el boxeo y el desempaquetado. De hecho, me preguntaba por qué preferirías esto:

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

a esto:

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

Las compensaciones, como las veo, son que la primera requiere menos JIT (y, por lo tanto, menos memoria), mientras que la segunda es más familiar y, supongo, un poco más rápido (evitando la desreferenciación del puntero).

Llamaría a mis TypedReferenceamigos y detalles de implementación. Usted ha señalado algunos usos interesantes para ellos, y creo que vale la pena explorarlos, pero se aplica la advertencia habitual de confiar en los detalles de implementación: la próxima versión puede romper su código.

Papi
fuente
44
Huh ... "no los necesitas" - Debería haberlo visto venir. :-) Eso es cierto, pero tampoco lo es. ¿Qué define como "necesidad"? ¿Son realmente "necesarios" los métodos de extensión, por ejemplo? Con respecto a su pregunta sobre el uso de genéricos en call(): es porque el código no siempre es tan coherente: me refería más a un ejemplo más como ese IAsyncResult.State, donde la introducción de genéricos simplemente no sería factible porque de repente introduciría genéricos para cada clase / método involucrado Sin embargo, +1 para la respuesta ... especialmente para señalar la parte "más rápido que Java". :]
user541686
1
Ah, y otro punto: TypedReferenceprobablemente no sufrirá cambios importantes en el corto plazo, dado que FieldInfo.SetValueDirect , que es público y probablemente utilizado por algunos desarrolladores, depende de ello. :)
user541686
Ah, pero usted no necesita métodos de extensión, a LINQ apoyo. De todos modos, en realidad no estoy hablando de una diferencia agradable / necesidad de tener. No llamaría a TypedReferenceninguno de esos. (La sintaxis atroz y la falta de manejabilidad general lo descalifican, en mi opinión, de la categoría agradable). Diría que es bueno tenerlo cuando realmente necesita recortar algunos microsegundos aquí y allá. Dicho esto, estoy pensando en un par de lugares en mi propio código que voy a ver ahora mismo, para ver si puedo optimizarlos usando las técnicas que usted señaló.
P Daddy
1
@Merhdad: estaba trabajando en un serializador / deserializador de objetos binarios en ese momento para las comunicaciones entre procesos / interhost (TCP y tuberías). Mis objetivos eran hacerlo lo más pequeño posible (en términos de bytes enviados a través del cable) y rápido (en términos de tiempo dedicado a serializar y deserializar). Pensé que podría evitar un poco de boxeo y unboxing con TypedReferences, pero IIRC, el único lugar donde pude evitar el boxeo en algún lugar fue con los elementos de matrices unidimensionales de primitivas. El ligero beneficio de velocidad aquí no valía la complejidad que agregó a todo el proyecto, así que lo eliminé.
P Daddy
1
Dada delegate void ActByRef<T1,T2>(ref T1 p1, ref T2 p2);una colección de tipos Tpodría proporcionar un método ActOnItem<TParam>(int index, ActByRef<T,TParam> proc, ref TParam param), pero el JITter tendría que crear una versión diferente del método para cada tipo de valor TParam. El uso de una referencia escrita permitiría que una versión JITted del método funcione con todos los tipos de parámetros.
supercat
4

No puedo entender si se supone que el título de esta pregunta es sarcástico: se ha establecido desde hace mucho tiempo que TypedReferencees el primo lento, hinchado y feo de los punteros administrados 'verdaderos', este último es lo que obtenemos con C ++ / CLI interior_ptr<T> , o incluso parámetros tradicionales de referencia ( ref/ out) en C # . De hecho, es bastante difícil hacer que TypedReferenceincluso alcance el rendimiento de la línea base con solo usar un número entero para volver a indexar la matriz CLR original cada vez.

Los detalles tristes están aquí , pero afortunadamente, nada de esto importa ahora ...

Esta pregunta ahora se vuelve discutible por los nuevos locales de referencia y características de devolución de referencia en C # 7

Estas nuevas características del lenguaje proporcionan un soporte destacado de primera clase en C # para declarar, compartir y manipular CLR tipos de tipo de referencia administrados verdaderos en situaciones cuidadosamente prescritas.

Las restricciones de uso no son más estrictas de lo que se requería anteriormente TypedReference(y el rendimiento está literalmente saltando de peor a mejor ), por lo que no veo ningún caso de uso concebible restante en C # para TypedReference. Por ejemplo, anteriormente no había forma de persistir TypedReferenceen el GCmontón, por lo que lo mismo ocurre con los punteros administrados superiores que ahora no es para llevar.

Y, obviamente, la desaparición de TypedReference—o su desaprobación casi completa al menos— también significa tirar __makerefbasura.

Glenn Slayden
fuente