Try-catch acelerando meu código?

1504

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 inttipos dentro do método Fibo, em vez de longtipos, 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.

Eren Ersönmez
fonte
111
@Loyd, ele tenta obter uma resposta para sua pergunta "ela realmente corre mais rápido! Mas por quê?"
Andreas Niedermair
137
Então, agora "Swallowing Exceptions" passou de uma prática ruim para uma boa otimização de desempenho: P
Luciano
2
Isso está em um contexto aritmético desmarcado ou verificado?
usar o seguinte comando
7
@ taras.roshko: Embora não deseje fazer um desserviço a Eric, essa não é realmente uma questão de C # - é uma questão de compilador JIT. A dificuldade final é trabalhar para fora porque o JIT x86 não usa tantos registros sem a try / catch como faz com o bloco try / catch.
Jon Skeet
63
Doce, então se aninhamos essas tentativas de captura, podemos ir ainda mais rápido, certo?
precisa saber é o seguinte

Respostas:

1053

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.

Eric Lippert
fonte
8
Eu sempre me perguntei por que o compilador C # gera tantos locais estranhos. Por exemplo, novas expressões de inicialização de matriz sempre geram um local, mas nunca é necessário gerar um local. Se ele permite que o JITter produza um código mensurável e com melhor desempenho, talvez o compilador C # deva ter um pouco mais de cuidado com a geração de locais desnecessários ...
Timwi
33
@ Timwi: Absolutamente. No código não otimizado, o compilador produz locais desnecessários com grande abandono, pois facilitam a depuração. No código otimizado, temporários desnecessários devem ser removidos, se possível. Infelizmente, tivemos muitos erros ao longo dos anos em que acidentalmente des otimizamos o otimizador de eliminação temporária. O engenheiro mencionado acima está refazendo completamente todo o código para o Roslyn e, como resultado, deveríamos ter muito melhor comportamento otimizado no gerador de código do Roslyn.
precisa
24
Houve algum movimento nessa questão?
Robert Harvey
10
Parece que Roslyn consertou.
Eren Ersönmez
56
Você perdeu a oportunidade de chamá-lo de "bug do JITter".
mbomb007
734

Bem, a maneira como você está cronometrando as coisas me parece bastante desagradável. Seria muito mais sensato apenas cronometrar todo o loop:

var stopwatch = Stopwatch.StartNew();
for (int i = 1; i < 100000000; i++)
{
    Fibo(100);
}
stopwatch.Stop();
Console.WriteLine("Elapsed time: {0}", stopwatch.Elapsed);

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:

  • Colocar o try / catch em torno da n++;linha ainda melhora o desempenho, mas não tanto quanto colocá-lo em torno de todo o bloco
  • Se você pegar uma exceção específica ( ArgumentExceptionnos meus testes) ainda é rápido
  • Se você imprimir a exceção no bloco catch, ainda é rápido
  • Se você repetir a exceção no bloco de captura, fica lento novamente
  • Se você usar um bloco finalmente em vez de um bloco de captura, fica lento novamente
  • Se você usar um bloco final e um bloco de captura, é rápido

Esquisito...

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 trybloco em torno de tudo entre as declarações de variável e a declaração de retorno, com apenas um catch{}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:

 [0000] push        ebp
 [0001] mov         ebp,esp
 [0003] push        edi
 [0004] push        esi
 [0005] push        ebx
 [0006] sub         esp,1Ch
 [0009] xor         eax,eax
 [000b] mov         dword ptr [ebp-20h],eax
 [000e] mov         dword ptr [ebp-1Ch],eax
 [0011] mov         dword ptr [ebp-18h],eax
 [0014] mov         dword ptr [ebp-14h],eax
 [0017] xor         eax,eax
 [0019] mov         dword ptr [ebp-18h],eax
*[001c] mov         esi,1
 [0021] xor         edi,edi
 [0023] mov         dword ptr [ebp-28h],1
 [002a] mov         dword ptr [ebp-24h],0
 [0031] inc         ecx
 [0032] mov         ebx,2
 [0037] cmp         ecx,2
 [003a] jle         00000024
 [003c] mov         eax,esi
 [003e] mov         edx,edi
 [0040] mov         esi,dword ptr [ebp-28h]
 [0043] mov         edi,dword ptr [ebp-24h]
 [0046] add         eax,dword ptr [ebp-28h]
 [0049] adc         edx,dword ptr [ebp-24h]
 [004c] mov         dword ptr [ebp-28h],eax
 [004f] mov         dword ptr [ebp-24h],edx
 [0052] inc         ebx
 [0053] cmp         ebx,ecx
 [0055] jl          FFFFFFE7
 [0057] jmp         00000007
 [0059] call        64571ACB
 [005e] mov         eax,dword ptr [ebp-28h]
 [0061] mov         edx,dword ptr [ebp-24h]
 [0064] lea         esp,[ebp-0Ch]
 [0067] pop         ebx
 [0068] pop         esi
 [0069] pop         edi
 [006a] pop         ebp
 [006b] ret

Código desmontado para a versão lenta:

 [0000] push        ebp
 [0001] mov         ebp,esp
 [0003] push        esi
 [0004] sub         esp,18h
*[0007] mov         dword ptr [ebp-14h],1
 [000e] mov         dword ptr [ebp-10h],0
 [0015] mov         dword ptr [ebp-1Ch],1
 [001c] mov         dword ptr [ebp-18h],0
 [0023] inc         ecx
 [0024] mov         esi,2
 [0029] cmp         ecx,2
 [002c] jle         00000031
 [002e] mov         eax,dword ptr [ebp-14h]
 [0031] mov         edx,dword ptr [ebp-10h]
 [0034] mov         dword ptr [ebp-0Ch],eax
 [0037] mov         dword ptr [ebp-8],edx
 [003a] mov         eax,dword ptr [ebp-1Ch]
 [003d] mov         edx,dword ptr [ebp-18h]
 [0040] mov         dword ptr [ebp-14h],eax
 [0043] mov         dword ptr [ebp-10h],edx
 [0046] mov         eax,dword ptr [ebp-0Ch]
 [0049] mov         edx,dword ptr [ebp-8]
 [004c] add         eax,dword ptr [ebp-1Ch]
 [004f] adc         edx,dword ptr [ebp-18h]
 [0052] mov         dword ptr [ebp-1Ch],eax
 [0055] mov         dword ptr [ebp-18h],edx
 [0058] inc         esi
 [0059] cmp         esi,ecx
 [005b] jl          FFFFFFD3
 [005d] mov         eax,dword ptr [ebp-1Ch]
 [0060] mov         edx,dword ptr [ebp-18h]
 [0063] lea         esp,[ebp-4]
 [0066] pop         esi
 [0067] pop         ebp
 [0068] ret

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, nisso é 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.

Jon Skeet
fonte
4
@GordonSimpson, mas no caso em que apenas uma exceção específica for detectada, todas as outras exceções não serão detectadas; portanto, qualquer sobrecarga envolvida na sua hipótese de não tentativa ainda será necessária.
perfil completo de Jon Hanna
45
Parece uma diferença na alocação de registros. A versão rápida consegue usar esi,edipara um dos longos em vez da pilha. Ele usa ebxcomo contador, onde a versão lenta usa esi.
Jeffrey Sax
13
@JeffreySax: Não são apenas quais registros são usados, mas quantos. A versão lenta usa mais espaço na pilha, tocando em menos registros. Não tenho a menor idéia do porquê ...
Jon Skeet
2
Como os quadros de exceção do CLR são tratados em termos de registros e pilha? A configuração de uma configuração liberou um registro para uso de alguma forma?
usar o seguinte comando
4
O IIRC x64 tem mais registros disponíveis que o x86. A aceleração que você viu seria consistente com a tentativa / captura, forçando o uso adicional do registro em x86.
Dan Is Fiddling Por Firelight
116

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:

interface IIndexed { int this[int index] { get; set; } }
struct StructArray : IIndexed { 
    public int[] Array;
    public int this[int index] {
        get { return Array[index]; }
        set { Array[index] = value; }
    }
}

static int Generic<T>(int length, T a, T b) where T : IIndexed {
    int sum = 0;
    for (int i = 0; i < length; i++)
        sum += a[i] * b[i];
    return sum;
}
static int Specialized(int length, StructArray a, StructArray b) {
    int sum = 0;
    for (int i = 0; i < length; i++)
        sum += a[i] * b[i];
    return sum;
}

Um é uma versão genérica do outro. Substituir o tipo genérico por StructArraytornaria os métodos idênticos. Por StructArrayser 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.

Jeffrey Sax
fonte
6
Com isso dito ... você pode forçar diferentes opções de alocação de registro sem usar um Try / Catch? Ou como teste para essa hipótese ou como uma tentativa geral de ajustar a velocidade?
WernerCD 19/01/12
1
Existem várias razões pelas quais esse caso específico pode ser diferente. Talvez seja o try-catch. Talvez seja o fato de que as variáveis ​​são reutilizadas em um escopo interno. Qualquer que seja o motivo específico, é um detalhe de implementação que você não pode contar para ser preservado, mesmo que o mesmo código exato seja chamado em um programa diferente.
Jeffrey Sax
4
@WernerCD Eu diria que o fato de C e C ++ terem uma palavra-chave para sugerir que (A) é ignorado por muitos compiladores modernos e (B) foi decidido não colocar C #, sugere que isso não é algo que nós '' Veremos de maneira mais direta.
perfil completo de Jon Hanna
2
@WernerCD - Somente se você escrever a montagem você mesmo
OrangeDog
72

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.

Hans Passant
fonte
21

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.

molhar o gorila
fonte