Objetivo
Apesar desse ser um assunto muito básico pra quem já é experiente na área de desenvolvimento e está sendo tratado aqui de uma forma bem superficial, as vezes conversando com pessoas não tão experientes na área percebo que este conceito de performance gera bastante confusão.
Essa foi minha motivação para escrever esse post, ou seja, tentar entender melhor como formatar requisitos de performance e analisar problemas.
O que é performance?
Um bom post tem que começar com uma citação de uma frase famosa e de efeito. Lá vai:
“Premature optimization is the root of all evil” – Donald Knuth.
Para não tirar o texto do contexto, vai a íntegra: “Programmers waste enormous amounts of time thinking about, or worrying about, the speed of noncritical parts of their programs, and these attempts at efficiency actually have a strong negative impact when debugging and maintenance are considered. We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%.”
Fazendo uma tradução livre: “Otimização prematura é a origem de todo o mal”. O que ele quer dizer aqui é que programadores perdem um tempo enorme se preocupando com a velocidade de partes não-críticas dos programas, gerando um impacto negativo na manutenção e depuração. Vale mais apena focar nos outros 3%.
Eu gosto dessa corrente de pensamento porque vai mais de encontro com o que enfrentamos no dia-a-dia.
Mas performance é só velocidade? Olha o que a Wikipedia nos diz: Computer Performance.
Aqui percebemos que existe uma série de métricas de performance, diversas formas de estabelecer o que é importante ser otimizado na sua aplicação.
Onde eu quero chegar é que é importante estabelecer critérios na hora de otimizar. O que é uma performance aceitável? Um usuário não demorar mais de 5s para ter a resposta de uma página? O processo de fechamento do mês levar 1h e não 1 dia para ser concluído?
Geralmente chegamos naquelas premissas: “O mais rápido o possível”. Isso não tem fim. Sempre tem o que melhorar, a um preço cada vez maior. Se você ouvir uma afirmação dessas, é fácil contra argumentar com: Vamos construir o maior cluster de computadores do mundo e então conseguiremos a melhor performance do mundo, aí vai ouvir um contra-argumento: “Mas não pode aumentar o custo”. Então a brincadeira começa.
Quando falo de critérios, é importante saber o tipo de aplicação que estamos falando. Isso é determinante para estabelecer os critérios. Escrevi com foco em aplicações corporativas. Em software embarcado por exemplo, existem menos variáveis, porém a disponbilidade de CPU e memória é muito pequena. É quase uma arte fazer o que esse pessoal faz com tão pouco hardware. Mobile também tem uma característica muito parecida.
Equilíbrio: A chave do sucesso
Não dá para conseguir ganhar sempre quando estamos desenvolvendo software. Se quer produtividade durante o desenvolvimento, é possível que a performance tenha que ser sacrificada. Se quer otimizar um determinado pedaço da aplicação, é provável que aquele código fique com uma manutenção mais complexa que outros. Não dá pra ganhar todas.
Muitas vezes eu passei por discussões de design em que o argumento performance é jogado na mesa. Eu adoro fazer sistemas rápidos e eficientes, mas também gosto de sistemas que tem um longo ciclo de vida, que suporte manutenção e extensão sem ficar impossível de gerenciar. Por isso, é importante ter os critérios, definir claramente os requisitos não funcionais para conseguir um bom equilíbrio entre performance, manutenção, custo, time-to-market ou quaisquer outros fatores que sejam relevantes para o negócio (ver A arquitetura perfeita).
Como otimizar uma aplicação?
Partindo mais para o lado prático, o que exatamente fazemos para melhorar o desempenho?
A aplicação perde desempenho quando faz mal uso de recursos. É igual as contas do governo ou a nossa conta corrente. Se não administramos bem nossos recursos, gastamos muito sem desfrutar muito destes gastos. A máquina é igual.
Mas quais recursos a máquina tem?
A resposta é: vários. Pensando em aplicações enterprise orientadas a banco de dados, temos como principais fatores:
- Banco de dados
- Rede
- Memória
- CPU
- Disco rígido
Esses fatores na grande maioria das vezes são os principais vilões, a maioria das vezes nessa ordem.
Como investigar problemas de performance?
Troubleshooting
Geralmente quando recebemos uma reclamação sobre performance, recebemos uma solicitação do tipo: “O usuário manda visualizar o pedido e demora demais”. Como vamos identificar onde é o problema?
Como qualquer técnica de troubleshooting, é importante isolar variáveis. Coloquei em negrito porque essa regra é de ouro. Se você tem um problema, vá removendo todas as variáveis possíveis até encontrar aquela que é a causa. Isso vale não só para performance, mas para qualquer outro problema.
Esse procedimento utiliza quais recursos? Praticamente todos. Vamos usar uma aplicação Web como exemplo: tem a velocidade da internet (largura de banda e latência), o tempo de processamento do request pelo servidor web, o tempo de acesso do servidor até o banco de dados (LAN) e a volta.
Acessando a mesma funcionalidade de uma rede local já elimina a primeira variável. Se continua tendo o problema, a rede não é o problema.
Em .NET e mesmo em outras plataformas, existem ferramentas de profiling. Em .NET tem o Red Gate ANTS, ou mesmo o JetBrains dotTrace que são muito boas, em C/C++ tem o profiler da Microsoft.
Em geral essas ferramentas dizem em qual lugar do código, a CPU ficou mais tempo parada. Em geral isso denuncia não só problemas de CPU como problemas de espera por outros recursos (Ex.: acesso a um arquivo mapeado na rede, acesso ao banco de dados).
Na inexistência desse tipo de ferramenta, é só tirar aleatoriamente chamadas do banco de dados e você começa a perceber quais delas causam a lentidão. Se a lentidão ainda está lá, não é problema nestas variáveis.
Essa dinâmica ajuda facilmente a encontrar onde é o problema (não a solução).
Banco de dados
Quando identifica-se o problema no banco de dados (quase sempre é nele!) caem-se em outros casos. Plano de execução da query ruim, varrendo muitos registros para conseguir retornar o resultado é o mais comum. Nesse caso é importante corrigir a consulta, as vezes criar índices, ou mesmo rever conceitos da modelagem do banco.
O segundo grande vilão é a quantidade de informações retornadas. Se o banco devolve muitos registros (Ex.: 500.000 registros), essa informação vai trafegar pela rede entre o banco de dados e o servidor de aplicação (client/server ou o Web Server). Isso é muitas vezes causa de lentidão.
O terceiro caso é que o banco de dados, sendo um servidor separado também tem CPU, Rede, Memória e disco. Rodar o task manager do Windows ou outra ferramenta de monitoração (como NAGIOS por exemplo), ajuda a saber se o servidor está sofrendo de algum desses recursos.
As vezes o plano de execução da query está bom, mas o servidor de banco de dados tem um disco muito lento, ou um storage muito lento que não dá vazão para as solicitações, lembrando que muitas vezes o banco de dados atende várias aplicações diferentes e é o ponto mais difícil de dar escalabilidade. É difícil e caro montar clusters para bancos de dados.
Outro caso muito comum é quando a lentidão é intermitente. Isso é uma evidência que pode existir problema de concorrência. Verifique se existem transações longas e muita disputa por recursos do banco de dados. Cada banco de dados tem seus meios para investigar esse tipo de problema.
Alguns dos tópicos que eu citei aqui muitas vezes estão nas responsabilidades do DBA.
Rede
Já falei um pouco deste caso especificamente para banco de dados, mas para outros tipos de aplicação como aplicações Web, aplicações “thin client” ou RIA (que rodam em redes remotas com acesso sobre HTTP), existem outras variáveis.
Uma delas é a latência da rede. Redes remotas não se dão bem com grandes quantidades de pequenso requests. A internet é uma delas. Nesse caso, deve-se procurar os famosos “Coarse Grained Services” como o Fowler gosta de chamá-los.
Nos casos em que eles já existem, observar o protocolo usado. Informações representadas em HTML, XML são muito pesadas. Existem muitas tags para poucas informações. ViewState para aplicações ASP.NET também é um vilão. É bem pesado.
Para identificar esses casos, é só mudar para uma rede local e ver se a performance muda drasticamente, acessando o mesmo recurso. Outra forma é colocando um sniffer na rede e analisando os pacotes. Em aplicações Web o plugin Live HTTP Headers do Firefox também é um bom amigo.
Não é raro pegar casos de aplicações ASP.NET WebForms com páginas na ordem de megabytes de peso.
Memória
Em geral, um dos problemas de ter um garbage collector no .NET é que muitas pessoas assumiram que com essa facilidade não precisam mais se preocupar com a memória. Isso é mentira. Dá pra fazer memory leaks em .NET como em qualquer outra linguagem (ver Garbage Collector e IDisposable). Em linguagens não gerenciadas é mais fácil ainda criar memory leaks.
Executando o task manager é possível analisar o quanto a aplicação consome de memória. O Red Gate Memory Profiler também é muito bom para caçar memory leaks em .NET.
O problema é que quando muita memória começa a ser consumida, o sistema operacional começa a paginar a memória em disco, consequentemente a máquina vai ficando cada vez mais lenta.
Neste caso em ambiente .NET o ideal é um memory profiler mesmo. Só assim é possível saber onde estão os gargalos e os leaks.
CPU
Também é fácil identificar quando o problema é CPU. Qualquer task manager denuncia facilmente o problema. O difícil é corrigir. Quando caímos nestes problemas de CPU é porque a aplicação possui algoritmos ruins para realizar suas tarefas.
Nesse caso, a otimização é baseada na complexidade dos algoritmos. Trocar algoritmos O(n) por algoritmos de complexidade inferior, ajuda bastante. Em geral algoritmos de ordem quadrática são os grandes vilões (loops dentro de loops).
Pra quem não tem a menor idéia do que eu estou falando, procurem um livro sobre isso. Pode realmente mudar sua vida como cientista da computação. A obra de referência neste campo é o The Art of Computer Programming do Knuth, mas confesso que é um livro um tanto quanto difícil. A parte matemática dele é insana. A menos que você vá seguir uma carreira acadêmica e pretenda escrever provas matemáticas para seus algoritmos, sugiro uma leitura mais fácil.
Eu gostei muito do livro do Steve Skiena Algorithm Design Manual. Lembrando que quase nunca você vai escrever todas estas estruturas de dados, mas saber como elas funcionam ajuda muito na hora de criar um algoritmo eficiente.
Por exemplo, imagine que você tem uma lista (List) com 100.000 registros. Toda vez que você procura um produto por código, você faz um foreach nesta lista (vou sumir com o LINQ daqui pra simplificar o exemplo) para encontrar um produto. Por estar usando uma lista, automaticamente, vc está usando um algoritmo O(n) (O livro te explica o por que!).
Uma boa otimização nesse caso, seria usar um Dictionary (sendo a string o código do produto). Para este caso, cada busca é O(log n), muito melhor.
O livro do Skiena inclusive dá um “mapa” pra saber a partir de qual massa de dados as complexidades passam a ser impossíveis. Vou repetir ele aqui:
n f(n) | log n | n | n log n | n2 | 2n |
10 | 0,003 µs | 0,01 µs | 0,033 µs | 0,1 µs | 1 µs |
20 | 0,004 µs | 0,02 µs | 0,086 µs | 0,4 µs | 1 ms |
30 | 0,005 µs | 0,03 µs | 0,147 µs | 0,9 µs | 1 s |
40 | 0,005 µs | 0,04 µs | 0,213 µs | 1,6 µs | 18,3 min |
50 | 0,006 µs | 0,05 µs | 0,282 µs | 2,5 µs | 13 dias |
100 | 0,007 µs | 0,1 µs | 0,644 µs | 10 µs | 4 x 1013 anos |
1.000 | 0,010 µs | 1 µs | 9,966 µs | 1 ms | |
10.000 | 0,013 µs | 10 µs | 130 µs | 100 ms | |
100.000 | 0,017 µs | 10 µs | 130 µs | 100 ms | |
1.000.000 | 0,020 µs | 1 ms | 19,93 ms | 16,7 min | |
10.000.000 | 0,023 µs | 0,01 s | 0,23 s | 1,16 dias | |
100.000.000 | 0,027 µs | 0,10 s | 2,26 s | 115,7 dias | |
1.000.000.000 | 0.030 µs | 1 s | 29.9 s | 31.7 anos |
É óbvio que o tempo exato para execução do algoritmo depende do algoritmo e da máquina, mas essa tabela nos mostra a ordem de grandeza de uma forma mais simples. Por exemplo, se eu pegar um bubble sort que é de complexidade O(n2) e compará-lo com um quicksort que é O(n log n) (ambos comparados em casos MÉDIOS), para uma massa de dados de 1 bilhão, o primeiro demora 31,7 anos para executar enquanto o segundo 29,9 segundos.
Disco rígido
Esse caso é bem chatinho de monitorar. Mas a se o seu código utiliza algum FileStream dentro dele ou salva alguma coisa em disco por outro método, já dá uma pista. A vantagem é que este caso é bem mais difícil de ocorrer.
A idéia é encontrar um bom task manager para identificar esse tipo de problema. Lembrando que disco rígido é lento por natureza (é mecânico).
As vezes cachear informações em memória e jogar para o disco somente quando elas atingem um determinado acúmulo é melhor do que fazer muitas gravações pontuais.
Outros casos
Esses casos são mais difícieis de acontecerem em aplicações comerciais. Acontecem muito em aplicações mais de baixo nível, mas vale a pena relatar.
Um deles é o problema de concorrência dentro da máquina. Muitas vezes usa-se threads sem muito critério (afinal, thread é mais rápido, né?) e as vezes ocorrem “corridas de saco” um monte de threads disputando a CPU e brigando por recursos, esperando uma série de locks, semáforos, mutexes.
Thread tem um custo alto para o sistema operacional. Toda vez que ele troca de uma thread para outra, ocorre uma troca de contexto (mudam-se vários registradores na CPU pra saber qual a próxima coisa que ela deve fazer). Você pode jogar 500 threads na aplicação, mas só vai rodar em paralelo a quantidade de núcleos (cores) que a sua CPU tem. Não estou dizendo para não usar threads nunca, estou dizendo para usar com critério e sabendo muito bem o que está fazendo.
Outras causas de lentidão também podem ser recursos como GDI ou outros kernel objects do Windows. Vazamento desse tipo de recurso é bem chatinho de localizar. Dá para monitorar isso pelo task manager do windows (as colunas vem por default ocultas).
Outros casos bem difíceis de depurar são combinações de dois recursos diferentes que “escondem” o real problema das nossas ferramentas. Um bom exemplo disso são grandes loops com pequenos acessos a banco. O custo de CPU não aparece porque ela não chega a ficar com alto consumo já que a cada passada do loop, a CPU para para esperar o resultado do servidor. A banda não aparece porque são muitos requests parecidos e pequenos. E o tempo para execução da consulta não aparece porque é uma pequena consulta executada uma grande quantidade de vezes. Talvez o único lugar que isso apareça é no profiler. Felizmente este caso é mais exceção do que regra.
Conclusão
A idéia desse post é dar uma dica de como o mal uso de diferentes recursos computacionais pode impactar na performance e sugerir algumas técnicas simples de análise destes problemas.
Lembre-se sempre da otimização prematura e tenha cuidado em ficar se preocupando demais com otimizar o que é desnecessário. Por isso, estabeleça “alvos” sempre que começar uma otimização.
Outra dica é que por melhor que seja o seu código e o seu design, sempre tem o que melhorar em relação a performance. Busque soluções equilibradas.