c# - use - manipulação de exceções




Try-catch acelerando meu código? (4)

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 sobre o uso de registro para código que contém um bloco try-catch vs. código que não. Isso faz com que faça diferentes opções de alocação de registros. Nesse caso, isso favorece o código com o bloco try-catch. Código diferente pode levar ao efeito oposto, então eu não contaria isso como uma técnica de aceleração de propósito geral.

No final, é muito difícil dizer qual código será executado mais rapidamente. Algo como alocação de registro e os fatores que o influenciam são detalhes de implementação de baixo nível que não vejo como uma técnica específica poderia produzir mais rapidamente um código 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;
}

Uma é uma versão genérica da outra. Substituir o tipo genérico por StructArray tornaria os métodos idênticos. Como StructArray é um tipo de valor, ele obtém sua própria versão compilada do método genérico. No entanto, o tempo real de execução é significativamente maior que o do método especializado, mas apenas para x86. Para x64, os horários são praticamente idênticos. Em outros casos, observei também diferenças para o x64.

Eu escrevi algum 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 imprime consistentemente um valor em torno de 0,96.

Quando eu enrolo 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 consistentemente 0,69 ... - na verdade, ele corre mais rápido! Mas por que?

Nota: Eu compilei isso usando a configuração Release e executei diretamente o arquivo EXE (fora do Visual Studio).

EDIT: excelente análise de Jon Skeet mostra que try-catch é de alguma forma fazendo com que o x86 CLR para usar os registradores da CPU de uma forma mais favorável neste caso específico (e acho que ainda estamos a entender por que). Eu confirmei Jon achando que o x64 CLR não tem essa diferença, e que foi mais rápido que o x86 CLR. Eu também testei usando int tipos dentro do método Fibo ao invés de tipos long , e então o x86 CLR era tão rápido quanto o x64 CLR.

UPDATE: Parece que este problema foi corrigido por Roslyn. Mesma máquina, mesma versão CLR - o problema permanece como descrito acima quando compilado com o VS 2013, mas o problema desaparece quando compilado com o VS 2015.


Bem, a maneira como você está cronometrando as coisas parece muito desagradável para mim. Seria muito mais sensato apenas tempo 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 está à mercê de pequenos timings, aritmética de ponto flutuante e erro acumulado.

Tendo feito essa mudança, veja se a versão "não-captura" ainda é mais lenta que a versão "catch".

EDIT: Ok, eu tentei eu mesmo - e estou vendo o mesmo resultado. Muito estranho. Gostaria de saber se o try / catch estava desabilitando alguns inlining ruim, mas usando [MethodImpl(MethodImplOptions.NoInlining)] vez disso não ajudou ...

Basicamente, você precisa olhar para o código JITted otimizado sob o cordbg, eu suspeito ...

EDIT: mais alguns bits de informação:

  • Colocar o try / catch em torno do 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 ( ArgumentException em meus testes) ainda é rápido
  • Se você imprimir a exceção no bloco catch ainda é rápido
  • Se você relançar a exceção no bloco catch, ele será lento novamente
  • Se você usar um bloco finally em vez de um bloco catch, ele será lento novamente
  • Se você usar um bloco finally e um bloco catch, é rápido

Esquisito...

EDIT: Ok, temos desmontagem ...

Isso é usando o compilador C # 2 e .NET 2 (32-bit) CLR, desmontando com mdbg (como eu não tenho cordbg na minha máquina). Ainda vejo os mesmos efeitos de desempenho, mesmo sob o depurador. A versão rápida usa um bloco try torno de tudo entre as declarações de variáveis ​​e a instrução de retorno, com apenas um manipulador catch{} . Obviamente, a versão lenta é a mesma, exceto sem o try / catch. O código de chamada (isto é, Main) é o mesmo em ambos os casos e tem a mesma representação de montagem (portanto, não é um problema inline).

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 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, o * mostra onde o depurador entrou em um simples "passo a passo".

EDIT: Ok, agora eu olhei através do código e acho que posso ver como cada versão funciona ... e eu acredito que a versão mais lenta é mais lenta porque usa menos registros e mais espaço de pilha. Para valores pequenos de n que é 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, de modo que o JIT também os use para o loop ... o que acontece para melhorar 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".

EDIT: 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.


Isso parece um caso de inlining indo mal. Em um núcleo x86, o jitter tem o registro ebx, edx, esi e edi disponível para armazenamento de propósito geral de variáveis ​​locais. O registrador ecx se torna disponível em um método estático, ele não precisa armazenar isso . O registro eax geralmente é necessário para cálculos. Mas estes 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, ele deve gerar código para carregar e armazená-los a partir do quadro de pilha. Isso retarda o código, evita uma otimização de processador chamada "renomear registro", um truque de otimização do núcleo do processador interno que usa várias cópias de um registro e permite a execução superescalar. Que permite que várias instruções sejam executadas simultaneamente, mesmo quando elas usam o mesmo registro. Não ter registros suficientes é um problema comum em núcleos x86, endereçados em x64 que tem 8 registros extras (r9 a r15).

O jitter fará o seu melhor para aplicar outra otimização de geração de código, ele tentará embutir 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 inline no método Main (). Otimização muito importante que, por um lado, torna as propriedades de uma classe C # de graça, dando-lhes o perf de um campo. Isso evita a sobrecarga de fazer a chamada do método e configurar seu quadro de pilha, economiza alguns nanossegundos.

Existem várias regras que determinam exatamente quando um método pode ser embutido. Eles não são exatamente documentados, mas foram mencionados em posts do blog. Uma regra é que isso não acontecerá quando o corpo do método for muito grande. Isso anula o ganho de inlining, ele gera muito código que não se encaixa bem no cache de instrução L1. Outra regra difícil que se aplica aqui é que um método não será embutido quando contiver uma instrução try / catch. O pano de fundo por trás disso é um detalhe de implementação das exceções, eles são colocados no suporte interno do Windows para o SEH (Structure Exception Handling) que é baseado no stack-frame.

Um comportamento do algoritmo de alocação de registros no jitter pode ser inferido a partir da reprodução com este código. Parece estar ciente de quando o jitter está tentando embutir 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.

Então 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 inlined tão prontamente usa edi: ebx para armazenamento para a variável longa. Você tem a versão lenta porque o jitter não sabia de antemão que o inlining não funcionaria. Só descobriu depois de gerar o código para o corpo do método.

A falha, então, é que ele não voltou e gerou novamente o código para o método. O que é compreensível, dadas as restrições de tempo em que ele tem que operar.

Esse slow-down não ocorre em x64 porque, para um, ele tem mais 8 registros. Por outro, porque ele pode armazenar um longo em apenas um registro (como rax). E a desaceleração não ocorre quando você usa int ao invés de longo, porque o jitter tem muito mais flexibilidade na escolha de registradores.


Um dos engenheiros da Roslyn especializados em entender a otimização do uso da pilha deu uma olhada nisso e relatou que parece haver um problema na interação entre o modo 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 é geração de código abaixo do ideal nas cargas e armazenamentos dos locais.

Por alguma razão, claro para todos nós, o caminho de geração de código problemático é evitado quando o JITter sabe que o bloco está em uma região protegida por teste.

Isso é muito estranho. Vamos falar com a equipe do JITter e ver se conseguimos inserir um bug para que eles consertem isso.

Além disso, estamos trabalhando em melhorias para Roslyn para os algoritmos dos compiladores C # e VB para determinar quando os locais podem se tornar "efêmeros" - ou seja, apenas colocados e empurrados na pilha, em vez de serem alocados em um local específico na pilha. a duração da ativação. Acreditamos que o JITter será capaz de fazer um melhor trabalho de alocação de registros e outras coisas se dermos melhores dicas sobre quando os habitantes locais podem ser "mortos" mais cedo.

Obrigado por nos chamar a atenção e pedir desculpas pelo comportamento estranho.







performance-testing