Surpresa de desempenho com "AS" e tipos anuláveis
-
21-09-2019 - |
Pergunta
Estou apenas revisando o capítulo 4 de C# em profundidade, que lida com tipos anuláveis, e estou adicionando uma seção sobre o uso do 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 em relação ao equivalente C# 1, usando "is" seguido por um elenco - afinal, dessa maneira só precisamos pedir verificação de tipo dinâmico uma vez e, em seguida, uma simples verificação de valor .
Parece não ser o caso, no entanto. Incluí um aplicativo de teste de amostra abaixo, que basicamente resume todos os números inteiros dentro de uma matriz de objetos - mas a matriz contém muitas referências nulas e referências de string, além de números inteiros em caixa. O benchmark mede o código que você teria que usar no C# 1, o código usando o operador "AS" e apenas para chutes uma solução LINQ. Para minha surpresa, o código C# 1 é 20 vezes mais rápido neste caso - e até o código LINQ (que eu esperava ser mais lento, dado que os iteradores envolvidos) vencem o código "AS".
É a implementação .NET de isinst
Para tipos anuláveis, muito lento? É o adicional unbox.any
Isso causa o problema? Existe outra explicação para isso? No momento, parece que vou ter que incluir um aviso contra usar isso em situações sensíveis ao desempenho ...
Resultados:
Elenco: 10000000: 121
AS: 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);
}
}
Solução
Claramente, o código da máquina que o compilador JIT pode gerar para o primeiro caso é muito mais eficiente. Uma regra que realmente ajuda a haver é que um objeto só pode ser não caixa para uma variável que tenha o mesmo tipo que o valor da caixa. Isso permite que o compilador JIT gere código muito eficiente, nenhuma conversão de valor deve ser considerada.
o é O teste do operador é fácil, basta verificar se o objeto não é nulo e é do tipo esperado, leva apenas algumas instruções de código da máquina. O elenco também é fácil, o compilador JIT conhece a localização dos bits de valor no objeto e os usa diretamente. Nenhuma cópia ou conversão ocorre, todo o código da máquina está embutido e leva apenas cerca de uma dúzia de instruções. Isso precisava ser realmente eficiente de volta no .NET 1.0 quando o boxe era comum.
Lançando para int? leva muito mais trabalho. A representação de valor do número inteiro em caixa não é compatível com o layout de memória de Nullable<int>
. Uma conversão é necessária e o código é complicado devido aos possíveis tipos de enumeração em caixa. O compilador JIT gera uma chamada para uma função CLR Helper chamada jit_unbox_nullable para fazer o trabalho. Esta é uma função de uso geral para qualquer tipo de valor, muito código para verificar os tipos. E o valor é copiado. Difícil estimar o custo, pois esse código está bloqueado dentro do mscorwks.dll, mas é provável que centenas de instruções de código da máquina.
O método de extensão linq oftype () também usa o é operador e o elenco. No entanto, este é um elenco para um tipo genérico. O compilador JIT gera uma chamada para uma função auxiliar, jit_unbox () que pode executar um elenco em um tipo de valor arbitrário. Eu não tenho uma ótima explicação por que é tão lenta quanto o elenco para Nullable<int>
, dado que menos trabalho deve ser necessário. Eu suspeito que o NGEN.EXE possa causar problemas aqui.
Outras dicas
Parece -me que o isinst
é realmente lento em tipos anuláveis. No método FindSumWithCast
eu mudei
if (o is int)
para
if (o is int?)
que também diminui significativamente a execução. A única diferença em IL que posso ver é que
isinst [mscorlib]System.Int32
é alterado para
isinst valuetype [mscorlib]System.Nullable`1<int32>
Isso começou originalmente como um comentário para a excelente resposta de Hans Passant, mas ficou muito tempo, então eu quero adicionar alguns bits aqui:
Primeiro, o C# as
O operador emitirá um isinst
Il Instrução (o mesmo acontece com o is
operador). (Outra instrução interessante é castclass
, emitiu quando você faz um elenco direto e o compilador sabe que a verificação do tempo de execução não pode ser omitida.)
Aqui está o quê isinst
faz (ECMA 335 Partição III, 4.6):
Formato: isinst typetok
typetok é um token de metadados (a
typeref
,typedef
outypespec
), indicando a classe desejada.Se typetok é um tipo de valor não indicado ou um tipo de parâmetro genérico que é interpretado como "em caixa" typetok.
Se typetok é um tipo anulável,
Nullable<T>
, é interpretado como "embalado"T
Mais importante:
Se o tipo real (não o tipo de verificador rastreado) de obj é verificador-Assignável a o tipo tipo tipoTok então
isinst
é bem -sucedido e obj (Como resultado) é retornado inalterado enquanto a verificação rastreia seu tipo como typetok. Ao contrário das coercões (§1.6) e conversões (§3.27),isinst
Nunca altera o tipo real de um objeto e preserva a identidade do objeto (consulte a Partição I).
Então, o assassino de desempenho não é isinst
neste caso, mas o adicional unbox.any
. Isso não ficou claro com a resposta de Hans, pois ele olhou apenas para o código Jited. Em geral, o compilador C# emitirá um unbox.any
após um isinst T?
(mas omitirá caso você faça isinst T
, quando T
é um tipo de referência).
Por que ele faz isso? isinst T?
nunca o efeito que teria sido óbvio, ou seja, você recebe um T?
. Em vez disso, todas essas instruções garantem que você tenha um "boxed T"
que pode ser desbote para T?
. Para obter um real T?
, ainda precisamos unir nosso "boxed T"
para T?
, é por isso que o compilador emite um unbox.any
depois isinst
. Se você pensar bem, isso faz sentido porque o "formato da caixa" para T?
é apenas um "boxed T"
e fazendo castclass
e isinst
Executar o Unbox seria inconsistente.
Apoiando a descoberta de Hans com algumas informações do padrão, aqui vai:
(ECMA 335 Partição III, 4.33): unbox.any
Quando aplicado à forma em caixa de um tipo de valor, o
unbox.any
Instrução extrai o valor contido no OBJ (do tipoO
). (É equivalente aunbox
Seguido porldobj
.) Quando aplicado a um tipo de referência, ounbox.any
a instrução tem o mesmo efeito quecastclass
typetok.
(ECMA 335 Partição III, 4.32): unbox
Tipicamente,
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 unirboxing Nullable Value Tipos. PorqueNullable<T>
Os valores são convertidos em caixaTs
Durante a operação da caixa, uma implementação geralmente deve fabricar um novoNullable<T>
na pilha e calcule o endereço para o objeto recém -alocado.
Curiosamente, eu transmiti feedback sobre o suporte do operador via dynamic
sendo uma ordem de magnitude mais lenta para Nullable<T>
(igual a Este teste inicial) - Eu suspeito por razões muito semelhantes.
Tem que amar Nullable<T>
. Outra diversão é que, embora os pontos JIT (e removam) null
Para estruturas não nulos, ele faz com que Nullable<T>
:
using System;
using System.Diagnostics;
static class Program {
static void Main() {
// JIT
TestUnrestricted<int>(1,5);
TestUnrestricted<string>("abc",5);
TestUnrestricted<int?>(1,5);
TestNullable<int>(1, 5);
const int LOOP = 100000000;
Console.WriteLine(TestUnrestricted<int>(1, LOOP));
Console.WriteLine(TestUnrestricted<string>("abc", LOOP));
Console.WriteLine(TestUnrestricted<int?>(1, LOOP));
Console.WriteLine(TestNullable<int>(1, LOOP));
}
static long TestUnrestricted<T>(T x, int loop) {
Stopwatch watch = Stopwatch.StartNew();
int count = 0;
for (int i = 0; i < loop; i++) {
if (x != null) count++;
}
watch.Stop();
return watch.ElapsedMilliseconds;
}
static long TestNullable<T>(T? x, int loop) where T : struct {
Stopwatch watch = Stopwatch.StartNew();
int count = 0;
for (int i = 0; i < loop; i++) {
if (x != null) count++;
}
watch.Stop();
return watch.ElapsedMilliseconds;
}
}
Este é o resultado de findsumwithasandhas acima:
Este é o resultado do findsumWithcast:
Descobertas:
Usando
as
, Teste primeiro se um objeto é uma instância de int32; sob o capô, está usandoisinst Int32
(que é semelhante ao código escrito à mão: se (o é int)). E usandoas
, ele também descondicionalmente desbota o objeto. E é um verdadeiro assassino de desempenho chamar uma propriedade (ainda é uma função sob o capô), IL_0027Usando o elenco, você testa primeiro se o objeto é um
int
if (o is int)
; Sob o capô, isso está usandoisinst Int32
. Se for uma instância de int, você pode desboir com segurança 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 de usar a abordagem de elenco:
if (o isinst Int32)
sum += (o unbox Int32)
Então o elenco ((int)a[i]
, bem, a sintaxe parece um elenco, mas na verdade é que a abordagem da mesma sintaxe, da próxima vez que serei pedante com a terminologia certa) a abordagem é realmente mais rápida, você só precisava para desbotar um valor quando um objeto é decididamente um int
. O mesmo não pode ser dito sobre o uso de um as
abordagem.
Profiling ainda mais:
using System;
using System.Diagnostics;
class Program
{
const int Size = 30000000;
static void Main(string[] args)
{
object[] values = new object[Size];
for (int i = 0; i < Size - 2; i += 3)
{
values[i] = null;
values[i + 1] = "";
values[i + 2] = 1;
}
FindSumWithIsThenCast(values);
FindSumWithAsThenHasThenValue(values);
FindSumWithAsThenHasThenCast(values);
FindSumWithManualAs(values);
FindSumWithAsThenManualHasThenValue(values);
Console.ReadLine();
}
static void FindSumWithIsThenCast(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("Is then Cast: {0} : {1}", sum,
(long)sw.ElapsedMilliseconds);
}
static void FindSumWithAsThenHasThenValue(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 then Has then Value: {0} : {1}", sum,
(long)sw.ElapsedMilliseconds);
}
static void FindSumWithAsThenHasThenCast(object[] values)
{
Stopwatch sw = Stopwatch.StartNew();
int sum = 0;
foreach (object o in values)
{
int? x = o as int?;
if (x.HasValue)
{
sum += (int)o;
}
}
sw.Stop();
Console.WriteLine("As then Has then Cast: {0} : {1}", sum,
(long)sw.ElapsedMilliseconds);
}
static void FindSumWithManualAs(object[] values)
{
Stopwatch sw = Stopwatch.StartNew();
int sum = 0;
foreach (object o in values)
{
bool hasValue = o is int;
int x = hasValue ? (int)o : 0;
if (hasValue)
{
sum += x;
}
}
sw.Stop();
Console.WriteLine("Manual As: {0} : {1}", sum,
(long)sw.ElapsedMilliseconds);
}
static void FindSumWithAsThenManualHasThenValue(object[] values)
{
Stopwatch sw = Stopwatch.StartNew();
int sum = 0;
foreach (object o in values)
{
int? x = o as int?;
if (o is int)
{
sum += x.Value;
}
}
sw.Stop();
Console.WriteLine("As then Manual Has then Value: {0} : {1}", sum,
(long)sw.ElapsedMilliseconds);
}
}
Resultado:
Is then Cast: 10000000 : 303
As then Has then Value: 10000000 : 3524
As then Has then Cast: 10000000 : 3272
Manual As: 10000000 : 395
As then Manual Has then Value: 10000000 : 3282
O que podemos inferir dessas figuras?
- Primeiro, a abordagem IS-Then-Cast é significativamente mais rápida do que Como abordagem. 303 vs 3524
- Segundo, o valor é marginalmente mais lento do que a fundição. 3524 vs 3272
- Terceiro, .Hasvalue é marginalmente mais lento do que usar o manual (ou seja, usando é). 3524 vs 3282
- Quarto, fazendo uma comparação de maçã a apple (ou seja, atribuindo o hasvalue simulado e a conversão de valor simulado) entre simulado como e real como abordagem, podemos ver simulado como ainda é significativamente mais rápido do que real como. 395 vs 3524
- Por fim, com base na primeira e quarta conclusão, há algo errado com Comoimplementação ^_ ^
Para manter essa resposta atualizada, vale a pena mencionar que a maior parte da discussão nesta página agora está discutível agora com C# 7.1 e .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
}
Descobri que um uso comum para a nova sintaxe é quando você está escrevendo um .NET Tipo de valor (ou seja struct
dentro C#) Isso implementa IEquatable<MyStruct>
(como a maioria deveria). Depois de implementar o fortemente tado Equals(MyStruct other)
Método, agora você pode redirecionar graciosamente os não Equals(Object obj)
substituir (herdado de Object
) a ele o seguinte:
public override bool Equals(Object obj) => obj is MyStruct o && Equals(o);
Apêndice: o Release
construir Il O código para as duas primeiras funções de exemplo mostradas acima nesta resposta (respectivamente) são fornecidas aqui. Enquanto o código da IL para a nova sintaxe é de fato 1 byte menor, ele ganha principalmente por zero chamadas (vs. dois) e evitando o unbox
Operação 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, o que substancia minha observação sobre o desempenho do novo C#7 Sintaxe superando as opções previamente disponíveis, consulte aqui (em particular, exemplo 'd').
Não tenho tempo para tentar, 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, que não explicará completamente o problema, mas pode contribuir.
Eu tentei o construto de verificação exato do tipo
typeof(int) == item.GetType()
, que tem um desempenho tão rápido quanto o item is int
versão, e sempre retorna o número (ênfase: mesmo se você escrevesse um Nullable<int>
para a matriz, você precisaria usar typeof(int)
). Você também precisa de um adicional null != item
Verifique aqui.
No entanto
typeof(int?) == item.GetType()
permanece rápido (em contraste com item is int?
), mas sempre retorna falsa.
O tipo de construção é aos meus olhos da maneira mais rápida para exato Tipo de verificação, pois usa o RunTimetypeHandle. Como os tipos exatos neste caso não combinam com o Nullable, meu palpite é, is/as
tem que fazer um peso adicional aqui para garantir que seja de fato uma instância de um tipo anulável.
E honestamente: o que é o seu is Nullable<xxx> plus HasValue
comprar você? Nada. Você sempre pode ir diretamente para o tipo (valor) subjacente (neste caso). Você obtém o valor ou "não, não uma instância do tipo que você estava pedindo". Mesmo se você escreveu (int?)null
Para a matriz, a verificação do tipo retornará falsa.
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);
FindSumWithAsAndHas(values);
FindSumWithAsAndIs(values);
FindSumWithIsThenAs(values);
FindSumWithIsThenConvert(values);
FindSumWithLinq(values);
Console.ReadLine();
}
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 FindSumWithAsAndHas(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 and Has: {0} : {1}", sum,
(long)sw.ElapsedMilliseconds);
}
static void FindSumWithAsAndIs(object[] values)
{
Stopwatch sw = Stopwatch.StartNew();
int sum = 0;
foreach (object o in values)
{
int? x = o as int?;
if (o is int)
{
sum += x.Value;
}
}
sw.Stop();
Console.WriteLine("As and Is: {0} : {1}", sum,
(long)sw.ElapsedMilliseconds);
}
static void FindSumWithIsThenAs(object[] values)
{
// Apple-to-apple comparison with Cast routine above.
// Using the similar steps in Cast routine above,
// the AS here cannot be slower than Linq.
Stopwatch sw = Stopwatch.StartNew();
int sum = 0;
foreach (object o in values)
{
if (o is int)
{
int? x = o as int?;
sum += x.Value;
}
}
sw.Stop();
Console.WriteLine("Is then As: {0} : {1}", sum,
(long)sw.ElapsedMilliseconds);
}
static void FindSumWithIsThenConvert(object[] values)
{
Stopwatch sw = Stopwatch.StartNew();
int sum = 0;
foreach (object o in values)
{
if (o is int)
{
int x = Convert.ToInt32(o);
sum += x;
}
}
sw.Stop();
Console.WriteLine("Is then Convert: {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);
}
}
Saídas:
Cast: 10000000 : 456
As and Has: 10000000 : 2103
As and Is: 10000000 : 2029
Is then As: 10000000 : 1376
Is then Convert: 10000000 : 566
LINQ: 10000000 : 1811
Edit: 2010-06-19
NOTA: O teste anterior foi realizado dentro do VS, depuração de configuração, usando o VS2009, usando o Core i7 (empresa de desenvolvimento da empresa).
O seguinte foi feito na minha máquina usando o Core 2 Duo, usando o VS2010
Inside VS, Configuration: Debug
Cast: 10000000 : 309
As and Has: 10000000 : 3322
As and Is: 10000000 : 3249
Is then As: 10000000 : 1926
Is then Convert: 10000000 : 410
LINQ: 10000000 : 2018
Outside VS, Configuration: Debug
Cast: 10000000 : 303
As and Has: 10000000 : 3314
As and Is: 10000000 : 3230
Is then As: 10000000 : 1942
Is then Convert: 10000000 : 418
LINQ: 10000000 : 1944
Inside VS, Configuration: Release
Cast: 10000000 : 305
As and Has: 10000000 : 3327
As and Is: 10000000 : 3265
Is then As: 10000000 : 1942
Is then Convert: 10000000 : 414
LINQ: 10000000 : 1932
Outside VS, Configuration: Release
Cast: 10000000 : 301
As and Has: 10000000 : 3274
As and Is: 10000000 : 3240
Is then As: 10000000 : 1904
Is then Convert: 10000000 : 414
LINQ: 10000000 : 1936