Eu escrevi um código para testar o impacto do try-catch, mas vendo alguns resultados surpreendentes.
static void Main(string[] args)
{
Thread.CurrentThread.Priority = ThreadPriority.Highest;
Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.RealTime;
long start = 0, stop = 0, elapsed = 0;
double avg = 0.0;
long temp = Fibo(1);
for (int i = 1; i < 100000000; i++)
{
start = Stopwatch.GetTimestamp();
temp = Fibo(100);
stop = Stopwatch.GetTimestamp();
elapsed = stop - start;
avg = avg + ((double)elapsed - avg) / i;
}
Console.WriteLine("Elapsed: " + avg);
Console.ReadKey();
}
static long Fibo(int n)
{
long n1 = 0, n2 = 1, fibo = 0;
n++;
for (int i = 1; i < n; i++)
{
n1 = n2;
n2 = fibo;
fibo = n1 + n2;
}
return fibo;
}
No meu computador, isso sempre imprime um valor em torno de 0,96.
Quando envolvo o loop for dentro do Fibo () com um bloco try-catch como este:
static long Fibo(int n)
{
long n1 = 0, n2 = 1, fibo = 0;
n++;
try
{
for (int i = 1; i < n; i++)
{
n1 = n2;
n2 = fibo;
fibo = n1 + n2;
}
}
catch {}
return fibo;
}
Agora, ele imprime 0,69 de forma consistente ... - na verdade, corre mais rápido! Mas por que?
Nota: compilei isso usando a configuração da versão e executei diretamente o arquivo EXE (fora do Visual Studio).
EDIT: A excelente análise de Jon Skeet mostra que o try-catch está de alguma forma fazendo com que o x86 CLR use os registros da CPU de uma maneira mais favorável neste caso específico (e acho que ainda precisamos entender o porquê). Confirmei a descoberta de Jon de que o x64 CLR não tem essa diferença e que era mais rápido que o x86 CLR. Também testei usando int
tipos dentro do método Fibo, em vez de long
tipos, e então o x86 CLR foi tão rápido quanto o x64 CLR.
ATUALIZAÇÃO: Parece que esse problema foi corrigido por Roslyn. Mesma máquina, mesma versão do CLR - o problema permanece como acima quando compilado com o VS 2013, mas o problema desaparece quando compilado com o VS 2015.
Respostas:
Um dos engenheiros da Roslyn , especializado em entender a otimização do uso da pilha, examinou isso e me informou que parece haver um problema na interação entre a maneira como o compilador C # gera armazenamentos de variáveis locais e a maneira como o compilador JIT se registra agendamento no código x86 correspondente. O resultado é uma geração de código abaixo do ideal nas cargas e lojas dos locais.
Por algum motivo pouco claro para todos nós, o caminho problemático de geração de código é evitado quando o JITter sabe que o bloco está em uma região protegida por tentativa.
Isso é bem estranho. Vamos acompanhar a equipe do JITter e ver se conseguimos inserir um bug para que eles possam corrigir isso.
Além disso, estamos trabalhando em melhorias para Roslyn nos algoritmos dos compiladores C # e VB para determinar quando os locais podem ser "efêmeros" - isto é, apenas pressionados e exibidos na pilha, em vez de alocar um local específico na pilha para a duração da ativação. Acreditamos que o JITter será capaz de fazer um trabalho melhor de alocação de registros e outros enfeites, se dermos dicas melhores sobre quando os locais podem ser "mortos" antes.
Agradecemos por trazer isso à nossa atenção e desculpas pelo comportamento estranho.
fonte
Bem, a maneira como você está cronometrando as coisas me parece bastante desagradável. Seria muito mais sensato apenas cronometrar todo o loop:
Dessa forma, você não estará à mercê de tempos minúsculos, aritmética de ponto flutuante e erro acumulado.
Depois de fazer essa alteração, verifique se a versão "non-catch" ainda é mais lenta que a versão "catch".
EDIT: Ok, eu já tentei - e estou vendo o mesmo resultado. Muito estranho. Gostaria de saber se o try / catch estava desativando alguns inlining ruins, mas o uso
[MethodImpl(MethodImplOptions.NoInlining)]
não ajudou ...Basicamente, você precisará olhar para o código JITted otimizado em cordbg, suspeito ...
EDIT: Mais algumas informações:
n++;
linha ainda melhora o desempenho, mas não tanto quanto colocá-lo em torno de todo o blocoArgumentException
nos meus testes) ainda é rápidoEsquisito...
EDIT: Ok, temos desmontagem ...
Isso está usando o compilador C # 2 e o .NET 2 (32 bits) CLR, desmontando com mdbg (como não tenho cordbg na minha máquina). Eu ainda vejo os mesmos efeitos de desempenho, mesmo sob o depurador. A versão rápida usa um
try
bloco em torno de tudo entre as declarações de variável e a declaração de retorno, com apenas umcatch{}
manipulador. Obviamente, a versão lenta é a mesma, exceto sem a tentativa / captura. O código de chamada (ou seja, Principal) é o mesmo em ambos os casos e tem a mesma representação de montagem (portanto, não é um problema interno).Código desmontado para versão rápida:
Código desmontado para a versão lenta:
Em cada caso, os
*
shows onde o depurador entrou em um simples "passo a passo".EDIT: Ok, agora examinei o código e acho que posso ver como cada versão funciona ... e acredito que a versão mais lenta é mais lenta porque usa menos registros e mais espaço de pilha. Para valores pequenos,
n
isso é possivelmente mais rápido - mas quando o loop ocupa a maior parte do tempo, é mais lento.Possivelmente, o bloco try / catch força mais registros a serem salvos e restaurados, então o JIT também os usa para o loop ... o que melhora o desempenho geral. Não está claro se é uma decisão razoável para o JIT não usar tantos registros no código "normal".
Edição: Apenas tentei isso na minha máquina x64. O x64 CLR é muito mais rápido (cerca de 3-4 vezes mais rápido) que o x86 CLR neste código e, em x64, o bloco try / catch não faz uma diferença perceptível.
fonte
esi,edi
para um dos longos em vez da pilha. Ele usaebx
como contador, onde a versão lenta usaesi
.As desmontagens de Jon mostram que a diferença entre as duas versões é que a versão rápida usa um par de registradores (
esi,edi
) para armazenar uma das variáveis locais onde a versão lenta não.O compilador JIT faz suposições diferentes em relação ao uso do registro para código que contém um bloco try-catch vs. código que não contém. Isso faz com que faça diferentes escolhas de alocação de registro. Nesse caso, isso favorece o código com o bloco try-catch. Código diferente pode levar ao efeito oposto, portanto, eu não consideraria isso como uma técnica de aceleração de uso geral.
No final, é muito difícil dizer qual código acabará sendo executado mais rapidamente. Algo como alocação de registro e os fatores que a influenciam são detalhes de implementação de baixo nível que não vejo como nenhuma técnica específica possa produzir código mais rápido de maneira confiável.
Por exemplo, considere os dois métodos a seguir. Eles foram adaptados de um exemplo da vida real:
Um é uma versão genérica do outro. Substituir o tipo genérico por
StructArray
tornaria os métodos idênticos. PorStructArray
ser um tipo de valor, ele obtém sua própria versão compilada do método genérico. No entanto, o tempo de execução real é significativamente maior que o do método especializado, mas apenas para x86. Para x64, os tempos são praticamente idênticos. Em outros casos, também observei diferenças no x64.fonte
Parece um caso de inlining que deu errado. Em um núcleo x86, o jitter possui os registros ebx, edx, esi e edi disponíveis para armazenamento de uso geral de variáveis locais. O registo ecx se torna disponível em um método estático, ele não tem para armazenar este . O registro eax geralmente é necessário para cálculos. Mas esses são registradores de 32 bits; para variáveis do tipo long, ele deve usar um par de registradores. Quais são edx: eax para cálculos e edi: ebx para armazenamento.
Qual é o que se destaca na desmontagem para a versão lenta, nem edi nem ebx são usados.
Quando o jitter não consegue encontrar registros suficientes para armazenar variáveis locais, deve gerar código para carregar e armazená-las no quadro da pilha. Isso reduz a velocidade do código, evita a otimização do processador denominada "renomeação de registrador", um truque interno de otimização do núcleo do processador que usa várias cópias de um registrador e permite execução superescalar. O que permite que várias instruções sejam executadas simultaneamente, mesmo quando eles usam o mesmo registro. Não ter registros suficientes é um problema comum nos núcleos x86, endereçados no x64, que possui 8 registros extras (r9 a r15).
O jitter fará o possível para aplicar outra otimização de geração de código; tentará incorporar seu método Fibo (). Em outras palavras, não faça uma chamada para o método, mas gere o código para o método embutido no método Main (). Otimização bastante importante que, por um lado, cria propriedades de uma classe C # gratuitamente, fornecendo o desempenho de um campo. Isso evita a sobrecarga de fazer a chamada de método e a configuração de seu quadro de pilha, economiza alguns nanossegundos.
Existem várias regras que determinam exatamente quando um método pode ser incorporado. Eles não estão exatamente documentados, mas foram mencionados nas postagens do blog. Uma regra é que isso não acontecerá quando o corpo do método for muito grande. Isso impede o ganho de inlining, gera muito código que não se encaixa tão bem no cache de instruções L1. Outra regra rígida que se aplica aqui é que um método não será incorporado quando contiver uma instrução try / catch. O pano de fundo por trás disso é um detalhe de implementação de exceções, eles se voltam para o suporte interno do Windows para SEH (Structure Exception Handling), que é baseado em frame de pilha.
Um comportamento do algoritmo de alocação de registro no jitter pode ser inferido ao jogar com esse código. Parece estar ciente de quando o tremor está tentando incorporar um método. Uma regra parece usar que apenas o par de registradores edx: eax pode ser usado para código embutido que possui variáveis locais do tipo long. Mas não edi: ebx. Sem dúvida, porque isso seria muito prejudicial para a geração de código para o método de chamada, edi e ebx são importantes registros de armazenamento.
Portanto, você obtém a versão rápida porque o jitter sabe de antemão que o corpo do método contém instruções try / catch. Ele sabe que nunca pode ser incorporado, portanto, usa prontamente o edi: ebx para armazenamento da variável longa. Você adquiriu a versão lenta porque o tremor não sabia de antemão que inlining não funcionaria. Ele só descobriu depois de gerar o código para o corpo do método.
O problema é que ele não voltou e gerou novamente o código do método. O que é compreensível, dadas as restrições de tempo em que ele deve operar.
Essa desaceleração não ocorre no x64 porque, para um, possui mais 8 registros. Por outro, porque pode armazenar um longo em apenas um registro (como rax). E a desaceleração não ocorre quando você usa int, em vez de longo, porque o jitter tem muito mais flexibilidade na seleção de registros.
fonte
Eu colocaria isso como um comentário, pois não tenho certeza de que provavelmente seja esse o caso, mas, pelo que me lembro, uma declaração try / except não envolve uma modificação na maneira como o mecanismo de descarte de lixo é usado. o compilador funciona, na medida em que limpa as alocações de memória do objeto de forma recursiva da pilha. Pode não haver um objeto a ser resolvido nesse caso ou o loop for pode constituir um fechamento que o mecanismo de coleta de lixo reconheça suficiente para impor um método de coleta diferente. Provavelmente não, mas achei que vale a pena mencionar, pois não o vi discutido em nenhum outro lugar.
fonte