Performance .NET: Arrays em C#

Bruno Silveira Cruz (e-mail) é Analista de Sistemas, instrutor certificado Microsoft, mais de 2 anos de experiencia lecionando cursos oficiais. Possui 6 anos de experiência em infra-estrutura, e 4 em desenvolvimento, 2 com a pltaforma .NET e C#. Certficado MCP, MCSA 2000 e 2003, MCSA Exchange 2000, MCSE 2000, MCAD C# e MCT, mais de 15 exames de certificação no currículo.

Talvez o array seja um dos objetos mais comumente utilizados no ciclo de desenvolvimento de uma aplicação. Versáteis, de fácil uso, eficientes e de certa forma baratos, do ponto de vista de desempenho, os arrays são utilizados para os mais diversos fins e das mais diversas maneiras.

No entanto, o que pode não ser muito claro para a maioria dos desenvolvedores, são os mecanismos internos de como a CLR manipula estas estruturas. A grande verdade é que arrays mal utilizados e manipulados podem corromper o desempenho de seu aplicativo, até níveis inaceitáveis.

Qualquer desenvolvedor que se preze preocupa-se, e muito, com o desempenho de seu aplicativo, e na melhor forma de otimizá-lo. Benchmarking é a palavra de ordem aqui. Para demonstrar que o mais útil objeto pode se tornar um estorvo, preparei um aplicativo de console, que executa 5 testes simples envolvendo arrays.

Benchmarking

Os cinco testes propostos envolvem a utilização de arrays da seguinte maneira: O objeto array é inicializado com um numero pré-definido de elementos. O objeto é então percorrido por um loop, sendo feito uma leitura e atribuição de cada elemento a uma variável criada. O download do aplicativo de teste pode ser feito aqui.

Note que os tempos obtidos podem variar um pouco, dependendo de seu hardware.

01. Value Type vs Reference Type

Teste:
O tipo de estrutura do array é muito importante. O array por si só é um reference type. Isso é, ele é mantido no heap gerenciado e não no stack. Os value types são mais rápidos do que os reference types, exatamente por isso. Value types são mantidos no stack e não no heap gerenciado, não sofrendo assim a degradação de desempenho do GC, nem do overhead de criação de objetos.

O teste proposto compara o desempenho de dois objetos array, um Int32 e um String, de 10.000 elementos cada, durante a execução e iteração do loop. O array de Int32 é mais rapido correto? ERRADO. De uma olhada nos tempos obtidos no teste:

Resultado:

. Testando value type array:
Tempo de execução: 00:00:00.0066723 ms

. Testando reference type array:
Tempo de execução: 00:00:00.0049852 ms

Fato:

O array é um reference type. Quando você cria um array de um value type, como, por exemplo, Int32, dentro de um reference type como o array, o que acontece é que é guardada uma referência do objeto no heap, para uma estrutura no stack, o que reduz drasticamente o desempenho.

Conclusão:

Value types são tipos chamados de lightweight objects. Eles não possuem o overhead dos reference types, portanto são mais rápidos. Os value types também tem suas limitações; não podem ser base para nenhum outro tipo, são sempre objetos distintos, não recebem notificações de liberação de memória (Finalize), e ainda por cima tem grandes problemas com relação a desempenho, se falarmos de Boxing/Unboxing. Mas no geral são mais rápidos do que reference types.

No entanto, quando falamos de arrays, a diferença de desempenho é quase nula, porque o array em si é um reference type, anulando os benefícios da criação da estrutura no stack.

02. Array Multidimensional vs Jagged Array

Teste:
Apesar do desempenho extremamente inferior, o CLR suporta o uso de arrays multidimensionais e jagged arrays. O resultado é praticamente idêntico, assim como o desempenho. Dois arrays distintos com 10.000 elementos cada serão utilizados neste teste.

Resultado:

. Testando array multidimensional:
Tempo de execução: 00:00:02.4207101 ms

. Testando jagged array:
Tempo de execução: 00:00:02.0582227 ms

Fato:

O jagged array se mostrou mais rápido do que o array multidimensional em quase 4 décimos de segundo! No entanto o fato apresentado aqui é uma verdade parcial. O tempo de acesso de um jagger array é menor, mas sua criação é mais demorada do que um array multidimensional, pois cada dimensão de um jagged array necessita de um novo objeto alocado no heap.

Conclusão:

Aqui temos uma faca de dois gumes. Enquanto o jagged array se mostrou superior nos tempos de acesso, é importante lembrar que sua criação é mais demorada do que um array multidimensional. Portanto, se seu aplicativo criar poucos objetos de array, mas acessa-os constantemente, o jagged array é a melhor solução. No entanto, se seu aplicativo criar arrays com freqüência, mas só acessa poucas vezes, então o array multidimensional é a melhor escolha.

03. Safe Access vs Unsafe Access

Teste:

O último teste proposto envolve o acesso aos elementos de um array recuperando-os diretamente do heap, usando código não gerenciado. Foi comparado o tempo de acesso de arrays multidimensionais e jagged arrays com um array multidimensional acessado através de código não gerenciado como mostrado abaixo.

System.Int32 lowbound0 = unsafemultidimensionalarray.GetLowerBound(0);
System.Int32 highbound0 = unsafemultidimensionalarray.GetUpperBound(0);
System.Int32 lowbound1 = unsafemultidimensionalarray.GetLowerBound(1);
System.Int32 highbound1 = unsafemultidimensionalarray.GetUpperBound(1);
System.Int32 elements = highbound0 - lowbound0;

Console.WriteLine("Testando array dimensional unsafe:");
timertotal = Stopwatch.StartNew();

unsafe
{
     fixed (System.Int32* pi = &unsafemultidimensionalarray[0, 0])
     {
         for (int x = lowbound0; x < highbound0; x++)
         {
             System.Int32 baseelement = x * elements;
             for (int i = lowbound1; i < highbound1; i++)
             {
                 System.Int32 el = pi[baseelement + i];
             }
         }
     }
}

Resultado:

. Testando array multidimensional:
Tempo de execução: 00:00:02.4207101 ms

. Testando jagged array:
Tempo de execução: 00:00:02.0582227 ms

. Testando array dimensional unsafe:
Tempo de execução: 00:00:00.6456633 ms

Fato:

Ok, claro que o array acessado através de código não gerenciado seria mais rápido. Mas notem que a diferença nos tempos de acesso é de quase 2 segundos! Isso se deve ao fato de que todo overhead de checagem e segurança que o código gerenciado introduz não é executado.

Conclusão:

A diferença de desempenho é ultrajante. O acesso utilizando código não gerenciado se mostrou quase 2 segundos mais rápido do que os métodos de acesso tradicionais. No entanto, é interessante lembrar alguns fatos:

. O código é menos legível do que o código gerenciado. Consequentemente, pode ser mais difícil e demorado realizar um debug no código.

. A chance de erro é maior, visto que é necessário calcular manualmente os endereços de memoria do array.

. Em caso de erro, como acessa um valor fora dos limites do objeto, nenhuma exceção é gerada. Isso pode levar seu aplicativo a ter um comportamento imprevisível, causando corrupção de estruturas, crash no aplicativo ou até mesmo no SO. Além de que esse tipo de falha tende a abrir furos de segurança.

. Devido a estes pequenos detalhes, o CLR só executará código marcado como unsafe se o administrador ou o usuário permitirem, e se a montagem tiver permissões para isso. Uma montagem instalada localmente tem essa permissão, mas uma montagem carregada pela intranet ou internet não. Ao tentar usar este recurso, o CLR lança uma exceção imediatamente.

Finalizando

Existem um milhão de maneiras diferentes de otimizar um aplicativo. O desempenho tem que ser encarado como uma meta, tão importante como a funcionalidade do aplicativo. A partir do momento que mensurar o desempenho de seu aplicativo e otimizá-lo, tornando um hábito, será mais e mais fácil conseguir resultados positivos, sem aumentar o tempo de desenvolvimento e, consequentemente, o custo de seu projeto.

As diferenças de décimos de segundos podem parecer insignificantes. Mas imagine sua aplicação real. Seu aplicativo possivelmente passará por estas operações dezenas, centenas, até milhares de vezes por dia. Nesse momento, a diferença de desempenho irá transparecer.

Usar código não gerenciado não é a resposta a todos os problemas de desempenho. Moderação é a regra aqui. Para construir um sistema robusto, rápido e auto-sustentável, é preciso encarar cada desafio, e implementar a solução mais adequada a cada situação.

Até a próxima.