Performance no chaveamento de enumerações

De ccppbrasil.org

Ou ainda: qual é mais rápido no chaveamento de enumerações: if/else ou switch?

Tabela de conteúdo

Introdução

No desenvolvimento de sistemas embutidos, sempre lido com distribuição de mensagens e tratamento de eventos entre componentes do sistema. Além disso, sempre codifico chaveamento de enumerações, que podem variar entre identificadores de dispositivos, canais de comunicação, portas, timers, entre outros. Como nessa área a performance do código conta muito, a verificação de assembly sempre é bem vinda! Recentemente, analisando código legado do sistema, notei que o tratamento de enumerações é quase sempre feito por switch, mas algumas vezes o cascateamento de if/else é usado. Resolvi então verificar qual dos dois é melhor na prática, tendo em vista performance para velocidade.

Cenário

Na prática eu não utilizo o Visual Studio, o compilador e a IDE são outros, mas para efeito de comparação já é um começo. Foi utilizado o Visual C++ 2005, com duas variações de compilação:

  • sem otimização
  • /Ot (favorecer velocidade) e /GL (otimização geral do sistema)

Ambas variações geraram assembly idêntico.

Código fonte

#include <iostream>

using namespace std;

void my_if( int id )
{
   if( id == 76 ||
       id == 32 ||
       id == 43 )
   {
      cout << "opcao 1";
   }
   else if( id == 56 ||
            id == 23 ||
            id == 89 )
   {
      cout << "opcao 2";
   }
   else if( id == 12 ||
            id == 34 ||
            id == 98 ||
            id == 21 )
   {
      cout << "opcao 3";
   }
   else
   {
      cout << "opcao 4";
   }
}

void my_switch( int id )
{
   switch( id )
   {
      case 76:
      case 32:
      case 43:
         cout << "opcao 1";
         break;
      case 56:
      case 23:
      case 89:
         cout << "opcao 2";
         break;
      case 12:
      case 34:
      case 98:
      case 21:
         cout << "opcao 3";
         break;
      default:
         cout << "opcao 4";
         break;
   }
}

int main()
{
   int id = rand() % 100;

   my_if( id );
   my_switch( id );

   return 0;
}

Análise dos Resultados e Conclusão

O assembly gerado pelo cascateamento de if/else foi bastante óbvio e didático:

.
.
.
; conjunto 1 de condições
0044F853  cmp         dword ptr [id],4Ch 
0044F857  je          my_if+15h (44F865h) 
0044F859  cmp         dword ptr [id],20h 
0044F85D  je          my_if+15h (44F865h) 
0044F85F  cmp         dword ptr [id],2Bh 
0044F863  jne         my_if+29h (44F879h)
; bloco do conjunto 1 de condições
0044F865 - 0044F877
; conjunto 2 de condições
0044F879  cmp         dword ptr [id],38h 
0044F87D  je          my_if+3Bh (44F88Bh) 
0044F87F  cmp         dword ptr [id],17h 
0044F883  je          my_if+3Bh (44F88Bh) 
0044F885  cmp         dword ptr [id],59h 
0044F889  jne         my_if+4Fh (44F89Fh) 
; bloco do conjunto 2 de condições
0044F88B - 0044F89D
; conjunto 3 de condições
0044F89F  cmp         dword ptr [id],0Ch 
0044F8A3  je          my_if+67h (44F8B7h) 
0044F8A5  cmp         dword ptr [id],22h 
0044F8A9  je          my_if+67h (44F8B7h) 
0044F8AB  cmp         dword ptr [id],62h 
0044F8AF  je          my_if+67h (44F8B7h) 
0044F8B1  cmp         dword ptr [id],15h 
0044F8B5  jne         my_if+7Bh (44F8CBh)
; bloco do conjunto 3 de condições
0044F8B7 - 0044F8C9
; bloco padrão (caso 'default')
0044F8CB - 0044F8DA
.
.
.

O valor é comparado com o primeiro literal do primeiro grupo de condições, se for igual o bloco é executado senão a comparação do próximo é feita. Se o último literal do grupo for testado e não for igual ao valor, o próximo grupo de condições é comparado. Isso era o esperado, dada a robustez do if. A princípio eu esperava o mesmo do switch, pelo menos sem otimização alguma de código, mas não foi o que aconteceu:

.
.
.
0044F75B  mov         eax,dword ptr [id] 
0044F75E  mov         dword ptr [ebp-4],eax 
0044F761  mov         ecx,dword ptr [ebp-4] 
0044F764  sub         ecx,0Ch 
0044F767  mov         dword ptr [ebp-4],ecx 
0044F76A  cmp         dword ptr [ebp-4],56h 
0044F76E  ja          $LN2+14h (44F7BDh) 
0044F770  mov         edx,dword ptr [ebp-4] 
0044F773  movzx       eax,byte ptr  (44F7F0h)[edx] 
0044F77A  jmp         dword ptr  (44F7E0h)[eax*4] 
.
.
.

Nota-se que foi gerado um código nada didático, pelo menos à primeira vista. Pesquisando um pouco e logo encontrei: como o switch lida somente com dados integrais nas condições, a melhor otimização é feita, através da criação de uma tabela hash de jumps para os casos tratados, cada grupo de condição pulando para uma parte do código específica. Melhor impossível, até porque o código independe da quantidade de enumerações tratadas (o tamanho é sempre o mesmo), diferente do if/else, que percorre linearmente para comparar. Interessante notar que a tabela de jumps só é feita quando o compilador acha adequado, senão o mesmo algoritmo linear é usado para switchs. Isso acontece quando a quantidade de condições é muito pequena (1, 2 ou 3).

Com isso só posso concluir que para um compilador robusto e esperto como o do Visual C++, usar switch sempre é a melhor escolha, pois ele saberá escolher a melhor abordagem para o código. No meu caso, o compilador usado para produção do sistema é bem menos robusto, mas a mesma regra se aplica. Portanto, para chaveamentos, prefira sempre switch. Do ponto de vista semântico também é muito melhor, pois é muito mais identificável no código.

Os ganhos reais podem parecer pequenos, para a maioria das aplicações eles realmente são, mas fazem a diferença em alguns pontos críticos de sistemas de comunicação, que devem manter baixa latência, ou em sistemas de tempo real.

Ferramentas pessoais