Por que o TypedReference está nos bastidores? É tão rápido e seguro ... quase mágico!

128

Aviso: Esta pergunta é um pouco herética ... programadores religiosos sempre respeitando as boas práticas, por favor, não a leia. :)

Alguém sabe por que o uso de TypedReference é tão desencorajado (implicitamente, por falta de documentação)?

Encontrei ótimos usos para ele, como ao passar parâmetros genéricos por funções que não devem ser genéricas (ao usar um objectpode ser um exagero ou lento, se você precisar de um tipo de valor), para quando você precisar de um ponteiro opaco ou para quando você precisar acessar rapidamente um elemento de uma matriz, cujas especificações você encontra em tempo de execução (usando Array.InternalGetReference). Como o CLR nem sequer permite o uso incorreto desse tipo, por que é desencorajado? Não parece ser inseguro ou algo assim ...


Outros usos que encontrei para TypedReference:

Genéricos "especializados" em C # (isso é seguro para o tipo):

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

Escrevendo código que funciona com ponteiros genéricos (isso é muito inseguro se mal utilizado, mas rápido e seguro se usado corretamente):

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

Escrevendo uma versão do método da sizeofinstrução, que pode ser ocasionalmente ú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)); }
    }
}

Escrevendo um método que passa um parâmetro "state" que deseja evitar o boxe:

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

Então, por que usos como esse são "desencorajados" (por falta de documentação)? Algum motivo de segurança específico? Parece perfeitamente seguro e verificável se não estiver misturado com ponteiros (que não são seguros ou verificáveis) ...


Atualizar:

Código de exemplo para mostrar que, de fato, TypedReferencepode ser duas vezes mais rápido (ou mais):

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: editei o benchmark acima, pois a última versão do post usou uma versão de depuração do código [esqueci de alterá-lo para liberar] e não pressionei o GC. Essa versão é um pouco mais realista e no meu sistema, é três vezes mais rápido, TypedReferenceem média.)

user541686
fonte
Quando executo seu exemplo, obtenho resultados completamente diferentes. TypedReference: 203 ticks, boxing/unboxing: 31 ticks. Não importa o que eu tente (incluindo diferentes maneiras de fazer o tempo), o boxe / unboxing ainda é mais rápido no meu sistema.
Seph
1
@ Seph: Acabei de ver seu comentário. Isso é muito interessante - parece ser mais rápido no x64, mas mais lento no x86. Estranho ...
user541686
1
Acabei de testar esse código de referência na minha máquina x64 no .NET 4.5. Substituí Environment.TickCount por Diagnostics.Stopwatch e fui com ms em vez de carrapatos. Eu executei cada compilação (x86, 64, Qualquer) três vezes. O melhor dos três resultados foi o seguinte: x86: 205 / 27ms (o mesmo resultado para 2/3 de execução nesta compilação) x64: 218 / 109ms Qualquer: 205 / 27ms (o mesmo resultado para 2/3 de execução nesta compilação) todos os casos caixa / unboxing foi mais rápido.
precisa saber é o seguinte
2
As estranhas medições de velocidade podem ser atribuídas a esses dois fatos: * (T) (objeto) v NÃO realmente faz uma alocação de heap. No .NET 4+, ele é otimizado. Não há alocações nesse caminho, e é muito rápido. * O uso do makeref requer que a variável seja realmente alocada na pilha (enquanto o método kinda-box pode otimizá-la em registros). Além disso, observando os horários, presumo que isso prejudique o alinhamento, mesmo com o sinalizador force-inline. Então meio-box é embutido e enregistered, enquanto makeref faz uma chamada de função e opera a pilha
hypersw
1
Para ver os lucros da typeref casting, torne-a menos trivial. Por exemplo, converter um tipo subjacente no tipo de enumeração ( int-> DockStyle). Esta caixa é real e é quase dez vezes mais lenta.
hypersw

Respostas:

42

Resposta curta: portabilidade .

Enquanto __arglist, __makerefe __refvaluesão extensões de linguagem e estão em situação irregular na Especificação Linguagem C #, as construções usadas para implementá-las sob o capô ( varargconvenção de chamada, TypedReferencetipo, arglist, refanytype, mkanyref, e refanyvalinstruções) estão perfeitamente documentados na especificação CLI (ECMA-335) , em a biblioteca Vararg .

A definição na Biblioteca Vararg deixa bem claro que eles são principalmente destinados a suportar listas de argumentos de tamanho variável e não muito mais. As listas de argumentos variáveis ​​têm pouco uso em plataformas que não precisam fazer interface com código C externo que usa varargs. Por esse motivo, a biblioteca Varargs não faz parte de nenhum perfil da CLI. As implementações legítimas da CLI podem optar por não oferecer suporte à biblioteca Varargs, pois ela não está incluída no perfil do Kernel da CLI:

4.1.6 Vararg

O conjunto de recursos vararg suporta listas de argumentos de comprimento variável e ponteiros do tipo tempo de execução.

Se omitido: Qualquer tentativa de referenciar um método com a varargconvenção de chamada ou as codificações de assinatura associadas aos métodos vararg (consulte a Partição II) deve lançar a System.NotImplementedExceptionexceção. Métodos que utilizam as instruções CIL arglist, refanytype, mkrefany, e refanyvaldeve lançar a System.NotImplementedExceptionexceção. O momento exato da exceção não está especificado. O tipo System.TypedReferencenão precisa ser definido.

Atualização (resposta ao GetValueDirectcomentário):

FieldInfo.GetValueDirectsão FieldInfo.SetValueDirectsão não parte da Base Class Library. Observe que há uma diferença entre a Biblioteca de classes do .NET Framework e a Biblioteca de classes base. O BCL é a única coisa necessária para uma implementação em conformidade da CLI / C # e está documentado no ECMA TR / 84 . (De fato, FieldInfoele faz parte da biblioteca Reflection e também não está incluído no perfil do Kernel da CLI).

Assim que você usa um método fora da BCL, está perdendo um pouco de portabilidade (e isso está se tornando cada vez mais importante com o advento de implementações de CLI não .NET, como Silverlight e MonoTouch). Mesmo que uma implementação quisesse aumentar a compatibilidade com a Biblioteca de Classes do Microsoft .NET Framework, ela poderia simplesmente fornecer GetValueDirecte SetValueDirectreceber um TypedReferencesem fazer o TypedReferenceespecialmente tratado pelo tempo de execução (basicamente, tornando-o equivalente aos seus objectcolegas sem o benefício de desempenho).

Se eles tivessem documentado em C #, teriam pelo menos algumas implicações:

  1. Como qualquer recurso, ele pode se tornar um obstáculo para novos recursos, especialmente porque esse não se encaixa no design do C # e requer extensões de sintaxe estranhas e entrega especial de um tipo pelo tempo de execução.
  2. Todas as implementações de C # precisam, de alguma forma, implementar esse recurso e não é necessariamente trivial / possível para implementações de C # que não são executadas na parte superior de uma CLI ou executadas na parte superior de uma CLI sem Varargs.
Mehrdad Afshari
fonte
4
Bons argumentos para portabilidade, +1. Mas FieldInfo.GetValueDirecte quanto FieldInfo.SetValueDirect? Eles fazem parte do BCL e, para usá-los de que você precisa TypedReference , isso basicamente não força TypedReferencea sempre ser definido, independentemente da especificação do idioma? (Além disso, outra observação: mesmo que as palavras-chave não existissem, desde que as instruções existissem, você ainda poderia acessá-las emitindo métodos dinamicamente ... portanto, desde que sua plataforma interaja com as bibliotecas C, você pode usá-las, se C # tem ou não as palavras-chave.)
user541686
Ah, e outra questão: mesmo que não seja portátil, por que eles não documentaram as palavras-chave? No mínimo, é necessário quando você interage com C varargs, pelo menos eles poderiam ter mencionado isso?
user541686
@ Mehrdad: Ah, isso é interessante. Acho que sempre assumi que os arquivos na pasta BCL da fonte .NET fazem parte da BCL, nunca prestando realmente atenção à parte de padronização da ECMA. Isso é bastante convincente ... exceto uma coisinha: não é um pouco inútil incluir o recurso (opcional) na especificação da CLI, se não houver documentação sobre como usá-lo em qualquer lugar? (Faria sentido se TypedReferencefosse documentado apenas para um idioma - digamos, C ++ gerenciado - mas se nenhum idioma o documentar e, se ninguém puder usá-lo, então por que se preocupar em definir o recurso?)
user541686
@ Mehrdad Suspeito que a principal motivação tenha sido a necessidade desse recurso internamente para interoperabilidade ( por exemplo [DllImport("...")] void Foo(__arglist); ) e eles o implementaram em C # para uso próprio. O design da CLI é influenciado por muitos idiomas (as anotações "O padrão anotado da infra-estrutura de linguagem comum" demonstram esse fato.) Ser um tempo de execução adequado para o maior número possível de idiomas, incluindo os imprevistos, foi definitivamente um objetivo de design (daí o nome) e esse é um recurso do qual, por exemplo, uma implementação C gerenciada hipotética provavelmente poderia se beneficiar.
Mehrdad Afshari
@ Mehrdad: Ah ... sim, essa é uma razão bastante convincente. Obrigado!
user541686
15

Bem, eu não sou Eric Lippert, então não posso falar diretamente das motivações da Microsoft, mas se eu tivesse que adivinhar um palpite, diria isso TypedReferenceet al. não estão bem documentados porque, francamente, você não precisa deles.

Todo uso que você mencionou para esses recursos pode ser realizado sem eles, embora com uma penalidade de desempenho em alguns casos. Mas o C # (e o .NET em geral) não foi projetado para ser uma linguagem de alto desempenho. (Suponho que "mais rápido que Java" era o objetivo de desempenho.)

Isso não quer dizer que certas considerações de desempenho não tenham sido fornecidas. De fato, recursos como ponteiros stackalloce certas funções otimizadas da estrutura existem em grande parte para aumentar o desempenho em determinadas situações.

Os genéricos, que eu diria que têm o principal benefício da segurança de tipo, também melhoram o desempenho da mesma forma que TypedReferenceevitam boxe e unboxing. Na verdade, eu queria saber por que você prefere isso:

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

para isso:

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

As compensações, como eu as vejo, são que a primeira requer menos JITs (e, a seguir, menos memória), enquanto a segunda é mais familiar e, eu diria, um pouco mais rápida (evitando a desreferenciação de ponteiro).

Eu chamaria TypedReferencee detalhes de implementação de amigos. Você apontou alguns usos interessantes para eles, e acho que vale a pena explorar, mas a advertência usual de confiar nos detalhes da implementação se aplica - a próxima versão pode quebrar seu código.

P Papai
fonte
4
Huh ... "você não precisa deles" - eu deveria ter visto isso chegando. :-) Isso é verdade, mas também não é verdade. O que você define como "necessidade"? Os métodos de extensão são realmente "necessários", por exemplo? Com relação à sua pergunta sobre o uso de genéricos em call(): É porque o código nem sempre é tão coeso - eu estava me referindo mais a um exemplo mais parecido com o de IAsyncResult.Stateonde a introdução de genéricos simplesmente não seria viável porque, de repente, introduziria genéricos para toda classe / método envolvido. +1 para a resposta, no entanto ... especialmente para apontar a parte "mais rápido que Java". :]
user541686
1
Ah, e outro ponto: TypedReferenceprovavelmente não sofrerá alterações significativas tão cedo, dado que o FieldInfo.SetValueDirect , que é público e provavelmente usado por alguns desenvolvedores, depende disso. :)
user541686
Ah, mas você não precisa de métodos de extensão, para LINQ apoio. De qualquer forma, não estou falando realmente de uma diferença interessante de ter / ter que ter. Eu não ligaria para TypedReferencenenhum deles. (A sintaxe atroz e a dificuldade geral a desqualificam, na minha opinião, da categoria de bom ter.) Eu diria que é uma coisa boa de se ter quando você realmente precisa cortar alguns microssegundos aqui e ali. Dito isso, estou pensando em alguns lugares no meu próprio código que vou ver agora, para ver se posso otimizá-los usando as técnicas que você apontou.
P papai
1
@ Merhdad: Eu estava trabalhando em um serializador / desserializador de objetos binários na época para comunicações entre processos / interhost (TCP e pipes). Meu objetivo era torná-lo o mais pequeno possível (em termos de bytes enviados por fio) e rápido (em termos de tempo gasto serializando e desserializando). Pensei em evitar o boxe e o unboxing com TypedReferences, mas o IIRC, o único lugar em que consegui evitar o boxe em algum lugar, foi com os elementos das matrizes unidimensionais de primitivas. O pequeno benefício de velocidade aqui não valia a complexidade adicionada a todo o projeto, então eu o retirei.
P papai
1
Dado que delegate void ActByRef<T1,T2>(ref T1 p1, ref T2 p2);uma coleção de tipos Tpoderia fornecer um método ActOnItem<TParam>(int index, ActByRef<T,TParam> proc, ref TParam param), o JITter precisaria criar uma versão diferente do método para cada tipo de valor TParam. O uso de uma referência digitada permitiria que uma versão JITted do método funcionasse com todos os tipos de parâmetros.
supercat
4

Não consigo descobrir se o título dessa pergunta deve ser sarcástico: há muito tempo que TypedReferenceé o primo lento, inchado e feio dos ponteiros gerenciados 'verdadeiros', sendo o último o que obtemos com C ++ / CLI interior_ptr<T> ou até os parâmetros tradicionais de referência por ( ref/ out) em C # . De fato, é muito difícil TypedReferencealcançar o desempenho da linha de base usando apenas um número inteiro para reindexar sempre a matriz CLR original.

Os detalhes tristes estão aqui , mas felizmente, nada disso importa agora ...

Agora, esta questão é discutida pelos novos recursos locais de ref e retorno de ref no C # 7

Esses novos recursos de idioma fornecem suporte proeminente e de primeira classe em C # para declarar, compartilhar e manipular CLR tipos de tipos de referência gerenciados verdadeiros em situações cuidadosamente prescritas.

As restrições de uso não são mais rigorosas do que as exigidas anteriormente TypedReference(e o desempenho está literalmente saltando do pior para o melhor ), portanto, não vejo nenhum caso de uso restante em C # para TypedReference. Por exemplo, anteriormente não havia como persistir um TypedReferencena GCpilha, portanto, o mesmo se aplica aos ponteiros gerenciados superiores agora não é uma retirada.

E, obviamente, o desaparecimento TypedReference- ou sua depreciação quase completa, pelo menos - significa jogar __makereffora também.

Glenn Slayden
fonte