c# - Surpresa de desempenho com tipos "as" e anuláveis




5 Answers

Claramente, o código de máquina que o compilador JIT pode gerar para o primeiro caso é muito mais eficiente. Uma regra que realmente ajuda é que um objeto só pode ser descompactado para uma variável que tenha o mesmo tipo do valor da caixa. Isso permite que o compilador JIT gere um código muito eficiente, sem necessidade de considerar conversões de valor.

O teste do operador is é fácil, apenas verifique se o objeto não é nulo e é do tipo esperado, mas leva algumas instruções de código de máquina. O elenco também é fácil, o compilador JIT sabe a localização dos bits de valor no objeto e os usa diretamente. Nenhuma cópia ou conversão ocorre, todo código de máquina é embutido e leva apenas cerca de uma dúzia de instruções. Isso precisava ser realmente eficiente no .NET 1.0 quando o boxe era comum.

Casting para int? leva muito mais trabalho. A representação do valor do inteiro in a box não é compatível com o layout de memória do Nullable<int> . Uma conversão é necessária e o código é complicado devido a possíveis tipos de enumeração em caixa. O compilador JIT gera uma chamada para uma função auxiliar CLR chamada JIT_Unbox_Nullable para realizar o trabalho. Esta é uma função de propósito geral para qualquer tipo de valor, muitos códigos para checar os tipos. E o valor é copiado. É difícil estimar o custo, já que esse código está bloqueado dentro do mscorwks.dll, mas é provável que haja centenas de instruções de código de máquina.

O método de extensão Linq OfType () também usa o operador is e o cast. No entanto, isso é um cast para um tipo genérico. O compilador JIT gera uma chamada para uma função auxiliar, JIT_Unbox (), que pode executar uma conversão para um tipo de valor arbitrário. Eu não tenho uma grande explicação porque é tão lento quanto o elenco para Nullable<int> , dado que menos trabalho deveria ser necessário. Eu suspeito que o ngen.exe pode causar problemas aqui.

Estou apenas revisando o capítulo 4 do C # em Profundidade, que lida com tipos anuláveis, e estou adicionando uma seção sobre como usar o operador "as", que permite escrever:

object o = ...;
int? x = o as int?;
if (x.HasValue)
{
    ... // Use x.Value in here
}

Eu pensei que isso era realmente legal, e que poderia melhorar o desempenho sobre o equivalente em C # 1, usando "é" seguido de um elenco - afinal de contas, só precisamos pedir verificação dinâmica de tipo uma vez e depois uma simples verificação de valor .

Este não parece ser o caso, no entanto. Eu incluí um aplicativo de teste de amostra abaixo, que basicamente soma todos os inteiros dentro de uma matriz de objetos - mas a matriz contém muitas referências nulas e referências de string, bem como inteiros em caixa. O benchmark mede o código que você teria que usar em C # 1, o código usando o operador "as" e apenas para chutar uma solução LINQ. Para minha surpresa, o código C # 1 é 20 vezes mais rápido neste caso - e até mesmo o código LINQ (que eu esperava ser mais lento, considerando os iteradores envolvidos) bate o código "as".

A implementação do .NET do isinst para tipos anuláveis ​​é realmente muito lenta? É o unbox.any adicional. unbox.any que cause o problema? Existe outra explicação para isso? No momento, parece que vou ter que incluir um alerta contra o uso em situações sensíveis ao desempenho ...

Resultados:

Elenco: 10000000: 121
Como: 10000000: 2211
LINQ: 10000000: 2143

Código:

using System;
using System.Diagnostics;
using System.Linq;

class Test
{
    const int Size = 30000000;

    static void Main()
    {
        object[] values = new object[Size];
        for (int i = 0; i < Size - 2; i += 3)
        {
            values[i] = null;
            values[i+1] = "";
            values[i+2] = 1;
        }

        FindSumWithCast(values);
        FindSumWithAs(values);
        FindSumWithLinq(values);
    }

    static void FindSumWithCast(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {
            if (o is int)
            {
                int x = (int) o;
                sum += x;
            }
        }
        sw.Stop();
        Console.WriteLine("Cast: {0} : {1}", sum, 
                          (long) sw.ElapsedMilliseconds);
    }

    static void FindSumWithAs(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {
            int? x = o as int?;
            if (x.HasValue)
            {
                sum += x.Value;
            }
        }
        sw.Stop();
        Console.WriteLine("As: {0} : {1}", sum, 
                          (long) sw.ElapsedMilliseconds);
    }

    static void FindSumWithLinq(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = values.OfType<int>().Sum();
        sw.Stop();
        Console.WriteLine("LINQ: {0} : {1}", sum, 
                          (long) sw.ElapsedMilliseconds);
    }
}



Isso originalmente começou como um Comentário à excelente resposta de Hans Passant, mas ficou muito tempo, então quero adicionar alguns bits aqui:

Primeiro, o operador C # irá emitir uma instrução isinst (assim como o operador is ). (Outra instrução interessante é castclass , castclass quando você faz uma castclass direta e o compilador sabe que a verificação de tempo de execução não pode ser omitida.)

Aqui está o que isinst faz ( ECMA 335 Partition III, 4.6 ):

Formato: isinst typeTok

typeTok é um token de metadados (um typeref , typedef ou typespec ), indicando a classe desejada.

Se typeTok é um tipo de valor não anulável ou um tipo de parâmetro genérico, ele é interpretado como typeTok “em caixa”.

Se typeTok é um tipo anulável, Nullable<T> , ele é interpretado como "boxed" T

Mais importante:

Se o tipo real (não o tipo rastreado pelo verificador) de obj for atribuível ao verificador - para o tipo typeTok, então isinst bem-sucedido e obj (como resultado ) será retornado inalterado, enquanto a verificação rastreará seu tipo como typeTok . Ao contrário das coerções (§1.6) e conversões (§3.27), o isinst nunca altera o tipo real de um objeto e preserva a identidade do objeto (veja a Partição I).

Portanto, o matador de desempenho não é isinst neste caso, mas o unbox.any adicional. Isso não ficou claro na resposta de Hans, já que ele olhava apenas para o código do JIT. Em geral, o compilador C # emitirá uma unbox.any após um isinst T? (mas omitirá no caso de você fazer isinst T , quando T é um tipo de referência).

Por que ele faz isso? isinst T? nunca tem o efeito que teria sido óbvio, ou seja, você recebe um T? . Em vez disso, todas essas instruções garantem que você tem um "boxed T" que pode ser desmarcado para T? . Para obter um T? real T? , ainda precisamos desmarcar nosso "boxed T" para T? , é por isso que o compilador emite um unbox.any depois isinst . Se você pensar sobre isso, isso faz sentido porque o "formato de caixa" para T? é apenas um "boxed T" e fazer castclass e isinst executar o unbox seria inconsistente.

Apoiando a descoberta de Hans com algumas informações do padrão , aqui vai:

(ECMA 335 Partition III, 4.33): unbox.any

Quando aplicada à forma de caixa de um tipo de valor, a instrução unbox.any extrai o valor contido em obj (do tipo O ). (É equivalente a unbox seguido por ldobj .) Quando aplicado a um tipo de referência, a instrução unbox.any tem o mesmo efeito que o castclass castclass.

(ECMA 335 Partition III, 4.32): unbox

Normalmente, unbox simplesmente calcula o endereço do tipo de valor que já está presente dentro do objeto em caixa. Essa abordagem não é possível ao desativar tipos de valor anuláveis. Como valores Nullable<T> são convertidos em Ts em caixa durante a operação de caixa, uma implementação geralmente deve fabricar um novo Nullable<T> no heap e computar o endereço para o objeto recém-alocado.




Este é o resultado de FindSumWithAsAndHas acima: alt text http://www.freeimagehosting.net/uploads/9e3c0bfb75.png

Este é o resultado de FindSumWithCast: alt text http://www.freeimagehosting.net/uploads/ce8a5a3934.png

Resultados:

  • Usando as , teste primeiro se um objeto é uma instância de Int32; sob o capô está usando o isinst Int32 (que é semelhante ao código escrito à mão: if (o is int)). E usando as , ele também unbox incondicionalmente o objeto. E é um verdadeiro matador de desempenho para chamar uma propriedade (ainda é uma função sob o capô), IL_0027

  • Usando cast, você testa primeiro se o objeto é um int if (o is int) ; sob o capô isso está usando isinst Int32 . Se é uma instância de int, então você pode seguramente unbox o valor, IL_002D

Simplificando, este é o pseudo-código de usar as abordagem:

int? x;

(x.HasValue, x.Value) = (o isinst Int32, o unbox Int32)

if (x.HasValue)
    sum += x.Value;    

E este é o pseudo-código do uso da abordagem cast:

if (o isinst Int32)
    sum += (o unbox Int32)

Então o elenco ( (int)a[i] , bem a sintaxe parece um elenco, mas na verdade é unboxing, cast e unboxing compartilham a mesma sintaxe, da próxima vez eu vou ser pedante com a terminologia correta) abordagem é realmente mais rápido, você só precisava desmarcar um valor quando um objeto é decididamente um int . A mesma coisa não pode ser dita com o uso de uma abordagem.




Eu não tenho tempo para experimentar, mas você pode querer ter:

foreach (object o in values)
        {
            int? x = o as int?;

Como

int? x;
foreach (object o in values)
        {
            x = o as int?;

Você está criando um novo objeto a cada vez, o que não explica completamente o problema, mas pode contribuir.




Para manter esta resposta atualizada, vale a pena mencionar que a maior parte da discussão nesta página agora é discutível agora com o C # 7.1 eo .NET 4.7, que suporta uma sintaxe esbelta que também produz o melhor código IL.

O exemplo original do OP ...

object o = ...;
int? x = o as int?;
if (x.HasValue)
{
    // ...use x.Value in here
}

torna-se simplesmente ...

if (o is int x)
{
    // ...use x in here
}

Eu descobri que um uso comum para a nova sintaxe é quando você está escrevendo um tipo de valor .NET (ou seja, struct em C # ) que implementa IEquatable<MyStruct> (como a maioria deve). Depois de implementar o método Equals(MyStruct other) fortemente tipado, agora você pode Equals(MyStruct other) redirecionar a substituição Equals(Object obj) não tipificada (herdada de Object ) para ele da seguinte maneira:

public override bool Equals(Object obj) => obj is MyStruct o && Equals(o);

Apêndice: O código IL da versão Release para as duas primeiras funções de exemplo mostradas acima nesta resposta (respectivamente) é dado aqui. Enquanto o código de IL para a nova sintaxe é de fato 1 byte menor, ele ganha muito grande fazendo chamadas zero (vs. dois) e evitando a operação unbox completamente quando possível.

// static void test1(Object o, ref int y)
// {
//     int? x = o as int?;
//     if (x.HasValue)
//         y = x.Value;
// }

[0] valuetype [mscorlib]Nullable`1<int32> x
        ldarg.0
        isinst [mscorlib]Nullable`1<int32>
        unbox.any [mscorlib]Nullable`1<int32>
        stloc.0
        ldloca.s x
        call instance bool [mscorlib]Nullable`1<int32>::get_HasValue()
        brfalse.s L_001e
        ldarg.1
        ldloca.s x
        call instance !0 [mscorlib]Nullable`1<int32>::get_Value()
        stind.i4
L_001e: ret

// static void test2(Object o, ref int y)
// {
//     if (o is int x)
//         y = x;
// }

[0] int32 x,
[1] object obj2
        ldarg.0
        stloc.1
        ldloc.1
        isinst int32
        ldnull
        cgt.un
        dup
        brtrue.s L_0011
        ldc.i4.0
        br.s L_0017
L_0011: ldloc.1
        unbox.any int32
L_0017: stloc.0
        brfalse.s L_001d
        ldarg.1
        ldloc.0
        stind.i4
L_001d: ret

Para testes adicionais que substanciam minha observação sobre o desempenho da nova sintaxe C # 7 superando as opções anteriormente disponíveis, veja here (em particular, o exemplo 'D').




Related

c# performance clr nullable unboxing